diff --git a/packages/blocks-engine/CHANGELOG.md b/packages/blocks-engine/CHANGELOG.md index b84ddd2c1..7545d5257 100644 --- a/packages/blocks-engine/CHANGELOG.md +++ b/packages/blocks-engine/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to `@automattic/blocks-engine` will be documented in this fi This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Added +- convert `form` elements (labels, inputs, selects, textareas, radio/checkbox groups, submit buttons) to editable `jetpack/contact-form` block markup instead of a `core/html` fallback island + ## [0.3.3] - 2026-10-13 ### Changed diff --git a/packages/blocks-engine/src/__fixtures__/cases.json b/packages/blocks-engine/src/__fixtures__/cases.json index bab4edac9..1b64a56bf 100644 --- a/packages/blocks-engine/src/__fixtures__/cases.json +++ b/packages/blocks-engine/src/__fixtures__/cases.json @@ -135,6 +135,12 @@ "input": "
\"A\"/

Title

copy

", "expected": "\n
\"A\"/

Title

copy

\n" }, + { + "id": "compose.generic-form", + "op": "compose", + "input": "
", + "expected": "\n
\n\n\n\n
\n\n
\n" + }, { "id": "compose.sample-youtube-iframe", "op": "compose", diff --git a/packages/blocks-engine/src/__tests__/form-html.test.ts b/packages/blocks-engine/src/__tests__/form-html.test.ts new file mode 100644 index 000000000..91f2edb28 --- /dev/null +++ b/packages/blocks-engine/src/__tests__/form-html.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; +import * as cheerio from 'cheerio'; + +import { compose } from '../compose'; +import { parseHtmlForm } from '../form-html'; + +function parse(html: string) { + const $ = cheerio.load(html, null, false); + const form = $.root().children('form').get(0) as unknown as Parameters[1]; + return parseHtmlForm($, form); +} + +describe('parseHtmlForm', () => { + it('returns null for forms with no representable fields', () => { + expect(parse('
decor
')).toBeNull(); + }); + + it('maps input types to field kinds and carries required, placeholder, and value', () => { + const parsed = parse( + '
', + ); + expect(parsed?.fields).toEqual([ + { kind: 'email', label: 'Email', required: true, placeholder: 'you@x.test' }, + { kind: 'text', label: 'Team', required: false, defaultValue: 'Blue' }, + { kind: 'text', label: 'Pass', required: false }, + { kind: 'text', label: 'q', required: false }, + ]); + }); + + it('maps textarea and select controls, dropping empty-value placeholder options', () => { + const parsed = parse( + '
', + ); + expect(parsed?.fields).toEqual([ + { + kind: 'textarea', + label: 'Message', + required: false, + placeholder: 'Say hi', + defaultValue: 'Hello there', + }, + { kind: 'select', label: 'Topic', required: false, options: ['Buying', 'Selling'] }, + ]); + }); + + it('collapses a same-name radio group into one field with options in document order', () => { + const parsed = parse( + '
Plan
', + ); + expect(parsed?.fields).toEqual([ + { kind: 'radio', label: 'Basic', required: false, options: ['Basic', 'Pro'] }, + ]); + }); + + it('collapses a same-name checkbox group into options and keeps a lone checkbox optionless', () => { + const parsed = parse( + '
', + ); + expect(parsed?.fields).toEqual([ + { kind: 'checkbox', label: 'Mon', required: false, options: ['Mon', 'Tue'] }, + { kind: 'checkbox', label: 'Newsletter', required: false }, + ]); + }); + + it('resolves labels from wrapping labels without echoing the control, then aria-label, placeholder, name', () => { + const parsed = parse( + '
', + ); + expect(parsed?.fields.map((field) => field.label)).toEqual([ + 'Email', + 'Name', + 'Company', + 'Org name', + ]); + }); + + it('reads aria-required as required and keeps file and hidden kinds for downstream skipping', () => { + const parsed = parse( + '
', + ); + expect(parsed?.fields).toEqual([ + { kind: 'file', label: 'Resume', required: false }, + { kind: 'hidden', label: 'campaign', required: false }, + { kind: 'text', label: 'x', required: true }, + ]); + }); + + it('derives the submit label from button[type=submit], typeless button, then input[type=submit] value', () => { + expect(parse('
')?.submitLabel).toBe( + 'Send it', + ); + expect(parse('
')?.submitLabel).toBe('Go'); + expect( + parse('
') + ?.submitLabel, + ).toBe('Request a Quote'); + expect(parse('
')?.submitLabel).toBe('Submit'); + expect(parse('
')?.submitLabel).toBe( + 'Submit', + ); + }); + + it('ignores non-submit button/reset/image inputs as fields', () => { + const parsed = parse( + '
', + ); + expect(parsed?.fields).toEqual([{ kind: 'text', label: 'a', required: false }]); + }); + + it('is deterministic across calls', () => { + const html = + '
'; + expect(parse(html)).toEqual(parse(html)); + }); +}); + +describe('form conversion through compose', () => { + it('converts an evidence-shaped label+input+submit form to jetpack contact-form markup with no core/html island', () => { + const html = + '
'; + const out = compose(html, { url: 'https://housing-resolutions.com/' }); + + expect(out).toContain('', + ); + expect(out).toContain(''); + expect(out).toContain('>Sign in'); + expect(out).not.toContain('wp:html'); + expect(out).not.toContain(' { + const html = + '

Join us

'; + const out = compose(html, { url: 'https://x.test/' }); + + expect(out).toContain('wp:group'); + expect(out).toContain('wp:jetpack/contact-form'); + expect(out).not.toContain(' { + const out = compose('
decor
', { url: 'https://x.test/' }); + expect(out).toContain('wp:html'); + }); + + it('is byte-identical across repeated compositions', () => { + const html = + '
'; + expect(compose(html, { url: 'https://x.test/' })).toBe(compose(html, { url: 'https://x.test/' })); + }); + + it('keeps hostile label text inside the block comment', () => { + const html = + '
'; + const out = compose(html, { url: 'https://x.test/' }); + const opener = out.split('\n')[0]; + + expect(opener.startsWith('')).toBe(true); + expect(opener.slice(''.length)).not.toContain('-->'); + expect(out).toContain('{"label":"Name \\u002d\\u002d\\u003e tricky \\u003cscript\\u003e"}'); + }); +}); diff --git a/packages/blocks-engine/src/catalog.ts b/packages/blocks-engine/src/catalog.ts index e9120af4e..24099b382 100644 --- a/packages/blocks-engine/src/catalog.ts +++ b/packages/blocks-engine/src/catalog.ts @@ -4,6 +4,8 @@ import type { CheerioAPI } from 'cheerio'; import { PIPELINE_ISLAND_OPENER } from './block-policy.js'; import { buildEmbedBlock, guessEmbedProvider } from './embed.js'; import { escapeHtmlAttr as escapeAttr, escapeHtmlText as escapeHtml } from './escape.js'; +import { parseHtmlForm } from './form-html.js'; +import { formToBlocks } from './theme/form-blocks.js'; import { sanitize } from './sanitize.js'; import type { ConversionContext } from './types.js'; @@ -66,6 +68,8 @@ function convertElement( if (buttons) return { matched: true, markup: buttons }; const mediaText = tryMediaText($, el, ctx, htmlFallback); if (mediaText) return { matched: true, markup: mediaText }; + const form = tryForm($, el); + if (form) return { matched: true, markup: form }; return { matched: false, markup: htmlFallback($.html(el)) }; } @@ -311,3 +315,10 @@ function tryMediaText( `` ); } + +function tryForm($: CheerioAPI, el: Element): string | null { + if (el.tagName !== 'form') return null; + const parsed = parseHtmlForm($, el); + if (!parsed) return null; + return formToBlocks(parsed).markup; +} diff --git a/packages/blocks-engine/src/form-html.ts b/packages/blocks-engine/src/form-html.ts new file mode 100644 index 000000000..97e3681a6 --- /dev/null +++ b/packages/blocks-engine/src/form-html.ts @@ -0,0 +1,194 @@ +import type { CheerioAPI } from 'cheerio'; + +import type { SectionSpecForm, SectionSpecFormField } from './theme/section-spec.js'; + +type FormNode = NonNullable[0]> & { tagName: string }; +type Control = ReturnType; + +const SIMPLE_INPUT_KINDS: Record = { + text: 'text', + password: 'text', + search: 'text', + email: 'email', + tel: 'tel', + url: 'url', + number: 'number', + date: 'date', +}; + +function collapse(s: string): string { + return s.replace(/\s+/g, ' ').trim(); +} + +function attrText($control: Control, attr: string): string { + return collapse($control.attr(attr) ?? ''); +} + +function isRequired($control: Control): boolean { + return $control.attr('required') !== undefined || $control.attr('aria-required') === 'true'; +} + +function controlLabel($: CheerioAPI, $form: ReturnType, $control: Control): string { + const id = $control.attr('id'); + if (id) { + const forLabel = $form + .find('label') + .filter((_, label) => $(label).attr('for') === id) + .first(); + if (forLabel.length > 0) return collapse(forLabel.text()); + } + const wrapping = $control.closest('label'); + if (wrapping.length > 0) { + return collapse( + wrapping + .clone() + .children('input,select,textarea') + .remove() + .end() + .text(), + ); + } + return attrText($control, 'aria-label') || attrText($control, 'placeholder') || attrText($control, 'name'); +} + +function choiceOption($: CheerioAPI, $form: ReturnType, $control: Control): string { + return controlLabel($, $form, $control) || attrText($control, 'value'); +} + +function selectOptions($: CheerioAPI, $control: Control): string[] { + return $control + .children('option') + .toArray() + .map((option) => $(option)) + .filter(($option) => ($option.attr('value') ?? '') !== '') + .map(($option) => collapse($option.text())) + .filter(Boolean); +} + +function submitLabel($: CheerioAPI, $form: ReturnType): string { + const typelessButton = $form + .find('button') + .toArray() + .find((button) => $(button).attr('type') === undefined); + const candidates = [ + $form.find('button[type="submit"]').first(), + typelessButton ? $(typelessButton) : $(), + $form.find('input[type="submit"]').first(), + ]; + for (const candidate of candidates) { + if (candidate.length === 0) continue; + const label = candidate.is('input') ? attrText(candidate, 'value') : collapse(candidate.text()); + if (label) return label; + } + return 'Submit'; +} + +function compact(field: SectionSpecFormField): SectionSpecFormField { + const entries = Object.entries(field).filter(([, value]) => value !== undefined); + if (Array.isArray(entries.find(([key]) => key === 'options')?.[1])) { + const options = entries.find(([key]) => key === 'options')?.[1] as string[]; + if (options.length === 0) { + return Object.fromEntries(entries.filter(([key]) => key !== 'options')) as SectionSpecFormField; + } + } + return Object.fromEntries(entries) as SectionSpecFormField; +} + +/** + * Parse a DOM `
` element into the engine's SectionSpecForm grammar so it + * can be emitted through the same `formToBlocks` mapper the theme assembler + * uses — one form grammar, one block idiom (jetpack/contact-form). + * + * Returns null for forms with no representable fields (degenerate or + * layout-only forms), leaving them on the caller's fallback path. + * + * Determinism: document-order traversal, stable key order, no ids — two calls + * on the same markup are byte-identical. + */ +export function parseHtmlForm($: CheerioAPI, form: FormNode): SectionSpecForm | null { + const $form = $(form); + const controls = $form.find('input,textarea,select').toArray(); + + const groups = new Map< + string, + { kind: 'radio' | 'checkbox'; field: SectionSpecFormField; count: number } + >(); + const singleCheckboxes: SectionSpecFormField[] = []; + const fields: SectionSpecFormField[] = []; + + for (const control of controls) { + const $control = $(control); + const tag = control.tagName; + + if (tag === 'textarea') { + fields.push( + compact({ + kind: 'textarea', + label: controlLabel($, $form, $control), + required: isRequired($control), + placeholder: attrText($control, 'placeholder') || undefined, + defaultValue: collapse($control.text()) || undefined, + }), + ); + continue; + } + + if (tag === 'select') { + fields.push( + compact({ + kind: 'select', + label: controlLabel($, $form, $control), + required: isRequired($control), + options: selectOptions($, $control), + }), + ); + continue; + } + + const type = ($control.attr('type') || 'text').toLowerCase(); + if (type === 'submit' || type === 'button' || type === 'reset' || type === 'image') continue; + + if (type === 'radio' || type === 'checkbox') { + const name = $control.attr('name') || ''; + const option = choiceOption($, $form, $control); + const existing = name ? groups.get(name) : undefined; + if (existing) { + existing.count += 1; + if (option) existing.field.options = [...(existing.field.options ?? []), option]; + continue; + } + const field: SectionSpecFormField = compact({ + kind: type, + label: controlLabel($, $form, $control), + required: isRequired($control), + options: option ? [option] : undefined, + }); + if (name) groups.set(name, { kind: type, field, count: 1 }); + else if (type === 'checkbox') singleCheckboxes.push(field); + fields.push(field); + continue; + } + + const kind = SIMPLE_INPUT_KINDS[type] ?? (type === 'hidden' || type === 'file' ? type : 'text'); + fields.push( + compact({ + kind, + label: controlLabel($, $form, $control), + required: isRequired($control), + placeholder: attrText($control, 'placeholder') || undefined, + defaultValue: + kind === 'file' || kind === 'hidden' + ? undefined + : (attrText($control, 'value') || undefined), + }), + ); + } + + for (const group of groups.values()) { + if (group.kind === 'checkbox' && group.count === 1) delete group.field.options; + } + for (const field of singleCheckboxes) delete field.options; + + if (fields.length === 0) return null; + return { fields, submitLabel: submitLabel($, $form) }; +}