diff --git a/packages/blocks-engine/src/wp/__tests__/button-save-validity.test.ts b/packages/blocks-engine/src/wp/__tests__/button-save-validity.test.ts new file mode 100644 index 00000000..9311d338 --- /dev/null +++ b/packages/blocks-engine/src/wp/__tests__/button-save-validity.test.ts @@ -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): 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; + 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: 'Product$25', + url: '/product', + style: { color: { background: '#123456' } }, + }); + const persisted = wp.serialize([button]); + const reloaded = wp.parse(persisted); + + expect(persisted).toContain('
'); + expect(persisted).toContain(' Source control DOM paths mapped to core/button wrapper classes. */ private array $sourceControlMarkers = array(); + /** @var array Direct flex-child controls mapped to synthetic wrapper bridge CSS. */ + private array $directFlexButtonStyleRules = array(); + + /** @var array Full-width controls mapped to synthetic wrapper bridge CSS. */ + private array $fullWidthButtonStyleRules = array(); + /** @var array Source wrapper paths promoted into core/button. */ private array $sourceButtonPresentationMarkers = array(); @@ -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(); @@ -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 ) { @@ -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]; @@ -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() ?? ''; @@ -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)) diff --git a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php index 13a6a68a..2fba01e7 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php @@ -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( @@ -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); } @@ -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'); } @@ -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); } @@ -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 $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; diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 50a52e36..f17ff76c 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -856,6 +856,92 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(str_contains($descendantSurfaceButtonCss, '> :where(.wp-block-button__link)') && str_contains($descendantSurfaceButtonCss, 'min-width:170px') && str_contains($descendantSurfaceButtonCss, 'padding:22px 26px'), 'composite button descendant selectors project their complete painted geometry onto the native link'); $assert('pass' === ($descendantSurfaceButton['source_reports']['wp_block_validity']['status'] ?? ''), 'composite button surface conversion remains editor-valid'); +$flexAnchorButton = ( new HtmlTransformer() )->transform( + '
Product$25
' +)->toArray(); +$flexAnchorButtonAttrs = $flexAnchorButton['blocks'][0]['innerBlocks'][0]['innerBlocks'][0]['attrs'] ?? array(); +$flexAnchorButtonMarkup = (string) ($flexAnchorButton['serialized_blocks'] ?? ''); +$flexAnchorButtonCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $flexAnchorButton['assets'] ?? array())); +$assert(str_contains((string) ($flexAnchorButtonAttrs['className'] ?? ''), 'blocks-engine-control-') && ! str_contains((string) ($flexAnchorButtonAttrs['className'] ?? ''), 'product-row'), 'styled anchor button uses a generated control marker instead of its source anchor class'); +$assert(! str_contains($flexAnchorButtonMarkup, 'wp-block-button product-row') && ! str_contains($flexAnchorButtonMarkup, 'wp-element-button product-row'), 'styled anchor button keeps source anchor classes out of canonical core/button markup'); +$assert(str_contains($flexAnchorButtonCss, '> :where(.wp-block-button__link){display:flex;align-items:center;gap:1rem') && str_contains($flexAnchorButtonCss, 'blocks-engine-richtext-marker') && str_contains($flexAnchorButtonCss, '{flex:1}'), 'styled anchor root and descendant selectors project through the generated marker after lowering'); +$assert(str_contains($flexAnchorButtonMarkup, 'class="product-row__name"'), 'styled anchor button preserves descendant classes in its RichText content'); +$assert('pass' === ($flexAnchorButton['source_reports']['wp_block_validity']['status'] ?? ''), 'styled anchor button remains editor-valid with marker-projected source selectors'); + +$flexChainButton = ( new HtmlTransformer() )->transform( + '
' +)->toArray(); +$flexChainButtonMarkup = (string) ($flexChainButton['serialized_blocks'] ?? ''); +$flexChainButtonCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $flexChainButton['assets'] ?? array())); +$assert(str_contains($flexChainButtonMarkup, 'wp-block-buttons blocks-engine-control-') && str_contains($flexChainButtonMarkup, 'wp-block-button blocks-engine-control-'), 'direct flex-child anchor carries one generated marker across both synthetic wrappers'); +$assert(str_contains($flexChainButtonCss, '.wp-block-buttons){display:block!important;gap:0!important;min-width:0;width:100%!important}') && str_contains($flexChainButtonCss, '.wp-block-button){display:block!important;margin:0!important;min-width:0;width:100%!important}') && str_contains($flexChainButtonCss, '.wp-block-button__link){box-sizing:border-box;width:100%!important}'), 'direct column flex-child anchor bridges wrapper sizing while only the synthetic inner wrapper has neutral margin'); +$assert('pass' === ($flexChainButton['source_reports']['wp_block_validity']['status'] ?? ''), 'direct flex-child wrapper chain remains editor-valid'); + +$flexAnchorAutoMargin = ( new HtmlTransformer() )->transform( + '
' +)->toArray(); +$flexAnchorAutoMarginMarkup = (string) ($flexAnchorAutoMargin['serialized_blocks'] ?? ''); +$flexAnchorAutoMarginCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $flexAnchorAutoMargin['assets'] ?? array())); +$assert(str_contains($flexAnchorAutoMarginMarkup, 'margin-right:auto') && 2 === substr_count($flexAnchorAutoMarginMarkup, 'wp-block-buttons'), 'direct flex anchor preserves its authored auto margin on the lowered source flex-item wrapper beside navigation and button siblings'); +$assert(str_contains($flexAnchorAutoMarginCss, '.wp-block-buttons){display:block!important;gap:0!important;min-width:0}') && ! str_contains($flexAnchorAutoMarginCss, '.wp-block-buttons){display:block!important;gap:0!important;margin:0!important;min-width:0}') && str_contains($flexAnchorAutoMarginCss, '.wp-block-button){display:block!important;margin:0!important;min-width:0}'), 'direct flex bridge leaves source wrapper margins intact while neutralizing only the synthetic inner wrapper'); +$assert('pass' === ($flexAnchorAutoMargin['source_reports']['wp_block_validity']['status'] ?? ''), 'direct flex anchor with auto margin remains editor-valid beside navigation and button siblings'); + +$anchorButtonMarginCases = array( + 'directional' => array( + 'source' => 'margin-left:2rem;margin-right:3rem', + 'expected' => array( 'right' => '3rem', 'left' => '2rem' ), + 'css' => 'margin-left:2rem;margin-right:3rem', + ), + 'shorthand' => array( + 'source' => 'margin:1rem 2rem 3rem 4rem', + 'expected' => array( 'top' => '1rem', 'right' => '2rem', 'bottom' => '3rem', 'left' => '4rem' ), + 'css' => 'margin:1rem 2rem 3rem 4rem', + ), +); +foreach ( $anchorButtonMarginCases as $marginCase => $margin ) { + $directFlexMarginButton = ( new HtmlTransformer() )->transform( + '
' + )->toArray(); + $directFlexMarginWrapper = $directFlexMarginButton['blocks'][0]['innerBlocks'][0] ?? array(); + $directFlexMarginInner = $directFlexMarginWrapper['innerBlocks'][0] ?? array(); + $directFlexMarginCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $directFlexMarginButton['assets'] ?? array())); + $assert($margin['expected'] === ($directFlexMarginWrapper['attrs']['style']['spacing']['margin'] ?? null), 'direct-flex ' . $marginCase . ' anchor margin stays on the outer core/buttons source flex item'); + $assert(! isset($directFlexMarginInner['attrs']['style']['spacing']['margin']) && str_contains($directFlexMarginCss, '.wp-block-button){display:block!important;margin:0!important;min-width:0;width:100%!important}'), 'direct-flex ' . $marginCase . ' anchor keeps the synthetic inner core/button margin-neutral'); + $assert(str_contains($directFlexMarginCss, $margin['css']) && ! str_contains($directFlexMarginCss, $margin['css'] . '!important'), 'direct-flex ' . $marginCase . ' anchor preserves authored outer margin priority without !important'); + + $fullWidthMarginButton = ( new HtmlTransformer() )->transform( + '
Start
' + )->toArray(); + $fullWidthMarginWrapper = $fullWidthMarginButton['blocks'][0] ?? array(); + $fullWidthMarginInner = $fullWidthMarginWrapper['innerBlocks'][0] ?? array(); + $fullWidthMarginCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $fullWidthMarginButton['assets'] ?? array())); + $assert($margin['expected'] === ($fullWidthMarginWrapper['attrs']['style']['spacing']['margin'] ?? null), 'full-width ' . $marginCase . ' anchor margin stays on the outer core/buttons wrapper'); + $assert(! isset($fullWidthMarginInner['attrs']['style']['spacing']['margin']) && str_contains($fullWidthMarginCss, '.wp-block-button){display:block!important;margin:0!important;width:100%!important}'), 'full-width ' . $marginCase . ' anchor keeps the synthetic inner core/button margin-neutral'); + $assert(str_contains($fullWidthMarginCss, $margin['css']) && ! str_contains($fullWidthMarginCss, $margin['css'] . '!important'), 'full-width ' . $marginCase . ' anchor preserves authored outer margin priority without !important'); +} + +$fullWidthAnchorButton = ( new HtmlTransformer() )->transform( + '
Submit
' +)->toArray(); +$fullWidthAnchorButtonMarkup = (string) ($fullWidthAnchorButton['serialized_blocks'] ?? ''); +$fullWidthAnchorButtonCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $fullWidthAnchorButton['assets'] ?? array())); +$assert(str_contains($fullWidthAnchorButtonMarkup, 'has-custom-width wp-block-button__width-100') && str_contains($fullWidthAnchorButtonMarkup, 'blocks-engine-control-'), 'styled full-width anchor preserves native core/button width support and its generated marker'); +$assert(! str_contains($fullWidthAnchorButtonMarkup, 'wp-block-button selector-submit') && ! str_contains($fullWidthAnchorButtonMarkup, 'wp-element-button selector-submit'), 'styled full-width anchor without descendants keeps authored root classes out of canonical button markup'); +$assert(str_contains($fullWidthAnchorButtonCss, '.wp-block-buttons){display:block!important;gap:0!important;width:100%!important}') && str_contains($fullWidthAnchorButtonCss, '.wp-block-button){display:block!important;margin:0!important;width:100%!important}') && str_contains($fullWidthAnchorButtonCss, '.wp-block-button__link){box-sizing:border-box;width:100%!important}'), 'styled full-width anchor bridges width through every synthetic wrapper while preserving source wrapper margins'); +$assert('pass' === ($fullWidthAnchorButton['source_reports']['wp_block_validity']['status'] ?? ''), 'styled full-width anchor wrapper chain remains editor-valid'); + +$fullWidthNativeButton = ( new HtmlTransformer() )->transform( + '
' +)->toArray(); +$fullWidthNativeButtonMarkup = (string) ($fullWidthNativeButton['serialized_blocks'] ?? ''); +$fullWidthNativeButtonAttrs = $fullWidthNativeButton['blocks'][0]['innerBlocks'][0]['attrs'] ?? array(); +$fullWidthNativeButtonCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $fullWidthNativeButton['assets'] ?? array())); +$assert(100 === ($fullWidthNativeButtonAttrs['width'] ?? null) && str_contains((string) ($fullWidthNativeButtonAttrs['className'] ?? ''), 'blocks-engine-control-') && ! str_contains((string) ($fullWidthNativeButtonAttrs['className'] ?? ''), 'selector-submit'), 'styled full-width native button uses native width support and a generated marker instead of source root classes'); +$assert(! str_contains($fullWidthNativeButtonMarkup, 'wp-block-button selector-submit') && ! str_contains($fullWidthNativeButtonMarkup, 'wp-element-button selector-submit'), 'styled full-width native button keeps source root classes out of canonical markup'); +$assert(! str_contains((string) ($fullWidthNativeButtonAttrs['className'] ?? ''), 'is-style-outline') && '#123456' === ($fullWidthNativeButtonAttrs['style']['color']['background'] ?? null), 'a filled button variant overrides an earlier native-button background reset without becoming an outline control'); +$assert(str_contains($fullWidthNativeButtonCss, '.wp-block-buttons){display:block!important;gap:0!important;width:100%!important}') && str_contains($fullWidthNativeButtonCss, '.wp-block-button__link){box-sizing:border-box;width:100%!important}'), 'styled full-width native button projects root geometry through the wrapper chain without overriding source wrapper margins'); +$assert('pass' === ($fullWidthNativeButton['source_reports']['wp_block_validity']['status'] ?? ''), 'styled full-width native button wrapper chain remains editor-valid'); + $contextualSurfaceButton = ( new HtmlTransformer() )->transform( '
Learn more
' )->toArray();