/** * Reads AT Protocol lexicon schemas from landing/.well-known/site.exosphere.*.json * and generates TypeScript interfaces + PdsRecordMap. * * Usage: bun run generate:lexicons */ import { resolve } from "node:path"; const LEXICON_DIR = resolve(import.meta.dirname!, "../../landing/lexicons/site/exosphere"); const OUTPUT_FILE = resolve( import.meta.dirname!, "../packages/core/src/generated/lexicon-records.ts", ); const PREFIX = "site.exosphere."; interface LexiconProperty { type: string; format?: string; description?: string; items?: { type: string }; knownValues?: string[]; maxLength?: number; ref?: string; } interface LexiconDef { type: string; description?: string; knownValues?: string[]; } interface LexiconSchema { lexicon: number; id: string; defs: Record & { main: { type: string; record: { type: string; required?: string[]; properties: Record; }; }; }; } function toInterfaceName(lexiconId: string): string { const name = lexiconId.slice(PREFIX.length); return ( name .split(".") .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) .join("") + "Record" ); } /** Quote property names that aren't valid JS identifiers. */ function formatKey(key: string): string { return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : `"${key}"`; } function tsType(prop: LexiconProperty, defs: Record): string { if (prop.type === "array" && prop.items?.type === "string") return "string[]"; if (prop.type === "string") return "string"; // Resolve local refs like "#role" → defs.role if (prop.type === "ref" && prop.ref) { const defName = prop.ref.replace(/^#/, ""); const resolved = defs[defName]; if (resolved) return tsType({ type: resolved.type } as LexiconProperty, defs); } throw new Error(`Unsupported lexicon property type: ${JSON.stringify(prop)}`); } function formatComment(prop: LexiconProperty): string | null { const parts: string[] = []; if (prop.format) parts.push(prop.format); if (prop.description) parts.push(prop.description); return parts.length > 0 ? parts.join(" — ") : null; } async function main() { const glob = new Bun.Glob("**/*.json"); const files = Array.from(glob.scanSync({ cwd: LEXICON_DIR, absolute: true })).sort(); const schemas: LexiconSchema[] = []; for (const file of files) { const content = await Bun.file(file).json(); schemas.push(content as LexiconSchema); } // Sort by lexicon ID for stable output schemas.sort((a, b) => a.id.localeCompare(b.id)); const lines: string[] = [ "// AUTO-GENERATED from landing/lexicons/site/exosphere/*.json", "// Do not edit manually. Run: bun run generate:lexicons", "", ]; for (const schema of schemas) { const name = toInterfaceName(schema.id); const record = schema.defs.main.record; const required = new Set(record.required ?? []); lines.push(`export interface ${name} {`); for (const [key, prop] of Object.entries(record.properties)) { const comment = formatComment(prop); if (comment) { lines.push(` /** ${comment} */`); } const optional = required.has(key) ? "" : "?"; lines.push(` ${formatKey(key)}${optional}: ${tsType(prop, schema.defs)};`); } lines.push("}"); lines.push(""); } // Generate PdsRecordMap lines.push("export interface PdsRecordMap {"); for (const schema of schemas) { const name = toInterfaceName(schema.id); lines.push(` "${schema.id}": ${name};`); } lines.push("}"); lines.push(""); await Bun.write(OUTPUT_FILE, lines.join("\n")); console.log(`Generated ${schemas.length} interfaces → ${OUTPUT_FILE}`); } main();