Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions lib/phoenix_kit/utils/slug.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
defmodule PhoenixKit.Utils.Slug do
@moduledoc """
Helpers for generating consistent, URL-friendly slugs across PhoenixKit.

Provides functions to slugify arbitrary text, enforce separator rules,
and ensure uniqueness by delegating existence checks via callback.
"""

@doc """
Converts the given `text` into a slug.

Options:
* `:separator` - character used between words (defaults to "-")

Returns an empty string when the input is blank or cannot be converted.
"""
@spec slugify(String.t() | nil, keyword()) :: String.t()
def slugify(text, opts \\ [])

def slugify(nil, _opts), do: ""

def slugify(text, opts) when is_binary(text) do
separator = Keyword.get(opts, :separator, "-")
escaped_separator = Regex.escape(separator)

text
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/u, separator)
|> String.replace(~r/#{escaped_separator}+/, separator)
|> String.trim(separator)
end

def slugify(_text, _opts), do: ""

@doc """
Ensures the provided slug is unique by calling `exists_fun`.

`exists_fun` should return truthy when the slug is already taken.
"""
@spec ensure_unique(String.t(), (String.t() -> boolean())) :: String.t()
def ensure_unique("", _exists_fun), do: ""

def ensure_unique(slug, exists_fun) when is_function(exists_fun, 1) do
if exists_fun.(slug) do
increment_slug(slug, 2, exists_fun)
else
slug
end
end

defp increment_slug(base_slug, counter, exists_fun) do
candidate = "#{base_slug}-#{counter}"

if exists_fun.(candidate) do
increment_slug(base_slug, counter + 1, exists_fun)
else
candidate
end
end
end
91 changes: 41 additions & 50 deletions lib/phoenix_kit_web/live/modules.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -301,38 +301,6 @@
enabled={@blogging_enabled}
toggle_event="toggle_blogging"
>
<:info>
<% url_prefix =
PhoenixKit.Config.get_url_prefix()
|> case do
"/" -> ""
prefix -> prefix
end %>

<div class="space-y-2">
<p class="text-xs font-medium text-base-content">
{gettext("Key paths & URLs")}
</p>
<ul class="list-disc ml-4 space-y-1">
<li>
<span class="font-medium text-base-content">
{gettext("Post files on disk:")}
</span>
<code class="font-mono text-xs bg-base-300/60 rounded px-2 py-0.5">
priv/blogging/&lt;blog&gt;/&lt;slug&gt;/&lt;locale&gt;.phk
</code>
</li>
<li>
<span class="font-medium text-base-content">
{gettext("Public URL pattern:")}
</span>
<code class="font-mono text-xs bg-base-300/60 rounded px-2 py-0.5">
{url_prefix}/:locale/:blog_slug/:post_slug
</code>
</li>
</ul>
</div>
</:info>
<:status_badges>
<span class={[
"badge",
Expand All @@ -350,7 +318,7 @@
}
class={"btn btn-sm #{if @blogging_enabled, do: "btn-primary", else: "btn-outline"}"}
>
<.icon name="hero-document-text" class="w-4 h-4 mr-1" /> {gettext("Open Blogging")}
<.icon name="hero-document-text" class="w-4 h-4 mr-1" /> {gettext("Open")}
</.link>
<.link
navigate={
Expand All @@ -360,10 +328,38 @@
}
class="btn btn-sm btn-outline"
>
<.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> {gettext("Manage Blogging")}
<.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> {gettext("Configure")}
</.link>
</div>
</:action_buttons>

<:stats>
<% url_prefix =
PhoenixKit.Config.get_url_prefix()
|> case do
"/" -> ""
prefix -> prefix
end %>

<div class="space-y-1.5">
<div>
<span class="font-medium text-base-content">
{gettext("Post files on disk:")}
</span>
<code class="font-mono text-xs bg-base-300/60 rounded px-2 py-0.5 break-all">
priv/blogging/&lt;blog&gt;/&lt;slug&gt;/&lt;locale&gt;.phk
</code>
</div>
<div>
<span class="font-medium text-base-content">
{gettext("Public URL pattern:")}
</span>
<code class="font-mono text-xs bg-base-300/60 rounded px-2 py-0.5 break-all">
{url_prefix}/:locale/:blog_slug/:post_slug
</code>
</div>
</div>
</:stats>
</PhoenixKitWeb.Components.Core.ModuleCard.module_card>

<%!-- SEO Module --%>
Expand All @@ -374,27 +370,13 @@
enabled={@seo_module_enabled}
toggle_event="toggle_seo_module"
>
<:info>
<p class="text-sm text-base-content/70">
Toggle the SEO module to unlock dedicated settings for search engines.
</p>
</:info>

<:status_badges>
<span class={[
"badge",
if(@seo_module_enabled, do: "badge-success", else: "badge-neutral")
]}>
{if @seo_module_enabled, do: "Module Enabled", else: "Module Disabled"}
{if @seo_module_enabled, do: "Enabled", else: "Disabled"}
</span>
<%= if @seo_module_enabled do %>
<span class={[
"badge badge-outline ml-2",
if(@seo_no_index_enabled, do: "badge-warning", else: "badge-info")
]}>
{if @seo_no_index_enabled, do: "Noindex Active", else: "Indexing Allowed"}
</span>
<% end %>
</:status_badges>

<:action_buttons>
Expand All @@ -404,9 +386,18 @@
}
class={"btn btn-sm w-full #{if @seo_module_enabled, do: "btn-primary", else: "btn-outline"}"}
>
<.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure SEO
<.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure
</.link>
</:action_buttons>

<:stats>
<div class="text-sm">
<span class="text-base-content/70">Noindex:</span>
<span class="ml-2 font-medium">
{if @seo_no_index_enabled, do: "On", else: "Off"}
</span>
</div>
</:stats>
</PhoenixKitWeb.Components.Core.ModuleCard.module_card>

<%!-- Pages module remains disabled but code retained for future use --%>
Expand Down
66 changes: 21 additions & 45 deletions lib/phoenix_kit_web/live/modules/blogging/context/storage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do

alias PhoenixKit.Modules.Languages
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Slug
alias PhoenixKitWeb.Live.Modules.Blogging.Metadata

@doc """
Expand Down Expand Up @@ -117,26 +118,36 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do
|> File.dir?()
end

defp slug_exists_for_generation?(_blog_slug, candidate, current_slug)
when not is_nil(current_slug) and candidate == current_slug,
do: false

defp slug_exists_for_generation?(blog_slug, candidate, _current_slug) do
slug_exists?(blog_slug, candidate)
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) ::
@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(blog_slug, title, preferred_slug \\ nil) do
def generate_unique_slug(blog_slug, title, preferred_slug \\ nil, opts \\ []) do
current_slug = Keyword.get(opts, :current_slug)

base_slug_result =
case preferred_slug do
nil ->
{:ok, generate_slug_from_title(title)}
{:ok, Slug.slugify(title)}

slug when is_binary(slug) ->
sanitized = sanitize_slug(slug)
sanitized = Slug.slugify(slug)

if sanitized == "" do
{:ok, generate_slug_from_title(title)}
{:ok, Slug.slugify(title)}
else
# Validate the sanitized slug
case validate_slug(sanitized) do
Expand All @@ -152,7 +163,10 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do

case base_slug_result do
{:ok, base_slug} when base_slug != "" ->
{:ok, ensure_unique_slug(blog_slug, base_slug)}
{:ok,
Slug.ensure_unique(base_slug, fn candidate ->
slug_exists_for_generation?(blog_slug, candidate, current_slug)
end)}

{:ok, ""} ->
# Empty slug - return empty for auto-generation, will show placeholder
Expand All @@ -163,45 +177,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do
end
end

defp generate_slug_from_title(title) when is_binary(title) do
title
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/u, "-")
|> String.replace(~r/-+/, "-")
|> String.trim("-")
|> case do
# Return empty string instead of generating fallback
# This allows the form to show placeholder and user can enter custom slug
"" -> ""
slug -> slug
end
end

defp sanitize_slug(slug) do
slug
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/u, "-")
|> String.replace(~r/-+/, "-")
|> String.trim("-")
end

defp ensure_unique_slug(blog_slug, base_slug) do
if slug_exists?(blog_slug, base_slug) do
find_available_slug(blog_slug, base_slug, 2)
else
base_slug
end
end

defp find_available_slug(blog_slug, base_slug, counter) do
candidate = "#{base_slug}-#{counter}"

if slug_exists?(blog_slug, candidate) do
find_available_slug(blog_slug, base_slug, counter + 1)
else
candidate
end
end
# slug helpers now handled by PhoenixKit.Utils.Slug

@type post :: %{
blog: String.t() | nil,
Expand Down
Loading
Loading