diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index b26e0537..bffb9c4d 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -83,6 +83,10 @@ public function compile(array $artifact): TransformerResult $sourceReports['runtime_dependency_parity'] = ( new RuntimeDependencyParityReport() )->fromArtifact($normalized['files'], $html, $serializedBlocks, $entryPath, $entryBlocks['runtime_islands'], $referenceReports['asset_references'], $entryBlocks['interaction_candidates']); if ( array() !== $entryBlocks['runtime_islands'] ) { $sourceReports['runtime_islands'] = $entryBlocks['runtime_islands']; + $runtimeIslandPackage = ( new RuntimeIslandPackageBuilder() )->fromRuntimeIslands($entryBlocks['runtime_islands'], $normalized['files'], $entryPath); + if ( array() !== $runtimeIslandPackage ) { + $sourceReports['runtime_island_package'] = $runtimeIslandPackage; + } } $provenance = array( array( diff --git a/php-transformer/src/ArtifactCompiler/RuntimeIslandPackageBuilder.php b/php-transformer/src/ArtifactCompiler/RuntimeIslandPackageBuilder.php new file mode 100644 index 00000000..b613bde5 --- /dev/null +++ b/php-transformer/src/ArtifactCompiler/RuntimeIslandPackageBuilder.php @@ -0,0 +1,393 @@ + + */ + private const TELEMETRY_SIGNALS = array( + 'googletagmanager', + 'google-analytics', + 'gtag', + 'gtm.js', + 'analytics.js', + 'ga.js', + 'doubleclick', + 'segment.com', + 'segment.io', + 'mixpanel', + 'hotjar', + 'fullstory', + 'amplitude', + 'plausible', + 'matomo', + 'piwik', + 'fbevents', + 'facebook.net', + 'fbq(', + 'clarity.ms', + 'newrelic', + 'nr-data', + 'rum', + 'sentry', + 'datadog', + 'cdn.heapanalytics', + ); + + /** + * Build the runtime-island package from preserved runtime-island metadata. + * + * @param array> $runtimeIslands Preserved islands from the compiled result. + * @param array> $files Normalized artifact files (resolve external scripts). + * @param string $sourcePath Source document path (resolve relative script src). + * @return array Empty array when there are no runtime islands. + */ + public function fromRuntimeIslands(array $runtimeIslands, array $files = array(), string $sourcePath = ''): array + { + $islands = array(); + foreach ( $runtimeIslands as $runtimeIsland ) { + if ( ! is_array($runtimeIsland) ) { + continue; + } + $island = $this->buildIsland($runtimeIsland, $files, $sourcePath); + if ( array() !== $island ) { + $islands[] = $island; + } + } + + if ( array() === $islands ) { + return array(); + } + + return array( + 'schema' => self::SCHEMA, + 'islands' => $islands, + 'totals' => $this->totals($islands), + ); + } + + /** + * @param array $runtimeIsland One preserved runtime island. + * @param array> $files + * @return array + */ + private function buildIsland(array $runtimeIsland, array $files, string $sourcePath): array + { + $kind = is_scalar($runtimeIsland['kind'] ?? null) ? (string) $runtimeIsland['kind'] : ''; + $selector = is_scalar($runtimeIsland['selector'] ?? null) ? (string) $runtimeIsland['selector'] : ''; + $markup = is_scalar($runtimeIsland['source_snippet'] ?? null) ? (string) $runtimeIsland['source_snippet'] : ''; + if ( '' === $kind && '' === $selector && '' === $markup ) { + return array(); + } + + $scripts = $this->scriptsForIsland($kind, $runtimeIsland, $files, $sourcePath); + $disposition = $this->disposition($kind, $scripts); + + $island = array( + 'id' => $this->islandId($kind, $selector, $markup), + 'kind' => $kind, + 'selector' => $selector, + 'tag' => is_scalar($runtimeIsland['tag'] ?? null) ? (string) $runtimeIsland['tag'] : '', + 'markup' => $markup, + 'markup_truncated' => (bool) ($runtimeIsland['source_truncated'] ?? false), + 'markup_fidelity' => 'verbatim', + 'preservation_reason' => is_scalar($runtimeIsland['preservation_reason'] ?? null) ? (string) $runtimeIsland['preservation_reason'] : '', + 'runtime_requirement' => is_scalar($runtimeIsland['runtime_requirement'] ?? null) ? (string) $runtimeIsland['runtime_requirement'] : '', + 'disposition' => $disposition, + 'js_handling' => 'drop' === $disposition ? 'drop' : 'preserve_verbatim', + 'handle_hint' => $this->handleHint($kind, $selector, $markup), + 'attributes' => is_array($runtimeIsland['attributes'] ?? null) ? $runtimeIsland['attributes'] : array(), + 'scripts' => $scripts, + ); + + return array_filter($island, static fn (mixed $value): bool => '' !== $value && array() !== $value || is_bool($value)); + } + + /** + * Resolve the script assets associated with an island. + * + * For a `script` island the island element IS the script — one entry built + * from its attributes (external src) and verbatim inline body. For other + * island kinds (canvas, form, template, control, ...) the associated scripts + * are the transformer-recorded `required_scripts` references. + * + * @param array $runtimeIsland + * @param array> $files + * @return array> + */ + private function scriptsForIsland(string $kind, array $runtimeIsland, array $files, string $sourcePath): array + { + $scripts = array(); + + if ( 'script' === $kind ) { + $attributes = is_array($runtimeIsland['attributes'] ?? null) ? $runtimeIsland['attributes'] : array(); + $sourceKind = is_scalar($runtimeIsland['script_source_kind'] ?? null) ? (string) $runtimeIsland['script_source_kind'] : ''; + if ( '' === $sourceKind ) { + $sourceKind = '' !== trim((string) ($attributes['src'] ?? '')) ? 'external' : 'inline'; + } + $scriptRole = is_scalar($runtimeIsland['script_role'] ?? null) ? (string) $runtimeIsland['script_role'] : 'runtime'; + $inline = ''; + if ( 'inline' === $sourceKind ) { + // The island carries the verbatim inline body directly (see + // FallbackEmitter::captureScriptFallback); fall back to parsing + // the bounded snippet only if an older island shape omits it. + $inline = is_scalar($runtimeIsland['script_body'] ?? null) ? trim((string) $runtimeIsland['script_body']) : ''; + if ( '' === $inline ) { + $inline = $this->inlineScriptBody((string) ($runtimeIsland['source_snippet'] ?? '')); + } + } + + $scripts[] = $this->buildScript($sourceKind, $attributes, $scriptRole, $inline, $files, $sourcePath); + + return $this->dedupeRows($scripts); + } + + $required = is_array($runtimeIsland['required_scripts'] ?? null) ? $runtimeIsland['required_scripts'] : array(); + foreach ( $required as $requiredScript ) { + if ( ! is_array($requiredScript) ) { + continue; + } + $attributes = is_array($requiredScript['attributes'] ?? null) ? $requiredScript['attributes'] : array(); + $sourceKind = is_scalar($requiredScript['script_source_kind'] ?? null) ? (string) $requiredScript['script_source_kind'] : ''; + if ( '' === $sourceKind ) { + $sourceKind = '' !== trim((string) ($attributes['src'] ?? '')) ? 'external' : 'inline'; + } + $scriptRole = is_scalar($requiredScript['script_role'] ?? null) ? (string) $requiredScript['script_role'] : 'runtime'; + $scripts[] = $this->buildScript($sourceKind, $attributes, $scriptRole, '', $files, $sourcePath); + } + + return $this->dedupeRows($scripts); + } + + /** + * @param array $attributes + * @param array> $files + * @return array + */ + private function buildScript(string $sourceKind, array $attributes, string $scriptRole, string $inline, array $files, string $sourcePath): array + { + $src = trim((string) ($attributes['src'] ?? '')); + $script = array( + 'source_kind' => '' !== $sourceKind ? $sourceKind : ('' !== $src ? 'external' : 'inline'), + 'script_role' => '' !== $scriptRole ? $scriptRole : 'runtime', + 'attributes' => $attributes, + ); + + if ( 'external' === $script['source_kind'] && '' !== $src ) { + $script['src'] = $src; + $resolved = ArtifactPath::resolveRelativePath($src, $sourcePath); + $content = $this->externalScriptContent($src, $resolved, $files); + if ( '' !== $resolved ) { + $script['resolved_path'] = $resolved; + } + $script['materialized'] = null !== $content; + if ( null !== $content ) { + $script['content'] = $content; + } + } elseif ( '' !== $inline ) { + $script['content'] = $inline; + } + + $role = $this->scriptRole($scriptRole, $src, (string) ($script['content'] ?? '')); + $script['role'] = $role; + if ( 'telemetry' === $role ) { + $script['droppable'] = true; + } + + return array_filter($script, static fn (mixed $value): bool => '' !== $value && array() !== $value || is_bool($value)); + } + + /** + * Locate the verbatim content of an external script that was materialized as + * an artifact file. Returns null when the script is third-party / not carried + * (so the consumer knows to reference the original src instead of inlining). + * + * @param array> $files + */ + private function externalScriptContent(string $src, string $resolved, array $files): ?string + { + $candidates = array_filter(array($resolved, ltrim($src, '/')), static fn (string $value): bool => '' !== $value); + foreach ( $files as $file ) { + if ( ! is_array($file) || ! empty($file['binary']) ) { + continue; + } + $path = is_scalar($file['path'] ?? null) ? (string) $file['path'] : ''; + if ( '' === $path || ! in_array($path, $candidates, true) ) { + continue; + } + if ( is_scalar($file['content'] ?? null) ) { + return (string) $file['content']; + } + } + + return null; + } + + /** + * Extract the verbatim inline JS body from a bounded `" + } + }, + "expect_island": { + "kind": "script", + "select_by_role": "first_party", + "disposition": "preserve", + "js_handling": "preserve_verbatim", + "markup_fidelity": "verbatim", + "script_source_kind": "inline", + "script_role": "first_party", + "content_contains": "window.toggleApp()", + "droppable": false + } + }, + { + "name": "telemetry-external-script-dropped", + "artifact": { + "entrypoint": "index.html", + "files": { + "index.html": "

Home

" + } + }, + "expect_island": { + "kind": "script", + "select_by_role": "telemetry", + "disposition": "drop", + "js_handling": "drop", + "markup_fidelity": "verbatim", + "script_source_kind": "external", + "script_role": "telemetry", + "materialized": false, + "droppable": true + } + }, + { + "name": "canvas-island-external-and-inline-scripts", + "artifact": { + "entrypoint": "index.html", + "files": { + "index.html": "
fallback
", + "js/chart.js": "const c = document.getElementById(\"chart\").getContext(\"2d\"); c.strokeRect(0, 0, 5, 5);" + } + }, + "expect_island": { + "kind": "canvas", + "select_by_kind": "canvas", + "disposition": "preserve", + "js_handling": "preserve_verbatim", + "markup_fidelity": "verbatim", + "markup_contains": "