From 23f05b72f79473cbaa5cf6df8afa07ec04f673ff Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 17 Jul 2026 22:12:23 +0200 Subject: [PATCH] fix: issue #35 validation expected unrendered .lang translation variant routes; main-language lists now render main-language translations --- lib/bds/generation.ex | 102 ++++++++++----------------- lib/bds/generation/data.ex | 63 +++-------------- lib/bds/generation/outputs.ex | 10 --- lib/bds/generation/validation.ex | 6 +- specs/generation.allium | 11 +++ test/bds/generation_test.exs | 117 +++++++++++++++++++++++++++++++ 6 files changed, 180 insertions(+), 129 deletions(-) diff --git a/lib/bds/generation.ex b/lib/bds/generation.ex index 023b20a..b83c0bd 100644 --- a/lib/bds/generation.ex +++ b/lib/bds/generation.ex @@ -178,7 +178,7 @@ defmodule BDS.Generation do build_core_outputs( plan, - data.published_list_posts, + render_data.main_list_posts, render_data.localized_list_posts_by_language, on_output ) ++ @@ -274,7 +274,7 @@ defmodule BDS.Generation do end defp full_localized_archive_outputs(plan, render_data, builder) do - main_outputs = builder.(plan, render_data.data.post_index, [plan.language]) + main_outputs = builder.(plan, render_data.main_post_index, [plan.language]) language_outputs = Enum.flat_map(additional_languages(plan), fn language -> @@ -286,11 +286,8 @@ defmodule BDS.Generation do end defp core_sitemap_outputs(plan, data) do - published_route_posts = - suppress_subtree_translation_variants(data.published_route_posts, additional_languages(plan)) - {_sitemap_content, sitemap_to_write, _expected_paths, _timestamp_checks} = - build_validation_sitemap_artifacts(plan, data, published_route_posts, %{}, nil) + build_validation_sitemap_artifacts(plan, data, data.published_posts, %{}, nil) [{"sitemap.xml", sitemap_to_write}] end @@ -321,17 +318,13 @@ defmodule BDS.Generation do {:ok, generated_files_list} = list_generated_files(project_id) generated_file_updated_at = generated_file_updated_at_map(generated_files_list) - additional_languages = additional_languages(plan) - - published_route_posts = - suppress_subtree_translation_variants(data.published_route_posts, additional_languages) {sitemap_content, sitemap_to_write, additional_expected_paths, additional_post_timestamp_checks} = build_validation_sitemap_artifacts( plan, data, - published_route_posts, + data.published_posts, generated_file_updated_at, on_progress ) @@ -350,7 +343,7 @@ defmodule BDS.Generation do post_timestamp_checks: build_post_timestamp_checks( data.project_data_dir, - published_route_posts, + data.published_posts, generated_file_updated_at ) ++ additional_post_timestamp_checks, additional_expected_paths: additional_expected_paths @@ -647,53 +640,15 @@ defmodule BDS.Generation do defp build_outputs(plan, on_output \\ nil) do plan = with_render_context(plan) - data = generation_data(plan) - published_translations = flattened_generation_translations(data.translations_by_post) - translations_by_post_language = translation_lookup_map(published_translations) - - translatable_published_posts = - Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate))) - - translatable_published_list_posts = - Enum.reject(data.published_list_posts, &truthy_flag?(Map.get(&1, :do_not_translate))) - - localized_posts_by_language = - additional_languages(plan) - |> Enum.map(fn language -> - {language, - resolve_posts_for_language( - translatable_published_posts, - language, - translations_by_post_language, - plan.language - )} - end) - |> Map.new() - - localized_list_posts_by_language = - additional_languages(plan) - |> Enum.map(fn language -> - {language, - resolve_posts_for_language( - translatable_published_list_posts, - language, - translations_by_post_language, - plan.language - )} - end) - |> Map.new() - - localized_post_indexes = - localized_list_posts_by_language - |> Enum.map(fn {language, posts} -> {language, build_generation_post_index(posts)} end) - |> Map.new() + render_data = validation_render_data(plan) + data = render_data.data core_outputs = if :core in plan.sections do build_core_outputs( plan, - data.published_list_posts, - localized_list_posts_by_language, + render_data.main_list_posts, + render_data.localized_list_posts_by_language, on_output ) else @@ -706,8 +661,8 @@ defmodule BDS.Generation do plan_render_target(plan), plan.language, data.published_posts, - translations_by_post_language, - localized_posts_by_language, + render_data.translations_by_post_language, + render_data.localized_posts_by_language, on_output ) else @@ -720,8 +675,8 @@ defmodule BDS.Generation do plan_render_target(plan), plan.language, data.published_posts, - translations_by_post_language, - localized_posts_by_language, + render_data.translations_by_post_language, + render_data.localized_posts_by_language, on_output ) else @@ -729,7 +684,12 @@ defmodule BDS.Generation do end archive_outputs = - build_archive_outputs(plan, data.post_index, localized_post_indexes, on_output) + build_archive_outputs( + plan, + render_data.main_post_index, + render_data.localized_post_indexes, + on_output + ) urls = (core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs) @@ -770,14 +730,14 @@ defmodule BDS.Generation do defp build_validation_sitemap_artifacts( plan, data, - published_route_posts, + published_posts, generated_file_updated_at, on_progress ) do main_paths = build_validation_route_paths( plan, - published_route_posts, + published_posts, data.published_list_posts, data.post_index, nil @@ -1111,12 +1071,26 @@ defmodule BDS.Generation do {language, build_generation_post_index(posts)} end) + # Main-language lists must show the main-language translation of a + # foreign-language post when one exists, mirroring the canonical variant + # lookup single-page renders do. Resolved here (not in generation_data) + # because subtree resolution above needs the raw posts' ids intact. + main_list_posts = + resolve_posts_for_language( + data.published_list_posts, + plan.language, + translations_by_post_language, + plan.language + ) + %{ data: data, translations_by_post_language: translations_by_post_language, localized_posts_by_language: localized_posts_by_language, localized_list_posts_by_language: localized_list_posts_by_language, - localized_post_indexes: localized_post_indexes + localized_post_indexes: localized_post_indexes, + main_list_posts: main_list_posts, + main_post_index: build_generation_post_index(main_list_posts) } end @@ -1125,7 +1099,7 @@ defmodule BDS.Generation do main_root = if targeted_plan.request_root_routes do - posts = build_list_posts(plan, data.published_list_posts, nil) + posts = build_list_posts(plan, render_data.main_list_posts, nil) build_root_outputs(plan, plan.language, posts) else [] @@ -1228,7 +1202,7 @@ defmodule BDS.Generation do end defp build_targeted_archive_outputs(plan, render_data, targeted_plan, builder) do - main_outputs = builder.(plan, render_data.data.post_index, targeted_plan, [plan.language]) + main_outputs = builder.(plan, render_data.main_post_index, targeted_plan, [plan.language]) language_outputs = Enum.flat_map(additional_languages(plan), fn language -> diff --git a/lib/bds/generation/data.ex b/lib/bds/generation/data.ex index 962053f..bc2d9ed 100644 --- a/lib/bds/generation/data.ex +++ b/lib/bds/generation/data.ex @@ -74,8 +74,8 @@ defmodule BDS.Generation.Data do |> Enum.uniq_by(& &1.id) |> Enum.sort_by(&{-(&1.created_at || 0), -(&1.published_at || 0), to_string(&1.slug)}) - {published_route_posts, translations_by_post} = - build_generation_route_posts( + translations_by_post = + build_generation_translations( plan.project_id, project_data_dir, published_posts, @@ -87,7 +87,6 @@ defmodule BDS.Generation.Data do project_data_dir: project_data_dir, published_posts: published_posts, published_list_posts: published_list_posts, - published_route_posts: published_route_posts, translations_by_post: translations_by_post, post_index: build_generation_post_index(published_list_posts) } @@ -121,18 +120,13 @@ defmodule BDS.Generation.Data do post_language = String.downcase(to_string(post.language || "")) effective_language = if post_language == "", do: main, else: post_language - cond do - is_binary(Map.get(post, :translation_source_slug)) -> - post - - effective_language == target -> - post - - true -> - case Map.get(translations_by_post_language, {post.id, target_language}) do - nil -> post - translation -> build_localized_subtree_variant(post, translation) - end + if effective_language == target do + post + else + case Map.get(translations_by_post_language, {post.id, target_language}) do + nil -> post + translation -> build_localized_subtree_variant(post, translation) + end end end) end @@ -231,7 +225,7 @@ defmodule BDS.Generation.Data do read_snapshot(full_path, fallback_post, &build_post_snapshot/2) end - defp build_generation_route_posts( + defp build_generation_translations( project_id, project_data_dir, published_posts, @@ -268,17 +262,7 @@ defmodule BDS.Generation.Data do end) |> Map.new(fn {post_id, translations} -> {post_id, Enum.reverse(translations)} end) - route_posts = - Enum.flat_map(published_posts, fn post -> - variants = - translations_by_post - |> Map.get(post.id, []) - |> Enum.map(&build_published_translation_variant(post, &1)) - - [post | variants] - end) - - {route_posts, translations_by_post} + translations_by_post end defp published_translation_snapshot(project_data_dir, %Translation{} = translation) do @@ -357,31 +341,6 @@ defmodule BDS.Generation.Data do } end - defp build_published_translation_variant(post, translation) do - %{ - id: translation.id, - project_id: post.project_id, - title: translation.title, - slug: "#{post.slug}.#{translation.language}", - excerpt: translation.excerpt, - content: nil, - status: :published, - author: post.author, - created_at: post.created_at, - updated_at: translation.updated_at, - published_at: translation.published_at || post.published_at, - file_path: translation.file_path, - tags: post.tags, - categories: post.categories, - template_slug: post.template_slug, - language: translation.language, - do_not_translate: post.do_not_translate, - translation_source_slug: post.slug, - translation_canonical_language: post.language, - translation_file_path: translation.file_path - } - end - defp build_localized_subtree_variant(post, translation) do %{ post diff --git a/lib/bds/generation/outputs.ex b/lib/bds/generation/outputs.ex index 5d9600d..27b9981 100644 --- a/lib/bds/generation/outputs.ex +++ b/lib/bds/generation/outputs.ex @@ -22,16 +22,6 @@ defmodule BDS.Generation.Outputs do def route_post_output_path(post, ""), do: post_output_path(post) def route_post_output_path(post, route_language), do: post_output_path(post, route_language) - @spec suppress_subtree_translation_variants([map()], [String.t()]) :: [map()] - def suppress_subtree_translation_variants(route_posts, additional_languages) do - subtree_languages = MapSet.new(additional_languages) - - Enum.reject(route_posts, fn post -> - is_binary(Map.get(post, :translation_source_slug)) and - MapSet.member?(subtree_languages, to_string(post.language)) - end) - end - @spec build_validation_route_paths(map(), [map()], [map()], map(), String.t() | nil) :: [ String.t() ] diff --git a/lib/bds/generation/validation.ex b/lib/bds/generation/validation.ex index fec33cc..99f0a5c 100644 --- a/lib/bds/generation/validation.ex +++ b/lib/bds/generation/validation.ex @@ -28,15 +28,15 @@ defmodule BDS.Generation.Validation do @spec build_post_timestamp_checks(String.t(), [map()], map()) :: [map()] def build_post_timestamp_checks( project_data_dir, - published_route_posts, + published_posts, generated_file_updated_at ) do build_timestamp_checks( project_data_dir, - published_route_posts, + published_posts, generated_file_updated_at, &BDS.Generation.Paths.post_output_path/1, - fn post -> Map.get(post, :translation_file_path) || post.file_path end + & &1.file_path ) end diff --git a/specs/generation.allium b/specs/generation.allium index 1a3178d..41210f1 100644 --- a/specs/generation.allium +++ b/specs/generation.allium @@ -113,6 +113,17 @@ invariant MultiLanguageRoutes { -- Each language subtree gets its own feeds and archives } +invariant LanguageVariantSelection { + -- Every language tree renders each post in its own language when a + -- matching variant exists, falling back to the post's original language. + -- This applies uniformly to single pages, list pages (home/pagination, + -- category/tag/date archives), and feeds: + -- Main tree: a post whose language differs from main_language renders + -- its main_language translation when one exists (canonical variant). + -- Additional-language trees: posts resolve to that language's + -- translation; do_not_translate posts are excluded from those trees. +} + invariant CanonicalBaseUrlConfigured { for generation in SiteGenerations: generation.base_url != "" diff --git a/test/bds/generation_test.exs b/test/bds/generation_test.exs index c493554..f696df2 100644 --- a/test/bds/generation_test.exs +++ b/test/bds/generation_test.exs @@ -1473,6 +1473,123 @@ defmodule BDS.GenerationTest do ) end + test "validate_site reports no missing pages for main-language translations of foreign-language 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: "de", + blog_languages: ["de", "en"] + }) + + assert {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "English Original", + content: "English 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, _translation} = + Posts.upsert_post_translation(post.id, "de", %{ + title: "Deutsches Original", + content: "Deutscher Inhalt" + }) + + assert {:ok, published_post} = Posts.publish_post(post.id) + assert {:ok, _result} = BDS.Generation.generate_site(project.id, [:core, :single]) + assert {:ok, report} = BDS.Generation.validate_site(project.id, [:core, :single]) + + assert report.missing_url_paths == [] + assert report.extra_url_paths == [] + assert report.updated_post_url_paths == [] + + canonical_html = + File.read!( + Path.join([temp_dir, "html", BDS.Generation.post_output_path(published_post)]) + ) + + english_html = + File.read!( + Path.join([temp_dir, "html", BDS.Generation.post_output_path(published_post, "en")]) + ) + + assert canonical_html =~ "Deutscher Inhalt" + assert english_html =~ "English body" + + sitemap_xml = File.read!(Path.join([temp_dir, "html", "sitemap.xml"])) + + refute sitemap_xml =~ "english-original.de" + end + + test "main-language list pages and feeds use main-language translations of foreign-language 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: "de", + blog_languages: ["de", "en"] + }) + + assert {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "English List Original", + content: "English list body", + language: "en", + categories: ["notes"], + tags: ["Elixir"] + }) + + 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, _translation} = + Posts.upsert_post_translation(post.id, "de", %{ + title: "Deutsche Listenfassung", + content: "Deutscher Listeninhalt" + }) + + assert {:ok, _published_post} = Posts.publish_post(post.id) + + assert {:ok, _result} = + BDS.Generation.generate_site(project.id, [:core, :single, :category, :tag, :date]) + + read_output = fn segments -> File.read!(Path.join([temp_dir, "html" | segments])) end + + for segments <- [ + ["index.html"], + ["rss.xml"], + ["atom.xml"], + ["category", "notes", "index.html"], + ["tag", "Elixir", "index.html"], + ["2026", "index.html"] + ] do + html = read_output.(segments) + assert html =~ "Deutsche Listenfassung", "expected translated title in #{Path.join(segments)}" + refute html =~ "English List Original", "unexpected original title in #{Path.join(segments)}" + end + + for segments <- [ + ["en", "index.html"], + ["en", "rss.xml"], + ["en", "category", "notes", "index.html"] + ] do + html = read_output.(segments) + assert html =~ "English List Original", "expected original title in #{Path.join(segments)}" + refute html =~ "Deutsche Listenfassung", "unexpected translated title in #{Path.join(segments)}" + end + end + test "validate_site follows old language subtree expectations and combined sitemap output", %{ project: project, temp_dir: temp_dir