# `PhoenixKitCatalogue.Catalogue.Slugs`
[🔗](https://github.com/BeamLabEU/phoenix_kit_catalogue/blob/v0.44.2/lib/phoenix_kit_catalogue/catalogue/slugs.ex#L1)

Per-language slug generation for catalogue items and categories.

`slug` is a flat `lang -> value` map (see the moduledoc amendment in the
design spec): secondary languages in the multilang `data` column store
overrides only, and replaying that merge inside a DB trigger would be
expensive, so the slug column is written in full for every language up
front instead.

The generation rule (`from_title/3`) is ported from
`PhoenixKitEcommerce.AITranslatable`'s private `slug_base/3`: only the
head segment of a title is slug-worthy (long SEO titles pack a
breadcrumb/marketing tail after a `|` or a spaced dash), the result is
capped at a word boundary, and a title with no romanizable content
(CJK, Arabic, emoji) falls back to a stable hash rather than an empty
string. Unlike that adapter, an unromanizable title here does NOT
borrow the default-language slug — it simply hashes on its own, with no
identity tail, so a bare CJK title never silently serves another
language's URL.

## `maybe_generate/3` is NOT wired into `Item.changeset/2` / `Category.changeset/2`

`Catalogue.duplicate_category/1` (and any other internal copy path)
creates a subtree's items/categories verbatim — same multilang `data`,
same name, deliberately no suffix (see
`PhoenixKitCatalogue.Catalogue.DuplicationTest` — "Nested copies keep
their translations verbatim"). Auto-generating a slug from `:name` on
every `create_item`/`create_category` call would derive the SAME slug
for the original and its copy and fail the very
`unique_constraint/3` this module asks callers to declare — a
duplicate that used to succeed would start erroring, which the design
spec's regression rule for this change forbids.

So generation is explicit, not automatic: a caller that wants an
empty slug filled from the name — the item/category form, in
practice — calls `maybe_generate/3` itself on its own changeset
before persisting. A plain `create_item`/`create_category` call
(duplication, bulk import, any other context) leaves `:slug` exactly
as given (`%{}` by default) and is unaffected.

# `default_lang_slug`

```elixir
@spec default_lang_slug(map(), map()) :: String.t() | nil
```

The primary language's own slug (falling back to the first non-blank
value present) — the source of the numeric identity tail every other
language's generated slug carries via `from_title/3`'s `:default_slug`
option.

Public so a caller generating a slug outside `maybe_generate/3` (the AI
translation adapter's write-once slug fill, in practice) derives the
same default-language slug this module's own form-driven path would,
rather than re-deriving primary-language detection by hand.

# `from_title`

```elixir
@spec from_title(String.t(), String.t(), keyword()) :: String.t()
```

A URL slug derived from `title`, generated in `lang`.

Only the first non-blank segment before a `|` or a spaced dash (` - `,
` – `, ` — `) is used, capped to 60 characters at a word boundary. A
title that slugifies to `""` (no romanizable content in `lang`) falls
back to `"item-" <> <12 hex chars of sha256(title)>` — no identity tail.

`opts[:default_slug]` — when the derived base is non-empty and this
ends in a numeric tail (`-22153`), the tail is carried onto the result
(unless already present), so every language of the same record shares
one machine-imported identity suffix.

# `maybe_generate`

```elixir
@spec maybe_generate(Ecto.Changeset.t(), atom(), keyword()) :: Ecto.Changeset.t()
```

Fills missing per-language slugs on `changeset`'s `field` (default use
is `:slug`) from the multilang name stored under `:data`, for every
language present there. A language that already has a non-blank slug
is left alone (write-once — renaming must not move a live URL).

`opts[:from]` names the source column (`:name` for both items and
categories); its per-language text is read via
`PhoenixKitCatalogue.Catalogue.Translations.translated_name/2`.

`opts[:taken?]` — a `(candidate, lang) -> boolean` probe
(`Catalogue.item_slug_taken?/3` with the record's own uuid excluded,
in practice). When given, a generated slug that is already projected
for that language gets a `-2`, `-3`, … suffix (`unique/3`) instead of
failing the save on the projection's primary key. Uniqueness is one
scope per entity kind, across every catalogue, trashed rows included
— so without the probe two supplier catalogues each holding an "Oak
panel", or a re-created item whose predecessor sits in the trash,
could not both save on a field the user never typed. Without the
option generation stays deterministic, as before.

# `unique`

```elixir
@spec unique(String.t(), String.t(), (String.t(), String.t() -&gt; boolean())) ::
  String.t()
```

The first of `base`, `base-2`, `base-3`, … that `taken?` (a
`(candidate, lang) -> boolean`) reports free in `lang`. A proactive
probe, not a lock: two writers landing on the same free slug at once
still meet the projection's `unique_constraint` on save. Shared by
`maybe_generate/3` and the AI translation adapter's write-once fill.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
