From 05a7ab38240d297af8fbb9b0706aba386f0d6cf5 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 26 Jun 2026 23:40:01 -0400 Subject: [PATCH] Add runtime island reporting --- .../src/ArtifactCompiler/ArtifactCompiler.php | 71 +++++++- .../Contract/ConversionReportProjection.php | 18 ++ .../src/Contract/TransformerResult.php | 2 +- .../src/HtmlToBlocks/HtmlTransformer.php | 167 ++++++++++++++++++ php-transformer/tests/contract/run.php | 18 ++ 5 files changed, 273 insertions(+), 3 deletions(-) diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index e8ba8e2..e9fd898 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -77,6 +77,9 @@ public function compile(array $artifact): TransformerResult $sourceReports['compiled_site'] = $this->compiledSiteReport($normalized, $entryPath, $documents['documents'], $assets, $blockTypes, $serializedBlocks); $sourceReports['materialization_plan'] = ( new MaterializationPlanBuilder() )->fromCompiledSite($sourceReports['compiled_site']); $sourceReports['runtime_dependency_parity'] = ( new RuntimeDependencyParityReport() )->fromArtifact($normalized['files'], $html, $serializedBlocks, $entryPath); + if ( array() !== $entryBlocks['runtime_islands'] ) { + $sourceReports['runtime_islands'] = $entryBlocks['runtime_islands']; + } $provenance = array( array( 'source_format' => 'artifact', @@ -126,7 +129,7 @@ public function compileFragment(string $content, string $source = 'fragment', st /** * @param array> $files - * @return array{blocks: array>, serialized_blocks: string, diagnostics: array>, fallbacks: array>, assets: array>} + * @return array{blocks: array>, serialized_blocks: string, diagnostics: array>, fallbacks: array>, assets: array>, runtime_islands: array>} */ private function compileEntryBlocks(string $html, string $entryPath, array $files): array { @@ -138,12 +141,13 @@ private function compileEntryBlocks(string $html, string $entryPath, array $file 'diagnostics' => $this->entryTransformDiagnostics($result['diagnostics']), 'fallbacks' => $result['fallbacks'], 'assets' => $result['assets'], + 'runtime_islands' => $result['runtime_islands'], ); } /** * @param array> $files - * @return array{blocks: array>, serialized_blocks: string, diagnostics: array>, fallbacks: array>, assets: array>} + * @return array{blocks: array>, serialized_blocks: string, diagnostics: array>, fallbacks: array>, assets: array>, runtime_islands: array>} */ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, array $files, string $sourceScope): array { @@ -154,6 +158,7 @@ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, arr 'diagnostics' => array(), 'fallbacks' => array(), 'assets' => array(), + 'runtime_islands' => array(), ); } @@ -164,6 +169,7 @@ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, arr 'diagnostics' => array(), 'fallbacks' => array(), 'assets' => array(), + 'runtime_islands' => array(), ); } @@ -172,6 +178,7 @@ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, arr 'source_scope' => $sourceScope, 'static_css' => $this->linkedStylesheetCss($html, $sourcePath, $files), 'asset_metadata' => $this->assetMetadataForSource($sourcePath, $files), + 'runtime_script_metadata' => $this->runtimeScriptMetadataForSource($html, $sourcePath, $files), 'runtime_dom_selectors' => $this->runtimeDomSelectors($html, $sourcePath, $files), 'runtime_canvas_selectors' => $this->runtimeCanvasSelectors($html, $sourcePath, $files), ))->toArray(); @@ -182,6 +189,7 @@ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, arr 'diagnostics' => is_array($result['diagnostics'] ?? null) ? $result['diagnostics'] : array(), 'fallbacks' => is_array($result['fallbacks'] ?? null) ? $result['fallbacks'] : array(), 'assets' => is_array($result['assets'] ?? null) ? $result['assets'] : array(), + 'runtime_islands' => is_array($result['source_reports']['runtime_islands'] ?? null) ? $result['source_reports']['runtime_islands'] : array(), ); } @@ -625,6 +633,65 @@ private function assetMetadataForSource(string $sourcePath, array $files): array return $metadata; } + /** + * @param array> $files + * @return array> + */ + private function runtimeScriptMetadataForSource(string $html, string $sourcePath, array $files): array + { + if ( ! preg_match_all('/]*>/i', $html, $matches) ) { + return array(); + } + + $metadata = array(); + foreach ( $matches[0] as $tag ) { + $src = $this->htmlAttribute((string) $tag, 'src'); + if ( '' === $src ) { + continue; + } + + $asset = $this->findAssetByHtmlReference($src, $sourcePath, $files); + if ( ! is_array($asset) || ! $this->isMaterializedScriptAsset($asset) ) { + continue; + } + + $metadata[] = array_filter(array( + 'path' => (string) ($asset['path'] ?? ''), + 'selector' => 'script[src="' . $src . '"]', + 'attributes' => array_filter(array( + 'src' => $src, + 'type' => $this->htmlAttribute((string) $tag, 'type'), + 'async' => $this->htmlAttribute((string) $tag, 'async'), + 'defer' => $this->htmlAttribute((string) $tag, 'defer'), + ), static fn (string $value): bool => '' !== $value), + 'script_role' => 'runtime', + 'script_source_kind' => 'external', + ), static fn (mixed $value): bool => '' !== $value && array() !== $value); + } + + return $this->dedupeRows($metadata); + } + + /** + * @param array> $rows + * @return array> + */ + private function dedupeRows(array $rows): array + { + $seen = array(); + $deduped = array(); + foreach ( $rows as $row ) { + $key = json_encode($row, JSON_UNESCAPED_SLASHES); + if ( ! is_string($key) || isset($seen[$key]) ) { + continue; + } + $seen[$key] = true; + $deduped[] = $row; + } + + return $deduped; + } + /** * @return array */ diff --git a/php-transformer/src/Contract/ConversionReportProjection.php b/php-transformer/src/Contract/ConversionReportProjection.php index 2c9d70f..99b9ad2 100644 --- a/php-transformer/src/Contract/ConversionReportProjection.php +++ b/php-transformer/src/Contract/ConversionReportProjection.php @@ -30,6 +30,7 @@ public static function fromResultParts(string $sourceFormat, array $blocks, arra 'navigation_candidates' => self::navigationCandidates($blocks, $sourceReports), 'semantic_parity' => self::semanticParity($sourceReports), 'runtime_dependency_parity' => self::runtimeDependencyParity($sourceReports), + 'runtime_islands' => self::runtimeIslands($sourceReports), 'interaction_candidates' => self::interactionCandidates($sourceReports), 'presentation_gaps' => self::presentationGaps($sourceReports), 'metrics' => $metrics, @@ -275,6 +276,23 @@ private static function interactionCandidates(array $sourceReports): array return self::dedupeRows(array_values(array_filter($candidates, static fn (mixed $candidate): bool => is_array($candidate)))); } + /** + * @param array $sourceReports + * @return array> + */ + private static function runtimeIslands(array $sourceReports): array + { + $islands = $sourceReports['runtime_islands'] ?? array(); + if ( ! is_array($islands) ) { + $islands = array(); + } + + $html = is_array($sourceReports['html'] ?? null) ? $sourceReports['html'] : array(); + $htmlIslands = is_array($html['runtime_islands'] ?? null) ? $html['runtime_islands'] : array(); + + return self::dedupeRows(array_values(array_filter(array_merge($islands, $htmlIslands), static fn (mixed $island): bool => is_array($island)))); + } + /** * @param array $sourceReports * @return array diff --git a/php-transformer/src/Contract/TransformerResult.php b/php-transformer/src/Contract/TransformerResult.php index efe1973..8606976 100644 --- a/php-transformer/src/Contract/TransformerResult.php +++ b/php-transformer/src/Contract/TransformerResult.php @@ -128,7 +128,7 @@ public static function assertCanonicalEnvelope(array $result, bool $requireMater throw new InvalidArgumentException('Canonical transformer result conversion report is missing source_format.'); } - foreach ( array( 'source_summary', 'selector_summary', 'fallback_diagnostics', 'asset_refs', 'navigation_candidates', 'interaction_candidates', 'presentation_gaps', 'metrics' ) as $key ) { + foreach ( array( 'source_summary', 'selector_summary', 'fallback_diagnostics', 'asset_refs', 'navigation_candidates', 'interaction_candidates', 'runtime_islands', 'presentation_gaps', 'metrics' ) as $key ) { if ( array_key_exists($key, $conversionReport) && ! is_array($conversionReport[$key]) ) { throw new InvalidArgumentException(sprintf('Canonical transformer result conversion report %s must be an array.', $key)); } diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 8d1cb55..3e26404 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -54,6 +54,16 @@ final class HtmlTransformer */ private array $scriptMetadata = array(); + /** + * @var array> + */ + private array $runtimeIslands = array(); + + /** + * @var array> + */ + private array $runtimeScriptMetadata = array(); + /** * @var array> */ @@ -107,6 +117,8 @@ public function transform(string $html, array $options = array()): TransformerRe $this->sourceProvenance = array(); $this->structureProvenance = array(); $this->scriptMetadata = array(); + $this->runtimeIslands = array(); + $this->runtimeScriptMetadata = $this->runtimeScriptMetadataFromOptions($options); $this->assetMetadata = $this->assetMetadataFromOptions($options); $this->generatedAssets = array(); $this->staticClassPromotions = $this->detectStaticClassPromotions($html); @@ -250,6 +262,7 @@ public function transform(string $html, array $options = array()): TransformerRe $metrics = $this->metrics($html, $blocks, $serializedBlocks, $fallbacks, $diagnostics, $startedAt); $sourceReports = array( + 'runtime_islands' => $this->runtimeIslands, 'interaction_candidates' => $interactionCandidates, 'wp_block_validity' => $blockValidityReport, 'semantic_parity' => $semanticParityReport, @@ -258,6 +271,7 @@ public function transform(string $html, array $options = array()): TransformerRe 'source_provenance' => $sourceProvenance, 'structure_signals' => $this->structureProvenance, 'script_metadata' => $this->scriptMetadata, + 'runtime_islands' => $this->runtimeIslands, ), ); $sourceReports['conversion_report'] = ConversionReportProjection::fromResultParts('html', $blocks, $fallbacks, $sourceReports, array(), $provenance, $metrics); @@ -1255,6 +1269,13 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca $controls = $this->formControls($element); $readableFormBlock = $this->readableFormBlockFromForm($element, true); $boundedHtml = $this->boundedFallbackHtml($this->safeFallbackHtml($element)); + $this->recordRuntimeIsland($element, 'form', 'form_requires_runtime', 'server_or_client_form_handler', array( + 'form' => $this->formMetadata($element), + 'controls' => $controls, + 'control_count' => count($controls), + 'events' => $this->eventMetadata($element), + 'required_scripts' => $this->requiredScriptsForElement($element), + )); $fallbacks[] = FallbackDiagnostic::build(array( 'type' => 'html', 'reason' => 'form_requires_runtime', @@ -3042,6 +3063,14 @@ private function captureCanvasFallback(DOMElement $element, array &$fallbacks): { $boundedHtml = $this->boundedFallbackHtml($this->safeFallbackHtml($element)); $id = trim($this->attr($element, 'id')); + if ( $this->isRuntimeCanvasTarget($element) ) { + $this->recordRuntimeIsland($element, 'canvas', 'canvas_requires_runtime', 'canvas_element_and_client_script_execution', array( + 'script_dependency_hint' => '' !== $id + ? 'Scripts may target #' . $id . ' and call canvas APIs such as getContext(); replacing it with a wrapper block changes runtime behavior.' + : 'Scripts may target this canvas by selector and call canvas APIs such as getContext(); replacing it with a wrapper block changes runtime behavior.', + 'required_scripts' => $this->requiredScriptsForElement($element), + )); + } $fallbacks[] = FallbackDiagnostic::build(array_filter(array( 'type' => 'html', @@ -3116,6 +3145,134 @@ private function isRuntimeDomTarget(DOMElement $element): bool return false; } + /** + * @param array $metadata + */ + private function recordRuntimeIsland(DOMElement $element, string $kind, string $reason, string $runtimeRequirement, array $metadata = array()): void + { + $boundedHtml = $this->boundedFallbackHtml($this->safeFallbackHtml($element)); + $island = array_filter(array_merge(array( + 'kind' => $kind, + 'selector' => $this->runtimeIslandSelector($element), + 'tag' => strtolower($element->tagName), + 'preservation_reason' => $reason, + 'runtime_requirement' => $runtimeRequirement, + 'source_snippet' => $boundedHtml['html'], + 'source_bytes' => $boundedHtml['bytes'], + 'source_truncated' => $boundedHtml['truncated'], + 'attributes' => $this->htmlAttributes($element), + 'context' => $this->sourceContext($element), + 'required_assets' => array(), + 'required_scripts' => array(), + ), $metadata), static fn (mixed $value): bool => null !== $value && '' !== $value && array() !== $value); + + $key = json_encode(array( + 'kind' => $island['kind'] ?? '', + 'selector' => $island['selector'] ?? '', + 'snippet' => $island['source_snippet'] ?? '', + ), JSON_UNESCAPED_SLASHES); + foreach ( $this->runtimeIslands as $existing ) { + $existingKey = json_encode(array( + 'kind' => $existing['kind'] ?? '', + 'selector' => $existing['selector'] ?? '', + 'snippet' => $existing['source_snippet'] ?? '', + ), JSON_UNESCAPED_SLASHES); + if ( $key === $existingKey ) { + return; + } + } + + $this->runtimeIslands[] = $island; + } + + private function runtimeIslandSelector(DOMElement $element): string + { + $id = trim($this->attr($element, 'id')); + if ( '' !== $id ) { + return '#' . $id; + } + + foreach ( preg_split('/\s+/', trim($this->attr($element, 'class'))) ?: array() as $class ) { + if ( '' !== $class ) { + return '.' . $class; + } + } + + return $this->elementSelector($element); + } + + /** + * @return array> + */ + private function requiredScriptsForElement(DOMElement $element): array + { + $scripts = $this->runtimeScriptMetadata; + + $owner = $element->ownerDocument; + if ( ! $owner instanceof DOMDocument ) { + return $scripts; + } + + foreach ( $owner->getElementsByTagName('script') as $script ) { + if ( ! $script instanceof DOMElement || 'runtime' !== $this->scriptRole($script) ) { + continue; + } + + $scripts[] = array_filter(array( + 'selector' => $this->elementSelector($script), + 'attributes' => $this->safeScriptAttributes($script), + 'script_role' => 'runtime', + 'script_source_kind' => '' !== trim($this->attr($script, 'src')) ? 'external' : 'inline', + ), static fn (mixed $value): bool => '' !== $value && array() !== $value); + } + + return $this->dedupeArrayRows($scripts); + } + + /** + * @param array $options + * @return array> + */ + private function runtimeScriptMetadataFromOptions(array $options): array + { + $metadata = array(); + foreach ( $options['runtime_script_metadata'] ?? array() as $script ) { + if ( ! is_array($script) ) { + continue; + } + + $metadata[] = array_filter(array( + 'path' => is_string($script['path'] ?? null) ? $script['path'] : '', + 'selector' => is_string($script['selector'] ?? null) ? $script['selector'] : '', + 'attributes' => is_array($script['attributes'] ?? null) ? $script['attributes'] : array(), + 'script_role' => 'runtime', + 'script_source_kind' => is_string($script['script_source_kind'] ?? null) ? $script['script_source_kind'] : 'external', + ), static fn (mixed $value): bool => '' !== $value && array() !== $value); + } + + return $this->dedupeArrayRows($metadata); + } + + /** + * @param array> $rows + * @return array> + */ + private function dedupeArrayRows(array $rows): array + { + $seen = array(); + $deduped = array(); + foreach ( $rows as $row ) { + $key = json_encode($row, JSON_UNESCAPED_SLASHES); + if ( ! is_string($key) || isset($seen[$key]) ) { + continue; + } + $seen[$key] = true; + $deduped[] = $row; + } + + return $deduped; + } + /** * @param array $options * @return array @@ -3542,6 +3699,11 @@ private function readableFormBlockFromForm(DOMElement $form, bool $allowFormEven } if ( $this->isRuntimeDomTarget($control) ) { + $this->recordRuntimeIsland($control, 'control', 'runtime_dom_target', 'client_script_execution', array( + 'control' => $this->formControlMetadata($control), + 'events' => $this->eventMetadata($control), + 'required_scripts' => $this->requiredScriptsForElement($control), + )); $contentBlocks[] = $this->createBlock('core/html', array( 'content' => $this->safeFallbackHtml($control) ), array(), $control); continue; } @@ -3625,6 +3787,11 @@ private function readableFormControlBlockFromElement(DOMElement $element): ?arra } if ( $this->isRuntimeDomTarget($element) ) { + $this->recordRuntimeIsland($element, 'control', 'runtime_dom_target', 'client_script_execution', array( + 'control' => $this->formControlMetadata($element), + 'events' => $this->eventMetadata($element), + 'required_scripts' => $this->requiredScriptsForElement($element), + )); return $this->createBlock('core/html', array( 'content' => $this->safeFallbackHtml($element) ), array(), $element); } diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index ee78fcf..94dbbae 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -206,6 +206,11 @@ function serialize_blocks(array $blocks): string $assertInvalidCanonicalEnvelope(array_merge($result, array('legacy_mapping' => array())), 'legacy_mapping', 'canonical validation rejects legacy mapping aliases'); $assertInvalidCanonicalEnvelope(array_merge($result, array('conversion_report' => $result['source_reports']['conversion_report'])), 'only under source_reports', 'canonical validation rejects top-level conversion report aliases'); $assertInvalidCanonicalEnvelope(array_merge($result, array('materialization_plan' => array())), 'only under source_reports', 'canonical validation rejects top-level materialization plan aliases'); +$runtimeCanvasResult = ( new HtmlTransformer() )->transform('
Fallback
', array('runtime_canvas_selectors' => array('#fixture-canvas')))->toArray(); +$assert('canvas' === ($runtimeCanvasResult['source_reports']['runtime_islands'][0]['kind'] ?? ''), 'HTML transform reports runtime-targeted canvas fallback as a runtime island'); +$assert('canvas_requires_runtime' === ($runtimeCanvasResult['source_reports']['runtime_islands'][0]['preservation_reason'] ?? ''), 'runtime island exposes canvas preservation reason'); +$assert(str_contains((string) ($runtimeCanvasResult['source_reports']['runtime_islands'][0]['source_snippet'] ?? ''), 'Fallback'), 'runtime island exposes bounded source snippet'); +$assert($runtimeCanvasResult['source_reports']['runtime_islands'] === ($runtimeCanvasResult['source_reports']['conversion_report']['runtime_islands'] ?? array()), 'conversion report projects runtime islands'); $invalidStatus = $result; $invalidStatus['status'] = 'ok'; @@ -274,6 +279,10 @@ function serialize_blocks(array $blocks): string $assert('core/html' === ($standaloneControlBlocks[2]['blockName'] ?? ''), 'runtime-targeted select preserves native DOM markup'); $assert(str_contains((string) ($standaloneControls['serialized_blocks'] ?? ''), 'Featured (selected)'), 'readable select summary preserves selected option state'); $assert(str_contains((string) ($standaloneControls['serialized_blocks'] ?? ''), '