generate-validators.mjs
105 lines · archify/scripts/generate-validators.mjs at a406594
1#!/usr/bin/env node
3import fs from 'node:fs';
4import path from 'node:path';
5import { fileURLToPath } from 'node:url';
6import Ajv2020 from 'ajv/dist/2020.js';
7import standaloneCode from 'ajv/dist/standalone/index.js';
9const __dirname = path.dirname(fileURLToPath(import.meta.url));
10const root = path.resolve(__dirname, '..');
11const schemasDir = path.join(root, 'schemas');
12const output = path.join(root, 'renderers/shared/generated-validators.mjs');
13const diagramTypes = ['workflow', 'sequence', 'dataflow', 'lifecycle', 'architecture', 'erd', 'tree', 'class', 'timeline', 'waterfall', 'explainer'];
15const ajv = new Ajv2020({
16 allErrors: true,
17 strict: true,
18 code: { source: true, esm: true },
19});
20ajv.addSchema(JSON.parse(fs.readFileSync(path.join(schemasDir, 'common.schema.json'), 'utf8')));
22const schemaIds = {};
23for (const type of diagramTypes) {
24 const schema = JSON.parse(fs.readFileSync(path.join(schemasDir, `${type}.schema.json`), 'utf8'));
25 ajv.addSchema(schema);
26 schemaIds[type] = schema.$id;
27}
29const banner = '// Generated by scripts/generate-validators.mjs. Do not edit by hand.\n';
30const ajvUcs2Import = 'require("ajv/dist/runtime/ucs2length").default';
31const inlineUcs2Length = `function ucs2length(str) {
32 const len = str.length;
33 let length = 0;
34 let pos = 0;
35 while (pos < len) {
36 length += 1;
37 const value = str.charCodeAt(pos++);
38 if (value >= 0xd800 && value <= 0xdbff && pos < len
39 && (str.charCodeAt(pos) & 0xfc00) === 0xdc00) pos += 1;
40 }
41 return length;
42}`;
43let validatorCode = standaloneCode(ajv, schemaIds);
44if (!validatorCode.includes(ajvUcs2Import)) {
45 throw new Error('AJV standalone output no longer contains the expected ucs2length helper');
46}
47validatorCode = validatorCode.replaceAll(ajvUcs2Import, inlineUcs2Length);
48if (validatorCode.includes('require(')) {
49 throw new Error('AJV standalone output contains an unexpected runtime dependency');
50}
51for (const type of diagramTypes) {
52 const exportPattern = new RegExp(`export const ${type} = (validate\\d+);`);
53 const match = validatorCode.match(exportPattern);
54 if (!match) throw new Error(`AJV standalone output no longer exports the ${type} validator as expected`);
55 validatorCode = validatorCode.replace(exportPattern, `const ${type}Schema = ${match[1]};`);
56}
58// A diagram type that is a reserved word (`class`) cannot name a function, so
59// it is defined under a safe local name and exported under the type itself.
60const RESERVED_TYPE_NAMES = new Set(['class']);
61const portableOutputWrappers = diagramTypes.map((type) => {
62 const name = RESERVED_TYPE_NAMES.has(type) ? `${type}Diagram` : type;
63 const exported = name === type ? '' : `\nexport { ${name} as ${type} };`;
64 return `${name === type ? 'export ' : ''}function ${name}(data, context = undefined) {
65 if (!${type}Schema(data, context)) {
66 ${name}.errors = ${type}Schema.errors;
67 return false;
68 }
69 const output = data?.meta?.output;
70 if (typeof output === 'string') {
71 try {
72 validatePortablePath(output, { profile: 'output' });
73 } catch (error) {
74 ${name}.errors = [{
75 instancePath: \`${'${context?.instancePath || \'\'}'}/meta/output\`,
76 schemaPath: 'common.schema.json#/$defs/portableOutputPath',
77 keyword: 'portablePath',
78 params: { reason: error?.reason || 'invalid' },
79 message: 'must satisfy the portable output path contract',
80 }];
81 return false;
82 }
83 }
84 ${name}.errors = null;
85 return true;
86}
87${name}.evaluated = ${type}Schema.evaluated;${exported}`;
88}).join('\n');
90const generated = `${banner}import { validatePortablePath } from './portable-path.mjs';\n${validatorCode}\n${portableOutputWrappers}\n`;
92if (process.argv.includes('--check')) {
93 const current = fs.existsSync(output)
94 ? fs.readFileSync(output, 'utf8').replace(/\r\n?/g, '\n')
95 : '';
96 if (current !== generated) {
97 console.error('generated validators are stale — run npm run generate:validators');
98 process.exit(1);
99 }
100} else {
101 const temporary = `${output}.${process.pid}.tmp`;
102 fs.writeFileSync(temporary, generated);
103 fs.renameSync(temporary, output);
104 console.log(`generated ${path.relative(root, output)}`);
105}