jtlt 0.2.0 → 0.4.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.
- package/CHANGES.md +27 -0
- package/README.md +61 -164
- package/demo/calltemplate-params-demo.js +138 -0
- package/demo/codemirror.esm.js +28242 -0
- package/demo/codemirror.js +94 -0
- package/demo/index.css +7 -0
- package/demo/index.html +28 -0
- package/demo/index.js +210 -0
- package/demo/vendor/jamilih/dist/jml.mjs +2341 -0
- package/demo/vendor/jhtml/src/SAJJ/SAJJ.ObjectArrayDelegator.js +356 -0
- package/demo/vendor/jhtml/src/SAJJ/SAJJ.Stringifier.js +186 -0
- package/demo/vendor/jhtml/src/SAJJ/SAJJ.js +746 -0
- package/demo/vendor/jhtml/src/SAJJ/testing/SAJJ.html +33 -0
- package/demo/vendor/jhtml/src/SAJJ/testing/SAJJ.testing.js +25 -0
- package/demo/vendor/jhtml/src/jhtml-browser.js +5 -0
- package/demo/vendor/jhtml/src/jhtml-node.cts +3 -0
- package/demo/vendor/jhtml/src/jhtml-node.js +8 -0
- package/demo/vendor/jhtml/src/jhtml-node.mts +1 -0
- package/demo/vendor/jhtml/src/jhtml.cts +3 -0
- package/demo/vendor/jhtml/src/jhtml.js +602 -0
- package/demo/vendor/jhtml/src/jhtml.mts +1 -0
- package/demo/vendor/jsonpath-plus/dist/index-browser-esm.js +2158 -0
- package/demo/vendor/simple-get-json/dist/index-es.js +151 -0
- package/demo/xpath2-placeholder.js +1 -0
- package/dist/AbstractJoiningTransformer.d.ts +83 -9
- package/dist/AbstractJoiningTransformer.d.ts.map +1 -1
- package/dist/DOMJoiningTransformer.d.ts +85 -25
- package/dist/DOMJoiningTransformer.d.ts.map +1 -1
- package/dist/JSONJoiningTransformer.d.ts +159 -51
- package/dist/JSONJoiningTransformer.d.ts.map +1 -1
- package/dist/JSONPathTransformer.d.ts +37 -38
- package/dist/JSONPathTransformer.d.ts.map +1 -1
- package/dist/JSONPathTransformerContext.d.ts +334 -124
- package/dist/JSONPathTransformerContext.d.ts.map +1 -1
- package/dist/StringJoiningTransformer.d.ts +132 -41
- package/dist/StringJoiningTransformer.d.ts.map +1 -1
- package/dist/XPathTransformer.d.ts +35 -20
- package/dist/XPathTransformer.d.ts.map +1 -1
- package/dist/XPathTransformerContext.d.ts +281 -105
- package/dist/XPathTransformerContext.d.ts.map +1 -1
- package/dist/index-browser.d.ts +4 -0
- package/dist/index-browser.d.ts.map +1 -0
- package/dist/index-node.d.ts +4 -0
- package/dist/index-node.d.ts.map +1 -0
- package/dist/index.d.ts +330 -57
- package/dist/index.d.ts.map +1 -1
- package/dist/types.d.ts +204 -0
- package/dist/types.d.ts.map +1 -0
- package/docs/API.expanded.md +172 -5
- package/docs/API.md +92 -2
- package/docs/TO-DO.md +168 -0
- package/docs/calltemplate-params.md +251 -0
- package/eslint.config.js +11 -5
- package/package.json +35 -9
- package/pnpm-workspace.yaml +1 -0
- package/rollup.config.js +13 -0
- package/src/AbstractJoiningTransformer.js +54 -15
- package/src/DOMJoiningTransformer.js +275 -28
- package/src/JSONJoiningTransformer.js +351 -70
- package/src/JSONPathTransformer.js +48 -30
- package/src/JSONPathTransformerContext.js +729 -107
- package/src/StringJoiningTransformer.js +311 -57
- package/src/XPathTransformer.js +27 -12
- package/src/XPathTransformerContext.js +928 -99
- package/src/index-browser.js +5 -0
- package/src/index-node.js +7 -0
- package/src/index.js +502 -98
- package/tsconfig.json +5 -2
- package/typings/xpath2-js.d.ts +40 -1
- package/src/types/xpath2-js.d.ts +0 -2
|
@@ -1,51 +1,62 @@
|
|
|
1
1
|
import xpath2 from 'xpath2.js'; // Runtime JS import; ambient types declared
|
|
2
|
-
//
|
|
2
|
+
// eslint-disable-next-line @stylistic/max-len -- Long
|
|
3
|
+
// xpathVersion: 1 => browser/native XPathEvaluator API; 2 => xpath2.js, 3 => fontoxpath
|
|
4
|
+
import fontoxpath from 'fontoxpath';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {object} XPathTransformerContextConfig
|
|
8
|
+
* @property {unknown} [data] - XML/DOM root to transform
|
|
9
|
+
* @property {number} [xpathVersion] - 1, 2, 3.1 (default 1)
|
|
10
|
+
* @property {import('./index.js').
|
|
11
|
+
* JoiningTransformer} joiningTransformer Joiner
|
|
12
|
+
* @property {boolean} [errorOnEqualPriority]
|
|
13
|
+
* @property {(path: string) => number} [specificityPriorityResolver]
|
|
14
|
+
*/
|
|
3
15
|
|
|
4
16
|
/**
|
|
5
17
|
* Execution context for XPath-driven template application.
|
|
6
18
|
*
|
|
7
19
|
* Similar to JSONPathTransformerContext but uses XPath expressions on a
|
|
8
|
-
* DOM/XML-like tree. Supports XPath 1.0 (default)
|
|
9
|
-
* `xpathVersion: 2`.
|
|
20
|
+
* DOM/XML-like tree. Supports XPath 1.0 (default), 2.0 when
|
|
21
|
+
* `xpathVersion: 2`, or 3.1 when `xpathVersion: 3.1`.
|
|
10
22
|
*
|
|
11
23
|
* Expected config:
|
|
12
24
|
* - data: A Document, Element, or XML-like root node.
|
|
13
25
|
* - joiningTransformer: joiner with append(), string(), object(), array(), etc.
|
|
14
|
-
* - xpathVersion: 1|2 (default 1)
|
|
26
|
+
* - xpathVersion: 1|2|3.1 (default 1)
|
|
15
27
|
* - errorOnEqualPriority, specificityPriorityResolver (same semantics).
|
|
16
28
|
*/
|
|
17
29
|
class XPathTransformerContext {
|
|
18
30
|
/**
|
|
19
|
-
* @param {
|
|
20
|
-
* @param {
|
|
21
|
-
*
|
|
22
|
-
* @param {object} config.joiningTransformer Joiner
|
|
23
|
-
* @param {Function} config.joiningTransformer.append Append output
|
|
24
|
-
* @param {Function} config.joiningTransformer.get Get output
|
|
25
|
-
* @param {Function} config.joiningTransformer.string Emit string
|
|
26
|
-
* @param {Function} config.joiningTransformer.object Emit object
|
|
27
|
-
* @param {Function} config.joiningTransformer.array Emit array
|
|
28
|
-
* @param {boolean} [config.errorOnEqualPriority]
|
|
29
|
-
* @param {Function} [config.specificityPriorityResolver]
|
|
30
|
-
* @param {any[]} templates - Template objects
|
|
31
|
+
* @param {XPathTransformerContextConfig} config
|
|
32
|
+
* @param {import('./index.js').
|
|
33
|
+
* XPathTemplateObject<any>[]} templates - Template objects
|
|
31
34
|
*/
|
|
32
35
|
constructor (config, templates) {
|
|
33
36
|
this._config = config;
|
|
34
37
|
this._templates = templates;
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
if (!config.data) {
|
|
39
|
+
throw new Error('XPathTransformerContext requires config.data');
|
|
40
|
+
}
|
|
41
|
+
/** @type {Document|Element|Node} */
|
|
42
|
+
this._contextNode = this._origNode = /** @type {Document|Element|Node} */ (
|
|
43
|
+
config.data
|
|
44
|
+
);
|
|
45
|
+
/** @type {Record<string, unknown>} */
|
|
37
46
|
this.vars = {};
|
|
38
|
-
/** @type {Record<string,
|
|
47
|
+
/** @type {Record<string, Record<string, unknown>>} */
|
|
39
48
|
this.propertySets = {};
|
|
40
|
-
/** @type {Record<string,
|
|
49
|
+
/** @type {Record<string, {match: string, use: string}>} */
|
|
41
50
|
this.keys = {};
|
|
42
51
|
/** @type {boolean|undefined} */
|
|
43
52
|
this._initialized = undefined;
|
|
44
53
|
/** @type {string|undefined} */
|
|
45
54
|
this._currPath = undefined; // XPath string of current context
|
|
55
|
+
/** @type {Record<string, any> | undefined} */
|
|
56
|
+
this._params = undefined;
|
|
46
57
|
}
|
|
47
58
|
|
|
48
|
-
/** @returns {
|
|
59
|
+
/** @returns {import('./index.js').JoiningTransformer} */
|
|
49
60
|
_getJoiningTransformer () {
|
|
50
61
|
return this._config.joiningTransformer;
|
|
51
62
|
}
|
|
@@ -53,24 +64,26 @@ class XPathTransformerContext {
|
|
|
53
64
|
/**
|
|
54
65
|
* Evaluate an XPath expression against the current context node.
|
|
55
66
|
* @param {string} expr - XPath expression
|
|
56
|
-
|
|
57
|
-
* @returns {
|
|
67
|
+
* @param {boolean} [asNodes] Return nodes (array) instead of scalar
|
|
68
|
+
* @returns {unknown}
|
|
58
69
|
*/
|
|
59
70
|
_evalXPath (expr, asNodes) {
|
|
60
71
|
if (!expr) {
|
|
61
72
|
return this._contextNode;
|
|
62
73
|
}
|
|
63
|
-
const version = this._config.xpathVersion
|
|
74
|
+
const version = this._config.xpathVersion ?? 1;
|
|
64
75
|
if (version === 1) {
|
|
65
76
|
// Use native XPath (browser-like); rely on DOM doc if available.
|
|
66
77
|
const doc = this._contextNode && this._contextNode.ownerDocument
|
|
67
78
|
? this._contextNode.ownerDocument
|
|
68
79
|
: (this._contextNode.nodeType === 9 ? this._contextNode : undefined);
|
|
69
|
-
if (!doc ||
|
|
80
|
+
if (!doc || doc.nodeType !== 9) {
|
|
70
81
|
throw new Error(
|
|
71
82
|
'Native XPath unavailable for xpathVersion=1'
|
|
72
83
|
);
|
|
73
84
|
}
|
|
85
|
+
/** @type {Document} */
|
|
86
|
+
const docTyped = /** @type {Document} */ (doc);
|
|
74
87
|
// Evaluate relative to current node. Namespace support optional.
|
|
75
88
|
const resolver = null; // Placeholder for future namespaceResolver config
|
|
76
89
|
/* c8 ignore start -- environment-dependent XPathResult availability */
|
|
@@ -86,31 +99,33 @@ class XPathTransformerContext {
|
|
|
86
99
|
: 0
|
|
87
100
|
);
|
|
88
101
|
/* c8 ignore stop */
|
|
89
|
-
const resultObj =
|
|
102
|
+
const resultObj = docTyped.evaluate(
|
|
90
103
|
expr, this._contextNode, resolver, type, null
|
|
91
104
|
);
|
|
92
105
|
if (asNodes) {
|
|
106
|
+
/** @type {Node[]} */
|
|
93
107
|
const arr = [];
|
|
94
108
|
for (let i = 0; i < resultObj.snapshotLength; i++) {
|
|
95
|
-
|
|
109
|
+
const item = resultObj.snapshotItem(i);
|
|
110
|
+
if (item) {
|
|
111
|
+
arr.push(item);
|
|
112
|
+
}
|
|
96
113
|
}
|
|
97
114
|
return arr;
|
|
98
115
|
}
|
|
99
116
|
// Handle primitive types from XPathResult
|
|
100
|
-
const XR =
|
|
101
|
-
|
|
102
|
-
* set resultType for STRING_TYPE, NUMBER_TYPE, or BOOLEAN_TYPE. These
|
|
103
|
-
* branches work in real browsers but cannot be tested in JSDOM. */
|
|
117
|
+
const XR = docTyped.defaultView?.XPathResult ||
|
|
118
|
+
globalThis.XPathResult || {};
|
|
104
119
|
switch (resultObj.resultType) {
|
|
105
120
|
case XR.STRING_TYPE: return resultObj.stringValue;
|
|
106
121
|
case XR.NUMBER_TYPE: return resultObj.numberValue;
|
|
107
|
-
case XR.BOOLEAN_TYPE:
|
|
108
|
-
|
|
109
|
-
/* c8 ignore start -- iterator result branch env-dependent */
|
|
122
|
+
case XR.BOOLEAN_TYPE:
|
|
123
|
+
return resultObj.booleanValue;
|
|
110
124
|
case XR.UNORDERED_NODE_ITERATOR_TYPE:
|
|
111
125
|
case XR.ORDERED_NODE_ITERATOR_TYPE: {
|
|
112
126
|
/* c8 ignore start -- jsdom yields snapshots; iterator traversal
|
|
113
127
|
* validated logically but not triggered in this environment. */
|
|
128
|
+
/** @type {Node[]} */
|
|
114
129
|
const nodes = [];
|
|
115
130
|
let n = resultObj.iterateNext();
|
|
116
131
|
while (n) {
|
|
@@ -118,8 +133,8 @@ class XPathTransformerContext {
|
|
|
118
133
|
n = resultObj.iterateNext();
|
|
119
134
|
}
|
|
120
135
|
return nodes;
|
|
136
|
+
/* c8 ignore stop */
|
|
121
137
|
}
|
|
122
|
-
/* c8 ignore stop */
|
|
123
138
|
/* c8 ignore start -- Default fallback for unsupported XPathResult
|
|
124
139
|
* types; environment-dependent and not hit under jsdom. */
|
|
125
140
|
default:
|
|
@@ -128,8 +143,25 @@ class XPathTransformerContext {
|
|
|
128
143
|
/* c8 ignore stop */
|
|
129
144
|
}
|
|
130
145
|
}
|
|
131
|
-
|
|
132
|
-
|
|
146
|
+
if (version === 2) {
|
|
147
|
+
// Version 2: xpath2.js
|
|
148
|
+
const result = xpath2.evaluate(expr, this._contextNode);
|
|
149
|
+
if (asNodes) {
|
|
150
|
+
// eslint-disable-next-line @stylistic/max-len -- Long
|
|
151
|
+
/* c8 ignore next -- array wrap/identity branch counted in other tests */
|
|
152
|
+
return Array.isArray(result) ? result : [result];
|
|
153
|
+
}
|
|
154
|
+
/* c8 ignore next -- scalar return trivial; wrap behavior tested */
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// eslint-disable-next-line @stylistic/max-len -- Long
|
|
159
|
+
// eslint-disable-next-line import/no-named-as-default-member -- Only as default
|
|
160
|
+
const result = fontoxpath.evaluateXPath(
|
|
161
|
+
expr, this._contextNode, undefined, undefined,
|
|
162
|
+
// Non-deprecated, predictable all results
|
|
163
|
+
14 // ReturnType.ALL_RESULTS
|
|
164
|
+
);
|
|
133
165
|
if (asNodes) {
|
|
134
166
|
/* c8 ignore next -- array wrap/identity branch counted in other tests */
|
|
135
167
|
return Array.isArray(result) ? result : [result];
|
|
@@ -140,15 +172,18 @@ class XPathTransformerContext {
|
|
|
140
172
|
|
|
141
173
|
/**
|
|
142
174
|
* Append raw item to output.
|
|
143
|
-
* @param {
|
|
175
|
+
* @param {unknown} item
|
|
144
176
|
* @returns {XPathTransformerContext}
|
|
145
177
|
*/
|
|
146
178
|
appendOutput (item) {
|
|
147
|
-
|
|
179
|
+
// Cast item since we trust the caller provides valid append types
|
|
180
|
+
this._getJoiningTransformer().append(
|
|
181
|
+
/** @type {string | Node} */ (item)
|
|
182
|
+
);
|
|
148
183
|
return this;
|
|
149
184
|
}
|
|
150
185
|
|
|
151
|
-
/** @returns {
|
|
186
|
+
/** @returns {unknown} */
|
|
152
187
|
getOutput () {
|
|
153
188
|
return this._getJoiningTransformer().get();
|
|
154
189
|
}
|
|
@@ -157,16 +192,16 @@ class XPathTransformerContext {
|
|
|
157
192
|
* Get value(s) by XPath relative to current context.
|
|
158
193
|
* @param {string} select - XPath expression
|
|
159
194
|
* @param {boolean} [asNodes]
|
|
160
|
-
* @returns {
|
|
195
|
+
* @returns {Node[]}
|
|
161
196
|
*/
|
|
162
197
|
get (select, asNodes) {
|
|
163
|
-
return this._evalXPath(select, Boolean(asNodes));
|
|
198
|
+
return /** @type {Node[]} */ (this._evalXPath(select, Boolean(asNodes)));
|
|
164
199
|
}
|
|
165
200
|
|
|
166
201
|
/**
|
|
167
202
|
* Set current context's parent property (for parity with JSONPath context).
|
|
168
203
|
* Mostly placeholder for object-mirroring behavior.
|
|
169
|
-
* @param {
|
|
204
|
+
* @param {Document|Element|Node} v
|
|
170
205
|
* @returns {XPathTransformerContext}
|
|
171
206
|
*/
|
|
172
207
|
set (v) {
|
|
@@ -176,7 +211,7 @@ class XPathTransformerContext {
|
|
|
176
211
|
|
|
177
212
|
/**
|
|
178
213
|
* Apply templates to nodes matched by an XPath expression.
|
|
179
|
-
* @param {string} select - XPath expression (default '.')
|
|
214
|
+
* @param {string} [select] - XPath expression (default '.')
|
|
180
215
|
* @param {string} [mode]
|
|
181
216
|
* @returns {XPathTransformerContext}
|
|
182
217
|
*/
|
|
@@ -189,17 +224,28 @@ class XPathTransformerContext {
|
|
|
189
224
|
} else {
|
|
190
225
|
select = select || '*';
|
|
191
226
|
}
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
227
|
+
const nodesResult = this._evalXPath(select, true);
|
|
228
|
+
const nodes = /** @type {Node[]} */ (nodesResult);
|
|
229
|
+
const modeMatched = this._templates.filter((t) => {
|
|
230
|
+
// Exclude named-only templates (those with name but no path)
|
|
231
|
+
if (t.name && !t.path) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
return mode ? t.mode === mode : !t.mode;
|
|
235
|
+
});
|
|
196
236
|
// Process each node
|
|
197
237
|
for (const node of nodes) {
|
|
198
238
|
// Path resolution simplified (could track full XPath if needed)
|
|
199
239
|
const pathMatchedTemplates = modeMatched.filter((t) => {
|
|
200
240
|
// Basic matching: template.path is XPath tested for existence
|
|
241
|
+
// At this point, we know t.path exists because we filtered
|
|
242
|
+
// out named-only templates in modeMatched
|
|
243
|
+
if (!t.path) {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
201
246
|
try {
|
|
202
|
-
const
|
|
247
|
+
const resResult = this._evalXPath(t.path, true);
|
|
248
|
+
const res = /** @type {Node[]} */ (resResult);
|
|
203
249
|
return res.includes(node);
|
|
204
250
|
} catch {
|
|
205
251
|
return false;
|
|
@@ -226,12 +272,12 @@ class XPathTransformerContext {
|
|
|
226
272
|
pathMatchedTemplates.sort((a, b) => {
|
|
227
273
|
const aPr = typeof a.priority === 'number'
|
|
228
274
|
? a.priority
|
|
229
|
-
: (this._config.specificityPriorityResolver
|
|
275
|
+
: (this._config.specificityPriorityResolver && a.path
|
|
230
276
|
? this._config.specificityPriorityResolver(a.path)
|
|
231
277
|
: 0);
|
|
232
278
|
const bPr = typeof b.priority === 'number'
|
|
233
279
|
? b.priority
|
|
234
|
-
: (this._config.specificityPriorityResolver
|
|
280
|
+
: (this._config.specificityPriorityResolver && b.path
|
|
235
281
|
? this._config.specificityPriorityResolver(b.path)
|
|
236
282
|
: 0);
|
|
237
283
|
if (aPr === bPr && this._config.errorOnEqualPriority) {
|
|
@@ -239,7 +285,12 @@ class XPathTransformerContext {
|
|
|
239
285
|
}
|
|
240
286
|
return aPr > bPr ? -1 : 1;
|
|
241
287
|
});
|
|
242
|
-
templateObj =
|
|
288
|
+
templateObj =
|
|
289
|
+
/**
|
|
290
|
+
* @type {import('./index.js').XPathTemplateObject<any>}
|
|
291
|
+
*/ (
|
|
292
|
+
pathMatchedTemplates.shift()
|
|
293
|
+
);
|
|
243
294
|
}
|
|
244
295
|
this._contextNode = node;
|
|
245
296
|
const ret = templateObj.template.call(this, node, {mode});
|
|
@@ -251,14 +302,74 @@ class XPathTransformerContext {
|
|
|
251
302
|
return this;
|
|
252
303
|
}
|
|
253
304
|
|
|
305
|
+
/**
|
|
306
|
+
* @param {string|
|
|
307
|
+
* {name: string, withParam?: any[]}} name - Template name or
|
|
308
|
+
* options object
|
|
309
|
+
* @param {any[]} [withParams] - Parameters to pass to template
|
|
310
|
+
* @returns {this}
|
|
311
|
+
*/
|
|
312
|
+
callTemplate (name, withParams) {
|
|
313
|
+
// Invokes a named template, optionally passing values via withParam.
|
|
314
|
+
if (name && typeof name === 'object') {
|
|
315
|
+
withParams = name.withParam || withParams;
|
|
316
|
+
({name} = name);
|
|
317
|
+
}
|
|
318
|
+
withParams = withParams || /* c8 ignore next */ [];
|
|
319
|
+
|
|
320
|
+
// Store parameters in a temporary context for valueOf() access
|
|
321
|
+
const prevParams = this._params;
|
|
322
|
+
/** @type {Record<string, any>} */
|
|
323
|
+
const params = {};
|
|
324
|
+
this._params = params;
|
|
325
|
+
|
|
326
|
+
withParams.forEach((withParam, index) => {
|
|
327
|
+
const value = withParam.value !== undefined
|
|
328
|
+
? withParam.value
|
|
329
|
+
: this.get(withParam.select, false);
|
|
330
|
+
|
|
331
|
+
// Store by name if provided, otherwise by index
|
|
332
|
+
if (withParam.name) {
|
|
333
|
+
params[withParam.name] = value;
|
|
334
|
+
} else {
|
|
335
|
+
params[String(index)] = value;
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const results = this._getJoiningTransformer();
|
|
340
|
+
const templateObj = this._templates.find((template) => {
|
|
341
|
+
return template.name === name;
|
|
342
|
+
});
|
|
343
|
+
if (!templateObj) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
'Template, ' + name + ', cannot be called as it was not found.'
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// @ts-expect-error Todo: Fix
|
|
350
|
+
const result = templateObj.template.call(this, this._contextNode, {});
|
|
351
|
+
if (typeof result !== 'undefined') {
|
|
352
|
+
/** @type {any} */ (results).append(result);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Restore previous parameter context
|
|
356
|
+
this._params = prevParams;
|
|
357
|
+
|
|
358
|
+
return this;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
254
362
|
/**
|
|
255
363
|
* Iterate over nodes selected by XPath.
|
|
256
364
|
* @param {string} select - XPath expression
|
|
257
|
-
* @param {
|
|
365
|
+
* @param {(this: XPathTransformerContext,
|
|
366
|
+
* node: Node
|
|
367
|
+
* )=>void} cb - Callback invoked per node
|
|
258
368
|
* @returns {XPathTransformerContext}
|
|
259
369
|
*/
|
|
260
370
|
forEach (select, cb) {
|
|
261
|
-
const
|
|
371
|
+
const nodesResult = this._evalXPath(select, true);
|
|
372
|
+
const nodes = /** @type {Node[]} */ (nodesResult);
|
|
262
373
|
for (const n of nodes) {
|
|
263
374
|
cb.call(this, n);
|
|
264
375
|
}
|
|
@@ -273,19 +384,128 @@ class XPathTransformerContext {
|
|
|
273
384
|
valueOf (select) {
|
|
274
385
|
const jt = this._getJoiningTransformer();
|
|
275
386
|
let val;
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
387
|
+
|
|
388
|
+
const selectStr = typeof select === 'object'
|
|
389
|
+
? /** @type {{select?: string}} */ (select).select
|
|
390
|
+
: select;
|
|
391
|
+
|
|
392
|
+
// Check if this is a parameter reference (starts with $)
|
|
393
|
+
if (selectStr && selectStr.startsWith('$')) {
|
|
394
|
+
const paramName = selectStr.slice(1);
|
|
395
|
+
if (this._params && paramName in this._params) {
|
|
396
|
+
val = this._params[paramName];
|
|
397
|
+
} else {
|
|
398
|
+
// Fall back to normal XPath evaluation
|
|
399
|
+
const resResult = this._evalXPath(selectStr, true);
|
|
400
|
+
const res = /** @type {Node[]} */ (resResult);
|
|
401
|
+
const first = res[0];
|
|
402
|
+
/* c8 ignore start */
|
|
403
|
+
val = first && first.nodeType
|
|
404
|
+
? first.textContent
|
|
405
|
+
: first;
|
|
406
|
+
/* c8 ignore stop */
|
|
407
|
+
}
|
|
408
|
+
} else if (!selectStr || selectStr === '.') {
|
|
279
409
|
val = this._contextNode.nodeType === 3
|
|
280
410
|
? this._contextNode.nodeValue
|
|
281
411
|
: this._contextNode.textContent;
|
|
282
412
|
} else {
|
|
283
|
-
const
|
|
413
|
+
const resResult = this._evalXPath(selectStr, true);
|
|
414
|
+
const res = /** @type {Node[]} */ (resResult);
|
|
284
415
|
// Simplify: use textContent of first match if node, else raw
|
|
285
416
|
const first = res[0];
|
|
286
417
|
val = first && first.nodeType ? first.textContent : first;
|
|
287
418
|
}
|
|
288
|
-
|
|
419
|
+
// Ensure val is not null before appending
|
|
420
|
+
if (val !== null) {
|
|
421
|
+
jt.append(val);
|
|
422
|
+
}
|
|
423
|
+
return this;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Deep copy selection or current context when omitted.
|
|
428
|
+
* For DOM nodes uses cloneNode(true); for scalars copies the value.
|
|
429
|
+
* @param {string} [select] XPath expression selecting nodes (optional)
|
|
430
|
+
* @returns {XPathTransformerContext}
|
|
431
|
+
*/
|
|
432
|
+
copyOf (select) {
|
|
433
|
+
/** @type {Node[]} */ let nodes = [];
|
|
434
|
+
if (select) {
|
|
435
|
+
try {
|
|
436
|
+
const res = this.get(select, true);
|
|
437
|
+
nodes = Array.isArray(res) ? res : /* c8 ignore next */ [];
|
|
438
|
+
} catch { /* c8 ignore next */
|
|
439
|
+
nodes = /* c8 ignore next */ [];
|
|
440
|
+
}
|
|
441
|
+
} else {
|
|
442
|
+
nodes = [this._contextNode];
|
|
443
|
+
}
|
|
444
|
+
if (nodes.length) {
|
|
445
|
+
for (const n of nodes) {
|
|
446
|
+
if (n && typeof n === 'object' && 'cloneNode' in n) {
|
|
447
|
+
let deep;
|
|
448
|
+
try {
|
|
449
|
+
deep = /** @type {Node} */ (n.cloneNode(true));
|
|
450
|
+
} catch { /* c8 ignore start */
|
|
451
|
+
deep = /** @type {Node} */ (n.cloneNode(false));
|
|
452
|
+
} /* c8 ignore stop */
|
|
453
|
+
this._getJoiningTransformer().append(/** @type {any} */ (deep));
|
|
454
|
+
} else { /* c8 ignore start */
|
|
455
|
+
this._getJoiningTransformer().append(/** @type {any} */ (n));
|
|
456
|
+
} /* c8 ignore stop */
|
|
457
|
+
}
|
|
458
|
+
} else if (select) { // Scalar path
|
|
459
|
+
const scalar = this._evalXPath(select, false);
|
|
460
|
+
// If scalar evaluation unexpectedly returns a Node/Document, use its
|
|
461
|
+
// textContent instead of attempting to append the Node itself (which
|
|
462
|
+
// can cause HierarchyRequestError for Document nodes).
|
|
463
|
+
if (
|
|
464
|
+
scalar &&
|
|
465
|
+
typeof scalar === 'object' &&
|
|
466
|
+
'nodeType' in /** @type {any} */ (scalar)
|
|
467
|
+
) {
|
|
468
|
+
const node = /** @type {Node} */ (scalar);
|
|
469
|
+
let txt = /** @type {any} */ (node.textContent);
|
|
470
|
+
if (/* c8 ignore start */
|
|
471
|
+
(txt === null || typeof txt === 'undefined') &&
|
|
472
|
+
/** @type {any} */ (node).nodeType === 9 // Document
|
|
473
|
+
) {
|
|
474
|
+
// Fallback to documentElement textContent if available
|
|
475
|
+
const docEl = /** @type {any} */ (
|
|
476
|
+
/** @type {any} */ (node)
|
|
477
|
+
).documentElement;
|
|
478
|
+
txt = /** @type {any} */ (docEl && docEl.textContent) || '';
|
|
479
|
+
} /* c8 ignore stop */
|
|
480
|
+
this._getJoiningTransformer().append(
|
|
481
|
+
/** @type {any} */ (txt || /* c8 ignore next */ '')
|
|
482
|
+
);
|
|
483
|
+
} else {
|
|
484
|
+
this._getJoiningTransformer().append(/** @type {any} */ (scalar));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return this;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Shallow copy current context node (cloneNode(false)); scalars copied
|
|
492
|
+
* directly. Provided for parity with JSONPath copy().
|
|
493
|
+
* @param {string[]} [_propertySets] Ignored in XPath variant (parity only)
|
|
494
|
+
* @returns {XPathTransformerContext}
|
|
495
|
+
*/
|
|
496
|
+
copy (_propertySets) {
|
|
497
|
+
const target = this._contextNode;
|
|
498
|
+
let clone;
|
|
499
|
+
if (target && typeof target === 'object' && 'nodeType' in target) {
|
|
500
|
+
try {
|
|
501
|
+
clone = /** @type {Node} */ (target.cloneNode(false));
|
|
502
|
+
} catch { /* c8 ignore start */
|
|
503
|
+
clone = target;
|
|
504
|
+
} /* c8 ignore stop */
|
|
505
|
+
} else { /* c8 ignore start */
|
|
506
|
+
clone = target;
|
|
507
|
+
} /* c8 ignore stop */
|
|
508
|
+
this._getJoiningTransformer().append(/** @type {any} */ (clone));
|
|
289
509
|
return this;
|
|
290
510
|
}
|
|
291
511
|
|
|
@@ -301,32 +521,330 @@ class XPathTransformerContext {
|
|
|
301
521
|
}
|
|
302
522
|
/**
|
|
303
523
|
* Log a message (for debugging).
|
|
304
|
-
* @param {
|
|
524
|
+
* @param {unknown} json Any value
|
|
305
525
|
* @returns {void}
|
|
306
526
|
*/
|
|
307
|
-
|
|
527
|
+
// eslint-disable-next-line class-methods-use-this -- Convenient
|
|
528
|
+
message (json) {
|
|
308
529
|
/* eslint-disable-next-line no-console -- Debug output */
|
|
309
530
|
console.log(json);
|
|
310
531
|
}
|
|
311
532
|
/**
|
|
312
533
|
* Append string.
|
|
313
534
|
* @param {string} str String to append
|
|
314
|
-
* @param {
|
|
535
|
+
* @param {(this: XPathTransformerContext) => void} [cb] Callback
|
|
315
536
|
* @returns {XPathTransformerContext}
|
|
316
537
|
*/
|
|
317
538
|
string (str, cb) {
|
|
318
|
-
this
|
|
539
|
+
// We don't pass the callback because it has incompatible 'this' type
|
|
540
|
+
// The callback is mainly used for context-building in string transformers
|
|
541
|
+
this._getJoiningTransformer().string(str);
|
|
319
542
|
return this;
|
|
320
543
|
}
|
|
321
544
|
/**
|
|
322
|
-
* Append number.
|
|
323
|
-
* @param {number
|
|
545
|
+
* Append number with xsl:number-like formatting.
|
|
546
|
+
* @param {number|string|{
|
|
547
|
+
* value?: number|string,
|
|
548
|
+
* count?: string,
|
|
549
|
+
* level?: 'single'|'multiple'|'any',
|
|
550
|
+
* from?: string,
|
|
551
|
+
* format?: string,
|
|
552
|
+
* groupingSeparator?: string,
|
|
553
|
+
* groupingSize?: number
|
|
554
|
+
* }} num - Number value, "position()" string, or options object
|
|
324
555
|
* @returns {XPathTransformerContext}
|
|
325
556
|
*/
|
|
326
557
|
number (num) {
|
|
327
|
-
|
|
558
|
+
// Handle xsl:number-like functionality
|
|
559
|
+
if (typeof num === 'object' && num !== null) {
|
|
560
|
+
const opts = num;
|
|
561
|
+
let {value} = opts;
|
|
562
|
+
|
|
563
|
+
// Handle position() calculation
|
|
564
|
+
if (value === 'position()' || value === undefined) {
|
|
565
|
+
const {count} = opts;
|
|
566
|
+
const level = opts.level || 'single';
|
|
567
|
+
const {from} = opts;
|
|
568
|
+
|
|
569
|
+
switch (level) {
|
|
570
|
+
case 'single': {
|
|
571
|
+
value = this._calculatePosition(count, from);
|
|
572
|
+
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
case 'multiple': {
|
|
576
|
+
// Hierarchical numbering: get position for each ancestor up to root
|
|
577
|
+
const positions = [];
|
|
578
|
+
let node = /** @type {any} */ (this._config).currentNode;
|
|
579
|
+
while (node) {
|
|
580
|
+
positions.unshift(this._calculatePosition(count, undefined));
|
|
581
|
+
node = node.parentNode;
|
|
582
|
+
if (from) {
|
|
583
|
+
const fromResult = /** @type {any} */ (
|
|
584
|
+
this._evalXPath(from, node)
|
|
585
|
+
);
|
|
586
|
+
if (fromResult && fromResult.length > 0) {
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
value = positions.join('.');
|
|
592
|
+
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
case 'any': {
|
|
596
|
+
value = this._calculatePositionAny(count, from);
|
|
597
|
+
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
// No default
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Determine format string and locale
|
|
605
|
+
let format = opts.format || '1';
|
|
606
|
+
// @ts-expect-error: dynamic property access
|
|
607
|
+
const locale = opts.lang || 'en';
|
|
608
|
+
// @ts-expect-error: dynamic property access
|
|
609
|
+
const {letterValue} = opts;
|
|
610
|
+
|
|
611
|
+
// If letterValue is 'alphabetic', force alphabetic format
|
|
612
|
+
if (letterValue === 'alphabetic') {
|
|
613
|
+
format = (opts.format && (/^[aA]$/v).test(opts.format)) ? opts.format : 'a';
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const numValue = typeof value === 'string' ? Number(value) : (value || 1);
|
|
617
|
+
const formatted = this._formatNumber(
|
|
618
|
+
numValue,
|
|
619
|
+
format,
|
|
620
|
+
opts.groupingSeparator,
|
|
621
|
+
opts.groupingSize,
|
|
622
|
+
locale
|
|
623
|
+
);
|
|
624
|
+
this._getJoiningTransformer().plainText(formatted);
|
|
625
|
+
} else if (num === 'position()') {
|
|
626
|
+
// Simple position() call
|
|
627
|
+
const pos = this._calculatePosition();
|
|
628
|
+
this._getJoiningTransformer().number(pos);
|
|
629
|
+
} else {
|
|
630
|
+
// Simple number
|
|
631
|
+
this._getJoiningTransformer().number(
|
|
632
|
+
typeof num === 'string' ? Number(num) : num
|
|
633
|
+
);
|
|
634
|
+
}
|
|
328
635
|
return this;
|
|
329
636
|
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Calculate position of current node.
|
|
640
|
+
* @param {string} [count] - XPath pattern to match
|
|
641
|
+
* @param {string} [from] - XPath pattern for ancestor
|
|
642
|
+
* @returns {number}
|
|
643
|
+
* @private
|
|
644
|
+
*/
|
|
645
|
+
_calculatePosition (count, from) {
|
|
646
|
+
// eslint-disable-next-line prefer-destructuring -- TS
|
|
647
|
+
const currentNode = /** @type {any} */ (this._config).currentNode;
|
|
648
|
+
if (!currentNode) {
|
|
649
|
+
return 1;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Get parent node
|
|
653
|
+
const parent = currentNode.parentNode;
|
|
654
|
+
if (!parent) {
|
|
655
|
+
return 1;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// If from pattern specified, find that ancestor
|
|
659
|
+
let startNode = parent;
|
|
660
|
+
if (from) {
|
|
661
|
+
const fromResult = /** @type {any} */ (
|
|
662
|
+
this._evalXPath(from, currentNode)
|
|
663
|
+
);
|
|
664
|
+
if (fromResult && fromResult.length > 0) {
|
|
665
|
+
startNode = fromResult[0];
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Count preceding siblings
|
|
670
|
+
let position = 1;
|
|
671
|
+
let sibling = currentNode.previousSibling;
|
|
672
|
+
|
|
673
|
+
while (sibling) {
|
|
674
|
+
if (count) {
|
|
675
|
+
// Check if sibling matches count pattern
|
|
676
|
+
const matches = /** @type {any} */ (this._evalXPath(count, sibling));
|
|
677
|
+
if (matches && matches.length > 0) {
|
|
678
|
+
position++;
|
|
679
|
+
}
|
|
680
|
+
} else if (sibling.nodeType === currentNode.nodeType &&
|
|
681
|
+
(!currentNode.nodeName || sibling.nodeName === currentNode.nodeName)) {
|
|
682
|
+
position++;
|
|
683
|
+
}
|
|
684
|
+
sibling = sibling.previousSibling;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return position;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Calculate position counting all ancestors (level=any).
|
|
692
|
+
* @param {string} [count] - XPath pattern to match
|
|
693
|
+
* @param {string} [from] - XPath pattern for ancestor
|
|
694
|
+
* @returns {number}
|
|
695
|
+
* @private
|
|
696
|
+
*/
|
|
697
|
+
_calculatePositionAny (count, from) {
|
|
698
|
+
// eslint-disable-next-line prefer-destructuring -- TS
|
|
699
|
+
const currentNode = /** @type {any} */ (this._config).currentNode;
|
|
700
|
+
if (!currentNode) {
|
|
701
|
+
return 1;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Find root or 'from' node
|
|
705
|
+
let root = currentNode.ownerDocument || currentNode;
|
|
706
|
+
if (from) {
|
|
707
|
+
const fromResult = /** @type {any} */ (
|
|
708
|
+
this._evalXPath(from, currentNode)
|
|
709
|
+
);
|
|
710
|
+
if (fromResult && fromResult.length > 0) {
|
|
711
|
+
root = fromResult[0];
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Count all matching nodes in document order up to current
|
|
716
|
+
const pattern = count || 'node()';
|
|
717
|
+
const allNodes = /** @type {any[]} */ (
|
|
718
|
+
this._evalXPath('//' + pattern, root)
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
for (const [i, allNode] of allNodes.entries()) {
|
|
722
|
+
if (allNode === currentNode) {
|
|
723
|
+
return i + 1;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return 1;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Format a number according to format string.
|
|
732
|
+
* @param {number} num - Number to format
|
|
733
|
+
* @param {string} format - Format string (1, a, A, i, I, 01, etc.)
|
|
734
|
+
* @param {string} [groupingSeparator] - Separator for grouping (e.g., ',')
|
|
735
|
+
* @param {number} [groupingSize] - Size of groups (e.g., 3 for 1,000)
|
|
736
|
+
* @param {string} [locale]
|
|
737
|
+
* @returns {string}
|
|
738
|
+
* @private
|
|
739
|
+
*/
|
|
740
|
+
_formatNumber (num, format, groupingSeparator, groupingSize, locale = 'en') {
|
|
741
|
+
if (Number.isNaN(num)) {
|
|
742
|
+
return String(num);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
let result;
|
|
746
|
+
const formatChar = format.charAt(0);
|
|
747
|
+
|
|
748
|
+
switch (formatChar) {
|
|
749
|
+
case 'i': {
|
|
750
|
+
result = this._toRoman(num).toLowerCase();
|
|
751
|
+
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
case 'I': {
|
|
755
|
+
result = this._toRoman(num);
|
|
756
|
+
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
case 'a': {
|
|
760
|
+
result = this._toAlphabetic(num, false);
|
|
761
|
+
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
case 'A': {
|
|
765
|
+
result = this._toAlphabetic(num, true);
|
|
766
|
+
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
case '0': {
|
|
770
|
+
const width = format.length;
|
|
771
|
+
result = String(num).padStart(width, '0');
|
|
772
|
+
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
775
|
+
default: {
|
|
776
|
+
// Use Intl.NumberFormat for decimal formatting if grouping/locale
|
|
777
|
+
// options are provided
|
|
778
|
+
let options = {};
|
|
779
|
+
if (groupingSeparator || groupingSize) {
|
|
780
|
+
options = {
|
|
781
|
+
useGrouping: true
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
try {
|
|
785
|
+
result = new Intl.NumberFormat(locale, options).format(num);
|
|
786
|
+
if (groupingSeparator) {
|
|
787
|
+
result = result.replaceAll(',', groupingSeparator);
|
|
788
|
+
}
|
|
789
|
+
} catch (e) {
|
|
790
|
+
result = String(num);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return result;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Convert number to Roman numerals.
|
|
799
|
+
* @param {number} num - Number to convert (1-3999)
|
|
800
|
+
* @returns {string}
|
|
801
|
+
* @private
|
|
802
|
+
*/
|
|
803
|
+
// eslint-disable-next-line class-methods-use-this -- Avoid for now
|
|
804
|
+
_toRoman (num) {
|
|
805
|
+
if (num < 1 || num > 3999) {
|
|
806
|
+
return String(num);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
|
|
810
|
+
const syms = [
|
|
811
|
+
'M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I'
|
|
812
|
+
];
|
|
813
|
+
|
|
814
|
+
let result = '';
|
|
815
|
+
for (const [i, val] of vals.entries()) {
|
|
816
|
+
while (num >= val) {
|
|
817
|
+
result += syms[i];
|
|
818
|
+
num -= val;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return result;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Convert number to alphabetic sequence.
|
|
826
|
+
* @param {number} num - Number to convert
|
|
827
|
+
* @param {boolean} uppercase - Use uppercase letters
|
|
828
|
+
* @returns {string}
|
|
829
|
+
* @private
|
|
830
|
+
*/
|
|
831
|
+
// eslint-disable-next-line class-methods-use-this -- Avoid for now
|
|
832
|
+
_toAlphabetic (num, uppercase) {
|
|
833
|
+
if (num < 1) {
|
|
834
|
+
return String(num);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
let result = '';
|
|
838
|
+
const base = uppercase ? 65 : 97; // 'A' or 'a'
|
|
839
|
+
|
|
840
|
+
while (num > 0) {
|
|
841
|
+
num--; // Make 0-indexed
|
|
842
|
+
result = String.fromCodePoint(base + (num % 26)) + result;
|
|
843
|
+
num = Math.floor(num / 26);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return result;
|
|
847
|
+
}
|
|
330
848
|
/**
|
|
331
849
|
* Append plain text (no escaping changes).
|
|
332
850
|
* @param {string} str Text
|
|
@@ -339,7 +857,7 @@ class XPathTransformerContext {
|
|
|
339
857
|
/**
|
|
340
858
|
* Append property/value pair.
|
|
341
859
|
* @param {string} prop Property name
|
|
342
|
-
* @param {
|
|
860
|
+
* @param {any} val Value
|
|
343
861
|
* @returns {XPathTransformerContext}
|
|
344
862
|
*/
|
|
345
863
|
propValue (prop, val) {
|
|
@@ -348,43 +866,82 @@ class XPathTransformerContext {
|
|
|
348
866
|
}
|
|
349
867
|
/**
|
|
350
868
|
* Append object.
|
|
351
|
-
* @param {
|
|
869
|
+
* @param {Record<string, unknown>|
|
|
870
|
+
* ((this: XPathTransformerContext) => void)} objOrCb Object or callback
|
|
871
|
+
* @param {((this: XPathTransformerContext) => void)|
|
|
872
|
+
* any[]} [cbOrUsePropertySets] Callback or property sets
|
|
873
|
+
* @param {any[]|
|
|
874
|
+
* Record<string, unknown>} [usePropertySetsOrPropSets]
|
|
875
|
+
* Property sets or props
|
|
876
|
+
* @param {Record<string, unknown>} [propSets] Additional property sets
|
|
352
877
|
* @returns {XPathTransformerContext}
|
|
353
878
|
*/
|
|
354
|
-
object (
|
|
355
|
-
this._getJoiningTransformer()
|
|
879
|
+
object (objOrCb, cbOrUsePropertySets, usePropertySetsOrPropSets, propSets) {
|
|
880
|
+
const jt = this._getJoiningTransformer();
|
|
881
|
+
// Union of transformers creates intersection types
|
|
882
|
+
// @ts-expect-error
|
|
883
|
+
jt.object(objOrCb, cbOrUsePropertySets, usePropertySetsOrPropSets,
|
|
884
|
+
propSets);
|
|
356
885
|
return this;
|
|
357
886
|
}
|
|
358
887
|
/**
|
|
359
888
|
* Append array.
|
|
360
|
-
* @param {
|
|
889
|
+
* @param {any[]|
|
|
890
|
+
* ((this: XPathTransformerContext) => void)} [arrOrCb]
|
|
891
|
+
* Array or callback
|
|
892
|
+
* @param {(this: XPathTransformerContext) => void} [cb] Callback
|
|
361
893
|
* @returns {XPathTransformerContext}
|
|
362
894
|
*/
|
|
363
|
-
array (
|
|
364
|
-
this._getJoiningTransformer()
|
|
895
|
+
array (arrOrCb, cb) {
|
|
896
|
+
const jt = this._getJoiningTransformer();
|
|
897
|
+
// Union of transformers creates intersection types
|
|
898
|
+
// @ts-expect-error
|
|
899
|
+
jt.array(arrOrCb, cb);
|
|
365
900
|
return this;
|
|
366
901
|
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Append text node content.
|
|
905
|
+
* @param {import('./StringJoiningTransformer.js').OutputConfig} cfg Text
|
|
906
|
+
* @returns {XPathTransformerContext}
|
|
907
|
+
*/
|
|
908
|
+
output (cfg) {
|
|
909
|
+
this._getJoiningTransformer().output(cfg);
|
|
910
|
+
return this;
|
|
911
|
+
}
|
|
912
|
+
|
|
367
913
|
/**
|
|
368
914
|
* Append element.
|
|
369
915
|
* @param {string} name Tag name
|
|
370
|
-
* @param {
|
|
371
|
-
*
|
|
372
|
-
* @param {
|
|
916
|
+
* @param {Record<string, string>|any[]|
|
|
917
|
+
* ((this: XPathTransformerContext)=>void)} [atts] Attributes
|
|
918
|
+
* @param {any[]|((this: XPathTransformerContext)=>void)} [children]
|
|
919
|
+
* Children
|
|
920
|
+
* @param {(this: XPathTransformerContext)=>void} [cb] Callback
|
|
373
921
|
* @returns {XPathTransformerContext}
|
|
374
922
|
*/
|
|
375
923
|
element (name, atts, children, cb) {
|
|
924
|
+
// @ts-expect-error - Union of transformers creates intersection types
|
|
376
925
|
this._getJoiningTransformer().element(name, atts, children, cb);
|
|
377
926
|
return this;
|
|
378
927
|
}
|
|
379
928
|
/**
|
|
380
929
|
* Append attribute.
|
|
381
930
|
* @param {string} name Attribute name
|
|
382
|
-
* @param {string|
|
|
931
|
+
* @param {string|Record<string, unknown>} val Value
|
|
383
932
|
* @param {boolean} [avoid] Avoid duplicates
|
|
384
933
|
* @returns {XPathTransformerContext}
|
|
385
934
|
*/
|
|
386
935
|
attribute (name, val, avoid) {
|
|
387
|
-
this._getJoiningTransformer()
|
|
936
|
+
const jt = this._getJoiningTransformer();
|
|
937
|
+
// Only StringJoiningTransformer supports the third parameter
|
|
938
|
+
if (typeof avoid !== 'undefined') {
|
|
939
|
+
// Union of transformers creates intersection types
|
|
940
|
+
// @ts-expect-error
|
|
941
|
+
jt.attribute(name, /** @type {string} */ (val), avoid);
|
|
942
|
+
} else {
|
|
943
|
+
jt.attribute(name, /** @type {string} */ (val));
|
|
944
|
+
}
|
|
388
945
|
return this;
|
|
389
946
|
}
|
|
390
947
|
/**
|
|
@@ -396,27 +953,59 @@ class XPathTransformerContext {
|
|
|
396
953
|
this._getJoiningTransformer().text(txt);
|
|
397
954
|
return this;
|
|
398
955
|
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Append a comment.
|
|
959
|
+
* @param {string} text - Comment text
|
|
960
|
+
* @returns {XPathTransformerContext}
|
|
961
|
+
*/
|
|
962
|
+
comment (text) {
|
|
963
|
+
const jt = this._getJoiningTransformer();
|
|
964
|
+
if (jt.comment) {
|
|
965
|
+
jt.comment(text);
|
|
966
|
+
}
|
|
967
|
+
return this;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Append a processing instruction.
|
|
972
|
+
* @param {string} target - Processing instruction target
|
|
973
|
+
* @param {string} data - Processing instruction data
|
|
974
|
+
* @returns {XPathTransformerContext}
|
|
975
|
+
*/
|
|
976
|
+
processingInstruction (target, data) {
|
|
977
|
+
const jt = this._getJoiningTransformer();
|
|
978
|
+
if (jt.processingInstruction) {
|
|
979
|
+
jt.processingInstruction(target, data);
|
|
980
|
+
}
|
|
981
|
+
return this;
|
|
982
|
+
}
|
|
983
|
+
|
|
399
984
|
/**
|
|
400
985
|
* Define a property set (optionally composed from other sets).
|
|
401
986
|
* @param {string} name Property set name
|
|
402
|
-
* @param {
|
|
987
|
+
* @param {Record<string, unknown>} obj Base properties
|
|
403
988
|
* @param {string[]} [use] Property set names to merge
|
|
404
989
|
* @returns {XPathTransformerContext}
|
|
405
990
|
*/
|
|
406
991
|
propertySet (name, obj, use) {
|
|
407
|
-
this.propertySets[name] =
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
992
|
+
this.propertySets[name] = /** @type {Record<string, unknown>} */ (
|
|
993
|
+
use
|
|
994
|
+
? ({
|
|
995
|
+
...obj,
|
|
996
|
+
...use.reduce(
|
|
997
|
+
(acc, psName) => this._usePropertySets(acc, psName), {}
|
|
998
|
+
)
|
|
999
|
+
})
|
|
1000
|
+
: obj
|
|
1001
|
+
);
|
|
413
1002
|
return this;
|
|
414
1003
|
}
|
|
415
1004
|
/**
|
|
416
1005
|
* Merge properties from a named property set into obj.
|
|
417
|
-
* @param {
|
|
1006
|
+
* @param {Record<string, unknown>} obj Target object
|
|
418
1007
|
* @param {string} name Property set name
|
|
419
|
-
* @returns {
|
|
1008
|
+
* @returns {Record<string, unknown>}
|
|
420
1009
|
*/
|
|
421
1010
|
_usePropertySets (obj, name) {
|
|
422
1011
|
return Object.assign(obj, this.propertySets[name]);
|
|
@@ -424,16 +1013,20 @@ class XPathTransformerContext {
|
|
|
424
1013
|
/**
|
|
425
1014
|
* Retrieve a key-mapped node matching a value or return context.
|
|
426
1015
|
* @param {string} name Key name
|
|
427
|
-
* @param {
|
|
428
|
-
* @returns {
|
|
1016
|
+
* @param {any} value Value to match
|
|
1017
|
+
* @returns {any}
|
|
429
1018
|
*/
|
|
430
1019
|
getKey (name, value) {
|
|
431
1020
|
const key = this.keys[name];
|
|
432
1021
|
const matches = this.get(key.match, true);
|
|
433
|
-
|
|
1022
|
+
// When asNodes=true, get() returns Node[]
|
|
1023
|
+
/** @type {Node[]} */
|
|
1024
|
+
const nodesArray = /** @type {Node[]} */ (matches);
|
|
1025
|
+
for (const m of nodesArray) {
|
|
434
1026
|
if (m && m.nodeType === 1) { // Element
|
|
435
|
-
|
|
436
|
-
|
|
1027
|
+
const elem = /** @type {Element} */ (m);
|
|
1028
|
+
if (elem.getAttribute(key.use) === value) {
|
|
1029
|
+
return elem;
|
|
437
1030
|
}
|
|
438
1031
|
}
|
|
439
1032
|
}
|
|
@@ -451,27 +1044,260 @@ class XPathTransformerContext {
|
|
|
451
1044
|
return this;
|
|
452
1045
|
}
|
|
453
1046
|
|
|
1047
|
+
/**
|
|
1048
|
+
* Conditionally execute a callback when an XPath evaluates to a truthy
|
|
1049
|
+
* scalar or a non-empty node set (akin to xsl:if semantics).
|
|
1050
|
+
*
|
|
1051
|
+
* Truthiness rules:
|
|
1052
|
+
* - Node set: length > 0 passes.
|
|
1053
|
+
* - Scalar: Boolean(value) must be true.
|
|
1054
|
+
*
|
|
1055
|
+
* @param {string} select XPath expression
|
|
1056
|
+
* @param {(this: XPathTransformerContext)
|
|
1057
|
+
* => void} cb Callback invoked if condition passes
|
|
1058
|
+
* @returns {XPathTransformerContext}
|
|
1059
|
+
*/
|
|
1060
|
+
if (select, cb) {
|
|
1061
|
+
const passes = this._passesIf(select);
|
|
1062
|
+
if (passes && typeof cb === 'function') {
|
|
1063
|
+
cb.call(this);
|
|
1064
|
+
}
|
|
1065
|
+
return this;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Internal helper: evaluate XPath truthiness like if().
|
|
1070
|
+
* @param {string} select
|
|
1071
|
+
* @returns {boolean}
|
|
1072
|
+
*/
|
|
1073
|
+
_passesIf (select) {
|
|
1074
|
+
let passes = false;
|
|
1075
|
+
// Try scalar evaluation first (handles boolean/comparison expressions)
|
|
1076
|
+
try {
|
|
1077
|
+
const scalar = this.get(select, false);
|
|
1078
|
+
let normalized;
|
|
1079
|
+
// Unwrap single-item array if it contains a primitive
|
|
1080
|
+
/* c8 ignore next 6 -- Defensive code for edge case where _evalXPath
|
|
1081
|
+
* returns single-item array with asNodes=false; both native XPath v1
|
|
1082
|
+
* and xpath2.js v2 return scalars directly in standard usage. */
|
|
1083
|
+
if (
|
|
1084
|
+
Array.isArray(scalar) &&
|
|
1085
|
+
scalar.length === 1 &&
|
|
1086
|
+
['boolean', 'number', 'string'].includes(typeof scalar[0])
|
|
1087
|
+
) {
|
|
1088
|
+
normalized = scalar[0];
|
|
1089
|
+
} else if (['boolean', 'number', 'string'].includes(typeof scalar)) {
|
|
1090
|
+
normalized = scalar;
|
|
1091
|
+
}
|
|
1092
|
+
if (typeof normalized !== 'undefined') {
|
|
1093
|
+
passes = Boolean(normalized);
|
|
1094
|
+
}
|
|
1095
|
+
} catch {
|
|
1096
|
+
// Scalar eval failed; will try node selection
|
|
1097
|
+
}
|
|
1098
|
+
// If not yet truthy, attempt node selection (location paths)
|
|
1099
|
+
if (!passes && (/[\/@*]/v).test(select)) {
|
|
1100
|
+
try {
|
|
1101
|
+
const nodes = this.get(select, true);
|
|
1102
|
+
// eslint-disable-next-line unicorn/prefer-ternary -- for coverage
|
|
1103
|
+
if (Array.isArray(nodes)) {
|
|
1104
|
+
passes = nodes.length > 0;
|
|
1105
|
+
// Defensive for non-array nodes, but _evalXPath with asNodes=true
|
|
1106
|
+
// always returns arrays in both v1 and v2.
|
|
1107
|
+
/* c8 ignore start */
|
|
1108
|
+
} else {
|
|
1109
|
+
passes = Boolean(nodes);
|
|
1110
|
+
}
|
|
1111
|
+
/* c8 ignore stop */
|
|
1112
|
+
} catch {
|
|
1113
|
+
passes = false;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
return passes;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Conditional with optional fallback (like choose/otherwise).
|
|
1121
|
+
* Truthiness same as `if()`.
|
|
1122
|
+
* @param {string} select XPath expression
|
|
1123
|
+
* @param {(this: XPathTransformerContext)
|
|
1124
|
+
* => void} whenCb Callback when condition passes
|
|
1125
|
+
* @param {(this: XPathTransformerContext)
|
|
1126
|
+
* => void} [otherwiseCb] Callback when condition fails
|
|
1127
|
+
* @returns {XPathTransformerContext}
|
|
1128
|
+
*/
|
|
1129
|
+
choose (select, whenCb, otherwiseCb) {
|
|
1130
|
+
const passes = this._passesIf(select);
|
|
1131
|
+
if (passes) {
|
|
1132
|
+
if (typeof whenCb === 'function') {
|
|
1133
|
+
whenCb.call(this);
|
|
1134
|
+
}
|
|
1135
|
+
} else if (typeof otherwiseCb === 'function') {
|
|
1136
|
+
otherwiseCb.call(this);
|
|
1137
|
+
}
|
|
1138
|
+
return this;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Analyze a string with a regular expression, equivalent to
|
|
1143
|
+
* xsl:analyze-string. Processes matching and non-matching substrings
|
|
1144
|
+
* with separate callbacks.
|
|
1145
|
+
* @param {string} str - The string to analyze
|
|
1146
|
+
* @param {string|RegExp} regex - Regular expression to match against
|
|
1147
|
+
* @param {{
|
|
1148
|
+
* matchingSubstring?: (
|
|
1149
|
+
* this: XPathTransformerContext,
|
|
1150
|
+
* substring: string,
|
|
1151
|
+
* groups: string[],
|
|
1152
|
+
* regexGroup: (n: number) => string
|
|
1153
|
+
* ) => void,
|
|
1154
|
+
* nonMatchingSubstring?: (
|
|
1155
|
+
* this: XPathTransformerContext,
|
|
1156
|
+
* substring: string
|
|
1157
|
+
* ) => void,
|
|
1158
|
+
* flags?: string
|
|
1159
|
+
* }} options - Options object
|
|
1160
|
+
* @returns {XPathTransformerContext}
|
|
1161
|
+
*/
|
|
1162
|
+
analyzeString (str, regex, options = {}) {
|
|
1163
|
+
// Ensure we have a string
|
|
1164
|
+
const inputString = String(str || '');
|
|
1165
|
+
|
|
1166
|
+
// If empty string, do nothing
|
|
1167
|
+
if (inputString.length === 0) {
|
|
1168
|
+
return this;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const {
|
|
1172
|
+
matchingSubstring,
|
|
1173
|
+
nonMatchingSubstring,
|
|
1174
|
+
flags = ''
|
|
1175
|
+
} = options;
|
|
1176
|
+
|
|
1177
|
+
// Convert regex to RegExp if it's a string
|
|
1178
|
+
let regexObj;
|
|
1179
|
+
if (typeof regex === 'string') {
|
|
1180
|
+
// Ensure 'g' flag is present for global matching
|
|
1181
|
+
const actualFlags = flags.includes('g') ? flags : flags + 'g';
|
|
1182
|
+
regexObj = new RegExp(regex, actualFlags);
|
|
1183
|
+
} else {
|
|
1184
|
+
regexObj = regex;
|
|
1185
|
+
// Ensure global flag is set
|
|
1186
|
+
if (!regexObj.global) {
|
|
1187
|
+
regexObj = new RegExp(
|
|
1188
|
+
regexObj.source,
|
|
1189
|
+
regexObj.flags + 'g'
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Check for zero-length matches (error condition in XSLT)
|
|
1195
|
+
if (regexObj.test('')) {
|
|
1196
|
+
throw new Error(
|
|
1197
|
+
'Regular expression matches zero-length string'
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// Store captured groups for access during callback
|
|
1202
|
+
/** @type {string[] | undefined} */
|
|
1203
|
+
let currentCapturedGroups;
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Get captured group by index.
|
|
1207
|
+
* @param {number} groupNumber - Group index
|
|
1208
|
+
* @returns {string} - Captured group or empty string
|
|
1209
|
+
*/
|
|
1210
|
+
const getRegexGroup = (groupNumber) => {
|
|
1211
|
+
if (!currentCapturedGroups ||
|
|
1212
|
+
groupNumber < 0 ||
|
|
1213
|
+
groupNumber >= currentCapturedGroups.length) {
|
|
1214
|
+
return '';
|
|
1215
|
+
}
|
|
1216
|
+
return currentCapturedGroups[groupNumber] || '';
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
// Save previous context to restore later
|
|
1220
|
+
const prevContext = this._contextNode;
|
|
1221
|
+
|
|
1222
|
+
let lastIndex = 0;
|
|
1223
|
+
let match;
|
|
1224
|
+
|
|
1225
|
+
// Bind callbacks to this context
|
|
1226
|
+
const boundMatchingSubstring = matchingSubstring
|
|
1227
|
+
? matchingSubstring.bind(this)
|
|
1228
|
+
: undefined;
|
|
1229
|
+
const boundNonMatchingSubstring = nonMatchingSubstring
|
|
1230
|
+
? nonMatchingSubstring.bind(this)
|
|
1231
|
+
: undefined;
|
|
1232
|
+
|
|
1233
|
+
// Find all matches
|
|
1234
|
+
while ((match = regexObj.exec(inputString)) !== null) {
|
|
1235
|
+
// Process non-matching substring before this match
|
|
1236
|
+
if (match.index > lastIndex) {
|
|
1237
|
+
const nonMatchingStr = inputString.slice(lastIndex, match.index);
|
|
1238
|
+
if (boundNonMatchingSubstring) {
|
|
1239
|
+
boundNonMatchingSubstring(nonMatchingStr);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Process matching substring
|
|
1244
|
+
if (boundMatchingSubstring) {
|
|
1245
|
+
const matchingStr = match[0];
|
|
1246
|
+
// Store captured groups: [full match, group1, group2, ...]
|
|
1247
|
+
currentCapturedGroups = [...match];
|
|
1248
|
+
boundMatchingSubstring(
|
|
1249
|
+
matchingStr, currentCapturedGroups, getRegexGroup
|
|
1250
|
+
);
|
|
1251
|
+
currentCapturedGroups = undefined;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const {lastIndex: newLastIndex} = regexObj;
|
|
1255
|
+
lastIndex = newLastIndex;
|
|
1256
|
+
|
|
1257
|
+
// Prevent infinite loop on zero-length matches (shouldn't happen
|
|
1258
|
+
// due to earlier check, but defensive)
|
|
1259
|
+
if (match.index === regexObj.lastIndex) {
|
|
1260
|
+
regexObj.lastIndex++;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// Process final non-matching substring
|
|
1265
|
+
if (lastIndex < inputString.length) {
|
|
1266
|
+
const nonMatchingStr = inputString.slice(lastIndex);
|
|
1267
|
+
if (boundNonMatchingSubstring) {
|
|
1268
|
+
boundNonMatchingSubstring(nonMatchingStr);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// Restore previous context
|
|
1273
|
+
this._contextNode = prevContext;
|
|
1274
|
+
|
|
1275
|
+
return this;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
454
1278
|
/* c8 ignore start -- static default rules object has spotty function
|
|
455
1279
|
* attribution under coverage; behavior is exercised via applyTemplates */
|
|
456
1280
|
static DefaultTemplateRules = {
|
|
457
1281
|
transformRoot: {
|
|
458
1282
|
/**
|
|
459
|
-
* @
|
|
1283
|
+
* @this {XPathTransformerContext}
|
|
1284
|
+
* @param {unknown} node Root node
|
|
460
1285
|
* @param {{mode:string}} cfg Config
|
|
461
1286
|
* @returns {void}
|
|
462
1287
|
*/
|
|
463
1288
|
template (node, cfg) {
|
|
464
|
-
|
|
1289
|
+
this.applyTemplates('.', cfg.mode);
|
|
465
1290
|
}
|
|
466
1291
|
},
|
|
467
1292
|
transformElements: {
|
|
468
1293
|
/**
|
|
469
|
-
* @
|
|
470
|
-
* @param {
|
|
1294
|
+
* @this {XPathTransformerContext}
|
|
1295
|
+
* @param {unknown} node Element node
|
|
1296
|
+
* @param {{mode?:string}} cfg Config
|
|
471
1297
|
* @returns {void}
|
|
472
1298
|
*/
|
|
473
1299
|
template (node, cfg) {
|
|
474
|
-
|
|
1300
|
+
this.applyTemplates('*', cfg.mode);
|
|
475
1301
|
}
|
|
476
1302
|
},
|
|
477
1303
|
transformTextNodes: {
|
|
@@ -484,9 +1310,12 @@ class XPathTransformerContext {
|
|
|
484
1310
|
}
|
|
485
1311
|
},
|
|
486
1312
|
transformScalars: {
|
|
487
|
-
/**
|
|
1313
|
+
/**
|
|
1314
|
+
* @this {XPathTransformerContext}
|
|
1315
|
+
* @returns {XPathTransformerContext}
|
|
1316
|
+
*/
|
|
488
1317
|
template () {
|
|
489
|
-
return
|
|
1318
|
+
return this.valueOf({select: '.'});
|
|
490
1319
|
}
|
|
491
1320
|
}
|
|
492
1321
|
};
|