refactor(html): build body as a live mutable TreeNode open-elements stack (#83)

This commit is contained in:
Mihaela Mihaljevic 2026-06-09 05:35:26 +02:00
parent 8062dea342
commit fccb407797
2 changed files with 76 additions and 10 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- The HTML document builder's body is now constructed as a live mutable `TreeNode` tree with a single open-elements stack (the HTML5 model), attaching nodes to their parent as they open rather than accumulating children on close (#83, internal). Behavior is unchanged; this is the foundation the adoption agency algorithm needs to reparent already-built subtrees.
- DTD attribute validation is now decomposed into five named, independently composable `Validation` rules (#101): `DTD.requiredAttributes`, `fixedAttributeValues`, `enumeratedAttributeValues`, `tokenizedAttributeTypes`, and `notationAttributes`, replacing the single `attributeDeclarations` rule. Each is removable by identity and isolation-tested one constraint at a time, honoring the validation-framework idiom. Behavior is unchanged. (XSD content validation stays one recursive rule by a documented scope decision, since its constraints are interdependent through type resolution; Schematron already exposes its rules as composable `Validation<Node, Void>` values.)
### Added

View File

@ -45,8 +45,13 @@ private final class HTMLDocument {
private var bodyAttributes: [Attribute] = []
private var headStack: [DocFrame] = []
private var headChildren: [Node] = []
private var bodyStack: [DocFrame] = []
private var bodyChildren: [Node] = []
/// The body is built as a live mutable tree (the HTML5 model): nodes are
/// attached to their parent as soon as they open, and `openBody` is the stack
/// of currently-open elements into that tree, with the body element at the
/// bottom. This lets the adoption agency reparent already-built subtrees, which
/// a pop-on-close model cannot.
private let bodyRoot = PureXML.Model.TreeNode.element("body")
private lazy var openBody: [PureXML.Model.TreeNode] = [bodyRoot]
func build(_ tokens: [Token]) -> Node {
for token in tokens {
@ -55,11 +60,10 @@ private final class HTMLDocument {
while !headStack.isEmpty {
pop(&headStack, &headChildren)
}
while !bodyStack.isEmpty {
pop(&bodyStack, &bodyChildren)
}
// Open body elements are already attached to the live tree, so no closing
// pass is needed: the body's children are read straight off bodyRoot.
let head = PureXML.Model.Element(name: .init("head"), attributes: headAttributes, children: headChildren)
let body = PureXML.Model.Element(name: .init("body"), attributes: bodyAttributes, children: bodyChildren)
let body = PureXML.Model.Element(name: .init("body"), attributes: bodyAttributes, children: bodyRoot.children.map(\.node))
let html = PureXML.Model.Element(
name: .init("html"),
attributes: htmlAttributes,
@ -155,18 +159,79 @@ private final class HTMLDocument {
private func processInBody(_ token: Token) {
switch token {
case let .startTag(name, attributes, selfClosing):
open(name, attributes, selfClosing: selfClosing, &bodyStack, &bodyChildren)
bodyOpen(name, attributes, selfClosing: selfClosing)
case let .endTag(name) where name != "body" && name != "html":
close(name, &bodyStack, &bodyChildren)
bodyClose(name)
case let .text(value):
attach(.text(value), &bodyStack, &bodyChildren)
bodyInsert(.text(value))
case let .comment(value):
attach(.comment(value), &bodyStack, &bodyChildren)
bodyInsert(.comment(value))
default:
break
}
}
// MARK: Body building (live mutable tree)
/// The lowercased tag name of an open element, for case-insensitive stack
/// matching (an SVG element's canonical camel case folds back to the token).
private func tagName(_ node: PureXML.Model.TreeNode) -> String {
node.name?.localName.lowercased() ?? ""
}
private func bodyOpen(_ name: String, _ attributes: [(String, String)], selfClosing: Bool) {
if let closes = PureXML.HTML.Elements.impliedClose[name] {
while let top = openBody.last, top !== bodyRoot, closes.contains(tagName(top)) {
openBody.removeLast()
}
}
bodyEnsureTableContext(for: name)
let namespace = bodyForeignNamespace(for: name)
let element = PureXML.Model.TreeNode.element(qualifiedName(name, namespace), attributes: modelAttributes(attributes))
openBody.last?.append(element)
if !(PureXML.HTML.Elements.void.contains(name) || selfClosing) {
openBody.append(element)
}
}
private func bodyClose(_ name: String) {
guard let index = openBody.lastIndex(where: { tagName($0) == name }), index >= 1 else { return }
openBody.removeLast(openBody.count - index)
}
private func bodyInsert(_ node: PureXML.Model.TreeNode) {
openBody.last?.append(node)
}
/// The table-construction implied insertions, against the live body stack.
private func bodyEnsureTableContext(for name: String) {
let structural: Set = ["table", "tbody", "thead", "tfoot", "tr"]
guard let context = openBody.reversed().first(where: { structural.contains(tagName($0)) }).map(tagName) else { return }
switch name {
case "tr" where context == "table":
bodyPushImplied("tbody")
case "td", "th":
if context == "table" { bodyPushImplied("tbody") }
if context == "table" || ["tbody", "thead", "tfoot"].contains(context) { bodyPushImplied("tr") }
default:
break
}
}
private func bodyPushImplied(_ name: String) {
let element = PureXML.Model.TreeNode.element(name)
openBody.last?.append(element)
openBody.append(element)
}
/// The foreign-content namespace for a body element: SVG/MathML on entry, or
/// the nearest open foreign ancestor's namespace inside one.
private func bodyForeignNamespace(for name: String) -> String? {
if name == "svg" { return ForeignNamespace.svg }
if name == "math" { return ForeignNamespace.mathml }
return openBody.reversed().compactMap { $0.name?.namespaceURI }.first
}
// MARK: Insertion primitives
private func open(