diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index b7a537f5..4e7d6e14 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -2954,6 +2954,12 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca return null; } + // Empty, visually clipped live regions are runtime accessibility + // scaffolding with no editable static content to retain. + if ( $this->isInertLiveRegionScaffolding($element) ) { + return null; + } + // A direct phrasing child participates in its parent's flex or grid // layout. Preserve that source element as the editable leaf rather // than introducing a paragraph wrapper with core paragraph margins. @@ -3441,6 +3447,56 @@ private function isInertRuntimeMediaPlaceholder(DOMElement $element): bool return true; } + private function isInertLiveRegionScaffolding(DOMElement $element): bool + { + if ( ! str_contains(strtolower($element->tagName), '-') + || '' !== trim($element->textContent ?? '') + || ! $this->isSafeTransparentCustomElement($element) + || 0 !== $element->attributes->length ) { + return false; + } + + $liveRegion = $this->soleElementChild($element); + if ( ! $liveRegion instanceof DOMElement + || 0 !== $this->childElementCount($liveRegion) + || ! in_array(strtolower($liveRegion->tagName), array( 'div', 'p', 'span' ), true) + || ! in_array(strtolower(trim($this->attr($liveRegion, 'role'))), array( 'alert', 'log', 'status' ), true) + || ! in_array(strtolower(trim($this->attr($liveRegion, 'aria-live'))), array( 'assertive', 'polite' ), true) + || ! $this->isVisuallyClippedLiveRegion($liveRegion) + || array() !== $this->safeDataAttributes($liveRegion) ) { + return false; + } + + $allowedAttributes = array( 'aria-atomic', 'aria-live', 'class', 'id', 'role', 'style' ); + foreach ( $liveRegion->attributes as $attribute ) { + if ( ! in_array(strtolower($attribute->name), $allowedAttributes, true) ) { + return false; + } + } + + return true; + } + + private function isVisuallyClippedLiveRegion(DOMElement $element): bool + { + $declarations = $this->styleResolver->structuralPresentationDeclarations($element); + $width = trim((string) ($declarations['width'] ?? '')); + $height = trim((string) ($declarations['height'] ?? '')); + $clip = strtolower(trim((string) ($declarations['clip'] ?? ''))); + $clipPath = strtolower(trim((string) ($declarations['clip-path'] ?? ''))); + + return 'absolute' === strtolower(trim((string) ($declarations['position'] ?? ''))) + && 'hidden' === strtolower(trim((string) ($declarations['overflow'] ?? ''))) + && $this->isAtMostOnePixelLength($width) + && $this->isAtMostOnePixelLength($height) + && (str_starts_with($clip, 'rect(') || str_starts_with($clipPath, 'inset(')); + } + + private function isAtMostOnePixelLength(string $value): bool + { + return 1 === preg_match('/^(?:0|1)px$/i', $value); + } + /** * @return array|null */ diff --git a/php-transformer/tests/unit/inert-capture-scaffolding.php b/php-transformer/tests/unit/inert-capture-scaffolding.php index 03a91421..024cd2f8 100644 --- a/php-transformer/tests/unit/inert-capture-scaffolding.php +++ b/php-transformer/tests/unit/inert-capture-scaffolding.php @@ -20,6 +20,19 @@ $assert(array() === ($inertIframe['source_reports']['runtime_islands'] ?? array()), 'hidden sourceless iframe emits no runtime island'); $assert(str_contains((string) ($inertIframe['serialized_blocks'] ?? ''), 'Visible copy') && ! str_contains((string) ($inertIframe['serialized_blocks'] ?? ''), 'archetype'), 'hidden sourceless iframe emits no block'); +$inertLiveRegions = $transform('

Visible heading

'); +$assert(array() === ($inertLiveRegions['fallbacks'] ?? array()), 'empty visually clipped custom-element live regions emit no fallback regardless of wrapper name'); +$assert(str_contains((string) ($inertLiveRegions['serialized_blocks'] ?? ''), 'Visible heading') && ! str_contains((string) ($inertLiveRegions['serialized_blocks'] ?? ''), 'capture-shell') && ! str_contains((string) ($inertLiveRegions['serialized_blocks'] ?? ''), 'next-route-announcer'), 'inert live-region scaffolding emits no editable block'); + +$liveRegionWithContent = $transform('

Page changed

'); +$assert(str_contains((string) ($liveRegionWithContent['serialized_blocks'] ?? ''), 'Page changed'), 'live regions with captured content remain editable'); + +$namedWrapper = $transform('

'); +$assert('capture-shell' === ($namedWrapper['fallbacks'][0]['tag'] ?? ''), 'semantically named custom wrappers remain explicit fallbacks'); + +$customElement = $transform('
'); +$assert('custom-widget' === ($customElement['fallbacks'][0]['tag'] ?? ''), 'unrelated custom elements remain explicit fallbacks'); + $unreferencedStore = $transform('

Visible copy

'); $assert(array() === ($unreferencedStore['fallbacks'] ?? array()), 'hidden unreferenced SVG store emits no fallback'); $assert(! str_contains((string) ($unreferencedStore['serialized_blocks'] ?? ''), 'unused'), 'hidden unreferenced SVG store emits no raw HTML');