jtlt 0.14.0 → 0.16.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.
@@ -3,6 +3,7 @@ import xpath2 from 'xpath2.js'; // Runtime JS import; ambient types declared
3
3
  // xpathVersion: 1 => browser/native XPathEvaluator API; 2 => xpath2.js, 3 => fontoxpath
4
4
  import fontoxpath from 'fontoxpath';
5
5
  import {maybeAsyncLoop} from './maybeAsync.js';
6
+ import applyExtensions from './extendContext.js';
6
7
  import {
7
8
  queryIndexedDB,
8
9
  xpathExpressionUsesIndexedDB,
@@ -30,6 +31,14 @@ const escapeRegexReplacement = (string) => {
30
31
  * @property {boolean} [preventEval] - Whether to prevent eval in the JSONPath
31
32
  * trailing segment of an `indexedDB(...)` expression
32
33
  * @property {(path: string) => number} [specificityPriorityResolver]
34
+ * @property {Record<string, unknown>} [params] Runtime parameter values
35
+ * (like an XSLT processor's stylesheet parameters); a `param()` with a
36
+ * matching name uses this value instead of its declared default
37
+ * @property {Record<string, unknown> & ThisType<
38
+ * import('./XPathTransformerContext.js').default &
39
+ * import('./context-extensions.js').ContextExtensions
40
+ * >} [extensions] Extra methods/values
41
+ * merged onto this context so templates can call `this.myHelper()`
33
42
  */
34
43
 
35
44
  /**
@@ -130,10 +139,26 @@ class XPathTransformerContext {
130
139
  this._currPath = undefined; // XPath string of current context
131
140
  /** @type {Record<string, any> | undefined} */
132
141
  this._params = undefined;
142
+ /**
143
+ * Parameter values supplied at runtime via `config.params`, mirroring the
144
+ * stylesheet parameters an XSLT processor is handed. A `param()` whose
145
+ * name appears here takes this value instead of its declared default.
146
+ * @type {Record<string, unknown>}
147
+ */
148
+ this._runtimeParams = /** @type {any} */ (config).params || {};
149
+ /**
150
+ * Parameters staged by `withParam()` and consumed (then cleared) by the
151
+ * next `callTemplate()` or `applyTemplates()` call.
152
+ * @type {{name: string, select?: string, value?: unknown}[] | undefined}
153
+ */
154
+ this._pendingParams = undefined;
133
155
  /** @type {string[]} */
134
156
  this._preserveSpaceElements = [];
135
157
  /** @type {string[]} */
136
158
  this._stripSpaceElements = [];
159
+ if (config.extensions) {
160
+ applyExtensions(this, config.extensions);
161
+ }
137
162
  }
138
163
 
139
164
  /** @returns {import('./index.js').BuiltinJoiningTransformer} */
@@ -402,6 +427,12 @@ class XPathTransformerContext {
402
427
  } else {
403
428
  select ||= '*';
404
429
  }
430
+ // Resolve params staged via `this.withParam()` in the calling context,
431
+ // once, before iterating; each matched template receives them
432
+ // (`xsl:apply-templates` / `xsl:with-param`).
433
+ /** @type {Record<string, unknown>} */
434
+ const appliedParams = {};
435
+ this._drainPendingParams(appliedParams);
405
436
  const nodesResult = this._evalXPath(select, true);
406
437
  const nodes = /** @type {Node[]} */ (nodesResult);
407
438
  const modeMatched = this._templates.filter((t) => {
@@ -586,7 +617,7 @@ class XPathTransformerContext {
586
617
 
587
618
  // Set up parameter context for valueOf() access in templates
588
619
  const prevTemplateParams = this._params;
589
- this._params = {0: node};
620
+ this._params = {0: node, ...appliedParams};
590
621
 
591
622
  /**
592
623
  * The template may return synchronously or return a Promise (e.g. from
@@ -679,6 +710,10 @@ class XPathTransformerContext {
679
710
  const params = {};
680
711
  this._params = params;
681
712
 
713
+ // Params staged via `this.withParam()` seed the set; explicit `withParam`
714
+ // entries below override any of the same name (`xsl:with-param`).
715
+ this._drainPendingParams(params);
716
+
682
717
  withParams.forEach((withParam, index) => {
683
718
  const value = withParam.value !== undefined
684
719
  ? withParam.value
@@ -1143,9 +1178,8 @@ class XPathTransformerContext {
1143
1178
  // Parameter reference
1144
1179
  if (arg.startsWith('$')) {
1145
1180
  const paramName = arg.slice(1);
1146
- return this._params && Object.hasOwn(this._params, paramName)
1147
- ? this._params[paramName]
1148
- : undefined;
1181
+ const {has, value} = this._lookupParam(paramName);
1182
+ return has ? value : undefined;
1149
1183
  }
1150
1184
  // XPath expression - evaluate it
1151
1185
  return this._evalXPath(arg, false);
@@ -1184,9 +1218,8 @@ class XPathTransformerContext {
1184
1218
  if (valueExpr.trimStart().startsWith('$')) {
1185
1219
  // Parameter reference
1186
1220
  const paramName = valueExpr.trim().slice(1);
1187
- numValue = this._params && Object.hasOwn(this._params, paramName)
1188
- ? this._params[paramName]
1189
- : 0;
1221
+ const {has, value} = this._lookupParam(paramName);
1222
+ numValue = has ? value : 0;
1190
1223
  } else {
1191
1224
  // Try to parse as number or evaluate as XPath
1192
1225
  const trimmed = valueExpr.trim();
@@ -1212,8 +1245,9 @@ class XPathTransformerContext {
1212
1245
  // Check if this is a parameter reference (starts with $)
1213
1246
  if (selectStr && selectStr.startsWith('$')) {
1214
1247
  const paramName = selectStr.slice(1);
1215
- if (this._params && Object.hasOwn(this._params, paramName)) {
1216
- val = this._params[paramName];
1248
+ const paramLookup = this._lookupParam(paramName);
1249
+ if (paramLookup.has) {
1250
+ val = paramLookup.value;
1217
1251
  // If val is a Node, extract its text content
1218
1252
  if (val && typeof val === 'object' && 'nodeType' in val) {
1219
1253
  if (val.nodeType === 3) {
@@ -1372,6 +1406,125 @@ class XPathTransformerContext {
1372
1406
  this.vars[name] = this.get(select, true);
1373
1407
  return this;
1374
1408
  }
1409
+
1410
+ /**
1411
+ * Normalize a `param()`/`withParam()` default/value argument to `{select}`
1412
+ * or `{value}`: a bare string is an XPath expression, `{value}` is a
1413
+ * literal, and `{select}` (or an omitted argument) is an expression.
1414
+ * @param {string|{select?: string, value?: unknown}|undefined} arg
1415
+ * @returns {{select?: string, value?: unknown}}
1416
+ * @private
1417
+ */
1418
+ // eslint-disable-next-line class-methods-use-this -- pure helper
1419
+ _paramSpec (arg) {
1420
+ if (typeof arg === 'string') {
1421
+ return {select: arg};
1422
+ }
1423
+ if (arg && 'value' in arg) {
1424
+ return {value: arg.value};
1425
+ }
1426
+ // `{select}` object, or omitted (a default of `undefined`).
1427
+ return {select: arg && arg.select};
1428
+ }
1429
+
1430
+ /**
1431
+ * Look up a parameter by name across the active with-param scope and the
1432
+ * runtime `config.params`, so runtime-supplied params act like XSLT global
1433
+ * parameters (visible to every template and expression).
1434
+ * @param {string} name
1435
+ * @returns {{has: boolean, value: any}}
1436
+ * @private
1437
+ */
1438
+ _lookupParam (name) {
1439
+ if (this._params && Object.hasOwn(this._params, name)) {
1440
+ return {has: true, value: this._params[name]};
1441
+ }
1442
+ if (Object.hasOwn(this._runtimeParams, name)) {
1443
+ return {has: true, value: this._runtimeParams[name]};
1444
+ }
1445
+ return {has: false, value: undefined};
1446
+ }
1447
+
1448
+ /**
1449
+ * Resolve a `{select}` or `{value}` parameter spec to its value in the
1450
+ * current context. An XPath `select` is resolved to a first/atomic value
1451
+ * (as `callTemplate`'s `withParam` already does), so `$name` references
1452
+ * render as a string rather than a node object.
1453
+ * @param {{select?: string, value?: unknown}} spec
1454
+ * @returns {any}
1455
+ * @private
1456
+ */
1457
+ _resolveParam (spec) {
1458
+ if ('value' in spec) {
1459
+ return spec.value;
1460
+ }
1461
+ if (spec.select === undefined) {
1462
+ return undefined;
1463
+ }
1464
+ const resolved = this.get(spec.select, false);
1465
+ // A node-set `select` yields an array; bind the first node so a `$name`
1466
+ // reference renders as its string-value (XSLT behavior).
1467
+ return Array.isArray(resolved) ? resolved[0] : resolved;
1468
+ }
1469
+
1470
+ /**
1471
+ * Resolve staged `withParam()` entries into `target` (in the calling
1472
+ * context) and clear the staged set. Shared by `callTemplate()` and
1473
+ * `applyTemplates()`.
1474
+ * @param {Record<string, unknown>} target
1475
+ * @returns {void}
1476
+ * @private
1477
+ */
1478
+ _drainPendingParams (target) {
1479
+ const pending = this._pendingParams;
1480
+ this._pendingParams = undefined;
1481
+ if (!pending) {
1482
+ return;
1483
+ }
1484
+ for (const entry of pending) {
1485
+ target[entry.name] = this._resolveParam(entry);
1486
+ }
1487
+ }
1488
+
1489
+ /**
1490
+ * Declare a template parameter, equivalent to `xsl:param`. Binds `name` to
1491
+ * the given default, unless a value was supplied by the caller (via
1492
+ * `this.withParam()` or `callTemplate`'s `withParam`) or at runtime (via
1493
+ * `config.params`), in which case the supplied value wins.
1494
+ * @param {string} name - Parameter name
1495
+ * @param {string|{select: string}|{value: unknown}} [select] - The default:
1496
+ * an XPath expression string, an explicit `{select}`, or a literal
1497
+ * `{value}`. Omitted means a default of `undefined`.
1498
+ * @returns {XPathTransformerContext}
1499
+ */
1500
+ param (name, select) {
1501
+ this._params ||= {};
1502
+ const supplied = this._lookupParam(name);
1503
+ // A caller's with-param or a `config.params` value overrides the default.
1504
+ const resolved = supplied.has
1505
+ ? supplied.value
1506
+ : this._resolveParam(this._paramSpec(select));
1507
+ this._params[name] = resolved;
1508
+ this.vars[name] = resolved;
1509
+ return this;
1510
+ }
1511
+
1512
+ /**
1513
+ * Stage a parameter for the next `callTemplate()` or `applyTemplates()`
1514
+ * call, equivalent to `xsl:with-param`. The staged set is consumed and
1515
+ * cleared by that call; entries are evaluated in the current (calling)
1516
+ * context.
1517
+ * @param {string} name - Parameter name
1518
+ * @param {string|{select: string}|{value: unknown}} [select] - An XPath
1519
+ * expression string, an explicit `{select}`, or a literal `{value}`.
1520
+ * @returns {XPathTransformerContext}
1521
+ */
1522
+ withParam (name, select) {
1523
+ this._pendingParams ||= [];
1524
+ this._pendingParams.push({name, ...this._paramSpec(select)});
1525
+ return this;
1526
+ }
1527
+
1375
1528
  /**
1376
1529
  * Log a message (for debugging).
1377
1530
  * @param {unknown} json Any value
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Consumer-augmentable registry of the helper methods/values added to a
3
+ * template context through the `extensions` option.
4
+ *
5
+ * `extensions` are merged onto the `this` seen inside templates at runtime,
6
+ * but TypeScript cannot see them unless they are declared here. Augment this
7
+ * interface with declaration merging so calls like `this.myHelper()` inside a
8
+ * template type-check without suppressions:
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * declare module 'jtlt/context-extensions' {
13
+ * interface ContextExtensions {
14
+ * myHelper (): void;
15
+ * }
16
+ * }
17
+ * ```
18
+ *
19
+ * Leaving it un-augmented keeps templates strict: an undeclared `this.foo()`
20
+ * is still a type error.
21
+ *
22
+ * Intentionally empty; consumers populate it via declaration merging.
23
+ */
24
+ export interface ContextExtensions {}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Merges `extensions` onto a template context instance so templates can
3
+ * invoke them as `this.myHelper()`. Shared by JSONPathTransformerContext
4
+ * and XPathTransformerContext.
5
+ * @param {object} context - The context instance to extend
6
+ * @param {Record<string, unknown>} extensions - Extra methods/values
7
+ * @returns {void}
8
+ */
9
+ export default function applyExtensions (context, extensions) {
10
+ for (const key of Object.keys(extensions)) {
11
+ // eslint-disable-next-line @stylistic/max-len -- Long
12
+ // eslint-disable-next-line unicorn/no-computed-property-existence-check -- Needed
13
+ if (key in context) {
14
+ throw new Error(
15
+ `Extension property "${key}" conflicts with an existing ` +
16
+ 'context property.'
17
+ );
18
+ }
19
+ }
20
+ Object.assign(context, extensions);
21
+ }
package/src/index.js CHANGED
@@ -44,11 +44,15 @@ export const setWindow = (win) => {
44
44
  */
45
45
 
46
46
  /**
47
- * A callable template function with an engine-specific `this`.
47
+ * A callable template function with an engine-specific `this`. The `this`
48
+ * type is intersected with {@link ContextExtensions} so helpers registered
49
+ * through the `extensions` option are visible once a consumer augments that
50
+ * interface.
48
51
  * @template T
49
52
  * @template U
50
53
  * @template TCtx
51
- * @typedef {(this: TCtx,
54
+ * @typedef {(this: TCtx &
55
+ * import('./context-extensions.js').ContextExtensions,
52
56
  * value: ResultType<U>,
53
57
  * cfg?: {mode?: string}
54
58
  * ) => ResultType<T>|void|Promise<ResultType<T>|void>} TemplateFunction
@@ -214,6 +218,11 @@ export const setWindow = (win) => {
214
218
  * transformer.
215
219
  * @property {object} [parent] Parent object for context
216
220
  * @property {string} [parentProperty] Parent property name for context
221
+ * @property {Record<string, unknown>} [params] Parameter values supplied at
222
+ * runtime, mirroring the stylesheet parameters an XSLT processor is handed.
223
+ * A `this.param(name, default)` declaration whose name appears here resolves
224
+ * to this value instead of its default, and `$name` references such a
225
+ * parameter from any template.
217
226
  */
218
227
 
219
228
  /**
@@ -233,6 +242,10 @@ export const setWindow = (win) => {
233
242
  * forQuery?: [string, TemplateFunction<T, "json",
234
243
  * import('./XPathTransformerContext.js').default
235
244
  * >],
245
+ * extensions?: Record<string, unknown> & ThisType<
246
+ * import('./JSONPathTransformerContext.js').default<T> &
247
+ * import('./context-extensions.js').ContextExtensions
248
+ * >,
236
249
  * engineType?: 'jsonpath',
237
250
  * outputType?: T
238
251
  * }} JSONPathJTLTOptions
@@ -255,6 +268,10 @@ export const setWindow = (win) => {
255
268
  * forQuery?: [string, TemplateFunction<T, "dom",
256
269
  * import('./JSONPathTransformerContext.js').default
257
270
  * >],
271
+ * extensions?: Record<string, unknown> & ThisType<
272
+ * import('./XPathTransformerContext.js').default &
273
+ * import('./context-extensions.js').ContextExtensions
274
+ * >,
258
275
  * engineType: 'xpath',
259
276
  * xpathVersion?: 1|2|3.1,
260
277
  * outputType?: T
@@ -476,7 +493,9 @@ class JTLT {
476
493
  // eslint-disable-next-line @stylistic/max-len -- Long
477
494
  // eslint-disable-next-line unicorn/no-array-method-this-argument -- Not array
478
495
  this.forEach(path, (arg) => {
479
- const ret = fn.call(this, arg);
496
+ // `this` carries runtime `extensions`; a consumer's
497
+ // `ContextExtensions` augmentation would otherwise reject it here.
498
+ const ret = fn.call(/** @type {any} */ (this), arg);
480
499
  if (typeof ret !== 'undefined') {
481
500
  // @ts-expect-error Ok?
482
501
  this._getJoiningTransformer().append(ret);
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
  }