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
82 changes: 68 additions & 14 deletions php-transformer/src/HtmlToBlocks/HtmlTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<div class="label">
// The Shop</div>` 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 `<div class="label">The Shop
// </div>` 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 `<span>`).
if ( 0 === $this->childElementCount($element) && ! $this->hasBoxChromeWrapperStyling($element) ) {
return $this->createBlock(
'core/paragraph',
array_merge($this->presentationAttributes($element), array( 'content' => $content )),
Expand All @@ -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<int, string>
*/
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<int, string>
*/
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'));
Expand All @@ -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<int, string> $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;
}
}
Expand Down
13 changes: 13 additions & 0 deletions php-transformer/tests/unit/block-style-support-conversion.php
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,19 @@
$assert(str_contains($labelMarkup, '<div class="wp-block-group use-case-result'), '28: box-model card result row stays a group wrapper', $labelMarkup);
$assert(! preg_match('/<!-- wp:group[^>]*"className":"tier-name"/', $labelMarkup), '29: typography-only tier label does not round-trip as a group wrapping a default paragraph', $labelMarkup);

// A universal reset (`* { margin: 0; padding: 0 }`) sets zero-valued box
// properties on every element. Those must not count as box chrome, or an
// eyebrow like `<div class="eyebrow">The Shop</div>` 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 = '<header class="page-header"><div class="eyebrow">The Shop</div><h1>Carry something home.</h1></header>';
$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, '<p class="eyebrow">The Shop</p>'), '29b: a pure-text eyebrow under a universal zero reset collapses to a styled paragraph', $resetMarkup);
$assert(! preg_match('/<!-- wp:group[^>]*"className":"eyebrow"/', $resetMarkup), '29c: the zero-reset eyebrow does not round-trip as a one-child group', $resetMarkup);

$stackHtml = '<div class="hero-content"><p>Eyebrow</p><h1>Low Tide Table</h1><div></div><p>Local shrimp.</p><div><p>Next Run</p></div><div><a href="#reserve">Reserve</a></div></div>';
$stackResult = ( new HtmlTransformer() )->transform($stackHtml, array())->toArray();
$stack = $stackResult['blocks'][0] ?? array();
Expand Down
Loading