defmodule BDS.Rendering.PostRendering do @moduledoc false import Ecto.Query alias BDS.Rendering.Filters alias BDS.Rendering.Labels alias BDS.Rendering.LinksAndLanguages alias BDS.Rendering.Metadata, as: RenderMetadata alias BDS.Rendering.RenderContext alias BDS.MapUtils alias BDS.Posts.Post alias BDS.Posts.Translation alias BDS.Repo @spec post_assigns(RenderContext.t() | String.t(), map()) :: map() def post_assigns(project_id, assigns) when is_binary(project_id) do post_assigns(RenderContext.build(project_id), assigns) end def post_assigns(%RenderContext{} = ctx, assigns) do metadata = ctx.metadata language = MapUtils.attr(assigns, :language) || metadata.main_language || "en" main_language = metadata.main_language || language post_record = Map.get(assigns, :_post_record) || load_post_record(ctx, assigns) canonical_post = canonical_post_record(ctx, post_record) post_id = canonical_post_id(post_record, assigns) post_categories = record_list(post_record, :categories) post_tags = record_list(post_record, :tags) raw_content = MapUtils.attr(assigns, :content) rendered_content = cached_post_content(ctx, raw_content, language, post_id) incoming_links = RenderContext.link_contexts(ctx, post_id, :incoming) outgoing_links = RenderContext.link_contexts(ctx, post_id, :outgoing) post_assigns = assigns |> Map.put(:content, rendered_content) |> Map.put(:raw_content, raw_content) %{ language: language, language_prefix: assign_override( assigns, :language_prefix, "language_prefix", LinksAndLanguages.language_prefix(language, main_language) ), page_title: Map.get( assigns, :page_title, MapUtils.attr(assigns, :title) ), pico_stylesheet_href: assign_override( assigns, :pico_stylesheet_href, "pico_stylesheet_href", RenderMetadata.default_pico_stylesheet_href(metadata.pico_theme) ), html_theme_attribute: assign_override(assigns, :html_theme_attribute, "html_theme_attribute", nil), blog_languages: RenderMetadata.blog_languages(metadata, language), alternate_links: 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_month: RenderMetadata.calendar_initial_month(post_record), post_categories: post_categories, post_tags: post_tags, tag_color_by_name: ctx.tag_color_by_name, backlinks: RenderMetadata.backlinks(incoming_links), canonical_post_path_by_slug: ctx.canonical_post_paths, canonical_media_path_by_source_path: ctx.canonical_media_paths, post_data_json_by_id: 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) } end @doc """ Render post markdown through the context's canonical URL maps, memoizing by content, language, and post id so the same content is only rendered once per full-site render (posts appear on many list/archive pages). """ @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 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} def load_post_records_batch([]), do: %{} def load_post_records_batch(post_ids) when is_list(post_ids) do posts = Repo.all(from p in Post, where: p.id in ^post_ids) |> Enum.map(&{&1.id, &1}) |> Map.new() missing_ids = Enum.reject(post_ids, &Map.has_key?(posts, &1)) translations = if Enum.empty?(missing_ids) do %{} else Repo.all(from t in Translation, where: t.id in ^missing_ids) |> Enum.map(&{&1.id, &1}) |> Map.new() end missing_after_translations = Enum.reject(missing_ids, &Map.has_key?(translations, &1)) nil_entries = missing_after_translations |> Enum.map(&{&1, nil}) |> Map.new() Map.merge(posts, Map.merge(translations, nil_entries)) end defp load_post_record(%RenderContext{} = ctx, assigns) do case MapUtils.attr(assigns, :id) do 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(%Post{id: post_id}, _assigns), do: post_id defp canonical_post_id(_post_record, assigns), do: MapUtils.attr(assigns, :id) defp post_data_json(ctx, assigns, post_record, incoming_links, outgoing_links) do id = MapUtils.attr(assigns, :id) if is_binary(id) do %{ id => post_data_json_value( build_post_context(ctx, assigns, post_record, incoming_links, outgoing_links) ) } else %{} end end @spec post_data_json_value(map()) :: String.t() def post_data_json_value(post_context) do case Jason.encode(%{ id: Map.get(post_context, :id), title: Map.get(post_context, :title), slug: Map.get(post_context, :slug), excerpt: Map.get(post_context, :excerpt), author: Map.get(post_context, :author), language: Map.get(post_context, :language), published_at: Map.get(post_context, :published_at), created_at: Map.get(post_context, :created_at), updated_at: Map.get(post_context, :updated_at), tags: Map.get(post_context, :tags, []), categories: Map.get(post_context, :categories, []) }) do {:ok, json} -> json {:error, _reason} -> "{}" end end defp build_post_context(ctx, assigns, post_record, incoming_links, outgoing_links) do %{ id: MapUtils.attr(assigns, :id), slug: MapUtils.attr(assigns, :slug), title: MapUtils.attr(assigns, :title), content: MapUtils.attr(assigns, :content), raw_content: MapUtils.attr(assigns, :raw_content), project_id: MapUtils.attr(assigns, :project_id) || record_value(post_record, :project_id), excerpt: Map.get( assigns, :excerpt, record_value(post_record, :excerpt) ), author: record_value(post_record, :author), language: Map.get( assigns, :language, record_value(post_record, :language) ), show_title: true, published_at: record_value(post_record, :published_at), created_at: record_value(post_record, :created_at), updated_at: record_value(post_record, :updated_at), tags: record_list(post_record, :tags), categories: record_list(post_record, :categories), template_slug: Map.get( post_record || %{}, :template_slug, MapUtils.attr(assigns, :template_slug) ), do_not_translate: record_value(post_record, :do_not_translate, false), linked_media: linked_media_images(ctx, assigns), outgoing_links: outgoing_links, incoming_links: incoming_links } end @spec render_post_content( String.t() | nil, map(), map(), String.t(), Liquex.Context.t(), term() ) :: String.t() def render_post_content( content, canonical_post_paths, canonical_media_paths, language, template_context, post_id \\ nil ) do Filters.render_markdown( content, canonical_post_paths, canonical_media_paths, language, template_context, post_id ) end defp linked_media_images(%RenderContext{} = ctx, assigns) do post_id = MapUtils.attr(assigns, :id) if is_binary(post_id) do Map.get(ctx.linked_media_by_post, post_id, []) else [] end end defp assign_override(assigns, atom_key, string_key, default) do Map.get(assigns, atom_key, Map.get(assigns, string_key, default)) end defp record_value(post_record, key, default \\ nil) do Map.get(post_record || %{}, key, default) end defp record_list(post_record, key), do: record_value(post_record, key, []) || [] end