From f56e04390c7c586d8d9fc1ab5c8cf43a900d10bc Mon Sep 17 00:00:00 2001 From: jamey Date: Wed, 1 Apr 2026 00:35:15 +0100 Subject: [PATCH] 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 --- lib/berrypod/pages/defaults.ex | 3 +- lib/berrypod/pages/page.ex | 57 ++++++++++++++++++- .../20260329210409_add_url_slug_to_pages.exs | 12 ++++ 3 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 priv/repo/migrations/20260329210409_add_url_slug_to_pages.exs diff --git a/lib/berrypod/pages/defaults.ex b/lib/berrypod/pages/defaults.ex index ae3c814..99a4bf4 100644 --- a/lib/berrypod/pages/defaults.ex +++ b/lib/berrypod/pages/defaults.ex @@ -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 diff --git a/lib/berrypod/pages/page.ex b/lib/berrypod/pages/page.ex index e8edc8b..23c1533 100644 --- a/lib/berrypod/pages/page.ex +++ b/lib/berrypod/pages/page.ex @@ -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 diff --git a/priv/repo/migrations/20260329210409_add_url_slug_to_pages.exs b/priv/repo/migrations/20260329210409_add_url_slug_to_pages.exs new file mode 100644 index 0000000..042913d --- /dev/null +++ b/priv/repo/migrations/20260329210409_add_url_slug_to_pages.exs @@ -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