feat: PLAN step 1 done, supposedly

This commit is contained in:
2026-04-25 21:53:44 +02:00
parent f1957cbab2
commit 2991edf4cf
18 changed files with 704 additions and 133 deletions

View File

@@ -10,6 +10,7 @@ The rewrite already implements most of the backend and compatibility-critical su
- Foundation and persistence: OTP app startup, Ecto repo, migrations, and persisted tables for projects, posts, translations, media, tags, templates, scripts, settings, chat, AI catalog data, embeddings, imports, publishing jobs, MCP proposals, and CLI notifications.
- Compatibility-critical file contracts: post frontmatter, media sidecars, thumbnail layout, template and script frontmatter, menu OPML, metadata diff, and rebuild-from-filesystem flows.
- Compatibility parity locks: executable parity coverage now pins the old-bDS behavior for serializer/file contracts, metadata-diff ordering, canonical generation routes, feed output, localized archive rendering, preview draft language selection, taxonomy archive links, and media URL rewriting.
- Core editorial domains: projects, posts, post translations, media, media translations, tags, templates, scripts, menu data, and project metadata.
- Output and infrastructure: search, Liquid rendering, site generation, preview server, publishing, background tasks, i18n, git support, MCP server, AI operations, embeddings, and CLI sync.
- Desktop shell foundation: native menu definitions, shell HTML/CSS/JS bundle, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, project switcher, and shell command routing.
@@ -45,8 +46,8 @@ Ordered from base contracts upward:
The remaining work needs to proceed from base contracts upward. Later phases should not outrun the lower layers they depend on.
1. Lock compatibility contracts.
Validate schema, frontmatter, sidecars, template context, generation output, metadata diff, and rebuild behavior against both the Allium specs and the old bDS application using fixture-based parity tests.
1. Lock compatibility contracts. Completed 2026-04-25.
Schema, frontmatter, sidecars, template context, generation output, preview behavior, metadata diff, and rebuild behavior are now pinned against the Allium specs and the old bDS application with executable parity tests.
2. Close engine-level behavior gaps.
Finish any remaining save/publish/delete side-effects, translation cascades, link graph maintenance, thumbnail regeneration rules, and rebuild notifications so backend behavior is fully spec-complete independent of UI.

View File

@@ -56,7 +56,11 @@ defmodule BDS.Frontmatter do
Integer.to_string(value)
end
["#{key}: #{rendered}"]
if timestamp_key?(key) do
["#{key}: '#{rendered}'"]
else
["#{key}: #{rendered}"]
end
end
defp serialize_field({key, value}) do
@@ -196,7 +200,7 @@ defmodule BDS.Frontmatter do
defp serialize_scalar(key, value) when is_integer(value) do
if is_binary(key) and timestamp_key?(key) do
Persistence.timestamp_to_iso8601(value)
"'#{Persistence.timestamp_to_iso8601(value)}'"
else
Integer.to_string(value)
end

View File

@@ -238,7 +238,13 @@ defmodule BDS.Generation do
single_outputs =
if :single in plan.sections do
build_single_outputs(plan.project_id, published_posts, published_translations, post_by_id)
build_single_outputs(
plan.project_id,
plan.language,
published_posts,
published_translations,
post_by_id
)
else
[]
end
@@ -439,7 +445,14 @@ defmodule BDS.Generation do
Enum.map(languages, fn language ->
{
archive_path(route_language(plan.language, language), [year], 1),
render_date_archive_page(plan, year, posts, language, pagination)
render_date_archive_page(
plan,
year,
%{kind: "year", year: String.to_integer(year)},
posts,
language,
pagination
)
}
end)
end)
@@ -451,7 +464,14 @@ defmodule BDS.Generation do
Enum.map(languages, fn language ->
{
archive_path(route_language(plan.language, language), [year, month], 1),
render_date_archive_page(plan, "#{year}-#{month}", posts, language, pagination)
render_date_archive_page(
plan,
"#{year}-#{month}",
%{kind: "month", year: String.to_integer(year), month: String.to_integer(month)},
posts,
language,
pagination
)
}
end)
end)
@@ -505,62 +525,100 @@ defmodule BDS.Generation do
end)
end
defp build_single_outputs(project_id, published_posts, published_translations, post_by_id) do
defp build_single_outputs(
project_id,
main_language,
published_posts,
published_translations,
post_by_id
) do
translations_by_post_language =
Map.new(published_translations, fn translation ->
{{translation.translation_for, translation.language}, translation}
end)
post_outputs =
Enum.map(published_posts, fn post ->
body = load_body(project_id, post.file_path, post.content)
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)
{post_output_path(post),
render_post_output(
project_id,
post.template_slug,
%{
id: post.id,
title: post.title,
id: canonical_variant.id,
title: canonical_variant.title,
content: body,
slug: post.slug,
language: post.language,
excerpt: post.excerpt
language: canonical_variant.language,
excerpt: canonical_variant.excerpt
},
fn ->
render_post_page(post.title, body, post.slug, post.language)
render_post_page(canonical_variant.title, body, post.slug, canonical_variant.language)
end
)}
end)
translation_outputs =
Enum.flat_map(published_translations, fn translation ->
case post_by_id[translation.translation_for] do
nil ->
[]
post ->
body = load_body(project_id, translation.file_path, translation.content)
[
{post_output_path(post, translation.language),
render_post_output(
project_id,
post.template_slug,
%{
id: translation.id,
title: translation.title,
content: body,
slug: post.slug,
language: translation.language,
excerpt: translation.excerpt
},
fn ->
render_post_page(translation.title, body, post.slug, translation.language)
end
)}
]
end
end)
post_outputs_for_noncanonical_variants(
project_id,
main_language,
published_posts,
published_translations,
post_by_id
)
post_outputs ++ translation_outputs
end
defp post_outputs_for_noncanonical_variants(
project_id,
main_language,
published_posts,
published_translations,
post_by_id
) do
Enum.flat_map(published_posts, fn post ->
post_variant =
if post.language == main_language do
[]
else
[{post.language, post}]
end
translation_variants =
published_translations
|> Enum.filter(&(&1.translation_for == post.id and &1.language != main_language))
|> Enum.map(&{&1.language, &1})
(post_variant ++ translation_variants)
|> Enum.flat_map(fn {language, variant} ->
canonical_post = Map.get(post_by_id, post.id, post)
body = load_body(project_id, variant.file_path, variant.content)
[
{post_output_path(canonical_post, language),
render_post_output(
project_id,
canonical_post.template_slug,
%{
id: variant.id,
title: variant.title,
content: body,
slug: canonical_post.slug,
language: variant.language,
excerpt: variant.excerpt
},
fn ->
render_post_page(variant.title, body, canonical_post.slug, variant.language)
end
)}
]
end)
end)
end
defp list_published_posts(project_id) do
Repo.all(
from post in Post,
@@ -778,7 +836,7 @@ defmodule BDS.Generation do
)
end
defp render_date_archive_page(plan, label, posts, language, pagination) do
defp render_date_archive_page(plan, label, archive_context, posts, language, pagination) do
fallback = fn ->
items =
posts
@@ -801,18 +859,8 @@ defmodule BDS.Generation do
plan,
language,
label,
Enum.map(posts, fn post ->
%{
id: post.id,
slug: post.slug,
title: post.title,
href: "#",
excerpt: post.excerpt,
content: nil,
language: post.language
}
end),
%{kind: "date", name: label},
build_list_posts(plan.base_url, posts, route_language(plan.language, language)),
archive_context,
pagination,
fallback
)

View File

@@ -367,16 +367,25 @@ defmodule BDS.Maintenance do
end
defp diff_field(name, db_value, file_value) do
db_value = stringify_value(db_value)
file_value = stringify_value(file_value)
if db_value == file_value do
if equal_diff_values?(db_value, file_value) do
nil
else
%{name: name, db_value: db_value, file_value: file_value}
%{name: name, db_value: stringify_value(db_value), file_value: stringify_value(file_value)}
end
end
defp equal_diff_values?(left, right) when is_list(left) and is_list(right) do
normalize_list_diff_values(left) == normalize_list_diff_values(right)
end
defp equal_diff_values?(left, right), do: stringify_value(left) == stringify_value(right)
defp normalize_list_diff_values(values) do
values
|> Enum.map(&stringify_value/1)
|> Enum.sort()
end
defp stringify_value(nil), do: ""
defp stringify_value(value) when is_atom(value), do: Atom.to_string(value)
defp stringify_value(value) when is_boolean(value), do: to_string(value)

View File

@@ -4,7 +4,9 @@ defmodule BDS.Preview do
use GenServer
alias BDS.Posts
alias BDS.Posts.Translation
alias BDS.Projects
alias BDS.Repo
alias BDS.Rendering
@host "127.0.0.1"
@@ -34,24 +36,9 @@ defmodule BDS.Preview do
def preview_draft(project_id, request_path, post_id)
when is_binary(project_id) and is_binary(request_path) and is_binary(post_id) do
post = Posts.get_post!(post_id)
{_path, query_params} = split_request_path(request_path)
GenServer.call(__MODULE__, {
:preview_draft,
project_id,
query_params,
%{
id: post.id,
title: post.title,
content: post.content || "",
body: post.content || "",
slug: post.slug,
language: post.language,
excerpt: post.excerpt,
template_slug: post.template_slug
}
})
GenServer.call(__MODULE__, {:preview_draft, project_id, query_params, post_id})
end
@impl true
@@ -108,12 +95,13 @@ defmodule BDS.Preview do
end
end
def handle_call({:preview_draft, project_id, query_params, post}, _from, state) do
with :ok <- ensure_running(state.current, project_id) do
def handle_call({:preview_draft, project_id, query_params, post_id}, _from, state) do
with :ok <- ensure_running(state.current, project_id),
{:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do
body =
case Rendering.render_post_page(project_id, Map.get(post, :template_slug), post) do
case Rendering.render_post_page(project_id, Map.get(payload, :template_slug), payload) do
{:ok, rendered} -> rendered
{:error, _reason} -> render_draft(post)
{:error, _reason} -> render_draft(payload)
end
|> apply_preview_overrides(query_params)
@@ -124,6 +112,7 @@ defmodule BDS.Preview do
{:reply, {:ok, response}, state}
else
{:error, :not_found} = error -> {:reply, error, state}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
@@ -174,11 +163,50 @@ defmodule BDS.Preview do
end
defp resolve_draft_request(project_id, post_id, query_params) do
with {:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do
body =
case Rendering.render_post_page(project_id, Map.get(payload, :template_slug), payload) do
{:ok, rendered} -> rendered
{:error, _reason} -> render_draft(payload)
end
|> apply_preview_overrides(query_params)
{:ok, %{content_type: "text/html", body: body}}
end
end
defp load_draft_preview_payload(project_id, post_id, query_params) do
try do
post = Posts.get_post!(post_id)
if post.project_id == project_id do
payload = %{
{:ok, draft_preview_payload(post, query_params)}
else
{:error, :not_found}
end
rescue
Ecto.NoResultsError -> {:error, :not_found}
end
end
defp draft_preview_payload(post, query_params) do
requested_language = query_params |> Map.get("lang") |> normalize_requested_language()
case draft_preview_translation(post.id, requested_language, post.language) do
%Translation{} = translation ->
%{
id: translation.id,
title: translation.title,
content: translation.content || "",
body: translation.content || "",
slug: post.slug,
language: translation.language,
excerpt: translation.excerpt,
template_slug: post.template_slug
}
nil ->
%{
id: post.id,
title: post.title,
content: post.content || "",
@@ -188,23 +216,20 @@ defmodule BDS.Preview do
excerpt: post.excerpt,
template_slug: post.template_slug
}
body =
case Rendering.render_post_page(project_id, post.template_slug, payload) do
{:ok, rendered} -> rendered
{:error, _reason} -> render_draft(payload)
end
|> apply_preview_overrides(query_params)
{:ok, %{content_type: "text/html", body: body}}
else
{:error, :not_found}
end
rescue
Ecto.NoResultsError -> {:error, :not_found}
end
end
defp draft_preview_translation(_post_id, nil, _post_language), do: nil
defp draft_preview_translation(_post_id, requested_language, post_language) when requested_language == post_language, do: nil
defp draft_preview_translation(post_id, requested_language, _post_language) do
Repo.get_by(Translation, translation_for: post_id, language: requested_language)
end
defp normalize_requested_language(nil), do: nil
defp normalize_requested_language(""), do: nil
defp normalize_requested_language(language), do: language |> String.trim() |> String.downcase()
defp route_request(request_path) do
normalized = request_path |> URI.parse() |> Map.get(:path, "/")
segments = String.split(normalized, "/", trim: true)

View File

@@ -500,6 +500,8 @@ defmodule BDS.Rendering do
language_prefix <> post_path(post, nil)
end
defp post_path(post, ""), do: post_path(post, nil)
defp post_path(post, nil) do
datetime = Persistence.from_unix_ms!(post.created_at)

View File

@@ -4,6 +4,7 @@ defmodule BDS.Rendering.Filters do
use Liquex.Filter
alias BDS.I18n
alias BDS.Slug
def i18n(value, language, _context) do
key = value |> to_string() |> String.trim()
@@ -32,6 +33,12 @@ defmodule BDS.Rendering.Filters do
|> rewrite_rendered_html_urls(canonical_post_paths || %{}, canonical_media_paths || %{})
end
def slugify(value, _context) do
value
|> to_string()
|> Slug.slugify()
end
defp replace_built_in_macros(content, language, context) do
Regex.replace(~r/\[\[(\w+)(?:\s+([^\]]+))?\]\]/, content, fn full_match,
macro_name,

View File

@@ -4,12 +4,18 @@ defmodule BDS.Sidecar do
alias BDS.Persistence
@list_item_prefix " - "
@document_marker "---"
@always_quoted_keys MapSet.new(["originalName", "title", "alt", "caption", "author"])
def serialize_document(fields) when is_list(fields) do
fields
|> Enum.flat_map(&serialize_field/1)
serialized_fields =
fields
|> Enum.flat_map(&serialize_field/1)
|> Enum.join("\n")
[@document_marker, serialized_fields, @document_marker]
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n")
|> Kernel.<>("\n")
end
def parse_document(contents) when is_binary(contents) do
@@ -23,7 +29,8 @@ defmodule BDS.Sidecar do
defp serialize_field({_key, ""}), do: []
defp serialize_field({key, values}) when is_list(values) do
["#{key}:" | Enum.map(values, &" - #{serialize_scalar(nil, &1)}")]
serialized_values = values |> Enum.map(&serialize_inline_list_scalar/1) |> Enum.join(", ")
["#{key}: [#{serialized_values}]"]
end
defp serialize_field({key, value}) when is_boolean(value) do
@@ -104,6 +111,10 @@ defmodule BDS.Sidecar do
defp parse_generic_scalar("false"), do: false
defp parse_generic_scalar("[]"), do: []
defp parse_generic_scalar("[" <> _rest = value) do
parse_inline_list(value)
end
defp parse_generic_scalar(value) do
if Regex.match?(~r/^-?\d+$/, value) do
String.to_integer(value)
@@ -142,26 +153,77 @@ defmodule BDS.Sidecar do
end
end
defp serialize_scalar(_key, value) do
defp serialize_scalar(key, value) do
string_value = to_string(value)
if is_binary(key) and MapSet.member?(@always_quoted_keys, key) do
quote_string(string_value)
else
maybe_quote_string(string_value)
end
end
defp serialize_inline_list_scalar(value) when is_binary(value), do: quote_string(value)
defp serialize_inline_list_scalar(value), do: serialize_scalar(nil, value)
defp parse_inline_list(value) do
inner =
value
|> String.trim()
|> String.trim_leading("[")
|> String.trim_trailing("]")
|> String.trim()
if inner == "" do
[]
else
parse_inline_list_items(inner)
end
end
defp parse_inline_list_items(inner) do
if String.contains?(inner, "\"") or String.contains?(inner, "'") do
Regex.scan(~r/"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'/, inner, capture: :all_but_first)
|> Enum.map(fn captures ->
captures
|> Enum.find(&(&1 != ""))
|> parse_inline_string_item()
end)
else
inner
|> String.split(",", trim: true)
|> Enum.map(&(String.trim(&1) |> parse_scalar(nil)))
end
end
defp parse_inline_string_item(nil), do: ""
defp parse_inline_string_item(value) do
value
|> to_string()
|> maybe_quote_string()
|> String.replace("\\n", "\n")
|> String.replace("\\\"", "\"")
|> String.replace("\\'", "'")
|> String.replace("\\\\", "\\")
end
defp maybe_quote_string(value) do
if Regex.match?(~r/^[\p{L}\p{N} ._\/-]+$/u, value) do
value
else
escaped =
value
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
|> String.replace("\n", "\\n")
"\"#{escaped}\""
quote_string(value)
end
end
defp quote_string(value) do
escaped =
value
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
|> String.replace("\n", "\\n")
"\"#{escaped}\""
end
defp timestamp_key?(key) do
rendered = to_string(key)
String.ends_with?(rendered, "_at") or String.ends_with?(rendered, "At")

View File

@@ -17,11 +17,11 @@ version: 1
{% if post_categories.size > 0 or post_tags.size > 0 %}
<div class="single-post-taxonomy" aria-label="{{ 'render.taxonomy.ariaLabel' | i18n: language }}">
{% for category in post_categories %}
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/{{ category | url_encode }}/">{{ category | escape }}</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/{{ category | slugify | url_encode }}/">{{ category | escape }}</a>
{% endfor %}
{% for tag in post_tags %}
{% assign tag_color = tag_color_by_name[tag] %}
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/{{ tag | url_encode }}/"{% if tag_color %} style="--bubble-accent: {{ tag_color | escape }};"{% endif %}>{{ tag | escape }}</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/{{ tag | slugify | url_encode }}/"{% if tag_color %} style="--bubble-accent: {{ tag_color | escape }};"{% endif %}>{{ tag | escape }}</a>
{% endfor %}
</div>
{% endif %}

View File

@@ -9,11 +9,11 @@
{% if post_categories.size > 0 or post_tags.size > 0 %}
<div class="single-post-taxonomy" aria-label="{{ 'render.taxonomy.ariaLabel' | i18n: language }}">
{% for category in post_categories %}
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/{{ category | url_encode }}/">{{ category | escape }}</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/{{ category | slugify | url_encode }}/">{{ category | escape }}</a>
{% endfor %}
{% for tag in post_tags %}
{% assign tag_color = tag_color_by_name[tag] %}
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/{{ tag | url_encode }}/"{% if tag_color %} style="--bubble-accent: {{ tag_color | escape }};"{% endif %}>{{ tag | escape }}</a>
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/{{ tag | slugify | url_encode }}/"{% if tag_color %} style="--bubble-accent: {{ tag_color | escape }};"{% endif %}>{{ tag | escape }}</a>
{% endfor %}
</div>
{% endif %}

View File

@@ -0,0 +1,138 @@
defmodule BDS.CompatibilitySerializerParityTest do
use ExUnit.Case, async: true
test "post frontmatter serialization matches old bDS output" do
rendered =
BDS.Frontmatter.serialize_document(
[
{"id", "post-1"},
{"title", "Published Post"},
{"slug", "published-post"},
{"excerpt", "Summary"},
{"status", :published},
{"author", "Writer"},
{"language", "en"},
{"doNotTranslate", true},
{"templateSlug", "article"},
{"createdAt", 1_711_833_600_000},
{"updatedAt", 1_711_920_000_000},
{"publishedAt", 1_712_006_400_000},
{"tags", ["alpha"]},
{"categories", ["notes"]}
],
"Hello from markdown"
)
assert rendered ==
[
"---",
"id: post-1",
"title: Published Post",
"slug: published-post",
"excerpt: Summary",
"status: published",
"author: Writer",
"language: en",
"doNotTranslate: true",
"templateSlug: article",
"createdAt: '2024-03-30T21:20:00.000Z'",
"updatedAt: '2024-03-31T21:20:00.000Z'",
"publishedAt: '2024-04-01T21:20:00.000Z'",
"tags:",
" - alpha",
"categories:",
" - notes",
"---",
"Hello from markdown",
""
]
|> Enum.join("\n")
end
test "media sidecar serialization matches old bDS output" do
rendered =
BDS.Sidecar.serialize_document([
{"id", "media-from-file"},
{"originalName", "original.jpg"},
{"mimeType", "image/jpeg"},
{"size", 123},
{"width", 3},
{"height", 2},
{"title", "Recovered"},
{"alt", "Recovered alt"},
{"caption", "Recovered caption"},
{"author", "Writer"},
{"language", "en"},
{"createdAt", 1_711_833_600_000},
{"updatedAt", 1_711_920_000_000},
{"tags", ["alpha"]},
{"linkedPostIds", ["post-a"]}
])
assert rendered ==
[
"---",
"id: media-from-file",
"originalName: \"original.jpg\"",
"mimeType: image/jpeg",
"size: 123",
"width: 3",
"height: 2",
"title: \"Recovered\"",
"alt: \"Recovered alt\"",
"caption: \"Recovered caption\"",
"author: \"Writer\"",
"language: en",
"createdAt: 2024-03-30T21:20:00.000Z",
"updatedAt: 2024-03-31T21:20:00.000Z",
"tags: [\"alpha\"]",
"linkedPostIds: [\"post-a\"]",
"---"
]
|> Enum.join("\n")
end
test "media sidecar parsing accepts old bDS inline arrays and document markers" do
contents =
[
"---",
"id: media-from-file",
"originalName: \"original.jpg\"",
"mimeType: image/jpeg",
"size: 123",
"width: 3",
"height: 2",
"title: \"Recovered\"",
"alt: \"Recovered alt\"",
"caption: \"Recovered caption\"",
"author: \"Writer\"",
"language: en",
"createdAt: 2024-03-30T21:20:00.000Z",
"updatedAt: 2024-03-31T21:20:00.000Z",
"tags: [\"alpha\", \"beta\"]",
"linkedPostIds: [\"post-a\", \"post-b\"]",
"---"
]
|> Enum.join("\n")
assert {:ok, fields} = BDS.Sidecar.parse_document(contents)
assert fields == %{
"id" => "media-from-file",
"originalName" => "original.jpg",
"mimeType" => "image/jpeg",
"size" => 123,
"width" => 3,
"height" => 2,
"title" => "Recovered",
"alt" => "Recovered alt",
"caption" => "Recovered caption",
"author" => "Writer",
"language" => "en",
"createdAt" => 1_711_833_600_000,
"updatedAt" => 1_711_920_000_000,
"tags" => ["alpha", "beta"],
"linkedPostIds" => ["post-a", "post-b"]
}
end
end

View File

@@ -125,6 +125,48 @@ defmodule BDS.GenerationTest do
assert File.read!(Path.join([temp_dir, "html", "sitemap.xml"])) =~ "https://example.com/blog/"
end
test "generation writes feed and atom entries with canonical URLs for published posts", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
public_url: "https://example.com/blog",
main_language: "en",
blog_languages: ["en"]
})
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Feed Entry",
content: "Feed body",
language: "en"
})
created_at = DateTime.to_unix(~U[2026-04-15 12:00:00Z])
Repo.update_all(from(p in BDS.Posts.Post, where: p.id == ^post.id),
set: [created_at: created_at, updated_at: created_at]
)
assert {:ok, published_post} = Posts.publish_post(post.id)
assert {:ok, _result} = BDS.Generation.generate_site(project.id, [:core, :single])
canonical_url =
"https://example.com/blog" <>
"/" <> String.trim_trailing(BDS.Generation.post_output_path(published_post), "index.html")
feed_xml = File.read!(Path.join([temp_dir, "html", "feed.xml"]))
atom_xml = File.read!(Path.join([temp_dir, "html", "atom.xml"]))
assert feed_xml =~ "<rss>"
assert feed_xml =~ "<item><title>Feed Entry</title><link>#{canonical_url}</link></item>"
assert atom_xml =~ "<feed>"
assert atom_xml =~ "<entry><title>Feed Entry</title><id>#{canonical_url}</id></entry>"
end
test "generation renders published list and post templates for core and single pages", %{
project: project,
temp_dir: temp_dir
@@ -377,6 +419,97 @@ defmodule BDS.GenerationTest do
assert not_found_html =~ "Back to preview home"
end
test "generation starter templates render localized archive headings in each output language", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
public_url: "https://example.com/blog",
main_language: "fr",
blog_languages: ["fr", "en"],
max_posts_per_page: 10
})
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Archive Heading",
content: "Archive body",
language: "fr"
})
created_at = DateTime.to_unix(~U[2026-02-15 12:00:00Z])
Repo.update_all(from(p in BDS.Posts.Post, where: p.id == ^post.id),
set: [created_at: created_at, updated_at: created_at]
)
assert {:ok, _published_post} = Posts.publish_post(post.id)
assert {:ok, _result} = BDS.Generation.generate_site(project.id, [:date])
french_html = File.read!(Path.join([temp_dir, "html", "2026", "02", "index.html"]))
english_html = File.read!(Path.join([temp_dir, "html", "en", "2026", "02", "index.html"]))
assert french_html =~ ~s(<html lang="fr")
assert french_html =~ "Archives février 2026"
assert english_html =~ ~s(<html lang="en")
assert english_html =~ "Archive February 2026"
end
test "generation rewrites media aliases with suffixes and renders slugged taxonomy links with tag colors",
%{project: project, temp_dir: temp_dir} do
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
public_url: "https://example.com/blog",
main_language: "en",
blog_languages: ["en"]
})
source_path = Path.join(temp_dir, "sample.txt")
File.write!(source_path, "media body")
assert {:ok, media} =
Media.import_media(%{
project_id: project.id,
source_path: source_path,
title: "Sample"
})
media_source_reference =
"/" <> Path.join(Path.dirname(media.file_path), media.original_name) <> "?download=1#preview"
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Taxonomy Colors",
content: "![Asset](#{media_source_reference})",
language: "en",
categories: ["Release Notes"],
tags: ["Elixir"]
})
assert {:ok, published_post} = Posts.publish_post(post.id)
assert {:ok, [tag]} = BDS.Tags.sync_tags_from_posts(project.id)
assert {:ok, _updated_tag} = BDS.Tags.update_tag(tag.id, %{color: "#112233"})
assert {:ok, _result} = BDS.Generation.generate_site(project.id, [:single])
post_html =
File.read!(Path.join([temp_dir, "html", BDS.Generation.post_output_path(published_post)]))
category_link = ~s(href="/category/release-notes/")
tag_link = ~s(href="/tag/elixir/" style="--bubble-accent: #112233;")
assert post_html =~ category_link
assert post_html =~ tag_link
assert elem(:binary.match(post_html, category_link), 0) <
elem(:binary.match(post_html, tag_link), 0)
assert post_html =~ ~s(src="/#{media.file_path}?download=1#preview")
end
test "single generation writes canonical post pages and language-prefixed translation pages", %{
project: project,
temp_dir: temp_dir
@@ -417,6 +550,52 @@ defmodule BDS.GenerationTest do
assert File.read!(Path.join([temp_dir, "html", post_path])) =~ ~s(data-pagefind-body)
end
test "single generation renders the canonical route in the project main language when a translation exists",
%{project: project, temp_dir: temp_dir} do
assert {:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{
public_url: "https://example.com/blog",
main_language: "fr",
blog_languages: ["fr", "en"]
})
assert {:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Hello World",
content: "Canonical body",
language: "en"
})
assert {:ok, _translation} =
Posts.upsert_post_translation(post.id, "fr", %{
title: "Bonjour le monde",
content: "Corps FR"
})
assert {:ok, published_post} = Posts.publish_post(post.id)
assert {:ok, result} = BDS.Generation.generate_site(project.id, [:single])
post_path = BDS.Generation.post_output_path(published_post)
translation_path = BDS.Generation.post_output_path(published_post, "en")
assert Enum.map(result.generated_files, & &1.relative_path) |> Enum.sort() ==
Enum.sort([post_path, translation_path])
canonical_html = File.read!(Path.join([temp_dir, "html", post_path]))
english_html = File.read!(Path.join([temp_dir, "html", translation_path]))
assert canonical_html =~ ~s(<html lang="fr")
assert canonical_html =~ "Bonjour le monde"
assert canonical_html =~ "Corps FR"
refute canonical_html =~ "Canonical body"
assert english_html =~ ~s(<html lang="en")
assert english_html =~ "Hello World"
assert english_html =~ "Canonical body"
end
test "archive generation writes paginated category, tag, and date pages", %{
project: project,
temp_dir: temp_dir

View File

@@ -700,6 +700,55 @@ defmodule BDS.MaintenanceTest do
assert "templates/orphan-view.liquid" in orphan_paths
end
test "metadata_diff ignores tag and category order like old bDS", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Ordered Post",
content: "Body",
tags: ["alpha", "beta"],
categories: ["article", "notes"]
})
assert {:ok, published_post} = BDS.Posts.publish_post(post.id)
post_path = Path.join(temp_dir, published_post.file_path)
File.write!(
post_path,
[
"---",
"id: #{published_post.id}",
"title: #{published_post.title}",
"slug: #{published_post.slug}",
"status: published",
"createdAt: '#{BDS.Persistence.timestamp_to_iso8601(published_post.created_at)}'",
"updatedAt: '#{BDS.Persistence.timestamp_to_iso8601(published_post.updated_at)}'",
"publishedAt: '#{BDS.Persistence.timestamp_to_iso8601(published_post.published_at)}'",
"tags:",
" - beta",
" - alpha",
"categories:",
" - notes",
" - article",
"---",
"Body",
""
]
|> Enum.join("\n")
)
assert {:ok, %{diff_reports: diff_reports}} = BDS.Maintenance.metadata_diff(project.id)
refute Enum.any?(diff_reports, fn report ->
report.entity_type == "post" and report.entity_id == published_post.id and
Enum.any?(report.differences, &(&1.name in ["tags", "categories"]))
end)
end
defp collect_progress_events(acc \\ []) do
receive do
{:rebuild_progress, value, message} -> collect_progress_events([{value, message} | acc])

View File

@@ -42,18 +42,20 @@ defmodule BDS.MediaTest do
assert File.read!(Path.join(temp_dir, media.file_path)) == "hello media"
sidecar = File.read!(Path.join(temp_dir, media.sidecar_path))
assert sidecar =~ "---\n"
assert sidecar =~ "id: #{media.id}\n"
assert sidecar =~ "originalName: sample.txt\n"
assert sidecar =~ "originalName: \"sample.txt\"\n"
assert sidecar =~ "mimeType: text/plain\n"
assert sidecar =~ "title: Sample\n"
assert sidecar =~ "alt: Alt text\n"
assert sidecar =~ "caption: Caption\n"
assert sidecar =~ "author: Writer\n"
assert sidecar =~ "title: \"Sample\"\n"
assert sidecar =~ "alt: \"Alt text\"\n"
assert sidecar =~ "caption: \"Caption\"\n"
assert sidecar =~ "author: \"Writer\"\n"
assert sidecar =~ "language: en\n"
assert sidecar =~ "tags:\n - alpha\n"
assert sidecar =~ "linkedPostIds:\n"
assert sidecar =~ "tags: [\"alpha\"]\n"
assert sidecar =~ "linkedPostIds: []\n"
assert sidecar =~ ~r/createdAt: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\n/
assert sidecar =~ ~r/updatedAt: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\n/
assert String.ends_with?(sidecar, "\n---")
refute File.exists?(Path.join(temp_dir, media.sidecar_path <> ".tmp"))
end
@@ -78,10 +80,10 @@ defmodule BDS.MediaTest do
assert updated.language == "de"
sidecar = File.read!(Path.join(temp_dir, updated.sidecar_path))
assert sidecar =~ "title: Updated\n"
assert sidecar =~ "alt: Updated alt\n"
assert sidecar =~ "title: \"Updated\"\n"
assert sidecar =~ "alt: \"Updated alt\"\n"
assert sidecar =~ "language: de\n"
assert sidecar =~ "tags:\n - beta\n"
assert sidecar =~ "tags: [\"beta\"]\n"
end
test "delete_media removes the binary, sidecar, and database row", %{
@@ -452,11 +454,12 @@ defmodule BDS.MediaTest do
translated_sidecar_path = Path.join(temp_dir, media.file_path <> ".de.meta")
contents = File.read!(translated_sidecar_path)
assert contents =~ "---\n"
assert contents =~ "translationFor: #{media.id}\n"
assert contents =~ "language: de\n"
assert contents =~ "title: Titel\n"
assert contents =~ "alt: Alt text\n"
assert contents =~ "caption: Bildunterschrift\n"
assert contents =~ "title: \"Titel\"\n"
assert contents =~ "alt: \"Alt text\"\n"
assert contents =~ "caption: \"Bildunterschrift\"\n---"
end
defp tiny_jpeg_binary do

View File

@@ -151,9 +151,9 @@ defmodule BDS.PostsTest do
assert file_contents =~ "templateSlug: article\n"
assert file_contents =~ "tags:\n - alpha\n"
assert file_contents =~ "categories:\n - notes\n"
assert file_contents =~ ~r/createdAt: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\n/
assert file_contents =~ ~r/updatedAt: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\n/
assert file_contents =~ ~r/publishedAt: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\n/
assert file_contents =~ ~r/createdAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
assert file_contents =~ ~r/updatedAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
assert file_contents =~ ~r/publishedAt: '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z'\n/
assert file_contents =~ "\n---\nHello from markdown\n"
refute File.exists?(full_path <> ".tmp")

View File

@@ -158,6 +158,50 @@ defmodule BDS.PreviewTest do
assert :ok = BDS.Preview.stop_preview(project.id)
end
test "draft preview honors the lang query parameter and falls back to the canonical draft", %{
project: project
} do
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: "Canonical Draft",
content: "Canonical body",
language: "en"
})
assert {:ok, _translation} =
Posts.upsert_post_translation(post.id, "de", %{
title: "Deutscher Entwurf",
content: "Deutscher Inhalt"
})
assert {:ok, _server} = BDS.Preview.start_preview(project.id)
assert {:ok, %{body: german_html, content_type: "text/html"}} =
BDS.Preview.preview_draft(project.id, "/draft/canonical-draft?lang=de", post.id)
assert german_html =~ ~s(<html lang="de")
assert german_html =~ "Deutscher Entwurf"
assert german_html =~ "Deutscher Inhalt"
refute german_html =~ "Canonical body"
assert {:ok, %{body: fallback_html, content_type: "text/html"}} =
BDS.Preview.preview_draft(project.id, "/draft/canonical-draft?lang=fr", post.id)
assert fallback_html =~ ~s(<html lang="en")
assert fallback_html =~ "Canonical Draft"
assert fallback_html =~ "Canonical body"
assert :ok = BDS.Preview.stop_preview(project.id)
end
test "preview renders not-found template for missing routes and rewrites markdown macros and canonical URLs",
%{project: project, temp_dir: temp_dir} do
:inets.start()

View File

@@ -72,8 +72,8 @@ defmodule BDS.ScriptsTest do
assert contents =~ "entrypoint: main\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 =~ ~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---\nfunction main() return 'ok' end\n"
refute File.exists?(full_path <> ".tmp")
end

View File

@@ -72,8 +72,8 @@ defmodule BDS.TemplatesTest do
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 =~ ~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<section>{{ page_title }}</section>\n"
refute File.exists?(full_path <> ".tmp")
end