Packages
phoenix_kit
1.4.9
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.Module.Languages
alias PhoenixKit.Settings
alias PhoenixKitWeb.Live.Modules.Blogging
alias PhoenixKitWeb.BlogHTML
@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, time] -> Timestamp mode post
"""
def show(conn, %{"language" => language} = params) do
language = validate_language(language)
conn = assign(conn, :current_language, language)
if Blogging.enabled?() and public_enabled?() 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)
{:error, reason} ->
handle_not_found(conn, reason)
end
end
else
handle_not_found(conn, :module_disabled)
end
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, post_slug]) do
{:slug_post, blog_slug, post_slug}
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} ->
page = get_page_param(params)
per_page = get_per_page_setting()
# List published posts
all_posts =
Blogging.list_posts(blog_slug, language)
|> filter_published()
total_count = length(all_posts)
posts = paginate(all_posts, page, per_page)
breadcrumbs = [
%{label: blog["name"], url: nil}
]
conn
|> assign(:page_title, blog["name"])
|> assign(:blog, blog)
|> assign(:posts, posts)
|> assign(:current_language, language)
|> 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)
{: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
# Render markdown
html_content = render_markdown(post.content)
# Build translation links
translations = build_translation_links(blog_slug, post, language)
# Build breadcrumbs
breadcrumbs = build_breadcrumbs(blog_slug, post, language)
conn
|> assign(:page_title, post.metadata.title)
|> assign(:blog_slug, blog_slug)
|> assign(:post, post)
|> assign(:html_content, html_content)
|> assign(:current_language, language)
|> assign(:translations, translations)
|> assign(:breadcrumbs, breadcrumbs)
|> render(:show)
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
# ============================================================================
# Helper Functions
# ============================================================================
defp fetch_blog(blog_slug) do
blog_slug = blog_slug |> to_string() |> String.trim()
case Enum.find(Blogging.list_blogs(), fn blog ->
with slug when is_binary(slug) <- blog["slug"] do
String.downcase(slug) == String.downcase(blog_slug)
else
_ -> 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
# Build path for timestamp mode: blog/date/time/language.phk
path = "#{blog_slug}/#{date}/#{time}/#{language}.phk"
case Blogging.read_post(blog_slug, path) do
{:ok, post} -> {:ok, post}
_ -> {:error, :post_not_found}
end
end
defp render_markdown(content) do
Renderer.render_markdown(content)
end
defp build_translation_links(blog_slug, post, current_language) do
languages =
if Enum.empty?(post.available_languages) do
[current_language]
else
post.available_languages
end
Enum.map(languages, fn lang ->
%{
code: lang,
name: get_language_name(lang),
url: BlogHTML.build_post_url(blog_slug, post, lang),
current: lang == current_language
}
end)
end
defp build_breadcrumbs(blog_slug, post, language) do
{:ok, blog} = fetch_blog(blog_slug)
[
%{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 validate_language(code) do
if Languages.language_enabled?(code) do
code
else
get_default_language()
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
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("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"
path = conn.params["path"] || []
case {reason, path} do
# Post not found or unpublished -> try blog listing
{reason, [blog_slug, _post_identifier]}
when reason in [:post_not_found, :unpublished] ->
fallback_to_blog_or_overview(blog_slug, language)
# Timestamp post not found -> try blog listing
{reason, [blog_slug, _date, _time]}
when reason in [:post_not_found, :unpublished] ->
fallback_to_blog_or_overview(blog_slug, language)
# Blog not found -> redirect to first available blog when possible
{:blog_not_found, [_blog_slug]} ->
case default_blog_listing(language) do
nil -> :no_fallback
path -> {:ok, path}
end
{:blog_not_found, []} ->
case default_blog_listing(language) do
nil -> :no_fallback
path -> {:ok, path}
end
# Invalid language for post -> try default language
{:post_not_found, [blog_slug, post_slug]} ->
fallback_to_default_language(blog_slug, post_slug, language)
# Other errors -> no fallback
_ ->
:no_fallback
end
end
defp fallback_to_blog_or_overview(blog_slug, language) do
if blog_exists?(blog_slug) do
{:ok, BlogHTML.blog_listing_path(language, blog_slug)}
else
case default_blog_listing(language) do
nil -> :no_fallback
path -> {:ok, path}
end
end
end
defp fallback_to_default_language(blog_slug, post_slug, language) do
default_lang = get_default_language()
if language != default_lang and blog_exists?(blog_slug) do
# Try the post in default language
case Blogging.read_post(blog_slug, post_slug, default_lang) do
{:ok, post} when post.metadata.status == "published" ->
{:ok, BlogHTML.build_post_url(blog_slug, post, default_lang)}
_ ->
# Post doesn't exist in default language either, go to blog listing
{:ok, BlogHTML.blog_listing_path(default_lang, blog_slug)}
end
else
:no_fallback
end
end
defp blog_exists?(blog_slug) do
case fetch_blog(blog_slug) do
{:ok, _} -> true
_ -> false
end
end
end