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
1 change: 1 addition & 0 deletions php-transformer/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"php tests/unit/css-value-splitter.php",
"php tests/unit/fallback-finding-normalizer.php",
"php tests/unit/navigation-underline-color-resolver.php",
"php tests/unit/button-signal-classifier.php",
"php tests/unit/block-style-support-conversion.php",
"php tests/unit/content-round-trip-reporter.php",
"php tests/unit/content-round-trip-form-echo.php",
Expand Down
119 changes: 119 additions & 0 deletions php-transformer/src/HtmlToBlocks/Patterns/ButtonSignalClassifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);

namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns;

use DOMElement;

final class ButtonSignalClassifier
{
public function hasTransformSignal(DOMElement $element): 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);
}

/**
* Detect button-like class/id tokens generically.
*
* Keys off the generic "btn"/"button" substring rather than any one specific
* class string, so framework variants are recognized: btn, btn-primary,
* hero-btn, link-btn, btnPrimary, actionButton, icon-button, roundedbtn, etc.
*/
public function hasClassSignal(DOMElement $element): bool
{
foreach ( array( 'class', 'id' ) as $attribute ) {
$value = strtolower($element->hasAttribute($attribute) ? $element->getAttribute($attribute) : '');
if ( str_contains($value, 'btn') || str_contains($value, 'button') ) {
return true;
}
}

return false;
}

/**
* Detect button-like inline styling.
*
* 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.
*/
public function hasStyleSignal(DOMElement $element): bool
{
$style = strtolower($element->hasAttribute('style') ? $element->getAttribute('style') : '');
if ( '' === $style || ! preg_match('/(?:^|;)\s*padding(?:-[a-z]+)?\s*:\s*[^;]+/', $style) ) {
return false;
}

if ( preg_match('/(?:^|;)\s*border[a-z-]*radius\s*:\s*[^;]+/', $style) ) {
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;
}
}
}

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);
}
}
78 changes: 5 additions & 73 deletions php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ final class ButtonsPattern
private const BLOCK_LEVEL_LABEL_TAGS = 'address|article|aside|blockquote|div|dl|fieldset|figcaption|figure|footer|form|h[1-6]|header|hr|main|nav|ol|p|pre|section|table|ul';

private readonly ButtonStyleResolver $styleResolver;
private readonly ButtonSignalClassifier $signalClassifier;

public function __construct()
{
$this->styleResolver = new ButtonStyleResolver();
$this->signalClassifier = new ButtonSignalClassifier();
}

/**
Expand Down Expand Up @@ -306,59 +308,9 @@ private function hasOutlineSignal(DOMElement $element, string $style): bool

private function hasButtonSignal(DOMElement $anchor): bool
{
if ( 'button' === strtolower($anchor->hasAttribute('role') ? $anchor->getAttribute('role') : '') ) {
return true;
}

return $this->hasButtonClassSignal($anchor)
|| $this->hasAnyToken($anchor, array( 'cta', 'action' ))
|| $this->hasPhrase($anchor, array( 'call-to-action', 'primary-action', 'secondary-action' ))
|| $this->hasActionText($anchor)
|| $this->hasButtonStyleSignal($anchor);
return $this->signalClassifier->hasTransformSignal($anchor);
}

/**
* Detect button-like class/id tokens generically.
*
* Keys off the generic "btn"/"button" substring rather than any one specific
* class string, so framework variants are all recognized: btn, btn-primary,
* hero-btn, link-btn, btnPrimary, actionButton, icon-button, roundedbtn, etc.
*/
private function hasButtonClassSignal(DOMElement $element): bool
{
foreach ( array( 'class', 'id' ) as $attribute ) {
$value = strtolower($element->hasAttribute($attribute) ? $element->getAttribute($attribute) : '');
if ( str_contains($value, 'btn') || str_contains($value, 'button') ) {
return true;
}
}

return false;
}

/**
* Detect button-like inline styling.
*
* 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.
*/
private function hasButtonStyleSignal(DOMElement $element): bool
{
$style = strtolower($element->hasAttribute('style') ? $element->getAttribute('style') : '');
if ( '' === $style || ! preg_match('/(?:^|;)\s*padding(?:-[a-z]+)?\s*:\s*[^;]+/', $style) ) {
return false;
}

if ( preg_match('/(?:^|;)\s*border[a-z-]*radius\s*:\s*[^;]+/', $style) ) {
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;
}

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' ));
Expand All @@ -370,10 +322,10 @@ private function hasWrapperButtonSignal(DOMElement $element): bool
return true;
}

return $this->hasButtonClassSignal($element)
return $this->signalClassifier->hasClassSignal($element)
|| $this->hasAnyToken($element, array( 'cta', 'action' ))
|| $this->hasPhrase($element, array( 'call-to-action', 'primary-action', 'secondary-action' ))
|| $this->hasButtonStyleSignal($element);
|| $this->signalClassifier->hasStyleSignal($element);
}

private function isDirectAnchorRow(DOMElement $element): bool
Expand Down Expand Up @@ -481,24 +433,4 @@ private function hasPhrase(DOMElement $element, array $phrases): bool
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);
}
}
19 changes: 9 additions & 10 deletions php-transformer/src/VisualParity/ButtonMenuVisualProbe.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace Automattic\BlocksEngine\PhpTransformer\VisualParity;

use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\ButtonSignalClassifier;
use DOMDocument;
use DOMElement;
use DOMNode;
Expand All @@ -12,6 +13,8 @@ final class ButtonMenuVisualProbe
{
public const SCHEMA = 'blocks-engine/php-transformer/visual-parity-probes/v1';

private readonly ButtonSignalClassifier $buttonSignalClassifier;

private const STYLE_FIELDS = array(
'background',
'background-color',
Expand Down Expand Up @@ -64,6 +67,11 @@ final class ButtonMenuVisualProbe
'width',
);

public function __construct()
{
$this->buttonSignalClassifier = new ButtonSignalClassifier();
}

/**
* @return array<string, mixed>
*/
Expand Down Expand Up @@ -260,16 +268,7 @@ private function hasCtaSignal(DOMElement $element): bool

private function hasButtonSignal(DOMElement $element): bool
{
// Generic button-token detection: any class/id containing the "btn" or
// "button" substring (covers btn, btn-primary, hero-btn, btnPrimary,
// actionButton, roundedbtn, wp-element-button, etc.). Kept consistent with
// the HTML transformer's button-signal heuristic.
$value = strtolower(
( $element->hasAttribute('class') ? $element->getAttribute('class') : '' ) . ' '
. ( $element->hasAttribute('id') ? $element->getAttribute('id') : '' )
);

return str_contains($value, 'btn') || str_contains($value, 'button');
return $this->buttonSignalClassifier->hasClassSignal($element);
}

private function hasRegressionRiskSignal(DOMElement $element): bool
Expand Down
66 changes: 66 additions & 0 deletions php-transformer/tests/unit/button-signal-classifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);

/**
* Unit coverage for the shared button signal classifier used by HTML transform
* button promotion and visual parity probes.
*/

require dirname(__DIR__, 2) . '/vendor/autoload.php';

use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\ButtonSignalClassifier;

$failures = 0;
$passes = 0;

$assert = static function (bool $condition, string $message, string $detail = '') use (&$failures, &$passes): void {
if ( $condition ) {
++$passes;
return;
}

++$failures;
fwrite(STDERR, 'FAIL: ' . $message . ('' !== $detail ? ' - ' . $detail : '') . PHP_EOL);
};

$element = static function (string $html): DOMElement {
$document = new DOMDocument();
$previous = libxml_use_internal_errors(true);
$document->loadHTML('<?xml encoding="utf-8" ?><body>' . $html . '</body>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previous);

$body = $document->getElementsByTagName('body')->item(0);
if ( ! $body instanceof DOMElement || ! $body->firstElementChild instanceof DOMElement ) {
throw new RuntimeException('Unable to parse fixture element.');
}

return $body->firstElementChild;
};

$classifier = new ButtonSignalClassifier();

$assert($classifier->hasClassSignal($element('<a class="hero-btn" href="#">Learn more</a>')), '1: class signal detects btn substring');
$assert($classifier->hasClassSignal($element('<a id="actionButton" href="#">Learn more</a>')), '2: id signal detects button substring');
$assert($classifier->hasTransformSignal($element('<a role="button" href="#">Learn more</a>')), '3: role=button is a transform signal');
$assert($classifier->hasTransformSignal($element('<a class="cta" href="#">Learn more</a>')), '4: cta token is a transform signal');
$assert($classifier->hasTransformSignal($element('<a class="primary-action" href="#">Learn more</a>')), '5: primary-action phrase is a transform signal');
$assert($classifier->hasTransformSignal($element('<a href="#">Buy now</a>')), '6: exact action text is a transform signal');
$assert($classifier->hasStyleSignal($element('<a style="padding:12px 18px;background:#135e96" href="#">Learn more</a>')), '7: padding plus filled background is a style signal');
$assert($classifier->hasStyleSignal($element('<a style="padding:12px 18px;border-radius:999px" href="#">Learn more</a>')), '8: padding plus radius is a style signal');
$assert(! $classifier->hasStyleSignal($element('<a style="padding:12px 18px;background:transparent" href="#">Learn more</a>')), '9: transparent background alone is not a style signal');
$assert(! $classifier->hasTransformSignal($element('<a href="#">Learn more</a>')), '10: plain link has no transform signal');

$result = ( new HtmlTransformer() )->transform('<a style="padding:12px 18px;background:#135e96;color:#fff" href="/buy">Buy tickets</a>', array())->toArray();
$button = $result['blocks'][0]['innerBlocks'][0] ?? array();
$assert('core/buttons' === ($result['blocks'][0]['blockName'] ?? ''), '11: styled anchor is promoted to core/buttons', json_encode($result['blocks'] ?? array()));
$assert('core/button' === ($button['blockName'] ?? ''), '12: styled anchor inner block is core/button', json_encode($button));
$assert('/buy' === ($button['attrs']['url'] ?? ''), '13: styled anchor promotion preserves URL', json_encode($button['attrs'] ?? array()));

if ( $failures > 0 ) {
fwrite(STDERR, "ButtonSignalClassifier unit tests: {$failures} failed, {$passes} passed\n");
exit(1);
}

fwrite(STDOUT, "ButtonSignalClassifier unit tests: {$passes} passed\n");
Loading