PhoenixKit.Utils.Slug (phoenix_kit v2.0.1)

Copy Markdown View Source

Helpers for generating consistent, URL-friendly slugs across PhoenixKit.

The slug rule itself now lives in the locale_slug package. This module keeps its existing public shape — slugify/2, transliterate/1, ensure_unique/2 — so no caller changes, and delegates the hard part.

Why it moved out

The hand-rolled table here could not express a locale, and two bugs followed from that directly:

  • Größe Fußballgro-e-fu-ball. NFD strips the umlaut and ß has no canonical decomposition at all, so the ASCII pass deleted it. German expands these (oe, ss) and Estonian folds them (o) — one table cannot do both, and this one was doing neither.
  • A Cyrillic-only title produced an EMPTY slug wherever a caller forgot transliterate: true. Callers read empty as "no slug yet" and regenerate forever; a CSV re-import of a Cyrillic catalogue inserted the whole feed again on every run.

Transliteration is now the default

:transliterate used to default to false, which is what made the empty-slug bug reachable. Blanks help nobody, so romanization is now always on and the option is accepted and ignored, for source compatibility.

Stored slugs are not rewritten — only newly generated ones change. Existing URLs are unaffected.

What that sentence does not cover, and what to check before upgrading: a caller that re-derives a slug from a title in order to find an existing row, rather than reading the stored one, now derives a different string. The old default mangled accented Latin as badly as it did Cyrillic — Café slugged to caf, Ünïcödé Tëst to n-c-d-t-st — so the change is not confined to scripts nobody used. Deriving is the wrong lookup key either way; this release is when it stops working quietly.

Locale

Pass :locale when the caller knows it; most do and were discarding it.

Slug.slugify("Größe Fußball", locale: "de")   #=> "groesse-fussball"
Slug.slugify("Töö õun", locale: "et")         #=> "too-oun"
Slug.slugify("Цветокоррекция")                #=> "tsvetokorrektsiya"

Without a locale the result is still correct, just not locale-tuned.

Summary

Functions

Ensures the provided slug is unique by calling exists_fun.

Converts the given text into a slug.

Romanizes text and leaves everything else alone.

Functions

ensure_unique(slug, exists_fun)

@spec ensure_unique(String.t(), (String.t() -> boolean())) :: String.t()

Ensures the provided slug is unique by calling exists_fun.

exists_fun should return truthy when the slug is already taken.

This is where uniqueness belongs. Romanization is lossy in every language — Turkish ılık and ilik both romanize to ilik, and plain English Café/Cafe collide too — so slugs are not identifiers and the suffix here is the answer, not a better table.

slugify(text, opts \\ [])

@spec slugify(
  String.t() | nil,
  keyword()
) :: String.t()

Converts the given text into a slug.

Options:

  • :separator — character used between words (defaults to "-")
  • :locale — BCP 47 tag ("de", "et"); improves accuracy where known
  • :max_length — truncate, never mid-mapping
  • :transliterateignored; romanization is always on now. Accepted so the hundreds of existing transliterate: true call sites keep compiling.

Returns an empty string when the input is blank or has no slug-able content.

transliterate(text)

@spec transliterate(String.t()) :: String.t()

Romanizes text and leaves everything else alone.

Spacing and punctuation survive, which is the part callers depend on — generate_username_from_email/1 calls this and then does String.replace(".", "_"), so folding punctuation into a separator here would turn ülo.kask@ into ulokask instead of ulo_kask. Characters with no romanizer pass through in their own script ("日本" stays "日本"), so a caller that needs ASCII must still say so.

Now covers Greek and the Latin letters that used to vanish, not just the Russian/Ukrainian Cyrillic this module hardcoded.

One thing that did change: the result is lower-cased

The old table only had lowercase Cyrillic keys, so it left case alone and mangled uppercase input — "Кашпо" came back "Кashpo", half-romanized. This lower-cases first and maps the whole string, so "MiXeD CaSe" is now "mixed case" where it used to be returned untouched. Core's only caller downcases before calling anyway; a caller that needs the original casing has to keep its own copy.