diff --git a/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php b/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php index 6af71f65..c1dfb8c5 100644 --- a/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php +++ b/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php @@ -115,6 +115,7 @@ private function marginSelectorPrelude(string $prelude, AuthorStylesheetProjecti private function rewriteStyleRule(string $prelude, string $body, AuthorStylesheetProjectionContext $context): string { $projectedPrelude = $this->rewriteSelectorPrelude($prelude, $context); + $body = $this->buttonLinkCompatDeclarations($prelude, $projectedPrelude, $body, $context); $wrapperPrelude = $this->buttonPresentationWrapperPrelude($prelude, $context); if ( '' === $wrapperPrelude ) { $directWrapperPrelude = $this->directButtonGeometryWrapperPrelude($prelude, $context); @@ -139,6 +140,67 @@ private function rewriteStyleRule(string $prelude, string $body, AuthorStyleshee return $this->withButtonWrapperInnerFill($wrapperPrelude, $layout, $projectedPrelude . '{' . $control . '}'); } + /** + * core/button defaults and support styles are emitted after carried author CSS. + * Keep source button declarations authoritative after their selector is lowered + * to a generated marker, including media-query overrides. + */ + private function buttonLinkCompatDeclarations(string $prelude, string $projectedPrelude, string $body, AuthorStylesheetProjectionContext $context): string + { + if ( ! str_contains($projectedPrelude, '.wp-block-button__link') || ! $this->projectsAnchorButtonControl($prelude, $context) ) { + return $body; + } + + $declarations = array(); + foreach ( CssValueSplitter::splitTopLevel($body, array( ';' )) as $declaration ) { + $colon = strpos($declaration, ':'); + if ( false === $colon ) { + $declarations[] = $declaration; + continue; + } + $name = trim(substr($declaration, 0, $colon)); + $value = trim(substr($declaration, $colon + 1)); + if ( '' === $name || '' === $value || ! $this->isButtonLinkLayoutProperty($name) || preg_match('/\s*!important\s*$/i', $value) ) { + $declarations[] = $declaration; + continue; + } + $declarations[] = $name . ':' . $value . '!important'; + } + return implode(';', $declarations); + } + + private function projectsAnchorButtonControl(string $prelude, AuthorStylesheetProjectionContext $context): bool + { + foreach ( CssStylesheetTransformer::splitSelectorList($prelude) ?? array() as $selector ) { + $parsed = $context->sourceStyles->parsedSelector($selector); + if ( ! $parsed['supported'] ) { + continue; + } + foreach ( $this->matchingSourceElements($selector, $parsed, $context) as $element ) { + if ( 'a' === strtolower($element->tagName) + && '' !== $context->selectorProjections->controlMarker($element->getNodePath() ?? '') ) { + return true; + } + } + } + return false; + } + + private function isButtonLinkLayoutProperty(string $property): bool + { + return 'display' === $property + || 'gap' === $property + || str_starts_with($property, 'flex-') + || str_starts_with($property, 'align-') + || str_starts_with($property, 'justify-') + || 'width' === $property + || 'height' === $property + || str_starts_with($property, 'min-') + || str_starts_with($property, 'max-') + || 'padding' === $property + || str_starts_with($property, 'padding-'); + } + private function withoutCollapsedButtonProjectedWidths(string $prelude, string $body): ?string { $selectors = CssStylesheetTransformer::splitSelectorList($prelude); diff --git a/php-transformer/src/HtmlToBlocks/Style/GeneratedBlockStyleProjector.php b/php-transformer/src/HtmlToBlocks/Style/GeneratedBlockStyleProjector.php index c5cd7c65..9242e1b5 100644 --- a/php-transformer/src/HtmlToBlocks/Style/GeneratedBlockStyleProjector.php +++ b/php-transformer/src/HtmlToBlocks/Style/GeneratedBlockStyleProjector.php @@ -174,6 +174,9 @@ public function registerNativeButtonStyleRule( $wrapperDeclarations = array(); $outerWrapperDeclarations = array(); $intrinsicWrapperDeclarations = array(); + $responsiveAuthoredProperties = $sourceControl instanceof DOMElement + ? $this->responsiveAuthoredProperties($sourceControl) + : array(); foreach ( array( 'background-color' => $style['color']['background'] ?? '', 'color' => $style['color']['text'] ?? '', @@ -191,6 +194,9 @@ public function registerNativeButtonStyleRule( 'padding-bottom' => $style['spacing']['padding']['bottom'] ?? '', 'padding-left' => $style['spacing']['padding']['left'] ?? '', ) as $property => $value ) { + if ( isset($responsiveAuthoredProperties[$property]) ) { + continue; + } $value = trim((string) $value); if ( '' !== $value && ! preg_match('/[{}<>;]/', $value) ) { $declarations[] = $property . ':' . $value . '!important'; @@ -277,6 +283,54 @@ public function registerNativeButtonStyleRule( $generatedStyles->registerNativeButton($marker, $outerWrapperRule . $wrapperRule . $intrinsicWrapperRule . '.' . $marker . '.' . $marker . '>.wp-block-button__link{' . implode(';', $declarations) . '}'); } + /** + * Block support values are resolved before responsive author CSS is replayed. + * Do not let the resolved value override a later authored breakpoint. + * + * @return array + */ + private function responsiveAuthoredProperties(DOMElement $sourceControl): array + { + $propertySources = array( + 'background-color' => array( 'background', 'background-color' ), + 'color' => array( 'color' ), + 'border-color' => array( 'border', 'border-color' ), + 'border-style' => array( 'border', 'border-style' ), + 'border-width' => array( 'border', 'border-width' ), + 'border-radius' => array( 'border-radius' ), + 'font-size' => array( 'font-size' ), + 'font-weight' => array( 'font-weight' ), + 'letter-spacing' => array( 'letter-spacing' ), + 'line-height' => array( 'line-height' ), + 'text-transform' => array( 'text-transform' ), + 'padding-top' => array( 'padding', 'padding-top' ), + 'padding-right' => array( 'padding', 'padding-right' ), + 'padding-bottom' => array( 'padding', 'padding-bottom' ), + 'padding-left' => array( 'padding', 'padding-left' ), + ); + $properties = array_values(array_unique(array_merge(...array_values($propertySources)))); + $declared = $this->styleResolver->authorDeclaredPropertyValues( + $sourceControl, + $properties + ); + $conditional = $this->styleResolver->conditionalAuthorDeclaredPropertyValues($sourceControl, $properties); + $responsive = array(); + foreach ( $propertySources as $property => $sources ) { + $values = array(); + $hasConditionalDeclaration = false; + foreach ( $sources as $source ) { + foreach ( $declared[$source] ?? array() as $value ) { + $values[CssValueInspector::comparable($value)] = true; + } + $hasConditionalDeclaration = $hasConditionalDeclaration || isset($conditional[$source]); + } + if ( $hasConditionalDeclaration && count($values) > 1 ) { + $responsive[$property] = true; + } + } + return $responsive; + } + public function registerDirectFlexButton(string $marker, DOMElement $control, GeneratedSupportStylesheetState $generatedStyles): void { $parent = $control->parentNode; diff --git a/php-transformer/src/HtmlToBlocks/Style/SourceBlockAttributeProjector.php b/php-transformer/src/HtmlToBlocks/Style/SourceBlockAttributeProjector.php index 278bd861..f44b5867 100644 --- a/php-transformer/src/HtmlToBlocks/Style/SourceBlockAttributeProjector.php +++ b/php-transformer/src/HtmlToBlocks/Style/SourceBlockAttributeProjector.php @@ -166,7 +166,7 @@ private function projectButtonAttributes( if ( $facts->isDirectChildOfAuthorFlexLayout ) { $this->generatedStyleProjector->registerDirectFlexButton($controlMarker, $logicalControl, $context->generatedStyles); } - self::registerButtonWidth($attrs, $controlMarker, $context, $this->generatedStyleProjector); + $this->registerButtonWidth($attrs, $controlMarker, $logicalControl, $context); } } if ( '' !== $controlMarker && '' !== $presentationPath && $presentationPath !== $logicalControlPath ) { @@ -190,7 +190,7 @@ private function projectButtonAttributes( : 'blocks-engine-native-button-alignment-' . $nativeButtonTextAlignment; $attrs['className'] = SourceDom::mergeClassNames((string) ($attrs['className'] ?? ''), $nativeButtonMarker); $this->generatedStyleProjector->registerNativeButtonStyleRule($nativeButtonMarker, $hasNativeButtonColor ? $attrs : array(), $context->generatedStyles, $nativeButtonTextAlignment); - self::registerButtonWidth($attrs, $nativeButtonMarker, $context, $this->generatedStyleProjector); + $this->registerButtonWidth($attrs, $nativeButtonMarker, $logicalControl, $context); } return $attrs; } @@ -261,12 +261,20 @@ private function buttonLabelHasAuthoredColor( } /** @param array $attrs */ - private static function registerButtonWidth(array $attrs, string $marker, SourceBlockAttributeProjectionContext $context, GeneratedBlockStyleProjector $generatedStyleProjector): void + private function registerButtonWidth(array $attrs, string $marker, DOMElement $sourceControl, SourceBlockAttributeProjectionContext $context): void { $buttonWidth = (int) ($attrs['width'] ?? 0); - if ( in_array($buttonWidth, array( 25, 50, 75, 100 ), true) ) { - $generatedStyleProjector->registerButtonWidth($marker, $buttonWidth, $context->generatedStyles); + if ( ! in_array($buttonWidth, array( 25, 50, 75, 100 ), true) ) { + return; } + if ( 100 === $buttonWidth ) { + foreach ( $this->styleResolver->authorDeclaredPropertyValues($sourceControl, array( 'width' ))['width'] ?? array() as $value ) { + if ( '100%' !== CssValueInspector::comparable($value) ) { + return; + } + } + } + $this->generatedStyleProjector->registerButtonWidth($marker, $buttonWidth, $context->generatedStyles); } /** @param array> $innerBlocks */ diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php index a2a33ca1..ea97bf52 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolver.php @@ -1004,6 +1004,30 @@ public function authorDeclaredPropertyValues(DOMElement $element, array $propert return $declared; } + /** + * Author-declared values from conditional rules, such as media queries. + * + * @param array $properties + * @return array> + */ + public function conditionalAuthorDeclaredPropertyValues(DOMElement $element, array $properties): array + { + sort($properties, SORT_STRING); + $wanted = array_fill_keys($properties, true); + $declared = array(); + foreach ( $this->styleRuleCandidates($element, 'conditional') as $rule ) { + if ( ! $this->matchesCssSelector($element, $rule['selector']) ) { + continue; + } + foreach ( $rule['declarations'] as $property => $value ) { + if ( isset($wanted[ strtolower((string) $property) ]) ) { + $declared[ strtolower((string) $property) ][] = $this->context->cssComparableValue((string) $value); + } + } + } + return $declared; + } + /** * One `text-align` declaration on a container carrier restores its whole * subtree, which is the source's own inheritance semantics: it covers block diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 6305e5e0..4044964a 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -1353,7 +1353,7 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter $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($flexAnchorButtonCss, '> :where(.wp-block-button__link){display:flex!important;align-items:center!important;gap:1rem!important') && str_contains($flexAnchorButtonCss, 'blocks-engine-richtext-marker') && str_contains($flexAnchorButtonCss, '{flex:1}'), 'styled anchor root and descendant selectors project through the generated marker with priority over core button defaults'); $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'); @@ -1429,6 +1429,18 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter $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'); +$responsiveFullWidthButton = ( new HtmlTransformer() )->transform( + '
' +)->toArray(); +$responsiveFullWidthButtonMarkup = (string) ($responsiveFullWidthButton['serialized_blocks'] ?? ''); +$responsiveFullWidthButtonCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $responsiveFullWidthButton['assets'] ?? array())); +$responsiveFullWidthButtonMarker = preg_match('/\bblocks-engine-control-[^\s"]+/', $responsiveFullWidthButtonMarkup, $matches) ? $matches[0] : ''; +$assert(str_contains($responsiveFullWidthButtonCss, '@media(min-width:992px)') && str_contains($responsiveFullWidthButtonCss, 'width:auto'), 'responsive full-width button retains its desktop intrinsic-width author rule'); +$assert(str_contains($responsiveFullWidthButtonMarkup, 'Learn more' )->toArray(); diff --git a/php-transformer/tests/fixtures/parity/html-responsive-grid-cta-intrinsic-width.json b/php-transformer/tests/fixtures/parity/html-responsive-grid-cta-intrinsic-width.json index c6bcebda..2e09c5ed 100644 --- a/php-transformer/tests/fixtures/parity/html-responsive-grid-cta-intrinsic-width.json +++ b/php-transformer/tests/fixtures/parity/html-responsive-grid-cta-intrinsic-width.json @@ -34,7 +34,7 @@ { "path": "assets.2.content", "assert": "contains", "value": ".wp-block-buttons{width:max-content;max-width:100%}" }, { "path": "assets.2.content", "assert": "contains", "value": ".wp-block-button{width:max-content;max-width:100%}" }, { "path": "assets.2.content", "assert": "contains", "value": "box-sizing:border-box;width:max-content;max-width:100%" }, - { "path": "assets.1.content", "assert": "contains", "value": "padding:12px 24px;border-radius:50px" }, + { "path": "assets.1.content", "assert": "contains", "value": "padding:12px 24px!important;border-radius:50px" }, { "path": "assets.1.content", "assert": "contains", "value": "@media(min-width:601px){.mobile-variant{display:none}.desktop-variant{display:grid}}" }, { "path": "fallbacks", "assert": "count", "count": 0 } ] diff --git a/php-transformer/tests/unit/button-signal-classifier.php b/php-transformer/tests/unit/button-signal-classifier.php index fbf67f1a..166358b0 100644 --- a/php-transformer/tests/unit/button-signal-classifier.php +++ b/php-transformer/tests/unit/button-signal-classifier.php @@ -82,7 +82,7 @@ $streamRow = ( new HtmlTransformer() )->transform('', array())->toArray(); $streamRowCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), $streamRow['assets'] ?? array())); $assert('core/buttons' === ($streamRow['blocks'][0]['blockName'] ?? '') && 2 === count($streamRow['blocks'][0]['innerBlocks'] ?? array()), '22: direct anchor CTA rows group explicit stylesheet surfaces as buttons', json_encode($streamRow['blocks'] ?? array())); -$assert(2 === substr_count($streamRowCss, '> :where(.wp-block-button__link)') && str_contains($streamRowCss, '{display:inline-flex;padding:10px 16px;background:#135e96'), '23: direct anchor CTA stylesheet selectors remain on both rendered button links', $streamRowCss); +$assert(2 === substr_count($streamRowCss, '> :where(.wp-block-button__link)') && str_contains($streamRowCss, '{display:inline-flex!important;padding:10px 16px!important;background:#135e96'), '23: direct anchor CTA stylesheet selectors remain on both rendered button links', $streamRowCss); $buttonResult = ( new HtmlTransformer() )->transform('', array())->toArray(); $nativeButton = $buttonResult['blocks'][0]['innerBlocks'][0] ?? array(); diff --git a/php-transformer/tests/unit/engine-support-css-asset.php b/php-transformer/tests/unit/engine-support-css-asset.php index 3c881894..59d858a8 100644 --- a/php-transformer/tests/unit/engine-support-css-asset.php +++ b/php-transformer/tests/unit/engine-support-css-asset.php @@ -54,7 +54,7 @@ $assert(1 === count($authorAssets), 'G2: transform emits exactly one author-css asset'); $normalizedAuthorCss = preg_replace('/\s+/', '', (string) ($authorAssets[0]['content'] ?? '')) ?? ''; $assert( - '@layercontract;.contract-author-only{color:#123456}.desktop-nava{color:#fff}:where(.blocks-engine-control-6494fb2a0d77-3):where(.wp-block-buttons){width:100%}:where(.blocks-engine-control-6494fb2a0d77-3):where(.wp-block-buttons)>:where(.wp-block-button){width:100%!important}:where(.blocks-engine-control-6494fb2a0d77-3):where(.wp-block-buttons)>:where(.wp-block-button)>:where(.wp-block-button__link){width:100%!important;max-width:100%!important}:where(.blocks-engine-control-6494fb2a0d77-3):not(.blocks-engine-specificity-class-6494fb2a0d77-1)>:where(.wp-block-button__link){display:inline-flex;padding:1rem;background:#123456}@media(max-width:700px){.desktop-nav{display:none}.mobile-nav{background:rgba(0,0,0,.9)}}' === $normalizedAuthorCss, + '@layercontract;.contract-author-only{color:#123456}.desktop-nava{color:#fff}:where(.blocks-engine-control-6494fb2a0d77-3):where(.wp-block-buttons){width:100%!important}:where(.blocks-engine-control-6494fb2a0d77-3):where(.wp-block-buttons)>:where(.wp-block-button){width:100%!important}:where(.blocks-engine-control-6494fb2a0d77-3):where(.wp-block-buttons)>:where(.wp-block-button)>:where(.wp-block-button__link){width:100%!important;max-width:100%!important}:where(.blocks-engine-control-6494fb2a0d77-3):not(.blocks-engine-specificity-class-6494fb2a0d77-1)>:where(.wp-block-button__link){display:inline-flex!important;padding:1rem!important;background:#123456}@media(max-width:700px){.desktop-nav{display:none}.mobile-nav{background:rgba(0,0,0,.9)}}' === $normalizedAuthorCss, 'G2: author-css contains only its leading at-rule preamble and rewritten author stylesheet' ); $assert('author' === ($authorAssets[0]['stylesheet_placement'] ?? ''), 'G4: author-css record declares author placement');