PureXML/Sources/HTML/HTMLTreeBuilder.swift
Mihaela Mihaljevic cfba3ed7d2 feat(html): lenient HTML parser and serializer
Add the PureXML.HTML namespace: a lenient tokenizer for tag-soup input
(case-insensitive tags; quoted, unquoted, and boolean attributes; comments;
doctype; character references), a tree builder handling void elements,
optional end tags (li, p, table rows and cells), raw-text elements (script,
style), and unmatched end tags, and a serializer that writes void elements
unclosed and raw-text content unescaped.

Closes #36
Closes #37
Closes #38
Closes #39
Closes #20
2026-06-08 07:12:42 +02:00

87 lines
3.6 KiB
Swift

/// A partially-built element on the open-element stack. File-scope and private.
private struct HTMLFrame {
let name: String
let attributes: [PureXML.Model.Attribute]
var children: [PureXML.Model.Node] = []
}
extension PureXML.HTML {
/// Builds a lenient tree from HTML tokens (the libxml2 `HTMLtree.h` model):
/// void elements get no children and no end tag, optional-end-tag elements are
/// closed implicitly when a conflicting tag opens, and an unmatched end tag is
/// ignored. The result is a ``PureXML/Model/Node`` document.
enum TreeBuilder {
static func build(_ tokens: [Token]) -> PureXML.Model.Node {
var stack: [HTMLFrame] = []
var roots: [PureXML.Model.Node] = []
for token in tokens {
apply(token, stack: &stack, roots: &roots)
}
while !stack.isEmpty {
pop(&stack, &roots)
}
return .document(roots)
}
private static func apply(_ token: Token, stack: inout [HTMLFrame], roots: inout [PureXML.Model.Node]) {
switch token {
case let .startTag(name, attributes, selfClosing):
openElement(name, attributes, selfClosing: selfClosing, &stack, &roots)
case let .endTag(name):
closeElement(name, &stack, &roots)
case let .text(value):
attach(.text(value), &stack, &roots)
case let .comment(value):
attach(.comment(value), &stack, &roots)
case .doctype:
break
}
}
private static func openElement(
_ name: String,
_ attributes: [(String, String)],
selfClosing: Bool,
_ stack: inout [HTMLFrame],
_ roots: inout [PureXML.Model.Node],
) {
closeImplied(by: name, &stack, &roots)
let modelAttributes = attributes.map { PureXML.Model.Attribute($0.0, $0.1) }
if Elements.void.contains(name) || selfClosing {
let element = PureXML.Model.Element(name: .init(name), attributes: modelAttributes)
attach(.element(element), &stack, &roots)
} else {
stack.append(HTMLFrame(name: name, attributes: modelAttributes))
}
}
private static func closeElement(_ name: String, _ stack: inout [HTMLFrame], _ roots: inout [PureXML.Model.Node]) {
guard let index = stack.lastIndex(where: { $0.name == name }) else { return }
while stack.count > index {
pop(&stack, &roots)
}
}
private static func closeImplied(by name: String, _ stack: inout [HTMLFrame], _ roots: inout [PureXML.Model.Node]) {
guard let closes = Elements.impliedClose[name] else { return }
while let top = stack.last, closes.contains(top.name) {
pop(&stack, &roots)
}
}
private static func pop(_ stack: inout [HTMLFrame], _ roots: inout [PureXML.Model.Node]) {
guard let frame = stack.popLast() else { return }
let element = PureXML.Model.Element(name: .init(frame.name), attributes: frame.attributes, children: frame.children)
attach(.element(element), &stack, &roots)
}
private static func attach(_ node: PureXML.Model.Node, _ stack: inout [HTMLFrame], _ roots: inout [PureXML.Model.Node]) {
if stack.isEmpty {
roots.append(node)
} else {
stack[stack.count - 1].children.append(node)
}
}
}
}