Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions php-transformer/src/HtmlToBlocks/HtmlCompilation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, mixed>|null
*/
Expand Down
13 changes: 13 additions & 0 deletions php-transformer/tests/unit/inert-capture-scaffolding.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('<main><capture-shell><p aria-live="assertive" id="captured-live-region" role="alert" style="border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px;word-wrap:normal"></p></capture-shell><next-route-announcer><p aria-live="assertive" id="__next-route-announcer__" role="alert" style="border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px;word-wrap:normal"></p></next-route-announcer><h1>Visible heading</h1></main>');
$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('<main><capture-shell><p aria-live="assertive" role="alert" style="clip:rect(0 0 0 0);height:1px;overflow:hidden;position:absolute;width:1px">Page changed</p></capture-shell></main>');
$assert(str_contains((string) ($liveRegionWithContent['serialized_blocks'] ?? ''), 'Page changed'), 'live regions with captured content remain editable');

$namedWrapper = $transform('<main><capture-shell aria-label="Notifications"><p aria-live="polite" role="status" style="clip-path:inset(50%);height:1px;overflow:hidden;position:absolute;width:1px"></p></capture-shell></main>');
$assert('capture-shell' === ($namedWrapper['fallbacks'][0]['tag'] ?? ''), 'semantically named custom wrappers remain explicit fallbacks');

$customElement = $transform('<main><custom-widget aria-label="Custom control"></custom-widget></main>');
$assert('custom-widget' === ($customElement['fallbacks'][0]['tag'] ?? ''), 'unrelated custom elements remain explicit fallbacks');

$unreferencedStore = $transform('<main><svg data-dom-store style="display:none"><defs><symbol id="unused"><path d="M0 0h1v1z"/></symbol></defs></svg><p>Visible copy</p></main>');
$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');
Expand Down