diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
index 751e6f5..9eefd65 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 ff9bf30..80441de 100644
--- a/php-transformer/tests/contract/run.php
+++ b/php-transformer/tests/contract/run.php
@@ -510,6 +510,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 0c0a157..57aff3b 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" },