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 = '' + escapeAttribute( 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 = '' . $escape($attrs['label']) . $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, '