Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_publishing/web/controller/slug_resolution.ex
defmodule PhoenixKit.Modules.Publishing.Web.Controller.SlugResolution do
@moduledoc """
URL slug resolution for the publishing controller.
Handles resolving URL slugs to internal slugs, including:
- Per-language custom URL slugs
- Previous URL slugs for 301 redirects
- DB-based slug lookups
"""
alias PhoenixKit.Modules.Languages.DialectMapper
alias PhoenixKit.Modules.Publishing
alias PhoenixKit.Modules.Publishing.Web.Controller.Language
alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML
# ============================================================================
# URL Slug Resolution
# ============================================================================
@doc """
Resolves URL slug to internal slug using cache/DB.
Returns:
- `{:redirect, url}` for 301 redirect to new URL
- `{:ok, identifier}` for resolved internal slug
- `:passthrough` for direct use
"""
def resolve_url_slug(group_slug, {:slug, url_slug}, language) do
# Resolve base language codes (de, en) to stored dialect codes (de-DE, en-US)
# before DB queries, since content rows store full BCP-47 dialect codes
db_language = resolve_language_for_db(language)
case Publishing.find_by_url_slug(group_slug, db_language, url_slug) do
{:ok, cached_post} ->
internal_slug = cached_post.slug
if internal_slug == url_slug do
# URL slug matches internal slug - no resolution needed
:passthrough
else
# URL slug differs from internal slug - use resolved identifier
{:ok, {:slug, internal_slug}}
end
{:error, :not_found} ->
# Not found in current slugs - check previous slugs for 301 redirect
case Publishing.find_by_previous_url_slug(group_slug, db_language, url_slug) do
{:ok, cached_post} ->
# Found in previous slugs - redirect to current URL. The cache path
# carries `:language_slugs`; the DB path (db_content_to_post_map/1)
# does not, so fall back to the row's own `:url_slug` (the canonical
# custom slug for this language, which the DB map DOES carry) before
# the internal `:slug`. Without this, a post with a custom url_slug
# 301s to /group/<internal-slug> instead of the canonical URL.
current_url_slug =
Map.get(
cached_post[:language_slugs] || %{},
db_language,
cached_post[:url_slug] || cached_post.slug
)
redirect_url =
build_post_redirect_url(group_slug, cached_post, language, current_url_slug)
{:redirect, redirect_url}
{:error, _} ->
:passthrough
end
end
end
# Non-slug modes pass through directly
def resolve_url_slug(_group_slug, _identifier, _language), do: :passthrough
@doc """
Resolves a URL slug to the internal post slug.
Used by versioned URL handler and other places that need the internal slug.
"""
def resolve_url_slug_to_internal(group_slug, url_slug, language) do
db_language = resolve_language_for_db(language)
case Publishing.find_by_url_slug(group_slug, db_language, url_slug) do
{:ok, cached_post} ->
cached_post.slug || cached_post[:slug]
{:error, _} ->
# Not found in cache/DB - use as-is
url_slug
end
end
# ============================================================================
# Redirect URL Building
# ============================================================================
@doc """
Builds a 301 redirect URL from a resolved post map.
Accepts both the cache-shaped map (carries :mode/:date/:time/:language_slugs)
and the DB-shaped map from `db_content_to_post_map/1` (only :slug/:url_slug/
:language/:metadata), reading the cache-only fields defensively.
"""
def build_post_redirect_url(group_slug, cached_post, language, url_slug) do
# Build post struct with minimal fields needed for URL generation. Both the
# cache shape and the DB shape (db_content_to_post_map/1) now carry
# :mode/:date/:time, so the redirect resolves to the correct canonical URL
# for timestamp-mode posts too. Bracket access + a "slug" fallback are kept
# purely as defence against an unexpectedly sparse map.
post = %{
slug: cached_post.slug,
url_slug: url_slug,
mode: Map.get(cached_post, :mode, "slug"),
date: cached_post[:date],
time: cached_post[:time],
language_slugs: cached_post[:language_slugs] || %{}
}
PublishingHTML.build_post_url(group_slug, post, language)
end
# ============================================================================
# Language Resolution
# ============================================================================
# Resolves a URL language code to the stored dialect code for DB queries.
# Resolve language for DB lookup. If the language is an enabled code, use it directly.
# Only resolve base codes to dialects when the base itself isn't enabled.
defp resolve_language_for_db(language) do
enabled = Language.get_enabled_languages()
cond do
language in enabled ->
language
Language.base_code?(language) ->
Language.find_dialect_for_base(language, enabled) ||
DialectMapper.base_to_dialect(language)
true ->
language
end
end
end