From 419adb95bb7848e6bd24d989618cb1a0dbfc7dd0 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 9 Nov 2026 12:47:29 -0500 Subject: [PATCH 1/4] refactor(php-transformer): move inert element recognition into Elements/InertElements Moves isInertRuntimeMediaPlaceholder, isInertHiddenSvgStorage and hiddenSvgStoreHasNonSvgConsumer out of HtmlCompilation verbatim. --- .../HtmlToBlocks/Elements/InertElements.php | 123 ++++++++++++++++++ .../src/HtmlToBlocks/HtmlCompilation.php | 105 +-------------- 2 files changed, 129 insertions(+), 99 deletions(-) create mode 100644 php-transformer/src/HtmlToBlocks/Elements/InertElements.php diff --git a/php-transformer/src/HtmlToBlocks/Elements/InertElements.php b/php-transformer/src/HtmlToBlocks/Elements/InertElements.php new file mode 100644 index 000000000..3033dc6c2 --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Elements/InertElements.php @@ -0,0 +1,123 @@ +sourceElementClassifier->isRuntimeMediaSurfaceElement($element) + || ! str_contains(strtolower($element->tagName), '-') + || '' !== trim($element->textContent ?? '') + ) { + return false; + } + + foreach ( array_merge(array( $element ), iterator_to_array($element->getElementsByTagName('*'))) as $candidate ) { + if ( ! $candidate instanceof DOMElement + || ! $this->runtimeIslands->isSafeTransparentCustomElement($candidate) + || '' !== trim(SourceDom::attr($candidate, 'role')) + || '' !== trim(SourceDom::attr($candidate, 'aria-label')) + ) { + return false; + } + + foreach ( array( 'src', 'srcset', 'href', 'data-src', 'data-url' ) as $attribute ) { + if ( '' !== trim(SourceDom::attr($candidate, $attribute)) ) { + return false; + } + } + + foreach ( $candidate->attributes as $attribute ) { + if ( str_starts_with(strtolower($attribute->name), 'data-') && '' !== trim($attribute->value) ) { + return false; + } + } + + if ( $candidate !== $element && ! $this->sourceElementClassifier->isStructuralTransparentCustomWrapperChild($candidate) ) { + return false; + } + } + + return true; + } + + public function isInertHiddenSvgStorage(DOMElement $element): bool + { + $isDomStore = $element->hasAttribute('data-dom-store'); + if ( ! $this->sourceVisibility->startsHidden($element) + || (! $isDomStore && ( + $this->styleResolver->hasConditionalStyleFamily($element, 'layout') + || $this->styleResolver->hasConditionalStyleFamily($element, 'visibility') + || $this->styleResolver->hasConditionalStyleFamily($element, 'opacity') + )) + || '' !== trim(SourceDom::attr($element, 'aria-label')) + || '' !== trim(SourceDom::attr($element, 'aria-labelledby')) + || '' !== trim(SourceDom::attr($element, 'title')) + || ! $this->sourceElementClassifier->hasOnlySvgDefinitions($element) + || $this->hiddenSvgStoreHasNonSvgConsumer($element) ) { + return false; + } + + return true; + } + + private function hiddenSvgStoreHasNonSvgConsumer(DOMElement $store): bool + { + $ids = array(); + foreach ( $store->getElementsByTagName('*') as $definition ) { + if ( $definition instanceof DOMElement && '' !== trim(SourceDom::attr($definition, 'id')) ) { + $ids[] = trim(SourceDom::attr($definition, 'id')); + } + } + if ( array() === $ids || ! $store->ownerDocument instanceof DOMDocument ) { + return false; + } + + foreach ( $store->ownerDocument->getElementsByTagName('*') as $candidate ) { + if ( ! $candidate instanceof DOMElement || $this->sourceElementClassifier->isDescendantOf($candidate, $store) ) { + continue; + } + foreach ( $candidate->attributes as $attribute ) { + foreach ( $ids as $id ) { + if ( preg_match('/(?:^|[\\s,(])(?:url\\(\\s*["\']?#' . preg_quote($id, '/') . '["\']?\\s*\\)|#' . preg_quote($id, '/') . '(?:$|[\\s,)]))/', $attribute->value) + && 'svg' !== strtolower($candidate->tagName) + && null === SourceDom::ancestorElement($candidate, 'svg') ) { + return true; + } + } + } + if ( 'style' === strtolower($candidate->tagName) ) { + foreach ( $ids as $id ) { + if ( preg_match('/url\\(\\s*["\']?#' . preg_quote($id, '/') . '["\']?\\s*\\)/', $candidate->textContent ?? '') ) { + return true; + } + } + } + } + + return false; + } +} diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index f8bd86489..91e3451e1 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -314,6 +314,7 @@ final class HtmlCompilation extends SourceBlockCreator implements RichTextInline private readonly Elements\CustomImageHostLowering $customImageHosts; private readonly Elements\AnchorLowering $anchors; private readonly Elements\RenderNeutralElements $renderNeutral; + private readonly Elements\InertElements $inertElements; private readonly SourceProvenance $sourceProvenance; private readonly RuntimeResourceElementConverter $runtimeResourceConverter; @@ -605,6 +606,7 @@ function (DOMElement $element): array { $this->listStructure ); $this->renderNeutral = new Elements\RenderNeutralElements($this->session, $this->styleResolver, $this->sourceElementClassifier, $this->sourceElementDescription, $this->runtimeIslands, $this->emptyElementFootprint); + $this->inertElements = new Elements\InertElements($this->styleResolver, $this->sourceElementClassifier, $this->sourceVisibility, $this->runtimeIslands); $this->customImageHosts = new Elements\CustomImageHostLowering($this, $this->blockFactory, $this->styleResolver, $this->session, $this->sourceElementClassifier, $this->sourceElementDescription, $this->imageLowering, $this->layoutShells, $this->runtimeIslands, $this->authorLayoutOwnership); $this->anchors = new Elements\AnchorLowering($this, $this, $this->styleResolver, $this->session, $this->sourceElementClassifier, $this->sourceElementDescription, $this->imageLowering, $this->imageUrls, $this->layoutShells, $this->linkWrapperPropagator, $this->runtimeIslands, $this->customImageHosts); $this->layoutTables = new Elements\LayoutTableLowering( @@ -645,7 +647,7 @@ function (DOMElement $element): array { $this->textLeafConverter = new TextLeafElementConverter($this->richTextMaterializer, $this->runtime, $this, $this->styleResolver, $this->sourceElementClassifier, $this->createTextLeafElementContext()); $this->richTextConverter = new RichTextElementConverter($this, $this->styleResolver, $this->richTextMaterializer, $this->runtime, $this->createRichTextElementContext()); $svgConverter = new SvgElementConverter($this, $this->sourceElementClassifier, $this->styleResolver, new SvgElementContext( - fn (DOMElement $element): bool => $this->isInertHiddenSvgStorage($element), + fn (DOMElement $element): bool => $this->inertElements->isInertHiddenSvgStorage($element), fn (DOMElement $element): bool => $this->runtimeIslands->isRuntimeDomTarget($element), fn (DOMElement $element): string => $this->sanitizeInlineSvgMarkup($element), fn (string $html): bool => $this->isSafeSvgContent($html), @@ -954,7 +956,7 @@ function (DOMElement $element, array &$fallbacks): array { $this, $this->runtimeIslands, $this->session, - fn (DOMElement $element): bool => $this->isInertRuntimeMediaPlaceholder($element), + fn (DOMElement $element): bool => $this->inertElements->isInertRuntimeMediaPlaceholder($element), fn (DOMElement $element): bool => $this->sourceVisibility->startsHidden($element), $this->sourceElementDescription ); @@ -1094,7 +1096,7 @@ function (DOMElement $element, array &$fallbacks): ?array { fn (DOMElement $element): bool => $this->shouldPreserveEmptyVisualElement($element) ), $this->projectedNavigation, - new PhrasingSvgConverter($this->svgMaterializer, $this, fn (DOMElement $element): bool => $this->isInertHiddenSvgStorage($element)), + new PhrasingSvgConverter($this->svgMaterializer, $this, fn (DOMElement $element): bool => $this->inertElements->isInertHiddenSvgStorage($element)), $capturedDialogConverter, new CapturedChoiceGroupConverter( $this->session, @@ -3566,7 +3568,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca return $transparentCustomElement; } - if ( $this->isInertRuntimeMediaPlaceholder($element) ) { + if ( $this->inertElements->isInertRuntimeMediaPlaceholder($element) ) { return null; } @@ -3799,44 +3801,6 @@ private function recognizedNavigationHostBlock(DOMElement $element, array &$fall } - private function isInertRuntimeMediaPlaceholder(DOMElement $element): bool - { - if ( ! $this->sourceElementClassifier->isRuntimeMediaSurfaceElement($element) - || ! str_contains(strtolower($element->tagName), '-') - || '' !== trim($element->textContent ?? '') - ) { - return false; - } - - foreach ( array_merge(array( $element ), iterator_to_array($element->getElementsByTagName('*'))) as $candidate ) { - if ( ! $candidate instanceof DOMElement - || ! $this->runtimeIslands->isSafeTransparentCustomElement($candidate) - || '' !== trim($this->attr($candidate, 'role')) - || '' !== trim($this->attr($candidate, 'aria-label')) - ) { - return false; - } - - foreach ( array( 'src', 'srcset', 'href', 'data-src', 'data-url' ) as $attribute ) { - if ( '' !== trim($this->attr($candidate, $attribute)) ) { - return false; - } - } - - foreach ( $candidate->attributes as $attribute ) { - if ( str_starts_with(strtolower($attribute->name), 'data-') && '' !== trim($attribute->value) ) { - return false; - } - } - - if ( $candidate !== $element && ! $this->sourceElementClassifier->isStructuralTransparentCustomWrapperChild($candidate) ) { - return false; - } - } - - return true; - } - /** * @return array|null */ @@ -6883,63 +6847,6 @@ private function containsExcludedMedia(DOMElement $element): bool return false; } - private function isInertHiddenSvgStorage(DOMElement $element): bool - { - $isDomStore = $element->hasAttribute('data-dom-store'); - if ( ! $this->sourceVisibility->startsHidden($element) - || (! $isDomStore && ( - $this->styleResolver->hasConditionalStyleFamily($element, 'layout') - || $this->styleResolver->hasConditionalStyleFamily($element, 'visibility') - || $this->styleResolver->hasConditionalStyleFamily($element, 'opacity') - )) - || '' !== trim($this->attr($element, 'aria-label')) - || '' !== trim($this->attr($element, 'aria-labelledby')) - || '' !== trim($this->attr($element, 'title')) - || ! $this->sourceElementClassifier->hasOnlySvgDefinitions($element) - || $this->hiddenSvgStoreHasNonSvgConsumer($element) ) { - return false; - } - - return true; - } - - private function hiddenSvgStoreHasNonSvgConsumer(DOMElement $store): bool - { - $ids = array(); - foreach ( $store->getElementsByTagName('*') as $definition ) { - if ( $definition instanceof DOMElement && '' !== trim($this->attr($definition, 'id')) ) { - $ids[] = trim($this->attr($definition, 'id')); - } - } - if ( array() === $ids || ! $store->ownerDocument instanceof DOMDocument ) { - return false; - } - - foreach ( $store->ownerDocument->getElementsByTagName('*') as $candidate ) { - if ( ! $candidate instanceof DOMElement || $this->sourceElementClassifier->isDescendantOf($candidate, $store) ) { - continue; - } - foreach ( $candidate->attributes as $attribute ) { - foreach ( $ids as $id ) { - if ( preg_match('/(?:^|[\\s,(])(?:url\\(\\s*["\']?#' . preg_quote($id, '/') . '["\']?\\s*\\)|#' . preg_quote($id, '/') . '(?:$|[\\s,)]))/', $attribute->value) - && 'svg' !== strtolower($candidate->tagName) - && null === SourceDom::ancestorElement($candidate, 'svg') ) { - return true; - } - } - } - if ( 'style' === strtolower($candidate->tagName) ) { - foreach ( $ids as $id ) { - if ( preg_match('/url\\(\\s*["\']?#' . preg_quote($id, '/') . '["\']?\\s*\\)/', $candidate->textContent ?? '') ) { - return true; - } - } - } - } - - return false; - } - /** * Whether an image's wrapping link is a lightbox trigger rather than a * destination: it has to point at an image resource and be marked as a From 6be9dd88efcd98f521b58c9dd07cd3736a28608f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 9 Nov 2026 12:48:54 -0500 Subject: [PATCH 2/4] refactor(php-transformer): move empty inline geometry lowering into Elements/EmptyInlineGeometryLowering Moves emptyInlineGeometryBlock and its six helpers out of HtmlCompilation verbatim. preservedSourceControlBlock reaches htmlPreservationBlock through the injected SourceBlockCreator. --- .../Elements/EmptyInlineGeometryLowering.php | 157 ++++++++++++++++++ .../src/HtmlToBlocks/HtmlCompilation.php | 129 +------------- 2 files changed, 160 insertions(+), 126 deletions(-) create mode 100644 php-transformer/src/HtmlToBlocks/Elements/EmptyInlineGeometryLowering.php diff --git a/php-transformer/src/HtmlToBlocks/Elements/EmptyInlineGeometryLowering.php b/php-transformer/src/HtmlToBlocks/Elements/EmptyInlineGeometryLowering.php new file mode 100644 index 000000000..8d130e008 --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Elements/EmptyInlineGeometryLowering.php @@ -0,0 +1,157 @@ + */ + private function preservedSourceControlBlock(DOMElement $element): array + { + return $this->authoredStateButtons->fromElement($element) ?? $this->blocks->htmlPreservationBlock($element); + } + + /** + * An empty inline tree whose source CSS still generates a line box or flex + * track is layout, not text and not a raw HTML island. core/group cannot + * keep the source phrasing tags, so the existing layout shell does. + * + * @param array> $fallbacks + * @return array|null + */ + public function emptyInlineGeometryBlock(DOMElement $element, array &$fallbacks): ?array + { + if ( $this->hasPaintedSourceText($element) || $this->emptyElementFootprint->subtreeHasPaintedPseudo($element, $this->session->sourceStyleResolutionState()->pseudoElementRules()) || $this->runtimeIslands->isRuntimeDomTarget($element) ) { + return null; + } + $inner = array(); + $layout = false; + foreach ( SourceDom::elementElementChildren($element) as $child ) { + if ( FormControlClassifier::isControlElement($child) ) { + $inner[] = $this->preservedSourceControlBlock($child); + continue; + } + if ( ! $this->isEmptyInlineGeometryNode($child) || $this->containsExcludedMedia($child) ) { + return null; + } + $layout = $layout || $this->emptyElementFootprint->declaresEmptyInlineLayoutBox($child); + $inner[] = $this->emptyInlineGeometryShell($child); + } + if ( ! $layout || array() === $inner ) { + return null; + } + + return $this->layoutShells->blockForElements(array( $element ), $inner, $element); + } + + private function emptyInlineGeometryShell(DOMElement $element): array + { + $wrappers = array( $element ); + $branch = $element; + while ( true ) { + $children = SourceDom::elementElementChildren($branch); + if ( 1 !== count($children) || ! $this->isEmptyInlineGeometryNode($children[0]) ) { + break; + } + $child = $children[0]; + $wrappers[] = $child; + $branch = $child; + if ( count(SourceDom::elementElementChildren($child)) > 1 ) { + break; + } + } + $inner = array(); + foreach ( SourceDom::elementElementChildren($branch) as $child ) { + if ( $child === (array() === $wrappers ? null : $wrappers[array_key_last($wrappers)]) || ! $this->isEmptyInlineGeometryNode($child) ) { + continue; + } + $inner[] = count(SourceDom::elementElementChildren($child)) > 1 + ? $this->emptyInlineGeometryShell($child) + : $this->layoutShells->blockForElements($this->emptyInlineWrapperChain($child), array(), $child); + } + + return $this->layoutShells->blockForElements($wrappers, $inner, $element); + } + + /** + * @return list + */ + private function emptyInlineWrapperChain(DOMElement $element): array + { + $chain = array( $element ); + $cursor = $element; + while ( true ) { + $children = SourceDom::elementElementChildren($cursor); + if ( 1 !== count($children) || ! $this->isEmptyInlineGeometryNode($children[0]) ) { + break; + } + $child = $children[0]; + if ( '' === trim(SourceDom::attr($child, 'class')) && '' === trim(SourceDom::attr($child, 'style')) && 0 === $child->childElementCount ) { + break; + } + $chain[] = $child; + $cursor = $child; + } + + return $chain; + } + + private function isEmptyInlineGeometryNode(DOMElement $element): bool + { + return $this->sourceElementClassifier->isInlineContentElement(strtolower($element->tagName)) + && ! $this->hasPaintedSourceText($element) + && ! $this->containsExcludedMedia($element) + && ! $this->runtimeIslands->isRuntimeDomTarget($element); + } + + private function hasPaintedSourceText(DOMElement $element): bool + { + if ( '' !== trim($element->textContent ?? '') ) { + return true; + } + foreach ( $element->getElementsByTagName('*') as $node ) { + if ( $node instanceof DOMElement && '' !== trim(SourceDom::attr($node, 'data-text')) ) { + return true; + } + } + + return false; + } + + private function containsExcludedMedia(DOMElement $element): bool + { + foreach ( array_merge(array( $element ), SourceDom::descendantElements($element)) as $node ) { + $tag = strtolower($node->tagName); + if ( in_array($tag, array( 'audio', 'canvas', 'iframe', 'img', 'object', 'picture', 'svg', 'video' ), true) ) { + return true; + } + } + + return false; + } +} diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index 91e3451e1..9b2fcd02a 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -315,6 +315,7 @@ final class HtmlCompilation extends SourceBlockCreator implements RichTextInline private readonly Elements\AnchorLowering $anchors; private readonly Elements\RenderNeutralElements $renderNeutral; private readonly Elements\InertElements $inertElements; + private readonly Elements\EmptyInlineGeometryLowering $emptyInlineGeometry; private readonly SourceProvenance $sourceProvenance; private readonly RuntimeResourceElementConverter $runtimeResourceConverter; @@ -607,6 +608,7 @@ function (DOMElement $element): array { ); $this->renderNeutral = new Elements\RenderNeutralElements($this->session, $this->styleResolver, $this->sourceElementClassifier, $this->sourceElementDescription, $this->runtimeIslands, $this->emptyElementFootprint); $this->inertElements = new Elements\InertElements($this->styleResolver, $this->sourceElementClassifier, $this->sourceVisibility, $this->runtimeIslands); + $this->emptyInlineGeometry = new Elements\EmptyInlineGeometryLowering($this, $this->session, $this->sourceElementClassifier, $this->emptyElementFootprint, $this->authoredStateButtons, $this->layoutShells, $this->runtimeIslands); $this->customImageHosts = new Elements\CustomImageHostLowering($this, $this->blockFactory, $this->styleResolver, $this->session, $this->sourceElementClassifier, $this->sourceElementDescription, $this->imageLowering, $this->layoutShells, $this->runtimeIslands, $this->authorLayoutOwnership); $this->anchors = new Elements\AnchorLowering($this, $this, $this->styleResolver, $this->session, $this->sourceElementClassifier, $this->sourceElementDescription, $this->imageLowering, $this->imageUrls, $this->layoutShells, $this->linkWrapperPropagator, $this->runtimeIslands, $this->customImageHosts); $this->layoutTables = new Elements\LayoutTableLowering( @@ -1336,7 +1338,7 @@ function (DOMElement $element, array &$fallbacks, bool $captureUnsupported): arr return $this->convertChildren($element, $fallbacks, $captureUnsupported); }, function (DOMElement $element, array &$fallbacks): ?array { - return $this->emptyInlineGeometryBlock($element, $fallbacks); + return $this->emptyInlineGeometry->emptyInlineGeometryBlock($element, $fallbacks); }, function (DOMElement $element, array &$fallbacks): ?array { $block = $this->recognizePatterns($element, $fallbacks, array( DetailsPattern::class )); @@ -6660,12 +6662,6 @@ private function isDeclaredRuntimeDomTarget(DOMElement $element): bool return false; } - /** @return array */ - private function preservedSourceControlBlock(DOMElement $element): array - { - return $this->authoredStateButtons->fromElement($element) ?? $this->htmlPreservationBlock($element); - } - /** A bounded phrasing tree with named binary SVG controls is layout plus * editable text, rather than one invalid RichText run. Keep source wrappers * with the existing layout shell and transparent text carriers. @@ -6728,125 +6724,6 @@ private function inlineCheckableControlBlock(DOMElement $element): ?array return $build($element); } - /** - * An empty inline tree whose source CSS still generates a line box or flex - * track is layout, not text and not a raw HTML island. core/group cannot - * keep the source phrasing tags, so the existing layout shell does. - * - * @param array> $fallbacks - * @return array|null - */ - private function emptyInlineGeometryBlock(DOMElement $element, array &$fallbacks): ?array - { - if ( $this->hasPaintedSourceText($element) || $this->emptyElementFootprint->subtreeHasPaintedPseudo($element, $this->session->sourceStyleResolutionState()->pseudoElementRules()) || $this->runtimeIslands->isRuntimeDomTarget($element) ) { - return null; - } - $inner = array(); - $layout = false; - foreach ( SourceDom::elementElementChildren($element) as $child ) { - if ( FormControlClassifier::isControlElement($child) ) { - $inner[] = $this->preservedSourceControlBlock($child); - continue; - } - if ( ! $this->isEmptyInlineGeometryNode($child) || $this->containsExcludedMedia($child) ) { - return null; - } - $layout = $layout || $this->emptyElementFootprint->declaresEmptyInlineLayoutBox($child); - $inner[] = $this->emptyInlineGeometryShell($child); - } - if ( ! $layout || array() === $inner ) { - return null; - } - - return $this->layoutShells->blockForElements(array( $element ), $inner, $element); - } - - private function emptyInlineGeometryShell(DOMElement $element): array - { - $wrappers = array( $element ); - $branch = $element; - while ( true ) { - $children = SourceDom::elementElementChildren($branch); - if ( 1 !== count($children) || ! $this->isEmptyInlineGeometryNode($children[0]) ) { - break; - } - $child = $children[0]; - $wrappers[] = $child; - $branch = $child; - if ( count(SourceDom::elementElementChildren($child)) > 1 ) { - break; - } - } - $inner = array(); - foreach ( SourceDom::elementElementChildren($branch) as $child ) { - if ( $child === (array() === $wrappers ? null : $wrappers[array_key_last($wrappers)]) || ! $this->isEmptyInlineGeometryNode($child) ) { - continue; - } - $inner[] = count(SourceDom::elementElementChildren($child)) > 1 - ? $this->emptyInlineGeometryShell($child) - : $this->layoutShells->blockForElements($this->emptyInlineWrapperChain($child), array(), $child); - } - - return $this->layoutShells->blockForElements($wrappers, $inner, $element); - } - - /** - * @return list - */ - private function emptyInlineWrapperChain(DOMElement $element): array - { - $chain = array( $element ); - $cursor = $element; - while ( true ) { - $children = SourceDom::elementElementChildren($cursor); - if ( 1 !== count($children) || ! $this->isEmptyInlineGeometryNode($children[0]) ) { - break; - } - $child = $children[0]; - if ( '' === trim($this->attr($child, 'class')) && '' === trim($this->attr($child, 'style')) && 0 === $child->childElementCount ) { - break; - } - $chain[] = $child; - $cursor = $child; - } - - return $chain; - } - - private function isEmptyInlineGeometryNode(DOMElement $element): bool - { - return $this->sourceElementClassifier->isInlineContentElement(strtolower($element->tagName)) - && ! $this->hasPaintedSourceText($element) - && ! $this->containsExcludedMedia($element) - && ! $this->runtimeIslands->isRuntimeDomTarget($element); - } - - private function hasPaintedSourceText(DOMElement $element): bool - { - if ( '' !== trim($element->textContent ?? '') ) { - return true; - } - foreach ( $element->getElementsByTagName('*') as $node ) { - if ( $node instanceof DOMElement && '' !== trim($this->attr($node, 'data-text')) ) { - return true; - } - } - - return false; - } - - private function containsExcludedMedia(DOMElement $element): bool - { - foreach ( array_merge(array( $element ), SourceDom::descendantElements($element)) as $node ) { - $tag = strtolower($node->tagName); - if ( in_array($tag, array( 'audio', 'canvas', 'iframe', 'img', 'object', 'picture', 'svg', 'video' ), true) ) { - return true; - } - } - - return false; - } - /** * Whether an image's wrapping link is a lightbox trigger rather than a * destination: it has to point at an image resource and be marked as a From 7c1351b882605c617372280334d277c9d508b03f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 9 Nov 2026 12:49:57 -0500 Subject: [PATCH 3/4] refactor(php-transformer): move custom wrapper convention recognition into Elements/CustomWrapperConventions Moves isWrapperConventionTag, authorDataAttributeSelectors, authorAttributeSelectorAddresses and their two helpers, with the two convention constants, out of HtmlCompilation verbatim. unwrapRenderNeutralCustomElements stays: it owns the retained-hook state. --- .../Elements/CustomWrapperConventions.php | 163 ++++++++++++++++++ .../src/HtmlToBlocks/HtmlCompilation.php | 148 +--------------- 2 files changed, 168 insertions(+), 143 deletions(-) create mode 100644 php-transformer/src/HtmlToBlocks/Elements/CustomWrapperConventions.php diff --git a/php-transformer/src/HtmlToBlocks/Elements/CustomWrapperConventions.php b/php-transformer/src/HtmlToBlocks/Elements/CustomWrapperConventions.php new file mode 100644 index 000000000..6db5d658b --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Elements/CustomWrapperConventions.php @@ -0,0 +1,163 @@ + + */ + public function authorDataAttributeSelectors(string $css): array + { + $selectors = array(); + foreach ( CssValueSplitter::ruleBlocks($css) as $rule ) { + if ( ! str_contains($rule['selector'], '[data-') ) { + continue; + } + if ( preg_match_all('/\[\s*data-[A-Za-z][A-Za-z0-9_-]*\s*(?:[*^$|~]?=\s*(?:"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|[^\]\s"\']+)\s*(?:[is]\s*)?)?\]/', $rule['selector'], $matches) ) { + foreach ( $matches[0] as $selector ) { + $selectors[$selector] = true; + } + } + } + + return $selectors; + } + + /** + * Whether an author selector keys on this instance through its own + * attributes. Such an instance is a selector hook, not a wrapping + * convention: removing it silently detaches every rule that needs it as + * a subject or ancestor. Equality compounds must match the instance's + * value; any other operator fails closed on the attribute name. + * + * @param array $selectors + */ + public function authorAttributeSelectorAddresses(DOMElement $element, array $selectors): bool + { + foreach ( array_keys($selectors) as $selector ) { + $parsed = \Automattic\BlocksEngine\PhpTransformer\Support\RuntimeSelectorVocabulary::parseAttributeSelector($selector); + if ( null !== $parsed ) { + if ( \Automattic\BlocksEngine\PhpTransformer\Support\RuntimeSelectorVocabulary::matchesElement($element, $selector, array()) ) { + return true; + } + continue; + } + if ( 1 === preg_match('/^\[\s*(data-[A-Za-z][A-Za-z0-9_-]*)/', $selector, $name) && $element->hasAttribute(strtolower($name[1])) ) { + return true; + } + } + + return false; + } + + /** + * Whether a custom element tag is used as a wrapping convention rather than + * as a component host. + * + * @param array $elements Every instance of the tag. + */ + public function isWrapperConventionTag(string $tag, array $elements, string $staticCss): bool + { + if ( self::WRAPPER_CONVENTION_MIN_INSTANCES > count($elements) ) { + return false; + } + + // A tag that carries presentation of its own is not render-neutral. + // `display:contents` is the exception that proves the rule: it states + // the element generates no box and renders as its children. + if ( ! $this->customElementCssIsRenderNeutral($tag, $staticCss) ) { + return false; + } + + $shapes = array(); + foreach ( $elements as $element ) { + $shapes[$this->wrappedContentShape($element)] = true; + if ( 1 < count($shapes) ) { + return true; + } + } + + return false; + } + + /** + * Whether every author rule addressing a custom element tag leaves it + * render-neutral, so replacing instances with their children preserves the + * authored presentation. + */ + private function customElementCssIsRenderNeutral(string $tag, string $css): bool + { + if ( '' === trim($css) ) { + return true; + } + + $pattern = '/(?styleResolver->cssDeclarations($rule['declarations']) as $property => $value ) { + if ( 'display' !== strtolower(trim($property)) || 'contents' !== strtolower(trim($value)) ) { + return false; + } + } + } + + return true; + } + + /** + * Shallow tag skeleton of the content a wrapper carries. Instances of a real + * component repeat this shape; instances of a wrapper do not. + */ + private function wrappedContentShape(DOMElement $element, int $depth = 0): string + { + $parts = array(); + foreach ( $element->childNodes as $child ) { + if ( ! $child instanceof DOMElement ) { + continue; + } + $tag = strtolower($child->tagName); + $parts[] = self::WRAPPER_CONTENT_SHAPE_DEPTH > $depth + ? $tag . '(' . $this->wrappedContentShape($child, $depth + 1) . ')' + : $tag; + } + + return implode(',', $parts); + } +} diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index 9b2fcd02a..a5f001e5f 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -209,16 +209,6 @@ final class HtmlCompilation extends SourceBlockCreator implements RichTextInline { private const GENERATED_COMPONENT_MIN_SOURCE_DEPTH = 14; - /** - * Instances a custom element tag must reach before recurring usage can be - * read as a wrapping convention rather than a component. - */ - private const WRAPPER_CONVENTION_MIN_INSTANCES = 3; - - /** - * Depth of the shallow tag skeleton compared across wrapper instances. - */ - private const WRAPPER_CONTENT_SHAPE_DEPTH = 2; use DomHelpersTrait; private readonly AuthorLayoutOwnership $authorLayoutOwnership; @@ -316,6 +306,7 @@ final class HtmlCompilation extends SourceBlockCreator implements RichTextInline private readonly Elements\RenderNeutralElements $renderNeutral; private readonly Elements\InertElements $inertElements; private readonly Elements\EmptyInlineGeometryLowering $emptyInlineGeometry; + private readonly Elements\CustomWrapperConventions $wrapperConventions; private readonly SourceProvenance $sourceProvenance; private readonly RuntimeResourceElementConverter $runtimeResourceConverter; @@ -609,6 +600,7 @@ function (DOMElement $element): array { $this->renderNeutral = new Elements\RenderNeutralElements($this->session, $this->styleResolver, $this->sourceElementClassifier, $this->sourceElementDescription, $this->runtimeIslands, $this->emptyElementFootprint); $this->inertElements = new Elements\InertElements($this->styleResolver, $this->sourceElementClassifier, $this->sourceVisibility, $this->runtimeIslands); $this->emptyInlineGeometry = new Elements\EmptyInlineGeometryLowering($this, $this->session, $this->sourceElementClassifier, $this->emptyElementFootprint, $this->authoredStateButtons, $this->layoutShells, $this->runtimeIslands); + $this->wrapperConventions = new Elements\CustomWrapperConventions($this->styleResolver); $this->customImageHosts = new Elements\CustomImageHostLowering($this, $this->blockFactory, $this->styleResolver, $this->session, $this->sourceElementClassifier, $this->sourceElementDescription, $this->imageLowering, $this->layoutShells, $this->runtimeIslands, $this->authorLayoutOwnership); $this->anchors = new Elements\AnchorLowering($this, $this, $this->styleResolver, $this->session, $this->sourceElementClassifier, $this->sourceElementDescription, $this->imageLowering, $this->imageUrls, $this->layoutShells, $this->linkWrapperPropagator, $this->runtimeIslands, $this->customImageHosts); $this->layoutTables = new Elements\LayoutTableLowering( @@ -2263,13 +2255,13 @@ private function unwrapRenderNeutralCustomElements(DOMElement $body, string $sta $attributeSelectors = null; foreach ( $instances as $tag => $elements ) { - if ( ! $this->isWrapperConventionTag($tag, $elements, $staticCss) ) { + if ( ! $this->wrapperConventions->isWrapperConventionTag($tag, $elements, $staticCss) ) { continue; } - $attributeSelectors ??= $this->authorDataAttributeSelectors($staticCss); + $attributeSelectors ??= $this->wrapperConventions->authorDataAttributeSelectors($staticCss); foreach ( $elements as $element ) { if ( $this->renderNeutral->isRenderNeutralWrapper($element, false) ) { - if ( $this->authorAttributeSelectorAddresses($element, $attributeSelectors) ) { + if ( $this->wrapperConventions->authorAttributeSelectorAddresses($element, $attributeSelectors) ) { $this->retainedCustomWrapperHooks[] = $element; } else { $this->richTextStylingHooks()->unwrapElement($element); @@ -2279,86 +2271,6 @@ private function unwrapRenderNeutralCustomElements(DOMElement $body, string $sta } } - /** - * Data-attribute compounds written in author selectors, e.g. the - * `[data-key="a"]` of `[data-key="a"] .photo`. - * - * @return array - */ - private function authorDataAttributeSelectors(string $css): array - { - $selectors = array(); - foreach ( CssValueSplitter::ruleBlocks($css) as $rule ) { - if ( ! str_contains($rule['selector'], '[data-') ) { - continue; - } - if ( preg_match_all('/\[\s*data-[A-Za-z][A-Za-z0-9_-]*\s*(?:[*^$|~]?=\s*(?:"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|[^\]\s"\']+)\s*(?:[is]\s*)?)?\]/', $rule['selector'], $matches) ) { - foreach ( $matches[0] as $selector ) { - $selectors[$selector] = true; - } - } - } - - return $selectors; - } - - /** - * Whether an author selector keys on this instance through its own - * attributes. Such an instance is a selector hook, not a wrapping - * convention: removing it silently detaches every rule that needs it as - * a subject or ancestor. Equality compounds must match the instance's - * value; any other operator fails closed on the attribute name. - * - * @param array $selectors - */ - private function authorAttributeSelectorAddresses(DOMElement $element, array $selectors): bool - { - foreach ( array_keys($selectors) as $selector ) { - $parsed = \Automattic\BlocksEngine\PhpTransformer\Support\RuntimeSelectorVocabulary::parseAttributeSelector($selector); - if ( null !== $parsed ) { - if ( \Automattic\BlocksEngine\PhpTransformer\Support\RuntimeSelectorVocabulary::matchesElement($element, $selector, array()) ) { - return true; - } - continue; - } - if ( 1 === preg_match('/^\[\s*(data-[A-Za-z][A-Za-z0-9_-]*)/', $selector, $name) && $element->hasAttribute(strtolower($name[1])) ) { - return true; - } - } - - return false; - } - - /** - * Whether a custom element tag is used as a wrapping convention rather than - * as a component host. - * - * @param array $elements Every instance of the tag. - */ - private function isWrapperConventionTag(string $tag, array $elements, string $staticCss): bool - { - if ( self::WRAPPER_CONVENTION_MIN_INSTANCES > count($elements) ) { - return false; - } - - // A tag that carries presentation of its own is not render-neutral. - // `display:contents` is the exception that proves the rule: it states - // the element generates no box and renders as its children. - if ( ! $this->customElementCssIsRenderNeutral($tag, $staticCss) ) { - return false; - } - - $shapes = array(); - foreach ( $elements as $element ) { - $shapes[$this->wrappedContentShape($element)] = true; - if ( 1 < count($shapes) ) { - return true; - } - } - - return false; - } - /** * Author CSS visible to the document: declared stylesheets plus the styles * the source embeds inline. @@ -2368,56 +2280,6 @@ private function authoredCssText(string $html, string $staticCss): string return $this->stylesheetAnalysisComposer->combinedAuthorStylesheet($html, $staticCss); } - /** - * Whether every author rule addressing a custom element tag leaves it - * render-neutral, so replacing instances with their children preserves the - * authored presentation. - */ - private function customElementCssIsRenderNeutral(string $tag, string $css): bool - { - if ( '' === trim($css) ) { - return true; - } - - $pattern = '/(?styleResolver->cssDeclarations($rule['declarations']) as $property => $value ) { - if ( 'display' !== strtolower(trim($property)) || 'contents' !== strtolower(trim($value)) ) { - return false; - } - } - } - - return true; - } - - /** - * Shallow tag skeleton of the content a wrapper carries. Instances of a real - * component repeat this shape; instances of a wrapper do not. - */ - private function wrappedContentShape(DOMElement $element, int $depth = 0): string - { - $parts = array(); - foreach ( $element->childNodes as $child ) { - if ( ! $child instanceof DOMElement ) { - continue; - } - $tag = strtolower($child->tagName); - $parts[] = self::WRAPPER_CONTENT_SHAPE_DEPTH > $depth - ? $tag . '(' . $this->wrappedContentShape($child, $depth + 1) . ')' - : $tag; - } - - return implode(',', $parts); - } - private function collectGeneratedComponentCandidates(DOMElement $element, int $depth = 0): void { if (self::GENERATED_COMPONENT_MIN_SOURCE_DEPTH <= $depth From eec706f2bbbaebeb5b1fb18f1d4da9f7ac0ae213 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 9 Nov 2026 13:49:20 -0500 Subject: [PATCH 4/4] refactor(php-transformer): move empty visual spacer lowering into Elements/EmptyVisualSpacerLowering Moves emptyVisualSpacerBlock, emptyVisualElementAttributes and hasEmptyGridTrackDescendant, with the two private flex-item class constants, out of HtmlCompilation verbatim. The public empty-group class names are read through HtmlTransformer, as the style projectors already do. --- .../Elements/EmptyVisualSpacerLowering.php | 149 ++++++++++++++++++ .../src/HtmlToBlocks/HtmlCompilation.php | 128 +-------------- 2 files changed, 155 insertions(+), 122 deletions(-) create mode 100644 php-transformer/src/HtmlToBlocks/Elements/EmptyVisualSpacerLowering.php diff --git a/php-transformer/src/HtmlToBlocks/Elements/EmptyVisualSpacerLowering.php b/php-transformer/src/HtmlToBlocks/Elements/EmptyVisualSpacerLowering.php new file mode 100644 index 000000000..58c6df0e1 --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Elements/EmptyVisualSpacerLowering.php @@ -0,0 +1,149 @@ + */ + private function emptyVisualElementAttributes(DOMElement $element): array + { + $attrs = $this->styleResolver->presentationAttributes($element); + $parent = $element->parentNode; + if ( ! $parent instanceof DOMElement ) { + return $attrs; + } + + $parentDeclarations = $this->styleResolver->structuralPresentationDeclarations($parent); + $parentDisplay = CssValueInspector::comparable((string) ($parentDeclarations['display'] ?? '')); + if ( ! in_array($parentDisplay, array( 'flex', 'inline-flex' ), true) ) { + return $attrs; + } + + // Resolve the element's full matched CSS, not only its inline style: + // a utility-class divider (e.g. Tailwind's `h-px w-24`) declares its + // geometry on a stylesheet rule that the narrower inline-only + // resolver used here previously never saw, so an authored box was + // misread as sizeless. + $declarations = $this->styleResolver->structuralPresentationDeclarations($element); + $position = strtolower(trim((string) ($declarations['position'] ?? 'static'))); + if ( in_array($position, array( 'absolute', 'fixed' ), true) ) { + return $attrs; + } + + // The zero-collapse compatibility box below only removes the flex + // main-axis footprint core would otherwise add to a childless flex + // item. Which CSS properties own that axis depends on the parent's + // flex-direction: a row container's main axis is width, a column + // container's is height. Checking width alone let an authored + // height survive the escape hatch but still be crushed by the + // row-shaped compatibility box below. + $isColumnAxis = in_array(strtolower(trim((string) ($parentDeclarations['flex-direction'] ?? 'row'))), array( 'column', 'column-reverse' ), true); + $mainAxisSizeProperties = $isColumnAxis + ? array( 'height', 'min-height', 'max-height', 'flex', 'flex-basis' ) + : array( 'width', 'min-width', 'max-width', 'flex', 'flex-basis' ); + foreach ( $mainAxisSizeProperties as $property ) { + if ( isset($declarations[$property]) && '' !== trim($declarations[$property]) && 'auto' !== strtolower(trim($declarations[$property])) ) { + return $attrs; + } + } + foreach ( $this->session->sourceStyleResolutionState()->pseudoElementRules() as $rule ) { + if ( $this->styleResolver->matchesCssSelector($element, $rule['selector']) && array_intersect_key($rule['declarations'], array_flip(array( 'content', 'display', 'width', 'min-width' ))) ) { + return $attrs; + } + } + + $attrs['className'] = trim((string) ($attrs['className'] ?? '') . ' ' . ( $isColumnAxis ? self::EMPTY_FLEX_ITEM_COLUMN_CLASS : self::EMPTY_FLEX_ITEM_CLASS )); + if ( $this->runtimeIslands->isRuntimeDomTarget($element) ) { + $attrs['className'] = trim($attrs['className'] . ' ' . HtmlTransformer::EMPTY_RUNTIME_TARGET_CLASS); + $this->session->runtimeBehaviorState()->markEmptyRuntimeTargetGenerated(); + } + return $attrs; + } + + /** @return array */ + public function emptyVisualSpacerBlock(DOMElement $element): array + { + $attrs = $this->emptyVisualElementAttributes($element); + if ( ! $this->emptyElementFootprint->isEmptyVisualInlineCandidate($element) ) { + $attrs['className'] = SourceDom::mergeClassNames((string) ($attrs['className'] ?? ''), HtmlTransformer::EMPTY_VISUAL_GROUP_CLASS); + $this->session->runtimeBehaviorState()->markEmptyVisualGroupGenerated(); + // Carry complex paint, but let native background support own a + // CSS block box's solid colour so editor recolouring can win. + $attrs['className'] = SourceDom::mergeClassNames( + (string) $attrs['className'], + $this->styleResolver->emptyElementBackgroundCarrierClassName($element, $this->emptyElementFootprint->hasEmptyVisualBlockDisplay($element) && isset($attrs['style']['color']['background'])) + ); + // Empty, nested layout wrappers can still own measurable grid or + // flex tracks (fluid-engine sections commonly put those tracks on + // a child below a data-bearing presentation wrapper). Lowering the + // outer visual carrier as an empty group used to discard that + // child and its selector identity, collapsing its authored tracks. + $innerBlocks = array(); + if ( 0 < SourceDom::childElementCount($element) + && '' === SourceDom::renderedTextContent($element) + && $this->hasEmptyGridTrackDescendant($element) + ) { + $ignoredFallbacks = array(); + $innerBlocks = $this->tree->children($element, $ignoredFallbacks, true); + } + $block = $this->blocks->createBlock('core/group', $attrs, $innerBlocks, $element); + $block['_editability_visual_owned'] = true; + return $block; + } + + $declarations = $this->styleResolver->structuralPresentationDeclarations($element); + $attrs['height'] = $this->styleResolver->resolveCssVariablesInValue($declarations['height']); + $attrs['width'] = $this->styleResolver->resolveCssVariablesInValue($declarations['width']); + + return $this->blocks->createBlock('core/spacer', $attrs, array(), $element); + } + + private function hasEmptyGridTrackDescendant(DOMElement $element): bool + { + foreach ( $element->getElementsByTagName('*') as $descendant ) { + if ( $descendant instanceof DOMElement + && '' === SourceDom::renderedTextContent($descendant) + && $this->emptyElementFootprint->hasAuthoredGridTracks($descendant) + ) { + return true; + } + } + return false; + } +} diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index a5f001e5f..f8e7a3042 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -307,6 +307,7 @@ final class HtmlCompilation extends SourceBlockCreator implements RichTextInline private readonly Elements\InertElements $inertElements; private readonly Elements\EmptyInlineGeometryLowering $emptyInlineGeometry; private readonly Elements\CustomWrapperConventions $wrapperConventions; + private readonly Elements\EmptyVisualSpacerLowering $emptyVisualSpacers; private readonly SourceProvenance $sourceProvenance; private readonly RuntimeResourceElementConverter $runtimeResourceConverter; @@ -423,17 +424,6 @@ final class HtmlCompilation extends SourceBlockCreator implements RichTextInline private const INLINE_LAYOUT_CARRIER_CLASS = AuthorStylesheetProjector::INLINE_LAYOUT_CARRIER_CLASS; - private const EMPTY_FLEX_ITEM_CLASS = 'blocks-engine-empty-flex-item'; - - /** - * Marks a childless flex item inside a column-direction (or - * column-reverse) container. The main axis there is height, not width, - * so the compatibility box this class carries zeroes the opposite - * properties from {@see EMPTY_FLEX_ITEM_CLASS}'s row-axis assumption. - */ - private const EMPTY_FLEX_ITEM_COLUMN_CLASS = 'blocks-engine-empty-flex-column-item'; - - public const EMPTY_VISUAL_GROUP_CLASS = 'blocks-engine-empty-visual-group'; /** * Marks a core/paragraph lowered from a margin-styled text-only div. The @@ -601,6 +591,7 @@ function (DOMElement $element): array { $this->inertElements = new Elements\InertElements($this->styleResolver, $this->sourceElementClassifier, $this->sourceVisibility, $this->runtimeIslands); $this->emptyInlineGeometry = new Elements\EmptyInlineGeometryLowering($this, $this->session, $this->sourceElementClassifier, $this->emptyElementFootprint, $this->authoredStateButtons, $this->layoutShells, $this->runtimeIslands); $this->wrapperConventions = new Elements\CustomWrapperConventions($this->styleResolver); + $this->emptyVisualSpacers = new Elements\EmptyVisualSpacerLowering($this, $this, $this->session, $this->styleResolver, $this->emptyElementFootprint, $this->runtimeIslands); $this->customImageHosts = new Elements\CustomImageHostLowering($this, $this->blockFactory, $this->styleResolver, $this->session, $this->sourceElementClassifier, $this->sourceElementDescription, $this->imageLowering, $this->layoutShells, $this->runtimeIslands, $this->authorLayoutOwnership); $this->anchors = new Elements\AnchorLowering($this, $this, $this->styleResolver, $this->session, $this->sourceElementClassifier, $this->sourceElementDescription, $this->imageLowering, $this->imageUrls, $this->layoutShells, $this->linkWrapperPropagator, $this->runtimeIslands, $this->customImageHosts); $this->layoutTables = new Elements\LayoutTableLowering( @@ -680,7 +671,7 @@ function (DOMElement $element): bool { fn (DOMElement $element, string $tagName): ?DOMElement => SourceDom::ancestorElement($element, $tagName), fn (DOMElement $element): bool => $this->listStructure->isStructuralListItem($element), fn (DOMElement $element): bool => $this->shouldPreserveEmptyVisualElement($element), - fn (DOMElement $element): array => $this->emptyVisualSpacerBlock($element), + fn (DOMElement $element): array => $this->emptyVisualSpacers->emptyVisualSpacerBlock($element), function (DOMElement $element): void { $this->rememberNativeDisclosure($element); }, fn (DOMElement $element): bool => $this->runtimeIslands->retainsRuntimeTextLeafInRichText($element) ), $this->styleResolver, $this->runtime, $this->sourceBlockAttributeProjector); @@ -1053,7 +1044,7 @@ function (DOMElement $element, array &$fallbacks, bool $captureUnsupported): arr backgroundImageBlock: fn (DOMElement $element): ?array => $this->imageLowering->backgroundImageBlock($element), coalescedSingleGroupWrapper: fn (DOMElement $element, array $child): ?array => $this->coalescedSingleGroupWrapper($element, $child), shouldPreserveWrapper: fn (DOMElement $element, ?string $asTag = null): bool => $this->shouldPreserveWrapper($element, $asTag), - emptyVisualSpacerBlock: fn (DOMElement $element): array => $this->emptyVisualSpacerBlock($element) + emptyVisualSpacerBlock: fn (DOMElement $element): array => $this->emptyVisualSpacers->emptyVisualSpacerBlock($element) )); $this->mediaDispatchConverter = new MediaDispatchElementConverter(new MediaDispatchElementContext( function (DOMElement $element, array &$fallbacks): ?array { @@ -3337,7 +3328,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca // unsupported-element fallback. if ( SourceDom::isEmptyNonNavigatingAnchor($element) ) { return $this->shouldPreserveEmptyVisualElement($element) - ? $this->emptyVisualSpacerBlock($element) + ? $this->emptyVisualSpacers->emptyVisualSpacerBlock($element) : null; } @@ -4775,113 +4766,6 @@ private function isExplicitlyDisplayNoneEmptyElement(DOMElement $element): bool && array() === SourceDom::safeDataAttributes($element); } - /** @return array */ - private function emptyVisualElementAttributes(DOMElement $element): array - { - $attrs = $this->styleResolver->presentationAttributes($element); - $parent = $element->parentNode; - if ( ! $parent instanceof DOMElement ) { - return $attrs; - } - - $parentDeclarations = $this->styleResolver->structuralPresentationDeclarations($parent); - $parentDisplay = CssValueInspector::comparable((string) ($parentDeclarations['display'] ?? '')); - if ( ! in_array($parentDisplay, array( 'flex', 'inline-flex' ), true) ) { - return $attrs; - } - - // Resolve the element's full matched CSS, not only its inline style: - // a utility-class divider (e.g. Tailwind's `h-px w-24`) declares its - // geometry on a stylesheet rule that the narrower inline-only - // resolver used here previously never saw, so an authored box was - // misread as sizeless. - $declarations = $this->styleResolver->structuralPresentationDeclarations($element); - $position = strtolower(trim((string) ($declarations['position'] ?? 'static'))); - if ( in_array($position, array( 'absolute', 'fixed' ), true) ) { - return $attrs; - } - - // The zero-collapse compatibility box below only removes the flex - // main-axis footprint core would otherwise add to a childless flex - // item. Which CSS properties own that axis depends on the parent's - // flex-direction: a row container's main axis is width, a column - // container's is height. Checking width alone let an authored - // height survive the escape hatch but still be crushed by the - // row-shaped compatibility box below. - $isColumnAxis = in_array(strtolower(trim((string) ($parentDeclarations['flex-direction'] ?? 'row'))), array( 'column', 'column-reverse' ), true); - $mainAxisSizeProperties = $isColumnAxis - ? array( 'height', 'min-height', 'max-height', 'flex', 'flex-basis' ) - : array( 'width', 'min-width', 'max-width', 'flex', 'flex-basis' ); - foreach ( $mainAxisSizeProperties as $property ) { - if ( isset($declarations[$property]) && '' !== trim($declarations[$property]) && 'auto' !== strtolower(trim($declarations[$property])) ) { - return $attrs; - } - } - foreach ( $this->session->sourceStyleResolutionState()->pseudoElementRules() as $rule ) { - if ( $this->styleResolver->matchesCssSelector($element, $rule['selector']) && array_intersect_key($rule['declarations'], array_flip(array( 'content', 'display', 'width', 'min-width' ))) ) { - return $attrs; - } - } - - $attrs['className'] = trim((string) ($attrs['className'] ?? '') . ' ' . ( $isColumnAxis ? self::EMPTY_FLEX_ITEM_COLUMN_CLASS : self::EMPTY_FLEX_ITEM_CLASS )); - if ( $this->runtimeIslands->isRuntimeDomTarget($element) ) { - $attrs['className'] = trim($attrs['className'] . ' ' . self::EMPTY_RUNTIME_TARGET_CLASS); - $this->session->runtimeBehaviorState()->markEmptyRuntimeTargetGenerated(); - } - return $attrs; - } - - /** @return array */ - private function emptyVisualSpacerBlock(DOMElement $element): array - { - $attrs = $this->emptyVisualElementAttributes($element); - if ( ! $this->emptyElementFootprint->isEmptyVisualInlineCandidate($element) ) { - $attrs['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), self::EMPTY_VISUAL_GROUP_CLASS); - $this->session->runtimeBehaviorState()->markEmptyVisualGroupGenerated(); - // Carry complex paint, but let native background support own a - // CSS block box's solid colour so editor recolouring can win. - $attrs['className'] = $this->mergeClassNames( - (string) $attrs['className'], - $this->styleResolver->emptyElementBackgroundCarrierClassName($element, $this->emptyElementFootprint->hasEmptyVisualBlockDisplay($element) && isset($attrs['style']['color']['background'])) - ); - // Empty, nested layout wrappers can still own measurable grid or - // flex tracks (fluid-engine sections commonly put those tracks on - // a child below a data-bearing presentation wrapper). Lowering the - // outer visual carrier as an empty group used to discard that - // child and its selector identity, collapsing its authored tracks. - $innerBlocks = array(); - if ( 0 < $this->childElementCount($element) - && '' === SourceDom::renderedTextContent($element) - && $this->hasEmptyGridTrackDescendant($element) - ) { - $ignoredFallbacks = array(); - $innerBlocks = $this->convertChildren($element, $ignoredFallbacks, true); - } - $block = $this->createBlock('core/group', $attrs, $innerBlocks, $element); - $block['_editability_visual_owned'] = true; - return $block; - } - - $declarations = $this->styleResolver->structuralPresentationDeclarations($element); - $attrs['height'] = $this->styleResolver->resolveCssVariablesInValue($declarations['height']); - $attrs['width'] = $this->styleResolver->resolveCssVariablesInValue($declarations['width']); - - return $this->createBlock('core/spacer', $attrs, array(), $element); - } - - private function hasEmptyGridTrackDescendant(DOMElement $element): bool - { - foreach ( $element->getElementsByTagName('*') as $descendant ) { - if ( $descendant instanceof DOMElement - && '' === SourceDom::renderedTextContent($descendant) - && $this->emptyElementFootprint->hasAuthoredGridTracks($descendant) - ) { - return true; - } - } - return false; - } - /** * An empty href-less named `` is a fragment target. Preserve its * identifier on a native block HTML anchor rather than recording it as @@ -4905,7 +4789,7 @@ private function emptyNamedFragmentTargetBlock(DOMElement $element): ?array $element->setAttribute('id', $id); } - return $this->emptyVisualSpacerBlock($element); + return $this->emptyVisualSpacers->emptyVisualSpacerBlock($element); } private function hasEmptyVisualInlineChild(DOMElement $element): bool