From 392314497670633a7e9a02906394bd0d2eb5121e Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 27 Apr 2026 19:49:47 +0200 Subject: [PATCH] fix: site validation seems to finally be fixed --- lib/bds/generation.ex | 319 +++++++++++++++++++++-------------- test/bds/generation_test.exs | 166 +++++++++++++++++- 2 files changed, 353 insertions(+), 132 deletions(-) diff --git a/lib/bds/generation.ex b/lib/bds/generation.ex index 2e15383..73ac172 100644 --- a/lib/bds/generation.ex +++ b/lib/bds/generation.ex @@ -286,10 +286,10 @@ defmodule BDS.Generation do def post_output_path(post), do: post_output_path(post, nil) def post_output_path(post, language) when is_map(post) do - datetime = Persistence.from_unix_ms!(post.created_at) - year = Integer.to_string(datetime.year) - month = datetime.month |> Integer.to_string() |> String.pad_leading(2, "0") - day = datetime.day |> Integer.to_string() |> String.pad_leading(2, "0") + {year, month, day} = local_date_parts!(post.created_at) + year = Integer.to_string(year) + month = month |> Integer.to_string() |> String.pad_leading(2, "0") + day = day |> Integer.to_string() |> String.pad_leading(2, "0") path_parts = [year, month, day, post.slug, "index.html"] @@ -629,10 +629,9 @@ defmodule BDS.Generation do defp build_generation_post_index(posts) do Enum.reduce(posts, %{posts_by_category: %{}, posts_by_tag: %{}, posts_by_year: %{}, posts_by_year_month: %{}, posts_by_year_month_day: %{}}, fn post, acc -> - created_at = Persistence.from_unix_ms!(post.created_at) - year = created_at.year - month = String.pad_leading(Integer.to_string(created_at.month), 2, "0") - day = String.pad_leading(Integer.to_string(created_at.day), 2, "0") + {year, month_value, day_value} = local_date_parts!(post.created_at) + month = String.pad_leading(Integer.to_string(month_value), 2, "0") + day = String.pad_leading(Integer.to_string(day_value), 2, "0") year_month = "#{year}/#{month}" year_month_day = "#{year}/#{month}/#{day}" @@ -654,18 +653,61 @@ defmodule BDS.Generation do defp build_outputs(plan) do data = generation_data(plan) published_translations = flattened_generation_translations(data.translations_by_post) - post_by_id = Map.new(data.published_posts, &{&1.id, &1}) + 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() core_outputs = if :core in plan.sections do - build_core_outputs(plan, data.published_list_posts) + build_core_outputs( + plan, + data.published_list_posts, + localized_list_posts_by_language + ) else [] end page_outputs = if :core in plan.sections do - build_page_outputs(plan.project_id, plan.language, data.published_posts, published_translations, post_by_id) + build_page_outputs( + plan.project_id, + plan.language, + data.published_posts, + translations_by_post_language, + localized_posts_by_language + ) else [] end @@ -676,15 +718,15 @@ defmodule BDS.Generation do plan.project_id, plan.language, data.published_posts, - published_translations, - post_by_id + translations_by_post_language, + localized_posts_by_language ) else [] end archive_outputs = - build_archive_outputs(plan, data.published_list_posts, data.post_index) + build_archive_outputs(plan, data.post_index, localized_post_indexes) urls = (core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs) @@ -838,7 +880,7 @@ defmodule BDS.Generation do Enum.flat_map(posts_by_category, fn {category, posts} -> paginated_archive_paths( route_language, - ["category", Slug.slugify(category)], + ["category", archive_route_segment(category)], length(posts), plan.max_posts_per_page ) @@ -853,7 +895,7 @@ defmodule BDS.Generation do Enum.flat_map(posts_by_tag, fn {tag, posts} -> paginated_archive_paths( route_language, - ["tag", Slug.slugify(tag)], + ["tag", archive_route_segment(tag)], length(posts), plan.max_posts_per_page ) @@ -972,26 +1014,49 @@ defmodule BDS.Generation do defp strip_language_prefix(segments), do: segments - defp build_archive_outputs(plan, _published_posts, post_index) do - languages = plan.blog_languages - + defp build_archive_outputs(plan, post_index, localized_post_indexes) do category_outputs = if :category in plan.sections do - build_category_outputs(plan, post_index.posts_by_category, languages) + build_category_outputs(plan, post_index.posts_by_category, [plan.language]) ++ + Enum.flat_map(additional_languages(plan), fn language -> + build_category_outputs( + plan, + Map.get(localized_post_indexes, language, %{posts_by_category: %{}}).posts_by_category, + [language] + ) + end) else [] end tag_outputs = if :tag in plan.sections do - build_tag_outputs(plan, post_index.posts_by_tag, languages) + build_tag_outputs(plan, post_index.posts_by_tag, [plan.language]) ++ + Enum.flat_map(additional_languages(plan), fn language -> + build_tag_outputs( + plan, + Map.get(localized_post_indexes, language, %{posts_by_tag: %{}}).posts_by_tag, + [language] + ) + end) else [] end date_outputs = if :date in plan.sections do - build_date_outputs(plan, post_index, languages) + build_date_outputs(plan, post_index, [plan.language]) ++ + Enum.flat_map(additional_languages(plan), fn language -> + build_date_outputs( + plan, + Map.get( + localized_post_indexes, + language, + %{posts_by_year: %{}, posts_by_year_month: %{}, posts_by_year_month_day: %{}} + ), + [language] + ) + end) else [] end @@ -1002,7 +1067,7 @@ defmodule BDS.Generation do defp build_category_outputs(plan, posts_by_category, languages) do Enum.flat_map(posts_by_category, fn {category, posts} -> paginated_posts = Enum.chunk_every(posts, max(plan.max_posts_per_page, 1)) - category_slug = Slug.slugify(category) + category_slug = archive_route_segment(category) Enum.with_index(paginated_posts, 1) |> Enum.flat_map(fn {page_posts, page_number} -> @@ -1051,7 +1116,7 @@ defmodule BDS.Generation do defp build_tag_outputs(plan, posts_by_tag, languages) do Enum.flat_map(posts_by_tag, fn {tag, posts} -> - tag_slug = Slug.slugify(tag) + tag_slug = archive_route_segment(tag) build_paginated_archive_outputs(plan, languages, ["tag", tag_slug], posts, fn page_posts, language, pagination -> render_archive_page(plan, tag, page_posts, language, "tag", pagination) @@ -1109,7 +1174,7 @@ defmodule BDS.Generation do year_outputs ++ month_outputs ++ day_outputs end - defp build_core_outputs(plan, published_posts) do + defp build_core_outputs(plan, published_posts, localized_posts_by_language) do language = plan.language additional_languages = Enum.reject(plan.blog_languages, &(&1 == language)) main_posts = build_list_posts(plan.base_url, published_posts, nil) @@ -1123,23 +1188,19 @@ defmodule BDS.Generation do ] ++ Enum.flat_map(additional_languages, fn localized_language -> localized_prefix = route_language(plan.language, localized_language) - localized_posts = build_list_posts(plan.base_url, published_posts, localized_prefix) + localized_source_posts = Map.get(localized_posts_by_language, localized_language, []) + localized_posts = build_list_posts(plan.base_url, localized_source_posts, localized_prefix) build_root_outputs(plan, localized_language, localized_posts) ++ [ {Path.join(localized_language, "404.html"), render_not_found_output(plan, localized_language)}, - {Path.join(localized_language, "feed.xml"), render_feed(plan, localized_language, published_posts)}, - {Path.join(localized_language, "atom.xml"), render_atom(plan, localized_language, published_posts)} + {Path.join(localized_language, "feed.xml"), render_feed(plan, localized_language, localized_source_posts)}, + {Path.join(localized_language, "atom.xml"), render_atom(plan, localized_language, localized_source_posts)} ] end) end - defp build_page_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) - + defp build_page_outputs(project_id, main_language, published_posts, translations_by_post_language, localized_posts_by_language) do page_outputs = published_posts |> Enum.filter(&("page" in (&1.categories || []))) @@ -1164,38 +1225,26 @@ defmodule BDS.Generation do end) translation_page_outputs = - published_posts - |> Enum.filter(&("page" in (&1.categories || []))) - |> Enum.flat_map(fn post -> - post_variant = - if post.language == main_language do - [] - else - [{post.language, post}] - end + localized_posts_by_language + |> Enum.flat_map(fn {language, posts} -> + posts + |> Enum.filter(&("page" in (&1.categories || []))) + |> Enum.map(fn post -> + body = load_body(project_id, post.file_path, post.content) - translation_variants = - published_translations - |> Enum.filter(&(&1.translation_for == post.id and &1.language != main_language)) - |> Enum.map(&{&1.language, &1}) - - Enum.map(post_variant ++ translation_variants, fn {language, variant} -> - canonical_post = Map.get(post_by_id, post.id, post) - body = load_body(project_id, variant.file_path, variant.content) - - {page_output_path(canonical_post.slug, language), + {page_output_path(post.slug, language), render_post_output( project_id, - canonical_post.template_slug, + post.template_slug, %{ - id: variant.id, - title: variant.title, + id: post.id, + title: post.title, content: body, - slug: canonical_post.slug, - language: variant.language, - excerpt: variant.excerpt + slug: post.slug, + language: Map.get(post, :language), + excerpt: post.excerpt }, - fn -> render_post_page(variant.title, body, canonical_post.slug, variant.language) end + fn -> render_post_page(post.title, body, post.slug, Map.get(post, :language)) end )} end) end) @@ -1334,14 +1383,9 @@ defmodule BDS.Generation do project_id, main_language, published_posts, - published_translations, - post_by_id + translations_by_post_language, + localized_posts_by_language ) 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 -> canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post) @@ -1366,62 +1410,29 @@ defmodule BDS.Generation do end) translation_outputs = - post_outputs_for_noncanonical_variants( - project_id, - main_language, - published_posts, - published_translations, - post_by_id - ) + localized_posts_by_language + |> Enum.flat_map(fn {language, posts} -> + Enum.map(posts, fn post -> + body = load_body(project_id, post.file_path, post.content) - 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), + {post_output_path(post, language), render_post_output( project_id, - canonical_post.template_slug, + post.template_slug, %{ - id: variant.id, - title: variant.title, + id: post.id, + title: post.title, content: body, - slug: canonical_post.slug, - language: variant.language, - excerpt: variant.excerpt + slug: post.slug, + language: Map.get(post, :language), + excerpt: post.excerpt }, - fn -> - render_post_page(variant.title, body, canonical_post.slug, variant.language) - end + fn -> render_post_page(post.title, body, post.slug, Map.get(post, :language)) end )} - ] + end) end) - end) + + post_outputs ++ translation_outputs end defp list_published_posts(project_id) do @@ -1456,6 +1467,9 @@ defmodule BDS.Generation do Path.join(prefix ++ segments ++ ["index.html"]) end + defp archive_route_segment(nil), do: "" + defp archive_route_segment(value), do: value |> to_string() |> URI.encode(&URI.char_unreserved?/1) + defp normalize_base_url(nil), do: nil defp normalize_base_url(url), do: String.trim_trailing(url, "/") @@ -1468,6 +1482,50 @@ defmodule BDS.Generation do defp route_language(main_language, language) when main_language == language, do: nil defp route_language(_main_language, language), do: language + defp translation_lookup_map(published_translations) do + Map.new(published_translations, fn translation -> + {{translation.translation_for, translation.language}, translation} + end) + end + + defp resolve_posts_for_language(posts, target_language, translations_by_post_language, main_language) do + target = String.downcase(to_string(target_language || "")) + main = String.downcase(to_string(main_language || "")) + + Enum.map(posts, fn post -> + post_language = String.downcase(to_string(Map.get(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 + end + end) + end + + defp build_localized_subtree_variant(post, translation) do + %{ + post + | id: translation.id, + title: translation.title, + excerpt: translation.excerpt, + content: translation.content, + language: translation.language, + updated_at: translation.updated_at, + published_at: translation.published_at || post.published_at, + file_path: translation.file_path + } + end + defp render_home(plan, language) do [ "", @@ -1511,8 +1569,7 @@ defmodule BDS.Generation do defp render_calendar(published_posts) do published_posts |> Enum.map(fn post -> - datetime = Persistence.from_unix_ms!(post.created_at) - %{date: Date.to_iso8601(DateTime.to_date(datetime)), slug: post.slug, title: post.title} + %{date: local_date_iso8601!(post.created_at), slug: post.slug, title: post.title} end) |> Jason.encode!() end @@ -1628,7 +1685,7 @@ defmodule BDS.Generation do ) end) ++ Enum.map(Enum.sort_by(post_index.posts_by_category, &elem(&1, 0)), fn {category, _posts} -> - category_path = "/category/#{Slug.slugify(category)}" + category_path = "/category/#{archive_route_segment(category)}" render_multi_language_sitemap_url( url_for_path(plan.base_url, category_path), @@ -1639,7 +1696,7 @@ defmodule BDS.Generation do ) end) ++ Enum.map(Enum.sort_by(post_index.posts_by_tag, &elem(&1, 0)), fn {tag, _posts} -> - tag_path = "/tag/#{Slug.slugify(tag)}" + tag_path = "/tag/#{archive_route_segment(tag)}" render_multi_language_sitemap_url( url_for_path(plan.base_url, tag_path), @@ -2356,16 +2413,14 @@ defmodule BDS.Generation do |> Map.put(:request_root_routes, true) post -> - created_at = Persistence.from_unix_ms!(post.created_at) - year = created_at.year - month = created_at.month + {year, month, _day} = local_date_parts!(post.created_at) acc |> update_in([:requested_category_slugs], fn set -> - Enum.reduce(post.categories || [], set, &MapSet.put(&2, Slug.slugify(&1))) + Enum.reduce(post.categories || [], set, &MapSet.put(&2, archive_route_segment(&1))) end) |> update_in([:requested_tag_slugs], fn set -> - Enum.reduce(post.tags || [], set, &MapSet.put(&2, Slug.slugify(&1))) + Enum.reduce(post.tags || [], set, &MapSet.put(&2, archive_route_segment(&1))) end) |> update_in([:requested_years], &MapSet.put(&1, year)) |> update_in([:requested_year_months], &MapSet.put(&1, route_month_key(year, month))) @@ -2390,10 +2445,20 @@ defmodule BDS.Generation do end defp post_matches_route?(post, route) do - created_at = Persistence.from_unix_ms!(post.created_at) + {year, month, day} = local_date_parts!(post.created_at) - post.slug == route.slug and created_at.year == route.year and created_at.month == route.month and - created_at.day == route.day + post.slug == route.slug and year == route.year and month == route.month and day == route.day + end + + defp local_date_parts!(value) do + normalized = Persistence.normalize_unix_timestamp(value) + {{year, month, day}, _time} = :calendar.system_time_to_local_time(normalized, :millisecond) + {year, month, day} + end + + defp local_date_iso8601!(value) do + {year, month, day} = local_date_parts!(value) + Date.new!(year, month, day) |> Date.to_iso8601() end defp route_key(year, month, day, slug) do diff --git a/test/bds/generation_test.exs b/test/bds/generation_test.exs index 44c1719..883c21a 100644 --- a/test/bds/generation_test.exs +++ b/test/bds/generation_test.exs @@ -764,6 +764,42 @@ defmodule BDS.GenerationTest do assert english_html =~ "Canonical body" end + test "single generation uses host local calendar date for dated output paths", %{ + project: project, + temp_dir: temp_dir + } do + created_at = DateTime.to_unix(~U[2010-11-16 23:26:04Z], :millisecond) + {{year, month, day}, _time} = :calendar.system_time_to_local_time(created_at, :millisecond) + + assert {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Umzugsstatus", + content: "Local date body", + language: "de" + }) + + Repo.update_all(from(p in BDS.Posts.Post, where: p.id == ^post.id), + set: [created_at: created_at, published_at: created_at, updated_at: created_at] + ) + + assert {:ok, _published} = Posts.publish_post(post.id) + + assert {:ok, result} = BDS.Generation.generate_site(project.id, [:single]) + + expected_path = + Path.join([ + Integer.to_string(year), + String.pad_leading(Integer.to_string(month), 2, "0"), + String.pad_leading(Integer.to_string(day), 2, "0"), + "umzugsstatus", + "index.html" + ]) + + assert expected_path in Enum.map(result.generated_files, & &1.relative_path) + assert File.exists?(Path.join([temp_dir, "html", expected_path])) + end + test "archive generation writes paginated category, tag, and date pages", %{ project: project, temp_dir: temp_dir @@ -803,11 +839,11 @@ defmodule BDS.GenerationTest do expected_paths = [ "category/notes/index.html", "category/notes/page/2/index.html", - "tag/elixir/index.html", + "tag/Elixir/index.html", "2026/index.html", "2026/04/index.html", "de/category/notes/index.html", - "de/tag/elixir/index.html", + "de/tag/Elixir/index.html", "de/2026/index.html", "de/2026/04/index.html" ] @@ -821,7 +857,7 @@ defmodule BDS.GenerationTest do Path.join([temp_dir, "html", "category", "notes", "page", "2", "index.html"]) ) =~ "Archive 3" - assert File.read!(Path.join([temp_dir, "html", "tag", "elixir", "index.html"])) =~ "Elixir" + assert File.read!(Path.join([temp_dir, "html", "tag", "Elixir", "index.html"])) =~ "Elixir" assert File.read!(Path.join([temp_dir, "html", "2026", "04", "index.html"])) =~ "2026-04" end @@ -1029,6 +1065,126 @@ defmodule BDS.GenerationTest do refute sitemap_xml =~ "localized-post.de" end + test "generate_site and validate_site exclude do_not_translate posts from language subtree pagination", + %{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", "de"], + max_posts_per_page: 1 + }) + + assert {:ok, translatable_post} = + Posts.create_post(%{ + project_id: project.id, + title: "Translatable", + content: "Translatable body", + language: "en" + }) + + Repo.update_all(from(p in BDS.Posts.Post, where: p.id == ^translatable_post.id), + set: [created_at: DateTime.to_unix(~U[2026-04-15 12:00:00Z]), updated_at: DateTime.to_unix(~U[2026-04-15 12:00:00Z])] + ) + + assert {:ok, _published_translatable} = Posts.publish_post(translatable_post.id) + + assert {:ok, do_not_translate_post} = + Posts.create_post(%{ + project_id: project.id, + title: "Stay Local", + content: "Only main language", + language: "en", + do_not_translate: true + }) + + Repo.update_all(from(p in BDS.Posts.Post, where: p.id == ^do_not_translate_post.id), + set: [created_at: DateTime.to_unix(~U[2026-04-14 12:00:00Z]), updated_at: DateTime.to_unix(~U[2026-04-14 12:00:00Z])] + ) + + assert {:ok, _published_do_not_translate} = Posts.publish_post(do_not_translate_post.id) + + assert {:ok, _result} = BDS.Generation.generate_site(project.id, [:core]) + + refute File.exists?(Path.join([temp_dir, "html", "de", "page", "2", "index.html"])) + + assert {:ok, report} = BDS.Generation.validate_site(project.id, [:core]) + assert report.missing_url_paths == [] + assert report.extra_url_paths == [] + assert report.updated_post_url_paths == [] + end + + test "generate_site and validate_site use URL-encoded tag paths like old bDS", %{ + 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: ["en"] + }) + + assert {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Encoded Tag", + content: "Encoded tag body", + language: "de", + tags: ["bücher"] + }) + + assert {:ok, _published_post} = Posts.publish_post(post.id) + + assert {:ok, _result} = BDS.Generation.generate_site(project.id, [:tag]) + + assert File.exists?(Path.join([temp_dir, "html", "tag", "b%C3%BCcher", "index.html"])) + refute File.exists?(Path.join([temp_dir, "html", "tag", "bucher", "index.html"])) + + assert {:ok, report} = BDS.Generation.validate_site(project.id, [:tag]) + assert report.missing_url_paths == [] + assert report.extra_url_paths == [] + assert report.updated_post_url_paths == [] + end + + test "generate_site and validate_site percent-encode reserved tag characters like old bDS", %{ + 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: ["en"] + }) + + assert {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Reserved Tag", + content: "Reserved tag body", + language: "de", + tags: ["google+", "c#", "f#"] + }) + + assert {:ok, _published_post} = Posts.publish_post(post.id) + + assert {:ok, _result} = BDS.Generation.generate_site(project.id, [:tag]) + + assert File.exists?(Path.join([temp_dir, "html", "tag", "google%2B", "index.html"])) + assert File.exists?(Path.join([temp_dir, "html", "tag", "c%23", "index.html"])) + assert File.exists?(Path.join([temp_dir, "html", "tag", "f%23", "index.html"])) + + refute File.exists?(Path.join([temp_dir, "html", "tag", "google+", "index.html"])) + refute File.exists?(Path.join([temp_dir, "html", "tag", "c", "index.html"])) + refute File.exists?(Path.join([temp_dir, "html", "tag", "f", "index.html"])) + + assert {:ok, report} = BDS.Generation.validate_site(project.id, [:tag]) + assert report.missing_url_paths == [] + assert report.extra_url_paths == [] + assert report.updated_post_url_paths == [] + end + test "generation and validation include old-app pagination and day archive routes", %{ project: project, temp_dir: temp_dir @@ -1082,13 +1238,13 @@ defmodule BDS.GenerationTest do relative_paths = Enum.map(result.generated_files, & &1.relative_path) assert "page/2/index.html" in relative_paths - assert "tag/elixir/page/2/index.html" in relative_paths + assert "tag/Elixir/page/2/index.html" in relative_paths assert "2026/04/15/index.html" in relative_paths assert "2026/04/15/page/2/index.html" in relative_paths assert "about/index.html" in relative_paths assert File.exists?(Path.join([temp_dir, "html", "page", "2", "index.html"])) - assert File.exists?(Path.join([temp_dir, "html", "tag", "elixir", "page", "2", "index.html"])) + assert File.exists?(Path.join([temp_dir, "html", "tag", "Elixir", "page", "2", "index.html"])) assert File.exists?(Path.join([temp_dir, "html", "2026", "04", "15", "index.html"])) assert File.exists?(Path.join([temp_dir, "html", "2026", "04", "15", "page", "2", "index.html"])) assert File.exists?(Path.join([temp_dir, "html", "about", "index.html"]))