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-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 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-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 |

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

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