Merge branch 'main' into agent

# Conflicts:
#	src/mcp/Server.zig
This commit is contained in:
Adrià Arrufat committed 2026-06-11 09:00:44 +02:00
commit 314e3c58ea
37 files changed
+1926 -248

No files matched your search

+36 -7
View File
@@ -176,12 +176,21 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
const activation_state = try ActivationState.create(event, target, frame);
var path_len: usize = 0;
var node_path_len: usize = 0;
var path_buffer: [128]*EventTarget = undefined;
// Defer runs even on early return - ensures event phase is reset
// and default actions execute (unless prevented)
defer {
event._event_phase = .none;
event._current_target = null;
event._stop_propagation = false;
event._stop_immediate_propagation = false;
if (event._needs_retargeting and node_path_len > 0) {
const adjusted = getAdjustedTarget(event._dispatch_target, path_buffer[node_path_len - 1]);
event._target = if (rootIsShadowRoot(adjusted)) null else adjusted;
}
// Handle checkbox/radio activation rollback or commit
if (activation_state) |state| {
state.restore(event, frame);
@@ -201,9 +210,6 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
}
}
var path_len: usize = 0;
var path_buffer: [128]*EventTarget = undefined;
var node: ?*Node = target;
while (node) |n| {
if (path_len >= path_buffer.len) break;
@@ -227,11 +233,18 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
node = n._parent;
}
node_path_len = path_len;
// Even though the window isn't part of the DOM, most events propagate
// through it in the capture phase (unless we stopped at a shadow boundary)
// The only explicit exception is "load"
if (event._type_string.eql(comptime .wrap("load")) == false) {
if (path_len < path_buffer.len) {
// through it in the capture phase. It only participates when the tree's
// root is the document (not for detached trees, and not when propagation
// stopped at a shadow boundary). The only explicit exception is "load".
if (event._type_string.eql(comptime .wrap("load")) == false and path_len < path_buffer.len) {
const root_is_document = path_len > 0 and switch (path_buffer[path_len - 1]._type) {
.node => |n| n._type == .document,
else => false,
};
if (root_is_document) {
path_buffer[path_len] = frame.window.asEventTarget();
path_len += 1;
}
@@ -454,6 +467,22 @@ fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarge
return original_target;
}
// Whether the target's tree root (without crossing shadow boundaries) is a
// shadow root. Used for the spec's post-dispatch "clear targets" step.
fn rootIsShadowRoot(target_: ?*EventTarget) bool {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
const target = target_ orelse return false;
var current: *Node = switch (target._type) {
.node => |n| n,
else => return false,
};
while (current._parent) |p| {
current = p;
}
return current.is(ShadowRoot) != null;
}
// Check if ancestor is an ancestor of (or the same as) node
// WITHOUT crossing shadow boundaries (just regular DOM tree)
fn isAncestorOrSelf(ancestor: *Node, node: *Node) bool {
+3
View File
@@ -253,6 +253,9 @@ pub fn dispatchDirect(
ls.deinit();
}
// Per spec, currentTarget is only set while listeners are being invoked
defer event._current_target = null;
// Call the property handler (e.g., onmessage) if present
if (getFunction(handler, &ls.local)) |func| {
event._current_target = target;
+18 -1
View File
@@ -1703,6 +1703,23 @@ pub fn queueLoad(self: *Frame, html: *Element.Html) !void {
// splitting by route anyway).
const MAX_STYLESHEET_BYTES: usize = 2 * 1024 * 1024;
// start prefetching <link rel="preload" as="script" href=...>`
pub fn preloadScriptHint(self: *Frame, href: []const u8) void {
if (self.isGoingAway() or self._parse_mode == .fragment) {
return;
}
const arena = self.getArena(.small, "Frame.preloadScriptHint") catch return;
defer self.releaseArena(arena);
const resolved = URL.resolve(arena, self.base(), href, .{ .encoding = self.charset }) catch return;
if (!std.ascii.startsWithIgnoreCase(resolved, "http:") and !std.ascii.startsWithIgnoreCase(resolved, "https:")) {
// data:/blob: are synthesized locally — no round-trip to hide.
return;
}
self._script_manager.preloadScript(resolved) catch {};
}
// Synchronously fetch and parse an external `<link rel=stylesheet>`.
// href is passed in as an optimization since the [currently] only callsite has
// it, so why look it up again?
@@ -1751,7 +1768,7 @@ pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []co
const sm = &self._script_manager.base;
const was_evaluating = sm.is_evaluating;
sm.is_evaluating = true;
defer sm.is_evaluating = was_evaluating;
defer sm.endEvaluationWindow(was_evaluating);
var response = http_client.syncRequest(arena, .{
.url = resolved,
+163 -20
View File
@@ -46,25 +46,41 @@ frame: *Frame,
// "load" event).
frame_notified_of_completion: bool,
// scripts loaded based on a <link rel=preload as=script href=...> found during parsing
preloaded_scripts: std.StringHashMapUnmanaged(PreloadedScript),
pub fn init(allocator: Allocator, http_client: *HttpClient, frame: *Frame) ScriptManager {
var base = ScriptManagerBase.init(allocator, http_client, .{ .frame = frame });
base.tail_hook = tailHook;
return .{
.frame = frame,
.base = base,
.frame = frame,
.preloaded_scripts = .empty,
.frame_notified_of_completion = false,
};
}
pub fn deinit(self: *ScriptManager) void {
self.freePreloads();
self.base.deinit();
self.preloaded_scripts.deinit(self.base.allocator);
}
pub fn reset(self: *ScriptManager) void {
self.freePreloads();
self.preloaded_scripts.clearRetainingCapacity();
self.base.reset();
self.frame_notified_of_completion = false;
}
// Frees every preloaded Script
fn freePreloads(self: *ScriptManager) void {
var it = self.preloaded_scripts.valueIterator();
while (it.next()) |preload_script| {
preload_script.deinit();
}
}
// Frame wrapper uses this to fire documentIsLoaded and scriptsCompletedLoading
// once Base has finished processing its ready / defer queues.
pub fn tailHook(base: *ScriptManagerBase) void {
@@ -86,6 +102,81 @@ fn getHeaders(self: *ScriptManager) !HttpClient.Headers {
return self.base.getHeaders();
}
pub fn preloadScript(self: *ScriptManager, url: []const u8) !void {
if (self.preloaded_scripts.contains(url)) {
return;
}
const frame = self.frame;
const arena = try frame.getArena(.large, "SM.preloadScript");
errdefer frame.releaseArena(arena);
const owned_url = try arena.dupeZ(u8, url);
const script = try arena.create(Script);
script.* = .{
.arena = arena,
.url = owned_url,
.node = .{},
.manager = &self.base,
.complete = false,
.source = .{ .remote = .{} },
.extra = .preload,
};
try self.preloaded_scripts.putNoClobber(self.base.allocator, owned_url, .{ .state = .{ .loading = script } });
errdefer _ = self.preloaded_scripts.remove(owned_url);
if (comptime IS_DEBUG) {
log.debug(.http, "script queue", .{ .url = owned_url, .ctx = "preload" });
}
try frame.makeRequest(.{
.ctx = script,
.url = owned_url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.headers = try self.base.getHeaders(),
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.resource_type = .script,
.notification = frame._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = PreloadedScript.doneCallback,
.error_callback = PreloadedScript.errorCallback,
});
}
fn waitForPreload(self: *ScriptManager, url: [:0]const u8) ?*Script {
if (self.preloaded_scripts.getPtr(url) == null) {
return null;
}
const was_evaluating = self.base.is_evaluating;
self.base.is_evaluating = true;
defer self.base.endEvaluationWindow(was_evaluating);
var client = self.base.client;
while (true) {
const entry = self.preloaded_scripts.getPtr(url) orelse return null;
switch (entry.state) {
.loading => {
_ = client.tick(200, .sync_wait) catch return null;
continue;
},
.done => |script| {
// Preload scripts are single-use. We return it and it becomes
// the caller's responsibility to free.
_ = self.preloaded_scripts.remove(url);
return script;
},
}
}
}
pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_element: *Element.Html.Script, comptime ctx: []const u8) !void {
if (script_element._executed) {
// If a script tag gets dynamically created and added to the dom:
@@ -132,6 +223,13 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
var handover = false;
const frame = self.frame;
// A consumed preload (waitForPreload below) is owned by us: its buffer is
// borrowed by `script`, so it must outlive eval.
var consumed_preload: ?*Script = null;
defer if (consumed_preload) |p| {
p.deinit();
};
const arena = try frame.getArena(.large, "SM.addFromElement");
errdefer if (!handover) {
frame.releaseArena(arena);
@@ -235,26 +333,32 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
const was_evaluating = self.base.is_evaluating;
self.base.is_evaluating = true;
defer self.base.is_evaluating = was_evaluating;
const headers = try self.getHeaders();
defer self.base.endEvaluationWindow(was_evaluating);
if (is_blocking) {
const response = try self.base.client.syncRequest(arena, .{
.url = url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.headers = headers,
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.resource_type = .script,
.notification = frame._session.notification,
});
if (self.waitForPreload(url)) |pre| {
// There was a preloaded script, we borrow it's source and status
consumed_preload = pre;
script.source = pre.source;
script.status = pre.status;
script.complete = true;
} else {
const response = try self.base.client.syncRequest(arena, .{
.url = url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.headers = try self.getHeaders(),
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.resource_type = .script,
.notification = frame._session.notification,
});
script.source = .{ .remote = response.body };
script.status = response.status;
script.complete = true;
script.source = .{ .remote = response.body };
script.status = response.status;
script.complete = true;
}
} else {
errdefer {
self.base.scriptList(script).remove(&script.node);
@@ -267,7 +371,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.headers = headers,
.headers = try self.getHeaders(),
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.resource_type = .script,
@@ -301,8 +405,8 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
const was_evaluating = self.base.is_evaluating;
self.base.is_evaluating = true;
defer {
self.base.is_evaluating = was_evaluating;
script.deinit();
self.base.endEvaluationWindow(was_evaluating);
}
script.eval();
@@ -311,3 +415,42 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
pub fn staticScriptsDone(self: *ScriptManager) void {
self.base.staticScriptsDone();
}
const PreloadedScript = struct {
state: State,
const State = union(enum) {
loading: *Script,
done: *Script,
};
pub fn deinit(self: PreloadedScript) void {
switch (self.state) {
inline else => |script| script.deinit(),
}
}
fn doneCallback(ctx: *anyopaque) !void {
const script: *Script = @ptrCast(@alignCast(ctx));
script.complete = true;
if (comptime IS_DEBUG) {
log.debug(.http, "script fetch complete", .{ .req = script.url });
}
const self: *ScriptManager = @fieldParentPtr("base", script.manager);
self.preloaded_scripts.getPtr(script.url).?.state = .{ .done = script };
}
fn errorCallback(ctx: *anyopaque, err: anyerror) void {
const script: *Script = @ptrCast(@alignCast(ctx));
if (script.status == 404) {
log.info(.http, "script 404", .{ .req = script.url, .extra = "preload" });
} else {
log.warn(.http, "script fetch error", .{ .err = err, .req = script.url, .extra = "preload", .status = script.status });
}
const self: *ScriptManager = @fieldParentPtr("base", script.manager);
_ = self.preloaded_scripts.remove(script.url);
script.deinit();
}
};
+92 -48
View File
@@ -95,6 +95,10 @@ owner: Owner,
// used to prevent recursive evaluation
is_evaluating: bool,
// an evaluate() arrived while is_evaluating was set, signal to call evaluate()
// when is_evaluating becomes false (to prevent re-entrancy)
evaluate_pending: bool = false,
// Only once this is true can deferred scripts be run
static_scripts_done: bool,
@@ -167,6 +171,7 @@ pub fn reset(self: *ScriptManagerBase) void {
clearList(&self.async_scripts);
clearList(&self.ready_scripts);
self.static_scripts_done = false;
self.evaluate_pending = false;
}
fn clearList(list: *std.DoublyLinkedList) void {
@@ -193,6 +198,7 @@ fn releaseArena(self: *ScriptManagerBase, arena: Allocator) void {
pub fn scriptList(self: *ScriptManagerBase, script: *const Script) *std.DoublyLinkedList {
return switch (script.extra) {
.import, .import_async => &self.async_scripts,
.preload => unreachable, // done/error are handled directly, never via scriptList
.frame => |fe| switch (fe.mode) {
.normal => unreachable, // not added to a list, executed immediately
.@"defer" => &self.defer_scripts,
@@ -287,7 +293,7 @@ pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource
const was_evaluating = self.is_evaluating;
self.is_evaluating = true;
defer self.is_evaluating = was_evaluating;
defer self.endEvaluationWindow(was_evaluating);
var client = self.client;
while (true) {
@@ -350,13 +356,12 @@ pub fn getAsyncImport(self: *ScriptManagerBase, url: [:0]const u8, cb: ImportAsy
}
// It's possible, but unlikely, for client.request to immediately finish
// a request, thus calling our callback. We generally don't want a call
// from v8 (which is why we're here), to result in a new script evaluation.
// So we block even the slightest change that `client.request` immediately
// executes a callback.
// a request, thus calling our callback. We don't want that to trigger a
// script evaluation while we're still setting the request up (the script
// isn't fully linked into our lists yet).
const was_evaluating = self.is_evaluating;
self.is_evaluating = true;
defer self.is_evaluating = was_evaluating;
defer self.endEvaluationWindow(was_evaluating);
const owner = self.owner;
const session = self.owner.session();
@@ -405,62 +410,97 @@ pub fn scriptCreatedParseDone(self: *ScriptManagerBase) void {
pub fn evaluate(self: *ScriptManagerBase) void {
if (self.is_evaluating) {
// It's possible for a script.eval to cause evaluate to be called again.
// Signal that this happened so that, once the outer evaluate finishes,
// we evaluate() again to catch what we're skipping now.
self.evaluate_pending = true;
return;
}
self.is_evaluating = true;
defer self.is_evaluating = false;
defer self.endEvaluationWindow(false);
while (self.ready_scripts.popFirst()) |n| {
var script: *Script = @fieldParentPtr("node", n);
switch (script.extra) {
.frame => {
// Only .async mode reaches ready_scripts (defer stays in
// defer_scripts, normal is sync and never queued).
defer script.deinit();
script.eval();
},
.import_async => |ia| {
if (script.status < 200 or script.status > 299) {
while (true) {
self.evaluate_pending = false;
while (self.ready_scripts.popFirst()) |n| {
var script: *Script = @fieldParentPtr("node", n);
switch (script.extra) {
.frame => {
// Only .async mode reaches ready_scripts (defer stays in
// defer_scripts, normal is sync and never queued).
defer script.deinit();
script.eval();
},
.import_async => |ia| {
if (script.status < 200 or script.status > 299) {
script.deinit();
ia.callback(ia.data, error.FailedToLoad);
} else {
ia.callback(ia.data, .{
.shared = false,
.script = script,
.buffer = script.source.remote,
});
}
},
.import => unreachable, // .import doesn't go through ready_scripts
.preload => unreachable, // .preload is buffered in the map, never queued
}
}
if (self.static_scripts_done) {
while (self.defer_scripts.first) |n| {
var script: *Script = @fieldParentPtr("node", n);
if (script.complete == false) break;
defer {
_ = self.defer_scripts.popFirst();
script.deinit();
ia.callback(ia.data, error.FailedToLoad);
} else {
ia.callback(ia.data, .{
.shared = false,
.script = script,
.buffer = script.source.remote,
});
}
},
.import => unreachable, // .import doesn't go through ready_scripts
// Only frame scripts populate defer_scripts.
script.eval();
}
}
}
if (self.static_scripts_done == false) {
// We can only execute deferred scripts if
// 1 - all the normal scripts are done
// 2 - we've finished parsing the HTML and at least queued all the scripts
// The last one isn't obvious, but it's possible for self.scripts to
// be empty not because we're done executing all the normal scripts
// but because we're done executing some (or maybe none), but we're still
// parsing the HTML.
return;
}
while (self.defer_scripts.first) |n| {
var script: *Script = @fieldParentPtr("node", n);
if (script.complete == false) return;
defer {
_ = self.defer_scripts.popFirst();
script.deinit();
// A script.eval above may have pumped the http client (module
// imports, preloads); anything that completed during the pump had
// its evaluate() swallowed by the re-entrancy guard, so re-drain
// here rather than relying on a future doneCallback that may never
// come.
if (self.evaluate_pending) {
continue;
}
// Only frame scripts populate defer_scripts.
script.eval();
if (self.static_scripts_done == false) {
// We can only execute deferred scripts if
// 1 - all the normal scripts are done
// 2 - we've finished parsing the HTML and at least queued all the scripts
// The last one isn't obvious, but it's possible for self.scripts to
// be empty not because we're done executing all the normal scripts
// but because we're done executing some (or maybe none), but we're still
// parsing the HTML.
return;
}
if (self.defer_scripts.first != null) {
// Head of the defer list is still in flight; its doneCallback
// re-enters evaluate.
return;
}
break;
}
// Frame wrapper uses this to fire documentIsLoaded and
// scriptsCompletedLoading. Null for workers.
if (self.tail_hook) |hook| hook(self);
if (self.tail_hook) |hook| {
hook(self);
}
}
pub fn endEvaluationWindow(self: *ScriptManagerBase, was_evaluating: bool) void {
self.is_evaluating = was_evaluating;
if (was_evaluating == false and self.evaluate_pending) {
// we have something to evaluate
self.evaluate();
}
}
pub const Script = struct {
@@ -505,6 +545,8 @@ pub const Script = struct {
import,
// Dynamic JS import() — resolved via ready_scripts callback.
import_async: ImportAsync,
// <link rel=preload as=script href=...>
preload,
// <script> tag in a frame.
frame: FrameExtra,
@@ -646,6 +688,7 @@ pub const Script = struct {
entry.state = .{ .done = self };
entry.buffer = self.source.remote;
},
.preload => unreachable, // preloads use ScriptManager.PreloadedScript.doneCallback
}
manager.evaluate();
}
@@ -688,6 +731,7 @@ pub const Script = struct {
entry.state = .err;
},
.frame => self.executeCallback(comptime .wrap("error")),
.preload => unreachable, // preloads use ScriptManager.PreloadedScript.errorCallback
}
self.deinit();
manager.evaluate();
+6 -5
View File
@@ -129,11 +129,12 @@ pub const Caught = struct {
try writer.print("{s}caught: {any}", .{ separator, self.caught });
}
pub fn logFmt(self: Caught, comptime prefix: []const u8, writer: anytype) !void {
try writer.write(prefix ++ ".exception", self.exception orelse "???");
try writer.write(prefix ++ ".stack", self.stack orelse "na");
try writer.write(prefix ++ ".line", self.line);
try writer.write(prefix ++ ".caught", self.caught);
pub fn logFmt(self: Caught, prefix: []const u8, writer: anytype) !void {
var buf: [64]u8 = undefined;
try writer.write(try std.fmt.bufPrint(&buf, "{s}.exception", .{prefix}), self.exception orelse "???");
try writer.write(try std.fmt.bufPrint(&buf, "{s}.stack", .{prefix}), self.stack orelse "na");
try writer.write(try std.fmt.bufPrint(&buf, "{s}.line", .{prefix}), self.line);
try writer.write(try std.fmt.bufPrint(&buf, "{s}.caught", .{prefix}), self.caught);
}
pub fn jsonStringify(self: Caught, jw: anytype) !void {
+2
View File
@@ -926,6 +926,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/event/KeyboardEvent.zig"),
@import("../webapi/event/FocusEvent.zig"),
@import("../webapi/event/WheelEvent.zig"),
@import("../webapi/event/DragEvent.zig"),
@import("../webapi/event/TextEvent.zig"),
@import("../webapi/event/InputEvent.zig"),
@import("../webapi/event/PromiseRejectionEvent.zig"),
@@ -980,6 +981,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/File.zig"),
@import("../webapi/FileList.zig"),
@import("../webapi/FileReader.zig"),
@import("../webapi/DataTransfer.zig"),
@import("../webapi/Screen.zig"),
@import("../webapi/VisualViewport.zig"),
@import("../webapi/PerformanceObserver.zig"),
+1 -1
View File
@@ -884,7 +884,7 @@ fn testMarkdownShadow(light: []const u8, shadow: []const u8, expected: []const u
try frame.parseHtmlAsChildren(host.asNode(), light);
}
const sr = try host.attachShadow(comptime .wrap("open"), frame);
const sr = try host.attachShadow(.{ .mode = .open }, frame);
try frame.parseHtmlAsChildren(sr.asNode(), shadow);
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
+10 -5
View File
@@ -579,14 +579,19 @@ fn attachDeclarativeShadowCallback(ctx: *anyopaque, host_ref: *anyopaque, templa
fn _attachDeclarativeShadowCallback(self: *Parser, host_node: *Node, template_node: *Node, mode_is_open: bool) !u8 {
// guaranteed by html5ever
const host = host_node.as(Element);
const mode: lp.String = if (mode_is_open) comptime .wrap("open") else comptime .wrap("closed");
const shadow = host.attachShadow(mode, self.frame) catch |err| switch (err) {
// Expected per-spec fall-backs (host can't host a shadow, or already
// has one): keep the <template> in the light DOM instead.
const template_el = template_node.as(Element);
const shadow = host.attachShadow(.{
.declarative = true,
.mode = if (mode_is_open) .open else .closed,
.delegates_focus = template_el.hasAttributeSafe(.wrap("shadowrootdelegatesfocus")),
.clonable = template_el.hasAttributeSafe(.wrap("shadowrootclonable")),
.serializable = template_el.hasAttributeSafe(.wrap("shadowrootserializable")),
}, self.frame) catch |err| switch (err) {
error.NotSupported => return 0,
else => return err,
};
const template = template_node.as(Element).is(Element.Html.Template) orelse return 0;
const template = template_el.is(Element.Html.Template) orelse return 0;
template._content = shadow.asDocumentFragment();
return 1;
}
+128
View File
@@ -0,0 +1,128 @@
<!DOCTYPE html>
<head>
<title>Test DataTransfer Web API</title>
<script src="./testing.js"></script>
</head>
<script id=construction>
{
const dt = new DataTransfer();
testing.expectEqual(true, dt instanceof DataTransfer);
testing.expectEqual(0, dt.items.length);
testing.expectEqual(0, dt.files.length);
testing.expectEqual("none", dt.dropEffect);
testing.expectEqual("uninitialized", dt.effectAllowed);
testing.expectEqual(true, dt.items instanceof DataTransferItemList);
testing.expectEqual(true, dt.files instanceof FileList);
}
</script>
<script id=effects>
{
const dt = new DataTransfer();
dt.dropEffect = "copy";
testing.expectEqual("copy", dt.dropEffect);
dt.dropEffect = "bogus"; // invalid ignored
testing.expectEqual("copy", dt.dropEffect);
dt.effectAllowed = "copyMove";
testing.expectEqual("copyMove", dt.effectAllowed);
dt.effectAllowed = "nope"; // invalid ignored
testing.expectEqual("copyMove", dt.effectAllowed);
}
</script>
<script id=string_data>
{
const dt = new DataTransfer();
dt.setData("text/plain", "hello");
testing.expectEqual("hello", dt.getData("text/plain"));
// "text" normalizes to "text/plain"
testing.expectEqual("hello", dt.getData("text"));
// setData via the "text" alias overwrites the same item
dt.setData("text", "world");
testing.expectEqual("world", dt.getData("text/plain"));
testing.expectEqual(1, dt.items.length);
// "url" normalizes to "text/uri-list"
dt.setData("url", "https://example.com");
testing.expectEqual("https://example.com", dt.getData("text/uri-list"));
testing.expectEqual(true, dt.types.indexOf("text/plain") !== -1);
testing.expectEqual(true, dt.types.indexOf("text/uri-list") !== -1);
// getData miss returns ""
testing.expectEqual("", dt.getData("application/json"));
}
</script>
<script id=items_add_file>
{
const dt = new DataTransfer();
const f = new File(["abc"], "a.txt", { type: "text/plain" });
const item = dt.items.add(f);
testing.expectEqual("file", item.kind);
testing.expectEqual("text/plain", item.type);
// file is reflected in .files immediately
testing.expectEqual(1, dt.files.length);
testing.expectEqual("a.txt", dt.files[0].name);
testing.expectEqual(true, dt.files[0] instanceof File);
// "Files" appears in types when a file item exists
testing.expectEqual(true, dt.types.indexOf("Files") !== -1);
testing.expectEqual(f, item.getAsFile());
}
</script>
<script id=items_add_string>
{
const dt = new DataTransfer();
const item = dt.items.add("payload", "text/custom");
testing.expectEqual("string", item.kind);
testing.expectEqual("text/custom", item.type);
testing.expectEqual("payload", dt.getData("text/custom"));
testing.expectEqual(null, item.getAsFile());
}
</script>
<script id=get_as_string>
{
const dt = new DataTransfer();
dt.setData("text/plain", "world");
let got = null;
dt.items[0].getAsString(function (s) { got = s; });
testing.expectEqual("world", got);
}
</script>
<script id=remove_and_clear>
{
const dt = new DataTransfer();
dt.setData("text/plain", "s");
dt.items.add(new File(["x"], "f.txt", { type: "text/plain" }));
testing.expectEqual(2, dt.items.length);
testing.expectEqual(1, dt.files.length);
// remove the string item (index 0)
dt.items.remove(0);
testing.expectEqual(1, dt.items.length);
testing.expectEqual("", dt.getData("text/plain"));
testing.expectEqual(1, dt.files.length);
// clearData() drops string items, keeps files
dt.setData("text/plain", "again");
dt.clearData();
testing.expectEqual("", dt.getData("text/plain"));
testing.expectEqual(1, dt.files.length);
// items.clear() drops everything
dt.items.clear();
testing.expectEqual(0, dt.items.length);
testing.expectEqual(0, dt.files.length);
}
</script>
<script id=iterate_items>
{
const dt = new DataTransfer();
dt.items.add("a", "text/a");
dt.items.add("b", "text/b");
const kinds = [];
for (const it of dt.items) {
kinds.push(it.type);
}
testing.expectEqual("text/a,text/b", kinds.join(","));
}
</script>
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<script src="../../../testing.js"></script>
<!--
The preload hint is declared before the <script> that consumes it, so by the
time the parser blocks on `preload.js` the fetch is already in flight (or
done). ScriptManager.waitForPreload adopts that body instead of issuing a
fresh syncRequest, then the consumed entry is freed by the caller. Both halves
of that ownership handover run under the leak detector here.
-->
<link rel="preload" as="script" href="preload.js">
<!--
A preload with no matching <script> is fetched but never executed. It lingers
as a `.done` (or still-loading) entry until reset() frees it — exercises
freePreloads / map cleanup under the leak detector — and it must not run on
its own, nor delay the load event.
-->
<link rel="preload" as="script" href="preload_unused.js">
<script id="setup">
var order = '';
testing.expectEqual('', order);
</script>
<!-- Blocking remote script: its body is adopted from the preload above. -->
<script id="preloaded" src="preload.js"></script>
<script id="after">
// The preloaded blocking script executed, in document order, before us.
order += 'b';
testing.expectEqual('ab', order);
</script>
<script id="unused_check">
// A preload hint that nothing consumes must never execute.
testing.expectEqual(undefined, window.unused_preload_ran);
</script>
@@ -0,0 +1,2 @@
order += 'a';
testing.expectEqual('a', order);
@@ -0,0 +1,4 @@
// Nothing consumes this preload, so it must never be evaluated. If it runs, the
// flag trips the assertion in preload.html (and this fail() fires directly).
window.unused_preload_ran = true;
testing.fail('an unconsumed <link rel=preload as=script> must not execute');
@@ -228,3 +228,25 @@
testing.expectEqual('<p>hello, world</p>', out.innerHTML);
</script>
<script id=cloneNode_copies_content>
{
const template = $('#basic');
// Per spec, template content is only copied on a deep clone.
const shallow = template.cloneNode(false);
testing.expectEqual(0, shallow.content.childNodes.length);
const deep = template.cloneNode(true);
testing.expectEqual('Hello Template', deep.content.querySelector('h1').textContent);
testing.expectTrue(deep.content.querySelector('h1') !== template.content.querySelector('h1'));
// Cloning an ancestor must also clone nested template content.
const wrapper = document.createElement('div');
const inner = document.createElement('template');
inner.innerHTML = '<span id="in-template">x</span>';
wrapper.appendChild(inner);
const wrapperClone = wrapper.cloneNode(true);
testing.expectEqual('x', wrapperClone.querySelector('template').content.querySelector('#in-template').textContent);
}
</script>
+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=default>
{
const event = new DragEvent('drop');
testing.expectEqual('drop', event.type);
testing.expectEqual(true, event instanceof DragEvent);
testing.expectEqual(true, event instanceof MouseEvent);
testing.expectEqual(true, event instanceof UIEvent);
testing.expectEqual(true, event instanceof Event);
// no dataTransfer supplied -> null
testing.expectEqual(null, event.dataTransfer);
}
</script>
<script id=mouse_inherited>
{
const event = new DragEvent('dragover', { clientX: 5, clientY: 9, button: 0 });
testing.expectEqual(5, event.clientX);
testing.expectEqual(9, event.clientY);
}
</script>
<script id=carries_data_transfer>
{
const dt = new DataTransfer();
dt.items.add(new File(["x"], "drop.txt", { type: "text/plain" }));
const event = new DragEvent('drop', { dataTransfer: dt, bubbles: true });
testing.expectEqual(dt, event.dataTransfer);
testing.expectEqual(1, event.dataTransfer.files.length);
testing.expectEqual("drop.txt", event.dataTransfer.files[0].name);
}
</script>
<script id=dispatched_drop>
{
const dt = new DataTransfer();
dt.items.add(new File(["x"], "drop.txt", { type: "text/plain" }));
const el = document.createElement('div');
let seen = -1;
el.addEventListener('drop', function (e) {
testing.expectEqual(true, e instanceof DragEvent);
seen = e.dataTransfer.files.length;
});
el.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true }));
testing.expectEqual(1, seen);
}
</script>
<script id=refcount_balance>
{
// A DragEvent holds a ref on its DataTransfer so the store outlives the event
// even if the JS wrapper is dropped first. Build many event/store pairs and
// retain none: at teardown every acquire must be matched by a release, or the
// leak-checking test runner fails. Encodes the acquire/release contract that a
// plain "read the files" assertion wouldn't exercise.
let last = 0;
for (let i = 0; i < 25; i++) {
const dt = new DataTransfer();
dt.items.add(new File(["payload"], "f" + i + ".txt", { type: "text/plain" }));
const ev = new DragEvent('drop', { dataTransfer: dt });
last = ev.dataTransfer.files.length;
}
testing.expectEqual(1, last);
}
</script>
@@ -122,3 +122,96 @@
testing.expectError('NotSupportedError', () => sd.attachShadow({ mode: 'open' }));
}
</script>
<script id=attachShadow_options_reflection>
{
const host = document.createElement('div');
const shadow = host.attachShadow({
mode: 'open',
delegatesFocus: true,
slotAssignment: 'manual',
clonable: true,
serializable: true,
});
testing.expectEqual(true, shadow.delegatesFocus);
testing.expectEqual('manual', shadow.slotAssignment);
testing.expectEqual(true, shadow.clonable);
testing.expectEqual(true, shadow.serializable);
const defaults = document.createElement('div').attachShadow({ mode: 'open' });
testing.expectEqual(false, defaults.delegatesFocus);
testing.expectEqual('named', defaults.slotAssignment);
testing.expectEqual(false, defaults.clonable);
testing.expectEqual(false, defaults.serializable);
}
</script>
<script id=attachShadow_declarative_reattach>
{
const wrapper = document.createElement('div');
wrapper.setHTMLUnsafe('<div><template shadowrootmode="open" shadowrootdelegatesfocus><span>declared</span></template></div>');
const host = wrapper.firstElementChild;
const declarative = host.shadowRoot;
testing.expectEqual(true, declarative.delegatesFocus);
testing.expectEqual('declared', declarative.firstElementChild.textContent);
// Mismatched mode throws.
let caught = null;
try { host.attachShadow({ mode: 'closed' }); } catch (e) { caught = e.name; }
testing.expectEqual('NotSupportedError', caught);
// Matching mode returns the same root, emptied.
const reattached = host.attachShadow({ mode: 'open' });
testing.expectEqual(declarative, reattached);
testing.expectEqual(0, reattached.childNodes.length);
// No longer declarative: a second attachShadow now throws.
caught = null;
try { host.attachShadow({ mode: 'open' }); } catch (e) { caught = e.name; }
testing.expectEqual('NotSupportedError', caught);
}
</script>
<script id=clonable_shadow_cloned_with_host>
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open', clonable: true });
shadow.innerHTML = '<input><div><span></span></div>';
// Even a shallow host clone deep-clones a clonable shadow root.
const shallow = host.cloneNode(false);
testing.expectEqual(true, shallow.shadowRoot.clonable);
testing.expectEqual(2, shallow.shadowRoot.children.length);
testing.expectEqual('SPAN', shallow.shadowRoot.children[1].firstElementChild.tagName);
// Non-clonable shadow roots are not cloned.
const plainHost = document.createElement('div');
plainHost.attachShadow({ mode: 'open' });
testing.expectEqual(null, plainHost.cloneNode(true).shadowRoot);
}
</script>
<script id=template_shadowroot_reflection>
{
const t = document.createElement('template');
testing.expectEqual('', t.shadowRootMode);
testing.expectEqual(false, t.shadowRootDelegatesFocus);
t.shadowRootMode = 'open';
testing.expectEqual('open', t.shadowRootMode);
testing.expectEqual('open', t.getAttribute('shadowrootmode'));
// Limited to known values: invalid reads back as "".
t.shadowRootMode = 'blah';
testing.expectEqual('', t.shadowRootMode);
testing.expectEqual('blah', t.getAttribute('shadowrootmode'));
t.shadowRootDelegatesFocus = true;
testing.expectEqual(true, t.hasAttribute('shadowrootdelegatesfocus'));
t.shadowRootClonable = true;
testing.expectEqual(true, t.shadowRootClonable);
t.shadowRootClonable = false;
testing.expectEqual(false, t.hasAttribute('shadowrootclonable'));
testing.expectEqual(false, t.shadowRootSerializable);
}
</script>
+45
View File
@@ -281,3 +281,48 @@
host.remove();
}
</script>
<script id="composedPath_detached_tree_excludes_window">
{
// A detached tree's path ends at its root: no document, no window.
const root = document.createElement('div');
const target = document.createElement('span');
root.appendChild(target);
let capturedPath = null;
root.addEventListener('my-event', (e) => {
capturedPath = e.composedPath();
});
target.dispatchEvent(new Event('my-event', { bubbles: true, composed: true }));
testing.expectEqual(2, capturedPath.length);
testing.expectEqual(target, capturedPath[0]);
testing.expectEqual(root, capturedPath[1]);
}
</script>
<script id="post_dispatch_state">
{
const host = document.createElement('div');
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const target = document.createElement('span');
shadow.appendChild(target);
shadow.addEventListener('my-event', () => {});
// composed: crossing the boundary retargets to the host post-dispatch.
const composed = new Event('my-event', { bubbles: true, composed: true });
target.dispatchEvent(composed);
testing.expectEqual(host, composed.target);
testing.expectEqual(null, composed.currentTarget);
testing.expectEqual(0, composed.eventPhase);
testing.expectEqual(0, composed.composedPath().length);
// non-composed: the event never left the shadow tree, targets are cleared.
const scoped = new Event('my-event', { bubbles: true, composed: false });
target.dispatchEvent(scoped);
testing.expectEqual(null, scoped.target);
host.remove();
}
</script>
+16
View File
@@ -90,3 +90,19 @@
host.remove();
}
</script>
<script id="ownerDocument_in_shadow_of_other_document">
{
const doc = document.implementation.createHTMLDocument('Test');
const host = doc.createElement('div');
doc.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const child = doc.createElement('span');
shadow.appendChild(child);
// Nodes in a shadow tree belong to the host's document, not the
// frame's main document.
testing.expectEqual(doc, child.ownerDocument);
testing.expectEqual(doc, shadow.ownerDocument);
}
</script>
+2 -1
View File
@@ -119,7 +119,8 @@
window.reportError(err);
testing.expectEqual(window, evt.target);
testing.expectEqual(window, evt.currentTarget);
// currentTarget is only set while listeners are invoked; null post-dispatch
testing.expectEqual(null, evt.currentTarget);
}
</script>
+289
View File
@@ -0,0 +1,289 @@
// 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 std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const Page = @import("../Page.zig");
const File = @import("File.zig");
const FileList = @import("FileList.zig");
const DataTransferItem = @import("DataTransferItem.zig");
const DataTransferItemList = @import("DataTransferItemList.zig");
const Allocator = std.mem.Allocator;
// https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface
//
// The canonical drag-data-store: one ordered list of items (string- or
// file-kind). `.items` is a live DataTransferItemList view; `.files` is a
// FileList rebuilt from the file-kind items so the two stay in sync. Per v1
// scope the store is always read/write (no event-phase mode gating).
const DataTransfer = @This();
pub fn registerTypes() []const type {
return &.{
DataTransfer,
DataTransferItem,
DataTransferItemList,
DataTransferItemList.Iterator,
};
}
_arena: Allocator,
// Refcounted so the GC weak-finalizer (or page teardown) releases the pooled
// arena exactly once; mirrors Blob's lifecycle.
_rc: lp.RC(u32) = .{},
_items: std.ArrayList(*DataTransferItem) = .{},
_item_list: *DataTransferItemList,
// FileList lives on the factory slab and is frame-tracked, so each File ref it
// holds is released at frame teardown (same path as `<input type=file>`).
_files: *FileList,
_drop_effect: []const u8 = "none",
_effect_allowed: []const u8 = "uninitialized",
pub fn init(frame: *Frame) !*DataTransfer {
const arena = try frame.getArena(.medium, "DataTransfer");
errdefer frame.releaseArena(arena);
const fl = try frame._factory.create(FileList{});
try frame.trackFileList(fl);
const self = try arena.create(DataTransfer);
const list = try arena.create(DataTransferItemList);
self.* = .{
._arena = arena,
._item_list = list,
._files = fl,
};
list.* = .{ ._data_transfer = self };
return self;
}
pub fn deinit(self: *DataTransfer, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn acquireRef(self: *DataTransfer) void {
self._rc.acquire();
}
pub fn releaseRef(self: *DataTransfer, page: *Page) void {
self._rc.release(self, page);
}
// https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransfer-getdata
// "text" and "url" are shorthands the spec maps onto MIME types.
fn normalizeFormat(arena: Allocator, format: []const u8) ![]const u8 {
if (std.ascii.eqlIgnoreCase(format, "text")) {
return "text/plain";
}
if (std.ascii.eqlIgnoreCase(format, "url")) {
return "text/uri-list";
}
return std.ascii.allocLowerString(arena, format);
}
pub fn getData(self: *const DataTransfer, format: []const u8, frame: *Frame) ![]const u8 {
const norm = try normalizeFormat(frame.call_arena, format);
for (self._items.items) |it| {
if (it._kind == .string and std.mem.eql(u8, it._type, norm)) {
return it._payload.string;
}
}
return "";
}
pub fn setData(self: *DataTransfer, format: []const u8, data: []const u8) !void {
const norm = try normalizeFormat(self._arena, format);
const owned_data = try self._arena.dupe(u8, data);
for (self._items.items) |it| {
if (it._kind == .string and std.mem.eql(u8, it._type, norm)) {
it._payload = .{ .string = owned_data };
return;
}
}
const it = try self._arena.create(DataTransferItem);
it.* = .{ ._data_transfer = self, ._kind = .string, ._type = norm, ._payload = .{ .string = owned_data } };
try self._items.append(self._arena, it);
}
pub fn clearData(self: *DataTransfer, format_: ?[]const u8, frame: *Frame) !void {
if (format_) |format| {
const norm = try normalizeFormat(frame.call_arena, format);
var i: usize = 0;
while (i < self._items.items.len) {
const it = self._items.items[i];
if (it._kind == .string and std.mem.eql(u8, it._type, norm)) {
_ = self._items.orderedRemove(i);
} else {
i += 1;
}
}
return;
}
// No format: remove every string item, leave file items in place.
var i: usize = 0;
while (i < self._items.items.len) {
if (self._items.items[i]._kind == .string) {
_ = self._items.orderedRemove(i);
} else {
i += 1;
}
}
}
// --- DataTransferItemList delegation ---
// add(File) -> file item ; add(DOMString, DOMString) -> string item.
pub fn addItem(self: *DataTransfer, data: js.Value, type_: ?[]const u8, frame: *Frame) !?*DataTransferItem {
if (data.toZig(*File)) |file| {
return try self.addFileItem(file, frame);
} else |_| {}
const owned_data = try data.toStringSliceWithAlloc(self._arena);
const norm = try normalizeFormat(self._arena, type_ orelse "");
const it = try self._arena.create(DataTransferItem);
it.* = .{ ._data_transfer = self, ._kind = .string, ._type = norm, ._payload = .{ .string = owned_data } };
try self._items.append(self._arena, it);
return it;
}
fn addFileItem(self: *DataTransfer, file: *File, frame: *Frame) !*DataTransferItem {
file._proto.acquireRef();
const it = try self._arena.create(DataTransferItem);
it.* = .{ ._data_transfer = self, ._kind = .file, ._type = file._proto.getType(), ._payload = .{ .file = file } };
try self._items.append(self._arena, it);
try self.rebuildFiles(frame);
return it;
}
pub fn removeItem(self: *DataTransfer, index: u32, frame: *Frame) !void {
if (index >= self._items.items.len) {
return;
}
const it = self._items.orderedRemove(index);
if (it._kind == .file) {
it._payload.file._proto.releaseRef(frame._page);
try self.rebuildFiles(frame);
}
}
pub fn clearItems(self: *DataTransfer, frame: *Frame) !void {
for (self._items.items) |it| {
if (it._kind == .file) {
it._payload.file._proto.releaseRef(frame._page);
}
}
self._items.clearRetainingCapacity();
try self.rebuildFiles(frame);
}
// Rebuild the FileList slice from the current file-kind items, in order.
fn rebuildFiles(self: *DataTransfer, frame: *Frame) !void {
var files: std.ArrayList(*File) = .{};
for (self._items.items) |it| {
if (it._kind == .file) {
try files.append(frame.arena, it._payload.file);
}
}
self._files._files = try files.toOwnedSlice(frame.arena);
}
// --- accessors ---
pub fn getFiles(self: *DataTransfer) *FileList {
return self._files;
}
pub fn getItems(self: *DataTransfer) *DataTransferItemList {
return self._item_list;
}
pub fn getTypes(self: *DataTransfer, frame: *Frame) ![][]const u8 {
var out: std.ArrayList([]const u8) = .{};
var has_files = false;
for (self._items.items) |it| {
switch (it._kind) {
.string => try out.append(frame.call_arena, it._type),
.file => has_files = true,
}
}
if (has_files) {
try out.append(frame.call_arena, "Files");
}
return out.toOwnedSlice(frame.call_arena);
}
pub fn getDropEffect(self: *const DataTransfer) []const u8 {
return self._drop_effect;
}
pub fn setDropEffect(self: *DataTransfer, value: []const u8) !void {
inline for (.{ "none", "copy", "link", "move" }) |valid| {
if (std.mem.eql(u8, value, valid)) {
self._drop_effect = valid;
return;
}
}
}
pub fn getEffectAllowed(self: *const DataTransfer) []const u8 {
return self._effect_allowed;
}
pub fn setEffectAllowed(self: *DataTransfer, value: []const u8) !void {
inline for (.{ "none", "copy", "copyLink", "copyMove", "link", "linkMove", "move", "all", "uninitialized" }) |valid| {
if (std.mem.eql(u8, value, valid)) {
self._effect_allowed = valid;
return;
}
}
}
// https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransfer-setdragimage
// No-op: Lightpanda has no rendered drag feedback.
pub fn setDragImage(_: *DataTransfer, _: js.Value, _: i32, _: i32) void {}
pub const JsApi = struct {
pub const bridge = js.Bridge(DataTransfer);
pub const Meta = struct {
pub const name = "DataTransfer";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(DataTransfer.init, .{});
pub const dropEffect = bridge.accessor(DataTransfer.getDropEffect, DataTransfer.setDropEffect, .{});
pub const effectAllowed = bridge.accessor(DataTransfer.getEffectAllowed, DataTransfer.setEffectAllowed, .{});
pub const files = bridge.accessor(DataTransfer.getFiles, null, .{});
pub const items = bridge.accessor(DataTransfer.getItems, null, .{});
pub const types = bridge.accessor(DataTransfer.getTypes, null, .{});
pub const getData = bridge.function(DataTransfer.getData, .{});
pub const setData = bridge.function(DataTransfer.setData, .{});
pub const clearData = bridge.function(DataTransfer.clearData, .{});
pub const setDragImage = bridge.function(DataTransfer.setDragImage, .{});
};
const testing = @import("../../testing.zig");
test "WebApi: DataTransfer" {
try testing.htmlRunner("data_transfer.html", .{});
}
+104
View File
@@ -0,0 +1,104 @@
// 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 std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const File = @import("File.zig");
const Page = @import("../Page.zig");
const DataTransfer = @import("DataTransfer.zig");
const log = lp.log;
// https://html.spec.whatwg.org/multipage/dnd.html#the-datatransferitem-interface
const DataTransferItem = @This();
pub const Kind = enum { string, file };
// The owning DataTransfer. The item lives in that DataTransfer's arena and is
// handed to JS, so it forwards acquireRef/releaseRef to keep the arena alive as
// long as JS holds the item.
_data_transfer: *DataTransfer,
_kind: Kind,
// For string items: the normalized format (e.g. "text/plain").
// For file items: the File's MIME type.
_type: []const u8,
_payload: Payload,
pub const Payload = union(Kind) {
string: []const u8,
file: *File,
};
pub fn acquireRef(self: *DataTransferItem) void {
self._data_transfer.acquireRef();
}
pub fn releaseRef(self: *DataTransferItem, page: *Page) void {
self._data_transfer.releaseRef(page);
}
pub fn getKind(self: *const DataTransferItem) []const u8 {
return switch (self._kind) {
.string => "string",
.file => "file",
};
}
pub fn getType(self: *const DataTransferItem) []const u8 {
return self._type;
}
pub fn getAsFile(self: *const DataTransferItem) ?*File {
return switch (self._payload) {
.file => |f| f,
.string => null,
};
}
// https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransferitem-getasstring
// v1 invokes the callback synchronously with the string value. File items and a
// missing callback are no-ops, per spec.
pub fn getAsString(self: *const DataTransferItem, cb_: ?js.Function) !void {
const cb = cb_ orelse return;
const s = switch (self._payload) {
.string => |str| str,
.file => return,
};
var caught: js.TryCatch.Caught = undefined;
cb.tryCall(void, .{s}, &caught) catch {
log.debug(.js, "getAsString callback", .{ .caught = caught, .source = "DataTransferItem" });
};
}
pub const JsApi = struct {
pub const bridge = js.Bridge(DataTransferItem);
pub const Meta = struct {
pub const name = "DataTransferItem";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const kind = bridge.accessor(DataTransferItem.getKind, null, .{});
pub const @"type" = bridge.accessor(DataTransferItem.getType, null, .{});
pub const getAsFile = bridge.function(DataTransferItem.getAsFile, .{});
pub const getAsString = bridge.function(DataTransferItem.getAsString, .{});
};
+121
View File
@@ -0,0 +1,121 @@
// 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 js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const Page = @import("../Page.zig");
const DataTransfer = @import("DataTransfer.zig");
const DataTransferItem = @import("DataTransferItem.zig");
// https://html.spec.whatwg.org/multipage/dnd.html#the-datatransferitemlist-interface
//
// A live view over the owning DataTransfer's item list; all mutations are
// delegated to the DataTransfer so `.files` stays in sync.
const DataTransferItemList = @This();
_data_transfer: *DataTransfer,
pub fn acquireRef(self: *DataTransferItemList) void {
self._data_transfer.acquireRef();
}
pub fn releaseRef(self: *DataTransferItemList, page: *Page) void {
self._data_transfer.releaseRef(page);
}
pub fn getLength(self: *const DataTransferItemList) u32 {
return @intCast(self._data_transfer._items.items.len);
}
pub fn item(self: *const DataTransferItemList, index: u32) ?*DataTransferItem {
const items = self._data_transfer._items.items;
if (index >= items.len) {
return null;
}
return items[index];
}
// add(DOMString data, DOMString type) | add(File data)
// The overload is resolved by inspecting the first argument: a File yields a
// file item (the `type` argument is ignored), anything else a string item.
pub fn add(self: *DataTransferItemList, data: js.Value, type_: ?[]const u8, frame: *Frame) !?*DataTransferItem {
return self._data_transfer.addItem(data, type_, frame);
}
pub fn remove(self: *DataTransferItemList, index: u32, frame: *Frame) !void {
return self._data_transfer.removeItem(index, frame);
}
pub fn clear(self: *DataTransferItemList, frame: *Frame) !void {
return self._data_transfer.clearItems(frame);
}
pub fn iterator(self: *DataTransferItemList, exec: *const js.Execution) !*Iterator {
return Iterator.init(.{
.index = 0,
.list = self,
}, exec);
}
const GenericIterator = @import("collections/iterator.zig").Entry;
pub const Iterator = GenericIterator(struct {
index: u32,
list: *DataTransferItemList,
pub fn acquireRef(self: *@This()) void {
self.list.acquireRef();
}
pub fn releaseRef(self: *@This(), page: *Page) void {
self.list.releaseRef(page);
}
pub fn next(self: *@This(), _: *const js.Execution) ?*DataTransferItem {
const index = self.index;
const it = self.list.item(index) orelse return null;
self.index = index + 1;
return it;
}
}, null);
pub const JsApi = struct {
pub const bridge = js.Bridge(DataTransferItemList);
pub const Meta = struct {
pub const name = "DataTransferItemList";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const length = bridge.accessor(DataTransferItemList.getLength, null, .{});
pub const add = bridge.function(DataTransferItemList.add, .{});
pub const remove = bridge.function(DataTransferItemList.remove, .{});
pub const clear = bridge.function(DataTransferItemList.clear, .{});
pub const @"[]" = bridge.indexed(DataTransferItemList.item, getIndexes, .{ .null_as_undefined = true });
pub const symbol_iterator = bridge.iterator(DataTransferItemList.iterator, .{});
fn getIndexes(self: *DataTransferItemList, exec: *const js.Execution) !js.Array {
const len = self.getLength();
var arr = exec.js.local.?.newArray(len);
for (0..len) |i| {
_ = try arr.set(@intCast(i), i, .{});
}
return arr;
}
};
+60 -12
View File
@@ -763,10 +763,7 @@ fn isValidShadowHost(self: *const Element) bool {
};
}
pub fn attachShadow(self: *Element, mode_str: String, frame: *Frame) !*ShadowRoot {
if (frame._element_shadow_roots.get(self)) |_| {
return error.NotSupported;
}
pub fn attachShadow(self: *Element, opts: ShadowRoot.AttachOptions, frame: *Frame) !*ShadowRoot {
if (!self.isValidShadowHost()) {
return error.NotSupported;
}
@@ -780,13 +777,20 @@ pub fn attachShadow(self: *Element, mode_str: String, frame: *Frame) !*ShadowRoo
}
}
}
const mode: ShadowRoot.Mode = blk: {
if (mode_str.eql(comptime .wrap("open"))) break :blk .open;
if (mode_str.eql(comptime .wrap("closed"))) break :blk .closed;
return error.InvalidArgument;
};
const shadow_root = try ShadowRoot.init(self, mode, frame);
if (frame._element_shadow_roots.get(self)) |existing| {
// Imperative attachShadow over a declarative shadow root with a matching
// mode empties it and returns the same root. The parser
// (opts.declarative) never replaces an existing root.
if (opts.declarative or !existing._declarative or existing._mode != opts.mode) {
return error.NotSupported;
}
try existing.asNode().replaceChildren(&.{}, frame);
existing._declarative = false;
return existing;
}
const shadow_root = try ShadowRoot.init(self, opts, frame);
try frame._element_shadow_roots.put(frame.arena, self, shadow_root);
return shadow_root;
}
@@ -1459,10 +1463,33 @@ pub fn clone(self: *Element, deep: bool, frame: *Frame) !*Node {
const node = try frame.createElementNS(self._namespace, tag_name, self._attributes);
// Allow element-specific types to copy their runtime state
_ = Element.Build.call(node.as(Element), "cloned", .{ self, node.as(Element), frame }) catch |err| {
_ = Element.Build.call(node.as(Element), "cloned", .{ self, node.as(Element), deep, frame }) catch |err| {
log.err(.dom, "element.clone.failed", .{ .err = err });
};
// Per spec, a clonable shadow root is cloned along with its host — its
// children always deep-cloned, even when the host clone is shallow.
if (frame._element_shadow_roots.get(self)) |shadow| {
if (shadow._clonable) {
const cloned_shadow = node.as(Element).attachShadow(.{
.mode = shadow._mode,
.clonable = true,
.delegates_focus = shadow._delegates_focus,
.slot_assignment = shadow._slot_assignment,
.serializable = shadow._serializable,
.declarative = shadow._declarative,
}, frame) catch return error.CloneError;
const cloned_shadow_node = cloned_shadow.asNode();
var shadow_child_it = shadow.asNode().childrenIterator();
while (shadow_child_it.next()) |child| {
if (try child.cloneNodeForAppending(true, frame)) |cloned_child| {
try frame.appendNode(cloned_shadow_node, cloned_child, .{ .child_already_connected = true });
}
}
}
}
if (deep) {
var child_it = self.asNode().childrenIterator();
while (child_it.next()) |child| {
@@ -1915,9 +1942,30 @@ pub const JsApi = struct {
const ShadowRootInit = struct {
mode: String,
delegatesFocus: bool = false,
slotAssignment: ?String = null,
clonable: bool = false,
serializable: bool = false,
};
fn _attachShadow(self: *Element, init: ShadowRootInit, frame: *Frame) !*ShadowRoot {
return self.attachShadow(init.mode, frame);
const mode: ShadowRoot.Mode = blk: {
if (init.mode.eql(comptime .wrap("open"))) break :blk .open;
if (init.mode.eql(comptime .wrap("closed"))) break :blk .closed;
return error.InvalidArgument;
};
const slot_assignment: ShadowRoot.SlotAssignment = blk: {
const sa = init.slotAssignment orelse break :blk .named;
if (sa.eql(comptime .wrap("named"))) break :blk .named;
if (sa.eql(comptime .wrap("manual"))) break :blk .manual;
return error.InvalidArgument;
};
return self.attachShadow(.{
.mode = mode,
.delegates_focus = init.delegatesFocus,
.slot_assignment = slot_assignment,
.clonable = init.clonable,
.serializable = init.serializable,
}, frame);
}
pub const replaceChildren = bridge.function(Element.replaceChildren, .{ .dom_exception = true, .ce_reactions = true });
pub const replaceWith = bridge.function(Element.replaceWith, .{ .dom_exception = true, .ce_reactions = true });
+17 -9
View File
@@ -330,15 +330,23 @@ pub fn composedPath(self: *Event, exec: *Execution) ![]const *EventTarget {
node = n._parent;
}
// Add window at the end (unless we stopped at shadow boundary)
if (!stopped_at_shadow_boundary) {
if (path_len < path_buffer.len) {
switch (exec.js.global) {
.worker => {},
.frame => |frame| {
path_buffer[path_len] = frame.window.asEventTarget();
path_len += 1;
},
// Add window at the end. It only participates when propagation did not stop
// at a shadow boundary...
if (stopped_at_shadow_boundary == false) {
// ... AND when the tree's root is a document
const root_is_document = path_len > 0 and switch (path_buffer[path_len - 1]._type) {
.node => |n| n._type == .document,
else => false,
};
if (root_is_document) {
if (path_len < path_buffer.len) {
switch (exec.js.global) {
.worker => {},
.frame => |frame| {
path_buffer[path_len] = frame.window.asEventTarget();
path_len += 1;
},
}
}
}
}
+38 -36
View File
@@ -47,7 +47,8 @@ _root: ?*Element = null,
_root_margin: []const u8 = "0px",
_threshold: []const f64 = &.{0.0},
_pending_entries: std.ArrayList(*IntersectionObserverEntry) = .{},
_previous_states: std.AutoHashMapUnmanaged(*Element, bool) = .{},
// tracked targets that aren't reported yet
_tracked: std.AutoHashMapUnmanaged(*Element, void) = .{},
// Shared zero DOMRect to avoid repeated allocations for non-intersecting elements
var zero_rect: DOMRect = .{
@@ -140,8 +141,7 @@ pub fn observe(self: *IntersectionObserver, target: *Element, frame: *Frame) !vo
try frame.registerIntersectionObserver(self);
}
// Don't initialize previous state yet - let checkIntersection do it
// This ensures we get an entry on first observation
try self._tracked.put(self._arena, target, {});
// Check intersection for this new target and schedule delivery
try self.checkIntersection(target, frame);
@@ -155,7 +155,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, frame: *Frame) v
for (self._observing.items, 0..) |elem, i| {
if (elem == target) {
_ = self._observing.swapRemove(i);
_ = self._previous_states.remove(target);
_ = self._tracked.remove(target);
// Remove any pending entries for this target.
// Entries will be cleaned up by V8 GC via the finalizer.
@@ -182,7 +182,7 @@ pub fn disconnect(self: *IntersectionObserver, frame: *Frame) void {
entry.deinit(frame._page);
}
self._pending_entries.clearRetainingCapacity();
self._previous_states.clearRetainingCapacity();
self._tracked.clearRetainingCapacity();
if (self._observing.items.len > 0) {
frame.unregisterIntersectionObserver(self);
@@ -199,6 +199,7 @@ pub fn takeRecords(self: *IntersectionObserver, frame: *Frame) ![]*IntersectionO
fn calculateIntersection(
self: *IntersectionObserver,
target: *Element,
has_parent: bool,
frame: *Frame,
) !IntersectionData {
const target_rect = target.getBoundingClientRect(frame);
@@ -219,15 +220,13 @@ fn calculateIntersection(
// This avoids fingerprinting issues (massive viewports) and matches the behavior
// scripts expect when querying element visibility.
// However, elements without a parent cannot intersect (they have no containing block).
const has_parent = target.asNode().parentNode() != null;
const is_intersecting = has_parent;
const intersection_ratio: f64 = if (has_parent) 1.0 else 0.0;
// Intersection rect is the same as the target rect if visible, otherwise zero rect
const intersection_rect = if (has_parent) target_rect else zero_rect;
return .{
.is_intersecting = is_intersecting,
.is_intersecting = has_parent,
.intersection_ratio = intersection_ratio,
.intersection_rect = intersection_rect,
.bounding_client_rect = target_rect,
@@ -253,37 +252,40 @@ fn meetsThreshold(self: *IntersectionObserver, ratio: f64) bool {
}
fn checkIntersection(self: *IntersectionObserver, target: *Element, frame: *Frame) !void {
const data = try self.calculateIntersection(target, frame);
const was_intersecting_opt = self._previous_states.get(target);
const is_now_intersecting = data.is_intersecting and self.meetsThreshold(data.intersection_ratio);
const tracked = self._tracked.getEntry(target) orelse {
// If target isn't tracked then it was already reported. This is an
// important optimization which lets us skip the heavy work below.
// This function can be called a lot.
return;
};
// Create entry if:
// 1. First time observing this target AND it's intersecting
// 2. State changed
const should_report = (was_intersecting_opt == null and is_now_intersecting) or
(was_intersecting_opt != null and was_intersecting_opt.? != is_now_intersecting);
if (should_report) {
const arena = try frame.getArena(.tiny, "IntersectionObserverEntry");
errdefer frame.releaseArena(arena);
const entry = try arena.create(IntersectionObserverEntry);
entry.* = .{
._arena = arena,
._target = target,
._time = frame.window._performance.now(),
._is_intersecting = is_now_intersecting,
._root_bounds = try frame._factory.create(data.root_bounds),
._intersection_rect = try frame._factory.create(data.intersection_rect),
._bounding_client_rect = try frame._factory.create(data.bounding_client_rect),
._intersection_ratio = data.intersection_ratio,
};
try self._pending_entries.append(self._arena, entry);
const has_parent = target.asNode().parentNode() != null;
const is_now_intersecting = has_parent and self.meetsThreshold(1.0);
if (!is_now_intersecting) {
// Not intersecting yet (e.g. observed while still orphaned) — keep
// tracking so a later attach is reported.
return;
}
// Always update the previous state, even if we didn't report
// This ensures we can detect state changes on subsequent checks
try self._previous_states.put(self._arena, target, is_now_intersecting);
// Building the entry is the expensive part — getBoundingClientRect walks the
// document to fake a position (O(node count)) — so it only runs here.
const data = try self.calculateIntersection(target, has_parent, frame);
const arena = try frame.getArena(.tiny, "IntersectionObserverEntry");
errdefer frame.releaseArena(arena);
const entry = try arena.create(IntersectionObserverEntry);
entry.* = .{
._arena = arena,
._target = target,
._time = frame.window._performance.now(),
._is_intersecting = is_now_intersecting,
._root_bounds = try frame._factory.create(data.root_bounds),
._intersection_rect = try frame._factory.create(data.intersection_rect),
._bounding_client_rect = try frame._factory.create(data.bounding_client_rect),
._intersection_ratio = data.intersection_ratio,
};
try self._pending_entries.append(self._arena, entry);
_ = self._tracked.removeByPtr(tracked.key_ptr);
}
pub fn checkIntersections(self: *IntersectionObserver, frame: *Frame) !void {
+11 -1
View File
@@ -479,7 +479,7 @@ pub fn getRootNode(self: *Node, opts_: ?GetRootNodeOpts) *Node {
// If composed is true, traverse through shadow boundaries
if (opts.composed) {
while (true) {
const shadow_root = @constCast(root).is(ShadowRoot) orelse break;
const shadow_root = root.is(ShadowRoot) orelse break;
root = shadow_root.getHost().asNode();
while (root._parent) |parent| {
root = parent;
@@ -525,6 +525,16 @@ pub fn ownerDocument(self: *const Node, frame: *const Frame) ?*Document {
return current._type.document;
}
// A shadow tree's root is a parent-less ShadowRoot fragment; its owner
// is the host's owner document.
// can't use current.is(ShadowRoot) without @constCast on `current`
if (current._type == .document_fragment) {
const df = current._type.document_fragment;
if (df._type == .shadow_root) {
return df._type.shadow_root._host.asNode().ownerDocument(frame);
}
}
// Otherwise, this is a detached node. Check if it has a specific owner
// document registered (for nodes created via non-main documents).
if (frame._node_owner_documents.get(@constCast(self))) |owner| {
+46 -2
View File
@@ -31,18 +31,42 @@ pub const Mode = enum {
closed,
};
pub const SlotAssignment = enum {
named,
manual,
};
pub const AttachOptions = struct {
mode: Mode,
delegates_focus: bool = false,
slot_assignment: SlotAssignment = .named,
clonable: bool = false,
serializable: bool = false,
declarative: bool = false,
};
_proto: *DocumentFragment,
_mode: Mode,
_host: *Element,
_delegates_focus: bool,
_slot_assignment: SlotAssignment,
_clonable: bool,
_serializable: bool,
_declarative: bool,
_elements_by_id: std.StringHashMapUnmanaged(*Element) = .{},
_removed_ids: std.StringHashMapUnmanaged(void) = .{},
_adopted_style_sheets: ?js.Object.Global = null,
pub fn init(host: *Element, mode: Mode, frame: *Frame) !*ShadowRoot {
pub fn init(host: *Element, opts: AttachOptions, frame: *Frame) !*ShadowRoot {
return frame._factory.documentFragment(ShadowRoot{
._proto = undefined,
._mode = mode,
._mode = opts.mode,
._host = host,
._delegates_focus = opts.delegates_focus,
._slot_assignment = opts.slot_assignment,
._clonable = opts.clonable,
._serializable = opts.serializable,
._declarative = opts.declarative,
});
}
@@ -66,6 +90,22 @@ pub fn getHost(self: *const ShadowRoot) *Element {
return self._host;
}
pub fn getDelegatesFocus(self: *const ShadowRoot) bool {
return self._delegates_focus;
}
pub fn getSlotAssignment(self: *const ShadowRoot) []const u8 {
return @tagName(self._slot_assignment);
}
pub fn getClonable(self: *const ShadowRoot) bool {
return self._clonable;
}
pub fn getSerializable(self: *const ShadowRoot) bool {
return self._serializable;
}
pub fn setHTMLUnsafe(self: *ShadowRoot, html: []const u8, frame: *Frame) !void {
return self.asDocumentFragment().setHTMLUnsafe(html, frame);
}
@@ -125,6 +165,10 @@ pub const JsApi = struct {
pub const mode = bridge.accessor(ShadowRoot.getMode, null, .{});
pub const host = bridge.accessor(ShadowRoot.getHost, null, .{});
pub const delegatesFocus = bridge.accessor(ShadowRoot.getDelegatesFocus, null, .{});
pub const slotAssignment = bridge.accessor(ShadowRoot.getSlotAssignment, null, .{});
pub const clonable = bridge.accessor(ShadowRoot.getClonable, null, .{});
pub const serializable = bridge.accessor(ShadowRoot.getSerializable, null, .{});
pub const getElementById = bridge.function(_getElementById, .{});
fn _getElementById(self: *ShadowRoot, value_: ?js.Value, frame: *Frame) !?*Element {
const value = value_ orelse return null;
+2 -1
View File
@@ -1525,7 +1525,8 @@ pub const Build = struct {
}
}
pub fn cloned(source_element: *Element, cloned_element: *Element, _: *Frame) !void {
pub fn cloned(source_element: *Element, cloned_element: *Element, deep: bool, _: *Frame) !void {
_ = deep;
const source = source_element.as(Input);
const clone = cloned_element.as(Input);
+24 -10
View File
@@ -195,21 +195,13 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void {
const element = self.asElement();
const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return;
const loadable_rels = std.StaticStringMap(void).initComptime(.{
.{ "stylesheet", {} },
.{ "preload", {} },
.{ "modulepreload", {} },
});
if (loadable_rels.has(rel) == false) {
return;
}
const href = element.getAttributeSafe(comptime .wrap("href")) orelse return;
if (href.len == 0) {
return;
}
const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return;
// Opt-in fetch for `rel="stylesheet"` — drives `frame.loadExternalStylesheet`,
// which fires the load/error event itself. Other rels (preload,
// modulepreload) and the disabled case keep the rendering-free stub that
@@ -218,6 +210,28 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void {
return frame.loadExternalStylesheet(self, href);
}
var queue_load = false;
if (std.mem.eql(u8, rel, "preload")) {
const as = element.getAttributeSafe(comptime .wrap("as")) orelse "";
if (std.ascii.eqlIgnoreCase(as, "script")) {
frame.preloadScriptHint(href);
}
queue_load = true;
}
{
// this block just means we don't need to re-check rel for a type we
// already processed, e.g. "preload"
const loadable_rels = std.StaticStringMap(void).initComptime(.{
.{ "stylesheet", {} },
.{ "modulepreload", {} },
});
if (queue_load == false and loadable_rels.has(rel) == false) {
return;
}
}
try frame.queueLoad(self._proto);
}
+2 -1
View File
@@ -154,7 +154,8 @@ pub const Build = struct {
// Per the HTML spec, the "already started" flag must be propagated to the
// clone so that re-inserting a cloned <script> doesn't run it again.
pub fn cloned(source_element: *Element, cloned_element: *Element, _: *Frame) !void {
pub fn cloned(source_element: *Element, cloned_element: *Element, deep: bool, _: *Frame) !void {
_ = deep;
const source = source_element.as(Script);
const clone = cloned_element.as(Script);
clone._executed = source._executed;
+99 -1
View File
@@ -1,12 +1,35 @@
// 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 std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
const DocumentFragment = @import("../../DocumentFragment.zig");
const HtmlElement = @import("../Html.zig");
const String = lp.String;
const Template = @This();
_proto: *HtmlElement,
@@ -15,6 +38,10 @@ _content: *DocumentFragment,
pub fn asElement(self: *Template) *Element {
return self._proto._proto;
}
pub fn asConstElement(self: *const Template) *const Element {
return self._proto._proto;
}
pub fn asNode(self: *Template) *Node {
return self.asElement().asNode();
}
@@ -27,6 +54,36 @@ pub fn setInnerHTML(self: *Template, html: []const u8, frame: *Frame) !void {
return self._content.setInnerHTML(html, frame);
}
pub fn getShadowRootMode(self: *const Template) []const u8 {
const value = self.asConstElement().getAttributeSafe(.wrap("shadowrootmode")) orelse return "";
if (std.ascii.eqlIgnoreCase(value, "open")) {
return "open";
}
if (std.ascii.eqlIgnoreCase(value, "closed")) {
return "closed";
}
return "";
}
pub fn setShadowRootMode(self: *Template, value: []const u8, frame: *Frame) !void {
try self.asElement().setAttributeSafe(.wrap("shadowrootmode"), .wrap(value), frame);
}
fn getBoolAttribute(self: *const Template, name: String) bool {
return self.asConstElement().getAttributeSafe(name) != null;
}
fn setBoolAttribute(self: *Template, name: String, value: bool, frame: *Frame) !void {
if (value) {
try self.asElement().setAttributeSafe(name, .wrap(""), frame);
} else {
try self.asElement().removeAttribute(name, frame);
}
}
pub fn getOuterHTML(self: *Template, writer: *std.Io.Writer, frame: *Frame) !void {
const dump = @import("../../../dump.zig");
const el = self.asElement();
@@ -50,6 +107,29 @@ pub const JsApi = struct {
pub const content = bridge.accessor(Template.getContent, null, .{});
pub const innerHTML = bridge.accessor(_getInnerHTML, Template.setInnerHTML, .{ .ce_reactions = true });
pub const outerHTML = bridge.accessor(_getOuterHTML, null, .{});
pub const shadowRootMode = bridge.accessor(Template.getShadowRootMode, Template.setShadowRootMode, .{ .ce_reactions = true });
pub const shadowRootDelegatesFocus = bridge.accessor(_getShadowRootDelegatesFocus, _setShadowRootDelegatesFocus, .{ .ce_reactions = true });
pub const shadowRootClonable = bridge.accessor(_getShadowRootClonable, _setShadowRootClonable, .{ .ce_reactions = true });
pub const shadowRootSerializable = bridge.accessor(_getShadowRootSerializable, _setShadowRootSerializable, .{ .ce_reactions = true });
fn _getShadowRootDelegatesFocus(self: *const Template) bool {
return self.getBoolAttribute(.wrap("shadowrootdelegatesfocus"));
}
fn _setShadowRootDelegatesFocus(self: *Template, value: bool, frame: *Frame) !void {
try self.setBoolAttribute(.wrap("shadowrootdelegatesfocus"), value, frame);
}
fn _getShadowRootClonable(self: *const Template) bool {
return self.getBoolAttribute(.wrap("shadowrootclonable"));
}
fn _setShadowRootClonable(self: *Template, value: bool, frame: *Frame) !void {
try self.setBoolAttribute(.wrap("shadowrootclonable"), value, frame);
}
fn _getShadowRootSerializable(self: *const Template) bool {
return self.getBoolAttribute(.wrap("shadowrootserializable"));
}
fn _setShadowRootSerializable(self: *Template, value: bool, frame: *Frame) !void {
try self.setBoolAttribute(.wrap("shadowrootserializable"), value, frame);
}
fn _getInnerHTML(self: *Template, frame: *Frame) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(frame.call_arena);
@@ -70,6 +150,24 @@ pub const Build = struct {
// Create the template content DocumentFragment
self._content = try DocumentFragment.init(frame);
}
// Per the HTML spec's cloning steps for <template>, a deep clone must
// also copy the content fragment (the element itself has no childNodes,
// so the generic deep-clone loop won't do it).
pub fn cloned(source_element: *Element, cloned_element: *Element, deep: bool, frame: *Frame) !void {
if (!deep) {
return;
}
const source = source_element.as(Template);
const clone = cloned_element.as(Template);
const clone_content = clone._content.asNode();
var child_it = source._content.asNode().childrenIterator();
while (child_it.next()) |child| {
if (try child.cloneNodeForAppending(true, frame)) |cloned_child| {
try frame.appendNode(clone_content, cloned_child, .{ .child_already_connected = true });
}
}
}
};
const testing = @import("../../../../testing.zig");
+2 -1
View File
@@ -421,7 +421,8 @@ pub const JsApi = struct {
};
pub const Build = struct {
pub fn cloned(source_element: *Element, cloned_element: *Element, _: *Frame) !void {
pub fn cloned(source_element: *Element, cloned_element: *Element, deep: bool, _: *Frame) !void {
_ = deep;
const source = source_element.as(TextArea);
const clone = cloned_element.as(TextArea);
clone._value = source._value;
+134
View File
@@ -0,0 +1,134 @@
// 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 std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Page = @import("../../Page.zig");
const Event = @import("../Event.zig");
const MouseEvent = @import("MouseEvent.zig");
const DataTransfer = @import("../DataTransfer.zig");
const String = lp.String;
const DragEvent = @This();
_proto: *MouseEvent,
_data_transfer: ?*DataTransfer,
pub const DragEventOptions = struct {
dataTransfer: ?*DataTransfer = null,
};
pub const Options = Event.inheritOptions(DragEvent, DragEventOptions);
pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*DragEvent {
return initWithTrusted(typ, _opts, false, frame);
}
pub fn initTrusted(typ: []const u8, _opts: ?Options, frame: *Frame) !*DragEvent {
return initWithTrusted(typ, _opts, true, frame);
}
fn initWithTrusted(typ: []const u8, _opts: ?Options, trusted: bool, frame: *Frame) !*DragEvent {
const arena = try frame.getArena(.medium, "DragEvent");
errdefer frame.releaseArena(arena);
const type_string = try String.init(arena, typ, .{});
const opts = _opts orelse Options{};
const event = try frame._factory.mouseEvent(
arena,
type_string,
MouseEvent{
._type = .{ .drag_event = undefined },
._proto = undefined,
._screen_x = opts.screenX,
._screen_y = opts.screenY,
._client_x = opts.clientX,
._client_y = opts.clientY,
._ctrl_key = opts.ctrlKey,
._shift_key = opts.shiftKey,
._alt_key = opts.altKey,
._meta_key = opts.metaKey,
._button = std.meta.intToEnum(MouseEvent.MouseButton, opts.button) catch return error.TypeError,
._buttons = opts.buttons,
._related_target = opts.relatedTarget,
},
DragEvent{
._proto = undefined,
._data_transfer = opts.dataTransfer,
},
);
Event.populatePrototypes(event, opts, trusted);
// Hold a ref on the DataTransfer so its arena outlives this event even if the
// JS wrapper is collected first; released in deinit (mirrors MessageEvent's
// Blob handling). The shared refcount lives on the Event base, so reach it
// through asEvent() rather than the immediate _proto.
if (opts.dataTransfer) |dt| {
dt.acquireRef();
}
return event;
}
pub fn deinit(self: *DragEvent, page: *Page) void {
if (self._data_transfer) |dt| {
dt.releaseRef(page);
}
self.asEvent().deinit(page);
}
pub fn acquireRef(self: *DragEvent) void {
self.asEvent().acquireRef();
}
pub fn releaseRef(self: *DragEvent, page: *Page) void {
self.asEvent()._rc.release(self, page);
}
pub fn asEvent(self: *DragEvent) *Event {
return self._proto.asEvent();
}
pub fn getDataTransfer(self: *const DragEvent) ?*DataTransfer {
return self._data_transfer;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(DragEvent);
pub const Meta = struct {
pub const name = "DragEvent";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(DragEvent.init, .{});
pub const dataTransfer = bridge.accessor(DragEvent.getDataTransfer, null, .{});
};
const testing = @import("../../../testing.zig");
test "WebApi: DragEvent" {
try testing.htmlRunner("event/drag.html", .{});
}
+32 -1
View File
@@ -21,9 +21,11 @@ const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Page = @import("../../Page.zig");
const Event = @import("../Event.zig");
const UIEvent = @import("UIEvent.zig");
const DataTransfer = @import("../DataTransfer.zig");
const String = lp.String;
const Allocator = std.mem.Allocator;
@@ -32,12 +34,13 @@ const InputEvent = @This();
_proto: *UIEvent,
_data: ?[]const u8,
// TODO: add dataTransfer
_data_transfer: ?*DataTransfer = null,
_input_type: []const u8,
_is_composing: bool,
pub const InputEventOptions = struct {
data: ?[]const u8 = null,
dataTransfer: ?*DataTransfer = null,
inputType: ?[]const u8 = null,
isComposing: bool = false,
};
@@ -69,6 +72,7 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool
InputEvent{
._proto = undefined,
._data = if (opts.data) |d| try arena.dupe(u8, d) else null,
._data_transfer = opts.dataTransfer,
._input_type = if (opts.inputType) |it| try arena.dupe(u8, it) else "",
._is_composing = opts.isComposing,
},
@@ -82,9 +86,31 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool
rootevt._cancelable = false;
rootevt._composed = true;
// Hold a ref on the DataTransfer (when present) for this event's lifetime;
// released in deinit. Almost always null for input events, but keeps the
// refcount protocol intact when it isn't.
if (opts.dataTransfer) |dt| {
dt.acquireRef();
}
return event;
}
pub fn deinit(self: *InputEvent, page: *Page) void {
if (self._data_transfer) |dt| {
dt.releaseRef(page);
}
self.asEvent().deinit(page);
}
pub fn acquireRef(self: *InputEvent) void {
self.asEvent().acquireRef();
}
pub fn releaseRef(self: *InputEvent, page: *Page) void {
self.asEvent()._rc.release(self, page);
}
pub fn asEvent(self: *InputEvent) *Event {
return self._proto.asEvent();
}
@@ -93,6 +119,10 @@ pub fn getData(self: *const InputEvent) ?[]const u8 {
return self._data;
}
pub fn getDataTransfer(self: *const InputEvent) ?*DataTransfer {
return self._data_transfer;
}
pub fn getInputType(self: *const InputEvent) []const u8 {
return self._input_type;
}
@@ -112,6 +142,7 @@ pub const JsApi = struct {
pub const constructor = bridge.constructor(InputEvent.init, .{});
pub const data = bridge.accessor(InputEvent.getData, null, .{});
pub const dataTransfer = bridge.accessor(InputEvent.getDataTransfer, null, .{});
pub const inputType = bridge.accessor(InputEvent.getInputType, null, .{});
pub const isComposing = bridge.accessor(InputEvent.getIsComposing, null, .{});
};
+3 -1
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -45,6 +45,7 @@ pub const Type = union(enum) {
generic,
pointer_event: *PointerEvent,
wheel_event: *@import("WheelEvent.zig"),
drag_event: *@import("DragEvent.zig"),
};
_type: Type,
@@ -135,6 +136,7 @@ pub fn is(self: *MouseEvent, comptime T: type) ?*T {
.generic => return if (T == MouseEvent) self else null,
.pointer_event => |e| return if (T == PointerEvent) e else null,
.wheel_event => |e| return if (T == @import("WheelEvent.zig")) e else null,
.drag_event => |e| return if (T == @import("DragEvent.zig")) e else null,
}
return null;
}
+190 -82
View File
@@ -60,7 +60,7 @@ pub var sink: ?*const fn (bytes: []const u8) void = null;
// synchronizes access to last_log
var last_log_lock: Thread.Mutex = .{};
pub fn enabled(comptime scope: Scope, level: Level) bool {
pub fn enabled(scope: Scope, level: Level) bool {
if (@intFromEnum(level) < @intFromEnum(opts.level)) {
return false;
}
@@ -96,27 +96,27 @@ pub const Format = enum {
pretty,
};
pub fn debug(comptime scope: Scope, comptime msg: []const u8, data: anytype) void {
pub fn debug(scope: Scope, msg: []const u8, data: anytype) void {
log(scope, .debug, msg, data);
}
pub fn info(comptime scope: Scope, comptime msg: []const u8, data: anytype) void {
pub fn info(scope: Scope, msg: []const u8, data: anytype) void {
log(scope, .info, msg, data);
}
pub fn warn(comptime scope: Scope, comptime msg: []const u8, data: anytype) void {
pub fn warn(scope: Scope, msg: []const u8, data: anytype) void {
log(scope, .warn, msg, data);
}
pub fn err(comptime scope: Scope, comptime msg: []const u8, data: anytype) void {
pub fn err(scope: Scope, msg: []const u8, data: anytype) void {
log(scope, .err, msg, data);
}
pub fn fatal(comptime scope: Scope, comptime msg: []const u8, data: anytype) void {
pub fn fatal(scope: Scope, msg: []const u8, data: anytype) void {
log(scope, .fatal, msg, data);
}
pub fn log(comptime scope: Scope, level: Level, comptime msg: []const u8, data: anytype) void {
pub fn log(scope: Scope, level: Level, msg: []const u8, data: anytype) void {
if (enabled(scope, level) == false) {
return;
}
@@ -144,41 +144,57 @@ pub fn log(comptime scope: Scope, level: Level, comptime msg: []const u8, data:
};
}
fn logTo(comptime scope: Scope, level: Level, comptime msg: []const u8, data: anytype, out: *std.Io.Writer) !void {
comptime {
// Converts each field of `data` into a runtime Value so that a single copy of
// the formatting code (logToErased and below) can do the actual writing.
fn logTo(scope: Scope, level: Level, msg: []const u8, data: anytype, out: *std.Io.Writer) !void {
const fields = @typeInfo(@TypeOf(data)).@"struct".fields;
var kvs: [fields.len]KV = undefined;
inline for (fields, 0..) |f, i| {
const value = @field(data, f.name);
kvs[i] = .{ .key = f.name, .value = Value.init(&value) };
}
return logToErased(scope, level, msg, &kvs, out);
}
fn logToErased(scope: Scope, level: Level, msg: []const u8, kvs: []const KV, out: *std.Io.Writer) !void {
if (builtin.mode == .Debug) {
if (msg.len > 30) {
@compileError("log msg cannot be more than 30 characters: '" ++ msg ++ "'");
std.debug.print("debug-only-panic: log msg cannot be more than 30 characters: {s}", .{msg});
@panic("invalid log msg");
}
for (msg) |b| {
switch (b) {
'A'...'Z', 'a'...'z', ' ', '0'...'9', '_', '-', '.', '{', '}' => {},
else => @compileError("log msg contains an invalid character '" ++ msg ++ "'"),
else => {
std.debug.print("debug-only-panic: log msg contains an invalid character: {s}", .{msg});
@panic("invalid log msg");
},
}
}
}
switch (opts.format) {
.logfmt => try logLogfmt(scope, level, msg, data, out),
.pretty => try logPretty(scope, level, msg, data, out),
.logfmt => try logLogfmt(scope, level, msg, kvs, out),
.pretty => try logPretty(scope, level, msg, kvs, out),
}
out.flush() catch return;
}
fn logLogfmt(comptime scope: Scope, level: Level, comptime msg: []const u8, data: anytype, writer: *std.Io.Writer) !void {
fn logLogfmt(scope: Scope, level: Level, msg: []const u8, kvs: []const KV, writer: *std.Io.Writer) !void {
try logLogFmtPrefix(scope, level, msg, writer);
inline for (@typeInfo(@TypeOf(data)).@"struct".fields) |f| {
const value = @field(data, f.name);
if (std.meta.hasMethod(@TypeOf(value), "logFmt")) {
try value.logFmt(f.name, LogFormatWriter{ .writer = writer });
} else {
const key = " " ++ f.name ++ "=";
try writer.writeAll(key);
try writeValue(.logfmt, value, writer);
for (kvs) |kv| {
switch (kv.value) {
// logFmt implementations write their own complete " key=value" pairs
.log_fmt => |f| try f.logFmtFn(f.ptr, kv.key, writer),
else => {
try writer.print(" {s}=", .{kv.key});
try writeErased(.logfmt, kv.value, writer);
},
}
}
try writer.writeByte('\n');
}
fn logLogFmtPrefix(comptime scope: Scope, level: Level, comptime msg: []const u8, writer: *std.Io.Writer) !void {
fn logLogFmtPrefix(scope: Scope, level: Level, msg: []const u8, writer: *std.Io.Writer) !void {
try writer.writeAll("$time=");
try writer.print("{d}", .{timestamp(.clock)});
@@ -188,30 +204,23 @@ fn logLogFmtPrefix(comptime scope: Scope, level: Level, comptime msg: []const u8
try writer.writeAll(" $level=");
try writer.writeAll(if (level == .err) "error" else @tagName(level));
const full_msg = comptime blk: {
// only wrap msg in quotes if it contains a space
const prefix = " $msg=";
if (std.mem.indexOfScalar(u8, msg, ' ') == null) {
break :blk prefix ++ msg;
}
break :blk prefix ++ "\"" ++ msg ++ "\"";
};
try writer.writeAll(full_msg);
try writer.writeAll(" $msg=\"");
try writer.writeAll(msg);
try writer.writeByte('"');
}
fn logPretty(comptime scope: Scope, level: Level, comptime msg: []const u8, data: anytype, writer: *std.Io.Writer) !void {
fn logPretty(scope: Scope, level: Level, msg: []const u8, kvs: []const KV, writer: *std.Io.Writer) !void {
try logPrettyPrefix(scope, level, msg, writer);
inline for (@typeInfo(@TypeOf(data)).@"struct".fields) |f| {
const key = " " ++ f.name ++ " = ";
try writer.writeAll(key);
try writeValue(.pretty, @field(data, f.name), writer);
for (kvs) |kv| {
try writer.print(" {s} = ", .{kv.key});
try writeErased(.pretty, kv.value, writer);
try writer.writeByte('\n');
}
try writer.writeByte('\n');
}
fn logPrettyPrefix(comptime scope: Scope, level: Level, comptime msg: []const u8, writer: *std.Io.Writer) !void {
if (scope == .console and level == .fatal and comptime std.mem.eql(u8, msg, "lightpanda")) {
fn logPrettyPrefix(scope: Scope, level: Level, msg: []const u8, writer: *std.Io.Writer) !void {
if (scope == .console and level == .fatal) {
try writer.writeAll("\x1b[0;104mWARN ");
} else {
try writer.writeAll(switch (level) {
@@ -223,13 +232,15 @@ fn logPrettyPrefix(comptime scope: Scope, level: Level, comptime msg: []const u8
});
}
const prefix = @tagName(scope) ++ " : " ++ msg;
try writer.writeAll(prefix);
try writer.writeAll(@tagName(scope));
try writer.writeAll(" : ");
try writer.writeAll(msg);
{
// msg.len cannot be > 30, and @tagName(scope).len cannot be > 15
// so this is safe
const padding = 55 - prefix.len;
const prefix_len = @tagName(scope).len + msg.len + 2;
const padding = 55 - prefix_len;
for (0..padding / 2) |_| {
try writer.writeAll(" .");
}
@@ -242,50 +253,147 @@ fn logPrettyPrefix(comptime scope: Scope, level: Level, comptime msg: []const u8
}
}
pub fn writeValue(comptime format: Format, value: anytype, writer: *std.Io.Writer) !void {
const T = @TypeOf(value);
if (std.meta.hasMethod(T, "format")) {
return writer.print("{f}", .{value});
}
const KV = struct {
key: []const u8,
value: Value,
};
switch (@typeInfo(T)) {
.optional => {
if (value) |v| {
return writeValue(format, v, writer);
}
return writer.writeAll("null");
},
.comptime_int, .int, .comptime_float, .float => {
return writer.print("{d}", .{value});
},
.bool => {
return writer.writeAll(if (value) "true" else "false");
},
.error_set => return writer.writeAll(@errorName(value)),
.@"enum" => return writer.writeAll(@tagName(value)),
.array => return writeValue(format, &value, writer),
.pointer => |ptr| switch (ptr.size) {
.slice => switch (ptr.child) {
u8 => return writeString(format, value, writer),
const Value = union(enum) {
null,
string: []const u8,
int: i64,
uint: u64,
float32: f32,
float64: f64,
boolean: bool,
formatter: Formatter,
log_fmt: LogFmt,
const Formatter = struct {
ptr: *const anyopaque,
writeFn: *const fn (ptr: *const anyopaque, writer: *std.Io.Writer) anyerror!void,
};
const LogFmt = struct {
ptr: *const anyopaque,
// writes one or more complete " key=value" pairs (logfmt only)
logFmtFn: *const fn (ptr: *const anyopaque, key: []const u8, writer: *std.Io.Writer) anyerror!void,
// value-only formatting, used by the pretty format
writeFn: *const fn (ptr: *const anyopaque, writer: *std.Io.Writer) anyerror!void,
};
fn init(vp: anytype) Value {
const T = @TypeOf(vp.*);
if (comptime std.meta.hasMethod(T, "logFmt")) {
const Thunk = struct {
fn logFmt(ptr: *const anyopaque, key: []const u8, writer: *std.Io.Writer) anyerror!void {
const value: *const T = @ptrCast(@alignCast(ptr));
return value.logFmt(key, LogFormatWriter{ .writer = writer });
}
};
return .{ .log_fmt = .{
.ptr = @ptrCast(vp),
.logFmtFn = Thunk.logFmt,
.writeFn = writeThunk(T, if (std.meta.hasMethod(T, "format")) "f" else ""),
} };
}
if (comptime std.meta.hasMethod(T, "format")) {
return formatterValue(vp, "f");
}
switch (@typeInfo(T)) {
.optional => {
if (vp.*) |_| {
return init(&vp.*.?);
}
return .null;
},
.comptime_int => {
const value = vp.*;
if (value >= 0 and value <= std.math.maxInt(u64)) {
return .{ .uint = value };
}
if (value < 0 and value >= std.math.minInt(i64)) {
return .{ .int = value };
}
return .{ .string = std.fmt.comptimePrint("{d}", .{value}) };
},
.int => |int_info| {
if (comptime int_info.bits <= 64) {
return if (comptime int_info.signedness == .signed) .{ .int = vp.* } else .{ .uint = vp.* };
}
return formatterValue(vp, "d");
},
.comptime_float => return .{ .float64 = vp.* },
.float => |float_info| switch (comptime float_info.bits) {
32 => return .{ .float32 = vp.* },
64 => return .{ .float64 = vp.* },
else => return formatterValue(vp, "d"),
},
.bool => return .{ .boolean = vp.* },
.error_set => return .{ .string = @errorName(vp.*) },
.@"enum" => return .{ .string = @tagName(vp.*) },
.array => |arr| if (comptime arr.child == u8) {
return .{ .string = vp };
},
.pointer => |ptr| switch (comptime ptr.size) {
.slice => if (comptime ptr.child == u8) {
return .{ .string = vp.* };
},
.one => switch (@typeInfo(ptr.child)) {
.array => |arr| if (comptime arr.child == u8) {
return .{ .string = vp.* };
},
else => return formatterValue(vp, "f"),
},
else => {},
},
.one => switch (@typeInfo(ptr.child)) {
.array => |arr| if (arr.child == u8) {
return writeString(format, value, writer);
},
else => return writer.print("{f}", .{value}),
},
.@"union", .@"struct" => return formatterValue(vp, ""),
else => {},
},
.@"union" => return writer.print("{}", .{value}),
.@"struct" => return writer.print("{}", .{value}),
else => {},
}
}
@compileError("cannot log a: " ++ @typeName(T));
@compileError("cannot log a: " ++ @typeName(T));
}
};
pub fn writeValue(comptime format: Format, value: anytype, writer: *std.Io.Writer) !void {
return writeErased(format, Value.init(&value), writer);
}
fn writeString(comptime format: Format, value: []const u8, writer: *std.Io.Writer) !void {
fn formatterValue(vp: anytype, comptime spec: []const u8) Value {
return .{ .formatter = .{
.ptr = @ptrCast(vp),
.writeFn = writeThunk(@TypeOf(vp.*), spec),
} };
}
// The per-type fallback for values that Value cannot represent as a primitive:
fn writeThunk(comptime T: type, comptime spec: []const u8) *const fn (*const anyopaque, *std.Io.Writer) anyerror!void {
return struct {
fn write(ptr: *const anyopaque, writer: *std.Io.Writer) anyerror!void {
const vp: *const T = @ptrCast(@alignCast(ptr));
return writer.print("{" ++ spec ++ "}", .{vp.*});
}
}.write;
}
fn writeErased(format: Format, value: Value, writer: *std.Io.Writer) !void {
switch (value) {
.null => return writer.writeAll("null"),
.string => |s| return writeString(format, s, writer),
.int => |n| return writer.print("{d}", .{n}),
.uint => |n| return writer.print("{d}", .{n}),
.float32 => |n| return writer.print("{d}", .{n}),
.float64 => |n| return writer.print("{d}", .{n}),
.boolean => |b| return writer.writeAll(if (b) "true" else "false"),
.formatter => |f| return f.writeFn(f.ptr, writer),
.log_fmt => |f| return f.writeFn(f.ptr, writer),
}
}
fn writeString(format: Format, value: []const u8, writer: *std.Io.Writer) !void {
if (format == .pretty) {
return writer.writeAll(value);
}
@@ -345,7 +453,7 @@ pub const LogFormatWriter = struct {
pub fn write(self: LogFormatWriter, key: []const u8, value: anytype) !void {
const writer = self.writer;
try writer.print(" {s}=", .{key});
try writeValue(.logfmt, value, writer);
try writeErased(.logfmt, Value.init(&value), writer);
}
};
@@ -385,7 +493,7 @@ test "log: data" {
{
try logTo(.browser, .err, "nope", .{}, &aw.writer);
try testing.expectEqual("$time=1739795092929 $scope=browser $level=error $msg=nope\n", aw.written());
try testing.expectEqual("$time=1739795092929 $scope=browser $level=error $msg=\"nope\"\n", aw.written());
}
{
@@ -422,7 +530,7 @@ test "log: string escape" {
var aw = std.Io.Writer.Allocating.init(testing.allocator);
defer aw.deinit();
const prefix = "$time=1739795092929 $scope=app $level=error $msg=test ";
const prefix = "$time=1739795092929 $scope=app $level=error $msg=\"test\" ";
{
try logTo(.app, .err, "test", .{ .string = "hello world" }, &aw.writer);
try testing.expectEqual(prefix ++ "string=\"hello world\"\n", aw.written());
+2 -2
View File
@@ -98,7 +98,7 @@ fn handleOptional(server: anytype, req: protocol.Request, comptime method: []con
fn handlePing(server: anytype, req: protocol.Request) !void {
const id = req.id orelse return;
try server.sendResult(id, .{});
try server.sendResult(id, struct {}{});
}
const Server = @import("Server.zig");
@@ -130,7 +130,7 @@ test "MCP.router - handleMessage - synchronous unit tests" {
try handleMessage(server, aa,
\\{"jsonrpc":"2.0","id":2,"method":"ping"}
);
try testing.expectJson(.{ .jsonrpc = "2.0", .id = 2, .result = .{} }, out_alloc.writer.buffered());
try testing.expectJson(.{ .jsonrpc = "2.0", .id = 2, .result = struct {}{} }, out_alloc.writer.buffered());
out_alloc.writer.end = 0;
// 3. Tools list