diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index b71aaec4..d5ab581c 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -1090,7 +1090,15 @@ private function rewriteAuthorSelectorPrelude(string $prelude, bool $controlWrap } $matches = $this->matchingAuthorSourceElements($parsed); if ( array() === $matches ) { - $rewritten[] = $selector; + // A type selector (e.g. `.page-header p`) that matches no source + // element must still be projected through its source-tag marker + // rather than emitted bare. Otherwise a `
` later collapsed to a + // `

` (an eyebrow `

`) would be newly captured by + // the dormant `.page-header p` rule and lose its own type scale. + // Rewriting to `:where(.source-p-marker)` — carried only by elements + // that were `

` in the source — makes the rule match exactly what + // the author intended and nothing that was structurally promoted. + $rewritten[] = $this->rewriteSourceTagTypes($selector, $parsed); continue; } if ( $this->isRootChildSelector($parsed) ) { @@ -3754,6 +3762,12 @@ private function visualTextWrapperBlockFromElement(DOMElement $element): ?array // decoration, which the wrapper class still applies to the paragraph. Real // flex containers hold child elements and are already excluded above by the // `childElementCount === 0` guard (e.g. `.tier-price` wrapping a ``). + // + // Descendant paragraph rules the source used a non-`p` tag to escape (e.g. + // `.page-header p { font-size: ... }` styling body copy while an eyebrow + // authored as `

` avoided it) do not capture the collapsed + // paragraph: author `p` type selectors are projected through the source-`p` + // tag marker, which only elements that were `

` in the source carry. if ( 0 === $this->childElementCount($element) && ! $this->hasBoxChromeWrapperStyling($element) ) { return $this->createBlock( 'core/paragraph', diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php index 40554e00..38b30114 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php @@ -30,6 +30,16 @@ public function match(DOMElement $element, PatternContext $context): ?array return null; } + // A row of button-styled links (e.g. `

`) is a call-to-action button group, not + // site navigation. It matched here only because a container token like + // `links` looks navigational, but its anchors carry button signals and + // belong to the buttons pattern, which preserves their pill geometry and + // styling. Defer so navigation does not flatten them into menu items. + if ( 'nav' !== strtolower($element->tagName) && ! $this->hasDirectListNavigationSignal($element) && $this->hasButtonStyledLinkChildren($element) ) { + return null; + } + if ( $this->hasDirectBrandingAnchorBesideListNavigation($element, $innerHtml) ) { return null; } @@ -846,6 +856,34 @@ private function collectAnchorsExcluding(DOMElement $element, array &$anchors, a } } + /** + * Whether the container's direct link children are button-styled call-to- + * action anchors rather than navigation links. Requires every direct anchor + * to carry a button signal so a genuine nav menu with one incidental + * button-classed link is not misclassified. + */ + private function hasButtonStyledLinkChildren(DOMElement $element): bool + { + $classifier = new ButtonSignalClassifier(); + $anchors = array(); + foreach ( $element->childNodes as $child ) { + if ( $child instanceof DOMElement && 'a' === strtolower($child->tagName) ) { + $anchors[] = $child; + } + } + if ( 2 > count($anchors) ) { + return false; + } + + foreach ( $anchors as $anchor ) { + if ( ! $classifier->hasTransformSignal($anchor) ) { + return false; + } + } + + return true; + } + private function hasNavigationSignal(DOMElement $element): bool { if ( 'navigation' === strtolower($element->hasAttribute('role') ? $element->getAttribute('role') : '') ) { diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index d8e9af14..d674a498 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -322,6 +322,16 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert('About' === ($navigationBlock['innerBlocks'][0]['attrs']['label'] ?? null), 'navigation conversion still preserves link labels'); $assert('/about' === ($navigationBlock['innerBlocks'][0]['attrs']['url'] ?? null), 'navigation conversion still preserves link URLs'); +// A row of button-styled links whose container merely carries a `links` token is +// a call-to-action button group, not site navigation. It must convert to +// core/buttons (preserving pill geometry) instead of being flattened into a +// core/navigation menu of half-height text links. +$ctaLinkRowResult = ( new HtmlTransformer() )->transform('')->toArray(); +$ctaSerialized = (string) ($ctaLinkRowResult['serialized_blocks'] ?? ''); +$assert(str_contains($ctaSerialized, '