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.
- package/.gitignore +3 -0
- package/.npmignore +3 -0
- package/CHANGES.md +14 -0
- package/README.md +76 -0
- package/demo/vendor/jamilih/dist/jml.mjs +898 -25
- package/dist/DOMJoiningTransformer.d.ts +73 -25
- package/dist/DOMJoiningTransformer.d.ts.map +1 -1
- package/dist/JSONJoiningTransformer.d.ts +5 -3
- package/dist/JSONJoiningTransformer.d.ts.map +1 -1
- package/dist/JSONPathTransformer.d.ts.map +1 -1
- package/dist/JSONPathTransformerContext.d.ts +106 -19
- package/dist/JSONPathTransformerContext.d.ts.map +1 -1
- package/dist/StringJoiningTransformer.d.ts +8 -3
- package/dist/StringJoiningTransformer.d.ts.map +1 -1
- package/dist/XPathTransformerContext.d.ts +44 -0
- 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 +37 -0
- package/dist/jsonTemplate.d.ts.map +1 -0
- package/docs/API.expanded.md +75 -1
- package/docs/API.md +20 -0
- package/docs/TO-DO.md +10 -4
- package/package.json +6 -6
- package/pnpm-workspace.yaml +4 -1
- package/src/DOMJoiningTransformer.js +37 -11
- package/src/JSONJoiningTransformer.js +94 -67
- package/src/JSONPathTransformer.js +40 -10
- package/src/JSONPathTransformerContext.js +337 -47
- package/src/StringJoiningTransformer.js +35 -9
- package/src/XPathTransformerContext.js +140 -2
- package/src/index.js +115 -4
- package/src/jsonTemplate.js +410 -0
|
@@ -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
|
+
}
|