Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2-beta
- uses: actions/checkout@v4
with:
node-version: '20'
submodules: recursive
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: ArtiomTr/jest-coverage-report-action@v2
id: coverage
with:
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,54 @@ HTML per se is not strict XML. Because of that, starting on version 2.0.0, this
- Tags like `<hr>`, `<link>` and `<meta>` don't need to be closed. The output for these tags doesn't close them (adding a `/` before the tag closes, or a corresponding close tag);
- This rule doesn't apply for XHTML, which is strict XML.

### Whitespace Handling

This library supports `xsl:strip-space` and `xsl:preserve-space` for controlling whitespace in the input document.

#### `xsl:strip-space`

Use `<xsl:strip-space>` to remove whitespace-only text nodes from specified elements in the input document:

```xml
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<!-- Strip whitespace from all elements -->
<xsl:strip-space elements="*"/>

<!-- Or strip from specific elements -->
<xsl:strip-space elements="book chapter section"/>

<!-- ... templates ... -->
</xsl:stylesheet>
```

The `elements` attribute accepts:
- `*` - matches all elements
- `name` - matches elements with the specified local name
- `prefix:*` - matches all elements in a namespace
- `prefix:name` - matches a specific element in a namespace
- Multiple patterns separated by whitespace (e.g., `"book chapter section"`)

#### `xsl:preserve-space`

Use `<xsl:preserve-space>` to preserve whitespace in specific elements, overriding `xsl:strip-space`:

```xml
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:strip-space elements="*"/>
<!-- Preserve whitespace in pre and code elements -->
<xsl:preserve-space elements="pre code"/>

<!-- ... templates ... -->
</xsl:stylesheet>
```

#### Precedence Rules

1. `xml:space="preserve"` attribute on an element takes highest precedence
2. `xsl:preserve-space` overrides `xsl:strip-space` for matching elements
3. `xsl:strip-space` applies to remaining matches
4. By default (no declarations), whitespace is preserved

## References

- XPath Specification: http://www.w3.org/TR/1999/REC-xpath-19991116
Expand Down
152 changes: 150 additions & 2 deletions src/xslt/xslt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@
version: string;
firstTemplateRan: boolean;

/**
* List of element name patterns from xsl:strip-space declarations.
* Whitespace-only text nodes inside matching elements will be stripped.
*/
stripSpacePatterns: string[];

/**
* List of element name patterns from xsl:preserve-space declarations.
* Whitespace-only text nodes inside matching elements will be preserved.
* preserve-space takes precedence over strip-space for conflicting patterns.
*/
preserveSpacePatterns: string[];

constructor(
options: Partial<XsltOptions> = {
cData: true,
Expand All @@ -100,6 +113,8 @@
};
this.outputMethod = 'xml';
this.outputOmitXmlDeclaration = 'no';
this.stripSpacePatterns = [];
this.preserveSpacePatterns = [];
this.decimalFormatSettings = {
decimalSeparator: '.',
groupingSeparator: ',',
Expand Down Expand Up @@ -237,14 +252,16 @@
await this.xsltVariable(context, template, false);
break;
case 'preserve-space':
throw new Error(`not implemented: ${template.localName}`);
this.xsltPreserveSpace(template);
break;
case 'processing-instruction':
throw new Error(`not implemented: ${template.localName}`);
case 'sort':
this.xsltSort(context, template);
break;
case 'strip-space':
throw new Error(`not implemented: ${template.localName}`);
this.xsltStripSpace(template);
break;
case 'stylesheet':
case 'transform':
await this.xsltTransformOrStylesheet(context, template, output);
Expand Down Expand Up @@ -435,6 +452,10 @@
}

if (source.nodeType == DOM_TEXT_NODE) {
// Check if this whitespace-only text node should be stripped
if (this.shouldStripWhitespaceNode(source)) {
return null;
}
let node = domCreateTextNode(this.outputDocument, source.nodeValue);
node.siblingPosition = destination.childNodes.length;
domAppendChild(destination, node);
Expand Down Expand Up @@ -722,6 +743,127 @@
this.xPath.xPathSort(context, sort);
}

/**
* Implements `xsl:strip-space`.
* Collects element name patterns for which whitespace-only text nodes should be stripped.
* @param template The `<xsl:strip-space>` node.
*/
protected xsltStripSpace(template: XNode) {
const elements = xmlGetAttribute(template, 'elements');
if (elements) {
// Split on whitespace to get individual patterns (e.g., "* book" becomes ["*", "book"])
const patterns = elements.trim().split(/\s+/);
this.stripSpacePatterns.push(...patterns);
}
}

/**
* Implements `xsl:preserve-space`.
* Collects element name patterns for which whitespace-only text nodes should be preserved.
* preserve-space takes precedence over strip-space for matching elements.
* @param template The `<xsl:preserve-space>` node.
*/
protected xsltPreserveSpace(template: XNode) {
const elements = xmlGetAttribute(template, 'elements');
if (elements) {
// Split on whitespace to get individual patterns (e.g., "pre code" becomes ["pre", "code"])
const patterns = elements.trim().split(/\s+/);
this.preserveSpacePatterns.push(...patterns);
}
}

/**
* Determines if a text node from the input document should be stripped.
* This applies xsl:strip-space and xsl:preserve-space rules to whitespace-only text nodes.
* @param textNode The text node to check.
* @returns True if the text node should be stripped (not included in output).
*/
protected shouldStripWhitespaceNode(textNode: XNode): boolean {
// Only strip whitespace-only text nodes
if (!textNode.nodeValue || !textNode.nodeValue.match(/^\s*$/)) {
return false;
}

// If no strip-space patterns are defined, don't strip
if (this.stripSpacePatterns.length === 0) {
return false;
}

const parentElement = textNode.parentNode;
if (!parentElement || parentElement.nodeType !== DOM_ELEMENT_NODE) {
return false;

Check warning on line 794 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}

Check warning on line 795 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

// Check for xml:space="preserve" on parent or ancestors (highest precedence)
let ancestor = parentElement;
while (ancestor && ancestor.nodeType === DOM_ELEMENT_NODE) {
const xmlspace = domGetAttributeValue(ancestor, 'xml:space');
if (xmlspace === 'preserve') {
return false;

Check warning on line 802 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}

Check warning on line 803 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
if (xmlspace === 'default') {
break; // Continue to check strip-space/preserve-space rules

Check warning on line 805 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}

Check warning on line 806 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
ancestor = ancestor.parentNode;
}

const parentName = parentElement.localName || parentElement.nodeName;

Check warning on line 810 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

// Check preserve-space patterns first (they take precedence over strip-space)
for (const pattern of this.preserveSpacePatterns) {
if (this.matchesNamePattern(parentName, pattern, parentElement)) {
return false;

Check warning on line 815 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}

Check warning on line 816 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
}

// Check strip-space patterns
for (const pattern of this.stripSpacePatterns) {
if (this.matchesNamePattern(parentName, pattern, parentElement)) {
return true;
}
}

return false;
}

/**
* Matches an element name against a strip-space/preserve-space pattern.
* Supports:
* - "*" matches any element
* - "prefix:*" matches any element in a namespace
* - "name" matches elements with that local name
* - "prefix:name" matches elements with that QName
* @param elementName The local name of the element.
* @param pattern The pattern to match against.
* @param element The element node (for namespace checking).
* @returns True if the element matches the pattern.
*/
protected matchesNamePattern(elementName: string, pattern: string, element: XNode): boolean {
// Universal match
if (pattern === '*') {
return true;
}

// Handle patterns with namespace prefixes
if (pattern.includes(':')) {
const [prefix, localPart] = pattern.split(':');

Check warning on line 849 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

// Check if element has a matching prefix
const elementPrefix = element.prefix || '';

Check warning on line 852 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 852 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

Check warning on line 852 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

if (localPart === '*') {
// prefix:* - match any element in that namespace
return elementPrefix === prefix;

Check warning on line 856 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
} else {
// prefix:name - match specific element in namespace
return elementPrefix === prefix && elementName === localPart;

Check warning on line 859 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 859 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

Check warning on line 859 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
}

Check warning on line 860 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 860 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

Check warning on line 860 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
}

Check warning on line 861 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

// Simple name match (no namespace prefix in pattern)
return elementName === pattern;
}

/**
* Implements `xsl:template`.
* @param context The Expression Context.
Expand Down Expand Up @@ -966,6 +1108,12 @@
*/
private commonLogicTextNode(context: ExprContext, template: XNode, output: XNode) {
if (output) {
// Check if this whitespace-only text node should be stripped based on
// xsl:strip-space and xsl:preserve-space declarations
if (this.shouldStripWhitespaceNode(template)) {
return;

Check warning on line 1114 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}

Check warning on line 1115 in src/xslt/xslt.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

let node = domCreateTextNode(this.outputDocument, template.nodeValue);
// Set siblingPosition to preserve insertion order during serialization
node.siblingPosition = output.childNodes.length;
Expand Down
Loading