From c09420c1a5fcfc6e1fe99e201bb3bbb4dee8f338 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 27 Jun 2026 00:31:44 -0400 Subject: [PATCH] Improve header navigation semantic parity --- .../src/HtmlToBlocks/HtmlTransformer.php | 114 +++++++++++++++--- .../Patterns/NavigationPattern.php | 85 +++++++++++-- php-transformer/tests/contract/run.php | 13 ++ ...runtime-target-container-preservation.json | 2 +- 4 files changed, 188 insertions(+), 26 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 3b9ff5a..cf31e20 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -384,7 +384,8 @@ private function sourceLandmarkReport(DOMElement $body): array { $counts = array('header' => 0, 'nav' => 0, 'main' => 0, 'footer' => 0); $selectors = array('header' => array(), 'nav' => array(), 'main' => array(), 'footer' => array()); - $this->collectSourceLandmarks($body, $counts, $selectors); + $seenNavigation = array(); + $this->collectSourceLandmarks($body, $counts, $selectors, $seenNavigation); return array('counts' => $counts, 'selectors' => $selectors); } @@ -392,18 +393,30 @@ private function sourceLandmarkReport(DOMElement $body): array /** * @param array $counts * @param array> $selectors + * @param array $seenNavigation */ - private function collectSourceLandmarks(DOMElement $element, array &$counts, array &$selectors): void + private function collectSourceLandmarks(DOMElement $element, array &$counts, array &$selectors, array &$seenNavigation): void { $landmark = $this->landmarkKindForElement($element); if ( '' !== $landmark ) { + if ( 'nav' === $landmark ) { + $signature = $this->sourceNavigationMenuSignature($this->sourceNavigationMenuItems($element)); + if ( '' !== $signature && isset($seenNavigation[$signature]) && $this->isMobileDuplicateSourceNavigation($element) ) { + return; + } + + if ( '' !== $signature ) { + $seenNavigation[$signature] = true; + } + } + ++$counts[$landmark]; $selectors[$landmark][] = $this->elementSelector($element); } foreach ( $element->childNodes as $child ) { if ( $child instanceof DOMElement ) { - $this->collectSourceLandmarks($child, $counts, $selectors); + $this->collectSourceLandmarks($child, $counts, $selectors, $seenNavigation); } } } @@ -516,27 +529,27 @@ private function collectBlockNavigationLandmarks(array $blocks, array &$counts): private function sourceNavigationMenus(DOMElement $body): array { $menus = array(); - $this->collectSourceNavigationMenus($body, $menus); + $seen = array(); + $this->collectSourceNavigationMenus($body, $menus, $seen); return $menus; } /** * @param array> $menus + * @param array $seen */ - private function collectSourceNavigationMenus(DOMElement $element, array &$menus): void + private function collectSourceNavigationMenus(DOMElement $element, array &$menus, array &$seen): void { if ( 'nav' === strtolower($element->tagName) || 'navigation' === strtolower($this->attr($element, 'role')) ) { - $items = array(); - foreach ( $element->getElementsByTagName('a') as $anchor ) { - if ( $anchor instanceof DOMElement ) { - $label = $this->sourceNavigationAnchorLabel($anchor); - if ( '' !== $label ) { - $items[] = array( - 'label' => $label, - 'url' => $this->safeNavigationUrl($this->attr($anchor, 'href')), - ); - } - } + $items = $this->sourceNavigationMenuItems($element); + + $signature = $this->sourceNavigationMenuSignature($items); + if ( '' !== $signature && isset($seen[$signature]) && $this->isMobileDuplicateSourceNavigation($element) ) { + return; + } + + if ( '' !== $signature ) { + $seen[$signature] = true; } $menus[] = array( @@ -548,9 +561,74 @@ private function collectSourceNavigationMenus(DOMElement $element, array &$menus foreach ( $element->childNodes as $child ) { if ( $child instanceof DOMElement ) { - $this->collectSourceNavigationMenus($child, $menus); + $this->collectSourceNavigationMenus($child, $menus, $seen); + } + } + } + + /** + * @return array> + */ + private function sourceNavigationMenuItems(DOMElement $element): array + { + $items = array(); + foreach ( $element->getElementsByTagName('a') as $anchor ) { + if ( ! $anchor instanceof DOMElement || $this->isSourceNavigationChromeAnchor($anchor) ) { + continue; } + + $label = $this->sourceNavigationAnchorLabel($anchor); + if ( '' !== $label ) { + $items[] = array( + 'label' => $label, + 'url' => $this->safeNavigationUrl($this->attr($anchor, 'href')), + ); + } + } + + return $items; + } + + /** + * @param array> $items + */ + private function sourceNavigationMenuSignature(array $items): string + { + $links = array(); + foreach ( $items as $item ) { + $links[] = trim((string) ($item['label'] ?? '')) . '>' . trim((string) ($item['url'] ?? '')); + } + + return implode('|', $links); + } + + private function isMobileDuplicateSourceNavigation(DOMElement $element): bool + { + $tokens = array( + $this->attr($element, 'class'), + $this->attr($element, 'id'), + ); + + for ( $parent = $element->parentNode; $parent instanceof DOMElement && 'body' !== strtolower($parent->tagName); $parent = $parent->parentNode ) { + $tokens[] = $this->attr($parent, 'class'); + $tokens[] = $this->attr($parent, 'id'); } + + return (bool) preg_match('/(?:^|[^a-z0-9])(?:mobile|drawer|offcanvas|overlay|hamburger|menu-panel|nav-panel)(?:[^a-z0-9]|$)/', strtolower(implode(' ', $tokens))); + } + + private function isSourceNavigationChromeAnchor(DOMElement $anchor): bool + { + if ( in_array(strtolower($this->attr($anchor, 'role')), array( 'separator', 'presentation', 'none' ), true) ) { + return true; + } + + if ( '' === trim($anchor->textContent ?? '') && '' === trim($this->attr($anchor, 'aria-label') . $this->attr($anchor, 'title')) ) { + return true; + } + + $tokens = strtolower($this->attr($anchor, 'class') . ' ' . $this->attr($anchor, 'id')); + return (bool) preg_match('/(?:^|[^a-z0-9])(?:separator|divider)(?:[^a-z0-9]|$)/', $tokens); } /** @@ -842,7 +920,7 @@ private function collectNavigationBlockLinks(array $block, array &$links): void { if ( in_array($block['blockName'] ?? '', array( 'core/navigation-link', 'core/navigation-submenu' ), true) ) { $attrs = is_array($block['attrs'] ?? null) ? $block['attrs'] : array(); - $links[] = trim((string) ($attrs['label'] ?? '')) . '>' . trim((string) ($attrs['url'] ?? '')); + $links[] = $this->normalizedNavigationLabel((string) ($attrs['label'] ?? '')) . '>' . trim((string) ($attrs['url'] ?? '')); } foreach ( is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : array() as $innerBlock ) { diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php index ef840cd..d4f0fa7 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php @@ -66,6 +66,13 @@ private function navigationBlocks(DOMElement $element, callable $presentationAtt continue; } + if ( $child instanceof DOMElement && $this->isNavigationChromeElement($child) ) { + if ( null !== $isRuntimeDomTarget && $isRuntimeDomTarget($child) ) { + return array(); + } + continue; + } + if ( $child instanceof DOMElement && 'a' === strtolower($child->tagName) && '' !== $this->anchorLabel($child, $innerHtml) ) { if ( ! $allowsDirectItems ) { return array(); @@ -84,13 +91,6 @@ private function navigationBlocks(DOMElement $element, callable $presentationAtt } if ( $child instanceof DOMElement ) { - if ( $this->isMenuToggleControl($child) ) { - if ( null !== $isRuntimeDomTarget && $isRuntimeDomTarget($child) ) { - return array(); - } - continue; - } - if ( ! $allowsDirectItems ) { return array(); } @@ -100,6 +100,14 @@ private function navigationBlocks(DOMElement $element, callable $presentationAtt $blocks[] = $block; continue; } + + if ( $this->isNavigationWrapperElement($child) ) { + $wrappedBlocks = $this->navigationBlocks($child, $presentationAttributes, $innerHtml, $createBlock, $isRuntimeDomTarget); + if ( array() !== $wrappedBlocks ) { + $blocks = array_merge($blocks, $wrappedBlocks); + continue; + } + } } return array(); @@ -119,6 +127,10 @@ private function navigationBlocksFromList(DOMElement $list, callable $presentati continue; } + if ( $item instanceof DOMElement && $this->isNavigationChromeElement($item) ) { + continue; + } + if ( ! $item instanceof DOMElement || 'li' !== strtolower($item->tagName) ) { return array(); } @@ -149,6 +161,10 @@ private function navigationBlockFromItem(DOMElement $element, callable $presenta } if ( array() !== $submenuBlocks ) { + if ( 1 !== count($this->anchorsExcludingSubmenus($element, $anchor)) ) { + return null; + } + $submenuAttrs = array( 'label' => $this->anchorLabel($anchor, $innerHtml), 'url' => $this->safeNavigationUrl($anchor->hasAttribute('href') ? $anchor->getAttribute('href') : ''), @@ -267,6 +283,10 @@ private function submenuContainers(DOMElement $element, DOMElement $primaryAncho continue; } + if ( $this->isNavigationChromeElement($child) ) { + continue; + } + $tagName = strtolower($child->tagName); if ( in_array($tagName, array( 'ul', 'ol' ), true) || $this->hasSubmenuSignal($child) ) { $containers[] = $child; @@ -309,6 +329,57 @@ private function isMenuToggleControl(DOMElement $element): bool return false; } + private function isNavigationChromeElement(DOMElement $element): bool + { + $tagName = strtolower($element->tagName); + if ( $this->isMenuToggleControl($element) ) { + return true; + } + + if ( in_array(strtolower($this->attr($element, 'role')), array( 'separator', 'presentation', 'none' ), true) ) { + return true; + } + + if ( in_array($tagName, array( 'hr', 'svg' ), true) ) { + return true; + } + + if ( 'a' === $tagName && '' === trim($element->textContent ?? '') && '' === trim($this->attr($element, 'aria-label') . $this->attr($element, 'title')) ) { + return true; + } + + $tokens = strtolower($this->attr($element, 'class') . ' ' . $this->attr($element, 'id')); + return (bool) preg_match('/(?:^|[^a-z0-9])(?:separator|divider|toggle|hamburger|menu-button|menu-toggle)(?:[^a-z0-9]|$)/', $tokens); + } + + private function isNavigationWrapperElement(DOMElement $element): bool + { + if ( ! in_array(strtolower($element->tagName), array( 'div', 'span', 'section' ), true) ) { + return false; + } + + $hasNavigationChild = false; + foreach ( $element->childNodes as $child ) { + if ( ! $child instanceof DOMElement ) { + continue; + } + + $tagName = strtolower($child->tagName); + if ( in_array($tagName, array( 'a', 'ul', 'ol' ), true) || $this->hasNavigationSignal($child) || $this->isNavigationChromeElement($child) ) { + $hasNavigationChild = true; + continue; + } + + if ( ! $this->isNavigationWrapperElement($child) ) { + return false; + } + + $hasNavigationChild = true; + } + + return $hasNavigationChild; + } + private function isSectionLabelElement(DOMElement $element): bool { $tagName = strtolower($element->tagName); diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 4b62033..f62585d 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -488,6 +488,19 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(str_contains($footerNavigationSerialized, 'footer-link'), 'footer navigation preserves link classes for styling and script targets'); $assert(str_contains($footerNavigationSerialized, 'social-link'), 'social navigation preserves social link classes for styling and script targets'); +$complexHeaderNavigation = ( new HtmlTransformer() )->transform( + '' +)->toArray(); +$complexHeaderParity = $complexHeaderNavigation['source_reports']['semantic_parity'] ?? array(); +$complexHeaderBlockMenus = $complexHeaderParity['navigation_menus']['blocks'] ?? array(); +$complexHeaderSourceMenus = $complexHeaderParity['navigation_menus']['source'] ?? array(); +$assert('pass' === ($complexHeaderParity['status'] ?? ''), 'complex header navigation chrome preserves semantic parity'); +$assert(1 === count($complexHeaderSourceMenus), 'source semantic parity dedupes duplicated mobile drawer navigation'); +$assert(1 === count($complexHeaderBlockMenus), 'generated navigation dedupes duplicated mobile drawer navigation'); +$assert(5 === ($complexHeaderBlockMenus[0]['item_count'] ?? null), 'complex header navigation skips chrome and preserves real item count'); +$assert('Cart' === ($complexHeaderBlockMenus[0]['items'][4]['label'] ?? ''), 'icon-only header navigation links use accessible labels'); +$assert(! str_contains((string) ($complexHeaderNavigation['serialized_blocks'] ?? ''), 'drawer-nav'), 'complex header navigation removes duplicate mobile drawer core/navigation children'); + $runtimeTargetNavigation = ( new HtmlTransformer() )->transform( '', array('runtime_dom_selectors' => array('.nav-link')) diff --git a/php-transformer/tests/fixtures/parity/artifact-runtime-target-container-preservation.json b/php-transformer/tests/fixtures/parity/artifact-runtime-target-container-preservation.json index f0507f5..35a5145 100644 --- a/php-transformer/tests/fixtures/parity/artifact-runtime-target-container-preservation.json +++ b/php-transformer/tests/fixtures/parity/artifact-runtime-target-container-preservation.json @@ -30,7 +30,7 @@ } }, "expect": [ - { "path": "status", "assert": "equals", "value": "success_with_warnings" }, + { "path": "status", "assert": "equals", "value": "success" }, { "path": "source_reports.runtime_dependency_parity.status", "assert": "equals", "value": "pass" }, { "path": "source_reports.runtime_dependency_parity.dependencies", "assert": "count", "count": 17 }, { "path": "serialized_blocks", "assert": "contains", "value": "reveal" },