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
1 change: 1 addition & 0 deletions php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
297 changes: 297 additions & 0 deletions php-transformer/src/ArtifactCompiler/RuntimeDependencyParityReport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
<?php
declare(strict_types=1);

namespace Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler;

use DOMDocument;
use DOMElement;

final class RuntimeDependencyParityReport
{
public const SCHEMA = 'blocks-engine/php-transformer/runtime-dependency-parity/v1';

/**
* @param array<int, array<string, mixed>> $files
* @return array<string, mixed>
*/
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<string, mixed> $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<string, array{tag: string}>
*/
private function sourceTargets(string $html): array
{
$targets = array();
$document = new DOMDocument();
$previous = libxml_use_internal_errors(true);
$loaded = $document->loadHTML('<?xml encoding="utf-8" ?><body>' . $html . '</body>', 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<string, bool>, classes: array<string, bool>}
*/
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<int, array{kind: string, selector: string, events: array<int, string>, 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<string, array<int, string>>
*/
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<int, string>, canvas_api: bool} $dependency
* @param array{ids: array<string, bool>, classes: array<string, bool>} $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<int, array{kind: string, selector: string, events: array<int, string>, canvas_api: bool}> $dependencies
* @return array<int, array{kind: string, selector: string, events: array<int, string>, 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<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
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;
}
}
11 changes: 11 additions & 0 deletions php-transformer/src/Contract/ConversionReportProjection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -283,6 +284,16 @@ private static function semanticParity(array $sourceReports): array
return is_array($report) ? $report : array();
}

/**
* @param array<string, mixed> $sourceReports
* @return array<string, mixed>
*/
private static function runtimeDependencyParity(array $sourceReports): array
{
$report = $sourceReports['runtime_dependency_parity'] ?? array();
return is_array($report) ? $report : array();
}

/**
* @param array<int, array<string, mixed>> $blocks
*/
Expand Down
1 change: 1 addition & 0 deletions php-transformer/src/HtmlToBlocks/BlockFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? ''),
));
Expand Down
11 changes: 11 additions & 0 deletions php-transformer/src/HtmlToBlocks/HtmlTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading