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
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
Detected as uk: a character that belongs to one language and no other.
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. Reported by the engine itself through inspect_auto_lang, not by consulting a copy of its table.
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.
| Title | No profile | Detected | What identified it |
|---|---|---|---|
| Київ | kiyiv | kyiv | ї U+0457 |
| Fußgänger in Köln | fussganger-in-koln | fussgaenger-in-koeln | ß U+00DF |
| Ärger im Büro | arger-im-buro | arger-im-buro | Nothing — say de yourself |
| Београд | beograd | beograd | No difference for this word |
| ภาษาไทย | phasaaithy | phasaaithy | Thai 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.
Han is the exception, and a profile does not fix it. Han
characters are romanized as Mandarin under every one of the 83 profiles, so
東京 gives dong-jing and never
tokyo. Not one profile produces the Japanese reading; we checked
all of them rather than the two with ja in the name.
Kana are a different matter, and here the profile does work.
しんぶん gives shinbun by
default and under ja — Hepburn — and
sinbun under ja-kunrei, which is Kunrei-shiki. So a
mixed Japanese title splits down the middle:
東京タワー becomes
dong-jing-tawa, the kanji read as Mandarin and the katakana as
Japanese, in one slug. Try it above. If you are slugging Japanese titles, that is
the thing to know before you start.
The character the tool reports is not read from a copy of that table. disarm is
asked, through inspect_auto_lang, which also returns why
detection landed where it did: a discriminating character, a script that maps to
one language, the script default, or no detection at all. If disarm's table
changes, this follows it.
It used to be found by ablation — remove one character, ask again, and the
one whose absence changes the answer is the one the answer depended on. That is
sound only when the rest of the text still romanizes differently between the two
candidates, and silently wrong when it does not.
Ћирилица is Serbian
because of Ћ, and removing it leaves letters that Russian and
Serbian spell identically — so the two sides agreed and the page reported
no discriminator for a word that plainly has one. Asking outright has no such
blind spot.
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:
| Title | Fallback | Slug |
|---|---|---|
| 🔥🔥🔥 | n/a | n-a |
| 🔥 | ../../etc/passwd | etc-passwd |
| 🔥 | a/b?c#d | a-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")
// A slug romanized as the language, not character by character.
// cargo add disarm
use disarm::api::{slugify, SlugConfig};
fn slug(text: &str, lang: Option<&str>) -> String {
let mut cfg = SlugConfig::new();
cfg.lang = lang.map(str::to_string);
slugify(text, &cfg)
}
fn main() {
// и 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_eq!(slug("Київ", None), "kiyiv");
assert_eq!(slug("Київ", Some("uk")), "kyiv");
assert_eq!(slug("Київ", Some("auto")), "kyiv");
// Detection needs a character exclusive to one language. ї is Ukrainian
// and ß is German.
assert_eq!(slug("Fußgänger", Some("auto")), "fussgaenger");
assert_eq!(slug("Fußgänger", None), "fussganger");
// Where there is no such character, detection declines rather than guessing.
assert_eq!(slug("Ärger im Büro", Some("auto")), "arger-im-buro");
assert_eq!(slug("Ärger im Büro", Some("de")), "aerger-im-buero");
println!("ok: kyiv not kiyiv; detection found ї and ß, and declined on Ärger");
}
// A slug romanized as the language, not character by character.
// npm install disarm
const { slugify } = require("disarm");
const eq = (got, want, msg) => {
if (got !== want) throw new Error(`${msg}: expected ${want}, got ${got}`);
};
// и 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.
eq(slugify("Київ"), "kiyiv", "no profile");
eq(slugify("Київ", { lang: "uk" }), "kyiv", "explicit Ukrainian");
eq(slugify("Київ", { lang: "auto" }), "kyiv", "detected");
// Detection needs a character exclusive to one language. ї is Ukrainian and ß
// is German.
eq(slugify("Fußgänger", { lang: "auto" }), "fussgaenger", "detected German");
eq(slugify("Fußgänger"), "fussganger", "no profile");
// Where there is no such character, detection declines rather than guessing.
eq(slugify("Ärger im Büro", { lang: "auto" }), "arger-im-buro", "nothing to detect");
eq(slugify("Ärger im Büro", { lang: "de" }), "aerger-im-buero", "explicit German");
console.log("ok: kyiv not kiyiv; detection found ї and ß, and declined on Ärger");
# A slug romanized as the language, not character by character.
# gem install disarm
require "disarm"
def eq(got, want, msg)
raise "#{msg}: expected #{want}, got #{got}" unless got == want
end
# и 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.
eq(Disarm.slugify("Київ"), "kiyiv", "no profile")
eq(Disarm.slugify("Київ", lang: "uk"), "kyiv", "explicit Ukrainian")
eq(Disarm.slugify("Київ", lang: "auto"), "kyiv", "detected")
# Detection needs a character exclusive to one language. ї is Ukrainian, ß German.
eq(Disarm.slugify("Fußgänger", lang: "auto"), "fussgaenger", "detected German")
eq(Disarm.slugify("Fußgänger"), "fussganger", "no profile")
# Where there is no such character, detection declines rather than guessing.
eq(Disarm.slugify("Ärger im Büro", lang: "auto"), "arger-im-buro", "nothing to detect")
eq(Disarm.slugify("Ärger im Büro", lang: "de"), "aerger-im-buero", "explicit German")
puts "ok: kyiv not kiyiv; detection found ї and ß, and declined on Ärger"
// A slug romanized as the language, not character by character.
// implementation("dev.disarm:disarm:0.14.1")
import dev.disarm.Disarm;
import dev.disarm.SlugOptions;
public class SlugifyLang {
static void eq(String got, String want, String msg) {
if (!got.equals(want)) {
throw new IllegalStateException(msg + ": expected " + want + ", got " + got);
}
}
static String slug(String text, String lang) {
if (lang == null) return Disarm.slugify(text);
return Disarm.slugify(text, SlugOptions.builder().lang(lang).build());
}
public static void main(String[] args) {
// и 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.
String kyiv = "Київ";
eq(slug(kyiv, null), "kiyiv", "no profile");
eq(slug(kyiv, "uk"), "kyiv", "explicit Ukrainian");
eq(slug(kyiv, "auto"), "kyiv", "detected");
// Detection needs a character exclusive to one language: ї is
// Ukrainian, ß is German.
eq(slug("Fußgänger", "auto"), "fussgaenger", "detected German");
eq(slug("Fußgänger", null), "fussganger", "no profile");
// Where there is no such character, detection declines rather than guessing.
eq(slug("Ärger im Büro", "auto"), "arger-im-buro", "nothing to detect");
eq(slug("Ärger im Büro", "de"), "aerger-im-buero", "explicit German");
System.out.println("ok: kyiv not kiyiv; detection found ї and ß, "
+ "and declined on Ärger");
}
}
// A slug romanized as the language, not character by character.
// implementation("dev.disarm:disarm-kotlin:0.14.1")
import dev.disarm.Disarm
import dev.disarm.SlugOptions
fun eq(got: String, want: String, msg: String) {
check(got == want) { "$msg: expected $want, got $got" }
}
fun slug(text: String, lang: String?): String =
if (lang == null) Disarm.slugify(text)
else Disarm.slugify(text, SlugOptions.builder().lang(lang).build())
fun main() {
// и 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.
val kyiv = "Київ"
eq(slug(kyiv, null), "kiyiv", "no profile")
eq(slug(kyiv, "uk"), "kyiv", "explicit Ukrainian")
eq(slug(kyiv, "auto"), "kyiv", "detected")
// Detection needs a character exclusive to one language: ї is Ukrainian,
// ß is German.
eq(slug("Fußgänger", "auto"), "fussgaenger", "detected German")
eq(slug("Fußgänger", null), "fussganger", "no profile")
// Where there is no such character, detection declines rather than guessing.
eq(slug("Ärger im Büro", "auto"), "arger-im-buro", "nothing to detect")
eq(slug("Ärger im Büro", "de"), "aerger-im-buero", "explicit German")
println("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
- Sanitize a filename — the same transliteration, aimed at a filesystem rather than a URL.
- Which cleanup do I need? — the other presets that take a language profile.
- Detect script spoofing — the script detection this depends on, used for a different purpose.
- Why don't these two strings match? — when two slugs collide and you need to know why.