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
1 change: 1 addition & 0 deletions php-transformer/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"php tests/unit/author-selector-semantics.php",
"php tests/unit/engine-support-css-asset.php",
"php tests/unit/engine-support-css-specificity.php",
"php tests/unit/inline-display-carrier.php",
"php tests/unit/artifact-author-stylesheet-projection.php",
"php tests/unit/fallback-finding-normalizer.php",
"php tests/unit/navigation-underline-color-resolver.php",
Expand Down
118 changes: 106 additions & 12 deletions php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,24 @@ trait StyleResolutionTrait
/**
* @return list<string>
*/
private function inlineGeometryProperties(): array
private function inlineLayoutCarrierProperties(): array
{
return array(
'display',
'flex-direction',
'flex-wrap',
'align-items',
'justify-content',
'gap',
);
}

/**
* @return list<string>
*/
private function inlineGeometryProperties(): array
{
return array_merge($this->inlineLayoutCarrierProperties(), array(
'width',
'height',
'min-width',
Expand All @@ -79,7 +94,7 @@ private function inlineGeometryProperties(): array
'flex-basis',
'object-fit',
'object-position',
);
));
}

/**
Expand Down Expand Up @@ -337,6 +352,17 @@ private function inlineGeometryClassName(
: $this->cssDeclarations($this->attr($element, 'style'));
$geometry = array();
$properties = $this->inlineGeometryProperties();
if ( $this->inlineDisplayOverridesAuthorLayout($element, $declarations) ) {
$inlineDisplay = strtolower(trim((string) preg_replace('/\s*!\s*important\s*$/i', '', (string) ($declarations['display'] ?? ''))));
if ( ! in_array($inlineDisplay, array( 'flex', 'inline-flex' ), true) ) {
$properties = array_values(array_diff(
$properties,
array( 'flex-direction', 'flex-wrap', 'align-items', 'justify-content', 'gap' )
));
}
} else {
$properties = array_values(array_diff($properties, $this->inlineLayoutCarrierProperties()));
}
$inlineBackground = (string) ($declarations['background'] ?? $declarations['background-image'] ?? '');
if ( preg_match('/\burl\s*\(/i', $inlineBackground)
&& ( 0 < $this->directElementChildCount($element) || '' !== trim((string) $element->textContent) )
Expand Down Expand Up @@ -367,27 +393,95 @@ private function inlineGeometryClassName(
return '';
}

// Emit carried declarations in source order: with per-declaration
// !important, last-write-wins is decided by rule order, and an
// alphabetical sort silently flips shorthand/longhand winners
// (grid vs grid-template-columns, gap vs column-gap). Values not
// present inline (forced/custom-property fallbacks) sort last.
// Emit carried declarations in source order. For declarations sharing
// a priority tier, last-write-wins is decided by rule order, and an
// alphabetical sort silently flips shorthand/longhand winners (grid vs
// grid-template-columns, gap vs column-gap). Values not present inline
// (forced/custom-property fallbacks) sort last.
$sourceOrder = array_flip(array_keys($declarations));
uksort($geometry, static fn (string $a, string $b): int => (($sourceOrder[$a] ?? PHP_INT_MAX) <=> ($sourceOrder[$b] ?? PHP_INT_MAX)) ?: strcmp($a, $b));
$declarations = array();
$layoutDeclarations = array();
$importantDeclarations = array();
$forcedPropertyLookup = array_fill_keys($forcedProperties, true);
$inlineLayoutPropertyLookup = array_fill_keys($this->inlineLayoutCarrierProperties(), true);
foreach ($geometry as $property => $value) {
if ( isset($inlineLayoutPropertyLookup[$property]) && ! isset($forcedPropertyLookup[$property]) ) {
// Preserve source inline layout over a later plain author class
// without preventing an authored !important declaration from
// retaining its normal cascade priority.
$layoutDeclarations[] = $property . ':' . $value;
continue;
}

// A converted inline declaration must continue to outrank authored
// normal selectors, including ID selectors. Authored !important
// rules retain their normal cascade priority through specificity.
$declarations[] = $property . ':' . $value . ' !important';
$importantDeclarations[] = $property . ':' . $value . ' !important';
}
$rule = implode(';', $declarations);
$className = ($this->geometryCarrierClassAllocator ??= new GeometryCarrierClassAllocator())->allocate($this->geometryStructuralPath($element) . "\n" . $rule);
$this->generatedGeometryRules[$className] = '.' . $className . '{' . $rule . '}';
$signature = implode(';', array_merge($layoutDeclarations, $importantDeclarations));
$className = ($this->geometryCarrierClassAllocator ??= new GeometryCarrierClassAllocator())->allocate($this->geometryStructuralPath($element) . "\n" . $signature);
$rules = array();
if ( array() !== $layoutDeclarations ) {
$rules[] = ':root .' . $className . '{' . implode(';', $layoutDeclarations) . '}';
}
if ( array() !== $importantDeclarations ) {
$rules[] = '.' . $className . '{' . implode(';', $importantDeclarations) . '}';
}
$this->generatedGeometryRules[$className] = implode("\n", $rules);

return $className;
}

/**
* An inline display needs a carrier only when materialized author CSS would
* otherwise reassert a different layout mode on the transformed element.
* Conditional variants count because the inline declaration owns every
* viewport in the source document.
*
* @param array<string, string> $inlineDeclarations
*/
private function inlineDisplayOverridesAuthorLayout(DOMElement $element, array $inlineDeclarations): bool
{
$inlineDisplay = strtolower(trim((string) preg_replace(
'/\s*!\s*important\s*$/i',
'',
(string) ($inlineDeclarations['display'] ?? '')
)));
if ( '' === $inlineDisplay ) {
return false;
}

foreach ( $this->staticStyleRules as $rule ) {
if ( ! $this->matchesCssSelector($element, $rule['selector']) ) {
continue;
}
$authorDisplay = strtolower(trim((string) preg_replace(
'/\s*!\s*important\s*$/i',
'',
(string) ($rule['declarations']['display'] ?? '')
)));
if ( '' !== $authorDisplay && $inlineDisplay !== $authorDisplay ) {
return true;
}
}

foreach ( $this->conditionalStyleRules as $rule ) {
if ( ! $this->matchesCssSelector($element, $rule['selector']) ) {
continue;
}
$conditionalDisplay = strtolower(trim((string) preg_replace(
'/\s*!\s*important\s*$/i',
'',
(string) ($rule['declarations']['display'] ?? '')
)));
if ( '' !== $conditionalDisplay && $inlineDisplay !== $conditionalDisplay ) {
return true;
}
}

return false;
}

/**
* A bare source <img> serializes inside a generated
* <figure class="wp-block-image">. An authored percentage height on the
Expand Down
117 changes: 117 additions & 0 deletions php-transformer/tests/unit/inline-display-carrier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);

/**
* Contract for inline display declarations which override class-owned layout.
*/

require dirname(__DIR__, 2) . '/vendor/autoload.php';

use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer;

$failures = 0;
$passes = 0;

$assert = static function (bool $condition, string $message, string $detail = '') use (&$failures, &$passes): void {
if ( $condition ) {
++$passes;
return;
}

++$failures;
fwrite(STDERR, 'FAIL: ' . $message . ('' !== $detail ? ' - ' . $detail : '') . PHP_EOL);
};

/** @return array<int, array<string, mixed>> */
$sourceAssets = static fn (array $result, string $source): array => array_values(array_filter(
is_array($result['assets'] ?? null) ? $result['assets'] : array(),
static fn (array $asset): bool => $source === ($asset['source'] ?? '')
));

$cssFor = static function (array $result, string $source) use ($sourceAssets): string {
return implode("\n", array_map(
static fn (array $asset): string => (string) ($asset['content'] ?? ''),
$sourceAssets($result, $source)
));
};

/** @return array{int, int, int} */
$specificity = static function (string $selector): array {
$ids = preg_match_all('/#[A-Za-z0-9_-]+/', $selector);
$classes = preg_match_all('/\.[A-Za-z0-9_-]+|\[[^\]]+\]|:(?!:)[A-Za-z0-9_-]+(?:\([^)]*\))?/', $selector);
$withoutWeightedTokens = preg_replace('/#[A-Za-z0-9_-]+|\.[A-Za-z0-9_-]+|\[[^\]]+\]|::?[A-Za-z0-9_-]+(?:\([^)]*\))?|[>+~*]/', ' ', $selector) ?? $selector;
$elements = preg_match_all('/(?:^|\s)([A-Za-z][A-Za-z0-9_-]*)/', $withoutWeightedTokens);

return array( (int) $ids, (int) $classes, (int) $elements );
};

$cases = array(
'block' => array(
'class' => 'display-owner-block',
'style' => 'display:block',
'declarations' => 'display:block',
),
'inline-block' => array(
'class' => 'display-owner-inline-block',
'style' => 'display:inline-block',
'declarations' => 'display:inline-block',
),
'flex' => array(
'class' => 'display-owner-flex',
'style' => 'display:flex;flex-direction:column;flex-wrap:wrap;align-items:center;justify-content:center;gap:2rem',
'declarations' => 'display:flex;flex-direction:column;flex-wrap:wrap;align-items:center;justify-content:center;gap:2rem',
),
'mixed-specificity' => array(
'class' => 'display-owner-specificity',
'style' => 'display:block',
'declarations' => 'display:block',
'following_css' => 'div{display:block}',
),
);

foreach ( $cases as $name => $case ) {
$className = $case['class'];
$html = '<style>.' . $className . '{display:grid;align-items:start;gap:3rem}' . ($case['following_css'] ?? '') . '</style>'
. '<div class="' . $className . '" style="' . $case['style'] . '"><p>First</p><p>Second</p></div>';
$result = ( new HtmlTransformer() )->transform($html, array())->toArray();
$serialized = (string) ($result['serialized_blocks'] ?? '');
$engineCss = $cssFor($result, 'engine-support');
$authorCss = $cssFor($result, 'author-css');
preg_match('/\b(be-inline-geometry-[a-f0-9-]+)\b/', $serialized, $carrierMatch);
$carrierClass = (string) ($carrierMatch[1] ?? '');
$selector = ':root .' . $carrierClass;
$expectedRule = $selector . '{' . $case['declarations'] . '}';
$ruleMatch = array();
if ( '' !== $carrierClass ) {
preg_match('/(' . preg_quote($selector, '/') . ')\{([^}]*)\}/', $engineCss, $ruleMatch);
}

$assert('core/group' === ($result['blocks'][0]['blockName'] ?? ''), $name . ': source wrapper remains a core/group', (string) ($result['blocks'][0]['blockName'] ?? '(none)'));
$assert(str_contains($serialized, $className), $name . ': transformed markup retains the class whose grid declaration is being neutralized', $serialized);
$assert(str_contains($authorCss, '.' . $className . '{display:grid'), $name . ': author CSS retains the competing class-owned display:grid rule', $authorCss);
$assert('' !== $carrierClass, $name . ': transformed markup receives a geometry carrier class', $serialized);
$assert(str_contains($engineCss, $expectedRule), $name . ': engine-support carries the complete inline layout override', $engineCss);
$assert(1 !== preg_match('/be-inline-geometry-[a-f0-9-]+/', $authorCss), $name . ': generated carrier rule does not leak into author-css', $authorCss);
$assert(array( 0, 2, 0 ) === $specificity((string) ($ruleMatch[1] ?? '')), $name . ': carrier selector has specificity (0,2,0)', (string) ($ruleMatch[1] ?? '(missing)'));
$assert('' !== (string) ($ruleMatch[2] ?? '') && ! str_contains((string) $ruleMatch[2], '!important'), $name . ': inline layout carrier rule does not use !important', (string) ($ruleMatch[2] ?? '(missing)'));
}

$gridResult = ( new HtmlTransformer() )->transform(
'<section class="source-grid" style="display:grid;grid-template-columns:260px 1fr;align-items:center;justify-content:space-between;gap:32px"><div>Rail</div><div>Body</div></section>',
array()
)->toArray();
$gridSerialized = (string) ($gridResult['serialized_blocks'] ?? '');
$gridCss = $cssFor($gridResult, 'engine-support');
preg_match('/\b(be-inline-geometry-[a-f0-9-]+)\b/', $gridSerialized, $gridCarrierMatch);
$gridCarrier = (string) ($gridCarrierMatch[1] ?? '');
$gridRule = '.' . $gridCarrier . '{display:grid !important;grid-template-columns:260px 1fr !important;align-items:center !important;justify-content:space-between !important;gap:32px !important}';

$assert(str_contains($gridSerialized, 'blocks-engine-css-owned-grid') && '' !== $gridCarrier, 'grid control: legitimate inline grid remains on the existing css-owned-grid carrier path', $gridSerialized);
$assert(str_contains($gridCss, $gridRule), 'grid control: existing grid display and companion declaration bytes remain unchanged', $gridCss);

if ( $failures > 0 ) {
fwrite(STDERR, "Inline display carrier contract: {$failures} failed, {$passes} passed\n");
exit(1);
}

fwrite(STDOUT, "Inline display carrier contract passed: {$passes} assertions\n");
Loading