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