jtlt 0.18.0 → 0.20.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/index.js CHANGED
@@ -40,7 +40,16 @@ export const setWindow = (win) => {
40
40
  * @property {string} [name] - Optional name for calling via callTemplate
41
41
  * @property {string} [mode] - Optional mode for template matching
42
42
  * @property {number} [priority] - Priority for template selection
43
- * @property {TemplateFunction<T, U, TCtx>} template - Template function
43
+ * @property {'json'|'javascript'} [format] - For an Array `template` only:
44
+ * the jamilih validation strictness `compileJSONTemplate` applies
45
+ * (`isValidJamilih`'s own `format` option) — `'json'` (default) rejects
46
+ * live values (functions, DOM nodes, …) embedded in the structure,
47
+ * `'javascript'` allows them. Ignored for a function `template`. Falls
48
+ * back to `config.defaultTemplateFormat`, then `'json'`, when omitted.
49
+ * @property {TemplateFunction<T, U, TCtx> | JSONTemplateNode[]} template -
50
+ * Template function, or a declarative (jamilih-shaped) node array
51
+ * compiled via `compileJSONTemplate` — detected by `Array.isArray`, since
52
+ * a `TemplateFunction` is never an array.
44
53
  */
45
54
 
46
55
  /**
@@ -80,7 +89,78 @@ export const setWindow = (win) => {
80
89
  * @template {"json"|"string"|"dom"} T
81
90
  * @typedef {JSONPathTemplateObject<T> | [string, TemplateFunction<T, "json",
82
91
  * import('./JSONPathTransformerContext.js').default
83
- * >]} JSONPathTemplateArray
92
+ * > | JSONTemplateNode[]]} JSONPathTemplateArray
93
+ */
94
+
95
+ /**
96
+ * A jamilih-shaped element node — see
97
+ * `~/idb-manager/ROUTE-OVERRIDES-PLAN.md` §3.1. An attributes object may be
98
+ * omitted when there are no attributes, in which case the second item is
99
+ * the children array directly. Attribute *values* are left `unknown` rather
100
+ * than modeled precisely: they range from HTML-attribute primitives to
101
+ * jamilih's own richer magic-key values (`$on` handler arrays, etc.), and
102
+ * that shape is validated at runtime by `isValidJamilih`/`validateJamilih`,
103
+ * not statically here.
104
+ * @typedef {[string] |
105
+ * [string, Record<string, unknown>] |
106
+ * [string, JSONTemplateNode[]] |
107
+ * [string, Record<string, unknown>, JSONTemplateNode[]]
108
+ * } JSONElementNode
109
+ */
110
+
111
+ /**
112
+ * The declarative operation vocabulary (see
113
+ * `~/idb-manager/ROUTE-OVERRIDES-PLAN.md` §3.2). Every key on the leading
114
+ * object is `$`-prefixed — jamilih's own validator requires this of any
115
+ * first-position plain object. Most keys use a bare `$`-prefix. `$jtltMode`
116
+ * is namespaced unconditionally: jamilih hard-rejects `$mode` even bare
117
+ * (`RESERVED_OPTION`). `$text` / `$jtltText` are the general form and a
118
+ * jamilih-native-compatible shorthand for the same thing, not two distinct
119
+ * operations: `$jtltText` (never seen by jamilih's own `$text` handling, so
120
+ * an unrecognized `$select` alongside it is just ordinary dialect data) is
121
+ * the canonical form and works with or without `$select`; a **bare**
122
+ * `{$text: value}` (literal only, no `$select`) is jamilih's own native
123
+ * text-node form, equivalent to `{$jtltText: value}`, and validates as-is —
124
+ * but `{$text: value, $select}` does not, because jamilih rejects the
125
+ * unrecognized `$select` sitting next to its own reserved `$text`
126
+ * (`UNKNOWN_MAGIC_PROPERTY`); that combined form needs `$jtltText` instead.
127
+ * See §3.5. `$indexedDB`'s children are optional (unlike `$if`/`$forEach`,
128
+ * which both require theirs): a bare prefetch — binding via `$as` for later
129
+ * use, or simply discarding the rows — is a legitimate leaf use.
130
+ * @typedef {[{$text: unknown}] |
131
+ * [{$jtltText: unknown, $select?: string}] |
132
+ * [{$string: unknown, $select?: string}] |
133
+ * [{$valueOf: string}] |
134
+ * [{$applyTemplates: string, $jtltMode?: string, $sort?: unknown}] |
135
+ * [{$if: string}, JSONTemplateNode[]] |
136
+ * [{$if: string}, JSONTemplateNode[], JSONTemplateNode[]] |
137
+ * [{$forEach: string, $sort?: unknown}, JSONTemplateNode[]] |
138
+ * [{$variable: string, $select: string}] |
139
+ * [{
140
+ * $indexedDB: {
141
+ * db: string, store: string,
142
+ * options?: import('./indexedDB.js').QueryOptions
143
+ * },
144
+ * $as?: string
145
+ * }] |
146
+ * [{
147
+ * $indexedDB: {
148
+ * db: string, store: string,
149
+ * options?: import('./indexedDB.js').QueryOptions
150
+ * },
151
+ * $as?: string
152
+ * }, JSONTemplateNode[]] |
153
+ * [{$renderDefault: true}]
154
+ * } JSONOperationNode
155
+ */
156
+
157
+ /**
158
+ * A declarative (jamilih-shaped) template node: a text string, an element
159
+ * node, or an operation node. Passed as a `TemplateObject.template` (or a
160
+ * `[path, JSONTemplateNode[]]` tuple's second slot) wherever a
161
+ * `TemplateFunction` is otherwise accepted, compiled via
162
+ * `compileJSONTemplate` — see `~/idb-manager/JTLT-JSON-TEMPLATES-PROPOSAL.md`.
163
+ * @typedef {string | JSONElementNode | JSONOperationNode} JSONTemplateNode
84
164
  */
85
165
 
86
166
  /**
@@ -223,6 +303,10 @@ export const setWindow = (win) => {
223
303
  * A `this.param(name, default)` declaration whose name appears here resolves
224
304
  * to this value instead of its default, and `$name` references such a
225
305
  * parameter from any template.
306
+ * @property {'json'|'javascript'} [defaultTemplateFormat] Config-wide
307
+ * default for an Array `template`'s jamilih validation strictness (see
308
+ * `TemplateObject.format`), used for any entry that doesn't specify its
309
+ * own `format`. Defaults to `'json'` when omitted here too.
226
310
  */
227
311
 
228
312
  /**
@@ -230,7 +314,7 @@ export const setWindow = (win) => {
230
314
  * @template {"json"|"string"|"dom"} [T = "json"]
231
315
  * @template {boolean|undefined} [E=false]
232
316
  * @typedef {BaseJTLTOptions<T, E> & {
233
- * templates?: JSONPathTemplateArray<T>[] |
317
+ * templates?: JSONPathTemplateArray<T>[] | JSONPathTemplateArray<T> |
234
318
  * TemplateFunction<T, "json",
235
319
  * import('./JSONPathTransformerContext.js').default<T>>,
236
320
  * template?: JSONPathTemplateObject<T> | TemplateFunction<T, "json",
@@ -288,6 +372,29 @@ export const setWindow = (win) => {
288
372
  * XPathJTLTOptions<"dom", E>} JTLTOptions
289
373
  */
290
374
 
375
+ /**
376
+ * `config.templates` (and, for symmetry, `config.template`) may be given as
377
+ * a bare single entry — a `TemplateObject` (`{path, template}`) or a
378
+ * `[path, template]` tuple — rather than always wrapped in an outer array.
379
+ * A bare tuple is distinguished from a list of entries by its own first
380
+ * item being a string: no valid entry (a plain object, or itself a tuple
381
+ * whose own first item is a path string) is ever a bare string, so this
382
+ * can't collide with a real list.
383
+ * @param {unknown} templates
384
+ * @returns {unknown[]|undefined}
385
+ */
386
+ function normalizeBareTemplatesShape (templates) {
387
+ if (!templates) {
388
+ return undefined;
389
+ }
390
+ if (Array.isArray(templates)) {
391
+ return typeof templates[0] === 'string' ? [templates] : templates;
392
+ }
393
+ // A bare `TemplateObject` (a plain object, not a function — functions are
394
+ // already routed through the `query` branch above this call).
395
+ return [templates];
396
+ }
397
+
291
398
  /**
292
399
  * High-level façade for running a JTLT transform.
293
400
  *
@@ -519,7 +626,8 @@ class JTLT {
519
626
  ])
520
627
  // eslint-disable-next-line @stylistic/max-len -- Long
521
628
  : /** @type {JSONPathTemplateObject<joiningTypes>[]|XPathTemplateObject<joiningTypes>[]} */ (
522
- cfg.templates || [cfg.template]
629
+ normalizeBareTemplatesShape(cfg.templates) ||
630
+ normalizeBareTemplatesShape(cfg.template)
523
631
  );
524
632
  this.config.errorOnEqualPriority = cfg.errorOnEqualPriority || false;
525
633
  this.config.engine ||=
@@ -859,5 +967,9 @@ export {
859
967
  export {
860
968
  default as XPathTransformer
861
969
  } from './XPathTransformer.js';
970
+ export {
971
+ compileJSONTemplate, extractReads, isJSONTemplateNodeArray,
972
+ validateJSONTemplate
973
+ } from './jsonTemplate.js';
862
974
 
863
975
  export default JTLT;
@@ -0,0 +1,527 @@
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
+ * @typedef {{db: string, store: string}} ReadTarget
229
+ */
230
+
231
+ /**
232
+ * Recursively collect the `{db, store}` targets under one node's
233
+ * `$indexedDB` operations (if any), walking every position a node can
234
+ * appear — element children, `$if` then/else, `$forEach` children, and
235
+ * another `$indexedDB` node's own children — not just the top level. Uses
236
+ * `classify()` defensively, so a structurally-invalid node (already
237
+ * reported by `validateNode`) simply contributes nothing here rather than
238
+ * throwing a second time.
239
+ * @param {unknown} node
240
+ * @param {ReadTarget[]} targets - Populated in place
241
+ * @param {string[]} errors - Populated in place
242
+ * @returns {void}
243
+ */
244
+ function collectReads (node, targets, errors) {
245
+ const kind = classify(node);
246
+ if (kind !== 'element' && kind !== 'operation') {
247
+ return;
248
+ }
249
+ const arr = /** @type {unknown[]} */ (node);
250
+ if (kind === 'element') {
251
+ const [, ...rest] = /** @type {[string, ...unknown[]]} */ (arr);
252
+ const {children} = splitElementRest(rest);
253
+ for (const child of children) {
254
+ collectReads(child, targets, errors);
255
+ }
256
+ return;
257
+ }
258
+ // kind === 'operation'
259
+ const head = /** @type {Record<string, unknown>} */ (arr[0]);
260
+ if (Object.hasOwn(head, '$if')) {
261
+ const [, thenNodes, elseNodes] = arr;
262
+ const thenChildren = /** @type {unknown[]} */ (thenNodes ?? []);
263
+ for (const child of thenChildren) {
264
+ collectReads(child, targets, errors);
265
+ }
266
+ const elseChildren = /** @type {unknown[]} */ (elseNodes ?? []);
267
+ for (const child of elseChildren) {
268
+ collectReads(child, targets, errors);
269
+ }
270
+ return;
271
+ }
272
+ if (Object.hasOwn(head, '$forEach')) {
273
+ const [, childNodes] = arr;
274
+ const forEachChildren = /** @type {unknown[]} */ (childNodes ?? []);
275
+ for (const child of forEachChildren) {
276
+ collectReads(child, targets, errors);
277
+ }
278
+ return;
279
+ }
280
+ if (Object.hasOwn(head, '$indexedDB')) {
281
+ const spec = /** @type {any} */ (head.$indexedDB);
282
+ if (
283
+ !isPlainObject(spec) ||
284
+ typeof spec.db !== 'string' || spec.db.length === 0 ||
285
+ typeof spec.store !== 'string' || spec.store.length === 0
286
+ ) {
287
+ errors.push(
288
+ '`$indexedDB` target could not be resolved statically — `db` and ' +
289
+ '`store` must both be non-empty string literals: ' +
290
+ JSON.stringify(head)
291
+ );
292
+ } else {
293
+ targets.push({db: spec.db, store: spec.store});
294
+ }
295
+ const [, childNodes] = arr;
296
+ const idbChildren = /** @type {unknown[]} */ (childNodes ?? []);
297
+ for (const child of idbChildren) {
298
+ collectReads(child, targets, errors);
299
+ }
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Statically derive the `{db, store}` targets a declarative template's
305
+ * `$indexedDB` nodes touch (ROUTE-OVERRIDES-PLAN.md §3.4, §13 decision 4)
306
+ * — for a route override's data-access checks and its `reads` field. Total:
307
+ * an `$indexedDB` node whose `db`/`store` isn't a literal string is
308
+ * reported as an error rather than silently omitted from `reads` — the
309
+ * whole point is to drive a data-access allowlist, so an unresolvable
310
+ * target must never be mistaken for "no read happens here". Duplicate
311
+ * targets are deduplicated.
312
+ * @param {unknown[]} nodes
313
+ * @returns {{reads: ReadTarget[], errors: string[]}}
314
+ */
315
+ export function extractReads (nodes) {
316
+ if (!Array.isArray(nodes)) {
317
+ return {
318
+ reads: [],
319
+ errors: ['A declarative template must be an array of nodes.']
320
+ };
321
+ }
322
+ /** @type {ReadTarget[]} */
323
+ const targets = [];
324
+ /** @type {string[]} */
325
+ const errors = [];
326
+ for (const node of nodes) {
327
+ collectReads(node, targets, errors);
328
+ }
329
+ const seen = new Set();
330
+ const reads = targets.filter(({db, store}) => {
331
+ const key = `${db}${store}`;
332
+ if (seen.has(key)) {
333
+ return false;
334
+ }
335
+ seen.add(key);
336
+ return true;
337
+ });
338
+ return {reads, errors};
339
+ }
340
+
341
+ /**
342
+ * Non-executing structural check for a declarative template. Unlike
343
+ * `compileJSONTemplate`, this never throws — it reports every problem it
344
+ * finds, for a "validate before save" editor workflow. Includes the
345
+ * `extractReads()` check (an unresolvable `$indexedDB` target is a
346
+ * validation error, not just a `reads` omission).
347
+ * @param {unknown[]} nodes
348
+ * @param {{format?: 'json'|'javascript'}} [options]
349
+ * @returns {{valid: boolean, errors: string[]}}
350
+ */
351
+ export function validateJSONTemplate (nodes, {format = 'json'} = {}) {
352
+ if (!Array.isArray(nodes)) {
353
+ return {
354
+ valid: false,
355
+ errors: ['A declarative template must be an array of nodes.']
356
+ };
357
+ }
358
+ /** @type {string[]} */
359
+ const errors = [];
360
+ for (const node of nodes) {
361
+ validateNode(node, format, errors);
362
+ }
363
+ errors.push(...extractReads(nodes).errors);
364
+ return {valid: errors.length === 0, errors};
365
+ }
366
+
367
+ /**
368
+ * @param {unknown} x
369
+ * @returns {x is unknown[]}
370
+ */
371
+ export function isJSONTemplateNodeArray (x) {
372
+ return Array.isArray(x);
373
+ }
374
+
375
+ /**
376
+ * Run one node against a live jtlt context.
377
+ * @param {unknown} node
378
+ * @param {any} ctx - The bound `this` inside the compiled template
379
+ * @returns {Promise<void>}
380
+ */
381
+ async function runNode (node, ctx) {
382
+ const kind = classify(node);
383
+ if (kind === 'text') {
384
+ ctx.text(node);
385
+ return;
386
+ }
387
+ const arr = /** @type {unknown[]} */ (node);
388
+ if (kind === 'element') {
389
+ const [name, ...rest] = /** @type {[string, ...unknown[]]} */ (arr);
390
+ const {atts, children} = splitElementRest(rest);
391
+ await ctx.element(name, atts, [], async () => {
392
+ await runNodes(children, ctx);
393
+ });
394
+ return;
395
+ }
396
+ const head = /** @type {Record<string, unknown>} */ (arr[0]);
397
+ await runOperation(head, arr.slice(1), ctx);
398
+ }
399
+
400
+ /**
401
+ * @param {unknown[]} nodes
402
+ * @param {any} ctx
403
+ * @returns {Promise<void>}
404
+ */
405
+ async function runNodes (nodes, ctx) {
406
+ for (const node of nodes) {
407
+ // eslint-disable-next-line no-await-in-loop -- Sequential output order
408
+ await runNode(node, ctx);
409
+ }
410
+ }
411
+
412
+ /**
413
+ * @param {Record<string, unknown>} head
414
+ * @param {unknown[]} rest
415
+ * @param {any} ctx
416
+ * @returns {Promise<void>}
417
+ */
418
+ async function runOperation (head, rest, ctx) {
419
+ if (Object.hasOwn(head, '$text')) {
420
+ ctx.text(head.$text);
421
+ return;
422
+ }
423
+ if (Object.hasOwn(head, '$jtltText')) {
424
+ if (Object.hasOwn(head, '$select')) {
425
+ ctx.valueOf(/** @type {string} */ (head.$select));
426
+ } else {
427
+ ctx.text(head.$jtltText);
428
+ }
429
+ return;
430
+ }
431
+ if (Object.hasOwn(head, '$string')) {
432
+ if (Object.hasOwn(head, '$select')) {
433
+ ctx.string(String(ctx.get(head.$select, false)));
434
+ } else {
435
+ ctx.string(head.$string);
436
+ }
437
+ return;
438
+ }
439
+ if (Object.hasOwn(head, '$valueOf')) {
440
+ ctx.valueOf(head.$valueOf);
441
+ return;
442
+ }
443
+ if (Object.hasOwn(head, '$applyTemplates')) {
444
+ await ctx.applyTemplates(head.$applyTemplates, head.$jtltMode, head.$sort);
445
+ return;
446
+ }
447
+ if (Object.hasOwn(head, '$variable')) {
448
+ ctx.variable(head.$variable, {select: head.$select});
449
+ return;
450
+ }
451
+ if (Object.hasOwn(head, '$if')) {
452
+ const [thenNodes, elseNodes] = rest;
453
+ if (elseNodes) {
454
+ await ctx.choose(
455
+ head.$if,
456
+ async () => {
457
+ await runNodes(/** @type {unknown[]} */ (thenNodes), ctx);
458
+ },
459
+ async () => {
460
+ await runNodes(/** @type {unknown[]} */ (elseNodes), ctx);
461
+ }
462
+ );
463
+ } else {
464
+ await ctx.if(head.$if, async () => {
465
+ await runNodes(/** @type {unknown[]} */ (thenNodes), ctx);
466
+ });
467
+ }
468
+ return;
469
+ }
470
+ if (Object.hasOwn(head, '$forEach')) {
471
+ const [childNodes] = rest;
472
+ await ctx.forEach(
473
+ head.$forEach,
474
+ async () => {
475
+ await runNodes(/** @type {unknown[]} */ (childNodes), ctx);
476
+ },
477
+ head.$sort
478
+ );
479
+ return;
480
+ }
481
+ if (Object.hasOwn(head, '$renderDefault')) {
482
+ await ctx.renderDefault();
483
+ return;
484
+ }
485
+ // Only $indexedDB is left (validateOperationHead already rejects
486
+ // anything else, and every other recognized key returned above).
487
+ const idb = /** @type {{db: string, store: string, options?: unknown}} */
488
+ (head.$indexedDB);
489
+ const rows = await ctx.indexedDB(idb.db, idb.store, idb.options);
490
+ // Validation already confirmed this is an array, when given at all.
491
+ const childNodes = /** @type {unknown[]} */ (rest[0] ?? []);
492
+ if (Object.hasOwn(head, '$as')) {
493
+ ctx.variable(head.$as, {value: rows});
494
+ await runNodes(childNodes, ctx);
495
+ } else {
496
+ const prevContext = ctx._contextObj;
497
+ ctx._contextObj = rows;
498
+ try {
499
+ await runNodes(childNodes, ctx);
500
+ } finally {
501
+ ctx._contextObj = prevContext;
502
+ }
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Compile a declarative (jamilih-shaped) node array into a jtlt
508
+ * `TemplateFunction`. Validates the whole tree up front (see
509
+ * `validateJSONTemplate`) and throws on the first problem, rather than
510
+ * failing partway through execution.
511
+ * @param {unknown[]} nodes
512
+ * @param {{format?: 'json'|'javascript'}} [options]
513
+ * @returns {(
514
+ * this: any, value: unknown, cfg?: {mode?: string}
515
+ * ) => Promise<void>}
516
+ */
517
+ export function compileJSONTemplate (nodes, {format = 'json'} = {}) {
518
+ const {valid, errors} = validateJSONTemplate(nodes, {format});
519
+ if (!valid) {
520
+ throw new TypeError(
521
+ `Invalid declarative template:\n${errors.join('\n')}`
522
+ );
523
+ }
524
+ return async function () {
525
+ await runNodes(nodes, this);
526
+ };
527
+ }