From f32494d4684bf1d4c0bf934f42f783a465180341 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 13 Jul 2026 17:09:51 -0400 Subject: [PATCH 1/2] fix: preserve inline geometry in block assets --- .../src/ArtifactCompiler/ArtifactCompiler.php | 7 +- .../src/HtmlToBlocks/BlockFactory.php | 5 +- .../src/HtmlToBlocks/HtmlTransformer.php | 36 +++-- .../HtmlToBlocks/Patterns/ButtonsPattern.php | 10 +- .../HtmlToBlocks/Patterns/SpacerPattern.php | 6 +- .../Style/GeometryCarrierClassAllocator.php | 42 +++++ .../Style/StyleResolutionTrait.php | 145 +++++++++++++++++- .../Support/ButtonLinkDispatchTrait.php | 9 +- .../src/VisualParity/StaticCssCascade.php | 88 +++++++++-- .../StaticStyleParityComparator.php | 5 +- .../VisualParity/StaticStyleParityProbe.php | 33 +++- .../VisualParity/StaticStyleParityRunner.php | 39 ++++- php-transformer/tests/contract/run.php | 35 +++++ .../parity/html-expanded-gap-primitives.json | 2 +- .../unit/block-style-support-conversion.php | 108 ++++++++++++- .../tests/unit/live-wp-parity-runner.php | 5 +- 16 files changed, 523 insertions(+), 52 deletions(-) create mode 100644 php-transformer/src/HtmlToBlocks/Style/GeometryCarrierClassAllocator.php diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index bdf91632..f801fb34 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -47,7 +47,12 @@ public function compile(array $artifact): TransformerResult $blockTypes = $this->detectBlockTypes($normalized['files'], $diagnostics); $companionPluginPayloadBuilder = new CompanionPluginPayload(); $entryBlocks = $this->compileEntryBlocks($html, $entryPath, $normalized['files'], $companionPluginPayloadBuilder->blockNamespace($artifact)); - $assets = array_merge($this->assetManifest($normalized['files'], $entryPath, $referenceReports['asset_references']), $entryBlocks['assets']); + $manifestAssets = $this->assetManifest($normalized['files'], $entryPath, $referenceReports['asset_references']); + $geometryAssets = array_values(array_filter($entryBlocks['assets'], static fn (array $asset): bool => 'css' === ($asset['kind'] ?? '') && str_contains((string) ($asset['content'] ?? ''), '.be-inline-geometry-'))); + $otherGeneratedAssets = array_values(array_filter($entryBlocks['assets'], static fn (array $asset): bool => ! in_array($asset, $geometryAssets, true))); + // Runtime loads the manifest in array order. Put carrier CSS before + // authored assets so authored !important declarations preserve cascade. + $assets = array_merge($geometryAssets, $manifestAssets, $otherGeneratedAssets); $diagnostics = array_merge($diagnostics, $entryBlocks['diagnostics']); $serializedBlocks = $entryBlocks['serialized_blocks']; if ( '' === $serializedBlocks && ! empty($documents['documents'][0]['block_markup']) ) { diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php index 5553e608..fd023861 100644 --- a/php-transformer/src/HtmlToBlocks/BlockFactory.php +++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php @@ -120,6 +120,7 @@ private function normalizeClassNameAttr(array $attrs): array */ private function commentAttrs(string $name, array $attrs): array { + unset($attrs['inlineGeometryStyle']); if ( 'core/paragraph' === $name && preg_match('/^\s* (string) ($attrs['anchor'] ?? ''), 'class' => $this->mergeClassNames('wp-block-button', $this->buttonWidthClasses($attrs), (string) ($attrs['className'] ?? '')), + 'style' => (string) ($attrs['inlineGeometryStyle'] ?? ''), ); $controlAttrs = array( @@ -650,10 +652,11 @@ private function blockSupportAttrs(array $attrs, string $baseClass = ''): string $presetClasses = $this->presetColorClasses($attrs); $layoutClasses = $this->layoutClasses($attrs['layout'] ?? null, $baseClass); $classes = $this->mergeClassNames($baseClass, $presetClasses, $support['classes'], $layoutClasses, (string) ($attrs['className'] ?? '')); + $style = trim((string) $support['style'] . ';' . (string) ($attrs['inlineGeometryStyle'] ?? ''), ';'); return $this->htmlAttrs(array( 'id' => (string) ($attrs['anchor'] ?? ''), 'class' => $classes, - 'style' => $support['style'], + 'style' => $style, )); } diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index a49efb7d..baf3b01a 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -471,9 +471,12 @@ public function transform(string $html, array $options = array()): TransformerRe $this->appendCommerceControlsFallbacks($body, $fallbacks); $sourceProvenance = $this->sourceProvenanceForBlocks($blocks); $serializedBlocks = $this->runtime->serializeBlocks($blocks); - if ( true !== ($options['skip_author_stylesheet_materialization'] ?? false) ) { - $this->materializeAuthorStylesheet($html, (string) ($options['static_css'] ?? '')); - } + $this->materializeAuthorStylesheet( + $html, + (string) ($options['static_css'] ?? ''), + true !== ($options['skip_author_stylesheet_materialization'] ?? false), + $serializedBlocks + ); $blockValidityReport = $this->runtime->validateBlockSerialization($blocks); $semanticParityReport = $this->semanticParityReporter->report($body, $blocks, $sourceProvenance, $html, (string) ($options['static_css'] ?? '')); $contentRoundTripReport = $this->contentRoundTripReporter->report($serializedBlocks, $html, $this->formControlEchoTexts); @@ -554,10 +557,17 @@ private function metrics(string $input, array $blocks, string $output, array $fa ); } - private function materializeAuthorStylesheet(string $html, string $staticCss): void + private function materializeAuthorStylesheet(string $html, string $staticCss, bool $includeAuthorStyles = true, string $serializedBlocks = ''): void { $cssParts = array(); - if ( preg_match_all('@]*>(.*?)@is', $html, $matches) ) { + $geometryCss = $this->generatedGeometryCss($serializedBlocks); + if ( '' !== $geometryCss ) { + // Important carrier rules precede author CSS: they retain inline + // precedence over normal selectors while authored !important rules + // remain able to override them. + $cssParts[] = $geometryCss; + } + if ( $includeAuthorStyles && preg_match_all('@]*>(.*?)@is', $html, $matches) ) { foreach ( $matches[1] as $styleBlock ) { $styleBlock = trim(html_entity_decode((string) $styleBlock, ENT_QUOTES | ENT_HTML5, 'UTF-8')); if ( '' !== $styleBlock ) { @@ -566,9 +576,11 @@ private function materializeAuthorStylesheet(string $html, string $staticCss): v } } - $staticCss = trim($staticCss); - if ( '' !== $staticCss ) { - $cssParts[] = $staticCss; + if ( $includeAuthorStyles ) { + $staticCss = trim($staticCss); + if ( '' !== $staticCss ) { + $cssParts[] = $staticCss; + } } $css = trim(implode("\n\n", $cssParts)); @@ -823,7 +835,7 @@ private function convertChildren(DOMNode $parent, array &$fallbacks, bool $captu private function patternContext(bool $includeRuntimeDomTarget = true): PatternContext { return new PatternContext( - fn (DOMElement $sourceElement): array => $this->presentationAttributes($sourceElement), + fn (DOMElement $sourceElement, array $excludedGeometryProperties = array()): array => $this->presentationAttributes($sourceElement, $excludedGeometryProperties), fn (DOMElement $sourceElement): string => $this->innerHtml($sourceElement), fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement), $includeRuntimeDomTarget ? fn (DOMElement $sourceElement): bool => $this->isRuntimeDomTarget($sourceElement) : null, @@ -859,7 +871,7 @@ private function convertPatternChildrenWithoutTags(DOMElement $element, array $e private function probePatternContext(): PatternContext { return new PatternContext( - fn (DOMElement $sourceElement): array => $this->presentationAttributes($sourceElement), + fn (DOMElement $sourceElement, array $excludedGeometryProperties = array()): array => $this->presentationAttributes($sourceElement, $excludedGeometryProperties), fn (DOMElement $sourceElement): string => $this->innerHtml($sourceElement), static fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => array( 'blockName' => $name, @@ -1065,7 +1077,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca fn (DOMElement $sourceElement): string => $this->citationFromElement($sourceElement), fn (DOMElement $sourceElement, array $excludedTags): string => $this->innerHtmlWithoutTags($sourceElement, $excludedTags), fn (string $html): string => $this->runtime->stripAllTags($html), - fn (DOMElement $sourceElement): array => $this->presentationAttributes($sourceElement), + fn (DOMElement $sourceElement, array $excludedGeometryProperties = array()): array => $this->presentationAttributes($sourceElement, $excludedGeometryProperties), fn (DOMElement $sourceElement, array &$sourceFallbacks, array $excludedTags): array => $this->convertChildrenWithoutTags($sourceElement, $sourceFallbacks, $excludedTags), fn (string $inlineTagName): bool => $this->isInlineContentElement($inlineTagName), fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement) @@ -1379,7 +1391,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca fn (DOMElement $sourceElement): int => $this->childElementCount($sourceElement), fn (DOMElement $sourceElement, string $name): string => $this->attr($sourceElement, $name), fn (DOMElement $sourceElement, string $className): bool => $this->hasClass($sourceElement, $className), - fn (DOMElement $sourceElement): array => $this->presentationAttributes($sourceElement), + fn (DOMElement $sourceElement, array $excludedGeometryProperties = array()): array => $this->presentationAttributes($sourceElement, $excludedGeometryProperties), fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement) ); if ( null !== $spacer ) { diff --git a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php index 26b315f5..fad6d713 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php @@ -20,7 +20,7 @@ public function __construct() /** * @param callable(DOMElement): array|null $fileBlockFromAnchor - * @param callable(DOMElement): array $presentationAttributes + * @param callable(DOMElement, array): array $presentationAttributes * @param callable(DOMElement): string $resolvedStyle * @param callable(DOMElement): string $innerHtml * @param callable(DOMElement, string): string $attr @@ -202,7 +202,11 @@ private function spanWrapsEntireContent(string $inner): bool */ private function buttonPresentationAttributes(DOMElement $element, callable $presentationAttributes, callable $resolvedStyle): array { - $attrs = $presentationAttributes($element); + $resolvedStyle = (string) $resolvedStyle($element); + $width = $this->buttonWidth($resolvedStyle); + // Native core/button width owns only its canonical percentage values. + // Other anchors and width values retain their generated geometry carrier. + $attrs = $presentationAttributes($element, null !== $width ? array( 'width' ) : array()); // Buttons resolve styling from the raw merged CSS string, not the canonical // block style object, so the (now object-shaped) presentation `style` is // dropped and re-derived via ButtonStyleResolver below. @@ -211,7 +215,6 @@ 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']); - $resolvedStyle = (string) $resolvedStyle($element); $isOutline = $this->hasOutlineSignal($element, $resolvedStyle); if ( $isOutline ) { $attrs['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), 'is-style-outline'); @@ -227,7 +230,6 @@ private function buttonPresentationAttributes(DOMElement $element, callable $pre $attrs = array_merge($attrs, $native); } - $width = $this->buttonWidth($resolvedStyle); if ( null !== $width ) { $attrs['width'] = $width; } diff --git a/php-transformer/src/HtmlToBlocks/Patterns/SpacerPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/SpacerPattern.php index a9cbdebd..4b222c29 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/SpacerPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/SpacerPattern.php @@ -11,7 +11,7 @@ final class SpacerPattern * @param callable(DOMElement): int $childElementCount * @param callable(DOMElement, string): string $attr * @param callable(DOMElement, string): bool $hasClass - * @param callable(DOMElement): array $presentationAttributes + * @param callable(DOMElement, array): array $presentationAttributes * @param callable(string, array, array>, DOMElement|null): array $createBlock * @return array|null */ @@ -30,7 +30,9 @@ public function match(DOMElement $element, callable $childElementCount, callable return null; } - $attrs = $presentationAttributes($element); + // core/spacer serializes height itself. Preserve all remaining geometry + // through the generated stylesheet rather than removing the whole carrier. + $attrs = $presentationAttributes($element, array( 'height' )); $attrs['height'] = $height; unset($attrs['style']); diff --git a/php-transformer/src/HtmlToBlocks/Style/GeometryCarrierClassAllocator.php b/php-transformer/src/HtmlToBlocks/Style/GeometryCarrierClassAllocator.php new file mode 100644 index 00000000..8bd2ebdc --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Style/GeometryCarrierClassAllocator.php @@ -0,0 +1,42 @@ + */ + private array $classBySignature = array(); + + /** @var array */ + private array $signatureByClass = array(); + + /** @param callable(string): string|null $digest */ + public function __construct(?callable $digest = null) + { + $this->digest = $digest ?? static fn (string $value): string => hash('sha256', $value); + } + + public function allocate(string $signature): string + { + if (isset($this->classBySignature[$signature])) { + return $this->classBySignature[$signature]; + } + + $base = 'be-inline-geometry-' . ($this->digest)($signature); + $className = $base; + $attempt = 0; + while (isset($this->signatureByClass[$className]) && $this->signatureByClass[$className] !== $signature) { + ++$attempt; + $className = $base . '-' . hash('sha256', $signature . ':' . $attempt); + } + + $this->classBySignature[$signature] = $className; + $this->signatureByClass[$className] = $signature; + return $className; + } +} diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php index 331a5f87..df053d4a 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php @@ -46,11 +46,35 @@ trait StyleResolutionTrait */ private array $mergedPresentationStyleCache = array(); + /** + * Inline sizing declarations which core block supports cannot serialize are + * carried by deterministic classes in a generated stylesheet. + * + * @var array + */ + private array $generatedGeometryRules = array(); + + private ?GeometryCarrierClassAllocator $geometryCarrierClassAllocator = null; + + private const INLINE_GEOMETRY_PROPERTIES = array( + 'width', + 'height', + 'min-width', + 'min-height', + 'max-width', + 'max-height', + 'aspect-ratio', + 'box-sizing', + 'flex-basis', + ); + private function resetPresentationResolutionCache(): void { $this->presentationAttributesCache = array(); $this->presentationDeclarationsCache = array(); $this->mergedPresentationStyleCache = array(); + $this->generatedGeometryRules = array(); + $this->geometryCarrierClassAllocator = null; } private function styleAttributeMapper(): StyleAttributeMapper @@ -76,9 +100,9 @@ private function highValueStyleBoundaryPolicy(): HighValueStyleBoundaryPolicy * * @return array */ - private function presentationAttributes(DOMElement $element): array + private function presentationAttributes(DOMElement $element, array $excludedGeometryProperties = array()): array { - $cacheKey = $this->presentationCacheKey($element); + $cacheKey = $this->presentationCacheKey($element) . ':' . implode(',', $excludedGeometryProperties); if ( isset($this->presentationAttributesCache[$cacheKey]) ) { return $this->presentationAttributesCache[$cacheKey]; } @@ -88,7 +112,11 @@ private function presentationAttributes(DOMElement $element): array $attrs = array_filter(array_merge($mapped['attrs'] ?? array(), array( 'anchor' => $this->safeAnchor($this->attr($element, 'id')), - 'className' => $this->promotedClassName($this->attr($element, 'class')), + 'className' => $this->mergePresentationClassNames( + $this->promotedClassName($this->attr($element, 'class')), + $this->inlineGeometryClassName($element, $excludedGeometryProperties) + ), + 'inlineGeometryStyle' => $this->inlineGeometryStyle($element, $excludedGeometryProperties), 'style' => $mapped['style'], 'layout' => $this->layoutAttribute($element, $this->cssDeclarationString($declarations)), )), static fn ($value): bool => is_array($value) ? array() !== $value : '' !== trim((string) $value)); @@ -98,6 +126,117 @@ private function presentationAttributes(DOMElement $element): array return $attrs; } + /** + * Core supports cannot serialize arbitrary box dimensions. Keep only source + * inline geometry in a generated stylesheet; class-owned declarations are + * already retained by author stylesheet materialization. + */ + private function inlineGeometryClassName(DOMElement $element, array $excludedProperties = array()): string + { + $declarations = $this->cssDeclarations($this->attr($element, 'style')); + $geometry = array(); + foreach (self::INLINE_GEOMETRY_PROPERTIES as $property) { + if (in_array($property, $excludedProperties, true)) { + continue; + } + $rawValue = trim((string) ($declarations[$property] ?? '')); + if (1 === preg_match('/\s*!important\s*$/i', $rawValue)) { + continue; + } + $value = $rawValue; + if ('' !== $value && ! preg_match('/[{}<>;]/', $value)) { + $geometry[$property] = $value; + } + } + + if (array() === $geometry) { + return ''; + } + + ksort($geometry); + $declarations = array(); + foreach ($geometry as $property => $value) { + // 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'; + } + $rule = implode(';', $declarations); + $className = ($this->geometryCarrierClassAllocator ??= new GeometryCarrierClassAllocator())->allocate($this->geometryStructuralPath($element) . "\n" . $rule); + $this->generatedGeometryRules[$className] = '.' . $className . '{' . $rule . '}'; + + return $className; + } + + private function geometryStructuralPath(DOMElement $element): string + { + $segments = array(); + for ($node = $element; $node instanceof DOMElement; $node = $node->parentNode) { + $index = 1; + for ($sibling = $node->previousSibling; null !== $sibling; $sibling = $sibling->previousSibling) { + if ($sibling instanceof DOMElement && strtolower($sibling->tagName) === strtolower($node->tagName)) { + ++$index; + } + } + $segments[] = strtolower($node->tagName) . ':' . $index; + } + + return implode('/', array_reverse($segments)); + } + + private function inlineGeometryStyle(DOMElement $element, array $excludedProperties = array()): string + { + $declarations = $this->cssDeclarations($this->attr($element, 'style')); + $style = array(); + $geometryValues = array(); + foreach (self::INLINE_GEOMETRY_PROPERTIES as $property) { + if (in_array($property, $excludedProperties, true)) { + continue; + } + $value = trim((string) ($declarations[$property] ?? '')); + $geometryValues[] = $value; + if (1 === preg_match('/\s*!important\s*$/i', $value)) { + $style[] = $property . ':' . $value; + } + } + + if (array_filter($geometryValues, static fn (string $value): bool => str_contains($value, 'var('))) { + foreach ($declarations as $property => $value) { + if (str_starts_with($property, '--')) { + $style[] = $property . ':' . $value; + } + } + } + + return implode(';', $style); + } + + private function mergePresentationClassNames(string ...$classNames): string + { + $classes = array(); + foreach ($classNames as $className) { + foreach (preg_split('/\s+/', trim($className)) ?: array() as $class) { + if ('' !== $class && ! in_array($class, $classes, true)) { + $classes[] = $class; + } + } + } + + return implode(' ', $classes); + } + + private function generatedGeometryCss(string $serializedBlocks): string + { + $rules = array(); + foreach ($this->generatedGeometryRules as $className => $rule) { + if (preg_match('/(?:^|[^a-zA-Z0-9_-])' . preg_quote($className, '/') . '(?:$|[^a-zA-Z0-9_-])/', $serializedBlocks)) { + $rules[] = $rule; + } + } + + return implode("\n", $rules); + } + /** * @return array */ diff --git a/php-transformer/src/HtmlToBlocks/Support/ButtonLinkDispatchTrait.php b/php-transformer/src/HtmlToBlocks/Support/ButtonLinkDispatchTrait.php index 4eec1e94..95f680c6 100644 --- a/php-transformer/src/HtmlToBlocks/Support/ButtonLinkDispatchTrait.php +++ b/php-transformer/src/HtmlToBlocks/Support/ButtonLinkDispatchTrait.php @@ -20,7 +20,7 @@ private function convertAnchorDispatchElement(DOMElement $element, array &$fallb $logo = $this->logoPattern->match( $element, - fn (DOMElement $sourceElement): array => $this->presentationAttributes($sourceElement), + fn (DOMElement $sourceElement, array $excludedGeometryProperties = array()): array => $this->presentationAttributes($sourceElement, $excludedGeometryProperties), fn (DOMElement $sourceElement): string => $this->richTextContentWithMaterializedInlineStyles($sourceElement), fn (DOMElement $sourceElement): string => $this->restoreSvgCasing($this->outerHtml($sourceElement)), fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement) @@ -32,7 +32,7 @@ private function convertAnchorDispatchElement(DOMElement $element, array &$fallb $button = $this->buttonsPattern->matchAnchor( $element, fn (DOMElement $anchor): ?array => $this->fileBlockFromAnchor($anchor), - fn (DOMElement $sourceElement): array => $this->presentationAttributes($sourceElement), + fn (DOMElement $sourceElement, array $excludedGeometryProperties = array()): array => $this->presentationAttributes($sourceElement, $excludedGeometryProperties), fn (DOMElement $sourceElement): string => $this->resolveCssVariablesInValue($this->mergedPresentationStyle($sourceElement)), fn (DOMElement $sourceElement): string => $this->innerHtml($sourceElement), fn (DOMElement $sourceElement, string $name): string => $this->attr($sourceElement, $name), @@ -57,7 +57,10 @@ private function convertAnchorDispatchElement(DOMElement $element, array &$fallb } } - return $this->createBlock('core/paragraph', array( 'content' => $this->outerHtml($element) ), array(), $element); + // A non-button anchor has no native width support. Promote its source + // presentation to the paragraph wrapper so generated geometry remains + // attached to the rendered block rather than being silently discarded. + return $this->createBlock('core/paragraph', array_merge($this->presentationAttributes($element), array( 'content' => $this->outerHtml($element) )), array(), $element); } /** diff --git a/php-transformer/src/VisualParity/StaticCssCascade.php b/php-transformer/src/VisualParity/StaticCssCascade.php index 354520a0..156c2da4 100644 --- a/php-transformer/src/VisualParity/StaticCssCascade.php +++ b/php-transformer/src/VisualParity/StaticCssCascade.php @@ -62,15 +62,55 @@ public function resolve(DOMElement $element, array $properties, array $inheritab } } + $customProperties = $this->customProperties($element); + foreach ($style as $property => $value) { + if (! str_starts_with($property, '--')) { + $style[$property] = $this->resolveVariables($value, $customProperties); + } + } $style = array_intersect_key($style, array_flip($properties)); ksort($style); return $style; } + /** @return array */ + private function customProperties(DOMElement $element): array + { + $nodes = array(); + for ($node = $element; $node instanceof DOMElement; $node = $node->parentNode) { + array_unshift($nodes, $node); + } + $properties = array(); + foreach ($nodes as $node) { + foreach ($this->cascadedStyle($node) as $name => $value) { + if (str_starts_with($name, '--')) { + $properties[$name] = $value; + } + } + } + return $properties; + } + + /** Resolve local/inherited variables and one-level fallback chains deterministically. */ + private function resolveVariables(string $value, array $properties): string + { + for ($depth = 0; $depth < 12 && str_contains($value, 'var('); ++$depth) { + $resolved = preg_replace_callback('/var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,\s*([^()]+))?\)/', static function (array $matches) use ($properties): string { + $name = $matches[1]; + return isset($properties[$name]) ? $properties[$name] : trim((string) ($matches[2] ?? $matches[0])); + }, $value); + if ($resolved === $value || null === $resolved) { + break; + } + $value = $resolved; + } + return $value; + } + /** - * Merge every matching author rule in (specificity, source-order) order, then - * apply the inline style attribute on top. + * Resolve matching declarations using importance, specificity, source order, + * and inline-origin precedence. * * @return array */ @@ -83,20 +123,47 @@ private function cascadedStyle(DOMElement $element): array } } - usort($matched, static function (array $a, array $b): int { - return $a['specificity'] <=> $b['specificity'] ?: $a['order'] <=> $b['order']; - }); - - $style = array(); + $resolved = array(); foreach ( $matched as $rule ) { - $style = array_merge($style, $rule['declarations']); + $this->applyDeclarations($resolved, $rule['declarations'], $rule['specificity'], $rule['order'], false); } if ( $element->hasAttribute('style') ) { - $style = array_merge($style, $this->declarations($element->getAttribute('style'))); + $this->applyDeclarations($resolved, $this->declarations($element->getAttribute('style')), 10000, PHP_INT_MAX, true); } - return $style; + return array_map(static fn (array $entry): string => $entry['value'], $resolved); + } + + /** + * @param array $resolved + * @param array $declarations + */ + private function applyDeclarations(array &$resolved, array $declarations, int $specificity, int $order, bool $inline): void + { + foreach ($declarations as $name => $rawValue) { + $important = 1 === preg_match('/\s*!important\s*$/i', $rawValue); + $value = preg_replace('/\s*!important\s*$/i', '', $rawValue) ?? $rawValue; + $current = $resolved[$name] ?? null; + if (is_array($current) + && (int) $current['important'] > (int) $important) { + continue; + } + if (is_array($current) + && (bool) $current['important'] === $important + && ($current['specificity'] > $specificity + || ($current['specificity'] === $specificity && $current['order'] > $order) + || ($current['specificity'] === $specificity && $current['order'] === $order && $current['inline'] && ! $inline))) { + continue; + } + $resolved[$name] = array( + 'value' => $value, + 'important' => $important, + 'specificity' => $specificity, + 'order' => $order, + 'inline' => $inline, + ); + } } /** @@ -191,7 +258,6 @@ private function declarations(string $style): array } [$name, $value] = array_map('trim', explode(':', $declaration, 2)); $name = strtolower($name); - $value = preg_replace('/\s*!important\s*$/i', '', $value) ?? $value; if ( '' !== $name && '' !== $value ) { $declarations[$name] = preg_replace('/\s+/', ' ', $value) ?? $value; } diff --git a/php-transformer/src/VisualParity/StaticStyleParityComparator.php b/php-transformer/src/VisualParity/StaticStyleParityComparator.php index b18217bf..c9485cbf 100644 --- a/php-transformer/src/VisualParity/StaticStyleParityComparator.php +++ b/php-transformer/src/VisualParity/StaticStyleParityComparator.php @@ -545,7 +545,10 @@ private function classes(array $probe): array if ( ! is_array($classes) ) { return array(); } - $classes = array_values(array_filter($classes, 'is_string')); + // Generated geometry carriers are implementation detail classes, not + // source identity. Excluding them keeps correspondence anchored to the + // source classes they supplement. + $classes = array_values(array_filter($classes, static fn ($class): bool => is_string($class) && ! str_starts_with($class, 'be-inline-geometry-'))); sort($classes); return $classes; } diff --git a/php-transformer/src/VisualParity/StaticStyleParityProbe.php b/php-transformer/src/VisualParity/StaticStyleParityProbe.php index c8af8c26..563c88e1 100644 --- a/php-transformer/src/VisualParity/StaticStyleParityProbe.php +++ b/php-transformer/src/VisualParity/StaticStyleParityProbe.php @@ -28,6 +28,7 @@ final class StaticStyleParityProbe { public const SCHEMA = 'blocks-engine/php-transformer/static-style-parity-probes/v1'; + public const GEOMETRY_SCHEMA = 'blocks-engine/php-transformer/static-style-parity-probes/geometry-v2'; /** * Visually load-bearing, statically resolvable author properties. Sorted and @@ -54,6 +55,10 @@ final class StaticStyleParityProbe 'text-transform', ); + public const GEOMETRY_PROPERTIES = array( + 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height', 'aspect-ratio', 'flex-basis', + ); + /** * Properties that inherit through the cascade per CSS rules. */ @@ -76,6 +81,13 @@ final class StaticStyleParityProbe 'script', 'style', 'head', 'meta', 'link', 'title', 'base', 'noscript', 'template', ); + private bool $includeGeometry; + + public function __construct(bool $includeGeometry = false) + { + $this->includeGeometry = $includeGeometry; + } + /** * @param string $html Document (or fragment) HTML to probe. * @param string $extraCss Optional external CSS (e.g. a linked stylesheet the @@ -127,7 +139,7 @@ public function extract(string $html, string $extraCss = ''): array continue; } - $style = $cascade->resolve($node, self::TRACKED_PROPERTIES, self::INHERITABLE_PROPERTIES); + $style = $cascade->resolve($node, $this->trackedProperties(), self::INHERITABLE_PROPERTIES); $hasIdentity = $node->hasAttribute('class') || $node->hasAttribute('id') || $node->hasAttribute('style'); if ( array() === $style && ! $hasIdentity ) { continue; @@ -176,14 +188,14 @@ private function result(array $probes): array ksort($byTag); return array( - 'schema' => self::SCHEMA, + 'schema' => $this->schema(), 'status' => 'success', 'probes' => $probes, 'summary' => array( 'total' => count($probes), 'styled_total' => $styledCount, 'by_tag' => $byTag, - 'tracked_properties' => self::TRACKED_PROPERTIES, + 'tracked_properties' => $this->trackedProperties(), ), 'diagnostics' => array(), ); @@ -195,14 +207,14 @@ private function result(array $probes): array private function failed(): array { return array( - 'schema' => self::SCHEMA, + 'schema' => $this->schema(), 'status' => 'failed', 'probes' => array(), 'summary' => array( 'total' => 0, 'styled_total' => 0, 'by_tag' => array(), - 'tracked_properties' => self::TRACKED_PROPERTIES, + 'tracked_properties' => $this->trackedProperties(), ), 'diagnostics' => array( array('code' => 'html_parse_failed', 'message' => 'Unable to parse HTML for static style parity probes.'), @@ -210,6 +222,17 @@ private function failed(): array ); } + /** @return array */ + private function trackedProperties(): array + { + return $this->includeGeometry ? array_values(array_unique(array_merge(self::TRACKED_PROPERTIES, self::GEOMETRY_PROPERTIES))) : self::TRACKED_PROPERTIES; + } + + private function schema(): string + { + return $this->includeGeometry ? self::GEOMETRY_SCHEMA : self::SCHEMA; + } + /** * Stable, human-readable selector path: tag + id (anchors and stops) or * tag + up to two classes + :nth-of-type when needed, walked to . diff --git a/php-transformer/src/VisualParity/StaticStyleParityRunner.php b/php-transformer/src/VisualParity/StaticStyleParityRunner.php index bfeab703..431e4840 100644 --- a/php-transformer/src/VisualParity/StaticStyleParityRunner.php +++ b/php-transformer/src/VisualParity/StaticStyleParityRunner.php @@ -46,12 +46,45 @@ public function __construct( */ public function compareSourceToTransform(string $sourceHtml, string $authorCss = ''): array { - $result = $this->transformer->transform($sourceHtml, array())->toArray(); + $result = $this->transformer->transform($sourceHtml, array( 'static_css' => $authorCss ))->toArray(); $candidateHtml = self::candidateHtmlFromSerializedBlocks((string) ($result['serialized_blocks'] ?? '')); + $candidateCss = ''; + foreach ($result['assets'] ?? array() as $asset) { + if (! is_array($asset) || 'css' !== ($asset['kind'] ?? '') || ! is_string($asset['content'] ?? null)) { + continue; + } + $candidateCss .= "\n" . $asset['content']; + } // The render-free proxy carries the SAME author CSS to both sides so the - // only variable is the transformed DOM. - return $this->compareSourceToCandidate($sourceHtml, $candidateHtml, $authorCss, $authorCss); + // only variable is the transformed DOM. Candidate CSS comes from the + // generated asset itself, including geometry carriers and author CSS. + return $this->compareSourceToCandidate($sourceHtml, $candidateHtml, $authorCss, $candidateCss); + } + + /** + * Return the fixed v1 signal alongside a separately versioned geometry score. + * + * @return array{static_v1: array, geometry_v2: array} + */ + public function compareSourceToTransformWithGeometry(string $sourceHtml, string $authorCss = ''): array + { + $result = $this->transformer->transform($sourceHtml, array( 'static_css' => $authorCss ))->toArray(); + $candidateHtml = self::candidateHtmlFromSerializedBlocks((string) ($result['serialized_blocks'] ?? '')); + $candidateCss = ''; + foreach ($result['assets'] ?? array() as $asset) { + if (is_array($asset) && 'css' === ($asset['kind'] ?? '') && is_string($asset['content'] ?? null)) { + $candidateCss .= "\n" . $asset['content']; + } + } + + return array( + 'static_v1' => $this->compareSourceToCandidate($sourceHtml, $candidateHtml, $authorCss, $candidateCss), + 'geometry_v2' => (new StaticStyleParityComparator())->compare( + (new StaticStyleParityProbe(true))->extract($sourceHtml, $authorCss), + (new StaticStyleParityProbe(true))->extract($candidateHtml, $candidateCss) + ), + ); } /** diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index c8d3a4b8..6fd22785 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -1703,6 +1703,41 @@ public function match(DOMElement $element, PatternContext $context): ?array $artifactNavAnchorRepairCss = (string) ($artifactNavAnchorCss['source_reports']['compiled_site']['visual_repair']['css'] ?? ''); $assert(str_contains($artifactNavAnchorRepairCss, '.site-header .subnav.wp-block-navigation .wp-block-navigation-item__content, .site-header .subnav .wp-block-navigation .wp-block-navigation-item__content { color:#31251c;text-decoration:none;border-color:#31251c }'), 'artifact visual repair CSS carries nav anchor replay for downstream theme materializers'); +$artifactGeometry = $compiler->compile( + array( + 'schema' => ArtifactCompiler::INPUT_SCHEMA, + 'generated_html' => '

Geometry

', + ) +)->toArray(); +$artifactGeometryAssets = is_array($artifactGeometry['assets'] ?? null) ? $artifactGeometry['assets'] : array(); +$artifactGeometryCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $artifactGeometryAssets)); +$artifactGeometryMarkup = (string) ($artifactGeometry['serialized_blocks'] ?? ''); +$assert(str_contains($artifactGeometryMarkup, 'be-inline-geometry-'), 'artifact compiler serializes geometry carrier classes into primary block output', $artifactGeometryMarkup); +$assert(str_contains($artifactGeometryCss, 'width:75%') && str_contains($artifactGeometryCss, 'max-width:72rem') && str_contains($artifactGeometryCss, 'aspect-ratio:16 / 9'), 'artifact compiler exposes carrier CSS in primary assets', $artifactGeometryCss); +$artifactPlanCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $artifactGeometry['source_reports']['materialization_plan']['assets'] ?? array())); +$assert(str_contains($artifactPlanCss, 'width:75%') && str_contains($artifactPlanCss, 'max-width:72rem'), 'artifact materialization plan carries the primary geometry asset'); + +$artifactGeometryCascade = $compiler->compile( + array( + 'entry' => 'index.html', + 'files' => array( + 'index.html' => '

Cascade

', + 'site.css' => '#target{width:12rem}.authored-important{width:8rem!important}', + ), + ) +)->toArray(); +$geometryAssetIndex = null; +$authorAssetIndex = null; +foreach (($artifactGeometryCascade['assets'] ?? array()) as $index => $asset) { + if (str_contains((string) ($asset['content'] ?? ''), '.be-inline-geometry-')) { + $geometryAssetIndex = $index; + } + if ('site.css' === ($asset['path'] ?? '')) { + $authorAssetIndex = $index; + } +} +$assert(is_int($geometryAssetIndex) && is_int($authorAssetIndex) && $geometryAssetIndex < $authorAssetIndex, 'artifact compiler orders geometry carriers before authored CSS to preserve !important cascade'); + $artifactInlineSvg = $compiler->compile( array( 'schema' => ArtifactCompiler::INPUT_SCHEMA, diff --git a/php-transformer/tests/fixtures/parity/html-expanded-gap-primitives.json b/php-transformer/tests/fixtures/parity/html-expanded-gap-primitives.json index fff40c5f..2b0b6e58 100644 --- a/php-transformer/tests/fixtures/parity/html-expanded-gap-primitives.json +++ b/php-transformer/tests/fixtures/parity/html-expanded-gap-primitives.json @@ -17,7 +17,7 @@ }, "expected_blocks": [ { "path": "blocks.0", "name": "core/group" }, - { "path": "blocks.0.innerBlocks.0", "name": "core/group", "attrs": { "className": "decorative-placeholder" } }, + { "path": "blocks.0.innerBlocks.0", "name": "core/group", "attrs": { "className": "decorative-placeholder be-inline-geometry-bae6fdaf6367fd3f2664c5430c496f2e013c09cb1cdb789296bbc1f66a889481" } }, { "path": "blocks.0.innerBlocks.1", "name": "core/group", "attrs": { "className": "media-shell" } }, { "path": "blocks.0.innerBlocks.1.innerBlocks.0", "name": "core/image", "attrs": { "className": "is-resized", "url": "logo.svg", "alt": "Logo", "width": "120", "height": "80" } }, { "path": "blocks.0.innerBlocks.2", "name": "core/list" }, diff --git a/php-transformer/tests/unit/block-style-support-conversion.php b/php-transformer/tests/unit/block-style-support-conversion.php index 470f58f4..b6bc2ab3 100644 --- a/php-transformer/tests/unit/block-style-support-conversion.php +++ b/php-transformer/tests/unit/block-style-support-conversion.php @@ -9,6 +9,10 @@ require dirname(__DIR__, 2) . '/vendor/autoload.php'; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer; +use Automattic\BlocksEngine\PhpTransformer\VisualParity\StaticStyleParityRunner; +use Automattic\BlocksEngine\PhpTransformer\VisualParity\StaticStyleParityComparator; +use Automattic\BlocksEngine\PhpTransformer\VisualParity\StaticStyleParityProbe; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\GeometryCarrierClassAllocator; $failures = 0; $passes = 0; @@ -70,15 +74,113 @@ $assert(! str_contains($cardMarkup, 'max-width:360px'), '20: rendered nested core/group wrapper omits unsupported max-width style', $cardMarkup); $assert(! str_contains($cardMarkup, 'style="max-width:1120px;margin:0 auto;padding:5rem 2rem"'), '21: rendered section does not keep unsupported raw style wholesale', $cardMarkup); +$geometryAssets = $cardResult['assets'] ?? array(); +$geometryCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), is_array($geometryAssets) ? $geometryAssets : array())); +$assert(str_contains((string) ($cardShellAttrs['className'] ?? ''), 'be-inline-geometry-'), '22: unsupported inline container sizing gets a deterministic generated class', json_encode($cardShellAttrs)); +$assert(str_contains($geometryCss, 'max-width:1120px'), '23: generated stylesheet preserves unsupported inline container max-width', $geometryCss); + +$spacerResult = ( new HtmlTransformer() )->transform('
', array())->toArray(); +$spacer = $spacerResult['blocks'][0] ?? array(); +$spacerAttrs = is_array($spacer['attrs'] ?? null) ? $spacer['attrs'] : array(); +$spacerMarkup = (string) ($spacerResult['serialized_blocks'] ?? ''); +$spacerCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), is_array($spacerResult['assets'] ?? null) ? $spacerResult['assets'] : array())); + +$assert('core/spacer' === ($spacer['blockName'] ?? ''), '24: empty spacer stays a native core/spacer', (string) ($spacer['blockName'] ?? '(none)')); +$assert('3rem' === ($spacerAttrs['height'] ?? ''), '25: native spacer height owns only the height declaration', json_encode($spacerAttrs)); +$assert(str_contains((string) ($spacerAttrs['className'] ?? ''), 'be-inline-geometry-'), '26: spacer retains a geometry carrier for non-height declarations', json_encode($spacerAttrs)); +$assert(str_contains($spacerCss, 'width:50%') && str_contains($spacerCss, 'max-width:42rem') && str_contains($spacerCss, 'aspect-ratio:4 / 1'), '27: spacer carrier preserves mixed width/max-width/aspect-ratio geometry', $spacerCss); +$assert(! str_contains($spacerCss, 'height:3rem'), '28: spacer does not emit an orphan height carrier rule', $spacerCss); +$assert(str_contains($spacerMarkup, 'style="height:3rem"'), '29: spacer serialization retains native height support', $spacerMarkup); + +$anchorResult = ( new HtmlTransformer() )->transform('Read', array())->toArray(); +$anchorMarkup = (string) ($anchorResult['serialized_blocks'] ?? ''); +$anchorCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), is_array($anchorResult['assets'] ?? null) ? $anchorResult['assets'] : array())); +$assert(! str_contains($anchorMarkup, 'wp-block-button__width-50'), '30: ordinary anchors do not claim core/button width support', $anchorMarkup); +$assert(str_contains($anchorMarkup, 'be-inline-geometry-') && str_contains($anchorCss, 'width:50%'), '31: ordinary anchors retain applicable width geometry', $anchorMarkup . "\n" . $anchorCss); + +$buttonResult = ( new HtmlTransformer() )->transform('Buy', array())->toArray(); +$button = $buttonResult['blocks'][0]['innerBlocks'][0] ?? array(); +$buttonAttrs = is_array($button['attrs'] ?? null) ? $button['attrs'] : array(); +$buttonCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), is_array($buttonResult['assets'] ?? null) ? $buttonResult['assets'] : array())); +$assert(50 === ($buttonAttrs['width'] ?? null), '32: recognized core/button owns its canonical width', json_encode($buttonAttrs)); +$assert(! str_contains($buttonCss, 'width:50%') && str_contains($buttonCss, 'max-width:20rem') && str_contains($buttonCss, 'aspect-ratio:2 / 1'), '33: core/button removes only native-owned width from mixed geometry', $buttonCss); + +$specificityHtml = '

Specificity

'; +$specificityResult = ( new HtmlTransformer() )->transform($specificityHtml, array())->toArray(); +$specificityCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), is_array($specificityResult['assets'] ?? null) ? $specificityResult['assets'] : array())); +$assert(str_contains($specificityCss, 'width:30rem !important'), '34: carrier beats an authored normal ID width selector', $specificityCss); +$geometryProbe = new StaticStyleParityProbe(true); +$geometryComparator = new StaticStyleParityComparator(); +$missingCarrier = $geometryComparator->compare( + $geometryProbe->extract($specificityHtml), + $geometryProbe->extract('

Specificity

', '#target{width:12rem}') +); +$assert((float) ($missingCarrier['parity']['score'] ?? 1.0) < 1.0, '35: geometry v2 fails the specificity reproduction without its carrier', json_encode($missingCarrier['parity'] ?? array())); +$specificityParity = ( new StaticStyleParityRunner() )->compareSourceToTransformWithGeometry($specificityHtml); +$assert(1.0 === (float) ($specificityParity['geometry_v2']['parity']['score'] ?? 0.0), '36: geometry v2 passes the specificity reproduction with its emitted carrier', json_encode($specificityParity['geometry_v2']['parity'] ?? array())); + +$cascadeHtml = '

Cascade

'; +$cascadeResult = ( new HtmlTransformer() )->transform($cascadeHtml, array())->toArray(); +$cascadeMarkup = (string) ($cascadeResult['serialized_blocks'] ?? ''); +$cascadeCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), is_array($cascadeResult['assets'] ?? null) ? $cascadeResult['assets'] : array())); +$assert(str_contains($cascadeMarkup, 'be-inline-geometry-') && str_contains($cascadeCss, 'width:30rem !important'), '37: carrier preserves inline width against authored ID selectors', $cascadeMarkup . "\n" . $cascadeCss); +$assert(strpos($cascadeCss, '.be-inline-geometry-') < strpos($cascadeCss, '.authored-important{width:8rem!important}'), '38: authored !important rule remains ordered after the carrier', $cascadeCss); +$cascadeParity = ( new StaticStyleParityRunner() )->compareSourceToTransformWithGeometry($cascadeHtml); +$assert(1.0 === (float) ($cascadeParity['static_v1']['parity']['score'] ?? 0.0), '39: static v1 remains comparable for cascade fixture', json_encode($cascadeParity['static_v1']['parity'] ?? array())); +$assert(1.0 === (float) ($cascadeParity['geometry_v2']['parity']['score'] ?? 0.0), '40: geometry v2 respects authored !important cascade', json_encode($cascadeParity['geometry_v2']['parity'] ?? array())); + +$manyGeometry = '
' . str_repeat('
', 200) . '
'; +$manyResult = ( new HtmlTransformer() )->transform($manyGeometry, array())->toArray(); +$manyMarkup = (string) ($manyResult['serialized_blocks'] ?? ''); +$manyCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), is_array($manyResult['assets'] ?? null) ? $manyResult['assets'] : array())); +preg_match_all('/be-inline-geometry-[a-z0-9-]+/', $manyMarkup, $manyClasses); +preg_match_all('/\.be-inline-geometry-[a-z0-9-]+\{/', $manyCss, $manyRules); +$assert(200 === count(array_unique($manyClasses[0] ?? array())), '41: duplicate classes and IDs still receive one stable carrier class per element', (string) count(array_unique($manyClasses[0] ?? array()))); +$assert(200 === count($manyRules[0] ?? array()), '42: final serialized carrier classes retain exactly one emitted rule each', (string) count($manyRules[0] ?? array())); + +$importantGeometryHtml = '

Important geometry

'; +$importantGeometryResult = ( new HtmlTransformer() )->transform($importantGeometryHtml, array())->toArray(); +$importantGeometryMarkup = (string) ($importantGeometryResult['serialized_blocks'] ?? ''); +$importantGeometryParity = ( new StaticStyleParityRunner() )->compareSourceToTransformWithGeometry($importantGeometryHtml); +$assert(str_contains($importantGeometryMarkup, 'width:30rem!important;min-height:12rem!important'), '43: width and min-height !important remain serialized inline', $importantGeometryMarkup); +$assert(1.0 === (float) ($importantGeometryParity['geometry_v2']['parity']['score'] ?? 0.0), '44: geometry v2 preserves width and min-height !important', json_encode($importantGeometryParity['geometry_v2']['parity'] ?? array())); + +$collisionAllocator = new GeometryCarrierClassAllocator(static fn (string $signature): string => str_repeat('a', 64)); +$collisionFirst = $collisionAllocator->allocate('first-signature'); +$collisionSecond = $collisionAllocator->allocate('second-signature'); +$assert($collisionFirst !== $collisionSecond && $collisionFirst === $collisionAllocator->allocate('first-signature'), '45: colliding digests allocate distinct stable carrier classes', $collisionFirst . ' / ' . $collisionSecond); +$manyRepeat = ( new HtmlTransformer() )->transform($manyGeometry, array())->toArray(); +$assert( + (string) ($manyResult['serialized_blocks'] ?? '') === (string) ($manyRepeat['serialized_blocks'] ?? '') + && json_encode($manyResult['assets'] ?? array()) === json_encode($manyRepeat['assets'] ?? array()), + '46: repeated transforms retain byte-identical carrier markup and assets' +); + +$importantButton = ( new HtmlTransformer() )->transform('Buy', array())->toArray(); +$importantButtonMarkup = (string) ($importantButton['serialized_blocks'] ?? ''); +$assert(! str_contains($importantButtonMarkup, 'wp-block-button__width-50') && str_contains($importantButtonMarkup, 'width:50%!important') && str_contains($importantButtonMarkup, 'min-width:12rem!important') && str_contains($importantButtonMarkup, 'max-width:30rem!important') && str_contains($importantButtonMarkup, 'height:3rem!important') && str_contains($importantButtonMarkup, 'aspect-ratio:2 / 1!important') && str_contains($importantButtonMarkup, 'flex-basis:20rem!important'), '47: core/button preserves source-important geometry on its wrapper without native width classes', $importantButtonMarkup); + +$variableGeometryHtml = '

Variable geometry

'; +$variableGeometryResult = ( new HtmlTransformer() )->transform($variableGeometryHtml, array())->toArray(); +$variableGeometryMarkup = (string) ($variableGeometryResult['serialized_blocks'] ?? ''); +$variableGeometryParity = ( new StaticStyleParityRunner() )->compareSourceToTransformWithGeometry($variableGeometryHtml); +$assert(str_contains($variableGeometryMarkup, '--box-base:30rem;--box-width:var(--box-base,20rem)') && str_contains($variableGeometryMarkup, 'be-inline-geometry-'), '48: local custom properties used by geometry survive serialization', $variableGeometryMarkup); +$assert(1.0 === (float) ($variableGeometryParity['geometry_v2']['parity']['score'] ?? 0.0), '49: geometry v2 resolves local and transitive custom properties', json_encode($variableGeometryParity['geometry_v2']['parity'] ?? array())); +$missingVariableGeometry = $geometryComparator->compare( + $geometryProbe->extract($variableGeometryHtml), + $geometryProbe->extract('

Variable geometry

', '.be-inline-geometry-test{width:var(--box-width)}') +); +$assert((float) ($missingVariableGeometry['parity']['score'] ?? 1.0) < 1.0, '50: geometry v2 fails when a required local custom property is absent', json_encode($missingVariableGeometry['parity'] ?? array())); + $columnsMaxWidthHtml = '

A

B

'; $columnsMaxWidthResult = ( new HtmlTransformer() )->transform($columnsMaxWidthHtml, array())->toArray(); $columnsMaxWidthBlock = $columnsMaxWidthResult['blocks'][0] ?? array(); $columnsMaxWidthAttrs = is_array($columnsMaxWidthBlock['attrs'] ?? null) ? $columnsMaxWidthBlock['attrs'] : array(); $columnsMaxWidthMarkup = (string) ($columnsMaxWidthResult['serialized_blocks'] ?? ''); -$assert('core/columns' === ($columnsMaxWidthBlock['blockName'] ?? ''), '22: horizontal flex wrapper still becomes columns', (string) ($columnsMaxWidthBlock['blockName'] ?? '(none)')); -$assert(! isset($columnsMaxWidthAttrs['style']['dimensions']['maxWidth']), '23: core/columns omits max-width attr that Gutenberg save does not reproduce', json_encode($columnsMaxWidthAttrs['style']['dimensions'] ?? array())); -$assert(! str_contains($columnsMaxWidthMarkup, 'max-width:var(--max-w)'), '24: rendered core/columns wrapper omits unsupported max-width style', $columnsMaxWidthMarkup); +$assert('core/columns' === ($columnsMaxWidthBlock['blockName'] ?? ''), '24: horizontal flex wrapper still becomes columns', (string) ($columnsMaxWidthBlock['blockName'] ?? '(none)')); +$assert(! isset($columnsMaxWidthAttrs['style']['dimensions']['maxWidth']), '25: core/columns omits max-width attr that Gutenberg save does not reproduce', json_encode($columnsMaxWidthAttrs['style']['dimensions'] ?? array())); +$assert(! str_contains($columnsMaxWidthMarkup, 'max-width:var(--max-w)'), '26: rendered core/columns wrapper omits unsupported max-width style', $columnsMaxWidthMarkup); $labelHtml = '
Pricing

Simple plans

Team
$29/mo
Launch faster
'; $labelCss = '.tag{display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:100px}.pricing-card{padding:2rem}.tier-name{font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase}.tier-price{display:flex;align-items:flex-end;gap:6px}.use-case-result{display:flex;align-items:center;gap:8px;padding:10px 14px;border-radius:6px}'; diff --git a/php-transformer/tests/unit/live-wp-parity-runner.php b/php-transformer/tests/unit/live-wp-parity-runner.php index 236c1920..49970478 100644 --- a/php-transformer/tests/unit/live-wp-parity-runner.php +++ b/php-transformer/tests/unit/live-wp-parity-runner.php @@ -99,11 +99,12 @@ $proxyCss = '.hero { color: #112233; font-size: 24px; } .cta { background-color: #ff0000; }'; $proxyReport = $runner->compareSourceToTransform($sourceHtml, $proxyCss); $transformResult = (new \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer()) - ->transform($sourceHtml, array())->toArray(); + ->transform($sourceHtml, array( 'static_css' => $proxyCss ))->toArray(); $proxyCandidateHtml = StaticStyleParityRunner::candidateHtmlFromSerializedBlocks( (string) ($transformResult['serialized_blocks'] ?? '') ); -$viaExternal = $runner->compareSourceToCandidate($sourceHtml, $proxyCandidateHtml, $proxyCss, $proxyCss); +$proxyAssetCss = implode("\n", array_map(static fn (array $asset): string => 'css' === ($asset['kind'] ?? '') ? (string) ($asset['content'] ?? '') : '', $transformResult['assets'] ?? array())); +$viaExternal = $runner->compareSourceToCandidate($sourceHtml, $proxyCandidateHtml, $proxyCss, $proxyAssetCss); $assert( json_encode($proxyReport) === json_encode($viaExternal), '2: external entry reproduces the render-free proxy when fed the proxy candidate + same CSS' From 6db2031351448b3aabe6eabab6d43de27e78fbc8 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 13 Jul 2026 17:12:35 -0400 Subject: [PATCH 2/2] fix: support geometry carriers on older PHP --- .../Style/StyleResolutionTrait.php | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php index df053d4a..432824e3 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php @@ -56,17 +56,23 @@ trait StyleResolutionTrait private ?GeometryCarrierClassAllocator $geometryCarrierClassAllocator = null; - private const INLINE_GEOMETRY_PROPERTIES = array( - 'width', - 'height', - 'min-width', - 'min-height', - 'max-width', - 'max-height', - 'aspect-ratio', - 'box-sizing', - 'flex-basis', - ); + /** + * @return list + */ + private function inlineGeometryProperties(): array + { + return array( + 'width', + 'height', + 'min-width', + 'min-height', + 'max-width', + 'max-height', + 'aspect-ratio', + 'box-sizing', + 'flex-basis', + ); + } private function resetPresentationResolutionCache(): void { @@ -135,7 +141,7 @@ private function inlineGeometryClassName(DOMElement $element, array $excludedPro { $declarations = $this->cssDeclarations($this->attr($element, 'style')); $geometry = array(); - foreach (self::INLINE_GEOMETRY_PROPERTIES as $property) { + foreach ($this->inlineGeometryProperties() as $property) { if (in_array($property, $excludedProperties, true)) { continue; } @@ -189,7 +195,7 @@ private function inlineGeometryStyle(DOMElement $element, array $excludedPropert $declarations = $this->cssDeclarations($this->attr($element, 'style')); $style = array(); $geometryValues = array(); - foreach (self::INLINE_GEOMETRY_PROPERTIES as $property) { + foreach ($this->inlineGeometryProperties() as $property) { if (in_array($property, $excludedProperties, true)) { continue; }