diff --git a/src/browser/html/elements.zig b/src/browser/html/elements.zig index 15006bc5..a02861e0 100644 --- a/src/browser/html/elements.zig +++ b/src/browser/html/elements.zig @@ -245,8 +245,7 @@ pub const HTMLAnchorElement = struct { } inline fn url(self: *parser.Anchor, page: *Page) !URL { - const href = try parser.anchorGetHref(self); - return URL.constructor(href, null, page); // TODO inject base url + return URL.constructor(.{ .element = @ptrCast(self) }, null, page); // TODO inject base url } // TODO return a disposable string @@ -391,23 +390,16 @@ pub const HTMLAnchorElement = struct { try parser.anchorSetHref(self, href); } - // TODO return a disposable string pub fn get_search(self: *parser.Anchor, page: *Page) ![]const u8 { var u = try url(self, page); return try u.get_search(page); } pub fn set_search(self: *parser.Anchor, v: ?[]const u8, page: *Page) !void { - const arena = page.arena; var u = try url(self, page); + try u.set_search(v, page); - if (v) |vv| { - u.uri.query = .{ .raw = vv }; - } else { - u.uri.query = null; - } - const href = try u.toString(arena); - + const href = try u.toString(page.call_arena); try parser.anchorSetHref(self, href); } diff --git a/src/browser/url/query.zig b/src/browser/url/query.zig deleted file mode 100644 index a8621e5e..00000000 --- a/src/browser/url/query.zig +++ /dev/null @@ -1,447 +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; - -// Values is a map with string key of string values. -pub const Values = struct { - map: std.StringArrayHashMapUnmanaged(List) = .{}, - - const List = std.ArrayListUnmanaged([]const u8); - - // add the key value couple to the values. - // the key and the value are duplicated. - pub fn append(self: *Values, arena: Allocator, k: []const u8, v: []const u8) !void { - const owned_value = try arena.dupe(u8, v); - - var gop = try self.map.getOrPut(arena, k); - errdefer _ = self.map.orderedRemove(k); - - if (gop.found_existing) { - return gop.value_ptr.append(arena, owned_value); - } - - gop.key_ptr.* = try arena.dupe(u8, k); - - var list = List{}; - try list.append(arena, owned_value); - gop.value_ptr.* = list; - } - - pub fn set(self: *Values, arena: Allocator, k: []const u8, v: []const u8) !void { - const owned_value = try allocator.dupe(u8, v); - - var gop = try self.map.getOrPut(allocator, k); - errdefer _ = self.map.remove(k); - - if (gop.found_existing) { - gop.value_ptr.clearRetainingCapacity(); - } else { - gop._key_ptr.* = try arena.dupe(u8, k); - gop.value_ptr.* = .empty; - } - try gop.value_ptr.append(arena, owned_value); - } - - pub fn get(self: *const Values, k: []const u8) []const []const u8 { - if (self.map.get(k)) |list| { - return list.items; - } - - return &[_][]const u8{}; - } - - pub fn first(self: *const Values, k: []const u8) []const u8 { - if (self.map.getPtr(k)) |list| { - std.debug.assert(liste.items.len > 0); - return list.items[0]; - } - return ""; - } - - pub fn has(self: *const Values, k: []const u8) bool { - return self.map.contains(k); - } - - pub fn delete(self: *Values, k: []const u8) void { - _ = self.map.fetchSwapRemove(k); - } - - pub fn deleteValue(self: *Values, k: []const u8, v: []const u8) void { - const list = self.map.getPtr(k) orelse return; - - for (list.items, 0..) |vv, i| { - if (std.mem.eql(u8, v, vv)) { - _ = list.swapRemove(i); - if (i == 0) { - _ = self.map.orderedRemove(k); - } - return; - } - } - } - - pub fn count(self: *const Values) usize { - return self.map.count(); - } - - pub fn encode(self: *const Values, writer: anytype) !void { - var it = self.map.iterator(); - - const first_entry = it.next() orelse return; - try encodeKeyValues(first_entry, writer); - - while (it.next()) |entry| { - try writer.writeByte('&'); - try encodeKeyValues(entry, writer); - } - } -}; - -fn encodeKeyValues(entry: anytype, writer: anytype) !void { - const key = entry.key_ptr.*; - - try escape(key, writer); - const values = entry.value_ptr.items; - if (values.len == 0) { - return; - } - - if (values[0].len > 0) { - try writer.writeByte('='); - try escape(values[0], writer); - } - - for (values[1..]) |value| { - try writer.writeByte('&'); - try escape(key, writer); - if (value.len > 0) { - try writer.writeByte('='); - try escape(value, writer); - } - } -} - -fn escape(raw: []const u8, writer: anytype) !void { - var start: usize = 0; - for (raw, 0..) |char, index| { - if ('a' <= char and char <= 'z' or 'A' <= char and char <= 'Z' or '0' <= char and char <= '9') { - continue; - } - - try writer.print("{s}%{X:0>2}", .{ raw[start..index], char }); - start = index + 1; - } - try writer.writeAll(raw[start..]); -} - -// Parse the given query. -pub fn parseQuery(alloc: std.mem.Allocator, s: []const u8) !Values { - var values = Values.init(alloc); - errdefer values.deinit(); - - const arena = values.arena.allocator(); - - const ln = s.len; - if (ln == 0) return values; - - var r = Reader{ .data = s }; - while (true) { - const param = r.until('&'); - if (param.len == 0) break; - - var rr = Reader{ .data = param }; - const k = rr.until('='); - if (k.len == 0) continue; - - _ = rr.skip(); - const v = rr.tail(); - - // decode k and v - const kk = try unescape(arena, k); - const vv = try unescape(arena, v); - - try values.appendOwned(kk, vv); - - if (!r.skip()) break; - } - - return values; -} - -// The return'd string may or may not be allocated. Callers should use arenas -fn unescape(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { - const HEX_CHAR = comptime blk: { - var all = std.mem.zeroes([256]bool); - for ('a'..('f' + 1)) |b| all[b] = true; - for ('A'..('F' + 1)) |b| all[b] = true; - for ('0'..('9' + 1)) |b| all[b] = true; - break :blk all; - }; - - const HEX_DECODE = comptime blk: { - var all = std.mem.zeroes([256]u8); - for ('a'..('z' + 1)) |b| all[b] = b - 'a' + 10; - for ('A'..('Z' + 1)) |b| all[b] = b - 'A' + 10; - for ('0'..('9' + 1)) |b| all[b] = b - '0'; - break :blk all; - }; - - var has_plus = false; - var unescaped_len = input.len; - - { - // Figure out if we have any spaces and what the final unescaped length - // will be (which will let us know if we have anything to unescape in - // the first place) - var i: usize = 0; - while (i < input.len) { - const c = input[i]; - if (c == '%') { - if (i + 2 >= input.len or !HEX_CHAR[input[i + 1]] or !HEX_CHAR[input[i + 2]]) { - return error.EscapeError; - } - i += 3; - unescaped_len -= 2; - } else if (c == '+') { - has_plus = true; - i += 1; - } else { - i += 1; - } - } - } - - // no encoding, and no plus. nothing to unescape - if (unescaped_len == input.len and has_plus == false) { - return input; - } - - var unescaped = try allocator.alloc(u8, unescaped_len); - errdefer allocator.free(unescaped); - - var input_pos: usize = 0; - for (0..unescaped_len) |unescaped_pos| { - switch (input[input_pos]) { - '+' => { - unescaped[unescaped_pos] = ' '; - input_pos += 1; - }, - '%' => { - const encoded = input[input_pos + 1 .. input_pos + 3]; - const encoded_as_uint = @as(u16, @bitCast(encoded[0..2].*)); - unescaped[unescaped_pos] = switch (encoded_as_uint) { - asUint(u16, "20") => ' ', - asUint(u16, "21") => '!', - asUint(u16, "22") => '"', - asUint(u16, "23") => '#', - asUint(u16, "24") => '$', - asUint(u16, "25") => '%', - asUint(u16, "26") => '&', - asUint(u16, "27") => '\'', - asUint(u16, "28") => '(', - asUint(u16, "29") => ')', - asUint(u16, "2A") => '*', - asUint(u16, "2B") => '+', - asUint(u16, "2C") => ',', - asUint(u16, "2F") => '/', - asUint(u16, "3A") => ':', - asUint(u16, "3B") => ';', - asUint(u16, "3D") => '=', - asUint(u16, "3F") => '?', - asUint(u16, "40") => '@', - asUint(u16, "5B") => '[', - asUint(u16, "5D") => ']', - else => HEX_DECODE[encoded[0]] << 4 | HEX_DECODE[encoded[1]], - }; - input_pos += 3; - }, - else => |c| { - unescaped[unescaped_pos] = c; - input_pos += 1; - }, - } - } - return unescaped; -} - -pub fn asUint(comptime T: type, comptime string: []const u8) T { - return @bitCast(string[0..string.len].*); -} - -const testing = @import("../../testing.zig"); -test "url.Query: unescape" { - const allocator = testing.allocator; - const cases = [_]struct { expected: []const u8, input: []const u8, free: bool }{ - .{ .expected = "", .input = "", .free = false }, - .{ .expected = "over", .input = "over", .free = false }, - .{ .expected = "Hello World", .input = "Hello World", .free = false }, - .{ .expected = "~", .input = "%7E", .free = true }, - .{ .expected = "~", .input = "%7e", .free = true }, - .{ .expected = "Hello~World", .input = "Hello%7eWorld", .free = true }, - .{ .expected = "Hello World", .input = "Hello++World", .free = true }, - }; - - for (cases) |case| { - const value = try unescape(allocator, case.input); - defer if (case.free) { - allocator.free(value); - }; - try testing.expectEqual(case.expected, value); - } - - try testing.expectError(error.EscapeError, unescape(undefined, "%")); - try testing.expectError(error.EscapeError, unescape(undefined, "%a")); - try testing.expectError(error.EscapeError, unescape(undefined, "%1")); - try testing.expectError(error.EscapeError, unescape(undefined, "123%45%6")); - try testing.expectError(error.EscapeError, unescape(undefined, "%zzzzz")); - try testing.expectError(error.EscapeError, unescape(undefined, "%0\xff")); -} - -test "url.Query: parseQuery" { - try testParseQuery(.{}, ""); - - try testParseQuery(.{}, "&"); - - try testParseQuery(.{ .a = [_][]const u8{"b"} }, "a=b"); - - try testParseQuery(.{ .hello = [_][]const u8{"world"} }, "hello=world"); - - try testParseQuery(.{ .hello = [_][]const u8{ "world", "all" } }, "hello=world&hello=all"); - - try testParseQuery(.{ - .a = [_][]const u8{"b"}, - .b = [_][]const u8{"c"}, - }, "a=b&b=c"); - - try testParseQuery(.{ .a = [_][]const u8{""} }, "a"); - try testParseQuery(.{ .a = [_][]const u8{ "", "", "" } }, "a&a&a"); - - try testParseQuery(.{ .abc = [_][]const u8{""} }, "abc"); - try testParseQuery(.{ - .abc = [_][]const u8{""}, - .dde = [_][]const u8{ "", "" }, - }, "abc&dde&dde"); - - try testParseQuery(.{ - .@"power is >" = [_][]const u8{"9,000?"}, - }, "power%20is%20%3E=9%2C000%3F"); -} - -test "url.Query.Values: get/first/count" { - defer testing.reset(); - const arena = testing.arena_allocator; - - var values = Values{}; - { - // empty - try testing.expectEqual(0, values.count()); - try testing.expectEqual(0, values.get("").len); - try testing.expectEqual("", values.first("")); - try testing.expectEqual(0, values.get("key").len); - try testing.expectEqual("", values.first("key")); - } - - { - // add 1 value => key - try values.append(arena, "key", "value"); - try testing.expectEqual(1, values.count()); - try testing.expectEqual(1, values.get("key").len); - try testing.expectEqualSlices( - []const u8, - &.{"value"}, - values.get("key"), - ); - try testing.expectEqual("value", values.first("key")); - } - - { - // add another value for the same key - try values.append(arena, "key", "another"); - try testing.expectEqual(1, values.count()); - try testing.expectEqual(2, values.get("key").len); - try testing.expectEqualSlices( - []const u8, - &.{ "value", "another" }, - values.get("key"), - ); - try testing.expectEqual("value", values.first("key")); - } - - { - // add a new key (and value) - try values.append(arena, "over", "9000!"); - try testing.expectEqual(2, values.count()); - try testing.expectEqual(2, values.get("key").len); - try testing.expectEqual(1, values.get("over").len); - try testing.expectEqualSlices( - []const u8, - &.{"9000!"}, - values.get("over"), - ); - try testing.expectEqual("9000!", values.first("over")); - } - - { - // set (override) - try values.append(arena, "key", "9000!"); - try testing.expectEqual(1, values.count()); - try testing.expectEqual(1, values.get("key").len); - try testing.expectEqualSlices( - []const u8, - &.{"9000!"}, - values.get("key"), - ); - try testing.expectEqual("9000!", values.first("key")); - } -} - -test "url.Query.Values: encode" { - var values = try parseQuery( - testing.allocator, - "hello=world&i%20will%20not%20fear=%3E%3E&a=b&a=c", - ); - defer values.deinit(); - - var buf: std.ArrayListUnmanaged(u8) = .{}; - defer buf.deinit(testing.allocator); - try values.encode(buf.writer(testing.allocator)); - try testing.expectEqual( - "hello=world&i%20will%20not%20fear=%3E%3E&a=b&a=c", - buf.items, - ); -} - -fn testParseQuery(expected: anytype, query: []const u8) !void { - var values = try parseQuery(testing.allocator, query); - defer values.deinit(); - - var count: usize = 0; - inline for (@typeInfo(@TypeOf(expected)).@"struct".fields) |f| { - const actual = values.get(f.name); - const expect = @field(expected, f.name); - try testing.expectEqual(expect.len, actual.len); - for (expect, actual) |e, a| { - try testing.expectEqual(e, a); - } - count += 1; - } - try testing.expectEqual(count, values.count()); -} diff --git a/src/browser/url/url.zig b/src/browser/url/url.zig index 000967cf..75232d03 100644 --- a/src/browser/url/url.zig +++ b/src/browser/url/url.zig @@ -19,8 +19,10 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const parser = @import("../netsurf.zig"); const Page = @import("../page.zig").Page; const FormData = @import("../xhr/form_data.zig").FormData; +const HTMLElement = @import("../html/elements.zig").HTMLElement; const kv = @import("../key_value.zig"); const iterator = @import("../iterator/iterator.zig"); @@ -51,20 +53,37 @@ pub const URL = struct { uri: std.Uri, search_params: URLSearchParams, - pub fn constructor( - url: []const u8, - base: ?[]const u8, - page: *Page, - ) !URL { + const URLArg = union(enum) { + url: *URL, + element: *parser.ElementHTML, + string: []const u8, + + fn toString(self: URLArg, arena: Allocator) !?[]const u8 { + switch (self) { + .string => |s| return s, + .url => |url| return try url.toString(arena), + .element => |e| return try parser.elementGetAttribute(@ptrCast(e), "href"), + } + } + }; + + pub fn constructor(url: URLArg, base: ?URLArg, page: *Page) !URL { const arena = page.arena; - var raw: []const u8 = undefined; + const url_str = try url.toString(arena) orelse return error.InvalidArgument; + + var raw: ?[]const u8 = null; if (base) |b| { - raw = try @import("../../url.zig").URL.stitch(arena, url, b, .{}); - } else { - raw = try arena.dupe(u8, url); + if (try b.toString(arena)) |bb| { + raw = try @import("../../url.zig").URL.stitch(arena, url_str, bb, .{}); + } } - const uri = std.Uri.parse(raw) catch return error.TypeError; + if (raw == null) { + // if it was a URL, then it's already be owned by the arena + raw = if (url == .url) url_str else try arena.dupe(u8, url_str); + } + + const uri = std.Uri.parse(raw.?) catch return error.TypeError; return init(arena, uri); } @@ -95,30 +114,32 @@ pub const URL = struct { // The query is replaced by a dump of search params. // pub fn get_href(self: *URL, page: *Page) ![]const u8 { - const arena = page.arena; - // retrieve the query search from search_params. - const cur = self.uri.query; - defer self.uri.query = cur; - var q = std.ArrayList(u8).init(arena); - try self.search_params.encode(q.writer()); - self.uri.query = .{ .percent_encoded = q.items }; - - return try self.toString(arena); + return try self.toString(page.arena); } // format the url with all its components. - pub fn toString(self: *URL, arena: Allocator) ![]const u8 { + pub fn toString(self: *const URL, arena: Allocator) ![]const u8 { var buf: std.ArrayListUnmanaged(u8) = .empty; - try self.uri.writeToStream(.{ .scheme = true, .authentication = true, .authority = true, .path = uriComponentNullStr(self.uri.path).len > 0, - .query = uriComponentNullStr(self.uri.query).len > 0, - .fragment = uriComponentNullStr(self.uri.fragment).len > 0, }, buf.writer(arena)); + if (self.search_params.get_size() > 0) { + try buf.append(arena, '?'); + try self.search_params.write(buf.writer(arena)); + } + + { + const fragment = uriComponentNullStr(self.uri.fragment); + if (fragment.len > 0) { + try buf.append(arena, '#'); + try buf.appendSlice(arena, fragment); + } + } + return buf.items; } @@ -168,15 +189,24 @@ pub const URL = struct { pub fn get_search(self: *URL, page: *Page) ![]const u8 { const arena = page.arena; - if (self.search_params.get_size() == 0) return try arena.dupe(u8, ""); + + if (self.search_params.get_size() == 0) { + return ""; + } var buf: std.ArrayListUnmanaged(u8) = .{}; - try buf.append(arena, '?'); try self.search_params.encode(buf.writer(arena)); return buf.items; } + pub fn set_search(self: *URL, qs_: ?[]const u8, page: *Page) !void { + self.search_params = .{}; + if (qs_) |qs| { + self.search_params = try URLSearchParams.init(page.arena, qs); + } + } + pub fn get_hash(self: *URL, page: *Page) ![]const u8 { const arena = page.arena; if (self.uri.fragment == null) return try arena.dupe(u8, ""); @@ -210,7 +240,7 @@ fn uriComponentStr(c: std.Uri.Component) []const u8 { // https://url.spec.whatwg.org/#interface-urlsearchparams pub const URLSearchParams = struct { - entries: kv.List, + entries: kv.List = .{}, const URLSearchParamsOpts = union(enum) { qs: []const u8, @@ -279,10 +309,14 @@ pub const URLSearchParams = struct { fn _toString(self: *const URLSearchParams, page: *Page) ![]const u8 { var arr: std.ArrayListUnmanaged(u8) = .empty; - try kv.urlEncode(self.entries, .query, arr.writer(page.call_arena)); + try self.write(arr.writer(page.call_arena)); return arr.items; } + fn write(self: *const URLSearchParams, writer: anytype) !void { + return kv.urlEncode(self.entries, .query, writer); + } + // TODO pub fn _sort(_: *URLSearchParams) void {} @@ -449,6 +483,27 @@ test "Browser.URL" { .{ "url.search", "?query" }, .{ "url.hash", "#fragment" }, .{ "url.searchParams.get('query')", "" }, + + .{ "url.search = 'hello=world'", null }, + .{ "url.searchParams.size", "1" }, + .{ "url.searchParams.get('hello')", "world" }, + + .{ "url.search = '?over=9000'", null }, + .{ "url.searchParams.size", "1" }, + .{ "url.searchParams.get('over')", "9000" }, + + .{ "url.search = ''", null }, + .{ "url.searchParams.size", "0" }, + + .{ " const url2 = new URL(url);", null }, + .{ "url2.href", "https://foo.bar/path#fragment" }, + + .{ " try { new URL(document.createElement('a')); } catch (e) { e }", "TypeError: invalid argument" }, + + .{ " let a = document.createElement('a');", null }, + .{ " a.href = 'https://www.lightpanda.io/over?9000=!!';", null }, + .{ " const url3 = new URL(a);", null }, + .{ "url3.href", "https://www.lightpanda.io/over?9000=%21%21" }, }, .{}); try runner.testCases(&.{ diff --git a/src/runtime/js.zig b/src/runtime/js.zig index 8870eb6f..78076462 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -911,7 +911,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { // coerced to. var coerce_index: ?usize = null; - // the first field that we find which the js_Value is + // the first field that we find which the js_value is // compatible with. A compatible field has higher precedence // than a coercible, but still isn't a perfect match. var compatible_index: ?usize = null;