From cdbf5dd5c6cb650e73a439362e0a4a34135aabd9 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 2 Jul 2026 23:09:39 -0400 Subject: [PATCH 1/8] Fix SSI coffee transformer validity --- php-transformer/composer.json | 1 + .../src/ArtifactCompiler/ArtifactCompiler.php | 3 + .../Contract/ConversionReportProjection.php | 3 + .../src/CorpusDiagnostics/CorpusDetectors.php | 34 ++++++++-- .../src/HtmlToBlocks/BlockFactory.php | 68 ++++++++++++++++++- .../Diagnostics/DiagnosticsCollector.php | 3 + .../Diagnostics/FallbackEmitter.php | 3 + .../src/HtmlToBlocks/FallbackDiagnostic.php | 3 + .../src/HtmlToBlocks/HtmlTransformer.php | 67 +++++++++++++++++- .../Patterns/NavigationPattern.php | 48 +++++++++++-- .../Style/StyleAttributeMapper.php | 65 ++++++++++++++++-- .../Style/StyleResolutionTrait.php | 45 ++++++++++-- .../HtmlToBlocks/TypographyParityAnalyzer.php | 3 +- .../FontMaterializationPlanBuilder.php | 2 +- php-transformer/tests/contract/run.php | 66 +++++++++++++++++- ...nked-css-layout-wrapper-style-signals.json | 3 +- .../parity/html-anchor-inline-patterns.json | 2 +- .../parity/html-class-grid-card-layout.json | 2 +- ...explicit-grid-class-non-card-children.json | 1 + ...ml-group-wrapper-canonical-save-shape.json | 2 +- .../html-logo-nav-button-classification.json | 2 +- .../html-single-child-flex-svg-address.json | 2 +- .../parity/html-single-child-flex-svg.json | 2 +- ...ml-vertical-flex-column-becomes-group.json | 1 + .../unit/block-style-support-conversion.php | 62 +++++++++++++++++ .../tests/unit/css-value-splitter.php | 20 ++++++ 26 files changed, 477 insertions(+), 36 deletions(-) create mode 100644 php-transformer/tests/unit/block-style-support-conversion.php diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 80ecd4b7..c07d18df 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -57,6 +57,7 @@ "php tests/unit/subtree-classifier.php", "php tests/unit/custom-block-generator.php", "php tests/unit/css-value-splitter.php", + "php tests/unit/block-style-support-conversion.php", "php tests/unit/content-round-trip-reporter.php", "php tests/unit/content-round-trip-form-echo.php", "php tests/unit/corpus-detectors.php", diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 212babeb..50fe9afc 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -286,6 +286,9 @@ private function runtimeIslandsWithMaterializedInlineScripts(array $runtimeIslan 'diagnostic_code' => 'preserved_runtime_island', 'preservation_reason' => 'script_requires_runtime', 'runtime_requirement' => 'client_script_execution', + 'disposition' => 'preserve', + 'preservation_status' => 'accepted_runtime_preservation', + 'js_handling' => 'preserve_verbatim', 'source_snippet' => '', 'source_bytes' => strlen($content), 'source_truncated' => false, diff --git a/php-transformer/src/Contract/ConversionReportProjection.php b/php-transformer/src/Contract/ConversionReportProjection.php index 91fa0e07..24c46729 100644 --- a/php-transformer/src/Contract/ConversionReportProjection.php +++ b/php-transformer/src/Contract/ConversionReportProjection.php @@ -368,6 +368,9 @@ private static function runtimeIslandSummaryEntries(array $sourceReports): array 'tag' => $island['tag'] ?? '', 'conversion_classification' => 'runtime_island_preserved', 'preservation_strategy' => $island['preservation_strategy'] ?? 'scoped_runtime_metadata', + 'disposition' => $island['disposition'] ?? '', + 'preservation_status' => $island['preservation_status'] ?? '', + 'js_handling' => $island['js_handling'] ?? '', ), static fn (mixed $value): bool => '' !== $value ); diff --git a/php-transformer/src/CorpusDiagnostics/CorpusDetectors.php b/php-transformer/src/CorpusDiagnostics/CorpusDetectors.php index 6b7885b8..4ea9dbce 100644 --- a/php-transformer/src/CorpusDiagnostics/CorpusDetectors.php +++ b/php-transformer/src/CorpusDiagnostics/CorpusDetectors.php @@ -269,13 +269,14 @@ public static function varDependentStyling(array $flat): array foreach ( $flat as $block ) { $haystack = self::blockMarkup($block); - if ( '' === $haystack ) { - continue; - } - if ( ! preg_match_all('/var\(\s*(--[A-Za-z0-9_-]+)/', $haystack, $matches) ) { - continue; + if ( '' !== $haystack && preg_match_all('/var\(\s*(--[A-Za-z0-9_-]+)/', $haystack, $matches) ) { + foreach ( $matches[1] as $name ) { + ++$total; + $occurrences[$name] = ($occurrences[$name] ?? 0) + 1; + } } - foreach ( $matches[1] as $name ) { + + foreach ( self::presetVarNamesFromAttrs($block) as $name ) { ++$total; $occurrences[$name] = ($occurrences[$name] ?? 0) + 1; } @@ -729,6 +730,27 @@ private static function blockMarkup(array $block): string return is_string($block['innerHTML'] ?? null) ? $block['innerHTML'] : ''; } + /** + * Native preset color attrs are the valid form of CSS preset vars, so they no + * longer appear in innerHTML. Keep them in var_names for corpus visibility. + * + * @param array $block + * @return array + */ + private static function presetVarNamesFromAttrs(array $block): array + { + $attrs = is_array($block['attrs'] ?? null) ? $block['attrs'] : array(); + $names = array(); + foreach ( array( 'textColor', 'backgroundColor' ) as $attrName ) { + $slug = is_string($attrs[ $attrName ] ?? null) ? strtolower(trim($attrs[ $attrName ])) : ''; + if ( '' !== $slug && preg_match('/^[a-z0-9_-]+$/', $slug) ) { + $names[] = '--wp--preset--color--' . $slug; + } + } + + return $names; + } + /** * RichText content for a paragraph/heading/list-item block: the explicit * content attribute, falling back to saved innerHTML. diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php index a4b07561..920de2b2 100644 --- a/php-transformer/src/HtmlToBlocks/BlockFactory.php +++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php @@ -33,6 +33,7 @@ private function styleMapper(): StyleAttributeMapper */ public function create(string $name, array $attrs = array(), array $innerBlocks = array()): array { + $attrs = $this->normalizeAttrsForBlock($name, $attrs); $innerHtml = $this->blockHtml($name, $attrs, $innerBlocks); if ( is_array($innerHtml) ) { $innerContent = array( $innerHtml['opening'] ); @@ -54,6 +55,25 @@ public function create(string $name, array $attrs = array(), array $innerBlocks ); } + /** + * @param array $attrs + * @return array + */ + private function normalizeAttrsForBlock(string $name, array $attrs): array + { + if ( in_array($name, array( 'core/column', 'core/group', 'core/heading', 'core/list-item', 'core/paragraph' ), true) ) { + unset($attrs['style']['spacing']['blockGap']); + if ( empty($attrs['style']['spacing']) ) { + unset($attrs['style']['spacing']); + } + if ( empty($attrs['style']) ) { + unset($attrs['style']); + } + } + + return $attrs; + } + /** * @param array $attrs * @return array @@ -545,7 +565,9 @@ private function mergeClassNames(string ...$classNames): string private function blockSupportAttrs(array $attrs, string $baseClass = ''): string { $support = $this->styleSupport($attrs['style'] ?? null); - $classes = $this->mergeClassNames($baseClass, $support['classes'], (string) ($attrs['className'] ?? '')); + $presetClasses = $this->presetColorClasses($attrs); + $layoutClasses = $this->layoutClasses($attrs['layout'] ?? null, $baseClass); + $classes = $this->mergeClassNames($baseClass, $presetClasses, $support['classes'], $layoutClasses, (string) ($attrs['className'] ?? '')); return $this->htmlAttrs(array( 'id' => (string) ($attrs['anchor'] ?? ''), 'class' => $classes, @@ -573,6 +595,50 @@ private function styleSupport(mixed $style): array ); } + /** + * @param array $attrs + */ + private function presetColorClasses(array $attrs): string + { + $classes = array(); + $textColor = $this->safeSlug((string) ($attrs['textColor'] ?? '')); + if ( '' !== $textColor ) { + $classes[] = 'has-' . $textColor . '-color'; + $classes[] = 'has-text-color'; + } + + $backgroundColor = $this->safeSlug((string) ($attrs['backgroundColor'] ?? '')); + if ( '' !== $backgroundColor ) { + $classes[] = 'has-' . $backgroundColor . '-background-color'; + $classes[] = 'has-background'; + } + + return implode(' ', $classes); + } + + private function layoutClasses(mixed $layout, string $baseClass): string + { + if ( ! is_array($layout) ) { + return ''; + } + + $type = $this->safeSlug((string) ($layout['type'] ?? '')); + if ( ! in_array($type, array( 'constrained', 'flex', 'flow', 'grid' ), true) ) { + return ''; + } + + return $this->mergeClassNames( + 'is-layout-' . $type, + '' !== $baseClass ? $baseClass . '-is-layout-' . $type : '' + ); + } + + private function safeSlug(string $value): string + { + $value = strtolower(trim($value)); + return preg_match('/^[a-z0-9_-]+$/', $value) ? $value : ''; + } + /** * @param array $attrs * @param array $includeEmpty diff --git a/php-transformer/src/HtmlToBlocks/Diagnostics/DiagnosticsCollector.php b/php-transformer/src/HtmlToBlocks/Diagnostics/DiagnosticsCollector.php index 5d825d0d..6ca3870a 100644 --- a/php-transformer/src/HtmlToBlocks/Diagnostics/DiagnosticsCollector.php +++ b/php-transformer/src/HtmlToBlocks/Diagnostics/DiagnosticsCollector.php @@ -102,6 +102,9 @@ public function collect( 'diagnostic_class' => 'runtime_island_preserved', 'suggested_repair_class' => 'preserve_runtime_island', 'preservation_strategy' => $island['preservation_strategy'] ?? 'bounded_raw_html_runtime_island', + 'disposition' => $island['disposition'] ?? null, + 'preservation_status' => $island['preservation_status'] ?? null, + 'js_handling' => $island['js_handling'] ?? null, 'runtime_requirement' => $island['runtime_requirement'] ?? null, 'kind' => $island['kind'] ?? null, 'reason' => $island['preservation_reason'] ?? null, diff --git a/php-transformer/src/HtmlToBlocks/Diagnostics/FallbackEmitter.php b/php-transformer/src/HtmlToBlocks/Diagnostics/FallbackEmitter.php index 6aa70398..56f75995 100644 --- a/php-transformer/src/HtmlToBlocks/Diagnostics/FallbackEmitter.php +++ b/php-transformer/src/HtmlToBlocks/Diagnostics/FallbackEmitter.php @@ -506,6 +506,9 @@ public function recordRuntimeIsland(DOMElement $element, string $kind, string $r 'diagnostic_code' => 'preserved_runtime_island', 'preservation_reason' => $reason, 'runtime_requirement' => $runtimeRequirement, + 'disposition' => 'preserve', + 'preservation_status' => 'accepted_runtime_preservation', + 'js_handling' => 'client_script_execution' === $runtimeRequirement ? 'preserve_verbatim' : '', 'source_snippet' => $boundedHtml['html'], 'source_bytes' => $boundedHtml['bytes'], 'source_truncated' => $boundedHtml['truncated'], diff --git a/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php b/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php index 293190d0..1cf7b333 100644 --- a/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php +++ b/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php @@ -84,6 +84,9 @@ private static function defaults(array $fields): array 'conversion_classification' => 'runtime_island_preserved', 'loss_class' => 'runtime_island_preserved', 'diagnostic_class' => 'runtime_island_preserved', + 'disposition' => 'preserve', + 'preservation_status' => 'accepted_runtime_preservation', + 'js_handling' => 'preserve_verbatim', 'preservation_strategy' => 'scoped_runtime_metadata', 'runtime_requirement' => 'client_script_execution', 'recoverability' => 'recoverable_with_script_enqueue_or_component_runtime', diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 51944e1d..689080e1 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -1099,6 +1099,9 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca if ( preg_match('/^h([1-6])$/', $tagName, $matches) ) { $content = $this->innerHtml($element); + if ( $this->richTextRequiresHtmlFallback($content) ) { + return $this->createBlock('core/html', array( 'content' => $this->restoreSvgCasing($this->outerHtml($element)) ), array(), $element); + } if ( '' === trim($this->runtime->stripAllTags($content)) ) { return null; } @@ -1111,6 +1114,9 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca if ( 'p' === $tagName ) { $content = $this->innerHtml($element); + if ( $this->richTextRequiresHtmlFallback($content) ) { + return $this->createBlock('core/html', array( 'content' => $this->restoreSvgCasing($this->outerHtml($element)) ), array(), $element); + } if ( '' === trim($this->runtime->stripAllTags($content)) ) { if ( $this->isRuntimeDomTarget($element) ) { return $this->createBlock('core/group', $this->presentationAttributes($element), array(), $element); @@ -1829,6 +1835,10 @@ private function createBlock(string $name, array $attrs = array(), array $innerB { $attrs = $this->hoistContentWrappingSpans($name, $attrs); + if ( $sourceElement instanceof DOMElement && in_array($name, array( 'core/paragraph', 'core/heading' ), true) && $this->richTextRequiresHtmlFallback((string) ($attrs['content'] ?? '')) ) { + return $this->blockFactory->create('core/html', array( 'content' => $this->restoreSvgCasing($this->outerHtml($sourceElement)) )); + } + if ( $sourceElement instanceof DOMElement ) { $provenanceId = $this->nextSourceProvenanceId++; $this->recordPresentationProvenance($name, $attrs, $sourceElement); @@ -1880,7 +1890,10 @@ private function createBlock(string $name, array $attrs = array(), array $innerB * - Remaining sibling/partial styling-hook spans are UNWRAPPED to their * inner content. Their per-span class styling cannot ride valid RichText * here, so this is best-effort; the emitted block is always valid. - * Genuine inline formats (strong/em/a/br/…) are never touched. + * Genuine inline formats (strong/em/a/br/…) are kept, but arbitrary + * class/style hooks on links are moved to the block wrapper when the link is + * the sole content wrapper, or dropped when they are partial-content hooks. + * RichText's link format round-trips href/target/rel, not source CSS hooks. * * A list item whose content carries block-level children (an image/heading/ * paragraph "card", e.g. a commerce product grid) is left untouched here: @@ -1897,7 +1910,7 @@ private function hoistContentWrappingSpans(string $name, array $attrs): array } $content = (string) ($attrs['content'] ?? ''); - if ( '' === $content || ! preg_match('/unwrapElement($wrapper); } + $soleAnchor = $this->soleRichTextAnchor($body); + if ( $soleAnchor instanceof DOMElement ) { + $hoistedClasses = trim($hoistedClasses . ' ' . $this->attr($soleAnchor, 'class')); + $anchorStyle = trim($this->attr($soleAnchor, 'style')); + if ( '' !== $anchorStyle ) { + $hoistedDeclarations = array_merge($hoistedDeclarations, $this->cssDeclarations($anchorStyle)); + } + } + + foreach ( $this->richTextAnchors($body) as $anchor ) { + $anchor->removeAttribute('class'); + $anchor->removeAttribute('style'); + } + // Unwrap any remaining styling-hook spans (sibling / partial content): // best-effort, the class styling is not representable as valid RichText. foreach ( $this->stylingHookSpans($body) as $span ) { @@ -1982,6 +2009,22 @@ private function soleStylingHookSpan(DOMElement $container): ?DOMElement return $only instanceof DOMElement && $this->isStylingHookSpan($only) ? $only : null; } + private function soleRichTextAnchor(DOMElement $container): ?DOMElement + { + $only = null; + foreach ( $container->childNodes as $child ) { + if ( XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent ?? '') ) { + continue; + } + if ( null !== $only ) { + return null; + } + $only = $child; + } + + return $only instanceof DOMElement && 'a' === strtolower($only->tagName) ? $only : null; + } + /** * A `` whose only attributes are class and/or style (at least one * non-empty). These are presentational styling hooks RichText cannot store, @@ -2022,6 +2065,26 @@ private function stylingHookSpans(DOMElement $container): array return $spans; } + /** + * @return array + */ + private function richTextAnchors(DOMElement $container): array + { + $anchors = array(); + foreach ( $container->getElementsByTagName('a') as $anchor ) { + if ( $anchor instanceof DOMElement && ( $anchor->hasAttribute('class') || $anchor->hasAttribute('style') ) ) { + $anchors[] = $anchor; + } + } + + return $anchors; + } + + private function richTextRequiresHtmlFallback(string $content): bool + { + return (bool) preg_match('/<(?:svg|canvas|img|picture|video|audio|iframe|object|embed|input|button|select|textarea|form)\b/i', $content); + } + /** * Replace an element with its children in place, dropping only the wrapper. */ diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php index c7f60938..8eb1af74 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php @@ -39,7 +39,7 @@ public function match(DOMElement $element, PatternContext $context): ?array // `mobile` matches the core default: WP renders the responsive overlay // container and enqueues the `navigation/view` Interactivity module so the // hamburger menu functions on the rendered site (#native-interactivity). - return $createBlock('core/navigation', array_merge($presentationAttributes($element), array( + return $createBlock('core/navigation', array_merge($this->navigationContainerAttributes($element, $presentationAttributes), array( 'overlayMenu' => 'mobile', )), $links, $element); } @@ -251,9 +251,9 @@ private function navigationLabel(string $html): string */ private function navigationItemAttributes(DOMElement $item, DOMElement $anchor, ?DOMElement $submenuContainer, array $baseAttrs, callable $presentationAttributes): array { - $itemAttrs = $item->isSameNode($anchor) ? array() : $presentationAttributes($item); - $anchorAttrs = $presentationAttributes($anchor); - $submenuAttrs = $submenuContainer instanceof DOMElement ? $presentationAttributes($submenuContainer) : array(); + $itemAttrs = $item->isSameNode($anchor) ? array() : $this->withoutCoreNavigationClasses($presentationAttributes($item)); + $anchorAttrs = $this->withoutCoreNavigationClasses($presentationAttributes($anchor)); + $submenuAttrs = $submenuContainer instanceof DOMElement ? $this->withoutCoreNavigationClasses($presentationAttributes($submenuContainer)) : array(); if ( '' === (string) ($itemAttrs['className'] ?? '') && '' !== (string) ($anchorAttrs['className'] ?? '') ) { $itemAttrs['className'] = $anchorAttrs['className']; } @@ -267,6 +267,44 @@ private function navigationItemAttributes(DOMElement $item, DOMElement $anchor, )), static fn ($value): bool => '' !== $value); } + /** + * @return array + */ + private function navigationContainerAttributes(DOMElement $element, callable $presentationAttributes): array + { + return $this->withoutCoreNavigationClasses($presentationAttributes($element)); + } + + /** + * @param array $attrs + * @return array + */ + private function withoutCoreNavigationClasses(array $attrs): array + { + if ( empty($attrs['className']) || ! is_string($attrs['className']) ) { + return $attrs; + } + + $classNames = array_values(array_filter(preg_split('/\s+/', trim($attrs['className'])) ?: array(), static function (string $className): bool { + return ! in_array($className, array( + 'wp-block-navigation', + 'wp-block-navigation-item', + 'wp-block-navigation-link', + 'wp-block-navigation-submenu', + 'wp-block-navigation__container', + 'wp-block-navigation__submenu-container', + ), true); + })); + + if ( array() === $classNames ) { + unset($attrs['className']); + return $attrs; + } + + $attrs['className'] = implode(' ', $classNames); + return $attrs; + } + private function attr(DOMElement $element, string $name): string { return $element->hasAttribute($name) ? $element->getAttribute($name) : ''; @@ -310,7 +348,7 @@ private function submenuContainers(DOMElement $element, DOMElement $primaryAncho } $tagName = strtolower($child->tagName); - if ( in_array($tagName, array( 'ul', 'ol' ), true) || $this->hasSubmenuSignal($child) ) { + if ( in_array($tagName, array( 'nav', 'ul', 'ol' ), true) || $this->hasSubmenuSignal($child) ) { $containers[] = $child; } } diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleAttributeMapper.php b/php-transformer/src/HtmlToBlocks/Style/StyleAttributeMapper.php index 1d0c56f7..7efaa4a2 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleAttributeMapper.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleAttributeMapper.php @@ -53,7 +53,7 @@ final class StyleAttributeMapper * Map resolved CSS declarations to canonical block style attributes. * * @param array $declarations - * @return array{style: array, leftover: array} + * @return array{style: array, attrs: array, leftover: array} */ public function map(array $declarations): array { @@ -74,7 +74,8 @@ public function map(array $declarations): array $style['typography'] = $typography; } - $color = $this->color($normalized, $consumed); + $attrs = array(); + $color = $this->color($normalized, $consumed, $attrs); if ( array() !== $color ) { $style['color'] = $color; } @@ -92,6 +93,11 @@ public function map(array $declarations): array $style['spacing'] = $spacing; } + $blockGap = $this->blockGap($normalized, $consumed); + if ( '' !== $blockGap ) { + $style['spacing']['blockGap'] = $blockGap; + } + $border = $this->border($normalized, $consumed); if ( array() !== $border ) { $style['border'] = $border; @@ -107,6 +113,7 @@ public function map(array $declarations): array return array( 'style' => $style, + 'attrs' => $attrs, 'leftover' => $leftover, ); } @@ -167,6 +174,10 @@ public function serialize(array $style): array } } } + $blockGap = trim((string) ($spacing['blockGap'] ?? '')); + if ( '' !== $blockGap ) { + $declarations[] = 'gap:' . $blockGap; + } $typography = is_array($style['typography'] ?? null) ? $style['typography'] : array(); $typographyMap = array( @@ -225,7 +236,7 @@ private function typography(array $declarations, array &$consumed): array * @param array $consumed * @return array */ - private function color(array $declarations, array &$consumed): array + private function color(array $declarations, array &$consumed, array &$attrs): array { $color = array(); @@ -233,14 +244,24 @@ private function color(array $declarations, array &$consumed): array $consumed['color'] = true; $text = $this->cssColor($declarations['color']); if ( '' !== $text ) { - $color['text'] = $text; + $preset = $this->presetColorSlug($text); + if ( '' !== $preset ) { + $attrs['textColor'] = $preset; + } else { + $color['text'] = $text; + } } } $gradient = $this->gradient($declarations, $consumed); $background = $this->backgroundColor($declarations, $consumed); if ( '' !== $background ) { - $color['background'] = $background; + $preset = $this->presetColorSlug($background); + if ( '' !== $preset ) { + $attrs['backgroundColor'] = $preset; + } else { + $color['background'] = $background; + } } if ( '' !== $gradient ) { $color['gradient'] = $gradient; @@ -249,6 +270,40 @@ private function color(array $declarations, array &$consumed): array return $color; } + /** + * Gutenberg stores preset colors as top-level block attrs, not custom inline + * style values. Accept both serialized support syntax and CSS custom props. + */ + private function presetColorSlug(string $value): string + { + $value = trim($value); + if ( preg_match('/^var:preset\|color\|([a-z0-9_-]+)$/i', $value, $match) ) { + return strtolower($match[1]); + } + if ( preg_match('/^var\(\s*--wp--preset--color--([a-z0-9_-]+)\s*\)$/i', $value, $match) ) { + return strtolower($match[1]); + } + + return ''; + } + + /** + * @param array $declarations + * @param array $consumed + */ + private function blockGap(array $declarations, array &$consumed): string + { + foreach ( array( 'gap', 'row-gap', 'column-gap' ) as $name ) { + $value = trim((string) ($declarations[ $name ] ?? '')); + if ( '' !== $value ) { + $consumed[ $name ] = true; + return $value; + } + } + + return ''; + } + /** * @param array $declarations * @param array $consumed diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php index 91fc0583..0bd446b3 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php @@ -48,12 +48,12 @@ private function presentationAttributes(DOMElement $element): array $declarations = $this->stripFrozenHiddenState($element, $this->cssDeclarations($style)); $mapped = $this->styleAttributeMapper()->map($declarations); - return array_filter(array( + return array_filter(array_merge($mapped['attrs'] ?? array(), array( 'anchor' => $this->safeAnchor($this->attr($element, 'id')), 'className' => $this->promotedClassName($this->attr($element, 'class')), 'style' => $mapped['style'], 'layout' => $this->layoutAttribute($element, $this->cssDeclarationString($declarations)), - ), static fn ($value): bool => is_array($value) ? array() !== $value : '' !== trim((string) $value)); + )), static fn ($value): bool => is_array($value) ? array() !== $value : '' !== trim((string) $value)); } /** @@ -420,20 +420,28 @@ private function layoutAttribute(DOMElement $element, string $mergedStyle = ''): } $inlineStyle = strtolower($this->attr($element, 'style')); + $mergedDeclarations = $this->cssDeclarations($mergedStyle); + $inlineDeclarations = $this->cssDeclarations($inlineStyle); if ( preg_match('/(?:^|;)\s*display\s*:\s*(inline-)?flex\b/', $inlineStyle) ) { + $layout = array( 'type' => 'flex' ); // flex-direction: column / column-reverse is a vertical main axis. A // core/group flex layout defaults to a horizontal Row, so the // orientation must be made explicit or the children render // side-by-side instead of stacked. Row / row-reverse / default flex // keeps the implicit horizontal orientation. if ( preg_match('/(?:^|;)\s*flex-direction\s*:\s*column(?:-reverse)?\b/', $inlineStyle) ) { - return array( - 'type' => 'flex', - 'orientation' => 'vertical', - ); + $layout['orientation'] = 'vertical'; + } + $justifyContent = $this->layoutJustifyContent((string) ($inlineDeclarations['justify-content'] ?? $mergedDeclarations['justify-content'] ?? '')); + if ( '' !== $justifyContent ) { + $layout['justifyContent'] = $justifyContent; + } + $flexWrap = $this->layoutFlexWrap((string) ($inlineDeclarations['flex-wrap'] ?? $mergedDeclarations['flex-wrap'] ?? '')); + if ( '' !== $flexWrap ) { + $layout['flexWrap'] = $flexWrap; } - return array( 'type' => 'flex' ); + return $layout; } $style = strtolower('' !== trim($mergedStyle) ? $mergedStyle : $this->attr($element, 'style')); if ( preg_match('/(?:^|;)\s*display\s*:\s*(inline-)?flex\b/', $style) @@ -470,6 +478,29 @@ private function layoutAttribute(DOMElement $element, string $mergedStyle = ''): return array(); } + private function layoutJustifyContent(string $value): string + { + $value = strtolower(trim($value)); + $map = array( + 'flex-start' => 'left', + 'start' => 'left', + 'left' => 'left', + 'center' => 'center', + 'flex-end' => 'right', + 'end' => 'right', + 'right' => 'right', + 'space-between' => 'space-between', + ); + + return $map[ $value ] ?? ''; + } + + private function layoutFlexWrap(string $value): string + { + $value = strtolower(trim($value)); + return in_array($value, array( 'wrap', 'nowrap' ), true) ? $value : ''; + } + /** * Unambiguous grid class tokens: a bare `grid`, a numbered `grid-N`, or any * `*-grid` / `*_grid` suffix (footer-grid, card-grid, mission-grid, …) plus diff --git a/php-transformer/src/HtmlToBlocks/TypographyParityAnalyzer.php b/php-transformer/src/HtmlToBlocks/TypographyParityAnalyzer.php index f9ce2f5d..c8e6d23c 100644 --- a/php-transformer/src/HtmlToBlocks/TypographyParityAnalyzer.php +++ b/php-transformer/src/HtmlToBlocks/TypographyParityAnalyzer.php @@ -63,8 +63,9 @@ public function findings(string $html, string $css, array $inlineHeadingDeclarat ); } + $typographyCss = $this->planBuilder->resolveCssVariables(trim($this->styleBlockCss($html) . "\n" . $css)); $headingDeclarations = array_merge( - $this->headingFamiliesFromCss($this->styleBlockCss($html)), + $this->headingFamiliesFromCss($typographyCss), $this->normalizeInlineHeadingDeclarations($inlineHeadingDeclarations) ); diff --git a/php-transformer/src/StaticSite/FontMaterialization/FontMaterializationPlanBuilder.php b/php-transformer/src/StaticSite/FontMaterialization/FontMaterializationPlanBuilder.php index 8a0ba9e0..503115be 100644 --- a/php-transformer/src/StaticSite/FontMaterialization/FontMaterializationPlanBuilder.php +++ b/php-transformer/src/StaticSite/FontMaterialization/FontMaterializationPlanBuilder.php @@ -304,7 +304,7 @@ private function primaryFamily(string $declaration): string * passes resolve variables whose values reference other variables. Leaves * unresolved references intact so they can be filtered as invalid families. */ - private function resolveCssVariables(string $css): string + public function resolveCssVariables(string $css): string { if ( '' === trim($css) || ! str_contains($css, 'var(') ) { return $css; diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 81ab0f75..05162924 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -560,10 +560,42 @@ public function match(DOMElement $element, PatternContext $context): ?array $linkedLogoSerialized = (string) ($linkedLogoResult['serialized_blocks'] ?? ''); $assert('core/paragraph' === ($linkedLogoBlock['blockName'] ?? ''), 'linked logo text converts to a paragraph block'); $assert(! array_key_exists('content', is_array($linkedLogoBlock['attrs'] ?? null) ? $linkedLogoBlock['attrs'] : array()), 'paragraph source content is not serialized as a block comment attribute'); -$assert(str_contains($linkedLogoSerialized, ''), 'linked logo paragraph preserves anchor markup in saved HTML'); +$assert(str_contains($linkedLogoSerialized, ''), 'linked logo paragraph hoists link styling hooks to the paragraph wrapper and keeps valid anchor markup'); +$assert(! str_contains($linkedLogoSerialized, '