@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.
@@ -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