From 66117d375ef6e1925daf0fcb7760548185aa116c Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 7 Jul 2026 08:24:56 -0700 Subject: [PATCH 1/3] site-builder: ContactForm relay wiring (recipient, thank-you, honeypot, marker) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../forms/ContactForm.toHtml.test.ts | 24 +++++++++++++ craft/src/components/forms/ContactForm.tsx | 34 ++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 craft/src/components/forms/ContactForm.toHtml.test.ts diff --git a/craft/src/components/forms/ContactForm.toHtml.test.ts b/craft/src/components/forms/ContactForm.toHtml.test.ts new file mode 100644 index 0000000..d79c413 --- /dev/null +++ b/craft/src/components/forms/ContactForm.toHtml.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from 'vitest'; +import { ContactForm } from './ContactForm'; + +const toHtml = (ContactForm as any).toHtml; + +describe('ContactForm.toHtml relay wiring', () => { + test('with recipientEmail: emits marker, placeholder action, honeypot', () => { + const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, ''); + expect(html).toMatch(//); + expect(html).toMatch(/action="__WHP_FORM_ACTION__F[0-9a-z]+__"/); + expect(html).toContain('method="POST"'); + expect(html).toContain('name="_gotcha"'); + // marker id and action id match + const mid = html.match(/id="(F[0-9a-z]+)"/)![1]; + expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`); + }); + + test('without recipientEmail: no marker, falls back to formAction', () => { + const { html } = toHtml({ formAction: '/legacy', fields: [] }, ''); + expect(html).not.toContain('WHP-FORM'); + expect(html).toContain('action="/legacy"'); + expect(html).not.toContain('_gotcha'); + }); +}); diff --git a/craft/src/components/forms/ContactForm.tsx b/craft/src/components/forms/ContactForm.tsx index a813e01..0955cd9 100644 --- a/craft/src/components/forms/ContactForm.tsx +++ b/craft/src/components/forms/ContactForm.tsx @@ -21,6 +21,8 @@ interface ContactFormProps { labelColor?: string; inputBg?: string; inputBorder?: string; + recipientEmail?: string; + thankYouUrl?: string; } const defaultFields: ContactFormField[] = [ @@ -187,6 +189,23 @@ const ContactFormSettings: React.FC = () => { /> + {/* Relay recipient */} +
+ + setProp((p: ContactFormProps) => { p.recipientEmail = e.target.value; })} + placeholder="you@example.com" style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }} /> +

+ Delivered via the site's contact-form relay. Requires the relay to be enabled on this server. +

+
+
+ + setProp((p: ContactFormProps) => { p.thankYouUrl = e.target.value; })} + placeholder="/thank-you (blank = hosted page)" style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }} /> +
+ {/* Success Message */}
@@ -358,6 +377,8 @@ ContactForm.craft = { labelColor: '#374151', inputBg: '#ffffff', inputBorder: '#d1d5db', + recipientEmail: '', + thankYouUrl: '', }, rules: { canDrag: () => true, @@ -414,8 +435,19 @@ ContactForm.craft = { alignSelf: 'flex-start', }); + const useRelay = !!props.recipientEmail; + const fid = 'F' + Math.random().toString(36).slice(2, 8); + const actionAttr = useRelay ? `__WHP_FORM_ACTION__${fid}__` : esc(props.formAction || '#'); + const honeypot = useRelay + ? `` + : ''; + const marker = useRelay + ? `` + : ''; + return { - html: `
+ html: `${marker} + ${honeypot} ${fieldsHtml}
`, From cf5d30382a6da3e8ddc511e3b7425f3543241c42 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 7 Jul 2026 08:30:36 -0700 Subject: [PATCH 2/3] site-builder: ContactForm non-relay output byte-identical (honeypot whitespace fix) + test guard 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) --- craft/src/components/forms/ContactForm.toHtml.test.ts | 2 ++ craft/src/components/forms/ContactForm.tsx | 4 +--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/craft/src/components/forms/ContactForm.toHtml.test.ts b/craft/src/components/forms/ContactForm.toHtml.test.ts index d79c413..99b9598 100644 --- a/craft/src/components/forms/ContactForm.toHtml.test.ts +++ b/craft/src/components/forms/ContactForm.toHtml.test.ts @@ -20,5 +20,7 @@ describe('ContactForm.toHtml relay wiring', () => { expect(html).not.toContain('WHP-FORM'); expect(html).toContain('action="/legacy"'); expect(html).not.toContain('_gotcha'); + // Backward-compat: ensure non-relay output is byte-identical (no extra blank lines from honeypot) + expect(html).not.toMatch(/]*>\n\s*\n/); }); }); diff --git a/craft/src/components/forms/ContactForm.tsx b/craft/src/components/forms/ContactForm.tsx index 0955cd9..889de8b 100644 --- a/craft/src/components/forms/ContactForm.tsx +++ b/craft/src/components/forms/ContactForm.tsx @@ -447,9 +447,7 @@ ContactForm.craft = { return { html: `${marker}
- ${honeypot} - ${fieldsHtml} - +${honeypot ? ` ${honeypot}\n` : ''}${fieldsHtml ? ` ${fieldsHtml}\n` : ''}
`, }; }; From 4877a63a3b603225cf572483f9dc8cdb21f0c790 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 7 Jul 2026 12:22:28 -0700 Subject: [PATCH 3/3] site-builder: pin non-relay byte-identity on the realistic (non-empty fields) case [PR #2 review] --- .../components/forms/ContactForm.toHtml.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/craft/src/components/forms/ContactForm.toHtml.test.ts b/craft/src/components/forms/ContactForm.toHtml.test.ts index 99b9598..36e48f3 100644 --- a/craft/src/components/forms/ContactForm.toHtml.test.ts +++ b/craft/src/components/forms/ContactForm.toHtml.test.ts @@ -23,4 +23,21 @@ describe('ContactForm.toHtml relay wiring', () => { // Backward-compat: ensure non-relay output is byte-identical (no extra blank lines from honeypot) expect(html).not.toMatch(/]*>\n\s*\n/); }); + + test('without recipientEmail + real fields: byte-clean legacy output (realistic case)', () => { + // The empty-fields case is NOT byte-identical to the old code (the old + // template emitted a stray whitespace line when fields was empty; the new + // ternary drops it). Real forms always have fields, so pin THAT scenario: + // no marker, no honeypot, and no whitespace-only line between
and + // the first field. + const fields = [{ type: 'text', label: 'Name', name: 'name', placeholder: 'Your name', required: true }]; + const { html } = toHtml({ formAction: '/legacy', fields }, ''); + expect(html).not.toContain('WHP-FORM'); + expect(html).not.toContain('_gotcha'); + expect(html).toContain('action="/legacy"'); + expect(html).not.toMatch(/]*>\n\s*\n/); + // First field renders directly after the form tag (no stray blank line). + expect(html).toMatch(/]*>\n\s*