Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 1 addition & 71 deletions src/browser/Frame.zig
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const milliTimestamp = @import("../datetime.zig").milliTimestamp;

const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup;

pub const parse = @import("frame/parse.zig");
pub const preload = @import("frame/preload.zig");
pub const observers = @import("frame/observers.zig");
pub const user_input = @import("frame/user_input.zig");
Expand Down Expand Up @@ -2867,77 +2868,6 @@ pub fn updateRangesForNodeRemoval(self: *Frame, parent: *Node, child: *Node, chi
}
}

// TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '')
pub fn parseHtmlAsChildren(self: *Frame, node: *Node, html: []const u8) !void {
return self.parseHtmlAsChildrenInner(node, html, .{});
}

// setHTMLUnsafe variant: parse a fragment that may contain declarative shadow node
pub fn parseHtmlUnsafeAsChildren(self: *Frame, node: *Node, html: []const u8) !void {
return self.parseHtmlAsChildrenInner(node, html, .{ .allow_declarative_shadow = true });
}

// Range.createContextualFragment variant: unlike innerHTML et al., its scripts
// are run when the fragment is inserted into a document.
pub fn parseContextualFragment(self: *Frame, node: *Node, html: []const u8) !void {
return self.parseHtmlAsChildrenInner(node, html, .{ .scripts_runnable = true });
}

const FragmentParseOpts = struct {
scripts_runnable: bool = false,
allow_declarative_shadow: bool = false,
};

fn parseHtmlAsChildrenInner(self: *Frame, node: *Node, html: []const u8, opts: FragmentParseOpts) !void {
const previous_parse_mode = self._parse_mode;
self._parse_mode = .fragment;
defer self._parse_mode = previous_parse_mode;

// The html5ever wrapper-unwrap below rebinds children without going
// through the insertion path, so recompute slot assignments for any
// shadow tree this fragment landed in (idempotent; signals only on diff).
defer if (self._element_shadow_roots.count() != 0) {
const root = node.getRootNode(.{});
if (root.is(ShadowRoot) != null) {
slotting.assignSlottablesForTree(root, self);
}
if (node.is(Element)) |el| {
if (self._element_shadow_roots.get(el)) |shadow_root| {
slotting.assignSlottablesForTree(shadow_root.asNode(), self);
}
}
};

const previous_scripts_runnable = self._fragment_scripts_runnable;
self._fragment_scripts_runnable = opts.scripts_runnable;
defer self._fragment_scripts_runnable = previous_scripts_runnable;

var parser = Parser.init(self.call_arena, node, self, .{ .allow_declarative_shadow = opts.allow_declarative_shadow });
parser.parseFragment(html);
if (parser.terminated) {
return error.ExecutionTerminated;
}

// html5ever wraps fragment output in an <html> element; unwrap so its
// children land directly on `node`. See https://github.com/servo/html5ever/issues/583.
// Because of custom element callbacks, the structure might not be what
// we expect, and nodes might be altogether removed. We deal with this in a
// few different places, but always the same way: leave it as-is.
const children = node._children orelse return;
const first = Node.linkToNode(children.first.?);
if (first.is(Element.Html.Html) == null) {
return;
}
node._children = first._children;

// No mutation records for the unwrapped children either; see the comment
// about fragment parses in _insertNodeRelative.
var it = node.childrenIterator();
while (it.next()) |child| {
child._parent = node;
}
}

// Runs the "ready" work for an inserted node and, when it's an element with
// children, for its descendants in tree order: appending a subtree
// containing scripts must execute them all, after the whole insertion.
Expand Down
37 changes: 37 additions & 0 deletions src/browser/Mime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ pub const ContentTypeEnum = enum {
application_json,
unknown,
other,
other_xml,
};

pub const ContentType = union(ContentTypeEnum) {
Expand All @@ -68,6 +69,7 @@ pub const ContentType = union(ContentTypeEnum) {
// A valid but unrecognized type/subtype. Keeping it would require some
// memory management of the input. Nothing needs it right now, so why bother.
other: void,
other_xml: void, // i.e. application/xml or */*+xml
};

pub fn contentTypeString(mime: *const Mime) []const u8 {
Expand Down Expand Up @@ -346,6 +348,13 @@ pub fn isHTML(self: *const Mime) bool {
return self.content_type == .text_html;
}

pub fn isXML(self: *const Mime) bool {
return switch (self.content_type) {
.text_xml, .other_xml => true,
else => false,
};
}

pub fn isText(mime: *const Mime) bool {
return switch (mime.content_type) {
.text_xml, .text_html, .text_javascript, .text_plain, .text_css, .text_markdown => true,
Expand Down Expand Up @@ -378,9 +387,11 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
@"image/webp",

@"application/json",
@"application/xml",
}, type_name)) |known_type| {
const ct: ContentType = switch (known_type) {
.@"text/xml" => .{ .text_xml = {} },
.@"application/xml" => .{ .other_xml = {} },
.@"text/html" => .{ .text_html = {} },
.@"text/javascript", .@"application/javascript", .@"application/x-javascript" => .{ .text_javascript = {} },
.@"text/plain" => .{ .text_plain = {} },
Expand Down Expand Up @@ -408,6 +419,10 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
return error.Invalid;
}

if (std.mem.endsWith(u8, type_name, "+xml")) {
return .{ .{ .other_xml = {} }, attribute_start };
}

return .{ .{ .other = {} }, attribute_start };
}

Expand Down Expand Up @@ -897,6 +912,28 @@ test "Mime: isHTML" {
try assert(false, "over/9000");
}

test "Mime: isXML" {
defer testing.reset();

const assert = struct {
fn assert(expected: bool, input: []const u8) !void {
const mutable_input = try testing.arena_allocator.dupe(u8, input);
var mime = try Mime.parse(mutable_input);
try testing.expectEqual(expected, mime.isXML());
}
}.assert;
try assert(true, "text/xml");
try assert(true, "TEXT/XML; charset=utf-8");
try assert(true, "application/xml");
try assert(true, "image/svg+xml");
try assert(true, "application/xhtml+xml");
try assert(true, "application/rss+xml;charset=utf-8");
try assert(false, "text/html");
try assert(false, "application/json");
try assert(false, "application/xmlfoo");
try assert(false, "text/xm");
}

test "Mime: sniff" {
try testing.expectEqual(null, Mime.sniff(""));
try testing.expectEqual(null, Mime.sniff("<htm"));
Expand Down
2 changes: 1 addition & 1 deletion src/browser/forms.zig
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ fn testForms(html: []const u8) ![]FormInfo {

const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(), html);
try Frame.parse.htmlAsChildren(frame, div.asNode(), html);

return collectForms(frame.call_arena, div.asNode(), frame);
}
Expand Down
129 changes: 129 additions & 0 deletions src/browser/frame/parse.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

const Frame = @import("../Frame.zig");
const Parser = @import("../parser/Parser.zig");

const Node = @import("../webapi/Node.zig");
const Element = @import("../webapi/Element.zig");
const Document = @import("../webapi/Document.zig");
const ShadowRoot = @import("../webapi/ShadowRoot.zig");
const slotting = @import("../webapi/element/slotting.zig");

pub fn htmlAsChildren(frame: *Frame, node: *Node, html: []const u8) !void {
return htmlAsChildrenInner(frame, node, html, .{});
}

// setHTMLUnsafe variant: parse a fragment that may contain declarative shadow node
pub fn htmlUnsafeAsChildren(frame: *Frame, node: *Node, html: []const u8) !void {
return htmlAsChildrenInner(frame, node, html, .{ .allow_declarative_shadow = true });
}

// Range.createContextualFragment variant: unlike innerHTML et al., its scripts
// are run when the fragment is inserted into a document.
pub fn contextualFragment(frame: *Frame, node: *Node, html: []const u8) !void {
return htmlAsChildrenInner(frame, node, html, .{ .scripts_runnable = true });
}

const FragmentParseOpts = struct {
scripts_runnable: bool = false,
allow_declarative_shadow: bool = false,
};

fn htmlAsChildrenInner(frame: *Frame, node: *Node, html: []const u8, opts: FragmentParseOpts) !void {
const previous_parse_mode = frame._parse_mode;
frame._parse_mode = .fragment;
defer frame._parse_mode = previous_parse_mode;

// The html5ever wrapper-unwrap below rebinds children without going
// through the insertion path, so recompute slot assignments for any
// shadow tree this fragment landed in (idempotent; signals only on diff).
defer if (frame._element_shadow_roots.count() != 0) {
const root = node.getRootNode(.{});
if (root.is(ShadowRoot) != null) {
slotting.assignSlottablesForTree(root, frame);
}
if (node.is(Element)) |el| {
if (frame._element_shadow_roots.get(el)) |shadow_root| {
slotting.assignSlottablesForTree(shadow_root.asNode(), frame);
}
}
};

const previous_scripts_runnable = frame._fragment_scripts_runnable;
frame._fragment_scripts_runnable = opts.scripts_runnable;
defer frame._fragment_scripts_runnable = previous_scripts_runnable;

var parser = Parser.init(frame.call_arena, node, frame, .{ .allow_declarative_shadow = opts.allow_declarative_shadow });
parser.parseFragment(html);
if (parser.terminated) {
return error.ExecutionTerminated;
}

// html5ever wraps fragment output in an <html> element; unwrap so its
// children land directly on `node`. See https://github.com/servo/html5ever/issues/583.
// Because of custom element callbacks, the structure might not be what
// we expect, and nodes might be altogether removed. We deal with this in a
// few different places, but always the same way: leave it as-is.
const children = node._children orelse return;
const first = Node.linkToNode(children.first.?);
if (first.is(Element.Html.Html) == null) {
return;
}
node._children = first._children;

// No mutation records for the unwrapped children either; see the comment
// about fragment parses in _insertNodeRelative.
var it = node.childrenIterator();
while (it.next()) |child| {
child._parent = node;
}
}

// Build a detached XMLDocument from `xml` (DOMParser.parseFromString and
// XMLHttpRequest.responseXML). Returns null when the input isn't well-formed
// XML.
pub fn xmlDocument(frame: *Frame, xml: []const u8) !?*Document.XMLDocument {
const arena = try frame.getArena(.medium, "parse.xmlDocument");
defer frame.releaseArena(arena);

const previous_parse_mode = frame._parse_mode;
frame._parse_mode = .fragment;
defer frame._parse_mode = previous_parse_mode;

const doc = try frame._factory.document(Document.XMLDocument{ ._proto = undefined });
const doc_node = doc.asNode();
var parser = Parser.init(arena, doc_node, frame, .{});
parser.parseXML(xml);
if (parser.terminated) {
return error.ExecutionTerminated;
}

if (parser.err != null or doc_node.firstChild() == null) {
return null;
}

// If first node is a `ProcessingInstruction` (e.g. the <?xml?>
// declaration), skip it.
const first_child = doc_node.firstChild().?;
if (first_child.getNodeType() == 7) {
_ = try doc_node.removeChild(first_child, frame);
}

return doc;
}
2 changes: 1 addition & 1 deletion src/browser/interactive.zig
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ fn testInteractive(html: []const u8) ![]InteractiveElement {

const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(), html);
try Frame.parse.htmlAsChildren(frame, div.asNode(), html);

return collectInteractiveElements(div.asNode(), frame.call_arena, frame);
}
Expand Down
2 changes: 1 addition & 1 deletion src/browser/links.zig
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ fn testLinks(html: []const u8) ![]Link {

const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(), html);
try Frame.parse.htmlAsChildren(frame, div.asNode(), html);

return collectLinks(frame.call_arena, div.asNode(), frame);
}
Expand Down
12 changes: 6 additions & 6 deletions src/browser/markdown.zig
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,7 @@ fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
const doc = frame.window._document;

const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(), html);
try Frame.parse.htmlAsChildren(frame, div.asNode(), html);

var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
Expand Down Expand Up @@ -800,7 +800,7 @@ test "browser.markdown: resolve links" {

const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(),
try Frame.parse.htmlAsChildren(frame, div.asNode(),
\\<a href="b">Link</a>
\\<img src="../c.png" alt="Img">
\\<a href="/my page">Space</a>
Expand Down Expand Up @@ -839,7 +839,7 @@ test "browser.markdown: max_bytes leaves output untouched when under cap" {

const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(), "<p>Short</p>");
try Frame.parse.htmlAsChildren(frame, div.asNode(), "<p>Short</p>");

var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
Expand All @@ -855,7 +855,7 @@ test "browser.markdown: max_bytes truncates with marker" {

const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(), "<p>" ++ ("AAAA " ** 100) ++ "</p>");
try Frame.parse.htmlAsChildren(frame, div.asNode(), "<p>" ++ ("AAAA " ** 100) ++ "</p>");

var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
Expand All @@ -878,11 +878,11 @@ fn testMarkdownShadow(light: []const u8, shadow: []const u8, expected: []const u

const host = try doc.createElement("div", null, frame);
if (light.len > 0) {
try frame.parseHtmlAsChildren(host.asNode(), light);
try Frame.parse.htmlAsChildren(frame, host.asNode(), light);
}

const sr = try host.attachShadow(.{ .mode = .open }, frame);
try frame.parseHtmlAsChildren(sr.asNode(), shadow);
try Frame.parse.htmlAsChildren(frame, sr.asNode(), shadow);

var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
Expand Down
4 changes: 2 additions & 2 deletions src/browser/structured_data.zig
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ fn testStructuredData(html: []const u8) !StructuredData {

const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(), html);
try Frame.parse.htmlAsChildren(frame, div.asNode(), html);

return collectStructuredData(div.asNode(), frame.call_arena, frame);
}
Expand Down Expand Up @@ -714,7 +714,7 @@ test "structured_data: link headers from response" {

const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
try frame.parseHtmlAsChildren(div.asNode(), "<title>Example</title>");
try Frame.parse.htmlAsChildren(frame, div.asNode(), "<title>Example</title>");

const data = try collectStructuredData(div.asNode(), frame.call_arena, frame);

Expand Down
Loading
Loading