diff --git a/SPECGAPS.md b/SPECGAPS.md index 4cfe8ba..8b2bca5 100644 --- a/SPECGAPS.md +++ b/SPECGAPS.md @@ -118,7 +118,7 @@ All reconciled to follow code. Specs must be self-consistent and match code. | 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 | **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 | Write test: unsupported tag raises error | +| 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 | 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 | diff --git a/lib/bds/mcp/tools.ex b/lib/bds/mcp/tools.ex index 24e21ed..d641756 100644 --- a/lib/bds/mcp/tools.ex +++ b/lib/bds/mcp/tools.ex @@ -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 Liquex.parse(source, BDS.Rendering.LiquidParser) do {:ok, _ast} -> {:ok, %{valid: true, errors: []}} diff --git a/lib/bds/rendering/filters.ex b/lib/bds/rendering/filters.ex index 01b9fe1..917f86e 100644 --- a/lib/bds/rendering/filters.ex +++ b/lib/bds/rendering/filters.ex @@ -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 diff --git a/lib/bds/rendering/liquid_parser.ex b/lib/bds/rendering/liquid_parser.ex new file mode 100644 index 0000000..e4aeef0 --- /dev/null +++ b/lib/bds/rendering/liquid_parser.ex @@ -0,0 +1,66 @@ +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. + """ + + import NimbleParsec + + @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 diff --git a/lib/bds/rendering/template_selection.ex b/lib/bds/rendering/template_selection.ex index 8c9413a..506b6ea 100644 --- a/lib/bds/rendering/template_selection.ex +++ b/lib/bds/rendering/template_selection.ex @@ -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 diff --git a/lib/bds/templates.ex b/lib/bds/templates.ex index 3c9548f..bc5f241 100644 --- a/lib/bds/templates.ex +++ b/lib/bds/templates.ex @@ -350,7 +350,7 @@ defmodule BDS.Templates do end defp validate_liquid(source) do - case Liquex.parse(source) do + case Liquex.parse(source, BDS.Rendering.LiquidParser) do {:ok, _ast} -> :ok {:error, reason, line} -> {:error, "#{reason} at line #{line}"} end diff --git a/test/bds/templates_test.exs b/test/bds/templates_test.exs index 8f75b61..3826320 100644 --- a/test/bds/templates_test.exs +++ b/test/bds/templates_test.exs @@ -315,6 +315,31 @@ 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 + test "rebuild_templates_from_files recreates published templates from disk", %{ project: project, temp_dir: temp_dir