forms: support enctype=text/plain in form submission

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=<form-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.
This commit is contained in:
Navid EMAD
2026-05-13 18:08:54 +02:00
parent cedfdba0d7
commit 4b693db480
2 changed files with 61 additions and 0 deletions

View File

@@ -4023,6 +4023,9 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For
@import("../id.zig").uuidv4(&boundary_buf); @import("../id.zig").uuidv4(&boundary_buf);
break :blk .{ .formdata = &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")) { if (!std.ascii.eqlIgnoreCase(attr, "application/x-www-form-urlencoded")) {
log.warn(.not_implemented, "FormData.encoding", .{ .encoding = attr }); 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) { opts.header = switch (encoding) {
.urlencode => "Content-Type: application/x-www-form-urlencoded", .urlencode => "Content-Type: application/x-www-form-urlencoded",
.formdata => |b| try std.fmt.allocPrintSentinel(arena, "Content-Type: multipart/form-data; boundary={s}", .{b}, 0), .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 { } else {
action = try URL.concatQueryString(arena, action, buf.written()); action = try URL.concatQueryString(arena, action, buf.written());

View File

@@ -163,6 +163,7 @@ pub const EncType = union(enum) {
urlencode, urlencode,
// Boundary delimiter; caller owns the bytes (must outlive the write). // Boundary delimiter; caller owns the bytes (must outlive the write).
formdata: []const u8, formdata: []const u8,
plaintext,
}; };
pub const WriteOpts = struct { pub const WriteOpts = struct {
@@ -175,6 +176,7 @@ pub fn write(self: *const FormData, opts: WriteOpts, writer: *std.Io.Writer) !vo
switch (opts.encoding) { switch (opts.encoding) {
.urlencode => return self.urlEncode(opts, writer), .urlencode => return self.urlEncode(opts, writer),
.formdata => |boundary| return self.multipartEncode(boundary, 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); 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 { fn multipartEncode(self: *const FormData, boundary: []const u8, writer: *std.Io.Writer) !void {
for (self._entries.items) |*entry| { for (self._entries.items) |*entry| {
try multipartEncodeEntry(entry, boundary, writer); try multipartEncodeEntry(entry, boundary, writer);
@@ -461,3 +477,42 @@ test "FormData: multipart empty body" {
try testing.expectString("--B--\r\n", buf.written()); 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());
}