diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 1098779c7..311737a87 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -1271,6 +1271,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/XPathExpression.zig"),
@import("../webapi/XPathEvaluator.zig"),
@import("../webapi/collections/DOMStringList.zig"),
+ @import("../webapi/Sanitizer.zig"),
});
// APIs available on EVERY worker global — dedicated, shared and service. This
diff --git a/src/browser/tests/sanitizer.html b/src/browser/tests/sanitizer.html
new file mode 100644
index 000000000..0f85b0c61
--- /dev/null
+++ b/src/browser/tests/sanitizer.html
@@ -0,0 +1,370 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/Sanitizer.zig b/src/browser/webapi/Sanitizer.zig
new file mode 100644
index 000000000..3650356af
--- /dev/null
+++ b/src/browser/webapi/Sanitizer.zig
@@ -0,0 +1,1023 @@
+// Copyright (C) 2023-2026 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 .
+
+// THE ONE THING YOU NEED TO KNOW ABOUT THIS API:
+// It's a configuration for generating safe HTML. It can either be in the shape
+// of "deny these things" or "allow these things". That's mutually exclusive! You
+// can have _allow_elements != null OR _remove_elements != null, but not both.
+// Methods need to work with both shapes. E.g. if we're removing something and
+// _allow_elements != null, then we remove it from _allow_elements. BUT, if
+// _allow_elements == null, then we add it to _remove_elements. This applies
+// to every category, e.g _allow_attributes vs _remove_attributes.
+
+const std = @import("std");
+const lp = @import("lightpanda");
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+
+const defaults = @import("sanitizer_defaults.zig");
+
+const String = lp.String;
+const Execution = js.Execution;
+const Allocator = std.mem.Allocator;
+
+const Sanitizer = @This();
+
+// intern common namespaces
+pub const Namespace = union(enum) {
+ none, // always distinct from any other value
+ xhtml,
+ svg,
+ mathml,
+ xlink,
+ xml,
+ xmlns,
+ other: []const u8,
+
+ const lookup = std.StaticStringMap(Namespace).initComptime(.{
+ .{ defaults.xhtml_ns, .xhtml },
+ .{ defaults.svg_ns, .svg },
+ .{ defaults.mathml_ns, .mathml },
+ .{ defaults.xlink_ns, .xlink },
+ .{ defaults.xml_ns, .xml },
+ .{ defaults.xmlns_ns, .xmlns },
+ });
+
+ fn intern(namespace_: ?[]const u8) Namespace {
+ const namespace = namespace_ orelse return .none;
+ if (namespace.len == 0) {
+ return .none;
+ }
+ return lookup.get(namespace) orelse .{ .other = namespace };
+ }
+
+ pub fn uri(self: Namespace) ?[]const u8 {
+ switch (self) {
+ .none => return null,
+ .other => |value| return value,
+ inline else => |_, tag| {
+ for (lookup.values(), 0..) |value, i| {
+ if (value == tag) {
+ return lookup.keys()[i];
+ }
+ } else unreachable; // has to be in the lookup
+ },
+ }
+ }
+
+ fn eql(a: Namespace, b: Namespace) bool {
+ const a_uri = switch (a) {
+ .other => |value| value,
+ else => return std.meta.activeTag(a) == std.meta.activeTag(b),
+ };
+ const b_uri = switch (b) {
+ .other => |value| value,
+ else => return false,
+ };
+ return std.mem.eql(u8, a_uri, b_uri);
+ }
+};
+
+pub const Name = struct {
+ name: String, // attribute or element name
+ namespace: Namespace,
+
+ fn lessThan(_: void, a: Name, b: Name) bool {
+ const a_ns = a.namespace.uri() orelse {
+ return b.namespace != .none or std.mem.lessThan(u8, a.name.str(), b.name.str());
+ };
+ const b_ns = b.namespace.uri() orelse return false;
+ return switch (std.mem.order(u8, a_ns, b_ns)) {
+ .lt => true,
+ .gt => false,
+ .eq => std.mem.lessThan(u8, a.name.str(), b.name.str()),
+ };
+ }
+
+ fn eql(a: Name, b: Name) bool {
+ return a.namespace.eql(b.namespace) and a.name.eql(b.name);
+ }
+
+ fn isDataAttribute(self: Name) bool {
+ return self.namespace == .none and std.mem.startsWith(u8, self.name.str(), "data-");
+ }
+};
+
+const NameContext = struct {
+ pub fn hash(_: NameContext, key: Name) u32 {
+ var hasher = std.hash.Wyhash.init(0);
+ hasher.update(key.name.str());
+ hasher.update(&.{@intFromEnum(std.meta.activeTag(key.namespace))});
+ if (key.namespace == .other) {
+ hasher.update(key.namespace.other);
+ }
+ return @truncate(hasher.final());
+ }
+
+ pub fn eql(_: NameContext, a: Name, b: Name, _: usize) bool {
+ return a.eql(b);
+ }
+};
+
+const NameSet = std.array_hash_map.Custom(Name, void, NameContext, true);
+const NameMap = std.array_hash_map.Custom(Name, NameSet, NameContext, true);
+const TargetSet = std.array_hash_map.String(void);
+
+_rc: lp.RC = .{},
+_owned_arena: *lp.Arena,
+_arena: Allocator,
+
+_allow_elements: ?NameSet = null,
+_remove_elements: ?NameSet = null,
+_replace_elements: ?NameSet = null,
+_allow_attributes: ?NameSet = null,
+_remove_attributes: ?NameSet = null,
+_allow_processing_instructions: ?TargetSet = null,
+_remove_processing_instructions: ?TargetSet = null,
+_element_allow_attributes: NameMap = .empty,
+_element_remove_attributes: NameMap = .empty,
+
+// tri-states, need to capture absent
+_comments: ?bool = null,
+_data_attributes: ?bool = null,
+_javascript_urls: ?bool = null,
+
+const ElementNamespace = struct {
+ name: []const u8,
+ namespace: js.Nullable([]const u8) = .{ .value = defaults.xhtml_ns },
+};
+
+const ElementNamespaceWithAttributes = struct {
+ attributes: ?[]const SanitizerAttribute = null,
+ name: []const u8,
+ namespace: js.Nullable([]const u8) = .{ .value = defaults.xhtml_ns },
+ removeAttributes: ?[]const SanitizerAttribute = null,
+};
+
+const AttributeNamespace = struct {
+ name: []const u8,
+ namespace: js.Nullable([]const u8) = .{ .value = null },
+};
+
+const ProcessingInstruction = struct {
+ target: []const u8,
+};
+
+const SanitizerElement = union(enum) {
+ dictionary: ElementNamespace,
+ string: []const u8,
+};
+
+const SanitizerElementWithAttributes = union(enum) {
+ dictionary: ElementNamespaceWithAttributes,
+ string: []const u8,
+};
+
+const SanitizerAttribute = union(enum) {
+ dictionary: AttributeNamespace,
+ string: []const u8,
+};
+
+const SanitizerPI = union(enum) {
+ dictionary: ProcessingInstruction,
+ string: []const u8,
+};
+
+const Config = struct {
+ attributes: ?[]const SanitizerAttribute = null,
+ comments: ?js.Value = null,
+ dataAttributes: ?js.Value = null,
+ elements: ?[]const SanitizerElementWithAttributes = null,
+ javascriptURLs: ?js.Value = null,
+ processingInstructions: ?[]const SanitizerPI = null,
+ removeAttributes: ?[]const SanitizerAttribute = null,
+ removeElements: ?[]const SanitizerElement = null,
+ removeProcessingInstructions: ?[]const SanitizerPI = null,
+ replaceWithChildrenElements: ?[]const SanitizerElement = null,
+
+ fn boolean(value: ?js.Value, dflt: bool) bool {
+ // we use ?js.Value for our booleans to tell the difference between not
+ // provided vs null. Not provided -> default. Null -> false
+ const v = value orelse return dflt;
+ return v.toBool();
+ }
+};
+
+// ?js.Value because not provided, undefined and null are all handled differently
+pub fn init(configuration_: ?js.Value, exec: *const Execution) !*Sanitizer {
+ const arena = try exec.getPinnedArena(.small, "Sanitizer");
+ errdefer arena.release();
+
+ const self = try arena.create(Sanitizer);
+ self.* = .{ ._owned_arena = arena, ._arena = arena.allocator() };
+
+ blk: {
+ const configuration = configuration_ orelse {
+ try self.setFromDefault();
+ break :blk;
+ };
+
+ if (configuration.isUndefined()) {
+ try self.setFromDefault();
+ break :blk;
+ }
+
+ if (configuration.isString()) |preset| {
+ if ((try preset.toSSO(false)).eql(comptime .wrap("default")) == false) {
+ return exec.js.typeError("invalid Sanitizer preset");
+ }
+ try self.setFromDefault();
+ break :blk;
+ }
+
+ const config: Config = if (configuration.isNull()) .{} else try configuration.toZig(Config);
+ if (try self.setFromConfig(config) == false) {
+ return exec.js.typeError("invalid Sanitizer configuration");
+ }
+ }
+
+ arena.report();
+ return self;
+}
+
+pub fn deinit(self: *Sanitizer, _: *Page) void {
+ self._owned_arena.release();
+}
+
+pub fn acquireRef(self: *Sanitizer) void {
+ self._rc.acquire();
+}
+
+pub fn releaseRef(self: *Sanitizer, page: *Page) void {
+ self._rc.release(self, page);
+}
+
+// `new Sanitizer("default")`, uses the built-in safe defaults
+fn setFromDefault(self: *Sanitizer) !void {
+ const arena = self._arena;
+
+ var elements: NameSet = .empty;
+ try elements.ensureTotalCapacity(arena, defaults.default_elements.len);
+ for (defaults.default_elements) |element| {
+ const name = staticName(.{ .name = element.name, .namespace = element.namespace });
+ elements.putAssumeCapacity(name, {});
+
+ // Every default element carries an attribute list, empty or not.
+ var attributes: NameSet = .empty;
+ try attributes.ensureTotalCapacity(arena, element.attributes.len);
+ for (element.attributes) |attribute| {
+ attributes.putAssumeCapacity(staticName(attribute), {});
+ }
+ try self._element_allow_attributes.put(arena, name, attributes);
+ }
+ self._allow_elements = elements;
+
+ var attributes: NameSet = .empty;
+ try attributes.ensureTotalCapacity(arena, defaults.default_attributes.len);
+ for (defaults.default_attributes) |attribute| {
+ attributes.putAssumeCapacity(staticName(attribute), {});
+ }
+ self._allow_attributes = attributes;
+
+ self._allow_processing_instructions = .empty;
+ self._comments = false;
+ self._data_attributes = false;
+ self._javascript_urls = false;
+}
+
+fn setFromConfig(self: *Sanitizer, config: Config) !bool {
+ var all_new = true;
+
+ const arena = self._arena;
+ if (config.elements) |elements| {
+ var set: NameSet = .empty;
+ for (elements) |element| {
+ const name = try self.ownName(canonicalElementWithAttributes(element));
+ all_new = try insertNew(&set, arena, name) and all_new;
+
+ switch (element) {
+ .string => {},
+ .dictionary => |dictionary| {
+ if (dictionary.attributes) |attributes| {
+ all_new = try self.putElementAttributes(&self._element_allow_attributes, name, attributes) and all_new;
+ }
+ if (dictionary.removeAttributes) |attributes| {
+ all_new = try self.putElementAttributes(&self._element_remove_attributes, name, attributes) and all_new;
+ }
+ },
+ }
+ // canonical form: an element with neither list has an empty remove-list
+ if (self._element_allow_attributes.contains(name) == false and self._element_remove_attributes.contains(name) == false) {
+ try self._element_remove_attributes.put(arena, name, .empty);
+ }
+ }
+ self._allow_elements = set;
+ }
+ if (config.removeElements) |elements| {
+ self._remove_elements = try self.elementSet(elements, &all_new);
+ }
+ if (config.replaceWithChildrenElements) |elements| {
+ self._replace_elements = try self.elementSet(elements, &all_new);
+ }
+ if (config.attributes) |attributes| {
+ self._allow_attributes = try self.attributeSet(attributes, &all_new);
+ }
+ if (config.removeAttributes) |attributes| {
+ self._remove_attributes = try self.attributeSet(attributes, &all_new);
+ }
+ if (config.processingInstructions) |pis| {
+ self._allow_processing_instructions = try self.targetSet(pis, &all_new);
+ }
+ if (config.removeProcessingInstructions) |pis| {
+ self._remove_processing_instructions = try self.targetSet(pis, &all_new);
+ }
+
+ self._comments = Config.boolean(config.comments, true);
+ if (self._allow_attributes != null or config.dataAttributes != null) {
+ self._data_attributes = Config.boolean(config.dataAttributes, true);
+ }
+ self._javascript_urls = Config.boolean(config.javascriptURLs, true);
+
+ if (config.elements == null and config.removeElements == null) {
+ self._remove_elements = .empty;
+ }
+ if (config.attributes == null and config.removeAttributes == null) {
+ self._remove_attributes = .empty;
+ }
+ if (self._allow_processing_instructions == null and self._remove_processing_instructions == null) {
+ self._remove_processing_instructions = .empty;
+ }
+
+ return all_new and self.isValid();
+}
+
+fn elementSet(self: *Sanitizer, elements: []const SanitizerElement, all_new: *bool) !NameSet {
+ var set: NameSet = .empty;
+ for (elements) |element| {
+ const name = try self.ownName(canonicalElement(element));
+ all_new.* = try insertNew(&set, self._arena, name) and all_new.*;
+ }
+ return set;
+}
+
+fn attributeSet(self: *Sanitizer, attributes: []const SanitizerAttribute, all_new: *bool) !NameSet {
+ var set: NameSet = .empty;
+ for (attributes) |attribute| {
+ const name = try self.ownName(canonicalAttribute(attribute));
+ all_new.* = try insertNew(&set, self._arena, name) and all_new.*;
+ }
+ return set;
+}
+
+fn targetSet(self: *Sanitizer, pis: []const SanitizerPI, all_new: *bool) !TargetSet {
+ var set: TargetSet = .empty;
+ for (pis) |pi| {
+ const target = try self.own(canonicalTarget(pi));
+ const gop = try set.getOrPut(self._arena, target);
+ all_new.* = gop.found_existing == false and all_new.*;
+ gop.value_ptr.* = {};
+ }
+ return set;
+}
+
+fn putElementAttributes(self: *Sanitizer, map: *NameMap, element: Name, attributes: []const SanitizerAttribute) !bool {
+ var all_new = true;
+ var set: NameSet = .empty;
+ for (attributes) |attribute| {
+ const name = try self.ownName(canonicalAttribute(attribute));
+ all_new = try insertNew(&set, self._arena, name) and all_new;
+ }
+ const gop = try map.getOrPut(self._arena, element);
+ // A duplicated element name is already fatal; don't let it merge lists.
+ all_new = gop.found_existing == false and all_new;
+ gop.value_ptr.* = set;
+ return all_new;
+}
+
+fn insertNew(set: *NameSet, arena: Allocator, name: Name) !bool {
+ const gop = try set.getOrPut(arena, name);
+ gop.value_ptr.* = {};
+ return gop.found_existing == false;
+}
+
+fn canonicalElement(element: SanitizerElement) defaults.Name {
+ return switch (element) {
+ .string => |name| .{ .name = name, .namespace = .xhtml },
+ .dictionary => |d| .{ .name = d.name, .namespace = .intern(d.namespace.value) },
+ };
+}
+
+fn canonicalElementWithAttributes(element: SanitizerElementWithAttributes) defaults.Name {
+ return switch (element) {
+ .string => |name| .{ .name = name, .namespace = .xhtml },
+ .dictionary => |d| .{ .name = d.name, .namespace = .intern(d.namespace.value) },
+ };
+}
+
+fn canonicalAttribute(attribute: SanitizerAttribute) defaults.Name {
+ return switch (attribute) {
+ .string => |name| .{ .name = name, .namespace = .none },
+ .dictionary => |d| .{ .name = d.name, .namespace = .intern(d.namespace.value) },
+ };
+}
+
+fn canonicalTarget(pi: SanitizerPI) []const u8 {
+ return switch (pi) {
+ .string => |target| target,
+ .dictionary => |d| d.target,
+ };
+}
+
+fn staticName(name: defaults.Name) Name {
+ // no allocation required, comes from sanitizer_defaults, which are all literals
+ return .{ .name = .wrap(name.name), .namespace = name.namespace };
+}
+
+// Take ownership of the name into our arena
+fn ownName(self: *Sanitizer, name: defaults.Name) !Name {
+ return .{
+ .name = try .init(self._arena, name.name, .{}),
+ .namespace = switch (name.namespace) {
+ .other => |uri| .{ .other = try self.own(uri) },
+ else => name.namespace,
+ },
+ };
+}
+
+fn own(self: *Sanitizer, value: []const u8) ![]const u8 {
+ return String.intern(value) orelse self._arena.dupe(u8, value);
+}
+
+// -
+
+const JsName = struct {
+ name: String,
+ namespace: ?[]const u8,
+};
+
+const JsTarget = struct {
+ target: []const u8,
+};
+
+pub fn get(self: *const Sanitizer, exec: *const Execution) !js.Object {
+ const local = exec.js.local.?;
+ const arena = exec.call_arena;
+ const config = local.newObject();
+
+ if (self._allow_elements) |elements| {
+ const names = try sortedNames(elements, arena);
+ const array = local.newArray(@intCast(names.len));
+ for (names, 0..) |*name, i| {
+ // name.name == String, need name by ref, else we end up with
+ // dangling pointer on this stack when we set.
+ const element = local.newObject();
+ _ = try element.set("name", name.name, .{});
+ _ = try element.set("namespace", name.namespace.uri(), .{});
+
+ const allowed = self._element_allow_attributes.getPtr(name.*);
+ const removed = self._element_remove_attributes.getPtr(name.*);
+ if (allowed) |set| {
+ _ = try element.set("attributes", try nameArray(set.*, arena, local), .{});
+ }
+ if (removed) |set| {
+ _ = try element.set("removeAttributes", try nameArray(set.*, arena, local), .{});
+ }
+ _ = try array.set(@intCast(i), element, .{});
+ }
+ _ = try config.set("elements", array, .{});
+ }
+ if (self._remove_elements) |elements| {
+ _ = try config.set("removeElements", try nameArray(elements, arena, local), .{});
+ }
+ if (self._replace_elements) |elements| {
+ _ = try config.set("replaceWithChildrenElements", try nameArray(elements, arena, local), .{});
+ }
+ if (self._allow_attributes) |attributes| {
+ _ = try config.set("attributes", try nameArray(attributes, arena, local), .{});
+ }
+ if (self._remove_attributes) |attributes| {
+ _ = try config.set("removeAttributes", try nameArray(attributes, arena, local), .{});
+ }
+ if (self._allow_processing_instructions) |pis| {
+ _ = try config.set("processingInstructions", try sortedTargets(pis, arena, local), .{});
+ }
+ if (self._remove_processing_instructions) |pis| {
+ _ = try config.set("removeProcessingInstructions", try sortedTargets(pis, arena, local), .{});
+ }
+ if (self._comments) |comments| {
+ _ = try config.set("comments", comments, .{});
+ }
+ if (self._data_attributes) |data_attributes| {
+ _ = try config.set("dataAttributes", data_attributes, .{});
+ }
+ if (self._javascript_urls) |javascript_urls| {
+ _ = try config.set("javascriptURLs", javascript_urls, .{});
+ }
+ return config;
+}
+
+fn sortedNames(set: NameSet, arena: Allocator) ![]Name {
+ const names = try arena.dupe(Name, set.keys());
+ std.mem.sort(Name, names, {}, Name.lessThan);
+ return names;
+}
+
+fn nameArray(set: NameSet, arena: Allocator, local: *const js.Local) !js.Array {
+ const names = try sortedNames(set, arena);
+ const array = local.newArray(@intCast(names.len));
+ for (names, 0..) |*name, i| {
+ _ = try array.set(@intCast(i), JsName{ .name = name.name, .namespace = name.namespace.uri() }, .{});
+ }
+ return array;
+}
+
+fn sortedTargets(set: TargetSet, arena: Allocator, local: *const js.Local) !js.Array {
+ const targets = try arena.dupe([]const u8, set.keys());
+ std.mem.sort([]const u8, targets, {}, struct {
+ fn lessThan(_: void, a: []const u8, b: []const u8) bool {
+ return std.mem.lessThan(u8, a, b);
+ }
+ }.lessThan);
+
+ const array = local.newArray(@intCast(targets.len));
+ for (targets, 0..) |target, i| {
+ _ = try array.set(@intCast(i), JsTarget{ .target = target }, .{});
+ }
+ return array;
+}
+
+fn allowElement(self: *Sanitizer, element: SanitizerElementWithAttributes) !bool {
+ const name = try self.ownName(canonicalElementWithAttributes(element));
+ const dictionary = switch (element) {
+ .string => return self.allowName(name, null, null),
+ .dictionary => |d| d,
+ };
+
+ var allowed: NameSet = .empty;
+ if (dictionary.attributes) |attributes| {
+ for (attributes) |attribute| {
+ _ = try insertNew(&allowed, self._arena, try self.ownName(canonicalAttribute(attribute)));
+ }
+ }
+ var removed: NameSet = .empty;
+ if (dictionary.removeAttributes) |attributes| {
+ for (attributes) |attribute| {
+ _ = try insertNew(&removed, self._arena, try self.ownName(canonicalAttribute(attribute)));
+ }
+ }
+ return self.allowName(
+ name,
+ if (dictionary.attributes == null) null else &allowed,
+ if (dictionary.removeAttributes == null) null else &removed,
+ );
+}
+
+fn removeElement(self: *Sanitizer, element: SanitizerElement) !bool {
+ return self.removeName(try self.ownName(canonicalElement(element)));
+}
+
+fn replaceElementWithChildren(self: *Sanitizer, element: SanitizerElement) !bool {
+ return self.replaceName(try self.ownName(canonicalElement(element)));
+}
+
+fn allowAttribute(self: *Sanitizer, attribute: SanitizerAttribute) !bool {
+ return self.allowAttributeName(try self.ownName(canonicalAttribute(attribute)));
+}
+
+fn removeAttribute(self: *Sanitizer, attribute: SanitizerAttribute) !bool {
+ return self.removeAttributeName(try self.ownName(canonicalAttribute(attribute)));
+}
+
+fn allowProcessingInstruction(self: *Sanitizer, pi: SanitizerPI) !bool {
+ const target = try self.own(canonicalTarget(pi));
+ if (self._allow_processing_instructions) |*allowed| {
+ if (allowed.contains(target)) {
+ return false;
+ }
+ try allowed.put(self._arena, target, {});
+ return true;
+ }
+ const removed = &self._remove_processing_instructions.?;
+ return removed.swapRemove(target);
+}
+
+fn removeProcessingInstruction(self: *Sanitizer, pi: SanitizerPI) !bool {
+ const target = try self.own(canonicalTarget(pi));
+ if (self._allow_processing_instructions) |*allowed| {
+ return allowed.swapRemove(target);
+ }
+ const removed = &self._remove_processing_instructions.?;
+ if (removed.contains(target)) {
+ return false;
+ }
+ try removed.put(self._arena, target, {});
+ return true;
+}
+
+fn setComments(self: *Sanitizer, allow: bool) bool {
+ if (self._comments == allow) {
+ return false;
+ }
+ self._comments = allow;
+ return true;
+}
+
+fn setDataAttributes(self: *Sanitizer, allow: bool) bool {
+ const allowed = &(self._allow_attributes orelse return false);
+ if (self._data_attributes == allow) {
+ return false;
+ }
+ if (allow) {
+ // any data-* attribte named individually must now be dropped
+ for (self._element_allow_attributes.values()) |*set| {
+ removeDataAttributes(set);
+ }
+ removeDataAttributes(allowed);
+ }
+ self._data_attributes = allow;
+ return true;
+}
+
+fn setJavascriptURLs(self: *Sanitizer, allow: bool) bool {
+ if (self._javascript_urls == allow) {
+ return false;
+ }
+ self._javascript_urls = allow;
+ return true;
+}
+
+// The baseline is a remove list, removeName and removeAttributeName work with
+// both an allow-config and remove-config, so here, we don't need to worry about
+// which config shape we have.
+fn removeUnsafe(self: *Sanitizer) !bool {
+ var modified = false;
+ for (defaults.baseline_remove_elements) |element| {
+ modified = try self.removeName(staticName(element)) or modified;
+ }
+ for (defaults.event_handler_attributes) |attribute| {
+ modified = try self.removeAttributeName(staticName(.{ .name = attribute, .namespace = .none })) or modified;
+ }
+ if (self._javascript_urls == true) {
+ self._javascript_urls = false;
+ modified = true;
+ }
+ return modified;
+}
+
+fn allowName(self: *Sanitizer, name: Name, allowed_: ?*NameSet, removed_: ?*NameSet) !bool {
+ const allowed = allowed_;
+ var removed = removed_;
+
+ const elements = &(self._allow_elements orelse {
+ // A remove-list config has no per-element lists to put these in.
+ if (allowed != null or (removed != null and removed.?.count() != 0)) {
+ return false;
+ }
+ var modified = self.takeReplace(name);
+ const remove_elements = &self._remove_elements.?;
+ if (remove_elements.swapRemove(name)) {
+ modified = true;
+ }
+ return modified;
+ });
+
+ const modified = self.takeReplace(name);
+
+ if (self._allow_attributes) |global_allowed| {
+ if (allowed) |set| {
+ removeAll(set, global_allowed);
+ if (self._data_attributes == true) {
+ removeDataAttributes(set);
+ }
+ }
+ if (removed) |set| {
+ retainAll(set, global_allowed);
+ }
+ } else {
+ const global_removed = self._remove_attributes.?;
+ if (allowed) |set| {
+ if (removed) |other| {
+ removeAll(set, other.*);
+ removed = null;
+ }
+ removeAll(set, global_removed);
+ }
+ if (removed) |set| {
+ removeAll(set, global_removed);
+ }
+ }
+
+ // canonical form: an element with neither list has an empty remove-list
+ var empty: NameSet = .empty;
+ if (allowed == null and removed == null) {
+ removed = ∅
+ }
+
+ if (elements.contains(name) == false) {
+ try elements.put(self._arena, name, {});
+ try self.setElementAttributes(name, allowed, removed);
+ return true;
+ }
+
+ // Already allowed: only report a change if the attribute lists differ.
+ if (sameSet(self._element_allow_attributes.getPtr(name), allowed) and
+ sameSet(self._element_remove_attributes.getPtr(name), removed))
+ {
+ return modified;
+ }
+ try self.setElementAttributes(name, allowed, removed);
+ return true;
+}
+
+fn setElementAttributes(self: *Sanitizer, name: Name, allowed: ?*NameSet, removed: ?*NameSet) !void {
+ _ = self._element_allow_attributes.swapRemove(name);
+ if (allowed) |set| {
+ try self._element_allow_attributes.put(self._arena, name, set.*);
+ }
+ _ = self._element_remove_attributes.swapRemove(name);
+ if (removed) |set| {
+ try self._element_remove_attributes.put(self._arena, name, set.*);
+ }
+}
+
+fn removeName(self: *Sanitizer, name: Name) !bool {
+ var modified = self.takeReplace(name);
+ if (self._allow_elements) |*elements| {
+ if (elements.swapRemove(name)) {
+ modified = true;
+ }
+ _ = self._element_allow_attributes.swapRemove(name);
+ _ = self._element_remove_attributes.swapRemove(name);
+ return modified;
+ }
+ const elements = &self._remove_elements.?;
+ const gop = try elements.getOrPut(self._arena, name);
+ gop.value_ptr.* = {};
+ return modified or gop.found_existing == false;
+}
+
+fn replaceName(self: *Sanitizer, name: Name) !bool {
+ for (defaults.non_replaceable_elements) |element| {
+ if (staticName(element).eql(name)) {
+ return false;
+ }
+ }
+ if (self._replace_elements) |elements| {
+ if (elements.contains(name)) {
+ return false;
+ }
+ }
+ if (self._remove_elements) |*elements| {
+ _ = elements.swapRemove(name);
+ }
+ if (self._allow_elements) |*elements| {
+ _ = elements.swapRemove(name);
+ _ = self._element_allow_attributes.swapRemove(name);
+ _ = self._element_remove_attributes.swapRemove(name);
+ }
+ if (self._replace_elements == null) {
+ self._replace_elements = .empty;
+ }
+ try self._replace_elements.?.put(self._arena, name, {});
+ return true;
+}
+
+fn allowAttributeName(self: *Sanitizer, name: Name) !bool {
+ const allowed = &(self._allow_attributes orelse {
+ const removed = &self._remove_attributes.?;
+ return removed.swapRemove(name);
+ });
+
+ // Already covered by the blanket data-attribute allowance.
+ if (self._data_attributes == true and name.isDataAttribute()) {
+ return false;
+ }
+ if (allowed.contains(name)) {
+ return false;
+ }
+ // A global allowance subsumes any per-element one.
+ for (self._element_allow_attributes.values()) |*set| {
+ _ = set.swapRemove(name);
+ }
+ try allowed.put(self._arena, name, {});
+ return true;
+}
+
+fn removeAttributeName(self: *Sanitizer, name: Name) !bool {
+ if (self._allow_attributes) |*allowed| {
+ var modified = allowed.swapRemove(name);
+ for (self._element_allow_attributes.values()) |*set| {
+ if (set.swapRemove(name)) {
+ modified = true;
+ }
+ }
+ // Only meaningful against a global allow-list, which just lost `name`.
+ for (self._element_remove_attributes.values()) |*set| {
+ _ = set.swapRemove(name);
+ }
+ return modified;
+ }
+
+ const removed = &self._remove_attributes.?;
+ if (removed.contains(name)) {
+ return false;
+ }
+ for (self._element_allow_attributes.values()) |*set| {
+ _ = set.swapRemove(name);
+ }
+ for (self._element_remove_attributes.values()) |*set| {
+ _ = set.swapRemove(name);
+ }
+ try removed.put(self._arena, name, {});
+ return true;
+}
+
+fn takeReplace(self: *Sanitizer, name: Name) bool {
+ const elements = &(self._replace_elements orelse return false);
+ return elements.swapRemove(name);
+}
+
+fn removeAll(target: *NameSet, other: NameSet) void {
+ for (other.keys()) |key| {
+ _ = target.swapRemove(key);
+ }
+}
+
+fn retainAll(target: *NameSet, other: NameSet) void {
+ var i = target.count();
+ while (i > 0) {
+ i -= 1;
+ if (other.contains(target.keys()[i]) == false) {
+ target.swapRemoveAt(i);
+ }
+ }
+}
+
+fn removeDataAttributes(target: *NameSet) void {
+ var i = target.count();
+ while (i > 0) {
+ i -= 1;
+ if (target.keys()[i].isDataAttribute()) {
+ target.swapRemoveAt(i);
+ }
+ }
+}
+
+fn sameSet(current: ?*NameSet, new: ?*NameSet) bool {
+ const a = current orelse return new == null;
+ const b = new orelse return false;
+ if (a.count() != b.count()) {
+ return false;
+ }
+ for (a.keys()) |key| {
+ if (b.contains(key) == false) {
+ return false;
+ }
+ }
+ return true;
+}
+
+// The sets validate themselves, what we need to do here is apply cross-set
+// validation, e.g. either allow or remove
+fn isValid(self: *const Sanitizer) bool {
+ if (self._allow_elements != null and self._remove_elements != null) {
+ return false;
+ }
+ if (self._allow_attributes != null and self._remove_attributes != null) {
+ return false;
+ }
+ if (self._allow_processing_instructions != null and self._remove_processing_instructions != null) {
+ return false;
+ }
+
+ if (self._replace_elements) |replaced| {
+ for (defaults.non_replaceable_elements) |element| {
+ if (replaced.contains(staticName(element))) {
+ return false;
+ }
+ }
+ if (intersects(self._allow_elements, replaced) or intersects(self._remove_elements, replaced)) {
+ return false;
+ }
+ }
+
+ if (self._allow_attributes) |allowed| {
+ if (self._allow_elements) |elements| {
+ for (elements.keys()) |element| {
+ if (self._element_allow_attributes.getPtr(element)) |per_element| {
+ if (intersects(allowed, per_element.*)) {
+ return false;
+ }
+ if (self._data_attributes == true and hasDataAttribute(per_element.*)) {
+ return false;
+ }
+ }
+ if (self._element_remove_attributes.getPtr(element)) |per_element| {
+ if (isSubset(per_element.*, allowed) == false) {
+ return false;
+ }
+ }
+ }
+ }
+ if (self._data_attributes == true and hasDataAttribute(allowed)) {
+ return false;
+ }
+ return true;
+ }
+
+ const removed = self._remove_attributes.?;
+ if (self._allow_elements) |elements| {
+ for (elements.keys()) |element| {
+ const allow_per_element = self._element_allow_attributes.getPtr(element);
+ const remove_per_element = self._element_remove_attributes.getPtr(element);
+ if (allow_per_element != null and remove_per_element != null) {
+ return false;
+ }
+ if (allow_per_element) |per_element| {
+ if (intersects(removed, per_element.*)) {
+ return false;
+ }
+ }
+ if (remove_per_element) |per_element| {
+ if (intersects(removed, per_element.*)) {
+ return false;
+ }
+ }
+ }
+ }
+ return self._data_attributes == null;
+}
+
+fn intersects(a_: ?NameSet, b: NameSet) bool {
+ const a = a_ orelse return false;
+ for (a.keys()) |key| {
+ if (b.contains(key)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+fn isSubset(subset: NameSet, superset: NameSet) bool {
+ for (subset.keys()) |key| {
+ if (superset.contains(key) == false) {
+ return false;
+ }
+ }
+ return true;
+}
+
+fn hasDataAttribute(set: NameSet) bool {
+ for (set.keys()) |key| {
+ if (key.isDataAttribute()) {
+ return true;
+ }
+ }
+ return false;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Sanitizer);
+
+ pub const Meta = struct {
+ pub const name = "Sanitizer";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(Sanitizer.init, .{});
+ pub const get = bridge.function(Sanitizer.get, .{});
+ pub const allowElement = bridge.function(Sanitizer.allowElement, .{});
+ pub const removeElement = bridge.function(Sanitizer.removeElement, .{});
+ pub const replaceElementWithChildren = bridge.function(Sanitizer.replaceElementWithChildren, .{});
+ pub const allowAttribute = bridge.function(Sanitizer.allowAttribute, .{});
+ pub const removeAttribute = bridge.function(Sanitizer.removeAttribute, .{});
+ pub const allowProcessingInstruction = bridge.function(Sanitizer.allowProcessingInstruction, .{});
+ pub const removeProcessingInstruction = bridge.function(Sanitizer.removeProcessingInstruction, .{});
+ pub const setComments = bridge.function(Sanitizer.setComments, .{});
+ pub const setDataAttributes = bridge.function(Sanitizer.setDataAttributes, .{});
+ pub const setJavascriptURLs = bridge.function(Sanitizer.setJavascriptURLs, .{});
+ pub const removeUnsafe = bridge.function(Sanitizer.removeUnsafe, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: Sanitizer" {
+ testing.expectLog(&.{.js});
+ try testing.htmlRunner("sanitizer.html", .{});
+}
diff --git a/src/browser/webapi/sanitizer_defaults.zig b/src/browser/webapi/sanitizer_defaults.zig
new file mode 100644
index 000000000..652ff17ef
--- /dev/null
+++ b/src/browser/webapi/sanitizer_defaults.zig
@@ -0,0 +1,429 @@
+// Copyright (C) 2023-2026 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 .
+
+// default configuration for Sanitizer
+
+const std = @import("std");
+
+const global_event_handlers = @import("global_event_handlers.zig");
+
+const Namespace = @import("Sanitizer.zig").Namespace;
+
+pub const xhtml_ns = "http://www.w3.org/1999/xhtml";
+pub const svg_ns = "http://www.w3.org/2000/svg";
+pub const mathml_ns = "http://www.w3.org/1998/Math/MathML";
+pub const xlink_ns = "http://www.w3.org/1999/xlink";
+pub const xml_ns = "http://www.w3.org/XML/1998/namespace";
+pub const xmlns_ns = "http://www.w3.org/2000/xmlns/";
+
+// A name as a table writes it, and as one arrives from JS: still a plain slice,
+// because a `Sanitizer.Name` holds an `lp.String`, which cannot be built at
+// comptime past 12 bytes -- and `animateTransform` and friends are longer.
+// `Sanitizer.staticName` / `ownName` turn one of these into a `Name`.
+pub const Name = struct {
+ name: []const u8,
+ namespace: Namespace,
+};
+
+pub const Element = struct {
+ name: []const u8,
+ namespace: Namespace,
+ attributes: []const Name = &.{},
+};
+
+// https://html.spec.whatwg.org/#built-in-non-replaceable-elements-list
+pub const non_replaceable_elements: []const Name = &.{
+ .{ .name = "html", .namespace = .xhtml },
+ .{ .name = "svg", .namespace = .svg },
+ .{ .name = "math", .namespace = .mathml },
+};
+
+// https://wicg.github.io/sanitizer-api/#built-in-safe-default-configuration
+pub const default_elements: []const Element = &.{
+ .{ .name = "math", .namespace = .mathml },
+ .{ .name = "merror", .namespace = .mathml },
+ .{ .name = "mfrac", .namespace = .mathml },
+ .{ .name = "mi", .namespace = .mathml },
+ .{ .name = "mmultiscripts", .namespace = .mathml },
+ .{ .name = "mn", .namespace = .mathml },
+ .{ .name = "mo", .namespace = .mathml, .attributes = &.{ .{ .name = "fence", .namespace = .none }, .{ .name = "form", .namespace = .none }, .{ .name = "largeop", .namespace = .none }, .{ .name = "lspace", .namespace = .none }, .{ .name = "maxsize", .namespace = .none }, .{ .name = "minsize", .namespace = .none }, .{ .name = "movablelimits", .namespace = .none }, .{ .name = "rspace", .namespace = .none }, .{ .name = "separator", .namespace = .none }, .{ .name = "stretchy", .namespace = .none }, .{ .name = "symmetric", .namespace = .none } } },
+ .{ .name = "mover", .namespace = .mathml, .attributes = &.{.{ .name = "accent", .namespace = .none }} },
+ .{ .name = "mpadded", .namespace = .mathml, .attributes = &.{ .{ .name = "depth", .namespace = .none }, .{ .name = "height", .namespace = .none }, .{ .name = "lspace", .namespace = .none }, .{ .name = "voffset", .namespace = .none }, .{ .name = "width", .namespace = .none } } },
+ .{ .name = "mphantom", .namespace = .mathml },
+ .{ .name = "mprescripts", .namespace = .mathml },
+ .{ .name = "mroot", .namespace = .mathml },
+ .{ .name = "mrow", .namespace = .mathml },
+ .{ .name = "ms", .namespace = .mathml },
+ .{ .name = "mspace", .namespace = .mathml, .attributes = &.{ .{ .name = "depth", .namespace = .none }, .{ .name = "height", .namespace = .none }, .{ .name = "width", .namespace = .none } } },
+ .{ .name = "msqrt", .namespace = .mathml },
+ .{ .name = "mstyle", .namespace = .mathml },
+ .{ .name = "msub", .namespace = .mathml },
+ .{ .name = "msubsup", .namespace = .mathml },
+ .{ .name = "msup", .namespace = .mathml },
+ .{ .name = "mtable", .namespace = .mathml },
+ .{ .name = "mtd", .namespace = .mathml, .attributes = &.{ .{ .name = "columnspan", .namespace = .none }, .{ .name = "rowspan", .namespace = .none } } },
+ .{ .name = "mtext", .namespace = .mathml },
+ .{ .name = "mtr", .namespace = .mathml },
+ .{ .name = "munder", .namespace = .mathml, .attributes = &.{.{ .name = "accentunder", .namespace = .none }} },
+ .{ .name = "munderover", .namespace = .mathml, .attributes = &.{ .{ .name = "accent", .namespace = .none }, .{ .name = "accentunder", .namespace = .none } } },
+ .{ .name = "semantics", .namespace = .mathml },
+ .{ .name = "a", .namespace = .xhtml, .attributes = &.{ .{ .name = "href", .namespace = .none }, .{ .name = "hreflang", .namespace = .none }, .{ .name = "type", .namespace = .none } } },
+ .{ .name = "abbr", .namespace = .xhtml },
+ .{ .name = "address", .namespace = .xhtml },
+ .{ .name = "article", .namespace = .xhtml },
+ .{ .name = "aside", .namespace = .xhtml },
+ .{ .name = "b", .namespace = .xhtml },
+ .{ .name = "bdi", .namespace = .xhtml },
+ .{ .name = "bdo", .namespace = .xhtml },
+ .{ .name = "blockquote", .namespace = .xhtml, .attributes = &.{.{ .name = "cite", .namespace = .none }} },
+ .{ .name = "body", .namespace = .xhtml },
+ .{ .name = "br", .namespace = .xhtml },
+ .{ .name = "caption", .namespace = .xhtml },
+ .{ .name = "cite", .namespace = .xhtml },
+ .{ .name = "code", .namespace = .xhtml },
+ .{ .name = "col", .namespace = .xhtml, .attributes = &.{.{ .name = "span", .namespace = .none }} },
+ .{ .name = "colgroup", .namespace = .xhtml, .attributes = &.{.{ .name = "span", .namespace = .none }} },
+ .{ .name = "data", .namespace = .xhtml, .attributes = &.{.{ .name = "value", .namespace = .none }} },
+ .{ .name = "dd", .namespace = .xhtml },
+ .{ .name = "del", .namespace = .xhtml, .attributes = &.{ .{ .name = "cite", .namespace = .none }, .{ .name = "datetime", .namespace = .none } } },
+ .{ .name = "dfn", .namespace = .xhtml },
+ .{ .name = "div", .namespace = .xhtml },
+ .{ .name = "dl", .namespace = .xhtml },
+ .{ .name = "dt", .namespace = .xhtml },
+ .{ .name = "em", .namespace = .xhtml },
+ .{ .name = "figcaption", .namespace = .xhtml },
+ .{ .name = "figure", .namespace = .xhtml },
+ .{ .name = "footer", .namespace = .xhtml },
+ .{ .name = "h1", .namespace = .xhtml },
+ .{ .name = "h2", .namespace = .xhtml },
+ .{ .name = "h3", .namespace = .xhtml },
+ .{ .name = "h4", .namespace = .xhtml },
+ .{ .name = "h5", .namespace = .xhtml },
+ .{ .name = "h6", .namespace = .xhtml },
+ .{ .name = "head", .namespace = .xhtml },
+ .{ .name = "header", .namespace = .xhtml },
+ .{ .name = "hgroup", .namespace = .xhtml },
+ .{ .name = "hr", .namespace = .xhtml },
+ .{ .name = "html", .namespace = .xhtml },
+ .{ .name = "i", .namespace = .xhtml },
+ .{ .name = "ins", .namespace = .xhtml, .attributes = &.{ .{ .name = "cite", .namespace = .none }, .{ .name = "datetime", .namespace = .none } } },
+ .{ .name = "kbd", .namespace = .xhtml },
+ .{ .name = "li", .namespace = .xhtml, .attributes = &.{.{ .name = "value", .namespace = .none }} },
+ .{ .name = "main", .namespace = .xhtml },
+ .{ .name = "mark", .namespace = .xhtml },
+ .{ .name = "menu", .namespace = .xhtml },
+ .{ .name = "nav", .namespace = .xhtml },
+ .{ .name = "ol", .namespace = .xhtml, .attributes = &.{ .{ .name = "reversed", .namespace = .none }, .{ .name = "start", .namespace = .none }, .{ .name = "type", .namespace = .none } } },
+ .{ .name = "p", .namespace = .xhtml },
+ .{ .name = "pre", .namespace = .xhtml },
+ .{ .name = "q", .namespace = .xhtml },
+ .{ .name = "rp", .namespace = .xhtml },
+ .{ .name = "rt", .namespace = .xhtml },
+ .{ .name = "ruby", .namespace = .xhtml },
+ .{ .name = "s", .namespace = .xhtml },
+ .{ .name = "samp", .namespace = .xhtml },
+ .{ .name = "search", .namespace = .xhtml },
+ .{ .name = "section", .namespace = .xhtml },
+ .{ .name = "small", .namespace = .xhtml },
+ .{ .name = "span", .namespace = .xhtml },
+ .{ .name = "strong", .namespace = .xhtml },
+ .{ .name = "sub", .namespace = .xhtml },
+ .{ .name = "sup", .namespace = .xhtml },
+ .{ .name = "table", .namespace = .xhtml },
+ .{ .name = "tbody", .namespace = .xhtml },
+ .{ .name = "td", .namespace = .xhtml, .attributes = &.{ .{ .name = "colspan", .namespace = .none }, .{ .name = "headers", .namespace = .none }, .{ .name = "rowspan", .namespace = .none } } },
+ .{ .name = "tfoot", .namespace = .xhtml },
+ .{ .name = "th", .namespace = .xhtml, .attributes = &.{ .{ .name = "abbr", .namespace = .none }, .{ .name = "colspan", .namespace = .none }, .{ .name = "headers", .namespace = .none }, .{ .name = "rowspan", .namespace = .none }, .{ .name = "scope", .namespace = .none } } },
+ .{ .name = "thead", .namespace = .xhtml },
+ .{ .name = "time", .namespace = .xhtml, .attributes = &.{.{ .name = "datetime", .namespace = .none }} },
+ .{ .name = "title", .namespace = .xhtml },
+ .{ .name = "tr", .namespace = .xhtml },
+ .{ .name = "u", .namespace = .xhtml },
+ .{ .name = "ul", .namespace = .xhtml },
+ .{ .name = "var", .namespace = .xhtml },
+ .{ .name = "wbr", .namespace = .xhtml },
+ .{ .name = "a", .namespace = .svg, .attributes = &.{ .{ .name = "href", .namespace = .none }, .{ .name = "hreflang", .namespace = .none }, .{ .name = "type", .namespace = .none } } },
+ .{ .name = "circle", .namespace = .svg, .attributes = &.{ .{ .name = "cx", .namespace = .none }, .{ .name = "cy", .namespace = .none }, .{ .name = "pathLength", .namespace = .none }, .{ .name = "r", .namespace = .none } } },
+ .{ .name = "defs", .namespace = .svg },
+ .{ .name = "desc", .namespace = .svg },
+ .{ .name = "ellipse", .namespace = .svg, .attributes = &.{ .{ .name = "cx", .namespace = .none }, .{ .name = "cy", .namespace = .none }, .{ .name = "pathLength", .namespace = .none }, .{ .name = "rx", .namespace = .none }, .{ .name = "ry", .namespace = .none } } },
+ .{ .name = "foreignObject", .namespace = .svg, .attributes = &.{ .{ .name = "height", .namespace = .none }, .{ .name = "width", .namespace = .none }, .{ .name = "x", .namespace = .none }, .{ .name = "y", .namespace = .none } } },
+ .{ .name = "g", .namespace = .svg },
+ .{ .name = "line", .namespace = .svg, .attributes = &.{ .{ .name = "pathLength", .namespace = .none }, .{ .name = "x1", .namespace = .none }, .{ .name = "x2", .namespace = .none }, .{ .name = "y1", .namespace = .none }, .{ .name = "y2", .namespace = .none } } },
+ .{ .name = "marker", .namespace = .svg, .attributes = &.{ .{ .name = "markerHeight", .namespace = .none }, .{ .name = "markerUnits", .namespace = .none }, .{ .name = "markerWidth", .namespace = .none }, .{ .name = "orient", .namespace = .none }, .{ .name = "preserveAspectRatio", .namespace = .none }, .{ .name = "refX", .namespace = .none }, .{ .name = "refY", .namespace = .none }, .{ .name = "viewBox", .namespace = .none } } },
+ .{ .name = "metadata", .namespace = .svg },
+ .{ .name = "path", .namespace = .svg, .attributes = &.{ .{ .name = "d", .namespace = .none }, .{ .name = "pathLength", .namespace = .none } } },
+ .{ .name = "polygon", .namespace = .svg, .attributes = &.{ .{ .name = "pathLength", .namespace = .none }, .{ .name = "points", .namespace = .none } } },
+ .{ .name = "polyline", .namespace = .svg, .attributes = &.{ .{ .name = "pathLength", .namespace = .none }, .{ .name = "points", .namespace = .none } } },
+ .{ .name = "rect", .namespace = .svg, .attributes = &.{ .{ .name = "height", .namespace = .none }, .{ .name = "pathLength", .namespace = .none }, .{ .name = "rx", .namespace = .none }, .{ .name = "ry", .namespace = .none }, .{ .name = "width", .namespace = .none }, .{ .name = "x", .namespace = .none }, .{ .name = "y", .namespace = .none } } },
+ .{ .name = "svg", .namespace = .svg, .attributes = &.{ .{ .name = "height", .namespace = .none }, .{ .name = "preserveAspectRatio", .namespace = .none }, .{ .name = "viewBox", .namespace = .none }, .{ .name = "width", .namespace = .none }, .{ .name = "x", .namespace = .none }, .{ .name = "y", .namespace = .none } } },
+ .{ .name = "text", .namespace = .svg, .attributes = &.{ .{ .name = "dx", .namespace = .none }, .{ .name = "dy", .namespace = .none }, .{ .name = "lengthAdjust", .namespace = .none }, .{ .name = "rotate", .namespace = .none }, .{ .name = "textLength", .namespace = .none }, .{ .name = "x", .namespace = .none }, .{ .name = "y", .namespace = .none } } },
+ .{ .name = "textPath", .namespace = .svg, .attributes = &.{ .{ .name = "lengthAdjust", .namespace = .none }, .{ .name = "method", .namespace = .none }, .{ .name = "path", .namespace = .none }, .{ .name = "side", .namespace = .none }, .{ .name = "spacing", .namespace = .none }, .{ .name = "startOffset", .namespace = .none }, .{ .name = "textLength", .namespace = .none } } },
+ .{ .name = "title", .namespace = .svg },
+ .{ .name = "tspan", .namespace = .svg, .attributes = &.{ .{ .name = "dx", .namespace = .none }, .{ .name = "dy", .namespace = .none }, .{ .name = "lengthAdjust", .namespace = .none }, .{ .name = "rotate", .namespace = .none }, .{ .name = "textLength", .namespace = .none }, .{ .name = "x", .namespace = .none }, .{ .name = "y", .namespace = .none } } },
+};
+
+pub const default_attributes: []const Name = &.{
+ .{ .name = "alignment-baseline", .namespace = .none },
+ .{ .name = "baseline-shift", .namespace = .none },
+ .{ .name = "clip-path", .namespace = .none },
+ .{ .name = "clip-rule", .namespace = .none },
+ .{ .name = "color", .namespace = .none },
+ .{ .name = "color-interpolation", .namespace = .none },
+ .{ .name = "cursor", .namespace = .none },
+ .{ .name = "dir", .namespace = .none },
+ .{ .name = "direction", .namespace = .none },
+ .{ .name = "display", .namespace = .none },
+ .{ .name = "displaystyle", .namespace = .none },
+ .{ .name = "dominant-baseline", .namespace = .none },
+ .{ .name = "fill", .namespace = .none },
+ .{ .name = "fill-opacity", .namespace = .none },
+ .{ .name = "fill-rule", .namespace = .none },
+ .{ .name = "font-family", .namespace = .none },
+ .{ .name = "font-size", .namespace = .none },
+ .{ .name = "font-size-adjust", .namespace = .none },
+ .{ .name = "font-stretch", .namespace = .none },
+ .{ .name = "font-style", .namespace = .none },
+ .{ .name = "font-variant", .namespace = .none },
+ .{ .name = "font-weight", .namespace = .none },
+ .{ .name = "lang", .namespace = .none },
+ .{ .name = "letter-spacing", .namespace = .none },
+ .{ .name = "marker-end", .namespace = .none },
+ .{ .name = "marker-mid", .namespace = .none },
+ .{ .name = "marker-start", .namespace = .none },
+ .{ .name = "mathbackground", .namespace = .none },
+ .{ .name = "mathcolor", .namespace = .none },
+ .{ .name = "mathsize", .namespace = .none },
+ .{ .name = "opacity", .namespace = .none },
+ .{ .name = "paint-order", .namespace = .none },
+ .{ .name = "pointer-events", .namespace = .none },
+ .{ .name = "scriptlevel", .namespace = .none },
+ .{ .name = "shape-rendering", .namespace = .none },
+ .{ .name = "stop-color", .namespace = .none },
+ .{ .name = "stop-opacity", .namespace = .none },
+ .{ .name = "stroke", .namespace = .none },
+ .{ .name = "stroke-dasharray", .namespace = .none },
+ .{ .name = "stroke-dashoffset", .namespace = .none },
+ .{ .name = "stroke-linecap", .namespace = .none },
+ .{ .name = "stroke-linejoin", .namespace = .none },
+ .{ .name = "stroke-miterlimit", .namespace = .none },
+ .{ .name = "stroke-opacity", .namespace = .none },
+ .{ .name = "stroke-width", .namespace = .none },
+ .{ .name = "text-anchor", .namespace = .none },
+ .{ .name = "text-decoration", .namespace = .none },
+ .{ .name = "text-overflow", .namespace = .none },
+ .{ .name = "text-rendering", .namespace = .none },
+ .{ .name = "title", .namespace = .none },
+ .{ .name = "transform", .namespace = .none },
+ .{ .name = "transform-origin", .namespace = .none },
+ .{ .name = "unicode-bidi", .namespace = .none },
+ .{ .name = "vector-effect", .namespace = .none },
+ .{ .name = "visibility", .namespace = .none },
+ .{ .name = "white-space", .namespace = .none },
+ .{ .name = "word-spacing", .namespace = .none },
+ .{ .name = "writing-mode", .namespace = .none },
+};
+
+// https://html.spec.whatwg.org/#built-in-safe-baseline-configuration
+// Every HTML element the spec marks "Sanitization: Unsafe" (base, embed,
+// iframe, object, script), plus the obsolete frame and SVG's script and use.
+pub const baseline_remove_elements: []const Name = &.{
+ .{ .name = "base", .namespace = .xhtml },
+ .{ .name = "embed", .namespace = .xhtml },
+ .{ .name = "frame", .namespace = .xhtml },
+ .{ .name = "iframe", .namespace = .xhtml },
+ .{ .name = "object", .namespace = .xhtml },
+ .{ .name = "script", .namespace = .xhtml },
+ .{ .name = "script", .namespace = .svg },
+ .{ .name = "use", .namespace = .svg },
+};
+
+// The baseline's own removeAttributes list is empty; `remove unsafe` instead
+// walks every "event handler content attribute". We fold lightpanda's own
+// handler set into HTML's list so that a handler added to `Handler` -- which is
+// what an `on*` content attribute is compiled against -- can never be left
+// behind by removeUnsafe().
+pub const event_handler_attributes: []const []const u8 = blk: {
+ @setEvalBranchQuota(200_000);
+ const handlers = std.meta.fieldNames(global_event_handlers.Handler);
+ var all: [html_event_handler_attributes.len + handlers.len][]const u8 = undefined;
+ for (html_event_handler_attributes, 0..) |name, i| {
+ all[i] = name;
+ }
+ for (handlers, 0..) |name, i| {
+ all[html_event_handler_attributes.len + i] = name;
+ }
+ std.mem.sort([]const u8, &all, {}, struct {
+ fn lessThan(_: void, a: []const u8, b: []const u8) bool {
+ return std.mem.lessThan(u8, a, b);
+ }
+ }.lessThan);
+
+ var unique: [all.len][]const u8 = undefined;
+ var len: usize = 0;
+ for (all) |name| {
+ if (len == 0 or std.mem.eql(u8, unique[len - 1], name) == false) {
+ unique[len] = name;
+ len += 1;
+ }
+ }
+ const final = unique[0..len].*;
+ break :blk &final;
+};
+
+const html_event_handler_attributes: []const []const u8 = &.{
+ "onabort",
+ "onactivate",
+ "onafterprint",
+ "onanimationcancel",
+ "onanimationend",
+ "onanimationiteration",
+ "onanimationstart",
+ "onautofill",
+ "onauxclick",
+ "onbeforecopy",
+ "onbeforecut",
+ "onbeforefilter",
+ "onbeforeinput",
+ "onbeforepaste",
+ "onbeforeprint",
+ "onbeforetoggle",
+ "onbeforeunload",
+ "onbegin",
+ "onblur",
+ "oncancel",
+ "oncanplay",
+ "oncanplaythrough",
+ "onchange",
+ "onclick",
+ "onclose",
+ "oncommand",
+ "oncontentvisibilityautostatechange",
+ "oncontextlost",
+ "oncontextmenu",
+ "oncontextrestored",
+ "oncopy",
+ "oncuechange",
+ "oncut",
+ "ondblclick",
+ "ondrag",
+ "ondragend",
+ "ondragenter",
+ "ondragleave",
+ "ondragover",
+ "ondragstart",
+ "ondrop",
+ "ondurationchange",
+ "onemptied",
+ "onend",
+ "onended",
+ "onerror",
+ "onfocus",
+ "onfocusin",
+ "onfocusout",
+ "onformdata",
+ "ongotpointercapture",
+ "onhashchange",
+ "oninput",
+ "oninstallresult",
+ "oninvalid",
+ "onkeydown",
+ "onkeypress",
+ "onkeyup",
+ "onlanguagechange",
+ "onload",
+ "onloadeddata",
+ "onloadedmetadata",
+ "onloadstart",
+ "onlocation",
+ "onlostpointercapture",
+ "onmessage",
+ "onmessageerror",
+ "onmousedown",
+ "onmouseenter",
+ "onmouseleave",
+ "onmousemove",
+ "onmouseout",
+ "onmouseover",
+ "onmouseup",
+ "onmousewheel",
+ "onmove",
+ "onoffline",
+ "ononline",
+ "onorientationchange",
+ "onpagehide",
+ "onpageshow",
+ "onpaste",
+ "onpause",
+ "onplay",
+ "onplaying",
+ "onpointercancel",
+ "onpointerdown",
+ "onpointerenter",
+ "onpointerleave",
+ "onpointermove",
+ "onpointerout",
+ "onpointerover",
+ "onpointerrawupdate",
+ "onpointerup",
+ "onpopstate",
+ "onprogress",
+ "onpromptaction",
+ "onpromptdismiss",
+ "onratechange",
+ "onrepeat",
+ "onreset",
+ "onresize",
+ "onscroll",
+ "onscrollend",
+ "onscrollsnapchange",
+ "onscrollsnapchanging",
+ "onsearch",
+ "onsecuritypolicyviolation",
+ "onseeked",
+ "onseeking",
+ "onselect",
+ "onselectionchange",
+ "onselectstart",
+ "onshow",
+ "onslotchange",
+ "onstalled",
+ "onstream",
+ "onstorage",
+ "onsubmit",
+ "onsuspend",
+ "ontimeupdate",
+ "ontimezonechange",
+ "ontoggle",
+ "ontouchcancel",
+ "ontouchend",
+ "ontouchmove",
+ "ontouchstart",
+ "ontransitionend",
+ "onunload",
+ "onvalidationstatuschange",
+ "onvolumechange",
+ "onwaiting",
+ "onwebkitanimationend",
+ "onwebkitanimationiteration",
+ "onwebkitanimationstart",
+ "onwebkitfullscreenchange",
+ "onwebkitfullscreenerror",
+ "onwebkittransitionend",
+ "onwheel",
+};