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
6 changes: 4 additions & 2 deletions php-transformer/src/HtmlToBlocks/HtmlTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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))
);
}

Expand Down Expand Up @@ -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))
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand All @@ -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;
}
Expand All @@ -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<int, string> $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<int, string> $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;
}
}
71 changes: 8 additions & 63 deletions php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -491,21 +453,4 @@ private function hasAnyToken(DOMElement $element, array $tokens): bool
return false;
}

/**
* @param array<int, string> $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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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();
Expand All @@ -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;
}
}
Expand Down
12 changes: 11 additions & 1 deletion php-transformer/src/HtmlToBlocks/Patterns/PatternContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ final class PatternContext
* @param callable(DOMElement): array<int, array<string, mixed>>|null $convertChildren
* @param callable(DOMElement, array<int, string>): array<int, array<string, mixed>>|null $convertChildrenWithoutTags
* @param callable(DOMElement, DOMElement): string|null $navigationUnderlineColor
* @param callable(DOMElement): string|null $resolvedStyle
*/
public function __construct(
private readonly mixed $presentationAttributes,
Expand All @@ -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
) {
}

Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading