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:
| input | slugify 1.3.1 | slugger 0.3.0 | LocaleSlug |
|---|---|---|---|
Größe Fußball | grosse-fussball | groesse-fussball ✓ | groesse-fussball ✓ (locale: "de") |
Töö õun (Estonian) | too-oun ✓ | toeoe-oun ✗ | too-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
| option | default | |
|---|---|---|
:locale | nil | BCP 47 tag: "de", "et-EE", :et |
:script | :latin | :latin or :native |
:separator | "-" | |
:max_length | nil | in output characters; never severs a mapping |
:schema | per-locale | e.g. :kmu_2010, or %{cyrillic: :kmu_2010} |
:fallback | :native | :empty for ASCII-or-nothing |
:strict | false | raise 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 ...", ...}
verified— a 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
ı/icollapse.ılıkandilikboth slug toilik. 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. Usescript: :nativefor 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". - 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: :nativehandles Japanese correctly today.
License
MIT. Romanization tables derive in part from Unicode CLDR/ICU data under the Unicode License v3 — see NOTICE.