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