PhoenixKitCatalogue.Catalogue.Slugs (PhoenixKitCatalogue v0.44.2)

Copy Markdown View Source

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

default_lang_slug(data, slug_map)

@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(title, lang, opts \\ [])

@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(changeset, field, opts)

@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(base, lang, taken?)

@spec unique(String.t(), String.t(), (String.t(), String.t() -> 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.