fix: issue #3 slow rendering

This commit is contained in:
2026-07-05 10:26:21 +02:00
parent 1917043c10
commit fcc574428c
11 changed files with 896 additions and 234 deletions

View File

@@ -21,7 +21,7 @@ defmodule BDS.Generation do
import BDS.Generation.Progress import BDS.Generation.Progress
import BDS.Generation.Outputs import BDS.Generation.Outputs
import BDS.Generation.Data import BDS.Generation.Data
import BDS.Generation.Renderers, only: [build_list_posts: 3] import BDS.Generation.Renderers, only: [build_list_posts: 3, plan_render_target: 1]
import BDS.Generation.Validation import BDS.Generation.Validation
alias BDS.Generation.GeneratedFileHash alias BDS.Generation.GeneratedFileHash
@@ -31,6 +31,7 @@ defmodule BDS.Generation do
alias BDS.PreviewAssets alias BDS.PreviewAssets
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Projects alias BDS.Projects
alias BDS.Rendering.RenderContext
alias BDS.Repo alias BDS.Repo
@core_sections [:core, :single, :category, :tag, :date] @core_sections [:core, :single, :category, :tag, :date]
@@ -108,6 +109,7 @@ defmodule BDS.Generation do
on_progress = callback(opts) on_progress = callback(opts)
with {:ok, plan} <- plan_generation(project_id, [section]) do with {:ok, plan} <- plan_generation(project_id, [section]) do
plan = with_render_context(plan)
render_data = validation_render_data(plan) render_data = validation_render_data(plan)
route_total = section_route_total(plan, render_data) route_total = section_route_total(plan, render_data)
@@ -141,13 +143,17 @@ defmodule BDS.Generation do
pagefind_outputs = BDS.Generation.Pagefind.build_outputs(plan, html_outputs) pagefind_outputs = BDS.Generation.Pagefind.build_outputs(plan, html_outputs)
total = max(length(pagefind_outputs), 1) total = max(length(pagefind_outputs), 1)
write_ctx = stream_context(project_id, nil, 0, verb: "Built", gerund: "Building")
pagefind_outputs pagefind_outputs
|> Enum.with_index(1) |> Enum.with_index(1)
|> Enum.each(fn {{relative_path, content}, index} -> |> Enum.each(fn {{relative_path, content}, index} ->
_ = write_generated_file(project_id, relative_path, content) :ok = write_output_tracked(write_ctx, relative_path, content, false)
:ok = report_validation_progress(on_progress, index / total, "Built #{relative_path}") :ok = report_validation_progress(on_progress, index / total, "Built #{relative_path}")
end) end)
flush_pending_hashes(write_ctx)
{:ok, %{written_count: length(pagefind_outputs)}} {:ok, %{written_count: length(pagefind_outputs)}}
end end
end end
@@ -162,7 +168,7 @@ defmodule BDS.Generation do
on_output on_output
) ++ ) ++
build_page_outputs( build_page_outputs(
plan.project_id, plan_render_target(plan),
plan.language, plan.language,
data.published_posts, data.published_posts,
render_data.translations_by_post_language, render_data.translations_by_post_language,
@@ -176,7 +182,7 @@ defmodule BDS.Generation do
data = render_data.data data = render_data.data
build_single_outputs( build_single_outputs(
plan.project_id, plan_render_target(plan),
plan.language, plan.language,
data.published_posts, data.published_posts,
render_data.translations_by_post_language, render_data.translations_by_post_language,
@@ -238,6 +244,13 @@ defmodule BDS.Generation do
main + additional main + additional
end end
# Attach a load-once render context to the plan so every page render works
# from preloaded in-memory data instead of re-querying per page. Idempotent:
# a plan that already carries a context keeps it.
defp with_render_context(plan) do
Map.put_new_lazy(plan, :render_context, fn -> RenderContext.build(plan.project_id) end)
end
defp report_outputs(outputs, nil), do: outputs defp report_outputs(outputs, nil), do: outputs
defp report_outputs(outputs, on_output) do defp report_outputs(outputs, on_output) do
@@ -618,6 +631,7 @@ defmodule BDS.Generation do
end end
defp build_outputs(plan, on_output \\ nil) do defp build_outputs(plan, on_output \\ nil) do
plan = with_render_context(plan)
data = generation_data(plan) data = generation_data(plan)
published_translations = flattened_generation_translations(data.translations_by_post) published_translations = flattened_generation_translations(data.translations_by_post)
translations_by_post_language = translation_lookup_map(published_translations) translations_by_post_language = translation_lookup_map(published_translations)
@@ -674,7 +688,7 @@ defmodule BDS.Generation do
page_outputs = page_outputs =
if :core in plan.sections do if :core in plan.sections do
build_page_outputs( build_page_outputs(
plan.project_id, plan_render_target(plan),
plan.language, plan.language,
data.published_posts, data.published_posts,
translations_by_post_language, translations_by_post_language,
@@ -688,7 +702,7 @@ defmodule BDS.Generation do
single_outputs = single_outputs =
if :single in plan.sections do if :single in plan.sections do
build_single_outputs( build_single_outputs(
plan.project_id, plan_render_target(plan),
plan.language, plan.language,
data.published_posts, data.published_posts,
translations_by_post_language, translations_by_post_language,
@@ -856,6 +870,7 @@ defmodule BDS.Generation do
defp validation_apply_plan(project_id, sections, report) do defp validation_apply_plan(project_id, sections, report) do
with {:ok, plan} <- plan_generation(project_id, sections) do with {:ok, plan} <- plan_generation(project_id, sections) do
plan = with_render_context(plan)
published_posts = list_published_posts(project_id) published_posts = list_published_posts(project_id)
targeted_plan = targeted_plan =
@@ -913,15 +928,22 @@ defmodule BDS.Generation do
ctx = stream_context(project_id, on_progress, route_total, opts) ctx = stream_context(project_id, on_progress, route_total, opts)
Enum.each(outputs, fn output -> emit_output(output, ctx) end) Enum.each(outputs, fn output -> emit_output(output, ctx) end)
flush_pending_hashes(ctx)
:counters.get(ctx.counter, 1) :counters.get(ctx.counter, 1)
end end
defp stream_render(project_id, on_progress, route_total, opts, build_fn) do defp stream_render(project_id, on_progress, route_total, opts, build_fn) do
ctx = stream_context(project_id, on_progress, route_total, opts) ctx = stream_context(project_id, on_progress, route_total, opts)
_ = build_fn.(fn output -> emit_output(output, ctx) end) _ = build_fn.(fn output -> emit_output(output, ctx) end)
flush_pending_hashes(ctx)
:counters.get(ctx.counter, 1) :counters.get(ctx.counter, 1)
end end
# The stream context mirrors the old app's generation worker hash store: the
# hashes of every previously generated file are loaded once up front, writes
# compare against that in-memory map, and the changed hashes are accumulated
# in a (cross-process safe) ETS table that `flush_pending_hashes/1` persists
# in a few batched upserts — instead of 3 database roundtrips per file.
defp stream_context(project_id, on_progress, route_total, opts) do defp stream_context(project_id, on_progress, route_total, opts) do
gerund = Keyword.fetch!(opts, :gerund) gerund = Keyword.fetch!(opts, :gerund)
@@ -930,6 +952,9 @@ defmodule BDS.Generation do
%{ %{
project_id: project_id, project_id: project_id,
project: Projects.get_project!(project_id),
existing_hashes: existing_hash_map(project_id),
pending_hashes: :ets.new(:bds_generation_pending_hashes, [:set, :public]),
on_progress: on_progress, on_progress: on_progress,
route_total: route_total, route_total: route_total,
verb: Keyword.fetch!(opts, :verb), verb: Keyword.fetch!(opts, :verb),
@@ -938,13 +963,19 @@ defmodule BDS.Generation do
} }
end end
defp existing_hash_map(project_id) do
Repo.all(
from generated_file in GeneratedFileHash,
where: generated_file.project_id == ^project_id,
select: {generated_file.relative_path, generated_file.content_hash}
)
|> Map.new()
end
defp emit_output({relative_path, content} = output, ctx) do defp emit_output({relative_path, content} = output, ctx) do
route? = route_html_path?(relative_path) route? = route_html_path?(relative_path)
_ = :ok = write_output_tracked(ctx, relative_path, content, ctx.refresh_routes? and route?)
write_generated_file(ctx.project_id, relative_path, content,
refresh_timestamp_on_unchanged: ctx.refresh_routes? and route?
)
if route? do if route? do
:counters.add(ctx.counter, 1, 1) :counters.add(ctx.counter, 1, 1)
@@ -962,6 +993,62 @@ defmodule BDS.Generation do
output output
end end
defp write_output_tracked(ctx, relative_path, content, refresh_timestamp?) do
content_hash = sha256(content)
full_path = output_path(ctx.project, relative_path)
case Map.get(ctx.existing_hashes, relative_path) do
^content_hash ->
cond do
not File.exists?(full_path) ->
:ok = Persistence.atomic_write(full_path, content)
true = :ets.insert(ctx.pending_hashes, {relative_path, content_hash})
:ok
refresh_timestamp? ->
true = :ets.insert(ctx.pending_hashes, {relative_path, content_hash})
:ok
true ->
:ok
end
_other ->
:ok = Persistence.atomic_write(full_path, content)
true = :ets.insert(ctx.pending_hashes, {relative_path, content_hash})
:ok
end
end
# SQLite allows at most 999 bound parameters per statement; 4 columns per row
# keeps 200-row chunks comfortably below that.
@hash_flush_chunk_size 200
defp flush_pending_hashes(ctx) do
rows = :ets.tab2list(ctx.pending_hashes)
:ets.delete(ctx.pending_hashes)
now = Persistence.now_ms()
rows
|> Enum.map(fn {relative_path, content_hash} ->
%{
project_id: ctx.project_id,
relative_path: relative_path,
content_hash: content_hash,
updated_at: now
}
end)
|> Enum.chunk_every(@hash_flush_chunk_size)
|> Enum.each(fn chunk ->
Repo.insert_all(GeneratedFileHash, chunk,
on_conflict: {:replace, [:content_hash, :updated_at]},
conflict_target: [:project_id, :relative_path]
)
end)
:ok
end
defp url_progress_started_message(gerund, 0), do: "No URLs to #{String.downcase(gerund)}" defp url_progress_started_message(gerund, 0), do: "No URLs to #{String.downcase(gerund)}"
defp url_progress_started_message(gerund, 1), do: "#{gerund} 1 URL..." defp url_progress_started_message(gerund, 1), do: "#{gerund} 1 URL..."
defp url_progress_started_message(gerund, total), do: "#{gerund} #{total} URLs..." defp url_progress_started_message(gerund, total), do: "#{gerund} #{total} URLs..."
@@ -1021,7 +1108,7 @@ defmodule BDS.Generation do
main_root = main_root =
if targeted_plan.request_root_routes do if targeted_plan.request_root_routes do
posts = build_list_posts(plan.base_url, data.published_list_posts, nil) posts = build_list_posts(plan, data.published_list_posts, nil)
build_root_outputs(plan, plan.language, posts) build_root_outputs(plan, plan.language, posts)
else else
[] []
@@ -1034,7 +1121,7 @@ defmodule BDS.Generation do
if language_plan.request_root_routes do if language_plan.request_root_routes do
source = Map.get(render_data.localized_list_posts_by_language, language, []) source = Map.get(render_data.localized_list_posts_by_language, language, [])
prefix = route_language(plan.language, language) prefix = route_language(plan.language, language)
posts = build_list_posts(plan.base_url, source, prefix) posts = build_list_posts(plan, source, prefix)
build_root_outputs(plan, language, posts) build_root_outputs(plan, language, posts)
else else
[] []
@@ -1056,7 +1143,7 @@ defmodule BDS.Generation do
page_outputs = page_outputs =
build_page_outputs( build_page_outputs(
plan.project_id, plan_render_target(plan),
plan.language, plan.language,
main_page_posts, main_page_posts,
render_data.translations_by_post_language, render_data.translations_by_post_language,
@@ -1080,7 +1167,7 @@ defmodule BDS.Generation do
end) end)
build_single_outputs( build_single_outputs(
plan.project_id, plan_render_target(plan),
plan.language, plan.language,
main_posts, main_posts,
render_data.translations_by_post_language, render_data.translations_by_post_language,

View File

@@ -5,9 +5,11 @@ defmodule BDS.Generation.Outputs do
import BDS.Generation.Renderers import BDS.Generation.Renderers
import BDS.Generation.Sitemap, only: [render_feed: 3, render_atom: 3, render_calendar: 1] import BDS.Generation.Sitemap, only: [render_feed: 3, render_atom: 3, render_calendar: 1]
alias BDS.Rendering.RenderContext
alias BDS.Rendering.TemplateSelection alias BDS.Rendering.TemplateSelection
@type output_callback :: ({String.t(), iodata()} -> any()) | nil @type output_callback :: ({String.t(), iodata()} -> any()) | nil
@type render_target :: RenderContext.t() | String.t()
@spec additional_languages(map()) :: [String.t()] @spec additional_languages(map()) :: [String.t()]
def additional_languages(plan) do def additional_languages(plan) do
@@ -209,7 +211,7 @@ defmodule BDS.Generation.Outputs do
{String.t(), iodata()} {String.t(), iodata()}
] ]
def build_category_outputs(plan, posts_by_category, languages, on_output \\ nil) do def build_category_outputs(plan, posts_by_category, languages, on_output \\ nil) do
Enum.flat_map(posts_by_category, fn {category, posts} -> concurrent_flat_map(posts_by_category, fn {category, posts} ->
paginated_posts = Enum.chunk_every(posts, max(plan.max_posts_per_page, 1)) paginated_posts = Enum.chunk_every(posts, max(plan.max_posts_per_page, 1))
category_slug = archive_route_segment(category) category_slug = archive_route_segment(category)
total_pages = length(paginated_posts) total_pages = length(paginated_posts)
@@ -267,7 +269,7 @@ defmodule BDS.Generation.Outputs do
{String.t(), iodata()} {String.t(), iodata()}
] ]
def build_tag_outputs(plan, posts_by_tag, languages, on_output \\ nil) do def build_tag_outputs(plan, posts_by_tag, languages, on_output \\ nil) do
Enum.flat_map(posts_by_tag, fn {tag, posts} -> concurrent_flat_map(posts_by_tag, fn {tag, posts} ->
tag_slug = archive_route_segment(tag) tag_slug = archive_route_segment(tag)
build_paginated_archive_outputs( build_paginated_archive_outputs(
@@ -288,7 +290,7 @@ defmodule BDS.Generation.Outputs do
] ]
def build_date_outputs(plan, post_index, languages, on_output \\ nil) do def build_date_outputs(plan, post_index, languages, on_output \\ nil) do
year_outputs = year_outputs =
Enum.flat_map(post_index.posts_by_year, fn {year, posts} -> concurrent_flat_map(post_index.posts_by_year, fn {year, posts} ->
build_paginated_archive_outputs( build_paginated_archive_outputs(
plan, plan,
languages, languages,
@@ -309,7 +311,7 @@ defmodule BDS.Generation.Outputs do
end) end)
month_outputs = month_outputs =
Enum.flat_map(post_index.posts_by_year_month, fn {year_month, posts} -> concurrent_flat_map(post_index.posts_by_year_month, fn {year_month, posts} ->
[year, month] = String.split(year_month, "/", parts: 2) [year, month] = String.split(year_month, "/", parts: 2)
build_paginated_archive_outputs( build_paginated_archive_outputs(
@@ -332,7 +334,7 @@ defmodule BDS.Generation.Outputs do
end) end)
day_outputs = day_outputs =
Enum.flat_map(post_index.posts_by_year_month_day, fn {year_month_day, posts} -> concurrent_flat_map(post_index.posts_by_year_month_day, fn {year_month_day, posts} ->
[year, month, day] = String.split(year_month_day, "/", parts: 3) [year, month, day] = String.split(year_month_day, "/", parts: 3)
build_paginated_archive_outputs( build_paginated_archive_outputs(
@@ -366,7 +368,7 @@ defmodule BDS.Generation.Outputs do
def build_core_outputs(plan, published_posts, localized_posts_by_language, on_output \\ nil) do def build_core_outputs(plan, published_posts, localized_posts_by_language, on_output \\ nil) do
language = plan.language language = plan.language
additional_languages = Enum.reject(plan.blog_languages, &(&1 == language)) additional_languages = Enum.reject(plan.blog_languages, &(&1 == language))
main_posts = build_list_posts(plan.base_url, published_posts, nil) main_posts = build_list_posts(plan, published_posts, nil)
main_static_outputs = main_static_outputs =
[ [
@@ -384,7 +386,7 @@ defmodule BDS.Generation.Outputs do
localized_source_posts = Map.get(localized_posts_by_language, localized_language, []) localized_source_posts = Map.get(localized_posts_by_language, localized_language, [])
localized_posts = localized_posts =
build_list_posts(plan.base_url, localized_source_posts, localized_prefix) build_list_posts(plan, localized_source_posts, localized_prefix)
# `build_root_outputs` is called without `on_output` here because the # `build_root_outputs` is called without `on_output` here because the
# combined list (roots + static files) is reported exactly once by the # combined list (roots + static files) is reported exactly once by the
@@ -404,11 +406,12 @@ defmodule BDS.Generation.Outputs do
end) end)
end end
@spec build_page_outputs(String.t(), String.t(), [map()], map(), map(), output_callback()) :: [ @spec build_page_outputs(render_target(), String.t(), [map()], map(), map(), output_callback()) ::
{String.t(), iodata()} [
] {String.t(), iodata()}
]
def build_page_outputs( def build_page_outputs(
project_id, render_target,
main_language, main_language,
published_posts, published_posts,
translations_by_post_language, translations_by_post_language,
@@ -418,17 +421,17 @@ defmodule BDS.Generation.Outputs do
page_outputs = page_outputs =
published_posts published_posts
|> Enum.filter(&("page" in (&1.categories || []))) |> Enum.filter(&("page" in (&1.categories || [])))
|> Enum.map(fn post -> |> concurrent_map(fn post ->
canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post) canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post)
render_language = effective_render_language(canonical_variant.language, main_language) render_language = effective_render_language(canonical_variant.language, main_language)
body = load_body(project_id, canonical_variant.file_path, canonical_variant.content) body = load_body(render_target, canonical_variant.file_path, canonical_variant.content)
effective_slug = effective_template_slug(project_id, post) effective_slug = effective_template_slug(render_target, post)
output = output =
{page_output_path(post.slug, nil), {page_output_path(post.slug, nil),
render_post_output( render_post_output(
project_id, render_target,
effective_slug, effective_slug,
%{ %{
id: canonical_variant.id, id: canonical_variant.id,
@@ -457,16 +460,16 @@ defmodule BDS.Generation.Outputs do
|> Enum.flat_map(fn {language, posts} -> |> Enum.flat_map(fn {language, posts} ->
posts posts
|> Enum.filter(&("page" in (&1.categories || []))) |> Enum.filter(&("page" in (&1.categories || [])))
|> Enum.map(fn post -> |> concurrent_map(fn post ->
render_language = effective_render_language(post.language, language) render_language = effective_render_language(post.language, language)
body = load_body(project_id, post.file_path, post.content) body = load_body(render_target, post.file_path, post.content)
effective_slug = effective_template_slug(project_id, post) effective_slug = effective_template_slug(render_target, post)
output = output =
{page_output_path(post.slug, language), {page_output_path(post.slug, language),
render_post_output( render_post_output(
project_id, render_target,
effective_slug, effective_slug,
%{ %{
id: post.id, id: post.id,
@@ -497,7 +500,7 @@ defmodule BDS.Generation.Outputs do
posts posts
|> paginate_posts(plan.max_posts_per_page) |> paginate_posts(plan.max_posts_per_page)
|> Enum.with_index(1) |> Enum.with_index(1)
|> Enum.map(fn {page_posts, page_number} -> |> concurrent_map(fn {page_posts, page_number} ->
route_language = route_language(plan.language, language) route_language = route_language(plan.language, language)
output = output =
@@ -570,12 +573,12 @@ defmodule BDS.Generation.Outputs do
end) end)
end end
@spec build_single_outputs(String.t(), String.t(), [map()], map(), map(), output_callback()) :: @spec build_single_outputs(render_target(), String.t(), [map()], map(), map(), output_callback()) ::
[ [
{String.t(), iodata()} {String.t(), iodata()}
] ]
def build_single_outputs( def build_single_outputs(
project_id, render_target,
main_language, main_language,
published_posts, published_posts,
translations_by_post_language, translations_by_post_language,
@@ -583,17 +586,17 @@ defmodule BDS.Generation.Outputs do
on_output \\ nil on_output \\ nil
) do ) do
post_outputs = post_outputs =
Enum.map(published_posts, fn post -> concurrent_map(published_posts, fn post ->
canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post) canonical_variant = Map.get(translations_by_post_language, {post.id, main_language}, post)
render_language = effective_render_language(canonical_variant.language, main_language) render_language = effective_render_language(canonical_variant.language, main_language)
body = load_body(project_id, canonical_variant.file_path, canonical_variant.content) body = load_body(render_target, canonical_variant.file_path, canonical_variant.content)
effective_slug = effective_template_slug(project_id, post) effective_slug = effective_template_slug(render_target, post)
output = output =
{post_output_path(post), {post_output_path(post),
render_post_output( render_post_output(
project_id, render_target,
effective_slug, effective_slug,
%{ %{
id: canonical_variant.id, id: canonical_variant.id,
@@ -620,16 +623,16 @@ defmodule BDS.Generation.Outputs do
translation_outputs = translation_outputs =
localized_posts_by_language localized_posts_by_language
|> Enum.flat_map(fn {language, posts} -> |> Enum.flat_map(fn {language, posts} ->
Enum.map(posts, fn post -> concurrent_map(posts, fn post ->
render_language = effective_render_language(post.language, language) render_language = effective_render_language(post.language, language)
body = load_body(project_id, post.file_path, post.content) body = load_body(render_target, post.file_path, post.content)
effective_slug = effective_template_slug(project_id, post) effective_slug = effective_template_slug(render_target, post)
output = output =
{post_output_path(post, language), {post_output_path(post, language),
render_post_output( render_post_output(
project_id, render_target,
effective_slug, effective_slug,
%{ %{
id: post.id, id: post.id,
@@ -650,14 +653,14 @@ defmodule BDS.Generation.Outputs do
post_outputs ++ translation_outputs post_outputs ++ translation_outputs
end end
defp effective_template_slug(project_id, post) do defp effective_template_slug(render_target, post) do
slug = Map.get(post, :template_slug) slug = Map.get(post, :template_slug)
if is_binary(slug) and slug != "" do if is_binary(slug) and slug != "" do
slug slug
else else
TemplateSelection.resolve_post_template_slug( TemplateSelection.resolve_post_template_slug(
project_id, render_target,
Map.get(post, :tags) || [], Map.get(post, :tags) || [],
Map.get(post, :categories) || [] Map.get(post, :categories) || []
) )
@@ -682,4 +685,23 @@ defmodule BDS.Generation.Outputs do
on_output.(output) on_output.(output)
output output
end end
# Render pages on all cores, like the old app's worker threads — safe because
# the render context is immutable data and the output callback only touches
# cross-process-safe state (file writes, ETS, counters). Order is preserved.
defp concurrent_map(enum, fun) do
enum
|> Task.async_stream(fun,
timeout: :infinity,
max_concurrency: System.schedulers_online(),
ordered: true
)
|> Enum.map(fn {:ok, value} -> value end)
end
defp concurrent_flat_map(enum, fun) do
enum
|> concurrent_map(fun)
|> Enum.concat()
end
end end

View File

@@ -4,6 +4,7 @@ defmodule BDS.Generation.Renderers do
alias BDS.Generation.Paths alias BDS.Generation.Paths
alias BDS.Projects alias BDS.Projects
alias BDS.Rendering alias BDS.Rendering
alias BDS.Rendering.RenderContext
@doc "Render the home page (HTML) using the project's template engine." @doc "Render the home page (HTML) using the project's template engine."
@spec render_home(map(), String.t() | nil) :: String.t() @spec render_home(map(), String.t() | nil) :: String.t()
@@ -80,7 +81,7 @@ defmodule BDS.Generation.Renderers do
plan, plan,
language, language,
label, label,
build_list_posts(plan.base_url, posts, Paths.route_language(plan.language, language)), build_list_posts(plan, posts, Paths.route_language(plan.language, language)),
archive_context, archive_context,
pagination, pagination,
fallback fallback
@@ -88,9 +89,13 @@ defmodule BDS.Generation.Renderers do
end end
@doc "Try the project's post template; on error, fall back to the inline `fallback` thunk." @doc "Try the project's post template; on error, fall back to the inline `fallback` thunk."
@spec render_post_output(String.t(), String.t() | nil, map(), (-> String.t())) :: String.t() @spec render_post_output(RenderContext.t() | String.t(), String.t() | nil, map(), (-> String.t())) ::
def render_post_output(project_id, template_slug, assigns, fallback) do String.t()
render_with_fallback(fn -> Rendering.render_post_page(project_id, template_slug, assigns) end, fallback) def render_post_output(render_target, template_slug, assigns, fallback) do
render_with_fallback(
fn -> Rendering.render_post_page(render_target, template_slug, assigns) end,
fallback
)
end end
@doc "Render a list/archive page through the project template, falling back to inline." @doc "Render a list/archive page through the project template, falling back to inline."
@@ -105,7 +110,7 @@ defmodule BDS.Generation.Renderers do
) :: ) ::
String.t() String.t()
def render_list_output( def render_list_output(
%{project_id: project_id, language: main_language}, %{project_id: project_id, language: main_language} = plan,
language, language,
page_title, page_title,
posts, posts,
@@ -116,7 +121,7 @@ defmodule BDS.Generation.Renderers do
when is_binary(project_id) do when is_binary(project_id) do
render_with_fallback( render_with_fallback(
fn -> fn ->
Rendering.render_list_page(project_id, %{ Rendering.render_list_page(plan_render_target(plan), %{
language: language, language: language,
language_prefix: Paths.language_prefix(language, main_language), language_prefix: Paths.language_prefix(language, main_language),
page_title: page_title, page_title: page_title,
@@ -131,11 +136,14 @@ defmodule BDS.Generation.Renderers do
@doc "Render the project's 404 page via its template, falling back to a static page." @doc "Render the project's 404 page via its template, falling back to a static page."
@spec render_not_found_output(map(), String.t() | nil) :: String.t() @spec render_not_found_output(map(), String.t() | nil) :: String.t()
def render_not_found_output(%{project_id: project_id, language: main_language}, language) def render_not_found_output(
%{project_id: project_id, language: main_language} = plan,
language
)
when is_binary(project_id) do when is_binary(project_id) do
render_with_fallback( render_with_fallback(
fn -> fn ->
Rendering.render_not_found_page(project_id, %{ Rendering.render_not_found_page(plan_render_target(plan), %{
language: language, language: language,
language_prefix: Paths.language_prefix(language, main_language) language_prefix: Paths.language_prefix(language, main_language)
}) })
@@ -144,6 +152,15 @@ defmodule BDS.Generation.Renderers do
) )
end end
@doc "The render context stashed in the plan, or the bare project id when absent."
@spec plan_render_target(map()) :: RenderContext.t() | String.t()
def plan_render_target(plan) do
case Map.get(plan, :render_context) do
%RenderContext{} = ctx -> ctx
_other -> plan.project_id
end
end
@doc "Static fallback HTML for a 404 page." @doc "Static fallback HTML for a 404 page."
@spec render_not_found_page(String.t() | nil) :: String.t() @spec render_not_found_page(String.t() | nil) :: String.t()
def render_not_found_page(language) do def render_not_found_page(language) do
@@ -156,41 +173,49 @@ defmodule BDS.Generation.Renderers do
end end
@doc "Build the list-of-posts payload (with hrefs and bodies) for archive/list templates." @doc "Build the list-of-posts payload (with hrefs and bodies) for archive/list templates."
@spec build_list_posts(String.t() | nil, [map()], String.t() | nil) :: [map()] @spec build_list_posts(map(), [map()], String.t() | nil) :: [map()]
def build_list_posts(base_url, posts, language_prefix) do def build_list_posts(plan, posts, language_prefix) do
render_target = plan_render_target(plan)
Enum.map(posts, fn post -> Enum.map(posts, fn post ->
%{ %{
id: post.id, id: post.id,
slug: post.slug, slug: post.slug,
title: post.title, title: post.title,
href: Paths.url_for_output(base_url, Paths.post_output_path(post, language_prefix)), href: Paths.url_for_output(plan.base_url, Paths.post_output_path(post, language_prefix)),
excerpt: post.excerpt, excerpt: post.excerpt,
content: load_body(post.project_id, post.file_path, post.content) content: load_body(render_target, post.file_path, post.content)
} }
end) end)
end end
@doc "Load the post body from disk (or pass-through inline content) for list rendering." @doc "Load the post body from disk (or pass-through inline content) for list rendering."
@spec load_body(String.t() | nil, String.t() | nil, String.t() | nil) :: String.t() @spec load_body(RenderContext.t() | String.t() | nil, String.t() | nil, String.t() | nil) ::
def load_body(_project_id, _file_path, inline_content) when is_binary(inline_content), String.t()
def load_body(_render_target, _file_path, inline_content) when is_binary(inline_content),
do: inline_content do: inline_content
def load_body(project_id, file_path, _inline_content) do def load_body(%RenderContext{} = ctx, file_path, _inline_content)
case file_path do when is_binary(file_path) and file_path != "" do
nil -> RenderContext.memoize(ctx, {:post_body, file_path}, fn ->
"" read_body_file(Path.expand(file_path, ctx.project_data_dir))
end)
end
"" -> def load_body(project_id, file_path, _inline_content)
"" when is_binary(project_id) and is_binary(file_path) and file_path != "" do
project_id
|> Projects.get_project!()
|> Projects.project_data_dir()
|> then(&read_body_file(Path.expand(file_path, &1)))
end
value -> def load_body(_render_target, _file_path, _inline_content), do: ""
project_path =
Path.expand(value, Projects.project_data_dir(Projects.get_project!(project_id)))
case File.read(project_path) do defp read_body_file(full_path) do
{:ok, contents} -> parse_frontmatter_body(contents) case File.read(full_path) do
{:error, _reason} -> "" {:ok, contents} -> parse_frontmatter_body(contents)
end {:error, _reason} -> ""
end end
end end

View File

@@ -3,43 +3,58 @@ defmodule BDS.Rendering do
alias BDS.Rendering.ListArchive alias BDS.Rendering.ListArchive
alias BDS.Rendering.PostRendering alias BDS.Rendering.PostRendering
alias BDS.Rendering.RenderContext
alias BDS.Rendering.TemplateSelection alias BDS.Rendering.TemplateSelection
def render_post_page(project_id, template_slug, assigns) def render_post_page(project_id, template_slug, assigns)
when is_binary(project_id) and is_map(assigns) do when is_binary(project_id) and is_map(assigns) do
render_post_page(RenderContext.build(project_id), template_slug, assigns)
end
def render_post_page(%RenderContext{} = ctx, template_slug, assigns) when is_map(assigns) do
with {:ok, template_source} <- with {:ok, template_source} <-
TemplateSelection.load_template_source(project_id, :post, template_slug), TemplateSelection.load_template_source(ctx, :post, template_slug),
{:ok, rendered} <- {:ok, rendered} <-
TemplateSelection.render_template( TemplateSelection.render_template(
project_id, ctx,
template_source, template_source,
PostRendering.post_assigns(project_id, assigns) PostRendering.post_assigns(ctx, assigns)
) do ) do
{:ok, rendered} {:ok, rendered}
end end
end end
def render_list_page(project_id, assigns) when is_binary(project_id) and is_map(assigns) do def render_list_page(project_id, assigns) when is_binary(project_id) and is_map(assigns) do
with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :list, nil), render_list_page(RenderContext.build(project_id), assigns)
end
def render_list_page(%RenderContext{} = ctx, assigns) when is_map(assigns) do
with {:ok, template_source} <- TemplateSelection.load_template_source(ctx, :list, nil),
{:ok, rendered} <- {:ok, rendered} <-
TemplateSelection.render_template( TemplateSelection.render_template(
project_id, ctx,
template_source, template_source,
ListArchive.list_assigns(project_id, assigns) ListArchive.list_assigns(ctx, assigns)
) do ) do
{:ok, rendered} {:ok, rendered}
end end
end end
def render_not_found_page(project_id, assigns \\ %{}) def render_not_found_page(ctx_or_project_id, assigns \\ %{})
def render_not_found_page(project_id, assigns)
when is_binary(project_id) and is_map(assigns) do when is_binary(project_id) and is_map(assigns) do
render_not_found_page(RenderContext.build(project_id), assigns)
end
def render_not_found_page(%RenderContext{} = ctx, assigns) when is_map(assigns) do
with {:ok, template_source} <- with {:ok, template_source} <-
TemplateSelection.load_template_source(project_id, :not_found, nil), TemplateSelection.load_template_source(ctx, :not_found, nil),
{:ok, rendered} <- {:ok, rendered} <-
TemplateSelection.render_template( TemplateSelection.render_template(
project_id, ctx,
template_source, template_source,
ListArchive.not_found_assigns(project_id, assigns) ListArchive.not_found_assigns(ctx, assigns)
) do ) do
{:ok, rendered} {:ok, rendered}
end end

View File

@@ -84,19 +84,21 @@ defmodule BDS.Rendering.LinksAndLanguages do
end end
case Repo.get(Post, linked_post_id) do case Repo.get(Post, linked_post_id) do
nil -> nil -> nil
nil linked_post -> link_context_for_post(linked_post, main_language)
linked_post ->
%{
href: post_path(linked_post, nil),
title: linked_post.title,
display_slug: linked_post.slug,
language: normalize_language(linked_post.language, main_language)
}
end end
end end
@spec link_context_for_post(map(), String.t()) :: map()
def link_context_for_post(linked_post, main_language) do
%{
href: post_path(linked_post, nil),
title: linked_post.title,
display_slug: linked_post.slug,
language: normalize_language(linked_post.language, main_language)
}
end
@spec language_prefix(String.t() | nil, String.t()) :: String.t() @spec language_prefix(String.t() | nil, String.t()) :: String.t()
def language_prefix(language, main_language) when language == main_language, do: "" def language_prefix(language, main_language) when language == main_language, do: ""
def language_prefix(nil, _main_language), do: "" def language_prefix(nil, _main_language), do: ""

View File

@@ -7,43 +7,25 @@ defmodule BDS.Rendering.ListArchive do
alias BDS.Rendering.LinksAndLanguages alias BDS.Rendering.LinksAndLanguages
alias BDS.Rendering.Metadata, as: RenderMetadata alias BDS.Rendering.Metadata, as: RenderMetadata
alias BDS.Rendering.PostRendering alias BDS.Rendering.PostRendering
alias BDS.Rendering.TemplateSelection alias BDS.Rendering.RenderContext
use Gettext, backend: BDS.Gettext use Gettext, backend: BDS.Gettext
@spec list_assigns(String.t(), map()) :: map() @spec list_assigns(RenderContext.t() | String.t(), map()) :: map()
def list_assigns(project_id, assigns) do def list_assigns(project_id, assigns) when is_binary(project_id) do
metadata = RenderMetadata.project_metadata(project_id) list_assigns(RenderContext.build(project_id), assigns)
template_context = TemplateSelection.template_render_context(project_id) end
def list_assigns(%RenderContext{} = ctx, assigns) do
metadata = ctx.metadata
language = MapUtils.attr(assigns, :language, metadata.main_language || "en") language = MapUtils.attr(assigns, :language, metadata.main_language || "en")
main_language = metadata.main_language || language main_language = metadata.main_language || language
archive_context = MapUtils.attr(assigns, :archive_context, %{}) archive_context = MapUtils.attr(assigns, :archive_context, %{})
canonical_post_paths =
LinksAndLanguages.canonical_post_path_by_slug(project_id, main_language)
canonical_media_paths = LinksAndLanguages.canonical_media_path_by_source_path(project_id)
input_posts = MapUtils.attr(assigns, :posts, []) input_posts = MapUtils.attr(assigns, :posts, [])
post_ids = posts = normalize_list_posts(ctx, input_posts, language)
input_posts
|> Enum.map(&MapUtils.attr(&1, :id))
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
post_records = PostRendering.load_post_records_batch(post_ids)
posts =
normalize_list_posts(
input_posts,
post_records,
canonical_post_paths,
canonical_media_paths,
language,
template_context
)
pagination = pagination =
normalize_pagination(MapUtils.attr(assigns, :pagination), posts) normalize_pagination(MapUtils.attr(assigns, :pagination), posts)
@@ -73,7 +55,7 @@ defmodule BDS.Rendering.ListArchive do
assign_override(assigns, :html_theme_attribute, "html_theme_attribute", nil), assign_override(assigns, :html_theme_attribute, "html_theme_attribute", nil),
blog_languages: RenderMetadata.blog_languages(metadata, language), blog_languages: RenderMetadata.blog_languages(metadata, language),
alternate_links: [], alternate_links: [],
menu_items: RenderMetadata.menu_items(project_id), menu_items: ctx.menu_items,
calendar_initial_year: calendar_initial_year_from_posts(posts), calendar_initial_year: calendar_initial_year_from_posts(posts),
calendar_initial_month: calendar_initial_month_from_posts(posts), calendar_initial_month: calendar_initial_month_from_posts(posts),
archive_context: normalized_archive_context, archive_context: normalized_archive_context,
@@ -92,8 +74,8 @@ defmodule BDS.Rendering.ListArchive do
total_pages: pagination.total_pages, total_pages: pagination.total_pages,
total_items: pagination.total_items, total_items: pagination.total_items,
items_per_page: pagination.items_per_page, items_per_page: pagination.items_per_page,
canonical_post_path_by_slug: canonical_post_paths, canonical_post_path_by_slug: ctx.canonical_post_paths,
canonical_media_path_by_source_path: canonical_media_paths, canonical_media_path_by_source_path: ctx.canonical_media_paths,
post_data_json_by_id: post_data_json_by_id:
Enum.into(posts, %{}, fn post -> {post.id, PostRendering.post_data_json_value(post)} end), Enum.into(posts, %{}, fn post -> {post.id, PostRendering.post_data_json_value(post)} end),
day_blocks: day_blocks, day_blocks: day_blocks,
@@ -103,9 +85,13 @@ defmodule BDS.Rendering.ListArchive do
} }
end end
@spec not_found_assigns(String.t(), map()) :: map() @spec not_found_assigns(RenderContext.t() | String.t(), map()) :: map()
def not_found_assigns(project_id, assigns) do def not_found_assigns(project_id, assigns) when is_binary(project_id) do
metadata = RenderMetadata.project_metadata(project_id) not_found_assigns(RenderContext.build(project_id), assigns)
end
def not_found_assigns(%RenderContext{} = ctx, assigns) do
metadata = ctx.metadata
language = MapUtils.attr(assigns, :language, metadata.main_language || "en") language = MapUtils.attr(assigns, :language, metadata.main_language || "en")
@@ -129,7 +115,7 @@ defmodule BDS.Rendering.ListArchive do
html_theme_attribute: html_theme_attribute:
assign_override(assigns, :html_theme_attribute, "html_theme_attribute", nil), assign_override(assigns, :html_theme_attribute, "html_theme_attribute", nil),
blog_languages: RenderMetadata.blog_languages(metadata, language), blog_languages: RenderMetadata.blog_languages(metadata, language),
menu_items: RenderMetadata.menu_items(project_id), menu_items: ctx.menu_items,
alternate_links: [], alternate_links: [],
not_found_message: not_found_message:
assign_override( assign_override(
@@ -149,17 +135,10 @@ defmodule BDS.Rendering.ListArchive do
} }
end end
defp normalize_list_posts( defp normalize_list_posts(ctx, posts, language) do
posts,
post_records,
canonical_post_paths,
canonical_media_paths,
language,
template_context
) do
Enum.map(posts, fn post -> Enum.map(posts, fn post ->
post_id = MapUtils.attr(post, :id) post_id = MapUtils.attr(post, :id)
post_record = Map.get(post_records, post_id) post_record = Map.get(ctx.record_by_id, post_id)
raw_content = raw_content =
Map.get( Map.get(
@@ -172,14 +151,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: content: PostRendering.cached_post_content(ctx, raw_content, language),
PostRendering.render_post_content(
raw_content,
canonical_post_paths,
canonical_media_paths,
language,
template_context
),
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

@@ -103,6 +103,13 @@ defmodule BDS.Rendering.Metadata do
order_by: [asc: translation.language] order_by: [asc: translation.language]
) )
alternate_links_from(post, translations, main_language)
end
@spec alternate_links_from(Post.t() | nil, [Translation.t()], String.t()) :: [map()]
def alternate_links_from(nil, _translations, _main_language), do: []
def alternate_links_from(%Post{} = post, translations, main_language) do
[ [
%{ %{
href: LinksAndLanguages.post_path(post, nil), href: LinksAndLanguages.post_path(post, nil),

View File

@@ -7,49 +7,34 @@ defmodule BDS.Rendering.PostRendering do
alias BDS.Rendering.Labels alias BDS.Rendering.Labels
alias BDS.Rendering.LinksAndLanguages alias BDS.Rendering.LinksAndLanguages
alias BDS.Rendering.Metadata, as: RenderMetadata alias BDS.Rendering.Metadata, as: RenderMetadata
alias BDS.Rendering.TemplateSelection alias BDS.Rendering.RenderContext
alias BDS.MapUtils alias BDS.MapUtils
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Posts.PostMedia
alias BDS.Posts.Translation alias BDS.Posts.Translation
alias BDS.Media.Media, as: MediaRecord
alias BDS.Repo alias BDS.Repo
@spec post_assigns(String.t(), map()) :: map() @spec post_assigns(RenderContext.t() | String.t(), map()) :: map()
def post_assigns(project_id, assigns) do def post_assigns(project_id, assigns) when is_binary(project_id) do
metadata = RenderMetadata.project_metadata(project_id) post_assigns(RenderContext.build(project_id), assigns)
template_context = TemplateSelection.template_render_context(project_id) end
def post_assigns(%RenderContext{} = ctx, assigns) do
metadata = ctx.metadata
language = MapUtils.attr(assigns, :language) || metadata.main_language || "en" language = MapUtils.attr(assigns, :language) || metadata.main_language || "en"
main_language = metadata.main_language || language main_language = metadata.main_language || language
post_record = Map.get(assigns, :_post_record) || load_post_record(assigns) post_record = Map.get(assigns, :_post_record) || load_post_record(ctx, assigns)
canonical_post = canonical_post_record(post_record) canonical_post = canonical_post_record(ctx, post_record)
post_id = canonical_post_id(post_record, assigns) post_id = canonical_post_id(post_record, assigns)
post_categories = record_list(post_record, :categories) post_categories = record_list(post_record, :categories)
post_tags = record_list(post_record, :tags) post_tags = record_list(post_record, :tags)
canonical_post_paths =
LinksAndLanguages.canonical_post_path_by_slug(project_id, main_language)
canonical_media_paths = LinksAndLanguages.canonical_media_path_by_source_path(project_id)
raw_content = MapUtils.attr(assigns, :content) raw_content = MapUtils.attr(assigns, :content)
rendered_content = cached_post_content(ctx, raw_content, language, post_id)
rendered_content = incoming_links = RenderContext.link_contexts(ctx, post_id, :incoming)
render_post_content( outgoing_links = RenderContext.link_contexts(ctx, post_id, :outgoing)
raw_content,
canonical_post_paths,
canonical_media_paths,
language,
template_context,
post_id
)
incoming_links =
LinksAndLanguages.link_contexts(project_id, post_id, :incoming, main_language)
outgoing_links =
LinksAndLanguages.link_contexts(project_id, post_id, :outgoing, main_language)
post_assigns = post_assigns =
assigns assigns
@@ -81,30 +66,59 @@ defmodule BDS.Rendering.PostRendering do
html_theme_attribute: html_theme_attribute:
assign_override(assigns, :html_theme_attribute, "html_theme_attribute", nil), assign_override(assigns, :html_theme_attribute, "html_theme_attribute", nil),
blog_languages: RenderMetadata.blog_languages(metadata, language), blog_languages: RenderMetadata.blog_languages(metadata, language),
alternate_links: RenderMetadata.alternate_links(canonical_post, project_id, main_language), alternate_links:
menu_items: RenderMetadata.menu_items(project_id), RenderMetadata.alternate_links_from(
canonical_post,
ctx_translations(ctx, canonical_post),
main_language
),
menu_items: ctx.menu_items,
calendar_initial_year: RenderMetadata.calendar_initial_year(post_record), calendar_initial_year: RenderMetadata.calendar_initial_year(post_record),
calendar_initial_month: RenderMetadata.calendar_initial_month(post_record), calendar_initial_month: RenderMetadata.calendar_initial_month(post_record),
post_categories: post_categories, post_categories: post_categories,
post_tags: post_tags, post_tags: post_tags,
tag_color_by_name: RenderMetadata.tag_color_by_name(project_id), tag_color_by_name: ctx.tag_color_by_name,
backlinks: RenderMetadata.backlinks(incoming_links), backlinks: RenderMetadata.backlinks(incoming_links),
canonical_post_path_by_slug: canonical_post_paths, canonical_post_path_by_slug: ctx.canonical_post_paths,
canonical_media_path_by_source_path: canonical_media_paths, canonical_media_path_by_source_path: ctx.canonical_media_paths,
post_data_json_by_id: post_data_json(post_assigns, post_record), post_data_json_by_id:
post: build_post_context(post_assigns, post_record, incoming_links, outgoing_links), post_data_json(ctx, post_assigns, post_record, incoming_links, outgoing_links),
post:
build_post_context(ctx, post_assigns, post_record, incoming_links, outgoing_links),
labels: Labels.for_language(language) labels: Labels.for_language(language)
} }
end end
@spec load_post_record(map()) :: Post.t() | Translation.t() | nil @doc """
def load_post_record(assigns) do Render post markdown through the context's canonical URL maps, memoizing by
case MapUtils.attr(assigns, :id) do content, language, and post id so the same content is only rendered once per
nil -> nil full-site render (posts appear on many list/archive pages).
post_id -> Repo.get(Post, post_id) || Repo.get(Translation, post_id) """
end @spec cached_post_content(RenderContext.t(), term(), String.t(), term()) :: String.t()
def cached_post_content(%RenderContext{} = ctx, content, language, post_id \\ nil) do
raw = to_string(content || "")
RenderContext.memoize(
ctx,
{:rendered_markdown, :erlang.md5(raw), language, post_id},
fn ->
render_post_content(
raw,
ctx.canonical_post_paths,
ctx.canonical_media_paths,
language,
ctx.template_context,
post_id
)
end
)
end end
defp ctx_translations(_ctx, nil), do: []
defp ctx_translations(%RenderContext{} = ctx, %Post{id: post_id}),
do: Map.get(ctx.translations_by_post, post_id, [])
@spec load_post_records_batch([String.t()]) :: %{String.t() => Post.t() | Translation.t() | nil} @spec load_post_records_batch([String.t()]) :: %{String.t() => Post.t() | Translation.t() | nil}
def load_post_records_batch([]), do: %{} def load_post_records_batch([]), do: %{}
@@ -135,38 +149,35 @@ defmodule BDS.Rendering.PostRendering do
Map.merge(posts, Map.merge(translations, nil_entries)) Map.merge(posts, Map.merge(translations, nil_entries))
end end
defp canonical_post_record(%Translation{translation_for: post_id}), do: Repo.get(Post, post_id) defp load_post_record(%RenderContext{} = ctx, assigns) do
defp canonical_post_record(%Post{} = post), do: post case MapUtils.attr(assigns, :id) do
defp canonical_post_record(_other), do: nil nil -> nil
post_id -> Map.get(ctx.record_by_id, post_id)
end
end
defp canonical_post_record(%RenderContext{} = ctx, %Translation{translation_for: post_id}) do
case Map.get(ctx.record_by_id, post_id) do
%Post{} = post -> post
_other -> nil
end
end
defp canonical_post_record(_ctx, %Post{} = post), do: post
defp canonical_post_record(_ctx, _other), do: nil
defp canonical_post_id(%Translation{translation_for: post_id}, _assigns), do: post_id defp canonical_post_id(%Translation{translation_for: post_id}, _assigns), do: post_id
defp canonical_post_id(%Post{id: post_id}, _assigns), do: post_id defp canonical_post_id(%Post{id: post_id}, _assigns), do: post_id
defp canonical_post_id(_post_record, assigns), do: MapUtils.attr(assigns, :id) defp canonical_post_id(_post_record, assigns), do: MapUtils.attr(assigns, :id)
defp post_data_json(assigns, post_record) do defp post_data_json(ctx, assigns, post_record, incoming_links, outgoing_links) do
id = MapUtils.attr(assigns, :id) id = MapUtils.attr(assigns, :id)
if is_binary(id) do if is_binary(id) do
incoming_links =
LinksAndLanguages.link_contexts(
Map.get(post_record || %{}, :project_id),
canonical_post_id(post_record, assigns),
:incoming,
Map.get(post_record || %{}, :language)
)
outgoing_links =
LinksAndLanguages.link_contexts(
Map.get(post_record || %{}, :project_id),
canonical_post_id(post_record, assigns),
:outgoing,
Map.get(post_record || %{}, :language)
)
%{ %{
id => id =>
post_data_json_value( post_data_json_value(
build_post_context(assigns, post_record, incoming_links, outgoing_links) build_post_context(ctx, assigns, post_record, incoming_links, outgoing_links)
) )
} }
else else
@@ -194,7 +205,7 @@ defmodule BDS.Rendering.PostRendering do
end end
end end
defp build_post_context(assigns, post_record, incoming_links, outgoing_links) do defp build_post_context(ctx, assigns, post_record, incoming_links, outgoing_links) do
%{ %{
id: MapUtils.attr(assigns, :id), id: MapUtils.attr(assigns, :id),
slug: MapUtils.attr(assigns, :slug), slug: MapUtils.attr(assigns, :slug),
@@ -228,7 +239,7 @@ defmodule BDS.Rendering.PostRendering do
MapUtils.attr(assigns, :template_slug) MapUtils.attr(assigns, :template_slug)
), ),
do_not_translate: record_value(post_record, :do_not_translate, false), do_not_translate: record_value(post_record, :do_not_translate, false),
linked_media: linked_media_images(assigns), linked_media: linked_media_images(ctx, assigns),
outgoing_links: outgoing_links, outgoing_links: outgoing_links,
incoming_links: incoming_links incoming_links: incoming_links
} }
@@ -260,19 +271,11 @@ defmodule BDS.Rendering.PostRendering do
) )
end end
defp linked_media_images(assigns) do defp linked_media_images(%RenderContext{} = ctx, assigns) do
post_id = MapUtils.attr(assigns, :id) post_id = MapUtils.attr(assigns, :id)
if is_binary(post_id) do if is_binary(post_id) do
Repo.all( Map.get(ctx.linked_media_by_post, post_id, [])
from pm in PostMedia,
join: m in MediaRecord,
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],
select: m
)
else else
[] []
end end

View File

@@ -0,0 +1,201 @@
defmodule BDS.Rendering.RenderContext do
@moduledoc """
Load-once bundle of everything page rendering needs.
Mirrors the old app's generation worker design: all project-wide data
(metadata, menu, canonical URL maps, link graphs, media, template roots) is
loaded up front in a handful of queries, and per-page rendering then works
from immutable in-memory data instead of re-querying the database and
re-reading config files for every page. An ETS-backed `memoize/3` cache holds
parsed template ASTs, post bodies, and rendered markdown for the lifetime of
the context.
The ETS cache table is owned by the process that calls `build/1` and is
reclaimed automatically when that process exits, so a context must not
outlive its creator.
"""
import Ecto.Query
alias BDS.Media.Media, as: MediaRecord
alias BDS.Metadata, as: ProjectMetadata
alias BDS.Posts.Link
alias BDS.Posts.Post
alias BDS.Posts.PostMedia
alias BDS.Posts.Translation
alias BDS.Projects
alias BDS.Rendering.FileSystem
alias BDS.Rendering.Filters
alias BDS.Rendering.LinksAndLanguages
alias BDS.Rendering.Metadata, as: RenderMetadata
alias BDS.Repo
alias BDS.StarterTemplates
alias BDS.Tags.Tag
defstruct [
:project,
:project_id,
:project_data_dir,
:metadata,
:main_language,
:menu_items,
:tag_color_by_name,
:canonical_post_paths,
:canonical_media_paths,
:file_system,
:template_context,
:record_by_id,
:incoming_links_by_post,
:outgoing_links_by_post,
:translations_by_post,
:linked_media_by_post,
:tag_template_slug_by_name,
:cache
]
@type t :: %__MODULE__{}
@spec build(String.t()) :: t()
def build(project_id) when is_binary(project_id) do
project = Projects.get_project!(project_id)
{:ok, metadata} = ProjectMetadata.get_project_metadata(project_id)
main_language = metadata.main_language || "en"
posts = Repo.all(from post in Post, where: post.project_id == ^project_id)
post_by_id = Map.new(posts, &{&1.id, &1})
translations =
Repo.all(from translation in Translation, where: translation.project_id == ^project_id)
{incoming_links_by_post, outgoing_links_by_post} =
build_link_context_maps(project_id, post_by_id, main_language)
file_system = FileSystem.new(StarterTemplates.template_roots(project))
%__MODULE__{
project: project,
project_id: project_id,
project_data_dir: Projects.project_data_dir(project),
metadata: metadata,
main_language: main_language,
menu_items: RenderMetadata.menu_items(project_id),
tag_color_by_name: RenderMetadata.tag_color_by_name(project_id),
canonical_post_paths:
LinksAndLanguages.canonical_post_path_by_slug(project_id, main_language),
canonical_media_paths: LinksAndLanguages.canonical_media_path_by_source_path(project_id),
file_system: file_system,
template_context:
Liquex.Context.new(%{},
static_environment: %{},
filter_module: Filters,
file_system: file_system
),
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,
translations_by_post: build_translations_by_post(translations),
linked_media_by_post: build_linked_media_by_post(project_id),
tag_template_slug_by_name: build_tag_template_slugs(project_id),
cache: :ets.new(:bds_render_context_cache, [:set, :public, {:read_concurrency, true}])
}
end
@doc """
Return the cached value for `key`, computing and caching it with `fun` on the
first call. Safe to call from concurrently rendering processes; concurrent
first calls may compute twice but always return a consistent value.
"""
@spec memoize(t(), term(), (-> value)) :: value when value: term()
def memoize(%__MODULE__{cache: cache}, key, fun) when is_function(fun, 0) do
case :ets.lookup(cache, key) do
[{^key, value}] ->
value
[] ->
value = fun.()
:ets.insert(cache, {key, value})
value
end
end
@spec link_contexts(t(), String.t() | nil, :incoming | :outgoing) :: [map()]
def link_contexts(_ctx, nil, _direction), do: []
def link_contexts(%__MODULE__{} = ctx, post_id, :incoming),
do: Map.get(ctx.incoming_links_by_post, post_id, [])
def link_contexts(%__MODULE__{} = ctx, post_id, :outgoing),
do: Map.get(ctx.outgoing_links_by_post, post_id, [])
## --- internals -----------------------------------------------------------
defp build_link_context_maps(project_id, post_by_id, main_language) do
links =
Repo.all(
from link in Link,
join: source in Post,
on: link.source_post_id == source.id,
where: source.project_id == ^project_id,
order_by: [asc: link.created_at]
)
incoming =
links
|> Enum.group_by(& &1.target_post_id)
|> Map.new(fn {post_id, post_links} ->
{post_id, link_contexts_for(post_links, & &1.source_post_id, post_by_id, main_language)}
end)
outgoing =
links
|> Enum.group_by(& &1.source_post_id)
|> Map.new(fn {post_id, post_links} ->
{post_id, link_contexts_for(post_links, & &1.target_post_id, post_by_id, main_language)}
end)
{incoming, outgoing}
end
defp link_contexts_for(links, linked_id_fun, post_by_id, main_language) do
links
|> Enum.map(fn link ->
case Map.get(post_by_id, linked_id_fun.(link)) do
nil -> nil
linked_post -> LinksAndLanguages.link_context_for_post(linked_post, main_language)
end
end)
|> Enum.reject(&is_nil/1)
end
defp build_translations_by_post(translations) do
translations
|> Enum.filter(&(&1.status == :published))
|> Enum.sort_by(& &1.language)
|> Enum.group_by(& &1.translation_for)
end
defp build_linked_media_by_post(project_id) do
Repo.all(
from post_media in PostMedia,
join: media in MediaRecord,
on: post_media.media_id == media.id,
where: post_media.project_id == ^project_id,
where: like(media.mime_type, "image/%"),
order_by: [asc: post_media.post_id, asc: post_media.sort_order, asc: post_media.media_id],
select: {post_media.post_id, media}
)
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
end
defp build_tag_template_slugs(project_id) do
Repo.all(
from tag in Tag,
where:
tag.project_id == ^project_id and
not is_nil(tag.post_template_slug) and
tag.post_template_slug != "",
select: {tag.name, tag.post_template_slug}
)
|> Map.new()
end
end

View File

@@ -8,13 +8,19 @@ defmodule BDS.Rendering.TemplateSelection do
alias BDS.Projects alias BDS.Projects
alias BDS.Rendering.FileSystem alias BDS.Rendering.FileSystem
alias BDS.Rendering.Filters alias BDS.Rendering.Filters
alias BDS.Rendering.RenderContext
alias BDS.Repo alias BDS.Repo
alias BDS.StarterTemplates alias BDS.StarterTemplates
alias BDS.Tags.Tag alias BDS.Tags.Tag
alias BDS.Templates.Template alias BDS.Templates.Template
@spec resolve_post_template_slug(String.t(), [String.t()], [String.t()]) :: @spec resolve_post_template_slug(RenderContext.t() | String.t(), [String.t()], [String.t()]) ::
String.t() | nil String.t() | nil
def resolve_post_template_slug(%RenderContext{} = ctx, tag_names, category_names) do
Enum.find_value(tag_names, &Map.get(ctx.tag_template_slug_by_name, &1)) ||
resolve_from_category_settings(ctx.metadata.category_settings || %{}, category_names)
end
def resolve_post_template_slug(project_id, tag_names, category_names) do def resolve_post_template_slug(project_id, tag_names, category_names) do
resolve_from_tags(project_id, tag_names) || resolve_from_tags(project_id, tag_names) ||
resolve_from_categories(project_id, category_names) resolve_from_categories(project_id, category_names)
@@ -40,8 +46,12 @@ defmodule BDS.Rendering.TemplateSelection do
defp resolve_from_categories(project_id, category_names) do defp resolve_from_categories(project_id, category_names) do
{:ok, state} = Metadata.get_project_metadata(project_id) {:ok, state} = Metadata.get_project_metadata(project_id)
settings = state.category_settings || %{} resolve_from_category_settings(state.category_settings || %{}, category_names)
end
defp resolve_from_category_settings(_settings, []), do: nil
defp resolve_from_category_settings(settings, category_names) do
Enum.find_value(category_names, fn cat_name -> Enum.find_value(category_names, fn cat_name ->
case Map.get(settings, cat_name) do case Map.get(settings, cat_name) do
%{"post_template_slug" => slug} when is_binary(slug) and slug != "" -> slug %{"post_template_slug" => slug} when is_binary(slug) and slug != "" -> slug
@@ -50,14 +60,22 @@ defmodule BDS.Rendering.TemplateSelection do
end) end)
end end
@spec load_template_source(String.t(), atom(), String.t() | nil) :: @spec load_template_source(RenderContext.t() | String.t(), atom(), String.t() | nil) ::
{:ok, String.t()} | {:error, term()} {:ok, String.t()} | {:error, term()}
def load_template_source(project_id, kind, slug) do def load_template_source(%RenderContext{} = ctx, kind, slug) do
project = Projects.get_project!(project_id) RenderContext.memoize(ctx, {:template_source, kind, slug}, fn ->
do_load_template_source(ctx.project, ctx.project_id, kind, slug)
end)
end
def load_template_source(project_id, kind, slug) when is_binary(project_id) do
do_load_template_source(Projects.get_project!(project_id), project_id, kind, slug)
end
defp do_load_template_source(project, project_id, kind, slug) do
case select_template(project_id, kind, slug) do case select_template(project_id, kind, slug) do
%Template{} = template -> %Template{} = template ->
case published_template_body(template) do case published_template_body(project, template) do
{:ok, _source} = ok -> {:ok, _source} = ok ->
ok ok
@@ -105,11 +123,10 @@ defmodule BDS.Rendering.TemplateSelection do
limit: 1 limit: 1
end end
defp published_template_body(%Template{content: content}) when is_binary(content), defp published_template_body(_project, %Template{content: content}) when is_binary(content),
do: {:ok, content} do: {:ok, content}
defp published_template_body(%Template{} = template) do defp published_template_body(project, %Template{} = template) do
project = Projects.get_project!(template.project_id)
full_path = Path.join(Projects.project_data_dir(project), template.file_path) full_path = Path.join(Projects.project_data_dir(project), template.file_path)
case File.read(full_path) do case File.read(full_path) do
@@ -124,11 +141,16 @@ defmodule BDS.Rendering.TemplateSelection do
end end
end end
@spec render_template(String.t(), String.t(), map()) :: @spec render_template(RenderContext.t() | String.t(), String.t(), map()) ::
{:ok, String.t()} | {:error, String.t()} {:ok, String.t()} | {:error, String.t()}
def render_template(project_id, source, assigns) do def render_template(%RenderContext{} = ctx, source, assigns) do
with {:ok, template_ast} <- Liquex.parse(source, BDS.Rendering.LiquidParser), parse_result =
{:ok, _rendered} = ok <- safe_liquex_render(template_ast, project_id, assigns) do RenderContext.memoize(ctx, {:template_ast, :erlang.md5(source)}, fn ->
Liquex.parse(source, BDS.Rendering.LiquidParser)
end)
with {:ok, template_ast} <- parse_result,
{:ok, _rendered} = ok <- safe_liquex_render(template_ast, ctx.file_system, assigns) do
ok ok
else else
{:error, reason, line} when is_integer(line) -> {:error, "#{reason} at line #{line}"} {:error, reason, line} when is_integer(line) -> {:error, "#{reason} at line #{line}"}
@@ -136,14 +158,25 @@ defmodule BDS.Rendering.TemplateSelection do
end end
end end
defp safe_liquex_render(template_ast, project_id, assigns) do def render_template(project_id, source, assigns) when is_binary(project_id) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
file_system = FileSystem.new(StarterTemplates.template_roots(project))
with {:ok, template_ast} <- Liquex.parse(source, BDS.Rendering.LiquidParser),
{:ok, _rendered} = ok <- safe_liquex_render(template_ast, file_system, assigns) do
ok
else
{:error, reason, line} when is_integer(line) -> {:error, "#{reason} at line #{line}"}
{:error, _message} = error -> error
end
end
defp safe_liquex_render(template_ast, file_system, assigns) do
context = context =
Liquex.Context.new(assigns, Liquex.Context.new(assigns,
static_environment: assigns, static_environment: assigns,
filter_module: Filters, filter_module: Filters,
file_system: FileSystem.new(StarterTemplates.template_roots(project)) file_system: file_system
) )
{result, _context} = Liquex.render!(template_ast, context) {result, _context} = Liquex.render!(template_ast, context)
@@ -183,7 +216,10 @@ defmodule BDS.Rendering.TemplateSelection do
defp bundled_template_slug(_kind, slug) when is_binary(slug) and slug != "", do: slug defp bundled_template_slug(_kind, slug) when is_binary(slug) and slug != "", do: slug
defp bundled_template_slug(kind, _slug), do: StarterTemplates.default_slug(kind) defp bundled_template_slug(kind, _slug), do: StarterTemplates.default_slug(kind)
@spec template_render_context(String.t()) :: Liquex.Context.t() @spec template_render_context(RenderContext.t() | String.t()) :: Liquex.Context.t()
def template_render_context(%RenderContext{template_context: template_context}),
do: template_context
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)

View File

@@ -0,0 +1,292 @@
defmodule BDS.Rendering.RenderContextTest do
use ExUnit.Case, async: false
alias BDS.Generation
alias BDS.Rendering
alias BDS.Rendering.RenderContext
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir =
Path.join(System.tmp_dir!(), "bds-render-context-#{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: "RenderContext", data_path: temp_dir})
{:ok, _metadata} =
BDS.Metadata.update_project_metadata(project.id, %{
main_language: "en",
blog_languages: ["en", "de"]
})
%{project: project, temp_dir: temp_dir}
end
defp create_published_post(project, attrs) do
{:ok, post} =
BDS.Posts.create_post(
Map.merge(
%{project_id: project.id, language: "en", content: "Body"},
attrs
)
)
{:ok, published} = BDS.Posts.publish_post(post.id)
published
end
test "build/1 preloads canonical paths, menu, tag colors, and link maps", %{project: project} do
target = create_published_post(project, %{title: "Ctx Target", tags: ["alpha"]})
_source =
create_published_post(project, %{
title: "Ctx Source",
content: "See [Target](/#{Enum.join(post_date_parts(target), "/")}/#{target.slug}/)"
})
ctx = RenderContext.build(project.id)
assert ctx.project.id == project.id
assert ctx.main_language == "en"
assert Map.has_key?(ctx.canonical_post_paths, target.slug)
assert is_map(ctx.canonical_media_paths)
assert is_map(ctx.tag_color_by_name)
assert is_list(ctx.menu_items)
incoming = Map.get(ctx.incoming_links_by_post, target.id, [])
assert Enum.any?(incoming, fn link -> link.title == "Ctx Source" end)
end
test "memoize/3 executes the computation only once per key", %{project: project} do
ctx = RenderContext.build(project.id)
{:ok, counter} = Agent.start_link(fn -> 0 end)
compute = fn ->
Agent.update(counter, &(&1 + 1))
:computed
end
assert RenderContext.memoize(ctx, {:test, "key"}, compute) == :computed
assert RenderContext.memoize(ctx, {:test, "key"}, compute) == :computed
assert Agent.get(counter, & &1) == 1
end
test "render_post_page with a context matches the project_id render", %{project: project} do
{:ok, template} =
BDS.Templates.create_template(%{
project_id: project.id,
title: "Ctx Parity",
kind: :post,
content:
"{{ page_title }}|{{ post.author }}|{{ post.tags.size }}|{% for lang in blog_languages %}[{{ lang.code }}]{% endfor %}|{{ content }}"
})
{:ok, published_template} = BDS.Templates.publish_template(template.id)
post =
create_published_post(project, %{
title: "Parity Post",
author: "Writer",
tags: ["alpha", "beta"],
content: "Hello **world**",
template_slug: published_template.slug
})
assigns = %{
id: post.id,
title: post.title,
content: "Hello **world**",
slug: post.slug,
language: "en",
excerpt: post.excerpt,
template_slug: post.template_slug
}
assert {:ok, from_project_id} =
Rendering.render_post_page(project.id, published_template.slug, assigns)
ctx = RenderContext.build(project.id)
assert {:ok, from_ctx} =
Rendering.render_post_page(ctx, published_template.slug, assigns)
assert from_ctx == from_project_id
end
test "render_list_page with a context matches the project_id render", %{project: project} do
post = create_published_post(project, %{title: "List Parity", content: "List body"})
assigns = %{
language: "en",
page_title: "Home",
posts: [
%{
id: post.id,
slug: post.slug,
title: post.title,
href: "/#{post.slug}/",
excerpt: post.excerpt,
content: "List body"
}
],
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: "",
has_next_page: false,
next_page_href: ""
}
}
assert {:ok, from_project_id} = Rendering.render_list_page(project.id, assigns)
ctx = RenderContext.build(project.id)
assert {:ok, from_ctx} = Rendering.render_list_page(ctx, assigns)
assert from_ctx == from_project_id
end
test "render_template caches the parsed template AST in the context", %{project: project} do
ctx = RenderContext.build(project.id)
source = "Hello {{ name }}"
assert {:ok, "Hello A"} =
BDS.Rendering.TemplateSelection.render_template(ctx, source, %{"name" => "A"})
assert {:ok, "Hello B"} =
BDS.Rendering.TemplateSelection.render_template(ctx, source, %{"name" => "B"})
ast_entries =
:ets.tab2list(ctx.cache)
|> Enum.filter(fn {key, _value} -> match?({:template_ast, _hash}, key) end)
assert length(ast_entries) == 1
end
test "load_body caches post bodies for the lifetime of the context", %{
project: project,
temp_dir: temp_dir
} do
relative_path = "posts/cached-body.md"
full_path = Path.join(temp_dir, relative_path)
File.mkdir_p!(Path.dirname(full_path))
File.write!(full_path, "---\ntitle: Cached\n---\nCached body content\n")
ctx = RenderContext.build(project.id)
assert BDS.Generation.Renderers.load_body(ctx, relative_path, nil) ==
"Cached body content"
File.rm!(full_path)
assert BDS.Generation.Renderers.load_body(ctx, relative_path, nil) ==
"Cached body content"
end
test "generate_site batches hash bookkeeping and skips unchanged outputs", %{
project: project,
temp_dir: temp_dir
} do
_post = create_published_post(project, %{title: "Site Post", content: "Site body"})
assert {:ok, %{generated_files: files}} =
Generation.generate_site(project.id, [:core, :single])
assert files != []
Enum.each(files, fn file ->
disk_path = Path.join([temp_dir, "html", file.relative_path])
assert File.exists?(disk_path)
disk_hash =
:crypto.hash(:sha256, File.read!(disk_path)) |> Base.encode16(case: :lower)
assert disk_hash == file.content_hash
end)
first_by_path = Map.new(files, &{&1.relative_path, &1})
Process.sleep(5)
assert {:ok, %{generated_files: second_files}} =
Generation.generate_site(project.id, [:core, :single])
Enum.each(second_files, fn file ->
first = Map.fetch!(first_by_path, file.relative_path)
assert file.content_hash == first.content_hash
assert file.updated_at == first.updated_at,
"expected unchanged output #{file.relative_path} to keep its updated_at"
end)
end
test "full section render issues a bounded number of queries regardless of post count", %{
project: project
} do
for index <- 1..12 do
create_published_post(project, %{
title: "Query Bound #{index}",
content: "Body #{index}",
tags: ["tag-#{rem(index, 3)}"],
categories: ["article"]
})
end
query_count =
count_queries(fn ->
assert {:ok, %{rendered_url_count: rendered}} =
Generation.render_site_section(project.id, :single)
assert rendered >= 12
end)
# Before the render-context rework this was ~15 queries *per page*; the
# bound only holds if all per-page lookups stay preloaded.
assert query_count <= 40,
"expected a bounded query count for the section render, got #{query_count}"
end
defp count_queries(func) do
test_pid = self()
ref = make_ref()
handler_id = "render-context-query-counter-#{inspect(ref)}"
:telemetry.attach(
handler_id,
[:bds, :repo, :query],
fn _event, _measurements, _metadata, _config ->
send(test_pid, {:query_executed, ref})
end,
nil
)
func.()
:telemetry.detach(handler_id)
count_query_messages(ref, 0)
end
defp count_query_messages(ref, acc) do
receive do
{:query_executed, ^ref} -> count_query_messages(ref, acc + 1)
after
0 -> acc
end
end
defp post_date_parts(post) do
datetime = BDS.Persistence.from_unix_ms!(post.created_at)
[
Integer.to_string(datetime.year),
String.pad_leading(Integer.to_string(datetime.month), 2, "0"),
String.pad_leading(Integer.to_string(datetime.day), 2, "0")
]
end
end