Packages
phoenix_kit
1.7.39
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/modules/publishing/renderer.ex
defmodule PhoenixKit.Modules.Publishing.Renderer do
@moduledoc """
Renders blog 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.Components.EntityForm
alias PhoenixKit.Modules.Publishing.Components.Image
alias PhoenixKit.Modules.Publishing.Components.Video
alias PhoenixKit.Modules.Publishing.PageBuilder
alias PhoenixKit.Settings
@cache_name :publishing_posts
@cache_version "v1"
# New settings keys (write to these)
@global_cache_key "publishing_render_cache_enabled"
@per_blog_cache_prefix "publishing_render_cache_enabled_"
# Legacy settings keys (read from these as fallback)
@legacy_global_cache_key "blogging_render_cache_enabled"
@legacy_per_blog_cache_prefix "blogging_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
@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 `blogging_render_cache_enabled` (global) and
`blogging_render_cache_enabled_{blog_slug}` (per-blog) 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 blog.
Checks both the global setting and per-blog setting.
Both must be enabled (or default to enabled) for caching to work.
Checks new keys first, falls back to legacy keys.
"""
@spec render_cache_enabled?(String.t()) :: boolean()
def render_cache_enabled?(blog_slug) do
global_enabled = global_render_cache_enabled?()
per_blog_enabled = group_render_cache_enabled?(blog_slug)
global_enabled and per_blog_enabled
end
@doc """
Returns whether the global render cache is enabled.
Checks new key first, falls back to legacy key.
"""
@spec global_render_cache_enabled?() :: boolean()
def global_render_cache_enabled? do
case Settings.get_setting_cached(@global_cache_key, nil) do
nil -> Settings.get_setting_cached(@legacy_global_cache_key, "true") == "true"
value -> value == "true"
end
end
@doc """
Returns whether render cache is enabled for a specific group.
Does not check the global setting.
Checks new key first, falls back to legacy key.
"""
@spec group_render_cache_enabled?(String.t()) :: boolean()
def group_render_cache_enabled?(group_slug) do
new_key = @per_blog_cache_prefix <> group_slug
legacy_key = @legacy_per_blog_cache_prefix <> group_slug
case Settings.get_setting_cached(new_key, nil) do
nil -> Settings.get_setting_cached(legacy_key, "true") == "true"
value -> value == "true"
end
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_blog_cache_prefix <> group_slug
# Deprecated shims for backward compatibility
@doc false
@deprecated "Use group_render_cache_enabled?/1 instead"
def blog_render_cache_enabled?(group_slug), do: group_render_cache_enabled?(group_slug)
@doc false
@deprecated "Use per_group_cache_key/1 instead"
def per_blog_cache_key(group_slug), do: per_group_cache_key(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 <Page> or <Hero>)
defp pure_phk_content?(content) do
trimmed = String.trim(content)
String.starts_with?(trimmed, "<Page") || String.starts_with?(trimmed, "<Hero")
end
# Detect if markdown content has embedded XML components
defp has_embedded_components?(content) do
String.contains?(content, "<Image ") ||
String.contains?(content, "<Hero") ||
String.contains?(content, "<CTA") ||
String.contains?(content, "<Headline") ||
String.contains?(content, "<Subheadline") ||
String.contains?(content, "<Video") ||
String.contains?(content, "<EntityForm")
end
# Render .phk content using PageBuilder
defp render_phk_content(content) do
case PageBuilder.render_content(content) do
{:ok, html} ->
# Convert Phoenix.LiveView.Rendered to string
html
|> Safe.to_iodata()
|> IO.iodata_to_binary()
{:error, reason} ->
Logger.warning("PHK render error: #{inspect(reason)}")
"<p>Error rendering page content</p>"
end
end
# Render markdown using Earmark
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} -> html
{:error, _html, _errors} -> "<p>Error rendering markdown</p>"
end
end
defp normalize_markdown(content) when is_binary(content) do
# Remove leading indentation before Markdown headings (e.g., " ## Title")
Regex.replace(~r/^[ \t]+(?=#)/m, content, "")
end
# Render mixed content: markdown with embedded XML components
defp render_mixed_content(content) when content == "" or is_nil(content), do: ""
defp render_mixed_content(content) do
content
|> render_mixed_segments([])
|> Enum.reverse()
|> Enum.join()
end
defp render_mixed_segments("", acc), do: acc
defp render_mixed_segments(content, acc) do
case next_component_match(content) do
nil ->
[render_earmark_markdown(content) | acc]
{:self_closing, [{match_start, match_len}, {tag_start, tag_len}, {attrs_start, attrs_len}]} ->
before = binary_part(content, 0, match_start)
after_index = match_start + match_len
rest_content = binary_part(content, after_index, byte_size(content) - after_index)
tag = binary_part(content, tag_start, tag_len)
attrs = binary_part(content, attrs_start, attrs_len)
acc =
acc
|> maybe_add_markdown(before)
|> add_component(tag, attrs)
render_mixed_segments(rest_content, acc)
{:block, indexes} ->
[{match_start, match_len} | _rest] = indexes
before = binary_part(content, 0, match_start)
after_index = match_start + match_len
rest_content = binary_part(content, after_index, byte_size(content) - after_index)
fragment = binary_part(content, match_start, match_len)
acc =
acc
|> maybe_add_markdown(before)
|> add_block_component(fragment)
render_mixed_segments(rest_content, acc)
end
end
defp next_component_match(content) do
self_match = Regex.run(@component_regex, content, return: :index)
block_match = Regex.run(@component_block_regex, content, return: :index)
case {self_match, block_match} do
{nil, nil} ->
nil
{nil, block} ->
{:block, block}
{self, nil} ->
{:self_closing, self}
{self, block} ->
self_start = self |> hd() |> elem(0)
block_start = block |> hd() |> elem(0)
if self_start <= block_start do
{:self_closing, self}
else
{:block, block}
end
end
end
defp maybe_add_markdown(acc, ""), do: acc
defp maybe_add_markdown(acc, text) do
[render_earmark_markdown(text) | acc]
end
defp add_component(acc, tag, attrs) do
[render_inline_component(tag, attrs) | acc]
end
defp add_block_component(acc, fragment) do
[render_block_component(fragment) | acc]
end
# Render individual inline component
defp render_inline_component("Image", attrs) do
# Parse attributes
attr_map = parse_xml_attributes(attrs)
assigns = %{
__changed__: nil,
attributes: attr_map,
variant: "default",
content: nil,
children: []
}
Image.render(assigns)
|> Safe.to_iodata()
|> IO.iodata_to_binary()
rescue
error ->
Logger.warning("Error rendering Image component: #{inspect(error)}")
"<div class='error'>Error rendering image</div>"
end
defp render_inline_component("Video", attrs) do
attr_map = parse_xml_attributes(attrs)
assigns = %{
__changed__: nil,
attributes: attr_map,
variant: Map.get(attr_map, "variant", "default"),
content: Map.get(attr_map, "caption"),
children: []
}
Video.render(assigns)
|> Safe.to_iodata()
|> IO.iodata_to_binary()
rescue
error ->
Logger.warning("Error rendering Video component: #{inspect(error)}")
"<div class='error'>Error rendering video</div>"
end
defp render_inline_component("EntityForm", attrs) do
attr_map = parse_xml_attributes(attrs)
assigns = %{
__changed__: nil,
attributes: attr_map,
variant: Map.get(attr_map, "variant", "default"),
content: nil,
children: []
}
EntityForm.render(assigns)
|> Safe.to_iodata()
|> IO.iodata_to_binary()
rescue
error ->
Logger.warning("Error rendering EntityForm component: #{inspect(error)}")
"<div class='error'>Error rendering entity form</div>"
end
defp render_inline_component(tag, _attrs) do
# Fallback for other components
Logger.warning("Inline component not supported yet: #{tag}")
""
end
defp render_block_component(fragment) do
fragment
|> PageBuilder.render_content()
|> case do
{:ok, html} ->
html
|> Safe.to_iodata()
|> IO.iodata_to_binary()
{:error, reason} ->
Logger.warning("Error rendering block component: #{inspect(reason)}")
"<div class='error'>Error rendering component</div>"
end
end
# Parse XML attribute string into a map
defp parse_xml_attributes(attrs_string) do
# Match key="value" or key='value' patterns
attr_regex = ~r/(\w+)=["']([^"']+)["']/
Regex.scan(attr_regex, attrs_string)
|> Enum.map(fn [_, key, value] -> {key, value} end)
|> Enum.into(%{})
end
@doc """
Invalidates cache for a specific post.
Called when a post is updated in the admin editor.
## Examples
Renderer.invalidate_cache("docs", "getting-started", "en")
"""
def invalidate_cache(blog_slug, identifier, language) do
# Build pattern to match all cache keys for this post
# We don't know the content hash, so we invalidate by prefix
pattern = "#{@cache_version}:blog_post:#{blog_slug}:#{identifier}:#{language}:"
# Since PhoenixKit.Cache doesn't support pattern matching,
# we'll just log this for now and rely on content hash changes
Logger.info("Cache invalidation requested",
blog: blog_slug,
identifier: identifier,
language: language,
pattern: pattern
)
# The content hash in the key will change automatically when content changes
# So we don't need to explicitly delete old entries
:ok
end
@doc """
Clears all blog post caches.
Useful for testing or when doing bulk updates.
"""
def clear_all_cache do
PhoenixKit.Cache.clear(@cache_name)
Logger.info("Cleared all blog post caches")
:ok
rescue
_ ->
Logger.warning("Blog cache not available for clearing")
:ok
end
@doc """
Clears the render cache for a specific blog.
Returns `{:ok, count}` with the number of entries cleared.
## Examples
Renderer.clear_group_cache("my-group")
# => {:ok, 15}
"""
@spec clear_group_cache(String.t()) :: {:ok, non_neg_integer()} | {:error, any()}
def clear_group_cache(group_slug) do
prefix = "#{@cache_version}:blog_post:#{group_slug}:"
case PhoenixKit.Cache.clear_by_prefix(@cache_name, prefix) do
{:ok, count} = result ->
Logger.info("Cleared #{count} cached posts for group: #{group_slug}")
result
{:error, _} = error ->
error
end
rescue
_ ->
Logger.warning("Group cache not available for clearing")
{:ok, 0}
end
@doc false
@deprecated "Use clear_group_cache/1 instead"
def clear_blog_cache(group_slug), do: clear_group_cache(group_slug)
# Private Functions
defp render_and_cache(post, cache_key) do
html = render_markdown(post.content)
# Cache the rendered HTML
put_cached(cache_key, html)
{:ok, html}
end
defp build_cache_key(post) do
# Build content hash from content + metadata
content_to_hash = post.content <> inspect(post.metadata)
content_hash =
:crypto.hash(:md5, content_to_hash)
|> Base.encode16(case: :lower)
|> String.slice(0..7)
identifier = post.slug || extract_identifier_from_path(post.path)
"#{@cache_version}:blog_post:#{post.group}:#{identifier}:#{post.language}:#{content_hash}"
end
defp extract_identifier_from_path(path) when is_binary(path) do
# For timestamp mode: "blog/2025-01-15/09:30/en.phk" -> "2025-01-15/09:30"
# For slug mode: "blog/getting-started/en.phk" -> "getting-started"
path
|> String.split("/")
# Remove language.phk
|> Enum.drop(-1)
# Remove blog name
|> Enum.drop(1)
|> Enum.join("/")
end
defp extract_identifier_from_path(_), do: "unknown"
defp get_cached(key) do
case PhoenixKit.Cache.get(@cache_name, key) do
nil -> :miss
html -> {:ok, html}
end
rescue
_ ->
# Cache not available (tests, compilation)
:miss
end
defp put_cached(key, value) do
PhoenixKit.Cache.put(@cache_name, key, value)
rescue
error ->
Logger.debug("Cache unavailable, skipping: #{inspect(error)}")
:ok
end
end