From d1fdc72d29788b4f4c79ea9e1fff1b0fe7ff811e Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 11:25:36 +0200 Subject: [PATCH] feat(slug): URL-safe unique slugs for clients and projects --- src/lib/slug/slug.test.ts | 75 +++++++++++++++++++++++++++++++++++++++ src/lib/slug/slug.ts | 59 ++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/lib/slug/slug.test.ts create mode 100644 src/lib/slug/slug.ts diff --git a/src/lib/slug/slug.test.ts b/src/lib/slug/slug.test.ts new file mode 100644 index 0000000..157a2dd --- /dev/null +++ b/src/lib/slug/slug.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { slugify, uniqueSlug } from "@/lib/slug/slug"; + +describe("slugify", () => { + it("lowercases and hyphenates words", () => { + expect(slugify("WFDash Web App")).toBe("wfdash-web-app"); + }); + + it("strips punctuation", () => { + expect(slugify("Mega Infrastructure!")).toBe("mega-infrastructure"); + expect(slugify("R&D / Tooling")).toBe("r-d-tooling"); + }); + + it("collapses runs of separators", () => { + expect(slugify("a b")).toBe("a-b"); + expect(slugify("a---b")).toBe("a-b"); + }); + + it("trims leading and trailing hyphens", () => { + expect(slugify(" -Hello- ")).toBe("hello"); + }); + + it("folds accents to plain letters", () => { + expect(slugify("Café Münster")).toBe("cafe-munster"); + }); + + it("keeps digits", () => { + expect(slugify("Phase 2 Rollout")).toBe("phase-2-rollout"); + }); + + it("caps length at 60 without a trailing hyphen", () => { + const result = slugify("x".repeat(80)); + expect(result).toHaveLength(60); + expect(result.endsWith("-")).toBe(false); + }); + + it("returns a usable fallback when nothing survives", () => { + // A name of only punctuation, or of characters this does not transliterate, + // must still produce a valid slug — the field is required and pattern-checked. + expect(slugify("!!!")).toBe("item"); + expect(slugify("")).toBe("item"); + }); + + it("only ever emits characters the schema pattern allows", () => { + const pattern = /^[a-z0-9-]+$/; + for (const input of ["Ünïcødé ✨", "A_B_C", "100% Done", " "]) { + expect(slugify(input)).toMatch(pattern); + } + }); +}); + +describe("uniqueSlug", () => { + it("returns the base when it is free", () => { + expect(uniqueSlug("wfdash", [])).toBe("wfdash"); + expect(uniqueSlug("wfdash", ["other"])).toBe("wfdash"); + }); + + it("appends a counter when taken", () => { + expect(uniqueSlug("wfdash", ["wfdash"])).toBe("wfdash-2"); + expect(uniqueSlug("wfdash", ["wfdash", "wfdash-2"])).toBe("wfdash-3"); + }); + + it("skips gaps rather than reusing a freed slug", () => { + // "wfdash-2" was deleted. Reusing it would silently point old links at a + // different project, so keep counting past the highest taken value. + expect(uniqueSlug("wfdash", ["wfdash", "wfdash-3"])).toBe("wfdash-4"); + }); + + it("keeps the result inside the 60-character limit", () => { + const base = "y".repeat(60); + const result = uniqueSlug(base, [base]); + expect(result.length).toBeLessThanOrEqual(60); + expect(result).not.toBe(base); + }); +}); \ No newline at end of file diff --git a/src/lib/slug/slug.ts b/src/lib/slug/slug.ts new file mode 100644 index 0000000..8e8993f --- /dev/null +++ b/src/lib/slug/slug.ts @@ -0,0 +1,59 @@ +/** + * URL-safe identifiers for clients and projects. + * + * The schema declares slug with pattern ^[a-z0-9-]+$ and a 60-character cap, + * so anything this returns must satisfy both — including for input that is + * entirely punctuation or entirely non-Latin. A rejected create at the + * database, from a name the owner typed, is a bad failure. + */ + +export const MAX_SLUG_LENGTH = 60; + +const FALLBACK = "item"; + +export function slugify(input: string): string { + const slug = input + .normalize("NFD") + // Strip the combining diacritical marks NFD just split off, so "é" + // becomes "e" rather than "e" plus an accent that the next step deletes + // as punctuation. Written as escapes, not literal characters — the + // literal range is invisible in an editor and survives copy-paste badly. + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, MAX_SLUG_LENGTH) + .replace(/-$/, ""); + + return slug || FALLBACK; +} + +/** + * `base`, or `base-2`, `base-3`, … until one is free. + * + * Counts past the highest taken suffix rather than filling gaps: reusing a + * slug freed by a deletion would silently repoint old links — including any + * a client has bookmarked — at a different project. + */ +export function uniqueSlug(base: string, taken: string[]): string { + const used = new Set(taken); + if (!used.has(base)) return base; + + let highest = 1; + for (const value of used) { + if (value === base) continue; + if (!value.startsWith(`${base}-`)) continue; + const suffix = Number(value.slice(base.length + 1)); + if (Number.isInteger(suffix) && suffix > highest) highest = suffix; + } + + let counter = highest + 1; + for (;;) { + const suffix = `-${counter}`; + const trimmed = base.slice(0, MAX_SLUG_LENGTH - suffix.length).replace(/-$/, ""); + const candidate = `${trimmed}${suffix}`; + if (!used.has(candidate)) return candidate; + counter += 1; + } +} \ No newline at end of file