@khalidsaidi/slugshade
v0.2.0
Published
SlugShade - Unicode-first slug engine: never empty, never unsafe. CJK/Thai segmentation, deterministic fallbacks, trace output, optional AI transliteration hook.
Maintainers
Readme
SlugShade
Unicode titles → safe URLs: never empty, never unsafe.
Most slug libraries were built for English. Feed them Arabic, Chinese, or emoji-only titles and you get empty strings, mangled output, or unstable URLs. SlugShade is Unicode-preserving by default and guarantees a usable slug for any input:
- Unicode-first by default — Arabic, Cyrillic, Greek, Han, Thai, etc. survive instead of being stripped.
- Never empty — deterministic fallback (
untitled-<hash>) for empty or symbol-only input; the hash is stable per input. - Never unsafe — NFKC normalization, zero-width/bidi control-character stripping, no
/,\,., or.., reserved-name handling (admin,api,con, ...). - Real word segmentation —
Intl.Segmentersplits CJK and Thai text into words (你好世界→你好-世界), not one giant token. - Zero runtime dependencies.
Install
npm i @khalidsaidi/slugshadeQuick Start
import { slug, slugDetailed, presets } from '@khalidsaidi/slugshade';
slug('Hello, world!');
// => hello-world
slug('مرحبا بالعالم');
// => مرحبا-بالعالم
slug('สวัสดีชาวโลก');
// => สวัสดี-ชาว-โลก (Thai word segmentation via Intl.Segmenter)
slug('你好 世界', { alphabet: 'ascii' });
// => u4f60-u597d-u4e16-u754c
slug('C++ & C#', { ...presets.safe });
// => cpp-and-csharp
slug('!!!');
// => untitled-ckr8de (deterministic — same input, same hash)Comparison
Actual outputs with each library's defaults (slugify 1.6.9, github-slugger 2.0.0, limax 4.2.3):
| Input | slugshade | slugify | github-slugger | limax |
|---|---|---|---|---|
| مرحبا بالعالم (Arabic) | مرحبا-بالعالم | mrhba-balaalm | مرحبا-بالعالم | mrhba-balaalm |
| 你好世界 (CJK) | 你好-世界 | (empty) | `你好世界` | `ni3-hao3-shi4-jie4` |
| `Hello 🚀 World` | `hello-world` | `Hello-World` | `hello--world` | `hello-world` |
| `!!!` | `untitled-ckr8de` | `!!!` | (empty) | (empty) |
| (empty input) | untitled-<hash> | (empty) | (empty) | `` (empty) |
| Step-by-step trace | slugDetailed() | — | — | — |
To be fair to each library: slugify with { lower: true, strict: true } produces hello-world for Latin input but still returns "" for CJK; github-slugger is purpose-built for Markdown heading anchors (Unicode-preserving, dedupes within one document); limax's pinyin/romanization is a genuine transliteration strategy if romanized-by-default is what you want. SlugShade's position: preserve the script by default, never return empty, and make transliteration an explicit choice (see the AI hook below).
API
slug(input, options?)
Returns a deterministic slug string. input must be a string — slug(undefined), slug(null), or any other non-string throws a clear TypeError instead of silently minting an undefined/null slug.
Very long input is clamped before word segmentation: by default the first max(4096, maxLength * 16) characters (sliced on a grapheme boundary) are processed, which keeps megabyte-scale garbage input fast while never affecting the ~80-char output for normal titles. Raise or disable the budget with maxInputLength (pass Infinity to disable); a clamp records a truncated-input warning in slugDetailed().
slugDetailed(input, options?)
Returns { slug, tokens, warnings, steps } — see the trace below. Unrecognized option keys (e.g. the typo seperator) are ignored for the slug but reported as unknown-option: <key> warnings here; in TypeScript the compiler catches such typos at build time.
slugAsync(input, { ...options, ai })
Runs your AI suggester, then re-sanitizes its output with the same deterministic rules. Garbage or empty AI output — and an ai callback that throws (network failure, rate limit) — falls back to the deterministic slug. Throws a TypeError if the ai option is missing.
slugAsyncDetailed(input, { ...options, ai })
Same as slugAsync, but resolves to the full { slug, tokens, warnings, steps } shape. A throwing ai callback adds an ai-failed warning; rejected AI output (empty, non-string, or output that sanitizes to a fallback) adds ai-rejected.
uniqueSlug(base, isTaken, opts?)
Finds an available suffix (-1, -2, ...) using a synchronous predicate. If your predicate returns a Promise (e.g. a DB call), it throws a TypeError telling you to use uniqueSlugAsync — a Promise is always truthy, so silently accepting one would walk candidates incorrectly.
uniqueSlugAsync(base, isTaken, opts?)
Same contract, but isTaken may return boolean | Promise<boolean>. Candidates are checked sequentially, so your DB sees one query at a time.
createSlugger(defaults)
Returns a reusable function with preconfigured defaults.
AI transliteration hook
Want 你好世界 → hello-world instead of hex tokens? Plug in an LLM. SlugShade always re-sanitizes the model's output with the same deterministic rules, falls back to the deterministic slug if the model returns garbage, and catches a throwing callback (network failure, rate limit) the same way — the AI can only ever improve the slug, never break it.
Requires an Anthropic API key (ANTHROPIC_API_KEY) and npm i @anthropic-ai/sdk:
import Anthropic from '@anthropic-ai/sdk';
import { slugAsync } from '@khalidsaidi/slugshade';
const client = new Anthropic();
const result = await slugAsync('你好世界', {
ai: async ({ input, deterministic, maxLength }) => {
const response = await client.messages.create({
model: 'claude-sonnet-5',
max_tokens: 100,
messages: [{
role: 'user',
content: `Transliterate this title into a short English URL slug (lowercase words separated by spaces, under ${maxLength} characters). Reply with the slug text only, no explanation.\n\nTitle: ${input}`,
}],
});
const block = response.content.find((b) => b.type === 'text');
return block?.text ?? deterministic;
},
});
// => "hello-world" (model output, re-sanitized by SlugShade)
// If the model returns nonsense like "((()))" — or the API call throws —
// you get the deterministic slug for the original input instead: never an
// unsafe or empty URL. slugAsyncDetailed() reports which happened via the
// "ai-rejected" / "ai-failed" warnings.A note on ASCII mode for Arabic/CJK
{ alphabet: 'ascii' } turns unknown scripts into stable u<hex> tokens (你好 → u4f60-u597d). That is a stability fallback — the URL is safe, deterministic, and collision-resistant — but it is not an SEO strategy: hex tokens carry no meaning for readers or search engines. For Arabic, CJK, and other non-Latin scripts, prefer the default Unicode mode (modern browsers and search engines handle Unicode URLs well) or the AI transliteration hook above when you need readable ASCII.
Recipes
Unique slugs against a database (Prisma-style)
import { slug, uniqueSlugAsync } from '@khalidsaidi/slugshade';
const base = slug(title);
const unique = await uniqueSlugAsync(base, async (candidate) =>
Boolean(await prisma.post.findUnique({ where: { slug: candidate } })),
);
await prisma.post.create({ data: { title, slug: unique } });Next.js dynamic routes
// app/posts/[slug]/page.tsx
import { slug } from '@khalidsaidi/slugshade';
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({ slug: slug(post.title) }));
}Unicode slugs are percent-encoded on the wire. When building links by hand, wrap the slug in encodeURIComponent(...); when reading a dynamic route param, run it through decodeURIComponent(...) before comparing it to stored slugs — depending on your framework version, params.slug may arrive still encoded.
Inspecting a slug: slugDetailed()
slugDetailed('Café ✨ Déjà Vu — 2026!');{
"input": "Café ✨ Déjà Vu — 2026!",
"slug": "café-déjà-vu-2026",
"tokens": ["café", "déjà", "vu", "2026"],
"warnings": [],
"steps": [
{ "op": "normalize", "before": "Café ✨ Déjà Vu — 2026!", "after": "Café ✨ Déjà Vu - 2026!" },
{ "op": "tech", "before": "Café ✨ Déjà Vu - 2026!", "after": "Café ✨ Déjà Vu - 2026!", "meta": { "enabled": false } },
{ "op": "symbols", "before": "Café ✨ Déjà Vu - 2026!", "after": "Café ✨ Déjà Vu - 2026!", "meta": { "mode": "basic" } },
{ "op": "emoji", "before": "Café ✨ Déjà Vu - 2026!", "after": "Café Déjà Vu - 2026!", "meta": { "mode": "remove" } },
{ "op": "segment", "before": "Café Déjà Vu - 2026!", "after": "Café | Déjà | Vu | 2026", "meta": { "usedSegmenter": true, "tokenCount": 4 } },
{ "op": "lowercase", "before": "Café Déjà Vu 2026", "after": "café déjà vu 2026", "meta": { "locale": "en" } },
{ "op": "join", "before": "café déjà vu 2026", "after": "café-déjà-vu-2026", "meta": { "separator": "-" } },
{ "op": "strict", "before": "café-déjà-vu-2026", "after": "café-déjà-vu-2026", "meta": { "alphabet": "unicode" } }
]
}That is the complete, real output — every pipeline stage appears, including the ones that were no-ops for this input. When invisible characters (zero-width, bidi controls) are stripped, the normalize step's meta.strippedInvisible lists the exact codepoints and counts, and a stripped-invisible: n chars warning is added.
When you'd use this: debugging why a character disappeared from a slug (each step shows before/after), and letting AI agents choose or explain slugs — the trace is structured evidence of exactly what was transformed.
Options
type SlugOptions = {
separator?: '-' | '_' | '.';
lowercase?: boolean;
locale?: string;
maxLength?: number;
maxInputLength?: number; // input budget before segmentation; default max(4096, maxLength * 16), Infinity disables
alphabet?: 'unicode' | 'ascii';
mode?: 'classic' | 'semantic';
strict?: boolean;
emoji?: 'remove' | 'keep' | 'name';
symbols?: 'basic' | 'extended' | false;
tech?: boolean;
stopwords?: 'auto' | string[] | false;
keepNumbers?: boolean;
reserved?: string[];
unknown?: 'drop' | 'hex';
fallback?: string | ((input: string, ctx: { tokens: string[] }) => string);
};Presets
presets.safe: strict ASCII output with symbol and tech rewrites.presets.cyber: the neon-grade preset — semantic mode + emoji naming + ASCII output, tuned for terse, punchy slugs.presets.unicode: semantic + emoji naming, Unicode output.
Safety guarantees
- Never returns an empty string,
., or.. - Never returns slashes
- Handles reserved names (
admin,api,con,nul, etc.) - Fallback always produces a stable slug with a hash suffix
- NFKC compatibility folding plus zero-width and bidi control-character stripping — stripped invisibles are surfaced as a
stripped-invisible: n charswarning with a codepoint summary in the trace. Note the scope: this defuses invisible-character and compatibility-form tricks, but mixed-script homoglyph substitution (e.g. Cyrillicаstanding in for Latina) is out of scope — both are legitimate letters and are preserved as such. - Non-string input (
undefined,null, numbers) throws aTypeError— never anundefined/nullslug - Megabyte-scale input is clamped (grapheme-safe) before segmentation, so hostile or accidental huge input cannot stall the process (
truncated-inputwarning; tune withmaxInputLength)
Contributing
See CONTRIBUTING.md for development, testing, and the chaos suite.
License
MIT
