Locale-aware and script-aware URL slugs for Elixir. Zero dependencies.

LocaleSlug.slugify("Größe Fußball", locale: "de")   #=> "groesse-fussball"
LocaleSlug.slugify("Töö õun", locale: "et")         #=> "too-oun"
LocaleSlug.slugify("Цветокоррекция")                #=> "tsvetokorrektsiya"
LocaleSlug.slugify("Київ", locale: "uk")            #=> "kyiv"
LocaleSlug.slugify("България", locale: "bg")        #=> "bulgaria"
LocaleSlug.slugify("Καλημέρα")                      #=> "kalimera"
LocaleSlug.slugify("日本語 入門", script: :native)    #=> "日本語-入門"

Why

Slug generation is locale-dependent, and no other Elixir package treats it that way. ö must become oe in German and o in Estonian. Measured, by running each package:

inputslugify 1.3.1slugger 0.3.0LocaleSlug
Größe Fußballgrosse-fussballgroesse-fussballgroesse-fussball(locale: "de")
Töö õun (Estonian)too-ountoeoe-ountoo-oun(locale: "et")
Łódźlodz
Καλημέραkalimera

slugger applies German rules to Estonian; slugify the reverse. Neither can do better, because neither accepts a locale — and the two maintained transliterators (any_ascii, unidecode) expose arity-1 functions, so they are structurally incapable of it. The only locale-aware machinery in Elixir is an ICU C NIF, which needs a toolchain and ICU headers on every host that compiles it.

Install

def deps do
  [{:locale_slug, "~> 0.2"}]
end

Two axes

:locale picks locale preferences (German ö→oe) and, for languages with their own official romanization, the schema — uk selects Ukraine's KMU 55:2010 rather than treating Ukrainian as Russian with edits.

:script picks the output alphabet:

LocaleSlug.slugify("Цветокоррекция", script: :latin)   #=> "tsvetokorrektsiya"
LocaleSlug.slugify("Цветокоррекция", script: :native)  #=> "цветокоррекция"

Native-script URLs are legal (RFC 3987), render natively in browsers, and are what Russian and Japanese sites routinely use. :native is not "return unchanged" — it still normalises, lowercases locale-correctly, and strips every RFC 3986 reserved character.

Options

optiondefault
:localenilBCP 47 tag: "de", "et-EE", :et
:script:latin:latin or :native
:separator"-"
:max_lengthnilin output characters; never severs a mapping
:schemaper-localee.g. :kmu_2010, or %{cyrillic: :kmu_2010}
:fallback:native:empty for ASCII-or-nothing
:strictfalseraise on a malformed :locale instead of falling back

⚠️ :latin is not an unconditional ASCII guarantee. Under the default fallback: :native, a script with no romanizer survives in its own script rather than disappearing. Pass fallback: :empty when you need ASCII or nothing.

An unknown :schema raises with or without :strict. A locale can legitimately come from data — a profile field, an Accept-Language header — so "we ship no table for that" is a normal outcome and :strict is how you opt into hearing about it. A schema names a table, so an id with no table is a typo, and the quiet alternative is a plausible-looking slug in the wrong language. Neither option ever raises on unromanizable text.

:schema accepts a string anywhere it accepts an atom, so a value from config or an env var needs no String.to_atom/1. If you do source it from config, check it once at load against LocaleSlug.schemas/0 rather than on every call.

Contextual rules

The romanization standards this implements are not character maps. ISO 843 Type 2 is a transcription:

LocaleSlug.slugify("μπύρα")   #=> "byra"    # μπ is /b/ at a word edge
LocaleSlug.slugify("λάμπα")   #=> "lampa"   # ...and /mp/ inside one
LocaleSlug.slugify("Ежик")    #=> "yezhik"  # BGN/PCGN: е is "ye" word-initially
LocaleSlug.slugify("небо")    #=> "nebo"    # ...and "e" after a consonant

Locale input

Language tags only, case-insensitive, _ or -:

LocaleSlug.slugify(text, locale: "et")      # all equivalent
LocaleSlug.slugify(text, locale: "ET")
LocaleSlug.slugify(text, locale: "et_EE")
LocaleSlug.slugify(text, locale: :et)

resolve/1 tells you what happened, so slugify/2 never has to log:

LocaleSlug.resolve("de")        #=> {:ok, %LocaleSlug.Resolver{}}
LocaleSlug.resolve("xx")        #=> {:missing, "xx", %Resolver{}}  — still slugs fine
LocaleSlug.resolve("GB")        #=> {:invalid, :country_only}
LocaleSlug.resolve("Estonian")  #=> {:invalid, :not_a_language_tag}

Only codes that are not real language subtags are rejected. uk is Ukrainian, no is Norwegian, ar is Arabic, ca is Catalan — none of them are treated as country codes, even though each doubles as one.

Trust, and what verified means

Every table declares where it came from and how well it is checked:

LocaleSlug.info("de")
#=> %{status: "provisional", source: "Duden / Rat für deutsche Rechtschreibung ...", ...}
  • verifieda human who reads the language signed off. A machine may not grant it.
  • provisional — sourced from a published standard, no native-speaker sign-off yet.
  • draft — proposed, reviewed by nothing.

Prefer verified tables for slugs you persist. Everything in 0.1.0 is provisional: the tables cite real standards, but no native speaker has reviewed them yet. Native speakers wanted — see below.

Contributing a locale

Tables are YAML, not Elixir, so you do not need to write Elixir to fix one:

locale: de
status: provisional
source: "Duden / Rat für deutsche Rechtschreibung — umlaut transcription"
mappings:
  "ä": "ae"
  "ö": "oe"
examples:            # these become tests
  - in:  "Größe Fußball"
    out: "groesse-fussball"

Add an in/out pair to examples: and the suite proves it. A PR that changes a mapping must change or add an example.

Limits

  • Turkish ı/i collapse. ılık and ilik both slug to ilik. Six Turkish letters fold onto bare ASCII, and that fold is the Turkish convention — inventing a spelling to avoid it would produce slugs no Turkish speaker recognises. Use script: :native for fidelity; use your schema for uniqueness.
  • Slugs are not identifiers. Any lossy romanization merges words, and so does plain English (Café/Cafe, Blue Shirt/Blue shirt). Uniqueness belongs to your schema.
  • Cyrillic with no locale is romanized Russian-style. Nothing in a string declares its language and we do not guess from character frequency, so a Ukrainian shop must pass locale: "uk". The default schema covers the whole script — no letter is dropped — but it reads shared letters the Russian way, so Київ is kiyiv until you name the locale.
  • Kanji is out of scope for :latin. The reading is a property of the word, not the character (生物 is seibutsu or namamono), and it is an open research problem. script: :native handles Japanese correctly today.

License

MIT. Romanization tables derive in part from Unicode CLDR/ICU data under the Unicode License v3 — see NOTICE.