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:
parent
a65668d1fc
commit
7c24ada92b
@ -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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
73
Tests/XSLTDocumentFunctionTests.swift
Normal file
73
Tests/XSLTDocumentFunctionTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user