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
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.
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.
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:
| 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.