3,262 bytes · the TypeScript implementation · view raw
/** * The transliteration table: every letter of the Latin-1 supplement * (U+00C0-U+00FF) plus the seven letters Windows-1252 adds. * * Spelled out character by character rather than left to Unicode * normalisation, because that is the only way three languages with three * different Unicode stacks can be pinned to the same output by one vector. * * Diacritics are dropped rather than expanded, so "ä" is "a" and not "ae": * that is the convention every URL slug in the wild follows. The exceptions * are the letters that are not accented vowels at all - "ß" is genuinely two * letters, and "æ", "œ" and "þ" are letters in their own right. */const TRANSLITERATIONS: { [char: string]: string } = {
"À": "a", "Á": "a", "Â": "a", "Ã": "a", "Ä": "a", "Å": "a", "Æ": "ae", "Ç": "c",
"È": "e", "É": "e", "Ê": "e", "Ë": "e", "Ì": "i", "Í": "i", "Î": "i", "Ï": "i",
"Ð": "d", "Ñ": "n", "Ò": "o", "Ó": "o", "Ô": "o", "Õ": "o", "Ö": "o", "Ø": "o",
"Ù": "u", "Ú": "u", "Û": "u", "Ü": "u", "Ý": "y", "Þ": "th", "ß": "ss",
"à": "a", "á": "a", "â": "a", "ã": "a", "ä": "a", "å": "a", "æ": "ae", "ç": "c",
"è": "e", "é": "e", "ê": "e", "ë": "e", "ì": "i", "í": "i", "î": "i", "ï": "i",
"ð": "d", "ñ": "n", "ò": "o", "ó": "o", "ô": "o", "õ": "o", "ö": "o", "ø": "o",
"ù": "u", "ú": "u", "û": "u", "ü": "u", "ý": "y", "þ": "th", "ÿ": "y",
"Œ": "oe", "œ": "oe", "Š": "s", "š": "s", "Ž": "z", "ž": "z", "Ÿ": "y",
};
function isAsciiDigit(code: number): boolean {
return code >= 0x30 && code <= 0x39;
}
/** * A URL-safe slug: lowercase ASCII letters and digits, single hyphens between * them, none at either end. * * Anything outside the transliteration table is dropped rather than guessed, * so a title written entirely in another script slugifies to "". That empty * string is returned, not raised: the caller knows what its fallback is (an * id, a date, a hash) and this function does not. */exportfunction slugify(value: string): string {
if (typeof value !== "string") {
thrownew TypeError(`slugify needs a string, received ${value}`);
}
let out = "";
// A pending separator rather than a trailing hyphen plus a trim: it collapses// runs and drops the leading and trailing ones in the same single pass.let pendingSeparator = false;
// Iterating the string yields whole code points, so a surrogate pair is// dropped as one character instead of becoming two stray separators.for (const ch of value) {
let piece: string;
const code = ch.codePointAt(0) as number;
if (code >= 0x61 && code <= 0x7a) piece = ch;
elseif (code >= 0x41 && code <= 0x5a) piece = String.fromCharCode(code + 32);
elseif (isAsciiDigit(code)) piece = ch;
else piece = TRANSLITERATIONS[ch] ?? "";
if (piece === "") {
pendingSeparator = out.length > 0;
continue;
}
if (pendingSeparator) {
out += "-";
pendingSeparator = false;
}
out += piece;
}
return out;
}
/** A slug guaranteed to be non-empty, falling back when nothing survives. */exportfunction slugifyOr(value: string, fallback: string): string {
const slug = slugify(value);
return slug.length > 0 ? slug : fallback;
}