diff --git a/src/browser/State.zig b/src/browser/State.zig index d594d943..90e5494d 100644 --- a/src/browser/State.zig +++ b/src/browser/State.zig @@ -28,6 +28,7 @@ const Env = @import("env.zig").Env; const parser = @import("netsurf.zig"); +const DataSet = @import("html/DataSet.zig"); const CSSStyleDeclaration = @import("cssom/css_style_declaration.zig").CSSStyleDeclaration; // for HTMLScript (but probably needs to be added to more) @@ -36,6 +37,7 @@ onerror: ?Env.Function = null, // for HTMLElement style: CSSStyleDeclaration = .empty, +dataset: ?DataSet = null, // for html/document ready_state: ReadyState = .loading, diff --git a/src/browser/html/DataSet.zig b/src/browser/html/DataSet.zig new file mode 100644 index 00000000..40ba0417 --- /dev/null +++ b/src/browser/html/DataSet.zig @@ -0,0 +1,92 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . +const std = @import("std"); +const Page = @import("../page.zig").Page; +const Allocator = std.mem.Allocator; + +const DataSet = @This(); + +attributes: std.StringHashMapUnmanaged([]const u8), + +pub const empty: DataSet = .{ + .attributes = .empty, +}; + +const GetResult = union(enum) { + value: []const u8, + undefined: void, +}; +pub fn named_get(self: *const DataSet, name: []const u8, _: *bool) GetResult { + if (self.attributes.get(name)) |value| { + return .{ .value = value }; + } + return .{ .undefined = {} }; +} + +pub fn named_set(self: *DataSet, name: []const u8, value: []const u8, _: *bool, page: *Page) !void { + const arena = page.arena; + const gop = try self.attributes.getOrPut(arena, name); + errdefer _ = self.attributes.remove(name); + + if (!gop.found_existing) { + gop.key_ptr.* = try arena.dupe(u8, name); + } + gop.value_ptr.* = try arena.dupe(u8, value); +} + +pub fn named_delete(self: *DataSet, name: []const u8, _: *bool) void { + _ = self.attributes.remove(name); +} + +pub fn normalizeName(allocator: Allocator, name: []const u8) ![]const u8 { + std.debug.assert(std.mem.startsWith(u8, name, "data-")); + var owned = try allocator.alloc(u8, name.len - 5); + + var pos: usize = 0; + var capitalize = false; + for (name[5..]) |c| { + if (c == '-') { + capitalize = true; + continue; + } + + if (capitalize) { + capitalize = false; + owned[pos] = std.ascii.toUpper(c); + } else { + owned[pos] = c; + } + pos += 1; + } + return owned[0..pos]; +} + +const testing = @import("../../testing.zig"); +test "Browser.HTML.DataSet" { + var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = "" }); + defer runner.deinit(); + + try runner.testCases(&.{ + .{ "let el1 = document.createElement('div')", null }, + .{ "el1.dataset.x", "undefined" }, + .{ "el1.dataset.x = '123'", "123" }, + .{ "delete el1.dataset.x", "true" }, + .{ "el1.dataset.x", "undefined" }, + .{ "delete el1.dataset.other", "true" }, // yes, this is right + }, .{}); +} diff --git a/src/browser/html/elements.zig b/src/browser/html/elements.zig index 28179fee..a0ff8568 100644 --- a/src/browser/html/elements.zig +++ b/src/browser/html/elements.zig @@ -27,6 +27,7 @@ const urlStitch = @import("../../url.zig").URL.stitch; const URL = @import("../url/url.zig").URL; const Node = @import("../dom/node.zig").Node; const Element = @import("../dom/element.zig").Element; +const DataSet = @import("DataSet.zig"); const CSSStyleDeclaration = @import("../cssom/css_style_declaration.zig").CSSStyleDeclaration; @@ -122,6 +123,36 @@ pub const HTMLElement = struct { return &state.style; } + pub fn get_dataset(e: *parser.ElementHTML, page: *Page) !*DataSet { + const state = try page.getOrCreateNodeState(@ptrCast(e)); + if (state.dataset) |*ds| { + return ds; + } + + // The first time this is called, load the data attributes from the DOM + var ds: DataSet = .empty; + + if (try parser.nodeGetAttributes(@ptrCast(e))) |map| { + const arena = page.arena; + const count = try parser.namedNodeMapGetLength(map); + for (0..count) |i| { + const attr = try parser.namedNodeMapItem(map, @intCast(i)) orelse continue; + const name = try parser.attributeGetName(attr); + if (!std.mem.startsWith(u8, name, "data-")) { + continue; + } + const normalized_name = try DataSet.normalizeName(arena, name); + const value = try parser.attributeGetValue(attr) orelse ""; + // I don't think we need to dupe value, It'll live in libdom for + // as long as the page due to the fact that we're using an arena. + try ds.attributes.put(arena, normalized_name, value); + } + } + + state.dataset = ds; + return &state.dataset.?; + } + pub fn get_innerText(e: *parser.ElementHTML) ![]const u8 { const n = @as(*parser.Node, @ptrCast(e)); return try parser.nodeTextContent(n) orelse ""; @@ -1561,6 +1592,13 @@ test "Browser.HTML.Element" { }, .{}); } +test "Browser.HTML.Element.DataSet" { + var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = "
" }); + defer runner.deinit(); + + try runner.testCases(&.{ .{ "let div = document.getElementById('x')", null }, .{ "div.dataset.nope", "undefined" }, .{ "div.dataset.power", "over 9000" }, .{ "div.dataset.empty", "" }, .{ "div.dataset.someLongKey", "ok" }, .{ "delete div.dataset.power", "true" }, .{ "div.dataset.power", "undefined" } }, .{}); +} + test "Browser.HTML.HtmlInputElement.properties" { var runner = try testing.jsRunner(testing.tracking_allocator, .{ .url = "https://lightpanda.io/noslashattheend" }); defer runner.deinit(); diff --git a/src/browser/html/html.zig b/src/browser/html/html.zig index 1ba0a340..f6134bf8 100644 --- a/src/browser/html/html.zig +++ b/src/browser/html/html.zig @@ -36,6 +36,7 @@ pub const Interfaces = .{ History, Location, MediaQueryList, + @import("DataSet.zig"), @import("screen.zig").Interfaces, @import("error_event.zig").ErrorEvent, }; diff --git a/src/runtime/js.zig b/src/runtime/js.zig index cc48175b..150b97e1 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -1931,7 +1931,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { } generateIndexer(Struct, template_proto); - generateNamedIndexer(Struct, template_proto); + generateNamedIndexer(Struct, template.getInstanceTemplate()); generateUndetectable(Struct, template.getInstanceTemplate()); } @@ -2116,7 +2116,8 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { } return; } - const configuration = v8.NamedPropertyHandlerConfiguration{ + + var configuration = v8.NamedPropertyHandlerConfiguration{ .getter = struct { fn callback(c_name: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 { const info = v8.PropertyCallbackInfo.initFromV8(raw_info); @@ -2138,13 +2139,37 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { .flags = v8.PropertyHandlerFlags.OnlyInterceptStrings | v8.PropertyHandlerFlags.NonMasking, }; - // If you're trying to implement setter, read: - // https://groups.google.com/g/v8-users/c/8tahYBsHpgY/m/IteS7Wn2AAAJ - // The issue I had was - // (a) where to attache it: does it go ont he instance_template - // instead of the prototype? - // (b) defining the getter or query to respond with the - // PropertyAttribute to indicate if the property can be set + if (@hasDecl(Struct, "named_set")) { + configuration.setter = struct { + fn callback(c_name: ?*const v8.C_Name, c_value: ?*const v8.C_Value, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 { + const info = v8.PropertyCallbackInfo.initFromV8(raw_info); + var caller = Caller(Self, State).init(info); + defer caller.deinit(); + + const named_function = comptime NamedFunction.init(Struct, "named_set"); + return caller.setNamedIndex(Struct, named_function, .{ .handle = c_name.? }, .{ .handle = c_value.? }, info) catch |err| blk: { + caller.handleError(Struct, named_function, err, info); + break :blk v8.Intercepted.No; + }; + } + }.callback; + } + + if (@hasDecl(Struct, "named_delete")) { + configuration.deleter = struct { + fn callback(c_name: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 { + const info = v8.PropertyCallbackInfo.initFromV8(raw_info); + var caller = Caller(Self, State).init(info); + defer caller.deinit(); + + const named_function = comptime NamedFunction.init(Struct, "named_delete"); + return caller.deleteNamedIndex(Struct, named_function, .{ .handle = c_name.? }, info) catch |err| blk: { + caller.handleError(Struct, named_function, err, info); + break :blk v8.Intercepted.No; + }; + } + }.callback; + } template_proto.setNamedProperty(configuration, null); } @@ -2646,37 +2671,63 @@ fn Caller(comptime E: type, comptime State: type) type { } fn getNamedIndex(self: *Self, comptime Struct: type, comptime named_function: NamedFunction, name: v8.Name, info: v8.PropertyCallbackInfo) !u8 { - const js_context = self.js_context; const func = @field(Struct, named_function.name); - const NamedGet = @TypeOf(func); - if (@typeInfo(NamedGet).@"fn".return_type == null) { - @compileError(named_function.full_name ++ " must have a return type"); - } + comptime assertSelfReceiver(Struct, named_function); var has_value = true; - var args: ParamterTypes(NamedGet) = undefined; - const arg_fields = @typeInfo(@TypeOf(args)).@"struct".fields; - switch (arg_fields.len) { - 0, 1, 2 => @compileError(named_function.full_name ++ " must take at least a u32 and *bool parameter"), - 3, 4 => { - const zig_instance = try E.typeTaggedAnyOpaque(named_function, *Receiver(Struct), info.getThis()); - comptime assertSelfReceiver(Struct, named_function); - @field(args, "0") = zig_instance; - @field(args, "1") = try self.nameToString(name); - @field(args, "2") = &has_value; - if (comptime arg_fields.len == 4) { - comptime assertIsStateArg(Struct, named_function, 3); - @field(args, "3") = js_context.state; - } - }, - else => @compileError(named_function.full_name ++ " has too many parmaters"), - } + var args = try self.getArgs(Struct, named_function, 3, info); + const zig_instance = try E.typeTaggedAnyOpaque(named_function, *Receiver(Struct), info.getThis()); + @field(args, "0") = zig_instance; + @field(args, "1") = try self.nameToString(name); + @field(args, "2") = &has_value; const res = @call(.auto, func, args); if (has_value == false) { return v8.Intercepted.No; } - info.getReturnValue().set(try js_context.zigValueToJs(res)); + info.getReturnValue().set(try self.js_context.zigValueToJs(res)); + return v8.Intercepted.Yes; + } + + fn setNamedIndex(self: *Self, comptime Struct: type, comptime named_function: NamedFunction, name: v8.Name, js_value: v8.Value, info: v8.PropertyCallbackInfo) !u8 { + const js_context = self.js_context; + const func = @field(Struct, named_function.name); + comptime assertSelfReceiver(Struct, named_function); + + var has_value = true; + var args = try self.getArgs(Struct, named_function, 4, info); + const zig_instance = try E.typeTaggedAnyOpaque(named_function, *Receiver(Struct), info.getThis()); + @field(args, "0") = zig_instance; + @field(args, "1") = try self.nameToString(name); + @field(args, "2") = try js_context.jsValueToZig(named_function, @TypeOf(@field(args, "2")), js_value); + @field(args, "3") = &has_value; + + const res = @call(.auto, func, args); + return namedSetOrDeleteCall(res, has_value); + } + + fn deleteNamedIndex(self: *Self, comptime Struct: type, comptime named_function: NamedFunction, name: v8.Name, info: v8.PropertyCallbackInfo) !u8 { + const func = @field(Struct, named_function.name); + comptime assertSelfReceiver(Struct, named_function); + + var has_value = true; + var args = try self.getArgs(Struct, named_function, 3, info); + const zig_instance = try E.typeTaggedAnyOpaque(named_function, *Receiver(Struct), info.getThis()); + @field(args, "0") = zig_instance; + @field(args, "1") = try self.nameToString(name); + @field(args, "2") = &has_value; + + const res = @call(.auto, func, args); + return namedSetOrDeleteCall(res, has_value); + } + + fn namedSetOrDeleteCall(res: anytype, has_value: bool) !u8 { + if (@typeInfo(@TypeOf(res)) == .error_union) { + _ = try res; + } + if (has_value == false) { + return v8.Intercepted.No; + } return v8.Intercepted.Yes; }