ContactForm relay wiring (recipient, thank-you, honeypot, marker) #2

Merged
jknapp merged 3 commits from contact-form-relay into main 2026-07-07 19:35:28 +00:00
Owner

ContactForm relay wiring

Wires the site-builder Contact Form to the form-sender relay (companion PR: whp form-sender-relay).

  • Adds recipientEmail + thankYouUrl settings; toHtml emits a <!--WHP-FORM …--> marker (stripped at publish, so the recipient never ships), method=POST action=__WHP_FORM_ACTION__…__ placeholder, and a hidden _gotcha honeypot — only when a recipient is set.
  • Backward compatible: with no recipient, output is byte-identical to the old form (verified by test).
  • vitest: ContactForm.toHtml.test.ts.

🤖 Generated with Claude Code

## ContactForm relay wiring Wires the site-builder Contact Form to the form-sender relay (companion PR: whp `form-sender-relay`). - Adds `recipientEmail` + `thankYouUrl` settings; `toHtml` emits a `<!--WHP-FORM …-->` marker (stripped at publish, so the recipient never ships), `method=POST action=__WHP_FORM_ACTION__…__` placeholder, and a hidden `_gotcha` honeypot — only when a recipient is set. - Backward compatible: with no recipient, output is **byte-identical** to the old form (verified by test). - vitest: `ContactForm.toHtml.test.ts`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jknapp added 2 commits 2026-07-07 19:02:48 +00:00
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix non-relay form output to match pre-change byte-for-byte by conditionally omitting the honeypot and fields lines when empty. Add backward-compat regex assertion to catch extra blank lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jknapp reviewed 2026-07-07 19:07:20 +00:00
jknapp left a comment
Author
Owner

Review: ContactForm relay wiring (contact-form-relaymain)

Verdict: Approve, with one non-blocking nit.

Verified locally: npx vitest run src/components/forms/ContactForm.toHtml.test.ts → 2/2 pass; tsc --noEmit clean.

What checks out

  • Id consistency (ContactForm.tsx:439-446): fid is generated once per toHtml() call and reused for both the marker's id="..." and the __WHP_FORM_ACTION__<fid>__ placeholder — they can't drift apart within a single render.
  • Escaping: recipientEmail/thankYouUrl are passed through esc() (line 396) before being interpolated into the <!--WHP-FORM ...--> marker. Since esc() escapes "&quot; and >&gt;, a value containing " can't break out of the pseudo-attribute, and one containing --> can't prematurely close the HTML comment (the > is neutralized). Confirmed by hand-tracing esc() at line 396.
  • Honeypot: type="text" (not type="hidden", which spam bots often skip on purpose) + position:absolute;left:-9999px + tabindex="-1" + aria-hidden="true" + autocomplete="off", named _gotcha (matches the common Formspree-style convention). It's emitted before fieldsHtml, so it's the first child inside <form>, and being a real <input> it's submitted normally.
  • Backward compat (common case): with no recipientEmail, marker/honeypot are both '', and actionAttr falls back to the pre-existing esc(props.formAction || '#'). For the realistic case — fields non-empty (i.e. defaultFields, or any user-populated field list) — I byte-diffed the old (d0925d9) template against the new one and they produce identical output.
  • Math.random().toString(36).slice(2,8)-style id generation matches the existing project convention (same pattern used in Countdown.tsx, Tabs.tsx, Gallery.tsx, NumberCounter.tsx, Menu.tsx, ContentSlider.tsx), so no new pattern introduced.
  • Test file asserts marker regex, action="__WHP_FORM_ACTION__<id>__", _gotcha presence, id-match between marker and action, and marker/gotcha absence in the non-relay path. All pass.

Finding (non-blocking nit)

ContactForm.tsx:450 — the "byte-identical" guard doesn't actually hold for empty fields, and the new regression test only exercises that non-representative case.

Both ContactForm.toHtml.test.ts cases use fields: []. With empty fields and no recipientEmail, I confirmed by direct comparison that the new code's output differs from the true pre-PR (d0925d9) baseline: the old template unconditionally emitted ${fieldsHtml} producing a whitespace-only line (" ") between <form> and <button>; the new code's fieldsHtml ? ... : '' ternary drops that line entirely when fieldsHtml is falsy.

OLD: "<form action=\"/legacy\" method=\"POST\">\n  \n  <button type=\"submit\">Send Message</button>\n</form>"
NEW: "<form action=\"/legacy\" method=\"POST\">\n  <button type=\"submit\">Send Message</button>\n</form>"
IDENTICAL: false

For the realistic case — non-empty fields (the default, and what every real published form uses) — I confirmed output is byte-identical old vs. new. So this doesn't affect any real site today (nobody publishes a contact form with zero fields), and arguably the new output is better (no stray whitespace-only line). But two things worth tightening:

  1. The commit message/PR description claims general "byte-identical" backward compat; that's only true when fields is non-empty. Worth a one-line caveat, or just special-case empty-fields to match old output exactly if literal byte-identity is the goal.
  2. The regression test itself only covers the fields: [] edge case (which is not byte-identical to pre-PR baseline) and never exercises the common defaultFields/non-empty-fields path, which is the one that actually needs the regression guard. Recommend adding a case with real fields (or defaultFields) asserting full-string equality against a fixed expected HTML string, to actually pin the byte-identity guarantee for production forms.

Neither point blocks merge — the relay wiring, escaping, id-matching, and honeypot behavior are all correct for the shipping scenarios.

## Review: ContactForm relay wiring (`contact-form-relay` → `main`) **Verdict: Approve, with one non-blocking nit.** Verified locally: `npx vitest run src/components/forms/ContactForm.toHtml.test.ts` → 2/2 pass; `tsc --noEmit` clean. ### What checks out - **Id consistency** (`ContactForm.tsx:439-446`): `fid` is generated once per `toHtml()` call and reused for both the marker's `id="..."` and the `__WHP_FORM_ACTION__<fid>__` placeholder — they can't drift apart within a single render. - **Escaping**: `recipientEmail`/`thankYouUrl` are passed through `esc()` (line 396) before being interpolated into the `<!--WHP-FORM ...-->` marker. Since `esc()` escapes `"` → `&quot;` *and* `>` → `&gt;`, a value containing `"` can't break out of the pseudo-attribute, and one containing `-->` can't prematurely close the HTML comment (the `>` is neutralized). Confirmed by hand-tracing `esc()` at line 396. - **Honeypot**: `type="text"` (not `type="hidden"`, which spam bots often skip on purpose) + `position:absolute;left:-9999px` + `tabindex="-1"` + `aria-hidden="true"` + `autocomplete="off"`, named `_gotcha` (matches the common Formspree-style convention). It's emitted before `fieldsHtml`, so it's the first child inside `<form>`, and being a real `<input>` it's submitted normally. - **Backward compat (common case)**: with no `recipientEmail`, `marker`/`honeypot` are both `''`, and `actionAttr` falls back to the pre-existing `esc(props.formAction || '#')`. For the realistic case — `fields` non-empty (i.e. `defaultFields`, or any user-populated field list) — I byte-diffed the old (`d0925d9`) template against the new one and they produce **identical output**. - `Math.random().toString(36).slice(2,8)`-style id generation matches the existing project convention (same pattern used in `Countdown.tsx`, `Tabs.tsx`, `Gallery.tsx`, `NumberCounter.tsx`, `Menu.tsx`, `ContentSlider.tsx`), so no new pattern introduced. - Test file asserts marker regex, `action="__WHP_FORM_ACTION__<id>__"`, `_gotcha` presence, id-match between marker and action, and marker/gotcha absence in the non-relay path. All pass. ### Finding (non-blocking nit) **`ContactForm.tsx:450` — the "byte-identical" guard doesn't actually hold for empty `fields`, and the new regression test only exercises that non-representative case.** Both `ContactForm.toHtml.test.ts` cases use `fields: []`. With empty fields and no `recipientEmail`, I confirmed by direct comparison that the new code's output *differs* from the true pre-PR (`d0925d9`) baseline: the old template unconditionally emitted ` ${fieldsHtml}` producing a whitespace-only line (`" "`) between `<form>` and `<button>`; the new code's `fieldsHtml ? ... : ''` ternary drops that line entirely when `fieldsHtml` is falsy. ``` OLD: "<form action=\"/legacy\" method=\"POST\">\n \n <button type=\"submit\">Send Message</button>\n</form>" NEW: "<form action=\"/legacy\" method=\"POST\">\n <button type=\"submit\">Send Message</button>\n</form>" IDENTICAL: false ``` For the realistic case — non-empty `fields` (the default, and what every real published form uses) — I confirmed output *is* byte-identical old vs. new. So this doesn't affect any real site today (nobody publishes a contact form with zero fields), and arguably the new output is *better* (no stray whitespace-only line). But two things worth tightening: 1. The commit message/PR description claims general "byte-identical" backward compat; that's only true when `fields` is non-empty. Worth a one-line caveat, or just special-case empty-fields to match old output exactly if literal byte-identity is the goal. 2. The regression test itself only covers the `fields: []` edge case (which is *not* byte-identical to pre-PR baseline) and never exercises the common `defaultFields`/non-empty-fields path, which is the one that actually needs the regression guard. Recommend adding a case with real fields (or `defaultFields`) asserting full-string equality against a fixed expected HTML string, to actually pin the byte-identity guarantee for production forms. Neither point blocks merge — the relay wiring, escaping, id-matching, and honeypot behavior are all correct for the shipping scenarios.
jknapp added 1 commit 2026-07-07 19:22:30 +00:00
jknapp merged commit 6b9c258d26 into main 2026-07-07 19:35:28 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: cloud-hosting-platform/site-builder#2