@tradik/xslt-processor 1.0.2 → 1.1.1
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/README.md +292 -47
- package/bin/lib/options.js +114 -0
- package/bin/lib/paths.js +186 -0
- package/bin/lib/transform.js +115 -0
- package/bin/xslt.js +68 -162
- package/dist/xslt-processor.browser.js +2073 -163
- package/dist/xslt-processor.browser.js.map +4 -4
- package/dist/xslt-processor.browser.min.js +6 -2
- package/dist/xslt-processor.browser.min.js.map +4 -4
- package/dist/xslt-processor.cjs +2077 -162
- package/dist/xslt-processor.cjs.map +4 -4
- package/dist/xslt-processor.d.cts +299 -0
- package/dist/xslt-processor.d.ts +92 -4
- package/dist/xslt-processor.js +2072 -161
- package/dist/xslt-processor.js.map +4 -4
- package/package.json +27 -16
- package/src/XSLTProcessor.js +177 -8
- package/src/index.js +11 -5
- package/src/xpath/evaluator.js +48 -7
- package/src/xslt/elements.js +57 -0
- package/src/xslt/engine.js +474 -185
- package/src/xslt/formatNumber.js +220 -0
- package/src/xslt/functions.js +191 -0
- package/src/xslt/index.js +31 -0
- package/src/xslt/keys.js +141 -0
- package/src/xslt/literalResult.js +167 -0
- package/src/xslt/number.js +178 -0
- package/src/xslt/numberFormat.js +155 -0
- package/src/xslt/resultTree.js +74 -0
- package/src/xslt/serializer/baseWriter.js +283 -0
- package/src/xslt/serializer/constants.js +78 -0
- package/src/xslt/serializer/escape.js +98 -0
- package/src/xslt/serializer/htmlSerializer.js +141 -0
- package/src/xslt/serializer/indent.js +51 -0
- package/src/xslt/serializer/namespaces.js +68 -0
- package/src/xslt/serializer/rawText.js +41 -0
- package/src/xslt/serializer/settings.js +103 -0
- package/src/xslt/serializer/textSerializer.js +29 -0
- package/src/xslt/serializer/xmlSerializer.js +127 -0
- package/src/xslt/serializer.js +57 -0
- package/src/xslt/templatePriority.js +45 -0
- package/src/xslt/uri.js +68 -0
- package/src/xslt/whitespace.js +184 -0
- package/src/XSLTProcessor.test.js +0 -930
- package/src/xpath/evaluator.test.js +0 -1852
- package/src/xpath/tokenizer.test.js +0 -224
- package/src/xslt/engine.test.js +0 -3130
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Literal result element support: namespace aliasing and attribute filtering.
|
|
3
|
+
*
|
|
4
|
+
* `xsl:namespace-alias` rewrites the namespace of literal result elements and
|
|
5
|
+
* attributes, which is what makes it possible for a stylesheet to generate
|
|
6
|
+
* another stylesheet. Attribute filtering keeps XSLT-only attributes such as
|
|
7
|
+
* `xsl:use-attribute-sets` out of the result tree.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
"use strict";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a namespace prefix against the declarations in scope of a node.
|
|
14
|
+
*
|
|
15
|
+
* Falls back to walking `xmlns` attributes when the DOM implementation does not
|
|
16
|
+
* provide `lookupNamespaceURI`.
|
|
17
|
+
*
|
|
18
|
+
* @param {Element} node - The element whose scope is searched
|
|
19
|
+
* @param {string|null} prefix - The prefix, or null for the default namespace
|
|
20
|
+
* @returns {string|null} The namespace URI, or null when undeclared
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* lookupNamespaceUri(stylesheetElement, 'xsl');
|
|
24
|
+
*/
|
|
25
|
+
export function lookupNamespaceUri(node, prefix) {
|
|
26
|
+
if (typeof node.lookupNamespaceURI === "function") {
|
|
27
|
+
const found = node.lookupNamespaceURI(prefix);
|
|
28
|
+
if (found) return found;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const attributeName = prefix ? `xmlns:${prefix}` : "xmlns";
|
|
32
|
+
let current = node;
|
|
33
|
+
|
|
34
|
+
while (current?.nodeType === 1) {
|
|
35
|
+
const value = current.getAttribute(attributeName);
|
|
36
|
+
if (value) return value;
|
|
37
|
+
current = current.parentNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The `xsl:namespace-alias` declarations of a stylesheet.
|
|
45
|
+
*/
|
|
46
|
+
export class NamespaceAliasMap {
|
|
47
|
+
constructor() {
|
|
48
|
+
this.byUri = new Map();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Record one `xsl:namespace-alias` declaration.
|
|
53
|
+
*
|
|
54
|
+
* @param {Element} node - The `xsl:namespace-alias` element
|
|
55
|
+
* @returns {void}
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* aliases.add(namespaceAliasElement);
|
|
59
|
+
*/
|
|
60
|
+
add(node) {
|
|
61
|
+
const stylesheetPrefix = node.getAttribute("stylesheet-prefix");
|
|
62
|
+
const resultPrefix = node.getAttribute("result-prefix");
|
|
63
|
+
if (!stylesheetPrefix || !resultPrefix) return;
|
|
64
|
+
|
|
65
|
+
const fromUri = lookupNamespaceUri(
|
|
66
|
+
node,
|
|
67
|
+
stylesheetPrefix === "#default" ? null : stylesheetPrefix,
|
|
68
|
+
);
|
|
69
|
+
if (!fromUri) return;
|
|
70
|
+
|
|
71
|
+
const isDefaultResult = resultPrefix === "#default";
|
|
72
|
+
const toUri = lookupNamespaceUri(
|
|
73
|
+
node,
|
|
74
|
+
isDefaultResult ? null : resultPrefix,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
this.byUri.set(fromUri, {
|
|
78
|
+
uri: toUri,
|
|
79
|
+
prefix: isDefaultResult ? null : resultPrefix,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether any alias was declared.
|
|
85
|
+
*
|
|
86
|
+
* @returns {boolean} True when at least one alias is known
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* aliases.isEmpty();
|
|
90
|
+
*/
|
|
91
|
+
isEmpty() {
|
|
92
|
+
return this.byUri.size === 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Apply aliasing to a literal result name.
|
|
97
|
+
*
|
|
98
|
+
* @param {string|null} namespaceUri - The namespace of the stylesheet node
|
|
99
|
+
* @param {string} localName - The local name of the stylesheet node
|
|
100
|
+
* @returns {{namespaceUri: (string|null), qname: string}|null} The aliased name, or null when no alias applies
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* aliases.resolve('http://www.w3.org/1999/XSL/TransformAlias', 'stylesheet');
|
|
104
|
+
* // { namespaceUri: 'http://www.w3.org/1999/XSL/Transform', qname: 'xsl:stylesheet' }
|
|
105
|
+
*/
|
|
106
|
+
resolve(namespaceUri, localName) {
|
|
107
|
+
const alias = namespaceUri ? this.byUri.get(namespaceUri) : undefined;
|
|
108
|
+
if (!alias) return null;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
namespaceUri: alias.uri,
|
|
112
|
+
qname: alias.prefix ? `${alias.prefix}:${localName}` : localName,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Whether an attribute of a literal result element is copied to the output.
|
|
119
|
+
*
|
|
120
|
+
* Namespace declarations are re-created from the element namespaces themselves,
|
|
121
|
+
* and every XSLT attribute (`xsl:use-attribute-sets`, `xsl:version`,
|
|
122
|
+
* `xsl:exclude-result-prefixes`, `xsl:extension-element-prefixes`) is an
|
|
123
|
+
* instruction to the processor rather than result tree content.
|
|
124
|
+
*
|
|
125
|
+
* @param {Attr} attribute - The attribute of the stylesheet element
|
|
126
|
+
* @param {string} xsltNamespace - The XSLT namespace URI
|
|
127
|
+
* @returns {boolean} True when the attribute belongs in the result
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* shouldCopyAttribute(attr, 'http://www.w3.org/1999/XSL/Transform');
|
|
131
|
+
*/
|
|
132
|
+
export function shouldCopyAttribute(attribute, xsltNamespace) {
|
|
133
|
+
if (attribute.namespaceURI === xsltNamespace) return false;
|
|
134
|
+
if (attribute.name === "xmlns" || attribute.name.startsWith("xmlns:")) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
return !attribute.name.startsWith("xsl:");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read an XSLT attribute from a literal result element.
|
|
142
|
+
*
|
|
143
|
+
* Works both for namespace aware DOMs and for documents where the attribute is
|
|
144
|
+
* only known by its `xsl:` qualified name.
|
|
145
|
+
*
|
|
146
|
+
* @param {Element} node - The literal result element
|
|
147
|
+
* @param {string} localName - The XSLT attribute local name
|
|
148
|
+
* @param {string} xsltNamespace - The XSLT namespace URI
|
|
149
|
+
* @returns {string|null} The attribute value, or null when absent
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* getXsltAttribute(element, 'use-attribute-sets', XSLT_NS);
|
|
153
|
+
*/
|
|
154
|
+
export function getXsltAttribute(node, localName, xsltNamespace) {
|
|
155
|
+
if (!node.attributes) return null;
|
|
156
|
+
|
|
157
|
+
for (const attribute of node.attributes) {
|
|
158
|
+
const matchesNamespace =
|
|
159
|
+
attribute.namespaceURI === xsltNamespace &&
|
|
160
|
+
(attribute.localName || attribute.name) === localName;
|
|
161
|
+
if (matchesNamespace || attribute.name === `xsl:${localName}`) {
|
|
162
|
+
return attribute.value;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `xsl:number` counting and number-to-string conversion.
|
|
3
|
+
*
|
|
4
|
+
* Counting is kept independent from the engine: callers pass a `matcher`
|
|
5
|
+
* callback that answers "does this node match this XSLT pattern", which keeps
|
|
6
|
+
* this module free of any XPath dependency and easy to test in isolation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
/** Node types that participate in `xsl:number` counting. */
|
|
12
|
+
const COUNTABLE_NODE_TYPES = new Set([1, 3, 4, 7, 8]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Default `count` pattern behaviour: match nodes of the same type and name.
|
|
16
|
+
*
|
|
17
|
+
* @param {Node} candidate - The node being considered
|
|
18
|
+
* @param {Node} node - The node `xsl:number` is numbering
|
|
19
|
+
* @returns {boolean} True when the candidate is of the same kind
|
|
20
|
+
*/
|
|
21
|
+
function matchesDefaultCount(candidate, node) {
|
|
22
|
+
if (candidate.nodeType !== node.nodeType) return false;
|
|
23
|
+
if (candidate.nodeType === 1) return candidate.nodeName === node.nodeName;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the predicate used to decide whether a node is counted.
|
|
29
|
+
*
|
|
30
|
+
* @param {Node} node - The node being numbered
|
|
31
|
+
* @param {string|null} count - The `count` pattern, if any
|
|
32
|
+
* @param {(node: Node, pattern: string) => boolean} matcher - Pattern matcher
|
|
33
|
+
* @returns {(candidate: Node) => boolean} The predicate
|
|
34
|
+
*/
|
|
35
|
+
function createCountPredicate(node, count, matcher) {
|
|
36
|
+
if (count) return (candidate) => matcher(candidate, count);
|
|
37
|
+
return (candidate) => matchesDefaultCount(candidate, node);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the predicate marking `from` boundaries.
|
|
42
|
+
*
|
|
43
|
+
* @param {string|null} from - The `from` pattern, if any
|
|
44
|
+
* @param {(node: Node, pattern: string) => boolean} matcher - Pattern matcher
|
|
45
|
+
* @returns {(candidate: Node) => boolean} The predicate, always false without `from`
|
|
46
|
+
*/
|
|
47
|
+
function createFromPredicate(from, matcher) {
|
|
48
|
+
if (!from) return () => false;
|
|
49
|
+
return (candidate) => matcher(candidate, from);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Count preceding siblings of a node that satisfy the predicate.
|
|
54
|
+
*
|
|
55
|
+
* @param {Node} node - The node whose position is computed
|
|
56
|
+
* @param {(candidate: Node) => boolean} isCounted - Counting predicate
|
|
57
|
+
* @returns {number} The 1-based position
|
|
58
|
+
*/
|
|
59
|
+
function siblingPosition(node, isCounted) {
|
|
60
|
+
let position = 1;
|
|
61
|
+
let sibling = node.previousSibling;
|
|
62
|
+
while (sibling) {
|
|
63
|
+
if (COUNTABLE_NODE_TYPES.has(sibling.nodeType) && isCounted(sibling)) {
|
|
64
|
+
position++;
|
|
65
|
+
}
|
|
66
|
+
sibling = sibling.previousSibling;
|
|
67
|
+
}
|
|
68
|
+
return position;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Collect nodes in document order up to and including a target node.
|
|
73
|
+
*
|
|
74
|
+
* @param {Node} target - The node at which traversal stops
|
|
75
|
+
* @returns {Node[]} Nodes in document order, ending with the target
|
|
76
|
+
*/
|
|
77
|
+
function nodesUpToTarget(target) {
|
|
78
|
+
const root = target.ownerDocument || target;
|
|
79
|
+
const result = [];
|
|
80
|
+
const stack = [root];
|
|
81
|
+
|
|
82
|
+
while (stack.length > 0) {
|
|
83
|
+
const current = stack.pop();
|
|
84
|
+
result.push(current);
|
|
85
|
+
if (current === target) break;
|
|
86
|
+
const children = current.childNodes;
|
|
87
|
+
if (children) {
|
|
88
|
+
for (let i = children.length - 1; i >= 0; i--) stack.push(children[i]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Count a node according to `level="single"`.
|
|
97
|
+
*
|
|
98
|
+
* @param {Node} node - The node being numbered
|
|
99
|
+
* @param {(candidate: Node) => boolean} isCounted - Counting predicate
|
|
100
|
+
* @param {(candidate: Node) => boolean} isFrom - Boundary predicate
|
|
101
|
+
* @returns {number[]} A single number, or an empty list when nothing matches
|
|
102
|
+
*/
|
|
103
|
+
function countSingle(node, isCounted, isFrom) {
|
|
104
|
+
let current = node;
|
|
105
|
+
while (current && current.nodeType !== 9) {
|
|
106
|
+
if (isFrom(current)) return [];
|
|
107
|
+
if (isCounted(current)) return [siblingPosition(current, isCounted)];
|
|
108
|
+
current = current.parentNode;
|
|
109
|
+
}
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Count a node according to `level="multiple"`.
|
|
115
|
+
*
|
|
116
|
+
* @param {Node} node - The node being numbered
|
|
117
|
+
* @param {(candidate: Node) => boolean} isCounted - Counting predicate
|
|
118
|
+
* @param {(candidate: Node) => boolean} isFrom - Boundary predicate
|
|
119
|
+
* @returns {number[]} Numbers from the outermost ancestor inwards
|
|
120
|
+
*/
|
|
121
|
+
function countMultiple(node, isCounted, isFrom) {
|
|
122
|
+
const numbers = [];
|
|
123
|
+
let current = node;
|
|
124
|
+
|
|
125
|
+
while (current && current.nodeType !== 9) {
|
|
126
|
+
if (isFrom(current)) break;
|
|
127
|
+
if (isCounted(current)) {
|
|
128
|
+
numbers.unshift(siblingPosition(current, isCounted));
|
|
129
|
+
}
|
|
130
|
+
current = current.parentNode;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return numbers;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Count a node according to `level="any"`.
|
|
138
|
+
*
|
|
139
|
+
* @param {Node} node - The node being numbered
|
|
140
|
+
* @param {(candidate: Node) => boolean} isCounted - Counting predicate
|
|
141
|
+
* @param {(candidate: Node) => boolean} isFrom - Boundary predicate
|
|
142
|
+
* @returns {number[]} A single number, or an empty list when nothing matches
|
|
143
|
+
*/
|
|
144
|
+
function countAny(node, isCounted, isFrom) {
|
|
145
|
+
let total = 0;
|
|
146
|
+
|
|
147
|
+
for (const candidate of nodesUpToTarget(node)) {
|
|
148
|
+
if (!COUNTABLE_NODE_TYPES.has(candidate.nodeType)) continue;
|
|
149
|
+
if (isFrom(candidate)) {
|
|
150
|
+
total = 0;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (isCounted(candidate)) total++;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return total > 0 ? [total] : [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Compute the number sequence for an `xsl:number` instruction.
|
|
161
|
+
*
|
|
162
|
+
* @param {Node} node - The current node
|
|
163
|
+
* @param {{level?: string, count?: string|null, from?: string|null}} options - Instruction attributes
|
|
164
|
+
* @param {(node: Node, pattern: string) => boolean} matcher - XSLT pattern matcher
|
|
165
|
+
* @returns {number[]} The computed numbers, outermost first
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* countXsltNumber(item, { level: 'any' }, matcher); // [2]
|
|
169
|
+
*/
|
|
170
|
+
export function countXsltNumber(node, options, matcher) {
|
|
171
|
+
const { level = "single", count = null, from = null } = options;
|
|
172
|
+
const isCounted = createCountPredicate(node, count, matcher);
|
|
173
|
+
const isFrom = createFromPredicate(from, matcher);
|
|
174
|
+
|
|
175
|
+
if (level === "any") return countAny(node, isCounted, isFrom);
|
|
176
|
+
if (level === "multiple") return countMultiple(node, isCounted, isFrom);
|
|
177
|
+
return countSingle(node, isCounted, isFrom);
|
|
178
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `xsl:number` number-to-string conversion.
|
|
3
|
+
*
|
|
4
|
+
* Renders the number sequence produced by {@link countXsltNumber} using the
|
|
5
|
+
* `format` attribute of `xsl:number`: numeric tokens (`1`, `01`), alphabetic
|
|
6
|
+
* tokens (`a`, `A`) and Roman numerals (`i`, `I`), together with the prefix,
|
|
7
|
+
* separators and suffix taken from the format string itself.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
"use strict";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Convert a positive integer to a bijective base-26 alphabetic sequence.
|
|
14
|
+
*
|
|
15
|
+
* @param {number} value - The number to convert
|
|
16
|
+
* @param {boolean} upperCase - Whether to emit upper case letters
|
|
17
|
+
* @returns {string} The alphabetic representation, e.g. `27` becomes `aa`
|
|
18
|
+
*/
|
|
19
|
+
function toAlphabetic(value, upperCase) {
|
|
20
|
+
let remaining = value;
|
|
21
|
+
let result = "";
|
|
22
|
+
|
|
23
|
+
while (remaining > 0) {
|
|
24
|
+
const index = (remaining - 1) % 26;
|
|
25
|
+
result = String.fromCodePoint((upperCase ? 65 : 97) + index) + result;
|
|
26
|
+
remaining = Math.floor((remaining - 1) / 26);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Roman numeral building blocks, largest first. */
|
|
33
|
+
const ROMAN_NUMERALS = Object.freeze([
|
|
34
|
+
["M", 1000],
|
|
35
|
+
["CM", 900],
|
|
36
|
+
["D", 500],
|
|
37
|
+
["CD", 400],
|
|
38
|
+
["C", 100],
|
|
39
|
+
["XC", 90],
|
|
40
|
+
["L", 50],
|
|
41
|
+
["XL", 40],
|
|
42
|
+
["X", 10],
|
|
43
|
+
["IX", 9],
|
|
44
|
+
["V", 5],
|
|
45
|
+
["IV", 4],
|
|
46
|
+
["I", 1],
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Convert a positive integer to a Roman numeral.
|
|
51
|
+
*
|
|
52
|
+
* @param {number} value - The number to convert
|
|
53
|
+
* @returns {string} The upper case Roman numeral
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* toRoman(2004); // 'MMIV'
|
|
57
|
+
*/
|
|
58
|
+
export function toRoman(value) {
|
|
59
|
+
let remaining = value;
|
|
60
|
+
let result = "";
|
|
61
|
+
|
|
62
|
+
for (const [numeral, amount] of ROMAN_NUMERALS) {
|
|
63
|
+
while (remaining >= amount) {
|
|
64
|
+
result += numeral;
|
|
65
|
+
remaining -= amount;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Render one number with a single `xsl:number` format token.
|
|
74
|
+
*
|
|
75
|
+
* @param {number} value - The number to render
|
|
76
|
+
* @param {string} token - The format token, e.g. `1`, `01`, `a`, `I`
|
|
77
|
+
* @returns {string} The rendered number
|
|
78
|
+
*/
|
|
79
|
+
function formatToken(value, token) {
|
|
80
|
+
if (/^\d+$/.test(token)) {
|
|
81
|
+
return String(value).padStart(token.length, "0");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (value <= 0) return String(value);
|
|
85
|
+
|
|
86
|
+
switch (token) {
|
|
87
|
+
case "a":
|
|
88
|
+
return toAlphabetic(value, false);
|
|
89
|
+
case "A":
|
|
90
|
+
return toAlphabetic(value, true);
|
|
91
|
+
case "i":
|
|
92
|
+
return toRoman(value).toLowerCase();
|
|
93
|
+
case "I":
|
|
94
|
+
return toRoman(value);
|
|
95
|
+
default:
|
|
96
|
+
return String(value);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Split an `xsl:number` format string into prefix, tokens, separators, suffix.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} format - The format attribute value
|
|
104
|
+
* @returns {{prefix: string, suffix: string, tokens: string[], separators: string[]}} The parsed format
|
|
105
|
+
*/
|
|
106
|
+
function parseFormat(format) {
|
|
107
|
+
const parts = format.match(/[a-zA-Z0-9]+|[^a-zA-Z0-9]+/g) || [];
|
|
108
|
+
const isToken = (part) => /^[a-zA-Z0-9]+$/.test(part);
|
|
109
|
+
|
|
110
|
+
const tokens = [];
|
|
111
|
+
const separators = [];
|
|
112
|
+
let prefix = "";
|
|
113
|
+
let suffix = "";
|
|
114
|
+
|
|
115
|
+
for (const part of parts) {
|
|
116
|
+
if (isToken(part)) tokens.push(part);
|
|
117
|
+
else if (tokens.length === 0) prefix = part;
|
|
118
|
+
else separators.push(part);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (parts.length > 0 && tokens.length > 0 && !isToken(parts.at(-1))) {
|
|
122
|
+
suffix = separators.pop();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (tokens.length === 0) tokens.push("1");
|
|
126
|
+
|
|
127
|
+
return { prefix, suffix, tokens, separators };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Format a number sequence produced by {@link countXsltNumber}.
|
|
132
|
+
*
|
|
133
|
+
* @param {number[]} numbers - The numbers, outermost first
|
|
134
|
+
* @param {string} [format] - The `format` attribute value
|
|
135
|
+
* @returns {string} The formatted string, empty when there is nothing to number
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* formatXsltNumber([2, 3], '1.1'); // '2.3'
|
|
139
|
+
*/
|
|
140
|
+
export function formatXsltNumber(numbers, format = "1") {
|
|
141
|
+
if (numbers.length === 0) return "";
|
|
142
|
+
|
|
143
|
+
const { prefix, suffix, tokens, separators } = parseFormat(format);
|
|
144
|
+
let result = prefix;
|
|
145
|
+
|
|
146
|
+
numbers.forEach((value, index) => {
|
|
147
|
+
if (index > 0) {
|
|
148
|
+
const separator = separators[index - 1] ?? separators.at(-1) ?? ".";
|
|
149
|
+
result += separator;
|
|
150
|
+
}
|
|
151
|
+
result += formatToken(value, tokens[index] ?? tokens.at(-1));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return result + suffix;
|
|
155
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result tree construction helpers.
|
|
3
|
+
*
|
|
4
|
+
* XSLT builds its result tree in a neutral XML document: building directly in
|
|
5
|
+
* an HTML owner document would lower case element names and force the XHTML
|
|
6
|
+
* namespace on every created element. The finished tree is imported into the
|
|
7
|
+
* caller's document only at the very end, which keeps names, namespaces and the
|
|
8
|
+
* `disable-output-escaping` markers intact.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create an empty, namespace neutral XML document.
|
|
15
|
+
*
|
|
16
|
+
* @param {Document} ownerDocument - Any document, used for its DOM implementation
|
|
17
|
+
* @returns {Document} A fresh empty XML document
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* const resultDoc = createResultDocument(window.document);
|
|
21
|
+
*/
|
|
22
|
+
export function createResultDocument(ownerDocument) {
|
|
23
|
+
return ownerDocument.implementation.createDocument(null, null, null);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Deep-import a result tree node into another document.
|
|
28
|
+
*
|
|
29
|
+
* Unlike `Document.importNode` this preserves the internal
|
|
30
|
+
* `_disableOutputEscaping` marker set by `disable-output-escaping`.
|
|
31
|
+
*
|
|
32
|
+
* @param {Node} node - The node to import
|
|
33
|
+
* @param {Document} targetDoc - The document that will own the copy
|
|
34
|
+
* @returns {Node} The imported copy
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* const copy = importResultNode(element, window.document);
|
|
38
|
+
*/
|
|
39
|
+
export function importResultNode(node, targetDoc) {
|
|
40
|
+
const copy = targetDoc.importNode(node, false);
|
|
41
|
+
|
|
42
|
+
if (node._disableOutputEscaping) {
|
|
43
|
+
copy._disableOutputEscaping = true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (node.childNodes) {
|
|
47
|
+
for (const child of node.childNodes) {
|
|
48
|
+
copy.appendChild(importResultNode(child, targetDoc));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return copy;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Move a finished result fragment into the caller's output document.
|
|
57
|
+
*
|
|
58
|
+
* @param {DocumentFragment} fragment - The fragment built in the neutral document
|
|
59
|
+
* @param {Document} targetDoc - The document that will own the result
|
|
60
|
+
* @returns {DocumentFragment} A fragment owned by `targetDoc`
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* const result = importResultFragment(fragment, window.document);
|
|
64
|
+
*/
|
|
65
|
+
export function importResultFragment(fragment, targetDoc) {
|
|
66
|
+
if (fragment.ownerDocument === targetDoc) return fragment;
|
|
67
|
+
|
|
68
|
+
const imported = targetDoc.createDocumentFragment();
|
|
69
|
+
for (const child of fragment.childNodes) {
|
|
70
|
+
imported.appendChild(importResultNode(child, targetDoc));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return imported;
|
|
74
|
+
}
|