diff --git a/php-transformer/src/HtmlToBlocks/Diagnostics/SemanticParityReporter.php b/php-transformer/src/HtmlToBlocks/Diagnostics/SemanticParityReporter.php
index 6e1c82ab..3913afd3 100644
--- a/php-transformer/src/HtmlToBlocks/Diagnostics/SemanticParityReporter.php
+++ b/php-transformer/src/HtmlToBlocks/Diagnostics/SemanticParityReporter.php
@@ -255,6 +255,11 @@ private function collectBlockNavigationLandmarks(array $blocks, array &$counts):
continue;
}
+ if ( 'core/group' === ($block['blockName'] ?? '') && 'nav' === strtolower((string) ($block['attrs']['tagName'] ?? '')) ) {
+ ++$counts['nav'];
+ continue;
+ }
+
if ( 'core/navigation' === ($block['blockName'] ?? '') ) {
++$counts['nav'];
}
@@ -601,9 +606,45 @@ private function collectBlockNavigationMenus(array $blocks, string $path, array
}
$blockPath = $path . '.' . $index;
+ $innerBlocks = is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : array();
+ if ( 'core/group' === ($block['blockName'] ?? '')
+ && 'nav' === strtolower((string) ($block['attrs']['tagName'] ?? ''))
+ && $this->containsBlockName($innerBlocks, 'core/list')
+ && ! $this->containsBlockName($innerBlocks, 'core/navigation')
+ ) {
+ $items = array();
+ $seen = array();
+ $this->collectBlockAnchorItems($innerBlocks, $items, $seen);
+ $menus[] = array(
+ 'block_path' => $blockPath,
+ 'represented_as_native_list_navigation' => true,
+ 'item_count' => count($items),
+ 'items' => $items,
+ );
+ continue;
+ }
+
+ if ( 'core/group' === ($block['blockName'] ?? '')
+ && 'nav' === strtolower((string) ($block['attrs']['tagName'] ?? ''))
+ && ! $this->containsBlockName($innerBlocks, 'core/navigation')
+ ) {
+ $items = array();
+ $seen = array();
+ $this->collectBlockAnchorItems($innerBlocks, $items, $seen);
+ if ( array() !== $items ) {
+ $menus[] = array(
+ 'block_path' => $blockPath,
+ 'represented_as_native_group_navigation' => true,
+ 'item_count' => count($items),
+ 'items' => $items,
+ );
+ continue;
+ }
+ }
+
if ( 'core/navigation' === ($block['blockName'] ?? '') ) {
$items = array();
- $this->collectBlockNavigationItems(is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : array(), $items);
+ $this->collectBlockNavigationItems($innerBlocks, $items);
$menus[] = array(
'block_path' => $blockPath,
'represented_as_core_navigation' => true,
@@ -622,6 +663,24 @@ private function collectBlockNavigationMenus(array $blocks, string $path, array
}
}
+ /** @param array> $blocks */
+ private function containsBlockName(array $blocks, string $name): bool
+ {
+ foreach ( $blocks as $block ) {
+ if ( ! is_array($block) ) {
+ continue;
+ }
+ if ( $name === ($block['blockName'] ?? '') ) {
+ return true;
+ }
+ if ( is_array($block['innerBlocks'] ?? null) && $this->containsBlockName($block['innerBlocks'], $name) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/**
* A navigation whose landmark is a core/group{tagName:"nav"} carrier shares
* that landmark with blocks hoisted out of the menu — a branding anchor, for
@@ -877,7 +936,10 @@ private function semanticParityFindings(array $sourceLandmarks, array $blockLand
continue;
}
- if ( true !== ($blockMenu['represented_as_core_navigation'] ?? false) ) {
+ if ( true !== ($blockMenu['represented_as_core_navigation'] ?? false)
+ && true !== ($blockMenu['represented_as_native_list_navigation'] ?? false)
+ && true !== ($blockMenu['represented_as_native_group_navigation'] ?? false)
+ ) {
$findings[] = array(
'code' => 'navigation_core_block_missing',
'severity' => 'warning',
diff --git a/php-transformer/src/HtmlToBlocks/Elements/AuthoredFormControlBlockConverter.php b/php-transformer/src/HtmlToBlocks/Elements/AuthoredFormControlBlockConverter.php
index 29726bbf..95e5d731 100644
--- a/php-transformer/src/HtmlToBlocks/Elements/AuthoredFormControlBlockConverter.php
+++ b/php-transformer/src/HtmlToBlocks/Elements/AuthoredFormControlBlockConverter.php
@@ -101,7 +101,7 @@ public function select(DOMElement $select): ?array
*
* @return array|null
*/
- public function input(DOMElement $input): ?array
+ public function input(DOMElement $input, ?DOMElement $label = null, bool $preserveDataAttributes = false): ?array
{
if ( array() === ($this->structuralPresentationDeclarations)($input) ) {
return null;
@@ -125,7 +125,11 @@ public function input(DOMElement $input): ?array
'disabled' => $input->hasAttribute('disabled'),
'readOnly' => $input->hasAttribute('readonly'),
'checked' => $input->hasAttribute('checked'),
- ), static fn (mixed $value): bool => is_bool($value) ? $value : '' !== $value);
+ 'dataAttributes' => $preserveDataAttributes ? $this->dataAttributes($input) : array(),
+ 'label' => $label instanceof DOMElement ? $this->metadataBuilder->labelText($label) : '',
+ 'labelClassName' => $label instanceof DOMElement ? SourceDom::attr($label, 'class') : '',
+ 'labelStyle' => $label instanceof DOMElement ? SourceDom::attr($label, 'style') : '',
+ ), static fn (mixed $value): bool => is_array($value) ? array() !== $value : (is_bool($value) ? $value : '' !== $value));
$markup = $generator->markup($attrs);
return array(
@@ -137,6 +141,23 @@ public function input(DOMElement $input): ?array
);
}
+ /** @return array */
+ private function dataAttributes(DOMElement $input): array
+ {
+ $attributes = array();
+ foreach ( $input->attributes as $attribute ) {
+ $name = strtolower($attribute->nodeName);
+ if ( 1 !== preg_match('/^data-(?!wp-)[a-z0-9_.:-]+$/', $name) ) {
+ continue;
+ }
+ $attributes[$name] = $attribute->nodeValue ?? '';
+ }
+
+ ksort($attributes);
+
+ return $attributes;
+ }
+
/**
* @param array> $options
*/
diff --git a/php-transformer/src/HtmlToBlocks/Elements/ReadableFormControlBlockConverter.php b/php-transformer/src/HtmlToBlocks/Elements/ReadableFormControlBlockConverter.php
index d465ee76..13c1de12 100644
--- a/php-transformer/src/HtmlToBlocks/Elements/ReadableFormControlBlockConverter.php
+++ b/php-transformer/src/HtmlToBlocks/Elements/ReadableFormControlBlockConverter.php
@@ -49,6 +49,18 @@ public function convert(DOMElement $element): ?array
return null;
}
+ if ( ($this->isRuntimeDomTarget)($element) ) {
+ $this->runtimeIslandRecorder->recordControl($element);
+ if ( 'input' === $tagName ) {
+ $inputBlock = $this->authoredBlockConverter->input($element, null, true);
+ if ( null !== $inputBlock ) {
+ return $inputBlock;
+ }
+ }
+
+ return ($this->htmlPreservationBlock)($element);
+ }
+
if ( 'input' === $tagName && 'search' === FormControlClassifier::controlType($element) ) {
$label = $this->metadataBuilder->label($element);
if ( '' === $label ) {
@@ -61,11 +73,6 @@ public function convert(DOMElement $element): ?array
return ($this->htmlPreservationBlock)($element);
}
- if ( ($this->isRuntimeDomTarget)($element) ) {
- $this->runtimeIslandRecorder->recordControl($element);
- return ($this->htmlPreservationBlock)($element);
- }
-
if ( 'select' === $tagName ) {
$selectBlock = $this->authoredBlockConverter->select($element);
if ( null !== $selectBlock ) {
@@ -101,6 +108,12 @@ private function convertLabel(DOMElement $element): ?array
if ( ($this->isRuntimeDomTarget)($control) ) {
$this->runtimeIslandRecorder->recordControl($control);
+ if ( 1 === count($controls) && 'input' === strtolower($control->tagName) ) {
+ $inputBlock = $this->authoredBlockConverter->input($control, $element, true);
+ if ( null !== $inputBlock ) {
+ return $inputBlock;
+ }
+ }
return ($this->htmlPreservationBlock)($element);
}
diff --git a/php-transformer/src/HtmlToBlocks/Generators/AuthoredInputBlockGenerator.php b/php-transformer/src/HtmlToBlocks/Generators/AuthoredInputBlockGenerator.php
index 88fe3e82..f784b792 100644
--- a/php-transformer/src/HtmlToBlocks/Generators/AuthoredInputBlockGenerator.php
+++ b/php-transformer/src/HtmlToBlocks/Generators/AuthoredInputBlockGenerator.php
@@ -36,6 +36,10 @@ public function blockJson(): array
'disabled' => array( 'type' => 'boolean', 'default' => false ),
'readOnly' => array( 'type' => 'boolean', 'default' => false ),
'checked' => array( 'type' => 'boolean', 'default' => false ),
+ 'dataAttributes' => array( 'type' => 'object', 'default' => array() ),
+ 'label' => array( 'type' => 'string', 'default' => '' ),
+ 'labelClassName' => array( 'type' => 'string', 'default' => '' ),
+ 'labelStyle' => array( 'type' => 'string', 'default' => '' ),
),
'supports' => array( 'html' => false ),
);
@@ -49,8 +53,11 @@ public function assets(): array
var createElement = element.createElement;
var attributes = __BLOCK_ATTRIBUTES__;
function escapeAttribute( value ) { return String( value || '' ).replace( /&/g, '&' ).replace( /"/g, '"' ).replace( //g, '>' ); }
- function markup( attrs ) { var output = ''; }
- function edit( props ) { var attrs = props.attributes; return createElement( 'input', { type: attrs.type || 'text', id: attrs.id || undefined, name: attrs.name || undefined, value: attrs.value || undefined, placeholder: attrs.placeholder || undefined, 'aria-label': attrs.ariaLabel || undefined, className: attrs.className || undefined, style: attrs.style || undefined, min: attrs.min || undefined, max: attrs.max || undefined, step: attrs.step || undefined, required: attrs.required, disabled: attrs.disabled, readOnly: attrs.readOnly, checked: attrs.checked, onChange: function( event ) { var next = { value: event.target.value }; if ( 'checkbox' === attrs.type || 'radio' === attrs.type ) next.checked = event.target.checked; props.setAttributes( next ); } } ); }
+ function styleObject( value ) { if ( ! value ) return undefined; return String( value ).split( ';' ).reduce( function( output, declaration ) { var separator = declaration.indexOf( ':' ); if ( separator < 1 ) return output; var name = declaration.slice( 0, separator ).trim(); var property = name.indexOf( '--' ) === 0 ? name : name.replace( /-([a-z])/g, function( _, letter ) { return letter.toUpperCase(); } ); output[ property ] = declaration.slice( separator + 1 ).trim(); return output; }, {} ); }
+ function dataAttributes( attrs ) { return Object.keys( attrs.dataAttributes || {} ).reduce( function( output, name ) { if ( /^data-(?!wp-)[a-z0-9_.:-]+$/.test( name ) ) output[ name ] = attrs.dataAttributes[ name ]; return output; }, {} ); }
+ function inputProps( attrs ) { return Object.assign( { type: attrs.type || 'text', id: attrs.id || undefined, name: attrs.name || undefined, value: attrs.value || undefined, placeholder: attrs.placeholder || undefined, 'aria-label': attrs.ariaLabel || undefined, className: attrs.className || undefined, style: styleObject( attrs.style ), min: attrs.min || undefined, max: attrs.max || undefined, step: attrs.step || undefined, required: attrs.required, disabled: attrs.disabled, readOnly: attrs.readOnly, checked: attrs.checked }, dataAttributes( attrs ) ); }
+ function markup( attrs ) { var output = ''; if ( attrs.label ) output = ''; return output; }
+ function edit( props ) { var attrs = props.attributes; var input = createElement( 'input', Object.assign( inputProps( attrs ), { onChange: function( event ) { var next = { value: event.target.value }; if ( 'checkbox' === attrs.type || 'radio' === attrs.type ) next.checked = event.target.checked; props.setAttributes( next ); } } ) ); return attrs.label ? createElement( 'label', { className: attrs.labelClassName || undefined, style: styleObject( attrs.labelStyle ) }, attrs.label, input ) : input; }
function save( props ) { return createElement( element.RawHTML, null, markup( props.attributes ) ); }
blocks.registerBlockType( 'blocks-engine/authored-input', { attributes: attributes, supports: { html: false }, edit: edit, save: save } );
} )( window.wp.blocks, window.wp.element );
@@ -78,7 +85,27 @@ public function markup(array $attrs): string
}
}
- return $markup . '>';
+ $dataAttributes = is_array($attrs['dataAttributes'] ?? null) ? $attrs['dataAttributes'] : array();
+ ksort($dataAttributes);
+ foreach ( $dataAttributes as $name => $value ) {
+ if ( 1 === preg_match('/^data-(?!wp-)[a-z0-9_.:-]+$/', (string) $name) ) {
+ $markup .= ' ' . $name . '="' . $escape($value) . '"';
+ }
+ }
+
+ $markup .= '>';
+ if ( '' !== (string) ($attrs['label'] ?? '') ) {
+ $labelAttributes = '';
+ if ( '' !== (string) ($attrs['labelClassName'] ?? '') ) {
+ $labelAttributes .= ' class="' . $escape($attrs['labelClassName']) . '"';
+ }
+ if ( '' !== (string) ($attrs['labelStyle'] ?? '') ) {
+ $labelAttributes .= ' style="' . $escape($attrs['labelStyle']) . '"';
+ }
+ $markup = '';
+ }
+
+ return $markup;
}
/** @return array */
diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php
index b7a537f5..dcb3d81d 100644
--- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php
+++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php
@@ -2008,9 +2008,17 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo
}
if ( str_contains($serializedBlocks, self::CSS_OWNED_GRID_CLASS) ) {
// Core flow margins are not part of a source grid contract; the
- // carried grid geometry (gap) owns the spacing between items. The
- // carrier rides groups and lists, so the reset is class-scoped.
- $beforeAuthorCssParts[] = ':root :where(.' . self::CSS_OWNED_GRID_CLASS . ')>*{margin-block-start:0;margin-block-end:0}';
+ // carried grid geometry (gap) owns the spacing between items. Native
+ // headings retain their source browser-default margins unless the
+ // author stylesheet overrides them.
+ $beforeAuthorCssParts[] = ':root :where(.' . self::CSS_OWNED_GRID_CLASS . ')>:where(:not(h1,h2,h3,h4,h5,h6)){margin-block-start:0;margin-block-end:0}';
+ }
+ if ( str_contains($serializedBlocks, '