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

View File

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

View File

@@ -1,13 +1,17 @@
defmodule BDS.Rendering.FileSystem do
@moduledoc false
defstruct [:root_path]
defstruct [:root_paths]
def new(root_path) do
%__MODULE__{root_path: root_path}
def new(root_paths) when is_list(root_paths) do
%__MODULE__{root_paths: Enum.uniq(root_paths)}
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)
cond do
@@ -21,7 +25,16 @@ defmodule BDS.Rendering.FileSystem do
raise Liquex.Error, message: "Illegal template path '#{template_path}'"
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

View File

@@ -1,62 +1,22 @@
defmodule BDS.StarterTemplates do
@moduledoc false
alias BDS.Frontmatter
alias BDS.Projects
@top_level_templates [
%{file_name: "single-post.liquid", slug: "single-post", title: "Single Post", kind: :post},
%{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
def root_path do
Path.join(Application.app_dir(:bds, "priv/starter_templates"), "templates")
end
defp list_files(root) do
root
|> Path.join("**/*")
|> Path.wildcard(match_dot: true)
|> Enum.reject(&File.dir?/1)
|> Enum.sort()
def template_roots(project) do
[Path.join(Projects.project_data_dir(project), "templates"), root_path()]
end
def default_slug(:post), do: "single-post"
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

View File

@@ -73,8 +73,11 @@ rule CreateProject {
data_path: data_path,
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 {

View File

@@ -62,6 +62,28 @@ invariant TemplateFileLayout {
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 {
when: CreateTemplateRequested(title, kind, content)
let slug = slugify(title)

View File

@@ -254,6 +254,60 @@ defmodule BDS.GenerationTest do
assert post_html =~ "Language"
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",
%{project: project, temp_dir: temp_dir} do
assert {:ok, _metadata} =

View File

@@ -123,7 +123,7 @@ defmodule BDS.MaintenanceTest do
assert length(scripts) == 1
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.Media.Media, "dispatch-media") != nil

View File

@@ -43,7 +43,7 @@ defmodule BDS.ProjectsTest do
assert second.is_active == false
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
} do
temp_dir = Path.join(temp_root, "starter")
@@ -52,12 +52,12 @@ defmodule BDS.ProjectsTest do
assert {:ok, project} =
BDS.Projects.create_project(%{name: "Starter Blog", data_path: temp_dir})
assert File.exists?(Path.join([temp_dir, "templates", "single-post.liquid"]))
assert File.exists?(Path.join([temp_dir, "templates", "post-list.liquid"]))
assert File.exists?(Path.join([temp_dir, "templates", "not-found.liquid"]))
assert File.exists?(Path.join([temp_dir, "templates", "partials", "head.liquid"]))
assert 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", "single-post.liquid"]))
refute File.exists?(Path.join([temp_dir, "templates", "post-list.liquid"]))
refute File.exists?(Path.join([temp_dir, "templates", "not-found.liquid"]))
refute File.exists?(Path.join([temp_dir, "templates", "partials", "head.liquid"]))
refute File.exists?(Path.join([temp_dir, "templates", "partials", "menu-items.liquid"]))
refute File.exists?(Path.join([temp_dir, "templates", "macros", "gallery.liquid"]))
starter_slugs =
Repo.all(
@@ -66,34 +66,9 @@ defmodule BDS.ProjectsTest do
select: {template.slug, template.kind}
)
assert {"single-post", :post} in starter_slugs
assert {"post-list", :list} in starter_slugs
assert {"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"]
refute {"single-post", :post} in starter_slugs
refute {"post-list", :list} in starter_slugs
refute {"not-found", :not_found} in starter_slugs
end
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)
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 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 length(templates) == 4
assert length(templates) == 1
template = Repo.get!(BDS.Templates.Template, "template-from-file")
assert template.id == "template-from-file"