@jarenjs/emit 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js ADDED
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ //#region the jaren-emit command
3
+ // Nobody wires a code generator through its API by hand, so the CLI is what
4
+ // makes this adoptable: point it at a directory of schemas, get a directory
5
+ // of declarations, and put the command in `prebuild`.
6
+
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+
10
+ import { compileEmitModel } from './model.js';
11
+ import { renderTypeScript } from './typescript.js';
12
+ import { renderMarkdown } from './markdown.js';
13
+
14
+ const TARGETS = {
15
+ typescript: { render: renderTypeScript, extension: '.d.ts' },
16
+ markdown: { render: renderMarkdown, extension: '.md' },
17
+ };
18
+
19
+ const USAGE = `jaren-emit — build-time artifacts from JSON Schema
20
+
21
+ Usage:
22
+ jaren-emit --schema <file|dir> --out <dir> [options]
23
+
24
+ Options:
25
+ --schema <path> A .json schema file, or a directory of them (required)
26
+ --out <dir> Where to write the generated files (required)
27
+ --target <name> typescript (default) or markdown
28
+ --name <Name> Root declaration name for a single schema (default: file name)
29
+ --bundle <file> Write every schema into one output file instead of one each
30
+ --check Do not write; exit 1 if any output would differ (for CI)
31
+ --defaults Emit accepted/normalized variants for schema defaults
32
+ --coerce Emit accepted/normalized variants for type coercion
33
+ --suffix <s> Name for the accepted variant (default: Input)
34
+ --help This text
35
+
36
+ The --defaults/--coerce flags mirror the compileNormalizer options of the same
37
+ name. With either on, a type whose shape differs before and after normalizing
38
+ gains a second declaration: Config is what you have afterwards, ConfigInput is
39
+ what a caller may hand in.
40
+
41
+ Examples:
42
+ jaren-emit --schema ./schemas --out ./src/types
43
+ jaren-emit --schema ./schemas/user.json --out ./types --name User
44
+ jaren-emit --schema ./schemas --out ./src/types --check
45
+ `;
46
+
47
+ function parseArgs(argv) {
48
+ const options = {
49
+ schema: null, out: null, target: 'typescript',
50
+ name: null, bundle: null, check: false, help: false,
51
+ defaults: false, coerce: false, suffix: 'Input',
52
+ };
53
+ for (let i = 2; i < argv.length; i++) {
54
+ switch (argv[i]) {
55
+ case '--schema': options.schema = argv[++i]; break;
56
+ case '--out': options.out = argv[++i]; break;
57
+ case '--target': options.target = argv[++i]; break;
58
+ case '--name': options.name = argv[++i]; break;
59
+ case '--bundle': options.bundle = argv[++i]; break;
60
+ case '--check': options.check = true; break;
61
+ case '--defaults': options.defaults = true; break;
62
+ case '--coerce': options.coerce = true; break;
63
+ case '--suffix': options.suffix = argv[++i]; break;
64
+ case '--help': case '-h': options.help = true; break;
65
+ default:
66
+ throw new Error(`unknown option: ${argv[i]}`);
67
+ }
68
+ }
69
+ return options;
70
+ }
71
+
72
+ /** Every `.json` file the `--schema` path names, in sorted order so the
73
+ * output is the same whatever the filesystem feels like returning. */
74
+ function collectSchemaFiles(target) {
75
+ const stat = fs.statSync(target);
76
+ if (!stat.isDirectory()) return [target];
77
+ return fs.readdirSync(target)
78
+ .filter((f) => f.endsWith('.json'))
79
+ .sort()
80
+ .map((f) => path.join(target, f));
81
+ }
82
+
83
+ /** PascalCase the file's base name, so `user-account.json` becomes
84
+ * `UserAccount` — the name a reader would have chosen. */
85
+ function nameFromFile(file) {
86
+ const base = path.basename(file).replace(/\.schema\.json$|\.json$/, '');
87
+ return base.split(/[^A-Za-z0-9]+/)
88
+ .filter(Boolean)
89
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
90
+ .join('') || 'Root';
91
+ }
92
+
93
+ /** Write, or in `--check` mode compare and report. Returns true when the
94
+ * file on disk already matches. */
95
+ function writeOrCheck(file, content, check) {
96
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
97
+ if (existing === content) return true;
98
+ if (check) {
99
+ console.error(existing === null
100
+ ? `missing: ${file}`
101
+ : `out of date: ${file}`);
102
+ return false;
103
+ }
104
+ fs.mkdirSync(path.dirname(file), { recursive: true });
105
+ fs.writeFileSync(file, content);
106
+ console.log(`wrote ${file}`);
107
+ return true;
108
+ }
109
+
110
+ function main() {
111
+ let options;
112
+ try {
113
+ options = parseArgs(process.argv);
114
+ }
115
+ catch (error) {
116
+ console.error(error.message);
117
+ console.error(USAGE);
118
+ process.exit(2);
119
+ return;
120
+ }
121
+
122
+ if (options.help) {
123
+ console.log(USAGE);
124
+ return;
125
+ }
126
+ if (options.schema === null || options.out === null) {
127
+ console.error('both --schema and --out are required\n');
128
+ console.error(USAGE);
129
+ process.exit(2);
130
+ return;
131
+ }
132
+ const target = TARGETS[options.target];
133
+ if (target === undefined) {
134
+ console.error(`unknown target '${options.target}'; expected one of ${Object.keys(TARGETS).join(', ')}`);
135
+ process.exit(2);
136
+ return;
137
+ }
138
+
139
+ const files = collectSchemaFiles(options.schema);
140
+ if (files.length === 0) {
141
+ console.error(`no .json schemas under ${options.schema}`);
142
+ process.exit(2);
143
+ return;
144
+ }
145
+
146
+ // Only pass normalize options when at least one is on: a null here is what
147
+ // tells the model to emit a single declaration per type rather than a pair.
148
+ const normalizeOptions = options.defaults || options.coerce
149
+ ? { useDefaults: options.defaults, coerceTypes: options.coerce }
150
+ : null;
151
+
152
+ let ok = true;
153
+ const bundled = [];
154
+ // Bundling concatenates declarations into ONE file, so each model must
155
+ // avoid every name its predecessors used — two schemas that both declare
156
+ // `$defs.Id` would otherwise collide as duplicate identifiers. The files
157
+ // are processed in sorted order, so the renames are deterministic.
158
+ const reserved = [];
159
+ for (let i = 0; i < files.length; i++) {
160
+ const file = files[i];
161
+ const schema = JSON.parse(fs.readFileSync(file, 'utf8'));
162
+ const name = options.name ?? nameFromFile(file);
163
+ const model = compileEmitModel(schema, {
164
+ name, source: path.basename(file),
165
+ normalize: normalizeOptions, variantSuffix: options.suffix,
166
+ reserved: options.bundle !== null ? reserved : undefined,
167
+ });
168
+ if (options.bundle !== null) {
169
+ bundled.push(...model.declarations);
170
+ for (const declaration of model.declarations) reserved.push(declaration.name);
171
+ continue;
172
+ }
173
+ const out = path.join(options.out, nameFromFile(file) + target.extension);
174
+ ok = writeOrCheck(out, target.render(model), options.check) && ok;
175
+ }
176
+
177
+ if (options.bundle !== null) {
178
+ const model = { $emit: '0.1', source: options.schema, root: null, declarations: bundled };
179
+ const out = path.join(options.out, options.bundle);
180
+ ok = writeOrCheck(out, target.render(model), options.check) && ok;
181
+ }
182
+
183
+ if (!ok) {
184
+ console.error('\nGenerated output is out of date. Run jaren-emit without --check.');
185
+ process.exit(1);
186
+ }
187
+ }
188
+
189
+ main();
190
+
191
+ //#endregion
package/src/index.js ADDED
@@ -0,0 +1,21 @@
1
+ //#region @jarenjs/emit
2
+ // Build-time artifacts from JSON documents.
3
+ //
4
+ // A JSON Schema is JSON, TypeScript is text, and JTLT is JSON-to-text — so
5
+ // generating a declaration file is a stylesheet, not a new engine. That is the
6
+ // whole idea of this package, and the reason it is `emit` rather than `infer`:
7
+ // swapping the stylesheet swaps the target language, and nothing else moves.
8
+ //
9
+ // Two stages, because a schema graph is not shaped like a declaration file:
10
+ //
11
+ // schema ──▶ compileEmitModel ──▶ TYPE MODEL ──▶ stylesheet ──▶ artifact
12
+ //
13
+ // The type model is a published format, not a private intermediate: the
14
+ // bundled emitters have no privileged access to it, so a third-party emitter
15
+ // is exactly as capable as the ones shipped here.
16
+
17
+ export { compileEmitModel, EMIT_MODEL_VERSION } from './model.js';
18
+ export { emitTypeScript, renderTypeScript, TYPESCRIPT_STYLESHEET } from './typescript.js';
19
+ export { emitMarkdown, renderMarkdown, MARKDOWN_STYLESHEET } from './markdown.js';
20
+
21
+ //#endregion
@@ -0,0 +1,105 @@
1
+ //#region the Markdown emitter
2
+ // The second target, and the reason the type model exists as a separate,
3
+ // published stage rather than as private state inside the TypeScript printer.
4
+ //
5
+ // It is deliberately as unlike TypeScript as a target can be — prose and
6
+ // tables instead of declarations — so that if the model were secretly shaped
7
+ // around one language, writing this would have required changing it. It did
8
+ // not, which is the evidence the split earned its keep.
9
+
10
+ import { compileJtltStylesheet } from '@jarenjs/json/jtlt';
11
+ import { createTypeTestCompiler } from '@jarenjs/validate/query';
12
+
13
+ import { compileEmitModel } from './model.js';
14
+
15
+ /** Match a model node by its `kind` — a schema match, so it survives the
16
+ * location-less dispatch of `$apply` on the current node. */
17
+ const isKind = (kind) => ({
18
+ schema: { type: 'object', properties: { kind: { const: kind } }, required: ['kind'] },
19
+ });
20
+
21
+ /** Reference documentation for a type model. */
22
+ export const MARKDOWN_STYLESHEET = {
23
+ $jtlt: '0.1',
24
+ output: 'text',
25
+ rules: [
26
+ { match: '$', body: [[{ $apply: ['$.declarations[*]', 'decl'] }]] },
27
+
28
+ {
29
+ match: isKind('declaration'), mode: 'decl',
30
+ body: [
31
+ '## ', { $raw: '$.name' }, '\n\n',
32
+ [{ $apply: ['$.doc[*]', 'docline'] }],
33
+ [{ $apply: ['$.type', 'shape'] }],
34
+ ],
35
+ },
36
+ { mode: 'docline', body: [{ $raw: '$' }, '\n\n'] },
37
+
38
+ // An object declaration gets a member table; anything else gets a
39
+ // one-line type statement.
40
+ {
41
+ match: isKind('object'), mode: 'shape', priority: 2,
42
+ body: [
43
+ '| Member | Type | Required |\n| --- | --- | --- |\n',
44
+ [{ $apply: ['$.members[*]', 'row'] }],
45
+ '\n',
46
+ ],
47
+ },
48
+ {
49
+ mode: 'shape', priority: 1,
50
+ body: ['Type: `', [{ $apply: ['$', 'type'] }], '`\n\n'],
51
+ },
52
+ {
53
+ mode: 'row',
54
+ body: ['| `', { $raw: '$.name' }, '` | `', [{ $apply: ['$.type', 'type'] }], '` | ',
55
+ [{ $apply: ['$.required', 'yesno'] }], ' |\n'],
56
+ },
57
+ { mode: 'yesno', body: [{ $if: ['$', 'yes', 'no'] }] },
58
+
59
+ // --- the same type vocabulary, printed as prose ---------------------
60
+ { match: isKind('primitive'), mode: 'type', body: [{ $raw: '$.primitive' }] },
61
+ { match: isKind('ref'), mode: 'type', body: [{ $raw: '$.ref' }] },
62
+ { match: isKind('unknown'), mode: 'type', body: ['any'] },
63
+ { match: isKind('never'), mode: 'type', body: ['never'] },
64
+ { match: isKind('literal'), mode: 'type', body: [{ $json: '$.value' }] },
65
+ { match: isKind('array'), mode: 'type', body: ['array of ', [{ $apply: ['$.items', 'type'] }]] },
66
+ { match: isKind('record'), mode: 'type', body: ['map of ', [{ $apply: ['$.value', 'type'] }]] },
67
+ { match: isKind('optional'), mode: 'type', body: ['optional ', [{ $apply: ['$.item', 'type'] }]] },
68
+ { match: isKind('object'), mode: 'type', body: ['object'] },
69
+ { match: isKind('tuple'), mode: 'type', body: ['tuple'] },
70
+ {
71
+ match: isKind('union'), mode: 'type',
72
+ body: [[{ $apply: ['$.options[0]', 'type'] }], [{ $apply: ['$.options[1:]', 'or'] }]],
73
+ },
74
+ {
75
+ match: isKind('intersection'), mode: 'type',
76
+ body: [[{ $apply: ['$.parts[0]', 'type'] }], [{ $apply: ['$.parts[1:]', 'and'] }]],
77
+ },
78
+ { mode: 'or', body: [' or ', [{ $apply: ['$', 'type'] }]] },
79
+ { mode: 'and', body: [' and ', [{ $apply: ['$', 'type'] }]] },
80
+ ],
81
+ };
82
+
83
+ const compiled = compileJtltStylesheet(MARKDOWN_STYLESHEET,
84
+ { compileTypeTest: createTypeTestCompiler() });
85
+
86
+ /**
87
+ * Render a type model as Markdown reference documentation.
88
+ * @param {import('./model.js').EmitModel} model - A type model from {@link compileEmitModel}
89
+ * @returns {string} Markdown
90
+ */
91
+ export function renderMarkdown(model) {
92
+ return compiled(model);
93
+ }
94
+
95
+ /**
96
+ * Compile a JSON Schema straight to Markdown reference documentation.
97
+ * @param {object|boolean} schema - The schema to document
98
+ * @param {import('./model.js').EmitModelOptions} [options] - Model options
99
+ * @returns {string} Markdown
100
+ */
101
+ export function emitMarkdown(schema, options = {}) {
102
+ return renderMarkdown(compileEmitModel(schema, options));
103
+ }
104
+
105
+ //#endregion