From 2fdc82aa0579d95cda48d5d919cff65530411758 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Wed, 13 May 2026 17:49:19 +0200 Subject: [PATCH 1/6] forms: add enctype + 5 submitter form-* IDL accessors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six form-submission IDL accessors were missing from the JsApi blocks of HTMLFormElement, HTMLButtonElement, and HTMLInputElement, so reads produced undefined instead of the spec-mandated string/boolean. The content-attribute path (clicking a submit button honoring formaction / formmethod / formenctype) was wired up in #2279; this commit adds the matching IDL-property accessors per WHATWG HTML §4.10.18.6 and §4.10.21.5. - Form.enctype: limited to known values, missing+invalid both default to application/x-www-form-urlencoded (mirrors getMethod's shape). - Button/Input formAction: returns frame.url when missing/empty, else the resolved URL (mirrors Form.getAction). - Button/Input formEnctype, formMethod: limited to known values with no missing-value default ("" when missing, canonical invalid-value default application/x-www-form-urlencoded / get when invalid). - Button/Input formTarget: plain reflection, defaults to "". - Button/Input formNoValidate: boolean reflection of formnovalidate. Closes #2449 --- src/browser/tests/element/html/button.html | 117 ++++++++++++++++++ src/browser/tests/element/html/form.html | 45 +++++++ .../tests/element/html/input-attrs.html | 77 ++++++++++++ src/browser/webapi/element/html/Button.zig | 85 +++++++++++++ src/browser/webapi/element/html/Form.zig | 18 +++ src/browser/webapi/element/html/Input.zig | 79 ++++++++++++ 6 files changed, 421 insertions(+) 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..b7edcb66 100644 --- a/src/browser/webapi/element/html/Button.zig +++ b/src/browser/webapi/element/html/Button.zig @@ -120,6 +120,86 @@ 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 { + const enctype = self.asConstElement().getAttributeSafe(comptime .wrap("formenctype")) orelse return ""; + + if (std.ascii.eqlIgnoreCase(enctype, "multipart/form-data")) { + return "multipart/form-data"; + } + if (std.ascii.eqlIgnoreCase(enctype, "text/plain")) { + return "text/plain"; + } + if (std.ascii.eqlIgnoreCase(enctype, "application/x-www-form-urlencoded")) { + return "application/x-www-form-urlencoded"; + } + // invalid -> invalid-value default state (application/x-www-form-urlencoded) + return "application/x-www-form-urlencoded"; +} + +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 { + const method = self.asConstElement().getAttributeSafe(comptime .wrap("formmethod")) orelse return ""; + + if (std.ascii.eqlIgnoreCase(method, "post")) { + return "post"; + } + if (std.ascii.eqlIgnoreCase(method, "dialog")) { + return "dialog"; + } + // "get" or invalid -> invalid-value default state (get) + return "get"; +} + +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 +262,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..8473677f 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -119,6 +119,23 @@ 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 { + const enctype = self.asConstElement().getAttributeSafe(comptime .wrap("enctype")) orelse return "application/x-www-form-urlencoded"; + + if (std.ascii.eqlIgnoreCase(enctype, "multipart/form-data")) { + return "multipart/form-data"; + } + if (std.ascii.eqlIgnoreCase(enctype, "text/plain")) { + return "text/plain"; + } + // invalid, or it was application/x-www-form-urlencoded all along + return "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 +226,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..cfa93ab5 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -834,6 +834,80 @@ 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 { + const enctype = self.asConstElement().getAttributeSafe(comptime .wrap("formenctype")) orelse return ""; + + if (std.ascii.eqlIgnoreCase(enctype, "multipart/form-data")) { + return "multipart/form-data"; + } + if (std.ascii.eqlIgnoreCase(enctype, "text/plain")) { + return "text/plain"; + } + if (std.ascii.eqlIgnoreCase(enctype, "application/x-www-form-urlencoded")) { + return "application/x-www-form-urlencoded"; + } + // invalid -> invalid-value default state (application/x-www-form-urlencoded) + return "application/x-www-form-urlencoded"; +} + +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 { + const method = self.asConstElement().getAttributeSafe(comptime .wrap("formmethod")) orelse return ""; + + if (std.ascii.eqlIgnoreCase(method, "post")) { + return "post"; + } + if (std.ascii.eqlIgnoreCase(method, "dialog")) { + return "dialog"; + } + // "get" or invalid -> invalid-value default state (get) + return "get"; +} + +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 +1339,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, .{}); From cedfdba0d75120fed6909f2bde30ca23f0dd3d21 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Wed, 13 May 2026 17:58:55 +0200 Subject: [PATCH 2/6] forms: extract normalizeMethod / normalizeEnctype helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "limited to only known values" canonicalization (per WHATWG HTML §2.2.2) was duplicated five times: Form.getMethod + Form.getEnctype + {Button,Input}.{getFormMethod,getFormEnctype}. Each callsite differed only in the missing-value default ("" for submitter overrides, "get" / "application/x-www-form-urlencoded" for the form-side). Extract into two pub helpers on Form.zig taking the attribute slice + the missing-value default. The five callers collapse to one-liners. Behavior-preserving: existing form.html / button.html / input-attrs.html fixtures all pass unchanged; full suite 637/637 green. Net: -36 LOC. --- src/browser/webapi/element/html/Button.zig | 25 +----------- src/browser/webapi/element/html/Form.zig | 46 ++++++++++++---------- src/browser/webapi/element/html/Input.zig | 25 +----------- 3 files changed, 30 insertions(+), 66 deletions(-) diff --git a/src/browser/webapi/element/html/Button.zig b/src/browser/webapi/element/html/Button.zig index b7edcb66..a6c4fbe6 100644 --- a/src/browser/webapi/element/html/Button.zig +++ b/src/browser/webapi/element/html/Button.zig @@ -144,19 +144,7 @@ pub fn setFormAction(self: *Button, value: []const u8, frame: *Frame) !void { } pub fn getFormEnctype(self: *const Button) []const u8 { - const enctype = self.asConstElement().getAttributeSafe(comptime .wrap("formenctype")) orelse return ""; - - if (std.ascii.eqlIgnoreCase(enctype, "multipart/form-data")) { - return "multipart/form-data"; - } - if (std.ascii.eqlIgnoreCase(enctype, "text/plain")) { - return "text/plain"; - } - if (std.ascii.eqlIgnoreCase(enctype, "application/x-www-form-urlencoded")) { - return "application/x-www-form-urlencoded"; - } - // invalid -> invalid-value default state (application/x-www-form-urlencoded) - return "application/x-www-form-urlencoded"; + return Form.normalizeEnctype(self.asConstElement().getAttributeSafe(comptime .wrap("formenctype")), ""); } pub fn setFormEnctype(self: *Button, value: []const u8, frame: *Frame) !void { @@ -164,16 +152,7 @@ pub fn setFormEnctype(self: *Button, value: []const u8, frame: *Frame) !void { } pub fn getFormMethod(self: *const Button) []const u8 { - const method = self.asConstElement().getAttributeSafe(comptime .wrap("formmethod")) orelse return ""; - - if (std.ascii.eqlIgnoreCase(method, "post")) { - return "post"; - } - if (std.ascii.eqlIgnoreCase(method, "dialog")) { - return "dialog"; - } - // "get" or invalid -> invalid-value default state (get) - return "get"; + return Form.normalizeMethod(self.asConstElement().getAttributeSafe(comptime .wrap("formmethod")), ""); } pub fn setFormMethod(self: *Button, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index 8473677f..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); } @@ -120,16 +135,7 @@ pub fn setAcceptCharset(self: *Form, value: []const u8, frame: *Frame) !void { } pub fn getEnctype(self: *const Form) []const u8 { - const enctype = self.asConstElement().getAttributeSafe(comptime .wrap("enctype")) orelse return "application/x-www-form-urlencoded"; - - if (std.ascii.eqlIgnoreCase(enctype, "multipart/form-data")) { - return "multipart/form-data"; - } - if (std.ascii.eqlIgnoreCase(enctype, "text/plain")) { - return "text/plain"; - } - // invalid, or it was application/x-www-form-urlencoded all along - return "application/x-www-form-urlencoded"; + return normalizeEnctype(self.asConstElement().getAttributeSafe(comptime .wrap("enctype")), "application/x-www-form-urlencoded"); } pub fn setEnctype(self: *Form, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index cfa93ab5..f712e259 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -852,19 +852,7 @@ pub fn setFormAction(self: *Input, value: []const u8, frame: *Frame) !void { } pub fn getFormEnctype(self: *const Input) []const u8 { - const enctype = self.asConstElement().getAttributeSafe(comptime .wrap("formenctype")) orelse return ""; - - if (std.ascii.eqlIgnoreCase(enctype, "multipart/form-data")) { - return "multipart/form-data"; - } - if (std.ascii.eqlIgnoreCase(enctype, "text/plain")) { - return "text/plain"; - } - if (std.ascii.eqlIgnoreCase(enctype, "application/x-www-form-urlencoded")) { - return "application/x-www-form-urlencoded"; - } - // invalid -> invalid-value default state (application/x-www-form-urlencoded) - return "application/x-www-form-urlencoded"; + return Form.normalizeEnctype(self.asConstElement().getAttributeSafe(comptime .wrap("formenctype")), ""); } pub fn setFormEnctype(self: *Input, value: []const u8, frame: *Frame) !void { @@ -872,16 +860,7 @@ pub fn setFormEnctype(self: *Input, value: []const u8, frame: *Frame) !void { } pub fn getFormMethod(self: *const Input) []const u8 { - const method = self.asConstElement().getAttributeSafe(comptime .wrap("formmethod")) orelse return ""; - - if (std.ascii.eqlIgnoreCase(method, "post")) { - return "post"; - } - if (std.ascii.eqlIgnoreCase(method, "dialog")) { - return "dialog"; - } - // "get" or invalid -> invalid-value default state (get) - return "get"; + return Form.normalizeMethod(self.asConstElement().getAttributeSafe(comptime .wrap("formmethod")), ""); } pub fn setFormMethod(self: *Input, value: []const u8, frame: *Frame) !void { From 4b693db480b50a1769c314a817bed267a6200535 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Wed, 13 May 2026 18:08:54 +0200 Subject: [PATCH 3/6] forms: support enctype=text/plain in form submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the divergence introduced by the new IDL accessors: `submitter.formEnctype` (and `form.enctype`) now return "text/plain" for that attribute value per WHATWG HTML §4.10.21.5, but `Frame.submitForm` previously fell back to urlencoded with a `.not_implemented` log when it saw the same value on the submission path. Implement the spec's text/plain encoding algorithm (HTML §4.10.21.8): - FormData.EncType gains a `.plaintext` variant. - FormData.plaintextEncode writes "name=value CRLF" per entry, no URL-encoding, no escaping — the spec accepts that text/plain is a lossy, human-readable encoding (values containing "=" or CRLF produce an ambiguous wire format by design). - Frame.submitForm recognizes "text/plain" before the urlencoded fallback and sets the Content-Type header to "text/plain; charset=", per spec step 21.4. Two new Zig unit tests cover encoding output (`FormData: plaintext write`, `FormData: plaintext empty body`). Full suite 639/639 green. This is bundled with the IDL accessor commits because returning "text/plain" from the IDL while the submission silently re-encodes as urlencoded is a spec-internal inconsistency the IDL change itself introduces. Reviewers who'd prefer to land just the read-only accessors first should feel free to ask for a split — this commit is self-contained and reverts cleanly. --- src/browser/Frame.zig | 6 ++++ src/browser/webapi/net/FormData.zig | 55 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 1c7f0bfd..24faeade 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -4023,6 +4023,9 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For @import("../id.zig").uuidv4(&boundary_buf); break :blk .{ .formdata = &boundary_buf }; } + if (std.ascii.eqlIgnoreCase(attr, "text/plain")) { + break :blk .plaintext; + } if (!std.ascii.eqlIgnoreCase(attr, "application/x-www-form-urlencoded")) { log.warn(.not_implemented, "FormData.encoding", .{ .encoding = attr }); } @@ -4051,6 +4054,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/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index fb4f49f3..026bfafb 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -163,6 +163,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 +176,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 +197,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 writer.writeAll(entry.name.str()); + try writer.writeByte('='); + try writer.writeAll(entry.value.asString()); + 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 +477,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()); +} From f0cce42757fe877f7d4829674f31b474af038c02 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Wed, 13 May 2026 18:14:10 +0200 Subject: [PATCH 4/6] forms: route Frame.submitForm through Form.normalizeMethod/normalizeEnctype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The submitForm encoding path was the last duplicate of the "limited to only known values" canonicalization the previous commit consolidated for the IDL getters. Now it consumes the same Form.normalizeMethod / Form.normalizeEnctype helpers, so a single function owns the canonical mapping (`""` / unknown -> spec default, recognized values pass through unchanged). Side effect of routing through the helper: the `log.warn(.not_implemented, "FormData.encoding", ...)` branch falls out. After commit 4b693db4 added `text/plain`, the only attribute values that still reach the urlencoded fallback are spec-invalid ones, which per HTML §4.10.21.5 silently canonicalize to `application/x-www-form-urlencoded`. The warning was firing for valid spec behavior — Chrome doesn't log either. Behavior-preserving on all observable surfaces: full suite 639/639 green; existing form-submission integration tests (multipart, urlencoded, text/plain, GET-ignores-enctype) all pass unchanged. --- src/browser/Frame.zig | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 24faeade..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,19 +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, "text/plain")) { - break :blk .plaintext; - } - 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; }; From 14b4449628c104ca4ced1cca90fea78382f68620 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 14 May 2026 11:03:12 +0800 Subject: [PATCH 5/6] use format to write String value --- src/browser/webapi/net/FormData.zig | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index 026bfafb..41c09a35 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -50,6 +50,14 @@ 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 + }; + } }; }; @@ -204,9 +212,9 @@ fn urlEncodeEntry(entry: *const Entry, opts: WriteOpts, writer: *std.Io.Writer) /// 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 writer.writeAll(entry.name.str()); + try entry.name.format(writer); try writer.writeByte('='); - try writer.writeAll(entry.value.asString()); + try entry.value.format(writer); try writer.writeAll("\r\n"); } } From 80a09fc0fdf6456cf32baee86e0a6528d2b59638 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 14 May 2026 13:19:17 +0800 Subject: [PATCH 6/6] zig fmt --- src/browser/webapi/net/FormData.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index 41c09a35..82436a24 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -51,7 +51,6 @@ pub const Entry = struct { }; } - pub fn format(self: Value, writer: *std.Io.Writer) !void { return switch (self) { .string => |s| s.format(writer),