fix: issue #7 with broken macro renderings for photo archive and tag cloud

This commit is contained in:
2026-07-05 14:11:12 +02:00
parent 28398b17f4
commit 8e60fe31a5
13 changed files with 503 additions and 41 deletions

View File

@@ -167,18 +167,14 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
translate_targets, translate_targets,
parent parent
) do ) 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}), with {:ok, media} <- Media.import_media(%{project_id: project_id, source_path: path}),
true <- String.starts_with?(media.mime_type || "", "image/"), 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 {:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
translate_media_translations(media.id, translate_targets) title = enrich_media(media, language, translate_targets)
title = result.title || media.original_name
send(parent, {:add_image_processed, title}) send(parent, {:add_image_processed, title})
else else
false -> false ->
@@ -190,6 +186,32 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
end end
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, []), do: :ok
defp translate_media_translations(media_id, [target | rest]) do defp translate_media_translations(media_id, [target | rest]) do

View File

@@ -20,6 +20,7 @@ defmodule BDS.Maintenance.DiffReports do
] ]
alias BDS.DocumentFields alias BDS.DocumentFields
alias BDS.Media.Linking
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.Media.Translation, as: MediaTranslation alias BDS.Media.Translation, as: MediaTranslation
alias BDS.Metadata alias BDS.Metadata
@@ -163,7 +164,12 @@ defmodule BDS.Maintenance.DiffReports do
diff_field("language", media.language, Map.get(fields, "language")), diff_field("language", media.language, Map.get(fields, "language")),
diff_field("created_at", media.created_at, DocumentFields.get(fields, "createdAt")), diff_field("created_at", media.created_at, DocumentFields.get(fields, "createdAt")),
diff_field("updated_at", media.updated_at, DocumentFields.get(fields, "updatedAt")), 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) |> Enum.reject(&is_nil/1)

View File

@@ -114,6 +114,53 @@ defmodule BDS.Media.Linking do
) )
end 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(:ok, _media_id), do: :ok
defp log_sidecar_error({:error, reason}, media_id) do defp log_sidecar_error({:error, reason}, media_id) do

View File

@@ -132,6 +132,12 @@ defmodule BDS.Media.Sidecars do
|> Media.changeset(attrs) |> Media.changeset(attrs)
|> Repo.insert_or_update!() |> 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 if Keyword.get(opts, :sync_search, true) do
:ok = Search.sync_media(media) :ok = Search.sync_media(media)
end end

View File

@@ -335,27 +335,40 @@ defmodule BDS.Rendering.Filters do
language = macro_language(context) language = macro_language(context)
project_id = project_id_from_context(context) project_id = project_id_from_context(context)
{words_json, width, height} = words_json = if project_id, do: build_tag_cloud_data(project_id), else: nil
if project_id do
build_tag_cloud_data(project_id)
else
{nil, 800, 400}
end
render_macro_template( render_macro_template(
"macros/tag-cloud", "macros/tag-cloud",
%{ %{
"orientation" => Map.get(params, "orientation", "horizontal"), "orientation" => normalize_tag_cloud_orientation(Map.get(params, "orientation")),
"words_json" => words_json, "words_json" => words_json,
"width" => Map.get(params, "width", width), "width" => tag_cloud_dimension(Map.get(params, "width"), 320, 1600, 900),
"height" => Map.get(params, "height", height), "height" => tag_cloud_dimension(Map.get(params, "height"), 180, 900, 420),
"aria_label" => "Tag cloud", "aria_label" => BDS.Gettext.lgettext(language, "render", "Tag cloud"),
"empty_label" => BDS.Gettext.lgettext(language, "render", "No tags") "empty_label" => BDS.Gettext.lgettext(language, "render", "No tags")
}, },
context context
) )
end 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 defp render_video_macro(kind, params, language, translation, context) do
render_macro_template( render_macro_template(
"macros/#{kind}", "macros/#{kind}",
@@ -382,14 +395,16 @@ defmodule BDS.Rendering.Filters do
end end
defp gallery_items(post_id) do defp gallery_items(post_id) do
group_name = "gallery-#{post_id}"
post_id post_id
|> linked_media_images() |> linked_media_images()
|> Enum.map(fn media -> |> Enum.map(fn media ->
%{ %{
"media_path" => "/#{media.file_path}", "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, "alt" => media.alt || media.title || media.original_name,
"group_name" => post_id "group_name" => group_name
} }
end) end)
end end
@@ -403,7 +418,7 @@ defmodule BDS.Rendering.Filters do
on: pm.media_id == m.id, on: pm.media_id == m.id,
where: pm.post_id == ^post_id, where: pm.post_id == ^post_id,
where: like(m.mime_type, "image/%"), 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 select: m
) )
end end
@@ -505,6 +520,7 @@ defmodule BDS.Rendering.Filters do
SELECT trim(je.value) AS tag, COUNT(*) AS cnt SELECT trim(je.value) AS tag, COUNT(*) AS cnt
FROM posts, json_each(posts.tags) je FROM posts, json_each(posts.tags) je
WHERE posts.project_id = ?1 WHERE posts.project_id = ?1
AND posts.status = 'published'
AND trim(je.value) != '' AND trim(je.value) != ''
GROUP BY tag GROUP BY tag
ORDER BY cnt DESC, lower(tag) ASC ORDER BY cnt DESC, lower(tag) ASC
@@ -518,29 +534,66 @@ defmodule BDS.Rendering.Filters do
end) end)
if tag_entries == [] do if tag_entries == [] do
{nil, 0, 0} nil
else else
max_count = Enum.map(tag_entries, & &1.count) |> Enum.max() counts = Enum.map(tag_entries, & &1.count)
min_count = Enum.map(tag_entries, & &1.count) |> Enum.min() max_count = Enum.max(counts)
range = max(max_count - min_count, 1) min_count = Enum.min(counts)
words = words =
Enum.map(tag_entries, fn %{tag: tag, count: count, color: color} -> Enum.map(tag_entries, fn %{tag: tag, count: count, color: color} ->
size = 12.0 + (count - min_count) / range * 28.0 base = %{
%{"text" => tag, "size" => size, "color" => color || "var(--accent-color)"} "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) end)
{Jason.encode!(words), 800, 400} escape_html(Jason.encode!(words))
end end
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("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace("\"", "&quot;")
|> String.replace("'", "&#39;")
end
# ── Helpers ──────────────────────────────────────────────────────────────── # ── Helpers ────────────────────────────────────────────────────────────────
defp macro_language(context), do: Access.get(context, "language") || "en" defp macro_language(context), do: Access.get(context, "language") || "en"
defp project_id_from_context(context) do 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 = 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 end
defp project_id_from_post(context) do defp project_id_from_post(context) do

View File

@@ -156,7 +156,7 @@ defmodule BDS.Rendering.ListArchive do
id: MapUtils.attr(post, :id), id: MapUtils.attr(post, :id),
slug: MapUtils.attr(post, :slug), slug: MapUtils.attr(post, :slug),
title: MapUtils.attr(post, :title), 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, raw_content: raw_content,
excerpt: MapUtils.attr(post, :excerpt, record_value(post_record, :excerpt)), excerpt: MapUtils.attr(post, :excerpt, record_value(post_record, :excerpt)),
author: MapUtils.attr(post, :author, record_value(post_record, :author)), author: MapUtils.attr(post, :author, record_value(post_record, :author)),

View File

@@ -64,7 +64,7 @@ defmodule BDS.Rendering.Metadata do
defp category_settings_for(_category_settings, _category), do: nil defp category_settings_for(_category_settings, _category), do: nil
defp category_title(settings) when is_map(settings), 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 defp category_title(_settings), do: nil

View File

@@ -89,7 +89,8 @@ defmodule BDS.Rendering.RenderContext do
static_environment: %{}, static_environment: %{},
filter_module: Filters, filter_module: Filters,
file_system: file_system file_system: file_system
), )
|> put_context_project_id(project_id),
record_by_id: Map.merge(Map.new(translations, &{&1.id, &1}), post_by_id), record_by_id: Map.merge(Map.new(translations, &{&1.id, &1}), post_by_id),
incoming_links_by_post: incoming_links_by_post, incoming_links_by_post: incoming_links_by_post,
outgoing_links_by_post: outgoing_links_by_post, outgoing_links_by_post: outgoing_links_by_post,
@@ -144,6 +145,13 @@ defmodule BDS.Rendering.RenderContext do
## --- internals ----------------------------------------------------------- ## --- 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 defp build_link_context_maps(project_id, post_by_id, main_language) do
links = links =
Repo.all( Repo.all(

View File

@@ -223,10 +223,13 @@ defmodule BDS.Rendering.TemplateSelection do
def template_render_context(project_id) do def template_render_context(project_id) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
context =
Liquex.Context.new(%{}, Liquex.Context.new(%{},
static_environment: %{}, static_environment: %{},
filter_module: Filters, filter_module: Filters,
file_system: FileSystem.new(StarterTemplates.template_roots(project)) file_system: FileSystem.new(StarterTemplates.template_roots(project))
) )
%{context | private: Map.put(context.private, :project_id, project_id)}
end end
end end

View File

@@ -71,19 +71,31 @@ surface BuiltinMacroSurface {
rule ExpandBuiltinMacros { rule ExpandBuiltinMacros {
when: MacroEncountered(macro, language, post_id) 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. -- Unknown macro names are left verbatim in the source.
if macro.name = "youtube" or macro.name = "vimeo": if macro.name = "youtube" or macro.name = "vimeo":
-- Embeds video by `id`; localized default title when title param absent. -- Embeds video by `id`; localized default title when title param absent.
ensures: MacroTemplateRendered(format("macros/{name}", name: macro.name)) ensures: MacroTemplateRendered(format("macros/{name}", name: macro.name))
if macro.name = "gallery": 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. -- `columns` clamped to 1..6 (default 3). Empty when no post_id/images.
ensures: MacroTemplateRendered("macros/gallery") ensures: MacroTemplateRendered("macros/gallery")
if macro.name = "photo_archive": if macro.name = "photo_archive":
-- Month-grouped image archive for the project (optional year/month filter). -- Month-grouped image archive for the project (optional year/month filter).
ensures: MacroTemplateRendered("macros/photo-archive") ensures: MacroTemplateRendered("macros/photo-archive")
if macro.name = "tag_cloud": 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") ensures: MacroTemplateRendered("macros/tag-cloud")
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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 =~ "&quot;"
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("&quot;", "\"")
|> String.replace("&amp;", "&")
|> String.replace("&lt;", "<")
|> String.replace("&gt;", ">")
|> String.replace("&#39;", "'")
|> 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