Add element.dataset API

Uses the State to store the dataset, but, on first load, loads the data
attributes from the DOM.
This commit is contained in:
Karl Seguin
2025-06-24 20:08:14 +08:00
parent 2815f02382
commit 2aa5eb85ad
5 changed files with 216 additions and 32 deletions

View File

@@ -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,

View File

@@ -0,0 +1,92 @@
// Copyright (C) 2023-2024 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 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
}, .{});
}

View File

@@ -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 = "<div id=x data-power='over 9000' data-empty data-some-long-key=ok></div>" });
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();

View File

@@ -36,6 +36,7 @@ pub const Interfaces = .{
History,
Location,
MediaQueryList,
@import("DataSet.zig"),
@import("screen.zig").Interfaces,
@import("error_event.zig").ErrorEvent,
};

View File

@@ -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 => {
var args = try self.getArgs(Struct, named_function, 3, info);
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"),
}
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;
}