Functional Weave
Code in TypeScript

text.slugify@1.0.0

impl/typescript.ts

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.
 */
export function slugify(value: string): string {
  if (typeof value !== "string") {
    throw new 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;
    else if (code >= 0x41 && code <= 0x5a) piece = String.fromCharCode(code + 32);
    else if (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. */
export function slugifyOr(value: string, fallback: string): string {
  const slug = slugify(value);
  return slug.length > 0 ? slug : fallback;
}