Packages

phoenix_kit

1.7.41
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
phoenix_kit lib modules publishing web controller post_rendering.ex
Raw

lib/modules/publishing/web/controller/post_rendering.ex

defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do
@moduledoc """
Post rendering functionality for the publishing controller.
Handles rendering individual posts including:
- Content rendering with caching
- Versioned post display
- Date-only URL handling
- Version dropdown building
"""
require Logger
alias PhoenixKit.Modules.Publishing
alias PhoenixKit.Modules.Publishing.ListingCache
alias PhoenixKit.Modules.Publishing.Renderer
alias PhoenixKit.Modules.Publishing.Storage
alias PhoenixKit.Modules.Publishing.Web.Controller.Language
alias PhoenixKit.Modules.Publishing.Web.Controller.Listing
alias PhoenixKit.Modules.Publishing.Web.Controller.PostFetching
alias PhoenixKit.Modules.Publishing.Web.Controller.SlugResolution
alias PhoenixKit.Modules.Publishing.Web.Controller.Translations
alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML
# Suppress dialyzer false positive for defensive fallback pattern
@dialyzer {:nowarn_function, render_post_content: 1}
# ============================================================================
# Main Rendering Functions
# ============================================================================
@doc """
Renders a post after resolving URL slugs.
"""
def render_post(conn, blog_slug, identifier, language) do
# For slug mode, resolve URL slug to internal slug first
# This enables per-language URL slugs and 301 redirects for old slugs
case SlugResolution.resolve_url_slug(blog_slug, identifier, language) do
{:redirect, redirect_url} ->
# Old URL slug - 301 redirect to current URL
{:redirect_301, redirect_url}
{:ok, resolved_identifier} ->
render_resolved_post(conn, blog_slug, resolved_identifier, language)
:passthrough ->
render_resolved_post(conn, blog_slug, identifier, language)
end
end
@doc """
Renders a post after identifier has been resolved.
"""
def render_resolved_post(conn, blog_slug, identifier, language) do
case PostFetching.fetch_post(blog_slug, identifier, language) do
{:ok, post} ->
# Check if published
if post.metadata.status == "published" do
# Check if we need to redirect to canonical URL
# The canonical URL uses the display_code (base or full dialect depending on enabled languages)
canonical_language = Language.get_canonical_url_language_for_post(post.language)
if canonical_language != language do
# Redirect to canonical URL
canonical_url = PublishingHTML.build_post_url(blog_slug, post, canonical_language)
{:redirect, canonical_url}
else
# Render markdown (cached for published posts)
html_content = render_post_content(post)
# Build translation links
translations =
Translations.build_translation_links(blog_slug, post, canonical_language)
# Build breadcrumbs
breadcrumbs = build_breadcrumbs(blog_slug, post, canonical_language)
# Build version dropdown data if allowed
version_dropdown = build_version_dropdown(blog_slug, post, canonical_language)
{:ok,
%{
page_title: post.metadata.title,
blog_slug: blog_slug,
post: post,
html_content: html_content,
current_language: canonical_language,
translations: translations,
breadcrumbs: breadcrumbs,
version_dropdown: version_dropdown
}}
end
else
log_404(conn, blog_slug, identifier, language, :unpublished)
{:error, :unpublished}
end
{:error, reason} ->
log_404(conn, blog_slug, identifier, language, reason)
{:error, reason}
end
end
@doc """
Renders a specific version of a post (for version browsing feature).
"""
def render_versioned_post(conn, blog_slug, url_slug, version, language) do
# Resolve URL slug to internal slug (handles per-language custom slugs)
internal_slug = SlugResolution.resolve_url_slug_to_internal(blog_slug, url_slug, language)
# Check per-post version access setting (from the live version's metadata)
# Each post controls its own version access - no global setting required
if post_allows_version_access?(blog_slug, internal_slug, language) do
# Resolve language to actual file language (e.g., "en" -> "en-US")
# This matches the behavior in PostFetching.fetch_post
version_dir = Path.join([Storage.group_path(blog_slug), internal_slug, "v#{version}"])
available_languages = PostFetching.detect_available_languages_in_dir(version_dir)
resolved_language = Language.resolve_language_for_post(language, available_languages)
# Fetch the specific version with resolved language
case Publishing.read_post(blog_slug, internal_slug, resolved_language, version) do
{:ok, post} ->
# Check if version is published
if post.metadata.status == "published" do
# Get canonical language
canonical_language = Language.get_canonical_url_language_for_post(post.language)
# Render markdown (cached for published posts)
html_content = render_post_content(post)
# Build translation links (preserve version in URLs)
translations =
Translations.build_translation_links(blog_slug, post, canonical_language,
version: version
)
# Build breadcrumbs
breadcrumbs = build_breadcrumbs(blog_slug, post, canonical_language)
# Build canonical URL (points to main post URL, not versioned URL)
canonical_url = PublishingHTML.build_post_url(blog_slug, post, canonical_language)
# Build version dropdown data (also gives us the live version)
version_dropdown = build_version_dropdown(blog_slug, post, canonical_language)
# Check if this is the live version by comparing to the published version
# (is_live field was removed from metadata, now derived from status)
{_allow_access, live_version} = get_cached_version_info(blog_slug, post)
is_live = version == live_version
{:ok,
%{
page_title: post.metadata.title,
blog_slug: blog_slug,
post: post,
html_content: html_content,
current_language: canonical_language,
translations: translations,
breadcrumbs: breadcrumbs,
canonical_url: canonical_url,
is_versioned_view: true,
is_live_version: is_live,
version: version,
version_dropdown: version_dropdown
}}
else
log_404(conn, blog_slug, {:slug, internal_slug, version}, language, :unpublished)
{:error, :unpublished}
end
{:error, reason} ->
log_404(conn, blog_slug, {:slug, internal_slug, version}, language, reason)
{:error, reason}
end
else
{:error, :version_access_disabled}
end
end
@doc """
Handles date-only URLs (e.g., /blog/2025-12-09).
If only one post exists on that date, render it directly.
If multiple posts exist, redirect to the first one with time in URL.
"""
def handle_date_only_url(conn, blog_slug, date, language) do
case Listing.fetch_blog(blog_slug) do
{:ok, _blog} ->
times = Storage.list_times_on_date(blog_slug, date)
case times do
[] ->
# No posts on this date
{:error, :post_not_found}
[single_time] ->
# Only one post - render it directly
render_post(conn, blog_slug, {:timestamp, date, single_time}, language)
[first_time | _rest] ->
# Multiple posts - redirect to first one with time in URL
canonical_language = Language.get_canonical_url_language(language)
redirect_url = build_timestamp_url(blog_slug, date, first_time, canonical_language)
{:redirect, redirect_url}
end
{:error, reason} ->
{:error, reason}
end
end
# ============================================================================
# Content Rendering
# ============================================================================
@doc """
Renders post content with caching for published posts.
Uses Renderer.render_post/1 which caches based on content hash.
"""
def render_post_content(post) do
case Renderer.render_post(post) do
{:ok, html} -> html
# Fallback to uncached rendering if render_post returns unexpected format
_ -> Renderer.render_markdown(post.content)
end
end
# ============================================================================
# Version Dropdown
# ============================================================================
@doc """
Builds version dropdown data for the public post template.
Returns nil if version access is disabled or only one published version exists.
Uses listing cache for fast lookups instead of reading files.
"""
def build_version_dropdown(blog_slug, post, language) do
# Try to get cached data first (sub-microsecond from :persistent_term)
# The cache stores the live version with all version metadata
{allow_access, live_version} = get_cached_version_info(blog_slug, post)
version_statuses = Map.get(post, :version_statuses, %{})
current_version = Map.get(post, :version, 1)
if allow_access and map_size(version_statuses) > 0 do
# Filter to only published versions
published_versions =
version_statuses
|> Enum.filter(fn {_v, status} -> status == "published" end)
|> Enum.map(fn {v, _status} -> v end)
|> Enum.sort(:desc)
# Only show dropdown if there are multiple published versions
if length(published_versions) > 1 do
versions_with_urls =
Enum.map(published_versions, fn version ->
url = build_version_url(blog_slug, post, language, version)
%{
version: version,
url: url,
is_current: version == current_version,
is_live: version == live_version
}
end)
%{
versions: versions_with_urls,
current_version: current_version
}
else
nil
end
else
nil
end
end
@doc """
Gets version info from cache (allow_version_access and live_version).
Falls back to file reads if cache miss.
"""
def get_cached_version_info(blog_slug, current_post) do
# Use appropriate cache lookup based on post mode
cache_result = find_cached_post(blog_slug, current_post)
case cache_result do
{:ok, cached_post} ->
# Cache stores the live version's metadata
allow_access = Map.get(cached_post.metadata, :allow_version_access, false)
live_version = cached_post.version
{allow_access, live_version}
{:error, _} ->
# Cache miss - fall back to file reads
post_identifier = get_post_identifier(current_post)
# Use post's stored primary language, not global
primary_language =
current_post[:primary_language] ||
Storage.get_post_primary_language(blog_slug, post_identifier)
allow_access = get_allow_access_from_file(blog_slug, current_post, primary_language)
live_version = get_live_version_from_file(blog_slug, post_identifier)
{allow_access, live_version}
end
end
# Find cached post using appropriate method based on post mode
defp find_cached_post(blog_slug, post) do
case Map.get(post, :mode) do
:timestamp ->
# For timestamp mode, use date/time lookup
date = post[:date]
time = post[:time]
if date && time do
date_str = if is_struct(date, Date), do: Date.to_iso8601(date), else: to_string(date)
time_str = format_time_for_cache(time)
ListingCache.find_post_by_path(blog_slug, date_str, time_str)
else
{:error, :not_found}
end
_ ->
# For slug mode, use slug lookup
ListingCache.find_post(blog_slug, post.slug)
end
end
defp format_time_for_cache(%Time{} = time) do
time |> Time.to_string() |> String.slice(0, 5)
end
defp format_time_for_cache(time) when is_binary(time), do: String.slice(time, 0, 5)
defp format_time_for_cache(_), do: ""
# Get the correct post identifier based on mode
defp get_post_identifier(post) do
case Map.get(post, :mode) do
:timestamp -> extract_timestamp_identifier(post.path)
_ -> post.slug
end
end
# Extract timestamp identifier (date/time) from a timestamp mode path
defp extract_timestamp_identifier(path) when is_binary(path) do
case Regex.run(~r/(\d{4}-\d{2}-\d{2}\/\d{2}:\d{2})/, path) do
[_, timestamp] -> timestamp
nil -> path
end
end
defp extract_timestamp_identifier(path), do: path
# Fallback: Gets allow_version_access from file when cache misses
defp get_allow_access_from_file(blog_slug, current_post, primary_language) do
if current_post.language == primary_language do
Map.get(current_post.metadata, :allow_version_access, false)
else
post_identifier = get_post_identifier(current_post)
case Publishing.read_post(blog_slug, post_identifier, primary_language, nil) do
{:ok, primary_post} -> Map.get(primary_post.metadata, :allow_version_access, false)
{:error, _} -> false
end
end
end
# Fallback: Gets published version from file when cache misses
defp get_live_version_from_file(blog_slug, post_identifier) do
case Storage.get_published_version(blog_slug, post_identifier) do
{:ok, version} -> version
{:error, _} -> nil
end
end
@doc """
Checks if a specific post allows public access to older versions.
Always reads from the primary language's live version to ensure consistency.
"""
def post_allows_version_access?(blog_slug, post_slug, _language) do
# Always read from post's stored primary language to ensure per-post behavior
primary_language = Storage.get_post_primary_language(blog_slug, post_slug)
# Read the live version (version: nil means get latest/live)
case Publishing.read_post(blog_slug, post_slug, primary_language, nil) do
{:ok, post} ->
Map.get(post.metadata, :allow_version_access, false)
{:error, _} ->
# If we can't read the live version, deny access
false
end
end
# ============================================================================
# URL Building
# ============================================================================
@doc """
Builds URL for a specific version of a post.
"""
def build_version_url(blog_slug, post, language, version) do
base_url = PublishingHTML.build_post_url(blog_slug, post, language)
"#{base_url}/v/#{version}"
end
@doc """
Builds a timestamp URL with date and time.
"""
def build_timestamp_url(blog_slug, date, time, language) do
PublishingHTML.build_public_path_with_time(language, blog_slug, date, time)
end
# ============================================================================
# Breadcrumbs
# ============================================================================
@doc """
Builds breadcrumbs for a post page.
"""
def build_breadcrumbs(blog_slug, post, language) do
blog_name =
case Listing.fetch_blog(blog_slug) do
{:ok, blog} -> blog["name"]
{:error, _} -> blog_slug
end
[
%{label: blog_name, url: PublishingHTML.blog_listing_path(language, blog_slug)},
%{label: post.metadata.title, url: nil}
]
end
# ============================================================================
# Logging
# ============================================================================
defp log_404(conn, blog_slug, identifier, language, reason) do
Logger.info("Publishing 404",
blog_slug: blog_slug,
identifier: inspect(identifier),
reason: reason,
language: language,
user_agent: Plug.Conn.get_req_header(conn, "user-agent") |> List.first(),
path: conn.request_path
)
end
end