Packages
phoenix_kit
1.7.31
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/web/html.ex
defmodule PhoenixKit.Modules.Publishing.Web.HTML do
@moduledoc """
HTML rendering functions for Publishing.Web.Controller.
"""
use PhoenixKitWeb, :html
alias PhoenixKit.Config
alias PhoenixKit.Modules.Languages
alias PhoenixKit.Modules.Publishing.Renderer
alias PhoenixKit.Modules.Publishing.Storage, as: PublishingStorage
# Import publishing-specific components for templates
import PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher
embed_templates("templates/*")
@doc """
Builds the public URL for a blog listing page.
When multiple languages are enabled, always includes locale prefix.
When languages module is off or only one language, uses clean URLs.
"""
def blog_listing_path(language, blog_slug, params \\ []) do
segments =
if single_language_mode?(), do: [blog_slug], else: [language, blog_slug]
base_path = build_public_path(segments)
case params do
[] -> base_path
_ -> base_path <> "?" <> URI.encode_query(params)
end
end
@doc """
Builds a post URL based on mode.
When multiple languages are enabled, always includes locale prefix.
When languages module is off or only one language, uses clean URLs.
For slug mode posts, uses the language-specific URL slug (from post.url_slug
or post.language_slugs[language]) for SEO-friendly localized URLs.
For timestamp mode posts:
- If only one post exists on the date, uses date-only URL (e.g., /blog/2025-12-09)
- If multiple posts exist on the date, includes time (e.g., /blog/2025-12-09/16:26)
"""
def build_post_url(blog_slug, post, language) do
case post.mode do
:slug ->
# Use language-specific URL slug for SEO-friendly localized URLs
url_slug = get_url_slug_for_language(post, language)
segments =
if single_language_mode?(),
do: [blog_slug, url_slug],
else: [language, blog_slug, url_slug]
build_public_path(segments)
:timestamp ->
# For timestamp mode, use the date/time from the directory structure
# (stored in post.date and post.time), not from metadata.published_at
date = get_timestamp_date(post)
# Check if we need time in URL (only if multiple posts on same date)
post_count = count_posts_on_date(blog_slug, date)
segments =
if post_count > 1 do
# Multiple posts - include time
time = get_timestamp_time(post)
if single_language_mode?(),
do: [blog_slug, date, time],
else: [language, blog_slug, date, time]
else
# Single post or no posts - date only
if single_language_mode?(),
do: [blog_slug, date],
else: [language, blog_slug, date]
end
build_public_path(segments)
_ ->
# Use language-specific URL slug for fallback mode as well
url_slug = get_url_slug_for_language(post, language)
segments =
if single_language_mode?(),
do: [blog_slug, url_slug],
else: [language, blog_slug, url_slug]
build_public_path(segments)
end
end
# Gets the URL slug for a specific language
# Priority:
# 1. Direct url_slug field on post (set by controller for specific language)
# 2. language_slugs map (from cache, contains all languages)
# 3. metadata.url_slug (from file, current language only)
# 4. post.slug (directory name fallback)
defp get_url_slug_for_language(post, language) do
cond do
# Direct url_slug on post (highest priority, set by controller)
Map.get(post, :url_slug) not in [nil, ""] ->
post.url_slug
# language_slugs map from cache
map_size(Map.get(post, :language_slugs, %{})) > 0 ->
Map.get(post.language_slugs, language, post.slug)
# metadata.url_slug
is_map(Map.get(post, :metadata)) and Map.get(post.metadata, :url_slug) not in [nil, ""] ->
post.metadata.url_slug
# Default to directory slug
true ->
post.slug
end
end
@doc """
Builds a public path with explicit date and time (always includes time).
Used when redirecting from date-only URLs to full timestamp URLs.
"""
def build_public_path_with_time(language, blog_slug, date, time) do
segments =
if single_language_mode?(),
do: [blog_slug, date, time],
else: [language, blog_slug, date, time]
build_public_path(segments)
end
@doc """
Formats a date for display.
"""
def format_date(datetime) when is_struct(datetime, DateTime) do
datetime
|> DateTime.to_date()
|> Calendar.strftime("%B %d, %Y")
end
def format_date(datetime_string) when is_binary(datetime_string) do
case DateTime.from_iso8601(datetime_string) do
{:ok, datetime, _} ->
datetime
|> DateTime.to_date()
|> Calendar.strftime("%B %d, %Y")
_ ->
datetime_string
end
end
def format_date(_), do: ""
@doc """
Formats a date with time for display.
Used when multiple posts exist on the same date.
"""
def format_date_with_time(datetime) when is_struct(datetime, DateTime) do
Calendar.strftime(datetime, "%B %d, %Y at %H:%M")
end
def format_date_with_time(datetime_string) when is_binary(datetime_string) do
case DateTime.from_iso8601(datetime_string) do
{:ok, datetime, _} ->
Calendar.strftime(datetime, "%B %d, %Y at %H:%M")
_ ->
datetime_string
end
end
def format_date_with_time(_), do: ""
@doc """
Checks if a post has a publication date to display.
For timestamp mode, the date comes from the directory structure.
For slug mode, it comes from metadata.published_at.
"""
def has_publication_date?(post) do
case post.mode do
:timestamp ->
# Timestamp mode always has a date (from directory structure)
post[:date] != nil
_ ->
# Slug mode uses metadata.published_at
published_at = get_in(post, [:metadata, :published_at])
published_at != nil and published_at != ""
end
end
@doc """
Formats a post's publication date, including time only when multiple posts exist on the same date.
"""
def format_post_date(post, blog_slug) do
case post.mode do
:timestamp ->
# For timestamp mode, use date/time from directory structure
date = get_timestamp_date(post)
post_count = count_posts_on_date(blog_slug, date)
if post_count > 1 do
format_timestamp_date_with_time(post)
else
format_timestamp_date(post)
end
_ ->
format_date(post.metadata.published_at)
end
end
@doc """
Formats a date for URL.
"""
def format_date_for_url(datetime) when is_struct(datetime, DateTime) do
datetime
|> DateTime.to_date()
|> Date.to_iso8601()
end
def format_date_for_url(datetime_string) when is_binary(datetime_string) do
case DateTime.from_iso8601(datetime_string) do
{:ok, datetime, _} ->
datetime
|> DateTime.to_date()
|> Date.to_iso8601()
_ ->
"2025-01-01"
end
end
def format_date_for_url(_), do: "2025-01-01"
@doc """
Formats time for URL (HH:MM).
"""
def format_time_for_url(datetime) when is_struct(datetime, DateTime) do
datetime
|> DateTime.to_time()
|> Time.truncate(:second)
|> Time.to_string()
|> String.slice(0..4)
end
def format_time_for_url(datetime_string) when is_binary(datetime_string) do
case DateTime.from_iso8601(datetime_string) do
{:ok, datetime, _} ->
datetime
|> DateTime.to_time()
|> Time.truncate(:second)
|> Time.to_string()
|> String.slice(0..4)
_ ->
"00:00"
end
end
def format_time_for_url(_), do: "00:00"
@doc """
Pluralizes a word based on count.
"""
def pluralize(1, singular, _plural), do: "1 #{singular}"
def pluralize(count, _singular, plural), do: "#{count} #{plural}"
@doc """
Extracts and renders an excerpt from post content.
Returns content before <!-- more --> tag, or first paragraph if no tag.
Renders markdown and strips HTML tags for plain text display.
"""
def extract_excerpt(content) when is_binary(content) do
excerpt_markdown =
if String.contains?(content, "<!-- more -->") do
# Extract content before <!-- more --> tag
content
|> String.split("<!-- more -->")
|> List.first()
|> String.trim()
else
# Get first paragraph (content before first double newline)
content
|> String.split(~r/\n\s*\n/, parts: 2)
|> List.first()
|> String.trim()
end
# Render markdown to HTML
html = Renderer.render_markdown(excerpt_markdown)
# Strip HTML tags to get plain text
html
|> Phoenix.HTML.raw()
|> Phoenix.HTML.safe_to_string()
|> strip_html_tags()
|> String.trim()
end
def extract_excerpt(_), do: ""
defp strip_html_tags(html) when is_binary(html) do
html
|> String.replace(~r/<[^>]*>/, " ")
|> String.replace(~r/\s+/, " ")
|> String.trim()
end
# Formats a timestamp post's date for display (e.g., "December 31, 2025")
defp format_timestamp_date(post) do
cond do
is_struct(post[:date], Date) ->
Calendar.strftime(post.date, "%B %d, %Y")
is_binary(post[:date]) ->
case Date.from_iso8601(post.date) do
{:ok, date} -> Calendar.strftime(date, "%B %d, %Y")
_ -> post.date
end
true ->
format_date(post.metadata.published_at)
end
end
# Formats a timestamp post's date with time for display (e.g., "December 31, 2025 at 03:42")
defp format_timestamp_date_with_time(post) do
date_str = format_timestamp_date(post)
time_str = get_timestamp_time(post)
"#{date_str} at #{time_str}"
end
# Gets the date for a timestamp-mode post from post.date field (directory structure)
# Falls back to metadata.published_at if post.date not available
defp get_timestamp_date(post) do
cond do
# Use post.date from directory structure (e.g., Date struct or "2025-12-31")
is_struct(post[:date], Date) ->
Date.to_iso8601(post.date)
is_binary(post[:date]) ->
post.date
# Fallback to metadata.published_at if no post.date
true ->
format_date_for_url(post.metadata.published_at)
end
end
# Gets the time for a timestamp-mode post from post.time field (directory structure)
# Falls back to metadata.published_at if post.time not available
defp get_timestamp_time(post) do
cond do
# Use post.time from directory structure (e.g., "03:42" or ~T[03:42:00])
is_struct(post[:time], Time) ->
post.time |> Time.to_string() |> String.slice(0..4)
is_binary(post[:time]) ->
# Ensure format is HH:MM (5 chars)
String.slice(post.time, 0..4)
# Fallback to metadata.published_at if no post.time
true ->
format_time_for_url(post.metadata.published_at)
end
end
defp build_public_path(segments) do
parts =
url_prefix_segments() ++
(segments
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.map(&to_string/1))
case parts do
[] -> "/"
_ -> "/" <> Enum.join(parts, "/")
end
end
defp url_prefix_segments do
Config.get_url_prefix()
|> case do
"/" -> []
prefix -> prefix |> String.trim("/") |> String.split("/", trim: true)
end
end
# Check if we're in single language mode (no locale prefix needed)
# Returns true when languages module is off OR only one language is enabled
defp single_language_mode? do
not Languages.enabled?() or length(Languages.get_enabled_languages()) <= 1
end
# Counts posts on a given date for a blog
# Used to determine if time should be included in URLs
defp count_posts_on_date(blog_slug, date) do
PublishingStorage.count_posts_on_date(blog_slug, date)
end
@doc """
Resolves a featured image URL for a post, falling back to the original variant.
"""
def featured_image_url(post, variant \\ "medium") do
post.metadata
|> Map.get(:featured_image_id)
|> resolve_featured_image_url(variant)
end
defp resolve_featured_image_url(nil, _variant), do: nil
defp resolve_featured_image_url("", _variant), do: nil
defp resolve_featured_image_url(file_id, variant) when is_binary(file_id) do
PhoenixKit.Modules.Storage.get_public_url_by_id(file_id, variant) ||
PhoenixKit.Modules.Storage.get_public_url_by_id(file_id)
rescue
_ -> nil
end
@doc """
Builds language data for the publishing_language_switcher component on public pages.
Converts the @translations assign to the format expected by the component.
"""
def build_public_translations(translations, _current_language) do
Enum.map(translations, fn translation ->
%{
code: translation[:code] || translation.code,
display_code: translation[:display_code] || translation[:code] || translation.code,
name: translation[:name] || translation.name,
flag: translation[:flag] || "",
url: translation[:url] || translation.url,
status: "published",
exists: true
}
end)
end
end