toopo

string/slugify

  • stable contract
  • TS · npm

Turn text into a URL-safe identifier: one lower-case run of letters, marks and digits per word, joined by single hyphens. The output is Unicode, not ASCII.

npx toopo add string/slugify

3 332 bytes, one file

0 imports

41 settled cases

Frozen contract

The signature and behaviour of slugify will never change — not in a minor release, not in a major one. Updates may only fix internal defects while preserving the contract, held to its 41 settled cases on every commit.

What it does

Turns a title, a name or a heading into an identifier that is safe in a URL path segment, readable, and stable. The first thing to know is the one that will surprise you: the output is not restricted to ASCII. Japanese text stays Japanese, Cyrillic stays Cyrillic, and Arabic stays Arabic - what is removed is punctuation, symbols, emoji and case, not other people's writing systems. Latin and Greek diacritics are folded to their base letter, so Crème Brûlée becomes creme-brulee, and that folding is Unicode's own decomposition rather than a table this contract wrote: Straße stays straße and Æther stays æther, because Unicode does not decompose those letters and inventing a mapping for them would be inventing German and English. If you need ASCII, romanise first and slugify the result - that is a different function, and it is named below. The result is lower case, carries no leading, trailing or doubled hyphen, is unchanged by slugifying it again, and is deliberately lossy: different texts can share one slug, so a slug is not a unique key.

The language ships normalisation and percent-encoding, and nothing that produces an identifier.

Human-authored short text held in memory: article titles, product names, headings, tags, category labels. The output is written for a URL path segment, a fragment identifier, or a key in a document a person will read. It is not written for a DNS label, a filename on a case-insensitive file system, or an identifier in a language with an ASCII grammar - each of those has a narrower alphabet than a URL does, and a contract that satisfied all of them at once would be the intersection, which is the empty slug for most of the world.

Signature

type Slugify = (text: string) => string

Source

lib/toopo/string/slugify.ts

// typescript/string/slugify@1 - https://toopo.dev/typescript/string/slugify@1/
// Copyright (c) 2026 Mathis Perron. SPDX-License-Identifier: MIT-0

/**
 * `string/slugify@1` - turn human-authored text into an identifier that is safe in a URL path
 * segment, readable, and stable.
 *
 * Written to be read. The whole function is one pass over the code points of the normalised text,
 * and the three questions it asks about each one are the three lines of the rule the contract
 * publishes: is this a letter, a mark or a digit; is this mark already carried by the letter in
 * front of it; and is there a boundary to emit before it.
 *
 * The rule adds no mapping of its own. Every fold below is Unicode's - `NFKC` unifies presentation
 * forms, and a canonical composition decides which marks a base absorbs - which is what makes it
 * three lines instead of a table of two thousand entries. A table is a language, and a language is
 * a locale; the contract argues that at length.
 *
 * Two subtleties, both of them measured rather than reasoned, and both of them defects this file
 * carried before they were caught.
 *
 * A mark is compared against **the base of its run**, never against the code point immediately to
 * its left. Unicode's blocking rule lets a mark of high combining class compose onto the starter
 * across a mark of lower class, so `e` + fatha + acute has the acute reaching the `e` over the
 * fatha; a neighbour test keeps that acute, and the next call composes it away.
 *
 * The output is a fixed point of this function, and the reason is that no surviving mark composes
 * onto its base and marks never compose onto one another. That sentence is the invariant, and the
 * contract has a property and a mutant for it, because the first two arguments for it were wrong.
 *
 * There is no diagnostic export beside this one. Every string has a slug - the empty one, when
 * nothing in the text is a letter, a mark or a digit - so there is no refusal to describe.
 */

/** What survives the fold: a letter, a mark, or a decimal digit. Everything else is a boundary. */
const KEPT = /[\p{L}\p{M}\p{Nd}]/u

const MARK = /\p{M}/u

/** The first code point of a canonical decomposition: `é` yields `e`, and `日` yields itself. */
const baseOf = (point: string): string => [...point.normalize('NFD')][0]

/** Whether the base already carries this mark - that is, whether the two compose into one. */
const absorbs = (base: string, mark: string): boolean =>
  [...`${base}${mark}`.normalize('NFC')].length === 1

export const slugify = (text: string): string => {
  const out: string[] = []

  /** The letter the current run hangs off, or `null` before one has been seen. */
  let runBase: string | null = null
  let boundary = false

  for (const point of text.normalize('NFKC')) {
    const base = baseOf(point)

    if (!KEPT.test(base)) {
      boundary = true
      runBase = null
      continue
    }

    const lowered = base.toLowerCase()

    if (MARK.test(lowered)) {
      if (runBase !== null && absorbs(runBase, lowered)) continue
    } else {
      runBase = lowered
    }

    // A boundary becomes a separator only between two runs, which is what keeps one off each end.
    if (boundary && out.length > 0) out.push('-')
    boundary = false

    out.push(lowered)
  }

  return out.join('')
}

Examples

slugify('Crème Brûlée, 12 façons')  // 'creme-brulee-12-facons'

Store the slug beside the article rather than recomputing it on read. A title corrected for a typo answers a different slug, and every link to the old one is then broken by an edit nobody thought was a move.

slugify('What NFKC unifies')  // 'what-nfkc-unifies'

Two headings can slug alike, and this function will not number the repeats for you. A document that allows a heading to occur twice has to disambiguate them itself, because the answer here depends on one input and cannot know what else is on the page.

slugify('  Kraków  ')  // 'krakow'

Good for grouping and wrong for identity. C++ and C# both answer c, so a tag store keyed on the slug alone silently merges them: keep the text the user typed as well, and let the slug decide only what sits together.

slugify('日本語テキスト')  // '日本語テキスト'

This is the reason to prefer this contract over an ASCII slugifier, and it is also the reason to check what your URLs are for: the answer is a valid path segment and is not ASCII. If something downstream needs Latin letters, romanise first and slugify the result.

Try it on your own input

This calls slugify on whatever you type. What you type into a field is the value, character for character, and the form opens on a-non-latin-script-is-kept so there is a call that works to edit. What comes back is what the function answered, under the call it was made from — invisible characters are named there, so two inputs that look alike on screen do not print alike.

The JavaScript this runs is string/slugify's own reference.ts with its types stripped. That is neither the file the registry serves nor the file its digest covers: both are TypeScript, and no browser runs TypeScript. It is also the only part of this page that needs JavaScript at all.