LocaleSlug

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("Καλημέρα") #=> "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.1"}]
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 malformed options only

⚠️ :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.

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 ...", ...}

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

License

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