fix: issue #35 validation expected unrendered .lang translation variant routes; main-language lists now render main-language translations
This commit is contained in:
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 != ""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user