jtlt 0.17.0 → 0.19.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,410 @@
1
+ import {isValidJamilih} from 'jamilih';
2
+
3
+ /**
4
+ * Compiles and runs the declarative (jamilih-shaped) node format used by
5
+ * idb-manager route overrides — see `~/idb-manager/ROUTE-OVERRIDES-PLAN.md`
6
+ * §3 and `~/idb-manager/JTLT-JSON-TEMPLATES-PROPOSAL.md`.
7
+ *
8
+ * A node is one of:
9
+ * - a string — a text node;
10
+ * - `[name, attrs?, children?]` — a jamilih-shaped element node (`attrs` is
11
+ * omitted when there are no attributes, in which case the second item is
12
+ * the children array directly);
13
+ * - `[{$op: ...}, ...]` — an operation node (an object as the first item).
14
+ *
15
+ * Every operation-node key is `$`-prefixed (jamilih's own validator requires
16
+ * this of any first-position plain object). See the vocabulary table in
17
+ * ROUTE-OVERRIDES-PLAN.md §3.2 for the full mapping to jtlt context calls,
18
+ * including the `$text`/`$jtltText` and `$mode`/`$jtltMode` namespacing
19
+ * rules and `$indexedDB`'s optional `$as`.
20
+ *
21
+ * `$indexedDB`/`$renderDefault` (the only async operations) may appear
22
+ * anywhere a node is allowed — nested inside an element's children, or a
23
+ * `$if`/`$forEach` body, included — because every node here is run through
24
+ * an `async` callback, and `element()`/`if()`/`choose()`/`forEach()` (in
25
+ * every joining transformer and `JSONPathTransformerContext`) duck-type
26
+ * their callback's return value: a synchronous callback keeps them
27
+ * synchronous, but one returning a `Promise` (an async function that
28
+ * awaited something) makes the call itself return a `Promise` that settles
29
+ * once the callback's work is done, keeping output correctly ordered
30
+ * either way.
31
+ */
32
+
33
+ /**
34
+ * @param {unknown} x
35
+ * @returns {x is Record<string, unknown>}
36
+ */
37
+ function isPlainObject (x) {
38
+ return Boolean(x) && typeof x === 'object' && !Array.isArray(x);
39
+ }
40
+
41
+ /**
42
+ * @typedef {'text'|'element'|'operation'} NodeKind
43
+ */
44
+
45
+ /**
46
+ * @param {unknown} node
47
+ * @returns {NodeKind|null} `null` for a structurally unrecognizable node.
48
+ */
49
+ function classify (node) {
50
+ if (typeof node === 'string') {
51
+ return 'text';
52
+ }
53
+ if (!Array.isArray(node) || node.length === 0) {
54
+ return null;
55
+ }
56
+ const head = node[0];
57
+ if (typeof head === 'string') {
58
+ return 'element';
59
+ }
60
+ if (isPlainObject(head)) {
61
+ return 'operation';
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Split an element node's trailing items into `{atts, children}`. The
68
+ * second item is an attributes object, or (when there are no attributes)
69
+ * the children array directly; the optional third item is the children
70
+ * array when the second was attributes.
71
+ * @param {unknown[]} rest - Everything after the element name
72
+ * @returns {{atts: Record<string, unknown>, children: unknown[]}}
73
+ */
74
+ function splitElementRest (rest) {
75
+ const [second, third] = rest;
76
+ if (Array.isArray(second)) {
77
+ return {atts: {}, children: second};
78
+ }
79
+ if (isPlainObject(second)) {
80
+ return {atts: second, children: Array.isArray(third) ? third : []};
81
+ }
82
+ return {atts: {}, children: []};
83
+ }
84
+
85
+ /**
86
+ * Operation keys recognized on an operation node's leading object. `$mode`
87
+ * is never valid bare (jamilih hard-reserves it); `$text` is valid bare
88
+ * (jamilih's own text-node form) but not combined with `$select` (jamilih
89
+ * rejects the unrecognized companion) — that combination needs `$jtltText`.
90
+ * @type {ReadonlySet<string>}
91
+ */
92
+ const RECOGNIZED_OP_KEYS = new Set([
93
+ '$text', '$jtltText', '$string', '$valueOf', '$applyTemplates', '$if',
94
+ '$forEach', '$variable', '$indexedDB', '$renderDefault'
95
+ ]);
96
+
97
+ /**
98
+ * Validate one operation node's leading object against the vocabulary
99
+ * rules that don't require a live jtlt context (safe to run non-executing).
100
+ * @param {Record<string, unknown>} head
101
+ * @returns {string|null} An error message, or `null` if valid.
102
+ */
103
+ function validateOperationHead (head) {
104
+ const keys = Object.keys(head);
105
+ if (Object.hasOwn(head, '$mode')) {
106
+ return '`$mode` is reserved by jamilih and is never valid here; use ' +
107
+ '`$jtltMode` instead.';
108
+ }
109
+ const opKeys = keys.filter((k) => RECOGNIZED_OP_KEYS.has(k));
110
+ if (opKeys.length === 0) {
111
+ const badKey = keys.find((k) => !k.startsWith('$')) ?? keys[0];
112
+ return `Unrecognized operation-node key \`${badKey}\`.`;
113
+ }
114
+ if (Object.hasOwn(head, '$text') && keys.length > 1) {
115
+ return '`$text` combined with any other key (e.g. `$select`) is not ' +
116
+ 'valid jamilih (jamilih rejects the unrecognized companion alongside ' +
117
+ 'its own reserved `$text`); use `$jtltText` for the combined form.';
118
+ }
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Recursively validate a node (and its descendants), collecting every
124
+ * problem found rather than stopping at the first.
125
+ * @param {unknown} node
126
+ * @param {'json'|'javascript'} format
127
+ * @param {string[]} errors - Populated in place
128
+ * @returns {void}
129
+ */
130
+ function validateNode (node, format, errors) {
131
+ const kind = classify(node);
132
+ if (kind === 'text') {
133
+ return;
134
+ }
135
+ if (kind === null) {
136
+ errors.push(`Not a valid declarative node: ${JSON.stringify(node)}`);
137
+ return;
138
+ }
139
+ const arr = /** @type {unknown[]} */ (node);
140
+ if (kind === 'element') {
141
+ // Only the element's own name+attributes are checked against jamilih:
142
+ // jamilih validates a structure's *whole* nested tree at once, and its
143
+ // tolerance for unrecognized `$`-prefixed keys (branch 4 of its own
144
+ // `struct[0]`-is-a-plain-object rule) applies only to a structure
145
+ // passed to it as a complete top-level call — not to something nested
146
+ // inside real children — so an operation node (which uses jtlt-only
147
+ // keys jamilih doesn't recognize) would wrongly fail jamilih's own
148
+ // validation if it were included here. Children are validated by our
149
+ // own node-kind dispatch below instead, recursively, each as its own
150
+ // notional top level.
151
+ const [name, ...rest] = /** @type {[string, ...unknown[]]} */ (arr);
152
+ const {atts, children} = splitElementRest(rest);
153
+ if (!isValidJamilih([name, atts], {format})) {
154
+ errors.push(
155
+ `Not valid jamilih (format: "${format}"): ` +
156
+ JSON.stringify([name, atts])
157
+ );
158
+ return;
159
+ }
160
+ for (const child of children) {
161
+ validateNode(child, format, errors);
162
+ }
163
+ return;
164
+ }
165
+ // kind === 'operation'
166
+ const head = /** @type {Record<string, unknown>} */ (arr[0]);
167
+ const headError = validateOperationHead(head);
168
+ if (headError) {
169
+ errors.push(headError);
170
+ return;
171
+ }
172
+ if (Object.hasOwn(head, '$if')) {
173
+ const [, thenNodes, elseNodes] = arr;
174
+ if (!Array.isArray(thenNodes)) {
175
+ errors.push(
176
+ '`$if` requires a then-branch node array as its second item.'
177
+ );
178
+ return;
179
+ }
180
+ for (const child of thenNodes) {
181
+ validateNode(child, format, errors);
182
+ }
183
+ if (elseNodes !== undefined) {
184
+ if (!Array.isArray(elseNodes)) {
185
+ errors.push(
186
+ "`$if`'s else-branch, when given, must be a node array."
187
+ );
188
+ return;
189
+ }
190
+ for (const child of elseNodes) {
191
+ validateNode(child, format, errors);
192
+ }
193
+ }
194
+ return;
195
+ }
196
+ if (Object.hasOwn(head, '$forEach')) {
197
+ const [, childNodes] = arr;
198
+ if (!Array.isArray(childNodes)) {
199
+ errors.push(
200
+ '`$forEach` requires a children node array as its second item.'
201
+ );
202
+ return;
203
+ }
204
+ for (const child of childNodes) {
205
+ validateNode(child, format, errors);
206
+ }
207
+ return;
208
+ }
209
+ if (Object.hasOwn(head, '$indexedDB')) {
210
+ // Unlike `$if`/`$forEach`, `$indexedDB`'s children are optional — a
211
+ // bare prefetch (binding via `$as` for later use, or discarding the
212
+ // rows entirely) with no immediate consumer is a legitimate leaf use.
213
+ const [, childNodes] = arr;
214
+ if (childNodes !== undefined && !Array.isArray(childNodes)) {
215
+ errors.push(
216
+ "`$indexedDB`'s children, when given, must be a node array."
217
+ );
218
+ return;
219
+ }
220
+ const idbChildren = childNodes || [];
221
+ for (const child of idbChildren) {
222
+ validateNode(child, format, errors);
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Non-executing structural check for a declarative template. Unlike
229
+ * `compileJSONTemplate`, this never throws — it reports every problem it
230
+ * finds, for a "validate before save" editor workflow.
231
+ * @param {unknown[]} nodes
232
+ * @param {{format?: 'json'|'javascript'}} [options]
233
+ * @returns {{valid: boolean, errors: string[]}}
234
+ */
235
+ export function validateJSONTemplate (nodes, {format = 'json'} = {}) {
236
+ if (!Array.isArray(nodes)) {
237
+ return {
238
+ valid: false,
239
+ errors: ['A declarative template must be an array of nodes.']
240
+ };
241
+ }
242
+ /** @type {string[]} */
243
+ const errors = [];
244
+ for (const node of nodes) {
245
+ validateNode(node, format, errors);
246
+ }
247
+ return {valid: errors.length === 0, errors};
248
+ }
249
+
250
+ /**
251
+ * @param {unknown} x
252
+ * @returns {x is unknown[]}
253
+ */
254
+ export function isJSONTemplateNodeArray (x) {
255
+ return Array.isArray(x);
256
+ }
257
+
258
+ /**
259
+ * Run one node against a live jtlt context.
260
+ * @param {unknown} node
261
+ * @param {any} ctx - The bound `this` inside the compiled template
262
+ * @returns {Promise<void>}
263
+ */
264
+ async function runNode (node, ctx) {
265
+ const kind = classify(node);
266
+ if (kind === 'text') {
267
+ ctx.text(node);
268
+ return;
269
+ }
270
+ const arr = /** @type {unknown[]} */ (node);
271
+ if (kind === 'element') {
272
+ const [name, ...rest] = /** @type {[string, ...unknown[]]} */ (arr);
273
+ const {atts, children} = splitElementRest(rest);
274
+ await ctx.element(name, atts, [], async () => {
275
+ await runNodes(children, ctx);
276
+ });
277
+ return;
278
+ }
279
+ const head = /** @type {Record<string, unknown>} */ (arr[0]);
280
+ await runOperation(head, arr.slice(1), ctx);
281
+ }
282
+
283
+ /**
284
+ * @param {unknown[]} nodes
285
+ * @param {any} ctx
286
+ * @returns {Promise<void>}
287
+ */
288
+ async function runNodes (nodes, ctx) {
289
+ for (const node of nodes) {
290
+ // eslint-disable-next-line no-await-in-loop -- Sequential output order
291
+ await runNode(node, ctx);
292
+ }
293
+ }
294
+
295
+ /**
296
+ * @param {Record<string, unknown>} head
297
+ * @param {unknown[]} rest
298
+ * @param {any} ctx
299
+ * @returns {Promise<void>}
300
+ */
301
+ async function runOperation (head, rest, ctx) {
302
+ if (Object.hasOwn(head, '$text')) {
303
+ ctx.text(head.$text);
304
+ return;
305
+ }
306
+ if (Object.hasOwn(head, '$jtltText')) {
307
+ if (Object.hasOwn(head, '$select')) {
308
+ ctx.valueOf(/** @type {string} */ (head.$select));
309
+ } else {
310
+ ctx.text(head.$jtltText);
311
+ }
312
+ return;
313
+ }
314
+ if (Object.hasOwn(head, '$string')) {
315
+ if (Object.hasOwn(head, '$select')) {
316
+ ctx.string(String(ctx.get(head.$select, false)));
317
+ } else {
318
+ ctx.string(head.$string);
319
+ }
320
+ return;
321
+ }
322
+ if (Object.hasOwn(head, '$valueOf')) {
323
+ ctx.valueOf(head.$valueOf);
324
+ return;
325
+ }
326
+ if (Object.hasOwn(head, '$applyTemplates')) {
327
+ await ctx.applyTemplates(head.$applyTemplates, head.$jtltMode, head.$sort);
328
+ return;
329
+ }
330
+ if (Object.hasOwn(head, '$variable')) {
331
+ ctx.variable(head.$variable, {select: head.$select});
332
+ return;
333
+ }
334
+ if (Object.hasOwn(head, '$if')) {
335
+ const [thenNodes, elseNodes] = rest;
336
+ if (elseNodes) {
337
+ await ctx.choose(
338
+ head.$if,
339
+ async () => {
340
+ await runNodes(/** @type {unknown[]} */ (thenNodes), ctx);
341
+ },
342
+ async () => {
343
+ await runNodes(/** @type {unknown[]} */ (elseNodes), ctx);
344
+ }
345
+ );
346
+ } else {
347
+ await ctx.if(head.$if, async () => {
348
+ await runNodes(/** @type {unknown[]} */ (thenNodes), ctx);
349
+ });
350
+ }
351
+ return;
352
+ }
353
+ if (Object.hasOwn(head, '$forEach')) {
354
+ const [childNodes] = rest;
355
+ await ctx.forEach(
356
+ head.$forEach,
357
+ async () => {
358
+ await runNodes(/** @type {unknown[]} */ (childNodes), ctx);
359
+ },
360
+ head.$sort
361
+ );
362
+ return;
363
+ }
364
+ if (Object.hasOwn(head, '$renderDefault')) {
365
+ await ctx.renderDefault();
366
+ return;
367
+ }
368
+ // Only $indexedDB is left (validateOperationHead already rejects
369
+ // anything else, and every other recognized key returned above).
370
+ const idb = /** @type {{db: string, store: string, options?: unknown}} */
371
+ (head.$indexedDB);
372
+ const rows = await ctx.indexedDB(idb.db, idb.store, idb.options);
373
+ // Validation already confirmed this is an array, when given at all.
374
+ const childNodes = /** @type {unknown[]} */ (rest[0] ?? []);
375
+ if (Object.hasOwn(head, '$as')) {
376
+ ctx.variable(head.$as, {value: rows});
377
+ await runNodes(childNodes, ctx);
378
+ } else {
379
+ const prevContext = ctx._contextObj;
380
+ ctx._contextObj = rows;
381
+ try {
382
+ await runNodes(childNodes, ctx);
383
+ } finally {
384
+ ctx._contextObj = prevContext;
385
+ }
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Compile a declarative (jamilih-shaped) node array into a jtlt
391
+ * `TemplateFunction`. Validates the whole tree up front (see
392
+ * `validateJSONTemplate`) and throws on the first problem, rather than
393
+ * failing partway through execution.
394
+ * @param {unknown[]} nodes
395
+ * @param {{format?: 'json'|'javascript'}} [options]
396
+ * @returns {(
397
+ * this: any, value: unknown, cfg?: {mode?: string}
398
+ * ) => Promise<void>}
399
+ */
400
+ export function compileJSONTemplate (nodes, {format = 'json'} = {}) {
401
+ const {valid, errors} = validateJSONTemplate(nodes, {format});
402
+ if (!valid) {
403
+ throw new TypeError(
404
+ `Invalid declarative template:\n${errors.join('\n')}`
405
+ );
406
+ }
407
+ return async function () {
408
+ await runNodes(nodes, this);
409
+ };
410
+ }