diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php
index ba7cb8c2..c5396fcd 100644
--- a/php-transformer/src/HtmlToBlocks/BlockFactory.php
+++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php
@@ -202,6 +202,8 @@ private function blockHtml(string $name, array $attrs, array $innerBlocks): stri
}
if ( 'core/button' === $name ) {
+ $support = $this->buttonStyleSupport($attrs);
+
if ( 'button' === ($attrs['tagName'] ?? '') ) {
$buttonAttrs = array_intersect_key($attrs, array_flip(array( 'type', 'role', 'aria-label', 'aria-controls', 'aria-expanded', 'aria-haspopup' )));
foreach ( $attrs as $attrName => $attrValue ) {
@@ -211,8 +213,8 @@ private function blockHtml(string $name, array $attrs, array $innerBlocks): stri
}
$buttonAttrs = array_merge(array(
'id' => (string) ($attrs['anchor'] ?? ''),
- 'class' => $this->mergeClassNames('wp-block-button__link wp-element-button', (string) ($attrs['className'] ?? '')),
- 'style' => (string) ($attrs['style'] ?? ''),
+ 'class' => $this->mergeClassNames('wp-block-button__link', $support['classes'], 'wp-element-button', (string) ($attrs['className'] ?? '')),
+ 'style' => $support['style'],
), $buttonAttrs);
return '
';
@@ -221,8 +223,8 @@ private function blockHtml(string $name, array $attrs, array $innerBlocks): stri
$href = '' !== ($attrs['url'] ?? '') ? ' href="' . htmlspecialchars((string) $attrs['url'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"' : '';
$wrapperClass = $this->mergeClassNames('wp-block-button', in_array('is-style-outline', preg_split('/\s+/', (string) ($attrs['className'] ?? '')) ?: array(), true) ? 'is-style-outline' : '');
$linkAttrs = array(
- 'class' => $this->mergeClassNames('wp-block-button__link wp-element-button', (string) ($attrs['className'] ?? '')),
- 'style' => (string) ($attrs['style'] ?? ''),
+ 'class' => $this->mergeClassNames('wp-block-button__link', $support['classes'], 'wp-element-button', (string) ($attrs['className'] ?? '')),
+ 'style' => $support['style'],
);
return '';
}
@@ -404,6 +406,80 @@ private function searchHtml(array $attrs): string
return '';
}
+ /**
+ * Translate a core/button block's native style support into the rendered
+ * has-* support classes and the inline style string WordPress emits for
+ * custom colors and borders. Accepts the canonical `style` object; falls back
+ * to a legacy raw `style` string when present for backward compatibility.
+ *
+ * @param array $attrs
+ * @return array{classes: string, style: string}
+ */
+ private function buttonStyleSupport(array $attrs): array
+ {
+ $style = $attrs['style'] ?? null;
+ if ( ! is_array($style) ) {
+ return array(
+ 'classes' => '',
+ 'style' => is_string($style) ? $style : '',
+ );
+ }
+
+ $classes = array();
+ $declarations = array();
+
+ $background = (string) ($style['color']['background'] ?? '');
+ $text = (string) ($style['color']['text'] ?? '');
+ if ( '' !== $text ) {
+ $classes[] = 'has-text-color';
+ $declarations[] = 'color:' . $text;
+ }
+ if ( '' !== $background ) {
+ $classes[] = 'has-background';
+ $declarations[] = 'background-color:' . $background;
+ }
+
+ $border = is_array($style['border'] ?? null) ? $style['border'] : array();
+ if ( isset($border['color']) && '' !== (string) $border['color'] ) {
+ $classes[] = 'has-border-color';
+ $declarations[] = 'border-color:' . (string) $border['color'];
+ }
+ if ( isset($border['width']) && '' !== (string) $border['width'] ) {
+ $declarations[] = 'border-width:' . (string) $border['width'];
+ }
+ if ( isset($border['style']) && '' !== (string) $border['style'] ) {
+ $declarations[] = 'border-style:' . (string) $border['style'];
+ }
+ if ( isset($border['radius']) && '' !== (string) $border['radius'] ) {
+ $declarations[] = 'border-radius:' . (string) $border['radius'];
+ }
+
+ $padding = is_array($style['spacing']['padding'] ?? null) ? $style['spacing']['padding'] : array();
+ foreach ( array( 'top', 'right', 'bottom', 'left' ) as $side ) {
+ if ( isset($padding[$side]) && '' !== (string) $padding[$side] ) {
+ $declarations[] = 'padding-' . $side . ':' . (string) $padding[$side];
+ }
+ }
+
+ $typography = is_array($style['typography'] ?? null) ? $style['typography'] : array();
+ $typographyMap = array(
+ 'fontSize' => 'font-size',
+ 'fontWeight' => 'font-weight',
+ 'lineHeight' => 'line-height',
+ 'textTransform' => 'text-transform',
+ );
+ foreach ( $typographyMap as $attrName => $cssName ) {
+ if ( isset($typography[$attrName]) && '' !== (string) $typography[$attrName] ) {
+ $declarations[] = $cssName . ':' . (string) $typography[$attrName];
+ }
+ }
+
+ return array(
+ 'classes' => implode(' ', $classes),
+ 'style' => implode(';', $declarations),
+ );
+ }
+
private function mergeClassNames(string ...$classNames): string
{
$classes = array();
diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
index cc786234..a5cd5278 100644
--- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
+++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
@@ -3182,7 +3182,7 @@ private function interactiveAttributes(DOMElement $element): array
private function structureSignals(DOMElement $element, array $attrs): array
{
$className = strtolower(trim($this->attr($element, 'class') . ' ' . (string) ($attrs['className'] ?? '')));
- $style = strtolower(trim($this->attr($element, 'style') . ';' . (string) ($attrs['style'] ?? '')));
+ $style = strtolower(trim($this->attr($element, 'style') . ';' . (is_string($attrs['style'] ?? null) ? $attrs['style'] : '')));
$signals = array();
if ( preg_match('/(?:^|[\s_-])(?:card|feature|service|provider|resource|post|project|stat|badge|tile|panel|item)(?:$|[\s_-])/', $className) || 'article' === strtolower($element->tagName) ) {
diff --git a/php-transformer/src/HtmlToBlocks/Patterns/ButtonStyleResolver.php b/php-transformer/src/HtmlToBlocks/Patterns/ButtonStyleResolver.php
new file mode 100644
index 00000000..3e3d2a93
--- /dev/null
+++ b/php-transformer/src/HtmlToBlocks/Patterns/ButtonStyleResolver.php
@@ -0,0 +1,269 @@
+`/linked CSS rules the transformer already matches to the element)
+ * into native WordPress core/button block attributes.
+ *
+ * This keeps imported buttons rendering with their source colors and borders
+ * instead of falling back to the theme's default (grey) button styling, because
+ * the styling lives in canonical block attributes (style.color.*, style.border.*)
+ * rather than a non-canonical inline style string that WordPress drops on
+ * block recovery.
+ *
+ * The translation is theme-independent and keys only off the resolved
+ * declarations:
+ * - Filled buttons get style.color.background + style.color.text (+ border radius).
+ * - Outline/ghost buttons (transparent background) get style.border.* + style.color.text
+ * and never a background.
+ * A button whose resolved CSS carries no paintable colors/border stays default.
+ */
+final class ButtonStyleResolver
+{
+ /**
+ * Build native core/button style attributes from a resolved CSS string.
+ *
+ * @return array Either an empty array (no native styling) or
+ * an array with a `style` object suitable for the
+ * core/button block attributes.
+ */
+ public function nativeAttributes(string $resolvedStyle): array
+ {
+ $declarations = $this->declarations($resolvedStyle);
+ if ( array() === $declarations ) {
+ return array();
+ }
+
+ $style = array();
+
+ $background = $this->backgroundColor($declarations);
+ $text = $this->color($declarations['color'] ?? '');
+ if ( '' !== $background ) {
+ $style['color']['background'] = $background;
+ }
+ if ( '' !== $text ) {
+ $style['color']['text'] = $text;
+ }
+
+ $border = $this->border($declarations);
+ if ( array() !== $border ) {
+ $style['border'] = $border;
+ }
+
+ $padding = $this->padding($declarations);
+ if ( array() !== $padding ) {
+ $style['spacing']['padding'] = $padding;
+ }
+
+ $typography = $this->typography($declarations);
+ if ( array() !== $typography ) {
+ $style['typography'] = $typography;
+ }
+
+ if ( array() === $style ) {
+ return array();
+ }
+
+ return array( 'style' => $style );
+ }
+
+ /**
+ * Resolve the background color, ignoring transparent/none/gradient/image
+ * backgrounds so outline/ghost buttons never receive a paintable fill.
+ *
+ * @param array $declarations
+ */
+ private function backgroundColor(array $declarations): string
+ {
+ $value = trim((string) ($declarations['background-color'] ?? ''));
+ if ( '' === $value ) {
+ // `background` shorthand: only treat it as a fill when it is a bare color.
+ $value = trim((string) ($declarations['background'] ?? ''));
+ if ( '' === $value || preg_match('/\b(?:url\s*\(|gradient\s*\()/i', $value) ) {
+ return '';
+ }
+ // Take the first token of the shorthand as the candidate color.
+ $value = preg_split('/\s+/', $value)[0] ?? '';
+ }
+
+ return $this->color($value);
+ }
+
+ /**
+ * @param array $declarations
+ * @return array
+ */
+ private function border(array $declarations): array
+ {
+ $border = array();
+
+ // Longhand declarations win over the shorthand.
+ $shorthand = $this->parseBorderShorthand((string) ($declarations['border'] ?? ''));
+
+ $width = trim((string) ($declarations['border-width'] ?? $shorthand['width'] ?? ''));
+ $style = strtolower(trim((string) ($declarations['border-style'] ?? $shorthand['style'] ?? '')));
+ $color = $this->color((string) ($declarations['border-color'] ?? $shorthand['color'] ?? ''));
+
+ // `border: 0` / `border: none` means no border at all.
+ $noBorder = 'none' === $style || ( '' !== $width && (float) $width === 0.0 && '' === $color && '' === $style );
+ if ( ! $noBorder ) {
+ if ( '' !== $width && (float) $width !== 0.0 ) {
+ $border['width'] = $width;
+ }
+ if ( '' !== $style && 'none' !== $style ) {
+ $border['style'] = $style;
+ }
+ if ( '' !== $color ) {
+ $border['color'] = $color;
+ }
+ }
+
+ $radius = trim((string) ($declarations['border-radius'] ?? ''));
+ if ( '' !== $radius ) {
+ $border['radius'] = $radius;
+ }
+
+ return $border;
+ }
+
+ /**
+ * @return array{width?: string, style?: string, color?: string}
+ */
+ private function parseBorderShorthand(string $value): array
+ {
+ $value = trim($value);
+ if ( '' === $value ) {
+ return array();
+ }
+
+ $parsed = array();
+ foreach ( preg_split('/\s+/', $value) ?: array() as $token ) {
+ $lower = strtolower($token);
+ if ( in_array($lower, array( 'none', 'hidden', 'solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'inset', 'outset' ), true) ) {
+ $parsed['style'] = $lower;
+ continue;
+ }
+ if ( preg_match('/^[0-9.]+(?:px|em|rem|%|pt|vw|vh)?$/i', $token) || in_array($lower, array( 'thin', 'medium', 'thick' ), true) ) {
+ $parsed['width'] = $token;
+ continue;
+ }
+ if ( '' !== $this->color($token) ) {
+ $parsed['color'] = $token;
+ }
+ }
+
+ return $parsed;
+ }
+
+ /**
+ * @param array $declarations
+ * @return array
+ */
+ private function padding(array $declarations): array
+ {
+ $shorthand = trim((string) ($declarations['padding'] ?? ''));
+ $sides = array( 'top' => '', 'right' => '', 'bottom' => '', 'left' => '' );
+
+ if ( '' !== $shorthand ) {
+ $parts = preg_split('/\s+/', $shorthand) ?: array();
+ $count = count($parts);
+ if ( 1 === $count ) {
+ $sides = array( 'top' => $parts[0], 'right' => $parts[0], 'bottom' => $parts[0], 'left' => $parts[0] );
+ } elseif ( 2 === $count ) {
+ $sides = array( 'top' => $parts[0], 'right' => $parts[1], 'bottom' => $parts[0], 'left' => $parts[1] );
+ } elseif ( 3 === $count ) {
+ $sides = array( 'top' => $parts[0], 'right' => $parts[1], 'bottom' => $parts[2], 'left' => $parts[1] );
+ } elseif ( $count >= 4 ) {
+ $sides = array( 'top' => $parts[0], 'right' => $parts[1], 'bottom' => $parts[2], 'left' => $parts[3] );
+ }
+ }
+
+ foreach ( array( 'top', 'right', 'bottom', 'left' ) as $side ) {
+ $longhand = trim((string) ($declarations[ 'padding-' . $side ] ?? ''));
+ if ( '' !== $longhand ) {
+ $sides[ $side ] = $longhand;
+ }
+ }
+
+ return array_filter($sides, static fn (string $value): bool => '' !== trim($value));
+ }
+
+ /**
+ * @param array $declarations
+ * @return array
+ */
+ private function typography(array $declarations): array
+ {
+ $typography = array();
+ $map = array(
+ 'font-size' => 'fontSize',
+ 'font-weight' => 'fontWeight',
+ 'line-height' => 'lineHeight',
+ 'text-transform' => 'textTransform',
+ );
+
+ foreach ( $map as $cssName => $attrName ) {
+ $value = trim((string) ($declarations[ $cssName ] ?? ''));
+ if ( '' !== $value ) {
+ $typography[ $attrName ] = $value;
+ }
+ }
+
+ return $typography;
+ }
+
+ /**
+ * Return the value when it is a usable CSS color, otherwise an empty string.
+ */
+ private function color(string $value): string
+ {
+ $value = trim($value);
+ if ( '' === $value ) {
+ return '';
+ }
+
+ $lower = strtolower($value);
+ if ( in_array($lower, array( 'transparent', 'none', 'inherit', 'initial', 'unset', 'revert', 'auto' ), true) ) {
+ return '';
+ }
+
+ if ( preg_match('/^#[0-9a-f]{3,8}$/i', $value) ) {
+ return $value;
+ }
+ if ( preg_match('/^(?:rgb|rgba|hsl|hsla)\s*\(/i', $value) ) {
+ return $value;
+ }
+ if ( 'currentcolor' === $lower ) {
+ return 'currentColor';
+ }
+ // Named colors (e.g. white, navy). Reject anything with whitespace/symbols.
+ if ( preg_match('/^[a-z]+$/', $lower) ) {
+ return $value;
+ }
+
+ return '';
+ }
+
+ /**
+ * @return array
+ */
+ private function declarations(string $style): array
+ {
+ $declarations = array();
+ foreach ( explode(';', $style) as $declaration ) {
+ if ( ! str_contains($declaration, ':') ) {
+ continue;
+ }
+ [ $name, $value ] = array_map('trim', explode(':', $declaration, 2));
+ $name = strtolower($name);
+ if ( '' !== $name && '' !== $value ) {
+ $declarations[ $name ] = preg_replace('/\s+/', ' ', $value) ?? $value;
+ }
+ }
+
+ return $declarations;
+ }
+}
diff --git a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php
index 8ae88171..bd973730 100644
--- a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php
+++ b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php
@@ -9,6 +9,13 @@ 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;
+
+ public function __construct()
+ {
+ $this->styleResolver = new ButtonStyleResolver();
+ }
+
/**
* @param callable(DOMElement): array|null $fileBlockFromAnchor
* @param callable(DOMElement): array $presentationAttributes
@@ -104,10 +111,22 @@ private function buttonText(string $html): string
private function buttonPresentationAttributes(DOMElement $element, callable $presentationAttributes): array
{
$attrs = $presentationAttributes($element);
- if ( $this->hasOutlineSignal($element, (string) ($attrs['style'] ?? '')) ) {
+ $resolvedStyle = (string) ($attrs['style'] ?? '');
+ if ( $this->hasOutlineSignal($element, $resolvedStyle) ) {
$attrs['className'] = trim((string) ($attrs['className'] ?? '') . ' is-style-outline');
}
+ // Translate the resolved source CSS (inline style merged with the matched
+ // Buy now