diff --git a/Sources/HTML/HTMLDocumentBuilder.swift b/Sources/HTML/HTMLDocumentBuilder.swift
new file mode 100644
index 0000000..b3d8270
--- /dev/null
+++ b/Sources/HTML/HTMLDocumentBuilder.swift
@@ -0,0 +1,221 @@
+/// A partially-built element on an open-element stack. File-scope and private.
+private struct DocFrame {
+ let name: String
+ let attributes: [PureXML.Model.Attribute]
+ var children: [PureXML.Model.Node] = []
+}
+
+/// The HTML5 tree-construction insertion modes this builder implements: the
+/// document-structure subset that establishes the implied `html`, `head`, and
+/// `body`. File-scope and private.
+private enum InsertionMode {
+ case initial
+ case beforeHtml
+ case beforeHead
+ case inHead
+ case afterHead
+ case inBody
+}
+
+/// Builds a full HTML document from tokens by driving the HTML5 insertion modes,
+/// materializing the implied `html`/`head`/`body` and routing head-only elements
+/// into the head. File-scope and private.
+private final class HTMLDocument {
+ typealias Token = PureXML.HTML.Token
+ typealias Node = PureXML.Model.Node
+ typealias Attribute = PureXML.Model.Attribute
+
+ /// Elements that belong in the head.
+ private static let headElements: Set = [
+ "base", "basefont", "bgsound", "link", "meta", "title", "style", "script", "noscript", "noframes", "template",
+ ]
+
+ private var mode: InsertionMode = .initial
+ private var htmlAttributes: [Attribute] = []
+ private var headAttributes: [Attribute] = []
+ private var bodyAttributes: [Attribute] = []
+ private var headStack: [DocFrame] = []
+ private var headChildren: [Node] = []
+ private var bodyStack: [DocFrame] = []
+ private var bodyChildren: [Node] = []
+
+ func build(_ tokens: [Token]) -> Node {
+ for token in tokens {
+ process(token)
+ }
+ while !headStack.isEmpty {
+ pop(&headStack, &headChildren)
+ }
+ while !bodyStack.isEmpty {
+ pop(&bodyStack, &bodyChildren)
+ }
+ 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 html = PureXML.Model.Element(
+ name: .init("html"),
+ attributes: htmlAttributes,
+ children: [.element(head), .element(body)],
+ )
+ return .document([.element(html)])
+ }
+
+ private func process(_ token: Token) {
+ switch mode {
+ case .initial: processInitial(token)
+ case .beforeHtml: processBeforeHtml(token)
+ case .beforeHead: processBeforeHead(token)
+ case .inHead: processInHead(token)
+ case .afterHead: processAfterHead(token)
+ case .inBody: processInBody(token)
+ }
+ }
+
+ // MARK: Document-structure modes
+
+ private func processInitial(_ token: Token) {
+ if case .doctype = token { return }
+ if isIgnorableWhitespace(token) { return }
+ mode = .beforeHtml
+ process(token)
+ }
+
+ private func processBeforeHtml(_ token: Token) {
+ if case let .startTag(name, attributes, _) = token, name == "html" {
+ htmlAttributes = modelAttributes(attributes)
+ mode = .beforeHead
+ return
+ }
+ if isIgnorableWhitespace(token) { return }
+ mode = .beforeHead
+ process(token)
+ }
+
+ private func processBeforeHead(_ token: Token) {
+ if isIgnorableWhitespace(token) { return }
+ if case let .startTag(name, attributes, _) = token, name == "head" {
+ headAttributes = modelAttributes(attributes)
+ mode = .inHead
+ return
+ }
+ mode = .inHead
+ process(token)
+ }
+
+ private func processInHead(_ token: Token) {
+ switch token {
+ case let .startTag(name, attributes, selfClosing) where Self.headElements.contains(name):
+ open(name, attributes, selfClosing: selfClosing, &headStack, &headChildren)
+ case let .endTag(name) where name == "head":
+ while !headStack.isEmpty {
+ pop(&headStack, &headChildren)
+ }
+ mode = .afterHead
+ case let .endTag(name) where headStack.contains(where: { $0.name == name }):
+ close(name, &headStack, &headChildren)
+ case let .comment(value):
+ attach(.comment(value), &headStack, &headChildren)
+ case let .text(value) where !headStack.isEmpty:
+ attach(.text(value), &headStack, &headChildren)
+ case let .text(value) where value.trimmingXMLWhitespace().isEmpty:
+ attach(.text(value), &headStack, &headChildren)
+ default:
+ while !headStack.isEmpty {
+ pop(&headStack, &headChildren)
+ }
+ mode = .afterHead
+ process(token)
+ }
+ }
+
+ private func processAfterHead(_ token: Token) {
+ if case let .startTag(name, attributes, _) = token, name == "body" {
+ bodyAttributes = modelAttributes(attributes)
+ mode = .inBody
+ return
+ }
+ if case let .startTag(name, _, _) = token, Self.headElements.contains(name) {
+ mode = .inHead
+ process(token)
+ return
+ }
+ if isIgnorableWhitespace(token) { return }
+ mode = .inBody
+ process(token)
+ }
+
+ private func processInBody(_ token: Token) {
+ switch token {
+ case let .startTag(name, attributes, selfClosing):
+ open(name, attributes, selfClosing: selfClosing, &bodyStack, &bodyChildren)
+ case let .endTag(name) where name != "body" && name != "html":
+ close(name, &bodyStack, &bodyChildren)
+ case let .text(value):
+ attach(.text(value), &bodyStack, &bodyChildren)
+ case let .comment(value):
+ attach(.comment(value), &bodyStack, &bodyChildren)
+ default:
+ break
+ }
+ }
+
+ // MARK: Insertion primitives
+
+ private func open(
+ _ name: String,
+ _ attributes: [(String, String)],
+ selfClosing: Bool,
+ _ stack: inout [DocFrame],
+ _ roots: inout [Node],
+ ) {
+ if let closes = PureXML.HTML.Elements.impliedClose[name] {
+ while let top = stack.last, closes.contains(top.name) {
+ pop(&stack, &roots)
+ }
+ }
+ let modeled = modelAttributes(attributes)
+ if PureXML.HTML.Elements.void.contains(name) || selfClosing {
+ attach(.element(PureXML.Model.Element(name: .init(name), attributes: modeled)), &stack, &roots)
+ } else {
+ stack.append(DocFrame(name: name, attributes: modeled))
+ }
+ }
+
+ private func close(_ name: String, _ stack: inout [DocFrame], _ roots: inout [Node]) {
+ guard let index = stack.lastIndex(where: { $0.name == name }) else { return }
+ while stack.count > index {
+ pop(&stack, &roots)
+ }
+ }
+
+ private func pop(_ stack: inout [DocFrame], _ roots: inout [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 func attach(_ node: Node, _ stack: inout [DocFrame], _ roots: inout [Node]) {
+ if stack.isEmpty {
+ roots.append(node)
+ } else {
+ stack[stack.count - 1].children.append(node)
+ }
+ }
+
+ private func modelAttributes(_ attributes: [(String, String)]) -> [Attribute] {
+ attributes.map { Attribute($0.0, $0.1) }
+ }
+
+ private func isIgnorableWhitespace(_ token: Token) -> Bool {
+ if case let .text(value) = token { return value.trimmingXMLWhitespace().isEmpty }
+ return false
+ }
+}
+
+extension PureXML.HTML {
+ /// Builds a full HTML document tree from tokens via the insertion modes.
+ enum DocumentBuilder {
+ static func build(_ tokens: [Token]) -> PureXML.Model.Node {
+ HTMLDocument().build(tokens)
+ }
+ }
+}
diff --git a/Sources/HTML/HTMLSerializer.swift b/Sources/HTML/HTMLSerializer.swift
index 630ed94..e698eea 100644
--- a/Sources/HTML/HTMLSerializer.swift
+++ b/Sources/HTML/HTMLSerializer.swift
@@ -81,13 +81,24 @@ extension PureXML.HTML {
}
public extension PureXML.HTML {
- /// Parses HTML into a ``PureXML/Model/Node`` document, leniently handling
- /// tag-soup input, void elements, optional end tags, and raw-text elements.
+ /// Parses an HTML fragment into a ``PureXML/Model/Node`` document, leniently
+ /// handling tag-soup input, void elements, optional end tags, and raw-text
+ /// elements. Markup is taken as-is, with no implied `html`/`head`/`body`.
static func parse(_ html: String) -> PureXML.Model.Node {
var tokenizer = Tokenizer(html)
return TreeBuilder.build(tokenizer.tokenize())
}
+ /// Parses a full HTML document by the HTML5 tree-construction insertion
+ /// modes: the result always has an `html` root containing a `head` and a
+ /// `body`, with head-only elements (`title`, `meta`, `link`, `style`,
+ /// `script`, and the rest) routed into the head and flow content into the
+ /// body, whether or not the source spelled those elements out.
+ static func parseDocument(_ html: String) -> PureXML.Model.Node {
+ var tokenizer = Tokenizer(html)
+ return DocumentBuilder.build(tokenizer.tokenize())
+ }
+
/// Serializes a node tree as HTML.
static func serialize(_ node: PureXML.Model.Node) -> String {
Serializer.serialize(node)
diff --git a/Tests/HTMLDocumentTests.swift b/Tests/HTMLDocumentTests.swift
new file mode 100644
index 0000000..0b170f1
--- /dev/null
+++ b/Tests/HTMLDocumentTests.swift
@@ -0,0 +1,43 @@
+@testable import PureXML
+import Testing
+
+@Suite("HTML5 document tree construction")
+struct HTMLDocumentTests {
+ private func document(_ html: String) -> String {
+ PureXML.HTML.serialize(PureXML.HTML.parseDocument(html))
+ }
+
+ @Test("Implied html, head, and body wrap a bare fragment")
+ func test_impliedStructure() {
+ #expect(document("hello
") == "hello
")
+ }
+
+ @Test("Head-only elements are routed into the head")
+ func test_headRouting() {
+ let html = "Tbody
"
+ let expected = "Tbody
"
+ #expect(document(html) == expected)
+ }
+
+ @Test("Explicit html, head, and body are honored")
+ func test_explicitStructure() {
+ let html = "THi
"
+ #expect(document(html) == "THi
")
+ }
+
+ @Test("Flow content before any head element starts the body")
+ func test_flowStartsBody() {
+ #expect(document("x
") == "x
")
+ }
+
+ @Test("A head element after body content stays in the body's flow")
+ func test_explicitBodyThenContent() {
+ let html = "one
two
"
+ #expect(document(html) == "one
two
")
+ }
+
+ @Test("html attributes are preserved on the implied structure")
+ func test_htmlAttributes() {
+ #expect(document("x
") == "x
")
+ }
+}