Current section

Files

Jump to
phoenix_kit_publishing lib phoenix_kit_publishing web controller language.ex
Raw

lib/phoenix_kit_publishing/web/controller/language.ex

defmodule PhoenixKit.Modules.Publishing.Web.Controller.Language do
@moduledoc """
Language detection and resolution for the publishing controller.
Handles detecting whether URL parameters represent language codes,
resolving language codes to content languages, and determining
canonical URL language codes.
"""
alias PhoenixKit.Modules.Languages
alias PhoenixKit.Modules.Languages.DialectMapper
alias PhoenixKit.Modules.Publishing
alias PhoenixKit.Modules.Publishing.LanguageHelpers
alias PhoenixKit.Modules.Publishing.ListingCache
# ============================================================================
# Language Detection
# ============================================================================
@doc """
Detects whether the 'language' parameter is actually a language code or a group slug.
This allows the same route pattern (/:language/:group/*path) to work for both:
- Multi-language: /en/my-group/my-post (language=en, group=my-group)
- Single-language: /my-group/my-post (language=my-group, needs adjustment)
Returns {detected_language, adjusted_params}
"""
def detect_language_or_group(language_param, params) do
# First check if it's a known/predefined language
# Then check if content exists for this language in the group (handles unknown languages like "af")
group_slug = params["group"]
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 group
# This handles content in unknown language codes like "af", "test", etc.
group_slug && has_content_for_language?(group_slug, language_param) ->
{language_param, params}
# Not a language - shift parameters (group slug in language position)
true ->
{get_default_language(), shift_language_to_group(language_param, params)}
end
end
defp shift_language_to_group(language_param, %{"group" => first_segment, "path" => rest})
when is_list(rest) do
%{"group" => language_param, "path" => [first_segment | rest]}
end
defp shift_language_to_group(language_param, %{"group" => first_segment}) do
%{"group" => language_param, "path" => [first_segment]}
end
defp shift_language_to_group(language_param, _params) do
%{"group" => language_param}
end
@doc """
Detects if the :group route param is actually a language code by checking if content exists.
Returns {:language_detected, language, adjusted_params} or :not_a_language
"""
def detect_language_in_group_param(
%{"group" => potential_lang, "path" => [_ | _] = path} = _params
)
when is_binary(potential_lang) do
[actual_group | rest_path] = path
group_exists = group_exists?(actual_group)
has_content = has_content_for_language?(actual_group, potential_lang)
# Check if there's a group with slug matching actual_group
# AND if there's content for potential_lang in that group
if group_exists and has_content do
adjusted_params = %{"group" => actual_group, "path" => rest_path}
{:language_detected, potential_lang, adjusted_params}
else
:not_a_language
end
end
def detect_language_in_group_param(_params), do: :not_a_language
# ============================================================================
# Language Validation
# ============================================================================
@doc """
Validates if a code represents a valid language.
"""
def valid_language?(code) when is_binary(code) do
# 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
base_code?(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 language codes like legacy imports
looks_like_language_code?(code) ->
true
true ->
false
end
rescue
# Languages may not be loaded / configured in this host — return
# `false` (not a valid language) rather than crashing the request.
# Other exception classes propagate so genuine bugs aren't masked.
UndefinedFunctionError -> false
ArgumentError -> false
end
def valid_language?(_), do: false
@doc """
Checks if a string looks like a language code pattern.
Matches: 2-letter codes (en, fr), or dialect codes (en-US, pt-BR)
"""
def looks_like_language_code?(code) when is_binary(code) do
# 2-3 letter base code or dialect code pattern (xx-XX, xxx-XXXX)
String.match?(code, ~r/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/i)
end
# ============================================================================
# Language Resolution
# ============================================================================
@doc """
Resolves a language code to an actual content language.
Handles base codes by finding a matching dialect in available languages.
"""
def resolve_language_for_post(language, available_languages) do
cond do
# Direct match - language exactly matches an available language
language in available_languages ->
language
# Base code - try to find a dialect that matches
base_code?(language) ->
find_dialect_for_base_in_languages(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_languages(base, available_languages) || language
end
end
@doc """
Find a dialect in a list of languages that matches the given base code.
"""
def find_dialect_for_base_in_languages(base_code, languages),
do: find_dialect_for_base(base_code, languages)
# ============================================================================
# Canonical URL Language
# ============================================================================
@doc """
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.
"""
def 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
Publishing.get_display_code(resolved_language, enabled_languages)
end
@doc """
Gets the canonical URL language code for a post's language.
This uses the actual content language (e.g., "en-US") to determine the canonical URL code.
"""
def get_canonical_url_language_for_post(post_language) do
enabled_languages = get_enabled_languages()
Publishing.get_display_code(post_language, enabled_languages)
end
@doc """
Returns true when the current request URL already matches the canonical URL.
"""
def request_matches_canonical_url?(conn, canonical_url) do
request_url =
case conn.query_string do
nil -> conn.request_path
"" -> conn.request_path
query -> conn.request_path <> "?" <> query
end
request_url == canonical_url
end
@doc """
Returns true when the request is using an explicit prefix for the default language
even though the default language should be prefixless.
"""
def prefixed_default_language_request?(conn, language) do
Map.has_key?(conn.params, "language") and
LanguageHelpers.default_language_no_prefix?() and
LanguageHelpers.url_language_code(language) == LanguageHelpers.get_primary_language_base()
end
# ============================================================================
# Helper Functions
# ============================================================================
@doc """
Gets the list of enabled language codes.
"""
def get_enabled_languages do
Publishing.enabled_language_codes()
rescue
_ -> ["en"]
end
@doc """
Checks if a code is a base language code (2-3 letters, no dialect suffix).
"""
def base_code?(code) when is_binary(code) do
LanguageHelpers.base_language_code?(code)
end
def base_code?(_), do: false
@doc """
Find a dialect in enabled languages that matches the given base code.
"""
def 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
@doc """
Gets the default language.
"""
def get_default_language do
case Publishing.get_primary_language() do
code when is_binary(code) and code != "" ->
code
_ ->
case Languages.get_default_language() do
%{"code" => code} -> code
_ -> "en"
end
end
end
@doc """
Gets a language's display name.
"""
def get_language_name(code) do
case Languages.get_language(code) do
%{"name" => name} -> name
_ -> String.upcase(code)
end
end
@doc """
Gets a language's flag emoji.
"""
def get_language_flag(code) do
case Languages.get_predefined_language(code) do
%{flag: flag} -> flag
_ -> "🌐"
end
end
# ============================================================================
# Content Checks
# ============================================================================
@doc """
Check if any post in the group has content for the given language.
Uses listing cache when available for fast lookups.
"""
def has_content_for_language?(group_slug, language) do
# Try cache first for fast lookup
case ListingCache.read(group_slug) do
{:ok, posts} ->
Enum.any?(posts, fn post ->
language in (post.available_languages || [])
end)
{:error, _} ->
# Cache miss - fall back to direct DB read
posts = Publishing.list_posts(group_slug, nil)
Enum.any?(posts, fn post ->
language in (post.available_languages || [])
end)
end
rescue
_ -> false
end
defp group_exists?(group_slug) do
target = to_string(group_slug)
Enum.any?(Publishing.list_groups(), &group_slug_matches?(&1, target))
end
defp group_slug_matches?(%{"slug" => slug}, target) when is_binary(slug) do
String.downcase(slug) == String.downcase(target)
end
defp group_slug_matches?(_, _), do: false
end