Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/php-transformer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ jobs:
run: |
php tests/unit/capture-runtime-ancestor-parity.php
PLAYWRIGHT_MODULE="$PWD/tools/visual-parity/node_modules/playwright/index.mjs" node tests/unit/capture-runtime-ancestor-parity-browser.mjs
php tests/unit/capture-runtime-attribute-parity.php
PLAYWRIGHT_MODULE="$PWD/tools/visual-parity/node_modules/playwright/index.mjs" node tests/unit/capture-runtime-attribute-parity-browser.mjs

- name: Verify native button default background neutralization
working-directory: php-transformer
Expand Down
2 changes: 2 additions & 0 deletions php-transformer/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@
"php tests/unit/captured-dialog-dropdown-panel.php",
"php tests/unit/scoped-mixed-dropdown.php",
"php tests/unit/capture-runtime-ancestor-parity.php",
"php tests/unit/capture-runtime-attribute-parity.php",
"php tests/unit/captured-dialog-text-trigger.php",
"php tests/unit/captured-selectable-set-projector.php",
"php tests/unit/captured-collection-filter.php",
"php tests/unit/captured-choice-group-projector.php",
Expand Down
87 changes: 59 additions & 28 deletions php-transformer/docs/contracts/capture-evidence.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,39 +184,69 @@ required (below).

What the native replacement reproduces:

- **Disclosure interpreter.** Opening and closing a dialog, and the ancestor
bindings in `data-blocks-engine-capture-dialog-ancestor-state`. A `class`
binding is replayed as tokens: tokens of the other state that the target
state lacks are removed, the target state's tokens are added, and every
other class on the ancestor is kept. The native dialog finds the ancestor by its tag and closed tokens.
`tests/unit/capture-runtime-ancestor-parity-browser.mjs` compares the two in
Chromium.
- **Disclosure interpreter.** Opening and closing a dialog (including Escape
and the trigger's `aria-expanded`), and the ancestor bindings in
`data-blocks-engine-capture-dialog-ancestor-state`. A binding row may change
`class`, `style`, `hidden`, `aria-*` and `data-*` attributes: the set the
capture records and the interpreter replays. A `class` binding is replayed
as tokens: tokens of the other state that the target state lacks are
removed, the target state's tokens are added, and every other class on the
ancestor is kept; an emptied `class` whose recorded value is null is
removed. `hidden`, `aria-*` and `data-*` are set to the recorded value, or
removed when it is null, on open, on close and, as the interpreter does on
load, to their closed values when the page starts. The native dialog finds
the ancestor by its tag and closed class tokens, preferring the one whose
other recorded attributes hold their closed values.
`tests/unit/capture-runtime-ancestor-parity-browser.mjs` (class rows) and
`tests/unit/capture-runtime-attribute-parity-browser.mjs` (attribute-only
rows) compare the two in Chromium.

The replayable attributes are the shared allowlist
`CapturedDialogBlockGenerator::ANCESTOR_ATTRIBUTE_PATTERN`, applied by the
projector, the block converter and the view script alike. It never admits an
event handler (`on*`) or a URL-bearing attribute (`href`, `src`, ...): the
capture does not record them and the interpreter never replays them. It also
excludes Blocks Engine's own `data-blocks-engine-*` namespace, which holds
the native dialog's state and capture evidence rather than source state. A
row naming any other attribute is dropped.

A projected dialog stays a native dialog block even when its trigger has a
text label and its panel has the shape of a source aria disclosure.
`core/details` cannot close on Escape, keep `aria-expanded` or replay
ancestor bindings, so it is chosen only for disclosures that no capture
runtime drives, where it loses nothing.
- **Gallery helper.** Frame galleries become native carousels. A src-swap
gallery (`data-blocks-engine-capture-gallery-sequence` with
`data-blocks-engine-capture-gallery-slot` images) becomes a native src-swap
carousel. It starts at index 0, wraps in both directions, sets each slot to
the sequence image at the current index plus the slot's offset, and removes
the slot's `srcset`.

**How Blocks Engine verifies them.** For each page with captured dialog
scopes, `CapturedDialogProjector` scans every `<script>`:

1. If the script carries the disclosure marker, it computes
`sha256(trim(textContent))`, where `trim` strips spaces, tabs, CR, LF, NUL
and vertical tab at both ends. The page can retire the interpreter only if
that digest equals the published disclosure digest.
2. If the script carries the gallery marker, the page keeps that eligibility
only if the digest equals the published gallery digest.
3. If the page has any other executable script (classic or module), apart from
the native-control helper (`data-blocks-engine-capture-native-control-runtime`),
the interpreter is not retired.

With a matching interpreter and every trigger/panel binding proven local and
bounded, the scoped disclosures become native dialog blocks and the
interpreter is retired with a recorded replacement. Any mismatch leaves the
capture's script and markup in place. That page then keeps working through
the capture's own runtime and loses no content; it just gets no native
replacement.
**How Blocks Engine verifies them.** A script is a recognized runtime when
it carries a marker attribute and `sha256(trim(textContent))` equals the
published digest for that marker, where `trim` strips spaces, tabs, CR, LF,
NUL and vertical tab at both ends
(`CapturedDialogProjector::isRecognizedRuntimeScript()`). Every retirement
path uses that check: native dialogs projected from captured interactions,
wired dropdowns in a document scope, navigation submenus (with or without an
interaction report) and native galleries.

1. A page that carries a marked script whose body is not recognized keeps
that runtime and the wiring it drives: Blocks Engine projects no native
dialog, gallery or submenu replacement on it and retires nothing.
2. Otherwise only recognized scripts are retired, each with a recorded
replacement proof (`native_runtime_replacements`, carrying the body
digest).
3. Wired dropdowns in a declared document scope additionally require the
recognized interpreter itself, and no other executable script (classic or
module) apart from the native-control helper
(`data-blocks-engine-capture-native-control-runtime`).

With a recognized interpreter and every trigger/panel binding proven local and
bounded, the disclosures become native dialog blocks and the interpreter is
retired with a recorded replacement. Any mismatch leaves the capture's script
and markup in place. That page then keeps working through the capture's own
runtime and loses no content; it just gets no native replacement.

To check a body, run `php -r 'echo hash("sha256", trim(file_get_contents($argv[1])));' <file>`.
`tests/contract/capture-evidence-vocabulary.php` holds the published
Expand Down Expand Up @@ -245,5 +275,6 @@ call the test suite made with the previous producer-specific names (a corpus
harvested from the last commit before the move), renamed by the published
rule, and requires byte-identical compile results, WordPress site plans,
projected files and exceptions from the pre-move source with the same rule
applied. See `tools/capture-evidence-equivalence/README.md` for how the
corpus and expectations were produced and how to regenerate them.
applied, except for the deliberate fidelity changes listed in
`tools/capture-evidence-equivalence/README.md`. That file also describes how
the corpus and expectations were produced and how to regenerate them.
92 changes: 56 additions & 36 deletions php-transformer/src/ArtifactCompiler/CapturedDialogProjector.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
namespace Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler;

use Automattic\BlocksEngine\PhpTransformer\Contract\CaptureEvidence;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\CapturedDialogBlockGenerator;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\NavigationPattern;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SourceDom;
use DOMDocument;
Expand All @@ -17,6 +18,11 @@ final class CapturedDialogProjector
private const MAX_PAGES = 128;
private const MAX_STATES_PER_PAGE = 8;
private const MAX_DIALOG_BYTES = 65536;
/** Capture runtime marker => sha256 of its published, trimmed body. */
private const RECOGNIZED_RUNTIMES = array(
CaptureEvidence::DISCLOSURE_RUNTIME => CaptureEvidence::DISCLOSURE_RUNTIME_SHA256,
CaptureEvidence::GALLERY_RUNTIME => CaptureEvidence::GALLERY_RUNTIME_SHA256,
);


/** @var list<DOMElement> Panels proven by the existing complete gallery contract. */
Expand Down Expand Up @@ -154,6 +160,12 @@ private function projectPage(string $html, array $states, string $sourcePath): a
return array('html' => $html, 'diagnostics' => array($this->diagnostic('captured_interaction_source_invalid', 'warning', 'Captured dialogs were not projected because the source HTML could not be parsed.', array('source_path' => $sourcePath))), 'projected_count' => 0, 'retired_scripts' => array(), 'script_bindings' => null);
}

// Native replacement retires the capture runtime that drives this
// page's wiring. A runtime body Blocks Engine does not recognize keeps
// running, so it keeps the wiring it drives too.
if ($this->hasUnrecognizedRuntime($document)) {
return array('html' => $html, 'diagnostics' => array(), 'projected_count' => 0, 'retired_scripts' => array(), 'script_bindings' => null);
}
$scriptBindings = new ScriptOccurrenceBindings($document);
$diagnostics = array();
$this->nativeGalleryPanels = array();
Expand Down Expand Up @@ -240,20 +252,15 @@ private function projectPage(string $html, array $states, string $sourcePath): a
}
$retired = array();
if ($galleryCount > 0) {
foreach (iterator_to_array($document->getElementsByTagName('script')) as $script) {
if (!$script instanceof DOMElement || !$script->hasAttribute(CaptureEvidence::GALLERY_RUNTIME)) continue;
$body = trim($script->textContent ?? '');
if ('' !== $body) $retired[] = array('body' => $body, 'attribute' => CaptureEvidence::GALLERY_RUNTIME, 'reason' => 'native_carousel_and_dialog_replace_capture_gallery');
$script->parentNode?->removeChild($script);
}
$retired = $this->retireRecognizedRuntime($document, CaptureEvidence::GALLERY_RUNTIME, 'native_carousel_and_dialog_replace_capture_gallery');
}
if ($this->hasNavigationOwnedDialogTrigger($document)) {
$this->consumeNavigationOwnedCloseHelpers($document);
$handledNavigationDropdown = true;
}
if (($projected > 0 || $handledNavigationDropdown) && !$this->hasDialogCloseHelper($document) && $this->everyDialogTriggerIsBound($document)) {
$reason = 0 < $projected ? 'native_dialog_close_replaces_capture_close_helper' : 'native_navigation_submenu_replaces_capture_disclosure';
$retired = array_merge($retired, $this->removeDisclosureRuntime($document, $reason));
$retired = array_merge($retired, $this->retireRecognizedRuntime($document, CaptureEvidence::DISCLOSURE_RUNTIME, $reason));
}

$output = $document->saveHTML();
Expand Down Expand Up @@ -420,31 +427,17 @@ private function triggerDocumentScope(DOMDocument $document, array $triggers): D
/**
* Source-proven open/closed changes the producer recorded on a trigger's
* ancestors (for example a header that paints itself only while its menu
* is open). Keep a bounded, attribute-limited copy so the dialog can replay
* them; anything outside that shape is dropped rather than guessed.
* is open, or a row that switches `data-section-theme`). Keep the bounded
* copy the native dialog can replay; anything outside that shape is
* dropped rather than guessed.
*
* @param array<int, DOMElement> $triggers
* @return array<int, array{tag:string, closed:array<string, string|null>, opened:array<string, string|null>}>
*/
private function triggerAncestorState(array $triggers): array
{
foreach ($triggers as $trigger) {
$bindings = json_decode($trigger->getAttribute(CaptureEvidence::DIALOG_ANCESTOR_STATE), true);
if (! is_array($bindings) || array() === $bindings || count($bindings) > 8) continue;
$state = array();
foreach ($bindings as $binding) {
$tag = is_array($binding) ? strtolower((string) ($binding['tag'] ?? '')) : '';
if (1 !== preg_match('/^[a-z][a-z0-9-]{0,31}$/', $tag) || ! is_array($binding['closed'] ?? null) || ! is_array($binding['opened'] ?? null)) continue 2;
$sides = array();
foreach (array('closed', 'opened') as $side) {
foreach ($binding[$side] as $name => $value) {
if (! in_array($name, array('class', 'style', 'hidden'), true) || (null !== $value && ! is_string($value)) || strlen((string) $value) > 2048) continue 3;
$sides[$side][$name] = $value;
}
}
if (array_keys($sides['closed'] ?? array()) !== array_keys($sides['opened'] ?? array()) || array() === ($sides['closed'] ?? array())) continue 2;
$state[] = array('tag' => $tag, 'closed' => $sides['closed'], 'opened' => $sides['opened']);
}
$state = CapturedDialogBlockGenerator::ancestorState(json_decode($trigger->getAttribute(CaptureEvidence::DIALOG_ANCESTOR_STATE), true));
if (array() !== $state) return $state;
}
return array();
Expand Down Expand Up @@ -558,12 +551,10 @@ private function canReplaceScopedDisclosure(DOMDocument $document, array $scopes
$known = false;
foreach ($document->getElementsByTagName('script') as $script) {
if ($script->hasAttribute(CaptureEvidence::DISCLOSURE_RUNTIME)) {
// A marker alone does not establish ownership of an arbitrary source
// script; the body must be the recognized capture interpreter.
if (CaptureEvidence::DISCLOSURE_RUNTIME_SHA256 !== hash('sha256', trim($script->textContent ?? ''))) return false;
if (!self::isRecognizedRuntimeScript($script, CaptureEvidence::DISCLOSURE_RUNTIME)) return false;
$known = true;
} elseif ($script->hasAttribute(CaptureEvidence::GALLERY_RUNTIME)) {
if (CaptureEvidence::GALLERY_RUNTIME_SHA256 !== hash('sha256', trim($script->textContent ?? ''))) return false;
if (!self::isRecognizedRuntimeScript($script, CaptureEvidence::GALLERY_RUNTIME)) return false;
} elseif (!$script->hasAttribute(CaptureEvidence::NATIVE_CONTROL_RUNTIME)
&& \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\WordPressSitePlan::isExecutableScriptType($script->getAttribute('type'), 'module' === $script->getAttribute('type'))
) {
Expand Down Expand Up @@ -664,19 +655,48 @@ private function consumeNavigationOwnedCloseHelpers(DOMDocument $document): void
}
}

/** @return array<int, array{body:string, attribute:string, reason:string}> */
private function removeDisclosureRuntime(DOMDocument $document, string $reason): array
/**
* Remove the capture runtime scripts carrying `$marker` that Blocks Engine
* recognizes. Every retirement path goes through here; a marked script with
* any other body stays with the page.
*
* @return array<int, array{body:string, attribute:string, reason:string}>
*/
private function retireRecognizedRuntime(DOMDocument $document, string $marker, string $reason): array
{
$retired = array();
foreach (iterator_to_array($document->getElementsByTagName('script')) as $script) {
if (!$script instanceof DOMElement || !$script->hasAttribute(CaptureEvidence::DISCLOSURE_RUNTIME)) continue;
$body = trim($script->textContent ?? '');
if ('' !== $body) $retired[] = array('body' => $body, 'attribute' => CaptureEvidence::DISCLOSURE_RUNTIME, 'reason' => $reason);
if (!$script instanceof DOMElement || !self::isRecognizedRuntimeScript($script, $marker)) continue;
$retired[] = array('body' => trim($script->textContent ?? ''), 'attribute' => $marker, 'reason' => $reason);
$script->parentNode?->removeChild($script);
}
return $retired;
}

/**
* A capture runtime Blocks Engine may replace: a script carrying `$marker`
* whose trimmed body is the published body for that marker
* (docs/contracts/capture-evidence.md, "Runtime scripts"). The marker alone
* does not establish ownership of an arbitrary source script.
*/
private static function isRecognizedRuntimeScript(DOMElement $script, string $marker): bool
{
$digest = self::RECOGNIZED_RUNTIMES[$marker] ?? null;
return null !== $digest && 'script' === strtolower($script->tagName) && $script->hasAttribute($marker)
&& $digest === hash('sha256', trim($script->textContent ?? ''));
}

/** Whether a page carries a capture runtime marker whose body is not the published one. */
private function hasUnrecognizedRuntime(DOMDocument $document): bool
{
foreach ($document->getElementsByTagName('script') as $script) {
foreach (array_keys(self::RECOGNIZED_RUNTIMES) as $marker) {
if ($script instanceof DOMElement && $script->hasAttribute($marker) && !self::isRecognizedRuntimeScript($script, $marker)) return true;
}
}
return false;
}

/**
* Navigation ownership is proven from in-place controls, independently of
* interaction probing. Non-navigation triggers still prevent retirement.
Expand All @@ -697,11 +717,11 @@ private function retireOwnedDisclosureWithoutProjection(array $files, array $dia
$loaded = $document->loadHTML('<?xml encoding="UTF-8">' . $content, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NONET);
libxml_clear_errors();
libxml_use_internal_errors($previous);
if (!$loaded || !$this->hasNavigationOwnedDialogTrigger($document) || !$this->everyDialogTriggerIsBound($document)) continue;
if (!$loaded || $this->hasUnrecognizedRuntime($document) || !$this->hasNavigationOwnedDialogTrigger($document) || !$this->everyDialogTriggerIsBound($document)) continue;
$scriptBindings = new ScriptOccurrenceBindings($document);
$this->consumeNavigationOwnedCloseHelpers($document);
if ($this->hasDialogCloseHelper($document)) continue;
$pageRetired = $this->removeDisclosureRuntime($document, 'native_navigation_submenu_replaces_capture_disclosure');
$pageRetired = $this->retireRecognizedRuntime($document, CaptureEvidence::DISCLOSURE_RUNTIME, 'native_navigation_submenu_replaces_capture_disclosure');
if (array() === $pageRetired) continue;
$html = $document->saveHTML();
$html = is_string($html) ? preg_replace('/^<\?xml encoding="UTF-8">/i', '', $html) : null;
Expand Down
Loading
Loading