feat(xslt): document() fragments and base-URI resolution, completing #82

A document() reference with a #fragment selects a subset of the loaded
document via XPointer; a node-set first argument unions the documents
named by each node's string value; and a relative URI is resolved against
a caller-supplied baseURI (the loader is wrapped so xsl:include/xsl:import
resolve the same way). Closes the XSLT function/output audit.
This commit is contained in:
Mihaela Mihaljevic 2026-06-09 03:12:49 +02:00
parent a65668d1fc
commit 7c24ada92b
4 changed files with 115 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 `document()` fragments and base-URI resolution (#82), completing the XSLT function/output audit. A `#fragment` selects a subset of the loaded document via XPointer, a node-set first argument unions the documents named by each node, and a relative URI is resolved against a caller-supplied `baseURI` (also applied to `xsl:include`/`xsl:import`).
- XSLT result-tree fragments usable as node-sets (#82). An `xsl:variable` with a body now binds a queryable document fragment instead of a plain string, so `count($rtf/*)`, `$rtf/child`, and `xsl:for-each select="$rtf/..."` work; it still has its concatenated text as a string value.
- 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.

View File

@ -131,9 +131,14 @@ extension PureXML.XSLT {
return .nodeSet(matched.map { .tree($0) })
}
.adding("document") { arguments, _ in
guard let uri = arguments.first?.string, let text = loader(uri),
let parsed = try? PureXML.parseTree(text) else { return .nodeSet([]) }
return .nodeSet([.tree(parsed)])
// A string or a node-set of URI references, each optionally with
// a `#fragment` selecting a subset of the loaded document.
let references: [String] = if let nodes = arguments.first?.nodes {
nodes.compactMap(\.treeNode).map(\.stringValue)
} else {
[arguments.first?.string ?? ""]
}
return .nodeSet(references.flatMap { documentReference($0, loader) })
}
.adding("generate-id") { arguments, context in
// No argument uses the context node; an explicit empty node-set
@ -148,6 +153,27 @@ extension PureXML.XSLT {
.adding("unparsed-entity-uri") { _, _ in .string("") }
}
/// Loads one `document()` reference: the whole document, or, when the
/// reference has a `#fragment`, the nodes its XPointer selects.
private static func documentReference(_ reference: String, _ loader: (String) -> String?) -> [PureXML.XPath.Node] {
let path: String
let fragment: String?
if let hash = reference.firstIndex(of: "#") {
path = String(reference[..<hash])
fragment = String(reference[reference.index(after: hash)...])
} else {
path = reference
fragment = nil
}
guard let text = loader(path), let parsed = try? PureXML.parse(text) else { return [] }
guard let fragment, !fragment.isEmpty else { return [.tree(PureXML.Model.TreeNode(parsed))] }
let selections = (try? PureXML.XPointer.evaluate(fragment, over: parsed)) ?? []
return selections.compactMap { selection in
guard case let .node(node) = selection else { return nil }
return .tree(PureXML.Model.TreeNode(node))
}
}
private static func localPart(_ name: String) -> String {
name.split(separator: ":").last.map(String.init) ?? name
}

View File

@ -9,7 +9,9 @@ public extension PureXML.XSLT {
source: String,
options: PureXML.Emitting.Options = .compact,
documentLoader: @escaping (String) -> String? = { _ in nil },
baseURI: String = "",
) throws -> String {
let documentLoader = resolvingLoader(documentLoader, baseURI: baseURI)
let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader)
let root = try PureXML.parseTree(source)
Whitespace.strip(root, stylesheet: sheet)
@ -46,6 +48,14 @@ public extension PureXML.XSLT {
}
}
/// Wraps `loader` so a relative URI (from `document()`, `xsl:include`, or
/// `xsl:import`) is resolved against `baseURI` before loading. An empty base
/// leaves URIs as written.
private static func resolvingLoader(_ loader: @escaping (String) -> String?, baseURI: String) -> (String) -> String? {
guard !baseURI.isEmpty else { return loader }
return { reference in loader(PureXML.XInclude.URIReference.resolve(reference, against: baseURI)) }
}
/// 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 {
@ -67,7 +77,9 @@ public extension PureXML.XSLT {
stylesheet: String,
source: String,
documentLoader: @escaping (String) -> String? = { _ in nil },
baseURI: String = "",
) throws -> PureXML.Model.Node {
let documentLoader = resolvingLoader(documentLoader, baseURI: baseURI)
let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader)
let root = try PureXML.parseTree(source)
Whitespace.strip(root, stylesheet: sheet)

View File

@ -0,0 +1,73 @@
@testable import PureXML
import Testing
@Suite("XSLT document() fragments and base-URI resolution")
struct XSLTDocumentFunctionTests {
private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\""
private func transform(_ stylesheet: String, _ source: String, baseURI: String = "", loader: @escaping (String) -> String?) throws -> String {
try PureXML.XSLT.transform(stylesheet: stylesheet, source: source, documentLoader: loader, baseURI: baseURI)
}
private let external = "<doc><section id=\"s1\">first</section><section id=\"s2\">second</section></doc>"
@Test("document() with no fragment loads the whole document")
func test_wholeDocument() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output method="text"/>
<xsl:template match="/"><xsl:value-of select="count(document('ext.xml')//section)"/></xsl:template>
</xsl:stylesheet>
"""
let out = try transform(style, "<x/>") { $0 == "ext.xml" ? external : nil }
#expect(out == "2")
}
@Test("document() with an xpointer fragment selects a subset")
func test_xpointerFragment() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output method="text"/>
<xsl:template match="/"><xsl:value-of select="document('ext.xml#xpointer(/doc/section[2])')"/></xsl:template>
</xsl:stylesheet>
"""
let out = try transform(style, "<x/>") { $0 == "ext.xml" ? external : nil }
#expect(out == "second")
}
@Test("A relative document() URI is resolved against the base URI")
func test_baseURI() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output method="text"/>
<xsl:template match="/"><xsl:value-of select="count(document('ext.xml')//section)"/></xsl:template>
</xsl:stylesheet>
"""
var requested: [String] = []
let out = try transform(style, "<x/>", baseURI: "http://example.org/docs/") { uri in
requested.append(uri)
return uri == "http://example.org/docs/ext.xml" ? external : nil
}
#expect(out == "2")
#expect(requested.contains("http://example.org/docs/ext.xml"))
}
@Test("document() with a node-set argument unions the loaded documents")
func test_nodeSetArgument() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output method="text"/>
<xsl:template match="/"><xsl:value-of select="count(document(//ref)//section)"/></xsl:template>
</xsl:stylesheet>
"""
let source = "<x><ref>a.xml</ref><ref>b.xml</ref></x>"
let out = try transform(style, source) { uri in
switch uri {
case "a.xml": "<doc><section>1</section></doc>"
case "b.xml": "<doc><section>2</section><section>3</section></doc>"
default: nil
}
}
#expect(out == "3")
}
}