Merge: HTML conformance validator in the Validation idiom
This commit is contained in:
commit
704370c779
@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- HTML conformance validator in the composable `Validation` idiom. `PureXML.HTML.validationErrors(in:)` (backed by `PureXML.Validation.HTML`) judges a parsed tree against the intrinsic HTML5 content-model invariants as named, removable, isolation-testable `Validation` rules, the same idiom as the DTD/XSD/structural validators: `voidElementsAreEmpty` (void elements carry no content), `requiredParent` (`li`/`td`/`option`/... appear inside an allowed parent, with the allowed parents named in the finding), and `uniqueIdentifiers` (`id` values are unique). Failures are located `ValidationError`s; this is the validation layer HTML previously lacked, so every parsing/schema subsystem now exposes its conformance checks as composable rules.
|
||||
- XInclude range inclusion (#87), completing the XInclude audit. An `xi:include` whose `xpointer` selects a range (`xpointer(range(...))`, `range-to`, or `string-range`) now includes that range's content: when no node-selecting scheme matches, the processor falls back to the XPointer range model and substitutes each range's spanned nodes (or the matched text for a `string-range`).
|
||||
- XPointer `range()` model (#88), completing the XPath/XPointer audit. `PureXML.XPointer.evaluateRanges(_:over:)` (and `Pointer.evaluateRanges(over:)`) evaluate the range forms inside an `xpointer()`/`xpath1()` scheme: `range(expr)` (the covering range of each location), `start/range-to(end)` (one range spanning from the first location of `start` to the first of `end`, the sibling run when they share a parent), and `string-range(location, "search"[, offset[, length]])` (a character range per occurrence, with the XPointer offset/length adjustment). Each `Range` materializes to the whole `nodes` it spans and the `text` it covers, and `xmlns()` bindings apply to the range parts that follow, so this also unblocks XInclude range inclusion (#87).
|
||||
- C14N node-subset canonicalization and Canonical XML 2.0 parameters (#85), completing the canonicalization audit. `Canonicalizer.canonicalize(_ subtree: TreeNode)` canonicalizes a fragment in place: the apex element renders the namespace context in scope from its omitted ancestors and inherits their `xml:base`/`xml:lang`/`xml:space` when it does not set them, so a signed fragment is independent of where it sat. `canonicalize(_:including:)` canonicalizes a node-set selected by a `TreeNode` predicate (XPath/position-based selection): an excluded element's tags are omitted but its selected descendants are kept and re-declare their in-scope namespaces. `Options.prefixRewrite = .sequential` rewrites every prefix to a canonical `n0`/`n1`/... in document order (eliminating the default namespace and placing declarations at first use), and `Options.qnameAwareLabels` extends that rewrite into QName-valued attributes and elements, so documents differing only in prefix spelling canonicalize to identical bytes.
|
||||
|
||||
@ -89,7 +89,7 @@ flowchart TB
|
||||
|
||||
PureXML is a working, dependency-free XML library today: parse, emit, validate,
|
||||
query, and stream documents on macOS, Linux, Windows, and WASM. The test suite
|
||||
currently runs **703 tests in 111 suites** (`swift test`).
|
||||
currently runs **710 tests in 112 suites** (`swift test`).
|
||||
|
||||
### Shipped (libxml2-aligned surface)
|
||||
|
||||
|
||||
109
Sources/Validation/HTMLValidations.swift
Normal file
109
Sources/Validation/HTMLValidations.swift
Normal file
@ -0,0 +1,109 @@
|
||||
public extension PureXML.Validation {
|
||||
/// HTML conformance as composable ``Validation`` values, in the same idiom as
|
||||
/// the DTD, XSD, and structural validators: each rule is a named, removable
|
||||
/// ``Validation`` with a positive description, the ``validator()`` composes
|
||||
/// them, and failures are located ``ValidationError``s. The rules are intrinsic
|
||||
/// to HTML (no external schema), so the document type is `Void`.
|
||||
///
|
||||
/// These check the HTML5 content-model invariants that a parsed tree can be
|
||||
/// judged against without a grammar: void elements stay empty, elements that
|
||||
/// require a particular parent appear inside one, and `id` values are unique.
|
||||
enum HTML {
|
||||
/// A void element (`br`, `img`, `input`, ...) carries no child content.
|
||||
static var voidElementsAreEmpty: Validation<PureXML.Model.Element, Void> {
|
||||
.init(description: "Void HTML elements have no content") { context in
|
||||
let name = elementName(context.subject)
|
||||
guard PureXML.HTML.Elements.void.contains(name), !context.subject.children.isEmpty else { return [] }
|
||||
return [ValidationError(reason: "void element <\(name)> must not have content", at: context.codingPath)]
|
||||
}
|
||||
}
|
||||
|
||||
/// An element that requires a particular parent (`li`, `td`, `option`, ...)
|
||||
/// appears directly inside one of its allowed parents.
|
||||
static var requiredParent: Validation<PureXML.Model.Element, Void> {
|
||||
.init(description: "HTML elements appear inside their required parent") { context in
|
||||
let name = elementName(context.subject)
|
||||
guard let allowed = requiredParents[name] else { return [] }
|
||||
let parent = parentName(in: context.codingPath)
|
||||
guard parent.map({ !allowed.contains($0) }) ?? true else { return [] }
|
||||
let hint = allowed.sorted().map { "<\($0)>" }.joined(separator: ", ")
|
||||
return [ValidationError(reason: "element <\(name)> must appear inside \(hint)", at: context.codingPath)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `id` attribute value is unique in the document. Runs once over the
|
||||
/// whole tree, so it is gated to the document root.
|
||||
static var uniqueIdentifiers: Validation<PureXML.Model.Node, Void> {
|
||||
.init(
|
||||
description: "HTML id attributes are unique",
|
||||
check: { context in
|
||||
var counts: [String: Int] = [:]
|
||||
collectIdentifiers(context.subject, into: &counts)
|
||||
return counts.filter { $0.value > 1 }.keys.sorted().map {
|
||||
ValidationError(reason: "duplicate id '\($0)' (used \(counts[$0] ?? 0) times)", at: context.codingPath)
|
||||
}
|
||||
},
|
||||
when: { $0.codingPath.isEmpty },
|
||||
)
|
||||
}
|
||||
|
||||
/// A validator combining the HTML conformance rules.
|
||||
static func validator() -> Validator<Void> {
|
||||
Validator<Void>.blank
|
||||
.validating(voidElementsAreEmpty, requiredParent)
|
||||
.validating(uniqueIdentifiers)
|
||||
}
|
||||
|
||||
/// The allowed direct parents for each element that requires one (the
|
||||
/// HTML5 content-model parent constraints).
|
||||
private static let requiredParents: [String: Set<String>] = [
|
||||
"li": ["ul", "ol", "menu"],
|
||||
"dt": ["dl"], "dd": ["dl"],
|
||||
"tr": ["table", "thead", "tbody", "tfoot"],
|
||||
"td": ["tr"], "th": ["tr"],
|
||||
"thead": ["table"], "tbody": ["table"], "tfoot": ["table"],
|
||||
"caption": ["table"], "colgroup": ["table"], "col": ["colgroup"],
|
||||
"option": ["select", "optgroup", "datalist"], "optgroup": ["select"],
|
||||
"figcaption": ["figure"], "summary": ["details"],
|
||||
]
|
||||
|
||||
private static func elementName(_ element: PureXML.Model.Element) -> String {
|
||||
element.name.description.lowercased()
|
||||
}
|
||||
|
||||
/// The name of the element's direct parent from the coding path, or nil at
|
||||
/// the document root. The path ends with the element's own name, so the
|
||||
/// parent is the entry before it.
|
||||
private static func parentName(in path: [PathKey]) -> String? {
|
||||
guard path.count >= 2 else { return nil }
|
||||
return path[path.count - 2].stringValue.lowercased()
|
||||
}
|
||||
|
||||
private static func collectIdentifiers(_ node: PureXML.Model.Node, into counts: inout [String: Int]) {
|
||||
switch node {
|
||||
case let .document(children):
|
||||
for child in children {
|
||||
collectIdentifiers(child, into: &counts)
|
||||
}
|
||||
case let .element(element):
|
||||
if let id = element.attributes.first(where: { $0.name.description.lowercased() == "id" })?.value, !id.isEmpty {
|
||||
counts[id, default: 0] += 1
|
||||
}
|
||||
for child in element.children {
|
||||
collectIdentifiers(child, into: &counts)
|
||||
}
|
||||
case .text, .cdata, .comment, .processingInstruction:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension PureXML.HTML {
|
||||
/// The HTML conformance errors for a parsed node, in document order; empty
|
||||
/// when the tree satisfies the intrinsic HTML5 content-model rules. Built on
|
||||
/// the composable ``PureXML/Validation/HTML`` rules.
|
||||
static func validationErrors(in node: PureXML.Model.Node) -> [PureXML.Validation.ValidationError] {
|
||||
PureXML.Validation.HTML.validator().errors(for: node, in: ())
|
||||
}
|
||||
}
|
||||
79
Tests/HTMLValidationTests.swift
Normal file
79
Tests/HTMLValidationTests.swift
Normal file
@ -0,0 +1,79 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("HTML conformance validator (composable Validation rules)")
|
||||
struct HTMLValidationTests {
|
||||
private typealias Element = PureXML.Model.Element
|
||||
private typealias Node = PureXML.Model.Node
|
||||
|
||||
private func errors(_ node: Node) -> [PureXML.Validation.ValidationError] {
|
||||
PureXML.HTML.validationErrors(in: node)
|
||||
}
|
||||
|
||||
@Test("A void element carrying content is flagged")
|
||||
func test_voidWithContent() {
|
||||
let node = Node.element(Element("br", children: [.text("x")]))
|
||||
let found = errors(node)
|
||||
#expect(found.count == 1)
|
||||
#expect(found.first?.reason == "void element <br> must not have content")
|
||||
}
|
||||
|
||||
@Test("li inside ul is valid; li with no list parent is flagged")
|
||||
func test_requiredParent() {
|
||||
let valid = Node.document([.element(Element("ul", children: [.element(Element("li", children: [.text("a")]))]))])
|
||||
#expect(errors(valid).isEmpty)
|
||||
|
||||
let invalid = Node.document([.element(Element("li", children: [.text("a")]))])
|
||||
let found = errors(invalid)
|
||||
#expect(found.count == 1)
|
||||
#expect(found.first?.reason == "element <li> must appear inside <menu>, <ol>, <ul>")
|
||||
}
|
||||
|
||||
@Test("td must appear inside tr")
|
||||
func test_tableCellParent() {
|
||||
let invalid = Node.document([.element(Element("table", children: [.element(Element("td", children: [.text("x")]))]))])
|
||||
let found = errors(invalid)
|
||||
#expect(found.contains { $0.reason == "element <td> must appear inside <tr>" })
|
||||
}
|
||||
|
||||
@Test("Duplicate id values are reported once each")
|
||||
func test_uniqueIdentifiers() {
|
||||
let div = Element("div", children: [
|
||||
.element(Element("p", attributes: [.init("id", "x")])),
|
||||
.element(Element("span", attributes: [.init("id", "x")])),
|
||||
])
|
||||
let found = errors(.document([.element(div)]))
|
||||
#expect(found.count == 1)
|
||||
#expect(found.first?.reason == "duplicate id 'x' (used 2 times)")
|
||||
}
|
||||
|
||||
@Test("A well-formed document passes")
|
||||
func test_valid() {
|
||||
let list = Element("ul", children: [
|
||||
.element(Element("li", attributes: [.init("id", "a")], children: [.text("one")])),
|
||||
.element(Element("li", attributes: [.init("id", "b")], children: [.text("two")])),
|
||||
])
|
||||
#expect(errors(.document([.element(list)])).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: Each rule is composable and isolation-testable on its own.
|
||||
|
||||
@Test("HTML.requiredParent runs in isolation")
|
||||
func test_requiredParentIsolated() {
|
||||
let node = Node.document([.element(Element("option", children: [.text("a")]))])
|
||||
let found = PureXML.Validation.Validator<Void>.blank
|
||||
.validating(PureXML.Validation.HTML.requiredParent)
|
||||
.errors(for: node, in: ())
|
||||
#expect(found.count == 1)
|
||||
#expect(found.first?.reason.contains("must appear inside") == true)
|
||||
}
|
||||
|
||||
@Test("HTML.uniqueIdentifiers runs in isolation")
|
||||
func test_uniqueIdentifiersIsolated() {
|
||||
let node = Node.document([.element(Element("a", attributes: [.init("id", "z")])), .element(Element("b", attributes: [.init("id", "z")]))])
|
||||
let found = PureXML.Validation.Validator<Void>.blank
|
||||
.validating(PureXML.Validation.HTML.uniqueIdentifiers)
|
||||
.errors(for: node, in: ())
|
||||
#expect(found.count == 1)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user