feat(html): frameset documents and void frame element (#83)
This commit is contained in:
parent
eb44eb3a49
commit
f637b8e154
@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- HTML5 frameset documents (#83, stage 8): a `<frameset>` after the head now produces a frameset document whose `frameset` element replaces the body (`<html><head></head><frameset>...`), with nested framesets and `<noframes>` handled, and `<frame>` is now a void element (it no longer wrongly nests). An ordinary document still produces a body.
|
||||
- HTML5 integration points (#83, stage 7): HTML content inside an SVG integration point (`foreignObject`, `desc`, `title`) is now parsed in the HTML namespace rather than SVG, so `<svg><foreignObject><div>` puts the `div` (and its descendants) back in HTML; re-entering `<svg>` inside an integration point switches back to the SVG namespace.
|
||||
- HTML5 table foster-parenting (#83, stage 6): stray flow content inside a table is now moved out rather than nested in the table structure, matching the HTML standard. A non-table element or character data appearing while a `table`/`tbody`/`thead`/`tfoot`/`tr` is the open node is inserted immediately before the table (`<table><b>x</table>` -> `<b>x</b><table></table>`), while well-formed cell content is untouched.
|
||||
- HTML5 SVG attribute-name case adjustment (#83, stage 5): an SVG attribute written in mixed case is restored to its canonical camel case in the tree (`viewbox` -> `viewBox`, `gradienttransform` -> `gradientTransform`, and the rest), applying only to elements in the SVG namespace. HTML attribute names are left as the tokenizer lowercased them. With this, SVG/MathML foreign content carries correct element and attribute names.
|
||||
|
||||
@ -89,7 +89,7 @@ flowchart TB
|
||||
|
||||
PureXML is a working, dependency-free XML library today: parse, emit, validate,
|
||||
query, and stream documents on macOS, Linux, Windows, and WASM. The test suite
|
||||
currently runs **740 tests in 115 suites** (`swift test`).
|
||||
currently runs **744 tests in 116 suites** (`swift test`).
|
||||
|
||||
### Shipped (libxml2-aligned surface)
|
||||
|
||||
|
||||
@ -24,6 +24,8 @@ private enum InsertionMode {
|
||||
case inHead
|
||||
case afterHead
|
||||
case inBody
|
||||
case inFrameset
|
||||
case afterFrameset
|
||||
}
|
||||
|
||||
/// Builds a full HTML document from tokens by driving the HTML5 insertion modes,
|
||||
@ -53,9 +55,10 @@ final class HTMLDocument {
|
||||
/// a pop-on-close model cannot.
|
||||
let bodyRoot = PureXML.Model.TreeNode.element("body")
|
||||
lazy var openBody: [PureXML.Model.TreeNode] = [bodyRoot]
|
||||
/// The active formatting elements (the HTML5 list), with nil entries acting as
|
||||
/// scope markers. Drives reconstruction and the adoption agency.
|
||||
/// 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 {
|
||||
@ -67,11 +70,15 @@ final class HTMLDocument {
|
||||
// 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 body = PureXML.Model.Element(name: .init("body"), attributes: bodyAttributes, children: bodyRoot.children.map(\.node))
|
||||
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), .element(body)],
|
||||
children: [.element(head), secondChild],
|
||||
)
|
||||
return .document([.element(html)])
|
||||
}
|
||||
@ -84,11 +91,11 @@ final class HTMLDocument {
|
||||
case .inHead: processInHead(token)
|
||||
case .afterHead: processAfterHead(token)
|
||||
case .inBody: processInBody(token)
|
||||
case .inFrameset: processInFrameset(token)
|
||||
case .afterFrameset: break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Document-structure modes
|
||||
|
||||
private func processInitial(_ token: Token) {
|
||||
if case .doctype = token { return }
|
||||
if isIgnorableWhitespace(token) { return }
|
||||
@ -145,6 +152,11 @@ final class HTMLDocument {
|
||||
}
|
||||
|
||||
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
|
||||
@ -180,17 +192,32 @@ final class HTMLDocument {
|
||||
}
|
||||
}
|
||||
|
||||
/// After opening a start tag, records it on the active-formatting list when it
|
||||
/// is a formatting element (so misnesting can be recovered later).
|
||||
/// 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)
|
||||
}
|
||||
|
||||
// MARK: Body building (live mutable tree)
|
||||
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 stack
|
||||
/// matching (an SVG element's canonical camel case folds back to the token).
|
||||
/// The lowercased tag name of an open element, for case-insensitive matching.
|
||||
func tagName(_ node: PureXML.Model.TreeNode) -> String {
|
||||
node.name?.localName.lowercased() ?? ""
|
||||
}
|
||||
@ -219,7 +246,6 @@ final class HTMLDocument {
|
||||
openBody.last?.append(node)
|
||||
}
|
||||
|
||||
/// The table-construction implied insertions, against the live body stack.
|
||||
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 }
|
||||
@ -240,8 +266,7 @@ final class HTMLDocument {
|
||||
openBody.append(element)
|
||||
}
|
||||
|
||||
/// Restores SVG attribute names to their canonical camel case (`viewBox`, not
|
||||
/// the tokenizer's `viewbox`) for elements in the SVG namespace.
|
||||
/// 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
|
||||
@ -251,9 +276,7 @@ final class HTMLDocument {
|
||||
}
|
||||
|
||||
/// The foreign-content namespace for a body element: SVG/MathML on entry, the
|
||||
/// nearest open foreign ancestor's namespace inside one, or HTML (nil) when
|
||||
/// that ancestor is an HTML integration point (`foreignObject`/`desc`/`title`),
|
||||
/// whose content is parsed as ordinary HTML.
|
||||
/// 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 }
|
||||
@ -265,13 +288,12 @@ final class HTMLDocument {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The SVG elements that are HTML integration points: their content is HTML.
|
||||
/// 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 still uses the pop-on-close `DocFrame` model (it
|
||||
/// needs no adoption agency); these primitives drive it, kept in an extension so
|
||||
/// the class body stays within its size budget.
|
||||
/// 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,
|
||||
@ -304,10 +326,8 @@ extension HTMLDocument {
|
||||
return stack.reversed().first { $0.namespace != nil }?.namespace
|
||||
}
|
||||
|
||||
/// A qualified name carrying its foreign-content namespace URI, so an SVG or
|
||||
/// MathML element is distinguishable from same-named HTML. SVG element names
|
||||
/// are restored to their canonical camel case (`foreignObject`, not the
|
||||
/// tokenizer's lowercased `foreignobject`).
|
||||
/// 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)
|
||||
|
||||
@ -27,7 +27,7 @@ extension PureXML.HTML {
|
||||
enum Elements {
|
||||
/// Void elements: no content and no end tag.
|
||||
static let void: Set<String> = [
|
||||
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
||||
"area", "base", "br", "col", "embed", "frame", "hr", "img", "input",
|
||||
"link", "meta", "param", "source", "track", "wbr",
|
||||
]
|
||||
|
||||
|
||||
35
Tests/HTMLFramesetTests.swift
Normal file
35
Tests/HTMLFramesetTests.swift
Normal file
@ -0,0 +1,35 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("HTML5 frameset documents")
|
||||
struct HTMLFramesetTests {
|
||||
private func document(_ html: String) -> String {
|
||||
PureXML.HTML.serialize(PureXML.HTML.parseDocument(html))
|
||||
}
|
||||
|
||||
@Test("A frameset element replaces the body and frames are void")
|
||||
func test_framesetReplacesBody() {
|
||||
let html = "<frameset cols=50%,50%><frame src=a><frame src=b></frameset>"
|
||||
let expected = "<html><head></head><frameset cols=\"50%,50%\"><frame src=\"a\"><frame src=\"b\"></frameset></html>"
|
||||
#expect(document(html) == expected)
|
||||
}
|
||||
|
||||
@Test("Nested framesets nest correctly")
|
||||
func test_nestedFrameset() {
|
||||
let html = "<frameset><frameset><frame></frameset></frameset>"
|
||||
let expected = "<html><head></head><frameset><frameset><frame></frameset></frameset></html>"
|
||||
#expect(document(html) == expected)
|
||||
}
|
||||
|
||||
@Test("A frameset after a head keeps head content")
|
||||
func test_framesetWithHead() {
|
||||
let html = "<head><title>T</title></head><frameset><frame></frameset>"
|
||||
let expected = "<html><head><title>T</title></head><frameset><frame></frameset></html>"
|
||||
#expect(document(html) == expected)
|
||||
}
|
||||
|
||||
@Test("An ordinary document still produces a body")
|
||||
func test_ordinaryStillHasBody() {
|
||||
#expect(document("<p>x</p>") == "<html><head></head><body><p>x</p></body></html>")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user