diff --git a/AGENTS.md b/AGENTS.md index 29e1118..a3555bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 --- diff --git a/lib/bds/projects.ex b/lib/bds/projects.ex index 3e238b8..ba185ce 100644 --- a/lib/bds/projects.ex +++ b/lib/bds/projects.ex @@ -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) diff --git a/lib/bds/rendering.ex b/lib/bds/rendering.ex index dba9a78..5ae3d2c 100644 --- a/lib/bds/rendering.ex +++ b/lib/bds/rendering.ex @@ -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) diff --git a/lib/bds/rendering/file_system.ex b/lib/bds/rendering/file_system.ex index a9ca641..d826aed 100644 --- a/lib/bds/rendering/file_system.ex +++ b/lib/bds/rendering/file_system.ex @@ -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 diff --git a/lib/bds/starter_templates.ex b/lib/bds/starter_templates.ex index ed4e89b..03c9d99 100644 --- a/lib/bds/starter_templates.ex +++ b/lib/bds/starter_templates.ex @@ -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 diff --git a/specs/project.allium b/specs/project.allium index 864027a..b259d23 100644 --- a/specs/project.allium +++ b/specs/project.allium @@ -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 { diff --git a/specs/template.allium b/specs/template.allium index c014bd1..a51284b 100644 --- a/specs/template.allium +++ b/specs/template.allium @@ -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) diff --git a/test/bds/generation_test.exs b/test/bds/generation_test.exs index 43e66a4..18d727b 100644 --- a/test/bds/generation_test.exs +++ b/test/bds/generation_test.exs @@ -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(