Packages

phoenix_kit

1.7.63
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 modules pages storage slugs.ex
Raw

lib/modules/pages/storage/slugs.ex

defmodule PhoenixKit.Modules.Pages.Storage.Slugs do
@moduledoc """
Slug validation and generation for pages storage.
Handles slug format validation, uniqueness checking,
URL slug validation for per-language slugs, and slug generation.
"""
alias PhoenixKit.Modules.Pages.ListingCache
alias PhoenixKit.Modules.Pages.Metadata
alias PhoenixKit.Modules.Pages.Storage
alias PhoenixKit.Modules.Pages.Storage.Languages
alias PhoenixKit.Modules.Pages.Storage.Paths
alias PhoenixKit.Modules.Pages.Storage.Versions
alias PhoenixKit.Utils.Slug
require Logger
@slug_pattern ~r/^[a-z0-9]+(?:-[a-z0-9]+)*$/
# Reserved route words that cannot be used as URL slugs
@reserved_route_words ~w(admin api assets phoenix_kit auth login logout register settings)
@doc """
Validates whether the given string is a valid slug format and not a reserved language code.
Returns:
- `{:ok, slug}` if valid
- `{:error, :invalid_format}` if format is invalid
- `{:error, :reserved_language_code}` if slug is a language code
Blog slugs cannot be language codes (like 'en', 'es', 'fr') to prevent routing ambiguity.
"""
@spec validate_slug(String.t()) ::
{:ok, String.t()} | {:error, :invalid_format | :reserved_language_code}
def validate_slug(slug) when is_binary(slug) do
cond do
not Regex.match?(@slug_pattern, slug) ->
{:error, :invalid_format}
Languages.reserved_language_code?(slug) ->
{:error, :reserved_language_code}
true ->
{:ok, slug}
end
end
@doc """
Validates whether the given string is a slug and not a reserved language code.
Blog slugs cannot be language codes (like 'en', 'es', 'fr') to prevent routing ambiguity.
"""
@spec valid_slug?(String.t()) :: boolean()
def valid_slug?(slug) when is_binary(slug) do
case validate_slug(slug) do
{:ok, _} -> true
{:error, _} -> false
end
end
@doc """
Validates a per-language URL slug for uniqueness within a group+language combination.
URL slugs have the same format requirements as directory slugs, plus:
- Cannot be reserved route words (admin, api, assets, etc.)
- Must be unique within the group+language combination
## Parameters
- `group_slug` - The pages group
- `url_slug` - The URL slug to validate
- `language` - The language code
- `exclude_post_slug` - Optional post slug to exclude from uniqueness check (for updates)
## Returns
- `{:ok, url_slug}` - Valid and unique
- `{:error, :invalid_format}` - Invalid format
- `{:error, :reserved_language_code}` - Is a language code
- `{:error, :reserved_route_word}` - Is a reserved route word
- `{:error, :slug_already_exists}` - Already in use for this language
- `{:error, :conflicts_with_directory_slug}` - Conflicts with another post's directory slug
"""
@spec validate_url_slug(String.t(), String.t(), String.t(), String.t() | nil) ::
{:ok, String.t()} | {:error, atom()}
def validate_url_slug(group_slug, url_slug, language, exclude_post_slug \\ nil) do
cond do
not Regex.match?(@slug_pattern, url_slug) ->
{:error, :invalid_format}
Languages.reserved_language_code?(url_slug) ->
{:error, :reserved_language_code}
url_slug in @reserved_route_words ->
{:error, :reserved_route_word}
# Directory slugs have priority - can't use another post's directory slug as your url_slug
conflicts_with_directory_slug?(group_slug, url_slug, exclude_post_slug) ->
{:error, :conflicts_with_directory_slug}
url_slug_exists?(group_slug, url_slug, language, exclude_post_slug) ->
{:error, :slug_already_exists}
true ->
{:ok, url_slug}
end
end
# Check if the url_slug matches any other post's directory slug
defp conflicts_with_directory_slug?(group_slug, url_slug, exclude_post_slug) do
# If the url_slug equals the post's own directory slug, that's fine
if url_slug == exclude_post_slug do
false
else
# Check if any other post has this as their directory slug
slug_exists?(group_slug, url_slug)
end
end
defp url_slug_exists?(group_slug, url_slug, language, exclude_post_slug) do
case ListingCache.read(group_slug) do
{:ok, posts} ->
Enum.any?(posts, fn post ->
# Skip the post being edited and skip posts whose directory slug equals the url_slug
# (directory slugs are checked separately and have priority)
post.slug != exclude_post_slug and
post.slug != url_slug and
Map.get(post.language_slugs || %{}, language) == url_slug
end)
{:error, _} ->
url_slug_exists_in_filesystem?(group_slug, url_slug, language, exclude_post_slug)
end
end
defp url_slug_exists_in_filesystem?(group_slug, url_slug, language, exclude_post_slug) do
group_path = Paths.group_path(group_slug)
if File.dir?(group_path) do
group_path
|> File.ls!()
|> Enum.filter(&File.dir?(Path.join(group_path, &1)))
|> Enum.reject(&(&1 == exclude_post_slug))
|> Enum.any?(fn post_slug ->
case read_post_url_slug(group_slug, post_slug, language) do
{:ok, existing_url_slug} -> existing_url_slug == url_slug
_ -> false
end
end)
else
false
end
end
defp read_post_url_slug(group_slug, post_slug, language) do
case Storage.read_post_slug_mode(group_slug, post_slug, language, nil) do
{:ok, post} ->
url_slug = Map.get(post.metadata, :url_slug) || post_slug
{:ok, url_slug}
error ->
error
end
end
@doc """
Checks if a slug already exists within the given pages group.
"""
@spec slug_exists?(String.t(), String.t()) :: boolean()
def slug_exists?(group_slug, post_slug) do
Path.join([Paths.group_path(group_slug), post_slug])
|> File.dir?()
end
@doc """
Clears custom url_slugs that conflict with a given directory slug.
When a new post is created with directory slug X, any other posts that have
custom url_slug = X need to have their url_slugs cleared (so they fall back
to their own directory slug). Directory slugs have priority.
Returns the list of {post_slug, language} tuples that were cleared.
"""
@spec clear_conflicting_url_slugs(String.t(), String.t()) :: [{String.t(), String.t()}]
def clear_conflicting_url_slugs(group_slug, directory_slug) do
case ListingCache.read(group_slug) do
{:ok, posts} ->
conflicts = find_conflicting_url_slugs(posts, directory_slug)
clear_url_slugs_for_conflicts(group_slug, conflicts)
log_cleared_conflicts(conflicts, directory_slug)
conflicts
{:error, _} ->
[]
end
end
defp find_conflicting_url_slugs(posts, directory_slug) do
Enum.flat_map(posts, fn post ->
if post.slug == directory_slug do
[]
else
find_post_language_conflicts(post, directory_slug)
end
end)
end
defp find_post_language_conflicts(post, directory_slug) do
(post.language_slugs || %{})
|> Enum.filter(fn {_lang, url_slug} -> url_slug == directory_slug end)
|> Enum.map(fn {lang, _} -> {post.slug, lang} end)
end
defp clear_url_slugs_for_conflicts(group_slug, conflicts) do
Enum.each(conflicts, fn {post_slug, language} ->
clear_url_slug_for_language(group_slug, post_slug, language)
end)
end
defp log_cleared_conflicts([], _directory_slug), do: :ok
defp log_cleared_conflicts(conflicts, directory_slug) do
Logger.warning(
"[Slugs] Cleared conflicting url_slugs for directory slug '#{directory_slug}': #{inspect(conflicts)}"
)
end
# Clears the url_slug for a specific post/language by removing it from metadata
defp clear_url_slug_for_language(group_slug, post_slug, language) do
post_path = Path.join([Paths.group_path(group_slug), post_slug])
structure = Versions.detect_post_structure(post_path)
versions = Versions.list_versions(group_slug, post_slug)
Enum.each(versions, fn version ->
clear_url_slug_in_version(post_path, structure, version, language)
end)
end
defp clear_url_slug_in_version(post_path, structure, version, language) do
version_dir = get_version_dir(post_path, structure, version)
if version_dir do
file_path = Path.join(version_dir, Languages.language_filename(language))
clear_url_slug_in_file(file_path)
end
end
defp get_version_dir(post_path, :versioned, version), do: Path.join(post_path, "v#{version}")
defp get_version_dir(post_path, :legacy, _version), do: post_path
defp get_version_dir(_post_path, _structure, _version), do: nil
defp clear_url_slug_in_file(file_path) do
with true <- File.exists?(file_path),
{:ok, content} <- File.read(file_path),
{:ok, metadata, body} <- Metadata.parse_with_content(content) do
new_metadata = Map.delete(metadata, :url_slug)
new_content = Metadata.serialize(new_metadata) <> body
File.write(file_path, new_content)
else
_ -> :ok
end
end
@doc """
Clears a specific url_slug from all translations of a single post.
This is used when saving a post with a conflicting url_slug - we clear the
url_slug from all translations of the same post that have that value.
Returns the list of language codes that were cleared.
"""
@spec clear_url_slug_from_post(String.t(), String.t(), String.t()) :: [String.t()]
def clear_url_slug_from_post(group_slug, post_slug, url_slug_to_clear) do
post_path = Path.join([Paths.group_path(group_slug), post_slug])
structure = Versions.detect_post_structure(post_path)
versions = Versions.list_versions(group_slug, post_slug)
languages_to_clear =
versions
|> Enum.flat_map(&find_languages_with_url_slug(post_path, structure, &1, url_slug_to_clear))
|> Enum.uniq()
Enum.each(languages_to_clear, fn language ->
clear_url_slug_for_language(group_slug, post_slug, language)
end)
log_cleared_languages(languages_to_clear, url_slug_to_clear, post_slug)
languages_to_clear
end
defp find_languages_with_url_slug(post_path, structure, version, url_slug_to_clear) do
version_dir = get_version_dir(post_path, structure, version)
if version_dir && File.dir?(version_dir) do
version_dir
|> Languages.detect_available_languages()
|> Enum.filter(&language_has_url_slug?(version_dir, &1, url_slug_to_clear))
else
[]
end
end
defp language_has_url_slug?(version_dir, lang, url_slug_to_clear) do
file_path = Path.join(version_dir, Languages.language_filename(lang))
with {:ok, content} <- File.read(file_path),
{:ok, metadata, _body} <- Metadata.parse_with_content(content) do
Map.get(metadata, :url_slug) == url_slug_to_clear
else
_ -> false
end
end
defp log_cleared_languages([], _url_slug, _post_slug), do: :ok
defp log_cleared_languages(languages, url_slug, post_slug) do
Logger.info(
"[Slugs] Cleared url_slug '#{url_slug}' from post '#{post_slug}' languages: #{inspect(languages)}"
)
end
@doc """
Generates a unique slug based on title and optional preferred slug.
Returns `{:ok, slug}` or `{:error, reason}` where reason can be:
- `:invalid_format` - slug has invalid format
- `:reserved_language_code` - slug is a reserved language code
"""
@spec generate_unique_slug(String.t(), String.t(), String.t() | nil, keyword()) ::
{:ok, String.t()} | {:error, :invalid_format | :reserved_language_code}
def generate_unique_slug(group_slug, title, preferred_slug \\ nil, opts \\ []) do
current_slug = Keyword.get(opts, :current_slug)
base_slug_result =
case preferred_slug do
nil ->
{:ok, Slug.slugify(title)}
slug when is_binary(slug) ->
sanitized = Slug.slugify(slug)
if sanitized == "" do
{:ok, Slug.slugify(title)}
else
case validate_slug(sanitized) do
{:ok, valid_slug} ->
{:ok, valid_slug}
{:error, reason} ->
{:error, reason}
end
end
end
case base_slug_result do
{:ok, base_slug} when base_slug != "" ->
{:ok,
Slug.ensure_unique(base_slug, fn candidate ->
slug_exists_for_generation?(group_slug, candidate, current_slug)
end)}
{:ok, ""} ->
{:ok,
Slug.ensure_unique("untitled", fn candidate ->
slug_exists_for_generation?(group_slug, candidate, current_slug)
end)}
{:error, reason} ->
{:error, reason}
end
end
defp slug_exists_for_generation?(_group_slug, candidate, current_slug)
when not is_nil(current_slug) and candidate == current_slug,
do: false
defp slug_exists_for_generation?(group_slug, candidate, _current_slug) do
slug_exists?(group_slug, candidate)
end
end