From 6810c1c51a5793fc2e28103311cd808aae45aa34 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 09:24:11 +0200 Subject: [PATCH] feat(html): add HTML5 document tree construction with insertion modes Add parseDocument, which builds a full HTML document by the HTML5 tree-construction insertion modes (initial, before html, before head, in head, after head, in body): - the result always has an html root containing a head and a body, whether or not the source spelled those elements out. - head-only elements (title, meta, link, base, style, script, and the rest) are routed into the head; flow content starts and fills the body. - explicit html, head, and body tags and their attributes are honored. The existing fragment parser (parse) is unchanged, so tag-soup fragments still round-trip without an implied wrapper. Closes #69 --- Sources/HTML/HTMLDocumentBuilder.swift | 221 +++++++++++++++++++++++++ Sources/HTML/HTMLSerializer.swift | 15 +- Tests/HTMLDocumentTests.swift | 43 +++++ 3 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 Sources/HTML/HTMLDocumentBuilder.swift create mode 100644 Tests/HTMLDocumentTests.swift 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 = "T

body

" + let expected = "T

body

" + #expect(document(html) == expected) + } + + @Test("Explicit html, head, and body are honored") + func test_explicitStructure() { + let html = "T

Hi

" + #expect(document(html) == "T

Hi

") + } + + @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

") + } +}