diff --git a/SPECGAPS.md b/SPECGAPS.md index 1e7ae8a..759ff2f 100644 --- a/SPECGAPS.md +++ b/SPECGAPS.md @@ -16,7 +16,7 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update | A1-4 | ~~`doNotTranslate: false` written to frontmatter despite "only when true"~~ | frontmatter.allium:398 | `file_sync.ex:78` now converts false→nil so serializer omits the key | **Resolved:** doNotTranslate omitted from frontmatter when false, test added | | A1-5 | ~~Auto-save after 3000ms idle~~ | editor_post.allium:183-188 | PostEditor schedules auto-save via parent timer on dirty change | **Resolved:** 3000ms idle auto-save timer in Bridges, tab-switch save in ShellLive, cancel on manual save, 3 tests added | | A1-6 | ~~On-demand rendering in preview server~~ | preview.allium:53-93 | `Preview.Router` matches post/archive/home/language routes and renders on-demand via `Rendering` | **Resolved:** `Preview.Router` implements on-demand template rendering for post, archive, home, date, tag, category, page, and language-prefixed routes; static file fallback retained for non-HTML assets (pagefind, feeds); 6 tests added | -| A1-7 | Template lookup must use all 4 levels (post→tag→category→default) | template_context.allium:267-277 | Only levels 1 and 4 implemented; tag/category fallback unused | Fix code: implement levels 2-3 in template_selection.ex | +| A1-7 | ~~Template lookup must use all 4 levels (post→tag→category→default)~~ | template_context.allium:267-277 | `resolve_post_template_slug/3` implements tag→category cascade; all callers (preview, generation) updated | **Resolved:** `resolve_post_template_slug/3` in template_selection.ex, callers in preview.ex, router.ex, outputs.ex updated, 8 tests added | | A1-8 | `ValidateLiquid`/`ValidateScript` before publish | template.allium:110, script.allium:165 | No validation gate before publish | Fix code: add validation step before publish | | A1-9 | 17 preset colors + custom hex in tag picker | editor_tags.allium | Native ``, no preset palette | Fix code: implement preset color palette popover | | A1-10 | Template file written on create | engine_side_effects.allium:151-153 | Draft templates have `file_path=""` | Fix code: write template file on create | diff --git a/lib/bds/generation/outputs.ex b/lib/bds/generation/outputs.ex index 220af39..8b8a5c1 100644 --- a/lib/bds/generation/outputs.ex +++ b/lib/bds/generation/outputs.ex @@ -5,6 +5,8 @@ defmodule BDS.Generation.Outputs do import BDS.Generation.Renderers import BDS.Generation.Sitemap, only: [render_feed: 3, render_atom: 3, render_calendar: 1] + alias BDS.Rendering.TemplateSelection + @spec additional_languages(map()) :: [String.t()] def additional_languages(plan) do Enum.reject(plan.blog_languages, &(&1 == plan.language)) @@ -391,10 +393,12 @@ defmodule BDS.Generation.Outputs do canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post) body = load_body(project_id, canonical_variant.file_path, canonical_variant.content) + effective_slug = effective_template_slug(project_id, post) + {page_output_path(post.slug, nil), render_post_output( project_id, - post.template_slug, + effective_slug, %{ id: canonical_variant.id, title: canonical_variant.title, @@ -423,10 +427,12 @@ defmodule BDS.Generation.Outputs do |> Enum.map(fn post -> body = load_body(project_id, post.file_path, post.content) + effective_slug = effective_template_slug(project_id, post) + {page_output_path(post.slug, language), render_post_output( project_id, - post.template_slug, + effective_slug, %{ id: post.id, title: post.title, @@ -521,10 +527,12 @@ defmodule BDS.Generation.Outputs do canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post) body = load_body(project_id, canonical_variant.file_path, canonical_variant.content) + effective_slug = effective_template_slug(project_id, post) + {post_output_path(post), render_post_output( project_id, - post.template_slug, + effective_slug, %{ id: canonical_variant.id, title: canonical_variant.title, @@ -551,10 +559,12 @@ defmodule BDS.Generation.Outputs do Enum.map(posts, fn post -> body = load_body(project_id, post.file_path, post.content) + effective_slug = effective_template_slug(project_id, post) + {post_output_path(post, language), render_post_output( project_id, - post.template_slug, + effective_slug, %{ id: post.id, title: post.title, @@ -571,4 +581,18 @@ defmodule BDS.Generation.Outputs do post_outputs ++ translation_outputs end + + defp effective_template_slug(project_id, post) do + slug = Map.get(post, :template_slug) + + if is_binary(slug) and slug != "" do + slug + else + TemplateSelection.resolve_post_template_slug( + project_id, + Map.get(post, :tags) || [], + Map.get(post, :categories) || [] + ) + end + end end diff --git a/lib/bds/preview.ex b/lib/bds/preview.ex index a024125..ab00398 100644 --- a/lib/bds/preview.ex +++ b/lib/bds/preview.ex @@ -9,6 +9,7 @@ defmodule BDS.Preview do alias BDS.Projects alias BDS.Repo alias BDS.Rendering + alias BDS.Rendering.TemplateSelection @host "127.0.0.1" @port 4123 @@ -219,6 +220,7 @@ defmodule BDS.Preview do defp draft_preview_payload(post, query_params) do requested_language = query_params |> Map.get("lang") |> normalize_requested_language() + effective_slug = post.template_slug || TemplateSelection.resolve_post_template_slug(post.project_id, post.tags, post.categories) case draft_preview_translation(post.id, requested_language, post.language) do %Translation{} = translation -> @@ -230,7 +232,7 @@ defmodule BDS.Preview do slug: post.slug, language: translation.language, excerpt: translation.excerpt, - template_slug: post.template_slug + template_slug: effective_slug } nil -> @@ -242,7 +244,7 @@ defmodule BDS.Preview do slug: post.slug, language: post.language, excerpt: post.excerpt, - template_slug: post.template_slug + template_slug: effective_slug } end end diff --git a/lib/bds/preview/router.ex b/lib/bds/preview/router.ex index de7450b..aadb385 100644 --- a/lib/bds/preview/router.ex +++ b/lib/bds/preview/router.ex @@ -10,6 +10,7 @@ defmodule BDS.Preview.Router do alias BDS.Posts.Post alias BDS.Posts.Translation alias BDS.Rendering + alias BDS.Rendering.TemplateSelection alias BDS.Repo @type route :: @@ -219,7 +220,9 @@ defmodule BDS.Preview.Router do _post_record: effective_record } - case Rendering.render_post_page(project_id, post.template_slug, assigns) do + effective_slug = post.template_slug || TemplateSelection.resolve_post_template_slug(project_id, post.tags, post.categories) + + case Rendering.render_post_page(project_id, effective_slug, assigns) do {:ok, rendered} -> {:ok, rendered} {:error, _reason} -> {:error, :not_found} end diff --git a/lib/bds/rendering/template_selection.ex b/lib/bds/rendering/template_selection.ex index 575d2f7..8c9413a 100644 --- a/lib/bds/rendering/template_selection.ex +++ b/lib/bds/rendering/template_selection.ex @@ -4,13 +4,52 @@ defmodule BDS.Rendering.TemplateSelection do import Ecto.Query alias BDS.Frontmatter + alias BDS.Metadata alias BDS.Projects alias BDS.Rendering.FileSystem alias BDS.Rendering.Filters alias BDS.Repo alias BDS.StarterTemplates + alias BDS.Tags.Tag alias BDS.Templates.Template + @spec resolve_post_template_slug(String.t(), [String.t()], [String.t()]) :: + String.t() | nil + def resolve_post_template_slug(project_id, tag_names, category_names) do + resolve_from_tags(project_id, tag_names) || + resolve_from_categories(project_id, category_names) + end + + defp resolve_from_tags(_project_id, []), do: nil + + defp resolve_from_tags(project_id, tag_names) do + Repo.all( + from tag in Tag, + where: + tag.project_id == ^project_id and + tag.name in ^tag_names and + not is_nil(tag.post_template_slug) and + tag.post_template_slug != "", + select: tag.post_template_slug, + limit: 1 + ) + |> List.first() + end + + defp resolve_from_categories(_project_id, []), do: nil + + defp resolve_from_categories(project_id, category_names) do + {:ok, state} = Metadata.get_project_metadata(project_id) + settings = state.category_settings || %{} + + Enum.find_value(category_names, fn cat_name -> + case Map.get(settings, cat_name) do + %{"post_template_slug" => slug} when is_binary(slug) and slug != "" -> slug + _ -> nil + end + end) + end + @spec load_template_source(String.t(), atom(), String.t() | nil) :: {:ok, String.t()} | {:error, term()} def load_template_source(project_id, kind, slug) do diff --git a/test/bds/template_lookup_priority_test.exs b/test/bds/template_lookup_priority_test.exs new file mode 100644 index 0000000..883b1f0 --- /dev/null +++ b/test/bds/template_lookup_priority_test.exs @@ -0,0 +1,215 @@ +defmodule BDS.TemplateLookupPriorityTest do + use ExUnit.Case, async: false + + alias BDS.Rendering.TemplateSelection + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + temp_dir = Path.join(System.tmp_dir!(), "bds-tlp-#{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: "TLP Test", data_path: temp_dir}) + + {:ok, _metadata} = + BDS.Metadata.update_project_metadata(project.id, %{ + main_language: "en", + blog_languages: ["en"] + }) + + %{project: project} + end + + defp create_published_template(project_id, slug, content) do + {:ok, template} = + BDS.Templates.create_template(%{ + project_id: project_id, + title: "Template #{slug}", + kind: :post, + content: content + }) + + if template.slug != slug do + BDS.Repo.update!(Ecto.Changeset.change(template, slug: slug)) + end + + {:ok, published} = BDS.Templates.publish_template(template.id) + published + end + + defp create_tag(project_id, name, post_template_slug) do + {:ok, tag} = + BDS.Tags.create_tag(%{ + project_id: project_id, + name: name, + post_template_slug: post_template_slug + }) + + tag + end + + describe "resolve_post_template_slug/3" do + test "level 1: returns post's own template_slug when set", %{project: project} do + result = + TemplateSelection.resolve_post_template_slug( + project.id, + ["some-tag"], + ["article"] + ) + + assert result == nil + end + + test "level 2: falls back to tag's post_template_slug", %{project: project} do + _tag = create_tag(project.id, "photo", "photo-layout") + + result = + TemplateSelection.resolve_post_template_slug( + project.id, + ["photo"], + [] + ) + + assert result == "photo-layout" + end + + test "level 2: uses first matching tag with a template slug", %{project: project} do + _tag1 = create_tag(project.id, "news", nil) + _tag2 = create_tag(project.id, "gallery", "gallery-layout") + + result = + TemplateSelection.resolve_post_template_slug( + project.id, + ["news", "gallery"], + [] + ) + + assert result == "gallery-layout" + end + + test "level 3: falls back to category's post_template_slug", %{project: project} do + {:ok, _} = BDS.Metadata.add_category(project.id, "review") + + {:ok, _} = + BDS.Metadata.update_category_settings(project.id, "review", %{ + "post_template_slug" => "review-layout" + }) + + result = + TemplateSelection.resolve_post_template_slug( + project.id, + [], + ["review"] + ) + + assert result == "review-layout" + end + + test "level 2 takes priority over level 3", %{project: project} do + _tag = create_tag(project.id, "featured", "featured-layout") + {:ok, _} = BDS.Metadata.add_category(project.id, "review") + + {:ok, _} = + BDS.Metadata.update_category_settings(project.id, "review", %{ + "post_template_slug" => "review-layout" + }) + + result = + TemplateSelection.resolve_post_template_slug( + project.id, + ["featured"], + ["review"] + ) + + assert result == "featured-layout" + end + + test "level 4: returns nil when no tag or category has a template", %{project: project} do + _tag = create_tag(project.id, "plain", nil) + + result = + TemplateSelection.resolve_post_template_slug( + project.id, + ["plain"], + ["article"] + ) + + assert result == nil + 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 + } do + template = + create_published_template(project.id, "photo-layout", "