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
@@ -0,0 +1,530 @@
1
+ import {JSONPath as jsonpath} from 'jsonpath-plus';
2
+ import fontoxpath from 'fontoxpath';
3
+
4
+ /**
5
+ * @typedef {typeof import("idb").openDB} OpenDBFunction
6
+ */
7
+
8
+ /**
9
+ * Memoized loader for the `idb` package's `openDB`, installing the
10
+ * `indexeddbshim` polyfill first when running outside a browser (e.g. Node).
11
+ * Held on an object so the memo write is a property assignment rather than a
12
+ * reassignment of a module-level binding.
13
+ * @type {{promise: Promise<OpenDBFunction> | undefined}}
14
+ */
15
+ const openDBLoader = {promise: undefined};
16
+
17
+ /**
18
+ * @returns {Promise<OpenDBFunction>}
19
+ */
20
+ function getOpenDB () {
21
+ openDBLoader.promise ||= (async () => {
22
+ /* c8 ignore next -- browser vs. Node branch; one runs per environment */
23
+ if (typeof window === 'undefined' || typeof indexedDB === 'undefined') {
24
+ const {default: setGlobalVars} = await import('indexeddbshim');
25
+ setGlobalVars(globalThis, {
26
+ checkOrigin: false, memoryDatabase: ''
27
+ });
28
+ }
29
+ const idb = await import('idb');
30
+ return idb.openDB;
31
+ })();
32
+ return openDBLoader.promise;
33
+ }
34
+
35
+ /**
36
+ * What each matched record contributes to the returned array. Mirrors
37
+ * `jsonpath-plus`'s `resultType`.
38
+ * - `'value'` (default): the stored record.
39
+ * - `'primaryKey'`: the object store's primary key.
40
+ * - `'key'`: the query key — the same as the primary key for a store query,
41
+ * or the indexed field's value when `index` is set.
42
+ * - `'all'`: a `{key, primaryKey, value}` object.
43
+ * @typedef {'value' | 'key' | 'primaryKey' | 'all'} IndexedDBResultType
44
+ */
45
+
46
+ /**
47
+ * @typedef {object} QueryOptions
48
+ * @property {string} [index]
49
+ * @property {{
50
+ * lower?: IDBValidKey, upper?: IDBValidKey,
51
+ * lowerOpen?: boolean, upperOpen?: boolean
52
+ * }} [range]
53
+ * @property {NonNullable<object|string|number>} [query]
54
+ * @property {'next' | 'nextunique' | 'prev' | 'prevunique'} [direction]
55
+ * @property {number} [count]
56
+ * @property {IndexedDBResultType} [resultType]
57
+ */
58
+
59
+ /**
60
+ * One entry of a cursor walk (as wrapped by `idb`, so `continue()` is async).
61
+ * @typedef {object} IdbCursorLike
62
+ * @property {IDBValidKey} key
63
+ * @property {IDBValidKey} primaryKey
64
+ * @property {unknown} value
65
+ * @property {() => Promise<IdbCursorLike | null>} continue
66
+ */
67
+
68
+ /**
69
+ * A `{key, primaryKey, value}` triple for a single matched entry, as returned
70
+ * by `queryIndexedDB(..., {resultType: 'all'})`.
71
+ * @typedef {{
72
+ * key: IDBValidKey, primaryKey: IDBValidKey, value: unknown
73
+ * }} IdbRecord
74
+ */
75
+
76
+ /**
77
+ * The subset of an IndexedDB object store / index (as wrapped by `idb`, so
78
+ * the request methods resolve Promises) that {@link queryIndexedDB} calls.
79
+ * @typedef {object} IdbQueryTarget
80
+ * @property {(
81
+ * range?: IDBKeyRange | null, direction?: IDBCursorDirection
82
+ * ) => Promise<IdbCursorLike | null>} openCursor
83
+ * @property {(
84
+ * range?: IDBKeyRange | null, direction?: IDBCursorDirection
85
+ * ) => Promise<IdbCursorLike | null>} openKeyCursor
86
+ * @property {(
87
+ * range?: IDBKeyRange | null, count?: number
88
+ * ) => Promise<unknown[]>} getAll
89
+ * @property {(
90
+ * range?: IDBKeyRange | null, count?: number
91
+ * ) => Promise<IDBValidKey[]>} getAllKeys
92
+ * @property {((options?: {
93
+ * query?: IDBKeyRange | null,
94
+ * count?: number,
95
+ * direction?: IDBCursorDirection
96
+ * }) => Promise<IdbRecord[]>)} [getAllRecords]
97
+ */
98
+
99
+ /**
100
+ * Walk a cursor over `target`, collecting `project(cursor)` for each entry.
101
+ * @param {IdbQueryTarget} target - An object store or index
102
+ * @param {IDBKeyRange|null} range
103
+ * @param {IDBCursorDirection|undefined} direction
104
+ * @param {number|undefined} count
105
+ * @param {(cursor: IdbCursorLike) => unknown} project
106
+ * @param {boolean} keyOnly - Open a key-only cursor (skips reading records)
107
+ * @returns {Promise<unknown[]>}
108
+ */
109
+ async function collectViaCursor (
110
+ target, range, direction, count, project, keyOnly
111
+ ) {
112
+ const results = [];
113
+ let cursor = await (keyOnly
114
+ ? target.openKeyCursor(range, direction)
115
+ : target.openCursor(range, direction));
116
+ // eslint-disable-next-line @stylistic/max-len -- Long
117
+ // eslint-disable-next-line no-unmodified-loop-condition -- `results` grows and `cursor` advances each iteration
118
+ while (cursor && (!count || results.length < count)) {
119
+ results.push(project(cursor));
120
+ // eslint-disable-next-line no-await-in-loop -- sequential cursor walk
121
+ cursor = await cursor.continue();
122
+ }
123
+ return results;
124
+ }
125
+
126
+ /**
127
+ * Read every matched entry as an {@link IdbRecord}, preferring the newer bulk
128
+ * `getAllRecords()` API and falling back to a cursor walk.
129
+ * @param {IdbQueryTarget} target - An object store or index
130
+ * @param {IDBKeyRange|null} range
131
+ * @param {IDBCursorDirection|undefined} direction
132
+ * @param {number|undefined} count
133
+ * @returns {Promise<IdbRecord[]>}
134
+ */
135
+ async function readAllRecords (target, range, direction, count) {
136
+ if (typeof target.getAllRecords === 'function') {
137
+ const records = await target.getAllRecords({
138
+ query: range ?? undefined, count, direction
139
+ });
140
+ return records.map((record) => ({
141
+ key: record.key, primaryKey: record.primaryKey, value: record.value
142
+ }));
143
+ }
144
+ /* c8 ignore start -- cursor fallback only where getAllRecords is absent */
145
+ return /** @type {Promise<IdbRecord[]>} */ (
146
+ collectViaCursor(target, range, direction, count, (cursor) => ({
147
+ key: cursor.key, primaryKey: cursor.primaryKey, value: cursor.value
148
+ }), false)
149
+ );
150
+ /* c8 ignore stop */
151
+ }
152
+
153
+ /**
154
+ * Read entries from an IndexedDB object store (or one of its indexes). The
155
+ * element type of the resolved array is narrowed by `options.resultType`.
156
+ * @overload
157
+ * @param {string} dbName
158
+ * @param {string} storeName
159
+ * @param {QueryOptions & {resultType: 'all'}} options
160
+ * @returns {Promise<IdbRecord[]>}
161
+ */
162
+ /**
163
+ * @overload
164
+ * @param {string} dbName
165
+ * @param {string} storeName
166
+ * @param {QueryOptions & {resultType: 'key' | 'primaryKey'}} options
167
+ * @returns {Promise<IDBValidKey[]>}
168
+ */
169
+ /**
170
+ * @overload
171
+ * @param {string} dbName
172
+ * @param {string} storeName
173
+ * @param {QueryOptions} [options]
174
+ * @returns {Promise<unknown[]>}
175
+ */
176
+ /**
177
+ * @param {string} dbName
178
+ * @param {string} storeName
179
+ * @param {QueryOptions} [options]
180
+ * @returns {Promise<unknown[]>}
181
+ */
182
+ export async function queryIndexedDB (dbName, storeName, options = {}) {
183
+ const {resultType = 'value', count, direction} = options;
184
+ const openDB = await getOpenDB();
185
+ const db = await openDB(dbName);
186
+ const tx = db.transaction(storeName, 'readonly');
187
+ const store = tx.objectStore(storeName);
188
+
189
+ const target = /** @type {IdbQueryTarget} */ (/** @type {unknown} */ (
190
+ options.index ? store.index(options.index) : store
191
+ ));
192
+
193
+ let range = null;
194
+ if (options.range) {
195
+ range = IDBKeyRange.bound(
196
+ options.range.lower,
197
+ options.range.upper,
198
+ options.range.lowerOpen || false,
199
+ options.range.upperOpen || false
200
+ );
201
+ } else if ('query' in options) {
202
+ range = IDBKeyRange.only(options.query);
203
+ }
204
+
205
+ const reversed = Boolean(direction && direction.startsWith('prev'));
206
+
207
+ if (resultType === 'all') {
208
+ return readAllRecords(target, range, direction, count);
209
+ }
210
+
211
+ // The index key is only available per-entry via a cursor.
212
+ if (resultType === 'key' && options.index) {
213
+ return collectViaCursor(
214
+ target, range, direction, count, (cursor) => cursor.key, true
215
+ );
216
+ }
217
+
218
+ if (reversed) {
219
+ // At this point `resultType` is 'value', 'primaryKey', or 'key' without
220
+ // an index; for a store cursor the key and primary key are the same.
221
+ return collectViaCursor(
222
+ target, range, direction, count,
223
+ resultType === 'value'
224
+ ? (cursor) => cursor.value
225
+ : (cursor) => cursor.primaryKey,
226
+ resultType !== 'value'
227
+ );
228
+ }
229
+
230
+ // Forward iteration has bulk APIs. For a store query the primary key and
231
+ // the query key are the same, so `getAllKeys` serves both.
232
+ if (resultType === 'value') {
233
+ return target.getAll(range, count);
234
+ }
235
+ return target.getAllKeys(range, count);
236
+ }
237
+
238
+ /**
239
+ * Split a raw argument string (the text between the parentheses of an
240
+ * `indexedDB(...)` call) into its top-level, comma-separated pieces, ignoring
241
+ * commas that appear inside quotes, objects (`{}`), arrays (`[]`) or nested
242
+ * parentheses.
243
+ * @param {string} argStr
244
+ * @returns {string[]}
245
+ */
246
+ function splitTopLevelArgs (argStr) {
247
+ const pieces = [];
248
+ let current = '';
249
+ let depth = 0;
250
+ /** @type {string|null} */
251
+ let quote = null;
252
+ for (let i = 0; i < argStr.length; i++) {
253
+ const ch = argStr[i];
254
+ if (quote) {
255
+ current += ch;
256
+ if (ch === quote && argStr[i - 1] !== '\\') {
257
+ quote = null;
258
+ }
259
+ continue;
260
+ }
261
+ if (ch === '"' || ch === "'") {
262
+ quote = ch;
263
+ current += ch;
264
+ continue;
265
+ }
266
+ if (['{', '[', '('].includes(ch)) {
267
+ depth++;
268
+ } else if (['}', ']', ')'].includes(ch)) {
269
+ depth--;
270
+ }
271
+ if (ch === ',' && depth === 0) {
272
+ pieces.push(current);
273
+ current = '';
274
+ continue;
275
+ }
276
+ current += ch;
277
+ }
278
+ if (current.trim() !== '') {
279
+ pieces.push(current);
280
+ }
281
+ return pieces;
282
+ }
283
+
284
+ /**
285
+ * Coerce a single raw argument token from an `indexedDB(...)` expression into a
286
+ * JavaScript value. Supports single/double-quoted strings, numbers, the
287
+ * literals `true`/`false`/`null`, and JSON object/array literals.
288
+ * @param {string} raw
289
+ * @returns {unknown}
290
+ */
291
+ function parseArgValue (raw) {
292
+ const s = raw.trim();
293
+ if (
294
+ (s.startsWith("'") && s.endsWith("'")) ||
295
+ (s.startsWith('"') && s.endsWith('"'))
296
+ ) {
297
+ return s.slice(1, -1);
298
+ }
299
+ if (s === 'true') {
300
+ return true;
301
+ }
302
+ if (s === 'false') {
303
+ return false;
304
+ }
305
+ if (s === 'null') {
306
+ return null;
307
+ }
308
+ if (s !== '' && !Number.isNaN(Number(s))) {
309
+ return Number(s);
310
+ }
311
+ if (s.startsWith('{') || s.startsWith('[')) {
312
+ try {
313
+ return JSON.parse(s);
314
+ } catch {
315
+ // Fall through to returning the raw string for callers to handle.
316
+ }
317
+ }
318
+ return s;
319
+ }
320
+
321
+ /**
322
+ * @typedef {object} ParsedIndexedDBExpression
323
+ * @property {string} dbName
324
+ * @property {string} storeName
325
+ * @property {QueryOptions|undefined} options
326
+ * @property {string} trailing - A JSONPath segment applied to the fetched
327
+ * records (e.g. `.*.name` or `[0].name`), with surrounding whitespace
328
+ * trimmed. Used by the JSONPath engine only; the XPath engine exposes
329
+ * `indexedDB()` as a registered XPath function instead (see
330
+ * {@link evaluateXPathWithIndexedDB}).
331
+ */
332
+
333
+ /**
334
+ * Detect and parse an `indexedDB(...)` call at the start of a selector string.
335
+ * @param {string} expr
336
+ * @returns {ParsedIndexedDBExpression|null} `null` when `expr` is not an
337
+ * `indexedDB(...)` expression.
338
+ */
339
+ export function parseIndexedDBExpression (expr) {
340
+ const match = (/^\s*indexedDB\((?<args>.*)\)(?<trailing>.*)$/v).exec(expr);
341
+ if (!match || !match.groups) {
342
+ return null;
343
+ }
344
+ const args = splitTopLevelArgs(match.groups.args).map(
345
+ (piece) => parseArgValue(piece)
346
+ );
347
+ const [dbName, storeName, options] = args;
348
+ return {
349
+ dbName: /** @type {string} */ (dbName),
350
+ storeName: /** @type {string} */ (storeName),
351
+ options: /** @type {QueryOptions|undefined} */ (
352
+ options && typeof options === 'object' ? options : undefined
353
+ ),
354
+ trailing: match.groups.trailing.trim()
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Fetch the records for a parsed `indexedDB(...)` expression and, when a
360
+ * trailing JSONPath segment is present (e.g. `.*.name`), evaluate it against
361
+ * the fetched records. Shared by the JSONPath and XPath engines.
362
+ * @param {ParsedIndexedDBExpression} parsed
363
+ * @param {{preventEval?: boolean}} [options]
364
+ * @returns {Promise<unknown>}
365
+ */
366
+ export async function resolveIndexedDBQuery (parsed, {preventEval} = {}) {
367
+ const {dbName, storeName, options, trailing} = parsed;
368
+ const data = await queryIndexedDB(dbName, storeName, options);
369
+ if (!trailing) {
370
+ return data;
371
+ }
372
+ // A JSONPath trailing segment always begins with a step (`.` or `[`).
373
+ return /** @type {(config: object) => unknown} */ (jsonpath)({
374
+ path: '$' + trailing,
375
+ json: data,
376
+ preventEval,
377
+ wrap: false,
378
+ returnType: 'value'
379
+ });
380
+ }
381
+
382
+ /**
383
+ * Namespace URI backing the predefined `jtlt` prefix under which the XPath
384
+ * `indexedDB()` function is registered.
385
+ */
386
+ export const JTLT_XPATH_NAMESPACE = 'urn:jtlt';
387
+
388
+ /**
389
+ * State for the process-global `indexedDB()` XPath function: the current
390
+ * collect/resolve mode, the pending request list, and the fetched-record
391
+ * cache. Kept on one module-level object so updates are property writes, not
392
+ * rebindings of a module binding.
393
+ */
394
+ const xpathIndexedDB = {
395
+ registered: false,
396
+ /** @type {'collect'|'resolve'} */
397
+ mode: 'resolve',
398
+ /** @type {{dbName: string, storeName: string, options: unknown}[]} */
399
+ requests: [],
400
+ /** @type {Map<string, unknown[]>} */
401
+ cache: new Map()
402
+ };
403
+
404
+ /**
405
+ * @param {string} dbName
406
+ * @param {string} storeName
407
+ * @param {unknown} options
408
+ * @returns {string}
409
+ */
410
+ function xpathCacheKey (dbName, storeName, options) {
411
+ return JSON.stringify([dbName, storeName, options ?? null]);
412
+ }
413
+
414
+ /**
415
+ * Implementation shared by both arities of the `jtlt:indexedDB` XPath
416
+ * function. In `collect` mode it records the requested query and returns an
417
+ * empty sequence; in `resolve` mode it returns the pre-fetched records.
418
+ * @param {unknown} _domFacade - fontoxpath dynamic context (unused)
419
+ * @param {string} dbName
420
+ * @param {string} storeName
421
+ * @param {unknown} [options] - An XPath map, surfaced to JS as a plain object
422
+ * @returns {unknown[]}
423
+ */
424
+ function xpathIndexedDBFunction (_domFacade, dbName, storeName, options) {
425
+ const opts = options ?? undefined;
426
+ if (xpathIndexedDB.mode === 'collect') {
427
+ xpathIndexedDB.requests.push({dbName, storeName, options: opts});
428
+ return [];
429
+ }
430
+ return xpathIndexedDB.cache.get(
431
+ xpathCacheKey(dbName, storeName, opts)
432
+ /* c8 ignore next -- resolve mode always finds the record cached in phase 2 */
433
+ ) ?? [];
434
+ }
435
+
436
+ /**
437
+ * Register `jtlt:indexedDB(dbName, storeName)` and
438
+ * `jtlt:indexedDB(dbName, storeName, options)` with fontoxpath. Idempotent.
439
+ * @returns {void}
440
+ */
441
+ function ensureXPathIndexedDBFunction () {
442
+ if (xpathIndexedDB.registered) {
443
+ return;
444
+ }
445
+ xpathIndexedDB.registered = true;
446
+ fontoxpath.registerCustomXPathFunction(
447
+ {namespaceURI: JTLT_XPATH_NAMESPACE, localName: 'indexedDB'},
448
+ ['xs:string', 'xs:string'], 'item()*',
449
+ xpathIndexedDBFunction
450
+ );
451
+ fontoxpath.registerCustomXPathFunction(
452
+ {namespaceURI: JTLT_XPATH_NAMESPACE, localName: 'indexedDB'},
453
+ ['xs:string', 'xs:string', 'map(*)'], 'item()*',
454
+ xpathIndexedDBFunction
455
+ );
456
+ }
457
+
458
+ /**
459
+ * @param {string} prefix
460
+ * @returns {string|null}
461
+ */
462
+ const xpathNamespaceResolver = (prefix) => {
463
+ /* c8 ignore next -- fontoxpath only resolves the explicit `jtlt` prefix */
464
+ return prefix === 'jtlt' ? JTLT_XPATH_NAMESPACE : null;
465
+ };
466
+
467
+ /**
468
+ * @param {unknown} expr
469
+ * @returns {boolean} Whether `expr` calls the `indexedDB()` XPath function
470
+ */
471
+ export function xpathExpressionUsesIndexedDB (expr) {
472
+ return typeof expr === 'string' && (/\bindexedDB\s*\(/v).test(expr);
473
+ }
474
+
475
+ /**
476
+ * Evaluate an XPath 3.1 expression that may call `jtlt:indexedDB(...)`,
477
+ * to a string. Because fontoxpath is synchronous, this first evaluates the
478
+ * expression in "collect" mode to discover every `indexedDB()` call, awaits
479
+ * those queries, then evaluates again with the records available.
480
+ * @param {string} selectStr
481
+ * @param {Node} contextNode
482
+ * @returns {Promise<string>}
483
+ */
484
+ export async function evaluateXPathWithIndexedDB (selectStr, contextNode) {
485
+ ensureXPathIndexedDBFunction();
486
+ const evalOptions = {
487
+ namespaceResolver: xpathNamespaceResolver,
488
+ language: fontoxpath.Language.XPATH_3_1_LANGUAGE
489
+ };
490
+
491
+ // Phase 1: discover the queries (the string result is discarded). During
492
+ // this pass `jtlt:indexedDB()` yields an empty sequence, so an otherwise
493
+ // valid expression can still raise a type error here; that is harmless
494
+ // because only the collected request list matters.
495
+ xpathIndexedDB.mode = 'collect';
496
+ xpathIndexedDB.requests = [];
497
+ /* c8 ignore start -- defensive: swallow phase-1 evaluation errors */
498
+ try {
499
+ fontoxpath.evaluateXPathToString(
500
+ selectStr, contextNode, null, null, evalOptions
501
+ );
502
+ } catch {
503
+ // Ignore: only the collected request list matters from this pass.
504
+ }
505
+ /* c8 ignore stop */
506
+
507
+ // Phase 2: fetch each distinct query and cache the records.
508
+ const fetched = new Set();
509
+ await Promise.all(xpathIndexedDB.requests.map(async (req) => {
510
+ const key = xpathCacheKey(req.dbName, req.storeName, req.options);
511
+ if (fetched.has(key)) {
512
+ return;
513
+ }
514
+ fetched.add(key);
515
+ xpathIndexedDB.cache.set(
516
+ key,
517
+ await queryIndexedDB(
518
+ req.dbName,
519
+ req.storeName,
520
+ /** @type {QueryOptions|undefined} */ (req.options)
521
+ )
522
+ );
523
+ }));
524
+
525
+ // Phase 3: evaluate for real, now that the records are available.
526
+ xpathIndexedDB.mode = 'resolve';
527
+ return fontoxpath.evaluateXPathToString(
528
+ selectStr, contextNode, null, null, evalOptions
529
+ );
530
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Iterate over an array, staying fully synchronous while `body` returns
3
+ * synchronously and only chaining Promises once `body` returns a thenable.
4
+ *
5
+ * This lets a nested asynchronous operation (e.g. an
6
+ * `await this.indexedDB(...)` fetch inside a template) suspend the loop,
7
+ * without forcing the common synchronous case through microtasks or
8
+ * breaking DOM-bound consumers.
9
+ * @template T
10
+ * @param {T[]} array
11
+ * @param {(item: T, index: number, arr: T[]) => any} body
12
+ * @returns {Promise<void>|void}
13
+ */
14
+ export function maybeAsyncLoop (array, body) {
15
+ let i = 0;
16
+
17
+ /**
18
+ * Advance past the awaited item and continue looping. Declared once (not
19
+ * inside the loop) so it can be reused as the `.then()` continuation.
20
+ * @returns {Promise<void>|void}
21
+ */
22
+ function resume () {
23
+ i++;
24
+ return next();
25
+ }
26
+
27
+ /**
28
+ * @returns {Promise<void>|void}
29
+ */
30
+ function next () {
31
+ while (i < array.length) {
32
+ const result = body(array[i], i, array);
33
+ if (result && typeof result.then === 'function') {
34
+ // eslint-disable-next-line @stylistic/max-len -- Long
35
+ // eslint-disable-next-line promise/prefer-await-to-then -- intentional dynamic sync/async
36
+ return result.then(resume);
37
+ }
38
+ i++;
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ return next();
44
+ }
package/tsconfig.json CHANGED
@@ -11,8 +11,8 @@
11
11
  "types": ["mocha"]
12
12
  },
13
13
  "include": [
14
- "*.js", "*.d.ts", "src/**/*.js", "demo/**/*.js", "test/**/*.js",
15
- "typings/xpath2-js.d.ts"
14
+ "*.js", "*.d.ts", "src/**/*.js", "src/**/*.d.ts", "demo/**/*.js",
15
+ "test/**/*.js", "test/**/*.d.ts", "typings/xpath2-js.d.ts"
16
16
  ],
17
17
  "exclude": ["node_modules", "test.js", "./dist/**/*.js", "demo/codemirror.esm.js", "demo/vendor"]
18
18
  }