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.
@@ -391,6 +391,12 @@ class StringJoiningTransformer extends AbstractJoiningTransformer {
391
391
  if (_oldStrTemp !== undefined) {
392
392
  this._strTemp = (this._strTemp || '') + str;
393
393
  } else {
394
+ // Close any open parent start tag before appending content, matching
395
+ // text()'s behavior — string() is content too, not an attribute value.
396
+ if (this._openTagState) {
397
+ this.append('>');
398
+ this._openTagState = false;
399
+ }
394
400
  // Append to the output (or current container via append()).
395
401
  this.append(tmpStr + str);
396
402
  }
@@ -497,9 +503,14 @@ class StringJoiningTransformer extends AbstractJoiningTransformer {
497
503
  * @param {string|Element} elem - Element name or element object
498
504
  * @param {ElementAttributes} [atts] - Element attributes
499
505
  * @param {any[]} [childNodes] - Child nodes
500
- * @param {(this: StringJoiningTransformer) => void} [cb] - Callback function
506
+ * @param {(this: StringJoiningTransformer) => void} [cb] -
507
+ * Callback function; may be async (e.g. to `await` a `$indexedDB`
508
+ * fetch), in which case `element()` itself returns a `Promise` instead
509
+ * of `this` — check for `.then` (or `await`) rather than assuming a
510
+ * synchronous return. (Typed as plain `void`, not `void|Promise<void>`
511
+ * — see the note on `SimpleCallback` in JSONJoiningTransformer.js.)
501
512
  * @param {string[]} [useAttributeSets] - Attribute set names to apply
502
- * @returns {StringJoiningTransformer}
513
+ * @returns {StringJoiningTransformer|Promise<StringJoiningTransformer>}
503
514
  */
504
515
  element (elem, atts, childNodes, cb, useAttributeSets) {
505
516
  // If a parent element's start tag is still open, close it before
@@ -635,17 +646,32 @@ class StringJoiningTransformer extends AbstractJoiningTransformer {
635
646
  jml[method]({'#': childNodes})
636
647
  ));
637
648
  }
638
- cb.call(this._context || this);
649
+ // `cb`'s declared return type is plain `void` (see the parameter's
650
+ // JSDoc) so the leniency that lets any actual return value through
651
+ // still applies to callers — cast here to duck-type the real value.
652
+ const cbResult = /** @type {any} */ (cb.call(this._context || this));
639
653
 
640
654
  // Todo: Depending on an this._cfg.xmlElements option, allow for
641
655
  // XML self-closing when empty or as per the tag, HTML
642
656
  // self-closing tags (or polyglot-friendly self-closing)
643
- if (this._openTagState) {
644
- this.append('>');
645
- }
646
- this.append('</' + elName + '>');
647
- this._openTagState = oldTagState;
648
- return this;
657
+ const finish = () => {
658
+ if (this._openTagState) {
659
+ this.append('>');
660
+ }
661
+ this.append('</' + elName + '>');
662
+ this._openTagState = oldTagState;
663
+ return this;
664
+ };
665
+ // `cb` may be an async function (e.g. one that awaits
666
+ // `this.indexedDB(...)`/`this.renderDefault()` before appending
667
+ // content) — mirroring how a root/matched/named template's own return
668
+ // value is already handled elsewhere, closing the tag only after that
669
+ // settles keeps output correctly ordered.
670
+ if (cbResult && typeof cbResult.then === 'function') {
671
+ // eslint-disable-next-line promise/prefer-await-to-then -- Not async
672
+ return cbResult.then(() => finish());
673
+ }
674
+ return finish();
649
675
  }
650
676
 
651
677
  /**
@@ -619,12 +619,36 @@ class XPathTransformerContext {
619
619
  const prevTemplateParams = this._params;
620
620
  this._params = {0: node, ...appliedParams};
621
621
 
622
+ const {template: xpathTemplateFn} = templateObj;
623
+ /* c8 ignore start -- Todo: compile via compileJSONTemplate() once it
624
+ exists (see ~/idb-manager/JTLT-JSON-TEMPLATES-PROPOSAL.md) instead
625
+ of rejecting; no matched template can reach here with an Array
626
+ `template` until that lands (and the declarative subset is
627
+ JSONPath-only per that plan, so this may stay unreachable longer
628
+ than the JSONPath-side guards). */
629
+ if (Array.isArray(xpathTemplateFn)) {
630
+ throw new TypeError(
631
+ 'JSON (jamilih-shaped) Array templates are not yet supported by ' +
632
+ 'the XPath engine; compile with compileJSONTemplate() first.'
633
+ );
634
+ }
635
+ /* c8 ignore stop */
622
636
  /**
623
637
  * The template may return synchronously or return a Promise (e.g. from
624
638
  * `await this.indexedDB(...)`), which is awaited unless `config.sync`.
625
639
  * @type {any}
626
640
  */
627
- const ret = templateObj.template.call(this, node, {mode});
641
+ const ret = xpathTemplateFn.call(
642
+ // `this` carries runtime `extensions`; a consumer's
643
+ // `ContextExtensions` augmentation would otherwise reject it here
644
+ // (matching the cast already used at the other three call sites).
645
+ // `node` is cast too: `templateObj.template`'s static type is a
646
+ // union across a `dom`-typed `TemplateFunction` (expecting
647
+ // `DocumentFragment | Element`) and the mode-callback signature
648
+ // above (expecting plain `Node`) — the same node value satisfies
649
+ // whichever one actually runs.
650
+ /** @type {any} */ (this), /** @type {any} */ (node), {mode}
651
+ );
628
652
 
629
653
  // Restore previous parameter context
630
654
  this._params = prevTemplateParams;
@@ -2469,6 +2493,13 @@ class XPathTransformerContext {
2469
2493
  * supplied via `withParam()`, or provided at runtime as `config.params` —
2470
2494
  * rather than an XPath expression.
2471
2495
  *
2496
+ * The test may also be a simple binary comparison of a `$name` parameter
2497
+ * against a literal, evaluated without `eval`, e.g.
2498
+ * `this.if('$name === "x"')` or `this.if('$count < 50')`. The operator is
2499
+ * one of `===`, `!==`, `==`, `!=`, `<`, `<=`, `>`, `>=`; the right side is
2500
+ * a string, number, boolean, `null`, or `undefined` literal. Anything else
2501
+ * is left to the XPath engine.
2502
+ *
2472
2503
  * Truthiness rules:
2473
2504
  * - Node set: length > 0 passes.
2474
2505
  * - Scalar: Boolean(value) must be true.
@@ -2514,13 +2545,20 @@ class XPathTransformerContext {
2514
2545
  */
2515
2546
  _passesIf (select) {
2516
2547
  if (typeof select === 'string') {
2517
- const paramRef = select.trim().match(/^\$(?<name>[\w\-]+)$/v);
2548
+ const trimmed = select.trim();
2549
+ const paramRef = trimmed.match(/^\$(?<name>[\w\-]+)$/v);
2518
2550
  if (paramRef && paramRef.groups) {
2519
2551
  const param = this._lookupParam(paramRef.groups.name);
2520
2552
  if (param.has) {
2521
2553
  return this._isTruthyResult(param.value);
2522
2554
  }
2523
2555
  }
2556
+ const cmp = this._parseComparison(trimmed);
2557
+ if (cmp) {
2558
+ return this._compareValues(
2559
+ this._resolveComparand(cmp.left), cmp.op, cmp.right
2560
+ );
2561
+ }
2524
2562
  }
2525
2563
  let passes = false;
2526
2564
  // Try scalar evaluation first (handles boolean/comparison expressions)
@@ -2567,6 +2605,106 @@ class XPathTransformerContext {
2567
2605
  return passes;
2568
2606
  }
2569
2607
 
2608
+ /**
2609
+ * Parse a simple `$name <op> <literal>` comparison test (no `eval`), such
2610
+ * as `$name === "x"` or `$count < 50`. The left side must be a bare `$name`
2611
+ * parameter reference; the right side a string, number, boolean, `null`,
2612
+ * or `undefined` literal. Returns `null` when the string is not such a
2613
+ * comparison, so richer XPath expressions fall through untouched.
2614
+ * @param {string} str
2615
+ * @returns {{left: string, op: string, right: unknown}|null}
2616
+ * @private
2617
+ */
2618
+ _parseComparison (str) {
2619
+ const m = str.match(
2620
+ /^(?<left>\$[\w\-]+)\s*(?<op>===|!==|==|!=|<=|>=|<|>)\s*(?<right>.+)$/v
2621
+ );
2622
+ if (!m || !m.groups) {
2623
+ return null;
2624
+ }
2625
+ const right = this._parseLiteral(m.groups.right.trim());
2626
+ if (!right) {
2627
+ return null;
2628
+ }
2629
+ return {left: m.groups.left, op: m.groups.op, right: right.value};
2630
+ }
2631
+
2632
+ /**
2633
+ * Parse a JSON-ish scalar literal: a double- or single-quoted string, a
2634
+ * number, or `true` / `false` / `null` / `undefined`. Returns `null` when
2635
+ * `str` is none of these.
2636
+ * @param {string} str
2637
+ * @returns {{value: unknown}|null}
2638
+ * @private
2639
+ */
2640
+ // eslint-disable-next-line class-methods-use-this -- pure helper
2641
+ _parseLiteral (str) {
2642
+ if ((/^"(?:[^"\\]|\\.)*"$/v).test(str)) {
2643
+ return {value: JSON.parse(str)};
2644
+ }
2645
+ if ((/^'(?:[^'\\]|\\.)*'$/v).test(str)) {
2646
+ return {value: str.slice(1, -1).replaceAll(/\\(?=['\\])/gv, '')};
2647
+ }
2648
+ if ((/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+\-]?\d+)?$/v).test(str)) {
2649
+ return {value: Number(str)};
2650
+ }
2651
+ if (str === 'true') {
2652
+ return {value: true};
2653
+ }
2654
+ if (str === 'false') {
2655
+ return {value: false};
2656
+ }
2657
+ if (str === 'null') {
2658
+ return {value: null};
2659
+ }
2660
+ if (str === 'undefined') {
2661
+ return {value: undefined};
2662
+ }
2663
+ return null;
2664
+ }
2665
+
2666
+ /**
2667
+ * Resolve the left side of a simple comparison: a bare `$name` parameter
2668
+ * reference (local, with-param, then runtime `config.params`).
2669
+ * @param {string} ref
2670
+ * @returns {unknown}
2671
+ * @private
2672
+ */
2673
+ _resolveComparand (ref) {
2674
+ const paramRef = ref.match(/^\$(?<name>[\w\-]+)$/v);
2675
+ /* c8 ignore next 3 -- `_parseComparison` only yields a `$name` left side */
2676
+ if (!paramRef || !paramRef.groups) {
2677
+ return undefined;
2678
+ }
2679
+ return this._lookupParam(paramRef.groups.name).value;
2680
+ }
2681
+
2682
+ /**
2683
+ * Apply a comparison operator to two already-resolved values.
2684
+ * @param {any} a - Left operand
2685
+ * @param {string} op - One of `===`, `!==`, `==`, `!=`, `<`, `<=`, `>`, `>=`
2686
+ * @param {any} b - Right operand
2687
+ * @returns {boolean}
2688
+ * @private
2689
+ */
2690
+ // eslint-disable-next-line class-methods-use-this -- pure helper
2691
+ _compareValues (a, op, b) {
2692
+ /* eslint-disable eqeqeq -- `==`/`!=` are deliberately offered */
2693
+ switch (op) {
2694
+ case '===': return a === b;
2695
+ case '!==': return a !== b;
2696
+ case '==': return a == b;
2697
+ case '!=': return a != b;
2698
+ case '<': return a < b;
2699
+ case '<=': return a <= b;
2700
+ case '>': return a > b;
2701
+ case '>=': return a >= b;
2702
+ /* c8 ignore next -- the regex only yields the operators handled above */
2703
+ default: return false;
2704
+ }
2705
+ /* eslint-enable eqeqeq -- restore */
2706
+ }
2707
+
2570
2708
  /**
2571
2709
  * Conditional with optional fallback (like choose/otherwise).
2572
2710
  * Truthiness same as `if()`.
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,8 @@ export {
859
967
  export {
860
968
  default as XPathTransformer
861
969
  } from './XPathTransformer.js';
970
+ export {
971
+ compileJSONTemplate, isJSONTemplateNodeArray, validateJSONTemplate
972
+ } from './jsonTemplate.js';
862
973
 
863
974
  export default JTLT;