diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index eeca1d72..b71aaec4 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -3740,16 +3740,21 @@ private function visualTextWrapperBlockFromElement(DOMElement $element): ?array return null; } - // A pure-text styled wrapper whose CSS is typographic only (no box-model - // geometry) round-trips as a single styled `core/paragraph` carrying the - // wrapper class. The `core/group` + default inner paragraph form neither - // inherits the wrapper's typographic scale onto that inner paragraph nor - // suppresses default block spacing, so an eyebrow like `
- // The Shop
` renders at the wrong size and pushes every following - // block down. The group form is retained only when the wrapper owns - // box-model geometry (padding/border/flex) that a block-level container - // must preserve. - if ( 0 === $this->childElementCount($element) && ! $this->hasBoxModelWrapperStyling($element) ) { + // A pure-text styled wrapper whose CSS carries no block-level box chrome + // round-trips as a single styled `core/paragraph` carrying the wrapper + // class. The `core/group` + default inner paragraph form neither inherits + // the wrapper's typographic scale onto that inner paragraph nor suppresses + // default block spacing, so an eyebrow like `
The Shop + //
` renders at the wrong size and pushes every following block down. + // + // Only real box chrome — padding, border, or explicit sizing — disqualifies + // the collapse, because a paragraph cannot reproduce that geometry. Flex + // layout (`display`/`gap`) does not: a childless text leaf has no flex + // items, so its `display:inline-flex;gap` only positions a `::before` + // decoration, which the wrapper class still applies to the paragraph. Real + // flex containers hold child elements and are already excluded above by the + // `childElementCount === 0` guard (e.g. `.tier-price` wrapping a ``). + if ( 0 === $this->childElementCount($element) && ! $this->hasBoxChromeWrapperStyling($element) ) { return $this->createBlock( 'core/paragraph', array_merge($this->presentationAttributes($element), array( 'content' => $content )), @@ -3768,13 +3773,23 @@ private function visualTextWrapperBlockFromElement(DOMElement $element): ?array /** * Box-model CSS declarations that give a text wrapper block-level geometry - * (padding, border, explicit sizing, or flex/grid layout) which must be - * preserved on a `core/group` rather than flattened onto a paragraph. + * (padding, border, explicit sizing, or flex/grid layout) which mark it as a + * visual text wrapper worth preserving as a distinct block. * * @var array */ private const BOX_MODEL_WRAPPER_PROPERTIES = array( 'display', 'gap', 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'border', 'border-color', 'border-radius', 'width', 'height', 'min-width', 'max-width', 'min-height' ); + /** + * Box-chrome CSS declarations that a `core/paragraph` cannot reproduce, so a + * pure-text wrapper carrying any of them must stay a `core/group`. Excludes + * flex/grid layout properties, which only matter when the wrapper actually has + * child elements to lay out. + * + * @var array + */ + private const BOX_CHROME_WRAPPER_PROPERTIES = array( 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'border', 'border-color', 'border-radius', 'width', 'height', 'min-width', 'max-width', 'min-height' ); + private function hasVisualTextWrapperSignal(DOMElement $element): bool { $className = strtolower($this->attr($element, 'class')); @@ -3790,13 +3805,52 @@ private function hasVisualTextWrapperSignal(DOMElement $element): bool } private function hasBoxModelWrapperStyling(DOMElement $element): bool + { + return $this->wrapperStylingMatches($element, self::BOX_MODEL_WRAPPER_PROPERTIES); + } + + private function hasBoxChromeWrapperStyling(DOMElement $element): bool + { + return $this->wrapperStylingMatches($element, self::BOX_CHROME_WRAPPER_PROPERTIES); + } + + /** + * @param array $properties + */ + private function wrapperStylingMatches(DOMElement $element, array $properties): bool { // Read the raw matched declarations rather than the post-projection // presentation set: box-model properties such as padding are consumed // into block-supports attributes and would otherwise be invisible here. $declarations = $this->structuralPresentationDeclarations($element); - foreach ( self::BOX_MODEL_WRAPPER_PROPERTIES as $property ) { - if ( isset($declarations[$property]) && '' !== trim((string) $declarations[$property]) ) { + foreach ( $properties as $property ) { + if ( isset($declarations[$property]) && $this->cssValueIsNonZero((string) $declarations[$property]) ) { + return true; + } + } + + return false; + } + + /** + * Whether a CSS length/box value contributes real geometry. A universal reset + * (`* { margin: 0; padding: 0 }`) sets zero-valued box properties on every + * element; those must not be treated as box chrome or every wrapper would be + * disqualified from collapsing to a paragraph. Treats empty, `0`, `none`, and + * all-zero shorthand values (`0 0 0 0`, `0px`) as no geometry. + */ + private function cssValueIsNonZero(string $value): bool + { + $normalized = strtolower(trim($value)); + if ( '' === $normalized || 'none' === $normalized ) { + return false; + } + + foreach ( preg_split('/[\s,]+/', $normalized) ?: array() as $token ) { + if ( '' === $token ) { + continue; + } + if ( ! preg_match('/^0(?:\.0+)?[a-z%]*$/', $token) ) { return true; } } diff --git a/php-transformer/tests/unit/block-style-support-conversion.php b/php-transformer/tests/unit/block-style-support-conversion.php index dca52009..1df05b97 100644 --- a/php-transformer/tests/unit/block-style-support-conversion.php +++ b/php-transformer/tests/unit/block-style-support-conversion.php @@ -193,6 +193,19 @@ $assert(str_contains($labelMarkup, '
The Shop
` would be disqualified from +// collapsing and would render at the wrong scale with default block spacing. +// The wrapper also uses `display:inline-flex;gap` only to align a `::before` +// dash — flex without child elements is not block geometry. +$resetHtml = ''; +$resetCss = '*,*::before,*::after{margin:0;padding:0}.eyebrow{display:inline-flex;align-items:center;gap:0.8rem;font-size:0.68rem;letter-spacing:0.22em;text-transform:uppercase}.eyebrow::before{content:"";display:block;width:2.2rem;height:1px}'; +$resetResult = ( new HtmlTransformer() )->transform($resetHtml, array('static_css' => $resetCss))->toArray(); +$resetMarkup = (string) ($resetResult['serialized_blocks'] ?? ''); +$assert(str_contains($resetMarkup, '

The Shop

'), '29b: a pure-text eyebrow under a universal zero reset collapses to a styled paragraph', $resetMarkup); +$assert(! preg_match('/