Files
bDS2/lib/bds/rendering/render_context.ex

202 lines
6.6 KiB
Elixir

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