support form.submit()

Only supports application/x-www-form-urlencoded
This commit is contained in:
Karl Seguin
2025-05-28 22:56:42 +08:00
parent 58215a470b
commit 74b36d6d32
9 changed files with 231 additions and 52 deletions

View File

@@ -175,7 +175,7 @@ pub const HTMLDocument = struct {
} }
pub fn set_location(_: *const parser.DocumentHTML, url: []const u8, page: *Page) !void { pub fn set_location(_: *const parser.DocumentHTML, url: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(url); return page.navigateFromWebAPI(url, .{ .reason = .script });
} }
pub fn get_designMode(_: *parser.DocumentHTML) []const u8 { pub fn get_designMode(_: *parser.DocumentHTML) []const u8 {

View File

@@ -19,6 +19,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const parser = @import("../netsurf.zig"); const parser = @import("../netsurf.zig");
const Page = @import("../page.zig").Page;
const HTMLElement = @import("elements.zig").HTMLElement; const HTMLElement = @import("elements.zig").HTMLElement;
const FormData = @import("../xhr/form_data.zig").FormData; const FormData = @import("../xhr/form_data.zig").FormData;
@@ -27,6 +28,10 @@ pub const HTMLFormElement = struct {
pub const prototype = *HTMLElement; pub const prototype = *HTMLElement;
pub const subtype = .node; 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 { pub fn _requestSubmit(self: *parser.Form) !void {
try parser.formElementSubmit(self); try parser.formElementSubmit(self);
} }
@@ -35,20 +40,3 @@ pub const HTMLFormElement = struct {
try parser.formElementReset(self); 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

View File

@@ -70,15 +70,15 @@ pub const Location = struct {
} }
pub fn _assign(_: *const Location, url: []const u8, page: *Page) !void { pub fn _assign(_: *const Location, url: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(url); return page.navigateFromWebAPI(url, .{ .reason = .script });
} }
pub fn _replace(_: *const Location, url: []const u8, page: *Page) !void { pub fn _replace(_: *const Location, url: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(url); return page.navigateFromWebAPI(url, .{ .reason = .script });
} }
pub fn _reload(_: *const Location, page: *Page) !void { pub fn _reload(_: *const Location, page: *Page) !void {
return page.navigateFromWebAPI(page.url.raw); return page.navigateFromWebAPI(page.url.raw, .{ .reason = .script });
} }
pub fn _toString(self: *Location, page: *Page) ![]const u8 { pub fn _toString(self: *Location, page: *Page) ![]const u8 {

View File

@@ -101,7 +101,7 @@ pub const Window = struct {
} }
pub fn set_location(_: *const Window, url: []const u8, page: *Page) !void { pub fn set_location(_: *const Window, url: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(url); return page.navigateFromWebAPI(url, .{ .reason = .script });
} }
pub fn get_console(self: *Window) *Console { pub fn get_console(self: *Window) *Console {

View File

@@ -199,8 +199,9 @@ pub const Page = struct {
self.url = request_url; self.url = request_url;
// load the data // 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(); defer request.deinit();
request.body = opts.body;
request.notification = notification; request.notification = notification;
notification.dispatch(.page_navigate, &.{ notification.dispatch(.page_navigate, &.{
@@ -541,7 +542,7 @@ pub const Page = struct {
.a => { .a => {
const element: *parser.Element = @ptrCast(node); const element: *parser.Element = @ptrCast(node);
const href = (try parser.elementGetAttribute(element, "href")) orelse return; const href = (try parser.elementGetAttribute(element, "href")) orelse return;
try self.navigateFromWebAPI(href); try self.navigateFromWebAPI(href, .{});
}, },
else => {}, else => {},
} }
@@ -550,10 +551,11 @@ pub const Page = struct {
// As such we schedule the function to be called as soon as possible. // 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 // The page.arena is safe to use here, but the transfer_arena exists
// specifically for this type of lifetime. // specifically for this type of lifetime.
pub fn navigateFromWebAPI(self: *Page, url: []const u8) !void { pub fn navigateFromWebAPI(self: *Page, url: []const u8, opts: NavigateOpts) !void {
const arena = self.session.transfer_arena; const arena = self.session.transfer_arena;
const navi = try arena.create(DelayedNavigation); const navi = try arena.create(DelayedNavigation);
navi.* = .{ navi.* = .{
.opts = opts,
.session = self.session, .session = self.session,
.url = try arena.dupe(u8, url), .url = try arena.dupe(u8, url),
}; };
@@ -578,17 +580,45 @@ pub const Page = struct {
} }
return null; 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 { const DelayedNavigation = struct {
url: []const u8, url: []const u8,
session: *Session, session: *Session,
opts: NavigateOpts,
navigate_node: Loop.CallbackNode = .{ .func = delayNavigate }, navigate_node: Loop.CallbackNode = .{ .func = delayNavigate },
fn delayNavigate(node: *Loop.CallbackNode, repeat_delay: *?u63) void { fn delayNavigate(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
_ = repeat_delay; _ = repeat_delay;
const self: *DelayedNavigation = @fieldParentPtr("navigate_node", node); const self: *DelayedNavigation = @fieldParentPtr("navigate_node", node);
self.session.pageNavigate(self.url) catch |err| { self.session.pageNavigate(self.url, self.opts) catch |err| {
log.err(.page, "delayed navigation error", .{ .err = err, .url = self.url }); log.err(.page, "delayed navigation error", .{ .err = err, .url = self.url });
}; };
} }
@@ -697,11 +727,15 @@ const Script = struct {
pub const NavigateReason = enum { pub const NavigateReason = enum {
anchor, anchor,
address_bar, address_bar,
form,
script,
}; };
pub const NavigateOpts = struct { pub const NavigateOpts = struct {
cdp_id: ?i64 = null, cdp_id: ?i64 = null,
reason: NavigateReason = .address_bar, reason: NavigateReason = .address_bar,
method: http.Request.Method = .GET,
body: ?[]const u8 = null,
}; };
fn timestamp() u32 { fn timestamp() u32 {

View File

@@ -23,6 +23,7 @@ const Allocator = std.mem.Allocator;
const Env = @import("env.zig").Env; const Env = @import("env.zig").Env;
const Page = @import("page.zig").Page; const Page = @import("page.zig").Page;
const Browser = @import("browser.zig").Browser; const Browser = @import("browser.zig").Browser;
const NavigateOpts = @import("page.zig").NavigateOpts;
const log = @import("../log.zig"); const log = @import("../log.zig");
const parser = @import("netsurf.zig"); const parser = @import("netsurf.zig");
@@ -122,7 +123,7 @@ pub const Session = struct {
return &(self.page orelse return null); 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 // currently, this is only called from the page, so let's hope
// it isn't null! // it isn't null!
std.debug.assert(self.page != null); std.debug.assert(self.page != null);
@@ -136,8 +137,6 @@ pub const Session = struct {
self.removePage(); self.removePage();
var page = try self.createPage(); var page = try self.createPage();
return page.navigate(url, .{ return page.navigate(url, opts);
.reason = .anchor,
});
} }
}; };

View File

@@ -51,21 +51,15 @@ pub const Interfaces = .{
// https://xhr.spec.whatwg.org/#interface-formdata // https://xhr.spec.whatwg.org/#interface-formdata
pub const FormData = struct { pub const FormData = struct {
entries: std.ArrayListUnmanaged(Entry), entries: Entry.List,
pub fn constructor(form_: ?*parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !FormData { pub fn constructor(form_: ?*parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !FormData {
const form = form_ orelse return .{ .entries = .empty }; const form = form_ orelse return .{ .entries = .empty };
return fromForm(form, submitter_, page, .{}); return fromForm(form, submitter_, page);
} }
const FromFormOpts = struct { pub fn fromForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !FormData {
// Uses the page.arena if null. This is needed for when we're handling const entries = try collectForm(form, submitter_, page);
// 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);
return .{ .entries = entries }; return .{ .entries = entries };
} }
@@ -144,11 +138,78 @@ pub const FormData = struct {
} }
return null; 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 { const Entry = struct {
key: []const u8, key: []const u8,
value: []const u8, value: []const u8,
pub const List = std.ArrayListUnmanaged(Entry);
}; };
const KeyIterable = iterator.Iterable(KeyIterator, "FormDataKeyIterator"); const KeyIterable = iterator.Iterable(KeyIterator, "FormDataKeyIterator");
@@ -157,7 +218,7 @@ const EntryIterable = iterator.Iterable(EntryIterator, "FormDataEntryIterator");
const KeyIterator = struct { const KeyIterator = struct {
index: usize = 0, index: usize = 0,
entries: *const std.ArrayListUnmanaged(Entry), entries: *const Entry.List,
pub fn _next(self: *KeyIterator) ?[]const u8 { pub fn _next(self: *KeyIterator) ?[]const u8 {
const index = self.index; const index = self.index;
@@ -171,7 +232,7 @@ const KeyIterator = struct {
const ValueIterator = struct { const ValueIterator = struct {
index: usize = 0, index: usize = 0,
entries: *const std.ArrayListUnmanaged(Entry), entries: *const Entry.List,
pub fn _next(self: *ValueIterator) ?[]const u8 { pub fn _next(self: *ValueIterator) ?[]const u8 {
const index = self.index; const index = self.index;
@@ -185,7 +246,7 @@ const ValueIterator = struct {
const EntryIterator = struct { const EntryIterator = struct {
index: usize = 0, index: usize = 0,
entries: *const std.ArrayListUnmanaged(Entry), entries: *const Entry.List,
pub fn _next(self: *EntryIterator) ?struct { []const u8, []const u8 } { pub fn _next(self: *EntryIterator) ?struct { []const u8, []const u8 } {
const index = self.index; 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 collection = try parser.formGetCollection(form);
const len = try parser.htmlCollectionGetLength(collection); const len = try parser.htmlCollectionGetLength(collection);
var entries: std.ArrayListUnmanaged(Entry) = .empty; var entries: Entry.List = .empty;
try entries.ensureTotalCapacity(arena, len); try entries.ensureTotalCapacity(arena, len);
const submitter_name_ = try getSubmitterName(submitter_); const submitter_name_ = try getSubmitterName(submitter_);
@@ -275,7 +338,7 @@ fn collectForm(arena: Allocator, form: *parser.Form, submitter_: ?*parser.Elemen
return entries; 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; const HTMLSelectElement = @import("../html/select.zig").HTMLSelectElement;
// Go through the HTMLSelectElement because it has specific logic for handling // 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);
}
}

View File

@@ -169,18 +169,27 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa
bc.reset(); bc.reset();
const is_anchor = event.opts.reason == .anchor; const reason_: ?[]const u8 = switch (event.opts.reason) {
if (is_anchor) { .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", .{ try cdp.sendEvent("Page.frameScheduledNavigation", .{
.frameId = target_id, .frameId = target_id,
.delay = 0, .delay = 0,
.reason = "anchorClick", .reason = reason,
.url = event.url.raw, .url = event.url.raw,
}, .{ .session_id = session_id }); }, .{ .session_id = session_id });
try cdp.sendEvent("Page.frameRequestedNavigation", .{ try cdp.sendEvent("Page.frameRequestedNavigation", .{
.frameId = target_id, .frameId = target_id,
.reason = "anchorClick", .reason = reason,
.url = event.url.raw, .url = event.url.raw,
.disposition = "currentTab", .disposition = "currentTab",
}, .{ .session_id = session_id }); }, .{ .session_id = session_id });
@@ -224,7 +233,7 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa
}, .{ .session_id = session_id }); }, .{ .session_id = session_id });
} }
if (is_anchor) { if (reason_ != null) {
try cdp.sendEvent("Page.frameClearedScheduledNavigation", .{ try cdp.sendEvent("Page.frameClearedScheduledNavigation", .{
.frameId = target_id, .frameId = target_id,
}, .{ .session_id = session_id }); }, .{ .session_id = session_id });

View File

@@ -87,7 +87,7 @@ pub const URL = struct {
/// ///
/// For URLs with a path, it will replace the last entry with the src. /// 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. /// 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) { if (base.len == 0) {
return src; return src;
} }
@@ -111,6 +111,31 @@ pub const URL = struct {
return std.fmt.allocPrint(allocator, "{s}/{s}", .{ base, src }); 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" { test "Url resolve size" {
@@ -170,3 +195,33 @@ test "URL: Stiching Base & Src URLs (Both Local)" {
defer allocator.free(result); defer allocator.free(result);
try testing.expectString("./abcdef/something.js", 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);
}
}