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. - 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-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. - 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. - 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. - 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. 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. 1. Lock compatibility contracts. Completed 2026-04-25.
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. 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. 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. 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) Integer.to_string(value)
end end
["#{key}: #{rendered}"] if timestamp_key?(key) do
["#{key}: '#{rendered}'"]
else
["#{key}: #{rendered}"]
end
end end
defp serialize_field({key, value}) do defp serialize_field({key, value}) do
@@ -196,7 +200,7 @@ defmodule BDS.Frontmatter do
defp serialize_scalar(key, value) when is_integer(value) do defp serialize_scalar(key, value) when is_integer(value) do
if is_binary(key) and timestamp_key?(key) do if is_binary(key) and timestamp_key?(key) do
Persistence.timestamp_to_iso8601(value) "'#{Persistence.timestamp_to_iso8601(value)}'"
else else
Integer.to_string(value) Integer.to_string(value)
end end

View File

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

View File

@@ -367,16 +367,25 @@ defmodule BDS.Maintenance do
end end
defp diff_field(name, db_value, file_value) do defp diff_field(name, db_value, file_value) do
db_value = stringify_value(db_value) if equal_diff_values?(db_value, file_value) do
file_value = stringify_value(file_value)
if db_value == file_value do
nil nil
else 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
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(nil), do: ""
defp stringify_value(value) when is_atom(value), do: Atom.to_string(value) 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) defp stringify_value(value) when is_boolean(value), do: to_string(value)

View File

@@ -4,7 +4,9 @@ defmodule BDS.Preview do
use GenServer use GenServer
alias BDS.Posts alias BDS.Posts
alias BDS.Posts.Translation
alias BDS.Projects alias BDS.Projects
alias BDS.Repo
alias BDS.Rendering alias BDS.Rendering
@host "127.0.0.1" @host "127.0.0.1"
@@ -34,24 +36,9 @@ defmodule BDS.Preview do
def preview_draft(project_id, request_path, post_id) 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 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) {_path, query_params} = split_request_path(request_path)
GenServer.call(__MODULE__, { GenServer.call(__MODULE__, {:preview_draft, project_id, query_params, post_id})
: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
}
})
end end
@impl true @impl true
@@ -108,12 +95,13 @@ defmodule BDS.Preview do
end end
end end
def handle_call({:preview_draft, project_id, query_params, post}, _from, state) do def handle_call({:preview_draft, project_id, query_params, post_id}, _from, state) do
with :ok <- ensure_running(state.current, project_id) do with :ok <- ensure_running(state.current, project_id),
{:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do
body = 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 {:ok, rendered} -> rendered
{:error, _reason} -> render_draft(post) {:error, _reason} -> render_draft(payload)
end end
|> apply_preview_overrides(query_params) |> apply_preview_overrides(query_params)
@@ -124,6 +112,7 @@ defmodule BDS.Preview do
{:reply, {:ok, response}, state} {:reply, {:ok, response}, state}
else else
{:error, :not_found} = error -> {:reply, error, state}
{:error, reason} -> {:reply, {:error, reason}, state} {:error, reason} -> {:reply, {:error, reason}, state}
end end
end end
@@ -174,11 +163,50 @@ defmodule BDS.Preview do
end end
defp resolve_draft_request(project_id, post_id, query_params) do 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 try do
post = Posts.get_post!(post_id) post = Posts.get_post!(post_id)
if post.project_id == project_id do 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, id: post.id,
title: post.title, title: post.title,
content: post.content || "", content: post.content || "",
@@ -188,23 +216,20 @@ defmodule BDS.Preview do
excerpt: post.excerpt, excerpt: post.excerpt,
template_slug: post.template_slug 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
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 defp route_request(request_path) do
normalized = request_path |> URI.parse() |> Map.get(:path, "/") normalized = request_path |> URI.parse() |> Map.get(:path, "/")
segments = String.split(normalized, "/", trim: true) segments = String.split(normalized, "/", trim: true)

View File

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

View File

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

View File

@@ -4,12 +4,18 @@ defmodule BDS.Sidecar do
alias BDS.Persistence alias BDS.Persistence
@list_item_prefix " - " @list_item_prefix " - "
@document_marker "---"
@always_quoted_keys MapSet.new(["originalName", "title", "alt", "caption", "author"])
def serialize_document(fields) when is_list(fields) do def serialize_document(fields) when is_list(fields) do
fields serialized_fields =
|> Enum.flat_map(&serialize_field/1) fields
|> Enum.flat_map(&serialize_field/1)
|> Enum.join("\n")
[@document_marker, serialized_fields, @document_marker]
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n") |> Enum.join("\n")
|> Kernel.<>("\n")
end end
def parse_document(contents) when is_binary(contents) do 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, ""}), do: []
defp serialize_field({key, values}) when is_list(values) 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 end
defp serialize_field({key, value}) when is_boolean(value) do 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("false"), do: false
defp parse_generic_scalar("[]"), do: [] defp parse_generic_scalar("[]"), do: []
defp parse_generic_scalar("[" <> _rest = value) do
parse_inline_list(value)
end
defp parse_generic_scalar(value) do defp parse_generic_scalar(value) do
if Regex.match?(~r/^-?\d+$/, value) do if Regex.match?(~r/^-?\d+$/, value) do
String.to_integer(value) String.to_integer(value)
@@ -142,26 +153,77 @@ defmodule BDS.Sidecar do
end end
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 value
|> to_string() |> String.replace("\\n", "\n")
|> maybe_quote_string() |> String.replace("\\\"", "\"")
|> String.replace("\\'", "'")
|> String.replace("\\\\", "\\")
end end
defp maybe_quote_string(value) do defp maybe_quote_string(value) do
if Regex.match?(~r/^[\p{L}\p{N} ._\/-]+$/u, value) do if Regex.match?(~r/^[\p{L}\p{N} ._\/-]+$/u, value) do
value value
else else
escaped = quote_string(value)
value
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
|> String.replace("\n", "\\n")
"\"#{escaped}\""
end end
end end
defp quote_string(value) do
escaped =
value
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
|> String.replace("\n", "\\n")
"\"#{escaped}\""
end
defp timestamp_key?(key) do defp timestamp_key?(key) do
rendered = to_string(key) rendered = to_string(key)
String.ends_with?(rendered, "_at") or String.ends_with?(rendered, "At") 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 %} {% if post_categories.size > 0 or post_tags.size > 0 %}
<div class="single-post-taxonomy" aria-label="{{ 'render.taxonomy.ariaLabel' | i18n: language }}"> <div class="single-post-taxonomy" aria-label="{{ 'render.taxonomy.ariaLabel' | i18n: language }}">
{% for category in post_categories %} {% 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 %} {% endfor %}
{% for tag in post_tags %} {% for tag in post_tags %}
{% assign tag_color = tag_color_by_name[tag] %} {% 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 %} {% endfor %}
</div> </div>
{% endif %} {% endif %}

View File

@@ -9,11 +9,11 @@
{% if post_categories.size > 0 or post_tags.size > 0 %} {% if post_categories.size > 0 or post_tags.size > 0 %}
<div class="single-post-taxonomy" aria-label="{{ 'render.taxonomy.ariaLabel' | i18n: language }}"> <div class="single-post-taxonomy" aria-label="{{ 'render.taxonomy.ariaLabel' | i18n: language }}">
{% for category in post_categories %} {% 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 %} {% endfor %}
{% for tag in post_tags %} {% for tag in post_tags %}
{% assign tag_color = tag_color_by_name[tag] %} {% 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 %} {% endfor %}
</div> </div>
{% endif %} {% 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/" assert File.read!(Path.join([temp_dir, "html", "sitemap.xml"])) =~ "https://example.com/blog/"
end 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", %{ test "generation renders published list and post templates for core and single pages", %{
project: project, project: project,
temp_dir: temp_dir temp_dir: temp_dir
@@ -377,6 +419,97 @@ defmodule BDS.GenerationTest do
assert not_found_html =~ "Back to preview home" assert not_found_html =~ "Back to preview home"
end 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", %{ test "single generation writes canonical post pages and language-prefixed translation pages", %{
project: project, project: project,
temp_dir: temp_dir 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) assert File.read!(Path.join([temp_dir, "html", post_path])) =~ ~s(data-pagefind-body)
end 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", %{ test "archive generation writes paginated category, tag, and date pages", %{
project: project, project: project,
temp_dir: temp_dir temp_dir: temp_dir

View File

@@ -700,6 +700,55 @@ defmodule BDS.MaintenanceTest do
assert "templates/orphan-view.liquid" in orphan_paths assert "templates/orphan-view.liquid" in orphan_paths
end 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 defp collect_progress_events(acc \\ []) do
receive do receive do
{:rebuild_progress, value, message} -> collect_progress_events([{value, message} | acc]) {: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" assert File.read!(Path.join(temp_dir, media.file_path)) == "hello media"
sidecar = File.read!(Path.join(temp_dir, media.sidecar_path)) sidecar = File.read!(Path.join(temp_dir, media.sidecar_path))
assert sidecar =~ "---\n"
assert sidecar =~ "id: #{media.id}\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 =~ "mimeType: text/plain\n"
assert sidecar =~ "title: Sample\n" assert sidecar =~ "title: \"Sample\"\n"
assert sidecar =~ "alt: Alt text\n" assert sidecar =~ "alt: \"Alt text\"\n"
assert sidecar =~ "caption: Caption\n" assert sidecar =~ "caption: \"Caption\"\n"
assert sidecar =~ "author: Writer\n" assert sidecar =~ "author: \"Writer\"\n"
assert sidecar =~ "language: en\n" assert sidecar =~ "language: en\n"
assert sidecar =~ "tags:\n - alpha\n" assert sidecar =~ "tags: [\"alpha\"]\n"
assert sidecar =~ "linkedPostIds:\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/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 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")) refute File.exists?(Path.join(temp_dir, media.sidecar_path <> ".tmp"))
end end
@@ -78,10 +80,10 @@ defmodule BDS.MediaTest do
assert updated.language == "de" assert updated.language == "de"
sidecar = File.read!(Path.join(temp_dir, updated.sidecar_path)) sidecar = File.read!(Path.join(temp_dir, updated.sidecar_path))
assert sidecar =~ "title: Updated\n" assert sidecar =~ "title: \"Updated\"\n"
assert sidecar =~ "alt: Updated alt\n" assert sidecar =~ "alt: \"Updated alt\"\n"
assert sidecar =~ "language: de\n" assert sidecar =~ "language: de\n"
assert sidecar =~ "tags:\n - beta\n" assert sidecar =~ "tags: [\"beta\"]\n"
end end
test "delete_media removes the binary, sidecar, and database row", %{ 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") translated_sidecar_path = Path.join(temp_dir, media.file_path <> ".de.meta")
contents = File.read!(translated_sidecar_path) contents = File.read!(translated_sidecar_path)
assert contents =~ "---\n"
assert contents =~ "translationFor: #{media.id}\n" assert contents =~ "translationFor: #{media.id}\n"
assert contents =~ "language: de\n" assert contents =~ "language: de\n"
assert contents =~ "title: Titel\n" assert contents =~ "title: \"Titel\"\n"
assert contents =~ "alt: Alt text\n" assert contents =~ "alt: \"Alt text\"\n"
assert contents =~ "caption: Bildunterschrift\n" assert contents =~ "caption: \"Bildunterschrift\"\n---"
end end
defp tiny_jpeg_binary do defp tiny_jpeg_binary do

View File

@@ -151,9 +151,9 @@ defmodule BDS.PostsTest do
assert file_contents =~ "templateSlug: article\n" assert file_contents =~ "templateSlug: article\n"
assert file_contents =~ "tags:\n - alpha\n" assert file_contents =~ "tags:\n - alpha\n"
assert file_contents =~ "categories:\n - notes\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/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/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/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" assert file_contents =~ "\n---\nHello from markdown\n"
refute File.exists?(full_path <> ".tmp") refute File.exists?(full_path <> ".tmp")

View File

@@ -158,6 +158,50 @@ defmodule BDS.PreviewTest do
assert :ok = BDS.Preview.stop_preview(project.id) assert :ok = BDS.Preview.stop_preview(project.id)
end 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", test "preview renders not-found template for missing routes and rewrites markdown macros and canonical URLs",
%{project: project, temp_dir: temp_dir} do %{project: project, temp_dir: temp_dir} do
:inets.start() :inets.start()

View File

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

View File

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