From 42b8c58ce8d8381766e5883a747e1f0736791021 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 7 Oct 2026 12:41:09 -0400 Subject: [PATCH 1/3] fix(php-transformer): preserve associated label hosts (#1617) (cherry picked from commit 39ce819ecfd2e52aa7b357f810d1e674fd8ce98e) --- .../associated-label-save-validity.test.ts | 162 ++++++++++++++++++ .../wp/__tests__/file-save-validity.test.ts | 2 +- php-transformer/composer.json | 1 + .../AuthoredFormControlBlockConverter.php | 41 ++++- .../ReadableFormControlBlockConverter.php | 21 ++- .../AuthoredLabelBlockGenerator.php | 82 +++++++++ .../src/HtmlToBlocks/HtmlCompilation.php | 60 ++++--- php-transformer/tests/contract/run.php | 4 +- .../tests/fixtures/associated-label.html | 18 ++ .../html-div-pseudo-form-materializable.json | 4 +- .../tests/unit/associated-label.php | 65 +++++++ 11 files changed, 430 insertions(+), 30 deletions(-) create mode 100644 packages/blocks-engine/src/wp/__tests__/associated-label-save-validity.test.ts create mode 100644 php-transformer/src/HtmlToBlocks/Generators/AuthoredLabelBlockGenerator.php create mode 100644 php-transformer/tests/fixtures/associated-label.html create mode 100644 php-transformer/tests/unit/associated-label.php diff --git a/packages/blocks-engine/src/wp/__tests__/associated-label-save-validity.test.ts b/packages/blocks-engine/src/wp/__tests__/associated-label-save-validity.test.ts new file mode 100644 index 000000000..a722337c0 --- /dev/null +++ b/packages/blocks-engine/src/wp/__tests__/associated-label-save-validity.test.ts @@ -0,0 +1,162 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { setupDomGlobals } from '../dom-globals.js'; + +const require = createRequire(import.meta.url) as { (id: string): any; resolve(id: string): string }; +const phpRoot = fileURLToPath(new URL('../../../../../php-transformer/', import.meta.url)); +const source = readFileSync(`${phpRoot}tests/fixtures/associated-label.html`, 'utf8'); +const compile = (html: string) => JSON.parse(execFileSync('php', ['-r', + 'require $argv[1] . "/vendor/autoload.php"; echo json_encode((new Automattic\\BlocksEngine\\PhpTransformer\\HtmlToBlocks\\HtmlTransformer())->transform(base64_decode($argv[2]))->toArray());', + phpRoot, Buffer.from(html).toString('base64')], { encoding: 'utf8' })); + +describe('associated label save contract', () => { + let wp: any; + beforeAll(() => { + setupDomGlobals(); + wp = require('@wordpress/blocks'); + require('@wordpress/block-library').registerCoreBlocks(); + }); + + const registerCompanions = (result: any) => { + const definitions = result.source_reports.generated_blocks; + wp.unstable__bootstrapServerSideBlockDefinitions(Object.fromEntries( + definitions.map((definition: any) => [definition.block_json.name, definition.block_json]), + )); + const libraryRequire = createRequire(require.resolve('@wordpress/block-library')) as typeof require; + const nativeWp = { + blocks: wp, blockEditor: libraryRequire('@wordpress/block-editor'), + components: libraryRequire('@wordpress/components'), element: libraryRequire('@wordpress/element'), + richText: libraryRequire('@wordpress/rich-text'), + }; + for (const definition of definitions) { + if (!wp.getBlockType(definition.block_json.name)) { + new Function('window', definition.assets['index.js'])({ wp: nativeWp }); + } + } + return nativeWp; + }; + + it('proves core paragraph and group cannot save an associated label', () => { + const paragraph = wp.serialize([wp.createBlock('core/paragraph', { + tagName: 'label', for: 'choice', content: 'Choice', + })]); + expect(paragraph).toContain(' { + const result = compile(source); + const nativeWp = registerCompanions(result); + const blocks = wp.parse(result.serialized_blocks); + const flatten = (items: any[]): any[] => items.flatMap(block => [block, ...flatten(block.innerBlocks)]); + for (const block of flatten(blocks)) expect(wp.validateBlock(block)[0]).toBe(true); + const saved = wp.serialize(blocks); + const reopened = wp.parse(saved); + for (const block of flatten(reopened)) expect(wp.validateBlock(block)[0]).toBe(true); + const labels = flatten(reopened).filter(block => block.name === 'custom/authored-label'); + expect(labels).toHaveLength(2); + expect(labels[0].attributes.htmlFor).toBe('after'); + expect(labels[1].attributes.content).toContain('Science & Health'); + const host = document.createElement('div'); + host.innerHTML = saved; + expect(host.querySelector('#after + label')?.getAttribute('for')).toBe('after'); + expect(host.querySelector('label + #before')?.previousElementSibling?.id).toBe('before-caption'); + + // Render the registered edit with the actual block-editor RichText component. + // useBlockProps needs editor context; supply only that hook's neutral props. + const definition = result.source_reports.generated_blocks.find((item: any) => item.name === 'authored-label'); + let settings: any; + new Function('window', definition.assets['index.js'])({ wp: { + ...nativeWp, + blocks: { registerBlockType: (_name: string, value: any) => { settings = value; } }, + blockEditor: { ...nativeWp.blockEditor, useBlockProps: (props: any) => props }, + } }); + const label = labels[1]; + const edit = settings.edit({ attributes: label.attributes, setAttributes: (next: any) => Object.assign(label.attributes, next) }); + expect(edit.type).toBe(nativeWp.blockEditor.RichText); + expect(edit.props.tagName).toBe('label'); + expect(edit.props.htmlFor).toBe('before'); + edit.props.onChange('Edited & editable'); + const edited = wp.parse(wp.serialize(reopened)); + const editedLabel = flatten(edited).find(block => block.attributes.id === 'before-caption'); + expect(wp.validateBlock(editedLabel)[0]).toBe(true); + expect(editedLabel.attributes.content).toBe('Edited & editable'); + expect(editedLabel.attributes.htmlFor).toBe('before'); + }); + + it('preserves exact browser geometry, checked selectors, and native label clicks', async () => { + const { chromium } = require('playwright'); + const result = compile(source); + registerCompanions(result); + const saved = wp.serialize(wp.parse(result.serialized_blocks)); + const css = result.assets.filter((asset: any) => asset.kind === 'css').map((asset: any) => asset.content || '').join('\n'); + let rendered: string | undefined; + if (process.env.BLOCKS_ENGINE_WP_CLI && process.env.BLOCKS_ENGINE_WP_PATH) { + const payload = Buffer.from(JSON.stringify({ + markup: saved, definitions: result.source_reports.generated_blocks.map((definition: any) => definition.block_json), + })).toString('base64'); + // Register static companion metadata in this process only. No site content is saved. + const code = `$input=json_decode(base64_decode('${payload}'),true); foreach($input['definitions'] as $definition) { register_block_type($definition['name'],$definition); } echo json_encode(array('html'=>do_blocks(serialize_blocks(parse_blocks($input['markup']))),'version'=>get_bloginfo('version')));`; + const native = JSON.parse(execFileSync(process.env.BLOCKS_ENGINE_WP_CLI, [ + `--path=${process.env.BLOCKS_ENGINE_WP_PATH}`, '--skip-plugins', '--skip-themes', 'eval', code, + ], { encoding: 'utf8' })); + expect(native.version).toMatch(/^7\./); + rendered = native.html; + } + const browser = await chromium.launch({ headless: true }); + try { + for (const width of [390, 768, 1440]) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + const probe = async (html: string, styles = '') => { + await page.setContent(`${html}`); + const measure = () => page.evaluate(() => Array.from(document.querySelectorAll('.caption')).map(label => { + const range = document.createRange(); + range.selectNodeContents(label); + const box = label.getBoundingClientRect(); + const text = range.getBoundingClientRect(); + const style = getComputedStyle(label); + const pseudo = getComputedStyle(label, '::after'); + return { + id: label.id, for: label.getAttribute('for'), + box: [box.x, box.y, box.width, box.height], text: [text.x, text.y, text.width, text.height], + font: [style.fontFamily, style.fontSize, style.fontWeight, style.lineHeight], + paint: [style.color, style.backgroundColor, pseudo.content, pseudo.top, pseudo.left], + control: (label as HTMLLabelElement).control?.id, + before: label.previousElementSibling?.tagName, after: label.nextElementSibling?.tagName, + }; + })); + const closed = await measure(); + if (width === 390) await page.locator('.filters').evaluate((node: Element) => node.classList.add('open')); + const open = await measure(); + const states = []; + for (const id of ['after-caption', 'before-caption']) { + await page.locator(`#${id}`).click(); + states.push(await page.locator('.choice input').evaluateAll((nodes: HTMLInputElement[]) => nodes.map(input => input.checked))); + states.push(await measure()); + } + return { closed, open, states }; + }; + const original = await probe(source); + expect(await probe(saved, css)).toEqual(original); + if (rendered !== undefined) expect(await probe(rendered, css)).toEqual(original); + expect(original.states[0]).toEqual([false, false]); + expect(original.states[2]).toEqual([false, true]); + expect(original.open[0].font.slice(1)).toEqual(['14px', '400', '20px']); + if (width === 390) expect(original.closed[0].box[2]).toBe(0); + await page.close(); + } + } finally { + await browser.close(); + } + }, 60000); +}); diff --git a/packages/blocks-engine/src/wp/__tests__/file-save-validity.test.ts b/packages/blocks-engine/src/wp/__tests__/file-save-validity.test.ts index 81d5144d8..3356adbcf 100644 --- a/packages/blocks-engine/src/wp/__tests__/file-save-validity.test.ts +++ b/packages/blocks-engine/src/wp/__tests__/file-save-validity.test.ts @@ -56,7 +56,7 @@ describe('WordPress 7.1 core/file save validity', () => { const blocks = wp.parse(serializedBlocks as string); expect(blocks).toHaveLength(3); - expect(blocks[0].attributes.fileName?.toString()).toBe('Privacy policy'); + expect(blocks[0].attributes.fileName?.toString()).toBe('Privacy policy'); expect(blocks.map((block) => wp.validateBlock(block)[0])).toEqual([true, true, true]); }); }); diff --git a/php-transformer/composer.json b/php-transformer/composer.json index b960d0638..ea8edeb7b 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -429,6 +429,7 @@ "php tests/unit/authored-select-block.php", "php tests/unit/form-runtime-island-recorder.php", "php tests/unit/readable-form-control-block-converter.php", + "php tests/unit/associated-label.php", "php tests/unit/readable-form-block-builder.php", "php tests/unit/readable-form-wrapper-presentation.php", "php tests/unit/form-composition-planner.php", diff --git a/php-transformer/src/HtmlToBlocks/Elements/AuthoredFormControlBlockConverter.php b/php-transformer/src/HtmlToBlocks/Elements/AuthoredFormControlBlockConverter.php index 75bb83309..74e6b4a2b 100644 --- a/php-transformer/src/HtmlToBlocks/Elements/AuthoredFormControlBlockConverter.php +++ b/php-transformer/src/HtmlToBlocks/Elements/AuthoredFormControlBlockConverter.php @@ -7,6 +7,7 @@ use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\GeneratedBlockRegistry; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\AuthoredButtonBlockGenerator; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\AuthoredInputBlockGenerator; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\AuthoredLabelBlockGenerator; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\AuthoredSelectBlockGenerator; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\AuthoredTextareaBlockGenerator; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\SourceBlockCreator; @@ -20,6 +21,8 @@ final class AuthoredFormControlBlockConverter { /** @var Closure(DOMElement): string */ private readonly Closure $richTextLabelContent; + /** @var Closure(DOMElement): string */ + private readonly Closure $editableLabelContent; /** * @param Closure(DOMElement): array $structuralPresentationDeclarations @@ -29,6 +32,7 @@ final class AuthoredFormControlBlockConverter * @param Closure(string): string $safeAnchor * @param Closure(DOMElement): ?DOMElement $projectSourceTags * @param Closure(DOMElement): string|null $richTextLabelContent + * @param Closure(DOMElement): string|null $editableLabelContent */ public function __construct( private readonly FormControlMetadataBuilder $metadataBuilder, @@ -40,9 +44,44 @@ public function __construct( private readonly Runtime $runtime, private readonly Closure $safeAnchor, private readonly Closure $projectSourceTags, - ?Closure $richTextLabelContent = null + ?Closure $richTextLabelContent = null, + ?Closure $editableLabelContent = null ) { $this->richTextLabelContent = $richTextLabelContent ?? static fn (DOMElement $label): string => ''; + $this->editableLabelContent = $editableLabelContent ?? $this->richTextLabelContent; + } + + public function labelContent(DOMElement $label): string + { + $content = ($this->editableLabelContent)($label); + return '' !== $content ? $content : $this->runtime->escapeHtml($this->metadataBuilder->labelText($label)); + } + + /** Keep an explicit external association even when its control is outside this fragment. */ + public function label(DOMElement $label): ?array + { + if ('label' !== strtolower($label->tagName) || '' === SourceDom::attr($label, 'for') || array() !== FormControlClassifier::controlElements($label)) { + return null; + } + $generator = new AuthoredLabelBlockGenerator(); + $registry = ($this->generatedBlocks)(); + $registry->register(AuthoredLabelBlockGenerator::class, $generator->definition($registry->namespace())); + $projected = ($this->projectSourceTags)($label) ?? $label; + $attrs = array_filter(array( + 'htmlFor' => SourceDom::attr($label, 'for'), + 'id' => SourceDom::attr($label, 'id'), + 'className' => SourceDom::attr($projected, 'class'), + 'style' => SourceDom::attr($label, 'style'), + 'content' => $this->labelContent($label), + ), static fn (string $value): bool => '' !== $value); + $markup = $generator->markup($attrs); + return array( + 'blockName' => $registry->blockName(AuthoredLabelBlockGenerator::LOCAL_NAME), + 'attrs' => $attrs, + 'innerBlocks' => array(), + 'innerHTML' => $markup, + 'innerContent' => array($markup), + ); } /** @return array|null */ diff --git a/php-transformer/src/HtmlToBlocks/Elements/ReadableFormControlBlockConverter.php b/php-transformer/src/HtmlToBlocks/Elements/ReadableFormControlBlockConverter.php index 9a5d98d3a..6cbcca6e6 100644 --- a/php-transformer/src/HtmlToBlocks/Elements/ReadableFormControlBlockConverter.php +++ b/php-transformer/src/HtmlToBlocks/Elements/ReadableFormControlBlockConverter.php @@ -87,6 +87,14 @@ public function convert(DOMElement $element, ?DOMElement $label = null, bool $fo } if ( 'input' === $tagName ) { + // A standalone choice label remains at its source position. Its + // toggle target must remain native even without authored CSS. + $externalLabel = null === $label ? $this->metadataBuilder->associatedLabel($element) : null; + if ($externalLabel instanceof DOMElement + && in_array(FormControlClassifier::controlType($element), array('checkbox', 'radio'), true) + && array() === FormControlClassifier::controlElements($externalLabel)) { + $forceNative = true; + } $inputBlock = $this->authoredBlockConverter->input($element, $label, false, $forceNative); if ( null !== $inputBlock ) { return $inputBlock; @@ -159,13 +167,20 @@ private function convertLabel(DOMElement $element): ?array : null; } - $label = $this->metadataBuilder->labelText($element); + // An external label owns its own DOM position; folding it into the + // input would change input + label and wrapping-label selector topology. + $associatedLabel = $this->authoredBlockConverter->label($element); + if (null !== $associatedLabel) { + return $associatedLabel; + } + + $label = $this->authoredBlockConverter->labelContent($element); if ( '' === $label ) { - $label = trim(preg_replace('/\s+/', ' ', $element->textContent ?? '') ?? ''); + $label = $this->runtime->escapeHtml(trim(preg_replace('/\s+/', ' ', $element->textContent ?? '') ?? '')); } return '' !== $label - ? $this->createBlock->createBlock('core/paragraph', array( 'content' => $this->runtime->escapeHtml($label) ), array(), $element) + ? $this->createBlock->createBlock('core/paragraph', array( 'content' => $label ), array(), $element) : null; } diff --git a/php-transformer/src/HtmlToBlocks/Generators/AuthoredLabelBlockGenerator.php b/php-transformer/src/HtmlToBlocks/Generators/AuthoredLabelBlockGenerator.php new file mode 100644 index 000000000..18bfe0247 --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Generators/AuthoredLabelBlockGenerator.php @@ -0,0 +1,82 @@ + */ + public function definition(string $namespace): array + { + $attributes = array(); + foreach (array('htmlFor', 'id', 'className', 'style', 'content') as $name) { + $attributes[$name] = array('type' => 'string', 'default' => ''); + } + $script = <<<'JS' +( function( blocks, blockEditor, element ) { + var createElement = element.createElement; + var RichText = blockEditor.RichText; + function escapeAttribute( value ) { return String( value || '' ).replace( /&/g, '&' ).replace( /"/g, '"' ).replace( //g, '>' ); } + function markup( attrs ) { + var output = '' + ( attrs.content || '' ) + ''; + } + function edit( props ) { + var attrs = props.attributes; + var style = {}; + // Use the browser's declaration parser for custom properties and quoted values. + var probe = document.createElement( 'label' ); + probe.setAttribute( 'style', attrs.style || '' ); + Array.from( probe.style ).forEach( function( name ) { + var key = name.indexOf( '--' ) === 0 ? name : name.replace( /-([a-z])/g, function( _, letter ) { return letter.toUpperCase(); } ); + style[ key ] = probe.style.getPropertyValue( name ); + } ); + return createElement( RichText, Object.assign( blockEditor.useBlockProps( { htmlFor: attrs.htmlFor || undefined, id: attrs.id || undefined, className: attrs.className || undefined, style: style } ), { + tagName: 'label', value: attrs.content || '', + onChange: function( content ) { props.setAttributes( { content: content } ); } + } ) ); + } + blocks.registerBlockType( '__BLOCK_NAME__', { + attributes: __ATTRIBUTES__, supports: { html: false }, edit: edit, + save: function( props ) { return createElement( element.RawHTML, null, markup( props.attributes ) ); } + } ); +} )( window.wp.blocks, window.wp.blockEditor, window.wp.element ); +JS; + return array( + 'name' => self::LOCAL_NAME, + 'block_json' => array( + 'apiVersion' => 3, + 'name' => $namespace . '/' . self::LOCAL_NAME, + 'title' => 'Control Label', + 'category' => 'text', + 'editorScript' => 'file:./index.js', + 'attributes' => $attributes, + 'supports' => array('html' => false), + ), + 'assets' => array('index.js' => str_replace( + array('__BLOCK_NAME__', '__ATTRIBUTES__'), + array($namespace . '/' . self::LOCAL_NAME, json_encode($attributes, JSON_THROW_ON_ERROR)), + $script + )), + 'script_dependencies' => array('index.js' => array('wp-blocks', 'wp-block-editor', 'wp-element')), + ); + } + + /** @param array $attrs */ + public function markup(array $attrs): string + { + $html = ' 'for', 'id' => 'id', 'className' => 'class', 'style' => 'style') as $key => $name) { + if ('' !== (string) ($attrs[$key] ?? '')) { + $html .= ' ' . $name . '="' . htmlspecialchars((string) $attrs[$key], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"'; + } + } + return $html . '>' . ($attrs['content'] ?? '') . ''; + } +} diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index 76dc0259e..bb9a4b68a 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -637,7 +637,8 @@ function (string $text): void { $this->runtime, fn (string $id): string => $this->safeAnchor($id), fn (DOMElement $element): ?DOMElement => $this->sourceTagProjectedClone($element), - fn (DOMElement $label): string => $this->richTextMaterializer->content($label, array( 'input', 'select', 'textarea' )) + fn (DOMElement $label): string => $this->richTextMaterializer->content($label, array( 'input', 'select', 'textarea' )), + fn (DOMElement $label): string => $this->editableFormLabelContent($label) ); $this->projectedNavigation = new ProjectedNavigationConverter( $this->navigationToggleSuppressor, @@ -5109,26 +5110,7 @@ private function hoistContentWrappingSpans(string $name, array $attrs): array $this->unwrapElement($wrapper); } - // Unwrap any remaining styling hooks (sibling / partial content) unless - // their visual style can be carried by RichText's mark format. Unknown - // and custom elements (``, ``) are never valid RichText - // content, so they always become a mark carrier or are unwrapped. - foreach ( $this->richTextStylingHookElements($body) as $inline ) { - if ( 'font' === strtolower($inline->tagName) && ! $inline->hasAttributes() ) { - $this->unwrapElement($inline); - continue; - } - if ( $this->replaceRichTextStylingHookWithMark($inline) ) { - continue; - } - if ( 'span' === strtolower($inline->tagName) || RichTextInlineTags::isUnknownHtmlElement($inline) ) { - $this->unwrapElement($inline); - } - } - - foreach ( $this->richTextAnchors($body) as $anchor ) { - $anchor->removeAttribute('style'); - } + $this->normalizeRichTextStylingHooks($body); $newContent = $this->innerHtml($body); if ( $newContent === $content && '' === $hoistedClasses && array() === $hoistedDeclarations ) { @@ -5155,6 +5137,42 @@ private function hoistContentWrappingSpans(string $name, array $attrs): array return $attrs; } + /** A label's inline hooks stay inline, rather than being hoisted onto its host. */ + private function editableFormLabelContent(DOMElement $label): string + { + $content = $this->richTextMaterializer->content($label); + if ('' === $content) return ''; + $document = new DOMDocument(); + $previous = libxml_use_internal_errors(true); + $loaded = $document->loadHTML('' . $content . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + $body = $loaded ? $document->getElementsByTagName('body')->item(0) : null; + if (!$body instanceof DOMElement) return $content; + $this->normalizeRichTextStylingHooks($body); + return SourceDom::innerHtml($body); + } + + /** Use the same semantic carriers for native RichText and owned label RichText. */ + private function normalizeRichTextStylingHooks(DOMElement $body): void + { + // Remaining styling hooks stay on RichText-safe marks, without + // assigning highlighted-text semantics to mechanical carriers. + foreach ($this->richTextStylingHookElements($body) as $inline) { + if ('font' === strtolower($inline->tagName) && !$inline->hasAttributes()) { + $this->unwrapElement($inline); + continue; + } + if ($this->replaceRichTextStylingHookWithMark($inline)) continue; + if ('span' === strtolower($inline->tagName) || RichTextInlineTags::isUnknownHtmlElement($inline)) { + $this->unwrapElement($inline); + } + } + foreach ($this->richTextAnchors($body) as $anchor) { + $anchor->removeAttribute('style'); + } + } + /** * The single styling-hook span that is the container's only significant * child, or null when the content is plain text, inline formats, or sibling diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 63c8001ca..e1cf6d648 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -2442,7 +2442,7 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter $standaloneControlBlocks = $standaloneControls['blocks'][0]['innerBlocks'] ?? array(); $assert(array() === ($standaloneControls['fallbacks'] ?? array()), 'standalone readable controls convert without unsupported-element fallback'); $assert('core/paragraph' === ($standaloneControlBlocks[0]['blockName'] ?? ''), 'standalone non-runtime input converts to readable paragraph'); -$assert('core/paragraph' === ($standaloneControlBlocks[1]['blockName'] ?? ''), 'source select label remains a sibling editable block'); +$assert('custom/authored-label' === ($standaloneControlBlocks[1]['blockName'] ?? '') && 'product-sort' === ($standaloneControlBlocks[1]['attrs']['htmlFor'] ?? ''), 'source select label remains a sibling editable associated label'); $assert('core/group' === ($standaloneControlBlocks[2]['blockName'] ?? ''), 'standalone static select retains the legacy structural group boundary'); $assert('custom/authored-select' === ($standaloneControlBlocks[2]['innerBlocks'][0]['blockName'] ?? ''), 'standalone non-runtime select uses an authored-select editable native-control block inside its compatibility wrapper'); $authoredSelectBlocks = array_values(array_filter($standaloneControls['source_reports']['generated_blocks'] ?? array(), static fn (array $block): bool => 'authored-select' === ($block['name'] ?? ''))); @@ -2451,7 +2451,7 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter $assert('core/html' === ($standaloneControlBlocks[3]['blockName'] ?? ''), 'runtime-targeted select preserves native DOM output'); $assert(str_contains((string) ($standaloneControls['serialized_blocks'] ?? ''), ''), 'compact select preserves selected placeholder option state'); $assert(str_contains((string) ($standaloneControls['serialized_blocks'] ?? ''), ' +
+ diff --git a/php-transformer/tests/fixtures/parity/html-div-pseudo-form-materializable.json b/php-transformer/tests/fixtures/parity/html-div-pseudo-form-materializable.json index 968efbfd3..3e4be9d54 100644 --- a/php-transformer/tests/fixtures/parity/html-div-pseudo-form-materializable.json +++ b/php-transformer/tests/fixtures/parity/html-div-pseudo-form-materializable.json @@ -46,7 +46,7 @@ { "path": "fallbacks.0.binding.occurrence", "assert": "equals", "value": 1 }, { "path": "fallbacks.0.binding.position.schema", "assert": "equals", "value": "blocks-engine/runtime-binding-position/v1" }, { "path": "fallbacks.0.binding.position.block_index", "assert": "equals", "value": 0 }, - { "path": "fallbacks.0.binding.search_block_markup", "assert": "equals", "value": "

Email address

Email address: you@example.com (required)

" }, - { "path": "serialized_blocks", "assert": "equals", "value": "

Email address

Email address: you@example.com (required)

" } + { "path": "fallbacks.0.binding.search_block_markup", "assert": "equals", "value": "

Email address: you@example.com (required)

" }, + { "path": "serialized_blocks", "assert": "equals", "value": "

Email address: you@example.com (required)

" } ] } diff --git a/php-transformer/tests/unit/associated-label.php b/php-transformer/tests/unit/associated-label.php new file mode 100644 index 000000000..e34a7d481 --- /dev/null +++ b/php-transformer/tests/unit/associated-label.php @@ -0,0 +1,65 @@ + (new HtmlTransformer())->transform($html)->toArray(); +$result = $transform((string) file_get_contents(__DIR__ . '/../fixtures/associated-label.html')); +$html = $result['serialized_blocks']; +$document = new DOMDocument(); +$document->loadHTML($html, LIBXML_NOERROR | LIBXML_NOWARNING); +$xpath = new DOMXPath($document); +$assert(1 === $xpath->query('//input[@id="after"]/following-sibling::*[1][self::label][@for="after"]')->length, 'input + label association and adjacency survive compilation'); +$assert(1 === $xpath->query('//input[@id="before"]/preceding-sibling::*[1][self::label][@for="before"]')->length, 'label + input adjacency survives compilation'); +$assert(1 === $xpath->query('//label[@id="before-caption"]/strong')->length && 1 === $xpath->query('//label[@id="before-caption"]//em')->length, 'inline rich formatting stays in the actual label'); +$assert('caption' === $document->getElementById('after-caption')->getAttribute('class'), 'source label classes survive without synthetic paragraph classes'); +$assert('--caption-role: filter' === $document->getElementById('after-caption')->getAttribute('style'), 'source inline declarations survive'); +$assert(array() === $result['fallbacks'], 'associated controls have no fallback'); +$definitions = $result['source_reports']['generated_blocks']; +$labels = array_values(array_filter($definitions, static fn (array $definition): bool => 'authored-label' === $definition['name'])); +$assert(1 === count($labels), 'one registered definition owns both labels'); +$payload = (new CompanionPluginPayload())->fromBlockTypes(array(), array(), array(), $definitions); +$assert(count($definitions) === count($payload['blocks']), 'existing companion payload accepts all generated definitions'); +$assert(array('wp-blocks', 'wp-block-editor', 'wp-element') === $labels[0]['script_dependencies']['index.js'], 'label widget dependencies reach consumer'); + +foreach (array( + '', + '', +) as $source) { + $external = $transform($source); + $assert('custom/authored-label' === $external['blocks'][0]['blockName'], 'explicit for is retained when the target is outside the fragment, including empty labels'); + $assert(str_contains($external['serialized_blocks'], ''); +$assert('core/paragraph' === $plain['blocks'][0]['blockName'], 'unassociated prose retains its paragraph role'); +$assert('Caption only & more' === $plain['blocks'][0]['attrs']['content'], 'plain caption keeps editable rich content'); +$assert(array() === $plain['source_reports']['generated_blocks'], 'plain caption does not invent a field entity or companion'); +$unstyled = $transform(''); +$assert('custom/authored-input' === $unstyled['blocks'][0]['blockName'] && 'custom/authored-label' === $unstyled['blocks'][1]['blockName'], 'unstyled explicit association still retains its native target'); +$assert(!isset($unstyled['blocks'][0]['attrs']['label']), 'external label is not duplicated as a wrapping input label'); +$numeric = $transform(''); +$assert(str_contains($numeric['serialized_blocks'], ''; } function edit( props ) { @@ -37,7 +57,10 @@ function edit( props ) { var key = name.indexOf( '--' ) === 0 ? name : name.replace( /-([a-z])/g, function( _, letter ) { return letter.toUpperCase(); } ); style[ key ] = probe.style.getPropertyValue( name ); } ); - return createElement( RichText, Object.assign( blockEditor.useBlockProps( { htmlFor: attrs.htmlFor || undefined, id: attrs.id || undefined, className: attrs.className || undefined, style: style } ), { + var source = sourceAttributes( attrs ); + if ( source.tabindex !== undefined ) { source.tabIndex = source.tabindex; delete source.tabindex; } + Object.keys( source ).forEach( function( name ) { if ( booleanAttributes.indexOf( name ) !== -1 ) source[ name ] = true; } ); + return createElement( RichText, Object.assign( blockEditor.useBlockProps( Object.assign( source, { htmlFor: attrs.htmlFor || undefined, id: attrs.id || undefined, className: attrs.className || undefined, style: style } ) ), { tagName: 'label', value: attrs.content || '', onChange: function( content ) { props.setAttributes( { content: content } ); } } ) ); @@ -60,8 +83,8 @@ function edit( props ) { 'supports' => array('html' => false), ), 'assets' => array('index.js' => str_replace( - array('__BLOCK_NAME__', '__ATTRIBUTES__'), - array($namespace . '/' . self::LOCAL_NAME, json_encode($attributes, JSON_THROW_ON_ERROR)), + array('__BLOCK_NAME__', '__ATTRIBUTES__', '__HOST_ATTRIBUTES__', '__STATIC_POLICY__', '__BOOLEAN_ATTRIBUTES__'), + array($namespace . '/' . self::LOCAL_NAME, json_encode($attributes, JSON_THROW_ON_ERROR), json_encode(self::HOST_ATTRIBUTES, JSON_THROW_ON_ERROR), json_encode(SourceAttribute::staticPolicy(), JSON_THROW_ON_ERROR), json_encode(LayoutShellBlockGenerator::BOOLEAN_ATTRIBUTES, JSON_THROW_ON_ERROR)), $script )), 'script_dependencies' => array('index.js' => array('wp-blocks', 'wp-block-editor', 'wp-element')), @@ -72,11 +95,12 @@ function edit( props ) { public function markup(array $attrs): string { $html = ' 'for', 'id' => 'id', 'className' => 'class', 'style' => 'style') as $key => $name) { + foreach (self::HOST_ATTRIBUTES as $key => $name) { if ('' !== (string) ($attrs[$key] ?? '')) { $html .= ' ' . $name . '="' . htmlspecialchars((string) $attrs[$key], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"'; } } - return $html . '>' . ($attrs['content'] ?? '') . ''; + $source = SourceAttribute::staticAttributes(is_array($attrs['sourceAttributes'] ?? null) ? $attrs['sourceAttributes'] : array(), array_values(self::HOST_ATTRIBUTES)); + return $html . SourceDom::htmlAttributeString($source) . '>' . ($attrs['content'] ?? '') . ''; } } diff --git a/php-transformer/src/HtmlToBlocks/Style/AuthorSelectorSemanticPreparer.php b/php-transformer/src/HtmlToBlocks/Style/AuthorSelectorSemanticPreparer.php index 6c378ef58..3bce766cf 100644 --- a/php-transformer/src/HtmlToBlocks/Style/AuthorSelectorSemanticPreparer.php +++ b/php-transformer/src/HtmlToBlocks/Style/AuthorSelectorSemanticPreparer.php @@ -235,11 +235,15 @@ private function discoverAuthorAttributePaths(array $authorSelectors, AuthorStyl { foreach ( $authorSelectors as $authorSelector ) { $parsed = $authorSelector['parsed']; + // Attribute-carrying native hosts and document roots remain live + // source subjects; a captured-state marker would freeze their CSS. + if (SourceAttributeSubjects::retainsDataPredicates($parsed, $authorStyles)) continue; $this->discoverNegatedDataAttributeState($authorSelector['selector'], $authorStyles, $projections); $this->discoverAncestorAttributeState($authorSelector['selector'], $authorStyles, $projections); $pseudoHost = CssSelectorMatcher::pseudoElementHost($authorSelector['selector']); $selector = $pseudoHost['selector'] ?? $authorSelector['selector']; $parsed = $pseudoHost['parsed'] ?? $parsed; + if (SourceAttributeSubjects::retainsDataPredicates($parsed, $authorStyles)) continue; if ( ! $parsed['supported'] || null !== $parsed['pseudo_state_suffix_span'] ) { continue; } diff --git a/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php b/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php index f471f0c65..3c0228498 100644 --- a/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php +++ b/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php @@ -1857,6 +1857,7 @@ private function projectSourceAttributePseudoSelector(string $selector, AuthorSt /** @param array $parsed @param list $matches @return list|null */ private function projectSourceAttributeAncestrySelector(string $selector, array $parsed, array $matches, AuthorStylesheetProjectionContext $context): ?array { + if (SourceAttributeSubjects::retainsDataPredicates($parsed, $context->authorStyles)) return null; $rightmost = $parsed['rightmost_compound_span'] ?? null; $ancestry = is_array($rightmost) ? substr($selector, 0, (int) $rightmost['start']) : ''; if ( null !== $parsed['pseudo_state_suffix_span'] || ! preg_match('/\[\s*data-[a-z0-9_-]+(?:\s*[~|^$*]?=|\s*\])/i', $ancestry) ) { diff --git a/php-transformer/src/HtmlToBlocks/Style/SourceAttributeSubjects.php b/php-transformer/src/HtmlToBlocks/Style/SourceAttributeSubjects.php new file mode 100644 index 000000000..b8742a1bb --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Style/SourceAttributeSubjects.php @@ -0,0 +1,43 @@ + $parsed */ + public static function retainsDataPredicates(array $parsed, AuthorStyleAnalysis $authorStyles): bool + { + if (!($parsed['supported'] ?? false)) return false; + $found = false; + foreach ($parsed['compounds'] ?? array() as $compound) { + $names = CssSelectorCompoundInspector::dataAttributeNames($compound); + if (array() === $names) continue; + // Alternative subjects require the existing general projection. + if (array() !== ($compound['any'] ?? array())) return false; + if (count($names) !== count(SourceAttribute::staticAttributes(array_fill_keys($names, '')))) return false; + $found = true; + // Enumerate prospective subjects independently of the captured data + // value, including absent and negated states that can change later. + $shape = $compound; + $shape['attributes'] = array_values(array_filter($shape['attributes'] ?? array(), static fn (array $attribute): bool => !str_starts_with($attribute['name'], 'data-'))); + $shape['not'] = array(); + $subject = array_replace($parsed, array('compounds' => array($shape), 'combinators' => array())); + $owners = 0; + foreach ($authorStyles->selectorCandidates($subject) as $element) { + if (!CssSelectorMatcher::matches($element, $subject)['matches']) continue; + ++$owners; + if (!in_array(strtolower($element->tagName), array('html', 'body'), true) + && !FormControlClassifier::isExternalAssociatedLabel($element)) return false; + } + if (0 === $owners) return false; + } + return $found; + } +} diff --git a/php-transformer/src/HtmlToBlocks/Support/SourceDom.php b/php-transformer/src/HtmlToBlocks/Support/SourceDom.php index 8545806ec..a8f6ec20f 100644 --- a/php-transformer/src/HtmlToBlocks/Support/SourceDom.php +++ b/php-transformer/src/HtmlToBlocks/Support/SourceDom.php @@ -864,7 +864,7 @@ public static function eventMetadata(DOMElement $element): array 'attribute' => strtolower($name), ); } - if ( preg_match('/^(?:data-(?:action|on|event)|jsaction)$/i', $name) && '' !== trim($value) ) { + if ( \Automattic\BlocksEngine\PhpTransformer\Support\SourceAttribute::isDeclaredEventAttribute($name) && '' !== trim($value) ) { $events[] = array( 'type' => 'declared', 'attribute' => $name, diff --git a/php-transformer/src/Support/DocumentRootAttributes.php b/php-transformer/src/Support/DocumentRootAttributes.php index e40b8c6bc..eb8d64f22 100644 --- a/php-transformer/src/Support/DocumentRootAttributes.php +++ b/php-transformer/src/Support/DocumentRootAttributes.php @@ -33,7 +33,6 @@ public static function forDocument(string $html): array public static function isSelectorAttribute(string $name): bool { - return in_array($name, array('class', 'id', 'dir', 'lang', 'hidden'), true) - || 1 === preg_match('/^(?:data|aria)-[a-z0-9_-]+$/', $name); + return SourceAttribute::isSelectorAttribute($name); } } diff --git a/php-transformer/src/Support/SourceAttribute.php b/php-transformer/src/Support/SourceAttribute.php new file mode 100644 index 000000000..2c6b5ad5a --- /dev/null +++ b/php-transformer/src/Support/SourceAttribute.php @@ -0,0 +1,53 @@ + array_merge(self::SELECTOR_NAMES, self::STATIC_NAMES), + 'pattern' => self::SELECTOR_PATTERN, + 'excludedNames' => self::DECLARED_EVENTS, + 'excludedPrefix' => 'data-wp-', + ); + } + + /** @param array $attributes @param list $ownedNames @return array */ + public static function staticAttributes(array $attributes, array $ownedNames = array()): array + { + $safe = array(); + $policy = self::staticPolicy(); + foreach ($attributes as $name => $value) { + if (!is_string($name) || !is_string($value) || in_array($name, $ownedNames, true) + || in_array($name, $policy['excludedNames'], true) || str_starts_with($name, $policy['excludedPrefix'])) { + continue; + } + if (in_array($name, $policy['names'], true) || 1 === preg_match('/' . $policy['pattern'] . '/', $name)) { + $safe[$name] = $value; + } + } + ksort($safe); + return $safe; + } +} diff --git a/php-transformer/tests/fixtures/associated-label-static-attributes.html b/php-transformer/tests/fixtures/associated-label-static-attributes.html new file mode 100644 index 000000000..3b49b33be --- /dev/null +++ b/php-transformer/tests/fixtures/associated-label-static-attributes.html @@ -0,0 +1,28 @@ + +Static label predicates +
+
+
+

Choose your interests.

+
+ diff --git a/php-transformer/tests/unit/associated-label-static-attributes.php b/php-transformer/tests/unit/associated-label-static-attributes.php new file mode 100644 index 000000000..6f73597dd --- /dev/null +++ b/php-transformer/tests/unit/associated-label-static-attributes.php @@ -0,0 +1,56 @@ +transform('')->toArray(); +$minimalDom = new DOMDocument(); +$minimalDom->loadHTML($minimal['serialized_blocks'], LIBXML_NOERROR | LIBXML_NOWARNING); +$assert('caption' === $minimalDom->getElementsByTagName('label')->item(0)->getAttribute('data-role'), 'reviewer reproduction preserves the actual data-role subject'); +$assert('choice' === $minimalDom->getElementsByTagName('label')->item(0)->getAttribute('for'), 'reviewer reproduction preserves the native association'); +$source = (string) file_get_contents(__DIR__ . '/../fixtures/associated-label-static-attributes.html'); +$result = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'block_namespace' => 'custom', 'files' => array('index.html' => $source)))->toArray(); +$plan = $result['source_reports']['wordpress_site_plan']; +$page = $plan['pages'][0]; +$original = new DOMDocument(); +$original->loadHTML($source, LIBXML_NOERROR | LIBXML_NOWARNING); +$candidate = new DOMDocument(); +$candidate->loadHTML($page['canonical_block_markup'], LIBXML_NOERROR | LIBXML_NOWARNING); +$label = $candidate->getElementById('choice-caption'); +$sourceLabel = $original->getElementById('choice-caption'); +foreach (SourceDom::htmlAttributes($sourceLabel) as $name => $value) { + $assert($label->hasAttribute($name) && $value === $label->getAttribute($name), 'authored label host retains the actual ' . $name . ' subject'); +} +$assert('input' === $label->previousElementSibling?->tagName && 'choice' === $label->previousElementSibling?->getAttribute('id'), 'attribute preservation introduces no adjacency wrapper'); +$assert('label' === $candidate->getElementById('other')->previousElementSibling?->tagName, 'opposite source label order remains intact'); +$assert('caption-context' === $page['document_metadata']['body_attributes']['class'] && 'choices' === $page['document_metadata']['body_attributes']['data-document'], 'body context remains owned by the canonical page metadata'); +$css = implode("\n", array_column(array_filter($plan['assets'], static fn (array $asset): bool => 'css' === $asset['kind'] && 'editor' !== ($asset['stylesheet_target'] ?? 'both')), 'content')); +$assert(str_contains($css, 'label[data-role=caption]') && str_contains($css, 'body.caption-context'), 'source attribute and body subjects remain stylesheet predicates'); +$assert(str_contains($css, '@media') && str_contains($css, '[data-state=expanded]'), 'conditional width and live data-state predicates remain authored CSS'); +$assert(array() === $result['fallbacks'], 'static source attributes do not require a fallback'); +$assert(!str_contains($page['canonical_block_markup'], 'blocks-engine-attribute-'), 'actual label attributes need no synthetic selector compensation'); + +$unsafe = array('onclick' => 'alert(1)', 'onpointerdown' => 'alert(2)', 'data-action' => 'run()', 'data-on' => '', 'data-event' => 'run()', 'jsaction' => 'click:run', 'data-wp-on--click' => 'actions.run', 'srcdoc' => '', 'href' => 'javascript:run()', 'is' => 'active-label', 'for' => 'wrong', 'id' => 'wrong', 'class' => 'wrong', 'style' => 'display:none'); +$attrs = array('htmlFor' => 'choice', 'id' => 'safe-label', 'content' => 'Choice', 'sourceAttributes' => $unsafe + array('data-role' => 'caption', 'title' => 'A "quoted" & title', 'aria-label' => 'Choice', 'hidden' => '')); +$surface = (new AuthoredLabelBlockGenerator())->markup($attrs); +$filtered = new DOMDocument(); +$filtered->loadHTML($surface, LIBXML_NOERROR | LIBXML_NOWARNING); +$host = $filtered->getElementsByTagName('label')->item(0); +foreach ($unsafe as $name => $value) { + $assert(in_array($name, array('for', 'id'), true) ? $value !== $host->getAttribute($name) : !$host->hasAttribute($name), 'PHP save filter rejects unowned/executable attribute ' . $name); +} +$assert('caption' === $host->getAttribute('data-role') && 'A "quoted" & title' === $host->getAttribute('title') && $host->hasAttribute('hidden'), 'passive strings and presence attributes survive escaping'); +$captured = (new HtmlTransformer())->transform('')->toArray(); +$assert(str_contains($captured['serialized_blocks'], 'data-role="caption"') && !str_contains($captured['serialized_blocks'], 'onclick=') && !str_contains($captured['serialized_blocks'], 'data-action=') && !str_contains($captured['serialized_blocks'], 'data-wp-on--click='), 'source capture admits only the passive host attributes'); + +echo "Associated label static attributes: $assertions passed\n"; From c619828a14fee88851e3b0c811ba5078842d7c7d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 8 Oct 2026 17:23:58 -0400 Subject: [PATCH 3/3] ci(blocks-engine): install the PHP transformer for package save-validity tests --- .github/workflows/js.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index e68aab684..44bb19342 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -38,6 +38,18 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # Save-validity tests compile fixtures through the PHP transformer. + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: "8.3" + coverage: none + tools: composer:v2 + + - name: Install PHP transformer dependencies + working-directory: php-transformer + run: composer install --no-interaction --prefer-dist --no-progress + - name: Typecheck run: pnpm typecheck