diff --git a/Sources/XSLT/XSLTFunctions.swift b/Sources/XSLT/XSLTFunctions.swift
index 0a22e16..deadceb 100644
--- a/Sources/XSLT/XSLTFunctions.swift
+++ b/Sources/XSLT/XSLTFunctions.swift
@@ -183,3 +183,18 @@ extension PureXML.XSLT {
}
}
}
+
+extension PureXML.Emitting.Options {
+ /// These options with an `xsl:output`'s settings layered over them. Only an
+ /// explicit setting overrides; unspecified `xsl:output` attributes leave the
+ /// caller's corresponding option untouched.
+ func applying(_ output: PureXML.XSLT.Output) -> Self {
+ var copy = self
+ if let indent = output.indent { copy.prettyPrint = indent }
+ if let omit = output.omitXMLDeclaration { copy.includeXMLDeclaration = !omit }
+ if let encoding = output.encoding { copy.encodingName = encoding }
+ if let version = output.version { copy.xmlVersion = version }
+ if let standalone = output.standalone { copy.standalone = standalone }
+ return copy
+ }
+}
diff --git a/Sources/XSLT/XSLTModel.swift b/Sources/XSLT/XSLTModel.swift
index c1ce2b7..7ab3600 100644
--- a/Sources/XSLT/XSLTModel.swift
+++ b/Sources/XSLT/XSLTModel.swift
@@ -54,6 +54,51 @@ public extension PureXML.XSLT {
case variable(name: String, select: String?, body: [Instruction])
/// `xsl:number`: a generated sequence number for the context node.
case number(count: String?, from: String?, format: String)
+ /// `xsl:comment`: a comment node whose text is the instantiated body.
+ case comment(body: [Instruction])
+ /// `xsl:processing-instruction` whose target is `name` and whose data is
+ /// the instantiated body.
+ case processingInstruction(name: ValueTemplate, body: [Instruction])
+ }
+
+ /// The `xsl:output` controls that shape serialization: the output method, and
+ /// the declaration and indentation settings. Each is nil when unspecified, so
+ /// the serializer falls back to the caller's options.
+ struct Output: Sendable {
+ public var method: String?
+ public var indent: Bool?
+ public var omitXMLDeclaration: Bool?
+ public var encoding: String?
+ public var version: String?
+ public var standalone: Bool?
+
+ public init(
+ method: String? = nil,
+ indent: Bool? = nil,
+ omitXMLDeclaration: Bool? = nil,
+ encoding: String? = nil,
+ version: String? = nil,
+ standalone: Bool? = nil,
+ ) {
+ self.method = method
+ self.indent = indent
+ self.omitXMLDeclaration = omitXMLDeclaration
+ self.encoding = encoding
+ self.version = version
+ self.standalone = standalone
+ }
+
+ /// This output's settings with `other`'s non-nil settings layered over them.
+ func merged(with other: Output) -> Output {
+ Output(
+ method: other.method ?? method,
+ indent: other.indent ?? indent,
+ omitXMLDeclaration: other.omitXMLDeclaration ?? omitXMLDeclaration,
+ encoding: other.encoding ?? encoding,
+ version: other.version ?? version,
+ standalone: other.standalone ?? standalone,
+ )
+ }
}
/// An `xsl:key` declaration: a name, the nodes it indexes (a match pattern),
@@ -79,20 +124,24 @@ public extension PureXML.XSLT {
}
/// A template rule: a match pattern and/or a name, a mode, a priority, its
- /// declared parameters, and a body.
+ /// import precedence (higher wins, used to resolve `xsl:import`), its declared
+ /// parameters, and a body.
struct Template: Sendable {
public var match: String?
public var name: String?
public var mode: String?
public var priority: Double
+ public var importPrecedence: Int
public var parameters: [Binding]
public var body: [Instruction]
}
- /// A compiled stylesheet: its template rules, global variables, and keys.
+ /// A compiled stylesheet: its template rules, global variables, keys, and
+ /// output controls (with `xsl:include`/`xsl:import` already folded in).
struct Stylesheet: Sendable {
public var templates: [Template]
public var globals: [Instruction]
public var keys: [Key]
+ public var output: Output
}
}
diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift
index b086bf5..9b37f9b 100644
--- a/Sources/XSLT/XSLTParser.swift
+++ b/Sources/XSLT/XSLTParser.swift
@@ -44,25 +44,71 @@ extension PureXML.XSLT {
/// vocabulary is recognized by namespace or the `xsl` prefix; other elements
/// are literal result elements.
enum XSLTParser {
- static func parse(_ xsl: String) throws -> Stylesheet {
+ static func parse(_ xsl: String, loader: (String) -> String? = { _ in nil }) throws -> Stylesheet {
let root = try PureXML.parseTree(xsl)
+ guard let top = stylesheetElement(root) else { throw XSLTError.notAStylesheet }
+ return compile(top, loader: loader, precedence: 0)
+ }
+
+ private static func stylesheetElement(_ root: Tree) -> Tree? {
guard let top = XSLTNode.elementChildren(root).first, XSLTNode.isXSL(top),
XSLTNode.localName(top) == "stylesheet" || XSLTNode.localName(top) == "transform"
else {
- throw XSLTError.notAStylesheet
+ return nil
}
+ return top
+ }
+
+ /// Compiles a stylesheet element, recursively folding in `xsl:include`
+ /// (same import precedence) and `xsl:import` (one lower) resolved through
+ /// `loader`.
+ private static func compile(_ top: Tree, loader: (String) -> String?, precedence: Int) -> Stylesheet {
var templates: [Template] = []
var globals: [Instruction] = []
var keys: [Key] = []
+ var output = Output()
for child in XSLTNode.elementChildren(top) where XSLTNode.isXSL(child) {
switch XSLTNode.localName(child) {
- case "template": templates.append(template(child))
+ case "template": templates.append(template(child, precedence: precedence))
case "variable", "param": globals.append(variable(child))
case "key": keys.append(key(child))
+ case "output": output = output.merged(with: parseOutput(child))
+ case "include":
+ guard let sub = load(child, loader: loader, precedence: precedence) else { break }
+ templates += sub.templates
+ globals += sub.globals
+ keys += sub.keys
+ output = output.merged(with: sub.output)
+ case "import":
+ guard let sub = load(child, loader: loader, precedence: precedence - 1) else { break }
+ templates += sub.templates
+ globals = sub.globals + globals
+ keys += sub.keys
+ output = sub.output.merged(with: output)
default: break
}
}
- return Stylesheet(templates: templates, globals: globals, keys: keys)
+ return Stylesheet(templates: templates, globals: globals, keys: keys, output: output)
+ }
+
+ private static func load(_ node: Tree, loader: (String) -> String?, precedence: Int) -> Stylesheet? {
+ guard let href = XSLTNode.attribute(node, "href"), let text = loader(href),
+ let root = try? PureXML.parseTree(text), let top = stylesheetElement(root)
+ else {
+ return nil
+ }
+ return compile(top, loader: loader, precedence: precedence)
+ }
+
+ private static func parseOutput(_ node: Tree) -> Output {
+ Output(
+ method: XSLTNode.attribute(node, "method"),
+ indent: XSLTNode.attribute(node, "indent").map { $0 == "yes" },
+ omitXMLDeclaration: XSLTNode.attribute(node, "omit-xml-declaration").map { $0 == "yes" },
+ encoding: XSLTNode.attribute(node, "encoding"),
+ version: XSLTNode.attribute(node, "version"),
+ standalone: XSLTNode.attribute(node, "standalone").map { $0 == "yes" },
+ )
}
private static func key(_ node: Tree) -> Key {
@@ -75,7 +121,7 @@ extension PureXML.XSLT {
// MARK: Templates
- private static func template(_ node: Tree) -> Template {
+ private static func template(_ node: Tree, precedence: Int) -> Template {
let match = XSLTNode.attribute(node, "match")
let priority = XSLTNode.attribute(node, "priority").flatMap(Double.init)
?? match.map(defaultPriority) ?? 0
@@ -88,6 +134,7 @@ extension PureXML.XSLT {
name: XSLTNode.attribute(node, "name"),
mode: XSLTNode.attribute(node, "mode"),
priority: priority,
+ importPrecedence: precedence,
parameters: parameters,
body: body,
)
@@ -174,6 +221,10 @@ extension PureXML.XSLT {
from: XSLTNode.attribute(node, "from"),
format: XSLTNode.attribute(node, "format") ?? "1",
)
+ case "comment":
+ .comment(body: body(node))
+ case "processing-instruction":
+ .processingInstruction(name: valueTemplate(XSLTNode.attribute(node, "name") ?? ""), body: body(node))
default:
nil
}
diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift
index ba46f89..d8d31a0 100644
--- a/Sources/XSLT/XSLTTransformer.swift
+++ b/Sources/XSLT/XSLTTransformer.swift
@@ -48,7 +48,10 @@ extension PureXML.XSLT {
fileprivate func bestTemplate(for node: PureXML.Model.TreeNode, mode: String?) -> Template? {
stylesheet.templates.enumerated()
.filter { $0.element.mode == mode && ($0.element.match.map { matches(node, $0) } ?? false) }
- .max { lhs, rhs in (lhs.element.priority, lhs.offset) < (rhs.element.priority, rhs.offset) }?
+ .max { lhs, rhs in
+ (lhs.element.importPrecedence, lhs.element.priority, lhs.offset)
+ < (rhs.element.importPrecedence, rhs.element.priority, rhs.offset)
+ }?
.element
}
@@ -222,6 +225,10 @@ extension PureXML.XSLT.Transformer {
copyInstruction(body, context)
case let .number(count, _, format):
[.node(.text(PureXML.XSLT.Numbering.value(of: context.node, count: count, format: format)))]
+ case let .comment(body):
+ [.node(.comment(Self.text(of: instantiate(body, context))))]
+ case let .processingInstruction(name, body):
+ [.node(.processingInstruction(target: avt(name, context), data: Self.text(of: instantiate(body, context))))]
default:
[]
}
@@ -356,10 +363,11 @@ public extension PureXML.XSLT {
options: PureXML.Emitting.Options = .compact,
documentLoader: @escaping (String) -> String? = { _ in nil },
) throws -> String {
- try PureXML.serialize(
- transformToNode(stylesheet: stylesheet, source: source, documentLoader: documentLoader),
- options: options,
- )
+ let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader)
+ let root = try PureXML.parseTree(source)
+ let result = Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader).run()
+ if sheet.output.method == "text" { return textValue(of: result) }
+ return PureXML.serialize(result, options: options.applying(sheet.output))
}
/// Transforms `source` with `stylesheet`, returning the result tree.
@@ -368,8 +376,19 @@ public extension PureXML.XSLT {
source: String,
documentLoader: @escaping (String) -> String? = { _ in nil },
) throws -> PureXML.Model.Node {
- let sheet = try XSLTParser.parse(stylesheet)
+ let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader)
let root = try PureXML.parseTree(source)
return Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader).run()
}
+
+ /// The concatenated text content of a result tree, for the `text` output
+ /// method: character data only, with markup, comments, and PIs dropped.
+ private static func textValue(of node: PureXML.Model.Node) -> String {
+ switch node {
+ case let .text(value), let .cdata(value): value
+ case let .document(children): children.map(textValue).joined()
+ case let .element(element): element.children.map(textValue).joined()
+ case .comment, .processingInstruction: ""
+ }
+ }
}
diff --git a/Tests/XSLTParityTests.swift b/Tests/XSLTParityTests.swift
new file mode 100644
index 0000000..770474c
--- /dev/null
+++ b/Tests/XSLTParityTests.swift
@@ -0,0 +1,145 @@
+@testable import PureXML
+import Testing
+
+@Suite("XSLT keys, numbering, output")
+struct XSLTParityTests {
+ 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)
+ }
+
+ @Test("xsl:key indexes nodes and key() retrieves them")
+ func test_key() throws {
+ let stylesheet = """
+