Slug

URL-safe slug generation with two normalization paths and a uniqueness helper.

The default ASCII path uses Unicode NFKD decomposition + combining-mark strip (cafécafe) and drops anything outside [a-zA-Z0-9]. The preserveUnicode: true path uses NFC and only drops Unicode punctuation, symbols, and separators — Cyrillic, Greek, CJK, and other scripts pass through. Operators with non-Latin user content opt into preserveUnicode.

b.slug is a callable function with the rest of the API hung off it (callable-namespace pattern): b.slug.create builds a bound slugger pre-configured with operator opts, and b.slug.unique resolves the first un-taken candidate against an operator-supplied isUsed predicate (numeric suffixes -2, -3, … on collision).

Validation policy: opts and title are validated at the call site (throw on bad input). Titles that normalize to empty are tolerant — opts.fallback is returned instead of throwing. unique() exhausting maxAttempts throws SlugError.

Reserved web slugs (admin, api, login, …) live on b.slug.RESERVED as a mutable Set; operators extend it once at boot and pass it into their isUsed predicate.

b.slug(title, callOpts) #

0.1.0
{
  separator:       string,   // single-char join between tokens (default "-")
  lowercase:       boolean,  // lowercase output (default true, locale-independent)
  maxLength:       number,   // hard cap on output length, or null for none (default 80)
  preserveUnicode: boolean,  // keep non-ASCII letters/digits (default false)
  fallback:        string,   // returned when title normalizes to empty (default "")
}

Slugify a title. The default path produces lowercase ASCII separated by -: accents fold (cafécafe), runs of non-alphanumerics collapse to a single separator, and the result is trimmed of leading/trailing separators and capped at maxLength (truncating at a separator boundary when possible). Empty results return opts.fallback.

preserveUnicode: true keeps letters and digits in any script and only drops punctuation/symbols/separators — the right choice for non-Latin user content.

b.slug("Hello, World!");
// → "hello-world"

b.slug("café résumé");
// → "cafe-resume"

b.slug("Привет мир", { preserveUnicode: true });
// → "привет-мир"

b.slug("a".repeat(200), { maxLength: 10 });
// → "aaaaaaaaaa"

b.slug("---", { fallback: "untitled" });
// → "untitled"

b.slug.create(creatorOpts) #

0.1.0
{
  separator:       string,   // single-char join between tokens (default "-")
  lowercase:       boolean,  // lowercase output (default true)
  maxLength:       number,   // hard cap on output length, or null for none (default 80)
  preserveUnicode: boolean,  // keep non-ASCII letters/digits (default false)
  fallback:        string,   // returned when title normalizes to empty (default "")
}

Build a bound slugger pre-configured with operator opts. Returns a function with the same signature as b.slug — per-call opts merge over the bound opts, so the operator picks defaults once at boot and call sites stay short. Useful when one section of the app slugs with non-default settings (longer maxLength, Unicode-preserving, custom separator).

var titleSlug = b.slug.create({ maxLength: 60, preserveUnicode: true });
titleSlug("Привет мир");
// → "привет-мир"

titleSlug("Hello, World!");
// → "hello-world"

// Per-call opts override creator opts:
titleSlug("Hello, World!", { separator: "_" });
// → "hello_world"

b.slug.unique(title, isUsed, callOpts) #

0.1.0
{
  separator:       string,   // single-char join between tokens (default "-")
  lowercase:       boolean,  // lowercase output (default true)
  maxLength:       number,   // hard cap on output length, or null for none (default 80)
  preserveUnicode: boolean,  // keep non-ASCII letters/digits (default false)
  fallback:        string,   // returned when title normalizes to empty (default "")
  maxAttempts:     number,   // total tries including bare base (default 100)
  start:           number,   // first numeric suffix (default 2)
  suffixSeparator: string,   // separator between base and suffix (default opts.separator)
}

Resolve the first un-taken slug for title against an operator-supplied isUsed(candidate) predicate (sync or async). The bare slug is tried first; on collision the function appends a numeric suffix (-2, -3, …) and re-checks until isUsed returns falsy or maxAttempts is exhausted. When the suffix would push past maxLength, the base is truncated at a separator boundary so the final candidate fits. Throws SlugError on exhaustion.

var taken = new Set(["hello-world", "hello-world-2"]);
async function isUsed(cand) { return taken.has(cand); }

var s1 = await b.slug.unique("Hello, World!", isUsed);
// → "hello-world-3"

var s2 = await b.slug.unique("Brand New Title", isUsed);
// → "brand-new-title"

// Custom suffix separator + start index:
var s3 = await b.slug.unique("Hello, World!", isUsed, {
  suffixSeparator: "_",
  start: 10,
});
// → "hello-world_10"

Last updated 2026-08-08T16:39:49.652Z by seeder.