// namespace keys are free text a person types. this is the only place that // decides what a key may look like; the hash that names the space is taken // over the sanitized form, so two spellings that sanitize alike are one space. export const MAX_KEY_LENGTH = 200; export type KeyProblem = "empty" | "too-long" | "control-characters" | "reserved"; /** names the extension keeps for itself */ export const RESERVED = new Set(["lore-registry"]); export type Sanitized = { ok: true; key: string } | { ok: false; problem: KeyProblem }; const isControl = (code: number): boolean => code < 0x20 || code === 0x7f; /** a slug: lowercased, every run of anything but letters, digits, and hyphens becomes one hyphen, * none at the ends; refuse empty, overlong, control characters, or reserved names. */ export function sanitizeKey(raw: string): Sanitized { for (const ch of raw.replace(/[\t\n\r]/g, " ")) if (isControl(ch.codePointAt(0) ?? 0)) return { ok: false, problem: "control-characters" }; const collapsed = raw.toLowerCase().replace(/[^\p{L}\p{N}-]+/gu, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, ""); if (!collapsed) return { ok: false, problem: "empty" }; if (collapsed.length > MAX_KEY_LENGTH) return { ok: false, problem: "too-long" }; if (RESERVED.has(collapsed)) return { ok: false, problem: "reserved" }; return { ok: true, key: collapsed }; } export function explain(problem: KeyProblem): string { switch (problem) { case "empty": return "a key needs at least one character"; case "too-long": return `a key is at most ${MAX_KEY_LENGTH} characters`; case "control-characters": return "a key cannot contain control characters"; case "reserved": return "that name is reserved"; } }