Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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('<mark class="pdf-label" style="--blocks-engine-richtext-marker:label-1"><strong>Privacy</strong> policy</mark>');
expect(blocks[0].attributes.fileName?.toString()).toBe('<mark class="pdf-label" style="--blocks-engine-richtext-marker:label-1" role="none"><strong>Privacy</strong> policy</mark>');
expect(blocks.map((block) => wp.validateBlock(block)[0])).toEqual([true, true, true]);
});
});
2 changes: 2 additions & 0 deletions php-transformer/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions php-transformer/src/Css/CssSelectorCompoundInspector.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@
/** Shared inspection helpers for parsed selector compounds. */
final class CssSelectorCompoundInspector
{
/** @param array<string, mixed> $compound @return list<string> */
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<string, mixed> $compound */
public static function containsDataAttribute(array $compound): bool
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<string, mixed> $structuralPresentationDeclarations
Expand All @@ -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,
Expand All @@ -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<string, mixed>|null */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);

namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators;

use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SourceDom;
use Automattic\BlocksEngine\PhpTransformer\Support\SourceAttribute;

/** A RichText label host for the association that core/group cannot serialize. */
final class AuthoredLabelBlockGenerator
{
public const LOCAL_NAME = 'authored-label';
public const HOST_ATTRIBUTES = array('htmlFor' => 'for', 'id' => 'id', 'className' => 'class', 'style' => 'style');

/** @return array<string, mixed> */
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, '&amp;' ).replace( /"/g, '&quot;' ).replace( /</g, '&lt;' ).replace( />/g, '&gt;' ); }
function markup( attrs ) {
var output = '<label';
Object.keys( hostAttributes ).forEach( function( key ) {
if ( attrs[ key ] ) output += ' ' + hostAttributes[ key ] + '="' + escapeAttribute( attrs[ key ] ) + '"';
} );
var source = sourceAttributes( attrs );
Object.keys( source ).forEach( function( name ) { output += ' ' + name + '="' + escapeAttribute( source[ name ] ) + '"'; } );
return output + '>' + ( attrs.content || '' ) + '</label>';
}
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<string, mixed> $attrs */
public function markup(array $attrs): string
{
$html = '<label';
foreach (self::HOST_ATTRIBUTES as $key => $name) {
if ('' !== (string) ($attrs[$key] ?? '')) {
$html .= ' ' . $name . '="' . htmlspecialchars((string) $attrs[$key], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"';
}
}
$source = SourceAttribute::staticAttributes(is_array($attrs['sourceAttributes'] ?? null) ? $attrs['sourceAttributes'] : array(), array_values(self::HOST_ATTRIBUTES));
return $html . SourceDom::htmlAttributeString($source) . '>' . ($attrs['content'] ?? '') . '</label>';
}
}
60 changes: 39 additions & 21 deletions php-transformer/src/HtmlToBlocks/HtmlCompilation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (`<bdt>`, `<x-note>`) 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 ) {
Expand All @@ -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('<?xml encoding="utf-8" ?><body>' . $content . '</body>', 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
Expand Down
Loading
Loading