@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.
Files changed (47) hide show
  1. package/README.md +292 -47
  2. package/bin/lib/options.js +114 -0
  3. package/bin/lib/paths.js +186 -0
  4. package/bin/lib/transform.js +115 -0
  5. package/bin/xslt.js +68 -162
  6. package/dist/xslt-processor.browser.js +2073 -163
  7. package/dist/xslt-processor.browser.js.map +4 -4
  8. package/dist/xslt-processor.browser.min.js +6 -2
  9. package/dist/xslt-processor.browser.min.js.map +4 -4
  10. package/dist/xslt-processor.cjs +2077 -162
  11. package/dist/xslt-processor.cjs.map +4 -4
  12. package/dist/xslt-processor.d.cts +299 -0
  13. package/dist/xslt-processor.d.ts +92 -4
  14. package/dist/xslt-processor.js +2072 -161
  15. package/dist/xslt-processor.js.map +4 -4
  16. package/package.json +27 -16
  17. package/src/XSLTProcessor.js +177 -8
  18. package/src/index.js +11 -5
  19. package/src/xpath/evaluator.js +48 -7
  20. package/src/xslt/elements.js +57 -0
  21. package/src/xslt/engine.js +474 -185
  22. package/src/xslt/formatNumber.js +220 -0
  23. package/src/xslt/functions.js +191 -0
  24. package/src/xslt/index.js +31 -0
  25. package/src/xslt/keys.js +141 -0
  26. package/src/xslt/literalResult.js +167 -0
  27. package/src/xslt/number.js +178 -0
  28. package/src/xslt/numberFormat.js +155 -0
  29. package/src/xslt/resultTree.js +74 -0
  30. package/src/xslt/serializer/baseWriter.js +283 -0
  31. package/src/xslt/serializer/constants.js +78 -0
  32. package/src/xslt/serializer/escape.js +98 -0
  33. package/src/xslt/serializer/htmlSerializer.js +141 -0
  34. package/src/xslt/serializer/indent.js +51 -0
  35. package/src/xslt/serializer/namespaces.js +68 -0
  36. package/src/xslt/serializer/rawText.js +41 -0
  37. package/src/xslt/serializer/settings.js +103 -0
  38. package/src/xslt/serializer/textSerializer.js +29 -0
  39. package/src/xslt/serializer/xmlSerializer.js +127 -0
  40. package/src/xslt/serializer.js +57 -0
  41. package/src/xslt/templatePriority.js +45 -0
  42. package/src/xslt/uri.js +68 -0
  43. package/src/xslt/whitespace.js +184 -0
  44. package/src/XSLTProcessor.test.js +0 -930
  45. package/src/xpath/evaluator.test.js +0 -1852
  46. package/src/xpath/tokenizer.test.js +0 -224
  47. 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
+ }