diff --git a/SPECGAPS.md b/SPECGAPS.md
index be5f6ac..ab08bdf 100644
--- a/SPECGAPS.md
+++ b/SPECGAPS.md
@@ -139,7 +139,7 @@ All reconciled to follow code. Specs must be self-consistent and match code.
| ID | Claim | Spec | Path |
|---|---|---|---|
| ~~D2-1~~ | ~~RemoveCategory rule~~ | ~~metadata.allium:100~~ | **Resolved:** 2 tests added in metadata_test.exs — remove_category removes category+settings from state/files/DB (6 assertions); remove_category is a no-op for non-existent category |
-| D2-2 | CreateAndPublishTemplate rule | template.allium:105 | Write test: create+publish in one step |
+| ~~D2-2~~ | ~~CreateAndPublishTemplate rule~~ | template.allium:105 | **Resolved:** added `create_and_publish_template/1` (validates Liquid, creates published template + writs file), 3 tests added (happy path file assertions, invalid liquid rejection, slug dedup on title conflict) |
| D2-3 | CreateAndPublishScript rule | script.allium:160 | Write test: create+publish in one step |
| D2-4 | UniqueScriptSlug dedup | script.allium:115 | Write test: two scripts same title → dedup slug |
| D2-5 | FrontmatterRoundtrip invariant | post.allium:223 | Write test: write file, read back, assert all DB fields match |
diff --git a/lib/bds/templates.ex b/lib/bds/templates.ex
index 9a67b01..33e7982 100644
--- a/lib/bds/templates.ex
+++ b/lib/bds/templates.ex
@@ -109,6 +109,54 @@ defmodule BDS.Templates do
end
end
+ @spec create_and_publish_template(attrs()) :: template_result()
+ def create_and_publish_template(attrs) do
+ project_id = attr(attrs, :project_id)
+ title = attr(attrs, :title) || ""
+ kind = attr(attrs, :kind)
+ content = attr(attrs, :content) || ""
+
+ case validate_liquid(content) do
+ :ok ->
+ slug = unique_slug(project_id, Slug.slugify(title), "template")
+ file_path = template_file_path(slug)
+ now = Persistence.now_ms()
+
+ changeset =
+ %Template{}
+ |> Template.changeset(%{
+ id: Ecto.UUID.generate(),
+ project_id: project_id,
+ slug: slug,
+ title: title,
+ kind: kind,
+ enabled: true,
+ version: 1,
+ file_path: file_path,
+ status: :published,
+ content: nil,
+ created_at: now,
+ updated_at: now
+ })
+
+ with {:ok, template} <- Repo.insert(changeset) do
+ full_path = full_file_path(template.project_id, file_path)
+ File.mkdir_p!(Path.dirname(full_path))
+
+ :ok =
+ Persistence.atomic_write(
+ full_path,
+ serialize_template_file(template, content)
+ )
+
+ {:ok, template}
+ end
+
+ {:error, reason} ->
+ {:error, {:invalid_liquid, reason}}
+ end
+ end
+
@spec update_template(String.t(), attrs()) :: template_result() | {:error, :not_found}
def update_template(template_id, attrs) do
with %Template{} = template <- Repo.get(Template, template_id) do
diff --git a/test/bds/templates_test.exs b/test/bds/templates_test.exs
index 0e97dde..8c877e9 100644
--- a/test/bds/templates_test.exs
+++ b/test/bds/templates_test.exs
@@ -488,6 +488,72 @@ defmodule BDS.TemplatesTest do
assert template.updated_at == 202
end
+ test "create_and_publish_template creates a published template with file in one step", %{
+ project: project,
+ temp_dir: temp_dir
+ } do
+ assert {:ok, template} =
+ BDS.Templates.create_and_publish_template(%{
+ project_id: project.id,
+ title: "Published Layout",
+ kind: :list,
+ content: ""
+ })
+
+ assert template.status == :published
+ assert template.content == nil
+ assert template.slug == "published-layout"
+ assert template.enabled == true
+ assert template.version == 1
+ assert template.file_path == "templates/published-layout.liquid"
+
+ full_path = Path.join(temp_dir, template.file_path)
+ assert File.exists?(full_path)
+
+ contents = File.read!(full_path)
+ assert contents =~ "---\nid: #{template.id}\n"
+ assert contents =~ "projectId: #{project.id}\n"
+ assert contents =~ "slug: published-layout\n"
+ assert contents =~ "title: Published Layout\n"
+ assert contents =~ "kind: list\n"
+ assert contents =~ "enabled: true\n"
+ assert contents =~ "version: 1\n"
+ assert contents =~ ~r/createdAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
+ assert contents =~ ~r/updatedAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
+ assert contents =~ "\n---\n\n"
+ end
+
+ test "create_and_publish_template rejects invalid Liquid syntax", %{project: project} do
+ assert {:error, {:invalid_liquid, _reason}} =
+ BDS.Templates.create_and_publish_template(%{
+ project_id: project.id,
+ title: "Bad Layout",
+ kind: :list,
+ content: "{% for item in items %}unclosed"
+ })
+ end
+
+ test "create_and_publish_template deduplicates slug on title conflict", %{project: project} do
+ assert {:ok, _first} =
+ BDS.Templates.create_and_publish_template(%{
+ project_id: project.id,
+ title: "Same Title",
+ kind: :list,
+ content: ""
+ })
+
+ assert {:ok, second} =
+ BDS.Templates.create_and_publish_template(%{
+ project_id: project.id,
+ title: "Same Title",
+ kind: :list,
+ content: ""
+ })
+
+ assert second.slug == "same-title-2"
+ assert second.status == :published
+ end
+
test "rebuild_templates_from_files removes stale published default templates when no local template files exist",
%{
project: project