Compare commits

...

10 Commits

32 changed files with 956 additions and 423 deletions

3
.gitignore vendored
View File

@@ -10,6 +10,9 @@
/priv/data/*.db
/priv/data/*.db-shm
/priv/data/*.db-wal
# Project public content (posts, media, templates, generated html) lives in the
# per-user default content folder, never the repo. See PublicContentLivesInProjectFolder.
/priv/data/projects/
# Embeddings index artifacts are per-project runtime caches, never committed.
*.usearch
*.usearch.meta.json

View File

@@ -27,7 +27,8 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
| A1-14b | ~~USearch HNSW ANN index + debounced persistence not implemented~~ | embedding.allium config/FindSimilar/DebouncedPersistence | `Embeddings.Index` is now an HNSW (hnswlib) ANN index with debounced persistence | **Resolved:** rewrote `Embeddings.Index` as a DB-free GenServer wrapping an hnswlib HNSW graph (cosine, M=16, efConstruction=128, efSearch=64) — O(n·log n) build, O(log n) queries, replacing the O(n²) JSON cosine snapshot; per-project in-memory index + `label→post_id` map; 5s debounced `save_index` + `.meta.json` sidecar, force-save on project switch (`set_active_project`) and shutdown (`terminate`), `forget/1` on project delete; lazy reload from disk with rebuild-from-DB self-heal on miss; `find_similar`/`find_duplicates`/`compute_similarities` rewired (no brute-force fallback); USearch has no Elixir binding so hnswlib provides the identical HNSW algorithm/params (spec reconciled); supervision + dialyzer PLT updated; tests updated for debounced/binary persistence + self-heal. Follow-up hardening: explicit rebuild now forces re-embedding regardless of content_hash (ReindexAll), and model-unavailable errors propagate cleanly (post saves degrade to unindexed + log; rebuild/index return `{:error, reason}` surfaced as a failed task with a user-facing message instead of crashing). |
| A1-14c | ~~Embedding model runs on CPU only; no Apple GPU acceleration~~ | embedding.allium invariant NativeAcceleratedExecution | `Backends.Neural` now selects the defn compiler at serving-build time: Apple GPU via EMLX (MLX/Metal) on arm64 macOS, EXLA-CPU elsewhere | **Resolved:** added `{:emlx, "~> 0.2.0"}` dep (ships precompiled MLX binaries; EMLX 0.2.0 implements both `EMLX.Backend` and the `Nx.Defn.Compiler` behaviour, GPU-default); `Backends.Neural` gained a pure `select_accelerator/3` policy (`:auto` prefers EMLX only when available **and** on Apple Silicon; explicit `:emlx`/`:exla` honoured; forced `:emlx` degrades to EXLA when unavailable so misconfigured hosts still run), `current_accelerator/0`, and `defn_options/1`; `build_serving` places params on `{EMLX.Backend, device: :gpu}` and compiles with `EMLX` for the EMLX path, keeps `EXLA` otherwise; new `accelerator: :auto` config key; spec `NativeAcceleratedExecution` + `EmbeddingModel` updated; PLT app added; 7 tests added (offline — test config still uses the InApp stub). |
| A1-15 | ~~Preview vs generation content source strategy undocumented~~ | preview.allium (no invariant), generation.allium (no invariant) | Generation uses only published .md file content (`Generation.Data` snapshots set `content: nil`); preview includes published+draft posts and prefers DB content over file (`Preview.Router` queries `:published`/`:draft`, uses `editor_body`) | **Resolved:** added `PreviewDraftOverlay` invariant to preview.allium and `GenerationPublishedOnly` invariant to generation.allium; both cross-reference each other; code already correct, 3 tests added for draft-in-preview behavior |
| A1-16 | Public project content + data_path discovery not compliant with storage-location spec | project.allium `PublicContentLivesInProjectFolder` / `PrivateArtifactsLiveInOsAppDir` / `DataPathNotPersistedInProjectJson` / `DiscoverProjectDataPath` (newly added) | **Private side done:** `Projects.project_cache_root/0` now falls back to the OS private app dir (`:filename.basedir(:user_config, "bds")` → macOS `~/Library/Application Support/bds`) instead of `priv/data`, so the embeddings index no longer lands in the repo. **Still non-compliant (public side):** `project_data_dir/0` (projects.ex:97-99) falls back to `priv/data/projects/<id>` when `data_path` is nil, so the default project's *public* content (posts, media, templates, scripts, `meta/`, generated `html/`) is written into the application repo; there is no discovery of `data_path` from the `meta/project.json` location, and the `default` project is created with `data_path: nil` (projects.ex:80). | Implement project-folder discovery: `data_path` := the folder containing `meta/project.json` (never stored in project.json, keeping projects movable — `DiscoverProjectDataPath`); create the default project's folder at a per-user default content location on first launch (never in repo/private_dir); drop the `priv/data/projects/<id>` fallback in `project_data_dir/0`; persist the current project-folder location as a machine-local pointer (project registry) under `private_dir`. Migrate the committed `priv/data/projects/default/` content out of the repo. |
| A1-16 | ~~Public project content + data_path discovery not compliant with storage-location spec~~ | project.allium `PublicContentLivesInProjectFolder` / `PrivateArtifactsLiveInOsAppDir` / `DataPathNotPersistedInProjectJson` / `DiscoverProjectDataPath` | Public content now lives under a per-user default content location, never the repo | **Resolved:** `project_data_dir/1` drops the `priv/data/projects/<id>` repo fallback — a project without an explicit `data_path` resolves to `default_content_root()/<id>` (configurable via `:default_content_root`, else `~/bds`), never the repo or `private_dir`; the `default` project is now created on first launch with an explicit `data_path` under that location and its folder is `mkdir`'d (`PublicContentLivesInProjectFolder`); added `Projects.private_dir/0`, `default_content_root/0`, and a machine-local project registry (`registry_path/0``project_registry.json` under `private_dir`, written on create/ensure-default, removed on delete) that remembers each project's folder without embedding it in `meta/project.json` (`DataPathNotPersistedInProjectJson`/`DiscoverProjectDataPath` — already satisfied since `project.json` never serializes `data_path`); `delete_project` removes app-managed folders (those under `default_content_root`) but preserves user-chosen external folders; committed `priv/data/projects/default/` content removed from the repo and `/priv/data/projects/` git-ignored; test config redirects `:default_content_root` to a temp dir; 4 tests added (default folder outside repo/private, no-repo fallback, registry round-trip, registry cleanup on delete). |
| A1-17 | `bds2://new-post` blogmark deep link is never received or routed | script.allium:74 (`BlogmarkReceived`), script.allium:233-268 (`ExecuteTransform`/`TransformTrigger`), editor_settings.allium:141-143 (`BookmarkletCopy`) | Only the bookmarklet JS string is generated (`app_shell.ex:48`, now emitting the `bds2://` scheme so it does not clash with the legacy app's `bds://`); nothing handles the resulting `bds2://new-post?title=&url=` deep link. The `ExecuteTransform` engine now exists (`BDS.Scripts.Transforms.run/3`, A1-9) but no code emits `BlogmarkReceived(data)`, so the pipeline is never triggered and no post candidate is created. | **Open:** register a `bds2://` URL-scheme handler in the desktop layer, parse `new-post` query params into a candidate `{title, content?, tags, categories, url}`, run `BDS.Scripts.Transforms.run/3`, then create a draft post (defaulting category from `blogmark_category`) and open it in the editor; surface accepted transform toasts. |
### A2. Spec Should Update (code is normative)
@@ -116,13 +117,13 @@ All reconciled to follow code. Specs must be self-consistent and match code.
|---|---|---|---|
| D1-1 | ~~UniqueMediaTranslation invariant~~ | media.allium:108 | **Resolved:** test added (re-upsert updates not duplicates; direct duplicate insert rejected). Test exposed a real bug — `Media.Translation` declared the migration's index name but ecto_sqlite3 derives the violated-constraint name from columns, so violations crashed instead of returning a changeset error; fixed `unique_constraint` name to `:media_translations_translation_for_language_index` |
| D1-2 | ~~UniqueTranslationPerLanguage invariant~~ | translation.allium:94 | **Resolved:** test added (re-upsert updates not duplicates; direct duplicate insert rejected). Same bug as D1-1 — `Posts.Translation` declared the migration's index name but ecto_sqlite3 derives the violated-constraint name from columns, so duplicates crashed instead of returning a changeset error; fixed `unique_constraint` name to `:post_translations_translation_for_language_index` |
| D1-3 | BundledDefaultTemplatesExistOutsideProjectData | template.allium:65 | Write test: render with no Template rows, bundled template found |
| D1-4 | UserTemplateDirectoryOverridesBundledDefaults | template.allium:75 | Write test: project template overrides bundled same-slug |
| D1-5 | LiquidTagSubset (5 tags only) | template.allium:179 | Write test: unsupported tag raises error |
| D1-6 | LiquidFilterSubset (4 standard + 2 custom) | template.allium:191 | Write test: unsupported filter raises error |
| D1-7 | LiquidOperatorSubset | template.allium:210 | Write test: unsupported operator raises error |
| D1-8 | MacroTimeout guarantee | script.allium:94-95 | Write test: macro times out within budget |
| D1-9 | ExecuteTransform rule (pipeline, ordering, toast budget) | script.allium:229-263 | Write test: transform pipeline executes in order, toast budget enforced |
| D1-3 | ~~BundledDefaultTemplatesExistOutsideProjectData~~ | template.allium:65 | **Resolved:** added 4 tests in `template_lookup_priority_test.exs` — with no Template rows for the project, `load_template_source/3` resolves bundled single-post/post-list/not-found defaults (and still resolves when the project has no `templates/` directory at all) |
| D1-4 | ~~UserTemplateDirectoryOverridesBundledDefaults~~ | template.allium:75 | **Resolved:** added 2 tests in `template_lookup_priority_test.exs` — a published project Template row with the bundled default slug (`single-post`) wins over the bundled default both when resolving `:post` with no explicit slug and when the slug is requested explicitly |
| D1-5 | ~~LiquidTagSubset (5 tags only)~~ | template.allium:179 | **Resolved:** added `BDS.Rendering.LiquidParser`, a restricted Liquex parser recognizing only the subset (if/for/assign/render + `{{ }}` output); any other tag (`unless`, `case`, `capture`, `tablerow`, `cycle`, `increment`, …) leaves unmatched input and fails `eos/0`. Wired into `validate_liquid` (publish gate), `template_selection.render_template`, `filters.render_macro_source`, and MCP `validate_template` so validation and rendering share the same surface; 6 parametrized tests added asserting unsupported tags are rejected at publish |
| D1-6 | ~~LiquidFilterSubset (4 standard + 2 custom)~~ | template.allium:191 | **Resolved:** added `LiquidParser.validate/1`, which parses with the restricted tag grammar then walks the AST to reject any filter outside the allowed set — 4 standard (`escape`, `url_encode`, `default`, `append`) + 3 custom (`i18n`, `markdown`, `slugify`). Wired into `validate_liquid` (publish gate) and MCP `validate_template` so unsupported filters are rejected even though Liquex would otherwise apply them as built-in standard filters. Spec corrected to 3 custom filters (bundled templates use `slugify`); 9 tests added (6 unsupported filters rejected, 3 supported filters accepted). |
| D1-7 | ~~LiquidOperatorSubset~~ | template.allium:210 | **Resolved:** `LiquidParser.validate/1` now walks the parsed AST for `{:op, _}` nodes and rejects any comparison operator outside the allowed `==`/`>` subset (`!=`, `<`, `>=`, `<=`, `contains`), sharing the publish gate and MCP `validate_template` surface with the tag/filter checks; spec `LiquidOperatorSubset` annotated with enforcement note; 10 tests added (5 unsupported operators rejected at publish, 5 supported `==`/`>`/`and`/`or`/bare-truthy expressions accepted). |
| D1-8 | ~~MacroTimeout guarantee~~ | script.allium:94-95 | **Resolved:** added test in `api_test.exs` — an infinite-loop `render()` macro run with `max_reductions: :none` (forces the luerl sandbox onto its wall-clock path) and a 150ms `timeout` returns `{:error, :timeout}` and terminates within budget (<2s), proving the macro is killed near its budget rather than the default multi-minute script timeout |
| D1-9 | ~~ExecuteTransform rule (pipeline, ordering, toast budget)~~ | script.allium:229-263 | **Resolved:** the `ExecuteTransform` rule had no engine — added `BDS.Scripts.Transforms.run/3` (+ `Scripts.list_transform_scripts/1` ordered by updated_at→slug→id and `Scripts.resolved_content/1`). The pipeline runs enabled project transforms sequentially on the blogmark candidate with a `{source="blogmark", url}` context, captures per-script errors without rolling back the last valid candidate (TransformPipelineContinuation), and enforces the toast budget (`transform_max_toasts_per_script`/`transform_max_toasts_total`/`transform_max_toast_length`, new config keys). 6 tests added (ordering, project/disabled scoping, continuation, context, per-script + total toast caps with truncation). Deep-link OS routing into this engine remains future work. |
| D1-10 | TransformPipelineContinuation | script.allium:247-249 | Write test: error in transform doesn't halt pipeline |
| D1-11 | ChatContextTruncation invariant | ai.allium:375-379 | Write test: long chat history trimmed to context window |
| D1-12 | BoundedToolLoop enforcement | ai.allium:381-385 | Write test: tool rounds bounded by chat_max_tool_rounds |
@@ -188,7 +189,8 @@ All reconciled to follow code. Specs must be self-consistent and match code.
## Priority Order for Resolution
1. ~~**A1-1 through A1-15**~~ — all resolved: auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown, real embedding model, HNSW ANN index, Apple GPU/EMLX acceleration (A1-14c), and preview/generation content strategy (A1-15)
1b. **A1-16** — storage-location compliance: private side done (embeddings index → OS app dir); public side open (data_path discovery from meta/project.json, drop the `priv/data/projects/<id>` fallback, migrate committed default project out of repo)
1b. ~~**A1-16**~~ — storage-location compliance resolved: public content now lives under a per-user default content location (never the repo/private dir), `priv/data/projects/<id>` fallback dropped, machine-local project registry added, committed default project content removed from repo
1c. **A1-17** — blogmark deep-link OS handler not implemented: the `ExecuteTransform` engine exists but nothing receives `bds2://new-post` and emits `BlogmarkReceived(data)` to trigger it
2. **D1-1 through D1-18** — untested invariants/guarantees
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
4. **B1-1 through B1-6** — major code behaviors missing from spec

View File

@@ -58,7 +58,10 @@ config :bds, :scripting,
timeout: 300_000,
max_reductions: 5_000_000,
job_timeout: :infinity,
job_max_reductions: :none
job_max_reductions: :none,
transform_max_toasts_per_script: 5,
transform_max_toasts_total: 20,
transform_max_toast_length: 300
config :bds, :embeddings,
backend: BDS.Embeddings.Backends.Neural,

View File

@@ -71,7 +71,7 @@ defmodule BDS.MCP.Tools do
@spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}}
def validate_template(source) when is_binary(source) do
case Liquex.parse(source) do
case BDS.Rendering.LiquidParser.validate(source) do
{:ok, _ast} ->
{:ok, %{valid: true, errors: []}}

View File

@@ -69,6 +69,12 @@ defmodule BDS.Projects do
now = Persistence.now_ms()
is_active = not Repo.exists?(from project in Project, where: project.is_active == true)
# The default project's public content folder is created at the per-user
# default content location on first launch — never in the repo or the
# private app dir (PublicContentLivesInProjectFolder).
data_path = default_project_dir(@default_project_id)
File.mkdir_p!(data_path)
Repo.transaction(fn ->
project =
%Project{}
@@ -77,7 +83,7 @@ defmodule BDS.Projects do
name: @default_project_name,
slug: unique_slug(Slug.slugify(@default_project_name)),
description: nil,
data_path: nil,
data_path: data_path,
created_at: now,
updated_at: now,
is_active: is_active
@@ -87,17 +93,51 @@ defmodule BDS.Projects do
project
end)
|> case do
{:ok, project} -> rebuild_project_templates(project)
{:error, reason} -> {:error, reason}
{:ok, project} ->
record_project_location(project.id, data_path)
rebuild_project_templates(project)
{:error, reason} ->
{:error, reason}
end
end
end
@spec project_data_dir(Project.t()) :: String.t()
def project_data_dir(%Project{} = project) do
project.data_path || Path.expand("../../priv/data/projects/#{project.id}", __DIR__)
def project_data_dir(%Project{data_path: data_path}) when is_binary(data_path) and data_path != "",
do: data_path
# A project without an explicit data_path resolves to its folder under the
# per-user default content location — never priv/data inside the repo
# (PublicContentLivesInProjectFolder).
def project_data_dir(%Project{id: id}), do: default_project_dir(id)
@doc """
Per-user base directory that holds the public, portable content of projects
created without an explicit folder (the default project on first launch).
Configurable via `:default_content_root`; otherwise the user's home dir under
`bds/`. Never the application repo nor `private_dir/0`
(PublicContentLivesInProjectFolder).
"""
@spec default_content_root() :: String.t()
def default_content_root do
case Application.get_env(:bds, :default_content_root) do
root when is_binary(root) -> Path.expand(root)
_other -> Path.join(System.user_home!(), "bds")
end
end
defp default_project_dir(project_id), do: Path.join(default_content_root(), project_id)
@doc """
The OS per-user app-data directory holding machine-specific, regenerable
artifacts only (database, embeddings index, model cache, project registry,
UI state) — never project content (PrivateArtifactsLiveInOsAppDir).
"""
@spec private_dir() :: String.t()
def private_dir, do: private_app_dir()
@spec project_cache_dir(Project.t() | String.t()) :: String.t()
def project_cache_dir(%Project{} = project), do: project_cache_dir(project.id)
@@ -130,6 +170,8 @@ defmodule BDS.Projects do
end)
|> case do
{:ok, project} ->
record_project_location(project.id, project_data_dir(project))
with {:ok, project} <- rebuild_project_templates(project) do
sync_filesystem_metadata(project)
end
@@ -192,10 +234,15 @@ defmodule BDS.Projects do
{:error, :cannot_delete_active_project}
%Project{} = project ->
internal_dir = if is_nil(project.data_path), do: project_data_dir(project), else: nil
data_dir = project_data_dir(project)
# App-managed folders (those under the per-user default content location)
# are removed; user-chosen external folders are preserved.
managed_dir =
if String.starts_with?(data_dir, default_content_root()), do: data_dir, else: nil
cleanup_dirs =
[internal_dir, project_cache_dir(project)] |> Enum.filter(&is_binary/1) |> Enum.uniq()
[managed_dir, project_cache_dir(project)] |> Enum.filter(&is_binary/1) |> Enum.uniq()
Repo.transaction(fn ->
case Repo.delete(project) do
@@ -206,6 +253,7 @@ defmodule BDS.Projects do
|> case do
{:ok, deleted_project} ->
BDS.Embeddings.Index.forget(deleted_project.id)
forget_project_location(deleted_project.id)
Enum.each(cleanup_dirs, fn dir ->
_ = File.rm_rf(dir)
@@ -287,6 +335,41 @@ defmodule BDS.Projects do
|> Path.expand()
end
@doc """
Path to the machine-local project registry: a `id => data_path` pointer file
under `private_dir/0` that remembers where each project's folder currently
lives. The folder location is never embedded in `meta/project.json`, so a
project folder can be moved or renamed and only the registry is updated
(DataPathNotPersistedInProjectJson).
"""
@spec registry_path() :: String.t()
def registry_path, do: Path.join(private_dir(), "project_registry.json")
@doc "Reads the machine-local project registry as an `id => data_path` map."
@spec project_registry() :: %{optional(String.t()) => String.t()}
def project_registry do
with {:ok, contents} <- File.read(registry_path()),
{:ok, map} when is_map(map) <- Jason.decode(contents) do
map
else
_ -> %{}
end
end
defp record_project_location(project_id, data_path) when is_binary(data_path) do
project_registry() |> Map.put(project_id, data_path) |> write_registry()
end
defp forget_project_location(project_id) do
project_registry() |> Map.delete(project_id) |> write_registry()
end
defp write_registry(registry) do
path = registry_path()
File.mkdir_p!(Path.dirname(path))
File.write(path, Jason.encode!(registry))
end
defp attr(attrs, key) do
cond do
Map.has_key?(attrs, key) -> Map.get(attrs, key)

View File

@@ -155,7 +155,7 @@ defmodule BDS.Rendering.Filters do
end
defp render_macro_source(template_path, template_source, assigns, context) do
with {:ok, template_ast} <- Liquex.parse(template_source),
with {:ok, template_ast} <- Liquex.parse(template_source, BDS.Rendering.LiquidParser),
{:ok, rendered} <- safe_liquex_render(template_ast, context, assigns) do
rendered
else

View File

@@ -0,0 +1,150 @@
defmodule BDS.Rendering.LiquidParser do
@moduledoc """
Restricted Liquid parser enforcing the `LiquidTagSubset` invariant
(`specs/template.allium`).
Only the tags used by the bundled starter templates are recognized:
* `{% if %}` / `{% elsif %}` / `{% else %}` / `{% endif %}`
* `{% for %}` / `{% endfor %}`
* `{% assign %}`
* `{% render 'partial', name: value %}`
* `{{ object }}` output (and whitespace-stripped variants `{%- -%}` / `{{- -}}`)
Any tag outside this subset (`unless`, `case`, `capture`, `raw`, `comment`,
`cycle`, `tablerow`, `increment`, `decrement`, `liquid`, `echo`, `include`)
leaves unmatched input and fails the `eos/0` check, producing a parse error.
Pass this module as the second argument to `Liquex.parse/2` (and
`Liquex.parse!/2`) so validation and rendering share the same surface.
`validate/1` additionally enforces the `LiquidFilterSubset` invariant: only
the four standard filters (`escape`, `url_encode`, `default`, `append`) and
three custom filters (`i18n`, `markdown`, `slugify`) are permitted. Any other
filter (`upcase`, `date`, `truncate`, `split`, `join`, …) is rejected even
though Liquex would otherwise apply it as a built-in standard filter.
`validate/1` also enforces the `LiquidOperatorSubset` invariant: only the
`==` and `>` comparison operators are permitted (alongside the `and`/`or`
logical operators and bare-variable truthiness). Any other comparison
operator (`!=`, `<`, `>=`, `<=`, `contains`) is rejected even though Liquex
would otherwise evaluate it.
"""
import NimbleParsec
# LiquidFilterSubset invariant (specs/template.allium).
@allowed_filters ~w(escape url_encode default append i18n markdown slugify)
# LiquidOperatorSubset invariant (specs/template.allium).
@allowed_operators [:==, :>]
@doc "The filter names permitted by the `LiquidFilterSubset` invariant."
@spec allowed_filters() :: [String.t()]
def allowed_filters, do: @allowed_filters
@doc """
Parses `source` with the restricted tag grammar and enforces the
`LiquidFilterSubset` invariant.
Returns `{:ok, ast}` on success, or `{:error, reason, line}` on a parse error
or an unsupported filter (mirroring the `Liquex.parse/2` error shape so
callers can treat both failures uniformly).
"""
@spec validate(binary()) :: {:ok, term()} | {:error, term(), non_neg_integer()}
def validate(source) when is_binary(source) do
case Liquex.parse(source, __MODULE__) do
{:ok, ast} ->
with [] <- unsupported_filters(ast),
[] <- unsupported_operators(ast) do
{:ok, ast}
else
[{:filter, name} | _] -> {:error, "unsupported filter: #{name}", 0}
[{:operator, op} | _] -> {:error, "unsupported operator: #{op}", 0}
end
{:error, _reason, _line} = error ->
error
end
end
@spec unsupported_filters(term()) :: [{:filter, String.t()}]
defp unsupported_filters(ast) do
ast
|> collect_filters()
|> Enum.uniq()
|> Enum.reject(&(&1 in @allowed_filters))
|> Enum.map(&{:filter, &1})
end
@spec unsupported_operators(term()) :: [{:operator, String.t()}]
defp unsupported_operators(ast) do
ast
|> collect_operators()
|> Enum.uniq()
|> Enum.reject(&(&1 in @allowed_operators))
|> Enum.map(&{:operator, operator_to_string(&1)})
end
@spec collect_filters(term()) :: [String.t()]
defp collect_filters({:filter, [name | rest]}) when is_binary(name) do
[name | collect_filters(rest)]
end
defp collect_filters(term) when is_tuple(term), do: collect_filters(Tuple.to_list(term))
defp collect_filters(term) when is_list(term), do: Enum.flat_map(term, &collect_filters/1)
defp collect_filters(_term), do: []
@spec collect_operators(term()) :: [atom()]
defp collect_operators({:op, op}) when is_atom(op), do: [op]
defp collect_operators(term) when is_tuple(term), do: collect_operators(Tuple.to_list(term))
defp collect_operators(term) when is_list(term), do: Enum.flat_map(term, &collect_operators/1)
defp collect_operators(_term), do: []
@spec operator_to_string(atom()) :: String.t()
defp operator_to_string(:contains), do: "contains"
defp operator_to_string(op), do: Atom.to_string(op)
@tags [
Liquex.Tag.AssignTag,
Liquex.Tag.ForTag,
Liquex.Tag.IfTag,
Liquex.Tag.RenderTag,
Liquex.Tag.ObjectTag
]
tags_parser = Enum.map(@tags, &tag(&1.parse(), {:tag, &1}))
# Ensure the tags are loaded into scope, otherwise function_exported? will
# return false.
Enum.each(@tags, &Code.ensure_loaded!/1)
liquid_tags_parser =
@tags
|> Enum.filter(&function_exported?(&1, :parse_liquid_tag, 0))
|> Enum.map(&tag(&1.parse_liquid_tag(), {:tag, &1}))
|> choice()
# Special case for leading spaces before `{%-` and `{{-`.
leading_whitespace =
empty()
# credo:disable-for-lines:1
|> Liquex.Parser.Literal.whitespace(1)
|> lookahead(choice([string("{%-"), string("{{-")]))
|> ignore()
base =
choice(
tags_parser ++
[
# credo:disable-for-lines:2
Liquex.Parser.Literal.text(),
leading_whitespace
]
)
defcombinatorp(:document, repeat(base))
defcombinatorp(:liquid_tag_contents, repeat(liquid_tags_parser))
defparsec(:parse, parsec(:document) |> eos())
end

View File

@@ -132,7 +132,7 @@ defmodule BDS.Rendering.TemplateSelection do
@spec render_template(String.t(), String.t(), map()) ::
{:ok, String.t()} | {:error, String.t()}
def render_template(project_id, source, assigns) do
with {:ok, template_ast} <- Liquex.parse(source),
with {:ok, template_ast} <- Liquex.parse(source, BDS.Rendering.LiquidParser),
{:ok, _rendered} = ok <- safe_liquex_render(template_ast, project_id, assigns) do
ok
else

View File

@@ -46,7 +46,7 @@ defmodule BDS.Scripting.Capabilities.AppShell do
end
def blogmark_bookmarklet do
"javascript:(()=>{const t=encodeURIComponent(document.title||'');const u=encodeURIComponent(location.href||'');location.href='bds://new-post?title='+t+'&url='+u;})();"
"javascript:(()=>{const t=encodeURIComponent(document.title||'');const u=encodeURIComponent(location.href||'');location.href='bds2://new-post?title='+t+'&url='+u;})();"
end
def title_bar_metrics(opts) do

View File

@@ -225,6 +225,26 @@ defmodule BDS.Scripts do
end
end
@doc """
Returns the executable source for a script, reading the published file body
when the in-memory content is not loaded.
"""
@spec resolved_content(Script.t()) :: String.t()
def resolved_content(%Script{} = script), do: effective_script_content(script)
@doc """
Lists enabled `transform` scripts for a project in the deterministic order
the transform pipeline applies them: updated_at, then slug, then id.
"""
@spec list_transform_scripts(String.t()) :: [Script.t()]
def list_transform_scripts(project_id) when is_binary(project_id) do
Script
|> where([s], s.project_id == ^project_id and s.kind == :transform and s.enabled == true)
|> order_by([s], asc: s.updated_at, asc: s.slug, asc: s.id)
|> Repo.all()
|> Enum.map(&hydrate_script_content/1)
end
defp default_entrypoint(:macro), do: "render"
defp default_entrypoint(_kind), do: "main"

View File

@@ -0,0 +1,133 @@
defmodule BDS.Scripts.Transforms do
@moduledoc """
Runs the blogmark transform pipeline (spec: script.allium `ExecuteTransform`).
Enabled `transform` scripts for a project are applied sequentially to a post
candidate produced by a `bds2://new-post` blogmark deep link. Each transform
receives the current candidate plus a context describing the blogmark source
and origin URL, and returns the modified candidate.
Guarantees enforced here:
* `TransformTrigger` — each script receives the candidate plus
`{source = "blogmark", url = ...}` context.
* `TransformPipelineContinuation` — a transform error is captured per script
and does not roll back the last valid candidate; the pipeline continues.
* `TransformToastBudget` — at most `transform_max_toasts_per_script` toasts
are accepted from any one transform, with a total budget of
`transform_max_toasts_total`, each truncated to
`transform_max_toast_length` characters.
"""
require Logger
alias BDS.Scripts
alias BDS.Scripts.Script
alias BDS.Scripting
alias BDS.Scripting.Capabilities.Util
@type data :: %{optional(String.t()) => term()}
@type result :: %{
data: data(),
toasts: [String.t()],
errors: [%{slug: String.t() | nil, reason: term()}]
}
@doc """
Applies every enabled transform script for `project_id` to `data` in order.
Returns `{:ok, %{data:, toasts:, errors:}}` where `data` is the final
candidate, `toasts` are the budget-enforced messages accepted across the
pipeline, and `errors` records any transforms that failed.
"""
@spec run(String.t(), data(), keyword()) :: {:ok, result()}
def run(project_id, data, opts \\ [])
when is_binary(project_id) and is_map(data) and is_list(opts) do
context = %{"source" => "blogmark", "url" => Map.get(data, "url")}
transforms = Scripts.list_transform_scripts(project_id)
initial = %{data: data, toasts: [], errors: [], toast_total: 0}
final =
Enum.reduce(transforms, initial, fn script, acc ->
apply_transform(project_id, script, context, acc, opts)
end)
{:ok,
%{data: final.data, toasts: Enum.reverse(final.toasts), errors: Enum.reverse(final.errors)}}
end
defp apply_transform(_project_id, %Script{entrypoint: entry}, _context, acc, _opts)
when entry in [nil, ""] do
acc
end
defp apply_transform(project_id, %Script{} = script, context, acc, opts) do
source = Scripts.resolved_content(script)
case Scripting.execute_project_script(
project_id,
source,
script.entrypoint,
[acc.data, context],
opts
) do
{:ok, returned} ->
{next_data, raw_toasts} = split_return(Util.normalize_input(returned), acc.data)
accept_toasts(%{acc | data: next_data}, raw_toasts)
{:error, reason} ->
Logger.warning("transform #{script.slug} failed: #{inspect(reason)}")
%{acc | errors: [%{slug: script.slug, reason: reason} | acc.errors]}
end
end
# A transform may return either the candidate map directly, or a wrapper
# `{ data = <candidate>, toasts = [...] }`. Blogmark candidates never carry a
# nested "data" map, so the wrapper shape is unambiguous.
defp split_return(%{"data" => %{} = inner} = wrapper, _previous) do
{inner, toast_list(Map.get(wrapper, "toasts"))}
end
defp split_return(returned, _previous) when is_map(returned), do: {returned, []}
defp split_return(_returned, previous), do: {previous, []}
defp toast_list(list) when is_list(list), do: Enum.filter(list, &is_binary/1)
defp toast_list(_other), do: []
defp accept_toasts(acc, raw_toasts) do
per_script_max = config(:transform_max_toasts_per_script, 5)
total_max = config(:transform_max_toasts_total, 20)
max_length = config(:transform_max_toast_length, 300)
raw_toasts
|> Enum.take(per_script_max)
|> Enum.reduce(acc, fn message, inner_acc ->
if inner_acc.toast_total >= total_max do
inner_acc
else
truncated = truncate(message, max_length)
%{
inner_acc
| toasts: [truncated | inner_acc.toasts],
toast_total: inner_acc.toast_total + 1
}
end
end)
end
defp truncate(message, max_length) do
if String.length(message) > max_length do
String.slice(message, 0, max_length)
else
message
end
end
defp config(key, default) do
:bds
|> Application.fetch_env!(:scripting)
|> Keyword.get(key, default)
end
end

View File

@@ -350,7 +350,7 @@ defmodule BDS.Templates do
end
defp validate_liquid(source) do
case Liquex.parse(source) do
case BDS.Rendering.LiquidParser.validate(source) do
{:ok, _ast} -> :ok
{:error, reason, line} -> {:error, "#{reason} at line #{line}"}
end

View File

@@ -1,30 +0,0 @@
<div
class="macro-gallery gallery-cols-{{ columns }}"
data-post-id="{{ post_id | escape }}"
data-columns="{{ columns }}"
data-lightbox="true"
>
<div class="gallery-container gallery-lightbox">
{%- if items.size > 0 -%}
{%- for item in items -%}
<a
class="gallery-item"
href="{{ item.media_path | escape }}"
data-lightbox="{{ item.group_name | escape }}"
data-title="{{ item.title | escape }}"
>
<img
src="{{ item.media_path | escape }}"
alt="{{ item.alt | escape }}"
loading="lazy"
/>
</a>
{%- endfor -%}
{%- else -%}
<div class="gallery-empty">{{ empty_label | escape }}</div>
{%- endif -%}
</div>
{%- if caption -%}
<figcaption class="gallery-caption">{{ caption | escape }}</figcaption>
{%- endif -%}
</div>

View File

@@ -1,33 +0,0 @@
<div class="{{ root_classes }}"{% for attr in data_attrs %} {{ attr.name }}="{{ attr.value | escape }}"{% endfor %}>
<div class="photo-archive-container">
{%- if months.size > 0 -%}
{%- for month in months -%}
<div class="photo-archive-month-wrapper">
<div class="photo-archive-month">
<div class="photo-archive-month-label">
<span>{{ month.label | escape }}</span>
</div>
<div class="photo-archive-gallery">
{%- for item in month.items -%}
<a
class="photo-archive-item"
href="{{ item.media_path | escape }}"
data-lightbox="{{ item.group_name | escape }}"
data-title="{{ item.title | escape }}"
>
<img
src="{{ item.media_path | escape }}"
alt="{{ item.alt | escape }}"
loading="lazy"
/>
</a>
{%- endfor -%}
</div>
</div>
</div>
{%- endfor -%}
{%- else -%}
<div class="photo-archive-empty">{{ empty_label | escape }}</div>
{%- endif -%}
</div>
</div>

View File

@@ -1,19 +0,0 @@
<div
class="macro-tag-cloud"
data-tag-cloud="true"
data-orientation="{{ orientation }}"
data-color-distribution="quantile"
data-color-easing="0.7"
data-color-theme="pico"{%- if words_json -%} data-tag-cloud-words="{{ words_json }}" data-width="{{ width }}" data-height="{{ height }}"{%- endif -%}
>
{%- if words_json -%}
<svg
class="tag-cloud-canvas"
viewBox="0 0 {{ width }} {{ height }}"
preserveAspectRatio="xMidYMid meet"
aria-label="{{ aria_label | escape }}"
></svg>
{%- else -%}
<div class="tag-cloud-empty">{{ empty_label | escape }}</div>
{%- endif -%}
</div>

View File

@@ -1,9 +0,0 @@
<div class="macro-vimeo">
<iframe
src="https://player.vimeo.com/video/{{ id | escape }}"
title="{{ title | escape }}"
frameborder="0"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
></iframe>
</div>

View File

@@ -1,9 +0,0 @@
<div class="macro-youtube">
<iframe
src="https://www.youtube.com/embed/{{ id | escape }}?rel=0"
title="{{ title | escape }}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>

View File

@@ -1,23 +0,0 @@
---
id: 77f27148-8a19-4da2-8532-faa79683ba40
slug: not-found
title: Not Found
kind: not_found
enabled: true
version: 1
---
<!doctype html>
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
<body>
<main>
<section class="not-found" data-template="not-found">
<article>
<h1>404</h1>
<p>{{ not_found_message }}</p>
<p><a href="/" role="button">{{ not_found_back_label }}</a></p>
</article>
</section>
</main>
</body>
</html>

View File

@@ -1,27 +0,0 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ page_title }}</title>
{% assign resolved_pico_stylesheet_href = pico_stylesheet_href | default: '/assets/pico.min.css' %}
<link rel="stylesheet" href="{{ resolved_pico_stylesheet_href }}" />
<link rel="stylesheet" href="/assets/lightbox.min.css" />
<link rel="stylesheet" href="/assets/highlight.min.css" />
<link rel="stylesheet" href="/assets/vanilla-calendar.min.css" />
<link rel="stylesheet" href="/assets/bds.css" />
{% assign feed_prefix = language_prefix | default: '' %}
<link rel="alternate" type="application/rss+xml" title="RSS" href="{{ feed_prefix }}/rss.xml" />
<link rel="alternate" type="application/atom+xml" title="Atom" href="{{ feed_prefix }}/atom.xml" />
{% for alternate_link in alternate_links %}
<link rel="alternate" hreflang="{{ alternate_link.hreflang | escape }}" href="{{ alternate_link.href | escape }}" />
{% endfor %}
<script defer src="/assets/highlight.min.js"></script>
<script defer src="/assets/code-enhancements.js"></script>
<script defer src="/assets/d3.layout.cloud.js"></script>
<script defer src="/assets/tag-cloud.js"></script>
<script defer src="/assets/lightbox.min.js"></script>
<script defer src="/assets/vanilla-calendar.min.js"></script>
<script defer src="/assets/calendar-runtime.js"></script>
<script defer src="/assets/search-runtime.js"></script>
<link rel="stylesheet" href="{{ language_prefix }}/pagefind/pagefind-ui.css" />
<script defer src="{{ language_prefix }}/pagefind/pagefind-ui.js"></script>
</head>

View File

@@ -1,41 +0,0 @@
{% if blog_languages.size > 1 %}
<nav class="language-switcher" aria-label="{{ labels.language_switcher_label }}">
{% for lang in blog_languages %}
{% if lang.is_current %}
<span class="language-switcher-badge language-switcher-badge-current" aria-current="true" title="{{ lang.code }}">{{ lang.flag }}</span>
{% else %}
<a class="language-switcher-badge" href="{{ lang.href_prefix | default: '/' }}" data-lang-prefix="{{ lang.href_prefix }}" title="{{ lang.code }}">{{ lang.flag }}</a>
{% endif %}
{% endfor %}
<div class="blog-search-widget" aria-label="{{ labels.site_search_label }}">
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="{{ labels.site_search_label }}">
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<div class="blog-search-panel" data-blog-search-panel hidden>
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ labels.search_placeholder }}" data-search-no-results="{{ labels.search_no_results }}"></div>
</div>
</div>
</nav>
<script>
(function(){
var links=document.querySelectorAll('.language-switcher-badge[data-lang-prefix]');
var path=location.pathname.replace(/^\/[a-z]{2}(?=\/|$)/,'') || '/';
links.forEach(function(a){a.href=(a.dataset.langPrefix||'')+path;});
}());
</script>
{% else %}
<div class="blog-search-standalone" aria-label="{{ labels.site_search_label }}">
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="{{ labels.site_search_label }}">
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<div class="blog-search-panel" data-blog-search-panel hidden>
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ labels.search_placeholder }}" data-search-no-results="{{ labels.search_no_results }}"></div>
</div>
</div>
{% endif %}

View File

@@ -1,63 +0,0 @@
<ul class="blog-menu-list">
{% for item in items %}
<li class="blog-menu-item{% if item.has_children %} blog-menu-item-with-children{% endif %}">
{% if item.href == '#' %}
<span class="blog-menu-link">{{ item.title }}</span>
{% else %}
<a class="blog-menu-link" href="{{ item.href }}">{{ item.title }}</a>
{% endif %}
{% if item.has_children %}
<div class="blog-menu-submenu">
{% render 'partials/menu-items', items: item.children %}
</div>
{% endif %}
</li>
{% endfor %}
{% if include_calendar %}
<li class="blog-menu-item blog-menu-calendar">
<button
type="button"
class="blog-menu-calendar-button"
data-blog-calendar-toggle
{% if calendar_initial_year %}data-blog-calendar-year="{{ calendar_initial_year }}"{% endif %}
{% if calendar_initial_month %}data-blog-calendar-month="{{ calendar_initial_month }}"{% endif %}
aria-label="{{ labels.calendar_open_label }}"
title="{{ labels.calendar_open_label }}"
>
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false">
<rect x="3" y="5" width="18" height="16" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="8" y1="3" x2="8" y2="7"></line>
<line x1="16" y1="3" x2="16" y2="7"></line>
</svg>
</button>
<section
id="blog-calendar"
class="blog-calendar-panel"
data-blog-calendar-panel
data-i18n-loading="{{ labels.calendar_loading_label }}"
data-i18n-error="{{ labels.calendar_error_label }}"
hidden
>
<header class="blog-calendar-header">
<strong>{{ labels.calendar_title_label }}</strong>
<button
type="button"
class="blog-calendar-close"
data-blog-calendar-close
aria-label="{{ labels.calendar_close_label }}"
title="{{ labels.calendar_close_label }}"
>
×
</button>
</header>
<div class="blog-calendar-content">
<div data-blog-calendar-root></div>
<p class="blog-calendar-status" data-blog-calendar-status>{{ labels.calendar_loading_label }}</p>
</div>
</section>
</li>
{% endif %}
</ul>

View File

@@ -1,7 +0,0 @@
<nav class="blog-menu">
{% if menu_items and menu_items.size > 0 %}
{% render 'partials/menu-items', items: menu_items, include_calendar: true, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month, labels: labels %}
{% else %}
{% render 'partials/menu-items', items: menu_items, include_calendar: true, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month, labels: labels %}
{% endif %}
</nav>

View File

@@ -1,97 +0,0 @@
---
id: b27d1547-548a-47ca-8bab-d81edef003ba
slug: post-list
title: Post List
kind: list
enabled: true
version: 1
---
<!doctype html>
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix %}
<body>
<main>
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language, labels: labels %}
{% if archive_context %}
{% if show_archive_range_heading and min_date and max_date %}
{% if archive_context.kind == 'tag' or archive_context.kind == 'category' %}
<h1 class="archive-heading">{{ archive_context.name }} - {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}</h1>
{% else %}
<h1 class="archive-heading">{{ labels.archive_label }} {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}</h1>
{% endif %}
{% else %}
{% if archive_context.kind == 'tag' or archive_context.kind == 'category' %}
<h1 class="archive-heading">{{ archive_context.name }}</h1>
{% elsif archive_context.kind == 'month' and archive_context.month and archive_context.year %}
<h1 class="archive-heading">{{ labels.archive_label }} {{ archive_month_name }} {{ archive_context.year }}</h1>
{% elsif archive_context.kind == 'year' and archive_context.year %}
<h1 class="archive-heading">{{ labels.archive_label }} {{ archive_context.year }}</h1>
{% elsif archive_context.kind == 'day' and archive_context.day and archive_context.month and archive_context.year %}
<h1 class="archive-heading">{{ labels.archive_label }} {{ archive_context.day }}. {{ archive_month_name }} {{ archive_context.year }}</h1>
{% else %}
<h1 class="archive-heading">{{ page_title }}</h1>
{% endif %}
{% endif %}
{% endif %}
{% render 'partials/menu', menu_items: menu_items, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month, labels: labels %}
<section class="post-list" data-template="post-list" data-list-page="{{ is_list_page }}" data-first-page="{{ is_first_page }}" data-last-page="{{ is_last_page }}">
{% for day_block in day_blocks %}
{% if day_block.show_date_marker %}
<section class="archive-day-group">
<aside class="archive-day-marker"><span>{{ day_block.date_label }}</span></aside>
<div class="archive-day-posts">
{% for post in day_block.posts %}
<div class="post">
{% if post.show_title %}
{% assign canonical_post_href = canonical_post_path_by_slug[post.slug] %}
{% if canonical_post_href == blank %}
{% assign canonical_post_href = '/posts/' | append: post.slug %}
{% endif %}
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
{% endif %}
{{ post.content }}
</div>
{% endfor %}
</div>
</section>
{% else %}
{% for post in day_block.posts %}
<div class="post">
{% if post.show_title %}
{% assign canonical_post_href = canonical_post_path_by_slug[post.slug] %}
{% if canonical_post_href == blank %}
{% assign canonical_post_href = '/posts/' | append: post.slug %}
{% endif %}
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
{% endif %}
{{ post.content }}
</div>
{% endfor %}
{% endif %}
{% if day_block.show_separator %}
<div class="archive-day-separator" aria-hidden="true"></div>
{% endif %}
{% endfor %}
</section>
{% if has_prev_page or has_next_page %}
<nav class="preview-pagination" aria-label="{{ labels.pagination_label }}">
{% if has_prev_page %}
<a href="{{ prev_page_href }}" class="preview-pagination-link" aria-label="{{ labels.newer_label }}">{{ labels.newer_label }}</a>
{% else %}
<span class="spacer"></span>
{% endif %}
{% if has_next_page %}
<a href="{{ next_page_href }}" class="preview-pagination-link" aria-label="{{ labels.older_label }}">{{ labels.older_label }}</a>
{% else %}
<span class="spacer"></span>
{% endif %}
</nav>
{% endif %}
</main>
</body>
</html>

View File

@@ -1,41 +0,0 @@
---
id: d64dcf22-28dc-4406-bc2b-1ef72f92cc0a
slug: single-post
title: Single Post
kind: post
enabled: true
version: 1
---
<!doctype html>
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, alternate_links: alternate_links, language_prefix: language_prefix %}
<body>
<main>
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language, labels: labels %}
<h1>{{ post.title }}</h1>
{% render 'partials/menu', menu_items: menu_items, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month, labels: labels %}
{% if post_categories.size > 0 or post_tags.size > 0 %}
<div class="single-post-taxonomy" aria-label="{{ labels.taxonomy_label }}">
{% for category in post_categories %}
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/{{ category | slugify | url_encode }}/">{{ category | escape }}</a>
{% endfor %}
{% for tag in post_tags %}
{% assign tag_color = tag_color_by_name[tag] %}
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/{{ tag | slugify | url_encode }}/"{% if tag_color %} style="--bubble-accent: {{ tag_color | escape }};"{% endif %}>{{ tag | escape }}</a>
{% endfor %}
</div>
{% endif %}
<article class="single-post blog-post" data-template="single-post" data-pagefind-body>
<div class="post">{{ post.content }}</div>
</article>
{% if backlinks.size > 0 %}
<div class="single-post-backlinks" aria-label="{{ labels.backlinks_label }}">
<span class="single-post-backlinks-label">{{ labels.linked_from_label }}</span>
{% for backlink in backlinks %}
<a class="single-post-taxonomy-bubble single-post-backlink-bubble" href="{{ backlink.path }}">{{ backlink.display_slug }}</a>
{% endfor %}
</div>
{% endif %}
</main>
</body>
</html>

View File

@@ -262,7 +262,8 @@ rule ExecuteTransform {
-- config.transform_max_toast_length characters.
@guidance
-- bds://new-post deep links from browser bookmarks
-- bds2://new-post deep links from browser bookmarks
-- (bds2:// scheme avoids clashing with the legacy app's bds:// scheme)
-- Ordering is deterministic: updated_at, then slug, then id
}

View File

@@ -199,11 +199,16 @@ invariant LiquidFilterSubset {
-- | default: fallback_value
-- | append: suffix_string
--
-- Custom filters (2):
-- Custom filters (3):
-- | i18n: language — translates a key string for given language
-- | markdown: post_id, post_data_json_by_id, canonical_post_path_by_slug,
-- canonical_media_path_by_source_path, language, language_prefix
-- — renders Markdown to HTML with link rewriting (6 arguments)
-- | slugify — slugifies a string (used for tag/category URLs)
--
-- Enforced at publish/validation time (LiquidParser.validate); any other
-- filter is rejected even though Liquex would otherwise apply it as a
-- built-in standard filter.
--
-- NOT used: date, strip_html, truncate, split, join, size (as filter),
-- upcase, downcase, replace, remove, sort, map, where, first, last,
@@ -218,6 +223,10 @@ invariant LiquidOperatorSubset {
-- Special values: blank (nil/empty comparison)
-- Property access: dot notation (object.property), .size on arrays,
-- bracket notation for map lookups (map[key])
--
-- Enforced at publish/validation time (LiquidParser.validate); any other
-- comparison operator (!=, <, >=, <=, contains) is rejected even though
-- Liquex would otherwise evaluate it.
}
invariant LiquidRenderContext {

View File

@@ -157,6 +157,48 @@ defmodule BDS.ProjectsTest do
assert Repo.aggregate(Project, :count, :id) == 1
end
test "the default project's public content folder lives outside the repo and private dir" do
Repo.delete_all(Project)
assert {:ok, default_project} = BDS.Projects.ensure_default_project()
data_dir = BDS.Projects.project_data_dir(default_project)
# Public content must live under the per-user default content location,
# never in the application repo (priv/data) nor the private app dir.
refute String.starts_with?(data_dir, Path.expand("../../priv/data", __DIR__))
refute String.starts_with?(data_dir, BDS.Projects.private_dir())
assert String.starts_with?(data_dir, Application.fetch_env!(:bds, :default_content_root))
end
test "project_data_dir never falls back into the application repo" do
# A project without an explicit data_path resolves to the per-user default
# content location, not priv/data/projects/<id> inside the repo.
project = %Project{id: "no-path-#{System.unique_integer([:positive])}", data_path: nil}
data_dir = BDS.Projects.project_data_dir(project)
refute String.starts_with?(data_dir, Path.expand("../../priv/data", __DIR__))
assert String.starts_with?(data_dir, Application.fetch_env!(:bds, :default_content_root))
end
test "project locations are recorded in a machine-local registry under private_dir", %{
temp_root: temp_root
} do
external_dir = Path.join(temp_root, "registry-blog")
File.mkdir_p!(external_dir)
assert {:ok, project} =
BDS.Projects.create_project(%{name: "Registry Blog", data_path: external_dir})
registry = BDS.Projects.project_registry()
assert registry[project.id] == external_dir
assert String.starts_with?(BDS.Projects.registry_path(), BDS.Projects.private_dir())
assert {:ok, _deleted} = BDS.Projects.delete_project(project.id)
refute Map.has_key?(BDS.Projects.project_registry(), project.id)
end
test "delete_project rejects the default and active projects", %{temp_root: temp_root} do
Repo.delete_all(Project)

View File

@@ -109,6 +109,33 @@ defmodule BDS.Scripting.ApiTest do
assert {:error, _reason} = BDS.Scripting.execute_macro(project.id, bad_source, [])
end
test "macro execution is bounded by its timeout budget (MacroTimeout)", %{project: project} do
# An entrypoint that never returns must not run forever: the macro timeout
# budget terminates it and degrades to an error. max_reductions: :none forces
# the luerl sandbox onto its wall-clock path so we exercise the time budget
# itself rather than the reduction limit.
looping_source = "function render() while true do end end"
budget_ms = 150
{elapsed_us, result} =
:timer.tc(fn ->
BDS.Scripting.execute_macro(project.id, looping_source, [],
timeout: budget_ms,
max_reductions: :none
)
end)
assert {:error, :timeout} = result
elapsed_ms = div(elapsed_us, 1000)
# The macro must be killed close to its budget, never allowed to run for the
# default multi-minute script timeout. Generous upper bound to stay stable
# on loaded CI while still proving the budget is enforced.
assert elapsed_ms < 2_000,
"macro ran for #{elapsed_ms}ms, expected termination near the #{budget_ms}ms budget"
end
test "project scripting exposes project, post, script, template, metadata, and task namespaces",
%{
project: project
@@ -618,6 +645,13 @@ defmodule BDS.Scripting.ApiTest do
assert result["startup_project_name"] == "Scripting API"
end
test "blogmark bookmarklet uses the bds2:// scheme to avoid clashing with the old app" do
bookmarklet = BDS.Scripting.Capabilities.AppShell.blogmark_bookmarklet()
assert String.contains?(bookmarklet, "bds2://new-post?")
refute String.contains?(bookmarklet, "'bds://new-post")
end
defp write_binary_fixture(base_dir, name, contents) do
path = Path.join(base_dir, name)
File.write!(path, contents)

View File

@@ -0,0 +1,240 @@
defmodule BDS.Scripts.TransformsTest do
use ExUnit.Case, async: false
alias BDS.Scripts
alias BDS.Scripts.Transforms
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
temp_dir =
Path.join(System.tmp_dir!(), "bds-transforms-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "Transforms", data_path: temp_dir})
%{project: project}
end
defp transform(project_id, title, content, opts \\ []) do
{:ok, script} =
Scripts.create_script(%{
project_id: project_id,
title: title,
kind: :transform,
content: content,
entrypoint: Keyword.get(opts, :entrypoint, "main")
})
script =
case Keyword.get(opts, :enabled, true) do
true ->
script
false ->
{:ok, s} = Scripts.update_script(script.id, %{enabled: false})
s
end
script
end
test "runs enabled transforms in deterministic order (updated_at, slug, id)", %{
project: project
} do
# Each transform appends its marker to content so we can read execution order.
transform(project.id, "Bravo", """
function main(data, _ctx)
data.content = data.content .. "B"
return data
end
""")
# Ensure distinct updated_at ordering by spacing out creation.
Process.sleep(5)
transform(project.id, "Alpha", """
function main(data, _ctx)
data.content = data.content .. "A"
return data
end
""")
data = %{
"title" => "t",
"content" => "",
"tags" => [],
"categories" => [],
"url" => "http://x"
}
assert {:ok, result} = Transforms.run(project.id, data)
# Bravo created first (earlier updated_at) so runs before Alpha.
assert result.data["content"] == "BA"
assert result.errors == []
end
test "disabled transforms and transforms from other projects are skipped", %{project: project} do
{:ok, other} =
BDS.Projects.create_project(%{
name: "Other",
data_path:
Path.join(
System.tmp_dir!(),
"bds-transforms-other-#{System.unique_integer([:positive])}"
)
})
transform(
project.id,
"Disabled",
"""
function main(data, _ctx)
data.content = data.content .. "D"
return data
end
""", enabled: false)
transform(other.id, "Foreign", """
function main(data, _ctx)
data.content = data.content .. "F"
return data
end
""")
transform(project.id, "Enabled", """
function main(data, _ctx)
data.content = data.content .. "E"
return data
end
""")
data = %{
"title" => "t",
"content" => "",
"tags" => [],
"categories" => [],
"url" => "http://x"
}
assert {:ok, result} = Transforms.run(project.id, data)
assert result.data["content"] == "E"
end
test "pipeline continues after a failing transform, keeping last valid state", %{
project: project
} do
transform(project.id, "First", """
function main(data, _ctx)
data.content = data.content .. "1"
return data
end
""")
Process.sleep(5)
transform(project.id, "Boom", """
function main(_data, _ctx)
error("boom")
end
""")
Process.sleep(5)
transform(project.id, "Third", """
function main(data, _ctx)
data.content = data.content .. "3"
return data
end
""")
data = %{
"title" => "t",
"content" => "",
"tags" => [],
"categories" => [],
"url" => "http://x"
}
assert {:ok, result} = Transforms.run(project.id, data)
# Boom's failure does not roll back "1" and does not stop "3".
assert result.data["content"] == "13"
assert [%{reason: _}] = result.errors
end
test "receives blogmark context with source and originating url", %{project: project} do
transform(project.id, "Ctx", """
function main(data, ctx)
data.content = ctx.source .. "|" .. ctx.url
return data
end
""")
data = %{
"title" => "t",
"content" => "",
"tags" => [],
"categories" => [],
"url" => "http://example.com/a"
}
assert {:ok, result} = Transforms.run(project.id, data)
assert result.data["content"] == "blogmark|http://example.com/a"
end
test "per-script toast budget caps and truncates toasts", %{project: project} do
long = String.duplicate("x", 500)
transform(project.id, "Noisy", """
function main(data, _ctx)
local toasts = {}
for i = 1, 10 do toasts[i] = "#{long}" end
return { data = data, toasts = toasts }
end
""")
data = %{
"title" => "t",
"content" => "",
"tags" => [],
"categories" => [],
"url" => "http://x"
}
assert {:ok, result} = Transforms.run(project.id, data)
# max 5 per script
assert length(result.toasts) == 5
# truncated to 300 chars
assert Enum.all?(result.toasts, &(String.length(&1) == 300))
end
test "total toast budget caps across the whole pipeline", %{project: project} do
body = """
function main(data, _ctx)
local toasts = {}
for i = 1, 5 do toasts[i] = "msg" end
return { data = data, toasts = toasts }
end
"""
# 5 transforms x 5 toasts each = 25 emitted, total budget is 20.
for i <- 1..5 do
transform(project.id, "T#{i}", body)
Process.sleep(3)
end
data = %{
"title" => "t",
"content" => "",
"tags" => [],
"categories" => [],
"url" => "http://x"
}
assert {:ok, result} = Transforms.run(project.id, data)
assert length(result.toasts) == 20
end
end

View File

@@ -1,6 +1,8 @@
defmodule BDS.TemplateLookupPriorityTest do
use ExUnit.Case, async: false
import Ecto.Query
alias BDS.Rendering.TemplateSelection
setup do
@@ -138,6 +140,78 @@ defmodule BDS.TemplateLookupPriorityTest do
end
end
describe "BundledDefaultTemplatesExistOutsideProjectData" do
test "single-post bundled template resolves with no Template rows", %{project: project} do
assert [] = BDS.Repo.all(from t in BDS.Templates.Template, where: t.project_id == ^project.id)
{:ok, source} = TemplateSelection.load_template_source(project.id, :post, nil)
assert source =~ ~s(data-template="single-post")
end
test "post-list bundled template resolves with no Template rows", %{project: project} do
assert [] = BDS.Repo.all(from t in BDS.Templates.Template, where: t.project_id == ^project.id)
{:ok, source} = TemplateSelection.load_template_source(project.id, :list, nil)
assert source =~ ~s({% if archive_context %})
end
test "not-found bundled template resolves with no Template rows", %{project: project} do
assert [] = BDS.Repo.all(from t in BDS.Templates.Template, where: t.project_id == ^project.id)
{:ok, source} = TemplateSelection.load_template_source(project.id, :not_found, nil)
assert source =~ ~s(data-template="not-found")
assert source =~ "404"
end
test "bundled defaults resolve even when project has no templates directory", %{
project: project
} do
template_dir = Path.join(BDS.Projects.project_data_dir(project), "templates")
refute File.exists?(template_dir)
{:ok, source} = TemplateSelection.load_template_source(project.id, :post, nil)
assert source =~ ~s(data-template="single-post")
end
end
describe "UserTemplateDirectoryOverridesBundledDefaults" do
test "published project template with default slug overrides bundled single-post", %{
project: project
} do
_template =
create_published_template(
project.id,
"single-post",
"<article data-template=\"user-single-post\">{{ page.title }}</article>"
)
{:ok, source} = TemplateSelection.load_template_source(project.id, :post, nil)
assert source =~ ~s(data-template="user-single-post")
refute source =~ ~s(data-template="single-post")
end
test "published project template overrides bundled default when slug requested explicitly", %{
project: project
} do
_template =
create_published_template(
project.id,
"single-post",
"<article data-template=\"user-single-post\">{{ page.title }}</article>"
)
{:ok, source} = TemplateSelection.load_template_source(project.id, :post, "single-post")
assert source =~ ~s(data-template="user-single-post")
refute source =~ ~s(data-template="single-post")
end
end
describe "end-to-end template lookup with rendering" do
test "post renders with tag-specific template when no post template set", %{
project: project

View File

@@ -315,6 +315,133 @@ defmodule BDS.TemplatesTest do
assert published.status == :published
end
# LiquidTagSubset invariant (template.allium): only {% if %}, {% for %},
# {% assign %}, {% render %} (plus {{ }} output) are supported. Any tag
# outside that subset must be rejected at publish time.
for tag <- ["unless", "case", "capture", "tablerow", "cycle", "increment"] do
@tag_name tag
test "publish_template rejects unsupported Liquid tag #{tag}", %{
project: project
} do
tag = @tag_name
assert {:ok, template} =
BDS.Templates.create_template(%{
project_id: project.id,
title: "Tag #{tag}",
kind: :post,
content: "{% #{tag} foo %}bar{% end#{tag} %}"
})
assert {:error, {:invalid_liquid, _reason}} = BDS.Templates.publish_template(template.id)
reloaded = Repo.get!(BDS.Templates.Template, template.id)
assert reloaded.status == :draft
end
end
# LiquidFilterSubset invariant (template.allium): only escape, url_encode,
# default, append (standard) and i18n, markdown, slugify (custom) are allowed.
# Any other filter must be rejected at publish time, even though Liquex would
# otherwise apply it as a built-in standard filter.
for filter <- ["upcase", "downcase", "date", "truncate", "split", "reverse"] do
@filter_name filter
test "publish_template rejects unsupported Liquid filter #{filter}", %{
project: project
} do
filter = @filter_name
assert {:ok, template} =
BDS.Templates.create_template(%{
project_id: project.id,
title: "Filter #{filter}",
kind: :post,
content: "{{ title | #{filter} }}"
})
assert {:error, {:invalid_liquid, reason}} =
BDS.Templates.publish_template(template.id)
assert reason =~ "unsupported filter: #{filter}"
reloaded = Repo.get!(BDS.Templates.Template, template.id)
assert reloaded.status == :draft
end
end
for filter <- ["escape", "url_encode", "slugify"] do
@filter_name filter
test "publish_template allows supported Liquid filter #{filter}", %{
project: project
} do
filter = @filter_name
assert {:ok, template} =
BDS.Templates.create_template(%{
project_id: project.id,
title: "Filter #{filter}",
kind: :post,
content: "{{ title | #{filter} }}"
})
assert {:ok, published} = BDS.Templates.publish_template(template.id)
assert published.status == :published
end
end
# LiquidOperatorSubset invariant (template.allium): only == and > comparison
# operators (plus the and/or logical operators and bare-variable truthiness)
# are allowed. Any other comparison operator must be rejected at publish time,
# even though Liquex would otherwise evaluate it.
for op <- ["!=", "<", ">=", "<=", "contains"] do
@op_name op
test "publish_template rejects unsupported Liquid operator #{op}", %{
project: project
} do
op = @op_name
assert {:ok, template} =
BDS.Templates.create_template(%{
project_id: project.id,
title: "Operator #{op}",
kind: :post,
content: "{% if title #{op} other %}yes{% endif %}"
})
assert {:error, {:invalid_liquid, reason}} =
BDS.Templates.publish_template(template.id)
assert reason =~ "unsupported operator: #{op}"
reloaded = Repo.get!(BDS.Templates.Template, template.id)
assert reloaded.status == :draft
end
end
for content <- [
"{% if title == other %}yes{% endif %}",
"{% if total > 0 %}yes{% endif %}",
"{% if published %}yes{% endif %}",
"{% if a == b and c > d %}yes{% endif %}",
"{% if a == b or c == d %}yes{% endif %}"
] do
@op_content content
test "publish_template allows supported operators in #{content}", %{project: project} do
content = @op_content
assert {:ok, template} =
BDS.Templates.create_template(%{
project_id: project.id,
title: "Allowed #{content}",
kind: :post,
content: content
})
assert {:ok, published} = BDS.Templates.publish_template(template.id)
assert published.status == :published
end
end
test "rebuild_templates_from_files recreates published templates from disk", %{
project: project,
temp_dir: temp_dir

View File

@@ -2,6 +2,16 @@ cache_root = Path.join(System.tmp_dir!(), "bds-test-cache-#{System.unique_intege
File.mkdir_p!(cache_root)
Application.put_env(:bds, :project_cache_root, cache_root)
# Public, user-owned default project content lives under a per-user default
# content location — never the repo or the private app dir. Tests redirect it
# to a throwaway temp dir so first-launch defaults never touch the developer's
# home directory.
content_root =
Path.join(System.tmp_dir!(), "bds-test-content-#{System.unique_integer([:positive])}")
File.mkdir_p!(content_root)
Application.put_env(:bds, :default_content_root, content_root)
Enum.each(["LC_ALL", "LC_MESSAGES", "LANG"], fn variable ->
System.put_env(variable, "en_US.UTF-8")
end)
@@ -10,6 +20,7 @@ ExUnit.start()
ExUnit.after_suite(fn _results ->
File.rm_rf(cache_root)
File.rm_rf(content_root)
end)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, :manual)