feat(xslt): implement xsl:output cdata-section-elements

The text content of the named result elements is emitted in CDATA
sections rather than escaped: the result tree's text children of those
elements are converted to CDATA nodes before serialization, leaving
nested and other elements unaffected. Part of #82.
This commit is contained in:
Mihaela Mihaljevic 2026-06-09 02:58:34 +02:00
parent 9b07a5901d
commit 3fec78da51
5 changed files with 77 additions and 3 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- XSLT `xsl:output cdata-section-elements` (#82). The text content of the named result elements is emitted in `<![CDATA[…]]>` sections instead of escaped, leaving nested elements and other elements unaffected.
- XSLT `xsl:output` `doctype-public`/`doctype-system` (#82). When either is set, a `<!DOCTYPE>` for the result's root element (PUBLIC when both are given, SYSTEM otherwise) is emitted before the serialized output, for XML and HTML methods.
- XSLT `key()` over a node-set second argument (#82). `key(name, node-set)` (including `key('k', .)`) now unions the matches for every node's string value and de-duplicates, rather than using only the first node's string value.
- XSLT `method="html"` output (#82). An `xsl:output method="html"` result is now serialized by the HTML rules (void elements like `<br>` emitted without a self-closing slash, raw-text elements like `<script>`/`<style>` left unescaped) instead of as XML.

View File

@ -102,6 +102,8 @@ public extension PureXML.XSLT {
/// a `<!DOCTYPE>` for the result's root element is emitted before it.
public var doctypePublic: String?
public var doctypeSystem: String?
/// Element names whose text content is emitted in `<![CDATA[…]]>` sections.
public var cdataSectionElements: Set<String>
public init(
method: String? = nil,
@ -112,6 +114,7 @@ public extension PureXML.XSLT {
standalone: Bool? = nil,
doctypePublic: String? = nil,
doctypeSystem: String? = nil,
cdataSectionElements: Set<String> = [],
) {
self.method = method
self.indent = indent
@ -121,6 +124,7 @@ public extension PureXML.XSLT {
self.standalone = standalone
self.doctypePublic = doctypePublic
self.doctypeSystem = doctypeSystem
self.cdataSectionElements = cdataSectionElements
}
/// This output's settings with `other`'s non-nil settings layered over them.
@ -134,6 +138,7 @@ public extension PureXML.XSLT {
standalone: other.standalone ?? standalone,
doctypePublic: other.doctypePublic ?? doctypePublic,
doctypeSystem: other.doctypeSystem ?? doctypeSystem,
cdataSectionElements: cdataSectionElements.union(other.cdataSectionElements),
)
}
}

View File

@ -167,6 +167,7 @@ extension PureXML.XSLT {
standalone: XSLTNode.attribute(node, "standalone").map { $0 == "yes" },
doctypePublic: XSLTNode.attribute(node, "doctype-public"),
doctypeSystem: XSLTNode.attribute(node, "doctype-system"),
cdataSectionElements: Set((XSLTNode.attribute(node, "cdata-section-elements") ?? "").split(whereSeparator: \.isWhitespace).map(String.init)),
)
}

View File

@ -17,12 +17,35 @@ public extension PureXML.XSLT {
let result = transformer.run()
if let message = transformer.terminationMessage { throw XSLTError.terminated(message) }
if sheet.output.method == "text" { return textValue(of: result) }
let body = sheet.output.method == "html"
? PureXML.HTML.serialize(result)
: PureXML.serialize(result, options: options.applying(sheet.output))
let body: String
if sheet.output.method == "html" {
body = PureXML.HTML.serialize(result)
} else {
let prepared = withCDATASections(result, sheet.output.cdataSectionElements)
body = PureXML.serialize(prepared, options: options.applying(sheet.output))
}
return doctype(for: result, sheet.output) + body
}
/// Returns `node` with the text children of any element named in `names`
/// replaced by CDATA nodes, so the serializer emits them as CDATA sections.
private static func withCDATASections(_ node: PureXML.Model.Node, _ names: Set<String>) -> PureXML.Model.Node {
guard !names.isEmpty else { return node }
switch node {
case let .document(children):
return .document(children.map { withCDATASections($0, names) })
case let .element(element):
let wrap = names.contains(element.name.localName) || names.contains(element.name.description)
let children = element.children.map { child -> PureXML.Model.Node in
if wrap, case let .text(value) = child { return .cdata(value) }
return withCDATASections(child, names)
}
return .element(.init(name: element.name, attributes: element.attributes, children: children))
default:
return node
}
}
/// The `<!DOCTYPE …>` prologue for the result's root element when the output
/// declares `doctype-system` (optionally with `doctype-public`), else empty.
private static func doctype(for result: PureXML.Model.Node, _ output: Output) -> String {

View File

@ -0,0 +1,44 @@
@testable import PureXML
import Testing
@Suite("XSLT xsl:output cdata-section-elements")
struct XSLTCDATASectionTests {
private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\""
private func transform(_ stylesheet: String, _ source: String) throws -> String {
try PureXML.XSLT.transform(stylesheet: stylesheet, source: source)
}
private func style(_ cdataElements: String, _ body: String) -> String {
"""
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output method="xml" omit-xml-declaration="yes" cdata-section-elements="\(cdataElements)"/>
<xsl:template match="/">\(body)</xsl:template>
</xsl:stylesheet>
"""
}
@Test("A named element's text is emitted as a CDATA section")
func test_wrapped() throws {
let out = try transform(style("script", "<script>if (a &lt; b) go()</script>"), "<x/>")
#expect(out == "<script><![CDATA[if (a < b) go()]]></script>")
}
@Test("An element not named keeps escaped text")
func test_notNamed() throws {
let out = try transform(style("script", "<data>a &lt; b</data>"), "<x/>")
#expect(out == "<data>a &lt; b</data>")
}
@Test("Only the named element's own text is wrapped, not nested elements")
func test_nestedUnaffected() throws {
let out = try transform(style("outer", "<outer>x<inner>y &lt; z</inner></outer>"), "<x/>")
#expect(out == "<outer><![CDATA[x]]><inner>y &lt; z</inner></outer>")
}
@Test("Multiple element names can be listed")
func test_multiple() throws {
let out = try transform(style("a b", "<root><a>1 &amp; 2</a><b>3 &amp; 4</b></root>"), "<x/>")
#expect(out == "<root><a><![CDATA[1 & 2]]></a><b><![CDATA[3 & 4]]></b></root>")
}
}