Slugs
Human-readable identifiers for taggable and browseable entities. Lowercase, hyphenated, 1–64 chars, regex-enforced at the API boundary.
Purpose
Slugs appear in URLs (/browse/ai/video), in filter params (?tags=gaussian-splats), and in denormalized references on Content (groupSlug, tagSlugs[]). The discipline is what lets us:
- Build links without an ID→slug lookup.
- Let operators rename taxonomy without migrating every reference.
- Keep APIs skimmable (
/tags/gaussian-splatsbeats/tags/65fb…).
Contract reference: API.md §3.
Implementation
Regex
^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$
- Starts and ends with
[a-z0-9](no leading or trailing hyphen). - Internal hyphens allowed.
- Length 1–64.
- No Unicode. Deliberate for v1 — add later with a scheme-versioned migration.
@IsSlug() decorator
// src/common/decorators/is-slug.decorator.ts
import { registerDecorator, ValidationOptions } from 'class-validator';
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
const RESERVED = new Set([
'new', 'edit', 'admin', 'api', 'auth', 'catalog', 'search', 'meta', 'tags', 'settings',
]);
export const IsSlug = (options?: ValidationOptions) => (target: object, propertyName: string) =>
registerDecorator({
name: 'isSlug',
target: target.constructor,
propertyName,
options: { message: 'slug must be lowercase-hyphenated, 1–64 chars, not reserved', ...options },
validator: {
validate: (v: unknown) => typeof v === 'string' && SLUG_RE.test(v) && !RESERVED.has(v),
},
});
Usage:
export class CreateTagDto {
@IsSlug() slug!: string;
@IsString() @MinLength(1) @MaxLength(100) label!: string;
@IsIn(FACETS) facet!: TagFacet;
}
Reserved list rationale
The reserved list collides with route literals (/tags/new, /content/search) and config endpoints. A slug equal to one of these would make URLs ambiguous. Keep the list small; audit when adding new route segments.
Uniqueness
Enforced by Mongo unique indexes (schema.index({ slug: 1 }, { unique: true }) globally for tags/content/surfaces; compound indexes for groups-per-surface and channels-per-group — see TAXONOMY.md).
On create, the service first checks existence (returns 409 early with a better error message) AND relies on the unique index as the final safeguard:
async create(dto: CreateTagDto): Promise<Tag> {
if (await this.model.exists({ slug: dto.slug })) {
throw new ConflictError('slug.conflict', { slug: dto.slug, entity: 'tag' });
}
try {
return await this.model.create(dto);
} catch (e) {
if (isDuplicateKeyError(e)) throw new ConflictError('slug.conflict', { slug: dto.slug, entity: 'tag' });
throw e;
}
}
The pre-check is a UX nicety — the unique index handles the race.
Suggestion helper (for clients)
export function slugify(input: string): string {
return input
.toLowerCase()
.normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip accents
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)
.replace(/-+$/, '');
}
Exposed via POST /slug/suggest (public, cheap). Does NOT check uniqueness — callers handle collisions (retry with a counter suffix: my-slug, my-slug-2, …).
Rename
Only tags expose a rename endpoint (POST /tags/<slug>/rename) — editorial hygiene is frequent. Group/channel/surface renames are rare admin operations performed via their standard PATCH routes and cascade through the catalog cache invalidation hook.
Tag rename mechanics: tag-rename.md.
Slug generation on content submit
Content.slug is generated server-side from title via slugify(); on collision the service appends -<6-char-random>. See content-submit.md.
Required variables and services
- None. Pure validator + helpers.
Gotchas
- Collision resolution on content submit. We do NOT retry-with-counter (
my-post-2,my-post-3, …) because it's racy under load. Random suffix gives unique-enough in one attempt. - Reserved list is in code, not DB. A new route segment conflicting with an existing slug would silently break linking. When adding reserved slugs, check if any existing content/tag/group already uses that slug — deal with the collision (rename existing data) before shipping the new route.
- Unicode pressure. Users submitting titles with emoji or non-Latin scripts get empty slugs (everything stripped). The create endpoint must fall back to a random slug if
slugify(title)is empty (e.g.item-<6-char-random>). - Validator runs before transform.
class-transformertrims whitespace only where@Transform(({ value }) => value.trim())is declared — don't rely on implicit trimming.
Testing
- Unit:
SLUG_REagainst a golden set (valid:a,ab,gaussian-splats,a-b-c,a1,1a; invalid: empty,-x,x-,x--y,X,x y, 65-char string). - Unit:
RESERVED— each reserved slug returns false. - Unit:
slugify— emoji titles, accented input, punctuation-heavy titles, empty results. - Integration: POST a tag with a valid slug — 201. POST the same slug — 409
slug.conflict. POST a reserved slug — 400validation.failed. - Integration: Create a tag, rename to a new slug — old slug 404s, new slug resolves, audit record exists.