Packages
Locale-aware and script-aware URL slugs. German ö→oe, Estonian ö→o, Ukrainian Київ→kyiv. Cyrillic and Greek romanization from cited national standards. Pure Elixir, no NIF, zero dependencies.
Current section
Files
Jump to
Current section
Files
locale_slug
CHANGELOG.md
CHANGELOG.md
# Changelog
## 0.2.0 — 2026-08-10
Cyrillic beyond Russian. 0.1.0 shipped a Cyrillic layer that only really covered
Russian, and named three romanization schemas it did not contain — so the two ways of
asking for Ukrainian, Bulgarian or Serbian both failed, one loudly and one silently.
### ⚠️ Slug output changes
Cyrillic slugs from 0.1.0 are not all reproducible in 0.2.0. If you persist slugs,
re-generate or pin the old values before upgrading. Nothing outside Cyrillic moves;
German, Estonian, Greek and `script: :native` are byte-identical.
0.1.0 gave two different wrong answers depending on `:fallback` — the default let the
unromanized letter through, `:empty` dropped it:
| input | 0.1.0 (default) | 0.1.0 (`fallback: :empty`) | 0.2.0 |
|---|---|---|---|
| `Київ` | `kiіv` | `ki-v` | `kiyiv` |
| `Ґанок` | `ґanok` | `anok` | `ganok` |
| `Ђорђе` | `ђorђe` | `or-e` | `djordje` |
| `Київ` (`locale: "uk"`) | `киів` | `""` | `kyiv` |
| `България` (`locale: "bg"`) | `българия` | `""` | `bulgaria` |
| `Шаркаўшчына` | `sharkaushchyna` | `sharkaushchyna` | `sharkawshchyna` |
### Fixed
* **`locale: "uk"`, `"bg"` and `"sr"` produced an empty slug for any Cyrillic input.**
The resolver mapped them to `kmu_2010` / `bg_2009` / `sr_ascii`, none of which
shipped a table. `Tables.script/1` returned `nil`, the matcher found no rules, and
every Cyrillic grapheme was stripped — so passing the *correct* locale for Ukrainian
reproduced the exact bug this package exists to fix. Under the default
`fallback: :native` it surfaced as unromanized passthrough (`"киів"`) instead, which
looked like it had worked.
* **The default Cyrillic schema deleted every letter Russian does not have.** BGN/PCGN
is a family of per-language systems, and only its Russian member shipped; `і ї є ґ ј
љ њ ћ џ ђ ѕ` had no rule at all. A name written only in those letters slugged to `""`.
* **An unknown `:schema` in the map form bypassed validation**, so
`schema: %{cyrillic: :kmu_2010}` slipped past `strict: true`. The bare-atom form
already raised.
### Added
* **Ukrainian — KMU 55:2010** (`schema: :kmu_2010`, selected by `locale: "uk"`).
Ukraine's own standard, including its word-position splits (`є`→`ye`/`ie`) and the
`зг`→`zgh` cluster. `Київ` → `kyiv`, `Запоріжжя` → `zaporizhzhia`.
* **Bulgarian — the 2009 Transliteration Act** (`schema: :bg_2009`, via `locale: "bg"`),
including Чл. 5(2)'s word-final `-ия` → `-ia` and Чл. 6's named exception for the
country itself, `България` → `Bulgaria` rather than the `Balgaria` the `ъ`→`a` rule
would otherwise give.
* **Serbian — Gaj's alphabet, ASCII-folded** (`schema: :sr_ascii`, via `locale: "sr"`).
The fold is lossy and every collision is declared in the table's `deviations:`.
* **The default Cyrillic schema now answers for the whole script**, taking letters
Russian lacks from the BGN/PCGN system that defines them. It stays Russian-primary
on shared letters, so `Київ` with no locale is `kiyiv` — name the locale for `kyiv`.
* Belarusian `ў` now follows the standard the table cites: BGN/PCGN Belarusian 1979
romanizes it `w`, so `Шаркаўшчына` is `sharkawshchyna`. It previously fell through to
`u` by Unicode decomposition, which no cited source supported.
* `LocaleSlug.info/1` gained `:script`.
### Changed
* A script now contributes **two** L1 layers: the schema you selected, and the script
default beneath it as a backstop. A national standard is not a complete script table
— KMU 55:2010 has no `ъ ы э ё` — so without this, selecting one would delete the
letters that language happens not to use.
* **An unknown `:schema` now raises `ArgumentError`, with or without `:strict`** — as
does a schema given under the wrong script, like `%{greek: :kmu_2010}`. Previously
the map form skipped validation entirely and the atom form only complained under
`strict: true`, so a typo such as `:kmu_201` silently romanized Ukrainian with the
Russian-primary default and looked entirely plausible doing it. That is the same
silent-wrong-output failure as the bug above, one option away.
The split is deliberate: a `:locale` may legitimately arrive from data, so a miss
stays soft and `:strict` opts into raising. A `:schema` names a table rather than
describing input, so an id with no table is a typo either way. The raise is never
triggered by the *text* being slugified — only by a value the operator controls.
* **`:schema` now accepts a string anywhere it accepted an atom**, so a value from
config or an env var needs no `String.to_atom/1` on external input. Passing a string
previously matched no clause at all and died with `CaseClauseError`. Every other
malformed shape — a tuple, a list, a string map key, a value with no `String.Chars`
— now raises a readable `ArgumentError` naming the actual problem instead of
surfacing as `CaseClauseError` or `Protocol.UndefinedError`.
### Internal
* **`examples:` in `priv/*.yaml` are now executed as tests.** They never were: the
generator parsed them and dropped them before codegen, while the README and AGENTS.md
both promised "they become tests". That unenforced contract is why the Cyrillic gap
shipped. A table with no examples now fails the suite.
* The "every letter is accounted for" invariant swept only `U+0430..U+044F`, and so
passed while all of `U+0450..U+045F` vanished. It now covers the extended block.
* A test now proves every schema id the resolver can name actually ships a table.
Nothing compared those two lists before, which is exactly how `uk → kmu_2010` came
to point at a table that did not exist.
* 189 tests, up from 106.
## 0.1.0 — 2026-08-09
First release.
* Locale-aware slugs: German `ö→oe`, Estonian `ö→o` — the distinction no other Elixir
package makes.
* Script romanization from cited standards: Cyrillic (BGN/PCGN) and Greek
(ISO 843 Type 2), including their contextual rules — Greek `μπ` is `b` at a word edge
and `mp` inside one; Russian `е` is `ye` word-initially and after a vowel or sign.
* `script: :native` for Unicode/IRI slugs, correct for Japanese and Chinese today.
* Letters with no canonical decomposition (`ø æ ł ı đ ß ð þ`) no longer vanish, and
non-ASCII digits fold rather than being deleted.
* `:max_length` never severs a mapping's output — `щ→shch` is kept whole or dropped.
* Zero runtime dependencies.