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, '