jtlt 0.13.0 → 0.15.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.
Files changed (46) hide show
  1. package/.c8rc.json +1 -0
  2. package/CHANGES.md +18 -0
  3. package/README.md +99 -85
  4. package/demo/calltemplate-params-demo.js +13 -22
  5. package/demo/codemirror.esm.js +50 -15
  6. package/demo/vendor/jhtml/src/SAJJ/SAJJ.ObjectArrayDelegator.js +17 -17
  7. package/demo/vendor/jhtml/src/SAJJ/SAJJ.js +60 -61
  8. package/demo/vendor/jhtml/src/jhtml-browser.js +1 -0
  9. package/demo/vendor/jhtml/src/jhtml-node.js +1 -0
  10. package/demo/vendor/jhtml/src/jhtml.js +18 -15
  11. package/dist/AbstractJoiningTransformer.d.ts +5 -5
  12. package/dist/AbstractJoiningTransformer.d.ts.map +1 -1
  13. package/dist/JSONPathTransformer.d.ts +13 -12
  14. package/dist/JSONPathTransformer.d.ts.map +1 -1
  15. package/dist/JSONPathTransformerContext.d.ts +60 -20
  16. package/dist/JSONPathTransformerContext.d.ts.map +1 -1
  17. package/dist/XPathTransformer.d.ts +9 -2
  18. package/dist/XPathTransformer.d.ts.map +1 -1
  19. package/dist/XPathTransformerContext.d.ts +56 -9
  20. package/dist/XPathTransformerContext.d.ts.map +1 -1
  21. package/dist/context-extensions.d.ts +24 -0
  22. package/dist/extendContext.d.ts +10 -0
  23. package/dist/extendContext.d.ts.map +1 -0
  24. package/dist/index.d.ts +121 -13
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/indexedDB.d.ts +107 -0
  27. package/dist/indexedDB.d.ts.map +1 -0
  28. package/dist/maybeAsync.d.ts +15 -0
  29. package/dist/maybeAsync.d.ts.map +1 -0
  30. package/docs/API.expanded.md +70 -20
  31. package/docs/API.md +34 -4
  32. package/docs/TO-DO.md +17 -3
  33. package/eslint.config.js +11 -3
  34. package/package.json +22 -15
  35. package/pnpm-workspace.yaml +9 -0
  36. package/src/AbstractJoiningTransformer.js +3 -3
  37. package/src/JSONPathTransformer.js +48 -8
  38. package/src/JSONPathTransformerContext.js +175 -25
  39. package/src/XPathTransformer.js +33 -1
  40. package/src/XPathTransformerContext.js +145 -16
  41. package/src/context-extensions.d.ts +24 -0
  42. package/src/extendContext.js +21 -0
  43. package/src/index.js +102 -11
  44. package/src/indexedDB.js +530 -0
  45. package/src/maybeAsync.js +44 -0
  46. package/tsconfig.json +2 -2
@@ -1,5 +1,10 @@
1
1
  import {JSONPath as jsonpath} from 'jsonpath-plus';
2
2
  import JSONPathTransformer from './JSONPathTransformer.js';
3
+ import {maybeAsyncLoop} from './maybeAsync.js';
4
+ import applyExtensions from './extendContext.js';
5
+ import {
6
+ parseIndexedDBExpression, queryIndexedDB, resolveIndexedDBQuery
7
+ } from './indexedDB.js';
3
8
 
4
9
  /**
5
10
  * @param {string} string
@@ -52,7 +57,7 @@ const escapeRegexReplacement = (string) => {
52
57
  * ctx: JSONPathTransformerContext
53
58
  * ) => number} SortComparator
54
59
  * @typedef {string | SortObject | SortComparator |
55
- * Array<string|SortObject>} SortSpec
60
+ * Array<string|SortObject> | null} SortSpec
56
61
  */
57
62
 
58
63
  /**
@@ -88,10 +93,17 @@ const escapeRegexReplacement = (string) => {
88
93
  * @property {JoiningTransformerMap[T]} joiningTransformer - Joining transformer
89
94
  * @property {boolean} [preventEval] - Whether to prevent eval in
90
95
  * JSONPath
96
+ * @property {boolean} [sync] - When true, throw if a template returns a
97
+ * Promise instead of awaiting it (disables `indexedDB()`)
91
98
  * @property {(path: string) => number} [specificityPriorityResolver]
92
99
  * Priority resolver function
93
100
  * @property {import('./index.js').JSONPathTemplateObject<T>[]|
94
101
  * import('./index.js').JSONPathTemplateArray<T>[]} [templates]
102
+ * @property {Record<string, unknown> & ThisType<
103
+ * import('./JSONPathTransformerContext.js').default<T> &
104
+ * import('./context-extensions.js').ContextExtensions
105
+ * >} [extensions] Extra methods/values
106
+ * merged onto this context so templates can call `this.myHelper()`
95
107
  */
96
108
 
97
109
  /**
@@ -143,6 +155,9 @@ class JSONPathTransformerContext {
143
155
  this._preserveSpaceElements = [];
144
156
  /** @type {string[]} */
145
157
  this._stripSpaceElements = [];
158
+ if (config.extensions) {
159
+ applyExtensions(this, config.extensions);
160
+ }
146
161
  }
147
162
 
148
163
  /**
@@ -195,7 +210,7 @@ class JSONPathTransformerContext {
195
210
 
196
211
  /**
197
212
  * Gets the current output.
198
- * @returns {any} The output from the joining transformer
213
+ * @returns {unknown} The output from the joining transformer
199
214
  */
200
215
  getOutput () {
201
216
  return this._getJoiningTransformer().get();
@@ -221,7 +236,7 @@ class JSONPathTransformerContext {
221
236
  }
222
237
 
223
238
  /**
224
- * @param {any} v - Value to set
239
+ * @param {unknown} v - Value to set
225
240
  * @returns {this}
226
241
  */
227
242
  set (v) {
@@ -403,7 +418,7 @@ class JSONPathTransformerContext {
403
418
  const prevCurrPath = that._currPath;
404
419
 
405
420
  // Process in (sorted) order
406
- for (const o of matches) {
421
+ const loopResult = maybeAsyncLoop(matches, (o) => {
407
422
  const {value, parent, parentProperty, path} = o;
408
423
  const _oldPath = that._currPath;
409
424
  that._currPath += path.replace(/^\$/v, '');
@@ -454,17 +469,19 @@ class JSONPathTransformerContext {
454
469
  }
455
470
  if (onNoMatch === 'deep-skip') {
456
471
  // Skip this node and its descendants entirely
457
- continue;
472
+ return;
458
473
  }
459
474
  if (onNoMatch === 'shallow-copy') {
460
475
  // Output the value as-is without processing children
461
476
  joiner.append(value);
462
- continue;
477
+ that._currPath = _oldPath;
478
+ return;
463
479
  }
464
480
  if (onNoMatch === 'deep-copy') {
465
481
  // Output the value and all descendants as-is
466
482
  joiner.append(JSON.stringify(value));
467
- continue;
483
+ that._currPath = _oldPath;
484
+ return;
468
485
  }
469
486
  if (onNoMatch === 'text-only-copy') {
470
487
  // Output only text content (primitives)
@@ -472,7 +489,8 @@ class JSONPathTransformerContext {
472
489
  typeof value === 'boolean') {
473
490
  joiner.append(String(value));
474
491
  }
475
- continue;
492
+ that._currPath = _oldPath;
493
+ return;
476
494
  }
477
495
  // 'apply-templates', 'shallow-skip', or other:
478
496
  // use default template rules
@@ -573,15 +591,58 @@ class JSONPathTransformerContext {
573
591
  const prevTemplateParams = that._params;
574
592
  that._params = {0: value};
575
593
 
594
+ /**
595
+ * The template may return synchronously or return a Promise (e.g. from
596
+ * `await this.indexedDB(...)`), which is awaited unless `config.sync`.
597
+ * @type {any}
598
+ */
576
599
  const ret =
577
600
  /** @type {import('./index.js').JSONPathTemplateObject<T>} */ (
578
601
  templateObj
579
602
  ).template.call(
580
- that, value, {mode, parent, parentProperty}
603
+ // `this` carries runtime `extensions`; a consumer's
604
+ // `ContextExtensions` augmentation would otherwise reject `that`.
605
+ /** @type {any} */ (that), value, {mode, parent, parentProperty}
581
606
  );
582
607
 
583
608
  // Restore previous parameter context
584
609
  that._params = prevTemplateParams;
610
+ if (ret !== null && typeof ret !== 'undefined' &&
611
+ typeof ret.then === 'function') {
612
+ if (that._config.sync) {
613
+ throw new Error(
614
+ 'A template returned a Promise but JTLT is configured with ' +
615
+ '`sync: true`.'
616
+ );
617
+ }
618
+ // The loop body deliberately mixes value and no-value returns so
619
+ // `maybeAsyncLoop` can stay synchronous unless a template awaits.
620
+ // eslint-disable-next-line @stylistic/max-len -- Long
621
+ // eslint-disable-next-line promise/prefer-await-to-then, consistent-return -- intentional dynamic sync/async
622
+ return ret.then((/** @type {any} */ resolvedRet) => {
623
+ that._params = prevTemplateParams;
624
+ if (typeof resolvedRet !== 'undefined') {
625
+ const joiner = that._getJoiningTransformer();
626
+ /* c8 ignore start -- _openTagState only on
627
+ StringJoiningTransformer; string-output defensive check */
628
+ // @ts-expect-error -- _openTagState: StringJoiningTransformer only
629
+ if (joiner._openTagState) {
630
+ joiner.append('>');
631
+ // @ts-expect-error -- _openTagState: StringJoiningTransformer
632
+ joiner._openTagState = false;
633
+ }
634
+ /* c8 ignore stop */
635
+ joiner.append(resolvedRet);
636
+ }
637
+ that._parent = parent;
638
+ // Matches reached here always carry a parentProperty; the sync
639
+ // path covers the root-node fallback.
640
+ /* c8 ignore next */
641
+ that._parentProperty = (parentProperty ?? that._parentProperty);
642
+ that._currPath = _oldPath;
643
+ return undefined;
644
+ });
645
+ }
585
646
  if (typeof ret !== 'undefined') {
586
647
  // After the undefined check, ret is ResultType<T>
587
648
  const joiner = that._getJoiningTransformer();
@@ -604,12 +665,27 @@ class JSONPathTransformerContext {
604
665
  that._parent = parent;
605
666
  that._parentProperty = (parentProperty ?? that._parentProperty);
606
667
  that._currPath = _oldPath;
668
+ });
669
+
670
+ if (typeof loopResult?.then === 'function') {
671
+ // Returns a Promise<this> when a nested template ran asynchronously.
672
+ return /** @type {any} */ (
673
+ // eslint-disable-next-line @stylistic/max-len -- Long
674
+ // eslint-disable-next-line promise/prefer-await-to-then -- intentional dynamic sync/async
675
+ loopResult.then(() => {
676
+ this._contextObj = prevContext;
677
+ this._parent = prevParent;
678
+ this._parentProperty = prevParentProp;
679
+ this._currPath = prevCurrPath;
680
+ return this;
681
+ })
682
+ );
607
683
  }
608
- // Restore outer context
609
- that._contextObj = prevContext;
610
- that._parent = prevParent;
611
- that._parentProperty = prevParentProp;
612
- that._currPath = prevCurrPath;
684
+
685
+ this._contextObj = prevContext;
686
+ this._parent = prevParent;
687
+ this._parentProperty = prevParentProp;
688
+ this._currPath = prevCurrPath;
613
689
  return this;
614
690
  }
615
691
 
@@ -657,7 +733,11 @@ class JSONPathTransformerContext {
657
733
  );
658
734
  }
659
735
 
660
- const result = templateObj.template.call(this, this._contextObj, {});
736
+ const result = templateObj.template.call(
737
+ // `this` carries runtime `extensions`; a consumer's `ContextExtensions`
738
+ // augmentation would otherwise reject the bare context.
739
+ /** @type {any} */ (this), this._contextObj, {}
740
+ );
661
741
  if (typeof result !== 'undefined') {
662
742
  /** @type {any} */ (results).append(result);
663
743
  }
@@ -819,9 +899,12 @@ class JSONPathTransformerContext {
819
899
  * expression matches
820
900
  * @param {string} [options.groupEndingWith] - Ends group when expression
821
901
  * matches
822
- * @param {any} [options.sort] - Sort specification (same as forEach)
902
+ * @param {SortSpec<V>} [options.sort] - Sort specification (same as forEach)
823
903
  * @param {(
824
- * this: JSONPathTransformerContext<T>, key: any, items: any[], ctx: any
904
+ * this: JSONPathTransformerContext<T>,
905
+ * key: unknown,
906
+ * items: unknown[],
907
+ * ctx: JSONPathTransformerContext<T>
825
908
  * ) => void} cb - Callback receives (groupingKey, groupItems, context)
826
909
  * @returns {this}
827
910
  */
@@ -1087,7 +1170,7 @@ class JSONPathTransformerContext {
1087
1170
  return 0;
1088
1171
  }
1089
1172
  if (Number.isNaN(an)) {
1090
- return Number(order);
1173
+ return order;
1091
1174
  }
1092
1175
  if (Number.isNaN(bn)) {
1093
1176
  return -order;
@@ -1135,7 +1218,7 @@ class JSONPathTransformerContext {
1135
1218
 
1136
1219
  /**
1137
1220
  * Returns the current group (for use within forEachGroup callback).
1138
- * @returns {any[]|undefined}
1221
+ * @returns {unknown[]|undefined}
1139
1222
  */
1140
1223
  currentGroup () {
1141
1224
  return /** @type {any} */ (this)._currentGroup;
@@ -1143,12 +1226,48 @@ class JSONPathTransformerContext {
1143
1226
 
1144
1227
  /**
1145
1228
  * Returns the current grouping key (for use within forEachGroup callback).
1146
- * @returns {any}
1229
+ * @returns {unknown}
1147
1230
  */
1148
1231
  currentGroupingKey () {
1149
1232
  return /** @type {any} */ (this)._currentGroupingKey;
1150
1233
  }
1151
1234
 
1235
+ /**
1236
+ * Directly query IndexedDB from within a template, e.g.
1237
+ * `await this.indexedDB('myDB', 'myStore', {index: 'byAge'})`.
1238
+ *
1239
+ * Since IndexedDB access is asynchronous, this is unavailable when JTLT is
1240
+ * configured with `sync: true`.
1241
+ * @param {string} dbName - Database name
1242
+ * @param {string} storeName - Object store name
1243
+ * @param {import('./indexedDB.js').QueryOptions} [options] - Query options
1244
+ * @returns {Promise<unknown[]>} The matching records
1245
+ */
1246
+ indexedDB (dbName, storeName, options) {
1247
+ if (this._config.sync) {
1248
+ throw new Error(
1249
+ 'The `indexedDB()` API is unavailable when JTLT is configured with ' +
1250
+ '`sync: true`.'
1251
+ );
1252
+ }
1253
+ return queryIndexedDB(dbName, storeName, options);
1254
+ }
1255
+
1256
+ /**
1257
+ * Await a parsed `indexedDB(...)` expression and append its (stringified)
1258
+ * value to the output. Used by {@link valueOf}. Callers reject `config.sync`.
1259
+ * @param {import('./indexedDB.js').ParsedIndexedDBExpression} parsed
1260
+ * @param {any} results - The joining transformer
1261
+ * @returns {Promise<this>}
1262
+ */
1263
+ async _appendIndexedDBValue (parsed, results) {
1264
+ const value = await resolveIndexedDBQuery(parsed, {
1265
+ preventEval: this._config.preventEval
1266
+ });
1267
+ results.text(String(value));
1268
+ return this;
1269
+ }
1270
+
1152
1271
  /**
1153
1272
  * @param {string|object} [select] - JSONPath selector
1154
1273
  * @returns {this}
@@ -1159,6 +1278,26 @@ class JSONPathTransformerContext {
1159
1278
  const results = this._getJoiningTransformer();
1160
1279
  let result;
1161
1280
 
1281
+ const selectString = select && typeof select === 'object'
1282
+ ? /** @type {{select?: string}} */ (select).select
1283
+ : select;
1284
+
1285
+ // Intercept `indexedDB('db', 'store')<trailing JSONPath>` expressions and
1286
+ // resolve them asynchronously before appending. Returns a Promise so
1287
+ // templates can `await this.valueOf(...)`.
1288
+ if (typeof selectString === 'string') {
1289
+ const parsed = parseIndexedDBExpression(selectString);
1290
+ if (parsed) {
1291
+ if (this._config.sync) {
1292
+ throw new Error(
1293
+ 'The `indexedDB()` function is unavailable when JTLT is ' +
1294
+ 'configured with `sync: true`.'
1295
+ );
1296
+ }
1297
+ return /** @type {any} */ (this._appendIndexedDBValue(parsed, results));
1298
+ }
1299
+ }
1300
+
1162
1301
  if (select && typeof select === 'object' &&
1163
1302
  /** @type {{select?: string}} */ (select).select === '.') {
1164
1303
  result = this._contextObj;
@@ -2079,8 +2218,8 @@ class JSONPathTransformerContext {
2079
2218
  /**
2080
2219
  * Invoke a registered stylesheet function with positional arguments.
2081
2220
  * @param {string} name - Function name (with namespace)
2082
- * @param {any[]} args - Positional arguments
2083
- * @returns {any} Function return value
2221
+ * @param {unknown[]} args - Positional arguments
2222
+ * @returns {unknown} Function return value
2084
2223
  */
2085
2224
  invokeFunctionByArity (name, args = []) {
2086
2225
  return this._getJoiningTransformer().invokeFunctionByArity(name, args);
@@ -2233,12 +2372,23 @@ class JSONPathTransformerContext {
2233
2372
 
2234
2373
  /**
2235
2374
  * @param {string} name - Key name
2236
- * @param {any} value - Value to match
2237
- * @returns {any}
2375
+ * @param {unknown} value - Value to match
2376
+ * @returns {unknown}
2238
2377
  */
2239
2378
  getKey (name, value) {
2240
2379
  const key = this.keys[name];
2241
- const matches = this.get(key.match, true);
2380
+ // Keys are document-global (like `xsl:key`): resolve `match` against the
2381
+ // root, not the current context (which may be a `forEach`/`applyTemplates`
2382
+ // item).
2383
+ const matches = /** @type {any[]} */ (
2384
+ (/** @type {any} */ (jsonpath))({
2385
+ path: JSONPathTransformer.makeJSONPathAbsolute(key.match),
2386
+ json: this._origObj,
2387
+ preventEval: this._config.preventEval,
2388
+ wrap: true,
2389
+ returnType: 'value'
2390
+ })
2391
+ );
2242
2392
  for (const match of matches) { // For objects or arrays
2243
2393
  if (match && typeof match === 'object' &&
2244
2394
  match[key.use] === value) {
@@ -7,6 +7,8 @@ import XPathTransformerContext from './XPathTransformerContext.js';
7
7
  * @property {import('./index.js').
8
8
  * XPathTemplateArray<T>} templates Template objects
9
9
  * @property {number} [xpathVersion] XPath version (1|2|3.1)
10
+ * @property {boolean} [sync] When true, throw if a template returns a Promise
11
+ * instead of awaiting it (disables `indexedDB()` and other async features)
10
12
  */
11
13
 
12
14
  /**
@@ -21,7 +23,7 @@ class XPathTransformer {
21
23
  static DefaultTemplateRules = {
22
24
  transformRoot: {
23
25
  /**
24
- * @param {any} node Node
26
+ * @param {unknown} node Node
25
27
  * @param {{mode:string}} cfg Config
26
28
  * @returns {void}
27
29
  */
@@ -106,7 +108,37 @@ class XPathTransformer {
106
108
  }
107
109
  // Set up parameter context for valueOf() access in root template
108
110
  xte._params = {0: xte._contextNode};
111
+ /**
112
+ * The template may return synchronously or return a Promise (e.g. from
113
+ * `await this.indexedDB(...)`), which is awaited unless `config.sync`.
114
+ * @type {any}
115
+ */
109
116
  const ret = templateObj.template.call(xte, undefined, {mode});
117
+
118
+ if (ret !== null && typeof ret !== 'undefined' &&
119
+ typeof ret.then === 'function') {
120
+ if (this._config.sync) {
121
+ throw new Error(
122
+ 'A template returned a Promise but JTLT is configured with ' +
123
+ '`sync: true`.'
124
+ );
125
+ }
126
+ return /** @type {any} */ (
127
+ // eslint-disable-next-line @stylistic/max-len -- Long
128
+ // eslint-disable-next-line promise/prefer-await-to-then -- intentional dynamic sync/async
129
+ ret.then((/** @type {any} */ resolvedRet) => {
130
+ if (typeof resolvedRet !== 'undefined') {
131
+ /** @type {any} */ (xte)._getJoiningTransformer().append(
132
+ resolvedRet
133
+ );
134
+ }
135
+ return /** @type {import('./index.js').ResultType<T>} */ (
136
+ xte.getOutput()
137
+ );
138
+ })
139
+ );
140
+ }
141
+
110
142
  if (typeof ret !== 'undefined') {
111
143
  /** @type {any} */ (xte)._getJoiningTransformer().append(ret);
112
144
  }
@@ -2,6 +2,13 @@ import xpath2 from 'xpath2.js'; // Runtime JS import; ambient types declared
2
2
  // eslint-disable-next-line @stylistic/max-len -- Long
3
3
  // xpathVersion: 1 => browser/native XPathEvaluator API; 2 => xpath2.js, 3 => fontoxpath
4
4
  import fontoxpath from 'fontoxpath';
5
+ import {maybeAsyncLoop} from './maybeAsync.js';
6
+ import applyExtensions from './extendContext.js';
7
+ import {
8
+ queryIndexedDB,
9
+ xpathExpressionUsesIndexedDB,
10
+ evaluateXPathWithIndexedDB
11
+ } from './indexedDB.js';
5
12
  // import xsdValidator from 'xsd-validator';
6
13
 
7
14
  /**
@@ -19,7 +26,16 @@ const escapeRegexReplacement = (string) => {
19
26
  * @property {import('./index.js').
20
27
  * JoiningTransformer} joiningTransformer Joiner
21
28
  * @property {boolean} [errorOnEqualPriority]
29
+ * @property {boolean} [sync] - When true, throw if a template returns a
30
+ * Promise instead of awaiting it (disables `indexedDB()`)
31
+ * @property {boolean} [preventEval] - Whether to prevent eval in the JSONPath
32
+ * trailing segment of an `indexedDB(...)` expression
22
33
  * @property {(path: string) => number} [specificityPriorityResolver]
34
+ * @property {Record<string, unknown> & ThisType<
35
+ * import('./XPathTransformerContext.js').default &
36
+ * import('./context-extensions.js').ContextExtensions
37
+ * >} [extensions] Extra methods/values
38
+ * merged onto this context so templates can call `this.myHelper()`
23
39
  */
24
40
 
25
41
  /**
@@ -124,11 +140,19 @@ class XPathTransformerContext {
124
140
  this._preserveSpaceElements = [];
125
141
  /** @type {string[]} */
126
142
  this._stripSpaceElements = [];
143
+ if (config.extensions) {
144
+ applyExtensions(this, config.extensions);
145
+ }
127
146
  }
128
147
 
129
- /** @returns {import('./index.js').JoiningTransformer} */
148
+ /** @returns {import('./index.js').BuiltinJoiningTransformer} */
130
149
  _getJoiningTransformer () {
131
- return this._config.joiningTransformer;
150
+ // Engine internals only ever run against a built-in joiner (they touch
151
+ // concrete members such as `_modeConfig`); a custom stub is the caller's
152
+ // responsibility.
153
+ return /** @type {import('./index.js').BuiltinJoiningTransformer} */ (
154
+ this._config.joiningTransformer
155
+ );
132
156
  }
133
157
 
134
158
  /**
@@ -398,9 +422,13 @@ class XPathTransformerContext {
398
422
  });
399
423
 
400
424
  const preApplyContext = this._contextNode;
425
+ const isSync = Boolean(/** @type {any} */ (this._config).sync);
401
426
 
402
- // Process each node
403
- for (const node of nodes) {
427
+ // Process each node. `maybeAsyncLoop` keeps this a plain synchronous
428
+ // loop unless a template returns a Promise (e.g. via
429
+ // `await this.indexedDB(...)`), in which case it chains and the whole
430
+ // call becomes asynchronous.
431
+ const loopResult = maybeAsyncLoop(nodes, (node) => {
404
432
  // Path resolution simplified (could track full XPath if needed)
405
433
  const pathMatchedTemplates = modeMatched.filter((t) => {
406
434
  // Basic matching: template.path is XPath tested for existence
@@ -438,7 +466,7 @@ class XPathTransformerContext {
438
466
  }
439
467
  if (onNoMatch === 'deep-skip') {
440
468
  // Skip this node entirely
441
- continue;
469
+ return undefined;
442
470
  }
443
471
  if (onNoMatch === 'shallow-copy') {
444
472
  // Output the node without processing children
@@ -451,7 +479,7 @@ class XPathTransformerContext {
451
479
  } else if (node.nodeType === 3 && node.nodeValue) { // Text
452
480
  joiner.text(node.nodeValue);
453
481
  }
454
- continue;
482
+ return undefined;
455
483
  }
456
484
  if (onNoMatch === 'deep-copy') {
457
485
  // Output the node and all descendants
@@ -462,7 +490,7 @@ class XPathTransformerContext {
462
490
  } else if (node.nodeType === 3 && node.nodeValue) { // Text
463
491
  joiner.text(node.nodeValue);
464
492
  }
465
- continue;
493
+ return undefined;
466
494
  }
467
495
  if (onNoMatch === 'text-only-copy') {
468
496
  // Output only text content
@@ -474,7 +502,7 @@ class XPathTransformerContext {
474
502
  joiner.text(textContent);
475
503
  }
476
504
  }
477
- continue;
505
+ return undefined;
478
506
  }
479
507
  // 'apply-templates', 'shallow-skip', or other:
480
508
  // use default template rules
@@ -569,11 +597,45 @@ class XPathTransformerContext {
569
597
  const prevTemplateParams = this._params;
570
598
  this._params = {0: node};
571
599
 
600
+ /**
601
+ * The template may return synchronously or return a Promise (e.g. from
602
+ * `await this.indexedDB(...)`), which is awaited unless `config.sync`.
603
+ * @type {any}
604
+ */
572
605
  const ret = templateObj.template.call(this, node, {mode});
573
606
 
574
607
  // Restore previous parameter context
575
608
  this._params = prevTemplateParams;
576
609
 
610
+ if (ret !== null && typeof ret !== 'undefined' &&
611
+ typeof ret.then === 'function') {
612
+ if (isSync) {
613
+ throw new Error(
614
+ 'A template returned a Promise but JTLT is configured with ' +
615
+ '`sync: true`.'
616
+ );
617
+ }
618
+ // eslint-disable-next-line @stylistic/max-len -- Long
619
+ // eslint-disable-next-line promise/prefer-await-to-then -- intentional dynamic sync/async
620
+ return ret.then((/** @type {any} */ resolvedRet) => {
621
+ if (typeof resolvedRet !== 'undefined') {
622
+ const joiner = this._getJoiningTransformer();
623
+ /* c8 ignore start -- _openTagState only on
624
+ StringJoiningTransformer; string-output defensive check */
625
+ // @ts-expect-error -- _openTagState: StringJoiningTransformer only
626
+ if (joiner._openTagState) {
627
+ joiner.append('>');
628
+ // @ts-expect-error -- _openTagState: StringJoiningTransformer
629
+ joiner._openTagState = false;
630
+ }
631
+ /* c8 ignore stop */
632
+ joiner.append(resolvedRet);
633
+ }
634
+ this._contextNode = node;
635
+ return undefined;
636
+ });
637
+ }
638
+
577
639
  if (typeof ret !== 'undefined') {
578
640
  const joiner = this._getJoiningTransformer();
579
641
  // Close any open tag before appending template return value
@@ -586,6 +648,18 @@ class XPathTransformerContext {
586
648
  joiner.append(ret);
587
649
  }
588
650
  this._contextNode = node; // Restore (placeholder for more complex state)
651
+ return undefined;
652
+ });
653
+
654
+ if (typeof loopResult?.then === 'function') {
655
+ return /** @type {any} */ (
656
+ // eslint-disable-next-line @stylistic/max-len -- Long
657
+ // eslint-disable-next-line promise/prefer-await-to-then -- intentional dynamic sync/async
658
+ loopResult.then(() => {
659
+ this._contextNode = preApplyContext;
660
+ return this;
661
+ })
662
+ );
589
663
  }
590
664
 
591
665
  this._contextNode = preApplyContext;
@@ -691,7 +765,10 @@ class XPathTransformerContext {
691
765
  * @param {string} [options.groupEndingWith] - Ends group when expression
692
766
  * matches
693
767
  * @param {(
694
- * this: XPathTransformerContext, key: any, items: Node[], ctx: any
768
+ * this: XPathTransformerContext,
769
+ * key: unknown,
770
+ * items: Node[],
771
+ * ctx: XPathTransformerContext
695
772
  * ) => void} cb - Callback receives (groupingKey, groupItems, context)
696
773
  * @returns {this}
697
774
  */
@@ -919,25 +996,77 @@ class XPathTransformerContext {
919
996
 
920
997
  /**
921
998
  * Returns the current grouping key (for use within forEachGroup callback).
922
- * @returns {any}
999
+ * @returns {unknown}
923
1000
  */
924
1001
  currentGroupingKey () {
925
1002
  return /** @type {any} */ (this)._currentGroupingKey;
926
1003
  }
927
1004
 
1005
+ /**
1006
+ * Directly query IndexedDB from within a template, e.g.
1007
+ * `await this.indexedDB('myDB', 'myStore', {index: 'byAge'})`.
1008
+ *
1009
+ * Since IndexedDB access is asynchronous, this is unavailable when JTLT is
1010
+ * configured with `sync: true`.
1011
+ * @param {string} dbName - Database name
1012
+ * @param {string} storeName - Object store name
1013
+ * @param {import('./indexedDB.js').QueryOptions} [options] - Query options
1014
+ * @returns {Promise<unknown[]>} The matching records
1015
+ */
1016
+ indexedDB (dbName, storeName, options) {
1017
+ if (/** @type {any} */ (this._config).sync) {
1018
+ throw new Error(
1019
+ 'The `indexedDB()` API is unavailable when JTLT is configured with ' +
1020
+ '`sync: true`.'
1021
+ );
1022
+ }
1023
+ return queryIndexedDB(dbName, storeName, options);
1024
+ }
1025
+
1026
+ /**
1027
+ * Evaluate an XPath selector that calls the `jtlt:indexedDB(...)` function,
1028
+ * awaiting the underlying IndexedDB reads, then append the string result.
1029
+ * Used by {@link valueOf}. Callers reject `config.sync`.
1030
+ * @param {string} selectStr
1031
+ * @returns {Promise<XPathTransformerContext>}
1032
+ */
1033
+ async _appendIndexedDBValue (selectStr) {
1034
+ const value = await evaluateXPathWithIndexedDB(
1035
+ selectStr, this._contextNode
1036
+ );
1037
+ this._getJoiningTransformer().text(value);
1038
+ return this;
1039
+ }
1040
+
928
1041
  /**
929
1042
  * Append the value from an XPath expression or the context node text.
930
1043
  * @param {string|object} [select]
931
1044
  * @returns {XPathTransformerContext}
932
1045
  */
933
1046
  valueOf (select) {
934
- const jt = this._getJoiningTransformer();
935
- let val;
936
-
937
1047
  const selectStr = typeof select === 'object'
938
1048
  ? /** @type {{select?: string}} */ (select).select
939
1049
  : select;
940
1050
 
1051
+ // `jtlt:indexedDB(...)` is a registered XPath function backed by async
1052
+ // IndexedDB reads. When it appears in the selector, evaluate the whole
1053
+ // expression asynchronously (fontoxpath itself stays synchronous) and
1054
+ // return a Promise so templates can `await this.valueOf(...)`.
1055
+ if (xpathExpressionUsesIndexedDB(selectStr)) {
1056
+ if (/** @type {any} */ (this._config).sync) {
1057
+ throw new Error(
1058
+ 'The `indexedDB()` XPath function is unavailable when JTLT is ' +
1059
+ 'configured with `sync: true`.'
1060
+ );
1061
+ }
1062
+ return /** @type {any} */ (
1063
+ this._appendIndexedDBValue(/** @type {string} */ (selectStr))
1064
+ );
1065
+ }
1066
+
1067
+ const jt = this._getJoiningTransformer();
1068
+ let val;
1069
+
941
1070
  // Check if select is a custom function call: prefix:name(...)
942
1071
  // Try to parse and invoke registered function
943
1072
  if (selectStr && (/^[^:]+:[^\(]+\(/v).test(selectStr)) {
@@ -1039,7 +1168,7 @@ class XPathTransformerContext {
1039
1168
  const resultToAppend = Array.isArray(result)
1040
1169
  ? result.join(' ')
1041
1170
  : result;
1042
- jt.append(resultToAppend);
1171
+ jt.append(/** @type {string | Node} */ (resultToAppend));
1043
1172
  return this;
1044
1173
  }
1045
1174
  /* c8 ignore start -- Error handler for malformed function calls */
@@ -2002,8 +2131,8 @@ class XPathTransformerContext {
2002
2131
  /**
2003
2132
  * Invoke a registered stylesheet function with positional arguments.
2004
2133
  * @param {string} name - Function name (with namespace)
2005
- * @param {any[]} args - Positional arguments
2006
- * @returns {any} Function return value
2134
+ * @param {unknown[]} args - Positional arguments
2135
+ * @returns {unknown} Function return value
2007
2136
  */
2008
2137
  invokeFunctionByArity (name, args = []) {
2009
2138
  return this._getJoiningTransformer().invokeFunctionByArity(name, args);