import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import { basename, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isMap, isSeq, parseDocument, stringify } from 'yaml'; const projectRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); const sourceDirectory = resolve( process.env.ROSETTA_PROTO_DIR || resolve(projectRoot, 'proto'), ); const outputPath = resolve(projectRoot, 'src/generated/protoDocs.ts'); async function exists(path) { try { await access(path); return true; } catch { return false; } } function capitalizeFirstLetter(value) { return value.trim().replace(/\p{L}/u, (letter) => letter.toLocaleUpperCase()); } function cleanComment(...comments) { const description = comments .filter(Boolean) .join('\n') .split('\n') .map((line) => line.replace(/^\s*#?\s?/, '').trim()) .filter(Boolean) .join(' '); return capitalizeFirstLetter(description); } function mapFields(node) { if (!isMap(node)) { return []; } const fields = node.get('fields', true); if (!isSeq(fields)) { return []; } return fields.items.map((field) => { const json = field.toJSON(); const nameNode = field.get('name', true); const typeNode = field.get('type', true); const rawName = String(json.name); const optional = rawName.endsWith('?'); return { id: Number(json.id), name: optional ? rawName.slice(0, -1) : rawName, type: String(json.type), optional, description: cleanComment( field.commentBefore, field.comment, nameNode?.commentBefore, nameNode?.comment, typeNode?.comment, ), }; }); } function mapNamedCollection(node, kind) { if (!isMap(node)) { return []; } return node.items.map((pair) => { const name = String(pair.key.value); const description = cleanComment( pair.key.commentBefore, pair.key.comment, pair.value?.commentBefore, pair.value?.comment, ); const yaml = stringify( { [name]: pair.value.toJSON() }, { indent: 2, lineWidth: 0 }, ).trimEnd(); if (kind === 'enum') { const values = pair.value.get('values', true); return { name, type: String(pair.value.get('type')), description, yaml, values: isSeq(values) ? values.items.map((value) => { const json = value.toJSON(); const nameNode = value.get('name', true); return { id: Number(json.id), name: String(json.name), description: cleanComment( value.commentBefore, value.comment, nameNode?.commentBefore, nameNode?.comment, ), }; }) : [], }; } const fields = mapFields(pair.value); return { name, id: kind === 'packet' ? Number(pair.value.get('id')) : undefined, description, yaml, fields, }; }); } async function readLayer(path) { const source = await readFile(path, 'utf8'); const document = parseDocument(source, { keepSourceTokens: true }); if (document.errors.length > 0) { throw new Error( `${basename(path)}: ${document.errors.map((error) => error.message).join('; ')}`, ); } return { version: Number(document.get('version')), description: capitalizeFirstLetter(String(document.get('description') || '')), source: basename(path), enums: mapNamedCollection(document.get('enums', true), 'enum'), types: mapNamedCollection(document.get('types', true), 'type'), packets: mapNamedCollection(document.get('packets', true), 'packet'), }; } let sourceFiles = []; try { sourceFiles = (await readdir(sourceDirectory)) .filter((file) => /^layer\..+\.ya?ml$/i.test(file)) .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) .map((file) => resolve(sourceDirectory, file)); } catch { sourceFiles = []; } if (sourceFiles.length === 0) { if (await exists(outputPath)) { console.log( `Proto sources are unavailable at ${sourceDirectory}; keeping the committed generated documentation.`, ); process.exit(0); } throw new Error(`No YAML schema files found in ${sourceDirectory}`); } const layers = []; for (const file of sourceFiles) { layers.push(await readLayer(file)); } layers.sort((a, b) => a.version - b.version); const generatedSource = `/* This file is generated by scripts/generate-proto-docs.mjs. */\nimport type { ProtoLayer } from '../types/proto';\n\nexport const protoLayers = ${JSON.stringify(layers, null, 2)} satisfies ProtoLayer[];\n`; await mkdir(resolve(projectRoot, 'src/generated'), { recursive: true }); await writeFile(outputPath, generatedSource); const totals = layers.reduce( (result, layer) => ({ enums: result.enums + layer.enums.length, types: result.types + layer.types.length, packets: result.packets + layer.packets.length, }), { enums: 0, types: 0, packets: 0 }, ); console.log( `Generated Proto docs from ${layers.length} layers: ${totals.packets} packets, ${totals.types} types, ${totals.enums} enums.`, );