@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
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
//#region the TypeScript emitter
|
|
2
|
+
// Stage two of `@jarenjs/emit`: a type model in, a `.d.ts` out.
|
|
3
|
+
//
|
|
4
|
+
// The emitter is a JTLT stylesheet, not JavaScript string concatenation, and
|
|
5
|
+
// that is the whole architectural point. The model dispatches by `kind` — the
|
|
6
|
+
// same vocabulary idiom the website's `ui` rules use — so adding a target
|
|
7
|
+
// language means writing rules, not forking a printer. If this file had to
|
|
8
|
+
// know anything the Markdown emitter also needs, the model would be wrong.
|
|
9
|
+
//
|
|
10
|
+
// Nested object types print inline (`{ a: string; b: number }`) while
|
|
11
|
+
// declarations print multi-line. That is a deliberate formatting choice, not
|
|
12
|
+
// a limitation: a text template has no indentation context to thread, and
|
|
13
|
+
// inline nested objects are what a human writes for shallow shapes anyway.
|
|
14
|
+
|
|
15
|
+
import { compileJtltStylesheet } from '@jarenjs/json/jtlt';
|
|
16
|
+
import { createTypeTestCompiler } from '@jarenjs/validate/query';
|
|
17
|
+
|
|
18
|
+
import { compileEmitModel } from './model.js';
|
|
19
|
+
|
|
20
|
+
/** Match a model node by its `kind`. A SCHEMA match, not a path match:
|
|
21
|
+
* `$apply` on the current node dispatches location-less, so a path match
|
|
22
|
+
* would silently never fire for a re-dispatched node. Shape is what these
|
|
23
|
+
* rules mean anyway. */
|
|
24
|
+
const isKind = (kind) => ({
|
|
25
|
+
schema: { type: 'object', properties: { kind: { const: kind } }, required: ['kind'] },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The stylesheet. Every rule dispatches on a node's `kind`, so the model's
|
|
30
|
+
* vocabulary is the only coupling between the two stages.
|
|
31
|
+
*/
|
|
32
|
+
export const TYPESCRIPT_STYLESHEET = {
|
|
33
|
+
$jtlt: '0.1',
|
|
34
|
+
output: 'text',
|
|
35
|
+
rules: [
|
|
36
|
+
// The whole model is the document, so declarations are reachable as
|
|
37
|
+
// children of `$.declarations`. A rule matching the dispatched node as
|
|
38
|
+
// the ROOT would never fire: `$..[?...]` selects children, not the root.
|
|
39
|
+
{ match: '$', body: [[{ $apply: ['$.declarations[*]', 'decl'] }]] },
|
|
40
|
+
|
|
41
|
+
// --- declarations -------------------------------------------------
|
|
42
|
+
// An object declaration becomes an interface: it is what a reader
|
|
43
|
+
// expects and what extends and merges cleanly. Everything else is a
|
|
44
|
+
// type alias.
|
|
45
|
+
{
|
|
46
|
+
match: { schema: { type: 'object', properties: { kind: { const: 'declaration' }, type: { properties: { kind: { const: 'object' } } } }, required: ['kind', 'type'] } },
|
|
47
|
+
mode: 'decl', priority: 2,
|
|
48
|
+
body: [
|
|
49
|
+
[{ $apply: ['$.doc', 'docblock'] }],
|
|
50
|
+
'export interface ', { $raw: '$.name' }, ' ',
|
|
51
|
+
[{ $apply: ['$.type', 'body'] }],
|
|
52
|
+
'\n',
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
match: isKind('declaration'), mode: 'decl', priority: 1,
|
|
57
|
+
body: [
|
|
58
|
+
[{ $apply: ['$.doc', 'docblock'] }],
|
|
59
|
+
'export type ', { $raw: '$.name' }, ' = ',
|
|
60
|
+
[{ $apply: ['$.type', 'type'] }],
|
|
61
|
+
';\n\n',
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
// --- an object printed as an interface body (multi-line) ----------
|
|
66
|
+
{
|
|
67
|
+
mode: 'body',
|
|
68
|
+
body: [
|
|
69
|
+
'{\n',
|
|
70
|
+
[{ $apply: ['$.members[*]', 'member'] }],
|
|
71
|
+
[{ $apply: ['$.index', 'index'] }],
|
|
72
|
+
'}\n\n',
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
mode: 'member',
|
|
77
|
+
body: [
|
|
78
|
+
[{ $apply: ['$.doc', 'memberdocblock'] }],
|
|
79
|
+
' ', { $raw: '$.name' },
|
|
80
|
+
{ $if: [{ $not: '$.required' }, '?'] },
|
|
81
|
+
': ',
|
|
82
|
+
[{ $apply: ['$.type', 'type'] }],
|
|
83
|
+
';\n',
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
{ mode: 'index', body: [' [key: string]: ', [{ $apply: ['$', 'type'] }], ';\n'] },
|
|
87
|
+
|
|
88
|
+
// --- type references ----------------------------------------------
|
|
89
|
+
{ match: isKind('primitive'), mode: 'type', body: [{ $raw: '$.primitive' }] },
|
|
90
|
+
{ match: isKind('ref'), mode: 'type', body: [{ $raw: '$.ref' }] },
|
|
91
|
+
{ match: isKind('unknown'), mode: 'type', body: ['unknown'] },
|
|
92
|
+
{ match: isKind('never'), mode: 'type', body: ['never'] },
|
|
93
|
+
{ match: isKind('literal'), mode: 'type', body: [{ $json: '$.value' }] },
|
|
94
|
+
{
|
|
95
|
+
match: isKind('array'), mode: 'type',
|
|
96
|
+
body: ['Array<', [{ $apply: ['$.items', 'type'] }], '>'],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
match: isKind('record'), mode: 'type',
|
|
100
|
+
body: ['Record<string, ', [{ $apply: ['$.value', 'type'] }], '>'],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
match: isKind('tuple'), mode: 'type',
|
|
104
|
+
body: ['[',
|
|
105
|
+
[{ $apply: ['$.items[0]', 'type'] }],
|
|
106
|
+
[{ $apply: ['$.items[1:]', 'comma'] }],
|
|
107
|
+
[{ $apply: ['$.rest', 'tuplerest'] }],
|
|
108
|
+
']'],
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
match: isKind('object'), mode: 'type',
|
|
112
|
+
body: ['{ ', [{ $apply: ['$.members[*]', 'inlinemember'] }],
|
|
113
|
+
[{ $apply: ['$.index', 'inlineindex'] }],
|
|
114
|
+
'}'],
|
|
115
|
+
},
|
|
116
|
+
// Separated lists without a position variable: dispatch the first item
|
|
117
|
+
// bare and the remainder through a mode that prints its own separator.
|
|
118
|
+
// `[0]` and `[1:]` are ordinary RFC 9535 selectors, so the split costs
|
|
119
|
+
// nothing and needs no help from the model.
|
|
120
|
+
{
|
|
121
|
+
match: isKind('union'), mode: 'type',
|
|
122
|
+
body: [[{ $apply: ['$.options[0]', 'type'] }],
|
|
123
|
+
[{ $apply: ['$.options[1:]', 'pipe'] }]],
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
match: isKind('intersection'), mode: 'type',
|
|
127
|
+
body: [[{ $apply: ['$.parts[0]', 'term'] }],
|
|
128
|
+
[{ $apply: ['$.parts[1:]', 'amp'] }]],
|
|
129
|
+
},
|
|
130
|
+
// An intersection PART that is itself a union has to be parenthesized:
|
|
131
|
+
// `&` binds tighter than `|`, so `(A|B) & (C|D)` printed bare becomes
|
|
132
|
+
// `A | B & C | D`, a different and wider type.
|
|
133
|
+
{
|
|
134
|
+
match: isKind('union'), mode: 'term',
|
|
135
|
+
body: ['(', [{ $apply: ['$', 'type'] }], ')'],
|
|
136
|
+
},
|
|
137
|
+
{ mode: 'term', body: [[{ $apply: ['$', 'type'] }]] },
|
|
138
|
+
// An OPTIONAL tuple element: the postfix `?` binds to a whole element,
|
|
139
|
+
// so a union or intersection inside one is parenthesized first.
|
|
140
|
+
{
|
|
141
|
+
match: isKind('optional'), mode: 'type',
|
|
142
|
+
body: [[{ $apply: ['$.item', 'opt'] }], '?'],
|
|
143
|
+
},
|
|
144
|
+
{ match: isKind('union'), mode: 'opt', body: ['(', [{ $apply: ['$', 'type'] }], ')'] },
|
|
145
|
+
{ match: isKind('intersection'), mode: 'opt', body: ['(', [{ $apply: ['$', 'type'] }], ')'] },
|
|
146
|
+
{ mode: 'opt', body: [[{ $apply: ['$', 'type'] }]] },
|
|
147
|
+
{ mode: 'pipe', body: [' | ', [{ $apply: ['$', 'type'] }]] },
|
|
148
|
+
{ mode: 'amp', body: [' & ', [{ $apply: ['$', 'term'] }]] },
|
|
149
|
+
{ mode: 'comma', body: [', ', [{ $apply: ['$', 'type'] }]] },
|
|
150
|
+
{ mode: 'tuplerest', body: [', ...Array<', [{ $apply: ['$', 'type'] }], '>'] },
|
|
151
|
+
{ mode: 'inlineindex', body: ['[key: string]: ', [{ $apply: ['$', 'type'] }], '; '] },
|
|
152
|
+
{
|
|
153
|
+
mode: 'inlinemember',
|
|
154
|
+
body: [{ $raw: '$.name' }, { $if: [{ $not: '$.required' }, '?'] }, ': ',
|
|
155
|
+
[{ $apply: ['$.type', 'type'] }], '; '],
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
// --- documentation ------------------------------------------------
|
|
159
|
+
// One comment block per node, opened by the first line and closed by it
|
|
160
|
+
// too. An empty `doc` array dispatches nothing, so a node with nothing to
|
|
161
|
+
// say emits no comment at all — no conditional required.
|
|
162
|
+
{
|
|
163
|
+
mode: 'docblock',
|
|
164
|
+
body: [
|
|
165
|
+
[{ $apply: ['$[0]', 'docopen'] }],
|
|
166
|
+
[{ $apply: ['$[1:]', 'docline'] }],
|
|
167
|
+
[{ $apply: ['$[0]', 'docclose'] }],
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
{ mode: 'docopen', body: ['/**\n * ', { $raw: '$' }, '\n'] },
|
|
171
|
+
{ mode: 'docline', body: [' * ', { $raw: '$' }, '\n'] },
|
|
172
|
+
{ mode: 'docclose', body: [' */\n'] },
|
|
173
|
+
{
|
|
174
|
+
mode: 'memberdocblock',
|
|
175
|
+
body: [
|
|
176
|
+
[{ $apply: ['$[0]', 'memberdocopen'] }],
|
|
177
|
+
[{ $apply: ['$[1:]', 'memberdocline'] }],
|
|
178
|
+
[{ $apply: ['$[0]', 'memberdocclose'] }],
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
{ mode: 'memberdocopen', body: [' /**\n * ', { $raw: '$' }, '\n'] },
|
|
182
|
+
{ mode: 'memberdocline', body: [' * ', { $raw: '$' }, '\n'] },
|
|
183
|
+
{ mode: 'memberdocclose', body: [' */\n'] },
|
|
184
|
+
],
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const compiled = compileJtltStylesheet(TYPESCRIPT_STYLESHEET,
|
|
188
|
+
{ compileTypeTest: createTypeTestCompiler() });
|
|
189
|
+
|
|
190
|
+
/** @typedef {import('./model.js').EmitModel} EmitModel */
|
|
191
|
+
/** @typedef {import('./model.js').EmitModelOptions} EmitModelOptions */
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Rendering options, shared by both entry points.
|
|
195
|
+
* @typedef {object} RenderTypeScriptOptions
|
|
196
|
+
* @property {boolean} [banner=true] - Emit the do-not-edit header
|
|
197
|
+
*/
|
|
198
|
+
|
|
199
|
+
/** A property name TypeScript accepts without quotes. */
|
|
200
|
+
const BARE_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Project a model into printable TypeScript spellings.
|
|
204
|
+
*
|
|
205
|
+
* The model holds the schema's own vocabulary: a member's `name` is the JSON
|
|
206
|
+
* property name and a doc line is the schema's own prose. Neither is
|
|
207
|
+
* constrained to what TypeScript's grammar accepts, and printing them raw was
|
|
208
|
+
* emitting source that does not parse — `my-key: string` is a subtraction, and
|
|
209
|
+
* a `description` containing a comment terminator closes the JSDoc block early
|
|
210
|
+
* and spills the rest of the schema into code position.
|
|
211
|
+
*
|
|
212
|
+
* Doing it here rather than in the model keeps the model language-neutral:
|
|
213
|
+
* `markdown.js` wants the unquoted name, and a future emitter for another
|
|
214
|
+
* language will want its own spelling.
|
|
215
|
+
* @param {any} node
|
|
216
|
+
* @returns {any} the node with names quoted and doc text made comment-safe
|
|
217
|
+
*/
|
|
218
|
+
function printable(node) {
|
|
219
|
+
if (Array.isArray(node)) return node.map(printable);
|
|
220
|
+
if (node === null || typeof node !== 'object') return node;
|
|
221
|
+
// A literal node carries the schema's own JSON, not model structure. Walking
|
|
222
|
+
// into it rewrote any data field that happened to be called `name` or `doc`,
|
|
223
|
+
// so a `const` of `{ name: 'a-b' }` was emitted as `{ name: '"a-b"' }` — the
|
|
224
|
+
// generator changing the value it was asked to reproduce. Key names are not
|
|
225
|
+
// a safe way to tell structure from data; the `kind` tag is.
|
|
226
|
+
if (node.kind === 'literal') return node;
|
|
227
|
+
/** @type {any} */
|
|
228
|
+
const out = {};
|
|
229
|
+
for (const [key, value] of Object.entries(node)) {
|
|
230
|
+
if (key === 'name' && typeof value === 'string' && !BARE_NAME.test(value))
|
|
231
|
+
out[key] = JSON.stringify(value);
|
|
232
|
+
else if (key === 'doc' && Array.isArray(value))
|
|
233
|
+
out[key] = value.map((line) => typeof line === 'string'
|
|
234
|
+
? line.replaceAll('*/', '*\\/') : line);
|
|
235
|
+
else out[key] = printable(value);
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Render a type model as TypeScript declarations.
|
|
242
|
+
* @param {EmitModel} model - A type model from `compileEmitModel`
|
|
243
|
+
* @param {RenderTypeScriptOptions} [options]
|
|
244
|
+
* @returns {string} TypeScript source
|
|
245
|
+
*/
|
|
246
|
+
export function renderTypeScript(model, options = {}) {
|
|
247
|
+
const banner = options.banner === false
|
|
248
|
+
? ''
|
|
249
|
+
// A newline in `source` would end the line comment and put whatever
|
|
250
|
+
// follows into code position — a generator that can be made to write
|
|
251
|
+
// arbitrary source by the name of its input file. Collapse the whitespace
|
|
252
|
+
// that could close the comment.
|
|
253
|
+
: `// Generated by @jarenjs/emit${model.source
|
|
254
|
+
? ` from ${String(model.source).replace(/[\r\n\u2028\u2029]+/g, ' ')}` : ''}.\n`
|
|
255
|
+
+ '// Do not edit: regenerate instead.\n\n';
|
|
256
|
+
return banner + compiled(printable(model));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Compile a JSON Schema straight to TypeScript declarations — the one-call
|
|
261
|
+
* form of {@link compileEmitModel} followed by {@link renderTypeScript}.
|
|
262
|
+
* @param {object|boolean} schema - The schema to emit
|
|
263
|
+
* @param {EmitModelOptions & RenderTypeScriptOptions} [options] - Model and
|
|
264
|
+
* rendering options — `normalize` and `variantSuffix` included, so the
|
|
265
|
+
* programmatic route can do everything the CLI flags can
|
|
266
|
+
* @returns {string} TypeScript source
|
|
267
|
+
* @example
|
|
268
|
+
* emitTypeScript({ type: 'object', properties: { id: { type: 'string' } } },
|
|
269
|
+
* { name: 'User' });
|
|
270
|
+
* // export interface User { id?: string; }
|
|
271
|
+
*/
|
|
272
|
+
export function emitTypeScript(schema, options = {}) {
|
|
273
|
+
return renderTypeScript(compileEmitModel(schema, options), options);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
//#endregion
|