From 8e60fe31a53c0b0de3d92b308ef21477ecf3aa0a Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 5 Jul 2026 14:11:12 +0200 Subject: [PATCH] fix: issue #7 with broken macro renderings for photo archive and tag cloud --- lib/bds/desktop/shell_live/gallery_import.ex | 40 +++- lib/bds/maintenance/diff_reports.ex | 8 +- lib/bds/media/linking.ex | 47 +++++ lib/bds/media/sidecars.ex | 6 + lib/bds/rendering/filters.ex | 95 +++++++--- lib/bds/rendering/list_archive.ex | 2 +- lib/bds/rendering/metadata.ex | 2 +- lib/bds/rendering/render_context.ex | 10 +- lib/bds/rendering/template_selection.ex | 13 +- specs/rendering.allium | 16 +- .../shell_live/gallery_import_test.exs | 57 ++++++ test/bds/media/link_restore_test.exs | 72 +++++++ test/bds/rendering/macro_rendering_test.exs | 176 ++++++++++++++++++ 13 files changed, 503 insertions(+), 41 deletions(-) create mode 100644 test/bds/desktop/shell_live/gallery_import_test.exs create mode 100644 test/bds/media/link_restore_test.exs create mode 100644 test/bds/rendering/macro_rendering_test.exs diff --git a/lib/bds/desktop/shell_live/gallery_import.ex b/lib/bds/desktop/shell_live/gallery_import.ex index 07fb26c..b520f52 100644 --- a/lib/bds/desktop/shell_live/gallery_import.ex +++ b/lib/bds/desktop/shell_live/gallery_import.ex @@ -167,18 +167,14 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do translate_targets, parent ) do + # Linking is mandatory and MUST happen regardless of AI availability + # (airplane mode / no local model), mirroring the drag-drop chain and the + # old app: import -> link -> (optional) AI enrichment. Gating the link + # behind AI is what left galleries empty. with {:ok, media} <- Media.import_media(%{project_id: project_id, source_path: path}), true <- String.starts_with?(media.mime_type || "", "image/"), - {:ok, result} <- AI.analyze_image(media.id, language: language), - {:ok, _updated} <- - Media.update_media(media.id, %{ - title: result.title, - alt: result.alt, - caption: result.caption - }), {:ok, _link} <- Media.link_media_to_post(media.id, post_id) do - translate_media_translations(media.id, translate_targets) - title = result.title || media.original_name + title = enrich_media(media, language, translate_targets) send(parent, {:add_image_processed, title}) else false -> @@ -190,6 +186,32 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do end end + # Best-effort AI enrichment: analysis + metadata update + translations. Any + # failure (offline, no model) is logged and never unlinks the image. Returns + # the display title used for progress reporting. + defp enrich_media(media, language, translate_targets) do + case AI.analyze_image(media.id, language: language) do + {:ok, result} -> + case Media.update_media(media.id, %{ + title: result.title, + alt: result.alt, + caption: result.caption + }) do + {:ok, _updated} -> + translate_media_translations(media.id, translate_targets) + result.title || media.original_name + + {:error, reason} -> + Logger.warning("Media metadata update failed for #{media.id}: #{inspect(reason)}") + media.original_name + end + + {:error, reason} -> + Logger.warning("AI image analysis skipped for #{media.id}: #{inspect(reason)}") + media.original_name + end + end + defp translate_media_translations(_media_id, []), do: :ok defp translate_media_translations(media_id, [target | rest]) do diff --git a/lib/bds/maintenance/diff_reports.ex b/lib/bds/maintenance/diff_reports.ex index 33f590a..c686c9e 100644 --- a/lib/bds/maintenance/diff_reports.ex +++ b/lib/bds/maintenance/diff_reports.ex @@ -20,6 +20,7 @@ defmodule BDS.Maintenance.DiffReports do ] alias BDS.DocumentFields + alias BDS.Media.Linking alias BDS.Media.Media alias BDS.Media.Translation, as: MediaTranslation alias BDS.Metadata @@ -163,7 +164,12 @@ defmodule BDS.Maintenance.DiffReports do diff_field("language", media.language, Map.get(fields, "language")), diff_field("created_at", media.created_at, DocumentFields.get(fields, "createdAt")), diff_field("updated_at", media.updated_at, DocumentFields.get(fields, "updatedAt")), - diff_field("tags", media.tags, Map.get(fields, "tags", [])) + diff_field("tags", media.tags, Map.get(fields, "tags", [])), + diff_field( + "linked_post_ids", + Linking.linked_post_ids(media.id), + DocumentFields.get(fields, "linkedPostIds", []) + ) ] |> Enum.reject(&is_nil/1) diff --git a/lib/bds/media/linking.ex b/lib/bds/media/linking.ex index 8269640..f7170d1 100644 --- a/lib/bds/media/linking.ex +++ b/lib/bds/media/linking.ex @@ -114,6 +114,53 @@ defmodule BDS.Media.Linking do ) end + @doc """ + Restore `PostMedia` links for `media_id` from the list of `post_ids` persisted + in the media sidecar (`linkedPostIds`). + + Used by rebuild-from-filesystem so gallery associations survive a rebuild (and + so projects whose link data lives only in sidecars still populate galleries). + Idempotent: skips links that already exist and post ids that are not present + in the database. Does not re-write sidecars. + """ + @spec restore_post_links(String.t(), String.t(), [String.t()] | term()) :: :ok + def restore_post_links(project_id, media_id, post_ids) + when is_binary(project_id) and is_binary(media_id) and is_list(post_ids) do + post_ids + |> Enum.filter(&is_binary/1) + |> Enum.uniq() + |> Enum.each(fn post_id -> ensure_link(project_id, media_id, post_id) end) + end + + def restore_post_links(_project_id, _media_id, _post_ids), do: :ok + + defp ensure_link(project_id, media_id, post_id) do + already_linked? = + Repo.exists?( + from pm in PostMedia, + where: pm.media_id == ^media_id and pm.post_id == ^post_id + ) + + post_exists? = Repo.exists?(from p in BDS.Posts.Post, where: p.id == ^post_id) + + if not already_linked? and post_exists? do + %PostMedia{} + |> PostMedia.changeset(%{ + id: Ecto.UUID.generate(), + project_id: project_id, + post_id: post_id, + media_id: media_id, + sort_order: next_sort_order(media_id), + created_at: Persistence.now_ms() + }) + |> Repo.insert!() + + :ok + else + :ok + end + end + defp log_sidecar_error(:ok, _media_id), do: :ok defp log_sidecar_error({:error, reason}, media_id) do diff --git a/lib/bds/media/sidecars.ex b/lib/bds/media/sidecars.ex index f6e749b..209cd31 100644 --- a/lib/bds/media/sidecars.ex +++ b/lib/bds/media/sidecars.ex @@ -132,6 +132,12 @@ defmodule BDS.Media.Sidecars do |> Media.changeset(attrs) |> Repo.insert_or_update!() + Linking.restore_post_links( + project.id, + media.id, + DocumentFields.get(sidecar.fields, "linkedPostIds", []) + ) + if Keyword.get(opts, :sync_search, true) do :ok = Search.sync_media(media) end diff --git a/lib/bds/rendering/filters.ex b/lib/bds/rendering/filters.ex index e359d8e..c6427ec 100644 --- a/lib/bds/rendering/filters.ex +++ b/lib/bds/rendering/filters.ex @@ -335,27 +335,40 @@ defmodule BDS.Rendering.Filters do language = macro_language(context) project_id = project_id_from_context(context) - {words_json, width, height} = - if project_id do - build_tag_cloud_data(project_id) - else - {nil, 800, 400} - end + words_json = if project_id, do: build_tag_cloud_data(project_id), else: nil render_macro_template( "macros/tag-cloud", %{ - "orientation" => Map.get(params, "orientation", "horizontal"), + "orientation" => normalize_tag_cloud_orientation(Map.get(params, "orientation")), "words_json" => words_json, - "width" => Map.get(params, "width", width), - "height" => Map.get(params, "height", height), - "aria_label" => "Tag cloud", + "width" => tag_cloud_dimension(Map.get(params, "width"), 320, 1600, 900), + "height" => tag_cloud_dimension(Map.get(params, "height"), 180, 900, 420), + "aria_label" => BDS.Gettext.lgettext(language, "render", "Tag cloud"), "empty_label" => BDS.Gettext.lgettext(language, "render", "No tags") }, context ) end + # Mirror the old app's normalizeTagCloudOrientation so `orientation=diagonal` + # (and the other aliases) map to the runtime's `mixed-diagonal`/`mixed-hv` + # rotation modes instead of silently falling back to horizontal. + defp normalize_tag_cloud_orientation(value) do + case value |> to_string() |> String.trim() |> String.downcase() do + v when v in ["mixed_hv", "mixed-hv", "hv", "horizontal_vertical"] -> "mixed-hv" + v when v in ["mixed_diagonal", "mixed-diagonal", "diagonal", "diag"] -> "mixed-diagonal" + _ -> "horizontal" + end + end + + defp tag_cloud_dimension(value, min, max, default) do + case value |> to_string() |> Integer.parse() do + {n, _rest} when n >= min and n <= max -> n + _other -> default + end + end + defp render_video_macro(kind, params, language, translation, context) do render_macro_template( "macros/#{kind}", @@ -382,14 +395,16 @@ defmodule BDS.Rendering.Filters do end defp gallery_items(post_id) do + group_name = "gallery-#{post_id}" + post_id |> linked_media_images() |> Enum.map(fn media -> %{ "media_path" => "/#{media.file_path}", - "title" => media.title || media.original_name, + "title" => media.caption || media.title || media.original_name, "alt" => media.alt || media.title || media.original_name, - "group_name" => post_id + "group_name" => group_name } end) end @@ -403,7 +418,7 @@ defmodule BDS.Rendering.Filters do on: pm.media_id == m.id, where: pm.post_id == ^post_id, where: like(m.mime_type, "image/%"), - order_by: [asc: pm.sort_order, asc: pm.media_id], + order_by: [desc: m.created_at, asc: m.id], select: m ) end @@ -505,6 +520,7 @@ defmodule BDS.Rendering.Filters do SELECT trim(je.value) AS tag, COUNT(*) AS cnt FROM posts, json_each(posts.tags) je WHERE posts.project_id = ?1 + AND posts.status = 'published' AND trim(je.value) != '' GROUP BY tag ORDER BY cnt DESC, lower(tag) ASC @@ -518,29 +534,66 @@ defmodule BDS.Rendering.Filters do end) if tag_entries == [] do - {nil, 0, 0} + nil else - max_count = Enum.map(tag_entries, & &1.count) |> Enum.max() - min_count = Enum.map(tag_entries, & &1.count) |> Enum.min() - range = max(max_count - min_count, 1) + counts = Enum.map(tag_entries, & &1.count) + max_count = Enum.max(counts) + min_count = Enum.min(counts) words = Enum.map(tag_entries, fn %{tag: tag, count: count, color: color} -> - size = 12.0 + (count - min_count) / range * 28.0 - %{"text" => tag, "size" => size, "color" => color || "var(--accent-color)"} + base = %{ + "text" => tag, + "size" => tag_cloud_font_size(count, min_count, max_count), + "count" => count, + "url" => "/tag/#{URI.encode(to_string(tag), &URI.char_unreserved?/1)}/" + } + + if is_binary(color) and color != "", do: Map.put(base, "color", color), else: base end) - {Jason.encode!(words), 800, 400} + escape_html(Jason.encode!(words)) end end + # Match the old app's tag-cloud sizing (14px..56px, integer point sizes) so the + # d3 word-cloud runtime lays tags out identically. + defp tag_cloud_font_size(_count, min_count, max_count) when max_count == min_count, + do: round((14 + 56) / 2) + + defp tag_cloud_font_size(count, min_count, max_count), + do: round(14 + (count - min_count) / (max_count - min_count) * (56 - 14)) + + # The tag-cloud JSON is embedded in a double-quoted HTML data attribute, so it + # must be HTML-escaped (matching the old app) or the first `"` closes the + # attribute and the client-side JSON.parse silently fails. + defp escape_html(value) do + value + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + |> String.replace("\"", """) + |> String.replace("'", "'") + end + # ── Helpers ──────────────────────────────────────────────────────────────── defp macro_language(context), do: Access.get(context, "language") || "en" defp project_id_from_context(context) do + context_private_project_id(context) || + context_post_project_id(context) || + project_id_from_post(context) + end + + defp context_private_project_id(%Liquex.Context{private: private}) when is_map(private), + do: Map.get(private, :project_id) + + defp context_private_project_id(_context), do: nil + + defp context_post_project_id(context) do post = Access.get(context, "post") || %{} - post["project_id"] || Access.get(post, :project_id) || project_id_from_post(context) + post["project_id"] || Access.get(post, :project_id) end defp project_id_from_post(context) do diff --git a/lib/bds/rendering/list_archive.ex b/lib/bds/rendering/list_archive.ex index 8434262..2c52868 100644 --- a/lib/bds/rendering/list_archive.ex +++ b/lib/bds/rendering/list_archive.ex @@ -156,7 +156,7 @@ defmodule BDS.Rendering.ListArchive do id: MapUtils.attr(post, :id), slug: MapUtils.attr(post, :slug), title: MapUtils.attr(post, :title), - content: PostRendering.cached_post_content(ctx, raw_content, language), + content: PostRendering.cached_post_content(ctx, raw_content, language, post_id), raw_content: raw_content, excerpt: MapUtils.attr(post, :excerpt, record_value(post_record, :excerpt)), author: MapUtils.attr(post, :author, record_value(post_record, :author)), diff --git a/lib/bds/rendering/metadata.ex b/lib/bds/rendering/metadata.ex index 9032351..6db939b 100644 --- a/lib/bds/rendering/metadata.ex +++ b/lib/bds/rendering/metadata.ex @@ -64,7 +64,7 @@ defmodule BDS.Rendering.Metadata do defp category_settings_for(_category_settings, _category), do: nil defp category_title(settings) when is_map(settings), - do: Map.get(settings, "title") || Map.get(settings, :title) + do: BDS.MapUtils.attr(settings, :title) defp category_title(_settings), do: nil diff --git a/lib/bds/rendering/render_context.ex b/lib/bds/rendering/render_context.ex index ad8d168..09fb7b5 100644 --- a/lib/bds/rendering/render_context.ex +++ b/lib/bds/rendering/render_context.ex @@ -89,7 +89,8 @@ defmodule BDS.Rendering.RenderContext do static_environment: %{}, filter_module: Filters, file_system: file_system - ), + ) + |> put_context_project_id(project_id), record_by_id: Map.merge(Map.new(translations, &{&1.id, &1}), post_by_id), incoming_links_by_post: incoming_links_by_post, outgoing_links_by_post: outgoing_links_by_post, @@ -144,6 +145,13 @@ defmodule BDS.Rendering.RenderContext do ## --- internals ----------------------------------------------------------- + # Stash the project id in the context's private map (not exposed to templates) + # so built-in macros (photo archive, tag cloud, gallery) can resolve their + # project-scoped data even when rendered outside a full page context. + defp put_context_project_id(%Liquex.Context{private: private} = context, project_id) do + %{context | private: Map.put(private, :project_id, project_id)} + end + defp build_link_context_maps(project_id, post_by_id, main_language) do links = Repo.all( diff --git a/lib/bds/rendering/template_selection.ex b/lib/bds/rendering/template_selection.ex index 056db04..c3f1438 100644 --- a/lib/bds/rendering/template_selection.ex +++ b/lib/bds/rendering/template_selection.ex @@ -223,10 +223,13 @@ defmodule BDS.Rendering.TemplateSelection do def template_render_context(project_id) do project = Projects.get_project!(project_id) - Liquex.Context.new(%{}, - static_environment: %{}, - filter_module: Filters, - file_system: FileSystem.new(StarterTemplates.template_roots(project)) - ) + context = + Liquex.Context.new(%{}, + static_environment: %{}, + filter_module: Filters, + file_system: FileSystem.new(StarterTemplates.template_roots(project)) + ) + + %{context | private: Map.put(context.private, :project_id, project_id)} end end diff --git a/specs/rendering.allium b/specs/rendering.allium index 69c7f29..8754a22 100644 --- a/specs/rendering.allium +++ b/specs/rendering.allium @@ -71,19 +71,31 @@ surface BuiltinMacroSurface { rule ExpandBuiltinMacros { when: MacroEncountered(macro, language, post_id) + -- Project-scoped macros (photo_archive, tag_cloud) resolve the owning + -- project id from the render context (carried in the Liquid context's + -- private data), so they populate correctly even when content is + -- pre-rendered outside a full page context. -- Unknown macro names are left verbatim in the source. if macro.name = "youtube" or macro.name = "vimeo": -- Embeds video by `id`; localized default title when title param absent. ensures: MacroTemplateRendered(format("macros/{name}", name: macro.name)) if macro.name = "gallery": - -- Image gallery from the post's linked image media (ordered by sort). + -- Image gallery from the post's linked image media (PostMedia links, + -- restored from media sidecars on rebuild). Linking on import is + -- independent of AI enrichment. Ordered newest-first (created_at desc); + -- lightbox group is "gallery-{post_id}"; title is caption|title|name. -- `columns` clamped to 1..6 (default 3). Empty when no post_id/images. ensures: MacroTemplateRendered("macros/gallery") if macro.name = "photo_archive": -- Month-grouped image archive for the project (optional year/month filter). ensures: MacroTemplateRendered("macros/photo-archive") if macro.name = "tag_cloud": - -- Weighted tag cloud from post tag counts; per-tag colours from Tag rows. + -- Weighted tag cloud from PUBLISHED post tag counts; per-tag colours + -- from Tag rows. Words carry {text, size, count, url}; the JSON is + -- HTML-escaped into the data-tag-cloud-words attribute so the client + -- runtime can JSON.parse it. `orientation` aliases normalize to + -- horizontal | mixed-hv | mixed-diagonal; width (320..1600, def 900) + -- and height (180..900, def 420) clamp to defaults when out of range. ensures: MacroTemplateRendered("macros/tag-cloud") } diff --git a/test/bds/desktop/shell_live/gallery_import_test.exs b/test/bds/desktop/shell_live/gallery_import_test.exs new file mode 100644 index 0000000..49307ca --- /dev/null +++ b/test/bds/desktop/shell_live/gallery_import_test.exs @@ -0,0 +1,57 @@ +defmodule BDS.Desktop.ShellLive.GalleryImportTest do + @moduledoc """ + Guards the regression where gallery images were imported but never linked to + the post when AI analysis was unavailable (offline / no local model), leaving + galleries empty. Linking must happen regardless of AI, matching the old app. + """ + use ExUnit.Case, async: false + + import Ecto.Query + + alias BDS.Desktop.ShellLive.GalleryImport + alias BDS.Posts.PostMedia + alias BDS.Repo + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo) + Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()}) + temp_dir = Path.join(System.tmp_dir!(), "bds-galimport-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) + on_exit(fn -> File.rm_rf(temp_dir) end) + + {:ok, project} = BDS.Projects.create_project(%{name: "GalImport", data_path: temp_dir}) + {:ok, _} = BDS.Metadata.update_project_metadata(project.id, %{main_language: "en"}) + + {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Gallery Post", + content: "[[gallery]]", + language: "en" + }) + + %{project: project, post: post, temp_dir: temp_dir} + end + + test "imports link every image to the post even without AI analysis", %{ + project: project, + post: post, + temp_dir: temp_dir + } do + paths = + for i <- 1..3 do + path = Path.join(temp_dir, "img_#{i}.jpg") + File.write!(path, "fake jpeg #{i}") + path + end + + GalleryImport.start(paths, project.id, post.id, "en", 2, self()) + + assert_received {:add_images_complete, 3} + + link_count = + Repo.aggregate(from(pm in PostMedia, where: pm.post_id == ^post.id), :count) + + assert link_count == 3 + end +end diff --git a/test/bds/media/link_restore_test.exs b/test/bds/media/link_restore_test.exs new file mode 100644 index 0000000..6b4e84b --- /dev/null +++ b/test/bds/media/link_restore_test.exs @@ -0,0 +1,72 @@ +defmodule BDS.Media.LinkRestoreTest do + @moduledoc """ + Guards the regression where gallery associations (PostMedia links) were lost + on rebuild-from-filesystem: the media sidecar persists `linkedPostIds`, but + the rebuild never restored them, so galleries rendered empty afterwards. + """ + use ExUnit.Case, async: false + + import Ecto.Query + + alias BDS.Media + alias BDS.Posts.PostMedia + alias BDS.Repo + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo) + Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()}) + temp_dir = Path.join(System.tmp_dir!(), "bds-linkrestore-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) + on_exit(fn -> File.rm_rf(temp_dir) end) + + {:ok, project} = BDS.Projects.create_project(%{name: "LinkRestore", data_path: temp_dir}) + {:ok, _} = BDS.Metadata.update_project_metadata(project.id, %{main_language: "en"}) + + {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Gallery Post", + content: "[[gallery]]", + language: "en" + }) + + source = Path.join(temp_dir, "linked.jpg") + File.write!(source, "fake jpeg") + {:ok, media} = Media.import_media(%{project_id: project.id, source_path: source}) + {:ok, _} = Media.link_media_to_post(media.id, post.id) + + %{project: project, post: post, media: media} + end + + defp link_count(post_id, media_id) do + Repo.aggregate( + from(pm in PostMedia, where: pm.post_id == ^post_id and pm.media_id == ^media_id), + :count + ) + end + + test "media rebuild-from-filesystem restores PostMedia links from the sidecar", %{ + project: project, + post: post, + media: media + } do + assert link_count(post.id, media.id) == 1 + + # Simulate the DB losing gallery links (e.g. a prior rebuild). + Repo.delete_all(from pm in PostMedia, where: pm.media_id == ^media.id) + assert link_count(post.id, media.id) == 0 + + assert {:ok, _} = BDS.Media.rebuild_media_from_files(project.id) + + assert link_count(post.id, media.id) == 1 + end + + test "restore is idempotent and does not duplicate existing links", %{ + project: project, + post: post, + media: media + } do + assert {:ok, _} = BDS.Media.rebuild_media_from_files(project.id) + assert link_count(post.id, media.id) == 1 + end +end diff --git a/test/bds/rendering/macro_rendering_test.exs b/test/bds/rendering/macro_rendering_test.exs new file mode 100644 index 0000000..599058f --- /dev/null +++ b/test/bds/rendering/macro_rendering_test.exs @@ -0,0 +1,176 @@ +defmodule BDS.Rendering.MacroRenderingTest do + @moduledoc """ + End-to-end coverage for built-in macros through the *real* render pipeline + (`RenderContext.build/1`). These guard the regression where photo archive and + tag cloud rendered empty because the project id was never threaded into the + macro context, and where galleries rendered empty on list pages because the + post id was dropped. + """ + use ExUnit.Case, async: false + + alias BDS.Media + alias BDS.Rendering + alias BDS.Rendering.PostRendering + alias BDS.Rendering.RenderContext + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()}) + + temp_dir = Path.join(System.tmp_dir!(), "bds-macros-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) + on_exit(fn -> File.rm_rf(temp_dir) end) + + {:ok, project} = BDS.Projects.create_project(%{name: "MacroBlog", data_path: temp_dir}) + {:ok, _} = BDS.Metadata.update_project_metadata(project.id, %{main_language: "en"}) + + {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Macro Post", + content: "seed", + language: "en", + tags: ["elixir", "phoenix"] + }) + + source = Path.join(temp_dir, "macro_image.jpg") + File.write!(source, "fake jpeg") + {:ok, media} = Media.import_media(%{project_id: project.id, source_path: source}) + {:ok, _} = Media.update_media(media.id, %{title: "Macro Image", alt: "Macro Alt"}) + {:ok, _} = Media.link_media_to_post(media.id, post.id) + + {:ok, _} = BDS.Posts.publish_post(post.id) + + %{project: project, post: post} + end + + test "photo archive and tag cloud resolve project data on single-post render", %{ + project: project, + post: post + } do + ctx = RenderContext.build(project.id) + + rendered = + PostRendering.cached_post_content( + ctx, + "[[photo_archive]]\n\n[[tag_cloud]]", + "en", + post.id + ) + + assert rendered =~ "photo-archive-item" + refute rendered =~ "photo-archive-empty" + + assert rendered =~ "data-tag-cloud-words" + refute rendered =~ "tag-cloud-empty" + + # The JSON is embedded in a double-quoted HTML attribute, so it must be + # HTML-escaped or the client-side JSON.parse silently fails (empty cloud). + assert rendered =~ """ + + words = extract_tag_cloud_words(rendered) + texts = Enum.map(words, &Map.get(&1, "text")) + assert "elixir" in texts + assert "phoenix" in texts + + # Matches the old app's word shape so the d3 runtime can size/link tags. + for word <- words do + assert is_integer(word["count"]) + assert word["size"] >= 14 and word["size"] <= 56 + assert String.starts_with?(word["url"], "/tag/") + end + end + + defp extract_tag_cloud_words(html) do + [_, raw] = Regex.run(~r/data-tag-cloud-words="([^"]*)"/, html) + + raw + |> String.replace(""", "\"") + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + |> String.replace("'", "'") + |> Jason.decode!() + end + + test "tag cloud orientation aliases normalize like the old app", %{ + project: project, + post: post + } do + ctx = RenderContext.build(project.id) + + render = fn source -> PostRendering.cached_post_content(ctx, source, "en", post.id) end + + assert render.("[[tag_cloud orientation=diagonal]]") =~ ~s(data-orientation="mixed-diagonal") + assert render.("[[tag_cloud orientation=diag]]") =~ ~s(data-orientation="mixed-diagonal") + + assert render.("[[tag_cloud orientation=mixed-diagonal]]") =~ + ~s(data-orientation="mixed-diagonal") + + assert render.("[[tag_cloud orientation=hv]]") =~ ~s(data-orientation="mixed-hv") + assert render.("[[tag_cloud orientation=mixed-hv]]") =~ ~s(data-orientation="mixed-hv") + assert render.("[[tag_cloud]]") =~ ~s(data-orientation="horizontal") + assert render.("[[tag_cloud orientation=bogus]]") =~ ~s(data-orientation="horizontal") + end + + test "tag cloud clamps out-of-range width/height to defaults", %{project: project, post: post} do + ctx = RenderContext.build(project.id) + + rendered = + PostRendering.cached_post_content(ctx, "[[tag_cloud width=99999 height=1]]", "en", post.id) + + assert rendered =~ ~s(data-width="900") + assert rendered =~ ~s(data-height="420") + end + + test "gallery resolves linked media on single-post render", %{project: project, post: post} do + ctx = RenderContext.build(project.id) + + rendered = PostRendering.cached_post_content(ctx, "[[gallery]]", "en", post.id) + + assert rendered =~ "gallery-item" + assert rendered =~ ~s(src="/media/) + assert rendered =~ "Macro Image" + refute rendered =~ "gallery-empty" + end + + test "gallery resolves linked media on list-page render", %{project: project, post: post} do + posts = [ + %{ + id: post.id, + slug: post.slug, + title: post.title, + content: "[[gallery]]", + language: "en", + created_at: post.created_at, + updated_at: post.updated_at, + published_at: post.published_at, + tags: post.tags, + categories: post.categories, + href: "/2024/01/01/macro-post/" + } + ] + + assert {:ok, rendered} = + Rendering.render_list_page(project.id, %{ + language: "en", + page_title: "Archive", + posts: posts, + archive_context: %{kind: "core"}, + pagination: %{ + current_page: 1, + total_pages: 1, + total_items: 1, + items_per_page: 10, + has_prev_page: false, + prev_page_href: nil, + has_next_page: false, + next_page_href: nil + } + }) + + assert rendered =~ "gallery-item" + assert rendered =~ ~s(src="/media/) + refute rendered =~ "gallery-empty" + end +end