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.
Summary
Functions
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.
A URL slug derived from title, generated in lang.
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).
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.
Functions
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.
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.
@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.
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.