PhoenixKit.Utils.Slug (phoenix_kit v2.7.0)

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.

Puts a generated, collision-free slug on changeset, derived from source_field.

Converts the given text into a slug.

Romanizes text and leaves everything else alone.

Functions

ensure_unique(slug, exists_fun, opts \\ [])

@spec ensure_unique(String.t(), (String.t() -> boolean()), keyword()) :: 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.

Options

  • :max_length — the ceiling the final slug must respect, suffix included.

Without it the suffix is simply appended, which overflows any cap the caller already applied: slugify(title, max_length: 20) returns 20 characters and -2 makes 22. That silently defeats an SEO cap, and against a varchar(n) column Postgres raises rather than truncating. Given the option, the base is trimmed to make room — per candidate, since -10 needs one more character than -9 — and a trailing separator left by the trim is stripped so the result is never foo--2.

put_slug(changeset, source_field, opts \\ [])

@spec put_slug(Ecto.Changeset.t(), atom(), keyword()) :: Ecto.Changeset.t()

Puts a generated, collision-free slug on changeset, derived from source_field.

This is the changeset glue that was missing from core, and which 14 hand-rolled maybe_generate_slug/1 copies across eight sibling packages each got a different subset of right.

The four cases, and why "absent" is not "empty"

The bug this exists to delete is reading get_change(:slug) and treating nil as "this record has no slug". It does not mean that — it means this save did not carry one, which is true of almost every save. cast/3 drops a value equal to the data, so an edit form that faithfully re-sends the current slug produces no change at all. Under get_change/2 that reads as "regenerate from the title", so renaming anything moved its live URL and nothing recorded the old one.

  • an explicit, non-blank slug always wins;
  • an explicitly blanked slug regenerates (the column is typically NOT NULL, so storing the blank is not an option);
  • no slug in the changeset means unchanged — generate only if the stored record has none;
  • a source that slugifies to "" leaves the changeset alone rather than writing a blank that the next save would read as "no slug yet" and regenerate forever.

Options

  • :to — the slug field. Defaults to :slug.
  • :scope — fields the uniqueness is scoped by, e.g. [:user_uuid] for a per-user slug backed by a composite index. Defaults to [] (global).
  • :unique — set false to skip the probe entirely. Defaults to true.
  • :repo — the repo to probe. Defaults to the configured PhoenixKit repo.
  • :queryable — override the probed source, e.g. to exclude soft-deleted rows: queryable: from(p in Post, where: is_nil(p.deleted_at)). Defaults to the changeset's own schema.
  • :locale, :separator, :max_length — forwarded to slugify/2.

Uniqueness here is an allocator, not a guarantee

The probe runs from inside the changeset, which is unusual but deliberate: it is what Ecto.Changeset.unsafe_validate_unique/4 does, and the alternative — generate in the schema, uniquify in the context — splits one decision across two modules and is how the copies drifted in the first place.

It stays advisory. A concurrent insert between the probe and the write still collides, so every caller must also declare unique_constraint/3 against an index that actually exists. This function picks a free-looking suffix; the index is what makes it true.

No repo configured is tolerated; a broken repo is not

If no repo is configured at all, the uniqueness probe is skipped — the library is usable for pure changeset construction without a database. If a repo is configured and the query then fails, the error is allowed to raise. The predecessor in phoenix_kit_posts wrapped the whole probe in a bare rescue _ -> slug, which cannot tell those apart and answers a transient database fault by writing the unsuffixed slug — reintroducing exactly the duplicate the probe exists to prevent.

Examples

changeset |> Slug.put_slug(:title)
changeset |> Slug.put_slug(:name, scope: [:user_uuid])
changeset |> Slug.put_slug(:title, locale: "de", max_length: 60)

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.