Packages
phoenix_kit
1.7.39
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/modules/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
- Filesystem fallback when cache is unavailable
"""
alias PhoenixKit.Modules.Publishing.ListingCache
alias PhoenixKit.Modules.Publishing.Metadata
alias PhoenixKit.Modules.Publishing.Storage
alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML
# ============================================================================
# URL Slug Resolution
# ============================================================================
@doc """
Resolves URL slug to internal slug using cache.
Returns:
- `{:redirect, url}` for 301 redirect to new URL
- `{:ok, identifier}` for resolved internal slug
- `:passthrough` for direct use
"""
def resolve_url_slug(blog_slug, {:slug, url_slug}, language) do
case ListingCache.find_by_url_slug(blog_slug, 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 ListingCache.find_by_previous_url_slug(blog_slug, language, url_slug) do
{:ok, cached_post} ->
# Found in previous slugs - redirect to current URL
current_url_slug =
Map.get(cached_post.language_slugs || %{}, language, cached_post.slug)
redirect_url =
build_post_redirect_url(blog_slug, cached_post, language, current_url_slug)
{:redirect, redirect_url}
{:error, _} ->
# Not found in cache - try filesystem fallback
resolve_url_slug_from_filesystem(blog_slug, url_slug, language)
end
{:error, :cache_miss} ->
# Cache not available - try filesystem fallback
resolve_url_slug_from_filesystem(blog_slug, url_slug, language)
end
end
# Non-slug modes pass through directly
def resolve_url_slug(_blog_slug, _identifier, _language), do: :passthrough
@doc """
Resolves a URL slug to the internal directory slug.
Used by versioned URL handler and other places that need the internal slug.
"""
def resolve_url_slug_to_internal(blog_slug, url_slug, language) do
case ListingCache.find_by_url_slug(blog_slug, language, url_slug) do
{:ok, cached_post} ->
cached_post.slug
{:error, _} ->
# Fallback: try filesystem scan for custom slug
case find_internal_slug_from_filesystem(blog_slug, url_slug, language) do
{:ok, internal_slug} -> internal_slug
{:error, _} -> url_slug
end
end
end
# ============================================================================
# Filesystem Fallback
# ============================================================================
@doc """
Filesystem fallback for URL slug resolution when cache is unavailable.
Also handles 301 redirects for previous_url_slugs.
"""
def resolve_url_slug_from_filesystem(blog_slug, url_slug, language) do
case find_slug_in_filesystem(blog_slug, url_slug, language) do
{:current, internal_slug} when internal_slug != url_slug ->
# Found as current url_slug - resolve to internal slug
{:ok, {:slug, internal_slug}}
{:current, _same_slug} ->
# URL slug matches internal slug - passthrough
:passthrough
{:previous, internal_slug, current_url_slug} ->
# Found in previous_url_slugs - redirect to current URL
redirect_url =
build_redirect_url_from_slugs(blog_slug, internal_slug, language, current_url_slug)
{:redirect, redirect_url}
{:error, _} ->
# Not found - passthrough for normal handling
:passthrough
end
end
@doc """
Scans filesystem to find a post with matching url_slug or previous_url_slugs.
Returns:
- `{:current, internal_slug}` - found as current url_slug
- `{:previous, internal_slug, current_url_slug}` - found in previous_url_slugs (for redirect)
- `{:error, reason}` - not found
"""
def find_slug_in_filesystem(blog_slug, url_slug, language) do
group_path = Storage.group_path(blog_slug)
with true <- File.dir?(group_path),
dirs <- File.ls!(group_path),
result when not is_nil(result) <-
scan_posts_for_slug(group_path, dirs, url_slug, language) do
result
else
false -> {:error, :group_not_found}
nil -> {:error, :not_found}
end
rescue
_ -> {:error, :scan_failed}
end
# ============================================================================
# Slug Scanning Helpers
# ============================================================================
defp scan_posts_for_slug(group_path, dirs, url_slug, language) do
Enum.find_value(dirs, fn post_dir ->
post_path = Path.join(group_path, post_dir)
if File.dir?(post_path) do
check_post_for_slug(post_path, post_dir, url_slug, language)
end
end)
end
defp check_post_for_slug(post_path, post_dir, url_slug, language) do
case read_slug_data_from_post(post_path, language) do
{:ok, current_slug, previous_slugs} ->
match_slug_data(post_dir, url_slug, current_slug, previous_slugs)
_ ->
nil
end
end
defp match_slug_data(post_dir, url_slug, current_slug, previous_slugs) do
cond do
current_slug == url_slug ->
{:current, post_dir}
url_slug in (previous_slugs || []) ->
{:previous, post_dir, current_slug || post_dir}
true ->
nil
end
end
# Legacy function for resolve_url_slug_to_internal (only needs current slug)
defp find_internal_slug_from_filesystem(blog_slug, url_slug, language) do
case find_slug_in_filesystem(blog_slug, url_slug, language) do
{:current, internal_slug} -> {:ok, internal_slug}
{:previous, internal_slug, _current} -> {:ok, internal_slug}
{:error, reason} -> {:error, reason}
end
end
# ============================================================================
# Slug Data Reading
# ============================================================================
@doc """
Reads url_slug and previous_url_slugs from a post's language file metadata.
"""
def read_slug_data_from_post(post_path, language) do
# Try versioned structure first, then legacy
content_dir =
case find_latest_version_dir(post_path) do
{:ok, version_dir} -> version_dir
{:error, _} -> post_path
end
file_path = Path.join(content_dir, "#{language}.phk")
if File.exists?(file_path) do
case File.read(file_path) do
{:ok, content} ->
case Metadata.parse_with_content(content) do
{:ok, metadata, _content} ->
url_slug = Map.get(metadata, :url_slug)
previous_slugs = Map.get(metadata, :previous_url_slugs) || []
{:ok, url_slug, previous_slugs}
_ ->
{:error, :parse_failed}
end
_ ->
{:error, :read_failed}
end
else
{:error, :file_not_found}
end
end
@doc """
Finds the latest version directory in a versioned post structure.
"""
def find_latest_version_dir(post_path) do
versions =
post_path
|> File.ls!()
|> Enum.filter(&String.starts_with?(&1, "v"))
|> Enum.map(fn dir ->
case Integer.parse(String.trim_leading(dir, "v")) do
{num, ""} -> num
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.sort(:desc)
case versions do
[latest | _] -> {:ok, Path.join(post_path, "v#{latest}")}
[] -> {:error, :no_versions}
end
rescue
_ -> {:error, :scan_failed}
end
# ============================================================================
# Redirect URL Building
# ============================================================================
@doc """
Builds redirect URL for 301 redirects from cached post data.
"""
def build_post_redirect_url(blog_slug, cached_post, language, url_slug) do
# Build post struct with minimal fields needed for URL generation
post = %{
slug: cached_post.slug,
url_slug: url_slug,
mode: cached_post.mode,
date: cached_post.date,
time: cached_post.time,
language_slugs: cached_post.language_slugs
}
PublishingHTML.build_post_url(blog_slug, post, language)
end
@doc """
Builds redirect URL when we only have slug data (no full post struct).
"""
def build_redirect_url_from_slugs(blog_slug, internal_slug, language, current_url_slug) do
# Build minimal post struct for URL generation
post = %{
slug: internal_slug,
url_slug: current_url_slug,
mode: :slug,
language_slugs: %{language => current_url_slug}
}
PublishingHTML.build_post_url(blog_slug, post, language)
end
end