diff --git a/craft/src/utils/html-export.test.ts b/craft/src/utils/html-export.test.ts index d61bba0..aee43a8 100644 --- a/craft/src/utils/html-export.test.ts +++ b/craft/src/utils/html-export.test.ts @@ -787,3 +787,94 @@ describe('#54: canonical link in ', () => { expect(buildSeoMeta({ canonicalUrl: '/relative' }, 'P')).not.toContain('canonical'); }); }); + +/** + * Finding 6: relay forms (ContactForm / SubscribeForm / FormContainer with a + * recipient) start their toHtml with a `` marker comment. + * injectAttrs used to insert responsive/visibility/animation attrs before the + * FIRST `>` -- the end of that comment -- which broke the marker + * (`thankyou=""-- data-wsb-r="...">`), swallowed the
into an + * unterminated comment in Preview, and made the backend refuse the publish. + */ +describe('relay-form WHP-FORM marker survives injected attrs (finding 6)', () => { + // Ported verbatim from web-files/libs/FormRelayRewrite.php + // fs_rewrite_contact_forms() -- the strict marker regex the publish step uses. + const BACKEND_MARKER_RE = + //g; + + const node = (type: string, props: Record, nodes: string[] = [], parent?: string) => ({ + type: { resolvedName: type }, + isCanvas: type === 'Container' || type === 'FormContainer', + props, + displayName: type, + custom: {}, + hidden: false, + nodes, + linkedNodes: {}, + ...(parent ? { parent } : {}), + }); + + const overrides = { + recipientEmail: 'owner@example.com', + thankYouUrl: '/thanks', + styleMobile: { padding: '8px' }, + styleTablet: { margin: '4px' }, + hideOnMobile: true, + animation: 'fade-in', + animationDelay: '0.3s', + }; + + const formState = (type: string, extra: Record = {}) => + JSON.stringify({ + ROOT: node('Container', { tag: 'div', style: {} }, ['form1']), + form1: node(type, { ...overrides, ...extra }, [], 'ROOT'), + }); + + const cases: Array<[string, string]> = [ + ['ContactForm', 'form'], + ['SubscribeForm', 'div'], + ['FormContainer', 'form'], + ]; + + test.each(cases)('%s: marker intact + attrs land on the first real <%s>', (type, tag) => { + const { html } = exportBodyHtml(formState(type)); + const markers = [...html.matchAll(BACKEND_MARKER_RE)]; + expect(markers).toHaveLength(1); + const [marker, , , recipient, thankyou] = markers[0]; + expect(recipient).toBe('owner@example.com'); + expect(thankyou).toBe('/thanks'); + expect(marker).not.toContain('data-'); + // The element immediately after the marker carries every injected attr. + const after = html.slice(html.indexOf(marker) + marker.length); + const openTag = after.match(/^<([a-z]+)\b[^>]*>/); + expect(openTag, `no element right after marker: ${after.slice(0, 80)}`).not.toBeNull(); + expect(openTag![1]).toBe(tag); + expect(openTag![0]).toContain('data-wsb-r="b-form1"'); + expect(openTag![0]).toContain('data-hide-mobile'); + expect(openTag![0]).toContain('data-animation="fade-in"'); + expect(openTag![0]).toContain('data-animation-delay="0.3s"'); + // Exactly one comment terminator for the marker -- nothing swallowed. + expect(html.match(//g)!.length); + }); +}); + +describe('injectAttrs skips leading comments / style blocks / whitespace (finding 6)', () => { + test('leading comment', () => { + expect(injectAttrs('\ny', ' data-z')).toBe('\n
y
'); + }); + test('mixed comment + style + whitespace, any order, repeated', () => { + const html = ' \n
z
'; + expect(injectAttrs(html, ' data-z')).toBe( + ' \n
z
', + ); + }); + test('comment containing ">" is skipped as a unit', () => { + expect(injectAttrs('

t

', ' data-z')).toBe('

t

'); + }); + test('bare comment with nothing after is left alone', () => { + expect(injectAttrs('', ' data-z')).toBe(''); + }); + test('void tag after a comment still gets attrs before the slash', () => { + expect(injectAttrs('', ' data-z')).toBe(''); + }); +}); diff --git a/craft/src/utils/html-export.ts b/craft/src/utils/html-export.ts index f40e78e..91f39ff 100644 --- a/craft/src/utils/html-export.ts +++ b/craft/src/utils/html-export.ts @@ -70,13 +70,21 @@ function buildDataAttrs(props: Record): string { * the attrs before it instead, yielding well-formed ``. * Non-void tags (no trailing `/`) are unaffected. * - * Leading `` elements (components such as ButtonLink and - * ColumnLayout emit a scoped style block before their element) are skipped, - * so the attrs land on the component's real element, not on the `` elements (components such as + * ButtonLink and ColumnLayout emit a scoped style block before their element) + * and `` comments (relay forms -- ContactForm, SubscribeForm, + * FormContainer -- emit the `` marker before their element) + * are skipped, in any order and repeated, so the attrs land on the + * component's first REAL element. Injecting into the marker comment would + * break it (`…thankyou=""-- data-wsb-r="…">`): the browser swallows the form + * into an unterminated comment, and the publish step refuses the page. */ export function injectAttrs(html: string, attrs: string): string { if (!attrs) return html; - const prefixLen = leadingStyleLength(html); + const prefixLen = leadingNonElementLength(html); + // Nothing but style blocks / comments: there is no element to carry the + // attrs, and injecting into a comment would corrupt it -- leave it alone. + if (prefixLen > 0 && prefixLen >= html.length) return html; if (prefixLen > 0) return html.slice(0, prefixLen) + injectAttrs(html.slice(prefixLen), attrs); // Find the first > of the opening tag and inject before it const idx = html.indexOf('>'); @@ -94,18 +102,18 @@ export function injectAttrs(html: string, attrs: string): string { return html.slice(0, idx) + attrs + html.slice(idx); } -/** Length of any run of `` elements (plus whitespace) at the - * very start of `html`; 0 when it does not start with one. Only skips when - * something follows the style block(s) -- a bare style block is left alone. */ -function leadingStyleLength(html: string): number { - const re = /^\s*]*>[\s\S]*?<\/style>\s*/i; +/** Length of any run of `` elements and `` comments + * (plus whitespace), in any order, at the very start of `html`; 0 when it + * does not start with one. Equals `html.length` when nothing follows. */ +function leadingNonElementLength(html: string): number { + const re = /^\s*(?:]*>[\s\S]*?<\/style>|)\s*/i; let len = 0; for (;;) { const m = re.exec(html.slice(len)); if (!m) break; len += m[0].length; } - return len < html.length ? len : 0; + return len; } /**