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
75 changes: 75 additions & 0 deletions php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,13 @@ private function linkedStylesheetCss(string $html, string $sourcePath, array $fi
private function runtimeDomSelectors(string $html, string $sourcePath, array $files): array
{
$selectors = array();
$controlSelectors = $this->formControlSelectors($html);
foreach ( $this->documentScriptContents($html, $sourcePath, $files) as $script ) {
$runtimeControlSelectors = $this->scriptControlRuntimeSelectors($script);
foreach ( $this->scriptDomSelectors($script) as $selector ) {
if ( isset($controlSelectors[$selector]) && ! isset($runtimeControlSelectors[$selector]) ) {
continue;
}
$selectors[$selector] = true;
}
}
Expand Down Expand Up @@ -385,6 +390,40 @@ private function canvasSelectors(string $html): array
return $selectors;
}

/**
* @return array<string, bool>
*/
private function formControlSelectors(string $html): array
{
$selectors = 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 || ! in_array(strtolower($element->tagName), array('button', 'input', 'select', 'textarea'), true) ) {
continue;
}

$id = trim($element->hasAttribute('id') ? $element->getAttribute('id') : '');
if ( '' !== $id ) {
$selectors['#' . $id] = true;
}
foreach ( preg_split('/\s+/', trim($element->hasAttribute('class') ? $element->getAttribute('class') : '')) ?: array() as $class ) {
if ( '' !== $class ) {
$selectors['.' . $class] = true;
}
}
}

return $selectors;
}

/**
* @param array<int, array<string, mixed>> $files
* @return array<int, string>
Expand Down Expand Up @@ -442,6 +481,42 @@ private function scriptDomSelectors(string $script): array
return array_keys($selectors);
}

/**
* @return array<string, bool>
*/
private function scriptControlRuntimeSelectors(string $script): array
{
$selectors = array();
$runtimeUsePattern = '\.\s*(?:addEventListener|value|checked|selectedIndex|selectedOptions|options|files|validity|setCustomValidity|focus|select|click|dispatchEvent)\b';

if ( preg_match_all('/document\s*\.\s*getElementById\s*\(\s*(["\'])([A-Za-z][A-Za-z0-9_-]*)\1\s*\)\s*(?:\.\s*[^;\n]*)?' . $runtimeUsePattern . '/', $script, $matches) ) {
foreach ( $matches[2] as $id ) {
$selectors['#' . (string) $id] = true;
}
}
if ( preg_match_all('/document\s*\.\s*querySelector(?:All)?\s*\(\s*(["\'])([#.][A-Za-z][A-Za-z0-9_-]*)\1\s*\)\s*(?:\.\s*[^;\n]*)?' . $runtimeUsePattern . '/', $script, $matches) ) {
foreach ( $matches[2] as $selector ) {
$selectors[(string) $selector] = true;
}
}
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('/\b' . preg_quote((string) $assignment[1], '/') . '\s*' . $runtimeUsePattern . '/', $script) ) {
$selectors['#' . (string) $assignment[3]] = true;
}
}
}
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('/\b' . preg_quote((string) $assignment[1], '/') . '\s*' . $runtimeUsePattern . '/', $script) ) {
$selectors[(string) $assignment[3]] = true;
}
}
}

return $selectors;
}

private function htmlAttribute(string $tag, string $name): string
{
if ( preg_match('/\s' . preg_quote($name, '/') . '\s*=\s*(["\'])(.*?)\1/is', $tag, $match) ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ public function fromArtifact(array $files, string $sourceHtml, string $generated
continue;
}

if ( $this->isFormControlTarget($target) && true !== ( $dependency['control_runtime'] ?? false ) ) {
continue;
}

$severity = 'telemetry' === $scriptKind ? 'info' : 'warning';
$repairBucket = $canvasApi ? 'runtime_canvas_target_preservation' : 'runtime_dom_target_preservation';
$findings[] = array_filter(array(
Expand Down Expand Up @@ -169,6 +173,7 @@ private function scriptDependencies(string $script): array
$dependencies = array();
$eventsBySelector = $this->eventsBySelector($script);
$canvasSelectors = $this->scriptCanvasSelectors($script);
$controlRuntimeSelectors = $this->scriptControlRuntimeSelectors($script);

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 ) {
Expand All @@ -178,6 +183,7 @@ private function scriptDependencies(string $script): array
'selector' => $selector,
'events' => $eventsBySelector[$selector] ?? array(),
'canvas_api' => isset($canvasSelectors[$selector]),
'control_runtime' => isset($controlRuntimeSelectors[$selector]),
);
}
}
Expand All @@ -190,6 +196,7 @@ private function scriptDependencies(string $script): array
'selector' => $selector,
'events' => $eventsBySelector[$selector] ?? array(),
'canvas_api' => isset($canvasSelectors[$selector]),
'control_runtime' => isset($controlRuntimeSelectors[$selector]),
);
}
}
Expand All @@ -202,6 +209,7 @@ private function scriptDependencies(string $script): array
'selector' => $selector,
'events' => $eventsBySelector[$selector] ?? array(),
'canvas_api' => isset($canvasSelectors[$selector]),
'control_runtime' => isset($controlRuntimeSelectors[$selector]),
);
}
}
Expand All @@ -214,13 +222,50 @@ private function scriptDependencies(string $script): array
'selector' => $selector,
'events' => $eventsBySelector[$selector] ?? array(),
'canvas_api' => isset($canvasSelectors[$selector]),
'control_runtime' => isset($controlRuntimeSelectors[$selector]),
);
}
}

return $this->dedupeDependencies($dependencies);
}

/**
* @return array<string, bool>
*/
private function scriptControlRuntimeSelectors(string $script): array
{
$selectors = array();
$runtimeUsePattern = '\.\s*(?:addEventListener|value|checked|selectedIndex|selectedOptions|options|files|validity|setCustomValidity|focus|select|click|dispatchEvent)\b';

if ( preg_match_all('/document\s*\.\s*getElementById\s*\(\s*(["\'])([A-Za-z][A-Za-z0-9_-]*)\1\s*\)\s*(?:\.\s*[^;\n]*)?' . $runtimeUsePattern . '/', $script, $matches) ) {
foreach ( $matches[2] as $id ) {
$selectors['#' . (string) $id] = true;
}
}
if ( preg_match_all('/document\s*\.\s*querySelector(?:All)?\s*\(\s*(["\'])([#.][A-Za-z][A-Za-z0-9_-]*)\1\s*\)\s*(?:\.\s*[^;\n]*)?' . $runtimeUsePattern . '/', $script, $matches) ) {
foreach ( $matches[2] as $selector ) {
$selectors[(string) $selector] = true;
}
}
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('/\b' . preg_quote((string) $assignment[1], '/') . '\s*' . $runtimeUsePattern . '/', $script) ) {
$selectors['#' . (string) $assignment[3]] = true;
}
}
}
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('/\b' . preg_quote((string) $assignment[1], '/') . '\s*' . $runtimeUsePattern . '/', $script) ) {
$selectors[(string) $assignment[3]] = true;
}
}
}

return $selectors;
}

/**
* @return array<string, bool>
*/
Expand Down Expand Up @@ -260,6 +305,14 @@ private function scriptCanvasSelectors(string $script): array
return $selectors;
}

/**
* @param array<string, mixed> $target
*/
private function isFormControlTarget(array $target): bool
{
return in_array((string) ($target['tag'] ?? ''), array('button', 'input', 'select', 'textarea'), true);
}

/**
* @return array<string, array<int, string>>
*/
Expand Down Expand Up @@ -330,8 +383,8 @@ private function scriptKind(string $path, string $script): string
}

/**
* @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}>
* @param array<int, array{kind: string, selector: string, events: array<int, string>, canvas_api: bool, control_runtime?: bool}> $dependencies
* @return array<int, array{kind: string, selector: string, events: array<int, string>, canvas_api: bool, control_runtime?: bool}>
*/
private function dedupeDependencies(array $dependencies): array
{
Expand All @@ -341,6 +394,7 @@ private function dedupeDependencies(array $dependencies): array
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'];
$deduped[$selector]['control_runtime'] = ( $deduped[$selector]['control_runtime'] ?? false ) || ( $dependency['control_runtime'] ?? false );
continue;
}
$deduped[$selector] = $dependency;
Expand Down
23 changes: 22 additions & 1 deletion php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,27 @@ public function match(DOMElement $element, PatternContext $context): ?array
$assert('.js-sort-select' === ($standaloneControls['source_reports']['runtime_islands'][0]['selector'] ?? ''), 'runtime-targeted standalone control reports selector metadata');
$assert('select' === ($standaloneControls['source_reports']['runtime_islands'][0]['control']['tag'] ?? ''), 'runtime-targeted standalone control reports control metadata');

$artifactControlSelectors = ( new ArtifactCompiler() )->compile(
array(
'entrypoint' => 'index.html',
'files' => array(
'index.html' => '<main><input id="newsletter-email" class="email-field" type="email" placeholder="you@example.com"><select id="sort-select" class="sort-select"><option selected>Featured</option><option>Newest</option></select><input id="live-filter" class="live-filter" type="text" placeholder="Filter"><script src="js/app.js"></script></main>',
'js/app.js' => 'document.getElementById("newsletter-email"); document.querySelector(".sort-select"); const liveFilter = document.getElementById("live-filter"); liveFilter.addEventListener("input", function () { window.__changed = true; });',
),
)
)->toArray();
$artifactControlMarkup = (string) ($artifactControlSelectors['serialized_blocks'] ?? '');
$assert(! str_contains($artifactControlMarkup, '<input id="newsletter-email"'), 'artifact compiler converts generically queried static input to readable block output');
$assert(! str_contains($artifactControlMarkup, '<select id="sort-select"'), 'artifact compiler converts generically queried static select to readable block output');
$assert(str_contains($artifactControlMarkup, 'you@example.com'), 'artifact static input readable output preserves placeholder text');
$assert(str_contains($artifactControlMarkup, 'Featured (selected)'), 'artifact static select readable output preserves selected option state');
$assert(str_contains($artifactControlMarkup, '<input id="live-filter"'), 'artifact compiler preserves behavior-bearing control native DOM');
$artifactControlIslands = $artifactControlSelectors['source_reports']['runtime_islands'] ?? array();
$assert(1 === count($artifactControlIslands), 'artifact compiler reports only behavior-bearing controls as runtime islands');
$assert('#live-filter' === ($artifactControlIslands[0]['selector'] ?? ''), 'artifact runtime control island points at behavior-bearing control selector');
$artifactControlRuntimeReport = $artifactControlSelectors['source_reports']['runtime_dependency_parity'] ?? array();
$assert('pass' === ($artifactControlRuntimeReport['status'] ?? ''), 'runtime parity does not flag readable static controls as missing runtime targets');

$buttonResult = ( new HtmlTransformer() )->transform(
'<main><a class="primary-button" href="#"><h3>Reserve now</h3><span aria-hidden="true"></span></a><button><strong>Call us</strong></button></main>'
)->toArray();
Expand Down Expand Up @@ -929,7 +950,7 @@ public function match(DOMElement $element, PatternContext $context): ?array
$runtimeTargetDependencies[$dependency['selector'] ?? ''] = $dependency;
}
$assert('pass' === ($runtimeTargetContainerReport['status'] ?? ''), 'runtime dependency parity passes generic preserved JS target containers');
foreach ( array( '.reveal', '.nav-toggle', '.menu-shell', '.primary-nav', '.mobile-nav-overlay', '.mobile-nav', '.faq-item', '.filter-btn', '.button-shell', '.filter-bar', '.filter-chips', '#note-search', '.search-input', '.js-sort-select', '.js-filter-check', '#contact-form', '#form-success' ) as $selector ) {
foreach ( array( '.reveal', '.nav-toggle', '.menu-shell', '.primary-nav', '.mobile-nav-overlay', '.mobile-nav', '.faq-item', '.filter-btn', '.button-shell', '.filter-bar', '.filter-chips', '#contact-form', '#form-success' ) as $selector ) {
$assert(true === ($runtimeTargetDependencies[$selector]['generated_present'] ?? null), 'runtime dependency parity records preserved target ' . $selector);
}
$assert(str_contains((string) ($runtimeTargetContainerSite['serialized_blocks'] ?? ''), 'nav-toggle'), 'artifact block markup preserves runtime-targeted menu toggle class');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,12 @@
{ "path": "serialized_blocks", "assert": "contains", "value": "filter-bar" },
{ "path": "serialized_blocks", "assert": "contains", "value": "filter-chips" },
{ "path": "serialized_blocks", "assert": "contains", "value": "card" },
{ "path": "serialized_blocks", "assert": "contains", "value": "note-search" },
{ "path": "serialized_blocks", "assert": "contains", "value": "search-input" },
{ "path": "serialized_blocks", "assert": "contains", "value": "js-sort-select" },
{ "path": "serialized_blocks", "assert": "contains", "value": "js-filter-check" },
{ "path": "serialized_blocks", "assert": "contains", "value": "Search notes" },
{ "path": "serialized_blocks", "assert": "contains", "value": "Newest" },
{ "path": "serialized_blocks", "assert": "contains", "value": "available" },
{ "path": "serialized_blocks", "assert": "not_contains", "value": "note-search" },
{ "path": "serialized_blocks", "assert": "not_contains", "value": "js-sort-select" },
{ "path": "serialized_blocks", "assert": "not_contains", "value": "js-filter-check" },
{ "path": "serialized_blocks", "assert": "contains", "value": "contact-form" },
{ "path": "serialized_blocks", "assert": "contains", "value": "form-success" },
{ "path": "serialized_blocks", "assert": "not_contains", "value": "drawer-nav" }
Expand Down
Loading