@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/ARCHITECTURE.md +97 -0
- package/README.md +300 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/markdown.d.ts +102 -0
- package/dist/types/model.d.ts +170 -0
- package/dist/types/typescript.d.ts +156 -0
- package/docs/EMIT-FORMAT.md +273 -0
- package/package.json +75 -0
- package/schemas/jaren-emit-model.schema.json +171 -0
- package/src/cli.js +191 -0
- package/src/index.js +21 -0
- package/src/markdown.js +105 -0
- package/src/model.js +993 -0
- package/src/typescript.js +276 -0
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
|
package/src/markdown.js
ADDED
|
@@ -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
|