add url_slug column to pages table
Adds url_slug field for custom page URLs: - Migration with unique partial index on non-null slugs - Page schema with validation (format, uniqueness, reserved words) - effective_url/1 helper to resolve custom or default slug - Default page configs updated with url_slug field Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
04ce28ca29
commit
f56e04390c
@ -18,7 +18,8 @@ defmodule Berrypod.Pages.Defaults do
|
|||||||
meta_description: nil,
|
meta_description: nil,
|
||||||
show_in_nav: false,
|
show_in_nav: false,
|
||||||
nav_label: nil,
|
nav_label: nil,
|
||||||
nav_position: nil
|
nav_position: nil,
|
||||||
|
url_slug: nil
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -28,16 +28,36 @@ defmodule Berrypod.Pages.Page do
|
|||||||
field :show_in_nav, :boolean, default: false
|
field :show_in_nav, :boolean, default: false
|
||||||
field :nav_label, :string
|
field :nav_label, :string
|
||||||
field :nav_position, :integer
|
field :nav_position, :integer
|
||||||
|
field :url_slug, :string
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the effective URL path for this page.
|
||||||
|
Uses url_slug if set, otherwise falls back to slug.
|
||||||
|
Works with both Page structs and maps (from cache).
|
||||||
|
"""
|
||||||
|
def effective_url(%__MODULE__{url_slug: url_slug, slug: slug}) do
|
||||||
|
url_slug || slug
|
||||||
|
end
|
||||||
|
|
||||||
|
def effective_url(%{url_slug: url_slug, slug: slug}) do
|
||||||
|
url_slug || slug
|
||||||
|
end
|
||||||
|
|
||||||
|
def effective_url(%{slug: slug}), do: slug
|
||||||
|
|
||||||
|
def effective_url(_), do: nil
|
||||||
|
|
||||||
def system_changeset(page, attrs) do
|
def system_changeset(page, attrs) do
|
||||||
page
|
page
|
||||||
|> cast(attrs, [:slug, :title, :blocks])
|
|> cast(attrs, [:slug, :title, :blocks, :url_slug])
|
||||||
|> validate_required([:slug, :title, :blocks])
|
|> validate_required([:slug, :title, :blocks])
|
||||||
|> validate_inclusion(:slug, @system_slugs)
|
|> validate_inclusion(:slug, @system_slugs)
|
||||||
|
|> validate_url_slug()
|
||||||
|> unique_constraint(:slug)
|
|> unique_constraint(:slug)
|
||||||
|
|> unique_constraint(:url_slug)
|
||||||
end
|
end
|
||||||
|
|
||||||
def custom_changeset(page, attrs) do
|
def custom_changeset(page, attrs) do
|
||||||
@ -51,7 +71,8 @@ defmodule Berrypod.Pages.Page do
|
|||||||
:meta_description,
|
:meta_description,
|
||||||
:show_in_nav,
|
:show_in_nav,
|
||||||
:nav_label,
|
:nav_label,
|
||||||
:nav_position
|
:nav_position,
|
||||||
|
:url_slug
|
||||||
])
|
])
|
||||||
|> validate_required([:slug, :title])
|
|> validate_required([:slug, :title])
|
||||||
|> validate_format(:slug, ~r/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/,
|
|> validate_format(:slug, ~r/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/,
|
||||||
@ -61,8 +82,10 @@ defmodule Berrypod.Pages.Page do
|
|||||||
|> validate_length(:slug, max: 100)
|
|> validate_length(:slug, max: 100)
|
||||||
|> validate_length(:title, max: 200)
|
|> validate_length(:title, max: 200)
|
||||||
|> validate_length(:meta_description, max: 300)
|
|> validate_length(:meta_description, max: 300)
|
||||||
|
|> validate_url_slug()
|
||||||
|> put_defaults()
|
|> put_defaults()
|
||||||
|> unique_constraint(:slug)
|
|> unique_constraint(:slug)
|
||||||
|
|> unique_constraint(:url_slug)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp put_defaults(changeset) do
|
defp put_defaults(changeset) do
|
||||||
@ -76,6 +99,36 @@ defmodule Berrypod.Pages.Page do
|
|||||||
if get_change(changeset, field), do: changeset, else: put_change(changeset, field, default)
|
if get_change(changeset, field), do: changeset, else: put_change(changeset, field, default)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Validates the url_slug format and ensures it doesn't conflict with reserved paths
|
||||||
|
defp validate_url_slug(changeset) do
|
||||||
|
case get_change(changeset, :url_slug) do
|
||||||
|
nil ->
|
||||||
|
changeset
|
||||||
|
|
||||||
|
"" ->
|
||||||
|
# Empty string means clear the custom URL
|
||||||
|
put_change(changeset, :url_slug, nil)
|
||||||
|
|
||||||
|
url_slug ->
|
||||||
|
changeset
|
||||||
|
|> validate_format(:url_slug, ~r/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/,
|
||||||
|
message: "must be lowercase letters, numbers, and hyphens only"
|
||||||
|
)
|
||||||
|
|> validate_exclusion(:url_slug, @reserved_paths, message: "is reserved")
|
||||||
|
|> validate_length(:url_slug, max: 100)
|
||||||
|
|> validate_url_slug_not_system_slug(url_slug)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Ensure url_slug doesn't match another page's canonical slug
|
||||||
|
defp validate_url_slug_not_system_slug(changeset, url_slug) do
|
||||||
|
if url_slug in @system_slugs do
|
||||||
|
add_error(changeset, :url_slug, "cannot match a system page slug")
|
||||||
|
else
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def system_slugs, do: @system_slugs
|
def system_slugs, do: @system_slugs
|
||||||
def reserved_paths, do: @reserved_paths
|
def reserved_paths, do: @reserved_paths
|
||||||
def system_slug?(slug), do: slug in @system_slugs
|
def system_slug?(slug), do: slug in @system_slugs
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
defmodule Berrypod.Repo.Migrations.AddUrlSlugToPages do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:pages) do
|
||||||
|
add :url_slug, :string
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unique index but only for non-null values
|
||||||
|
create unique_index(:pages, [:url_slug], where: "url_slug IS NOT NULL")
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue
Block a user