diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f52bdf..973cfbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` sections instead of escaped, leaving nested elements and other elements unaffected. - XSLT `xsl:output` `doctype-public`/`doctype-system` (#82). When either is set, a `` for the result's root element (PUBLIC when both are given, SYSTEM otherwise) is emitted before the serialized output, for XML and HTML methods. diff --git a/Sources/XSLT/XSLTFunctions.swift b/Sources/XSLT/XSLTFunctions.swift index b2892b5..4aeb767 100644 --- a/Sources/XSLT/XSLTFunctions.swift +++ b/Sources/XSLT/XSLTFunctions.swift @@ -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[.. String { name.split(separator: ":").last.map(String.init) ?? name } diff --git a/Sources/XSLT/XSLTTransform.swift b/Sources/XSLT/XSLTTransform.swift index 2570508..43c4c12 100644 --- a/Sources/XSLT/XSLTTransform.swift +++ b/Sources/XSLT/XSLTTransform.swift @@ -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 `` 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) diff --git a/Tests/XSLTDocumentFunctionTests.swift b/Tests/XSLTDocumentFunctionTests.swift new file mode 100644 index 0000000..b5e1491 --- /dev/null +++ b/Tests/XSLTDocumentFunctionTests.swift @@ -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 = "
first
second
" + + @Test("document() with no fragment loads the whole document") + func test_wholeDocument() throws { + let style = """ + + + + + """ + let out = try transform(style, "") { $0 == "ext.xml" ? external : nil } + #expect(out == "2") + } + + @Test("document() with an xpointer fragment selects a subset") + func test_xpointerFragment() throws { + let style = """ + + + + + """ + let out = try transform(style, "") { $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 = """ + + + + + """ + var requested: [String] = [] + let out = try transform(style, "", 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 = """ + + + + + """ + let source = "a.xmlb.xml" + let out = try transform(style, source) { uri in + switch uri { + case "a.xml": "
1
" + case "b.xml": "
2
3
" + default: nil + } + } + #expect(out == "3") + } +}