Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 96 additions & 18 deletions php-transformer/src/HtmlToBlocks/HtmlTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -384,26 +384,39 @@ 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);
}

/**
* @param array<string, int> $counts
* @param array<string, array<int, string>> $selectors
* @param array<string, bool> $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);
}
}
}
Expand Down Expand Up @@ -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<int, array<string, mixed>> $menus
* @param array<string, bool> $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(
Expand All @@ -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<int, array<string, string>>
*/
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<int, array<string, string>> $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);
}

/**
Expand Down Expand Up @@ -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 ) {
Expand Down
85 changes: 78 additions & 7 deletions php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
}
Expand All @@ -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();
Expand All @@ -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();
}
Expand Down Expand Up @@ -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') : ''),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<header class="site-header"><div class="header-inner"><button class="menu-toggle" aria-expanded="false" aria-controls="menu">Menu</button><nav class="primary-nav" aria-label="Primary"><div id="menu" class="nav-list"><a href="/">Home</a><a class="nav-divider" role="separator" href="#">/</a><span class="separator">|</span><button class="dropdown-toggle" aria-expanded="false">More</button><a href="/shop"><span>Shop</span><svg aria-hidden="true"><path d="M0 0h1v1z"></path></svg></a><ul><li><a href="/services">Services</a><ul><li><a href="/consulting">Consulting</a></li></ul></li></ul><a class="icon-button" href="/cart" aria-label="Cart"><svg aria-hidden="true"><path d="M0 0h1v1z"></path></svg></a></div></nav><div class="mobile-nav overlay"><div class="drawer-panel"><nav class="drawer-nav" aria-label="Mobile"><a href="/">Home</a><a href="/shop">Shop</a><ul><li><a href="/services">Services</a><ul><li><a href="/consulting">Consulting</a></li></ul></li></ul><a class="icon-button" href="/cart" aria-label="Cart"><svg aria-hidden="true"><path d="M0 0h1v1z"></path></svg></a></nav></div></div></div></header>'
)->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(
'<nav aria-label="Docs"><ul><li><a class="nav-link" href="/guide">Guide</a></li></ul></nav>',
array('runtime_dom_selectors' => array('.nav-link'))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Loading