mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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>
96 lines
3.5 KiB
JavaScript
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;
|
|
}
|