Packages
phoenix_kit
1.7.15
1.7.208
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/phoenix_kit_web/controllers/blog_controller.ex
defmodule PhoenixKitWeb.BlogController do
@moduledoc """
Public blog post display controller.
Handles public-facing routes for viewing published blog posts with multi-language support.
URL patterns:
/:language/:blog_slug/:post_slug - Slug mode post
/:language/:blog_slug/:date/:time - Timestamp mode post
/:language/:blog_slug - Blog listing
"""
use PhoenixKitWeb, :controller
require Logger
alias PhoenixKit.Blogging.Renderer
alias PhoenixKit.Modules.Languages
alias PhoenixKit.Modules.Languages.DialectMapper
alias PhoenixKit.Settings
alias PhoenixKitWeb.BlogHTML
alias PhoenixKitWeb.Live.Modules.Blogging
alias PhoenixKitWeb.Live.Modules.Blogging.Metadata
alias PhoenixKitWeb.Live.Modules.Blogging.Storage
@doc """
Displays a blog post, blog listing, or all blogs overview.
Path parsing determines which action to take:
- [] -> Invalid request (no blog specified)
- [blog_slug] -> Blog listing
- [blog_slug, post_slug] -> Slug mode post
- [blog_slug, date] -> Date-only timestamp (resolves to single post or first post)
- [blog_slug, date, time] -> Timestamp mode post
"""
def show(conn, %{"language" => language_param} = params) do
# Detect if 'language' param is actually a language code or a blog slug
# This allows the same route to work for both single and multi-language setups
{language, adjusted_params} = detect_language_or_blog(language_param, params)
conn = assign(conn, :current_language, language)
if Blogging.enabled?() and public_enabled?() do
case build_segments(adjusted_params) do
[] ->
handle_not_found(conn, :invalid_path)
segments ->
case parse_path(segments) do
{:listing, blog_slug} ->
render_blog_listing(conn, blog_slug, language, conn.params)
{:slug_post, blog_slug, post_slug} ->
render_post(conn, blog_slug, {:slug, post_slug}, language)
{:timestamp_post, blog_slug, date, time} ->
render_post(conn, blog_slug, {:timestamp, date, time}, language)
{:date_only_post, blog_slug, date} ->
handle_date_only_url(conn, blog_slug, date, language)
{:error, reason} ->
handle_not_found(conn, reason)
end
end
else
handle_not_found(conn, :module_disabled)
end
end
# Fallback for routes without language parameter
# This handles the non-localized route where :blog might actually be a language code
def show(conn, params) do
if Blogging.enabled?() and public_enabled?() do
# Check if the first segment (blog) is actually a language with content
case detect_language_in_blog_param(params) do
{:language_detected, language, adjusted_params} ->
# First segment was a language code with content - use localized logic
conn = assign(conn, :current_language, language)
handle_localized_request(conn, language, adjusted_params)
:not_a_language ->
# First segment is a blog slug - use default language
language = get_default_language()
conn = assign(conn, :current_language, language)
handle_non_localized_request(conn, language, params)
end
else
handle_not_found(conn, :module_disabled)
end
end
# Handles request after language has been detected
defp handle_localized_request(conn, language, params) do
case build_segments(params) do
[] ->
handle_not_found(conn, :invalid_path)
segments ->
case parse_path(segments) do
{:listing, blog_slug} ->
render_blog_listing(conn, blog_slug, language, conn.params)
{:slug_post, blog_slug, post_slug} ->
render_post(conn, blog_slug, {:slug, post_slug}, language)
{:timestamp_post, blog_slug, date, time} ->
render_post(conn, blog_slug, {:timestamp, date, time}, language)
{:date_only_post, blog_slug, date} ->
handle_date_only_url(conn, blog_slug, date, language)
{:error, reason} ->
handle_not_found(conn, reason)
end
end
end
# Handles non-localized request with default language
defp handle_non_localized_request(conn, language, params) do
case build_segments(params) do
[] ->
handle_not_found(conn, :invalid_path)
segments ->
case parse_path(segments) do
{:listing, blog_slug} ->
render_blog_listing(conn, blog_slug, language, conn.params)
{:slug_post, blog_slug, post_slug} ->
render_post(conn, blog_slug, {:slug, post_slug}, language)
{:timestamp_post, blog_slug, date, time} ->
render_post(conn, blog_slug, {:timestamp, date, time}, language)
{:date_only_post, blog_slug, date} ->
handle_date_only_url(conn, blog_slug, date, language)
{:error, reason} ->
handle_not_found(conn, reason)
end
end
end
# Detects if the "blog" param is actually a language code by checking if content exists
# Returns {:language_detected, language, adjusted_params} or :not_a_language
defp detect_language_in_blog_param(
%{"blog" => potential_lang, "path" => [_ | _] = path} = _params
)
when is_binary(potential_lang) do
[actual_blog | rest_path] = path
blog_exists = blog_exists?(actual_blog)
has_content = has_content_for_language?(actual_blog, potential_lang)
# Check if there's a blog with slug matching actual_blog
# AND if there's content for potential_lang in that blog
if blog_exists and has_content do
adjusted_params = %{"blog" => actual_blog, "path" => rest_path}
{:language_detected, potential_lang, adjusted_params}
else
:not_a_language
end
end
defp detect_language_in_blog_param(_params), do: :not_a_language
# Check if any post in the blog has content for the given language
defp has_content_for_language?(blog_slug, language) do
posts = Blogging.list_posts(blog_slug, nil)
Enum.any?(posts, fn post ->
language in (post.available_languages || [])
end)
rescue
_ -> false
end
# ============================================================================
# Language Detection
# ============================================================================
# Detects whether the 'language' parameter is actually a language code or a blog slug.
#
# This allows the same route pattern (/:language/:blog/*path) to work for both:
# - Multi-language: /en/my-blog/my-post (language=en, blog=my-blog)
# - Single-language: /my-blog/my-post (language=my-blog, needs adjustment)
#
# Returns {detected_language, adjusted_params}
defp detect_language_or_blog(language_param, params) do
# First check if it's a known/predefined language
# Then check if content exists for this language in the blog (handles unknown languages like "af")
blog_slug = params["blog"]
cond do
# Known/predefined language - use as-is
valid_language?(language_param) ->
{language_param, params}
# Unknown language code but content exists for it in this blog
# This handles files like af.phk, test.phk, etc.
blog_slug && has_content_for_language?(blog_slug, language_param) ->
{language_param, params}
# Not a language - shift parameters (blog slug in language position)
true ->
default_language = get_default_language()
adjusted_params =
case params do
# Pattern: %{"language" => blog_slug, "blog" => first_path_segment, "path" => rest}
%{"blog" => first_segment, "path" => rest} when is_list(rest) ->
%{"blog" => language_param, "path" => [first_segment | rest]}
# Pattern: %{"language" => blog_slug, "blog" => first_path_segment}
%{"blog" => first_segment} ->
%{"blog" => language_param, "path" => [first_segment]}
# Pattern: %{"language" => blog_slug} (just listing)
_ ->
%{"blog" => language_param}
end
{default_language, adjusted_params}
end
end
defp valid_language?(code) when is_binary(code) do
alias PhoenixKit.Modules.Languages.DialectMapper
# Check if it's a language code pattern (enabled, disabled, or even unknown)
# This allows access to legacy content in disabled languages
cond do
# Enabled language - definitely valid
Languages.language_enabled?(code) ->
true
# Base code that maps to an enabled dialect
String.length(code) == 2 and not String.contains?(code, "-") ->
dialect = DialectMapper.base_to_dialect(code)
if Languages.language_enabled?(dialect) do
true
else
# Even if disabled, it's still a valid language code pattern
# Check if it's a known language
Languages.get_predefined_language(dialect) != nil
end
# Known but disabled language (full dialect like "fr-FR")
Languages.get_predefined_language(code) != nil ->
true
# Check if it looks like a language code pattern (XX or XX-XX format)
# This allows access to unknown files like legacy imports
looks_like_language_code?(code) ->
true
true ->
false
end
rescue
_ -> false
end
defp valid_language?(_), do: false
# Check if a string looks like a language code pattern
# Matches: 2-letter codes (en, fr), or dialect codes (en-US, pt-BR)
defp looks_like_language_code?(code) when is_binary(code) do
# 2-letter base code
# Dialect code pattern (xx-XX or xx-XXX)
(String.length(code) == 2 and String.match?(code, ~r/^[a-z]{2}$/i)) or
String.match?(code, ~r/^[a-z]{2,3}-[A-Za-z]{2,4}$/i)
end
# ============================================================================
# Path Parsing
# ============================================================================
defp build_segments(%{"blog" => blog} = params) when is_binary(blog) do
case Map.get(params, "path") do
nil -> [blog]
path when is_list(path) -> [blog | path]
path when is_binary(path) -> [blog, path]
_ -> [blog]
end
end
defp build_segments(_), do: []
defp parse_path([]), do: {:error, :invalid_path}
defp parse_path([blog_slug]), do: {:listing, blog_slug}
defp parse_path([blog_slug, segment1, segment2]) do
# Check if this is timestamp mode: segment1 matches date, segment2 matches time
if date?(segment1) and time?(segment2) do
{:timestamp_post, blog_slug, segment1, segment2}
else
# Invalid format
{:error, :invalid_path}
end
end
defp parse_path([blog_slug, segment]) do
# Check if segment is a date (for date-only timestamp URLs)
# If it's a date, treat as date-only timestamp post
# Otherwise, treat as slug mode post
if date?(segment) do
{:date_only_post, blog_slug, segment}
else
{:slug_post, blog_slug, segment}
end
end
defp parse_path(_), do: {:error, :invalid_path}
# Date validation: YYYY-MM-DD
defp date?(str) when is_binary(str) do
String.match?(str, ~r/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/)
end
defp date?(_), do: false
# Time validation: HH:MM (24-hour)
defp time?(str) when is_binary(str) do
String.match?(str, ~r/^([01]\d|2[0-3]):[0-5]\d$/)
end
defp time?(_), do: false
# ============================================================================
# Rendering Functions
# ============================================================================
defp render_blog_listing(conn, blog_slug, language, params) do
case fetch_blog(blog_slug) do
{:ok, blog} ->
# Check if we need to redirect to canonical URL
canonical_language = get_canonical_url_language(language)
if canonical_language != language do
# Redirect to canonical URL
canonical_url = BlogHTML.blog_listing_path(canonical_language, blog_slug, params)
redirect(conn, to: canonical_url)
else
page = get_page_param(params)
per_page = get_per_page_setting()
# List posts that have this EXACT language file and are published
all_posts =
Blogging.list_posts(blog_slug, language)
|> filter_published()
|> filter_by_exact_language(blog_slug, language)
# If no posts exist for this language, return 404
# This prevents empty blog listing pages for languages without content
if all_posts == [] do
handle_not_found(conn, :no_content_for_language)
else
total_count = length(all_posts)
posts = paginate(all_posts, page, per_page)
breadcrumbs = [
%{label: blog["name"], url: nil}
]
# Build translation links for blog listing
translations = build_listing_translations(blog_slug, language)
conn
|> assign(:page_title, blog["name"])
|> assign(:blog, blog)
|> assign(:posts, posts)
|> assign(:current_language, canonical_language)
|> assign(:translations, translations)
|> assign(:page, page)
|> assign(:per_page, per_page)
|> assign(:total_count, total_count)
|> assign(:total_pages, ceil(total_count / per_page))
|> assign(:breadcrumbs, breadcrumbs)
|> render(:index)
end
end
{:error, reason} ->
handle_not_found(conn, reason)
end
end
defp render_post(conn, blog_slug, identifier, language) do
case 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 = get_canonical_url_language_for_post(post.language)
if canonical_language != language do
# Redirect to canonical URL
canonical_url = BlogHTML.build_post_url(blog_slug, post, canonical_language)
redirect(conn, to: canonical_url)
else
# Render markdown
html_content = render_markdown(post.content)
# Build translation links
translations = build_translation_links(blog_slug, post, canonical_language)
# Build breadcrumbs
breadcrumbs = build_breadcrumbs(blog_slug, post, canonical_language)
conn
|> assign(:page_title, post.metadata.title)
|> assign(:blog_slug, blog_slug)
|> assign(:post, post)
|> assign(:html_content, html_content)
|> assign(:current_language, canonical_language)
|> assign(:translations, translations)
|> assign(:breadcrumbs, breadcrumbs)
|> render(:show)
end
else
log_404(conn, blog_slug, identifier, language, :unpublished)
handle_not_found(conn, :unpublished)
end
{:error, reason} ->
log_404(conn, blog_slug, identifier, language, reason)
handle_not_found(conn, reason)
end
end
# 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
defp handle_date_only_url(conn, blog_slug, date, language) do
case fetch_blog(blog_slug) do
{:ok, _blog} ->
times = Storage.list_times_on_date(blog_slug, date)
case times do
[] ->
# No posts on this date
handle_not_found(conn, :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 = get_canonical_url_language(language)
redirect_url = build_timestamp_url(blog_slug, date, first_time, canonical_language)
redirect(conn, to: redirect_url)
end
{:error, reason} ->
handle_not_found(conn, reason)
end
end
# Builds a timestamp URL with date and time
defp build_timestamp_url(blog_slug, date, time, language) do
BlogHTML.build_public_path_with_time(language, blog_slug, date, time)
end
# ============================================================================
# Helper Functions
# ============================================================================
defp fetch_blog(blog_slug) do
blog_slug = blog_slug |> to_string() |> String.trim()
case Enum.find(Blogging.list_blogs(), fn blog ->
case blog["slug"] do
slug when is_binary(slug) ->
String.downcase(slug) == String.downcase(blog_slug)
_ ->
false
end
end) do
nil -> {:error, :blog_not_found}
blog -> {:ok, blog}
end
end
defp fetch_post(blog_slug, {:slug, post_slug}, language) do
case Blogging.read_post(blog_slug, post_slug, language) do
{:ok, post} -> {:ok, post}
_ -> {:error, :post_not_found}
end
end
defp fetch_post(blog_slug, {:timestamp, date, time}, language) do
# First, detect available languages for this post
post_dir = Path.join([Storage.root_path(), blog_slug, date, time])
available_languages = detect_available_languages_in_dir(post_dir)
# Resolve the language - find matching dialect in available languages
resolved_language = resolve_language_for_post(language, available_languages)
# Build path for timestamp mode: blog/date/time/language.phk
path = "#{blog_slug}/#{date}/#{time}/#{resolved_language}.phk"
case Blogging.read_post(blog_slug, path) do
{:ok, post} -> {:ok, post}
_ -> {:error, :post_not_found}
end
end
# Detect available language files in a directory
defp detect_available_languages_in_dir(dir_path) do
case File.ls(dir_path) do
{:ok, files} ->
files
|> Enum.filter(&String.ends_with?(&1, ".phk"))
|> Enum.map(&String.replace_suffix(&1, ".phk", ""))
{:error, _} ->
[]
end
end
# Resolve a language code to an actual file language
# Handles base codes by finding a matching dialect in available languages
defp resolve_language_for_post(language, available_languages) do
cond do
# Direct match - language exactly matches an available file
language in available_languages ->
language
# Base code - try to find a dialect that matches
base_code?(language) ->
find_dialect_for_base_in_files(language, available_languages) ||
DialectMapper.base_to_dialect(language)
# Full dialect code not found - try base code match as fallback
true ->
base = DialectMapper.extract_base(language)
find_dialect_for_base_in_files(base, available_languages) || language
end
end
# Find a dialect in available files that matches the given base code
defp find_dialect_for_base_in_files(base_code, available_languages) do
base_lower = String.downcase(base_code)
Enum.find(available_languages, fn lang ->
DialectMapper.extract_base(lang) == base_lower
end)
end
defp render_markdown(content) do
Renderer.render_markdown(content)
end
# Gets the canonical URL language code for a given language.
# If multiple dialects of the same base language are enabled, returns the full dialect.
# Otherwise returns the base code for cleaner URLs.
defp get_canonical_url_language(language) do
enabled_languages = get_enabled_languages()
# Resolve base code to a specific dialect if needed
resolved_language =
if base_code?(language) do
# Find the matching dialect in enabled languages
find_dialect_for_base(language, enabled_languages) || language
else
language
end
# Now determine if we should use base or full dialect code
Storage.get_display_code(resolved_language, enabled_languages)
end
# Gets the canonical URL language code for a post's language.
# This uses the actual file language (e.g., "en-US") to determine the canonical URL code.
defp get_canonical_url_language_for_post(post_language) do
enabled_languages = get_enabled_languages()
Storage.get_display_code(post_language, enabled_languages)
end
defp get_enabled_languages do
Languages.enabled_locale_codes()
rescue
_ -> ["en"]
end
defp base_code?(code) when is_binary(code) do
String.length(code) == 2 and not String.contains?(code, "-")
end
defp base_code?(_), do: false
# Find a dialect in enabled languages that matches the given base code
defp find_dialect_for_base(base_code, enabled_languages) do
base_lower = String.downcase(base_code)
Enum.find(enabled_languages, fn lang ->
DialectMapper.extract_base(lang) == base_lower
end)
end
defp build_listing_translations(blog_slug, current_language) do
# Get enabled languages - these are the ONLY languages that should show
enabled_languages =
try do
Languages.enabled_locale_codes()
rescue
_ -> ["en"]
end
# Extract base code from current language for comparison
current_base = DialectMapper.extract_base(current_language)
# Get the primary/default language
primary_language = List.first(enabled_languages) || "en"
# For each enabled language, check if there's published content for it
# Only show languages that are explicitly enabled (not just base code matches)
translations =
enabled_languages
|> Enum.filter(fn lang ->
# Check if this specific language has published content
has_published_content_for_language?(blog_slug, lang)
end)
|> Enum.map(fn lang ->
# Use display_code helper to determine if we show base or full code
display_code = Storage.get_display_code(lang, enabled_languages)
%{
code: display_code,
display_code: display_code,
name: get_language_name(lang),
flag: get_language_flag(lang),
url: BlogHTML.blog_listing_path(display_code, blog_slug),
current: DialectMapper.extract_base(lang) == current_base
}
end)
# Order: primary first, then the rest alphabetically
if Enum.any?(
translations,
&(&1.code == Storage.get_display_code(primary_language, enabled_languages))
) do
primary_display = Storage.get_display_code(primary_language, enabled_languages)
{primary, others} = Enum.split_with(translations, &(&1.code == primary_display))
primary ++ Enum.sort_by(others, & &1.code)
else
Enum.sort_by(translations, & &1.code)
end
|> Enum.uniq_by(& &1.code)
end
# Check if a specific enabled language has published content in the blog
# ONLY checks for EXACT file matches - no base code fallback
# This ensures only languages with actual files show in the public switcher
defp has_published_content_for_language?(blog_slug, language) do
# Get all posts and check if any have published content for this EXACT language
all_posts = Blogging.list_posts(blog_slug, nil)
Enum.any?(all_posts, fn post ->
# Check if there's a published file for this EXACT language only
post.available_languages
|> Enum.any?(fn file_lang ->
# Only match exact language - no base code matching
file_lang == language and lang_published?(blog_slug, post, file_lang)
end)
end)
end
# Check if a specific language version of a post is published
defp lang_published?(blog_slug, post, language) do
# If this is the post's current language, check its metadata directly
if post.language == language do
post.metadata.status == "published"
else
# Need to read the language-specific file to check its status
lang_path =
case post.mode do
:slug ->
"#{blog_slug}/#{post.slug}/#{language}.phk"
:timestamp when not is_nil(post.date) and not is_nil(post.time) ->
date_str = Date.to_iso8601(post.date)
time_str = post.time |> Time.to_string() |> String.slice(0..4)
"#{blog_slug}/#{date_str}/#{time_str}/#{language}.phk"
_ ->
# Can't build path for timestamp mode without date/time
nil
end
if lang_path do
case Blogging.read_post(blog_slug, lang_path) do
{:ok, lang_post} -> lang_post.metadata.status == "published"
_ -> false
end
else
false
end
end
end
defp build_translation_links(blog_slug, post, current_language) do
# Get enabled languages
enabled_languages =
try do
Languages.enabled_locale_codes()
rescue
_ -> ["en"]
end
# Extract base code from current language for comparison
current_base = DialectMapper.extract_base(current_language)
# Get the primary/default language
primary_language = List.first(enabled_languages) || "en"
# Include ALL available languages that are published
# This allows legacy/disabled languages to still show in the public switcher
# (they'll be styled differently by the component based on enabled/known flags)
available_and_published =
post.available_languages
|> normalize_languages(current_language)
|> Enum.filter(fn lang ->
translation_published_exact?(blog_slug, post, lang)
end)
# Remove legacy base code files when dialect files exist
# e.g., if both "en" and "en-CA" exist, remove "en" to avoid duplicates
deduplicated =
deduplicate_base_and_dialect_files(available_and_published, enabled_languages)
# Order: primary first (if present), then enabled languages, then disabled ones
languages = order_languages_for_public(deduplicated, enabled_languages, primary_language)
Enum.map(languages, fn lang ->
# Use display_code helper to determine if we show base or full code
display_code = Storage.get_display_code(lang, enabled_languages)
is_enabled = language_enabled_for_public?(lang, enabled_languages)
is_known = Languages.get_predefined_language(lang) != nil
%{
code: display_code,
display_code: display_code,
name: get_language_name(lang),
flag: get_language_flag(lang),
url: BlogHTML.build_post_url(blog_slug, post, display_code),
current: DialectMapper.extract_base(lang) == current_base,
enabled: is_enabled,
known: is_known
}
end)
end
# Order languages for public display: primary first, then enabled, then disabled
defp order_languages_for_public(languages, enabled_languages, primary_language) do
{enabled, disabled} =
Enum.split_with(languages, fn lang ->
language_enabled_for_public?(lang, enabled_languages)
end)
# Put primary first if present
{primary, other_enabled} = Enum.split_with(enabled, &(&1 == primary_language))
primary ++ Enum.sort(other_enabled) ++ Enum.sort(disabled)
end
# Remove legacy base code files when dialect files of the same language exist
# This prevents showing both "en" and "en-CA" in the switcher
defp deduplicate_base_and_dialect_files(languages, _enabled_languages) do
# Separate base codes and dialect codes
{base_codes, dialect_codes} = Enum.split_with(languages, &base_code?/1)
# For each base code, check if any dialect files exist for it
# If so, exclude the base code
filtered_base_codes =
Enum.reject(base_codes, fn base ->
Enum.any?(dialect_codes, fn dialect ->
DialectMapper.extract_base(dialect) == base
end)
end)
# Return dialect codes plus any base codes that don't have dialect alternatives
dialect_codes ++ filtered_base_codes
end
defp normalize_languages([], current_language), do: [current_language]
defp normalize_languages(languages, _current_language) when is_list(languages), do: languages
# Strict check for public display - only shows files that are:
# 1. Directly in the enabled languages list, OR
# 2. Base code files where any dialect of that base is enabled
# This prevents showing en-US, en-GB etc when only en-CA is enabled
defp language_enabled_for_public?(language, enabled_languages) do
cond do
# Direct match - file code exactly matches an enabled language
language in enabled_languages ->
true
# Base code file (e.g., "en") - show if any dialect is enabled
base_code?(language) ->
Enum.any?(enabled_languages, fn enabled_lang ->
DialectMapper.extract_base(enabled_lang) == language
end)
# Dialect file (e.g., "en-US") not directly enabled - DON'T show
# This is the key difference from language_enabled?
true ->
false
end
end
# Checks if the exact language file exists and is published
# Does not consider fallback/alias files - only the exact file path
# Used for building public translation links to show only actual files
defp translation_published_exact?(_blog_slug, post, language) do
# Build the exact file path from the post's full_path
# Replace the language portion of the filename
exact_file_path =
post.full_path
|> Path.dirname()
|> Path.join("#{language}.phk")
# Check if the exact file exists and is published
with true <- File.exists?(exact_file_path),
{:ok, contents} <- File.read(exact_file_path),
{:ok, metadata, _content} <- Metadata.parse_with_content(contents) do
metadata[:status] == "published"
else
_ -> false
end
end
defp build_breadcrumbs(blog_slug, post, language) do
blog_name =
case fetch_blog(blog_slug) do
{:ok, blog} -> blog["name"]
{:error, _} -> blog_slug
end
[
%{label: blog_name, url: BlogHTML.blog_listing_path(language, blog_slug)},
%{label: post.metadata.title, url: nil}
]
end
defp get_language_name(code) do
case Languages.get_language(code) do
%{"name" => name} -> name
_ -> String.upcase(code)
end
end
defp get_language_flag(code) do
case Languages.get_predefined_language(code) do
%{flag: flag} -> flag
_ -> "🌐"
end
end
defp get_default_language do
case Languages.get_default_language() do
%{"code" => code} -> code
_ -> "en"
end
end
defp filter_published(posts) do
Enum.filter(posts, fn post ->
post.metadata.status == "published"
end)
end
# Filter posts to only include those that have a matching language file
# Handles both exact matches and base code matches (e.g., "en" matches "en-US")
defp filter_by_exact_language(posts, blog_slug, language) do
Enum.filter(posts, fn post ->
# Find the matching language file (exact or base code match)
matching_language = find_matching_language(language, post.available_languages)
matching_language != nil and lang_published?(blog_slug, post, matching_language)
end)
end
# Find a matching language in available languages
# Handles exact matches and base code matching
defp find_matching_language(language, available_languages) do
cond do
# Direct match
language in available_languages ->
language
# Base code - find a dialect that matches
base_code?(language) ->
find_dialect_for_base_in_files(language, available_languages)
# Full dialect not found - try base code match
true ->
base = DialectMapper.extract_base(language)
find_dialect_for_base_in_files(base, available_languages)
end
end
defp default_blog_listing(language) do
case Blogging.list_blogs() do
[%{"slug" => slug} | _] -> BlogHTML.blog_listing_path(language, slug)
_ -> nil
end
end
defp paginate(posts, page, per_page) do
posts
|> Enum.drop((page - 1) * per_page)
|> Enum.take(per_page)
end
defp get_page_param(params) do
case Map.get(params, "page", "1") do
page when is_binary(page) ->
case Integer.parse(page) do
{num, _} when num > 0 -> num
_ -> 1
end
page when is_integer(page) and page > 0 ->
page
_ ->
1
end
end
defp get_per_page_setting do
case Settings.get_setting_cached("blogging_posts_per_page") do
nil ->
20
value when is_binary(value) ->
case Integer.parse(value) do
{num, _} when num > 0 -> num
_ -> 20
end
value when is_integer(value) and value > 0 ->
value
_ ->
20
end
end
defp public_enabled? do
Settings.get_boolean_setting("blogging_public_enabled", true)
end
defp log_404(conn, blog_slug, identifier, language, reason) do
Logger.info("Blog 404",
blog_slug: blog_slug,
identifier: inspect(identifier),
reason: reason,
language: language,
user_agent: get_req_header(conn, "user-agent") |> List.first(),
path: conn.request_path
)
end
defp handle_not_found(conn, reason) do
# Try to fall back to nearest valid parent in the breadcrumb chain
case attempt_breadcrumb_fallback(conn, reason) do
{:ok, redirect_path} ->
conn
|> put_flash(
:info,
gettext("The page you requested was not found. Showing closest match.")
)
|> redirect(to: redirect_path)
:no_fallback ->
conn
|> put_status(:not_found)
|> put_view(html: PhoenixKitWeb.ErrorHTML)
|> render(:"404")
end
end
defp attempt_breadcrumb_fallback(conn, reason) do
language = conn.assigns[:current_language] || "en"
blog_slug = conn.params["blog"]
path = conn.params["path"] || []
# Build full path including blog slug for proper fallback handling
# Route params are: %{"blog" => "date", "path" => ["2025-12-09", "15:02"]}
# We need: ["date", "2025-12-09", "15:02"] for pattern matching
full_path = if blog_slug, do: [blog_slug | path], else: path
handle_fallback_case(reason, full_path, language)
end
# Slug mode posts (2-element path) - try other languages before blog listing
defp handle_fallback_case(reason, [blog_slug, post_slug], language)
when reason in [:post_not_found, :unpublished] do
fallback_to_default_language(blog_slug, post_slug, language)
end
# Timestamp mode posts (3-element path) - try other languages before blog listing
defp handle_fallback_case(reason, [blog_slug, date, time], language)
when reason in [:post_not_found, :unpublished] do
fallback_timestamp_to_other_language(blog_slug, date, time, language)
end
defp handle_fallback_case(:blog_not_found, [_blog_slug], language) do
fallback_to_default_blog(language)
end
defp handle_fallback_case(:blog_not_found, [], language) do
fallback_to_default_blog(language)
end
defp handle_fallback_case(_reason, _path, _language), do: :no_fallback
defp fallback_to_default_blog(language) do
case default_blog_listing(language) do
nil -> :no_fallback
path -> {:ok, path}
end
end
defp fallback_to_default_language(blog_slug, post_slug, requested_language) do
if blog_exists?(blog_slug) do
find_any_available_language_version(blog_slug, post_slug, requested_language)
else
:no_fallback
end
end
# Fallback for timestamp mode posts - comprehensive fallback chain:
# 1. Try other languages for the exact date/time
# 2. If time doesn't exist, try other times on the same date
# 3. If date has no posts, fall back to blog listing
defp fallback_timestamp_to_other_language(blog_slug, date, time, requested_language) do
default_lang = get_default_language()
if blog_exists?(blog_slug) do
# Step 1: Try other languages for this exact time
post_dir = Path.join([Storage.root_path(), blog_slug, date, time])
available = detect_available_languages_in_dir(post_dir)
# Time exists with language files - try other languages
if available != [] do
languages_to_try =
([default_lang | available] -- [requested_language])
|> Enum.uniq()
case find_first_published_timestamp_version(blog_slug, date, time, languages_to_try) do
{:ok, url} ->
{:ok, url}
:not_found ->
# No published version at this time - try other times on this date
fallback_to_other_time_on_date(blog_slug, date, time, default_lang)
end
else
# Time doesn't exist - try other times on this date
fallback_to_other_time_on_date(blog_slug, date, time, default_lang)
end
else
:no_fallback
end
end
# Fallback to another time on the same date
defp fallback_to_other_time_on_date(blog_slug, date, exclude_time, default_lang) do
case Storage.list_times_on_date(blog_slug, date) do
[] ->
# No posts on this date at all - try other dates or fall back to blog listing
fallback_to_other_date(blog_slug, default_lang)
times ->
# Filter out the time we already tried
other_times = times -- [exclude_time]
case find_first_published_time(blog_slug, date, other_times, default_lang) do
{:ok, url} ->
{:ok, url}
:not_found ->
# No published posts on this date - try other dates
fallback_to_other_date(blog_slug, default_lang)
end
end
end
# No posts found on this date - fall back to blog listing
# The blog listing will show all available posts
defp fallback_to_other_date(blog_slug, default_lang) do
{:ok, BlogHTML.blog_listing_path(default_lang, blog_slug)}
end
# Find the first published post at any of the given times
defp find_first_published_time(blog_slug, date, times, preferred_lang) do
Enum.find_value(times, fn time ->
post_dir = Path.join([Storage.root_path(), blog_slug, date, time])
available = detect_available_languages_in_dir(post_dir)
if available != [] do
# Try preferred language first, then others
languages = [preferred_lang | available] |> Enum.uniq()
case find_first_published_timestamp_version(blog_slug, date, time, languages) do
{:ok, url} -> {:ok, url}
:not_found -> nil
end
else
nil
end
end) || :not_found
end
# Tries each language for timestamp mode until finding a published version
defp find_first_published_timestamp_version(blog_slug, date, time, languages) do
Enum.find_value(languages, fn lang ->
path = "#{blog_slug}/#{date}/#{time}/#{lang}.phk"
case Blogging.read_post(blog_slug, path) do
{:ok, post} when post.metadata.status == "published" ->
{:ok, build_timestamp_url(blog_slug, date, time, lang)}
_ ->
nil
end
end) || :not_found
end
# Tries to find any available published language version of the post
# Priority: default language first, then any other available language
# Falls back to blog listing if no published versions exist
defp find_any_available_language_version(blog_slug, post_slug, requested_language) do
default_lang = get_default_language()
# Find the post in the blog to get available languages
case find_post_by_slug(blog_slug, post_slug) do
{:ok, post} ->
available = post.available_languages || []
# Build priority list: default first, then others (excluding already-tried language)
languages_to_try =
([default_lang | available] -- [requested_language])
|> Enum.uniq()
find_first_published_version(blog_slug, post_slug, post, languages_to_try, default_lang)
:not_found ->
# Post doesn't exist at all - fall back to blog listing
{:ok, BlogHTML.blog_listing_path(default_lang, blog_slug)}
end
end
# Finds a post by its slug from the blog's post list
defp find_post_by_slug(blog_slug, post_slug) do
posts = Blogging.list_posts(blog_slug, nil)
case Enum.find(posts, fn p -> p.slug == post_slug end) do
nil -> :not_found
post -> {:ok, post}
end
end
# Tries each language in order until finding a published version
defp find_first_published_version(blog_slug, post_slug, _post, languages, fallback_lang) do
result =
Enum.find_value(languages, fn lang ->
case Blogging.read_post(blog_slug, post_slug, lang) do
{:ok, lang_post} when lang_post.metadata.status == "published" ->
{:ok, BlogHTML.build_post_url(blog_slug, lang_post, lang)}
_ ->
nil
end
end)
# If no published version found, fall back to blog listing
result || {:ok, BlogHTML.blog_listing_path(fallback_lang, blog_slug)}
end
defp blog_exists?(blog_slug) do
case fetch_blog(blog_slug) do
{:ok, _} -> true
_ -> false
end
end
end