feat(slug): URL-safe unique slugs for clients and projects

This commit is contained in:
Dave 2026-09-16 11:25:36 +02:00
parent 301af1f739
commit d1fdc72d29
2 changed files with 134 additions and 0 deletions

75
src/lib/slug/slug.test.ts Normal file
View file

@ -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);
});
});

59
src/lib/slug/slug.ts Normal file
View file

@ -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;
}
}