Files
landing/scripts/generate-proto-docs.mjs

189 lines
5.8 KiB
JavaScript

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.`,
);