jtlt 0.13.0 → 0.14.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 (38) hide show
  1. package/CHANGES.md +10 -0
  2. package/README.md +99 -85
  3. package/demo/calltemplate-params-demo.js +13 -22
  4. package/demo/vendor/jhtml/src/SAJJ/SAJJ.ObjectArrayDelegator.js +17 -17
  5. package/demo/vendor/jhtml/src/SAJJ/SAJJ.js +60 -61
  6. package/demo/vendor/jhtml/src/jhtml-browser.js +1 -0
  7. package/demo/vendor/jhtml/src/jhtml-node.js +1 -0
  8. package/demo/vendor/jhtml/src/jhtml.js +18 -15
  9. package/dist/AbstractJoiningTransformer.d.ts +5 -5
  10. package/dist/AbstractJoiningTransformer.d.ts.map +1 -1
  11. package/dist/JSONPathTransformer.d.ts +13 -12
  12. package/dist/JSONPathTransformer.d.ts.map +1 -1
  13. package/dist/JSONPathTransformerContext.d.ts +50 -20
  14. package/dist/JSONPathTransformerContext.d.ts.map +1 -1
  15. package/dist/XPathTransformer.d.ts +9 -2
  16. package/dist/XPathTransformer.d.ts.map +1 -1
  17. package/dist/XPathTransformerContext.d.ts +46 -9
  18. package/dist/XPathTransformerContext.d.ts.map +1 -1
  19. package/dist/index.d.ts +104 -10
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/indexedDB.d.ts +107 -0
  22. package/dist/indexedDB.d.ts.map +1 -0
  23. package/dist/maybeAsync.d.ts +15 -0
  24. package/dist/maybeAsync.d.ts.map +1 -0
  25. package/docs/API.expanded.md +70 -20
  26. package/docs/API.md +34 -4
  27. package/docs/TO-DO.md +2 -2
  28. package/eslint.config.js +11 -3
  29. package/package.json +7 -5
  30. package/pnpm-workspace.yaml +9 -0
  31. package/src/AbstractJoiningTransformer.js +3 -3
  32. package/src/JSONPathTransformer.js +45 -7
  33. package/src/JSONPathTransformerContext.js +157 -22
  34. package/src/XPathTransformer.js +33 -1
  35. package/src/XPathTransformerContext.js +136 -16
  36. package/src/index.js +85 -8
  37. package/src/indexedDB.js +530 -0
  38. package/src/maybeAsync.js +44 -0
@@ -2,6 +2,12 @@ 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 {
7
+ queryIndexedDB,
8
+ xpathExpressionUsesIndexedDB,
9
+ evaluateXPathWithIndexedDB
10
+ } from './indexedDB.js';
5
11
  // import xsdValidator from 'xsd-validator';
6
12
 
7
13
  /**
@@ -19,6 +25,10 @@ const escapeRegexReplacement = (string) => {
19
25
  * @property {import('./index.js').
20
26
  * JoiningTransformer} joiningTransformer Joiner
21
27
  * @property {boolean} [errorOnEqualPriority]
28
+ * @property {boolean} [sync] - When true, throw if a template returns a
29
+ * Promise instead of awaiting it (disables `indexedDB()`)
30
+ * @property {boolean} [preventEval] - Whether to prevent eval in the JSONPath
31
+ * trailing segment of an `indexedDB(...)` expression
22
32
  * @property {(path: string) => number} [specificityPriorityResolver]
23
33
  */
24
34
 
@@ -126,9 +136,14 @@ class XPathTransformerContext {
126
136
  this._stripSpaceElements = [];
127
137
  }
128
138
 
129
- /** @returns {import('./index.js').JoiningTransformer} */
139
+ /** @returns {import('./index.js').BuiltinJoiningTransformer} */
130
140
  _getJoiningTransformer () {
131
- return this._config.joiningTransformer;
141
+ // Engine internals only ever run against a built-in joiner (they touch
142
+ // concrete members such as `_modeConfig`); a custom stub is the caller's
143
+ // responsibility.
144
+ return /** @type {import('./index.js').BuiltinJoiningTransformer} */ (
145
+ this._config.joiningTransformer
146
+ );
132
147
  }
133
148
 
134
149
  /**
@@ -398,9 +413,13 @@ class XPathTransformerContext {
398
413
  });
399
414
 
400
415
  const preApplyContext = this._contextNode;
416
+ const isSync = Boolean(/** @type {any} */ (this._config).sync);
401
417
 
402
- // Process each node
403
- for (const node of nodes) {
418
+ // Process each node. `maybeAsyncLoop` keeps this a plain synchronous
419
+ // loop unless a template returns a Promise (e.g. via
420
+ // `await this.indexedDB(...)`), in which case it chains and the whole
421
+ // call becomes asynchronous.
422
+ const loopResult = maybeAsyncLoop(nodes, (node) => {
404
423
  // Path resolution simplified (could track full XPath if needed)
405
424
  const pathMatchedTemplates = modeMatched.filter((t) => {
406
425
  // Basic matching: template.path is XPath tested for existence
@@ -438,7 +457,7 @@ class XPathTransformerContext {
438
457
  }
439
458
  if (onNoMatch === 'deep-skip') {
440
459
  // Skip this node entirely
441
- continue;
460
+ return undefined;
442
461
  }
443
462
  if (onNoMatch === 'shallow-copy') {
444
463
  // Output the node without processing children
@@ -451,7 +470,7 @@ class XPathTransformerContext {
451
470
  } else if (node.nodeType === 3 && node.nodeValue) { // Text
452
471
  joiner.text(node.nodeValue);
453
472
  }
454
- continue;
473
+ return undefined;
455
474
  }
456
475
  if (onNoMatch === 'deep-copy') {
457
476
  // Output the node and all descendants
@@ -462,7 +481,7 @@ class XPathTransformerContext {
462
481
  } else if (node.nodeType === 3 && node.nodeValue) { // Text
463
482
  joiner.text(node.nodeValue);
464
483
  }
465
- continue;
484
+ return undefined;
466
485
  }
467
486
  if (onNoMatch === 'text-only-copy') {
468
487
  // Output only text content
@@ -474,7 +493,7 @@ class XPathTransformerContext {
474
493
  joiner.text(textContent);
475
494
  }
476
495
  }
477
- continue;
496
+ return undefined;
478
497
  }
479
498
  // 'apply-templates', 'shallow-skip', or other:
480
499
  // use default template rules
@@ -569,11 +588,45 @@ class XPathTransformerContext {
569
588
  const prevTemplateParams = this._params;
570
589
  this._params = {0: node};
571
590
 
591
+ /**
592
+ * The template may return synchronously or return a Promise (e.g. from
593
+ * `await this.indexedDB(...)`), which is awaited unless `config.sync`.
594
+ * @type {any}
595
+ */
572
596
  const ret = templateObj.template.call(this, node, {mode});
573
597
 
574
598
  // Restore previous parameter context
575
599
  this._params = prevTemplateParams;
576
600
 
601
+ if (ret !== null && typeof ret !== 'undefined' &&
602
+ typeof ret.then === 'function') {
603
+ if (isSync) {
604
+ throw new Error(
605
+ 'A template returned a Promise but JTLT is configured with ' +
606
+ '`sync: true`.'
607
+ );
608
+ }
609
+ // eslint-disable-next-line @stylistic/max-len -- Long
610
+ // eslint-disable-next-line promise/prefer-await-to-then -- intentional dynamic sync/async
611
+ return ret.then((/** @type {any} */ resolvedRet) => {
612
+ if (typeof resolvedRet !== 'undefined') {
613
+ const joiner = this._getJoiningTransformer();
614
+ /* c8 ignore start -- _openTagState only on
615
+ StringJoiningTransformer; string-output defensive check */
616
+ // @ts-expect-error -- _openTagState: StringJoiningTransformer only
617
+ if (joiner._openTagState) {
618
+ joiner.append('>');
619
+ // @ts-expect-error -- _openTagState: StringJoiningTransformer
620
+ joiner._openTagState = false;
621
+ }
622
+ /* c8 ignore stop */
623
+ joiner.append(resolvedRet);
624
+ }
625
+ this._contextNode = node;
626
+ return undefined;
627
+ });
628
+ }
629
+
577
630
  if (typeof ret !== 'undefined') {
578
631
  const joiner = this._getJoiningTransformer();
579
632
  // Close any open tag before appending template return value
@@ -586,6 +639,18 @@ class XPathTransformerContext {
586
639
  joiner.append(ret);
587
640
  }
588
641
  this._contextNode = node; // Restore (placeholder for more complex state)
642
+ return undefined;
643
+ });
644
+
645
+ if (typeof loopResult?.then === 'function') {
646
+ return /** @type {any} */ (
647
+ // eslint-disable-next-line @stylistic/max-len -- Long
648
+ // eslint-disable-next-line promise/prefer-await-to-then -- intentional dynamic sync/async
649
+ loopResult.then(() => {
650
+ this._contextNode = preApplyContext;
651
+ return this;
652
+ })
653
+ );
589
654
  }
590
655
 
591
656
  this._contextNode = preApplyContext;
@@ -691,7 +756,10 @@ class XPathTransformerContext {
691
756
  * @param {string} [options.groupEndingWith] - Ends group when expression
692
757
  * matches
693
758
  * @param {(
694
- * this: XPathTransformerContext, key: any, items: Node[], ctx: any
759
+ * this: XPathTransformerContext,
760
+ * key: unknown,
761
+ * items: Node[],
762
+ * ctx: XPathTransformerContext
695
763
  * ) => void} cb - Callback receives (groupingKey, groupItems, context)
696
764
  * @returns {this}
697
765
  */
@@ -919,25 +987,77 @@ class XPathTransformerContext {
919
987
 
920
988
  /**
921
989
  * Returns the current grouping key (for use within forEachGroup callback).
922
- * @returns {any}
990
+ * @returns {unknown}
923
991
  */
924
992
  currentGroupingKey () {
925
993
  return /** @type {any} */ (this)._currentGroupingKey;
926
994
  }
927
995
 
996
+ /**
997
+ * Directly query IndexedDB from within a template, e.g.
998
+ * `await this.indexedDB('myDB', 'myStore', {index: 'byAge'})`.
999
+ *
1000
+ * Since IndexedDB access is asynchronous, this is unavailable when JTLT is
1001
+ * configured with `sync: true`.
1002
+ * @param {string} dbName - Database name
1003
+ * @param {string} storeName - Object store name
1004
+ * @param {import('./indexedDB.js').QueryOptions} [options] - Query options
1005
+ * @returns {Promise<unknown[]>} The matching records
1006
+ */
1007
+ indexedDB (dbName, storeName, options) {
1008
+ if (/** @type {any} */ (this._config).sync) {
1009
+ throw new Error(
1010
+ 'The `indexedDB()` API is unavailable when JTLT is configured with ' +
1011
+ '`sync: true`.'
1012
+ );
1013
+ }
1014
+ return queryIndexedDB(dbName, storeName, options);
1015
+ }
1016
+
1017
+ /**
1018
+ * Evaluate an XPath selector that calls the `jtlt:indexedDB(...)` function,
1019
+ * awaiting the underlying IndexedDB reads, then append the string result.
1020
+ * Used by {@link valueOf}. Callers reject `config.sync`.
1021
+ * @param {string} selectStr
1022
+ * @returns {Promise<XPathTransformerContext>}
1023
+ */
1024
+ async _appendIndexedDBValue (selectStr) {
1025
+ const value = await evaluateXPathWithIndexedDB(
1026
+ selectStr, this._contextNode
1027
+ );
1028
+ this._getJoiningTransformer().text(value);
1029
+ return this;
1030
+ }
1031
+
928
1032
  /**
929
1033
  * Append the value from an XPath expression or the context node text.
930
1034
  * @param {string|object} [select]
931
1035
  * @returns {XPathTransformerContext}
932
1036
  */
933
1037
  valueOf (select) {
934
- const jt = this._getJoiningTransformer();
935
- let val;
936
-
937
1038
  const selectStr = typeof select === 'object'
938
1039
  ? /** @type {{select?: string}} */ (select).select
939
1040
  : select;
940
1041
 
1042
+ // `jtlt:indexedDB(...)` is a registered XPath function backed by async
1043
+ // IndexedDB reads. When it appears in the selector, evaluate the whole
1044
+ // expression asynchronously (fontoxpath itself stays synchronous) and
1045
+ // return a Promise so templates can `await this.valueOf(...)`.
1046
+ if (xpathExpressionUsesIndexedDB(selectStr)) {
1047
+ if (/** @type {any} */ (this._config).sync) {
1048
+ throw new Error(
1049
+ 'The `indexedDB()` XPath function is unavailable when JTLT is ' +
1050
+ 'configured with `sync: true`.'
1051
+ );
1052
+ }
1053
+ return /** @type {any} */ (
1054
+ this._appendIndexedDBValue(/** @type {string} */ (selectStr))
1055
+ );
1056
+ }
1057
+
1058
+ const jt = this._getJoiningTransformer();
1059
+ let val;
1060
+
941
1061
  // Check if select is a custom function call: prefix:name(...)
942
1062
  // Try to parse and invoke registered function
943
1063
  if (selectStr && (/^[^:]+:[^\(]+\(/v).test(selectStr)) {
@@ -1039,7 +1159,7 @@ class XPathTransformerContext {
1039
1159
  const resultToAppend = Array.isArray(result)
1040
1160
  ? result.join(' ')
1041
1161
  : result;
1042
- jt.append(resultToAppend);
1162
+ jt.append(/** @type {string | Node} */ (resultToAppend));
1043
1163
  return this;
1044
1164
  }
1045
1165
  /* c8 ignore start -- Error handler for malformed function calls */
@@ -2002,8 +2122,8 @@ class XPathTransformerContext {
2002
2122
  /**
2003
2123
  * Invoke a registered stylesheet function with positional arguments.
2004
2124
  * @param {string} name - Function name (with namespace)
2005
- * @param {any[]} args - Positional arguments
2006
- * @returns {any} Function return value
2125
+ * @param {unknown[]} args - Positional arguments
2126
+ * @returns {unknown} Function return value
2007
2127
  */
2008
2128
  invokeFunctionByArity (name, args = []) {
2009
2129
  return this._getJoiningTransformer().invokeFunctionByArity(name, args);
package/src/index.js CHANGED
@@ -51,7 +51,7 @@ export const setWindow = (win) => {
51
51
  * @typedef {(this: TCtx,
52
52
  * value: ResultType<U>,
53
53
  * cfg?: {mode?: string}
54
- * ) => ResultType<T>|void} TemplateFunction
54
+ * ) => ResultType<T>|void|Promise<ResultType<T>|void>} TemplateFunction
55
55
  */
56
56
 
57
57
  /**
@@ -80,11 +80,71 @@ export const setWindow = (win) => {
80
80
  */
81
81
 
82
82
  /**
83
+ * The output-sink surface a custom `joiningTransformer` may provide. Only
84
+ * `append` and `get` are required; the rest are optional because the engine
85
+ * guards each call. The built-in joiners' `append`/`string`/… signatures
86
+ * diverge (e.g. the DOM joiner also accepts `Node`), so under
87
+ * `strictFunctionTypes` no single structural type is a supertype of all
88
+ * three; this contract lists the surface with `unknown` parameters, and
89
+ * {@link JoiningTransformer} unions it with the concrete classes so real
90
+ * joiners still type precisely.
91
+ * @typedef {object} JoiningTransformerContract
92
+ * @property {(item: unknown) => unknown} append
93
+ * @property {() => unknown} get
94
+ * @property {(txt: string) => unknown} [text]
95
+ * @property {(str: unknown, cb?: () => void) => unknown} [string]
96
+ * @property {(num: unknown) => unknown} [number]
97
+ * @property {(
98
+ * obj: unknown, cb?: unknown, usePropertySets?: unknown, propSets?: unknown
99
+ * ) => unknown} [object]
100
+ * @property {(arr: unknown, cb?: unknown) => unknown} [array]
101
+ * @property {(
102
+ * name: string, atts?: unknown, children?: unknown,
103
+ * cb?: unknown, useAttributeSets?: unknown
104
+ * ) => unknown} [element]
105
+ * @property {(
106
+ * name: string, val: unknown, avoidAttEscape?: unknown
107
+ * ) => unknown} [attribute]
108
+ * @property {(text: string) => unknown} [comment]
109
+ * @property {(
110
+ * target: string, data: string
111
+ * ) => unknown} [processingInstruction]
112
+ * @property {(str: unknown) => unknown} [plainText]
113
+ * @property {(prop: unknown, val: unknown) => unknown} [propValue]
114
+ * @property {(item: unknown) => unknown} [rawAppend]
115
+ * @property {(prefix: string, namespaceURI: string) => unknown} [namespace]
116
+ * @property {(context: unknown) => unknown} [setContext]
117
+ * @property {(cfg: unknown) => unknown} [output]
118
+ * @property {(cfg: unknown) => unknown} [mode]
119
+ * @property {(cfg: unknown) => unknown} [stylesheet]
120
+ * @property {(cfg: unknown) => unknown} [function]
121
+ * @property {(
122
+ * name: string, args?: unknown[]
123
+ * ) => unknown} [invokeFunctionByArity]
124
+ * @property {(
125
+ * name: string, outputCharacters: unknown
126
+ * ) => unknown} [characterMap]
127
+ * @property {(name: string, attributes: unknown) => unknown} [attributeSet]
128
+ * @property {(
129
+ * stylesheetPrefix: string, resultPrefix: string
130
+ * ) => unknown} [namespaceAlias]
131
+ */
132
+
133
+ /**
134
+ * One of the three built-in joiners. Used where engine internals rely on
135
+ * concrete members (e.g. `_modeConfig`).
83
136
  * @typedef {(
84
137
  * StringJoiningTransformer|
85
138
  * DOMJoiningTransformer|
86
139
  * JSONJoiningTransformer
87
- * )} JoiningTransformer
140
+ * )} BuiltinJoiningTransformer
141
+ */
142
+
143
+ /**
144
+ * The type accepted for a config `joiningTransformer`: a built-in joiner or
145
+ * any object implementing {@link JoiningTransformerContract}.
146
+ * @typedef {BuiltinJoiningTransformer | JoiningTransformerContract
147
+ * } JoiningTransformer
88
148
  */
89
149
 
90
150
  /**
@@ -95,7 +155,7 @@ export const setWindow = (win) => {
95
155
  * @template T
96
156
  * @template {boolean|undefined} [E=false]
97
157
  * @typedef {T extends "json" ?
98
- * (E extends true ? any[] : unknown) :
158
+ * (E extends true ? unknown[] : unknown) :
99
159
  * T extends "string" ?
100
160
  * (E extends true ? string[] : string) :
101
161
  * (E extends true ? XMLDocument[] :
@@ -107,9 +167,12 @@ export const setWindow = (win) => {
107
167
  * @template T
108
168
  * @template {boolean|undefined} [E=false]
109
169
  * @typedef {object} BaseJTLTOptions
170
+ * @property {boolean} [sync] Off by default: the engine awaits any Promise a
171
+ * template returns (e.g. from `await this.indexedDB(...)`). Set `true` to
172
+ * forbid asynchrony — a template that returns a Promise then throws.
110
173
  * @property {(
111
174
  * result: ResultType<T, E>
112
- * ) => ResultType<T, E>|void} success A callback supplied
175
+ * ) => ResultType<T, E>|void} [success] A callback supplied
113
176
  * with a single argument that is the result of this instance's
114
177
  * transform() method. When used in TypeScript, this can be made
115
178
  * generic as `success<T>(result: T): void`.
@@ -199,7 +262,7 @@ export const setWindow = (win) => {
199
262
  */
200
263
 
201
264
  /**
202
- * @template {boolean|undefined} [E=any]
265
+ * @template {boolean|undefined} [E=boolean|undefined]
203
266
  * @typedef {JSONPathJTLTOptions<"json", E> |
204
267
  * JSONPathJTLTOptions<"string", E> |
205
268
  * JSONPathJTLTOptions<"dom", E> |
@@ -556,7 +619,8 @@ class JTLT {
556
619
  }
557
620
  throw new Error('You must wait until the ajax file is retrieved');
558
621
  }
559
- if (typeof this.config.success !== 'function') {
622
+ const {success} = this.config;
623
+ if (typeof success !== 'function') {
560
624
  throw new TypeError("You must supply a 'success' callback");
561
625
  }
562
626
 
@@ -585,7 +649,20 @@ class JTLT {
585
649
  );
586
650
  // The engine returns ResultType<T>. We cast through never to bypass
587
651
  // the impossible intersection type that TypeScript infers for the union.
588
- const ret = this.config.success(
652
+ // The engine returns a Promise instead when a template ran asynchronously
653
+ // (e.g. it performed an `await this.indexedDB(...)` fetch); under
654
+ // `config.sync` the engine throws rather than returning one.
655
+ const maybePromise = /** @type {{then?: unknown}} */ (result);
656
+ if (maybePromise && typeof maybePromise.then === 'function') {
657
+ return /** @type {any} */ (
658
+ // eslint-disable-next-line @stylistic/max-len -- Long
659
+ // eslint-disable-next-line promise/prefer-await-to-then -- intentional dynamic sync/async
660
+ /** @type {Promise<never>} */ (result).then((res) => {
661
+ return /** @type {any} */ (success(res));
662
+ })
663
+ );
664
+ }
665
+ const ret = success(
589
666
  /** @type {never} */ (result)
590
667
  );
591
668
  return /** @type {any} */ (ret);
@@ -657,7 +734,7 @@ class JTLT {
657
734
  */
658
735
  /**
659
736
  * @param {Omit<JTLTOptions, "success">} cfg Options
660
- * @returns {Promise<any>}
737
+ * @returns {Promise<unknown>}
661
738
  */
662
739
  export function jtlt (cfg) {
663
740
  // eslint-disable-next-line promise/avoid-new -- Own API