diff --git a/PLAN.md b/PLAN.md index 3ad094d..6a4c818 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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. diff --git a/lib/bds/frontmatter.ex b/lib/bds/frontmatter.ex index eae9572..d87a5ea 100644 --- a/lib/bds/frontmatter.ex +++ b/lib/bds/frontmatter.ex @@ -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 diff --git a/lib/bds/generation.ex b/lib/bds/generation.ex index 91ef07d..b4cd782 100644 --- a/lib/bds/generation.ex +++ b/lib/bds/generation.ex @@ -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 ) diff --git a/lib/bds/maintenance.ex b/lib/bds/maintenance.ex index 9aed98e..92b3bc0 100644 --- a/lib/bds/maintenance.ex +++ b/lib/bds/maintenance.ex @@ -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) diff --git a/lib/bds/preview.ex b/lib/bds/preview.ex index afabae0..51a24fe 100644 --- a/lib/bds/preview.ex +++ b/lib/bds/preview.ex @@ -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) diff --git a/lib/bds/rendering.ex b/lib/bds/rendering.ex index 37b4d4d..7edd7bc 100644 --- a/lib/bds/rendering.ex +++ b/lib/bds/rendering.ex @@ -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) diff --git a/lib/bds/rendering/filters.ex b/lib/bds/rendering/filters.ex index 53c0533..df82bb9 100644 --- a/lib/bds/rendering/filters.ex +++ b/lib/bds/rendering/filters.ex @@ -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, diff --git a/lib/bds/sidecar.ex b/lib/bds/sidecar.ex index 40ac09f..d734961 100644 --- a/lib/bds/sidecar.ex +++ b/lib/bds/sidecar.ex @@ -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") diff --git a/priv/data/projects/default/templates/single-post.liquid b/priv/data/projects/default/templates/single-post.liquid index be3c3d6..a1030e5 100644 --- a/priv/data/projects/default/templates/single-post.liquid +++ b/priv/data/projects/default/templates/single-post.liquid @@ -17,11 +17,11 @@ version: 1 {% if post_categories.size > 0 or post_tags.size > 0 %}
{% for category in post_categories %} - {{ category | escape }} + {{ category | escape }} {% endfor %} {% for tag in post_tags %} {% assign tag_color = tag_color_by_name[tag] %} - {{ tag | escape }} + {{ tag | escape }} {% endfor %}
{% endif %} diff --git a/priv/starter_templates/templates/single-post.liquid b/priv/starter_templates/templates/single-post.liquid index 736c9d9..d0fe643 100644 --- a/priv/starter_templates/templates/single-post.liquid +++ b/priv/starter_templates/templates/single-post.liquid @@ -9,11 +9,11 @@ {% if post_categories.size > 0 or post_tags.size > 0 %}
{% for category in post_categories %} - {{ category | escape }} + {{ category | escape }} {% endfor %} {% for tag in post_tags %} {% assign tag_color = tag_color_by_name[tag] %} - {{ tag | escape }} + {{ tag | escape }} {% endfor %}
{% endif %} diff --git a/test/bds/compatibility_serializer_parity_test.exs b/test/bds/compatibility_serializer_parity_test.exs new file mode 100644 index 0000000..33ea114 --- /dev/null +++ b/test/bds/compatibility_serializer_parity_test.exs @@ -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 diff --git a/test/bds/generation_test.exs b/test/bds/generation_test.exs index 18d727b..4904869 100644 --- a/test/bds/generation_test.exs +++ b/test/bds/generation_test.exs @@ -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 =~ "" + assert feed_xml =~ "Feed Entry#{canonical_url}" + + assert atom_xml =~ "" + assert atom_xml =~ "Feed Entry#{canonical_url}" + 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( 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( 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]) diff --git a/test/bds/media_test.exs b/test/bds/media_test.exs index b6da1d7..4016f48 100644 --- a/test/bds/media_test.exs +++ b/test/bds/media_test.exs @@ -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 diff --git a/test/bds/posts_test.exs b/test/bds/posts_test.exs index 5c0917a..d5f0fb2 100644 --- a/test/bds/posts_test.exs +++ b/test/bds/posts_test.exs @@ -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") diff --git a/test/bds/preview_test.exs b/test/bds/preview_test.exs index 1995508..9ad72ae 100644 --- a/test/bds/preview_test.exs +++ b/test/bds/preview_test.exs @@ -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( ".tmp") end diff --git a/test/bds/templates_test.exs b/test/bds/templates_test.exs index d02dcf4..32170cc 100644 --- a/test/bds/templates_test.exs +++ b/test/bds/templates_test.exs @@ -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
{{ page_title }}
\n" refute File.exists?(full_path <> ".tmp") end