disarm

Romanized as the language, not as characters

Slugify any language

Київ becomes kyiv if something knows it is Ukrainian, and kiyiv if nothing does. One of those is the name of the city. Plenty of slug libraries can romanise by language if you tell them which one — npm's slugify takes a locale, for instance. The unusual part here is that disarm can work the language out from the text itself, and this page shows you which character gave it away.

The tool

kyiv
83 language profiles

The language changes this slug.

With a profile it is kyiv; without one, kiyiv. The detected romanisation matches Ukrainian.

No language profile

kiyiv

Character by character, with no rule for the language.

With the language

kyiv

How the language actually romanises.

What gave the language away

Detection works by finding a character that belongs to exactly one language. Remove this one and disarm no longer knows which language this is, and falls back to the script default. Found by asking the engine rather than by consulting a copy of its table.

їU+0457CYRILLIC SMALL LETTER YI

Running disarm 0.14.1, compiled to WebAssembly. Your text is never uploaded — the engine is loaded into this page and runs on your machine.

One string, two romanizations

The tool above needs JavaScript. This is the same finding written out, so it is legible without running anything.

TitleNo profile DetectedWhat identified it
Київkiyiv kyivї U+0457
Fußgänger in Kölnfussganger-in-koln fussgaenger-in-koelnß U+00DF
Ärger im Büroarger-im-buro arger-im-buroNothing — say de yourself
Београдbeograd beogradNo difference for this word
ภาษาไทยphasaaithy phasaaithyThai maps to one language already

Measured against disarm 0.14.1. The third row is the honest one: Ärger im Büro is unambiguously German to a reader and contains nothing that is exclusively German to a machine. Both ä and ü appear in other languages, so detection declines rather than guessing, and you pass de yourself.

How the language is worked out

Three stages, and the first usually settles it. The dominant script is identified, and most scripts map to exactly one language in disarm's profile set — Thai, Georgian, Armenian, Hangul, Hebrew, Greek and about fifteen more. For those, asking for detection is the same as naming the language.

Three scripts are shared: Cyrillic, Arabic and Latin. For those, disarm looks for an exclusive character, one that appears in a single language's alphabet and no other — ї for Ukrainian, ђ for Serbian, ө for Mongolian, پ for Persian, ư for Vietnamese, ı for Turkish, ß for German. The first one found wins and the scan stops.

If none is found, the script default applies: Russian for Cyrillic, Arabic for Arabic, and no override at all for Latin. That is a deliberate refusal to guess, and it is why the tool above tells you when the profile made no difference — a slug that is the same either way is not evidence that detection worked.

The character the tool reports is not read from a copy of that table. It is found by removing one character at a time and asking the engine again: the character whose absence changes the answer is the one the answer depended on. If disarm's table changes, this follows it.

The empty slug is a routing bug

A title of emoji, punctuation or invisible characters has no romanization, so it slugs to the empty string. Two such titles collide on one URL, and the second overwrites the first or the route stops resolving. Supply a fallback rather than storing an empty slug, and make it unique per record if the slug is a key.

The Python binding takes a default for exactly this. In 0.14.1 that parameter is on the Python API and not on the Rust SlugConfig — but the behaviour needs no library support to reproduce, because it is two lines:

TitleFallbackSlug
🔥🔥🔥n/an-a
🔥../../etc/passwdetc-passwd
🔥a/b?c#da-b-c-d

Slug the title; if the result is empty, slug the fallback with the same configuration. That last part is the whole security property. A fallback is caller-supplied, and a caller-supplied string reaching a URL unsanitised is how ../../etc/passwd ends up in a value everything downstream assumes is safe. Running it through the same pipeline makes it etc-passwd, and the same max_length applies to it too. Set a fallback above and try it.

The same thing in your own code

Each code block has been compiled and verified in CI. Provided under the MIT license to illustrate disarm. disarm on GitHub →

# A slug romanized as the language, not character by character.
#   pip install disarm
from disarm import slugify

# и romanizes differently in Ukrainian than in Russian, so a library with one
# table for all Cyrillic cannot get both right. Kyiv is the city; kiyiv is what
# you get when nothing knew the language.
assert slugify("Київ") == "kiyiv"
assert slugify("Київ", lang="uk") == "kyiv"
assert slugify("Київ", lang="auto") == "kyiv"

# Detection needs a character exclusive to one language. ї is Ukrainian and
# appears in no other Cyrillic alphabet disarm profiles, so auto finds it.
# ß plays the same role for German.
assert slugify("Fußgänger", lang="auto") == "fussgaenger"
assert slugify("Fußgänger") == "fussganger"

# And where there is no such character, detection declines rather than guessing.
# Ärger is German to a reader and ambiguous to a machine: ä and ü are used by
# other languages too, so the profile has to be passed.
assert slugify("Ärger im Büro", lang="auto") == "arger-im-buro"
assert slugify("Ärger im Büro", lang="de") == "aerger-im-buero"

print("ok: kyiv not kiyiv; detection found ї and ß, and declined on Ärger")

Found a language this romanizes wrongly? That is worth reporting — a transliteration table is only as good as the speakers who check it. Open an issue with it.

Related tools