401 lines
17 KiB
Swift
401 lines
17 KiB
Swift
/// 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 foreign-content namespace URI (SVG or MathML) when this element is in
|
|
/// foreign content, or nil for ordinary HTML.
|
|
var namespace: String?
|
|
}
|
|
|
|
/// The foreign-content namespaces HTML5 switches into. File-scope and private.
|
|
private enum ForeignNamespace {
|
|
static let svg = "http://www.w3.org/2000/svg"
|
|
static let mathml = "http://www.w3.org/1998/Math/MathML"
|
|
}
|
|
|
|
/// 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
|
|
case inFrameset
|
|
case afterFrameset
|
|
}
|
|
|
|
/// 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. The adoption agency lives in a sibling file, so this type is
|
|
/// internal (its members stay private except the live-body state it shares).
|
|
final class HTMLDocument {
|
|
typealias Token = PureXML.HTML.Token
|
|
typealias Node = PureXML.Model.Node
|
|
typealias Attribute = PureXML.Model.Attribute
|
|
|
|
/// Elements that belong in the head (`template` is excluded: it nests in body).
|
|
private static let headElements: Set<String> = [
|
|
"base", "basefont", "bgsound", "link", "meta", "title", "style", "script", "noscript", "noframes",
|
|
]
|
|
|
|
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] = []
|
|
/// 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.
|
|
let bodyRoot = PureXML.Model.TreeNode.element("body")
|
|
lazy var openBody: [PureXML.Model.TreeNode] = [bodyRoot]
|
|
/// The active formatting elements (HTML5 list); nil entries are scope markers.
|
|
var activeFormatting: [PureXML.Model.TreeNode?] = []
|
|
/// A frameset document's `frameset` stack; when non-empty its root is the body.
|
|
private var framesetStack: [PureXML.Model.TreeNode] = []
|
|
|
|
func build(_ tokens: [Token]) -> Node {
|
|
for token in tokens {
|
|
process(token)
|
|
}
|
|
while !headStack.isEmpty {
|
|
pop(&headStack, &headChildren)
|
|
}
|
|
// 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 secondChild: Node = if let frameset = framesetStack.first {
|
|
frameset.node
|
|
} else {
|
|
.element(PureXML.Model.Element(name: .init("body"), attributes: bodyAttributes, children: bodyRoot.children.map(\.node)))
|
|
}
|
|
let html = PureXML.Model.Element(
|
|
name: .init("html"),
|
|
attributes: htmlAttributes,
|
|
children: [.element(head), secondChild],
|
|
)
|
|
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)
|
|
case .inFrameset: processInFrameset(token)
|
|
case .afterFrameset: break
|
|
}
|
|
}
|
|
|
|
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 == "frameset" {
|
|
framesetStack = [PureXML.Model.TreeNode.element("frameset", attributes: modelAttributes(attributes))]
|
|
mode = .inFrameset
|
|
return
|
|
}
|
|
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):
|
|
reconstructActiveFormatting()
|
|
bodyOpen(name, attributes, selfClosing: selfClosing)
|
|
noteOpenedFormatting(name, selfClosing: selfClosing)
|
|
case let .endTag(name) where isFormatting(name):
|
|
adoptionAgency(name)
|
|
case let .endTag(name) where name != "body" && name != "html":
|
|
bodyClose(name)
|
|
case let .text(value):
|
|
reconstructActiveFormatting()
|
|
placeText(.text(value))
|
|
case let .comment(value):
|
|
bodyInsert(.comment(value))
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
/// Records a just-opened formatting element on the active-formatting list.
|
|
private func noteOpenedFormatting(_ name: String, selfClosing: Bool) {
|
|
guard isFormatting(name), !selfClosing, !PureXML.HTML.Elements.void.contains(name), let opened = openBody.last else { return }
|
|
activeFormatting.append(opened)
|
|
}
|
|
|
|
private func processInFrameset(_ token: Token) {
|
|
switch token {
|
|
case let .startTag(name, attributes, _) where name == "frameset":
|
|
let element = PureXML.Model.TreeNode.element("frameset", attributes: modelAttributes(attributes))
|
|
framesetStack.last?.append(element)
|
|
framesetStack.append(element)
|
|
case let .startTag(name, attributes, _) where name == "frame":
|
|
framesetStack.last?.append(PureXML.Model.TreeNode.element("frame", attributes: modelAttributes(attributes)))
|
|
case let .startTag(name, attributes, _) where name == "noframes":
|
|
framesetStack.last?.append(PureXML.Model.TreeNode.element("noframes", attributes: modelAttributes(attributes)))
|
|
case let .endTag(name) where name == "frameset":
|
|
if framesetStack.count > 1 { framesetStack.removeLast() } else { mode = .afterFrameset }
|
|
case let .comment(value):
|
|
framesetStack.last?.append(.comment(value))
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
/// The lowercased tag name of an open element, for case-insensitive matching.
|
|
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: adjustedAttributes(modelAttributes(attributes), namespace: namespace))
|
|
placeElement(element, name: name)
|
|
if !(PureXML.HTML.Elements.void.contains(name) || selfClosing) {
|
|
openBody.append(element)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/// Restores SVG attribute names to their canonical camel case.
|
|
private func adjustedAttributes(_ attributes: [Attribute], namespace: String?) -> [Attribute] {
|
|
guard namespace == ForeignNamespace.svg else { return attributes }
|
|
return attributes.map { attribute in
|
|
guard let adjusted = PureXML.HTML.ForeignNames.svgAttributes[attribute.name.localName.lowercased()] else { return attribute }
|
|
return Attribute(adjusted, attribute.value)
|
|
}
|
|
}
|
|
|
|
/// The foreign-content namespace for a body element: SVG/MathML on entry, the
|
|
/// nearest open foreign ancestor's, or HTML (nil) inside an integration point.
|
|
private func bodyForeignNamespace(for name: String) -> String? {
|
|
if name == "svg" { return ForeignNamespace.svg }
|
|
if name == "math" { return ForeignNamespace.mathml }
|
|
for node in openBody.reversed() {
|
|
guard let namespace = node.name?.namespaceURI else { continue }
|
|
if Self.svgIntegrationPoints.contains(tagName(node)) { return nil }
|
|
return namespace
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// The SVG HTML integration points: their content is parsed as HTML.
|
|
private static let svgIntegrationPoints: Set<String> = ["foreignobject", "desc", "title"]
|
|
}
|
|
|
|
/// The head's open-element stack uses the pop-on-close `DocFrame` model; these
|
|
/// primitives drive it, in an extension to keep the class body within budget.
|
|
extension HTMLDocument {
|
|
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)
|
|
}
|
|
}
|
|
ensureTableContext(for: name, &stack, &roots)
|
|
let modeled = modelAttributes(attributes)
|
|
let namespace = foreignNamespace(for: name, in: stack)
|
|
if PureXML.HTML.Elements.void.contains(name) || selfClosing {
|
|
attach(.element(PureXML.Model.Element(name: qualifiedName(name, namespace), attributes: modeled)), &stack, &roots)
|
|
} else {
|
|
stack.append(DocFrame(name: name, attributes: modeled, namespace: namespace))
|
|
}
|
|
}
|
|
|
|
/// The namespace an element enters: SVG for `<svg>`, MathML for `<math>`, the
|
|
/// namespace of the nearest open foreign ancestor for anything inside one, or
|
|
/// nil for ordinary HTML content.
|
|
private func foreignNamespace(for name: String, in stack: [DocFrame]) -> String? {
|
|
if name == "svg" { return ForeignNamespace.svg }
|
|
if name == "math" { return ForeignNamespace.mathml }
|
|
return stack.reversed().first { $0.namespace != nil }?.namespace
|
|
}
|
|
|
|
/// A qualified name carrying its foreign-content namespace, with SVG element
|
|
/// names restored to their canonical camel case.
|
|
private func qualifiedName(_ name: String, _ namespace: String?) -> PureXML.Model.QualifiedName {
|
|
let local = namespace == ForeignNamespace.svg ? (PureXML.HTML.ForeignNames.svgElements[name] ?? name) : name
|
|
return PureXML.Model.QualifiedName(prefix: nil, localName: local, namespaceURI: namespace)
|
|
}
|
|
|
|
/// HTML table tree construction: a `<tr>` inside a bare `<table>` gets an
|
|
/// implied `<tbody>`, and a `<td>`/`<th>` gets an implied `<tr>` (and section),
|
|
/// so a table written without its section and row wrappers still nests
|
|
/// correctly. Only fires inside an open table.
|
|
private func ensureTableContext(for name: String, _ stack: inout [DocFrame], _: inout [Node]) {
|
|
guard let context = nearestTableContext(stack) else { return }
|
|
switch name {
|
|
case "tr" where context == "table":
|
|
stack.append(DocFrame(name: "tbody", attributes: []))
|
|
case "td", "th":
|
|
if context == "table" { stack.append(DocFrame(name: "tbody", attributes: [])) }
|
|
if context == "table" || ["tbody", "thead", "tfoot"].contains(context) {
|
|
stack.append(DocFrame(name: "tr", attributes: []))
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
/// The nearest open table-structural element (`table`/`tbody`/`thead`/`tfoot`/
|
|
/// `tr`), or nil when no table is open.
|
|
private func nearestTableContext(_ stack: [DocFrame]) -> String? {
|
|
let structural: Set = ["table", "tbody", "thead", "tfoot", "tr"]
|
|
return stack.reversed().first { structural.contains($0.name) }?.name
|
|
}
|
|
|
|
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: qualifiedName(frame.name, frame.namespace), 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)
|
|
}
|
|
}
|
|
}
|