From e18c7aaeff656d9647d215bc0d3252f53655ded5 Mon Sep 17 00:00:00 2001 From: x Date: Fri, 9 Oct 2026 12:15:29 -0400 Subject: [PATCH 1/3] fix(php-transformer): read a menu row with dropdown buttons as navigation beside non-menu controls Co-Authored-By: Claude Sonnet 5.5 --- php-transformer/composer.json | 1 + .../Patterns/NavigationPattern.php | 83 ++++++++++++++++++- .../navigation-menu-row-beside-controls.php | 49 +++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 php-transformer/tests/unit/navigation-menu-row-beside-controls.php diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 1c93d8889..8624bc2b5 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -343,6 +343,7 @@ "php tests/unit/navigation-nested-authored-anchor.php", "php tests/unit/navigation-anchor-cluster-submenu.php", "php tests/unit/navigation-button-dropdown-submenu.php", + "php tests/unit/navigation-menu-row-beside-controls.php", "php tests/unit/paragraph-label-desktop-menu.php", "php tests/unit/button-signal-classifier.php", "php tests/unit/pill-button-anchor.php", diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php index 8bfb1f9d8..b3d9075c1 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php @@ -228,7 +228,7 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter $links = $this->navigationBlocks($element, $presentationAttributes, $innerHtml, $createBlock, $navigationContext, false, false, $directAnchors, $listItems); if ( array() === $links ) { - return null; + return $this->menuRowBesideControls($element, $context, $presentationAttributes, $innerHtml, $createBlock, $navigationContext); } $label = $this->directSectionLabel($element); @@ -836,6 +836,87 @@ private function brandAnchorCarrier(DOMElement $element, array &$fallbacks, call return $createBlock('core/group', $carrierAttrs, $children, $element); } + /** + * A menu row that also holds controls which are not menu items, such as a + * language switcher made of buttons. A dropdown button item proves the row + * is a menu, so each run of menu items becomes one core/navigation and the + * other children are converted on their own, in authored order, inside the + * row's group. A row whose other children cannot convert natively is left + * alone. + */ + private function menuRowBesideControls(DOMElement $row, PatternContext $context, callable $presentationAttributes, callable $innerHtml, callable $createBlock, ?NavigationPatternContext $navigationContext): ?PatternRecognitionResult + { + $converter = $context->recursiveConverter(); + if ( null === $converter || ! $this->hasButtonDropdownChild($row) ) { + return null; + } + + $buttonSignals = new ButtonSignalClassifier(); + $slots = array(); + foreach ( $row->childNodes as $child ) { + if ( XML_COMMENT_NODE === $child->nodeType || ( XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent ?? '') ) ) { + continue; + } + if ( ! $child instanceof DOMElement ) { + return null; + } + $isItem = null !== $this->buttonDropdownItem($child) + || ( 'a' === strtolower($child->tagName) + && '' !== $this->anchorLabel($child, $innerHtml) + && ! $buttonSignals->hasTransformSignal($child, $navigationContext?->resolvedStyle($child) ?? '') ); + $last = count($slots) - 1; + if ( $isItem && 0 <= $last && 'items' === $slots[$last]['kind'] ) { + $slots[$last]['elements'][] = $child; + } else { + $slots[] = array( 'kind' => $isItem ? 'items' : 'control', 'elements' => array( $child ) ); + } + } + + $fallbacks = array(); + $children = array(); + $collected = array(); + $linkSets = array(); + foreach ( $slots as $slot ) { + if ( 'control' === $slot['kind'] ) { + $block = $converter->element($slot['elements'][0], $fallbacks, true); + if ( ! is_array($block) || in_array((string) ( $block['blockName'] ?? '' ), array( '', 'core/html' ), true) ) { + return null; + } + $children[] = $block; + continue; + } + + $links = array(); + foreach ( $slot['elements'] as $item ) { + $link = 'a' === strtolower($item->tagName) + ? $this->navigationLinkBlock($item, $presentationAttributes, $innerHtml, $createBlock, $item, $navigationContext) + : $this->navigationBlockFromItem($item, $presentationAttributes, $innerHtml, $createBlock, $navigationContext, $collected); + if ( null === $link ) { + return null; + } + $links[] = $link; + } + $attrs = array( 'overlayMenu' => 'never' ); + $gap = trim((string) ( $this->resolvedNavigationSpacing($navigationContext?->resolvedStyle($row) ?? '')['blockGap'] ?? '' )); + if ( '' !== $gap ) { + $attrs['style']['spacing']['blockGap'] = $gap; + } + $attrs = array_replace_recursive($attrs, $this->commonNavigationLinkTextAttributes($links)); + $linkSets[] = $links; + $children[] = $createBlock('core/navigation', $attrs, $links, $slot['elements'][0]); + } + if ( array() === $linkSets ) { + return null; + } + + $this->recordNavigationSources(array(), array(), $collected, $row, $navigationContext); + + return new PatternRecognitionResult( + $createBlock('core/group', array_merge($presentationAttributes($row), array( 'tagName' => 'div' )), $children, $row), + $fallbacks + ); + } + /** * Per-link colour support is retained in block attributes for diagnostics, * but core/navigation-link does not render it. Add a deterministic class to diff --git a/php-transformer/tests/unit/navigation-menu-row-beside-controls.php b/php-transformer/tests/unit/navigation-menu-row-beside-controls.php new file mode 100644 index 000000000..d04012cec --- /dev/null +++ b/php-transformer/tests/unit/navigation-menu-row-beside-controls.php @@ -0,0 +1,49 @@ + + '
' + . '
'; +$switcher = '
|
'; +$cta = 'Book now'; +$transform = static fn (string $html): string => (string) ( ( new HtmlTransformer() )->transform($html)->toArray()['serialized_blocks'] ?? '' ); + +$html = '' + . ''; +$markup = $transform($html); + +$assert(1 === substr_count($markup, '.*>EN<.*Book now/s', $markup), 'the switcher and the call to action follow the menu in authored order', $markup); +$assert(! str_contains($markup, '"label":"Book now"') && ! str_contains($markup, '"label":"EN"'), 'controls are not turned into menu items', $markup); + +// Without a dropdown button the row is not vouched as a menu: nothing claims it. +$plain = $transform('
One' . $switcher . '
'); +$assert(! str_contains($plain, 'wp:navigation '), 'a link beside a switcher with no dropdown is not claimed as a menu', $plain); + +if ( 0 < $failures ) { + fwrite(STDERR, "Menu row beside controls contract: {$failures} failed, {$passes} passed\n"); + exit(1); +} +echo "Menu row beside controls contract passed: {$passes} assertions\n"; From 4f35cff957ab6532ba6e62dddaa6f200c1944e82 Mon Sep 17 00:00:00 2001 From: x Date: Fri, 9 Oct 2026 14:53:09 -0400 Subject: [PATCH 2/3] fix(php-transformer): fold a captured mobile menu dialog into the navigation it repeats A header can draw its menu twice: a row of dropdown items for wide screens and a hamburger that opens a panel with the same items for narrow screens. When the capture records that panel as a dialog, the importer kept a plain button for the hamburger and a custom Dialog block for the panel, beside a core/navigation that had no hamburger of its own. When every entry in the dialog is an item the row already has, the row's navigation now takes it over: overlayMenu is mobile, the hamburger and the dialog are dropped, a hamburger wrapper that only repeats row controls is dropped, and dialog entries the row shows outside its items go into the overlay. The row is shown again below the source breakpoint with the repeated controls hidden. Co-Authored-By: Claude Sonnet 5.5 --- php-transformer/composer.json | 1 + .../Patterns/NavigationPattern.php | 155 +++++- .../Patterns/NavigationPatternContext.php | 22 + .../Session/NavigationProjectionState.php | 40 ++ .../Support/CapturedMenuDialogFold.php | 460 ++++++++++++++++++ .../Support/NavigationToggleSuppressor.php | 28 ++ .../unit/navigation-menu-dialog-fold.php | 111 +++++ 7 files changed, 815 insertions(+), 2 deletions(-) create mode 100644 php-transformer/src/HtmlToBlocks/Support/CapturedMenuDialogFold.php create mode 100644 php-transformer/tests/unit/navigation-menu-dialog-fold.php diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 8624bc2b5..32f2467b0 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -344,6 +344,7 @@ "php tests/unit/navigation-anchor-cluster-submenu.php", "php tests/unit/navigation-button-dropdown-submenu.php", "php tests/unit/navigation-menu-row-beside-controls.php", + "php tests/unit/navigation-menu-dialog-fold.php", "php tests/unit/paragraph-label-desktop-menu.php", "php tests/unit/button-signal-classifier.php", "php tests/unit/pill-button-anchor.php", diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php index b3d9075c1..e10caf34d 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php @@ -9,6 +9,7 @@ use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\AuthoredButtonBlockGenerator; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\AuthorSelectorProjectionState; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\StyleAttributeMapper; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\CapturedMenuDialogFold; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\LinkUrlSanitizer; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SourceDom; use DOMElement; @@ -55,6 +56,15 @@ final class NavigationPattern implements PatternRecognizerInterface */ private const LIST_NAVIGATION_CLASS = 'blocks-engine-list-navigation'; + /** The navigation that took over an absorbed menu dialog. */ + private const MENU_FOLD_NAVIGATION_CLASS = 'blocks-engine-menu-fold-navigation'; + + /** A row control the absorbed menu dialog repeats; hidden where the hamburger takes over. */ + private const MENU_FOLD_HIDE_CLASS = 'blocks-engine-menu-fold-hide'; + + /** A dialog entry carried into the overlay; hidden where the row shows in full. */ + private const MENU_FOLD_OVERLAY_ONLY_CLASS = 'blocks-engine-menu-fold-overlay-only'; + public function claimsBeforeAuthorOwnedLayout(DOMElement $element): bool { $tag = strtolower($element->tagName); @@ -851,6 +861,20 @@ private function menuRowBesideControls(DOMElement $row, PatternContext $context, return null; } + // A captured mobile menu that repeats this row belongs to the row's + // own navigation. It stays only if the row can take it over whole. + $fold = $navigationContext?->capturedMenuFold($row); + $result = $this->menuRowSlots($row, $converter, $presentationAttributes, $innerHtml, $createBlock, $navigationContext, $fold); + if ( null === $result && $fold instanceof CapturedMenuDialogFold ) { + $navigationContext?->releaseCapturedMenuFold($fold); + return $this->menuRowSlots($row, $converter, $presentationAttributes, $innerHtml, $createBlock, $navigationContext, null); + } + + return $result; + } + + private function menuRowSlots(DOMElement $row, PatternTreeConverter $converter, callable $presentationAttributes, callable $innerHtml, callable $createBlock, ?NavigationPatternContext $navigationContext, ?CapturedMenuDialogFold $fold): ?PatternRecognitionResult + { $buttonSignals = new ButtonSignalClassifier(); $slots = array(); foreach ( $row->childNodes as $child ) { @@ -872,21 +896,37 @@ private function menuRowBesideControls(DOMElement $row, PatternContext $context, } } + // One run of items can take the overlay; a row split into several runs + // keeps its dialog the way it was captured. + $itemRuns = count(array_filter($slots, static fn (array $slot): bool => 'items' === $slot['kind'])); + if ( $fold instanceof CapturedMenuDialogFold && 1 !== $itemRuns ) { + return null; + } + $fallbacks = array(); $children = array(); $collected = array(); $linkSets = array(); + $foldMarker = ''; foreach ( $slots as $slot ) { if ( 'control' === $slot['kind'] ) { $block = $converter->element($slot['elements'][0], $fallbacks, true); if ( ! is_array($block) || in_array((string) ( $block['blockName'] ?? '' ), array( '', 'core/html' ), true) ) { return null; } + // The dialog lists this control too, and carries its own copy + // into the overlay, so the row's copy is hidden below the + // breakpoint where the hamburger takes over. + if ( $fold instanceof CapturedMenuDialogFold && $fold->listsControl($slot['elements'][0]) ) { + $block = $this->withBlockClassName($block, self::MENU_FOLD_HIDE_CLASS, $createBlock, $slot['elements'][0]); + } $children[] = $block; continue; } $links = array(); + $itemLinks = array(); + $overlayExtras = array(); foreach ( $slot['elements'] as $item ) { $link = 'a' === strtolower($item->tagName) ? $this->navigationLinkBlock($item, $presentationAttributes, $innerHtml, $createBlock, $item, $navigationContext) @@ -895,15 +935,27 @@ private function menuRowBesideControls(DOMElement $row, PatternContext $context, return null; } $links[] = $link; + if ( 'a' === strtolower($item->tagName) ) { + $itemLinks[CapturedMenuDialogFold::signature('link', CapturedMenuDialogFold::label($item), trim($item->getAttribute('href')))] = true; + } } $attrs = array( 'overlayMenu' => 'never' ); $gap = trim((string) ( $this->resolvedNavigationSpacing($navigationContext?->resolvedStyle($row) ?? '')['blockGap'] ?? '' )); if ( '' !== $gap ) { $attrs['style']['spacing']['blockGap'] = $gap; } + if ( $fold instanceof CapturedMenuDialogFold ) { + $overlay = $this->foldedOverlayAttributes($row, $fold, $attrs, $itemLinks, $converter, $createBlock, $navigationContext, $fallbacks); + if ( null === $overlay ) { + return null; + } + [ $attrs, $overlayExtras, $foldMarker ] = $overlay; + } + // Shared link text settings are read from the menu's own items; the + // overlay-only extras are not items and must not dilute them. $attrs = array_replace_recursive($attrs, $this->commonNavigationLinkTextAttributes($links)); $linkSets[] = $links; - $children[] = $createBlock('core/navigation', $attrs, $links, $slot['elements'][0]); + $children[] = $createBlock('core/navigation', $attrs, array_merge($links, $overlayExtras), $slot['elements'][0]); } if ( array() === $linkSets ) { return null; @@ -911,12 +963,111 @@ private function menuRowBesideControls(DOMElement $row, PatternContext $context, $this->recordNavigationSources(array(), array(), $collected, $row, $navigationContext); + $groupAttrs = array_merge($presentationAttributes($row), array( 'tagName' => 'div' )); + if ( '' !== $foldMarker ) { + $groupAttrs = $this->withClassName($groupAttrs, $foldMarker); + } + return new PatternRecognitionResult( - $createBlock('core/group', array_merge($presentationAttributes($row), array( 'tagName' => 'div' )), $children, $row), + $createBlock('core/group', $groupAttrs, $children, $row), $fallbacks ); } + /** + * Turn the row's single navigation into the responsive one that replaces + * the hamburger and the dialog. + * + * The navigation takes core's overlay and the hamburger's look. Dialog + * entries the row shows outside its items (a call-to-action link, say) go + * into the overlay as well, hidden once the row is back to its wide layout. + * Below the breakpoint the row is shown again, with the controls the dialog + * lists hidden, so the page keeps one hamburger and one copy of each + * control. Returns null when an overlay entry cannot be converted. + * + * @param array $attrs + * @param array $itemLinks + * @param array> $fallbacks + * @return array{0: array, 1: array>, 2: string}|null The attributes, the overlay-only blocks, and the row marker. + */ + private function foldedOverlayAttributes(DOMElement $row, CapturedMenuDialogFold $fold, array $attrs, array $itemLinks, PatternTreeConverter $converter, callable $createBlock, ?NavigationPatternContext $navigationContext, array &$fallbacks): ?array + { + if ( null === $navigationContext ) { + return null; + } + + $extras = array(); + foreach ( $fold->dialog->getElementsByTagName('a') as $anchor ) { + if ( ! $anchor instanceof DOMElement + || isset($itemLinks[CapturedMenuDialogFold::signature('link', CapturedMenuDialogFold::label($anchor), trim($anchor->getAttribute('href')))]) + ) { + continue; + } + $block = $converter->element($anchor, $fallbacks, true); + if ( ! is_array($block) || in_array((string) ( $block['blockName'] ?? '' ), array( '', 'core/html' ), true) ) { + return null; + } + $extras[] = $this->withBlockClassName($block, self::MENU_FOLD_OVERLAY_ONLY_CLASS, $createBlock, $anchor); + } + + $attrs['overlayMenu'] = 'mobile'; + // The source's dropdown buttons and its mobile accordions both open on + // click; Core keeps overlay submenus collapsed until then only in click mode. + $attrs['openSubmenusOnClick'] = true; + $attrs = $this->withClassName($attrs, 'blocks-engine-native-responsive-navigation ' . self::MENU_FOLD_NAVIGATION_CLASS); + $attrs = $this->withResponsiveToggleMarker($attrs, $row, $navigationContext); + // The open overlay is the dialog's panel, so its paint comes from the dialog. + $attrs = $this->withResponsiveOverlayMarker($attrs, $fold->dialog, $navigationContext); + + $boundary = $navigationContext->menuCollapseBreakpoint($row, $fold->triggers[0]); + $below = null === $boundary || $boundary <= 600 ? 599 : (int) floor($boundary); + $display = 'flex'; + if ( 1 === preg_match('/(?:^|;)\s*display\s*:\s*(flex|inline-flex|grid|block)\b/i', $navigationContext->resolvedStyle($row), $match) ) { + $display = strtolower($match[1]); + } + $hostRule = '.wp-block-group.%s{display:' . $display . '!important}' + . '.wp-block-group.%s>.' . self::MENU_FOLD_HIDE_CLASS . '{display:none!important}'; + $groupGap = $fold->group instanceof DOMElement + ? trim((string) ( $this->resolvedNavigationSpacing($navigationContext->resolvedStyle($fold->group))['blockGap'] ?? '' )) + : ''; + if ( '' !== $groupGap ) { + $hostRule .= '.wp-block-group.%s{gap:' . $groupGap . '!important}'; + } + if ( $fold->hamburgerLast ) { + $hostRule .= '.wp-block-group.%s>.wp-block-navigation{order:99}'; + } + $marker = 'blocks-engine-menu-fold-' . substr(hash('sha256', $below . $display . $groupGap . ( $fold->hamburgerLast ? 'last' : '' )), 0, 12); + $host = '.wp-block-navigation.' . self::MENU_FOLD_NAVIGATION_CLASS; + $rule = '@media(max-width:' . $below . 'px){' . str_replace('%s', $marker, $hostRule) . '}' + . '@media(min-width:' . ( $below + 1 ) . 'px){.wp-block-group.' . $marker . ' .' . self::MENU_FOLD_OVERLAY_ONLY_CLASS . '{display:none!important}}' + // The engine's list-navigation repairs lay items out as list items. + // These items are the row's own dropdown entries, which Core already + // lays out as flex items, so keep that. + . $host . ' .wp-block-navigation-item{display:flex}' + . $host . ' .wp-block-navigation-item__content{display:block}' + // A header that blurs what is behind it becomes the containing block + // for fixed descendants. While the overlay is open the header gives + // that up so the overlay covers the screen the way Core draws it. + . ':is(nav,header,div,section):has(' . $host . ' .wp-block-navigation__responsive-container.is-menu-open){backdrop-filter:none!important;-webkit-backdrop-filter:none!important;filter:none!important}'; + $navigationContext->registerMenuFoldRule($marker, $rule); + + return array( $attrs, $extras, $marker ); + } + + /** + * Add a class to a block that is already built. The block is created again + * from its own attributes, so the saved markup carries the class too. + * + * @param array $block + * @return array + */ + private function withBlockClassName(array $block, string $className, callable $createBlock, DOMElement $source): array + { + $attrs = $this->withClassName(is_array($block['attrs'] ?? null) ? $block['attrs'] : array(), $className); + + return $createBlock((string) $block['blockName'], $attrs, is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : array(), $source); + } + /** * Per-link colour support is retained in block attributes for diagnostics, * but core/navigation-link does not render it. Add a deterministic class to diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPatternContext.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPatternContext.php index f1bfff4f3..6d70234b1 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPatternContext.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPatternContext.php @@ -11,6 +11,7 @@ use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueInspector; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\NavigationStyleProjector; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\StyleResolver; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\CapturedMenuDialogFold; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\NavigationToggleSuppressor; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SourceDom; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SvgMaterializer; @@ -499,6 +500,27 @@ public function overlayMenu(DOMElement $element): string return $this->navigationToggleSuppressor?->navigationOverlayMenu($element) ?? 'never'; } + public function capturedMenuFold(DOMElement $row): ?CapturedMenuDialogFold + { + return $this->navigationToggleSuppressor?->capturedMenuFoldForRow($row); + } + + public function releaseCapturedMenuFold(CapturedMenuDialogFold $fold): void + { + $this->navigationToggleSuppressor?->releaseCapturedMenuFold($fold); + } + + /** The width (px) at and below which the source shows the hamburger instead of the row. */ + public function menuCollapseBreakpoint(DOMElement $row, DOMElement $trigger): ?float + { + return $this->navigationToggleSuppressor?->sourceCollapseBreakpoint($row, $trigger); + } + + public function registerMenuFoldRule(string $marker, string $rule): void + { + $this->session?->generatedSupportStylesheetState()->registerNativeNavigationOverlay($marker, $rule); + } + public function responsiveToggleMarker(DOMElement $element): string { return $this->projectedNavigation?->responsiveNavigationToggleMarker($element) ?? ''; diff --git a/php-transformer/src/HtmlToBlocks/Session/NavigationProjectionState.php b/php-transformer/src/HtmlToBlocks/Session/NavigationProjectionState.php index 9994d76d5..c09172e28 100644 --- a/php-transformer/src/HtmlToBlocks/Session/NavigationProjectionState.php +++ b/php-transformer/src/HtmlToBlocks/Session/NavigationProjectionState.php @@ -3,6 +3,7 @@ namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Session; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\CapturedMenuDialogFold; use DOMElement; /** @@ -28,6 +29,12 @@ final class NavigationProjectionState /** @var array */ private array $implicitDialogControlPaths = array(); + /** @var array menu row path => the captured menu dialog it absorbs */ + private array $foldsByRowPath = array(); + + /** @var array hamburger path => the fold it opens */ + private array $foldsByTriggerPath = array(); + public function hasProjection(): bool { return array() !== $this->targetsByControlPath; @@ -67,4 +74,37 @@ public function isImplicitDialogControl(DOMElement $control): bool { return isset($this->implicitDialogControlPaths[$control->getNodePath()]); } + + public function foldMenuDialog(CapturedMenuDialogFold $fold): void + { + $this->foldsByRowPath[$fold->row->getNodePath()] = $fold; + foreach ( $fold->triggers as $trigger ) { + $this->foldsByTriggerPath[$trigger->getNodePath()] = $fold; + } + foreach ( $fold->suppressed as $element ) { + $this->suppress($element); + } + } + + public function foldForRow(DOMElement $row): ?CapturedMenuDialogFold + { + return $this->foldsByRowPath[$row->getNodePath()] ?? null; + } + + public function foldForTrigger(DOMElement $trigger): ?CapturedMenuDialogFold + { + return $this->foldsByTriggerPath[$trigger->getNodePath()] ?? null; + } + + /** Give the dialog and hamburger back to the output when the row could not absorb them. */ + public function releaseFold(CapturedMenuDialogFold $fold): void + { + unset($this->foldsByRowPath[$fold->row->getNodePath()]); + foreach ( $fold->triggers as $trigger ) { + unset($this->foldsByTriggerPath[$trigger->getNodePath()]); + } + foreach ( $fold->suppressed as $element ) { + unset($this->suppressedPaths[$element->getNodePath()]); + } + } } diff --git a/php-transformer/src/HtmlToBlocks/Support/CapturedMenuDialogFold.php b/php-transformer/src/HtmlToBlocks/Support/CapturedMenuDialogFold.php new file mode 100644 index 000000000..a722cb375 --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Support/CapturedMenuDialogFold.php @@ -0,0 +1,460 @@ + $triggers The controls that open the dialog. + * @param DOMElement $dialog The captured dialog. + * @param list $suppressed Source elements the folded output no longer needs. + * @param array $entrySignatures What the dialog lists, keyed `link:label|href` or `button:label`. + * @param list $linkEntries The dialog's plain link entries, in order. + * @param DOMElement|null $group The hamburger's wrapper, when it only repeated controls the row already has. + * @param bool $hamburgerLast Whether the hamburger came after those controls in that wrapper. + */ + private function __construct( + public readonly DOMElement $row, + public readonly array $triggers, + public readonly DOMElement $dialog, + public readonly array $suppressed, + public readonly array $entrySignatures, + public readonly array $linkEntries, + public readonly ?DOMElement $group, + public readonly bool $hamburgerLast + ) { + } + + /** @return list */ + public static function detect(DOMElement $root): array + { + $document = $root->ownerDocument; + if ( ! $document instanceof DOMDocument ) { + return array(); + } + + $folds = array(); + foreach ( iterator_to_array($root->getElementsByTagName('dialog')) as $dialog ) { + if ( ! $dialog instanceof DOMElement || 'true' !== $dialog->getAttribute('data-blocks-engine-captured-menu') ) { + continue; + } + $triggers = array(); + foreach ( preg_split('/\s+/', trim($dialog->getAttribute('data-blocks-engine-triggers'))) ?: array() as $id ) { + $trigger = '' === $id ? null : $document->getElementById($id); + if ( $trigger instanceof DOMElement ) { + $triggers[] = $trigger; + } + } + $entries = self::entries($dialog, 0); + if ( array() === $triggers || null === $entries || array() === $entries ) { + continue; + } + + $row = self::equivalentRow($root, $dialog, $triggers, $entries); + if ( ! $row instanceof DOMElement ) { + continue; + } + + $signatures = array(); + $links = array(); + foreach ( $entries as $entry ) { + $signatures[self::signature($entry['kind'], $entry['label'], $entry['href'])] = true; + if ( 'link' === $entry['kind'] ) { + $links[] = array( 'label' => $entry['label'], 'href' => $entry['href'] ); + } + } + $suppressed = array( $dialog ); + $group = self::redundantTriggerGroup($row, $triggers, $dialog); + if ( $group instanceof DOMElement ) { + $suppressed[] = $group; + } else { + array_push($suppressed, ...$triggers); + } + $folds[] = new self($row, $triggers, $dialog, $suppressed, $signatures, $links, $group, $group instanceof DOMElement && self::followsControls($group, $triggers[0])); + } + + return $folds; + } + + /** Whether the dialog lists this control: a link by label and href, or every link and button inside it. */ + public function listsControl(DOMElement $control): bool + { + $tag = strtolower($control->tagName); + if ( 'a' === $tag ) { + return isset($this->entrySignatures[self::signature('link', self::label($control), trim($control->getAttribute('href')))]); + } + if ( 'button' === $tag ) { + return isset($this->entrySignatures[self::signature('button', self::label($control), '')]); + } + + $found = 0; + foreach ( $control->getElementsByTagName('*') as $descendant ) { + if ( ! $descendant instanceof DOMElement || ! in_array(strtolower($descendant->tagName), array( 'a', 'button' ), true) ) { + continue; + } + if ( ! $this->listsControl($descendant) ) { + return false; + } + ++$found; + } + + return 0 < $found; + } + + public static function signature(string $kind, string $label, string $href): string + { + return $kind . ':' . $label . ( 'link' === $kind ? '|' . $href : '' ); + } + + public static function label(DOMElement $element): string + { + $text = ''; + foreach ( $element->childNodes as $child ) { + $text .= self::textWithoutArtwork($child); + } + + return strtolower(trim(preg_replace('/\s+/', ' ', $text) ?? '')); + } + + private static function textWithoutArtwork(DOMNode $node): string + { + if ( $node instanceof DOMElement ) { + if ( in_array(strtolower($node->tagName), array( 'svg', 'script', 'style' ), true) ) { + return ''; + } + $text = ''; + foreach ( $node->childNodes as $child ) { + $text .= self::textWithoutArtwork($child); + } + + return $text; + } + + return XML_TEXT_NODE === $node->nodeType ? (string) $node->textContent : ''; + } + + /** + * What a dialog lists, as plain links and accordion buttons. A dialog with + * anything else in it is not a plain menu copy. + * + * @return list}>|null + */ + private static function entries(DOMElement $container, int $depth): ?array + { + if ( 4 < $depth ) { + return null; + } + $entries = array(); + foreach ( $container->childNodes as $child ) { + if ( XML_COMMENT_NODE === $child->nodeType || ( XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent ?? '') ) ) { + continue; + } + if ( ! $child instanceof DOMElement ) { + return null; + } + $tag = strtolower($child->tagName); + if ( 'a' === $tag ) { + $label = self::label($child); + $href = trim($child->getAttribute('href')); + if ( '' === $label || '' === $href ) { + return null; + } + $entries[] = array( 'kind' => 'link', 'label' => $label, 'href' => $href, 'children' => array() ); + continue; + } + if ( 'button' === $tag ) { + $label = self::label($child); + if ( '' === $label ) { + return null; + } + $entries[] = array( 'kind' => 'button', 'label' => $label, 'href' => '', 'children' => array() ); + continue; + } + if ( ! in_array($tag, array( 'div', 'span', 'li', 'ul', 'ol', 'nav' ), true) ) { + return null; + } + + $button = null; + foreach ( $child->childNodes as $grandchild ) { + if ( $grandchild instanceof DOMElement && 'button' === strtolower($grandchild->tagName) ) { + $button = $grandchild; + break; + } + } + if ( ! $button instanceof DOMElement ) { + $nested = self::entries($child, $depth + 1); + if ( null === $nested ) { + return null; + } + array_push($entries, ...$nested); + continue; + } + + $label = self::label($button); + if ( '' === $label ) { + return null; + } + $children = array(); + foreach ( $child->getElementsByTagName('a') as $anchor ) { + if ( $anchor instanceof DOMElement ) { + $children[] = array( 'label' => self::label($anchor), 'href' => trim($anchor->getAttribute('href')) ); + } + } + $entries[] = array( 'kind' => 'button', 'label' => $label, 'href' => '', 'children' => $children ); + } + + return $entries; + } + + /** + * The one row that already has every entry. Entries that are accordion + * buttons must match a dropdown item by label (and, when the capture opened + * the accordion, by its links); links must match a link in the row by label + * and href. + * + * @param list $triggers + * @param list}> $entries + */ + private static function equivalentRow(DOMElement $root, DOMElement $dialog, array $triggers, array $entries): ?DOMElement + { + $matches = array(); + foreach ( $root->getElementsByTagName('*') as $candidate ) { + if ( ! $candidate instanceof DOMElement || SourceDom::elementContains($dialog, $candidate) || $candidate->isSameNode($dialog) ) { + continue; + } + foreach ( $triggers as $trigger ) { + if ( $candidate->isSameNode($trigger) || SourceDom::elementContains($candidate, $trigger) ) { + continue 2; + } + } + $dropdowns = self::dropdownItems($candidate); + if ( array() === $dropdowns ) { + continue; + } + if ( self::rowHasEntries($candidate, $dropdowns, $entries) ) { + $matches[] = $candidate; + } + } + + // Rows nest: a wrapper around the row also matches. The innermost one is the row. + foreach ( $matches as $index => $match ) { + foreach ( $matches as $other ) { + if ( ! $match->isSameNode($other) && SourceDom::elementContains($match, $other) ) { + unset($matches[$index]); + break; + } + } + } + + return 1 === count($matches) ? array_values($matches)[0] : null; + } + + /** @return array> dropdown label => its links */ + private static function dropdownItems(DOMElement $row): array + { + $items = array(); + foreach ( $row->childNodes as $child ) { + if ( ! $child instanceof DOMElement ) { + continue; + } + $parts = NavigationPattern::buttonDropdownItemParts($child); + if ( null === $parts ) { + continue; + } + $links = array(); + foreach ( $parts['cluster']->getElementsByTagName('a') as $anchor ) { + if ( $anchor instanceof DOMElement ) { + $links[] = array( 'label' => self::label($anchor), 'href' => trim($anchor->getAttribute('href')) ); + } + } + $items[strtolower($parts['label'])] = $links; + } + + return $items; + } + + /** + * @param array> $dropdowns + * @param list}> $entries + */ + private static function rowHasEntries(DOMElement $row, array $dropdowns, array $entries): bool + { + $anchors = array(); + foreach ( $row->getElementsByTagName('a') as $anchor ) { + if ( $anchor instanceof DOMElement ) { + $anchors[self::signature('link', self::label($anchor), trim($anchor->getAttribute('href')))] = true; + } + } + $buttons = array(); + foreach ( $row->getElementsByTagName('button') as $button ) { + if ( $button instanceof DOMElement ) { + $buttons[self::label($button)] = true; + } + } + + $matchedDropdown = false; + foreach ( $entries as $entry ) { + if ( 'link' === $entry['kind'] ) { + if ( ! isset($anchors[self::signature('link', $entry['label'], $entry['href'])]) ) { + return false; + } + continue; + } + if ( isset($dropdowns[$entry['label']]) ) { + $panelLinks = array_map(static fn (array $link): string => $link['label'] . '|' . $link['href'], $dropdowns[$entry['label']]); + foreach ( $entry['children'] as $child ) { + if ( ! in_array($child['label'] . '|' . $child['href'], $panelLinks, true) ) { + return false; + } + } + $matchedDropdown = true; + continue; + } + if ( ! isset($buttons[$entry['label']]) ) { + return false; + } + } + + return $matchedDropdown; + } + + /** + * The hamburger's own wrapper, when everything else in it is something the + * row already shows (a second copy of a language switcher, say). Dropping + * the wrapper then leaves one copy of each control. Returns null when the + * wrapper holds anything the row does not, so only the hamburger goes. + * + * @param list $triggers + */ + private static function redundantTriggerGroup(DOMElement $row, array $triggers, DOMElement $dialog): ?DOMElement + { + $trigger = $triggers[0]; + $group = $trigger; + while ( $group->parentNode instanceof DOMElement && ! SourceDom::elementContains($group->parentNode, $row) ) { + $group = $group->parentNode; + } + if ( $group->isSameNode($trigger) || SourceDom::elementContains($group, $dialog) || SourceDom::elementContains($group, $row) ) { + return null; + } + foreach ( $triggers as $other ) { + if ( ! SourceDom::elementContains($group, $other) ) { + return null; + } + } + + $rowLinks = array(); + foreach ( $row->getElementsByTagName('a') as $anchor ) { + if ( $anchor instanceof DOMElement ) { + $rowLinks[self::signature('link', self::label($anchor), trim($anchor->getAttribute('href')))] = true; + } + } + $rowButtons = array(); + foreach ( $row->getElementsByTagName('button') as $button ) { + if ( $button instanceof DOMElement ) { + $rowButtons[self::label($button)] = true; + } + } + + foreach ( $group->getElementsByTagName('*') as $descendant ) { + if ( ! $descendant instanceof DOMElement ) { + continue; + } + foreach ( $triggers as $other ) { + if ( $descendant->isSameNode($other) || SourceDom::elementContains($other, $descendant) ) { + continue 2; + } + } + $tag = strtolower($descendant->tagName); + if ( 'a' === $tag ) { + if ( ! isset($rowLinks[self::signature('link', self::label($descendant), trim($descendant->getAttribute('href')))]) ) { + return null; + } + } elseif ( 'button' === $tag ) { + if ( ! isset($rowButtons[self::label($descendant)]) ) { + return null; + } + } elseif ( in_array($tag, array( 'img', 'svg', 'picture', 'video', 'input', 'select', 'textarea', 'form', 'iframe', 'canvas' ), true) ) { + if ( ! self::insideMatchedControl($descendant, $group) ) { + return null; + } + } + } + + // Text outside every link and button must be bare punctuation, such as + // the divider between two switcher buttons. + return self::onlyPunctuationOutsideControls($group, $triggers) ? $group : null; + } + + /** Whether a link or button of the wrapper comes before the hamburger in document order. */ + private static function followsControls(DOMElement $group, DOMElement $trigger): bool + { + foreach ( $group->getElementsByTagName('*') as $descendant ) { + if ( ! $descendant instanceof DOMElement ) { + continue; + } + if ( $descendant->isSameNode($trigger) ) { + return false; + } + if ( in_array(strtolower($descendant->tagName), array( 'a', 'button' ), true) ) { + return true; + } + } + + return false; + } + + private static function insideMatchedControl(DOMElement $element, DOMElement $group): bool + { + for ( $node = $element->parentNode; $node instanceof DOMElement && ! $node->isSameNode($group); $node = $node->parentNode ) { + if ( in_array(strtolower($node->tagName), array( 'a', 'button' ), true) ) { + return true; + } + } + + return false; + } + + /** @param list $triggers */ + private static function onlyPunctuationOutsideControls(DOMNode $node, array $triggers): bool + { + foreach ( $node->childNodes as $child ) { + if ( $child instanceof DOMElement ) { + if ( in_array(strtolower($child->tagName), array( 'a', 'button', 'svg' ), true) ) { + continue; + } + if ( ! self::onlyPunctuationOutsideControls($child, $triggers) ) { + return false; + } + continue; + } + if ( XML_TEXT_NODE === $child->nodeType && 1 === preg_match('/[\p{L}\p{N}]/u', (string) $child->textContent) ) { + return false; + } + } + + return true; + } +} diff --git a/php-transformer/src/HtmlToBlocks/Support/NavigationToggleSuppressor.php b/php-transformer/src/HtmlToBlocks/Support/NavigationToggleSuppressor.php index 7acfb8757..ceda6b6ac 100644 --- a/php-transformer/src/HtmlToBlocks/Support/NavigationToggleSuppressor.php +++ b/php-transformer/src/HtmlToBlocks/Support/NavigationToggleSuppressor.php @@ -63,6 +63,13 @@ public function forgetCoreNavigationRecognitions(): void */ public function collectProjectedNavigationRelationships(DOMElement $root): void { + // A captured menu dialog that repeats a dropdown row belongs to that + // row's navigation. Record it first so the hamburger and the panel are + // never offered to the projections below. + foreach ( CapturedMenuDialogFold::detect($root) as $fold ) { + $this->context->navigationProjection()->foldMenuDialog($fold); + } + $elementsById = array(); foreach ( $root->getElementsByTagName('*') as $element ) { if ( $element instanceof DOMElement && '' !== trim(SourceDom::attr($element, 'id')) ) { @@ -1252,8 +1259,23 @@ public function navigationOverlayMenu(DOMElement $navigation): string return $this->navigationToggleControl($navigation) instanceof DOMElement ? 'mobile' : 'never'; } + /** The captured menu dialog a dropdown row has absorbed, if any. */ + public function capturedMenuFoldForRow(DOMElement $row): ?CapturedMenuDialogFold + { + return $this->context->navigationProjection()->foldForRow($row); + } + + public function releaseCapturedMenuFold(CapturedMenuDialogFold $fold): void + { + $this->context->navigationProjection()->releaseFold($fold); + } + public function navigationToggleControl(DOMElement $navigation): ?DOMElement { + $fold = $this->context->navigationProjection()->foldForRow($navigation); + if ( $fold instanceof CapturedMenuDialogFold ) { + return $fold->triggers[0]; + } $document = $navigation->ownerDocument; if ( ! $document instanceof DOMDocument ) { @@ -1526,6 +1548,12 @@ public function isHashAnchorMenuProjection(DOMElement $element): bool */ public function projectedOverlayMenu(DOMElement $control): string { + // The hamburger of an absorbed menu dialog is hidden at the wide + // reference viewport, so the navigation that takes it over shows its + // own button only on narrow screens. + if ( $this->context->navigationProjection()->foldForTrigger($control) instanceof CapturedMenuDialogFold ) { + return 'mobile'; + } if ( $control->hasAttribute('data-dla-dialog-trigger') ) { return 'always'; } diff --git a/php-transformer/tests/unit/navigation-menu-dialog-fold.php b/php-transformer/tests/unit/navigation-menu-dialog-fold.php new file mode 100644 index 000000000..94e79533f --- /dev/null +++ b/php-transformer/tests/unit/navigation-menu-dialog-fold.php @@ -0,0 +1,111 @@ +> $blocks */ +$findBlocks = static function (array $blocks, string $name) use (&$findBlocks): array { + $found = array(); + foreach ( $blocks as $block ) { + if ( ! is_array($block) ) { + continue; + } + if ( $name === ($block['blockName'] ?? '') ) { + $found[] = $block; + } + $found = array_merge($found, $findBlocks(is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : array(), $name)); + } + + return $found; +}; + +$css = '.hidden{display:none}.flex{display:flex}.items-center{align-items:center}.justify-between{justify-content:space-between}' + . '.gap-8{gap:2rem}.gap-3{gap:.75rem}.cta{display:inline-block;padding:8px 24px;border-radius:999px;background:#036;color:#fff}' + . '@media (min-width:768px){.md\\:flex{display:flex}.md\\:hidden{display:none}}'; +$item = static fn (string $id, string $label, string $links): string => + '
' + . '
'; +$switcher = '
|
'; +$cta = 'Book now'; +$accordion = static fn (string $label): string => '
'; + +$shop = 'New inSale'; +$help = 'FAQContact us'; +$page = static fn (string $dialog, string $mobileGroupExtra = ''): string => '' + . ''; +$dialog = '' + . $accordion('Shop') . $accordion('Help') . 'About' . 'Book now'; + +$transform = static fn (string $html): array => ( new HtmlTransformer() )->transform($html)->toArray(); +$result = $transform($page($dialog)); +$markup = (string) ($result['serialized_blocks'] ?? ''); +$blocks = is_array($result['blocks'] ?? null) ? $result['blocks'] : array(); +$navigations = $findBlocks($blocks, 'core/navigation'); + +$assert(1 === count($navigations), 'the row and the dialog are one core/navigation', $markup); +$assert('mobile' === ($navigations[0]['attrs']['overlayMenu'] ?? ''), 'that navigation owns the responsive overlay', $markup); +$assert(2 === count($findBlocks($blocks, 'core/navigation-submenu')), 'both dropdown items stay submenus', $markup); +$labels = array_map(static fn (array $block): string => (string) ($block['attrs']['label'] ?? ''), $findBlocks($blocks, 'core/navigation-link')); +foreach ( array( 'New in', 'Sale', 'FAQ', 'Contact us', 'About' ) as $label ) { + $assert(1 === count(array_keys($labels, $label, true)), 'the link "' . $label . '" is in the navigation once', json_encode($labels)); +} +$assert(! str_contains($markup, 'captured-dialog') && ! str_contains($markup, ' 'burger' === ($block['attrs']['anchor'] ?? '')); +$assert(array() === $hamburgers && ! str_contains($markup, 'id="burger"'), 'no separate hamburger button is left', $markup); +$assert(1 === substr_count($markup, '>EN<') && 1 === substr_count($markup, '>ES<'), 'the language switcher is kept once', $markup); +$assert(2 === substr_count($markup, 'Book now'), 'the call to action is kept in the row and in the overlay', $markup); +$assert(1 === count($findBlocks((array) ($navigations[0]['innerBlocks'] ?? array()), 'core/buttons')), 'the overlay carries the call to action', $markup); +$assert(str_contains($markup, 'blocks-engine-menu-fold-hide') && str_contains($markup, 'blocks-engine-menu-fold-overlay-only'), 'each copy of the call to action is marked for its own width', $markup); + +// A dialog that lists something the row does not have is not a copy of the row. +$other = '' + . $accordion('Shop') . $accordion('Help') . 'AboutCareers'; +$unmatched = $transform($page($other)); +$unmatchedMarkup = (string) ($unmatched['serialized_blocks'] ?? ''); +$assert(str_contains($unmatchedMarkup, 'captured-dialog'), 'a dialog with an entry the row lacks keeps its dialog block', $unmatchedMarkup); +$assert(! str_contains($unmatchedMarkup, 'blocks-engine-menu-fold'), 'and the row is not folded', $unmatchedMarkup); + +// The hamburger's wrapper keeps whatever the row does not have; only the hamburger goes. +$extra = $transform($page($dialog, 'Call us')); +$extraMarkup = (string) ($extra['serialized_blocks'] ?? ''); +$assert(! str_contains($extraMarkup, 'captured-dialog') && 1 === count($findBlocks(is_array($extra['blocks'] ?? null) ? $extra['blocks'] : array(), 'core/navigation')), 'a wrapper with its own content still folds the dialog into the navigation', $extraMarkup); +$assert(str_contains($extraMarkup, 'Call us') && 2 === substr_count($extraMarkup, '>EN<'), 'the wrapper and what it holds beyond the hamburger are kept', $extraMarkup); + +// A row that cannot hand its whole menu to one navigation keeps the dialog as captured. +$split = str_replace('About' . $switcher, $switcher . 'About', $page($dialog)); +$splitResult = $transform($split); +$splitMarkup = (string) ($splitResult['serialized_blocks'] ?? ''); +$assert(str_contains($splitMarkup, 'captured-dialog') && ! str_contains($splitMarkup, 'blocks-engine-menu-fold'), 'a row whose items are split by a control keeps the captured dialog', $splitMarkup); + +if ( 0 < $failures ) { + fwrite(STDERR, "Menu dialog fold contract: {$failures} failed, {$passes} passed\n"); + exit(1); +} +echo "Menu dialog fold contract passed: {$passes} assertions\n"; From c0365b0049eba415d3e173c3f0f9a6cb95b15192 Mon Sep 17 00:00:00 2001 From: x Date: Fri, 9 Oct 2026 14:53:09 -0400 Subject: [PATCH 3/3] fix(php-transformer): keep finding a shared header when empty groups come first The shell search takes the first block of a level as the header only when it holds the navigation. Pages that start with empty decoration groups skipped that check and settled on the navigation block alone, so they stopped sharing the header part with pages that did not. Skip empty decoration groups ahead of the header. This showed up once a folded menu dialog no longer sat beside the header's contents and kept the search one level deeper on every page. Co-Authored-By: Claude Sonnet 5.5 --- php-transformer/composer.json | 1 + .../src/WordPressSitePlan/ShellExtraction.php | 15 +++-- .../shared-menu-with-folded-dialog.php | 57 +++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 php-transformer/tests/contract/shared-menu-with-folded-dialog.php diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 32f2467b0..d6ff40d5a 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -345,6 +345,7 @@ "php tests/unit/navigation-button-dropdown-submenu.php", "php tests/unit/navigation-menu-row-beside-controls.php", "php tests/unit/navigation-menu-dialog-fold.php", + "php tests/contract/shared-menu-with-folded-dialog.php", "php tests/unit/paragraph-label-desktop-menu.php", "php tests/unit/button-signal-classifier.php", "php tests/unit/pill-button-anchor.php", diff --git a/php-transformer/src/WordPressSitePlan/ShellExtraction.php b/php-transformer/src/WordPressSitePlan/ShellExtraction.php index b5f64ff8b..174d9d432 100644 --- a/php-transformer/src/WordPressSitePlan/ShellExtraction.php +++ b/php-transformer/src/WordPressSitePlan/ShellExtraction.php @@ -222,11 +222,16 @@ private function unlabeledChromeCandidates(string $markup, string $sourcePath, a private function collectNestedChrome(string $markup, int $baseOffset, array &$headers, array &$footers): void { $ranges = self::topLevelBlockRanges($markup); - if (2 <= count($ranges)) { - $first = substr($markup, $ranges[0]['offset'], $ranges[0]['length']); + // Empty decoration groups ahead of the header do not move it: the first + // block that holds anything is the one that may be the header. + $lead = 0; + while ($lead < count($ranges) - 1 && self::isEmptyVisualGroup(substr($markup, $ranges[$lead]['offset'], $ranges[$lead]['length']))) ++$lead; + if (2 <= count($ranges) - $lead) { + $head = $ranges[$lead]; + $first = substr($markup, $head['offset'], $head['length']); if (self::containsNavigation($first) && !self::containsMainLandmark($first) && !self::chromeSplitIsInside($first)) { $restHasContent = false; - foreach (array_slice($ranges, 1) as $range) { + foreach (array_slice($ranges, $lead + 1) as $range) { $sibling = substr($markup, $range['offset'], $range['length']); if (!self::isEmptyVisualGroup($sibling)) { $restHasContent = true; @@ -234,10 +239,10 @@ private function collectNestedChrome(string $markup, int $baseOffset, array &$he } } if ($restHasContent) { - $headers[] = $this->nestedChromeRow($first, $baseOffset + $ranges[0]['offset'], $ranges[0]['length']); + $headers[] = $this->nestedChromeRow($first, $baseOffset + $head['offset'], $head['length']); $last = $ranges[count($ranges) - 1]; $lastMarkup = substr($markup, $last['offset'], $last['length']); - if ($last !== $ranges[0] && self::isFooterChrome($lastMarkup)) { + if ($last !== $head && self::isFooterChrome($lastMarkup)) { $footers[] = $this->nestedChromeRow($lastMarkup, $baseOffset + $last['offset'], $last['length']); } return; diff --git a/php-transformer/tests/contract/shared-menu-with-folded-dialog.php b/php-transformer/tests/contract/shared-menu-with-folded-dialog.php new file mode 100644 index 000000000..6b12fb913 --- /dev/null +++ b/php-transformer/tests/contract/shared-menu-with-folded-dialog.php @@ -0,0 +1,57 @@ + + '
' + . '
'; +$switcher = '
|
'; +$accordion = static fn (string $label): string => '
'; +$decor = ''; +$page = static fn (string $title, bool $decorated): string => '' . ($decorated ? $decor . '
' : '') + . '' + . '

' . $title . '

' . $title . ' page.

'; + +$plan = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array( + 'index.html' => $page('Home', false), + 'about.html' => $page('About', true), + 'contact.html' => $page('Contact', true), +)))->toArray()['source_reports']['wordpress_site_plan']; + +$menus = array_values(array_filter($plan['menus'] ?? array(), static fn(array $menu): bool => is_string($menu['token'] ?? null) && is_string($menu['block_markup'] ?? null))); +$headers = array_values(array_filter($plan['template_parts'] ?? array(), static fn(array $part): bool => 'header' === ($part['area'] ?? null))); +$assert(1 === count($menus), 'Three pages with the same folded menu share one navigation entity, got ' . count($menus) . '.'); +$assert(str_contains((string) $menus[0]['block_markup'], 'blocks-engine-menu-fold-overlay-only'), 'The entity carries the overlay-only call to action.'); +$assert(1 === count($headers), 'The repeated header becomes one header part.'); +$markup = (string) ($headers[0]['canonical_block_markup'] ?? ''); +$assert(str_contains($markup, '"overlayMenu":"mobile"') && ! str_contains($markup, 'captured-dialog'), 'The header part holds the responsive navigation and no dialog block.'); +foreach ($plan['pages'] as $planPage) { + $content = (string) ($planPage['block_markup'] ?? $planPage['canonical_block_markup'] ?? ''); + $assert(! str_contains($content, 'wp:navigation '), 'A page does not keep its own copy of the menu: ' . ($planPage['source_path'] ?? '?')); +} +echo "Shared folded menu contract passed\n";