diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 1c7f0bfd..961c2c91 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -3994,13 +3994,14 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For } break :blk form_element.getAttributeSafe(comptime .wrap("enctype")); }; - const method = blk: { + const method_attr: ?[]const u8 = blk: { if (submit_button) |s| { if (s.getAttributeSafe(comptime .wrap("formmethod"))) |fm| break :blk fm; } - break :blk form_element.getAttributeSafe(comptime .wrap("method")) orelse ""; + break :blk form_element.getAttributeSafe(comptime .wrap("method")); }; - const is_post = std.ascii.eqlIgnoreCase(method, "post"); + const method = Element.Html.Form.normalizeMethod(method_attr, "get"); + const is_post = std.mem.eql(u8, method, "post"); // Get charset from accept-charset attribute or fall back to document charset const charset: []const u8 = blk: { @@ -4017,16 +4018,14 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For var boundary_buf: [36]u8 = undefined; // GET ignores enctype per HTML spec; only resolve the union for POST. const encoding: FormData.EncType = blk: { - if (is_post) { - if (enctype_attr) |attr| { - if (std.ascii.eqlIgnoreCase(attr, "multipart/form-data")) { - @import("../id.zig").uuidv4(&boundary_buf); - break :blk .{ .formdata = &boundary_buf }; - } - if (!std.ascii.eqlIgnoreCase(attr, "application/x-www-form-urlencoded")) { - log.warn(.not_implemented, "FormData.encoding", .{ .encoding = attr }); - } - } + if (!is_post) break :blk .urlencode; + const canonical = Element.Html.Form.normalizeEnctype(enctype_attr, "application/x-www-form-urlencoded"); + if (std.mem.eql(u8, canonical, "multipart/form-data")) { + @import("../id.zig").uuidv4(&boundary_buf); + break :blk .{ .formdata = &boundary_buf }; + } + if (std.mem.eql(u8, canonical, "text/plain")) { + break :blk .plaintext; } break :blk .urlencode; }; @@ -4051,6 +4050,9 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For opts.header = switch (encoding) { .urlencode => "Content-Type: application/x-www-form-urlencoded", .formdata => |b| try std.fmt.allocPrintSentinel(arena, "Content-Type: multipart/form-data; boundary={s}", .{b}, 0), + // Per WHATWG HTML §4.10.21.6, text/plain submissions include the form's + // resolved encoding (accept-charset or document charset). + .plaintext => try std.fmt.allocPrintSentinel(arena, "Content-Type: text/plain; charset={s}", .{charset}, 0), }; } else { action = try URL.concatQueryString(arena, action, buf.written()); diff --git a/src/browser/tests/element/html/button.html b/src/browser/tests/element/html/button.html index 7c274cd8..0dd393ef 100644 --- a/src/browser/tests/element/html/button.html +++ b/src/browser/tests/element/html/button.html @@ -142,3 +142,120 @@ testing.expectEqual(true, captured.isTrusted) } + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/form.html b/src/browser/tests/element/html/form.html index 8a92cb10..4eec5e2f 100644 --- a/src/browser/tests/element/html/form.html +++ b/src/browser/tests/element/html/form.html @@ -85,6 +85,51 @@ } + +
+
+
+
+ + + + + + +
diff --git a/src/browser/tests/element/html/input-attrs.html b/src/browser/tests/element/html/input-attrs.html index 89372766..5f773469 100644 --- a/src/browser/tests/element/html/input-attrs.html +++ b/src/browser/tests/element/html/input-attrs.html @@ -287,3 +287,80 @@ testing.expectEqual('5', s12.value); } + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/element/html/Button.zig b/src/browser/webapi/element/html/Button.zig index 181be133..a6c4fbe6 100644 --- a/src/browser/webapi/element/html/Button.zig +++ b/src/browser/webapi/element/html/Button.zig @@ -120,6 +120,65 @@ pub fn getLabels(self: *Button, frame: *Frame) !js.Array { return @import("Label.zig").getControlLabels(self.asElement(), frame); } +// Form submission attribute overrides +// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-0 +// +// Each `formX` IDL attribute reflects the matching `formx` content attribute and +// overrides the form-owner's value when this button is the submitter. Per spec +// these are "no missing value default" reflections — getters return "" when the +// content attribute is absent, so the `submitter.formX || form.X` idiom in +// downstream CDP clients (e.g. Turbo's FormSubmission constructor) falls +// through to the form's value. + +pub fn getFormAction(self: *Button, frame: *Frame) ![]const u8 { + const element = self.asElement(); + const action = element.getAttributeSafe(comptime .wrap("formaction")) orelse return frame.url; + if (action.len == 0) { + return frame.url; + } + return element.asNode().resolveURL(action, frame, .{}); +} + +pub fn setFormAction(self: *Button, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("formaction"), .wrap(value), frame); +} + +pub fn getFormEnctype(self: *const Button) []const u8 { + return Form.normalizeEnctype(self.asConstElement().getAttributeSafe(comptime .wrap("formenctype")), ""); +} + +pub fn setFormEnctype(self: *Button, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("formenctype"), .wrap(value), frame); +} + +pub fn getFormMethod(self: *const Button) []const u8 { + return Form.normalizeMethod(self.asConstElement().getAttributeSafe(comptime .wrap("formmethod")), ""); +} + +pub fn setFormMethod(self: *Button, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("formmethod"), .wrap(value), frame); +} + +pub fn getFormTarget(self: *const Button) []const u8 { + return self.asConstElement().getAttributeSafe(comptime .wrap("formtarget")) orelse ""; +} + +pub fn setFormTarget(self: *Button, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("formtarget"), .wrap(value), frame); +} + +pub fn getFormNoValidate(self: *const Button) bool { + return self.asConstElement().getAttributeSafe(.wrap("formnovalidate")) != null; +} + +pub fn setFormNoValidate(self: *Button, value: bool, frame: *Frame) !void { + if (value) { + try self.asElement().setAttributeSafe(.wrap("formnovalidate"), .wrap(""), frame); + } else { + try self.asElement().removeAttribute(.wrap("formnovalidate"), frame); + } +} + // Constraint validation // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api // @@ -182,6 +241,11 @@ pub const JsApi = struct { pub const name = bridge.accessor(Button.getName, Button.setName, .{}); pub const required = bridge.accessor(Button.getRequired, Button.setRequired, .{}); pub const form = bridge.accessor(Button.getForm, null, .{}); + pub const formAction = bridge.accessor(Button.getFormAction, Button.setFormAction, .{}); + pub const formEnctype = bridge.accessor(Button.getFormEnctype, Button.setFormEnctype, .{}); + pub const formMethod = bridge.accessor(Button.getFormMethod, Button.setFormMethod, .{}); + pub const formNoValidate = bridge.accessor(Button.getFormNoValidate, Button.setFormNoValidate, .{}); + pub const formTarget = bridge.accessor(Button.getFormTarget, Button.setFormTarget, .{}); pub const value = bridge.accessor(Button.getValue, Button.setValue, .{}); pub const @"type" = bridge.accessor(Button.getType, Button.setType, .{}); pub const labels = bridge.accessor(Button.getLabels, null, .{}); diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index 7443cc0a..3e3d3585 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -54,19 +54,34 @@ pub fn setName(self: *Form, name: []const u8, frame: *Frame) !void { try self.asElement().setAttributeSafe(comptime .wrap("name"), .wrap(name), frame); } -pub fn getMethod(self: *const Form) []const u8 { - const method = self.asConstElement().getAttributeSafe(comptime .wrap("method")) orelse return "get"; - - if (std.ascii.eqlIgnoreCase(method, "post")) { - return "post"; - } - if (std.ascii.eqlIgnoreCase(method, "dialog")) { - return "dialog"; - } - // invalid, or it was get all along +/// Canonicalize the `method` content attribute (or its `formmethod` submitter +/// override) per WHATWG HTML "limited to only known values": +/// - missing → returns `missing_default` +/// - "post" / "dialog" → returns the lowercased keyword +/// - empty / invalid / "get" → returns "get" (invalid-value default) +pub fn normalizeMethod(attr: ?[]const u8, missing_default: []const u8) []const u8 { + const method = attr orelse return missing_default; + if (std.ascii.eqlIgnoreCase(method, "post")) return "post"; + if (std.ascii.eqlIgnoreCase(method, "dialog")) return "dialog"; return "get"; } +/// Canonicalize the `enctype` content attribute (or its `formenctype` submitter +/// override) per WHATWG HTML "limited to only known values": +/// - missing → returns `missing_default` +/// - "multipart/form-data" / "text/plain" → returns the lowercased keyword +/// - empty / invalid / urlencoded → returns "application/x-www-form-urlencoded" +pub fn normalizeEnctype(attr: ?[]const u8, missing_default: []const u8) []const u8 { + const enctype = attr orelse return missing_default; + if (std.ascii.eqlIgnoreCase(enctype, "multipart/form-data")) return "multipart/form-data"; + if (std.ascii.eqlIgnoreCase(enctype, "text/plain")) return "text/plain"; + return "application/x-www-form-urlencoded"; +} + +pub fn getMethod(self: *const Form) []const u8 { + return normalizeMethod(self.asConstElement().getAttributeSafe(comptime .wrap("method")), "get"); +} + pub fn setMethod(self: *Form, method: []const u8, frame: *Frame) !void { try self.asElement().setAttributeSafe(comptime .wrap("method"), .wrap(method), frame); } @@ -119,6 +134,14 @@ pub fn setAcceptCharset(self: *Form, value: []const u8, frame: *Frame) !void { try self.asElement().setAttributeSafe(.wrap("accept-charset"), .wrap(value), frame); } +pub fn getEnctype(self: *const Form) []const u8 { + return normalizeEnctype(self.asConstElement().getAttributeSafe(comptime .wrap("enctype")), "application/x-www-form-urlencoded"); +} + +pub fn setEnctype(self: *Form, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("enctype"), .wrap(value), frame); +} + pub fn getLength(self: *Form, frame: *Frame) !u32 { const elements = try self.getElements(frame); return elements.length(frame); @@ -209,6 +232,7 @@ pub const JsApi = struct { pub const action = bridge.accessor(Form.getAction, Form.setAction, .{}); pub const target = bridge.accessor(Form.getTarget, Form.setTarget, .{}); pub const acceptCharset = bridge.accessor(Form.getAcceptCharset, Form.setAcceptCharset, .{}); + pub const enctype = bridge.accessor(Form.getEnctype, Form.setEnctype, .{}); pub const elements = bridge.accessor(Form.getElements, null, .{}); pub const length = bridge.accessor(Form.getLength, null, .{}); pub const submit = bridge.function(Form.submit, .{}); diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index ebcd4073..f712e259 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -834,6 +834,59 @@ pub fn getForm(self: *const Input, frame: *Frame) ?*Form { return null; } +// Form submission attribute overrides +// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-0 +// Mirrors Button's overrides — same spec semantics. + +pub fn getFormAction(self: *Input, frame: *Frame) ![]const u8 { + const element = self.asElement(); + const action = element.getAttributeSafe(comptime .wrap("formaction")) orelse return frame.url; + if (action.len == 0) { + return frame.url; + } + return element.asNode().resolveURL(action, frame, .{}); +} + +pub fn setFormAction(self: *Input, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("formaction"), .wrap(value), frame); +} + +pub fn getFormEnctype(self: *const Input) []const u8 { + return Form.normalizeEnctype(self.asConstElement().getAttributeSafe(comptime .wrap("formenctype")), ""); +} + +pub fn setFormEnctype(self: *Input, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("formenctype"), .wrap(value), frame); +} + +pub fn getFormMethod(self: *const Input) []const u8 { + return Form.normalizeMethod(self.asConstElement().getAttributeSafe(comptime .wrap("formmethod")), ""); +} + +pub fn setFormMethod(self: *Input, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("formmethod"), .wrap(value), frame); +} + +pub fn getFormTarget(self: *const Input) []const u8 { + return self.asConstElement().getAttributeSafe(comptime .wrap("formtarget")) orelse ""; +} + +pub fn setFormTarget(self: *Input, value: []const u8, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("formtarget"), .wrap(value), frame); +} + +pub fn getFormNoValidate(self: *const Input) bool { + return self.asConstElement().getAttributeSafe(.wrap("formnovalidate")) != null; +} + +pub fn setFormNoValidate(self: *Input, value: bool, frame: *Frame) !void { + if (value) { + try self.asElement().setAttributeSafe(.wrap("formnovalidate"), .wrap(""), frame); + } else { + try self.asElement().removeAttribute(.wrap("formnovalidate"), frame); + } +} + /// Sanitize the value according to the current input type fn sanitizeValue(self: *Input, comptime dupe: bool, value: []const u8, frame: *Frame) ![]const u8 { switch (self._input_type) { @@ -1265,6 +1318,11 @@ pub const JsApi = struct { pub const size = bridge.accessor(Input.getSize, Input.setSize, .{}); pub const src = bridge.accessor(Input.getSrc, Input.setSrc, .{}); pub const form = bridge.accessor(Input.getForm, null, .{}); + pub const formAction = bridge.accessor(Input.getFormAction, Input.setFormAction, .{}); + pub const formEnctype = bridge.accessor(Input.getFormEnctype, Input.setFormEnctype, .{}); + pub const formMethod = bridge.accessor(Input.getFormMethod, Input.setFormMethod, .{}); + pub const formNoValidate = bridge.accessor(Input.getFormNoValidate, Input.setFormNoValidate, .{}); + pub const formTarget = bridge.accessor(Input.getFormTarget, Input.setFormTarget, .{}); pub const labels = bridge.accessor(Input.getLabels, null, .{}); pub const indeterminate = bridge.accessor(Input.getIndeterminate, Input.setIndeterminate, .{}); pub const placeholder = bridge.accessor(Input.getPlaceholder, Input.setPlaceholder, .{}); diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index fb4f49f3..82436a24 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -50,6 +50,13 @@ pub const Entry = struct { .file => unreachable, // nothing currently creates this type of value }; } + + pub fn format(self: Value, writer: *std.Io.Writer) !void { + return switch (self) { + .string => |s| s.format(writer), + .file => unreachable, // nothing currently creates this type of value + }; + } }; }; @@ -163,6 +170,7 @@ pub const EncType = union(enum) { urlencode, // Boundary delimiter; caller owns the bytes (must outlive the write). formdata: []const u8, + plaintext, }; pub const WriteOpts = struct { @@ -175,6 +183,7 @@ pub fn write(self: *const FormData, opts: WriteOpts, writer: *std.Io.Writer) !vo switch (opts.encoding) { .urlencode => return self.urlEncode(opts, writer), .formdata => |boundary| return self.multipartEncode(boundary, writer), + .plaintext => return self.plaintextEncode(writer), } } @@ -195,6 +204,20 @@ fn urlEncodeEntry(entry: *const Entry, opts: WriteOpts, writer: *std.Io.Writer) try KeyValueList.urlEncodeFormValue(entry.value.asString(), opts.allocator, opts.charset, writer); } +/// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#text/plain-encoding-algorithm +/// +/// For each entry: name, "=", value, CRLF. No URL-encoding, no escaping. Per the +/// spec this is a low-fidelity encoding intended for human-readable values; a +/// value containing "=" or CRLF produces an ambiguous wire format, by design. +fn plaintextEncode(self: *const FormData, writer: *std.Io.Writer) !void { + for (self._entries.items) |*entry| { + try entry.name.format(writer); + try writer.writeByte('='); + try entry.value.format(writer); + try writer.writeAll("\r\n"); + } +} + fn multipartEncode(self: *const FormData, boundary: []const u8, writer: *std.Io.Writer) !void { for (self._entries.items) |*entry| { try multipartEncodeEntry(entry, boundary, writer); @@ -461,3 +484,42 @@ test "FormData: multipart empty body" { try testing.expectString("--B--\r\n", buf.written()); } + +test "FormData: plaintext write" { + const allocator = testing.arena_allocator; + + var fd = FormData{ + ._arena = allocator, + ._entries = .empty, + }; + try fd.append("name", "John"); + try fd.append("note", "two\r\nlines"); + try fd.append("equals", "a=b"); + + var buf = std.Io.Writer.Allocating.init(allocator); + try fd.write(.{ .encoding = .plaintext, .allocator = allocator }, &buf.writer); + + // Per WHATWG HTML text/plain encoding algorithm: name=value CRLF per entry. + // Values containing "=" or CRLF are written verbatim — the spec accepts that + // text/plain is a low-fidelity, lossy encoding for human-readable content. + try testing.expectString( + "name=John\r\n" ++ + "note=two\r\nlines\r\n" ++ + "equals=a=b\r\n", + buf.written(), + ); +} + +test "FormData: plaintext empty body" { + const allocator = testing.arena_allocator; + + var fd = FormData{ + ._arena = allocator, + ._entries = .empty, + }; + + var buf = std.Io.Writer.Allocating.init(allocator); + try fd.write(.{ .encoding = .plaintext, .allocator = allocator }, &buf.writer); + + try testing.expectString("", buf.written()); +}