Merge epic/69-html5-tree: HTML5 document tree construction
This commit is contained in:
commit
c66beec6c8
221
Sources/HTML/HTMLDocumentBuilder.swift
Normal file
221
Sources/HTML/HTMLDocumentBuilder.swift
Normal file
@ -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<String> = [
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
43
Tests/HTMLDocumentTests.swift
Normal file
43
Tests/HTMLDocumentTests.swift
Normal file
@ -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("<p>hello</p>") == "<html><head></head><body><p>hello</p></body></html>")
|
||||
}
|
||||
|
||||
@Test("Head-only elements are routed into the head")
|
||||
func test_headRouting() {
|
||||
let html = "<title>T</title><meta charset=\"utf-8\"><p>body</p>"
|
||||
let expected = "<html><head><title>T</title><meta charset=\"utf-8\"></head><body><p>body</p></body></html>"
|
||||
#expect(document(html) == expected)
|
||||
}
|
||||
|
||||
@Test("Explicit html, head, and body are honored")
|
||||
func test_explicitStructure() {
|
||||
let html = "<html><head><title>T</title></head><body><h1>Hi</h1></body></html>"
|
||||
#expect(document(html) == "<html><head><title>T</title></head><body><h1>Hi</h1></body></html>")
|
||||
}
|
||||
|
||||
@Test("Flow content before any head element starts the body")
|
||||
func test_flowStartsBody() {
|
||||
#expect(document("<div>x</div>") == "<html><head></head><body><div>x</div></body></html>")
|
||||
}
|
||||
|
||||
@Test("A head element after body content stays in the body's flow")
|
||||
func test_explicitBodyThenContent() {
|
||||
let html = "<body><p>one</p><p>two</p></body>"
|
||||
#expect(document(html) == "<html><head></head><body><p>one</p><p>two</p></body></html>")
|
||||
}
|
||||
|
||||
@Test("html attributes are preserved on the implied structure")
|
||||
func test_htmlAttributes() {
|
||||
#expect(document("<html lang=\"en\"><p>x</p></html>") == "<html lang=\"en\"><head></head><body><p>x</p></body></html>")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user