Packages

phoenix_kit

1.4.8
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
phoenix_kit lib phoenix_kit_web controllers blog_controller.ex
Raw

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/:blog_slug/:post_slug - Slug mode post
/:language/blog/:blog_slug/:date/:time - Timestamp mode post
/:language/blog/:blog_slug - Blog listing
/:language/blog - All blogs overview
"""
use PhoenixKitWeb, :controller
require Logger
alias PhoenixKitWeb.Live.Modules.Blogging
alias PhoenixKit.Module.Languages
alias PhoenixKit.Blogging.Renderer
alias PhoenixKit.Settings
@doc """
Displays a blog post, blog listing, or all blogs overview.
Path parsing determines which action to take:
- [] -> All blogs overview
- [blog_slug] -> Blog listing
- [blog_slug, post_slug] -> Slug mode post
- [blog_slug, date, time] -> Timestamp mode post
"""
def show(conn, %{"path" => path, "language" => language}) do
# Validate language
language = validate_language(language)
conn = assign(conn, :current_language, language)
if Blogging.enabled?() and public_enabled?() do
case parse_path(path) do
{:overview} ->
render_all_blogs(conn, language)
{: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
else
handle_not_found(conn, :module_disabled)
end
end
# ============================================================================
# Path Parsing
# ============================================================================
defp parse_path([]), do: {:overview}
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 is_date?(segment1) and is_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 is_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 is_date?(_), do: false
# Time validation: HH:MM (24-hour)
defp is_time?(str) when is_binary(str) do
String.match?(str, ~r/^([01]\d|2[0-3]):[0-5]\d$/)
end
defp is_time?(_), do: false
# ============================================================================
# Rendering Functions
# ============================================================================
defp render_all_blogs(conn, language) do
blogs = Blogging.list_blogs()
# Get post counts per blog
blogs_with_counts =
Enum.map(blogs, fn blog ->
count = count_published_posts(blog["slug"], language)
Map.put(blog, "post_count", count)
end)
conn
|> assign(:page_title, "Blog")
|> assign(:blogs, blogs_with_counts)
|> assign(:current_language, language)
|> render(:all_blogs)
end
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", url: "/#{language}/blog"},
%{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
case Enum.find(Blogging.list_blogs(), fn blog -> blog["slug"] == blog_slug 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
Enum.map(post.available_languages || [current_language], fn lang ->
%{
code: lang,
name: get_language_name(lang),
url: build_post_url(blog_slug, post, lang),
current: lang == current_language
}
end)
end
defp build_post_url(blog_slug, post, language) do
case post.mode do
:slug ->
"/#{language}/blog/#{blog_slug}/#{post.slug}"
:timestamp ->
# Extract date and time from metadata or path
date =
post.metadata.published_at
|> DateTime.to_date()
|> Date.to_iso8601()
time = format_time_for_url(post.metadata.published_at)
"/#{language}/blog/#{blog_slug}/#{date}/#{time}"
_ ->
# Fallback: try to determine from path
"/#{language}/blog/#{blog_slug}/#{post.slug}"
end
end
defp format_time_for_url(datetime) when is_struct(datetime, DateTime) do
datetime
|> DateTime.to_time()
|> Time.truncate(:second)
|> Time.to_string()
# "14:30:00" → "14:30"
|> String.slice(0..4)
end
defp format_time_for_url(_), do: "00:00"
defp build_breadcrumbs(blog_slug, post, language) do
{:ok, blog} = fetch_blog(blog_slug)
[
%{label: "Blog", url: "/#{language}/blog"},
%{label: blog["name"], url: "/#{language}/blog/#{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 count_published_posts(blog_slug, language) do
Blogging.list_posts(blog_slug, language)
|> filter_published()
|> length()
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] ->
if blog_exists?(blog_slug) do
{:ok, "/#{language}/blog/#{blog_slug}"}
else
{:ok, "/#{language}/blog"}
end
# Timestamp post not found -> try blog listing
{reason, [blog_slug, _date, _time]}
when reason in [:post_not_found, :unpublished] ->
if blog_exists?(blog_slug) do
{:ok, "/#{language}/blog/#{blog_slug}"}
else
{:ok, "/#{language}/blog"}
end
# Blog not found -> show all blogs overview
{:blog_not_found, [_blog_slug]} ->
{:ok, "/#{language}/blog"}
# Invalid language for post -> try default language
{:post_not_found, [blog_slug, post_slug]} ->
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, "/#{default_lang}/blog/#{blog_slug}/#{post_slug}"}
_ ->
# Post doesn't exist in default language either, go to blog listing
{:ok, "/#{default_lang}/blog/#{blog_slug}"}
end
else
:no_fallback
end
# Other errors -> no fallback
_ ->
:no_fallback
end
end
defp blog_exists?(blog_slug) do
case fetch_blog(blog_slug) do
{:ok, _} -> true
_ -> false
end
end
end