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("Київ", 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 ...", ...}

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.