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:

input0.1.0 (default)0.1.0 (fallback: :empty)0.2.0
Київkiіvki-vkiyiv
Ґанокґanokanokganok
Ђорђеђorђeor-edjordje
Київ (locale: "uk")киів""kyiv
България (locale: "bg")българия""bulgaria
Шаркаўшчынаsharkaushchynasharkaushchynasharkawshchyna

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.