From 2f557e08841ff3ec052c4ef385170f0bbe8c4114 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 23 Apr 2026 21:46:47 +0200 Subject: [PATCH] feat: more work on templating --- lib/bds/generation.ex | 21 +++ lib/bds/preview.ex | 34 +++- lib/bds/rendering.ex | 48 +++++- lib/bds/rendering/filters.ex | 151 +++++++++++++++++- .../default/templates/not-found.liquid | 2 +- .../default/templates/post-list.liquid | 2 +- .../default/templates/single-post.liquid | 2 +- test/bds/generation_test.exs | 66 ++++++++ test/bds/preview_test.exs | 72 +++++++++ 9 files changed, 389 insertions(+), 9 deletions(-) diff --git a/lib/bds/generation.ex b/lib/bds/generation.ex index aaf762f..b286f40 100644 --- a/lib/bds/generation.ex +++ b/lib/bds/generation.ex @@ -260,6 +260,7 @@ defmodule BDS.Generation do [ {"index.html", render_list_output(plan, language, plan.project_name, main_posts, %{kind: "core"}, fn -> render_home(plan, language) end)}, + {"404.html", render_not_found_output(plan, language)}, {"feed.xml", render_feed(plan, language, published_posts)}, {"atom.xml", render_atom(plan, language, published_posts)}, {"calendar.json", render_calendar(published_posts)} @@ -270,6 +271,7 @@ defmodule BDS.Generation do [ {Path.join(localized_language, "index.html"), render_list_output(plan, localized_language, plan.project_name, localized_posts, %{kind: "core"}, fn -> render_home(plan, localized_language) end)}, + {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)} ] @@ -566,6 +568,16 @@ defmodule BDS.Generation do defp render_list_output(_plan, _language, _page_title, _posts, _archive_context, fallback), do: fallback.() + defp render_not_found_output(%{project_id: project_id, language: main_language}, language) + when is_binary(project_id) do + case Rendering.render_not_found_page(project_id, %{language: language, language_prefix: language_prefix(language, main_language)}) do + {:ok, rendered} -> rendered + {:error, _reason} -> render_not_found_page(language) + end + end + + defp render_not_found_output(_plan, language), do: render_not_found_page(language) + defp language_prefix(language, main_language) when language == main_language, do: "" defp language_prefix(nil, _main_language), do: "" defp language_prefix(language, _main_language), do: "/#{language}" @@ -578,6 +590,15 @@ defmodule BDS.Generation do String.trim_trailing(base_url, "/") <> suffix end + defp render_not_found_page(language) do + [ + "

404

Not Found

" + ] + |> IO.iodata_to_binary() + end + defp xml_escape(value) do value |> to_string() diff --git a/lib/bds/preview.ex b/lib/bds/preview.ex index bcd13af..6bdc462 100644 --- a/lib/bds/preview.ex +++ b/lib/bds/preview.ex @@ -146,7 +146,11 @@ defmodule BDS.Preview do case full_path do {:error, :not_found} -> {:error, :not_found} - resolved_path -> read_response(resolved_path) + resolved_path -> + case read_response(resolved_path) do + {:error, :not_found} -> render_not_found_response(server.project_id) + other -> other + end end end end @@ -263,8 +267,22 @@ defmodule BDS.Preview do end defp http_ok_response(response) do + status = Map.get(response, :status, 200) + + reason = + case status do + 200 -> "OK" + 404 -> "Not Found" + 503 -> "Service Unavailable" + _other -> "OK" + end + [ - "HTTP/1.1 200 OK\r\n", + "HTTP/1.1 ", + Integer.to_string(status), + " ", + reason, + "\r\n", "content-type: ", response.content_type, "; charset=utf-8\r\n", @@ -336,12 +354,22 @@ defmodule BDS.Preview do defp read_response(path) do case File.read(path) do - {:ok, body} -> {:ok, %{body: body, content_type: content_type(path)}} + {:ok, body} -> {:ok, %{status: 200, body: body, content_type: content_type(path)}} {:error, :enoent} -> {:error, :not_found} {:error, reason} -> {:error, reason} end end + defp render_not_found_response(project_id) do + body = + case Rendering.render_not_found_page(project_id, %{}) do + {:ok, rendered} -> rendered + {:error, _reason} -> "Not Found" + end + + {:ok, %{status: 404, content_type: "text/html", body: body}} + end + defp content_type(path) do case Path.extname(path) do ".html" -> "text/html" diff --git a/lib/bds/rendering.ex b/lib/bds/rendering.ex index 11c92c8..3e45541 100644 --- a/lib/bds/rendering.ex +++ b/lib/bds/rendering.ex @@ -4,6 +4,7 @@ defmodule BDS.Rendering do import Ecto.Query alias BDS.Frontmatter + alias BDS.Media.Media, as: MediaAsset alias BDS.Rendering.FileSystem alias BDS.Menu alias BDS.Metadata @@ -30,6 +31,13 @@ defmodule BDS.Rendering do end end + def render_not_found_page(project_id, assigns \\ %{}) when is_binary(project_id) and is_map(assigns) do + with {:ok, template_source} <- load_template_source(project_id, :not_found, nil), + {:ok, rendered} <- render_template(project_id, template_source, not_found_assigns(project_id, assigns)) do + {:ok, rendered} + end + end + defp load_template_source(project_id, kind, slug) do case select_template(project_id, kind, slug) do nil -> {:error, :template_not_found} @@ -119,7 +127,7 @@ defmodule BDS.Rendering do tag_color_by_name: tag_color_by_name(project_id), backlinks: [], canonical_post_path_by_slug: canonical_post_path_by_slug(project_id, main_language), - canonical_media_path_by_source_path: %{}, + canonical_media_path_by_source_path: canonical_media_path_by_source_path(project_id), post_data_json_by_id: post_data_json(assigns), post: %{ id: Map.get(assigns, :id, Map.get(assigns, "id")), @@ -164,7 +172,7 @@ defmodule BDS.Rendering do prev_page_href: "", next_page_href: "", canonical_post_path_by_slug: canonical_post_path_by_slug(project_id, main_language), - canonical_media_path_by_source_path: %{}, + canonical_media_path_by_source_path: canonical_media_path_by_source_path(project_id), post_data_json_by_id: Enum.into(posts, %{}, fn post -> {post.id, Jason.encode!(Map.take(post, [:id, :slug, :title, :content]))} end), day_blocks: [ %{ @@ -177,6 +185,25 @@ defmodule BDS.Rendering do } end + defp not_found_assigns(project_id, assigns) do + metadata = project_metadata(project_id) + language = Map.get(assigns, :language, Map.get(assigns, "language", metadata.main_language || "en")) + main_language = metadata.main_language || language + + %{ + page_title: Map.get(assigns, :page_title, Map.get(assigns, "page_title", "404 Not Found")), + language: language, + language_prefix: Map.get(assigns, :language_prefix, Map.get(assigns, "language_prefix", language_prefix(language, main_language))), + pico_stylesheet_href: nil, + html_theme_attribute: html_theme_attribute(metadata.pico_theme), + blog_languages: blog_languages(metadata, language), + menu_items: menu_items(project_id), + alternate_links: [], + not_found_message: Map.get(assigns, :not_found_message, Map.get(assigns, "not_found_message")), + not_found_back_label: Map.get(assigns, :not_found_back_label, Map.get(assigns, "not_found_back_label")) + } + end + defp project_metadata(project_id) do case Metadata.get_project_metadata(project_id) do {:ok, metadata} -> metadata @@ -280,6 +307,23 @@ defmodule BDS.Rendering do end) end + defp canonical_media_path_by_source_path(project_id) do + Repo.all(from media in MediaAsset, where: media.project_id == ^project_id) + |> Enum.reduce(%{}, fn media, acc -> + datetime = DateTime.from_unix!(media.created_at) + source_key = + Path.join([ + "media", + Integer.to_string(datetime.year), + String.pad_leading(Integer.to_string(datetime.month), 2, "0"), + media.original_name + ]) + |> String.downcase() + + Map.put(acc, source_key, "/" <> media.file_path) + end) + end + defp post_path(post, language_prefix) when is_binary(language_prefix) and language_prefix != "" do Path.join([String.trim_leading(language_prefix, "/"), post_path(post, nil)]) end diff --git a/lib/bds/rendering/filters.ex b/lib/bds/rendering/filters.ex index 8579d07..3a4efb0 100644 --- a/lib/bds/rendering/filters.ex +++ b/lib/bds/rendering/filters.ex @@ -15,9 +15,158 @@ defmodule BDS.Rendering.Filters do end end - def markdown(value, _post_id, _post_data_json_by_id, _canonical_post_paths, _canonical_media_paths, _language, _language_prefix, _context) do + def markdown(value, _post_id, _post_data_json_by_id, canonical_post_paths, canonical_media_paths, language, _language_prefix, context) do value |> to_string() + |> replace_built_in_macros(language, context) |> Earmark.as_html!() + |> rewrite_rendered_html_urls(canonical_post_paths || %{}, canonical_media_paths || %{}) end + + defp replace_built_in_macros(content, language, context) do + Regex.replace(~r/\[\[(\w+)(?:\s+([^\]]+))?\]\]/, content, fn full_match, macro_name, raw_params -> + params = parse_macro_params(raw_params) + + case String.downcase(macro_name) do + "youtube" -> + render_macro_template("macros/youtube", %{ + "id" => Map.get(params, "id", ""), + "title" => default_macro_title(Map.get(params, "title"), language, "render.video.youtubeTitle") + }, context) + + "vimeo" -> + render_macro_template("macros/vimeo", %{ + "id" => Map.get(params, "id", ""), + "title" => default_macro_title(Map.get(params, "title"), language, "render.video.vimeoTitle") + }, context) + + _other -> + full_match + end + end) + end + + defp default_macro_title(nil, language, translation_key), do: I18n.translate(language, translation_key) + defp default_macro_title("", language, translation_key), do: I18n.translate(language, translation_key) + defp default_macro_title(title, _language, _translation_key), do: title + + defp parse_macro_params(nil), do: %{} + defp parse_macro_params(""), do: %{} + + defp parse_macro_params(raw_params) do + Regex.scan(~r/(\w+)=(?:"([^"]*)"|'([^']*)'|([^\s\]]+))/, raw_params) + |> Enum.reduce(%{}, fn [_match, key, double_quoted, single_quoted, bare], acc -> + value = Enum.find([double_quoted, single_quoted, bare], &(&1 not in [nil, ""])) || "" + Map.put(acc, key, value) + end) + end + + defp render_macro_template(template_path, assigns, context) do + case Map.get(assigns, "id") do + "" -> "" + nil -> "" + _id -> + template_source = Liquex.FileSystem.read_template_file(context.file_system, template_path) + template_ast = Liquex.parse!(template_source) + isolated_context = Liquex.Context.new_isolated_subscope(context, assigns) + {result, _context} = Liquex.render!(template_ast, isolated_context) + IO.iodata_to_binary(result) + end + rescue + _error -> "" + end + + defp rewrite_rendered_html_urls(html, canonical_post_paths, canonical_media_paths) do + html + |> rewrite_attribute("href", &normalize_post_href(&1, canonical_post_paths, canonical_media_paths)) + |> rewrite_attribute("src", &normalize_media_src(&1, canonical_media_paths)) + end + + defp rewrite_attribute(html, attribute, rewriter) do + Regex.replace(~r/\b#{attribute}=(['"])(.*?)\1/i, html, fn _full_match, quote, value -> + rewritten = rewriter.(value) + ~s(#{attribute}=#{quote}#{rewritten}#{quote}) + end) + end + + defp normalize_post_href(raw_href, canonical_post_paths, canonical_media_paths) do + cond do + raw_href == "" -> raw_href + external_or_special_url?(raw_href) -> raw_href + true -> + {path_part, suffix} = split_path_suffix(raw_href) + + case path_part do + "/" <> _rest = value -> + case canonical_post_path(value, canonical_post_paths) do + nil -> normalize_media_src(raw_href, canonical_media_paths) + canonical -> canonical <> suffix + end + + _other -> raw_href + end + end + end + + defp canonical_post_path(path_part, canonical_post_paths) do + cond do + Regex.match?(~r|^/\d{4}/\d{2}/\d{2}/[a-z0-9-]+(?:\.html?)?$|i, path_part) -> + path_part |> String.replace(~r/\.html?$/i, "") + + match = Regex.run(~r|^/?post/([a-z0-9-]+(?:\.html?)?)$|i, path_part) -> + slug = match |> Enum.at(1) |> String.replace(~r/\.html?$/i, "") + Map.get(canonical_post_paths, slug) + + match = Regex.run(~r|^/?post/\d{4}/\d{1,2}/([a-z0-9-]+(?:\.html?)?)$|i, path_part) -> + slug = match |> Enum.at(1) |> String.replace(~r/\.html?$/i, "") + Map.get(canonical_post_paths, slug) + + match = Regex.run(~r|^/?posts/([a-z0-9-]+(?:\.html?)?)$|i, path_part) -> + slug = match |> Enum.at(1) |> String.replace(~r/\.html?$/i, "") + Map.get(canonical_post_paths, slug) + + match = Regex.run(~r|^/?posts/\d{4}/\d{1,2}/([a-z0-9-]+(?:\.html?)?)$|i, path_part) -> + slug = match |> Enum.at(1) |> String.replace(~r/\.html?$/i, "") + Map.get(canonical_post_paths, slug) + + true -> + nil + end + end + + defp normalize_media_src(raw_src, canonical_media_paths) do + cond do + raw_src == "" -> raw_src + external_or_special_url?(raw_src) -> raw_src + true -> + {path_part, suffix} = split_path_suffix(raw_src) + + case Regex.run(~r|^/?media/(\d{4})/(\d{2})/([^\s?#]+)$|i, path_part) do + [_, year, month, filename] -> + key = String.downcase(Path.join(["media", year, month, filename])) + Map.get(canonical_media_paths, key, ensure_leading_slash(path_part)) <> suffix + + _other -> + raw_src + end + end + end + + defp external_or_special_url?(value) do + normalized = String.trim(value) + + normalized == "" or String.starts_with?(normalized, ["#", "//"]) or + Regex.match?(~r/^[a-z][a-z0-9+.-]*:/i, normalized) + end + + defp split_path_suffix(value) do + case Regex.run(~r/^([^?#]*)([?#].*)?$/, String.trim(value)) do + [_, path_part, suffix] -> {path_part, suffix || ""} + [_, path_part] -> {path_part, ""} + _other -> {value, ""} + end + end + + defp ensure_leading_slash("/" <> _rest = path), do: path + defp ensure_leading_slash(path), do: "/" <> path end diff --git a/priv/data/projects/default/templates/not-found.liquid b/priv/data/projects/default/templates/not-found.liquid index da217cd..b89dfc6 100644 --- a/priv/data/projects/default/templates/not-found.liquid +++ b/priv/data/projects/default/templates/not-found.liquid @@ -1,5 +1,5 @@ --- -id: 1ba67928-a8e8-44d7-b5f8-70e654d6cfad +id: df4884e5-48e9-4e21-8013-468173fed7ab slug: not-found title: Not Found kind: not_found diff --git a/priv/data/projects/default/templates/post-list.liquid b/priv/data/projects/default/templates/post-list.liquid index 6f05408..cda105b 100644 --- a/priv/data/projects/default/templates/post-list.liquid +++ b/priv/data/projects/default/templates/post-list.liquid @@ -1,5 +1,5 @@ --- -id: 7d72d1a2-c8d6-4842-8327-35635b18c1fb +id: 10dde9aa-ba80-44d2-be97-f51c10ff88f9 slug: post-list title: Post List kind: list diff --git a/priv/data/projects/default/templates/single-post.liquid b/priv/data/projects/default/templates/single-post.liquid index c51bf35..421d73a 100644 --- a/priv/data/projects/default/templates/single-post.liquid +++ b/priv/data/projects/default/templates/single-post.liquid @@ -1,5 +1,5 @@ --- -id: 38f613a7-7b26-42b8-a086-4074bdf7032a +id: a175840b-170a-41db-b102-c5410344c8e6 slug: single-post title: Single Post kind: post diff --git a/test/bds/generation_test.exs b/test/bds/generation_test.exs index 7e80642..8891ac7 100644 --- a/test/bds/generation_test.exs +++ b/test/bds/generation_test.exs @@ -3,6 +3,7 @@ defmodule BDS.GenerationTest do import Ecto.Query + alias BDS.Media alias BDS.Metadata alias BDS.Posts alias BDS.Repo @@ -75,11 +76,13 @@ defmodule BDS.GenerationTest do assert result.sections == [:core] expected_paths = [ + "404.html", "index.html", "sitemap.xml", "feed.xml", "atom.xml", "calendar.json", + "de/404.html", "de/index.html", "de/feed.xml", "de/atom.xml" @@ -196,6 +199,69 @@ defmodule BDS.GenerationTest do assert post_html =~ "Language" end + test "generation expands starter-template markdown macros, rewrites canonical post links, media links, and emits not-found page", %{project: project, temp_dir: temp_dir} do + assert {:ok, _metadata} = + Metadata.update_project_metadata(project.id, %{ + public_url: "https://example.com/blog", + main_language: "en", + blog_languages: ["en"] + }) + + source_path = Path.join(temp_dir, "sample.txt") + File.write!(source_path, "media body") + + assert {:ok, media} = + Media.import_media(%{ + project_id: project.id, + source_path: source_path, + title: "Sample" + }) + + assert {:ok, linked_post} = + Posts.create_post(%{ + project_id: project.id, + title: "Linked Post", + content: "Linked body", + language: "en" + }) + + assert {:ok, published_linked_post} = Posts.publish_post(linked_post.id) + + media_source_reference = "/" <> Path.join(Path.dirname(media.file_path), media.original_name) + canonical_post_href = "/" <> String.trim_trailing(BDS.Generation.post_output_path(published_linked_post), "index.html") + + assert {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Rendered Post", + content: + [ + "[Read linked post](/posts/linked-post)", + "", + "![Asset](#{media_source_reference})", + "", + "[[youtube id=dQw4w9WgXcQ]]" + ] + |> Enum.join("\n"), + language: "en" + }) + + assert {:ok, published_post} = Posts.publish_post(post.id) + + assert {:ok, result} = BDS.Generation.generate_site(project.id, [:core, :single]) + + assert "404.html" in Enum.map(result.generated_files, & &1.relative_path) + + post_html = File.read!(Path.join([temp_dir, "html", BDS.Generation.post_output_path(published_post)])) + assert post_html =~ ~s(src="https://www.youtube.com/embed/dQw4w9WgXcQ?rel=0") + assert post_html =~ ~s(href="#{canonical_post_href}") + assert post_html =~ ~s(src="/#{media.file_path}") + + not_found_html = File.read!(Path.join([temp_dir, "html", "404.html"])) + assert not_found_html =~ ~s(data-template="not-found") + assert not_found_html =~ "Back to preview home" + end + test "single generation writes canonical post pages and language-prefixed translation pages", %{project: project, temp_dir: temp_dir} do assert {:ok, _metadata} = Metadata.update_project_metadata(project.id, %{ diff --git a/test/bds/preview_test.exs b/test/bds/preview_test.exs index c275623..89208f7 100644 --- a/test/bds/preview_test.exs +++ b/test/bds/preview_test.exs @@ -2,6 +2,7 @@ defmodule BDS.PreviewTest do use ExUnit.Case, async: false alias BDS.Generation + alias BDS.Media alias BDS.Metadata alias BDS.Posts @@ -125,6 +126,77 @@ defmodule BDS.PreviewTest do assert :ok = BDS.Preview.stop_preview(project.id) end + test "preview renders not-found template for missing routes and rewrites markdown macros and canonical URLs", %{project: project, temp_dir: temp_dir} do + :inets.start() + + assert {:ok, _metadata} = + Metadata.update_project_metadata(project.id, %{ + public_url: "https://example.com/blog", + main_language: "en", + blog_languages: ["en"] + }) + + source_path = Path.join(temp_dir, "sample.txt") + File.write!(source_path, "media body") + + assert {:ok, media} = + Media.import_media(%{ + project_id: project.id, + source_path: source_path, + title: "Sample" + }) + + assert {:ok, linked_post} = + Posts.create_post(%{ + project_id: project.id, + title: "Linked Post", + content: "Linked body", + language: "en" + }) + + assert {:ok, published_linked_post} = Posts.publish_post(linked_post.id) + + media_source_reference = "/" <> Path.join(Path.dirname(media.file_path), media.original_name) + canonical_post_href = "/" <> String.trim_trailing(BDS.Generation.post_output_path(published_linked_post), "index.html") + + assert {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Draft Post", + content: + [ + "[Read linked post](/posts/linked-post)", + "", + "![Asset](#{media_source_reference})", + "", + "[[youtube id=dQw4w9WgXcQ]]" + ] + |> Enum.join("\n"), + language: "en" + }) + + assert {:ok, server} = BDS.Preview.start_preview(project.id) + + assert {:ok, %{body: draft_html, content_type: "text/html"}} = + BDS.Preview.preview_draft(project.id, "/draft/draft-post", post.id) + + assert draft_html =~ ~s(src="https://www.youtube.com/embed/dQw4w9WgXcQ?rel=0") + assert draft_html =~ ~s(href="#{canonical_post_href}") + assert draft_html =~ ~s(src="/#{media.file_path}") + + assert {:ok, %{body: missing_body, content_type: "text/html"}} = + BDS.Preview.request(project.id, "/missing-page") + + assert missing_body =~ ~s(data-template="not-found") + + assert {:ok, {{_version, 404, _reason}, _headers, body}} = + :httpc.request(:get, {to_charlist("http://#{server.host}:#{server.port}/missing-page"), []}, [], body_format: :binary) + + assert body =~ ~s(data-template="not-found") + + assert :ok = BDS.Preview.stop_preview(project.id) + end + test "start_preview serves generated and draft routes over real HTTP on localhost", %{project: project} do :inets.start()