From 2aeaf02d05a372778257529771b091205bb48047 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 23 Jul 2025 15:34:32 +0800 Subject: [PATCH 1/2] Tweak cssom The only functionality change is adding a `named_set` to the CSSStyleDeclaration so that styles can be set (`named_get` was already defined) Combine the StringHashMapUnmanaged + ArrayListUnmanaged into a single StringArrayHashMapUnmanaged. Use file structs, because @import("css_style_declaration.zig").CSSStyleDeclaration is a bit tedious. Various micro-optimization around parsing CSS, e.g. ascii.eqlIgnoreCase in loops replaced by 1 lowercase + N*mem.eql. --- src/browser/State.zig | 4 +- src/browser/cssom/CSSParser.zig | 289 +++++ .../cssom/{css_rule.zig => CSSRule.zig} | 11 +- .../{css_rule_list.zig => CSSRuleList.zig} | 38 +- src/browser/cssom/CSSStyleDeclaration.zig | 1064 +++++++++++++++++ src/browser/cssom/CSSStyleSheet.zig | 89 ++ src/browser/cssom/css_parser.zig | 291 ----- src/browser/cssom/css_style_declaration.zig | 241 ---- src/browser/cssom/css_stylesheet.zig | 91 -- src/browser/cssom/css_value_analyzer.zig | 811 ------------- src/browser/cssom/cssom.zig | 15 +- src/browser/cssom/stylesheet.zig | 68 +- src/browser/dom/document.zig | 2 +- src/browser/html/elements.zig | 4 +- src/browser/html/window.zig | 2 +- 15 files changed, 1511 insertions(+), 1509 deletions(-) create mode 100644 src/browser/cssom/CSSParser.zig rename src/browser/cssom/{css_rule.zig => CSSRule.zig} (84%) rename src/browser/cssom/{css_rule_list.zig => CSSRuleList.zig} (64%) create mode 100644 src/browser/cssom/CSSStyleDeclaration.zig create mode 100644 src/browser/cssom/CSSStyleSheet.zig delete mode 100644 src/browser/cssom/css_parser.zig delete mode 100644 src/browser/cssom/css_style_declaration.zig delete mode 100644 src/browser/cssom/css_stylesheet.zig delete mode 100644 src/browser/cssom/css_value_analyzer.zig diff --git a/src/browser/State.zig b/src/browser/State.zig index fd10cf080..d53f80bea 100644 --- a/src/browser/State.zig +++ b/src/browser/State.zig @@ -30,8 +30,8 @@ const Env = @import("env.zig").Env; const parser = @import("netsurf.zig"); const DataSet = @import("html/DataSet.zig"); const ShadowRoot = @import("dom/shadow_root.zig").ShadowRoot; -const StyleSheet = @import("cssom/stylesheet.zig").StyleSheet; -const CSSStyleDeclaration = @import("cssom/css_style_declaration.zig").CSSStyleDeclaration; +const StyleSheet = @import("cssom/StyleSheet.zig"); +const CSSStyleDeclaration = @import("cssom/CSSStyleDeclaration.zig"); // for HTMLScript (but probably needs to be added to more) onload: ?Env.Function = null, diff --git a/src/browser/cssom/CSSParser.zig b/src/browser/cssom/CSSParser.zig new file mode 100644 index 000000000..20938c9e6 --- /dev/null +++ b/src/browser/cssom/CSSParser.zig @@ -0,0 +1,289 @@ +// 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 Allocator = std.mem.Allocator; + +const CSSConstants = struct { + const IMPORTANT = "!important"; + const URL_PREFIX = "url("; +}; + +const CSSParserState = enum { + seek_name, + in_name, + seek_colon, + seek_value, + in_value, + in_quoted_value, + in_single_quoted_value, + in_url, + in_important, +}; + +const CSSDeclaration = struct { + name: []const u8, + value: []const u8, + is_important: bool, +}; + +const CSSParser = @This(); +state: CSSParserState, +name_start: usize, +name_end: usize, +value_start: usize, +position: usize, +paren_depth: usize, +escape_next: bool, + +pub fn init() CSSParser { + return .{ + .state = .seek_name, + .name_start = 0, + .name_end = 0, + .value_start = 0, + .position = 0, + .paren_depth = 0, + .escape_next = false, + }; +} + +pub fn parseDeclarations(arena: Allocator, text: []const u8) ![]CSSDeclaration { + var parser = init(); + var declarations: std.ArrayListUnmanaged(CSSDeclaration) = .empty; + + while (parser.position < text.len) { + const c = text[parser.position]; + + switch (parser.state) { + .seek_name => { + if (!std.ascii.isWhitespace(c)) { + parser.name_start = parser.position; + parser.state = .in_name; + continue; + } + }, + .in_name => { + if (c == ':') { + parser.name_end = parser.position; + parser.state = .seek_value; + } else if (std.ascii.isWhitespace(c)) { + parser.name_end = parser.position; + parser.state = .seek_colon; + } + }, + .seek_colon => { + if (c == ':') { + parser.state = .seek_value; + } else if (!std.ascii.isWhitespace(c)) { + parser.state = .seek_name; + continue; + } + }, + .seek_value => { + if (!std.ascii.isWhitespace(c)) { + parser.value_start = parser.position; + if (c == '"') { + parser.state = .in_quoted_value; + } else if (c == '\'') { + parser.state = .in_single_quoted_value; + } else if (c == 'u' and parser.position + CSSConstants.URL_PREFIX.len <= text.len and std.mem.startsWith(u8, text[parser.position..], CSSConstants.URL_PREFIX)) { + parser.state = .in_url; + parser.paren_depth = 1; + parser.position += 3; + } else { + parser.state = .in_value; + continue; + } + } + }, + .in_value => { + if (parser.escape_next) { + parser.escape_next = false; + } else if (c == '\\') { + parser.escape_next = true; + } else if (c == '(') { + parser.paren_depth += 1; + } else if (c == ')' and parser.paren_depth > 0) { + parser.paren_depth -= 1; + } else if (c == ';' and parser.paren_depth == 0) { + try parser.finishDeclaration(arena, &declarations, text); + parser.state = .seek_name; + } + }, + .in_quoted_value => { + if (parser.escape_next) { + parser.escape_next = false; + } else if (c == '\\') { + parser.escape_next = true; + } else if (c == '"') { + parser.state = .in_value; + } + }, + .in_single_quoted_value => { + if (parser.escape_next) { + parser.escape_next = false; + } else if (c == '\\') { + parser.escape_next = true; + } else if (c == '\'') { + parser.state = .in_value; + } + }, + .in_url => { + if (parser.escape_next) { + parser.escape_next = false; + } else if (c == '\\') { + parser.escape_next = true; + } else if (c == '(') { + parser.paren_depth += 1; + } else if (c == ')') { + parser.paren_depth -= 1; + if (parser.paren_depth == 0) { + parser.state = .in_value; + } + } + }, + .in_important => {}, + } + + parser.position += 1; + } + + try parser.finalize(arena, &declarations, text); + + return declarations.items; +} + +fn finishDeclaration(self: *CSSParser, arena: Allocator, declarations: *std.ArrayListUnmanaged(CSSDeclaration), text: []const u8) !void { + const name = std.mem.trim(u8, text[self.name_start..self.name_end], &std.ascii.whitespace); + if (name.len == 0) return; + + const raw_value = text[self.value_start..self.position]; + const value = std.mem.trim(u8, raw_value, &std.ascii.whitespace); + + var final_value = value; + var is_important = false; + + if (std.mem.endsWith(u8, value, CSSConstants.IMPORTANT)) { + is_important = true; + final_value = std.mem.trimRight(u8, value[0 .. value.len - CSSConstants.IMPORTANT.len], &std.ascii.whitespace); + } + + try declarations.append(arena, .{ + .name = name, + .value = final_value, + .is_important = is_important, + }); +} + +fn finalize(self: *CSSParser, arena: Allocator, declarations: *std.ArrayListUnmanaged(CSSDeclaration), text: []const u8) !void { + if (self.state != .in_value) { + return; + } + return self.finishDeclaration(arena, declarations, text); +} + +const testing = @import("../../testing.zig"); +test "CSSParser - Simple property" { + defer testing.reset(); + + const text = "color: red;"; + const allocator = testing.arena_allocator; + + const declarations = try CSSParser.parseDeclarations(allocator, text); + + try testing.expectEqual(1, declarations.len); + try testing.expectEqual("color", declarations[0].name); + try testing.expectEqual("red", declarations[0].value); + try testing.expectEqual(false, declarations[0].is_important); +} + +test "CSSParser - Property with !important" { + defer testing.reset(); + const text = "margin: 10px !important;"; + const allocator = testing.arena_allocator; + + const declarations = try CSSParser.parseDeclarations(allocator, text); + + try testing.expectEqual(1, declarations.len); + try testing.expectEqual("margin", declarations[0].name); + try testing.expectEqual("10px", declarations[0].value); + try testing.expectEqual(true, declarations[0].is_important); +} + +test "CSSParser - Multiple properties" { + defer testing.reset(); + const text = "color: red; font-size: 12px; margin: 5px !important;"; + const allocator = testing.arena_allocator; + + const declarations = try CSSParser.parseDeclarations(allocator, text); + + try testing.expect(declarations.len == 3); + + try testing.expectEqual("color", declarations[0].name); + try testing.expectEqual("red", declarations[0].value); + try testing.expectEqual(false, declarations[0].is_important); + + try testing.expectEqual("font-size", declarations[1].name); + try testing.expectEqual("12px", declarations[1].value); + try testing.expectEqual(false, declarations[1].is_important); + + try testing.expectEqual("margin", declarations[2].name); + try testing.expectEqual("5px", declarations[2].value); + try testing.expectEqual(true, declarations[2].is_important); +} + +test "CSSParser - Quoted value with semicolon" { + defer testing.reset(); + const text = "content: \"Hello; world!\";"; + const allocator = testing.arena_allocator; + + const declarations = try CSSParser.parseDeclarations(allocator, text); + + try testing.expectEqual(1, declarations.len); + try testing.expectEqual("content", declarations[0].name); + try testing.expectEqual("\"Hello; world!\"", declarations[0].value); + try testing.expectEqual(false, declarations[0].is_important); +} + +test "CSSParser - URL value" { + defer testing.reset(); + const text = "background-image: url(\"test.png\");"; + const allocator = testing.arena_allocator; + + const declarations = try CSSParser.parseDeclarations(allocator, text); + + try testing.expectEqual(1, declarations.len); + try testing.expectEqual("background-image", declarations[0].name); + try testing.expectEqual("url(\"test.png\")", declarations[0].value); + try testing.expectEqual(false, declarations[0].is_important); +} + +test "CSSParser - Whitespace handling" { + defer testing.reset(); + const text = " color : purple ; margin : 10px ; "; + const allocator = testing.arena_allocator; + + const declarations = try CSSParser.parseDeclarations(allocator, text); + + try testing.expectEqual(2, declarations.len); + try testing.expectEqual("color", declarations[0].name); + try testing.expectEqual("purple", declarations[0].value); + try testing.expectEqual("margin", declarations[1].name); + try testing.expectEqual("10px", declarations[1].value); +} diff --git a/src/browser/cssom/css_rule.zig b/src/browser/cssom/CSSRule.zig similarity index 84% rename from src/browser/cssom/css_rule.zig rename to src/browser/cssom/CSSRule.zig index 857c0a8aa..e41c4e198 100644 --- a/src/browser/cssom/css_rule.zig +++ b/src/browser/cssom/CSSRule.zig @@ -18,7 +18,7 @@ const std = @import("std"); -const CSSStyleSheet = @import("css_stylesheet.zig").CSSStyleSheet; +const CSSStyleSheet = @import("CSSStyleSheet.zig"); pub const Interfaces = .{ CSSRule, @@ -26,11 +26,10 @@ pub const Interfaces = .{ }; // https://developer.mozilla.org/en-US/docs/Web/API/CSSRule -pub const CSSRule = struct { - css_text: []const u8, - parent_rule: ?*CSSRule = null, - parent_stylesheet: ?*CSSStyleSheet = null, -}; +const CSSRule = @This(); +css_text: []const u8, +parent_rule: ?*CSSRule = null, +parent_stylesheet: ?*CSSStyleSheet = null, pub const CSSImportRule = struct { pub const prototype = *CSSRule; diff --git a/src/browser/cssom/css_rule_list.zig b/src/browser/cssom/CSSRuleList.zig similarity index 64% rename from src/browser/cssom/css_rule_list.zig rename to src/browser/cssom/CSSRuleList.zig index bf5222f28..f17697bb5 100644 --- a/src/browser/cssom/css_rule_list.zig +++ b/src/browser/cssom/CSSRuleList.zig @@ -18,33 +18,33 @@ const std = @import("std"); -const StyleSheet = @import("stylesheet.zig").StyleSheet; -const CSSRule = @import("css_rule.zig").CSSRule; -const CSSImportRule = @import("css_rule.zig").CSSImportRule; +const CSSRule = @import("CSSRule.zig"); +const StyleSheet = @import("StyleSheet.zig").StyleSheet; -pub const CSSRuleList = struct { - list: std.ArrayListUnmanaged([]const u8), +const CSSImportRule = CSSRule.CSSImportRule; - pub fn constructor() CSSRuleList { - return .{ .list = .empty }; - } +const CSSRuleList = @This(); +list: std.ArrayListUnmanaged([]const u8), - pub fn _item(self: *CSSRuleList, _index: u32) ?CSSRule { - const index: usize = @intCast(_index); +pub fn constructor() CSSRuleList { + return .{ .list = .empty }; +} - if (index > self.list.items.len) { - return null; - } +pub fn _item(self: *CSSRuleList, _index: u32) ?CSSRule { + const index: usize = @intCast(_index); - // todo: for now, just return null. - // this depends on properly parsing CSSRule + if (index > self.list.items.len) { return null; } - pub fn get_length(self: *CSSRuleList) u32 { - return @intCast(self.list.items.len); - } -}; + // todo: for now, just return null. + // this depends on properly parsing CSSRule + return null; +} + +pub fn get_length(self: *CSSRuleList) u32 { + return @intCast(self.list.items.len); +} const testing = @import("../../testing.zig"); test "Browser.CSS.CSSRuleList" { diff --git a/src/browser/cssom/CSSStyleDeclaration.zig b/src/browser/cssom/CSSStyleDeclaration.zig new file mode 100644 index 000000000..df1a25459 --- /dev/null +++ b/src/browser/cssom/CSSStyleDeclaration.zig @@ -0,0 +1,1064 @@ +// 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 CSSRule = @import("CSSRule.zig"); +const CSSParser = @import("CSSParser.zig"); + +const Property = struct { + value: []const u8, + priority: bool, +}; + +const CSSStyleDeclaration = @This(); + +properties: std.StringArrayHashMapUnmanaged(Property), + +pub const empty: CSSStyleDeclaration = .{ + .properties = .empty, +}; + +pub fn get_cssFloat(self: *const CSSStyleDeclaration) []const u8 { + return self._getPropertyValue("float"); +} + +pub fn set_cssFloat(self: *CSSStyleDeclaration, value: ?[]const u8, page: *Page) !void { + const final_value = value orelse ""; + return self._setProperty("float", final_value, null, page); +} + +pub fn get_cssText(self: *const CSSStyleDeclaration, page: *Page) ![]const u8 { + var buffer: std.ArrayListUnmanaged(u8) = .empty; + const writer = buffer.writer(page.call_arena); + var it = self.properties.iterator(); + while (it.next()) |entry| { + const name = entry.key_ptr.*; + const property = entry.value_ptr; + const escaped = try escapeCSSValue(page.call_arena, property.value); + try writer.print("{s}: {s}", .{ name, escaped }); + if (property.priority) { + try writer.writeAll(" !important; "); + } else { + try writer.writeAll("; "); + } + } + return buffer.items; +} + +// TODO Propagate also upward to parent node +pub fn set_cssText(self: *CSSStyleDeclaration, text: []const u8, page: *Page) !void { + self.properties.clearRetainingCapacity(); + + // call_arena is safe here, because _setProperty will dupe the name + // using the page's longer-living arena. + const declarations = try CSSParser.parseDeclarations(page.call_arena, text); + + for (declarations) |decl| { + if (!isValidPropertyName(decl.name)) { + continue; + } + const priority: ?[]const u8 = if (decl.is_important) "important" else null; + try self._setProperty(decl.name, decl.value, priority, page); + } +} + +pub fn get_length(self: *const CSSStyleDeclaration) usize { + return self.properties.count(); +} + +pub fn get_parentRule(_: *const CSSStyleDeclaration) ?CSSRule { + return null; +} + +pub fn _getPropertyPriority(self: *const CSSStyleDeclaration, name: []const u8) []const u8 { + const property = self.properties.getPtr(name) orelse return ""; + return if (property.priority) "important" else ""; +} + +// TODO should handle properly shorthand properties and canonical forms +pub fn _getPropertyValue(self: *const CSSStyleDeclaration, name: []const u8) []const u8 { + if (self.properties.getPtr(name)) |property| { + return property.value; + } + + // default to everything being visible (unless it's been explicitly set) + if (std.mem.eql(u8, name, "visibility")) { + return "visible"; + } + + return ""; +} + +pub fn _item(self: *const CSSStyleDeclaration, index: usize) []const u8 { + const values = self.properties.entries.items(.key); + if (index >= values.len) { + return ""; + } + return values[index]; +} + +pub fn _removeProperty(self: *CSSStyleDeclaration, name: []const u8) ![]const u8 { + const property = self.properties.fetchOrderedRemove(name) orelse return ""; + return property.value.value; +} + +pub fn _setProperty(self: *CSSStyleDeclaration, name: []const u8, value: []const u8, priority: ?[]const u8, page: *Page) !void { + const gop = try self.properties.getOrPut(page.arena, name); + if (!gop.found_existing) { + const owned_name = try page.arena.dupe(u8, name); + gop.key_ptr.* = owned_name; + } + + const owned_value = try page.arena.dupe(u8, value); + const is_important = priority != null and std.ascii.eqlIgnoreCase(priority.?, "important"); + gop.value_ptr.* = .{ .value = owned_value, .priority = is_important }; +} + +pub fn named_get(self: *const CSSStyleDeclaration, name: []const u8, _: *bool) []const u8 { + return self._getPropertyValue(name); +} + +pub fn named_set(self: *CSSStyleDeclaration, name: []const u8, value: []const u8, _: *bool, page: *Page) !void { + return self._setProperty(name, value, null, page); +} + +fn isNumericWithUnit(value: []const u8) bool { + if (value.len == 0) { + return false; + } + + const first = value[0]; + + if (!std.ascii.isDigit(first) and first != '+' and first != '-' and first != '.') { + return false; + } + + var i: usize = 0; + var has_digit = false; + var decimal_point = false; + + while (i < value.len) : (i += 1) { + const c = value[i]; + if (std.ascii.isDigit(c)) { + has_digit = true; + } else if (c == '.' and !decimal_point) { + decimal_point = true; + } else if ((c == 'e' or c == 'E') and has_digit) { + if (i + 1 >= value.len) return false; + if (value[i + 1] != '+' and value[i + 1] != '-' and !std.ascii.isDigit(value[i + 1])) break; + i += 1; + if (value[i] == '+' or value[i] == '-') { + i += 1; + } + var has_exp_digits = false; + while (i < value.len and std.ascii.isDigit(value[i])) : (i += 1) { + has_exp_digits = true; + } + if (!has_exp_digits) return false; + break; + } else if (c != '-' and c != '+') { + break; + } + } + + if (!has_digit) { + return false; + } + + if (i == value.len) { + return true; + } + + const unit = value[i..]; + return CSSKeywords.isValidUnit(unit); +} + +fn isHexColor(value: []const u8) bool { + if (value.len == 0) { + return false; + } + if (value[0] != '#') { + return false; + } + + const hex_part = value[1..]; + if (hex_part.len != 3 and hex_part.len != 6 and hex_part.len != 8) { + return false; + } + + for (hex_part) |c| { + if (!std.ascii.isHex(c)) { + return false; + } + } + + return true; +} + +fn isMultiValueProperty(value: []const u8) bool { + var parts = std.mem.splitAny(u8, value, " "); + var multi_value_parts: usize = 0; + var all_parts_valid = true; + + while (parts.next()) |part| { + if (part.len == 0) continue; + multi_value_parts += 1; + + if (isNumericWithUnit(part)) { + continue; + } + if (isHexColor(part)) { + continue; + } + if (CSSKeywords.isKnownKeyword(part)) { + continue; + } + if (CSSKeywords.startsWithFunction(part)) { + continue; + } + + all_parts_valid = false; + break; + } + + return multi_value_parts >= 2 and all_parts_valid; +} + +fn isAlreadyQuoted(value: []const u8) bool { + return value.len >= 2 and ((value[0] == '"' and value[value.len - 1] == '"') or + (value[0] == '\'' and value[value.len - 1] == '\'')); +} + +fn isValidPropertyName(name: []const u8) bool { + if (name.len == 0) return false; + + if (std.mem.startsWith(u8, name, "--")) { + if (name.len == 2) return false; + for (name[2..]) |c| { + if (!std.ascii.isAlphanumeric(c) and c != '-' and c != '_') { + return false; + } + } + return true; + } + + const first_char = name[0]; + if (!std.ascii.isAlphabetic(first_char) and first_char != '-') { + return false; + } + + if (first_char == '-') { + if (name.len < 2) return false; + + if (!std.ascii.isAlphabetic(name[1])) { + return false; + } + + for (name[2..]) |c| { + if (!std.ascii.isAlphanumeric(c) and c != '-') { + return false; + } + } + } else { + for (name[1..]) |c| { + if (!std.ascii.isAlphanumeric(c) and c != '-') { + return false; + } + } + } + + return true; +} + +fn extractImportant(value: []const u8) struct { value: []const u8, is_important: bool } { + const trimmed = std.mem.trim(u8, value, &std.ascii.whitespace); + + if (std.mem.endsWith(u8, trimmed, "!important")) { + const clean_value = std.mem.trimRight(u8, trimmed[0 .. trimmed.len - 10], &std.ascii.whitespace); + return .{ .value = clean_value, .is_important = true }; + } + + return .{ .value = trimmed, .is_important = false }; +} + +fn needsQuotes(value: []const u8) bool { + if (value.len == 0) return true; + if (isAlreadyQuoted(value)) return false; + + if (CSSKeywords.containsSpecialChar(value)) { + return true; + } + + if (std.mem.indexOfScalar(u8, value, ' ') == null) { + return false; + } + + const is_url = std.mem.startsWith(u8, value, "url("); + const is_function = CSSKeywords.startsWithFunction(value); + + return !isMultiValueProperty(value) and + !is_url and + !is_function; +} + +fn escapeCSSValue(arena: std.mem.Allocator, value: []const u8) ![]const u8 { + if (!needsQuotes(value)) { + return value; + } + var out: std.ArrayListUnmanaged(u8) = .empty; + + // We'll need at least this much space, +2 for the quotes + try out.ensureTotalCapacity(arena, value.len + 2); + const writer = out.writer(arena); + + try writer.writeByte('"'); + + for (value, 0..) |c, i| { + switch (c) { + '"' => try writer.writeAll("\\\""), + '\\' => try writer.writeAll("\\\\"), + '\n' => try writer.writeAll("\\A "), + '\r' => try writer.writeAll("\\D "), + '\t' => try writer.writeAll("\\9 "), + 0...8, 11, 12, 14...31, 127 => { + try writer.print("\\{x}", .{c}); + if (i + 1 < value.len and std.ascii.isHex(value[i + 1])) { + try writer.writeByte(' '); + } + }, + else => try writer.writeByte(c), + } + } + + try writer.writeByte('"'); + return out.items; +} + +fn isKnownKeyword(value: []const u8) bool { + return CSSKeywords.isKnownKeyword(value); +} + +fn containsSpecialChar(value: []const u8) bool { + return CSSKeywords.containsSpecialChar(value); +} + +const CSSKeywords = struct { + const BORDER_STYLES = [_][]const u8{ + "none", "solid", "dotted", "dashed", "double", "groove", "ridge", "inset", "outset", + }; + + const COLOR_NAMES = [_][]const u8{ + "black", "white", "red", "green", "blue", "yellow", "purple", "gray", "transparent", + "currentColor", "inherit", + }; + + const POSITION_KEYWORDS = [_][]const u8{ + "auto", "center", "left", "right", "top", "bottom", + }; + + const BACKGROUND_REPEAT = [_][]const u8{ + "repeat", "no-repeat", "repeat-x", "repeat-y", "space", "round", + }; + + const FONT_STYLES = [_][]const u8{ + "normal", "italic", "oblique", "bold", "bolder", "lighter", + }; + + const FONT_SIZES = [_][]const u8{ + "xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", + "smaller", "larger", + }; + + const FONT_FAMILIES = [_][]const u8{ + "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", + }; + + const CSS_GLOBAL = [_][]const u8{ + "initial", "inherit", "unset", "revert", + }; + + const DISPLAY_VALUES = [_][]const u8{ + "block", "inline", "inline-block", "flex", "grid", "none", + }; + + const UNITS = [_][]const u8{ + // LENGTH + "px", "em", "rem", "vw", "vh", "vmin", "vmax", "%", "pt", "pc", "in", "cm", "mm", + "ex", "ch", "fr", + + // ANGLE + "deg", "rad", "grad", "turn", + + // TIME + "s", "ms", + + // FREQUENCY + "hz", "khz", + + // RESOLUTION + "dpi", "dpcm", + "dppx", + }; + + const SPECIAL_CHARS = [_]u8{ + '"', '\'', ';', '{', '}', '\\', '<', '>', '/', '\n', '\t', '\r', '\x00', '\x7F', + }; + + const FUNCTIONS = [_][]const u8{ + "rgb(", "rgba(", "hsl(", "hsla(", "url(", "calc(", "var(", "attr(", + "linear-gradient(", "radial-gradient(", "conic-gradient(", "translate(", "rotate(", "scale(", "skew(", "matrix(", + }; + + const KEYWORDS = BORDER_STYLES ++ COLOR_NAMES ++ POSITION_KEYWORDS ++ + BACKGROUND_REPEAT ++ FONT_STYLES ++ FONT_SIZES ++ FONT_FAMILIES ++ + CSS_GLOBAL ++ DISPLAY_VALUES; + + const MAX_KEYWORD_LEN = lengthOfLongestValue(&KEYWORDS); + + pub fn isKnownKeyword(value: []const u8) bool { + if (value.len > MAX_KEYWORD_LEN) { + return false; + } + var buf: [MAX_KEYWORD_LEN]u8 = undefined; + const normalized = std.ascii.lowerString(&buf, value); + + for (KEYWORDS) |keyword| { + if (std.ascii.eqlIgnoreCase(normalized, keyword)) { + return true; + } + } + + return false; + } + + pub fn containsSpecialChar(value: []const u8) bool { + return std.mem.indexOfAny(u8, value, &SPECIAL_CHARS) != null; + } + + const MAX_UNIT_LEN = lengthOfLongestValue(&UNITS); + + pub fn isValidUnit(unit: []const u8) bool { + if (unit.len > MAX_UNIT_LEN) { + return false; + } + var buf: [MAX_UNIT_LEN]u8 = undefined; + const normalized = std.ascii.lowerString(&buf, unit); + + for (UNITS) |u| { + if (std.mem.eql(u8, normalized, u)) { + return true; + } + } + return false; + } + + pub fn startsWithFunction(value: []const u8) bool { + const pos = std.mem.indexOfScalar(u8, value, '(') orelse return false; + if (pos == 0) return false; + + if (std.mem.indexOfScalarPos(u8, value, pos, ')') == null) { + return false; + } + const function_name = value[0..pos]; + return isValidFunctionName(function_name); + } + + fn isValidFunctionName(name: []const u8) bool { + if (name.len == 0) return false; + + const first = name[0]; + if (!std.ascii.isAlphabetic(first) and first != '_' and first != '-') { + return false; + } + + for (name[1..]) |c| { + if (!std.ascii.isAlphanumeric(c) and c != '_' and c != '-') { + return false; + } + } + + return true; + } +}; + +fn lengthOfLongestValue(values: []const []const u8) usize { + var max: usize = 0; + for (values) |v| { + max = @max(v.len, max); + } + return max; +} + +const testing = @import("../../testing.zig"); +test "CSSOM.CSSStyleDeclaration" { + var runner = try testing.jsRunner(testing.tracking_allocator, .{ + .html = "", + }); + defer runner.deinit(); + + try runner.testCases(&.{ + .{ "let style = document.createElement('div').style", null }, + .{ "style.cssText = 'color: red; font-size: 12px; margin: 5px !important;'", "color: red; font-size: 12px; margin: 5px !important;" }, + .{ "style.length", "3" }, + }, .{}); + + try runner.testCases(&.{ + .{ "style.getPropertyValue('color')", "red" }, + .{ "style.getPropertyValue('font-size')", "12px" }, + .{ "style.getPropertyValue('unknown-property')", "" }, + + .{ "style.getPropertyPriority('margin')", "important" }, + .{ "style.getPropertyPriority('color')", "" }, + .{ "style.getPropertyPriority('unknown-property')", "" }, + + .{ "style.item(0)", "color" }, + .{ "style.item(1)", "font-size" }, + .{ "style.item(2)", "margin" }, + .{ "style.item(3)", "" }, + }, .{}); + + try runner.testCases(&.{ + .{ "style.setProperty('background-color', 'blue')", "undefined" }, + .{ "style.getPropertyValue('background-color')", "blue" }, + .{ "style.length", "4" }, + + .{ "style.setProperty('color', 'green')", "undefined" }, + .{ "style.getPropertyValue('color')", "green" }, + .{ "style.length", "4" }, + .{ "style.color", "green" }, + + .{ "style.setProperty('padding', '10px', 'important')", "undefined" }, + .{ "style.getPropertyValue('padding')", "10px" }, + .{ "style.getPropertyPriority('padding')", "important" }, + + .{ "style.setProperty('border', '1px solid black', 'IMPORTANT')", "undefined" }, + .{ "style.getPropertyPriority('border')", "important" }, + }, .{}); + + try runner.testCases(&.{ + .{ "style.removeProperty('color')", "green" }, + .{ "style.getPropertyValue('color')", "" }, + .{ "style.length", "5" }, + + .{ "style.removeProperty('unknown-property')", "" }, + }, .{}); + + try runner.testCases(&.{ + .{ "style.cssText.includes('font-size: 12px;')", "true" }, + .{ "style.cssText.includes('margin: 5px !important;')", "true" }, + .{ "style.cssText.includes('padding: 10px !important;')", "true" }, + .{ "style.cssText.includes('border: 1px solid black !important;')", "true" }, + + .{ "style.cssText = 'color: purple; text-align: center;'", "color: purple; text-align: center;" }, + .{ "style.length", "2" }, + .{ "style.getPropertyValue('color')", "purple" }, + .{ "style.getPropertyValue('text-align')", "center" }, + .{ "style.getPropertyValue('font-size')", "" }, + + .{ "style.setProperty('cont', 'Hello; world!')", "undefined" }, + .{ "style.getPropertyValue('cont')", "Hello; world!" }, + + .{ "style.cssText = 'content: \"Hello; world!\"; background-image: url(\"test.png\");'", "content: \"Hello; world!\"; background-image: url(\"test.png\");" }, + .{ "style.getPropertyValue('content')", "\"Hello; world!\"" }, + .{ "style.getPropertyValue('background-image')", "url(\"test.png\")" }, + }, .{}); + + try runner.testCases(&.{ + .{ "style.cssFloat", "" }, + .{ "style.cssFloat = 'left'", "left" }, + .{ "style.cssFloat", "left" }, + .{ "style.getPropertyValue('float')", "left" }, + + .{ "style.cssFloat = 'right'", "right" }, + .{ "style.cssFloat", "right" }, + + .{ "style.cssFloat = null", "null" }, + .{ "style.cssFloat", "" }, + }, .{}); + + try runner.testCases(&.{ + .{ "style.setProperty('display', '')", "undefined" }, + .{ "style.getPropertyValue('display')", "" }, + + .{ "style.cssText = ' color : purple ; margin : 10px ; '", " color : purple ; margin : 10px ; " }, + .{ "style.getPropertyValue('color')", "purple" }, + .{ "style.getPropertyValue('margin')", "10px" }, + + .{ "style.setProperty('border-bottom-left-radius', '5px')", "undefined" }, + .{ "style.getPropertyValue('border-bottom-left-radius')", "5px" }, + }, .{}); + + try runner.testCases(&.{ + .{ "style.visibility", "visible" }, + .{ "style.getPropertyValue('visibility')", "visible" }, + }, .{}); + + try runner.testCases(&.{ + .{ "style.margin", "10px" }, + .{ "style.margin = 'auto'", null }, + .{ "style.margin", "auto" }, + }, .{}); +} + +test "CSSOM.CSSStyleDeclaration: isNumericWithUnit - valid numbers with units" { + try testing.expect(isNumericWithUnit("10px")); + try testing.expect(isNumericWithUnit("3.14em")); + try testing.expect(isNumericWithUnit("-5rem")); + try testing.expect(isNumericWithUnit("+12.5%")); + try testing.expect(isNumericWithUnit("0vh")); + try testing.expect(isNumericWithUnit(".5vw")); +} + +test "CSSOM.CSSStyleDeclaration: isNumericWithUnit - scientific notation" { + try testing.expect(isNumericWithUnit("1e5px")); + try testing.expect(isNumericWithUnit("2.5E-3em")); + try testing.expect(isNumericWithUnit("1e+2rem")); + try testing.expect(isNumericWithUnit("-3.14e10px")); +} + +test "CSSOM.CSSStyleDeclaration: isNumericWithUnit - edge cases and invalid inputs" { + try testing.expect(!isNumericWithUnit("")); + + try testing.expect(!isNumericWithUnit("px")); + try testing.expect(!isNumericWithUnit("--px")); + try testing.expect(!isNumericWithUnit(".px")); + + try testing.expect(!isNumericWithUnit("1e")); + try testing.expect(!isNumericWithUnit("1epx")); + try testing.expect(!isNumericWithUnit("1e+")); + try testing.expect(!isNumericWithUnit("1e+px")); + + try testing.expect(!isNumericWithUnit("1.2.3px")); + + try testing.expect(!isNumericWithUnit("10xyz")); + try testing.expect(!isNumericWithUnit("5invalid")); + + try testing.expect(isNumericWithUnit("10")); + try testing.expect(isNumericWithUnit("3.14")); + try testing.expect(isNumericWithUnit("-5")); +} + +test "CSSOM.CSSStyleDeclaration: isHexColor - valid hex colors" { + try testing.expect(isHexColor("#000")); + try testing.expect(isHexColor("#fff")); + try testing.expect(isHexColor("#123456")); + try testing.expect(isHexColor("#abcdef")); + try testing.expect(isHexColor("#ABCDEF")); + try testing.expect(isHexColor("#12345678")); +} + +test "CSSOM.CSSStyleDeclaration: isHexColor - invalid hex colors" { + try testing.expect(!isHexColor("")); + try testing.expect(!isHexColor("#")); + try testing.expect(!isHexColor("000")); + try testing.expect(!isHexColor("#00")); + try testing.expect(!isHexColor("#0000")); + try testing.expect(!isHexColor("#00000")); + try testing.expect(!isHexColor("#0000000")); + try testing.expect(!isHexColor("#000000000")); + try testing.expect(!isHexColor("#gggggg")); + try testing.expect(!isHexColor("#123xyz")); +} + +test "CSSOM.CSSStyleDeclaration: isMultiValueProperty - valid multi-value properties" { + try testing.expect(isMultiValueProperty("10px 20px")); + try testing.expect(isMultiValueProperty("solid red")); + try testing.expect(isMultiValueProperty("#fff black")); + try testing.expect(isMultiValueProperty("1em 2em 3em 4em")); + try testing.expect(isMultiValueProperty("rgb(255,0,0) solid")); +} + +test "CSSOM.CSSStyleDeclaration: isMultiValueProperty - invalid multi-value properties" { + try testing.expect(!isMultiValueProperty("")); + try testing.expect(!isMultiValueProperty("10px")); + try testing.expect(!isMultiValueProperty("invalid unknown")); + try testing.expect(!isMultiValueProperty("10px invalid")); + try testing.expect(!isMultiValueProperty(" ")); +} + +test "CSSOM.CSSStyleDeclaration: isAlreadyQuoted - various quoting scenarios" { + try testing.expect(isAlreadyQuoted("\"hello\"")); + try testing.expect(isAlreadyQuoted("'world'")); + try testing.expect(isAlreadyQuoted("\"\"")); + try testing.expect(isAlreadyQuoted("''")); + + try testing.expect(!isAlreadyQuoted("")); + try testing.expect(!isAlreadyQuoted("hello")); + try testing.expect(!isAlreadyQuoted("\"")); + try testing.expect(!isAlreadyQuoted("'")); + try testing.expect(!isAlreadyQuoted("\"hello'")); + try testing.expect(!isAlreadyQuoted("'hello\"")); + try testing.expect(!isAlreadyQuoted("\"hello")); + try testing.expect(!isAlreadyQuoted("hello\"")); +} + +test "CSSOM.CSSStyleDeclaration: isValidPropertyName - valid property names" { + try testing.expect(isValidPropertyName("color")); + try testing.expect(isValidPropertyName("background-color")); + try testing.expect(isValidPropertyName("-webkit-transform")); + try testing.expect(isValidPropertyName("font-size")); + try testing.expect(isValidPropertyName("margin-top")); + try testing.expect(isValidPropertyName("z-index")); + try testing.expect(isValidPropertyName("line-height")); +} + +test "CSSOM.CSSStyleDeclaration: isValidPropertyName - invalid property names" { + try testing.expect(!isValidPropertyName("")); + try testing.expect(!isValidPropertyName("123color")); + try testing.expect(!isValidPropertyName("color!")); + try testing.expect(!isValidPropertyName("color space")); + try testing.expect(!isValidPropertyName("@color")); + try testing.expect(!isValidPropertyName("color.test")); + try testing.expect(!isValidPropertyName("color_test")); +} + +test "CSSOM.CSSStyleDeclaration: extractImportant - with and without !important" { + var result = extractImportant("red !important"); + try testing.expect(result.is_important); + try testing.expectEqual("red", result.value); + + result = extractImportant("blue"); + try testing.expect(!result.is_important); + try testing.expectEqual("blue", result.value); + + result = extractImportant(" green !important "); + try testing.expect(result.is_important); + try testing.expectEqual("green", result.value); + + result = extractImportant("!important"); + try testing.expect(result.is_important); + try testing.expectEqual("", result.value); + + result = extractImportant("important"); + try testing.expect(!result.is_important); + try testing.expectEqual("important", result.value); +} + +test "CSSOM.CSSStyleDeclaration: needsQuotes - various scenarios" { + try testing.expect(needsQuotes("")); + try testing.expect(needsQuotes("hello world")); + try testing.expect(needsQuotes("test;")); + try testing.expect(needsQuotes("a{b}")); + try testing.expect(needsQuotes("test\"quote")); + + try testing.expect(!needsQuotes("\"already quoted\"")); + try testing.expect(!needsQuotes("'already quoted'")); + try testing.expect(!needsQuotes("url(image.png)")); + try testing.expect(!needsQuotes("rgb(255, 0, 0)")); + try testing.expect(!needsQuotes("10px 20px")); + try testing.expect(!needsQuotes("simple")); +} + +test "CSSOM.CSSStyleDeclaration: escapeCSSValue - escaping various characters" { + const allocator = testing.arena_allocator; + + var result = try escapeCSSValue(allocator, "simple"); + try testing.expectEqual("simple", result); + + result = try escapeCSSValue(allocator, "\"already quoted\""); + try testing.expectEqual("\"already quoted\"", result); + + result = try escapeCSSValue(allocator, "test\"quote"); + try testing.expectEqual("\"test\\\"quote\"", result); + + result = try escapeCSSValue(allocator, "test\nline"); + try testing.expectEqual("\"test\\A line\"", result); + + result = try escapeCSSValue(allocator, "test\\back"); + try testing.expectEqual("\"test\\\\back\"", result); +} + +test "CSSOM.CSSStyleDeclaration: CSSKeywords.isKnownKeyword - case sensitivity" { + try testing.expect(CSSKeywords.isKnownKeyword("red")); + try testing.expect(CSSKeywords.isKnownKeyword("solid")); + try testing.expect(CSSKeywords.isKnownKeyword("center")); + try testing.expect(CSSKeywords.isKnownKeyword("inherit")); + + try testing.expect(CSSKeywords.isKnownKeyword("RED")); + try testing.expect(CSSKeywords.isKnownKeyword("Red")); + try testing.expect(CSSKeywords.isKnownKeyword("SOLID")); + try testing.expect(CSSKeywords.isKnownKeyword("Center")); + + try testing.expect(!CSSKeywords.isKnownKeyword("invalid")); + try testing.expect(!CSSKeywords.isKnownKeyword("unknown")); + try testing.expect(!CSSKeywords.isKnownKeyword("")); +} + +test "CSSOM.CSSStyleDeclaration: CSSKeywords.containsSpecialChar - various special characters" { + try testing.expect(CSSKeywords.containsSpecialChar("test\"quote")); + try testing.expect(CSSKeywords.containsSpecialChar("test'quote")); + try testing.expect(CSSKeywords.containsSpecialChar("test;end")); + try testing.expect(CSSKeywords.containsSpecialChar("test{brace")); + try testing.expect(CSSKeywords.containsSpecialChar("test}brace")); + try testing.expect(CSSKeywords.containsSpecialChar("test\\back")); + try testing.expect(CSSKeywords.containsSpecialChar("testangle")); + try testing.expect(CSSKeywords.containsSpecialChar("test/slash")); + + try testing.expect(!CSSKeywords.containsSpecialChar("normal-text")); + try testing.expect(!CSSKeywords.containsSpecialChar("text123")); + try testing.expect(!CSSKeywords.containsSpecialChar("")); +} + +test "CSSOM.CSSStyleDeclaration: CSSKeywords.isValidUnit - various units" { + try testing.expect(CSSKeywords.isValidUnit("px")); + try testing.expect(CSSKeywords.isValidUnit("em")); + try testing.expect(CSSKeywords.isValidUnit("rem")); + try testing.expect(CSSKeywords.isValidUnit("%")); + + try testing.expect(CSSKeywords.isValidUnit("deg")); + try testing.expect(CSSKeywords.isValidUnit("rad")); + + try testing.expect(CSSKeywords.isValidUnit("s")); + try testing.expect(CSSKeywords.isValidUnit("ms")); + + try testing.expect(CSSKeywords.isValidUnit("PX")); + + try testing.expect(!CSSKeywords.isValidUnit("invalid")); + try testing.expect(!CSSKeywords.isValidUnit("")); +} + +test "CSSOM.CSSStyleDeclaration: CSSKeywords.startsWithFunction - function detection" { + try testing.expect(CSSKeywords.startsWithFunction("rgb(255, 0, 0)")); + try testing.expect(CSSKeywords.startsWithFunction("rgba(255, 0, 0, 0.5)")); + try testing.expect(CSSKeywords.startsWithFunction("url(image.png)")); + try testing.expect(CSSKeywords.startsWithFunction("calc(100% - 20px)")); + try testing.expect(CSSKeywords.startsWithFunction("var(--custom-property)")); + try testing.expect(CSSKeywords.startsWithFunction("linear-gradient(to right, red, blue)")); + + try testing.expect(CSSKeywords.startsWithFunction("custom-function(args)")); + try testing.expect(CSSKeywords.startsWithFunction("unknown(test)")); + + try testing.expect(!CSSKeywords.startsWithFunction("not-a-function")); + try testing.expect(!CSSKeywords.startsWithFunction("missing-paren)")); + try testing.expect(!CSSKeywords.startsWithFunction("missing-close(")); + try testing.expect(!CSSKeywords.startsWithFunction("")); + try testing.expect(!CSSKeywords.startsWithFunction("rgb")); +} + +test "CSSOM.CSSStyleDeclaration: isNumericWithUnit - whitespace handling" { + try testing.expect(!isNumericWithUnit(" 10px")); + try testing.expect(!isNumericWithUnit("10 px")); + try testing.expect(!isNumericWithUnit("10px ")); + try testing.expect(!isNumericWithUnit(" 10 px ")); +} + +test "CSSOM.CSSStyleDeclaration: extractImportant - whitespace edge cases" { + var result = extractImportant(" "); + try testing.expect(!result.is_important); + try testing.expectEqual("", result.value); + + result = extractImportant("\t\n\r !important\t\n"); + try testing.expect(result.is_important); + try testing.expectEqual("", result.value); + + result = extractImportant("red\t!important"); + try testing.expect(result.is_important); + try testing.expectEqual("red", result.value); +} + +test "CSSOM.CSSStyleDeclaration: isHexColor - mixed case handling" { + try testing.expect(isHexColor("#AbC")); + try testing.expect(isHexColor("#123aBc")); + try testing.expect(isHexColor("#FFffFF")); + try testing.expect(isHexColor("#000FFF")); +} + +test "CSSOM.CSSStyleDeclaration: edge case - very long inputs" { + const long_valid = "a" ** 1000 ++ "px"; + try testing.expect(!isNumericWithUnit(long_valid)); // not numeric + + const long_property = "a-" ** 100 ++ "property"; + try testing.expect(isValidPropertyName(long_property)); + + const long_hex = "#" ++ "a" ** 20; + try testing.expect(!isHexColor(long_hex)); +} + +test "CSSOM.CSSStyleDeclaration: boundary conditions - numeric parsing" { + try testing.expect(isNumericWithUnit("0px")); + try testing.expect(isNumericWithUnit("0.0px")); + try testing.expect(isNumericWithUnit(".0px")); + try testing.expect(isNumericWithUnit("0.px")); + + try testing.expect(isNumericWithUnit("999999999px")); + try testing.expect(isNumericWithUnit("1.7976931348623157e+308px")); + + try testing.expect(isNumericWithUnit("0.000000001px")); + try testing.expect(isNumericWithUnit("1e-100px")); +} + +test "CSSOM.CSSStyleDeclaration: extractImportant - malformed important declarations" { + var result = extractImportant("red ! important"); + try testing.expect(!result.is_important); + try testing.expectEqual("red ! important", result.value); + + result = extractImportant("red !Important"); + try testing.expect(!result.is_important); + try testing.expectEqual("red !Important", result.value); + + result = extractImportant("red !IMPORTANT"); + try testing.expect(!result.is_important); + try testing.expectEqual("red !IMPORTANT", result.value); + + result = extractImportant("!importantred"); + try testing.expect(!result.is_important); + try testing.expectEqual("!importantred", result.value); + + result = extractImportant("red !important !important"); + try testing.expect(result.is_important); + try testing.expectEqual("red !important", result.value); +} + +test "CSSOM.CSSStyleDeclaration: isMultiValueProperty - complex spacing scenarios" { + try testing.expect(isMultiValueProperty("10px 20px")); + try testing.expect(isMultiValueProperty("solid red")); + + try testing.expect(isMultiValueProperty(" 10px 20px ")); + + try testing.expect(!isMultiValueProperty("10px\t20px")); + try testing.expect(!isMultiValueProperty("10px\n20px")); + + try testing.expect(isMultiValueProperty("10px 20px 30px")); +} + +test "CSSOM.CSSStyleDeclaration: isAlreadyQuoted - edge cases with quotes" { + try testing.expect(isAlreadyQuoted("\"'hello'\"")); + try testing.expect(isAlreadyQuoted("'\"hello\"'")); + + try testing.expect(isAlreadyQuoted("\"hello\\\"world\"")); + try testing.expect(isAlreadyQuoted("'hello\\'world'")); + + try testing.expect(!isAlreadyQuoted("\"hello")); + try testing.expect(!isAlreadyQuoted("hello\"")); + try testing.expect(!isAlreadyQuoted("'hello")); + try testing.expect(!isAlreadyQuoted("hello'")); + + try testing.expect(isAlreadyQuoted("\"a\"")); + try testing.expect(isAlreadyQuoted("'b'")); +} + +test "CSSOM.CSSStyleDeclaration: needsQuotes - function and URL edge cases" { + try testing.expect(!needsQuotes("rgb(255, 0, 0)")); + try testing.expect(!needsQuotes("calc(100% - 20px)")); + + try testing.expect(!needsQuotes("url(path with spaces.jpg)")); + + try testing.expect(!needsQuotes("linear-gradient(to right, red, blue)")); + + try testing.expect(needsQuotes("rgb(255, 0, 0")); +} + +test "CSSOM.CSSStyleDeclaration: escapeCSSValue - control characters and Unicode" { + const allocator = testing.arena_allocator; + + var result = try escapeCSSValue(allocator, "test\ttab"); + try testing.expectEqual("\"test\\9 tab\"", result); + + result = try escapeCSSValue(allocator, "test\rreturn"); + try testing.expectEqual("\"test\\D return\"", result); + + result = try escapeCSSValue(allocator, "test\x00null"); + try testing.expectEqual("\"test\\0null\"", result); + + result = try escapeCSSValue(allocator, "test\x7Fdel"); + try testing.expectEqual("\"test\\7f del\"", result); + + result = try escapeCSSValue(allocator, "test\"quote\nline\\back"); + try testing.expectEqual("\"test\\\"quote\\A line\\\\back\"", result); +} + +test "CSSOM.CSSStyleDeclaration: isValidPropertyName - CSS custom properties and vendor prefixes" { + try testing.expect(isValidPropertyName("--custom-color")); + try testing.expect(isValidPropertyName("--my-variable")); + try testing.expect(isValidPropertyName("--123")); + + try testing.expect(isValidPropertyName("-webkit-transform")); + try testing.expect(isValidPropertyName("-moz-border-radius")); + try testing.expect(isValidPropertyName("-ms-filter")); + try testing.expect(isValidPropertyName("-o-transition")); + + try testing.expect(!isValidPropertyName("-123invalid")); + try testing.expect(!isValidPropertyName("--")); + try testing.expect(!isValidPropertyName("-")); +} + +test "CSSOM.CSSStyleDeclaration: startsWithFunction - case sensitivity and partial matches" { + try testing.expect(CSSKeywords.startsWithFunction("RGB(255, 0, 0)")); + try testing.expect(CSSKeywords.startsWithFunction("Rgb(255, 0, 0)")); + try testing.expect(CSSKeywords.startsWithFunction("URL(image.png)")); + + try testing.expect(CSSKeywords.startsWithFunction("rg(something)")); + try testing.expect(CSSKeywords.startsWithFunction("ur(something)")); + + try testing.expect(CSSKeywords.startsWithFunction("rgb(1,2,3)")); + try testing.expect(CSSKeywords.startsWithFunction("rgba(1,2,3,4)")); + + try testing.expect(CSSKeywords.startsWithFunction("my-custom-function(args)")); + try testing.expect(CSSKeywords.startsWithFunction("function-with-dashes(test)")); + + try testing.expect(!CSSKeywords.startsWithFunction("123function(test)")); +} + +test "CSSOM.CSSStyleDeclaration: isHexColor - Unicode and invalid characters" { + try testing.expect(!isHexColor("#ghijkl")); + try testing.expect(!isHexColor("#12345g")); + try testing.expect(!isHexColor("#xyz")); + + try testing.expect(!isHexColor("#АВС")); + + try testing.expect(!isHexColor("#1234567g")); + try testing.expect(!isHexColor("#g2345678")); +} + +test "CSSOM.CSSStyleDeclaration: complex integration scenarios" { + const allocator = testing.arena_allocator; + + try testing.expect(isMultiValueProperty("rgb(255,0,0) url(bg.jpg)")); + + try testing.expect(!needsQuotes("calc(100% - 20px)")); + + const result = try escapeCSSValue(allocator, "fake(function with spaces"); + try testing.expectEqual("\"fake(function with spaces\"", result); + + const important_result = extractImportant("rgb(255,0,0) !important"); + try testing.expect(important_result.is_important); + try testing.expectEqual("rgb(255,0,0)", important_result.value); +} + +test "CSSOM.CSSStyleDeclaration: performance edge cases - empty and minimal inputs" { + try testing.expect(!isNumericWithUnit("")); + try testing.expect(!isHexColor("")); + try testing.expect(!isMultiValueProperty("")); + try testing.expect(!isAlreadyQuoted("")); + try testing.expect(!isValidPropertyName("")); + try testing.expect(needsQuotes("")); + try testing.expect(!CSSKeywords.isKnownKeyword("")); + try testing.expect(!CSSKeywords.containsSpecialChar("")); + try testing.expect(!CSSKeywords.isValidUnit("")); + try testing.expect(!CSSKeywords.startsWithFunction("")); + + try testing.expect(!isNumericWithUnit("a")); + try testing.expect(!isHexColor("a")); + try testing.expect(!isMultiValueProperty("a")); + try testing.expect(!isAlreadyQuoted("a")); + try testing.expect(isValidPropertyName("a")); + try testing.expect(!needsQuotes("a")); +} diff --git a/src/browser/cssom/CSSStyleSheet.zig b/src/browser/cssom/CSSStyleSheet.zig new file mode 100644 index 000000000..bf12c02e6 --- /dev/null +++ b/src/browser/cssom/CSSStyleSheet.zig @@ -0,0 +1,89 @@ +// 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 StyleSheet = @import("StyleSheet.zig"); +const CSSRuleList = @import("CSSRuleList.zig"); +const CSSImportRule = @import("CSSRule.zig").CSSImportRule; + +const CSSStyleSheet = @This(); +pub const prototype = *StyleSheet; + +proto: StyleSheet, +css_rules: CSSRuleList, +owner_rule: ?*CSSImportRule, + +const CSSStyleSheetOpts = struct { + base_url: ?[]const u8 = null, + // TODO: Suupport media + disabled: bool = false, +}; + +pub fn constructor(_opts: ?CSSStyleSheetOpts) !CSSStyleSheet { + const opts = _opts orelse CSSStyleSheetOpts{}; + return .{ + .proto = StyleSheet{ .disabled = opts.disabled }, + .css_rules = .constructor(), + .owner_rule = null, + }; +} + +pub fn get_ownerRule(_: *CSSStyleSheet) ?*CSSImportRule { + return null; +} + +pub fn get_cssRules(self: *CSSStyleSheet) *CSSRuleList { + return &self.css_rules; +} + +pub fn _insertRule(self: *CSSStyleSheet, rule: []const u8, _index: ?usize, page: *Page) !usize { + const index = _index orelse 0; + if (index > self.css_rules.list.items.len) { + return error.IndexSize; + } + + const arena = page.arena; + try self.css_rules.list.insert(arena, index, try arena.dupe(u8, rule)); + return index; +} + +pub fn _deleteRule(self: *CSSStyleSheet, index: usize) !void { + if (index > self.css_rules.list.items.len) { + return error.IndexSize; + } + + _ = self.css_rules.list.orderedRemove(index); +} + +const testing = @import("../../testing.zig"); +test "Browser.CSS.StyleSheet" { + var runner = try testing.jsRunner(testing.tracking_allocator, .{}); + defer runner.deinit(); + + try runner.testCases(&.{ + .{ "let css = new CSSStyleSheet()", "undefined" }, + .{ "css instanceof CSSStyleSheet", "true" }, + .{ "css.cssRules.length", "0" }, + .{ "css.ownerRule", "null" }, + .{ "let index1 = css.insertRule('body { color: red; }', 0)", "undefined" }, + .{ "index1", "0" }, + .{ "css.cssRules.length", "1" }, + }, .{}); +} diff --git a/src/browser/cssom/css_parser.zig b/src/browser/cssom/css_parser.zig deleted file mode 100644 index ac1580445..000000000 --- a/src/browser/cssom/css_parser.zig +++ /dev/null @@ -1,291 +0,0 @@ -// 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 Allocator = std.mem.Allocator; - -const CSSConstants = struct { - const IMPORTANT = "!important"; - const URL_PREFIX = "url("; -}; - -pub const CSSParserState = enum { - seek_name, - in_name, - seek_colon, - seek_value, - in_value, - in_quoted_value, - in_single_quoted_value, - in_url, - in_important, -}; - -pub const CSSDeclaration = struct { - name: []const u8, - value: []const u8, - is_important: bool, -}; - -pub const CSSParser = struct { - state: CSSParserState, - name_start: usize, - name_end: usize, - value_start: usize, - position: usize, - paren_depth: usize, - escape_next: bool, - - pub fn init() CSSParser { - return .{ - .state = .seek_name, - .name_start = 0, - .name_end = 0, - .value_start = 0, - .position = 0, - .paren_depth = 0, - .escape_next = false, - }; - } - - pub fn parseDeclarations(arena: Allocator, text: []const u8) ![]CSSDeclaration { - var parser = init(); - var declarations: std.ArrayListUnmanaged(CSSDeclaration) = .empty; - - while (parser.position < text.len) { - const c = text[parser.position]; - - switch (parser.state) { - .seek_name => { - if (!std.ascii.isWhitespace(c)) { - parser.name_start = parser.position; - parser.state = .in_name; - continue; - } - }, - .in_name => { - if (c == ':') { - parser.name_end = parser.position; - parser.state = .seek_value; - } else if (std.ascii.isWhitespace(c)) { - parser.name_end = parser.position; - parser.state = .seek_colon; - } - }, - .seek_colon => { - if (c == ':') { - parser.state = .seek_value; - } else if (!std.ascii.isWhitespace(c)) { - parser.state = .seek_name; - continue; - } - }, - .seek_value => { - if (!std.ascii.isWhitespace(c)) { - parser.value_start = parser.position; - if (c == '"') { - parser.state = .in_quoted_value; - } else if (c == '\'') { - parser.state = .in_single_quoted_value; - } else if (c == 'u' and parser.position + CSSConstants.URL_PREFIX.len <= text.len and std.mem.startsWith(u8, text[parser.position..], CSSConstants.URL_PREFIX)) { - parser.state = .in_url; - parser.paren_depth = 1; - parser.position += 3; - } else { - parser.state = .in_value; - continue; - } - } - }, - .in_value => { - if (parser.escape_next) { - parser.escape_next = false; - } else if (c == '\\') { - parser.escape_next = true; - } else if (c == '(') { - parser.paren_depth += 1; - } else if (c == ')' and parser.paren_depth > 0) { - parser.paren_depth -= 1; - } else if (c == ';' and parser.paren_depth == 0) { - try parser.finishDeclaration(arena, &declarations, text); - parser.state = .seek_name; - } - }, - .in_quoted_value => { - if (parser.escape_next) { - parser.escape_next = false; - } else if (c == '\\') { - parser.escape_next = true; - } else if (c == '"') { - parser.state = .in_value; - } - }, - .in_single_quoted_value => { - if (parser.escape_next) { - parser.escape_next = false; - } else if (c == '\\') { - parser.escape_next = true; - } else if (c == '\'') { - parser.state = .in_value; - } - }, - .in_url => { - if (parser.escape_next) { - parser.escape_next = false; - } else if (c == '\\') { - parser.escape_next = true; - } else if (c == '(') { - parser.paren_depth += 1; - } else if (c == ')') { - parser.paren_depth -= 1; - if (parser.paren_depth == 0) { - parser.state = .in_value; - } - } - }, - .in_important => {}, - } - - parser.position += 1; - } - - try parser.finalize(arena, &declarations, text); - - return declarations.items; - } - - fn finishDeclaration(self: *CSSParser, arena: Allocator, declarations: *std.ArrayListUnmanaged(CSSDeclaration), text: []const u8) !void { - const name = std.mem.trim(u8, text[self.name_start..self.name_end], &std.ascii.whitespace); - if (name.len == 0) return; - - const raw_value = text[self.value_start..self.position]; - const value = std.mem.trim(u8, raw_value, &std.ascii.whitespace); - - var final_value = value; - var is_important = false; - - if (std.mem.endsWith(u8, value, CSSConstants.IMPORTANT)) { - is_important = true; - final_value = std.mem.trimRight(u8, value[0 .. value.len - CSSConstants.IMPORTANT.len], &std.ascii.whitespace); - } - - try declarations.append(arena, .{ - .name = name, - .value = final_value, - .is_important = is_important, - }); - } - - fn finalize(self: *CSSParser, arena: Allocator, declarations: *std.ArrayListUnmanaged(CSSDeclaration), text: []const u8) !void { - if (self.state != .in_value) { - return; - } - return self.finishDeclaration(arena, declarations, text); - } -}; - -const testing = @import("../../testing.zig"); - -test "CSSParser - Simple property" { - defer testing.reset(); - - const text = "color: red;"; - const allocator = testing.arena_allocator; - - const declarations = try CSSParser.parseDeclarations(allocator, text); - - try testing.expectEqual(1, declarations.len); - try testing.expectEqual("color", declarations[0].name); - try testing.expectEqual("red", declarations[0].value); - try testing.expectEqual(false, declarations[0].is_important); -} - -test "CSSParser - Property with !important" { - defer testing.reset(); - const text = "margin: 10px !important;"; - const allocator = testing.arena_allocator; - - const declarations = try CSSParser.parseDeclarations(allocator, text); - - try testing.expectEqual(1, declarations.len); - try testing.expectEqual("margin", declarations[0].name); - try testing.expectEqual("10px", declarations[0].value); - try testing.expectEqual(true, declarations[0].is_important); -} - -test "CSSParser - Multiple properties" { - defer testing.reset(); - const text = "color: red; font-size: 12px; margin: 5px !important;"; - const allocator = testing.arena_allocator; - - const declarations = try CSSParser.parseDeclarations(allocator, text); - - try testing.expect(declarations.len == 3); - - try testing.expectEqual("color", declarations[0].name); - try testing.expectEqual("red", declarations[0].value); - try testing.expectEqual(false, declarations[0].is_important); - - try testing.expectEqual("font-size", declarations[1].name); - try testing.expectEqual("12px", declarations[1].value); - try testing.expectEqual(false, declarations[1].is_important); - - try testing.expectEqual("margin", declarations[2].name); - try testing.expectEqual("5px", declarations[2].value); - try testing.expectEqual(true, declarations[2].is_important); -} - -test "CSSParser - Quoted value with semicolon" { - defer testing.reset(); - const text = "content: \"Hello; world!\";"; - const allocator = testing.arena_allocator; - - const declarations = try CSSParser.parseDeclarations(allocator, text); - - try testing.expectEqual(1, declarations.len); - try testing.expectEqual("content", declarations[0].name); - try testing.expectEqual("\"Hello; world!\"", declarations[0].value); - try testing.expectEqual(false, declarations[0].is_important); -} - -test "CSSParser - URL value" { - defer testing.reset(); - const text = "background-image: url(\"test.png\");"; - const allocator = testing.arena_allocator; - - const declarations = try CSSParser.parseDeclarations(allocator, text); - - try testing.expectEqual(1, declarations.len); - try testing.expectEqual("background-image", declarations[0].name); - try testing.expectEqual("url(\"test.png\")", declarations[0].value); - try testing.expectEqual(false, declarations[0].is_important); -} - -test "CSSParser - Whitespace handling" { - defer testing.reset(); - const text = " color : purple ; margin : 10px ; "; - const allocator = testing.arena_allocator; - - const declarations = try CSSParser.parseDeclarations(allocator, text); - - try testing.expectEqual(2, declarations.len); - try testing.expectEqual("color", declarations[0].name); - try testing.expectEqual("purple", declarations[0].value); - try testing.expectEqual("margin", declarations[1].name); - try testing.expectEqual("10px", declarations[1].value); -} diff --git a/src/browser/cssom/css_style_declaration.zig b/src/browser/cssom/css_style_declaration.zig deleted file mode 100644 index 6ec1d880e..000000000 --- a/src/browser/cssom/css_style_declaration.zig +++ /dev/null @@ -1,241 +0,0 @@ -// 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 CSSParser = @import("./css_parser.zig").CSSParser; -const CSSValueAnalyzer = @import("./css_value_analyzer.zig").CSSValueAnalyzer; -const CSSRule = @import("css_rule.zig").CSSRule; -const Page = @import("../page.zig").Page; - -pub const CSSStyleDeclaration = struct { - store: std.StringHashMapUnmanaged(Property), - order: std.ArrayListUnmanaged([]const u8), - - pub const empty: CSSStyleDeclaration = .{ - .store = .empty, - .order = .empty, - }; - - const Property = struct { - value: []const u8, - priority: bool, - }; - - pub fn get_cssFloat(self: *const CSSStyleDeclaration) []const u8 { - return self._getPropertyValue("float"); - } - - pub fn set_cssFloat(self: *CSSStyleDeclaration, value: ?[]const u8, page: *Page) !void { - const final_value = value orelse ""; - return self._setProperty("float", final_value, null, page); - } - - pub fn get_cssText(self: *const CSSStyleDeclaration, page: *Page) ![]const u8 { - var buffer: std.ArrayListUnmanaged(u8) = .empty; - const writer = buffer.writer(page.call_arena); - for (self.order.items) |name| { - const prop = self.store.get(name).?; - const escaped = try CSSValueAnalyzer.escapeCSSValue(page.call_arena, prop.value); - try writer.print("{s}: {s}", .{ name, escaped }); - if (prop.priority) try writer.writeAll(" !important"); - try writer.writeAll("; "); - } - return buffer.items; - } - - // TODO Propagate also upward to parent node - pub fn set_cssText(self: *CSSStyleDeclaration, text: []const u8, page: *Page) !void { - self.store.clearRetainingCapacity(); - self.order.clearRetainingCapacity(); - - // call_arena is safe here, because _setProperty will dupe the name - // using the page's longer-living arena. - const declarations = try CSSParser.parseDeclarations(page.call_arena, text); - - for (declarations) |decl| { - if (!CSSValueAnalyzer.isValidPropertyName(decl.name)) continue; - const priority: ?[]const u8 = if (decl.is_important) "important" else null; - try self._setProperty(decl.name, decl.value, priority, page); - } - } - - pub fn get_length(self: *const CSSStyleDeclaration) usize { - return self.order.items.len; - } - - pub fn get_parentRule(_: *const CSSStyleDeclaration) ?CSSRule { - return null; - } - - pub fn _getPropertyPriority(self: *const CSSStyleDeclaration, name: []const u8) []const u8 { - return if (self.store.get(name)) |prop| (if (prop.priority) "important" else "") else ""; - } - - // TODO should handle properly shorthand properties and canonical forms - pub fn _getPropertyValue(self: *const CSSStyleDeclaration, name: []const u8) []const u8 { - if (self.store.get(name)) |prop| { - return prop.value; - } - - // default to everything being visible (unless it's been explicitly set) - if (std.mem.eql(u8, name, "visibility")) { - return "visible"; - } - - return ""; - } - - pub fn _item(self: *const CSSStyleDeclaration, index: usize) []const u8 { - return if (index < self.order.items.len) self.order.items[index] else ""; - } - - pub fn _removeProperty(self: *CSSStyleDeclaration, name: []const u8) ![]const u8 { - const prop = self.store.fetchRemove(name) orelse return ""; - for (self.order.items, 0..) |item, i| { - if (std.mem.eql(u8, item, name)) { - _ = self.order.orderedRemove(i); - break; - } - } - // safe to return, since it's in our page.arena - return prop.value.value; - } - - pub fn _setProperty(self: *CSSStyleDeclaration, name: []const u8, value: []const u8, priority: ?[]const u8, page: *Page) !void { - const owned_value = try page.arena.dupe(u8, value); - const is_important = priority != null and std.ascii.eqlIgnoreCase(priority.?, "important"); - - const gop = try self.store.getOrPut(page.arena, name); - if (!gop.found_existing) { - const owned_name = try page.arena.dupe(u8, name); - gop.key_ptr.* = owned_name; - try self.order.append(page.arena, owned_name); - } - - gop.value_ptr.* = .{ .value = owned_value, .priority = is_important }; - } - - pub fn named_get(self: *const CSSStyleDeclaration, name: []const u8, _: *bool) []const u8 { - return self._getPropertyValue(name); - } -}; - -const testing = @import("../../testing.zig"); - -test "CSSOM.CSSStyleDeclaration" { - var runner = try testing.jsRunner(testing.tracking_allocator, .{}); - defer runner.deinit(); - - try runner.testCases(&.{ - .{ "let style = document.getElementById('content').style", "undefined" }, - .{ "style.cssText = 'color: red; font-size: 12px; margin: 5px !important;'", "color: red; font-size: 12px; margin: 5px !important;" }, - .{ "style.length", "3" }, - }, .{}); - - try runner.testCases(&.{ - .{ "style.getPropertyValue('color')", "red" }, - .{ "style.getPropertyValue('font-size')", "12px" }, - .{ "style.getPropertyValue('unknown-property')", "" }, - - .{ "style.getPropertyPriority('margin')", "important" }, - .{ "style.getPropertyPriority('color')", "" }, - .{ "style.getPropertyPriority('unknown-property')", "" }, - - .{ "style.item(0)", "color" }, - .{ "style.item(1)", "font-size" }, - .{ "style.item(2)", "margin" }, - .{ "style.item(3)", "" }, - }, .{}); - - try runner.testCases(&.{ - .{ "style.setProperty('background-color', 'blue')", "undefined" }, - .{ "style.getPropertyValue('background-color')", "blue" }, - .{ "style.length", "4" }, - - .{ "style.setProperty('color', 'green')", "undefined" }, - .{ "style.getPropertyValue('color')", "green" }, - .{ "style.length", "4" }, - .{ "style.color", "green" }, - - .{ "style.setProperty('padding', '10px', 'important')", "undefined" }, - .{ "style.getPropertyValue('padding')", "10px" }, - .{ "style.getPropertyPriority('padding')", "important" }, - - .{ "style.setProperty('border', '1px solid black', 'IMPORTANT')", "undefined" }, - .{ "style.getPropertyPriority('border')", "important" }, - }, .{}); - - try runner.testCases(&.{ - .{ "style.removeProperty('color')", "green" }, - .{ "style.getPropertyValue('color')", "" }, - .{ "style.length", "5" }, - - .{ "style.removeProperty('unknown-property')", "" }, - }, .{}); - - try runner.testCases(&.{ - .{ "style.cssText.includes('font-size: 12px;')", "true" }, - .{ "style.cssText.includes('margin: 5px !important;')", "true" }, - .{ "style.cssText.includes('padding: 10px !important;')", "true" }, - .{ "style.cssText.includes('border: 1px solid black !important;')", "true" }, - - .{ "style.cssText = 'color: purple; text-align: center;'", "color: purple; text-align: center;" }, - .{ "style.length", "2" }, - .{ "style.getPropertyValue('color')", "purple" }, - .{ "style.getPropertyValue('text-align')", "center" }, - .{ "style.getPropertyValue('font-size')", "" }, - - .{ "style.setProperty('cont', 'Hello; world!')", "undefined" }, - .{ "style.getPropertyValue('cont')", "Hello; world!" }, - - .{ "style.cssText = 'content: \"Hello; world!\"; background-image: url(\"test.png\");'", "content: \"Hello; world!\"; background-image: url(\"test.png\");" }, - .{ "style.getPropertyValue('content')", "\"Hello; world!\"" }, - .{ "style.getPropertyValue('background-image')", "url(\"test.png\")" }, - }, .{}); - - try runner.testCases(&.{ - .{ "style.cssFloat", "" }, - .{ "style.cssFloat = 'left'", "left" }, - .{ "style.cssFloat", "left" }, - .{ "style.getPropertyValue('float')", "left" }, - - .{ "style.cssFloat = 'right'", "right" }, - .{ "style.cssFloat", "right" }, - - .{ "style.cssFloat = null", "null" }, - .{ "style.cssFloat", "" }, - }, .{}); - - try runner.testCases(&.{ - .{ "style.setProperty('display', '')", "undefined" }, - .{ "style.getPropertyValue('display')", "" }, - - .{ "style.cssText = ' color : purple ; margin : 10px ; '", " color : purple ; margin : 10px ; " }, - .{ "style.getPropertyValue('color')", "purple" }, - .{ "style.getPropertyValue('margin')", "10px" }, - - .{ "style.setProperty('border-bottom-left-radius', '5px')", "undefined" }, - .{ "style.getPropertyValue('border-bottom-left-radius')", "5px" }, - }, .{}); - - try runner.testCases(&.{ - .{ "style.visibility", "visible" }, - .{ "style.getPropertyValue('visibility')", "visible" }, - }, .{}); -} diff --git a/src/browser/cssom/css_stylesheet.zig b/src/browser/cssom/css_stylesheet.zig deleted file mode 100644 index bb03d79cc..000000000 --- a/src/browser/cssom/css_stylesheet.zig +++ /dev/null @@ -1,91 +0,0 @@ -// 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 StyleSheet = @import("stylesheet.zig").StyleSheet; - -const CSSRuleList = @import("css_rule_list.zig").CSSRuleList; -const CSSImportRule = @import("css_rule.zig").CSSImportRule; - -pub const CSSStyleSheet = struct { - pub const prototype = *StyleSheet; - - proto: StyleSheet, - css_rules: CSSRuleList, - owner_rule: ?*CSSImportRule, - - const CSSStyleSheetOpts = struct { - base_url: ?[]const u8 = null, - // TODO: Suupport media - disabled: bool = false, - }; - - pub fn constructor(_opts: ?CSSStyleSheetOpts) !CSSStyleSheet { - const opts = _opts orelse CSSStyleSheetOpts{}; - return .{ - .proto = StyleSheet{ .disabled = opts.disabled }, - .css_rules = .constructor(), - .owner_rule = null, - }; - } - - pub fn get_ownerRule(_: *CSSStyleSheet) ?*CSSImportRule { - return null; - } - - pub fn get_cssRules(self: *CSSStyleSheet) *CSSRuleList { - return &self.css_rules; - } - - pub fn _insertRule(self: *CSSStyleSheet, rule: []const u8, _index: ?usize, page: *Page) !usize { - const index = _index orelse 0; - if (index > self.css_rules.list.items.len) { - return error.IndexSize; - } - - const arena = page.arena; - try self.css_rules.list.insert(arena, index, try arena.dupe(u8, rule)); - return index; - } - - pub fn _deleteRule(self: *CSSStyleSheet, index: usize) !void { - if (index > self.css_rules.list.items.len) { - return error.IndexSize; - } - - _ = self.css_rules.list.orderedRemove(index); - } -}; - -const testing = @import("../../testing.zig"); -test "Browser.CSS.StyleSheet" { - var runner = try testing.jsRunner(testing.tracking_allocator, .{}); - defer runner.deinit(); - - try runner.testCases(&.{ - .{ "let css = new CSSStyleSheet()", "undefined" }, - .{ "css instanceof CSSStyleSheet", "true" }, - .{ "css.cssRules.length", "0" }, - .{ "css.ownerRule", "null" }, - .{ "let index1 = css.insertRule('body { color: red; }', 0)", "undefined" }, - .{ "index1", "0" }, - .{ "css.cssRules.length", "1" }, - }, .{}); -} diff --git a/src/browser/cssom/css_value_analyzer.zig b/src/browser/cssom/css_value_analyzer.zig deleted file mode 100644 index 1fb35ed26..000000000 --- a/src/browser/cssom/css_value_analyzer.zig +++ /dev/null @@ -1,811 +0,0 @@ -// 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"); - -pub const CSSValueAnalyzer = struct { - pub fn isNumericWithUnit(value: []const u8) bool { - if (value.len == 0) return false; - - if (!std.ascii.isDigit(value[0]) and - value[0] != '+' and value[0] != '-' and value[0] != '.') - { - return false; - } - - var i: usize = 0; - var has_digit = false; - var decimal_point = false; - - while (i < value.len) : (i += 1) { - const c = value[i]; - if (std.ascii.isDigit(c)) { - has_digit = true; - } else if (c == '.' and !decimal_point) { - decimal_point = true; - } else if ((c == 'e' or c == 'E') and has_digit) { - if (i + 1 >= value.len) return false; - if (value[i + 1] != '+' and value[i + 1] != '-' and !std.ascii.isDigit(value[i + 1])) break; - i += 1; - if (value[i] == '+' or value[i] == '-') { - i += 1; - } - var has_exp_digits = false; - while (i < value.len and std.ascii.isDigit(value[i])) : (i += 1) { - has_exp_digits = true; - } - if (!has_exp_digits) return false; - break; - } else if (c != '-' and c != '+') { - break; - } - } - - if (!has_digit) return false; - - if (i == value.len) return true; - - const unit = value[i..]; - return CSSKeywords.isValidUnit(unit); - } - - pub fn isHexColor(value: []const u8) bool { - if (!std.mem.startsWith(u8, value, "#")) return false; - - const hex_part = value[1..]; - if (hex_part.len != 3 and hex_part.len != 6 and hex_part.len != 8) return false; - - for (hex_part) |c| { - if (!std.ascii.isHex(c)) return false; - } - - return true; - } - - pub fn isMultiValueProperty(value: []const u8) bool { - var parts = std.mem.splitAny(u8, value, " "); - var multi_value_parts: usize = 0; - var all_parts_valid = true; - - while (parts.next()) |part| { - if (part.len == 0) continue; - multi_value_parts += 1; - - const is_numeric = isNumericWithUnit(part); - const is_hex_color = isHexColor(part); - const is_known_keyword = CSSKeywords.isKnownKeyword(part); - const is_function = CSSKeywords.startsWithFunction(part); - - if (!is_numeric and !is_hex_color and !is_known_keyword and !is_function) { - all_parts_valid = false; - break; - } - } - - return multi_value_parts >= 2 and all_parts_valid; - } - - pub fn isAlreadyQuoted(value: []const u8) bool { - return value.len >= 2 and ((value[0] == '"' and value[value.len - 1] == '"') or - (value[0] == '\'' and value[value.len - 1] == '\'')); - } - - pub fn isValidPropertyName(name: []const u8) bool { - if (name.len == 0) return false; - - if (std.mem.startsWith(u8, name, "--")) { - if (name.len == 2) return false; - for (name[2..]) |c| { - if (!std.ascii.isAlphanumeric(c) and c != '-' and c != '_') { - return false; - } - } - return true; - } - - const first_char = name[0]; - if (!std.ascii.isAlphabetic(first_char) and first_char != '-') { - return false; - } - - if (first_char == '-') { - if (name.len < 2) return false; - - if (!std.ascii.isAlphabetic(name[1])) { - return false; - } - - for (name[2..]) |c| { - if (!std.ascii.isAlphanumeric(c) and c != '-') { - return false; - } - } - } else { - for (name[1..]) |c| { - if (!std.ascii.isAlphanumeric(c) and c != '-') { - return false; - } - } - } - - return true; - } - - pub fn extractImportant(value: []const u8) struct { value: []const u8, is_important: bool } { - const trimmed = std.mem.trim(u8, value, &std.ascii.whitespace); - - if (std.mem.endsWith(u8, trimmed, "!important")) { - const clean_value = std.mem.trimRight(u8, trimmed[0 .. trimmed.len - 10], &std.ascii.whitespace); - return .{ .value = clean_value, .is_important = true }; - } - - return .{ .value = trimmed, .is_important = false }; - } - - pub fn needsQuotes(value: []const u8) bool { - if (value.len == 0) return true; - if (isAlreadyQuoted(value)) return false; - - if (CSSKeywords.containsSpecialChar(value)) { - return true; - } - - if (std.mem.indexOfScalar(u8, value, ' ') == null) { - return false; - } - - const is_url = std.mem.startsWith(u8, value, "url("); - const is_function = CSSKeywords.startsWithFunction(value); - - return !isMultiValueProperty(value) and - !is_url and - !is_function; - } - - pub fn escapeCSSValue(arena: std.mem.Allocator, value: []const u8) ![]const u8 { - if (!needsQuotes(value)) { - return value; - } - var out: std.ArrayListUnmanaged(u8) = .empty; - - // We'll need at least this much space, +2 for the quotes - try out.ensureTotalCapacity(arena, value.len + 2); - const writer = out.writer(arena); - - try writer.writeByte('"'); - - for (value, 0..) |c, i| { - switch (c) { - '"' => try writer.writeAll("\\\""), - '\\' => try writer.writeAll("\\\\"), - '\n' => try writer.writeAll("\\A "), - '\r' => try writer.writeAll("\\D "), - '\t' => try writer.writeAll("\\9 "), - 0...8, 11, 12, 14...31, 127 => { - try writer.print("\\{x}", .{c}); - if (i + 1 < value.len and std.ascii.isHex(value[i + 1])) { - try writer.writeByte(' '); - } - }, - else => try writer.writeByte(c), - } - } - - try writer.writeByte('"'); - return out.items; - } - - pub fn isKnownKeyword(value: []const u8) bool { - return CSSKeywords.isKnownKeyword(value); - } - - pub fn containsSpecialChar(value: []const u8) bool { - return CSSKeywords.containsSpecialChar(value); - } -}; - -const CSSKeywords = struct { - const border_styles = [_][]const u8{ - "none", "solid", "dotted", "dashed", "double", "groove", "ridge", "inset", "outset", - }; - - const color_names = [_][]const u8{ - "black", "white", "red", "green", "blue", "yellow", "purple", "gray", "transparent", - "currentColor", "inherit", - }; - - const position_keywords = [_][]const u8{ - "auto", "center", "left", "right", "top", "bottom", - }; - - const background_repeat = [_][]const u8{ - "repeat", "no-repeat", "repeat-x", "repeat-y", "space", "round", - }; - - const font_styles = [_][]const u8{ - "normal", "italic", "oblique", "bold", "bolder", "lighter", - }; - - const font_sizes = [_][]const u8{ - "xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", - "smaller", "larger", - }; - - const font_families = [_][]const u8{ - "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", - }; - - const css_global = [_][]const u8{ - "initial", "inherit", "unset", "revert", - }; - - const display_values = [_][]const u8{ - "block", "inline", "inline-block", "flex", "grid", "none", - }; - - const length_units = [_][]const u8{ - "px", "em", "rem", "vw", "vh", "vmin", "vmax", "%", "pt", "pc", "in", "cm", "mm", - "ex", "ch", "fr", - }; - - const angle_units = [_][]const u8{ - "deg", "rad", "grad", "turn", - }; - - const time_units = [_][]const u8{ - "s", "ms", - }; - - const frequency_units = [_][]const u8{ - "Hz", "kHz", - }; - - const resolution_units = [_][]const u8{ - "dpi", "dpcm", "dppx", - }; - - const special_chars = [_]u8{ - '"', '\'', ';', '{', '}', '\\', '<', '>', '/', '\n', '\t', '\r', '\x00', '\x7F', - }; - - const functions = [_][]const u8{ - "rgb(", "rgba(", "hsl(", "hsla(", "url(", "calc(", "var(", "attr(", - "linear-gradient(", "radial-gradient(", "conic-gradient(", "translate(", "rotate(", "scale(", "skew(", "matrix(", - }; - - pub fn isKnownKeyword(value: []const u8) bool { - const all_categories = [_][]const []const u8{ - &border_styles, &color_names, &position_keywords, &background_repeat, - &font_styles, &font_sizes, &font_families, &css_global, - &display_values, - }; - - for (all_categories) |category| { - for (category) |keyword| { - if (std.ascii.eqlIgnoreCase(value, keyword)) { - return true; - } - } - } - - return false; - } - - pub fn containsSpecialChar(value: []const u8) bool { - for (value) |c| { - for (special_chars) |special| { - if (c == special) { - return true; - } - } - } - return false; - } - - pub fn isValidUnit(unit: []const u8) bool { - const all_units = [_][]const []const u8{ - &length_units, &angle_units, &time_units, &frequency_units, &resolution_units, - }; - - for (all_units) |category| { - for (category) |valid_unit| { - if (std.ascii.eqlIgnoreCase(unit, valid_unit)) { - return true; - } - } - } - - return false; - } - - pub fn startsWithFunction(value: []const u8) bool { - const pos = std.mem.indexOfScalar(u8, value, '(') orelse return false; - if (pos == 0) return false; - - if (std.mem.indexOfScalarPos(u8, value, pos, ')') == null) { - return false; - } - const function_name = value[0..pos]; - return isValidFunctionName(function_name); - } - - fn isValidFunctionName(name: []const u8) bool { - if (name.len == 0) return false; - - const first = name[0]; - if (!std.ascii.isAlphabetic(first) and first != '_' and first != '-') { - return false; - } - - for (name[1..]) |c| { - if (!std.ascii.isAlphanumeric(c) and c != '_' and c != '-') { - return false; - } - } - - return true; - } -}; - -const testing = @import("../../testing.zig"); - -test "CSSValueAnalyzer: isNumericWithUnit - valid numbers with units" { - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("10px")); - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("3.14em")); - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-5rem")); - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("+12.5%")); - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0vh")); - try testing.expect(CSSValueAnalyzer.isNumericWithUnit(".5vw")); -} - -test "CSSValueAnalyzer: isNumericWithUnit - scientific notation" { - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e5px")); - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("2.5E-3em")); - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e+2rem")); - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-3.14e10px")); -} - -test "CSSValueAnalyzer: isNumericWithUnit - edge cases and invalid inputs" { - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("")); - - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("px")); - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("--px")); - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(".px")); - - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1e")); - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1epx")); - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1e+")); - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1e+px")); - - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1.2.3px")); - - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10xyz")); - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("5invalid")); - - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("10")); - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("3.14")); - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-5")); -} - -test "CSSValueAnalyzer: isHexColor - valid hex colors" { - try testing.expect(CSSValueAnalyzer.isHexColor("#000")); - try testing.expect(CSSValueAnalyzer.isHexColor("#fff")); - try testing.expect(CSSValueAnalyzer.isHexColor("#123456")); - try testing.expect(CSSValueAnalyzer.isHexColor("#abcdef")); - try testing.expect(CSSValueAnalyzer.isHexColor("#ABCDEF")); - try testing.expect(CSSValueAnalyzer.isHexColor("#12345678")); -} - -test "CSSValueAnalyzer: isHexColor - invalid hex colors" { - try testing.expect(!CSSValueAnalyzer.isHexColor("")); - try testing.expect(!CSSValueAnalyzer.isHexColor("#")); - try testing.expect(!CSSValueAnalyzer.isHexColor("000")); - try testing.expect(!CSSValueAnalyzer.isHexColor("#00")); - try testing.expect(!CSSValueAnalyzer.isHexColor("#0000")); - try testing.expect(!CSSValueAnalyzer.isHexColor("#00000")); - try testing.expect(!CSSValueAnalyzer.isHexColor("#0000000")); - try testing.expect(!CSSValueAnalyzer.isHexColor("#000000000")); - try testing.expect(!CSSValueAnalyzer.isHexColor("#gggggg")); - try testing.expect(!CSSValueAnalyzer.isHexColor("#123xyz")); -} - -test "CSSValueAnalyzer: isMultiValueProperty - valid multi-value properties" { - try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px")); - try testing.expect(CSSValueAnalyzer.isMultiValueProperty("solid red")); - try testing.expect(CSSValueAnalyzer.isMultiValueProperty("#fff black")); - try testing.expect(CSSValueAnalyzer.isMultiValueProperty("1em 2em 3em 4em")); - try testing.expect(CSSValueAnalyzer.isMultiValueProperty("rgb(255,0,0) solid")); -} - -test "CSSValueAnalyzer: isMultiValueProperty - invalid multi-value properties" { - try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("")); - try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px")); - try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("invalid unknown")); - try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px invalid")); - try testing.expect(!CSSValueAnalyzer.isMultiValueProperty(" ")); -} - -test "CSSValueAnalyzer: isAlreadyQuoted - various quoting scenarios" { - try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"hello\"")); - try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'world'")); - try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"\"")); - try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("''")); - - try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("")); - try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello")); - try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"")); - try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("'")); - try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"hello'")); - try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("'hello\"")); - try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"hello")); - try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello\"")); -} - -test "CSSValueAnalyzer: isValidPropertyName - valid property names" { - try testing.expect(CSSValueAnalyzer.isValidPropertyName("color")); - try testing.expect(CSSValueAnalyzer.isValidPropertyName("background-color")); - try testing.expect(CSSValueAnalyzer.isValidPropertyName("-webkit-transform")); - try testing.expect(CSSValueAnalyzer.isValidPropertyName("font-size")); - try testing.expect(CSSValueAnalyzer.isValidPropertyName("margin-top")); - try testing.expect(CSSValueAnalyzer.isValidPropertyName("z-index")); - try testing.expect(CSSValueAnalyzer.isValidPropertyName("line-height")); -} - -test "CSSValueAnalyzer: isValidPropertyName - invalid property names" { - try testing.expect(!CSSValueAnalyzer.isValidPropertyName("")); - try testing.expect(!CSSValueAnalyzer.isValidPropertyName("123color")); - try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color!")); - try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color space")); - try testing.expect(!CSSValueAnalyzer.isValidPropertyName("@color")); - try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color.test")); - try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color_test")); -} - -test "CSSValueAnalyzer: extractImportant - with and without !important" { - var result = CSSValueAnalyzer.extractImportant("red !important"); - try testing.expect(result.is_important); - try testing.expectEqual("red", result.value); - - result = CSSValueAnalyzer.extractImportant("blue"); - try testing.expect(!result.is_important); - try testing.expectEqual("blue", result.value); - - result = CSSValueAnalyzer.extractImportant(" green !important "); - try testing.expect(result.is_important); - try testing.expectEqual("green", result.value); - - result = CSSValueAnalyzer.extractImportant("!important"); - try testing.expect(result.is_important); - try testing.expectEqual("", result.value); - - result = CSSValueAnalyzer.extractImportant("important"); - try testing.expect(!result.is_important); - try testing.expectEqual("important", result.value); -} - -test "CSSValueAnalyzer: needsQuotes - various scenarios" { - try testing.expect(CSSValueAnalyzer.needsQuotes("")); - try testing.expect(CSSValueAnalyzer.needsQuotes("hello world")); - try testing.expect(CSSValueAnalyzer.needsQuotes("test;")); - try testing.expect(CSSValueAnalyzer.needsQuotes("a{b}")); - try testing.expect(CSSValueAnalyzer.needsQuotes("test\"quote")); - - try testing.expect(!CSSValueAnalyzer.needsQuotes("\"already quoted\"")); - try testing.expect(!CSSValueAnalyzer.needsQuotes("'already quoted'")); - try testing.expect(!CSSValueAnalyzer.needsQuotes("url(image.png)")); - try testing.expect(!CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0)")); - try testing.expect(!CSSValueAnalyzer.needsQuotes("10px 20px")); - try testing.expect(!CSSValueAnalyzer.needsQuotes("simple")); -} - -test "CSSValueAnalyzer: escapeCSSValue - escaping various characters" { - const allocator = testing.arena_allocator; - - var result = try CSSValueAnalyzer.escapeCSSValue(allocator, "simple"); - try testing.expectEqual("simple", result); - - result = try CSSValueAnalyzer.escapeCSSValue(allocator, "\"already quoted\""); - try testing.expectEqual("\"already quoted\"", result); - - result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\"quote"); - try testing.expectEqual("\"test\\\"quote\"", result); - - result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\nline"); - try testing.expectEqual("\"test\\A line\"", result); - - result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\\back"); - try testing.expectEqual("\"test\\\\back\"", result); -} - -test "CSSValueAnalyzer: CSSKeywords.isKnownKeyword - case sensitivity" { - try testing.expect(CSSKeywords.isKnownKeyword("red")); - try testing.expect(CSSKeywords.isKnownKeyword("solid")); - try testing.expect(CSSKeywords.isKnownKeyword("center")); - try testing.expect(CSSKeywords.isKnownKeyword("inherit")); - - try testing.expect(CSSKeywords.isKnownKeyword("RED")); - try testing.expect(CSSKeywords.isKnownKeyword("Red")); - try testing.expect(CSSKeywords.isKnownKeyword("SOLID")); - try testing.expect(CSSKeywords.isKnownKeyword("Center")); - - try testing.expect(!CSSKeywords.isKnownKeyword("invalid")); - try testing.expect(!CSSKeywords.isKnownKeyword("unknown")); - try testing.expect(!CSSKeywords.isKnownKeyword("")); -} - -test "CSSValueAnalyzer: CSSKeywords.containsSpecialChar - various special characters" { - try testing.expect(CSSKeywords.containsSpecialChar("test\"quote")); - try testing.expect(CSSKeywords.containsSpecialChar("test'quote")); - try testing.expect(CSSKeywords.containsSpecialChar("test;end")); - try testing.expect(CSSKeywords.containsSpecialChar("test{brace")); - try testing.expect(CSSKeywords.containsSpecialChar("test}brace")); - try testing.expect(CSSKeywords.containsSpecialChar("test\\back")); - try testing.expect(CSSKeywords.containsSpecialChar("testangle")); - try testing.expect(CSSKeywords.containsSpecialChar("test/slash")); - - try testing.expect(!CSSKeywords.containsSpecialChar("normal-text")); - try testing.expect(!CSSKeywords.containsSpecialChar("text123")); - try testing.expect(!CSSKeywords.containsSpecialChar("")); -} - -test "CSSValueAnalyzer: CSSKeywords.isValidUnit - various units" { - try testing.expect(CSSKeywords.isValidUnit("px")); - try testing.expect(CSSKeywords.isValidUnit("em")); - try testing.expect(CSSKeywords.isValidUnit("rem")); - try testing.expect(CSSKeywords.isValidUnit("%")); - - try testing.expect(CSSKeywords.isValidUnit("deg")); - try testing.expect(CSSKeywords.isValidUnit("rad")); - - try testing.expect(CSSKeywords.isValidUnit("s")); - try testing.expect(CSSKeywords.isValidUnit("ms")); - - try testing.expect(CSSKeywords.isValidUnit("PX")); - - try testing.expect(!CSSKeywords.isValidUnit("invalid")); - try testing.expect(!CSSKeywords.isValidUnit("")); -} - -test "CSSValueAnalyzer: CSSKeywords.startsWithFunction - function detection" { - try testing.expect(CSSKeywords.startsWithFunction("rgb(255, 0, 0)")); - try testing.expect(CSSKeywords.startsWithFunction("rgba(255, 0, 0, 0.5)")); - try testing.expect(CSSKeywords.startsWithFunction("url(image.png)")); - try testing.expect(CSSKeywords.startsWithFunction("calc(100% - 20px)")); - try testing.expect(CSSKeywords.startsWithFunction("var(--custom-property)")); - try testing.expect(CSSKeywords.startsWithFunction("linear-gradient(to right, red, blue)")); - - try testing.expect(CSSKeywords.startsWithFunction("custom-function(args)")); - try testing.expect(CSSKeywords.startsWithFunction("unknown(test)")); - - try testing.expect(!CSSKeywords.startsWithFunction("not-a-function")); - try testing.expect(!CSSKeywords.startsWithFunction("missing-paren)")); - try testing.expect(!CSSKeywords.startsWithFunction("missing-close(")); - try testing.expect(!CSSKeywords.startsWithFunction("")); - try testing.expect(!CSSKeywords.startsWithFunction("rgb")); -} - -test "CSSValueAnalyzer: isNumericWithUnit - whitespace handling" { - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(" 10px")); - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10 px")); - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10px ")); - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(" 10 px ")); -} - -test "CSSValueAnalyzer: extractImportant - whitespace edge cases" { - var result = CSSValueAnalyzer.extractImportant(" "); - try testing.expect(!result.is_important); - try testing.expectEqual("", result.value); - - result = CSSValueAnalyzer.extractImportant("\t\n\r !important\t\n"); - try testing.expect(result.is_important); - try testing.expectEqual("", result.value); - - result = CSSValueAnalyzer.extractImportant("red\t!important"); - try testing.expect(result.is_important); - try testing.expectEqual("red", result.value); -} - -test "CSSValueAnalyzer: isHexColor - mixed case handling" { - try testing.expect(CSSValueAnalyzer.isHexColor("#AbC")); - try testing.expect(CSSValueAnalyzer.isHexColor("#123aBc")); - try testing.expect(CSSValueAnalyzer.isHexColor("#FFffFF")); - try testing.expect(CSSValueAnalyzer.isHexColor("#000FFF")); -} - -test "CSSValueAnalyzer: edge case - very long inputs" { - const long_valid = "a" ** 1000 ++ "px"; - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(long_valid)); // not numeric - - const long_property = "a-" ** 100 ++ "property"; - try testing.expect(CSSValueAnalyzer.isValidPropertyName(long_property)); - - const long_hex = "#" ++ "a" ** 20; - try testing.expect(!CSSValueAnalyzer.isHexColor(long_hex)); -} - -test "CSSValueAnalyzer: boundary conditions - numeric parsing" { - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0px")); - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0.0px")); - try testing.expect(CSSValueAnalyzer.isNumericWithUnit(".0px")); - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0.px")); - - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("999999999px")); - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1.7976931348623157e+308px")); - - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0.000000001px")); - try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e-100px")); -} - -test "CSSValueAnalyzer: extractImportant - malformed important declarations" { - var result = CSSValueAnalyzer.extractImportant("red ! important"); - try testing.expect(!result.is_important); - try testing.expectEqual("red ! important", result.value); - - result = CSSValueAnalyzer.extractImportant("red !Important"); - try testing.expect(!result.is_important); - try testing.expectEqual("red !Important", result.value); - - result = CSSValueAnalyzer.extractImportant("red !IMPORTANT"); - try testing.expect(!result.is_important); - try testing.expectEqual("red !IMPORTANT", result.value); - - result = CSSValueAnalyzer.extractImportant("!importantred"); - try testing.expect(!result.is_important); - try testing.expectEqual("!importantred", result.value); - - result = CSSValueAnalyzer.extractImportant("red !important !important"); - try testing.expect(result.is_important); - try testing.expectEqual("red !important", result.value); -} - -test "CSSValueAnalyzer: isMultiValueProperty - complex spacing scenarios" { - try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px")); - try testing.expect(CSSValueAnalyzer.isMultiValueProperty("solid red")); - - try testing.expect(CSSValueAnalyzer.isMultiValueProperty(" 10px 20px ")); - - try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px\t20px")); - try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px\n20px")); - - try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px 30px")); -} - -test "CSSValueAnalyzer: isAlreadyQuoted - edge cases with quotes" { - try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"'hello'\"")); - try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'\"hello\"'")); - - try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"hello\\\"world\"")); - try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'hello\\'world'")); - - try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"hello")); - try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello\"")); - try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("'hello")); - try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello'")); - - try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"a\"")); - try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'b'")); -} - -test "CSSValueAnalyzer: needsQuotes - function and URL edge cases" { - try testing.expect(!CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0)")); - try testing.expect(!CSSValueAnalyzer.needsQuotes("calc(100% - 20px)")); - - try testing.expect(!CSSValueAnalyzer.needsQuotes("url(path with spaces.jpg)")); - - try testing.expect(!CSSValueAnalyzer.needsQuotes("linear-gradient(to right, red, blue)")); - - try testing.expect(CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0")); -} - -test "CSSValueAnalyzer: escapeCSSValue - control characters and Unicode" { - const allocator = testing.arena_allocator; - - var result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\ttab"); - try testing.expectEqual("\"test\\9 tab\"", result); - - result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\rreturn"); - try testing.expectEqual("\"test\\D return\"", result); - - result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\x00null"); - try testing.expectEqual("\"test\\0null\"", result); - - result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\x7Fdel"); - try testing.expectEqual("\"test\\7f del\"", result); - - result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\"quote\nline\\back"); - try testing.expectEqual("\"test\\\"quote\\A line\\\\back\"", result); -} - -test "CSSValueAnalyzer: isValidPropertyName - CSS custom properties and vendor prefixes" { - try testing.expect(CSSValueAnalyzer.isValidPropertyName("--custom-color")); - try testing.expect(CSSValueAnalyzer.isValidPropertyName("--my-variable")); - try testing.expect(CSSValueAnalyzer.isValidPropertyName("--123")); - - try testing.expect(CSSValueAnalyzer.isValidPropertyName("-webkit-transform")); - try testing.expect(CSSValueAnalyzer.isValidPropertyName("-moz-border-radius")); - try testing.expect(CSSValueAnalyzer.isValidPropertyName("-ms-filter")); - try testing.expect(CSSValueAnalyzer.isValidPropertyName("-o-transition")); - - try testing.expect(!CSSValueAnalyzer.isValidPropertyName("-123invalid")); - try testing.expect(!CSSValueAnalyzer.isValidPropertyName("--")); - try testing.expect(!CSSValueAnalyzer.isValidPropertyName("-")); -} - -test "CSSValueAnalyzer: startsWithFunction - case sensitivity and partial matches" { - try testing.expect(CSSKeywords.startsWithFunction("RGB(255, 0, 0)")); - try testing.expect(CSSKeywords.startsWithFunction("Rgb(255, 0, 0)")); - try testing.expect(CSSKeywords.startsWithFunction("URL(image.png)")); - - try testing.expect(CSSKeywords.startsWithFunction("rg(something)")); - try testing.expect(CSSKeywords.startsWithFunction("ur(something)")); - - try testing.expect(CSSKeywords.startsWithFunction("rgb(1,2,3)")); - try testing.expect(CSSKeywords.startsWithFunction("rgba(1,2,3,4)")); - - try testing.expect(CSSKeywords.startsWithFunction("my-custom-function(args)")); - try testing.expect(CSSKeywords.startsWithFunction("function-with-dashes(test)")); - - try testing.expect(!CSSKeywords.startsWithFunction("123function(test)")); -} - -test "CSSValueAnalyzer: isHexColor - Unicode and invalid characters" { - try testing.expect(!CSSValueAnalyzer.isHexColor("#ghijkl")); - try testing.expect(!CSSValueAnalyzer.isHexColor("#12345g")); - try testing.expect(!CSSValueAnalyzer.isHexColor("#xyz")); - - try testing.expect(!CSSValueAnalyzer.isHexColor("#АВС")); - - try testing.expect(!CSSValueAnalyzer.isHexColor("#1234567g")); - try testing.expect(!CSSValueAnalyzer.isHexColor("#g2345678")); -} - -test "CSSValueAnalyzer: complex integration scenarios" { - const allocator = testing.arena_allocator; - - try testing.expect(CSSValueAnalyzer.isMultiValueProperty("rgb(255,0,0) url(bg.jpg)")); - - try testing.expect(!CSSValueAnalyzer.needsQuotes("calc(100% - 20px)")); - - const result = try CSSValueAnalyzer.escapeCSSValue(allocator, "fake(function with spaces"); - try testing.expectEqual("\"fake(function with spaces\"", result); - - const important_result = CSSValueAnalyzer.extractImportant("rgb(255,0,0) !important"); - try testing.expect(important_result.is_important); - try testing.expectEqual("rgb(255,0,0)", important_result.value); -} - -test "CSSValueAnalyzer: performance edge cases - empty and minimal inputs" { - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("")); - try testing.expect(!CSSValueAnalyzer.isHexColor("")); - try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("")); - try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("")); - try testing.expect(!CSSValueAnalyzer.isValidPropertyName("")); - try testing.expect(CSSValueAnalyzer.needsQuotes("")); - try testing.expect(!CSSKeywords.isKnownKeyword("")); - try testing.expect(!CSSKeywords.containsSpecialChar("")); - try testing.expect(!CSSKeywords.isValidUnit("")); - try testing.expect(!CSSKeywords.startsWithFunction("")); - - try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("a")); - try testing.expect(!CSSValueAnalyzer.isHexColor("a")); - try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("a")); - try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("a")); - try testing.expect(CSSValueAnalyzer.isValidPropertyName("a")); - try testing.expect(!CSSValueAnalyzer.needsQuotes("a")); -} diff --git a/src/browser/cssom/cssom.zig b/src/browser/cssom/cssom.zig index ffd2eabb3..381620a2a 100644 --- a/src/browser/cssom/cssom.zig +++ b/src/browser/cssom/cssom.zig @@ -16,15 +16,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -pub const Stylesheet = @import("stylesheet.zig").StyleSheet; -pub const CSSStylesheet = @import("css_stylesheet.zig").CSSStyleSheet; -pub const CSSStyleDeclaration = @import("css_style_declaration.zig").CSSStyleDeclaration; -pub const CSSRuleList = @import("css_rule_list.zig").CSSRuleList; - pub const Interfaces = .{ - Stylesheet, - CSSStylesheet, - CSSStyleDeclaration, - CSSRuleList, - @import("css_rule.zig").Interfaces, + @import("StyleSheet.zig"), + @import("CSSStyleSheet.zig"), + @import("CSSStyleDeclaration.zig"), + @import("CSSRuleList.zig"), + @import("CSSRule.zig").Interfaces, }; diff --git a/src/browser/cssom/stylesheet.zig b/src/browser/cssom/stylesheet.zig index 7086063ce..13d12c99c 100644 --- a/src/browser/cssom/stylesheet.zig +++ b/src/browser/cssom/stylesheet.zig @@ -19,37 +19,37 @@ const parser = @import("../netsurf.zig"); // https://developer.mozilla.org/en-US/docs/Web/API/StyleSheet#specifications -pub const StyleSheet = struct { - disabled: bool = false, - href: []const u8 = "", - owner_node: ?*parser.Node = null, - parent_stylesheet: ?*StyleSheet = null, - title: []const u8 = "", - type: []const u8 = "text/css", - - pub fn get_disabled(self: *const StyleSheet) bool { - return self.disabled; - } - - pub fn get_href(self: *const StyleSheet) []const u8 { - return self.href; - } - - // TODO: media - - pub fn get_ownerNode(self: *const StyleSheet) ?*parser.Node { - return self.owner_node; - } - - pub fn get_parentStyleSheet(self: *const StyleSheet) ?*StyleSheet { - return self.parent_stylesheet; - } - - pub fn get_title(self: *const StyleSheet) []const u8 { - return self.title; - } - - pub fn get_type(self: *const StyleSheet) []const u8 { - return self.type; - } -}; +const StyleSheet = @This(); + +disabled: bool = false, +href: []const u8 = "", +owner_node: ?*parser.Node = null, +parent_stylesheet: ?*StyleSheet = null, +title: []const u8 = "", +type: []const u8 = "text/css", + +pub fn get_disabled(self: *const StyleSheet) bool { + return self.disabled; +} + +pub fn get_href(self: *const StyleSheet) []const u8 { + return self.href; +} + +// TODO: media + +pub fn get_ownerNode(self: *const StyleSheet) ?*parser.Node { + return self.owner_node; +} + +pub fn get_parentStyleSheet(self: *const StyleSheet) ?*StyleSheet { + return self.parent_stylesheet; +} + +pub fn get_title(self: *const StyleSheet) []const u8 { + return self.title; +} + +pub fn get_type(self: *const StyleSheet) []const u8 { + return self.type; +} diff --git a/src/browser/dom/document.zig b/src/browser/dom/document.zig index 58d0bf963..eb206cdf2 100644 --- a/src/browser/dom/document.zig +++ b/src/browser/dom/document.zig @@ -32,7 +32,7 @@ const css = @import("css.zig"); const Element = @import("element.zig").Element; const ElementUnion = @import("element.zig").Union; const TreeWalker = @import("tree_walker.zig").TreeWalker; -const CSSStyleSheet = @import("../cssom/css_stylesheet.zig").CSSStyleSheet; +const CSSStyleSheet = @import("../cssom/CSSStyleSheet.zig"); const NodeIterator = @import("node_iterator.zig").NodeIterator; const Range = @import("range.zig").Range; diff --git a/src/browser/html/elements.zig b/src/browser/html/elements.zig index 4d72ebd49..20c37a01b 100644 --- a/src/browser/html/elements.zig +++ b/src/browser/html/elements.zig @@ -29,8 +29,8 @@ const Node = @import("../dom/node.zig").Node; const Element = @import("../dom/element.zig").Element; const DataSet = @import("DataSet.zig"); -const StyleSheet = @import("../cssom/stylesheet.zig").StyleSheet; -const CSSStyleDeclaration = @import("../cssom/css_style_declaration.zig").CSSStyleDeclaration; +const StyleSheet = @import("../cssom/StyleSheet.zig"); +const CSSStyleDeclaration = @import("../cssom/CSSStyleDeclaration.zig"); // HTMLElement interfaces pub const Interfaces = .{ diff --git a/src/browser/html/window.zig b/src/browser/html/window.zig index ef688b52f..b1aedf513 100644 --- a/src/browser/html/window.zig +++ b/src/browser/html/window.zig @@ -32,7 +32,7 @@ const Console = @import("../console/console.zig").Console; const EventTarget = @import("../dom/event_target.zig").EventTarget; const MediaQueryList = @import("media_query_list.zig").MediaQueryList; const Performance = @import("../dom/performance.zig").Performance; -const CSSStyleDeclaration = @import("../cssom/css_style_declaration.zig").CSSStyleDeclaration; +const CSSStyleDeclaration = @import("../cssom/CSSStyleDeclaration.zig"); const Screen = @import("screen.zig").Screen; const Css = @import("../css/css.zig").Css; From 8a0c4909b9f004815e2a96a629b7b9f9b028227d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 23 Jul 2025 16:06:07 +0800 Subject: [PATCH 2/2] fix file casing --- src/browser/cssom/{stylesheet.zig => StyleSheet.zig} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/browser/cssom/{stylesheet.zig => StyleSheet.zig} (100%) diff --git a/src/browser/cssom/stylesheet.zig b/src/browser/cssom/StyleSheet.zig similarity index 100% rename from src/browser/cssom/stylesheet.zig rename to src/browser/cssom/StyleSheet.zig