npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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.Segmenter splits CJK and Thai text into words (你好世界 → 你好-世界), not one giant token.
  • Zero runtime dependencies.

Install

npm i @khalidsaidi/slugshade

Quick 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 chars warning 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 Latin a) is out of scope — both are legitimate letters and are preserved as such.
  • Non-string input (undefined, null, numbers) throws a TypeError — never an undefined/null slug
  • Megabyte-scale input is clamped (grapheme-safe) before segmentation, so hostile or accidental huge input cannot stall the process (truncated-input warning; tune with maxInputLength)

Contributing

See CONTRIBUTING.md for development, testing, and the chaos suite.

License

MIT