cizgile
Zero-dependency URL slug engine.
Turn any title into a clean URL slug — in any language — and work with URLs the way RFC 3986 and RFC 3987 describe them. Pure TypeScript, works everywhere.
npm install cizgileimport { slugify } from "cizgile"
slugify("Hello, World!") // "hello-world"
slugify("İstanbul Şişli & Çığ", { locale: "tr" }) // "istanbul-sisli-ve-cig"
slugify("Straße Über Ärger", { locale: "de" }) // "strasse-ueber-aerger"
slugify("你好 World", { unicode: true }) // "你好-world"No dependencies. ESM only. Node 20+, Bun and Deno are exercised in CI; the code touches no host API, so browsers and edge workers run it as-is.
- Slugs that are correct by construction. Every ASCII slug is a valid URL path segment (RFC 3986
segment-nz-nc) — no percent-encoding needed, no.or.., no accidental scheme prefix. - Speaks your language. 45 locales (
tr,de,pl,sv,uk,hi,ta,ja,ko, …) and 19 scripts (Latin, Cyrillic, Greek, Arabic, Armenian, Georgian, Dhivehi, Hebrew, Hangul, kana, Devanagari and the seven other Indic scripts).ß→ss,İ→i,Щ→shch,서울→seoul. - Unicode slugs when you want them.
你好-worldstays readable, andiriToUrigives you the exact percent-encoded form for the wire. - A real URL toolkit underneath. Resolve, normalise, compare, validate and relativise URLs by the RFC, cross-checked against the WHATWG
URLparser. - Small and tree-shakeable.
import { slugify }ships the Latin table only; other scripts load only when you import them.
| import | what you get |
|---|---|
cizgile |
slugify, isSlug, createSlugger, truncateSlug, measure, decamelize, script and bidi guards |
cizgile/transliterate |
transliterate, per-script tables, locales, defineLocale |
cizgile/uri |
percent-encoding, resolveUri, normalizeUri, relativize, validators, IRI ↔ URI, punycode |
import { slugify } from "cizgile"
slugify("Déjà Vu!") // "deja-vu"
slugify("don't stop") // "dont-stop"
slugify("v1.2.3", { preserveCharacters: ["."] }) // "v1.2.3"
slugify("Hello World", { separator: "_" }) // "hello_world"
slugify("Donald E. Knuth", { lowercase: false }) // "Donald-E-Knuth"
slugify("getHTTPResponse", { decamelize: true }) // "get-http-response"
slugify("the quick brown fox", { maxLength: 9 }) // "the-quick"
slugify("Ünïcödé Büro", { unicode: true, maxLength: 11, maxLengthUnit: "bytes" }) // "ünïcödé"
slugify("!!!", { fallback: "untitled" }) // "untitled"
slugify("C++ & Rust", { replacements: [["C++", "cpp"]] }) // "cpp-and-rust"Locale ids for Latin-script languages; Cyrillic locales and other scripts come from cizgile/transliterate so they only end up in your bundle when you use them (or register them once with registerLocale and use their ids as strings).
import { slugify } from "cizgile"
import { cyrillic, greek, uk, ja, ko, defineLocale, de } from "cizgile/transliterate"
slugify("Çay & Simit", { locale: "tr" }) // "cay-ve-simit"
slugify("Fisch & Chips", { locale: "de" }) // "fisch-und-chips"
slugify("Ærø", { locale: "da" }) // "aeroe"
slugify("Zażółć & jaźń", { locale: "pl" }) // "zazolc-i-jazn"
slugify("Київ", { locale: uk }) // "kyiv"
slugify("Привет мир", { transliterate: [cyrillic] }) // "privet-mir"
slugify("Καλημέρα", { transliterate: [greek] }) // "kalimera"
slugify("서울 & 부산", { locale: ko }) // "seoul-mit-busan"
slugify("とうきょう", { locale: ja }) // "toukyou"
const swiss = defineLocale(de, { id: "de-CH", table: { ß: "ss" } })
slugify("Straße", { locale: swiss }) // "strasse"Locale ids: az ca cs da de es et fi fr hr hu is it lt lv nb nl pl pt ro sk sl sv tr vi. Locale objects: those plus be bg kk mk ru sr uk (Cyrillic) and el he hi ja ko (Greek, Hebrew, Devanagari, kana, Hangul), bn pa gu or ta te kn ml (Bengali, Gurmukhi, Gujarati, Odia, Tamil, Telugu, Kannada, Malayalam). registerLocale(ru, uk) makes those ids usable as strings too. Every Latin and Cyrillic locale spells &, %, $ and £ in its language: slugify("50% off", { locale: "de" }) is "50-prozent-off", without a locale "50-off".
import { slugify } from "cizgile"
import { iriToUri, uriToIri } from "cizgile/uri"
const slug = slugify("Ünïcödé final ①", { unicode: true }) // "ünïcödé-final-1"
const wire = iriToUri(slug) // "%C3%BCn%C3%AFc%C3%B6d%C3%A9-final-1"
uriToIri(wire) === slug // trueUnicode slugs keep letters, digits and combining marks, are NFKC-normalised, never start with a mark and contain no invisible or bidi-control characters. Two optional guards for user-supplied titles:
slugify("pаypal", { unicode: true, scripts: "single" }) // throws — that "а" is Cyrillic
slugify("مرحبا 123", { unicode: true, bidi: "encode" }) // "%D9%85%D8%B1%D8%AD%D8%A8%D8%A7-123"scripts applies the UTS #39 restriction levels ("single", "highly-restrictive", "moderately-restrictive", "any"); bidi enforces RFC 3987 §4.2 ("allow", "encode", "throw").
import { createSlugger } from "cizgile"
const slug = createSlugger()
slug("Hello") // "hello"
slug("Hello") // "hello-2"
slug("hello-2") // "hello-2-2" — never a duplicate
slug.reset()import { isSlug } from "cizgile"
isSlug("hello-world") // true
isSlug("Hello World") // false
isSlug("hello_world", { separator: "_" }) // true
isSlug("你好-world", { unicode: true }) // trueisSlug accepts exactly what slugify would produce under the same options, locale included.
| option | default | what it does |
|---|---|---|
separator |
"-" |
Joins words. Any URL-safe punctuation (- _ . ~ !$&'()*+,;= @), several of them ("--"), or "". |
lowercase |
true |
false keeps the original case. |
unicode |
false |
Keep letters from every script instead of transliterating to ASCII. |
locale |
— | Language-specific rules: a locale id or a Locale object. |
transliterate |
true |
false skips the Latin and symbol tables (the locale table and accent folding still apply); "none" keeps only accent folding and the symbol words; an array adds script tables. |
decamelize |
false |
fooBar → foo-bar, HTMLParser → html-parser. |
replacements |
[] |
[from, to] pairs applied first; spaces in to become separators. |
remove |
/['’]/g |
A global regex of characters to delete rather than turn into separators (don't → dont); false keeps them. |
preserveCharacters |
[] |
Extra URL-safe single characters to keep, e.g. ["."] for version numbers. The separator or anything outside segment-nz-nc throws. |
preserveLeadingUnderscore |
false |
_draft → _draft. |
preserveTrailingSeparator |
false |
Keep a trailing separator while the user is still typing. |
maxLength |
— | Cut at a word boundary, never inside a character (emoji sequences, combining marks). |
maxLengthUnit |
"units" |
What maxLength counts: UTF-16 code units like .length, "code-points", "graphemes", or UTF-8 "bytes" for a column or filename budget. |
fallback |
— | Used when the result would be "": a string or a function of the input, slugified with the same options ("untitled", then untitled-2 in a slugger). |
scripts |
"any" |
Unicode mode: UTS #39 mixed-script restriction level. |
bidi |
"allow" |
Unicode mode: RFC 3987 §4.2 direction rule — "encode" or "throw" on violation. |
The pipeline runs in this order: strip control/format characters → NFC → replacements → NFKC → decamelize → transliterate (locale → your tables → Latin → symbols → strip accents) → lowercase → remove → separators → maxLength → guards. Output is idempotent: slugify(slugify(x)) === slugify(x).
import { transliterate, cyrillic, hangul, kana, devanagari, locales } from "cizgile/transliterate"
transliterate("Straße Ærø") // "Strasse AEro"
transliterate("Привет", { tables: [cyrillic] }) // "Privet"
transliterate("Ängsö", { locale: locales.sv }) // "Aengsoe"
transliterate("서울 ひらがな नमस्ते", { tables: [hangul, kana, devanagari] }) // "seoul hiragana namaste"
transliterate("नमस्ते 你好") // "नमस्ते 你好" — unknown scripts are kept intact (use unknown: "drop" to remove)
transliterate("final x² Ⅷ", { nfkc: true }) // "final x2 VIII"Tables: latin symbols cyrillic cyrillicUk cyrillicBg cyrillicMk cyrillicSr greek arabic persian urdu pashto armenian georgian dhivehi hebrew devanagari bengali gurmukhi gujarati oriya tamil telugu kannada malayalam hangul kana, plus allScripts. Where a letter is spelled differently at the start of a word (Armenian ե, Ukrainian є ї й ю я), the capital carries the word-initial form. Devanagari and the other Indic scripts are romanised syllable by syllable (inherent a, no vowel length, no schwa deletion: भारत is bharata), Hangul jamo by jamo (Revised Romanization without sound-change rules, so 한국어 is hangukeo), kana with Hepburn (the long-vowel mark and sokuon are dropped); kanji and Han are left as they are. defineLocale and mergeTables return new objects — nothing global is ever mutated.
Everything in cizgile/uri follows RFC 3986 / RFC 3987 to the letter and is tested against the RFC's own examples and the WHATWG URL parser.
import {
resolveUri,
relativize,
normalizeUri,
equivalentUris,
encodePathSegment,
percentEncode,
percentDecode,
isUri,
isAbsoluteUri,
isIPv6Address,
extractUri,
getOrigin,
sortQuery,
joinPaths,
iriToUri,
uriToIri,
domainToAscii,
} from "cizgile/uri"
resolveUri("http://a/b/c/d;p?q", "../../g") // "http://a/g"
getOrigin("HTTP://Example.com:80/a?b") // "http://example.com"
sortQuery("http://a/p?b=2&a=1") // "http://a/p?a=1&b=2"
joinPaths("/api/", "/v1", "../v2/") // "/api/v2/"
relativize("http://a/b/c/d;p?q", "http://a/b/g") // "../g"
normalizeUri("HTTP://www.EXAMPLE.com:80/%7e%41/./b/../c") // "http://www.example.com/~A/c"
equivalentUris("http://example.com", "http://example.com:80/") // true
encodePathSegment("a/b?c") // "a%2Fb%3Fc"
percentEncode("À ア") // "%C3%80%20%E3%82%A2"
isAbsoluteUri("http://a/b#c") // false — fragments are not allowed in an absolute-URI
isIPv6Address("::ffff:192.0.2.1") // true
extractUri("<http://a/b>.") // "http://a/b"
iriToUri("http://例え.jp/résumé", { host: "punycode" }) // "http://xn--r8jz45g.jp/r%C3%A9sum%C3%A9"Full reference
Characters and percent-encoding (RFC 3986 §2)
isUnreserved isReserved isGenDelim isSubDelim isPchar isSegmentNzNc isQueryChar isScheme — per code point.
percentEncode(text, keep?) — UTF-8, uppercase hex. keep names a set: RFC "unreserved" "pchar" "segment-nz-nc" "path" "query" "fragment" "userinfo", WHATWG "whatwg-c0-control" "whatwg-fragment" "whatwg-query" "whatwg-special-query" "whatwg-path" "whatwg-userinfo" "whatwg-component" "form", or a predicate.
percentDecode(text, { plusAsSpace }), normalizePercentEncoding(text).
encodePathSegment(seg, { noColon }), encodePath(path, { relative }), encodeQuery, encodeFragment, encodeForm.
Hosts (§3.2.2)
isIPv4Address isIPv6Address isIPvFuture isIPLiteral isRegName isHost parseHost parseAuthority serializeAuthority normalizeIPv6Address (RFC 5952). 0x7f.0.0.1 and 2130706433 are registered names, not addresses (§7.4).
Parsing and validation (§4, Appendix A/B)
parseUri(uri, { authority }) serializeUri — components stay distinct from "absent"; authority: true also gives userinfo, host, port and portNumber, and the serializer accepts those in place of authority and inserts /. or ./ where the grammar requires it.
isUriReference isUri isAbsoluteUri isRelativeReference classifyReference pathForm — validating parser built from the ABNF.
isIriReference isIri isIunreserved isIpchar — the same for IRIs (RFC 3987 §2.2).
extractUri(text) — Appendix C: strips <>, quotes, URL: prefixes, trailing punctuation and line-wrap whitespace; a markdown link or an href/src attribute yields its URL.
findUris(text) — the URIs in ordinary prose (scheme://, www., mailto: and friends) with their offsets, trailing punctuation trimmed.
Resolution (§5)
resolveUri(base, ref, { strict, allowRelativeBase }) — every §5.4 example passes; strict by default (http:g stays http:g).
relativize(base, target) — shortest reference that resolves back to target.
removeDotSegments(path) — the literal two-buffer algorithm. mergePaths(base, refPath).
isSameDocumentReference(base, ref, { normalize }).
Normalisation and comparison (§6)
normalizeUri(uri, { defaultPorts, schemeBased, userinfo, trailingSlash, emptyQuery, emptyFragment, host, strict }) — case, percent-encoding, dot segments, default ports, empty path → /, IPv6 hosts in RFC 5952 form ([0:0:0:0:0:0:0:1] → [::1]); userinfo: "strip-password" | "strip" for logs; trailingSlash: "add" | "remove", emptyQuery/emptyFragment: "remove", host: "idna" | "unicode" and strict (throw on a bad host or port) are opt-in.
getOrigin(uri) isSameOrigin(a, b) — RFC 6454 origins with default-port elision. stripFragment(uri).
parseQuery(query) stringifyQuery(pairs) sortQuery(uri) — application/x-www-form-urlencoded pairs in order, and a URI with its parameters sorted by name then value.
joinPaths(...pieces) — single slashes, no dot segments, the first piece's leading and the last piece's trailing slash kept.
normalizePath(path, { trailingSlash }).
equivalentUris(a, b, { level, base, ignoreFragment, defaultPorts }) — "simple", "syntax" or "scheme" (default). Never maps IRIs to URIs (RFC 3987 §5.3.1).
IRIs (RFC 3987)
isUcschar isIprivate isBidiControl hasBidiControls.
iriToUri(iri, { bidi, nfc, strict, host }) — percent-encodes without altering characters (§3.1 step 1c); bidi: "throw" | "strip" handles direction controls; host: "punycode" converts the domain; strict rejects characters no IRI may contain.
uriToIri(uri) — decodes only what §3.2 allows, per component.
punycodeEncode punycodeDecode domainToAscii domainToUnicode — RFC 3492, no dependencies. domainToAscii maps and lowercases labels the way UTS #46 does, then rejects what no DNS name may carry: empty labels, labels over 63 octets, names over 253, leading or trailing hyphens, non-LDH characters and xn-- labels that do not round-trip.
Deliberately not implemented: RFC 6874 IPv6 zone identifiers (reverted by RFC 9844), the network-based normalisation of §6.2.4, and the UTS #46 status table, CONTEXTJ/CONTEXTO and the RFC 5893 bidi rule for domains.
If you are an assistant writing code with this library, these are the facts that matter:
- Import paths:
cizgile(slugs),cizgile/transliterate(tables, locales),cizgile/uri(URLs). ESM only, no default exports, no side effects, no runtime dependencies. slugify(text, options?)returns""for input with nothing usable — it never throws on ordinary text. It throwsRangeError/TypeErroronly for invalid options (separator: "/",preserveCharacterscontaining the separator, a non-globalremoveregex, a negativemaxLength) and, in unicode mode, whenscriptsorbidi: "throw"rejects the result.- The ASCII output is always a valid path segment; put it in a URL as-is. For
unicode: trueoutput, calliriToUri(slug)before putting it on the wire. - Cyrillic and other non-Latin scripts are opt-in: pass
transliterate: [cyrillic]or a locale object such asuk,jaorkofromcizgile/transliterate. Without them, Cyrillic text produces""in ASCII mode.transliterate()keeps scripts it has no table for intact;allScriptsloads every table. createSlugger()is the way to get unique slugs in a document or import job; do not append counters yourself.- Use
resolveUri,normalizeUriandequivalentUrisinstead of string concatenation ornew URL()when you need RFC behaviour (strict scheme handling, no special-scheme rewriting, no host IDNA unless you ask for it). - Every exported function has an explicit TypeScript signature; the
.d.mtsfiles indist/are the authoritative API.
Measured with bun run bench (vitest bench, Node 24, one core of a desktop CPU). Higher is better.
| input | cizgile | @sindresorhus/slugify |
slugify (simov) |
|---|---|---|---|
| ASCII title (60 chars) | 396k ops/s | 136k | 147k |
| Latin with diacritics | 235k ops/s | 96k | 179k |
Turkish, locale: "tr" |
212k ops/s | 99k | 216k |
Cyrillic, transliterate: [cyrillic] |
179k ops/s | 86k | 188k |
| 2.5 KB of mixed text | 6.4k ops/s | 4.3k | 3.4k |
isSlug |
3.7M ops/s | — | — |
resolveUri runs at ~0.8M ops/s (the built-in URL parser: ~0.9M), removeDotSegments at 2M,
percentEncode at 0.9M on mixed text and 3.6M on a pure-ASCII segment (encodeURIComponent: 3.1M and 4.7M — it is native),
normalizeUri at 0.37M, iriToUri at 0.66M.
Options objects are resolved once and cached structurally, so inline { locale: "tr" } literals cost
nothing after the first call.
| input | cizgile | Django slugify |
Rails parameterize |
@sindresorhus/slugify |
|---|---|---|---|---|
" Joel is a slug " |
joel-is-a-slug |
same | same | same |
"jack & jill" |
jack-and-jill |
jack-jill |
jack-jill |
jack-and-jill |
"don't" |
dont |
dont |
don-t |
dont |
"fooBar" |
foobar |
foobar |
foobar |
foo-bar |
"snake_case" |
snake-case |
snake_case |
snake_case |
snake-case |
"Straße" (locale: "de") |
strasse |
strae |
strasse |
strasse |
"Привет" |
"" (opt-in tables) |
"" |
"" |
privet |
decamelize is off by default (Django/Rails behaviour) and & is spelled out (sindresorhus behaviour); both are one option away.
RFC 3986 (with errata 2033, 4547, 4789, 5428), RFC 3987, RFC 3492 (all nineteen §7.1 sample strings), RFC 8820, RFC 9844, the UTS #46 mapping step, the WHATWG URL Standard's percent-encode sets, Unicode UTS #39 restriction levels and UAX #29 grapheme boundaries, Google Search Central's URL guidance. The test suite runs every example those documents contain.
Release notes live on the GitHub Releases page; each release lists the commits since the previous tag. See CONTRIBUTING.md for how to work on the library and SECURITY.md for reporting a vulnerability.
bun install
bun run test # oxlint, oxfmt, tsc, vitest under node, then vitest under bun
bun run build # rolldown → dist/*.mjs + dist/*.d.mts
bun run coverage
bun run bench # vitest bench against @sindresorhus/slugify, simov/slugify and the built-ins
bun run bench:baseline # write bench/baseline.json locally; the Bench workflow compares each run with the last successful one on main
bun run release # bumpp: bump, tag, push — the tag publishes to npm- simov/slugify — the charmap + per-locale override idea and most Cyrillic, Greek, Arabic and symbol values.
- sindresorhus/slugify and sindresorhus/transliterate —
decamelize, custom replacements, the counter slugger, and the Armenian, Georgian and Dhivehi tables. - Django and Rails — the reference behaviours the parity tests are written against.
- The WHATWG URL Standard — percent-encode sets and the parser every result is cross-checked with, and the RFC 3986-compatible subset of its
urltestdata.json(web-platform-tests, BSD 3-Clause) vendored as a fixture. - RFC 3986 by Berners-Lee, Fielding and Masinter, and RFC 3987 by Duerst and Suignard.
- Rolldown, Oxc, Vitest, Bun and TypeScript.
MIT. Transliteration values are derived from simov/slugify and sindresorhus/transliterate (both MIT).