diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index e967ae8e..6ac5da41 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -1628,7 +1628,8 @@ private function patternContext(bool $includeRuntimeDomTarget = true): PatternCo $includeRuntimeDomTarget ? fn (DOMElement $sourceElement): bool => $this->isRuntimeDomTarget($sourceElement) : null, fn (DOMElement $sourceElement): array => $this->convertPatternChildren($sourceElement), fn (DOMElement $sourceElement, array $excludedTags): array => $this->convertPatternChildrenWithoutTags($sourceElement, $excludedTags), - fn (DOMElement $item, DOMElement $anchor): string => $this->navigationUnderlineColor($item, $anchor) + fn (DOMElement $item, DOMElement $anchor): string => $this->navigationUnderlineColor($item, $anchor), + fn (DOMElement $sourceElement): string => $this->resolveCssVariablesInValue($this->mergedPresentationStyle($sourceElement)) ); } @@ -1668,7 +1669,8 @@ private function probePatternContext(): PatternContext null, null, null, - fn (DOMElement $item, DOMElement $anchor): string => $this->navigationUnderlineColor($item, $anchor) + fn (DOMElement $item, DOMElement $anchor): string => $this->navigationUnderlineColor($item, $anchor), + fn (DOMElement $sourceElement): string => $this->resolveCssVariablesInValue($this->mergedPresentationStyle($sourceElement)) ); } diff --git a/php-transformer/src/HtmlToBlocks/Patterns/ButtonSignalClassifier.php b/php-transformer/src/HtmlToBlocks/Patterns/ButtonSignalClassifier.php index d0372b79..8616e3f7 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/ButtonSignalClassifier.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/ButtonSignalClassifier.php @@ -7,17 +7,13 @@ final class ButtonSignalClassifier { - public function hasTransformSignal(DOMElement $element): bool + public function hasTransformSignal(DOMElement $element, string $resolvedStyle = ''): bool { if ( 'button' === strtolower($element->hasAttribute('role') ? $element->getAttribute('role') : '') ) { return true; } - return $this->hasClassSignal($element) - || $this->hasAnyToken($element, array( 'cta', 'action' )) - || $this->hasPhrase($element, array( 'call-to-action', 'primary-action', 'secondary-action' )) - || $this->hasActionText($element) - || $this->hasStyleSignal($element); + return $this->hasStyleSignal($element, $resolvedStyle); } /** @@ -40,16 +36,15 @@ public function hasClassSignal(DOMElement $element): bool } /** - * Detect button-like inline styling. + * Detect an explicit, visible button surface. * - * Treats an element as a button when it carries padding plus a button shape - * signal (a filled, non-transparent background or a border radius). This lets - * styled anchors with no recognizable class still be promoted to buttons, - * while plain text links (no padding/fill) stay links. + * Class names and action-oriented text are not enough: they commonly label + * textual CTAs, navigation, and legal links. A control needs box padding plus + * visible fill, border, or rounding in its resolved author styles. */ - public function hasStyleSignal(DOMElement $element): bool + public function hasStyleSignal(DOMElement $element, string $resolvedStyle = ''): bool { - $style = strtolower($element->hasAttribute('style') ? $element->getAttribute('style') : ''); + $style = strtolower('' !== trim($resolvedStyle) ? $resolvedStyle : ($element->hasAttribute('style') ? $element->getAttribute('style') : '')); if ( '' === $style || ! preg_match('/(?:^|;)\s*padding(?:-[a-z]+)?\s*:\s*[^;]+/', $style) ) { return false; } @@ -58,62 +53,13 @@ public function hasStyleSignal(DOMElement $element): bool return true; } - return preg_match('/(?:^|;)\s*background(?:-color)?\s*:\s*[^;]+/', $style) === 1 - && preg_match('/(?:^|;)\s*background(?:-color)?\s*:\s*(?:transparent|none|inherit|initial|rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\))\s*(?:;|$)/', $style) !== 1; - } - - /** - * @param array $tokens - */ - private function hasAnyToken(DOMElement $element, array $tokens): bool - { - foreach ( array( 'class', 'id' ) as $attribute ) { - $value = $element->hasAttribute($attribute) ? $element->getAttribute($attribute) : ''; - foreach ( preg_split('/[^a-z0-9]+/', strtolower($value)) ?: array() as $token ) { - if ( in_array($token, $tokens, true) ) { - return true; - } - } + // A side-specific border with matching padding is commonly an underline. + // Only the box-wide shorthand establishes an outlined control surface. + if ( preg_match('/(?:^|;)\s*border\s*:\s*[^;]+/', $style) === 1 ) { + return preg_match('/(?:^|;)\s*border\s*:\s*(?:0|none)\s*(?:;|$)/', $style) !== 1; } - return false; - } - - /** - * @param array $phrases - */ - private function hasPhrase(DOMElement $element, array $phrases): bool - { - foreach ( array( 'class', 'id' ) as $attribute ) { - $value = strtolower($element->hasAttribute($attribute) ? $element->getAttribute($attribute) : ''); - foreach ( $phrases as $phrase ) { - if ( str_contains($value, $phrase) ) { - return true; - } - } - } - - return false; - } - - private function hasActionText(DOMElement $element): bool - { - $text = strtolower(trim(preg_replace('/\s+/', ' ', $element->textContent ?? '') ?? '')); - if ( '' === $text ) { - return false; - } - - return in_array($text, array( - 'add to cart', - 'buy now', - 'checkout', - 'shop now', - 'get started', - 'sign up', - 'subscribe', - 'donate', - 'register', - 'book now', - ), true); + return preg_match('/(?:^|;)\s*background(?:-color)?\s*:\s*[^;]+/', $style) === 1 + && preg_match('/(?:^|;)\s*background(?:-color)?\s*:\s*(?:transparent|none|inherit|initial|rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\))\s*(?:;|$)/', $style) !== 1; } } diff --git a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php index d529d5aa..600e9c86 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php @@ -35,7 +35,7 @@ public function matchAnchor(DOMElement $anchor, callable $fileBlockFromAnchor, c return $fileBlock; } - if ( ! $this->hasButtonSignal($anchor) ) { + if ( ! $this->hasButtonSignal($anchor, (string) $resolvedStyle($anchor)) ) { return null; } @@ -84,14 +84,13 @@ public function matchButton(DOMElement $button, callable $presentationAttributes public function matchContainer(DOMElement $element, callable $presentationAttributes, callable $resolvedStyle, callable $innerHtml, callable $materializeSvgImages, callable $attr, callable $createBlock): ?array { $wrappedAnchor = $this->singleSimpleAnchorChild($element); - if ( null !== $wrappedAnchor && $this->hasWrapperButtonSignal($element) ) { + if ( null !== $wrappedAnchor && $this->hasWrapperButtonSignal($element, (string) $resolvedStyle($element)) ) { return $createBlock('core/buttons', $this->buttonWrapperAttributes($element, $presentationAttributes), array( $this->buttonBlockFromAnchor($wrappedAnchor, $presentationAttributes, $resolvedStyle, $innerHtml, $materializeSvgImages, $attr, $createBlock, $element) ), $element); } - $containerHasButtonSignal = $this->hasContainerButtonSignal($element) || $this->isDirectAnchorRow($element); $buttons = array(); foreach ( $element->childNodes as $child ) { - if ( $child instanceof DOMElement && 'a' === strtolower($child->tagName) && '' !== trim($child->textContent ?? '') && ( $containerHasButtonSignal || $this->hasButtonSignal($child) ) ) { + if ( $child instanceof DOMElement && 'a' === strtolower($child->tagName) && '' !== trim($child->textContent ?? '') && $this->hasButtonSignal($child, (string) $resolvedStyle($child)) ) { $buttons[] = $this->buttonBlockFromAnchor($child, $presentationAttributes, $resolvedStyle, $innerHtml, $materializeSvgImages, $attr, $createBlock); } } @@ -166,9 +165,7 @@ private function buttonText(DOMElement $element, string $html, callable $materia private function buttonAccessibleTitle(DOMElement $element, string $text): string { - return '' === $this->plainText($text) - ? html_entity_decode($this->accessibleFallbackLabel($element), ENT_QUOTES | ENT_HTML5, 'UTF-8') - : ''; + return html_entity_decode($this->accessibleFallbackLabel($element), ENT_QUOTES | ENT_HTML5, 'UTF-8'); } private function plainText(string $html): string @@ -381,53 +378,18 @@ private function hasOutlineSignal(DOMElement $element, string $style): bool return preg_match('/^(?:transparent|none|rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\))\s*$/i', trim((string) ($matches[1] ?? ''))) === 1; } - private function hasButtonSignal(DOMElement $anchor): bool + private function hasButtonSignal(DOMElement $anchor, string $resolvedStyle = ''): bool { - return $this->signalClassifier->hasTransformSignal($anchor); + return $this->signalClassifier->hasTransformSignal($anchor, $resolvedStyle); } - private function hasContainerButtonSignal(DOMElement $element): bool - { - return $this->hasAnyToken($element, array( 'buttons', 'button', 'btns', 'cta', 'actions' )) || $this->hasPhrase($element, array( 'button-group', 'button-row', 'cta-group', 'call-to-action' )); - } - - private function hasWrapperButtonSignal(DOMElement $element): bool + private function hasWrapperButtonSignal(DOMElement $element, string $resolvedStyle): bool { if ( 'button' === strtolower($element->hasAttribute('role') ? $element->getAttribute('role') : '') ) { return true; } - return $this->signalClassifier->hasClassSignal($element) - || $this->hasAnyToken($element, array( 'cta', 'action' )) - || $this->hasPhrase($element, array( 'call-to-action', 'primary-action', 'secondary-action' )) - || $this->signalClassifier->hasStyleSignal($element); - } - - private function isDirectAnchorRow(DOMElement $element): bool - { - $anchors = 0; - $buttonSignals = 0; - foreach ( $element->childNodes as $child ) { - if ( $child instanceof DOMElement ) { - if ( 'a' !== strtolower($child->tagName) || '' === trim($child->textContent ?? '') ) { - return false; - } - if ( ! $this->isSimpleAnchor($child) ) { - return false; - } - ++$anchors; - if ( $this->hasButtonSignal($child) ) { - ++$buttonSignals; - } - continue; - } - - if ( '' !== trim($child->textContent ?? '') ) { - return false; - } - } - - return $anchors > 1 && 0 === $buttonSignals; + return $this->signalClassifier->hasStyleSignal($element, $resolvedStyle); } private function singleSimpleAnchorChild(DOMElement $element): ?DOMElement @@ -491,21 +453,4 @@ private function hasAnyToken(DOMElement $element, array $tokens): bool return false; } - /** - * @param array $phrases - */ - private function hasPhrase(DOMElement $element, array $phrases): bool - { - foreach ( array( 'class', 'id' ) as $attribute ) { - $value = strtolower($element->hasAttribute($attribute) ? $element->getAttribute($attribute) : ''); - foreach ( $phrases as $phrase ) { - if ( str_contains($value, $phrase) ) { - return true; - } - } - } - - return false; - } - } diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php index 38b30114..89e0c84e 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php @@ -21,6 +21,7 @@ public function match(DOMElement $element, PatternContext $context): ?array $createBlock = $context->createBlockCallback(); $isRuntimeDomTarget = $context->isRuntimeDomTargetCallback(); $navigationUnderlineColor = $context->navigationUnderlineColorCallback(); + $resolvedStyle = $context->resolvedStyleCallback(); if ( 'nav' !== strtolower($element->tagName) && ! $this->hasNavigationSignal($element) && ! $this->hasDirectListNavigationSignal($element) ) { return null; @@ -36,7 +37,7 @@ public function match(DOMElement $element, PatternContext $context): ?array // `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) ) { + if ( 'nav' !== strtolower($element->tagName) && ! $this->hasDirectListNavigationSignal($element) && $this->hasButtonStyledLinkChildren($element, $resolvedStyle) ) { return null; } @@ -862,7 +863,8 @@ private function collectAnchorsExcluding(DOMElement $element, array &$anchors, a * 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 + /** @param callable(DOMElement): string|null $resolvedStyle */ + private function hasButtonStyledLinkChildren(DOMElement $element, ?callable $resolvedStyle): bool { $classifier = new ButtonSignalClassifier(); $anchors = array(); @@ -876,7 +878,7 @@ private function hasButtonStyledLinkChildren(DOMElement $element): bool } foreach ( $anchors as $anchor ) { - if ( ! $classifier->hasTransformSignal($anchor) ) { + if ( ! $classifier->hasTransformSignal($anchor, null !== $resolvedStyle ? $resolvedStyle($anchor) : '') ) { return false; } } diff --git a/php-transformer/src/HtmlToBlocks/Patterns/PatternContext.php b/php-transformer/src/HtmlToBlocks/Patterns/PatternContext.php index 86f0aec2..127d389f 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/PatternContext.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/PatternContext.php @@ -15,6 +15,7 @@ final class PatternContext * @param callable(DOMElement): array>|null $convertChildren * @param callable(DOMElement, array): array>|null $convertChildrenWithoutTags * @param callable(DOMElement, DOMElement): string|null $navigationUnderlineColor + * @param callable(DOMElement): string|null $resolvedStyle */ public function __construct( private readonly mixed $presentationAttributes, @@ -23,7 +24,8 @@ public function __construct( private readonly mixed $isRuntimeDomTarget = null, private readonly mixed $convertChildren = null, private readonly mixed $convertChildrenWithoutTags = null, - private readonly mixed $navigationUnderlineColor = null + private readonly mixed $navigationUnderlineColor = null, + private readonly mixed $resolvedStyle = null ) { } @@ -82,4 +84,12 @@ public function navigationUnderlineColorCallback(): ?callable { return is_callable($this->navigationUnderlineColor) ? $this->navigationUnderlineColor : null; } + + /** + * @return callable(DOMElement): string|null + */ + public function resolvedStyleCallback(): ?callable + { + return is_callable($this->resolvedStyle) ? $this->resolvedStyle : null; + } } diff --git a/php-transformer/src/HtmlToBlocks/Style/HighValueStyleBoundaryPolicy.php b/php-transformer/src/HtmlToBlocks/Style/HighValueStyleBoundaryPolicy.php index 227e8ca5..3109f577 100644 --- a/php-transformer/src/HtmlToBlocks/Style/HighValueStyleBoundaryPolicy.php +++ b/php-transformer/src/HtmlToBlocks/Style/HighValueStyleBoundaryPolicy.php @@ -38,16 +38,10 @@ public function matches(DOMElement $element): bool return true; } + // Anchor surface styles determine whether an anchor is a control or + // ordinary text, so classifier evidence must not depend on its name. if ( 'a' === $tagName ) { - for ( $node = $element->parentNode; $node instanceof DOMElement; $node = $node->parentNode ) { - if ( 'nav' === strtolower($node->tagName) ) { - return true; - } - $ancestorTokens = strtolower($this->attr($node, 'class') . ' ' . $this->attr($node, 'id')); - if ( preg_match('/(?:^|[^a-z0-9])(?:actions?|btns?|buttons?|cta|nav|menu|card|tile|panel|pricing|product)(?:[^a-z0-9]|$)/', $ancestorTokens) ) { - return true; - } - } + return true; } return false; diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index aa238ade..f332d175 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -353,7 +353,7 @@ public function match(DOMElement $element, PatternContext $context): ?array // 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(); +$ctaLinkRowResult = ( new HtmlTransformer() )->transform('')->toArray(); $ctaSerialized = (string) ($ctaLinkRowResult['serialized_blocks'] ?? ''); $assert(str_contains($ctaSerialized, '