From f637b8e1541086f2973febdc0d9547c17c67ef83 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Tue, 9 Jun 2026 06:17:55 +0200 Subject: [PATCH] feat(html): frameset documents and void frame element (#83) --- CHANGELOG.md | 1 + README.md | 2 +- Sources/HTML/HTMLDocumentBuilder.swift | 70 +++++++++++++++++--------- Sources/HTML/HTMLToken.swift | 2 +- Tests/HTMLFramesetTests.swift | 35 +++++++++++++ 5 files changed, 83 insertions(+), 27 deletions(-) create mode 100644 Tests/HTMLFramesetTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 57d65b6..cd19826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` after the head now produces a frameset document whose `frameset` element replaces the body (`...`), with nested framesets and `` 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. diff --git a/README.md b/README.md index 30bd579..0132c7c 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/Sources/HTML/HTMLDocumentBuilder.swift b/Sources/HTML/HTMLDocumentBuilder.swift index 5e8db4b..4ab0029 100644 --- a/Sources/HTML/HTMLDocumentBuilder.swift +++ b/Sources/HTML/HTMLDocumentBuilder.swift @@ -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) diff --git a/Sources/HTML/HTMLToken.swift b/Sources/HTML/HTMLToken.swift index d3964ae..cfe2487 100644 --- a/Sources/HTML/HTMLToken.swift +++ b/Sources/HTML/HTMLToken.swift @@ -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", ] diff --git a/Tests/HTMLFramesetTests.swift b/Tests/HTMLFramesetTests.swift new file mode 100644 index 0000000..710adde --- /dev/null +++ b/Tests/HTMLFramesetTests.swift @@ -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>") + } +}