From 8155b3e0dc0c0005bc7fb60e2f4ba22b9c3c6d17 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 1 Sep 2026 12:35:16 -0400 Subject: [PATCH] Give labeled navigation sections one owner --- .../SourceElementClassifier.php | 29 -------- .../Elements/FlowContainerElementContext.php | 6 +- .../FlowContainerElementConverter.php | 11 +-- .../src/HtmlToBlocks/HtmlTransformer.php | 58 +-------------- .../Patterns/NavigationPattern.php | 72 +++++++++++++++++++ .../unit/flow-container-element-converter.php | 3 +- .../unit/pattern-registry-staged-dispatch.php | 10 +++ 7 files changed, 95 insertions(+), 94 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php b/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php index 33656ab5..e0d58f55 100644 --- a/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php +++ b/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php @@ -423,35 +423,6 @@ public function isTimeValueElement(DOMElement $element): bool return $this->hasCommerceToken($element, array( 'time', 'hours', 'value', 'closed' )) || (bool) preg_match('/\b(?:closed|open|\d{1,2}(?::\d{2})?\s*(?:am|pm)?\s*(?:[\x{2013}\x{2014}-]|to)\s*\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\b/iu', $element->textContent ?? ''); } - public function isNavigationSectionHeading(DOMElement $element): bool - { - if ( preg_match('/^h[1-6]$/i', $element->tagName) ) { - return true; - } - - if ( ! in_array(strtolower($element->tagName), array( 'div', 'p', 'span' ), true) || '' === trim($element->textContent ?? '') ) { - return false; - } - - $name = strtolower(trim(SourceDom::attr($element, 'class') . ' ' . SourceDom::attr($element, 'id') . ' ' . SourceDom::attr($element, 'role') . ' ' . SourceDom::attr($element, 'aria-label'))); - return (bool) preg_match('/(?:^|[\s_-])(?:heading|label|title)(?:$|[\s_-])/', $name); - } - - public function hasSoftNavigationSectionHeadingSignal(DOMElement $element): bool - { - return ! preg_match('/^h[1-6]$/i', $element->tagName) && $this->isNavigationSectionHeading($element); - } - - public function hasNavigationContainerSignal(DOMElement $element): bool - { - if ( 'navigation' === strtolower(SourceDom::attr($element, 'role')) ) { - return true; - } - - $name = strtolower(trim(SourceDom::attr($element, 'class') . ' ' . SourceDom::attr($element, 'id'))); - return (bool) preg_match('/(?:^|[\s_-])(?:nav|navbar|navigation|menu|links)(?:$|[\s_-])/', $name); - } - public function hasDirectChildElement(DOMElement $element, string $tagName): bool { foreach ( $element->childNodes as $child ) { diff --git a/php-transformer/src/HtmlToBlocks/Elements/FlowContainerElementContext.php b/php-transformer/src/HtmlToBlocks/Elements/FlowContainerElementContext.php index 572cc9d0..98224647 100644 --- a/php-transformer/src/HtmlToBlocks/Elements/FlowContainerElementContext.php +++ b/php-transformer/src/HtmlToBlocks/Elements/FlowContainerElementContext.php @@ -3,6 +3,7 @@ namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\PatternContext; use Closure; use DOMElement; @@ -29,7 +30,7 @@ public function __construct( private readonly Closure $shouldPreserveEmptyVisualElement, private readonly Closure $emptyVisualElementAttributes, private readonly Closure $createBlock, - private readonly Closure $navigationSectionBlock, + private readonly PatternContext $patternContext, private readonly Closure $shouldDeferNavigationPatternToChildren, private readonly Closure $rememberAccordionDisclosureRoot, private readonly Closure $metadataGridBlock, @@ -83,8 +84,7 @@ public function shouldPreserveEmptyVisualElement(DOMElement $element): bool { re public function emptyVisualElementAttributes(DOMElement $element): array { return ($this->emptyVisualElementAttributes)($element); } /** @param array $attributes @param array> $innerBlocks @return array */ public function createBlock(string $name, array $attributes, array $innerBlocks, ?DOMElement $sourceElement): array { return ($this->createBlock)($name, $attributes, $innerBlocks, $sourceElement); } - /** @return array|null */ - public function navigationSectionBlock(DOMElement $element): ?array { return ($this->navigationSectionBlock)($element); } + public function patternContext(): PatternContext { return $this->patternContext; } public function shouldDeferNavigationPatternToChildren(DOMElement $element): bool { return ($this->shouldDeferNavigationPatternToChildren)($element); } /** @param array $block @return array */ public function rememberAccordionDisclosureRoot(array $block, DOMElement $element): array { return ($this->rememberAccordionDisclosureRoot)($block, $element); } diff --git a/php-transformer/src/HtmlToBlocks/Elements/FlowContainerElementConverter.php b/php-transformer/src/HtmlToBlocks/Elements/FlowContainerElementConverter.php index c598fcb3..5a4eb485 100644 --- a/php-transformer/src/HtmlToBlocks/Elements/FlowContainerElementConverter.php +++ b/php-transformer/src/HtmlToBlocks/Elements/FlowContainerElementConverter.php @@ -21,7 +21,10 @@ /** Converts flow containers through their ordered runtime, pattern, layout, and child strategies. */ final class FlowContainerElementConverter implements ElementConverter { - public function __construct(private readonly FlowContainerElementContext $context) + public function __construct( + private readonly FlowContainerElementContext $context, + private readonly NavigationPattern $navigationPattern = new NavigationPattern() + ) { } @@ -108,9 +111,9 @@ public function convert(DOMElement $element, string $tagName, array &$fallbacks) if ( null !== $block ) { return ConversionOutcome::handled($block); } - $block = $this->context->navigationSectionBlock($element); - if ( null !== $block ) { - return ConversionOutcome::handled($block); + $navigationSection = $this->navigationPattern->recognizeLabeledSection($element, $this->context->patternContext()); + if ( null !== $navigationSection ) { + return ConversionOutcome::handled($navigationSection->block()); } if ( ! $this->context->shouldDeferNavigationPatternToChildren($element) ) { $block = $this->context->recognizePatterns($element, $fallbacks, array( AccordionPattern::class, SocialLinksPattern::class, NavigationPattern::class )); diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index adc0bd02..1fbf8854 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -801,7 +801,7 @@ function (DOMElement $element, array &$fallbacks, bool $captureUnsupported): arr shouldPreserveEmptyVisualElement: fn (DOMElement $element): bool => $this->shouldPreserveEmptyVisualElement($element), emptyVisualElementAttributes: fn (DOMElement $element): array => $this->emptyVisualElementAttributes($element), createBlock: fn (string $name, array $attributes, array $innerBlocks, ?DOMElement $sourceElement): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement), - navigationSectionBlock: fn (DOMElement $element): ?array => $this->navigationSectionBlockFromElement($element), + patternContext: $this->patternContext, shouldDeferNavigationPatternToChildren: fn (DOMElement $element): bool => $this->shouldDeferNavigationPatternToChildren($element), rememberAccordionDisclosureRoot: fn (array $block, DOMElement $element): array => $this->rememberAccordionDisclosureRoot($block, $element), metadataGridBlock: fn (DOMElement $element): ?array => $this->metadataGridBlockFromElement($element), @@ -9335,62 +9335,6 @@ private function looksLikeDateOrTimeText(string $text): bool return (bool) preg_match('/\b(?:\d{1,2}(?::\d{2})?\s*(?:am|pm)?|\d{1,2}\s*(?:min|mins|minutes|hr|hrs|hours)|mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?|sun(?:day)?|day\s+\d+)\b/i', trim($text)); } - /** - * @return array|null - */ - private function navigationSectionBlockFromElement(DOMElement $element): ?array - { - $heading = null; - $anchors = array(); - foreach ( $element->childNodes as $child ) { - if ( XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent ?? '') ) { - continue; - } - - if ( $child instanceof DOMElement && $this->sourceElementClassifier->isNavigationSectionHeading($child) ) { - if ( $heading instanceof DOMElement ) { - return null; - } - $heading = $child; - continue; - } - - if ( $child instanceof DOMElement && 'a' === strtolower($child->tagName) && '' !== trim($child->textContent ?? '') ) { - $anchors[] = $child; - continue; - } - - return null; - } - - if ( ! $heading instanceof DOMElement || array() === $anchors ) { - return null; - } - - if ( ! $this->sourceElementClassifier->hasNavigationContainerSignal($element) && ! $this->sourceElementClassifier->hasSoftNavigationSectionHeadingSignal($heading) ) { - return null; - } - - $sectionFallbacks = array(); - $blocks = array( $this->convertElement($heading, $sectionFallbacks, true) ); - $links = array(); - foreach ( $anchors as $anchor ) { - $links[] = $this->createBlock('core/navigation-link', array_filter(array( - 'label' => $this->innerHtml($anchor), - 'url' => $this->safeNavigationUrl($this->attr($anchor, 'href')), - 'kind' => 'custom', - ), static fn ($value): bool => '' !== $value), array(), $anchor); - } - $overlayMenu = $this->navigationToggleSuppressor->navigationOverlayMenu($element); - $navigationAttrs = array( 'overlayMenu' => $overlayMenu ); - if ( 'mobile' === $overlayMenu ) { - $navigationAttrs['className'] = 'blocks-engine-native-responsive-navigation'; - } - $blocks[] = $this->createBlock('core/navigation', $navigationAttrs, $links, $element); - - return $this->createBlock('core/group', $this->styleResolver->presentationAttributes($element), array_values(array_filter($blocks)), $element); - } - private function convertMediaElement(DOMElement $element): ?array { $tagName = strtolower($element->tagName); diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php index 94716f7f..9902d6a3 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php @@ -162,6 +162,78 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter ); } + public function recognizeLabeledSection(DOMElement $element, PatternContext $context): ?PatternRecognitionResult + { + $converter = $context->recursiveConverter(); + if ( 'nav' === strtolower($element->tagName) || null === $converter ) { + return null; + } + + $heading = null; + $anchors = array(); + foreach ( $element->childNodes as $child ) { + if ( XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent ?? '') ) { + continue; + } + if ( $child instanceof DOMElement && $this->isNavigationSectionHeading($child) ) { + if ( $heading instanceof DOMElement ) { + return null; + } + $heading = $child; + continue; + } + if ( $child instanceof DOMElement && 'a' === strtolower($child->tagName) && '' !== trim($child->textContent ?? '') ) { + $anchors[] = $child; + continue; + } + return null; + } + + if ( ! $heading instanceof DOMElement || array() === $anchors ) { + return null; + } + $name = strtolower(trim(SourceDom::attr($element, 'class') . ' ' . SourceDom::attr($element, 'id'))); + $hasContainerSignal = 'navigation' === strtolower(SourceDom::attr($element, 'role')) + || (bool) preg_match('/(?:^|[\s_-])(?:nav|navbar|navigation|menu|links)(?:$|[\s_-])/', $name); + if ( ! $hasContainerSignal && preg_match('/^h[1-6]$/i', $heading->tagName) ) { + return null; + } + + $discardedFallbacks = array(); + $blocks = array($converter->element($heading, $discardedFallbacks, true)); + $links = array(); + foreach ( $anchors as $anchor ) { + $links[] = $context->createBlock('core/navigation-link', array_filter(array( + 'label' => SourceDom::innerHtml($anchor), + 'url' => SourceDom::safeNavigationUrl(SourceDom::attr($anchor, 'href')), + 'kind' => 'custom', + ), static fn ($value): bool => '' !== $value), array(), $anchor); + } + $overlayMenu = $context->navigationContext()?->overlayMenu($element) ?? 'never'; + $navigationAttrs = array('overlayMenu' => $overlayMenu); + if ( 'mobile' === $overlayMenu ) { + $navigationAttrs['className'] = 'blocks-engine-native-responsive-navigation'; + } + $blocks[] = $context->createBlock('core/navigation', $navigationAttrs, $links, $element); + + return new PatternRecognitionResult( + $context->createBlock('core/group', $context->presentationAttributes($element), array_values(array_filter($blocks)), $element) + ); + } + + private function isNavigationSectionHeading(DOMElement $element): bool + { + if ( preg_match('/^h[1-6]$/i', $element->tagName) ) { + return true; + } + if ( ! in_array(strtolower($element->tagName), array('div', 'p', 'span'), true) || '' === trim($element->textContent ?? '') ) { + return false; + } + + $name = strtolower(trim(SourceDom::attr($element, 'class') . ' ' . SourceDom::attr($element, 'id') . ' ' . SourceDom::attr($element, 'role') . ' ' . SourceDom::attr($element, 'aria-label'))); + return (bool) preg_match('/(?:^|[\s_-])(?:heading|label|title)(?:$|[\s_-])/', $name); + } + /** * A nav container that holds a brand beside its link cluster authors THREE * elements — the landmark, the brand, and the menu — each with its own CSS diff --git a/php-transformer/tests/unit/flow-container-element-converter.php b/php-transformer/tests/unit/flow-container-element-converter.php index 00a877b5..ec94ce3d 100644 --- a/php-transformer/tests/unit/flow-container-element-converter.php +++ b/php-transformer/tests/unit/flow-container-element-converter.php @@ -5,6 +5,7 @@ use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements\FlowContainerElementContext; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements\FlowContainerElementConverter; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\PatternContext; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\SpacerPattern; $assertions = 0; @@ -56,7 +57,7 @@ 'innerBlocks' => $innerBlocks, 'sourceTag' => $sourceElement?->tagName, ), - 'navigationSectionBlock' => $null, + 'patternContext' => new PatternContext($null, $null), 'shouldDeferNavigationPatternToChildren' => $false, 'rememberAccordionDisclosureRoot' => static fn (array $block): array => $block, 'metadataGridBlock' => $null, diff --git a/php-transformer/tests/unit/pattern-registry-staged-dispatch.php b/php-transformer/tests/unit/pattern-registry-staged-dispatch.php index 11906833..ecc02771 100644 --- a/php-transformer/tests/unit/pattern-registry-staged-dispatch.php +++ b/php-transformer/tests/unit/pattern-registry-staged-dispatch.php @@ -164,4 +164,14 @@ static function (DOMElement $source, bool $captureUnsupported) use (&$recursiveC (new NavigationPattern())->recognize($navigationElement, $probeContext); $assert(1 === $recursiveCalls, 'Navigation probe context performs no recursive conversion side effects.'); +$labeledNavigation = (new HtmlTransformer())->transform('')->toArray(); +$labeledNavigationGroup = $labeledNavigation['blocks'][0] ?? array(); +$labeledNavigationBlock = $labeledNavigationGroup['innerBlocks'][1] ?? array(); +$assert('core/group' === ($labeledNavigationGroup['blockName'] ?? null) && 'core/navigation' === ($labeledNavigationBlock['blockName'] ?? null), 'NavigationPattern owns labeled non-nav link sections.'); +$assert(! isset($labeledNavigationBlock['attrs']['layout']), 'Labeled non-nav sections retain their established source-flow navigation layout.'); +$labeledSocialNavigation = (new HtmlTransformer())->transform('')->toArray(); +$assert('core/navigation' === ($labeledSocialNavigation['blocks'][0]['innerBlocks'][1]['blockName'] ?? null), 'Labeled social-profile sections retain navigation precedence and their heading.'); +$punctuatedNavigation = (new HtmlTransformer())->transform('')->toArray(); +$assert('vertical' === ($punctuatedNavigation['blocks'][0]['innerBlocks'][1]['attrs']['layout']['orientation'] ?? null), 'Punctuation outside established navigation signal boundaries retains normal pattern lowering.'); + echo "pattern registry staged dispatch passed ({$assertions} assertions)\n";