From 419adb95bb7848e6bd24d989618cb1a0dbfc7dd0 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 9 Nov 2026 12:47:29 -0500 Subject: [PATCH 1/3] 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/3] 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/3] 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