cizgile — Zero-dependency URL slug engine. RFC 3986/3987 slugs, transliteration for 7 scripts, Unicode slugs, IRI ↔ URI, percent-encoding. Pure TypeScript, works everywhere.
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 cizgile
import { 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.
segment-nz-nc) — no percent-encoding needed, no . or .., no accidental scheme prefix.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.你好-world stays readable, and iriToUri gives you the exact percent-encoded form for the wire.URL parser.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 // true
Unicode 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 }) // true
isSlug 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"
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:
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 throws RangeError/TypeError only for invalid options (separator: "/", preserveCharacters containing the separator, a non-global remove regex, a negative maxLength) and, in unicode mode, when scripts or bidi: "throw" rejects the result.unicode: true output, call iriToUri(slug) before putting it on the wire.transliterate: [cyrillic] or a locale object such as uk, ja or ko from cizgile/transliterate. Without them, Cyrillic text produces "" in ASCII mode. transliterate() keeps scripts it has no table for intact; allScripts loads every table.createSlugger() is the way to get unique slugs in a document or import job; do not append counters yourself.resolveUri, normalizeUri and equivalentUris instead of string concatenation or new URL() when you need RFC behaviour (strict scheme handling, no special-scheme rewriting, no host IDNA unless you ask for it)..d.mts files in dist/ 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
decamelize, custom replacements, the counter slugger, and the Armenian, Georgian and Dhivehi tables.urltestdata.json (web-platform-tests, BSD 3-Clause) vendored as a fixture.MIT. Transliteration values are derived from simov/slugify and sindresorhus/transliterate (both MIT).