#!/usr/bin/env node // Regenerates packages/core/paths/reserved-slugs.ts from // server/internal/handler/reserved_slugs.json (the single source of truth). // // Run via `pnpm generate:reserved-slugs`. CI re-runs the generator and // `git diff --exit-code`s the output, so the JSON and TS sides cannot drift. import { readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const jsonPath = resolve(repoRoot, "server/internal/handler/reserved_slugs.json"); const tsPath = resolve(repoRoot, "packages/core/paths/reserved-slugs.ts"); const raw = readFileSync(jsonPath, "utf8"); const data = JSON.parse(raw); if (!Array.isArray(data.groups)) { throw new Error(`reserved_slugs.json: expected top-level "groups" array`); } const lines = []; lines.push("// AUTO-GENERATED by scripts/generate-reserved-slugs.mjs."); lines.push("// Do not edit by hand — edit server/internal/handler/reserved_slugs.json"); lines.push("// and run `pnpm generate:reserved-slugs`."); lines.push(""); lines.push("/**"); lines.push(" * Slugs reserved because they collide with frontend top-level routes,"); lines.push(" * platform features, or web standards."); lines.push(" *"); lines.push(" * Single source of truth: `server/internal/handler/reserved_slugs.json`."); lines.push(" * The Go backend embeds that JSON; this file is regenerated from it."); lines.push(" *"); lines.push(" * Convention for new global routes (CLAUDE.md): use a single word"); lines.push(" * (`/login`, `/inbox`) or `/{noun}/{verb}` (`/workspaces/new`). Hyphenated"); lines.push(" * root-level word groups (`/new-workspace`, `/create-team`) collide with"); lines.push(" * common user workspace names — see PR for full discussion."); lines.push(" */"); lines.push("export const RESERVED_SLUGS: ReadonlySet = new Set(["); const seen = new Set(); for (const [i, group] of data.groups.entries()) { if (!group.label || !Array.isArray(group.slugs)) { throw new Error( `reserved_slugs.json: each group must have a "label" string and "slugs" array`, ); } if (i > 0) lines.push(""); lines.push(` // ${group.label}`); if (typeof group.description === "string" && group.description.length > 0) { for (const wrapped of wrapComment(group.description, 76)) { lines.push(` // ${wrapped}`); } } for (const slug of group.slugs) { if (typeof slug !== "string" || slug.length === 0) { throw new Error(`reserved_slugs.json: slug entries must be non-empty strings`); } if (seen.has(slug)) { throw new Error(`reserved_slugs.json: duplicate slug ${JSON.stringify(slug)}`); } seen.add(slug); lines.push(` ${JSON.stringify(slug)},`); } } lines.push("]);"); lines.push(""); lines.push("export function isReservedSlug(slug: string): boolean {"); lines.push(" return RESERVED_SLUGS.has(slug);"); lines.push("}"); lines.push(""); writeFileSync(tsPath, lines.join("\n")); // Wrap a single-line description into comment lines no wider than `width`, // breaking on whitespace only. Tokens longer than `width` are kept intact. function wrapComment(text, width) { const tokens = text.split(/\s+/).filter(Boolean); const out = []; let current = ""; for (const tok of tokens) { if (current.length === 0) { current = tok; } else if (current.length + 1 + tok.length <= width) { current += ` ${tok}`; } else { out.push(current); current = tok; } } if (current.length > 0) out.push(current); return out; }