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/CHANGES.md +26 -0
- package/README.md +75 -0
- package/demo/vendor/jamilih/dist/jml.mjs +898 -25
- package/dist/DOMJoiningTransformer.d.ts +79 -30
- package/dist/DOMJoiningTransformer.d.ts.map +1 -1
- package/dist/JSONJoiningTransformer.d.ts +23 -24
- package/dist/JSONJoiningTransformer.d.ts.map +1 -1
- package/dist/JSONPathTransformer.d.ts.map +1 -1
- package/dist/JSONPathTransformerContext.d.ts +59 -19
- package/dist/JSONPathTransformerContext.d.ts.map +1 -1
- package/dist/StringJoiningTransformer.d.ts +9 -5
- package/dist/StringJoiningTransformer.d.ts.map +1 -1
- package/dist/XPathTransformerContext.d.ts.map +1 -1
- package/dist/index.d.ts +104 -261
- package/dist/index.d.ts.map +1 -1
- package/dist/jsonTemplate.d.ts +59 -0
- package/dist/jsonTemplate.d.ts.map +1 -0
- package/docs/API.expanded.md +87 -1
- package/docs/API.md +21 -0
- package/docs/TO-DO.md +10 -4
- package/package.json +6 -6
- package/pnpm-workspace.yaml +4 -1
- package/src/DOMJoiningTransformer.js +42 -14
- package/src/JSONJoiningTransformer.js +109 -80
- package/src/JSONPathTransformer.js +40 -10
- package/src/JSONPathTransformerContext.js +217 -46
- package/src/StringJoiningTransformer.js +40 -12
- package/src/XPathTransformerContext.js +25 -1
- package/src/index.js +116 -4
- package/src/jsonTemplate.js +527 -0
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 {
|
|
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 ||
|
|
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
|
+
}
|