Shared per-language slug generation for Product and Category.
Both schemas used to carry a private copy of this reduce. They drifted twice — Cyrillic, then German — and PR #21's empty-result guard would have been the third copy of the same function. There is one rule:
- an existing non-blank slug is left alone (write-once; renaming must not move a live URL)
- a blank or missing slug is derived from that language's title/name
Slug.slugify/2returning""(CJK, Arabic, emoji — scripts with no romanizer) must not write"". An empty value used to squat on the old primary-slug index and lock the shop out; after V171 it is simply not a URL. Either way the product is unreachable.- the fallback is a stable
item-<sha256>of the source text, so two different unromanizable titles never collide and the same title always produces the same slug - leftover
""values already in the map are scrubbed, so a legacy row self-heals on its next save
Summary
Functions
Stable ASCII slug for text Slug.slugify/2 cannot romanize.
Fill missing per-language slugs on changeset from source_field.
Fill a blank string slug (shipping methods) when put_slug/3 left it
empty because the source did not romanize.
Functions
Stable ASCII slug for text Slug.slugify/2 cannot romanize.
The hash is of the source text alone, so the same title under two languages shares one value (allowed: uniqueness is per language) and two different titles almost never collide.
@spec maybe_generate(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
Fill missing per-language slugs on changeset from source_field.
source_field is :title on products and :name on categories.
@spec put_plain_fallback(Ecto.Changeset.t(), atom(), keyword()) :: Ecto.Changeset.t()
Fill a blank string slug (shipping methods) when put_slug/3 left it
empty because the source did not romanize.
The column is NOT NULL. Leaving it blank is a failed insert, not a
missing URL — the same empty-slugify case as products, via a plain
unique column instead of the jsonb projection.