From c2818b0c9078fa3cf51dc344685bceaef05f140e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 3 Sep 2026 12:22:45 -0400 Subject: [PATCH 1/2] Ignore inert route announcer scaffolding --- .../src/HtmlToBlocks/HtmlCompilation.php | 49 +++++++++++++++++++ .../tests/unit/inert-capture-scaffolding.php | 10 ++++ 2 files changed, 59 insertions(+) diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index b7a537f50..c227c9abd 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; } + // Client-side routers add an empty, visually clipped live region to + // announce navigation. It has no editable static content to retain. + if ( $this->isInertRouteAnnouncerScaffolding($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,49 @@ private function isInertRuntimeMediaPlaceholder(DOMElement $element): bool return true; } + private function isInertRouteAnnouncerScaffolding(DOMElement $element): bool + { + if ( ! str_ends_with(strtolower($element->tagName), '-route-announcer') + || '' !== trim($element->textContent ?? '') + || ! $this->isSafeTransparentCustomElement($element) + || array() !== $this->safeDataAttributes($element) ) { + 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; + } + + 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 03a91421a..6a27b8bcf 100644 --- a/php-transformer/tests/unit/inert-capture-scaffolding.php +++ b/php-transformer/tests/unit/inert-capture-scaffolding.php @@ -20,6 +20,16 @@ $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'); +$routeAnnouncers = $transform('

Visible heading

'); +$assert(array() === ($routeAnnouncers['fallbacks'] ?? array()), 'empty visually clipped route announcers emit no fallback'); +$assert(str_contains((string) ($routeAnnouncers['serialized_blocks'] ?? ''), 'Visible heading') && ! str_contains((string) ($routeAnnouncers['serialized_blocks'] ?? ''), 'next-route-announcer'), 'route announcers emit no editable block'); + +$announcerWithContent = $transform('

Page changed

'); +$assert(str_contains((string) ($announcerWithContent['serialized_blocks'] ?? ''), 'Page changed'), 'route announcers with captured content remain editable'); + +$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'); From 19cad3572d4765040e64dd64f3924168b3b8192b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 3 Sep 2026 14:42:39 -0400 Subject: [PATCH 2/2] refactor(php-transformer): classify inert live-region scaffolding --- .../src/HtmlToBlocks/HtmlCompilation.php | 19 +++++++++++++------ .../tests/unit/inert-capture-scaffolding.php | 13 ++++++++----- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index c227c9abd..4e7d6e14c 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -2954,9 +2954,9 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca return null; } - // Client-side routers add an empty, visually clipped live region to - // announce navigation. It has no editable static content to retain. - if ( $this->isInertRouteAnnouncerScaffolding($element) ) { + // Empty, visually clipped live regions are runtime accessibility + // scaffolding with no editable static content to retain. + if ( $this->isInertLiveRegionScaffolding($element) ) { return null; } @@ -3447,12 +3447,12 @@ private function isInertRuntimeMediaPlaceholder(DOMElement $element): bool return true; } - private function isInertRouteAnnouncerScaffolding(DOMElement $element): bool + private function isInertLiveRegionScaffolding(DOMElement $element): bool { - if ( ! str_ends_with(strtolower($element->tagName), '-route-announcer') + if ( ! str_contains(strtolower($element->tagName), '-') || '' !== trim($element->textContent ?? '') || ! $this->isSafeTransparentCustomElement($element) - || array() !== $this->safeDataAttributes($element) ) { + || 0 !== $element->attributes->length ) { return false; } @@ -3467,6 +3467,13 @@ private function isInertRouteAnnouncerScaffolding(DOMElement $element): bool 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; } diff --git a/php-transformer/tests/unit/inert-capture-scaffolding.php b/php-transformer/tests/unit/inert-capture-scaffolding.php index 6a27b8bcf..024cd2f80 100644 --- a/php-transformer/tests/unit/inert-capture-scaffolding.php +++ b/php-transformer/tests/unit/inert-capture-scaffolding.php @@ -20,12 +20,15 @@ $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'); -$routeAnnouncers = $transform('

Visible heading

'); -$assert(array() === ($routeAnnouncers['fallbacks'] ?? array()), 'empty visually clipped route announcers emit no fallback'); -$assert(str_contains((string) ($routeAnnouncers['serialized_blocks'] ?? ''), 'Visible heading') && ! str_contains((string) ($routeAnnouncers['serialized_blocks'] ?? ''), 'next-route-announcer'), 'route announcers emit no editable 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'); -$announcerWithContent = $transform('

Page changed

'); -$assert(str_contains((string) ($announcerWithContent['serialized_blocks'] ?? ''), 'Page changed'), 'route announcers with captured content remain editable'); +$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');