fix: A1-7 implement 4-level template lookup cascade (post→tag→category→default)

This commit is contained in:
2026-05-28 22:38:35 +02:00
parent 1ae6152da7
commit c5e09e7316
6 changed files with 291 additions and 8 deletions

View File

@@ -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-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-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-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 tagcategory 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-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 `<input type="color">`, no preset palette | Fix code: implement preset color palette popover | | A1-9 | 17 preset colors + custom hex in tag picker | editor_tags.allium | Native `<input type="color">`, 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 | | 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 |

View File

@@ -5,6 +5,8 @@ defmodule BDS.Generation.Outputs do
import BDS.Generation.Renderers import BDS.Generation.Renderers
import BDS.Generation.Sitemap, only: [render_feed: 3, render_atom: 3, render_calendar: 1] import BDS.Generation.Sitemap, only: [render_feed: 3, render_atom: 3, render_calendar: 1]
alias BDS.Rendering.TemplateSelection
@spec additional_languages(map()) :: [String.t()] @spec additional_languages(map()) :: [String.t()]
def additional_languages(plan) do def additional_languages(plan) do
Enum.reject(plan.blog_languages, &(&1 == plan.language)) 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) 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) 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), {page_output_path(post.slug, nil),
render_post_output( render_post_output(
project_id, project_id,
post.template_slug, effective_slug,
%{ %{
id: canonical_variant.id, id: canonical_variant.id,
title: canonical_variant.title, title: canonical_variant.title,
@@ -423,10 +427,12 @@ defmodule BDS.Generation.Outputs do
|> Enum.map(fn post -> |> Enum.map(fn post ->
body = load_body(project_id, post.file_path, post.content) body = load_body(project_id, post.file_path, post.content)
effective_slug = effective_template_slug(project_id, post)
{page_output_path(post.slug, language), {page_output_path(post.slug, language),
render_post_output( render_post_output(
project_id, project_id,
post.template_slug, effective_slug,
%{ %{
id: post.id, id: post.id,
title: post.title, 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) 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) body = load_body(project_id, canonical_variant.file_path, canonical_variant.content)
effective_slug = effective_template_slug(project_id, post)
{post_output_path(post), {post_output_path(post),
render_post_output( render_post_output(
project_id, project_id,
post.template_slug, effective_slug,
%{ %{
id: canonical_variant.id, id: canonical_variant.id,
title: canonical_variant.title, title: canonical_variant.title,
@@ -551,10 +559,12 @@ defmodule BDS.Generation.Outputs do
Enum.map(posts, fn post -> Enum.map(posts, fn post ->
body = load_body(project_id, post.file_path, post.content) body = load_body(project_id, post.file_path, post.content)
effective_slug = effective_template_slug(project_id, post)
{post_output_path(post, language), {post_output_path(post, language),
render_post_output( render_post_output(
project_id, project_id,
post.template_slug, effective_slug,
%{ %{
id: post.id, id: post.id,
title: post.title, title: post.title,
@@ -571,4 +581,18 @@ defmodule BDS.Generation.Outputs do
post_outputs ++ translation_outputs post_outputs ++ translation_outputs
end 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 end

View File

@@ -9,6 +9,7 @@ defmodule BDS.Preview do
alias BDS.Projects alias BDS.Projects
alias BDS.Repo alias BDS.Repo
alias BDS.Rendering alias BDS.Rendering
alias BDS.Rendering.TemplateSelection
@host "127.0.0.1" @host "127.0.0.1"
@port 4123 @port 4123
@@ -219,6 +220,7 @@ defmodule BDS.Preview do
defp draft_preview_payload(post, query_params) do defp draft_preview_payload(post, query_params) do
requested_language = query_params |> Map.get("lang") |> normalize_requested_language() 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 case draft_preview_translation(post.id, requested_language, post.language) do
%Translation{} = translation -> %Translation{} = translation ->
@@ -230,7 +232,7 @@ defmodule BDS.Preview do
slug: post.slug, slug: post.slug,
language: translation.language, language: translation.language,
excerpt: translation.excerpt, excerpt: translation.excerpt,
template_slug: post.template_slug template_slug: effective_slug
} }
nil -> nil ->
@@ -242,7 +244,7 @@ defmodule BDS.Preview do
slug: post.slug, slug: post.slug,
language: post.language, language: post.language,
excerpt: post.excerpt, excerpt: post.excerpt,
template_slug: post.template_slug template_slug: effective_slug
} }
end end
end end

View File

@@ -10,6 +10,7 @@ defmodule BDS.Preview.Router do
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Posts.Translation alias BDS.Posts.Translation
alias BDS.Rendering alias BDS.Rendering
alias BDS.Rendering.TemplateSelection
alias BDS.Repo alias BDS.Repo
@type route :: @type route ::
@@ -219,7 +220,9 @@ defmodule BDS.Preview.Router do
_post_record: effective_record _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} {:ok, rendered} -> {:ok, rendered}
{:error, _reason} -> {:error, :not_found} {:error, _reason} -> {:error, :not_found}
end end

View File

@@ -4,13 +4,52 @@ defmodule BDS.Rendering.TemplateSelection do
import Ecto.Query import Ecto.Query
alias BDS.Frontmatter alias BDS.Frontmatter
alias BDS.Metadata
alias BDS.Projects alias BDS.Projects
alias BDS.Rendering.FileSystem alias BDS.Rendering.FileSystem
alias BDS.Rendering.Filters alias BDS.Rendering.Filters
alias BDS.Repo alias BDS.Repo
alias BDS.StarterTemplates alias BDS.StarterTemplates
alias BDS.Tags.Tag
alias BDS.Templates.Template 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) :: @spec load_template_source(String.t(), atom(), String.t() | nil) ::
{:ok, String.t()} | {:error, term()} {:ok, String.t()} | {:error, term()}
def load_template_source(project_id, kind, slug) do def load_template_source(project_id, kind, slug) do

View File

@@ -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", "<h1>PHOTO: {{ page.title }}</h1>")
_tag = create_tag(project.id, "photo", template.slug)
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "My Photo Post",
content: "photo content",
language: "en",
tags: ["photo"]
})
resolved_slug =
TemplateSelection.resolve_post_template_slug(
project.id,
post.tags,
post.categories
)
assert resolved_slug == "photo-layout"
{:ok, rendered} =
TemplateSelection.load_template_source(project.id, :post, resolved_slug)
assert rendered =~ "PHOTO:"
end
test "post renders with category-specific template when no post or tag template", %{
project: project
} do
template =
create_published_template(
project.id,
"review-layout",
"<h1>REVIEW: {{ page.title }}</h1>"
)
{:ok, _} = BDS.Metadata.add_category(project.id, "review")
{:ok, _} =
BDS.Metadata.update_category_settings(project.id, "review", %{
"post_template_slug" => template.slug
})
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "My Review",
content: "review content",
language: "en",
categories: ["review"]
})
resolved_slug =
TemplateSelection.resolve_post_template_slug(
project.id,
post.tags,
post.categories
)
assert resolved_slug == "review-layout"
{:ok, rendered} =
TemplateSelection.load_template_source(project.id, :post, resolved_slug)
assert rendered =~ "REVIEW:"
end
end
end