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
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { createRequire } from 'node:module';
import { setupDomGlobals } from '../dom-globals.js';

const require = createRequire(import.meta.url);

type WpRuntime = {
registerCoreBlocks(): void;
createBlock(name: string, attributes: Record<string, unknown>): unknown;
serialize(blocks: unknown[]): string;
parse(markup: string): unknown[];
validateBlock(block: unknown): [boolean, unknown[]];
};

describe('core/button save validity', () => {
let wp: WpRuntime;

beforeAll(() => {
setupDomGlobals();
wp = require('@wordpress/blocks') as WpRuntime;
const library = require('@wordpress/block-library') as Pick<WpRuntime, 'registerCoreBlocks'>;
library.registerCoreBlocks();
});

it('keeps the generated control carrier on the supported block wrapper', () => {
const button = wp.createBlock('core/button', {
className: 'blocks-engine-control-fixture',
text: '<span class="product-row__name">Product</span><span>$25</span>',
url: '/product',
style: { color: { background: '#123456' } },
});
const persisted = wp.serialize([button]);
const reloaded = wp.parse(persisted);

expect(persisted).toContain('<div class="wp-block-button blocks-engine-control-fixture">');
expect(persisted).toContain('<a class="wp-block-button__link has-background wp-element-button"');
expect(persisted).not.toContain('wp-block-button product-row');
expect(persisted).not.toContain('wp-block-button__link has-background product-row');
expect(reloaded).toHaveLength(1);
expect(wp.validateBlock(reloaded[0])[0]).toBe(true);
});
});
62 changes: 61 additions & 1 deletion php-transformer/src/HtmlToBlocks/HtmlTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,12 @@ final class HtmlTransformer
/** @var array<string, string> Source control DOM paths mapped to core/button wrapper classes. */
private array $sourceControlMarkers = array();

/** @var array<string, string> Direct flex-child controls mapped to synthetic wrapper bridge CSS. */
private array $directFlexButtonStyleRules = array();

/** @var array<string, string> Full-width controls mapped to synthetic wrapper bridge CSS. */
private array $fullWidthButtonStyleRules = array();

/** @var array<string, string> Source wrapper paths promoted into core/button. */
private array $sourceButtonPresentationMarkers = array();

Expand Down Expand Up @@ -578,6 +584,8 @@ public function transform(string $html, array $options = array()): TransformerRe
$this->gutenbergIncompatibilities = array();
$this->sourceTagMarkers = array();
$this->sourceControlMarkers = array();
$this->directFlexButtonStyleRules = array();
$this->fullWidthButtonStyleRules = array();
$this->sourceButtonPresentationMarkers = array();
$this->sourceControlPaths = array();
$this->sourceSemanticMarkers = array();
Expand Down Expand Up @@ -1037,6 +1045,12 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo
if ( array() !== $this->nativeButtonStyleRules ) {
$cssParts[] = implode("\n", $this->nativeButtonStyleRules);
}
if ( array() !== $this->directFlexButtonStyleRules ) {
$cssParts[] = implode("\n", $this->directFlexButtonStyleRules);
}
if ( array() !== $this->fullWidthButtonStyleRules ) {
$cssParts[] = implode("\n", $this->fullWidthButtonStyleRules);
}

$css = trim(implode("\n\n", $cssParts));
if ( '' === $css ) {
Expand Down Expand Up @@ -1548,7 +1562,7 @@ function (DOMElement $element) use ($shellTags): string {
$hasNonProjected = false;
foreach ( $matches as $element ) {
$path = $element->getNodePath() ?? '';
if ( $this->requiresStandaloneInlineLayoutLeaf($element) ) {
if ( $this->requiresStandaloneInlineLayoutLeaf($element) && ! $this->isDirectChildOfLoweredAuthorControl($element) ) {
$inlineLayoutCarriers = true;
} elseif ( isset($this->sourceControlMarkers[$path]) ) {
$controls[] = $this->sourceControlMarkers[$path];
Expand Down Expand Up @@ -3454,6 +3468,12 @@ private function createBlock(string $name, array $attrs = array(), array $innerB
$attrs['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), $this->sourceControlMarkers[$logicalControlPath]);
if ( 'core/button' === $name ) {
$this->registerNativeButtonStyleRule($this->sourceControlMarkers[$logicalControlPath], $attrs, $nativeButtonTextAlignment);
if ( $this->isDirectChildOfAuthorFlexLayout($logicalControl) ) {
$this->directFlexButtonStyleRules[$this->sourceControlMarkers[$logicalControlPath]] = $this->directFlexButtonStyleRule($this->sourceControlMarkers[$logicalControlPath], $logicalControl);
}
if ( 100 === (int) ($attrs['width'] ?? 0) ) {
$this->fullWidthButtonStyleRules[$this->sourceControlMarkers[$logicalControlPath]] = $this->fullWidthButtonStyleRule($this->sourceControlMarkers[$logicalControlPath]);
}
}
}
$presentationPath = $sourceElement->getNodePath() ?? '';
Expand Down Expand Up @@ -3967,11 +3987,51 @@ private function isDirectChildOfAuthorOwnedLayout(DOMElement $element): bool
return $element->parentNode instanceof DOMElement && $this->isAuthorOwnedLayout($element->parentNode);
}

private function isDirectChildOfAuthorFlexLayout(DOMElement $element): bool
{
return $element->parentNode instanceof DOMElement
&& in_array($this->authoredDisplay($element->parentNode), array( 'flex', 'inline-flex' ), true);
}

private function directFlexButtonStyleRule(string $marker, DOMElement $control): string
{
$parent = $control->parentNode;
$parentStyle = $parent instanceof DOMElement ? $this->structuralPresentationDeclarations($parent) : array();
$isColumn = str_starts_with(strtolower(trim((string) ($parentStyle['flex-direction'] ?? 'row'))), 'column');
$wrapper = ':where(.' . $marker . '.wp-block-buttons)';
$button = ':where(.' . $marker . '.wp-block-buttons)>:where(.' . $marker . '.wp-block-button)';
$link = $button . '>:where(.wp-block-button__link)';
$columnGeometry = $isColumn ? ';width:100%!important' : '';

// The outer core/buttons wrapper is the lowered source flex item, so its
// authored margins must remain intact. Only core/button is synthetic.
return $wrapper . '{display:block!important;gap:0!important;min-width:0' . $columnGeometry . '}'
. $button . '{display:block!important;margin:0!important;min-width:0' . $columnGeometry . '}'
. $link . '{box-sizing:border-box' . ($isColumn ? ';width:100%!important' : '') . '}';
}

private function fullWidthButtonStyleRule(string $marker): string
{
$wrapper = ':where(.' . $marker . '.wp-block-buttons)';
$button = ':where(.' . $marker . '.wp-block-buttons)>:where(.' . $marker . '.wp-block-button)';
$link = $button . '>:where(.wp-block-button__link)';

return $wrapper . '{display:block!important;gap:0!important;width:100%!important}'
. $button . '{display:block!important;margin:0!important;width:100%!important}'
. $link . '{box-sizing:border-box;width:100%!important}';
}

private function isDirectChildOfStructuralLayout(DOMElement $element): bool
{
return $element->parentNode instanceof DOMElement && $this->isStructuralLayoutElement($element->parentNode);
}

private function isDirectChildOfLoweredAuthorControl(DOMElement $element): bool
{
return $element->parentNode instanceof DOMElement
&& isset($this->sourceControlPaths[$element->parentNode->getNodePath() ?? '']);
}

private function requiresStandaloneInlineLayoutLeaf(DOMElement $element): bool
{
if ( ! $this->isInlineContentElement(strtolower($element->tagName))
Expand Down
28 changes: 19 additions & 9 deletions php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,14 @@ public function matchAnchor(DOMElement $anchor, callable $fileBlockFromAnchor, c
*/
public function matchButton(DOMElement $button, callable $presentationAttributes, callable $resolvedStyle, callable $innerHtml, callable $materializeSvgImages, callable $isGridItem, callable $createBlock): array
{
$resolvedButtonStyle = trim((string) $resolvedStyle($button));
$attrs = $this->buttonPresentationAttributes($button, $presentationAttributes, $resolvedStyle);
if ( $isGridItem($button) ) {
$attrs['width'] = 100;
}
if ( 100 === (int) ($attrs['width'] ?? 0) && $resolvedButtonStyle !== trim($button->getAttribute('style')) ) {
$this->removeSourceControlClasses($attrs, $button);
}
$text = $this->buttonText($button, $innerHtml($button), $materializeSvgImages);

return $createBlock('core/buttons', $this->buttonWrapperAttributes($button, $presentationAttributes, $resolvedStyle), array(
Expand Down Expand Up @@ -131,18 +135,16 @@ private function buttonBlockFromAnchor(DOMElement $anchor, callable $presentatio
$attrs['style']['border']['radius'] = '0';
}
}
// The canonical core/button wrapper is structural. A source control's
// classes would otherwise let an unprojected stylesheet paint that outer
// div instead of the link that Gutenberg actually renders as the button.
// core/button only saves className on its wrapper. Anchor-root selectors
// are projected through the generated control marker onto the saved link.
if ( $hasAuthoredStyleRules && ($presentationElement === $anchor || $presentationElement->parentNode === $anchor) ) {
$this->removeSourceControlClasses($attrs, $presentationElement);
}

$text = $this->buttonText($anchor, $innerHtml($anchor), $materializeSvgImages);

return $createBlock('core/button', array_filter(array_merge($attrs, array(
'text' => $text,
'url' => $attr($anchor, 'href'),
'text' => $text,
'url' => $attr($anchor, 'href'),
'title' => $this->buttonAccessibleTitle($anchor, $text),
)), static fn ($value): bool => is_array($value) ? array() !== $value : '' !== $value), array(), $presentationElement, $anchor);
}
Expand Down Expand Up @@ -318,7 +320,10 @@ private function buttonPresentationAttributes(DOMElement $element, callable $pre
// belongs on the parent core/buttons, not each button). Emitting it here
// produces an unsupported attribute and invalid block markup, so drop it.
unset($attrs['layout']);
$isOutline = $this->hasOutlineSignal($element, $resolvedStyle);
// Resolve native paint before classifying an outline: a generic reset such
// as `button { background: none }` can precede a filled button variant.
$native = $this->styleResolver->nativeAttributes($resolvedStyle);
$isOutline = $this->hasOutlineSignal($element, $resolvedStyle, $native);
if ( $isOutline ) {
$attrs['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), 'is-style-outline');
}
Expand All @@ -328,7 +333,6 @@ private function buttonPresentationAttributes(DOMElement $element, callable $pre
// button renders with its source colors/border instead of the theme default.
// A button with no paintable styling resolves to no native attributes and
// stays a default button.
$native = $this->styleResolver->nativeAttributes($resolvedStyle);
if ( array() !== $native ) {
$attrs = array_merge($attrs, $native);
}
Expand Down Expand Up @@ -420,12 +424,18 @@ private function removeSourceControlClasses(array &$attrs, DOMElement $element):
$attrs['className'] = implode(' ', $classes);
}

private function hasOutlineSignal(DOMElement $element, string $style): bool
/** @param array<string, mixed> $native */
private function hasOutlineSignal(DOMElement $element, string $style, array $native = array()): bool
{
if ( $this->hasAnyToken($element, array( 'outline', 'ghost', 'hollow', 'bordered' )) ) {
return true;
}

$background = trim((string) ($native['style']['color']['background'] ?? ''));
if ( '' !== $background && ! in_array(strtolower($background), array( 'transparent', 'none' ), true) ) {
return false;
}

$normalized = strtolower($style);
if ( ! preg_match('/(?:^|;)\s*border(?:-[a-z-]+)?\s*:\s*[^;]+/', $normalized) ) {
return false;
Expand Down
Loading
Loading