feat(slug): URL-safe unique slugs for clients and projects
This commit is contained in:
parent
301af1f739
commit
d1fdc72d29
2 changed files with 134 additions and 0 deletions
75
src/lib/slug/slug.test.ts
Normal file
75
src/lib/slug/slug.test.ts
Normal 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
59
src/lib/slug/slug.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue