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
14 changes: 12 additions & 2 deletions php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,16 @@ private function finalizeArtifact(array $artifact, array $reduction): Transforme
$sourceReports['superseded_selectors'] = $entryBlocks['superseded_selectors'];
}
$sourceReports['runtime_dependency_parity'] = ( new RuntimeDependencyParityReport() )->fromArtifact($normalized['files'], $html, $serializedBlocks, $entryPath, $entryBlocks['runtime_islands'], $referenceReports['asset_references'], $entryBlocks['interaction_candidates'], $entryBlocks['superseded_selectors']);
foreach ($sourceReports['runtime_dependency_parity']['findings'] ?? array() as $finding) {
if ('runtime_dependency_target_missing' !== ($finding['code'] ?? '') || 'telemetry' === ($finding['script_kind'] ?? '')) {
continue;
}
$diagnostics[] = $this->diagnostic('runtime_dependency_contract_failed', 'error', (string) ($finding['message'] ?? 'A required runtime DOM target is absent from generated markup.'), array_filter(array(
'selector' => $finding['selector'] ?? null,
'script_path' => $finding['script_path'] ?? null,
'source_path' => $finding['source_path'] ?? null,
), static fn (mixed $value): bool => null !== $value && '' !== $value));
}
if ( array() !== $entryBlocks['runtime_islands'] ) {
$sourceReports['runtime_islands'] = $entryBlocks['runtime_islands'];
if ( array() !== $runtimeIslandPackage ) {
Expand Down Expand Up @@ -3667,7 +3677,7 @@ private function isPresentationOnlyScriptSelector(string $script, string $select
}

$selectorPattern = preg_quote($selector, '/');
if ( preg_match_all('/\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:document\s*\.\s*)?querySelector(?:All)?\s*\(\s*(["\'])' . $selectorPattern . '\2\s*\)/', $script, $assignments, PREG_SET_ORDER) ) {
if ( preg_match_all('/\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:(?:document|[A-Za-z_$][A-Za-z0-9_$]*)\s*\.\s*)?querySelector(?:All)?\s*\(\s*(["\'])' . $selectorPattern . '\2\s*\)/', $script, $assignments, PREG_SET_ORDER) ) {
foreach ($assignments as $assignment) {
if (preg_match('/\b' . preg_quote((string) $assignment[1], '/') . '\s*\.\s*(?:addEventListener|appendChild|removeChild|replaceChildren|insertAdjacentHTML|setAttribute|removeAttribute|toggleAttribute|getContext|submit|fetch)\b|\b' . preg_quote((string) $assignment[1], '/') . '\s*\.\s*(?:textContent|innerHTML|outerHTML|value|checked|selectedIndex|hidden|disabled|style|dataset)\b/', $script)) {
return false;
Expand Down Expand Up @@ -5619,7 +5629,7 @@ private function statusFromDiagnostics(array $diagnostics): string
return 'failed';
}

if ( in_array(($diagnostic['code'] ?? ''), array('preserved_runtime_island', 'runtime_dom_contract_preserved'), true) ) {
if ( in_array(($diagnostic['code'] ?? ''), array('preserved_runtime_island', 'runtime_dom_contract_preserved', 'runtime_dom_contract_fallback'), true) ) {
continue;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1001,7 +1001,7 @@ private function isPresentationOnlyScriptSelector(string $script, string $select
}

$selectorPattern = preg_quote($selector, '/');
if ( preg_match_all('/\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:document\s*\.\s*)?querySelector(?:All)?\s*\(\s*(["\'])' . $selectorPattern . '\2\s*\)/', $script, $assignments, PREG_SET_ORDER) ) {
if ( preg_match_all('/\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:(?:document|[A-Za-z_$][A-Za-z0-9_$]*)\s*\.\s*)?querySelector(?:All)?\s*\(\s*(["\'])' . $selectorPattern . '\2\s*\)/', $script, $assignments, PREG_SET_ORDER) ) {
foreach ($assignments as $assignment) {
if (preg_match('/\b' . preg_quote((string) $assignment[1], '/') . '\s*\.\s*(?:addEventListener|appendChild|removeChild|replaceChildren|insertAdjacentHTML|setAttribute|removeAttribute|toggleAttribute|getContext|submit|fetch)\b|\b' . preg_quote((string) $assignment[1], '/') . '\s*\.\s*(?:textContent|innerHTML|outerHTML|value|checked|selectedIndex|hidden|disabled|style|dataset)\b/', $script)) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ final class DiagnosticsCollector
* @param array<int, array<string, mixed>> $fallbacks Fallback diagnostics emitted during conversion.
* @param array<int, array<string, mixed>> $runtimeIslands Preserved runtime islands.
* @param array<int, array<string, mixed>> $runtimeDomPreservations Runtime contracts retained by native blocks.
* @param array<int, array<string, mixed>> $runtimeDomFallbacks Runtime contracts retained by bounded islands.
* @param array<string, mixed> $blockValidityReport Block serialization validity report.
* @param array<string, mixed> $semanticParityReport Semantic parity report.
* @param array<string, mixed> $contentRoundTripReport Content round-trip (hallucination) report.
Expand All @@ -38,6 +39,7 @@ public function collect(
array $fallbacks,
array $runtimeIslands,
array $runtimeDomPreservations,
array $runtimeDomFallbacks,
array $blockValidityReport,
array $semanticParityReport,
array $contentRoundTripReport = array()
Expand Down Expand Up @@ -146,6 +148,20 @@ public function collect(
), static fn (mixed $value): bool => null !== $value && '' !== $value);
}

foreach ( $runtimeDomFallbacks as $fallback ) {
$diagnostics[] = array_filter(array(
'code' => 'runtime_dom_contract_fallback',
'message' => 'A script-addressed DOM contract requires a bounded runtime island because its native block cannot serialize the source element unchanged.',
'source' => $transformerSource,
'severity' => 'info',
'conversion_classification' => 'runtime_island_preserved',
'preservation_strategy' => 'bounded_raw_html_runtime_island',
'block_name' => $fallback['block_name'] ?? null,
'tag' => $fallback['tag'] ?? null,
'selector' => $fallback['selector'] ?? null,
), static fn (mixed $value): bool => null !== $value && '' !== $value);
}

foreach ( $blockValidityReport['findings'] ?? array() as $finding ) {
if ( ! is_array($finding) ) {
continue;
Expand Down
23 changes: 12 additions & 11 deletions php-transformer/src/HtmlToBlocks/HtmlTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,7 @@ public function transform(string $html, array $options = array()): TransformerRe
$fallbacks,
$this->runtimeIslands,
array_values($this->runtimeDomPreservations),
array_values($this->runtimeDomFallbacks),
$blockValidityReport,
$semanticParityReport,
$contentRoundTripReport
Expand Down Expand Up @@ -897,6 +898,7 @@ public function transform(string $html, array $options = array()): TransformerRe
'head_metadata' => $headMetadata,
'runtime_islands' => $this->runtimeIslands,
'runtime_dom_contracts' => array_values($this->runtimeDomPreservations),
'runtime_dom_fallbacks' => array_values($this->runtimeDomFallbacks),
'generated_blocks' => $this->generatedBlocks,
'gutenberg_gaps' => $this->descriptionListBlockGenerated ? array(
array(
Expand Down Expand Up @@ -4569,7 +4571,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
return $this->capturedDialogBlock($element, $fallbacks);
}

if ( $this->shouldPreserveDataAttributeRuntimeTarget($element) && ! in_array($tagName, array('p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'), true) ) {
if ( $this->shouldPreserveDataAttributeRuntimeTarget($element) ) {
return $this->htmlPreservationBlock($element);
}

Expand All @@ -4586,7 +4588,6 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca

if ( preg_match('/^h([1-6])$/', $tagName, $matches) ) {
$content = $this->richTextContentWithMaterializedInlineStyles($element);
$content = $this->richTextContentWithRuntimeRootAttributes($element, $content);
$content = $this->headingRichTextContent($content);
if ( $this->richTextRequiresHtmlFallbackWithoutNativeSvgImageObjects($content) ) {
return $this->htmlPreservationBlock($element);
Expand All @@ -4607,7 +4608,6 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
return $marquee;
}
$content = $this->richTextContentWithMaterializedInlineStyles($element);
$content = $this->richTextContentWithRuntimeRootAttributes($element, $content);
$inlineSvgContent = $this->richTextContentWithMaterializedSvgImages($element, $content);
if ( null !== $inlineSvgContent ) {
$content = $inlineSvgContent;
Expand Down Expand Up @@ -5777,6 +5777,7 @@ private function createBlock(string $name, array $attrs = array(), array $innerB
'events' => $this->eventMetadata($sourceElement),
'required_scripts' => $this->requiredScriptsForElement($sourceElement),
));
$this->recordRuntimeDomFallback($sourceElement, $name);
} else {
$this->recordNativeRuntimeDomPreservation($sourceElement, $name, in_array($name, array('core/paragraph', 'core/heading'), true));
}
Expand Down Expand Up @@ -12190,19 +12191,19 @@ private function recordNativeRuntimeDomPreservation(DOMElement $element, string
}
}

private function richTextContentWithRuntimeRootAttributes(DOMElement $element, string $content): string
private function recordRuntimeDomFallback(DOMElement $element, string $blockName): void
{
$attributes = array();
foreach ($this->runtimeDomSelectorsForElement($element) as $selector) {
if (!preg_match('/\[(data-[A-Za-z][A-Za-z0-9_-]*)/', $selector, $match)) {
$key = $blockName . "\n" . $selector;
if (isset($this->runtimeDomFallbacks[$key])) {
continue;
}
$name = strtolower((string) $match[1]);
if ($element->hasAttribute($name)) {
$attributes[$name] = $element->getAttribute($name);
}
$this->runtimeDomFallbacks[$key] = array(
'block_name' => $blockName,
'tag' => strtolower($element->tagName),
'selector' => $selector,
);
}
return array() === $attributes ? $content : '<mark' . $this->htmlAttributeString($attributes) . '>' . $content . '</mark>';
}

private function canRetainRuntimeDomContractNatively(DOMElement $element, string $blockName): bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ final class HtmlTransformerSession
public array $scriptMetadata = array();
public array $runtimeIslands = array();
public array $runtimeDomPreservations = array();
public array $runtimeDomFallbacks = array();
public array $nativeDisclosureRootIds = array();
public array $generatedBlocks = array();
public bool $descriptionListBlockGenerated = false;
Expand Down
24 changes: 18 additions & 6 deletions php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -3330,28 +3330,40 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter
array(
'entrypoint' => 'index.html',
'files' => array(
'index.html' => '<main><p id="clock" class="clock" data-counter="clock">0 <span id="score">score</span></p><script src="js/clock.js"></script></main>',
'js/clock.js' => 'document.getElementById("clock").textContent = "1"; document.querySelector(".clock").classList.add("ready"); document.querySelector("[data-counter]").textContent = "2"; document.querySelector("#score").textContent = "3";',
'index.html' => '<main><section class="panel"><p id="clock" class="clock">0 <span id="score">score</span></p><p data-counter="clock">Counter</p><p>Editable sibling</p></section><script src="js/clock.js"></script></main>',
'js/clock.js' => 'document.getElementById("clock").textContent = "1"; document.querySelector(".clock").classList.add("ready"); document.querySelector("[data-counter]").textContent = "2"; document.querySelector("#score").closest(".panel").classList.add("ready");',
),
)
)->toArray();
$firstPartyRuntimeMarkup = (string) ($firstPartyRuntimeContracts['serialized_blocks'] ?? '');
$firstPartyRuntimeDependencies = array_column($firstPartyRuntimeContracts['source_reports']['runtime_dependency_parity']['dependencies'] ?? array(), null, 'selector');
$firstPartyRuntimeDiagnostics = array_values(array_filter($firstPartyRuntimeContracts['diagnostics'] ?? array(), static fn (array $diagnostic): bool => 'runtime_dom_contract_preserved' === ($diagnostic['code'] ?? '')));
$firstPartyRuntimeDiagnosticSelectors = array_column($firstPartyRuntimeDiagnostics, 'selector');
$assert('pass' === ($firstPartyRuntimeContracts['source_reports']['runtime_dependency_parity']['status'] ?? '') && array() === ($firstPartyRuntimeContracts['source_reports']['runtime_dependency_parity']['findings'] ?? array()), 'first-party ID, class, and data-attribute runtime selectors pass only when their generated contracts are present');
foreach (array('#clock', '.clock', '[data-counter]', '#score') as $selector) {
$firstPartyRuntimeFallbacks = array_values(array_filter($firstPartyRuntimeContracts['diagnostics'] ?? array(), static fn (array $diagnostic): bool => 'runtime_dom_contract_fallback' === ($diagnostic['code'] ?? '')));
$assert('pass' === ($firstPartyRuntimeContracts['source_reports']['runtime_dependency_parity']['status'] ?? '') && array() === ($firstPartyRuntimeContracts['source_reports']['runtime_dependency_parity']['findings'] ?? array()), 'first-party ID, class, closest-parent, and data-attribute runtime selectors pass only when their generated contracts are present');
foreach (array('#clock', '.clock', '.panel', '[data-counter]', '#score') as $selector) {
$assert(true === ($firstPartyRuntimeDependencies[$selector]['generated_present'] ?? null), 'first-party runtime dependency parity preserves ' . $selector);
$assert(in_array($selector, $firstPartyRuntimeDiagnosticSelectors, true), 'native preservation diagnostics identify ' . $selector);
}
$assert(str_contains($firstPartyRuntimeMarkup, 'id="clock" class="clock"') && str_contains($firstPartyRuntimeMarkup, '<mark data-counter="clock">') && str_contains($firstPartyRuntimeMarkup, '<span id="score">score</span>'), 'native RichText output retains required ID, class, and data attributes for first-party runtime selectors', $firstPartyRuntimeMarkup);
$assert(in_array('#clock', $firstPartyRuntimeDiagnosticSelectors, true) && in_array('.clock', $firstPartyRuntimeDiagnosticSelectors, true) && in_array('#score', $firstPartyRuntimeDiagnosticSelectors, true) && '[data-counter]' === ($firstPartyRuntimeFallbacks[0]['selector'] ?? ''), 'per-target diagnostics distinguish native ID/class/inline preservation from data-attribute fallback');
$assert(str_contains($firstPartyRuntimeMarkup, '<section class="wp-block-group panel">') && str_contains($firstPartyRuntimeMarkup, '<p id="clock" class="clock">0 <span id="score">score</span></p>') && str_contains($firstPartyRuntimeMarkup, '<!-- wp:html --><p data-counter="clock">Counter</p><!-- /wp:html -->') && str_contains($firstPartyRuntimeMarkup, '<p>Editable sibling</p>') && ! str_contains($firstPartyRuntimeMarkup, '<mark data-counter='), 'runtime data attributes retain their source p tag in one bounded island while native parent and siblings remain editable', $firstPartyRuntimeMarkup);
$missingRuntimeContract = (new \Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler\RuntimeDependencyParityReport())->fromArtifact(
array(array('path' => 'js/clock.js', 'kind' => 'js', 'content' => 'document.querySelector("[data-counter]").textContent = "2";')),
'<main><p data-counter="clock">0</p></main>',
'<!-- wp:paragraph --><p>0</p><!-- /wp:paragraph -->',
'index.html'
);
$assert('warning' === ($missingRuntimeContract['status'] ?? '') && 'runtime_dependency_target_missing' === ($missingRuntimeContract['findings'][0]['code'] ?? ''), 'runtime dependency parity fails closed when a required source selector is absent from generated markup');
$missingRuntimeCompile = $compiler->compile(
array(
'entrypoint' => 'index.html',
'files' => array(
'index.html' => '<main><style data-counter="clock">.clock{color:red}</style><p>Editable</p><script src="js/clock.js"></script></main>',
'js/clock.js' => 'document.querySelector("[data-counter]").textContent = "2";',
),
)
)->toArray();
$missingRuntimeCompileDiagnostics = array_values(array_filter($missingRuntimeCompile['diagnostics'] ?? array(), static fn (array $diagnostic): bool => 'runtime_dependency_contract_failed' === ($diagnostic['code'] ?? '')));
$assert('failed' === ($missingRuntimeCompile['status'] ?? '') && '[data-counter]' === ($missingRuntimeCompileDiagnostics[0]['context']['selector'] ?? ''), 'ArtifactCompiler promotes required runtime selector parity failures to deterministic top-level errors');

$expandedRuntimeTargetsSite = $compiler->compile(
array(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schema": "blocks-engine/php-transformer/parity-fixture/v1",
"name": "artifact-first-party-runtime-dom-contracts",
"description": "Preserves first-party ID, class, and data-attribute DOM contracts in native RichText output.",
"description": "Preserves native first-party ID and class contracts while bounding root data-attribute contracts to their source element.",
"source_reference": {
"repo": "php-transformer",
"path": "tests/fixtures/parity/artifact-first-party-runtime-dom-contracts.json",
Expand All @@ -19,22 +19,22 @@
{
"path": "index.html",
"kind": "html",
"content": "<main><p id=\"clock\" class=\"clock\" data-counter=\"clock\">0 <span id=\"score\">score</span></p><script src=\"js/clock.js\"></script></main>"
"content": "<main><section class=\"panel\"><p id=\"clock\" class=\"clock\">0 <span id=\"score\">score</span></p><p data-counter=\"clock\">Counter</p><p>Editable sibling</p></section><script src=\"js/clock.js\"></script></main>"
},
{
"path": "js/clock.js",
"kind": "js",
"content": "document.getElementById('clock').textContent = '1'; document.querySelector('.clock').classList.add('ready'); document.querySelector('[data-counter]').textContent = '2'; document.querySelector('#score').textContent = '3';"
"content": "document.getElementById('clock').textContent = '1'; document.querySelector('.clock').classList.add('ready'); document.querySelector('[data-counter]').textContent = '2'; document.querySelector('#score').closest('.panel').classList.add('ready');"
}
]
}
},
"expect": [
{ "path": "status", "assert": "equals", "value": "success" },
{ "path": "source_reports.runtime_dependency_parity.status", "assert": "equals", "value": "pass" },
{ "path": "source_reports.runtime_dependency_parity.dependencies", "assert": "count", "count": 4 },
{ "path": "source_reports.runtime_dependency_parity.dependencies", "assert": "count", "count": 5 },
{ "path": "serialized_blocks", "assert": "contains", "value": "id=\"clock\" class=\"clock\"" },
{ "path": "serialized_blocks", "assert": "contains", "value": "data-counter=\"clock\"" },
{ "path": "serialized_blocks", "assert": "contains", "value": "<p data-counter=\"clock\">Counter</p>" },
{ "path": "serialized_blocks", "assert": "contains", "value": "id=\"score\"" }
]
}
Loading