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:
jamey 2026-04-01 00:35:15 +01:00
parent 04ce28ca29
commit f56e04390c
3 changed files with 69 additions and 3 deletions

View File

@ -18,7 +18,8 @@ defmodule Berrypod.Pages.Defaults do
meta_description: nil,
show_in_nav: false,
nav_label: nil,
nav_position: nil
nav_position: nil,
url_slug: nil
}
end

View File

@ -28,16 +28,36 @@ defmodule Berrypod.Pages.Page do
field :show_in_nav, :boolean, default: false
field :nav_label, :string
field :nav_position, :integer
field :url_slug, :string
timestamps(type: :utc_datetime)
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
page
|> cast(attrs, [:slug, :title, :blocks])
|> cast(attrs, [:slug, :title, :blocks, :url_slug])
|> validate_required([:slug, :title, :blocks])
|> validate_inclusion(:slug, @system_slugs)
|> validate_url_slug()
|> unique_constraint(:slug)
|> unique_constraint(:url_slug)
end
def custom_changeset(page, attrs) do
@ -51,7 +71,8 @@ defmodule Berrypod.Pages.Page do
:meta_description,
:show_in_nav,
:nav_label,
:nav_position
:nav_position,
:url_slug
])
|> validate_required([:slug, :title])
|> 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(:title, max: 200)
|> validate_length(:meta_description, max: 300)
|> validate_url_slug()
|> put_defaults()
|> unique_constraint(:slug)
|> unique_constraint(:url_slug)
end
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)
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 reserved_paths, do: @reserved_paths
def system_slug?(slug), do: slug in @system_slugs

View File

@ -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