Files
multica/scripts/generate-reserved-slugs.mjs
Bohan Jiang bda475cbba refactor(reserved-slugs): single JSON source for backend + frontend (#2148)
Reserved workspace slugs lived in two parallel files (`workspace_reserved_slugs.go`
and `packages/core/paths/reserved-slugs.ts`) with no parity check. Adding or
renaming a global route on one side without the other would slip through CI
and surface only when a real user hit the collision.

Collapse the two lists into one source: `server/internal/handler/reserved_slugs.json`.
Go embeds the JSON via `//go:embed` and parses it at package init; the TS file
is regenerated by `scripts/generate-reserved-slugs.mjs` (run via
`pnpm generate:reserved-slugs`). CI re-runs the generator and `git diff
--exit-code`s the TS output, so a stale TS file cannot land. The slug set is
unchanged (87 entries, byte-equivalent slug literals).

Update CLAUDE.md to describe the new "edit JSON, run generator" workflow.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-08 19:14:12 +08:00

96 lines
3.5 KiB
JavaScript

#!/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<string> = 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;
}