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
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..f6d89c481
--- /dev/null
+++ b/packages/blocks-engine/src/wp/__tests__/associated-label-save-validity.test.ts
@@ -0,0 +1,270 @@
+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' }));
+const compileDocument = (html: string) => JSON.parse(execFileSync('php', ['-r',
+ 'require $argv[1] . "/vendor/autoload.php"; $r=(new Automattic\\BlocksEngine\\PhpTransformer\\ArtifactCompiler\\ArtifactCompiler())->compile(array("entrypoint"=>"index.html","block_namespace"=>"custom","files"=>array("index.html"=>base64_decode($argv[2]))))->toArray(); $plan=$r["source_reports"]["wordpress_site_plan"]; $page=$plan["pages"][0]; echo json_encode(array("serialized_blocks"=>$page["canonical_block_markup"],"assets"=>$plan["assets"],"source_reports"=>array("generated_blocks"=>$r["source_reports"]["companion_plugin_payload"]["blocks"]),"document"=>$page,"bootstrap"=>Automattic\\BlocksEngine\\PhpTransformer\\WordPressSitePlan\\DocumentRootContext::bootstrap(array($page)),"canvas"=>Automattic\\BlocksEngine\\PhpTransformer\\WordPressSitePlan\\DocumentRootContext::canvas()));',
+ phpRoot, Buffer.from(html).toString('base64')], { encoding: 'utf8' }));
+
+const renderWordPress = (result: any, markup: string): string | undefined => {
+ if (!process.env.BLOCKS_ENGINE_WP_CLI || !process.env.BLOCKS_ENGINE_WP_PATH) return undefined;
+ const payload = Buffer.from(JSON.stringify({
+ markup, definitions: result.source_reports.generated_blocks.map((definition: any) => definition.block_json),
+ document: result.document, bootstrap: result.bootstrap, canvas: result.canvas,
+ css: result.assets.filter((asset: any) => asset.kind === 'css' && asset.stylesheet_target !== 'editor').map((asset: any) => asset.content || '').join('\n'),
+ })).toString('base64');
+ // All registrations and query fixtures are process-local; no content is saved.
+ const code = `$input=json_decode(base64_decode('${payload}'),true); foreach($input['definitions'] as $definition) { register_block_type($definition['name'],$definition); } $markup=serialize_blocks(parse_blocks($input['markup'])); if(isset($input['document'])) { eval($input['bootstrap']); global $wp_query; $wp_query=new WP_Query(); $wp_query->is_singular=true; $wp_query->is_page=true; $wp_query->queried_object_id=900001; $wp_query->queried_object=(object)array('ID'=>900001,'post_type'=>'page'); add_filter('get_post_metadata',static function($value,$id,$key)use($input){return $id===900001&&$key==='_blocks_engine_reconciliation_identity'?$input['document']['reconciliation_identity']:$value;},10,3); foreach(array('wp_head','wp_footer','wp_body_open')as $hook)remove_all_actions($hook); add_action('wp_head',static function()use($input){echo '';}); $GLOBALS['_wp_current_template_content']=$markup; $GLOBALS['_wp_current_template_id']=''; ob_start(); eval('?>'.$input['canvas']); $html=ob_get_clean(); } else { $html=do_blocks($markup); } echo json_encode(array('html'=>$html,'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\./);
+ return native.html;
+};
+
+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');
+
+ // Construct the edit element using native RichText. This exercises the
+ // callback/save contract, not a mounted or persistent wp-admin editor.
+ // 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');
+ const rendered = renderWordPress(result, saved);
+ 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);
+
+ it('retains static CSS subjects through registered saves and native document rendering', async () => {
+ const { chromium } = require('playwright');
+ const documentSource = readFileSync(`${phpRoot}tests/fixtures/associated-label-static-attributes.html`, 'utf8');
+ const result = compileDocument(documentSource);
+ registerCompanions(result);
+ const parsed = wp.parse(result.serialized_blocks);
+ const flatten = (items: any[]): any[] => items.flatMap(block => [block, ...flatten(block.innerBlocks)]);
+ for (const block of flatten(parsed)) expect(wp.validateBlock(block)[0]).toBe(true);
+ const saved = wp.serialize(parsed);
+ const reopened = wp.parse(saved);
+ for (const block of flatten(reopened)) expect(wp.validateBlock(block)[0]).toBe(true);
+ const label = flatten(reopened).find(block => block.attributes.id === 'choice-caption');
+ expect(label.attributes.sourceAttributes).toMatchObject({
+ 'data-role': 'caption', 'data-present': '', 'aria-label': 'Choice caption',
+ lang: 'en-CA', dir: 'ltr', title: 'Choose "Arts" & Science', tabindex: '0',
+ });
+ expect(saved).not.toContain('blocks-engine-attribute-');
+
+ // The registered save also filters caller-supplied attribute objects, not
+ // just compiler input. Owned fields cannot be overridden through this map.
+ const hostile = wp.createBlock('custom/authored-label', {
+ htmlFor: 'choice', id: 'hostile-probe', content: 'Choice',
+ sourceAttributes: {
+ 'data-role': 'caption', title: 'A "quoted" & title', hidden: '',
+ onclick: 'alert(1)', 'data-action': 'run()', 'data-on': '', 'data-event': 'run()',
+ 'data-wp-on--click': 'actions.run', srcdoc: '', href: 'javascript:run()',
+ for: 'wrong', id: 'wrong', class: 'wrong', style: 'display:none', 'data-object': { nested: true },
+ },
+ });
+ const hostileSaved = wp.serialize([hostile]);
+ expect(wp.validateBlock(wp.parse(hostileSaved)[0])[0]).toBe(true);
+ const host = document.createElement('div');
+ host.innerHTML = hostileSaved;
+ const safeHost = host.querySelector('label')!;
+ expect(Array.from(safeHost.attributes).map(attribute => attribute.name).sort()).toEqual(['data-role', 'for', 'hidden', 'id', 'title']);
+ expect(safeHost.getAttribute('for')).toBe('choice');
+ expect(safeHost.getAttribute('title')).toBe('A "quoted" & title');
+
+ const css = result.assets.filter((asset: any) => asset.kind === 'css' && asset.stylesheet_target !== 'editor').map((asset: any) => asset.content || '').join('\n');
+ expect(css).toContain('label[data-role=caption]');
+ expect(css).toContain('body[data-document=choices]');
+ const native = renderWordPress(result, saved);
+ 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 = '', metadata?: any) => {
+ await page.setContent(html);
+ if (styles) await page.addStyleTag({ content: styles });
+ if (metadata) await page.evaluate((state: any) => {
+ for (const [element, attributes] of [[document.documentElement, state.root_attributes], [document.body, state.body_attributes]] as const) {
+ for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, String(value));
+ }
+ }, metadata);
+ const measure = () => page.evaluate(() => Array.from(document.querySelectorAll('.attribute-caption')).map(label => {
+ const style = getComputedStyle(label);
+ const range = document.createRange(); range.selectNodeContents(label);
+ const box = label.getBoundingClientRect(); const text = range.getBoundingClientRect();
+ const control = (label as HTMLLabelElement).control! as HTMLInputElement;
+ const inputBox = control.getBoundingClientRect(); const inputStyle = getComputedStyle(control);
+ return {
+ id: label.id, for: label.getAttribute('for'), control: control.id, checked: control.checked,
+ attributes: Array.from(label.attributes).filter(attribute => attribute.name !== 'style').map(attribute => [attribute.name, attribute.value]).sort(),
+ 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, style.letterSpacing],
+ paint: [style.color, style.backgroundColor, style.border, style.textDecorationLine, getComputedStyle(label, '::after').content],
+ input: [inputBox.x, inputBox.y, inputBox.width, inputBox.height, inputStyle.outline],
+ };
+ }));
+ const rest = await measure();
+ await page.locator('#choice-caption').evaluate((node: Element) => node.setAttribute('data-role', 'helper'));
+ const changedPredicate = await measure();
+ await page.locator('#choice-caption').evaluate((node: Element) => {
+ node.setAttribute('data-role', 'caption'); node.setAttribute('data-state', 'expanded');
+ node.setAttribute('data-muted', ''); node.removeAttribute('data-present');
+ });
+ const expanded = await measure();
+ await page.locator('#choice-caption').click();
+ const clicked = await measure();
+ await page.locator('body').evaluate((node: Element) => { node.classList.remove('caption-context'); node.setAttribute('data-document', 'plain'); });
+ const changedBody = await measure();
+ await page.locator('#other-caption').click();
+ const otherClicked = await measure();
+ return { rest, changedPredicate, expanded, clicked, changedBody, otherClicked };
+ };
+ const original = await probe(documentSource);
+ expect(await probe(saved, css, result.document.document_metadata)).toEqual(original);
+ if (native !== undefined) expect(await probe(native)).toEqual(original);
+ expect(original.rest[0].font.slice(1, 4)).toEqual([width === 390 ? '20px' : width === 768 ? '22px' : '24px', '600', width === 390 ? '24px' : width === 768 ? '28px' : '30px']);
+ expect(original.rest[0].paint[0]).toBe(width === 1440 ? 'rgb(0, 100, 50)' : 'rgb(255, 0, 0)');
+ expect(original.changedPredicate[0].font[1]).toBe('14px');
+ expect(original.changedPredicate[0].paint[0]).toBe('rgb(0, 0, 0)');
+ expect(original.expanded[0].font[4]).toBe('normal');
+ expect(original.clicked[0].checked).toBe(false);
+ expect(original.otherClicked[1].checked).toBe(true);
+ 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 fff69825e..1c2543804 100644
--- a/php-transformer/composer.json
+++ b/php-transformer/composer.json
@@ -431,6 +431,8 @@
"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/associated-label-static-attributes.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/Css/CssSelectorCompoundInspector.php b/php-transformer/src/Css/CssSelectorCompoundInspector.php
index ce558af8b..b2a59f399 100644
--- a/php-transformer/src/Css/CssSelectorCompoundInspector.php
+++ b/php-transformer/src/Css/CssSelectorCompoundInspector.php
@@ -6,6 +6,29 @@
/** Shared inspection helpers for parsed selector compounds. */
final class CssSelectorCompoundInspector
{
+ /** @param array $compound @return list */
+ public static function dataAttributeNames(array $compound): array
+ {
+ $names = array();
+ foreach ($compound['attributes'] ?? array() as $attribute) {
+ $name = (string) ($attribute['name'] ?? '');
+ if (str_starts_with($name, 'data-')) $names[] = $name;
+ }
+ foreach ($compound['not'] ?? array() as $negated) {
+ foreach ($negated['compounds'] ?? array() as $nested) {
+ array_push($names, ...self::dataAttributeNames($nested));
+ }
+ }
+ foreach ($compound['any'] ?? array() as $group) {
+ foreach ($group['alternatives'] ?? array() as $alternative) {
+ foreach ($alternative['compounds'] ?? array() as $nested) {
+ array_push($names, ...self::dataAttributeNames($nested));
+ }
+ }
+ }
+ return array_values(array_unique($names));
+ }
+
/** @param array $compound */
public static function containsDataAttribute(array $compound): bool
{
diff --git a/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php b/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php
index 31a17cd46..ad191be94 100644
--- a/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php
+++ b/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php
@@ -22,6 +22,13 @@ final class FormControlClassifier
*/
public const DATA_ENTRY_TAGS = array( 'input', 'select', 'textarea' );
+ /** The external label host emitted independently from its control. */
+ public static function isExternalAssociatedLabel(DOMElement $element): bool
+ {
+ return 'label' === strtolower($element->tagName) && '' !== $element->getAttribute('for')
+ && array() === self::controlElements($element);
+ }
+
public static function isControlElement(DOMElement $element): bool
{
return in_array(strtolower($element->tagName), self::CONTROL_TAGS, true);
diff --git a/php-transformer/src/HtmlToBlocks/Elements/AuthoredFormControlBlockConverter.php b/php-transformer/src/HtmlToBlocks/Elements/AuthoredFormControlBlockConverter.php
index 75bb83309..8be8c7447 100644
--- a/php-transformer/src/HtmlToBlocks/Elements/AuthoredFormControlBlockConverter.php
+++ b/php-transformer/src/HtmlToBlocks/Elements/AuthoredFormControlBlockConverter.php
@@ -7,11 +7,13 @@
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;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SourceDom;
use Automattic\BlocksEngine\PhpTransformer\WordPress\Runtime;
+use Automattic\BlocksEngine\PhpTransformer\Support\SourceAttribute;
use Closure;
use DOMElement;
@@ -20,6 +22,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 +33,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 +45,45 @@ 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 (!FormControlClassifier::isExternalAssociatedLabel($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),
+ 'sourceAttributes' => SourceAttribute::staticAttributes(SourceDom::htmlAttributes($label), array_values(AuthoredLabelBlockGenerator::HOST_ATTRIBUTES)),
+ ), static fn (mixed $value): bool => is_array($value) ? array() !== $value : '' !== $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..2f5e6400d
--- /dev/null
+++ b/php-transformer/src/HtmlToBlocks/Generators/AuthoredLabelBlockGenerator.php
@@ -0,0 +1,106 @@
+ 'for', 'id' => 'id', 'className' => 'class', 'style' => 'style');
+
+ /** @return array */
+ public function definition(string $namespace): array
+ {
+ $attributes = array();
+ foreach (array('htmlFor', 'id', 'className', 'style', 'content') as $name) {
+ $attributes[$name] = array('type' => 'string', 'default' => '');
+ }
+ $attributes['sourceAttributes'] = array('type' => 'object', 'default' => array());
+ $script = <<<'JS'
+( function( blocks, blockEditor, element ) {
+ var createElement = element.createElement;
+ var RichText = blockEditor.RichText;
+ var hostAttributes = __HOST_ATTRIBUTES__;
+ var staticPolicy = __STATIC_POLICY__;
+ var staticPattern = new RegExp( staticPolicy.pattern );
+ var booleanAttributes = __BOOLEAN_ATTRIBUTES__;
+ function sourceAttributes( attrs ) {
+ var safe = {};
+ Object.keys( attrs.sourceAttributes || {} ).sort().forEach( function( name ) {
+ var value = attrs.sourceAttributes[ name ];
+ if ( typeof value !== 'string' || Object.values( hostAttributes ).indexOf( name ) !== -1 || staticPolicy.excludedNames.indexOf( name ) !== -1 || name.indexOf( staticPolicy.excludedPrefix ) === 0 ) return;
+ if ( staticPolicy.names.indexOf( name ) !== -1 || staticPattern.test( name ) ) safe[ name ] = value;
+ } );
+ return safe;
+ }
+ function escapeAttribute( value ) { return String( value || '' ).replace( /&/g, '&' ).replace( /"/g, '"' ).replace( //g, '>' ); }
+ function markup( attrs ) {
+ var output = '';
+ }
+ 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 );
+ } );
+ 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 } ); }
+ } ) );
+ }
+ 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__', '__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')),
+ );
+ }
+
+ /** @param array $attrs */
+ public function markup(array $attrs): string
+ {
+ $html = '';
+ }
+}
diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php
index cb7a5cf0c..90d087780 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,
@@ -5112,26 +5113,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 ) {
@@ -5158,6 +5140,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/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/contract/run.php b/php-transformer/tests/contract/run.php
index 1c7964a01..2b63908a8 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'] ?? ''), '
') && ! str_contains((string) ($standaloneControls['serialized_blocks'] ?? ''), '') && str_contains((string) ($standaloneControls['serialized_blocks'] ?? ''), '