defmodule PhoenixKit.Modules.Publishing.Renderer do @moduledoc """ Renders publishing post markdown to HTML with caching support. Uses PhoenixKit.Cache for performance optimization of markdown rendering. Cache keys include content hashes for automatic invalidation. """ require Logger alias Phoenix.HTML.Safe alias PhoenixKit.Modules.Publishing.PageBuilder alias PhoenixKit.Modules.Shared.Components.EntityForm alias PhoenixKit.Modules.Shared.Components.Image alias PhoenixKit.Modules.Shared.Components.Video alias PhoenixKit.Settings @cache_name :publishing_posts @cache_version "v2" @global_cache_key "publishing_render_cache_enabled" @per_group_cache_prefix "publishing_render_cache_enabled_" @component_regex ~r/<(Image|Hero|CTA|Headline|Subheadline|Video|EntityForm)\s+([^>]*?)\/>/s @component_block_regex ~r/<(Hero|CTA|Headline|Subheadline|Video|EntityForm)\s*([^>]*)>(.*?)<\/\1>/s # Tailwind/daisyUI classes for post-processing Earmark HTML output. # Code blocks (pre, code) are handled separately in style_code_blocks/1. @pre_classes "bg-base-300 p-4 rounded-lg overflow-x-auto my-4" @inline_code_classes "bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono" @tag_classes [ {"h1", "text-4xl font-bold mt-6 mb-4 pb-2 border-b border-base-content/10"}, {"h2", "text-3xl font-semibold mt-6 mb-3"}, {"h3", "text-2xl font-semibold mt-5 mb-2"}, {"h4", "text-xl font-semibold mt-4 mb-2"}, {"h5", "text-lg font-semibold mt-4 mb-2"}, {"h6", "text-base font-semibold mt-4 mb-2"}, {"p", "my-4 leading-relaxed"}, {"a", "link link-primary"}, {"blockquote", "border-l-4 border-primary pl-4 my-4 text-base-content/70 italic"}, {"table", "table w-full my-4"}, {"thead", "bg-base-200"}, {"th", "font-semibold text-left p-2"}, {"td", "border-t border-base-content/10 p-2"}, {"img", "max-w-full h-auto rounded-lg my-4"}, {"ul", "list-disc pl-8 my-4"}, {"ol", "list-decimal pl-8 my-4"}, {"li", "my-1"}, {"hr", "my-8 border-0 border-t-2 border-base-content/10"} ] # Build {regex_source, tag, classes} tuples at compile time. # Regex structs can't be stored in module attributes, so we store the source # strings and compile them once at runtime via a persistent cache. @tag_patterns Enum.map(@tag_classes, fn {tag, classes} -> {"<#{Regex.escape(tag)}(?=[\\s>\\/])([^>]*)>", tag, classes} end) @doc """ Renders a post's markdown content to HTML. Caches the result for published posts using content-hash-based keys. Lazy-loads cache (only caches after first render). Respects `publishing_render_cache_enabled` (global) and `publishing_render_cache_enabled_{group_slug}` (per-group) settings. ## Examples {:ok, html} = Renderer.render_post(post) """ def render_post(post) do if post.metadata.status == "published" and render_cache_enabled?(post.group) do cache_key = build_cache_key(post) case get_cached(cache_key) do {:ok, html} -> {:ok, html} :miss -> render_and_cache(post, cache_key) end else # Don't cache drafts, archived posts, or when cache is disabled {:ok, render_markdown(post.content)} end end @doc """ Returns whether render caching is enabled for a group. Checks both the global setting and per-group setting. Both must be enabled (or default to enabled) for caching to work. """ @spec render_cache_enabled?(String.t()) :: boolean() def render_cache_enabled?(group_slug) do global_enabled = global_render_cache_enabled?() per_group_enabled = group_render_cache_enabled?(group_slug) global_enabled and per_group_enabled end @doc """ Returns whether the global render cache is enabled. """ @spec global_render_cache_enabled?() :: boolean() def global_render_cache_enabled? do Settings.get_setting_cached(@global_cache_key, "true") == "true" end @doc """ Returns whether render cache is enabled for a specific group. Does not check the global setting. """ @spec group_render_cache_enabled?(String.t()) :: boolean() def group_render_cache_enabled?(group_slug) do key = @per_group_cache_prefix <> group_slug Settings.get_setting_cached(key, "true") == "true" end @doc """ Returns the settings key for per-group render cache. Used by other modules that need to write to the setting. """ @spec per_group_cache_key(String.t()) :: String.t() def per_group_cache_key(group_slug), do: @per_group_cache_prefix <> group_slug @doc """ Renders markdown or PHK content directly without caching. Automatically detects PHK XML format and routes to PageBuilder. Falls back to Earmark markdown rendering for non-XML content. ## Examples html = Renderer.render_markdown(content) """ def render_markdown(content) when is_binary(content) do {time, result} = :timer.tc(fn -> cond do pure_phk_content?(content) -> render_phk_content(content) has_embedded_components?(content) -> render_mixed_content(content) true -> render_earmark_markdown(content) end end) Logger.debug("Content render time: #{time}μs", content_size: byte_size(content)) result end def render_markdown(_), do: "" # Detect if content is pure PHK XML format (starts with or ) defp pure_phk_content?(content) do trimmed = String.trim(content) String.starts_with?(trimmed, " # Convert Phoenix.LiveView.Rendered to string html |> Safe.to_iodata() |> IO.iodata_to_binary() {:error, reason} -> Logger.warning("PHK render error: #{inspect(reason)}") "

Error rendering page content

" end end # Render markdown using Earmark, then inject Tailwind/daisyUI classes on each tag. defp render_earmark_markdown(content) do content = normalize_markdown(content) case Earmark.as_html(content, %Earmark.Options{ code_class_prefix: "language-", smartypants: true, gfm: true, escape: false }) do {:ok, html, _warnings} -> add_tailwind_classes(html) {:error, _html, _errors} -> ~s(

Error rendering markdown

) end end defp normalize_markdown(content) when is_binary(content) do content # Remove leading indentation before Markdown headings (e.g., " ## Title") |> then(&Regex.replace(~r/^[ \t]+(?=#)/m, &1, "")) # Preserve intentional blank lines: convert runs of 2+ blank lines into # visible spacing so the rendered output matches what the author typed. # A single blank line remains a normal paragraph break (standard Markdown). |> preserve_blank_lines() end # Converts sequences of 2+ consecutive blank lines into paragraph breaks # with
spacers. Each extra blank line beyond the first becomes one
. defp preserve_blank_lines(content) do Regex.replace(~r/\n{3,}/, content, fn match -> # Number of extra blank lines beyond the standard paragraph break # \n\n = 1 blank line (normal paragraph break), \n\n\n = 2 blank lines, etc. extra_lines = String.length(match) - 2 br_tags = String.duplicate(" \n\n", extra_lines) "\n\n#{br_tags}" end) end # ============================================================================ # Tailwind Class Injection # ============================================================================ # Adds Tailwind/daisyUI classes to rendered HTML tags so markdown content # is styled without requiring a prose plugin or inline