diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index fafd6c2..60d80b4 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -74,6 +74,7 @@ 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); $provenance = array( array( 'source_format' => 'artifact', diff --git a/php-transformer/src/ArtifactCompiler/RuntimeDependencyParityReport.php b/php-transformer/src/ArtifactCompiler/RuntimeDependencyParityReport.php new file mode 100644 index 0000000..cbfa521 --- /dev/null +++ b/php-transformer/src/ArtifactCompiler/RuntimeDependencyParityReport.php @@ -0,0 +1,297 @@ +> $files + * @return array + */ + public function fromArtifact(array $files, string $sourceHtml, string $generatedHtml): array + { + $sourceTargets = $this->sourceTargets($sourceHtml); + $generatedTargets = $this->htmlTargets($generatedHtml); + $dependencies = array(); + $findings = array(); + + foreach ( $files as $file ) { + if ( ! $this->isScriptFile($file) ) { + continue; + } + + $scriptPath = (string) ($file['path'] ?? ''); + $script = (string) ($file['content'] ?? ''); + if ( '' === trim($script) ) { + continue; + } + + $scriptKind = $this->scriptKind($scriptPath, $script); + foreach ( $this->scriptDependencies($script) as $dependency ) { + $selector = (string) $dependency['selector']; + $target = $sourceTargets[$selector] ?? array(); + $exists = $this->targetExists($dependency, $generatedTargets); + $canvasApi = true === $dependency['canvas_api'] && 'canvas' === ($target['tag'] ?? ''); + $dependencyRow = array_filter(array( + 'script_path' => $scriptPath, + 'script_kind' => $scriptKind, + 'selector' => $selector, + 'target_kind' => $target['tag'] ?? '', + 'dependency_kind' => $dependency['kind'], + 'events' => $dependency['events'], + 'canvas_api' => $canvasApi, + 'source_present' => array() !== $target, + 'generated_present' => $exists, + ), static fn (mixed $value): bool => null !== $value && '' !== $value && array() !== $value); + $dependencies[] = $dependencyRow; + + if ( $exists ) { + continue; + } + + $severity = 'telemetry' === $scriptKind ? 'info' : 'warning'; + $findings[] = array_filter(array( + 'code' => 'runtime_dependency_target_missing', + 'severity' => $severity, + 'script_path' => $scriptPath, + 'script_kind' => $scriptKind, + 'selector' => $selector, + 'target_kind' => $target['tag'] ?? '', + 'dependency_kind' => $dependency['kind'], + 'events' => $dependency['events'], + 'canvas_api' => $canvasApi, + 'message' => sprintf('Script %s references %s, but the generated block markup does not expose that DOM target.', $scriptPath, $selector), + ), static fn (mixed $value): bool => null !== $value && '' !== $value && array() !== $value); + } + } + + return array_filter(array( + 'schema' => self::SCHEMA, + 'status' => array() === $findings ? 'pass' : 'warning', + 'dependencies' => $this->dedupeRows($dependencies), + 'findings' => $this->dedupeRows($findings), + ), static fn (mixed $value): bool => array() !== $value); + } + + /** + * @param array $file + */ + private function isScriptFile(array $file): bool + { + return in_array($file['kind'] ?? '', array('js', 'mjs'), true) + || 'script' === ($file['role'] ?? '') + || in_array($file['mime_type'] ?? '', array('application/javascript', 'text/javascript', 'application/ecmascript', 'text/ecmascript'), true); + } + + /** + * @return array + */ + private function sourceTargets(string $html): array + { + $targets = array(); + $document = new DOMDocument(); + $previous = libxml_use_internal_errors(true); + $loaded = $document->loadHTML('' . $html . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + if ( ! $loaded ) { + return array(); + } + + foreach ( $document->getElementsByTagName('*') as $element ) { + if ( ! $element instanceof DOMElement ) { + continue; + } + $tag = strtolower($element->tagName); + $id = trim($element->hasAttribute('id') ? $element->getAttribute('id') : ''); + if ( '' !== $id ) { + $targets['#' . $id] = array('tag' => $tag); + } + foreach ( preg_split('/\s+/', trim($element->hasAttribute('class') ? $element->getAttribute('class') : '')) ?: array() as $class ) { + if ( '' !== $class ) { + $targets['.' . $class] = array('tag' => $tag); + } + } + } + + return $targets; + } + + /** + * @return array{ids: array, classes: array} + */ + private function htmlTargets(string $html): array + { + $targets = array('ids' => array(), 'classes' => array()); + if ( preg_match_all('/\sid\s*=\s*(["\'])(.*?)\1/is', $html, $matches) ) { + foreach ( $matches[2] as $id ) { + $id = trim(html_entity_decode((string) $id, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + if ( '' !== $id ) { + $targets['ids'][$id] = true; + } + } + } + if ( preg_match_all('/\sclass\s*=\s*(["\'])(.*?)\1/is', $html, $matches) ) { + foreach ( $matches[2] as $classList ) { + foreach ( preg_split('/\s+/', trim(html_entity_decode((string) $classList, ENT_QUOTES | ENT_HTML5, 'UTF-8'))) ?: array() as $class ) { + if ( '' !== $class ) { + $targets['classes'][$class] = true; + } + } + } + } + + return $targets; + } + + /** + * @return array, canvas_api: bool}> + */ + private function scriptDependencies(string $script): array + { + $dependencies = array(); + $eventsBySelector = $this->eventsBySelector($script); + $canvasApi = preg_match('/\.\s*getContext\s*\(\s*(["\'])2d\1\s*\)/', $script) === 1; + + if ( preg_match_all('/document\s*\.\s*getElementById\s*\(\s*(["\'])([A-Za-z][A-Za-z0-9_-]*)\1\s*\)/', $script, $matches) ) { + foreach ( $matches[2] as $id ) { + $selector = '#' . (string) $id; + $dependencies[] = array( + 'kind' => 'id', + 'selector' => $selector, + 'events' => $eventsBySelector[$selector] ?? array(), + 'canvas_api' => $canvasApi, + ); + } + } + + if ( preg_match_all('/document\s*\.\s*querySelector(?:All)?\s*\(\s*(["\'])([#.][A-Za-z][A-Za-z0-9_-]*)\1\s*\)/', $script, $matches) ) { + foreach ( $matches[2] as $selector ) { + $selector = (string) $selector; + $dependencies[] = array( + 'kind' => str_starts_with($selector, '#') ? 'id' : 'class', + 'selector' => $selector, + 'events' => $eventsBySelector[$selector] ?? array(), + 'canvas_api' => $canvasApi, + ); + } + } + + return $this->dedupeDependencies($dependencies); + } + + /** + * @return array> + */ + private function eventsBySelector(string $script): array + { + $events = array(); + if ( preg_match_all('/document\s*\.\s*getElementById\s*\(\s*(["\'])([A-Za-z][A-Za-z0-9_-]*)\1\s*\)\s*\.\s*addEventListener\s*\(\s*(["\'])([A-Za-z][A-Za-z0-9_-]*)\3/', $script, $matches) ) { + foreach ( $matches[2] as $index => $id ) { + $events['#' . (string) $id][] = (string) $matches[4][$index]; + } + } + if ( preg_match_all('/document\s*\.\s*querySelector(?:All)?\s*\(\s*(["\'])([#.][A-Za-z][A-Za-z0-9_-]*)\1\s*\)\s*\.\s*addEventListener\s*\(\s*(["\'])([A-Za-z][A-Za-z0-9_-]*)\3/', $script, $matches) ) { + foreach ( $matches[2] as $index => $selector ) { + $events[(string) $selector][] = (string) $matches[4][$index]; + } + } + if ( preg_match_all('/(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*document\s*\.\s*getElementById\s*\(\s*(["\'])([A-Za-z][A-Za-z0-9_-]*)\2\s*\)/', $script, $assignments, PREG_SET_ORDER) ) { + foreach ( $assignments as $assignment ) { + if ( preg_match_all('/\b' . preg_quote((string) $assignment[1], '/') . '\s*\.\s*addEventListener\s*\(\s*(["\'])([A-Za-z][A-Za-z0-9_-]*)\1/', $script, $matches) ) { + foreach ( $matches[2] as $event ) { + $events['#' . (string) $assignment[3]][] = (string) $event; + } + } + } + } + if ( preg_match_all('/(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*document\s*\.\s*querySelector(?:All)?\s*\(\s*(["\'])([#.][A-Za-z][A-Za-z0-9_-]*)\2\s*\)/', $script, $assignments, PREG_SET_ORDER) ) { + foreach ( $assignments as $assignment ) { + if ( preg_match_all('/\b' . preg_quote((string) $assignment[1], '/') . '\s*\.\s*addEventListener\s*\(\s*(["\'])([A-Za-z][A-Za-z0-9_-]*)\1/', $script, $matches) ) { + foreach ( $matches[2] as $event ) { + $events[(string) $assignment[3]][] = (string) $event; + } + } + } + } + + foreach ( $events as $selector => $selectorEvents ) { + $events[$selector] = array_values(array_unique($selectorEvents)); + } + + return $events; + } + + /** + * @param array{kind: string, selector: string, events: array, canvas_api: bool} $dependency + * @param array{ids: array, classes: array} $targets + */ + private function targetExists(array $dependency, array $targets): bool + { + $selector = (string) $dependency['selector']; + if ( str_starts_with($selector, '#') ) { + return isset($targets['ids'][substr($selector, 1)]); + } + if ( str_starts_with($selector, '.') ) { + return isset($targets['classes'][substr($selector, 1)]); + } + + return false; + } + + private function scriptKind(string $path, string $script): string + { + $haystack = strtolower($path . "\n" . substr($script, 0, 2000)); + if ( str_contains($haystack, 'netlify') || str_contains($haystack, 'rum') || str_contains($haystack, 'analytics') || str_contains($haystack, 'gtag') ) { + return 'telemetry'; + } + + return 'first_party'; + } + + /** + * @param array, canvas_api: bool}> $dependencies + * @return array, canvas_api: bool}> + */ + private function dedupeDependencies(array $dependencies): array + { + $deduped = array(); + foreach ( $dependencies as $dependency ) { + $selector = $dependency['selector']; + if ( isset($deduped[$selector]) ) { + $deduped[$selector]['events'] = array_values(array_unique(array_merge($deduped[$selector]['events'], $dependency['events']))); + $deduped[$selector]['canvas_api'] = $deduped[$selector]['canvas_api'] || $dependency['canvas_api']; + continue; + } + $deduped[$selector] = $dependency; + } + + return array_values($deduped); + } + + /** + * @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; + } +} diff --git a/php-transformer/src/Contract/ConversionReportProjection.php b/php-transformer/src/Contract/ConversionReportProjection.php index d05eab9..91bb309 100644 --- a/php-transformer/src/Contract/ConversionReportProjection.php +++ b/php-transformer/src/Contract/ConversionReportProjection.php @@ -29,6 +29,7 @@ public static function fromResultParts(string $sourceFormat, array $blocks, arra 'asset_refs' => self::assetReferences($blocks, $sourceReports), 'navigation_candidates' => self::navigationCandidates($blocks, $sourceReports), 'semantic_parity' => self::semanticParity($sourceReports), + 'runtime_dependency_parity' => self::runtimeDependencyParity($sourceReports), 'interaction_candidates' => self::interactionCandidates($sourceReports), 'presentation_gaps' => self::presentationGaps($sourceReports), 'metrics' => $metrics, @@ -283,6 +284,16 @@ private static function semanticParity(array $sourceReports): array return is_array($report) ? $report : array(); } + /** + * @param array $sourceReports + * @return array + */ + private static function runtimeDependencyParity(array $sourceReports): array + { + $report = $sourceReports['runtime_dependency_parity'] ?? array(); + return is_array($report) ? $report : array(); + } + /** * @param array> $blocks */ diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php index f07a954..fdedb10 100644 --- a/php-transformer/src/HtmlToBlocks/BlockFactory.php +++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php @@ -347,6 +347,7 @@ private function blockSupportAttrs(array $attrs, string $baseClass = ''): string { $classes = $this->mergeClassNames($baseClass, (string) ($attrs['className'] ?? '')); return $this->htmlAttrs(array( + 'id' => (string) ($attrs['anchor'] ?? ''), 'class' => $classes, 'style' => (string) ($attrs['style'] ?? ''), )); diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 28db573..98340c7 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -1451,12 +1451,23 @@ private function presentationAttributes(DOMElement $element): array $style = $this->mergedPresentationStyle($element); return array_filter(array( + 'anchor' => $this->safeAnchor($this->attr($element, 'id')), 'className' => $this->promotedClassName($this->attr($element, 'class')), 'style' => $style, 'layout' => $this->layoutAttribute($element, $style), ), static fn ($value): bool => is_array($value) ? array() !== $value : '' !== trim((string) $value)); } + private function safeAnchor(string $id): string + { + $id = trim($id); + if ( '' === $id || ! preg_match('/^[A-Za-z][A-Za-z0-9_-]*$/', $id) ) { + return ''; + } + + return $id; + } + private function mergedPresentationStyle(DOMElement $element): string { $inlineStyle = $this->attr($element, 'style'); diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index d440929..f278a7c 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -568,6 +568,46 @@ function serialize_blocks(array $blocks): string $assert(($staticPlan['totals']['navigation_links'] ?? null) === ($staticSummary['navigation_link_count'] ?? null), 'conversion report navigation link count matches materialization plan totals'); $assert(($staticPlan['totals']['menus'] ?? null) === ($staticSummary['menu_count'] ?? null), 'conversion report menu count matches materialization plan totals'); +$runtimeDependencySite = $compiler->compile( + array( + 'entrypoint' => 'index.html', + 'files' => array( + 'index.html' => '

Status

Ready

', + 'js/script.js' => 'const canvas = document.getElementById("canvas"); canvas.getContext("2d"); const status = document.querySelector("#status-container"); status.addEventListener("click", function () {});', + 'js/rum.js' => 'document.querySelector("#netlify-rum-target");', + ), + ) +)->toArray(); +$runtimeDependencyReport = $runtimeDependencySite['source_reports']['runtime_dependency_parity'] ?? array(); +$runtimeDependencyConversionReport = $runtimeDependencySite['source_reports']['conversion_report']['runtime_dependency_parity'] ?? array(); +$runtimeFindings = $runtimeDependencyReport['findings'] ?? array(); +$canvasFinding = null; +$rumFinding = null; +foreach ( $runtimeFindings as $finding ) { + if ( '#canvas' === ($finding['selector'] ?? '') ) { + $canvasFinding = $finding; + } + if ( '#netlify-rum-target' === ($finding['selector'] ?? '') ) { + $rumFinding = $finding; + } +} +$statusDependency = null; +foreach ( $runtimeDependencyReport['dependencies'] ?? array() as $dependency ) { + if ( '#status-container' === ($dependency['selector'] ?? '') ) { + $statusDependency = $dependency; + } +} +$assert('blocks-engine/php-transformer/runtime-dependency-parity/v1' === ($runtimeDependencyReport['schema'] ?? ''), 'runtime dependency parity report exposes schema'); +$assert($runtimeDependencyReport === $runtimeDependencyConversionReport, 'conversion report projects runtime dependency parity'); +$assert('runtime_dependency_target_missing' === ($canvasFinding['code'] ?? ''), 'runtime dependency parity reports missing canvas DOM target'); +$assert('canvas' === ($canvasFinding['target_kind'] ?? ''), 'runtime dependency parity identifies canvas source target kind'); +$assert(true === ($canvasFinding['canvas_api'] ?? null), 'runtime dependency parity flags canvas 2d API usage'); +$assert('warning' === ($canvasFinding['severity'] ?? ''), 'first-party missing runtime dependency target is warning severity'); +$assert(null !== $statusDependency, 'runtime dependency parity records preserved status container dependency'); +$assert(true === ($statusDependency['generated_present'] ?? null), 'runtime dependency parity passes preserved div id target'); +$assert(! empty($statusDependency['events'] ?? array()), 'runtime dependency parity records simple addEventListener usage'); +$assert('info' === ($rumFinding['severity'] ?? ''), 'telemetry-like runtime dependency misses are info severity'); + $legacyFrontPageSite = $compiler->compile( array( 'entrypoint' => 'index.html',