diff --git a/src/browser/html/document.zig b/src/browser/html/document.zig
index f0b533d4c..85926a65e 100644
--- a/src/browser/html/document.zig
+++ b/src/browser/html/document.zig
@@ -174,6 +174,10 @@ pub const HTMLDocument = struct {
return try parser.documentHTMLGetLocation(Location, self);
}
+ pub fn set_location(_: *const parser.DocumentHTML, url: []const u8, page: *Page) !void {
+ return page.navigateFromWebAPI(url, .{ .reason = .script });
+ }
+
pub fn get_designMode(_: *parser.DocumentHTML) []const u8 {
return "off";
}
diff --git a/src/browser/html/form.zig b/src/browser/html/form.zig
index 3cb169d59..cd90589cf 100644
--- a/src/browser/html/form.zig
+++ b/src/browser/html/form.zig
@@ -19,6 +19,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const parser = @import("../netsurf.zig");
+const Page = @import("../page.zig").Page;
const HTMLElement = @import("elements.zig").HTMLElement;
const FormData = @import("../xhr/form_data.zig").FormData;
@@ -27,6 +28,10 @@ pub const HTMLFormElement = struct {
pub const prototype = *HTMLElement;
pub const subtype = .node;
+ pub fn _submit(self: *parser.Form, page: *Page) !void {
+ return page.submitForm(self, null);
+ }
+
pub fn _requestSubmit(self: *parser.Form) !void {
try parser.formElementSubmit(self);
}
@@ -35,20 +40,3 @@ pub const HTMLFormElement = struct {
try parser.formElementReset(self);
}
};
-
-pub const Submission = struct {
- method: ?[]const u8,
- form_data: FormData,
-};
-
-pub fn processSubmission(arena: Allocator, form: *parser.Form) !?Submission {
- const form_element: *parser.Element = @ptrCast(form);
- const method = try parser.elementGetAttribute(form_element, "method");
-
- return .{
- .method = method,
- .form_data = try FormData.fromForm(arena, form),
- };
-}
-
-// Check xhr/form_data.zig for tests
diff --git a/src/browser/html/location.zig b/src/browser/html/location.zig
index 604b9622d..31904db60 100644
--- a/src/browser/html/location.zig
+++ b/src/browser/html/location.zig
@@ -69,18 +69,17 @@ pub const Location = struct {
return "";
}
- // TODO
- pub fn _assign(_: *Location, url: []const u8) !void {
- _ = url;
+ pub fn _assign(_: *const Location, url: []const u8, page: *Page) !void {
+ return page.navigateFromWebAPI(url, .{ .reason = .script });
}
- // TODO
- pub fn _replace(_: *Location, url: []const u8) !void {
- _ = url;
+ pub fn _replace(_: *const Location, url: []const u8, page: *Page) !void {
+ return page.navigateFromWebAPI(url, .{ .reason = .script });
}
- // TODO
- pub fn _reload(_: *Location) !void {}
+ pub fn _reload(_: *const Location, page: *Page) !void {
+ return page.navigateFromWebAPI(page.url.raw, .{ .reason = .script });
+ }
pub fn _toString(self: *Location, page: *Page) ![]const u8 {
return try self.get_href(page);
diff --git a/src/browser/html/window.zig b/src/browser/html/window.zig
index b58f7cbcf..03c2ebea6 100644
--- a/src/browser/html/window.zig
+++ b/src/browser/html/window.zig
@@ -100,6 +100,10 @@ pub const Window = struct {
return &self.location;
}
+ pub fn set_location(_: *const Window, url: []const u8, page: *Page) !void {
+ return page.navigateFromWebAPI(url, .{ .reason = .script });
+ }
+
pub fn get_console(self: *Window) *Console {
return &self.console;
}
diff --git a/src/browser/page.zig b/src/browser/page.zig
index 54639b0d3..efc896fff 100644
--- a/src/browser/page.zig
+++ b/src/browser/page.zig
@@ -199,8 +199,9 @@ pub const Page = struct {
self.url = request_url;
// load the data
- var request = try self.newHTTPRequest(.GET, &self.url, .{ .navigation = true });
+ var request = try self.newHTTPRequest(opts.method, &self.url, .{ .navigation = true });
defer request.deinit();
+ request.body = opts.body;
request.notification = notification;
notification.dispatch(.page_navigate, &.{
@@ -541,23 +542,26 @@ pub const Page = struct {
.a => {
const element: *parser.Element = @ptrCast(node);
const href = (try parser.elementGetAttribute(element, "href")) orelse return;
-
- // We cannot navigate immediately as navigating will delete the DOM tree, which holds this event's node.
- // As such we schedule the function to be called as soon as possible.
- // NOTE Using the page.arena assumes that the scheduling loop does use this object after invoking the callback
- // If that changes we may want to consider storing DelayedNavigation in the session instead.
- const arena = self.arena;
- const navi = try arena.create(DelayedNavigation);
- navi.* = .{
- .session = self.session,
- .href = try arena.dupe(u8, href),
- };
- _ = try self.loop.timeout(0, &navi.navigate_node);
+ try self.navigateFromWebAPI(href, .{});
},
else => {},
}
}
+ // As such we schedule the function to be called as soon as possible.
+ // The page.arena is safe to use here, but the transfer_arena exists
+ // specifically for this type of lifetime.
+ pub fn navigateFromWebAPI(self: *Page, url: []const u8, opts: NavigateOpts) !void {
+ const arena = self.session.transfer_arena;
+ const navi = try arena.create(DelayedNavigation);
+ navi.* = .{
+ .opts = opts,
+ .session = self.session,
+ .url = try arena.dupe(u8, url),
+ };
+ _ = try self.loop.timeout(0, &navi.navigate_node);
+ }
+
pub fn getOrCreateNodeWrapper(self: *Page, comptime T: type, node: *parser.Node) !*T {
if (try self.getNodeWrapper(T, node)) |wrap| {
return wrap;
@@ -576,19 +580,46 @@ pub const Page = struct {
}
return null;
}
+
+ pub fn submitForm(self: *Page, form: *parser.Form, submitter: ?*parser.ElementHTML) !void {
+ const FormData = @import("xhr/form_data.zig").FormData;
+
+ const transfer_arena = self.session.transfer_arena;
+ var form_data = try FormData.fromForm(form, submitter, self);
+
+ const encoding = try parser.elementGetAttribute(@ptrCast(form), "enctype");
+
+ var buf: std.ArrayListUnmanaged(u8) = .empty;
+ try form_data.write(encoding, buf.writer(transfer_arena));
+
+ const method = try parser.elementGetAttribute(@ptrCast(form), "method") orelse "";
+ var action = try parser.elementGetAttribute(@ptrCast(form), "action") orelse self.url.raw;
+
+ var opts = NavigateOpts{
+ .reason = .form,
+ };
+ if (std.ascii.eqlIgnoreCase(method, "post")) {
+ opts.method = .POST;
+ opts.body = buf.items;
+ } else {
+ action = try URL.concatQueryString(transfer_arena, action, buf.items);
+ }
+
+ try self.navigateFromWebAPI(action, opts);
+ }
};
const DelayedNavigation = struct {
- navigate_node: Loop.CallbackNode = .{ .func = DelayedNavigation.delay_navigate },
+ url: []const u8,
session: *Session,
- href: []const u8,
+ opts: NavigateOpts,
+ navigate_node: Loop.CallbackNode = .{ .func = delayNavigate },
- fn delay_navigate(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
+ fn delayNavigate(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
_ = repeat_delay;
const self: *DelayedNavigation = @fieldParentPtr("navigate_node", node);
- self.session.pageNavigate(self.href) catch |err| {
- // TODO: should we trigger a specific event here?
- log.err(.page, "delayed navigation error", .{ .err = err });
+ self.session.pageNavigate(self.url, self.opts) catch |err| {
+ log.err(.page, "delayed navigation error", .{ .err = err, .url = self.url });
};
}
};
@@ -696,11 +727,15 @@ const Script = struct {
pub const NavigateReason = enum {
anchor,
address_bar,
+ form,
+ script,
};
pub const NavigateOpts = struct {
cdp_id: ?i64 = null,
reason: NavigateReason = .address_bar,
+ method: http.Request.Method = .GET,
+ body: ?[]const u8 = null,
};
fn timestamp() u32 {
diff --git a/src/browser/session.zig b/src/browser/session.zig
index 28432498a..d71bb27e3 100644
--- a/src/browser/session.zig
+++ b/src/browser/session.zig
@@ -23,6 +23,7 @@ const Allocator = std.mem.Allocator;
const Env = @import("env.zig").Env;
const Page = @import("page.zig").Page;
const Browser = @import("browser.zig").Browser;
+const NavigateOpts = @import("page.zig").NavigateOpts;
const log = @import("../log.zig");
const parser = @import("netsurf.zig");
@@ -122,7 +123,7 @@ pub const Session = struct {
return &(self.page orelse return null);
}
- pub fn pageNavigate(self: *Session, url_string: []const u8) !void {
+ pub fn pageNavigate(self: *Session, url_string: []const u8, opts: NavigateOpts) !void {
// currently, this is only called from the page, so let's hope
// it isn't null!
std.debug.assert(self.page != null);
@@ -136,8 +137,6 @@ pub const Session = struct {
self.removePage();
var page = try self.createPage();
- return page.navigate(url, .{
- .reason = .anchor,
- });
+ return page.navigate(url, opts);
}
};
diff --git a/src/browser/xhr/form_data.zig b/src/browser/xhr/form_data.zig
index afa2e6992..32d56bc9d 100644
--- a/src/browser/xhr/form_data.zig
+++ b/src/browser/xhr/form_data.zig
@@ -51,21 +51,15 @@ pub const Interfaces = .{
// https://xhr.spec.whatwg.org/#interface-formdata
pub const FormData = struct {
- entries: std.ArrayListUnmanaged(Entry),
+ entries: Entry.List,
pub fn constructor(form_: ?*parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !FormData {
const form = form_ orelse return .{ .entries = .empty };
- return fromForm(form, submitter_, page, .{});
+ return fromForm(form, submitter_, page);
}
- const FromFormOpts = struct {
- // Uses the page.arena if null. This is needed for when we're handling
- // form submission from the Page, and we want to capture the form within
- // the session's transfer_arena.
- allocator: ?Allocator = null,
- };
- pub fn fromForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page, opts: FromFormOpts) !FormData {
- const entries = try collectForm(opts.allocator orelse page.arena, form, submitter_, page);
+ pub fn fromForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !FormData {
+ const entries = try collectForm(form, submitter_, page);
return .{ .entries = entries };
}
@@ -144,11 +138,78 @@ pub const FormData = struct {
}
return null;
}
+
+ pub fn write(self: *const FormData, encoding_: ?[]const u8, writer: anytype) !void {
+ const encoding = encoding_ orelse {
+ return urlEncode(self, writer);
+ };
+
+ if (std.ascii.eqlIgnoreCase(encoding, "application/x-www-form-urlencoded")) {
+ return urlEncode(self, writer);
+ }
+
+ log.warn(.form_data, "encoding not supported", .{ .encoding = encoding });
+ return error.EncodingNotSupported;
+ }
};
+fn urlEncode(data: *const FormData, writer: anytype) !void {
+ const entries = data.entries.items;
+ if (entries.len == 0) {
+ return;
+ }
+
+ try urlEncodeEntry(entries[0], writer);
+ for (entries[1..]) |entry| {
+ try writer.writeByte('&');
+ try urlEncodeEntry(entry, writer);
+ }
+}
+
+fn urlEncodeEntry(entry: Entry, writer: anytype) !void {
+ try urlEncodeValue(entry.key, writer);
+ try writer.writeByte('=');
+ try urlEncodeValue(entry.value, writer);
+}
+
+fn urlEncodeValue(value: []const u8, writer: anytype) !void {
+ if (!urlEncodeShouldEscape(value)) {
+ return writer.writeAll(value);
+ }
+
+ for (value) |b| {
+ if (urlEncodeUnreserved(b)) {
+ try writer.writeByte(b);
+ } else if (b == ' ') {
+ // for form submission, space should be encoded as '+', not '%20'
+ try writer.writeByte('+');
+ } else {
+ try writer.print("%{X:0>2}", .{b});
+ }
+ }
+}
+
+fn urlEncodeShouldEscape(value: []const u8) bool {
+ for (value) |b| {
+ if (!urlEncodeUnreserved(b)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+fn urlEncodeUnreserved(b: u8) bool {
+ return switch (b) {
+ 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true,
+ else => false,
+ };
+}
+
const Entry = struct {
key: []const u8,
value: []const u8,
+
+ pub const List = std.ArrayListUnmanaged(Entry);
};
const KeyIterable = iterator.Iterable(KeyIterator, "FormDataKeyIterator");
@@ -157,7 +218,7 @@ const EntryIterable = iterator.Iterable(EntryIterator, "FormDataEntryIterator");
const KeyIterator = struct {
index: usize = 0,
- entries: *const std.ArrayListUnmanaged(Entry),
+ entries: *const Entry.List,
pub fn _next(self: *KeyIterator) ?[]const u8 {
const index = self.index;
@@ -171,7 +232,7 @@ const KeyIterator = struct {
const ValueIterator = struct {
index: usize = 0,
- entries: *const std.ArrayListUnmanaged(Entry),
+ entries: *const Entry.List,
pub fn _next(self: *ValueIterator) ?[]const u8 {
const index = self.index;
@@ -185,7 +246,7 @@ const ValueIterator = struct {
const EntryIterator = struct {
index: usize = 0,
- entries: *const std.ArrayListUnmanaged(Entry),
+ entries: *const Entry.List,
pub fn _next(self: *EntryIterator) ?struct { []const u8, []const u8 } {
const index = self.index;
@@ -198,11 +259,13 @@ const EntryIterator = struct {
}
};
-fn collectForm(arena: Allocator, form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !std.ArrayListUnmanaged(Entry) {
+// TODO: handle disabled fieldsets
+fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !Entry.List {
+ const arena = page.arena;
const collection = try parser.formGetCollection(form);
const len = try parser.htmlCollectionGetLength(collection);
- var entries: std.ArrayListUnmanaged(Entry) = .empty;
+ var entries: Entry.List = .empty;
try entries.ensureTotalCapacity(arena, len);
const submitter_name_ = try getSubmitterName(submitter_);
@@ -275,7 +338,7 @@ fn collectForm(arena: Allocator, form: *parser.Form, submitter_: ?*parser.Elemen
return entries;
}
-fn collectSelectValues(arena: Allocator, select: *parser.Select, name: []const u8, entries: *std.ArrayListUnmanaged(Entry), page: *Page) !void {
+fn collectSelectValues(arena: Allocator, select: *parser.Select, name: []const u8, entries: *Entry.List, page: *Page) !void {
const HTMLSelectElement = @import("../html/select.zig").HTMLSelectElement;
// Go through the HTMLSelectElement because it has specific logic for handling
@@ -456,3 +519,34 @@ test "Browser.FormData" {
},
}, .{});
}
+
+test "Browser.FormData: urlEncode" {
+ var arr: std.ArrayListUnmanaged(u8) = .empty;
+ defer arr.deinit(testing.allocator);
+
+ {
+ var fd = FormData{ .entries = .empty };
+ try testing.expectError(error.EncodingNotSupported, fd.write("unknown", arr.writer(testing.allocator)));
+
+ try fd.write(null, arr.writer(testing.allocator));
+ try testing.expectEqual("", arr.items);
+
+ try fd.write("application/x-www-form-urlencoded", arr.writer(testing.allocator));
+ try testing.expectEqual("", arr.items);
+ }
+
+ {
+ var fd = FormData{ .entries = Entry.List.fromOwnedSlice(@constCast(&[_]Entry{
+ .{ .key = "a", .value = "1" },
+ .{ .key = "it's over", .value = "9000 !!!" },
+ .{ .key = "emot", .value = "ok: ☺" },
+ })) };
+ const expected = "a=1&it%27s+over=9000+%21%21%21&emot=ok%3A+%E2%98%BA";
+ try fd.write(null, arr.writer(testing.allocator));
+ try testing.expectEqual(expected, arr.items);
+
+ arr.clearRetainingCapacity();
+ try fd.write("application/x-www-form-urlencoded", arr.writer(testing.allocator));
+ try testing.expectEqual(expected, arr.items);
+ }
+}
diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig
index 4059e4cb6..f5a728318 100644
--- a/src/cdp/domains/page.zig
+++ b/src/cdp/domains/page.zig
@@ -169,18 +169,27 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa
bc.reset();
- const is_anchor = event.opts.reason == .anchor;
- if (is_anchor) {
+ const reason_: ?[]const u8 = switch (event.opts.reason) {
+ .anchor => "anchorClick",
+ .script => "scriptInitiated",
+ .form => switch (event.opts.method) {
+ .GET => "formSubmissionGet",
+ .POST => "formSubmissionPost",
+ else => unreachable,
+ },
+ .address_bar => null,
+ };
+ if (reason_) |reason| {
try cdp.sendEvent("Page.frameScheduledNavigation", .{
.frameId = target_id,
.delay = 0,
- .reason = "anchorClick",
+ .reason = reason,
.url = event.url.raw,
}, .{ .session_id = session_id });
try cdp.sendEvent("Page.frameRequestedNavigation", .{
.frameId = target_id,
- .reason = "anchorClick",
+ .reason = reason,
.url = event.url.raw,
.disposition = "currentTab",
}, .{ .session_id = session_id });
@@ -224,7 +233,7 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa
}, .{ .session_id = session_id });
}
- if (is_anchor) {
+ if (reason_ != null) {
try cdp.sendEvent("Page.frameClearedScheduledNavigation", .{
.frameId = target_id,
}, .{ .session_id = session_id });
diff --git a/src/url.zig b/src/url.zig
index fb28c4afd..e6a738a3d 100644
--- a/src/url.zig
+++ b/src/url.zig
@@ -87,7 +87,7 @@ pub const URL = struct {
///
/// For URLs with a path, it will replace the last entry with the src.
/// For URLs without a path, it will add src as the path.
- pub fn stitch(allocator: std.mem.Allocator, src: []const u8, base: []const u8) ![]const u8 {
+ pub fn stitch(allocator: Allocator, src: []const u8, base: []const u8) ![]const u8 {
if (base.len == 0) {
return src;
}
@@ -111,6 +111,31 @@ pub const URL = struct {
return std.fmt.allocPrint(allocator, "{s}/{s}", .{ base, src });
}
}
+
+ pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []const u8) ![]const u8 {
+ std.debug.assert(url.len != 0);
+
+ if (query_string.len == 0) {
+ return url;
+ }
+
+ var buf: std.ArrayListUnmanaged(u8) = .empty;
+
+ // the most space well need is the url + ('?' or '&') + the query_string
+ try buf.ensureTotalCapacity(arena, url.len + 1 + query_string.len);
+ buf.appendSliceAssumeCapacity(url);
+
+ if (std.mem.indexOfScalar(u8, url, '?')) |index| {
+ const last_index = url.len - 1;
+ if (index != last_index and url[last_index] != '&') {
+ buf.appendAssumeCapacity('&');
+ }
+ } else {
+ buf.appendAssumeCapacity('?');
+ }
+ buf.appendSliceAssumeCapacity(query_string);
+ return buf.items;
+ }
};
test "Url resolve size" {
@@ -170,3 +195,33 @@ test "URL: Stiching Base & Src URLs (Both Local)" {
defer allocator.free(result);
try testing.expectString("./abcdef/something.js", result);
}
+
+test "URL: concatQueryString" {
+ defer testing.reset();
+ const arena = testing.arena_allocator;
+
+ {
+ const url = try URL.concatQueryString(arena, "https://www.lightpanda.io/", "");
+ try testing.expectEqual("https://www.lightpanda.io/", url);
+ }
+
+ {
+ const url = try URL.concatQueryString(arena, "https://www.lightpanda.io/index?", "");
+ try testing.expectEqual("https://www.lightpanda.io/index?", url);
+ }
+
+ {
+ const url = try URL.concatQueryString(arena, "https://www.lightpanda.io/index?", "a=b");
+ try testing.expectEqual("https://www.lightpanda.io/index?a=b", url);
+ }
+
+ {
+ const url = try URL.concatQueryString(arena, "https://www.lightpanda.io/index?1=2", "a=b");
+ try testing.expectEqual("https://www.lightpanda.io/index?1=2&a=b", url);
+ }
+
+ {
+ const url = try URL.concatQueryString(arena, "https://www.lightpanda.io/index?1=2&", "a=b");
+ try testing.expectEqual("https://www.lightpanda.io/index?1=2&a=b", url);
+ }
+}