fix: templates are not copied automatically to projects

This commit is contained in:
2026-04-25 07:25:56 +02:00
parent 2296ff0e99
commit 6d86d0ce3f
11 changed files with 160 additions and 107 deletions

View File

@@ -26,7 +26,7 @@ This document provides context and best practices for GitHub Copilot when workin
- HEREDOCs don't work most of the time. Don't use them. Use editor tools to create proper scripots - HEREDOCs don't work most of the time. Don't use them. Use editor tools to create proper scripots
- we have an allium spec in the specs/ folder. you must weed the specs against built code to make sure you follow the spec. - we have an allium spec in the specs/ folder. you must weed the specs against built code to make sure you follow the spec.
- when changing the spec, validate the spec with the available command line tool. - when changing the spec, validate the spec with the available command line tool.
- test with command line tools at least once to capture compile errors in tests, do not use the integrated testing of vscode, as that blocks on compile errors - you MUST run tests with command line tools at least once to capture compile errors in tests, do not use the integrated testing of vscode, as that blocks on compile errors
--- ---

View File

@@ -6,7 +6,6 @@ defmodule BDS.Projects do
alias BDS.Persistence alias BDS.Persistence
alias BDS.Projects.Project alias BDS.Projects.Project
alias BDS.Repo alias BDS.Repo
alias BDS.StarterTemplates
alias BDS.Slug alias BDS.Slug
alias BDS.Templates alias BDS.Templates
@@ -59,7 +58,6 @@ defmodule BDS.Projects do
}) })
|> Repo.insert!() |> Repo.insert!()
:ok = StarterTemplates.install(project)
{:ok, _templates} = Templates.rebuild_templates_from_files(project.id) {:ok, _templates} = Templates.rebuild_templates_from_files(project.id)
project project
end) end)
@@ -94,7 +92,6 @@ defmodule BDS.Projects do
}) })
|> Repo.insert!() |> Repo.insert!()
:ok = StarterTemplates.install(project)
{:ok, _templates} = Templates.rebuild_templates_from_files(project.id) {:ok, _templates} = Templates.rebuild_templates_from_files(project.id)
project project
end) end)

View File

@@ -14,6 +14,7 @@ defmodule BDS.Rendering do
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.Posts.Post alias BDS.Posts.Post
alias BDS.Posts.Translation alias BDS.Posts.Translation
alias BDS.Tags.Tag alias BDS.Tags.Tag
@@ -46,9 +47,14 @@ defmodule BDS.Rendering do
end end
defp load_template_source(project_id, kind, slug) do defp load_template_source(project_id, kind, slug) do
project = Projects.get_project!(project_id)
case select_template(project_id, kind, slug) do case select_template(project_id, kind, slug) do
nil -> {:error, :template_not_found} %Template{} = template ->
%Template{} = template -> published_template_body(template) published_template_body(template)
nil ->
load_bundled_template_source(project, kind, slug)
end end
end end
@@ -60,7 +66,7 @@ defmodule BDS.Rendering do
template.status == :published and template.status == :published and
template.enabled == true and template.slug == ^slug, template.enabled == true and template.slug == ^slug,
limit: 1 limit: 1
) || select_template(project_id, kind, nil) )
end end
defp select_template(project_id, kind, nil) do defp select_template(project_id, kind, nil) do
@@ -97,13 +103,12 @@ defmodule BDS.Rendering do
defp render_template(project_id, source, assigns) do defp render_template(project_id, source, assigns) do
with {:ok, template_ast} <- Liquex.parse(source) do with {:ok, template_ast} <- Liquex.parse(source) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
template_root = Path.join(Projects.project_data_dir(project), "templates")
context = context =
Liquex.Context.new(assigns, Liquex.Context.new(assigns,
static_environment: assigns, static_environment: assigns,
filter_module: Filters, filter_module: Filters,
file_system: FileSystem.new(template_root) file_system: FileSystem.new(StarterTemplates.template_roots(project))
) )
{result, _context} = Liquex.render!(template_ast, context) {result, _context} = Liquex.render!(template_ast, context)
@@ -113,6 +118,30 @@ defmodule BDS.Rendering do
error -> {:error, error} error -> {:error, error}
end end
defp load_bundled_template_source(project, kind, slug) do
desired_slug = bundled_template_slug(kind, slug)
if is_binary(desired_slug) do
file_system = project |> StarterTemplates.template_roots() |> FileSystem.new()
source = Liquex.FileSystem.read_template_file(file_system, desired_slug)
case Frontmatter.parse_document(source) do
{:ok, %{body: body}} -> {:ok, body}
{:error, :invalid_frontmatter} -> {:ok, source}
{:error, reason} -> {:error, reason}
end
else
{:error, :template_not_found}
end
rescue
error in [Liquex.Error] ->
_ = error
{:error, :template_not_found}
end
defp bundled_template_slug(_kind, slug) when is_binary(slug) and slug != "", do: slug
defp bundled_template_slug(kind, _slug), do: StarterTemplates.default_slug(kind)
defp post_assigns(project_id, assigns) do defp post_assigns(project_id, assigns) do
metadata = project_metadata(project_id) metadata = project_metadata(project_id)

View File

@@ -1,13 +1,17 @@
defmodule BDS.Rendering.FileSystem do defmodule BDS.Rendering.FileSystem do
@moduledoc false @moduledoc false
defstruct [:root_path] defstruct [:root_paths]
def new(root_path) do def new(root_paths) when is_list(root_paths) do
%__MODULE__{root_path: root_path} %__MODULE__{root_paths: Enum.uniq(root_paths)}
end end
def full_path(%__MODULE__{root_path: root_path}, template_path) do def new(root_path) when is_binary(root_path) do
new([root_path])
end
def full_path(%__MODULE__{root_paths: root_paths}, template_path) do
normalized_path = to_string(template_path) normalized_path = to_string(template_path)
cond do cond do
@@ -21,7 +25,16 @@ defmodule BDS.Rendering.FileSystem do
raise Liquex.Error, message: "Illegal template path '#{template_path}'" raise Liquex.Error, message: "Illegal template path '#{template_path}'"
true -> true ->
Path.expand(Path.join(root_path, normalized_path <> ".liquid")) root_paths
|> Enum.map(&Path.expand(Path.join(&1, normalized_path <> ".liquid")))
|> Enum.find(&File.regular?/1)
|> case do
nil ->
Path.expand(Path.join(List.first(root_paths) || ".", normalized_path <> ".liquid"))
path ->
path
end
end end
end end
end end

View File

@@ -1,62 +1,22 @@
defmodule BDS.StarterTemplates do defmodule BDS.StarterTemplates do
@moduledoc false @moduledoc false
alias BDS.Frontmatter
alias BDS.Projects alias BDS.Projects
@top_level_templates [ def root_path do
%{file_name: "single-post.liquid", slug: "single-post", title: "Single Post", kind: :post}, Path.join(Application.app_dir(:bds, "priv/starter_templates"), "templates")
%{file_name: "post-list.liquid", slug: "post-list", title: "Post List", kind: :list},
%{file_name: "not-found.liquid", slug: "not-found", title: "Not Found", kind: :not_found}
]
def install(project) do
source_root = Path.join(Application.app_dir(:bds, "priv/starter_templates"), "templates")
target_root = Path.join(Projects.project_data_dir(project), "templates")
:ok = File.mkdir_p(target_root)
source_root
|> list_files()
|> Enum.each(fn source_path ->
relative_path = Path.relative_to(source_path, source_root)
target_path = Path.join(target_root, relative_path)
:ok = File.mkdir_p(Path.dirname(target_path))
unless File.exists?(target_path) do
case Enum.find(@top_level_templates, &(&1.file_name == relative_path)) do
nil ->
File.cp!(source_path, target_path)
template ->
body = File.read!(source_path)
File.write!(
target_path,
Frontmatter.serialize_document(
[
{:id, Ecto.UUID.generate()},
{:slug, template.slug},
{:title, template.title},
{:kind, template.kind},
{:enabled, true},
{:version, 1}
],
body
)
)
end
end
end)
:ok
end end
defp list_files(root) do def template_roots(project) do
root [Path.join(Projects.project_data_dir(project), "templates"), root_path()]
|> Path.join("**/*") end
|> Path.wildcard(match_dot: true)
|> Enum.reject(&File.dir?/1) def default_slug(:post), do: "single-post"
|> Enum.sort() def default_slug(:list), do: "post-list"
def default_slug(:not_found), do: "not-found"
def default_slug(_kind), do: nil
def default_template?(kind, slug) when is_binary(slug) do
default_slug(kind) == slug
end end
end end

View File

@@ -73,8 +73,11 @@ rule CreateProject {
data_path: data_path, data_path: data_path,
is_active: false is_active: false
) )
ensures: StarterTemplatesCopied(project) }
-- Bundled starter templates are copied into the new project
invariant ProjectTemplatesDirectoryReservedForUserTemplates {
-- The project templates directory stores only user-managed templates.
-- Creating a project does not populate effective_data_dir/templates with bundled defaults.
} }
rule SetActiveProject { rule SetActiveProject {

View File

@@ -62,6 +62,28 @@ invariant TemplateFileLayout {
t.file_path = format("templates/{slug}.liquid", slug: t.slug) t.file_path = format("templates/{slug}.liquid", slug: t.slug)
} }
invariant BundledDefaultTemplatesExistOutsideProjectData {
-- The application ships bundled default templates for:
-- single-post
-- post-list
-- not-found
-- plus supporting partials and macros.
-- These bundled templates are available for rendering even when the project
-- has no template files and no Template rows for those defaults.
}
invariant UserTemplateDirectoryOverridesBundledDefaults {
-- When a project template file or published Template row resolves the same slug
-- as a bundled template or partial, the project-owned template wins.
-- Bundled templates are fallback roots, not copied seed data.
}
invariant RebuildTemplatesIndexesOnlyProjectTemplates {
-- Rebuild-from-files scans only project.effective_data_dir/templates.
-- Bundled defaults are render-time fallbacks and are not indexed into Templates
-- unless the user has created matching project files.
}
rule CreateTemplate { rule CreateTemplate {
when: CreateTemplateRequested(title, kind, content) when: CreateTemplateRequested(title, kind, content)
let slug = slugify(title) let slug = slugify(title)

View File

@@ -254,6 +254,60 @@ defmodule BDS.GenerationTest do
assert post_html =~ "Language" assert post_html =~ "Language"
end end
test "generation falls back to bundled default templates when the project has no template files or template rows",
%{project: project, temp_dir: temp_dir} do
File.rm_rf!(Path.join(temp_dir, "templates"))
Repo.delete_all(
from template in BDS.Templates.Template,
where: template.project_id == ^project.id
)
assert {:ok, _menu} =
BDS.Menu.update_menu(project.id, [
%{kind: :page, label: "Notes", slug: "notes"}
])
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
public_url: "https://example.com/blog",
main_language: "en",
blog_languages: ["en", "de"]
})
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Bundled Rendered Post",
content: "**Rendered** body",
language: "en",
categories: ["notes"],
tags: ["Elixir"]
})
assert {:ok, published_post} = Posts.publish_post(post.id)
assert {:ok, _tags} = BDS.Tags.sync_tags_from_posts(project.id)
assert {:ok, result} = BDS.Generation.generate_site(project.id, [:core, :single])
post_path = BDS.Generation.post_output_path(published_post)
relative_paths = Enum.map(result.generated_files, & &1.relative_path)
assert "index.html" in relative_paths
assert post_path in relative_paths
index_html = File.read!(Path.join([temp_dir, "html", "index.html"]))
assert index_html =~ ~s(<nav class="blog-menu">)
assert index_html =~ ~s(/assets/pico.min.css)
assert index_html =~ "Bundled Rendered Post"
post_html = File.read!(Path.join([temp_dir, "html", post_path]))
assert post_html =~ ~s(data-template="single-post")
assert post_html =~ ~s(<strong>Rendered</strong> body)
assert post_html =~ "Taxonomy"
assert post_html =~ "Language"
end
test "generation expands starter-template markdown macros, rewrites canonical post links, media links, and emits not-found page", test "generation expands starter-template markdown macros, rewrites canonical post links, media links, and emits not-found page",
%{project: project, temp_dir: temp_dir} do %{project: project, temp_dir: temp_dir} do
assert {:ok, _metadata} = assert {:ok, _metadata} =

View File

@@ -123,7 +123,7 @@ defmodule BDS.MaintenanceTest do
assert length(scripts) == 1 assert length(scripts) == 1
assert {:ok, templates} = BDS.Maintenance.rebuild_from_filesystem(project.id, "template") assert {:ok, templates} = BDS.Maintenance.rebuild_from_filesystem(project.id, "template")
assert length(templates) == 4 assert length(templates) == 1
assert Repo.get(BDS.Posts.Post, "dispatch-post") != nil assert Repo.get(BDS.Posts.Post, "dispatch-post") != nil
assert Repo.get(BDS.Media.Media, "dispatch-media") != nil assert Repo.get(BDS.Media.Media, "dispatch-media") != nil

View File

@@ -43,7 +43,7 @@ defmodule BDS.ProjectsTest do
assert second.is_active == false assert second.is_active == false
end end
test "create_project installs starter templates into the project data directory", %{ test "create_project leaves the project templates directory empty by default", %{
temp_root: temp_root temp_root: temp_root
} do } do
temp_dir = Path.join(temp_root, "starter") temp_dir = Path.join(temp_root, "starter")
@@ -52,12 +52,12 @@ defmodule BDS.ProjectsTest do
assert {:ok, project} = assert {:ok, project} =
BDS.Projects.create_project(%{name: "Starter Blog", data_path: temp_dir}) BDS.Projects.create_project(%{name: "Starter Blog", data_path: temp_dir})
assert File.exists?(Path.join([temp_dir, "templates", "single-post.liquid"])) refute File.exists?(Path.join([temp_dir, "templates", "single-post.liquid"]))
assert File.exists?(Path.join([temp_dir, "templates", "post-list.liquid"])) refute File.exists?(Path.join([temp_dir, "templates", "post-list.liquid"]))
assert File.exists?(Path.join([temp_dir, "templates", "not-found.liquid"])) refute File.exists?(Path.join([temp_dir, "templates", "not-found.liquid"]))
assert File.exists?(Path.join([temp_dir, "templates", "partials", "head.liquid"])) refute File.exists?(Path.join([temp_dir, "templates", "partials", "head.liquid"]))
assert File.exists?(Path.join([temp_dir, "templates", "partials", "menu-items.liquid"])) refute File.exists?(Path.join([temp_dir, "templates", "partials", "menu-items.liquid"]))
assert File.exists?(Path.join([temp_dir, "templates", "macros", "gallery.liquid"])) refute File.exists?(Path.join([temp_dir, "templates", "macros", "gallery.liquid"]))
starter_slugs = starter_slugs =
Repo.all( Repo.all(
@@ -66,34 +66,9 @@ defmodule BDS.ProjectsTest do
select: {template.slug, template.kind} select: {template.slug, template.kind}
) )
assert {"single-post", :post} in starter_slugs refute {"single-post", :post} in starter_slugs
assert {"post-list", :list} in starter_slugs refute {"post-list", :list} in starter_slugs
assert {"not-found", :not_found} in starter_slugs refute {"not-found", :not_found} in starter_slugs
end
test "starter template installation is idempotent for existing top-level templates", %{
temp_root: temp_root
} do
temp_dir = Path.join(temp_root, "idempotent-starter")
File.mkdir_p!(temp_dir)
assert {:ok, project} =
BDS.Projects.create_project(%{name: "Starter Blog", data_path: temp_dir})
template_path = Path.join([temp_dir, "templates", "single-post.liquid"])
original_contents = File.read!(template_path)
assert {:ok, %{fields: original_fields}} = BDS.Frontmatter.parse_document(original_contents)
assert is_binary(original_fields["id"])
assert :ok = BDS.StarterTemplates.install(project)
reinstalled_contents = File.read!(template_path)
assert reinstalled_contents == original_contents
assert {:ok, %{fields: reinstalled_fields}} =
BDS.Frontmatter.parse_document(reinstalled_contents)
assert reinstalled_fields["id"] == original_fields["id"]
end end
test "set_active_project clears the previous active project and activates the target", %{ test "set_active_project clears the previous active project and activates the target", %{
@@ -161,7 +136,7 @@ defmodule BDS.ProjectsTest do
_ = File.rm_rf(internal_dir) _ = File.rm_rf(internal_dir)
end) end)
assert File.exists?(Path.join(internal_dir, "templates/single-post.liquid")) refute File.exists?(Path.join(internal_dir, "templates/single-post.liquid"))
assert {:ok, deleted_internal_project} = BDS.Projects.delete_project(internal_project.id) assert {:ok, deleted_internal_project} = BDS.Projects.delete_project(internal_project.id)
assert deleted_internal_project.id == internal_project.id assert deleted_internal_project.id == internal_project.id

View File

@@ -246,7 +246,7 @@ defmodule BDS.TemplatesTest do
) )
assert {:ok, templates} = BDS.Templates.rebuild_templates_from_files(project.id) assert {:ok, templates} = BDS.Templates.rebuild_templates_from_files(project.id)
assert length(templates) == 4 assert length(templates) == 1
template = Repo.get!(BDS.Templates.Template, "template-from-file") template = Repo.get!(BDS.Templates.Template, "template-from-file")
assert template.id == "template-from-file" assert template.id == "template-from-file"