diff --git a/CHANGELOG.md b/CHANGELOG.md
index 973cfbe..2edeb2e 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
+- RELAX NG compact `>>` follow-annotations (#79), completing the RNC syntax. A `>>` annotation after a pattern (a foreign element name with optional `[ … ]` content) is skipped, chaining and combining with leading `[ … ]` annotations; both carry no schema semantics. (Namespace declarations inside annotations are already dropped by the annotation skipper.)
- 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.
diff --git a/Sources/Schema/RelaxNGCompactParser.swift b/Sources/Schema/RelaxNGCompactParser.swift
index 6d66d52..c2972ff 100644
--- a/Sources/Schema/RelaxNGCompactParser.swift
+++ b/Sources/Schema/RelaxNGCompactParser.swift
@@ -192,14 +192,28 @@ extension RNCParser {
private func parseParticle() -> Pattern {
let primary = parsePrimary()
+ let particle: Pattern
switch peek() {
case .symbol("?"): advance()
- return .choice(primary, .empty)
+ particle = .choice(primary, .empty)
case .symbol("*"): advance()
- return .choice(.oneOrMore(primary), .empty)
+ particle = .choice(.oneOrMore(primary), .empty)
case .symbol("+"): advance()
- return .oneOrMore(primary)
- default: return primary
+ particle = .oneOrMore(primary)
+ default: particle = primary
+ }
+ skipFollowAnnotations()
+ return particle
+ }
+
+ /// Skips `>>` follow-annotations after a pattern: each is a foreign element
+ /// name with optional `[ … ]` content, carrying no schema semantics.
+ private func skipFollowAnnotations() {
+ while peek() == .symbol(">"), peek(1) == .symbol(">") {
+ advance()
+ advance()
+ if case .word = peek() { advance() }
+ skipAnnotation()
}
}
diff --git a/Tests/RelaxNGCompactFollowAnnotationTests.swift b/Tests/RelaxNGCompactFollowAnnotationTests.swift
new file mode 100644
index 0000000..679e76c
--- /dev/null
+++ b/Tests/RelaxNGCompactFollowAnnotationTests.swift
@@ -0,0 +1,38 @@
+@testable import PureXML
+import Testing
+
+@Suite("RELAX NG compact >> follow-annotations")
+struct RelaxNGCompactFollowAnnotationTests {
+ private func valid(_ rnc: String, _ xml: String) throws -> Bool {
+ try PureXML.Schema.RelaxNG(compact: rnc).validate(xml)
+ }
+
+ @Test("A follow-annotation after an element is ignored")
+ func test_afterElement() throws {
+ let rnc = "element root { element a { empty } >> a:documentation [ \"a child\" ] }"
+ #expect(try valid(rnc, ""))
+ #expect(try !valid(rnc, ""))
+ }
+
+ @Test("A follow-annotation on a define's pattern is ignored")
+ func test_onDefine() throws {
+ let rnc = """
+ start = element root { ref-a }
+ ref-a = element a { empty } >> a:doc [ "the a element" ]
+ """
+ #expect(try valid(rnc, ""))
+ }
+
+ @Test("Follow-annotations chain after a particle")
+ func test_chained() throws {
+ let rnc = "element root { element a { empty } >> a:x [ \"1\" ] >> a:y [ \"2\" ], element b { empty } }"
+ #expect(try valid(rnc, ""))
+ #expect(try !valid(rnc, ""))
+ }
+
+ @Test("A leading and a follow annotation can both appear")
+ func test_leadingAndFollow() throws {
+ let rnc = "element root { [ a:doc [ \"lead\" ] ] element a { empty } >> a:doc [ \"follow\" ] }"
+ #expect(try valid(rnc, ""))
+ }
+}