typescript/string/slugify@1 — 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.

41 named edge cases, settled and frozen. TypeScript source copied into your project: one file, 3 332 bytes, no dependencies.

[Toopo](../../../)

# typescript/string/slugify@1

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.

```
toopo add string/slugify
```

One file, 3 332 bytes, copied into your project. It imports nothing. You get slugify.

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

## What it is for, and what it is not

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
```

## 41 settled cases

Every one of them is named, frozen with the major version, and linkable. This is what the contract decides, one input at a time.

the answers this contract argues for, having no oracle of any kind to appeal to.

### The surprise, in front

A reader arriving from "slugify javascript" expects ASCII. These are the rows where this contract contradicts that expectation, and they come first for that reason.

`slugify('日本語テキスト') → '日本語テキスト'`

Japanese text keeps its own script. Measured, two of the four libraries in \`ecosystem\` answer the empty string here, so every Japanese title on a site collides on one slug in silence - which is the failure this contract refuses. A reader who needs \`nihongo-tekisuto\` wants a romanisation, and \`composeInsteadOfConfiguring\` names it as a separate contract because Hepburn and Kunrei disagree and no single answer settles that.

`slugify('Привет мир') → 'привет-мир'`

Cyrillic is lower-cased and kept, and the space between the words becomes one separator. Three of the four libraries transliterate it to \`privet-mir\`; that is a scheme choice they made for their users, frozen into a table, and this contract makes no such choice.

`slugify('مرحبا بالعالم') → 'مرحبا-بالعالم'`

A right-to-left script passes through unchanged apart from the separator. The slug is the same sequence of code points as the input, which is what makes it a stable identifier in a URL even though the four libraries produce four different answers for it.

`slugify('ह\u093Fन\u094Dद\u0940') → 'ह\u093Fन\u094Dद\u0940'`

The vowel signs, the virama and the consonants all survive. This is the case that decides the absorb step: dropping every combining mark would answer \`हनद\`, which is not a word, and a rule that mangles a writing system to make Latin text prettier is not defensible. None of these marks composes onto its base, so none of them is a diacritic in the sense the fold means.

`slugify('٤٢') → '٤٢'`

Arabic-Indic digits are decimal digits, so they survive exactly as ASCII digits do. Named because "digits" in a rule written in English reads as \`0-9\`, and the rule means what Unicode means.

`slugify('𐌰𐌱') → '𐌰𐌱'`

Two Gothic letters, each one code point stored as two UTF-16 code units, survive as themselves. It is the row that fixes the unit: an implementation walking code units sees four unpaired surrogates, none of which is a letter, and answers the empty slug. Emoji cannot settle this - both conventions discard them - so a letter outside the basic plane is the only input that can.

### The fold, which is Unicode's and not this contract's

`slugify('Crème Brûlée') → 'creme-brulee'`

The answer a reader expects, reached without a table: each letter is replaced by the first code point of its canonical decomposition, which Unicode already publishes. It is the one row where four of the five measured columns agree with this contract.

`slugify('cafe\u0301') → 'cafe'`

The word written as a bare letter followed by a combining acute. It answers the same slug as the precomposed spelling below, which is the whole point of normalising first: two encodings of one word must not produce two identifiers.

`slugify('café') → 'cafe'`

The same word written with the precomposed character. It is named separately from the decomposed spelling because the two are different strings, and a contract that settled only one of them would leave an implementation free to answer differently on the other.

`slugify('Tiếng Việt') → 'tieng-viet'`

Vietnamese stacks a vowel mark and a tone mark on one letter, and both decompose off the base. It is the case that shows the fold is not a single-diacritic rule.

`slugify('Straße') → 'straße'`

Three of the five measured columns answer \`strasse\`, and this contract answers \`straße\`. The divergence is the rule being honest: Unicode gives the sharp s no decomposition, so answering \`ss\` means writing down a German convention by hand, and a contract that writes down one language's convention has to write down every language's or be wrong for somebody. A reader who wants \`strasse\` wants a romanisation of German, which composes in front of this function.

`slugify('Æther') → 'æther'`

The same decision on a letter the measured columns disagree about three ways: \`aether\`, \`a-ether\`, and \`æther\`. Unicode does not decompose it either, so it is lower-cased and kept. It is named beside the sharp s because together they are the whole cost of refusing to write a table, and a reader should be able to see that cost rather than discover it.

`slugify('Ødegård') → 'ødegard'`

One word carrying both halves of the rule: the stroked o has no decomposition and is kept, the ring above the a decomposes and is removed. It is the clearest single input for what "the fold is Unicode's" actually means.

### The absorb step, and the two defects that wrote it

`slugify('é\u0301') → 'e'`

A precomposed e-acute followed by a second combining acute. The first formulation of this contract kept the second mark, because normalisation had nothing left to compose it with - and the next call composed it onto the bare \`e\` the fold had just produced and dropped it, so the function was not idempotent. Measured on thirty words out of four thousand. The rule now asks whether the base absorbs a mark rather than whether normalisation already did.

This case exists because a mutant survived without it: string-slugify/G-06.

`slugify('é\u064E\u0301') → 'e\u064E'`

The same defect one layer deeper. The second formulation asked whether the code point to the left absorbed the mark, and here the acute's left-hand neighbour is an Arabic fatha, which absorbs nothing - so the acute was kept, and the next call composed it onto the \`e\` anyway, because Unicode lets a mark of high combining class reach the base across a mark of lower class. The test is the base of the run, and this is the input that says so.

This case exists because a mutant survived without it: string-slugify/G-07.

`slugify('x\u0301') → 'x\u0301'`

There is no precomposed x-acute, so the base does not absorb the mark and the mark stays. It is the case that shows the fold is not "remove combining marks": what is removed is the mark a letter already carries, and nothing else.

### Greek, where the fold and the case rule meet

`slugify('Ελληνικά') → 'ελληνικα'`

The Greek tonos decomposes off its vowel exactly as a French accent does, so it is removed by the same step and for the same reason. The script is kept; only the mark goes. The two published transliteration schemes answer \`ellhnika\` and \`ellinika\`, measured, and that disagreement is why this contract transliterates nothing.

`slugify('ΟΔΟΣ') → 'οδοσ'`

An upper-case Greek word ending in sigma answers an ordinary small sigma, not a final one. JavaScript's lower-casing is context sensitive and would answer the final form, but that context includes the characters this function is about to discard - so \`ΟΔΟΣ.\` and \`ΟΔΟΣΑ\` would disagree about a letter because of a full stop that does not survive. Each base is therefore lower-cased on its own. The cost is named in the row below and is real.

`slugify('οδος') → 'οδος'`

The same word already written with a final sigma keeps it, because a final sigma is a letter and letters are kept. So the upper-case spelling and the correctly written lower-case spelling of one Greek word produce two slugs. That is a real limit of this contract rather than a defect of the case rule: the two are distinct code points that Unicode does not make canonically equivalent, and unifying them needs a case \*folding\*, which JavaScript does not expose and which this contract will not hand-write.

### Upper and lower case

`slugify('İstanbul') → 'istanbul'`

The Turkish dotted capital I decomposes to a plain I and a combining dot above; the I lower-cases to \`i\`, and the dot is a mark the \`i\` absorbs. The answer falls out of the rule with no special case, which is worth naming because this character is the standard example of case conversion depending on a locale.

`slugify('Işık') → 'isık'`

The dotless i has no decomposition and is a letter, so it survives - while the cedilla on the s decomposes and goes. An ASCII-only slugifier answers \`is-k\` or \`isik\` depending on whether it drops the letter or invents a mapping for it; this contract does neither.

### What NFKC unifies, and what it costs

`slugify('ＡＢＣ') → 'abc'`

Fullwidth letters and ordinary letters are the same letters typed on two keyboards, and NFKC says so. Without this step they would produce two identifiers for one text, which is the collision defect in reverse and is worse than a collision, because nothing warns anyone.

`slugify('ﬁle') → 'file'`

The fi ligature is a presentation form of two letters, so NFKC separates it and the slug carries both. It also shows that the compatibility step, not a hand-written table, is what gets a two-letter answer out of a one-code-point input.

`slugify('x²') → 'x2'`

A superscript two is a compatibility form of the digit two, so it survives as \`2\`. All five measured columns answer \`x\`, dropping it, and this contract diverges: the character carries information a reader typed, and Unicode already says what it is made of. It is the clearest row for what choosing NFKC over NFC buys and costs.

`slugify('Ⅷ') → 'viii'`

The Roman numeral eight is a compatibility form of four Latin letters, so it becomes four letters. Named because it is the most surprising consequence of the compatibility step, and because the alternative - answering the empty string, which is what the ASCII-only columns do - throws away a character a reader chose deliberately.

### Separators and shape

`slugify('  hello   world  ') → 'hello-world'`

Any run of boundary characters becomes one separator, and a boundary before the first run or after the last produces nothing. \`github-slugger\` answers \`---hello---world---\` here, measured, which is a valid anchor and not a slug.

`slugify('already-slugged') → 'already-slugged'`

Text that is already a slug is returned as it is. It is the concrete anchor for the fixed point property, and the case a caller relies on when a slug is recomputed on read.

`slugify('double--dash') → 'double-dash'`

A hyphen is not a letter, so two hyphens are one boundary and become one separator. It is the row that makes the shape rule bite on input that already looks like a slug.

`slugify('under_score') → 'under-score'`

An underscore is punctuation, not a letter, so it is a boundary like any other. The four measured columns split four ways here - keep it, drop it, or turn it into a separator - which is exactly the kind of decision a contract exists to settle once.

`slugify('a.b.c') → 'a-b-c'`

A full stop separates rather than disappears. Three of the measured columns answer \`abc\`, silently joining two words that were not adjacent; a boundary that vanishes makes \`a.b\` and \`ab\` one slug for no reason a reader can see.

`slugify('l\'été') → 'l-ete'`

An apostrophe is a boundary, so a French elision splits. Three columns answer \`lete\`, treating the apostrophe as invisible, and one answers \`l-ete\`. Splitting is the answer the rule gives without an exception list, and an exception list for the apostrophe is where a table starts.

### No symbol becomes a word

`slugify('Salt & Pepper') → 'salt-pepper'`

An ampersand is a symbol and is removed; it does not become \`and\`, because \`and\` is English. Two of the measured columns answer \`salt-and-pepper\`, which is a translation decision made on behalf of every caller in every language.

`slugify('10€ and 5$') → '10-and-5'`

The currency signs go and the English word a caller actually typed stays. Measured, the euro sign becomes \`euro\`, \`e\`, or nothing depending on the library, and that three-way disagreement about a single character is the strongest evidence in \`ecosystem\` that symbol tables are not a shared convention anyone can appeal to.

### Nothing retainable, and the empty slug

`slugify('') → ''`

The empty string slugs to itself. It is the input an implementation writing a separator before its first run is most likely to fall off, and the cheapest sensor for it.

`slugify('!!!') → ''`

Text holding no letter, mark or digit answers the empty slug. \`slug\` answers \`iseh\` here - the base64 of the input, slugged - which is an identifier that looks meaningful and is noise. The empty slug is an answer, and a caller who cannot use it substitutes its own.

`slugify('🎉 party 🎉') → 'party'`

Emoji are symbols, so they are boundaries. All five measured columns agree that emoji do not belong in a slug, which makes this one of the few rows where the ecosystem is unanimous.

`slugify('👩\u200D💻 developer') → 'developer'`

A joined sequence is three code points - two symbols and a zero-width joiner - and none of them is a letter, a mark or a digit. Named because a rule stated over grapheme clusters would have to decide what a partial sequence means, and this rule never sees a cluster.

`slugify('\uD83D') → ''`

An unpaired surrogate is not a letter, so it is a boundary and the slug is empty. It reaches this function out of a truncated string or a byte-level slice, and \`slug\` throws on it - measured. A total function does not throw on input a caller cannot pre-validate without writing this function first.

`slugify('a\uD83Db') → 'a-b'`

The same character between two letters is a boundary and splits them. It is the row that says the surrogate is removed rather than skipped: skipping would answer \`ab\`, joining two letters that were not adjacent in the input.

### The loss, made concrete

One decision read twice. These rows exist so that what this contract says about lossiness has a demonstration on its own page, rather than a sentence a reader has to believe.

`slugify('C++') → 'c'`

The plus signs are symbols and go, so the language is \`c\`. Named as half of a pair: the other half is \`C#\`, which answers the same slug. Two different programming languages, one identifier - which is what \`lossiness\` means and why a slug is not a key.

`slugify('C#') → 'c'`

The other half of the pair. It is settled separately rather than mentioned in the row above, because the collision is a fact about two inputs and a table row is about one - and a guard below replays the collision rather than asserting it in prose.

## 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 settled answer is on the case's own line above, and is deliberately not repeated here.

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.

## Properties

Every property below is checked on 1 000 generated cases per run, re-seeded each time.

- `never mutates its arguments — not applicable`

  The signature takes one \`string\`, a primitive that is immutable by construction in JavaScript. No implementation, correct or broken, can violate this, so a test asserting it would be structurally incapable of failing - the same measurement \`number/parse@1\` recorded and \`string/levenshtein@1\` confirmed, on a third signature over primitives.
- `deterministic — checked`

  Violable in practice, and witnessed by G-19 of the battery: an implementation that accumulates the runs into an array hoisted out of the function - the shape a variable reaches when it is made "reusable" - answers the slug on the first call and the slug of every call so far on the second. It is the only witness that reddens this property on its own failure condition; every other candidate on a function of this shape is a cache, and a cache answers a repeated call from its own first answer. This property is ordered under \`no ambient input\` rather than independent of it: every mutant measured to redden it reddens that one too, and the memoise-last mutant reddens that one and not this. G-20 is that mutant here.
- `no ambient input — checked`

  Violable in practice: this function reads no clock and no time zone, and the one ambient thing it could plausibly read is the call history, through a cache keyed on a cheap proxy for its argument. The property interleaves a probe with an arbitrary history and requires the probe to answer identically either way. Witnessed by G-20, which remembers the last answer under the length of the argument: measured, it reddens here and leaves determinism green, which is the fifth contract on which that ordering has been measured.
- `no ambient output — not applicable`

  Not reachable by a property - a test cannot observe a write that happened before it ran, and a correct memoising cache is indistinguishable from a defect by behaviour alone. Confirmed here on a fifth shape without changing a word of it.

## Benchmark profiles

The shapes of input an implementation is timed on. No figures yet: there is no reference machine, and a number produced on a developer laptop would be a number with nothing behind it.

- `already-a-slug — all`

  The text is already a slug. The dominant shape wherever slugs are recomputed on read, and the one a fast path is written for: an implementation that compares the input against the alphabet first answers in linear time here and pays the whole fold everywhere else.
- `ascii-prose — most`

  Ordinary English words with spaces and a little punctuation - the common case, and the one where nothing needs folding at all. It is here so that a reader can see what the fold costs on text that does not need it, which is the price every caller pays.
- `latin-diacritics — most`

  Text where most letters carry a mark to fold, so the decomposition and the composition question are paid on nearly every code point. It is the worst case of the fold on text a European site actually stores.
- `other-writing-systems — most`

  Japanese, Cyrillic, Arabic and Devanagari - the text this contract keeps and two of the four measured libraries answer the empty string for. Measured separately because the marks in the last two make the absorb question fire on every one of them and never drop anything.
- `punctuation-heavy — few`

  Text that is mostly boundaries: single letters between runs of symbols, one word inside an emoji run, and two hundred separators before a word. It is where the boundary logic dominates and where an implementation that allocates per discarded code point shows it.
- `nothing-retainable — none`

  Nothing survives, so the answer is the empty slug. It is the cheapest possible call that still reads all of its input, and callers filtering a list of user-supplied labels make it far more often than they expect to.

## What you can check yourself

This definition is frozen. Its canonical text hashes to 8753bb972e614de3bdb6a4c27cd227d0a6bdc617c5ba5c6c5eabb392a23a78a1, and the 7 files of its test harness are listed inside it with their own hashes — so a copy of the harness can be checked against this definition before it is trusted, then run against any implementation, without taking our word for any of it.

Written for node, browser, bun.
