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..3045670a 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);
}
@@ -92,33 +111,37 @@ pub const URL = struct {
}
// get_href returns the URL by writing all its components.
- // 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 self.toString(page.arena);
+ }
- return try self.toString(arena);
+ pub fn _toString(self: *URL, page: *Page) ![]const u8 {
+ return 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 +191,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, "");
@@ -189,7 +221,7 @@ pub const URL = struct {
}
pub fn _toJSON(self: *URL, page: *Page) ![]const u8 {
- return try self.get_href(page);
+ return self.get_href(page);
}
};
@@ -210,7 +242,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,
@@ -277,12 +309,16 @@ pub const URLSearchParams = struct {
return self._entries();
}
- fn _toString(self: *const URLSearchParams, page: *Page) ![]const u8 {
+ pub 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 +485,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..4bde54db 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;
@@ -1806,14 +1806,8 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
}
fn generateProperty(comptime Struct: type, comptime name: []const u8, isolate: v8.Isolate, template_proto: v8.ObjectTemplate) void {
- const getter = @field(Struct, "get_" ++ name);
- const param_count = @typeInfo(@TypeOf(getter)).@"fn".params.len;
-
var js_name: v8.Name = undefined;
if (comptime std.mem.eql(u8, name, "symbol_toStringTag")) {
- if (param_count != 0) {
- @compileError(@typeName(Struct) ++ ".get_symbol_toStringTag() cannot take any parameters");
- }
js_name = v8.Symbol.getToStringTag(isolate).toName();
} else {
js_name = v8.String.initUtf8(isolate, name).toName();
diff --git a/src/url.zig b/src/url.zig
index c4341bf4..6244bf59 100644
--- a/src/url.zig
+++ b/src/url.zig
@@ -125,16 +125,16 @@ pub const URL = struct {
}
};
+ const normalized_src = if (src[0] == '/') src[1..] else src;
+
if (std.mem.lastIndexOfScalar(u8, base[protocol_end..], '/')) |index| {
const last_slash_pos = index + protocol_end;
if (last_slash_pos == base.len - 1) {
- return std.fmt.allocPrint(allocator, "{s}{s}", .{ base, src });
- } else {
- return std.fmt.allocPrint(allocator, "{s}/{s}", .{ base[0..last_slash_pos], src });
+ return std.fmt.allocPrint(allocator, "{s}{s}", .{ base, normalized_src });
}
- } else {
- return std.fmt.allocPrint(allocator, "{s}/{s}", .{ base, src });
+ return std.fmt.allocPrint(allocator, "{s}/{s}", .{ base[0..last_slash_pos], normalized_src });
}
+ return std.fmt.allocPrint(allocator, "{s}/{s}", .{ base, normalized_src });
}
pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []const u8) ![]const u8 {
@@ -233,6 +233,16 @@ test "URL: Stitching Base & Src URLs (Just Ending Slash)" {
try testing.expectString("https://www.google.com/something.js", result);
}
+test "URL: Stitching Base & Src URLs with leading slash" {
+ const allocator = testing.allocator;
+
+ const base = "https://www.google.com/";
+ const src = "/something.js";
+ const result = try URL.stitch(allocator, src, base, .{});
+ defer allocator.free(result);
+ try testing.expectString("https://www.google.com/something.js", result);
+}
+
test "URL: Stitching Base & Src URLs (No Ending Slash)" {
const allocator = testing.allocator;