From e1e43c32818bf5a017c8cb0841949e4278955d5a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 26 Oct 2026 18:51:29 -0400 Subject: [PATCH 01/10] Scope page author stylesheet variants to routes in place --- .../src/ArtifactCompiler/ArtifactCompiler.php | 35 +++-- .../src/Css/CssSelectorMatcher.php | 6 +- .../src/Css/CssStylesheetTransformer.php | 142 ++++++++++++++++-- .../WordPressSitePlan/DocumentRootContext.php | 9 +- .../WordPressSitePlan/WordPressSitePlan.php | 22 +++ .../tests/contract/context-seeded-markers.php | 22 +-- .../tests/contract/document-head-runtime.php | 12 +- .../page-stylesheet-dead-richtext-markers.php | 2 +- .../shared-author-stylesheet-scaling.php | 130 ++++++++++++++-- .../artifact-author-stylesheet-projection.php | 30 ++-- .../unit/document-root-font-ownership.php | 6 +- ...dpress-compat-nav-anchor-replay-growth.php | 2 +- 12 files changed, 331 insertions(+), 87 deletions(-) diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 9c5c48249..8584036ea 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -356,7 +356,7 @@ private function finalizeArtifact(array $artifact, array $reduction): Transforme $pageMarkup[(string) ($ownedBy(array(array()), (string) $sourcePath)[0]['owner'] ?? '')] = $document['serialized_blocks']; } $authoredFiles = $normalized['files']; - $normalized['files'] = $this->applyAuthorStylesheetProjections($normalized['files'], $authorStylesheetProjections, $entryBlocks['author_stylesheet_projections'], (string) ($ownedBy(array(array()), $entryPath)[0]['owner'] ?? ''), $pageMarkup); + $normalized['files'] = $this->applyAuthorStylesheetProjections($normalized['files'], $authorStylesheetProjections, $entryBlocks['author_stylesheet_projections'], (string) ($ownedBy(array(array()), $entryPath)[0]['owner'] ?? ''), $pageMarkup, $allDiagnostics); foreach ($normalized['files'] as $document) { if ('html' !== ($document['kind'] ?? '') || !is_string($document['content'] ?? null) || !empty($document['metadata']['compilation']['included_component'])) continue; $documentFiles = $normalized['files']; @@ -2493,7 +2493,7 @@ private function allocateStylesheetOccurrencePath(string $candidate, array &$res * @param string $primaryOwner The page that produced the primary projections, '' when they are site-wide. * @return array> */ - private function applyAuthorStylesheetProjections(array $files, array $projections, array $primaryProjections = array(), string $primaryOwner = '', array $pageMarkup = array()): array + private function applyAuthorStylesheetProjections(array $files, array $projections, array $primaryProjections = array(), string $primaryOwner = '', array $pageMarkup = array(), array &$projectionDiagnostics = array()): array { $attributeStateMarkers = array(); foreach ($projections as $projection) { @@ -2548,6 +2548,12 @@ private function applyAuthorStylesheetProjections(array $files, array $projectio } } $transformer = new CssStylesheetTransformer(); + $routeIdentities = array(); + foreach ($files as $document) { + if ('html' !== ($document['kind'] ?? '')) continue; + $source = (string) $document['path']; + $routeIdentities[$this->fileOwnership($document)['id']] = \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\WordPressSitePlan::identity('page', $source, $source); + } $reserved = array_fill_keys(array_column($files, 'path'), true); $output = array(); foreach ( $files as $file ) { @@ -2613,12 +2619,8 @@ private function applyAuthorStylesheetProjections(array $files, array $projectio } } $sharedStylesheets = array_merge(array_map('strval', array_keys($sharedByPath[$path] ?? array())), $stylesheets); - // A page's projection repeats every rule it did not rewrite. Only the - // rules missing from the site-wide copy go to a stylesheet the page - // alone loads. Place it after the site-wide copy: the latter may - // contain a generic selector that still matches the lowered block - // (e.g. a grid's default two-column rule), while the page projection - // carries the source's later attribute-specific responsive winner. + // Shared-shell projections are part of every route's stylesheet. + // Align the full route projection, never a trailing cascade delta. $native = array(); foreach (array_merge(array_keys($sharedByPath[$path] ?? array()), $authoritativeContent) as $content) { $native += $nativeByContent[$path][$content] ?? array(); @@ -2628,14 +2630,26 @@ private function applyAuthorStylesheetProjections(array $files, array $projectio foreach ($ownersByPath[$path] ?? array() as $content => $owners) { foreach ($owners as $owner => $_) $pageProjections[$owner][] = (string) $content; } + foreach ($pageProjections as &$contents) { + $contents = array($transformer->concatenateWithoutRedundantRules(array_merge(array_map('strval', array_keys($sharedByPath[$path] ?? array())), $contents))); + } + unset($contents); + $sharedStylesheets = array($transformer->concatenateWithoutRedundantRules($sharedStylesheets)); $folded = $transformer->foldPositiveMarkerArms($sharedStylesheets, $pageProjections, $pageMarkup); $sharedStylesheets = $folded['shared']; + $fallbackSharedStylesheets = $sharedStylesheets; foreach ($pages as $owner => $_) $pages[$owner] = $folded['pages'][$owner] ?? $pages[$owner]; + $routed = $transformer->scopeRouteVariants($sharedStylesheets, $folded['pages'], $routeIdentities, preg_match('/@namespace\b/i', implode('', $preambles)) ? '@namespace' : null); + $sharedStylesheets = array($routed['stylesheet']); + foreach ($routed['scoped'] as $owner) unset($pages[$owner]); + foreach ($routed['reasons'] as $owner => $reason) { + $projectionDiagnostics[] = $this->diagnostic('author_stylesheet_route_scope_fallback', 'warning', $path . ' (' . $owner . '): ' . $reason); + } $projectedFile = $this->projectedStylesheetFile($file, $path, implode("\n", array_merge($preambles, array( $transformer->concatenateWithoutRedundantRules($sharedStylesheets) )))); $projectedFile['metadata']['native_selectors'] = array_keys($native); $output[] = $projectedFile; foreach ( $pages as $owner => $pageStylesheets ) { - $delta = $transformer->rulesAbsentFrom($pageStylesheets, $sharedStylesheets, true); + $delta = $transformer->rulesAbsentFrom($pageStylesheets, $fallbackSharedStylesheets, true); if ( '' === trim($delta) ) { continue; } @@ -4162,7 +4176,8 @@ private function fullDocumentMetadata(string $html, string $sourcePath, array $f } $titles = HtmlTagScanner::scan($html, 'title'); $title = isset($titles[0]) ? trim(html_entity_decode(strip_tags($titles[0]['content']), ENT_QUOTES | ENT_HTML5, 'UTF-8')) : $this->titleFromHtml($html, $sourcePath); - $metadata = array('source_context' => array('source_path' => $sourcePath, 'kind' => 'html'), ...\Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::metadataFromHtml($html), 'title' => $title, 'title_declaration' => array('order' => 0, 'placement' => 'head'), 'meta' => $meta, 'links' => $links, 'scripts' => $scripts); + $routeIdentity = \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\WordPressSitePlan::identity('page', $sourcePath, $sourcePath); + $metadata = array('source_context' => array('source_path' => $sourcePath, 'kind' => 'html'), ...\Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::metadataFromHtml($html, $routeIdentity), 'title' => $title, 'title_declaration' => array('order' => 0, 'placement' => 'head'), 'meta' => $meta, 'links' => $links, 'scripts' => $scripts); if ( array() !== $projectedRootMarkers ) { $rootClasses = array_values(array_filter(preg_split('/\s+/', trim((string) ($metadata['root_attributes']['class'] ?? ''))) ?: array(), static fn(string $class): bool => '' !== $class)); $metadata['root_attributes']['class'] = implode(' ', array_unique(array_merge($rootClasses, $projectedRootMarkers))); diff --git a/php-transformer/src/Css/CssSelectorMatcher.php b/php-transformer/src/Css/CssSelectorMatcher.php index 02f31be5f..2f60f2e1a 100644 --- a/php-transformer/src/Css/CssSelectorMatcher.php +++ b/php-transformer/src/Css/CssSelectorMatcher.php @@ -519,7 +519,7 @@ private static function parseCompound(string $source, int $sourceStart, bool $is // A neutral native target can be a complete descendant // path, or contain nested shims. Flattening it into one // compound changes both its match and its specificity. - if (($argument['supported'] ?? false) && (count($argument['compounds']) > 1 || !empty($leaf['not']) || !empty($leaf['any']) || !empty($leaf['first_child']) || !empty($leaf['last_child']) || null !== ($leaf['nth_child'] ?? null) || null !== ($leaf['nth_type'] ?? null))) { + if (($argument['supported'] ?? false) && (count($argument['compounds']) > 1 || !empty($leaf['root']) || !empty($leaf['not']) || !empty($leaf['any']) || !empty($leaf['first_child']) || !empty($leaf['last_child']) || null !== ($leaf['nth_child'] ?? null) || null !== ($leaf['nth_type'] ?? null))) { $group = self::parseSelectorListArgument($alternatives, 'where' === $lowerName); if (null === $group) return null; $compound['any'][] = $group; @@ -582,7 +582,9 @@ private static function parseCompound(string $source, int $sourceStart, bool $is if ( '' === $argument ) { return null; } - $negated = self::parseUncached($argument); + $alternatives = CssStylesheetTransformer::splitSelectorList($argument); + if (null === $alternatives) return null; + $negated = self::parseUncached(count($alternatives) > 1 ? ':is(' . $argument . ')' : $argument); if ( ! ($negated['supported'] ?? false) || null !== ($negated['pseudo_state_suffix_span'] ?? null) ) { return null; } diff --git a/php-transformer/src/Css/CssStylesheetTransformer.php b/php-transformer/src/Css/CssStylesheetTransformer.php index 13d5cae2d..8e046c29a 100644 --- a/php-transformer/src/Css/CssStylesheetTransformer.php +++ b/php-transformer/src/Css/CssStylesheetTransformer.php @@ -281,14 +281,15 @@ public function foldPositiveMarkerArms(array $shared, array $pages, array $marku } } foreach ($index as $key => $matches) { - if (1 !== count($matches)) continue; + if (1 !== count(array_unique(array_map(static fn (int $i): string => $sharedUnits[$i]['text'], $matches)))) continue; $i = $matches[0]; $base = $describe($sharedUnits[$i]); $union = $base['arms']; $candidates = array(); foreach ($pageIndex as $owner => $rules) { - if (1 !== count($rules[$key] ?? array())) continue; - $description = $describe($pageUnits[$owner][$rules[$key][0]]); + $pageMatches = $rules[$key] ?? array(); + if (!$pageMatches || 1 !== count(array_unique(array_map(static fn (int $j): string => $pageUnits[$owner][$j]['text'], $pageMatches)))) continue; + $description = $describe($pageUnits[$owner][$pageMatches[0]]); foreach (array_diff($description['arms'], $union) as $arm) { $marker = $markerOf($arm); if (null !== $marker) $candidates[$arm] = $marker; @@ -299,20 +300,20 @@ public function foldPositiveMarkerArms(array $shared, array $pages, array $marku if (array_diff_key($pages, $markup)) $safe = false; foreach ($carriers[$marker] ?? array() as $owner => $_) { $rules = $pageIndex[$owner][$key] ?? array(); - if (1 !== count($rules) || !in_array($arm, $describe($pageUnits[$owner][$rules[0]])['arms'], true)) { $safe = false; break; } + if (!$rules) $safe = false; + foreach ($rules as $j) if (!in_array($arm, $describe($pageUnits[$owner][$j])['arms'], true)) $safe = false; + if (!$safe) break; } if ($safe) $union[] = $arm; } if ($union === $base['arms']) continue; - $sharedUnits[$i]['text'] = implode(',', $union) . substr($sharedUnits[$i]['text'], $base['brace']); + foreach ($matches as $match) $sharedUnits[$match]['text'] = implode(',', $union) . substr($sharedUnits[$match]['text'], $base['brace']); foreach ($pageIndex as $owner => $rules) { - if (1 !== count($rules[$key] ?? array())) continue; - $j = $rules[$key][0]; - $description = $describe($pageUnits[$owner][$j]); - // Leave the entire rule on the old delta path if any arm was - // not safe to fold; its later cascade dependencies still matter. - if (array() === array_diff($description['arms'], $union)) { - $pageUnits[$owner][$j]['text'] = $sharedUnits[$i]['text']; + foreach ($rules[$key] ?? array() as $j) { + $description = $describe($pageUnits[$owner][$j]); + if (array() === array_diff($description['arms'], $union)) { + $pageUnits[$owner][$j]['text'] = $sharedUnits[$i]['text']; + } } } } @@ -321,6 +322,123 @@ public function foldPositiveMarkerArms(array $shared, array $pages, array $marku return $result; } + /** + * Reconcile ordered page projections at the source cascade position. + * Unsupported pages are returned separately for the legacy copy path. + * + * @param list $shared + * @param array> $pages + * @param array $routeIdentities Reconciliation identities by compilation owner. + * @return array{stylesheet:string, scoped:list, reasons:array} + */ + public function scopeRouteVariants(array $shared, array $pages, array $routeIdentities = array(), ?string $unsafeReason = null): array + { + $base = array(); + foreach ($shared as $css) $this->collectRuleUnits($css, array(), $base); + $key = static function (array $unit): string { + $brace = strpos($unit['text'], '{'); + return serialize(array($unit['context'], false === $brace || str_starts_with(ltrim($unit['text']), '@') ? trim($unit['text']) : substr($unit['text'], $brace))); + }; + $positions = array(); + foreach ($base as $i => $unit) $positions[$key($unit)][] = $i; + $changes = $insertions = $reasons = array(); + $scoped = array(); + foreach ($pages as $owner => $sheets) { + $units = array(); + $reason = $unsafeReason; + foreach (array_merge($shared, $sheets) as $css) { + if (!$this->isWellFormedStylesheet($css)) $reason = 'malformed stylesheet'; + if (preg_match('/@namespace\b/i', $css)) $reason = '@namespace'; + } + if (null !== $reason) { $reasons[$owner] = $reason; continue; } + foreach ($sheets as $css) $this->collectRuleUnits($css, array(), $units); + $cursor = 0; + $localChanges = $localInsertions = array(); + foreach ($units as $unit) { + $match = null; + foreach ($positions[$key($unit)] ?? array() as $position) { + if ($position >= $cursor) { $match = $position; break; } + } + if (null === $match) { + $localInsertions[$cursor][] = $unit; + continue; + } + while ($cursor < $match) $localChanges[$cursor++] = null; + if (trim($unit['text']) !== trim($base[$match]['text'])) $localChanges[$match] = $unit; + $cursor = $match + 1; + } + while ($cursor < count($base)) $localChanges[$cursor++] = null; + // Validate every affected selector before committing any route edits. + foreach ($localChanges as $i => $variant) { + foreach (array_filter(array($base[$i], $variant)) as $unit) { + $this->scopeRouteUnit($unit, array('be-route-probe'), true, $reason); + } + } + foreach ($localInsertions as $list) foreach ($list as $unit) { + $this->scopeRouteUnit($unit, array('be-route-probe'), true, $reason); + } + if (null !== $reason) { $reasons[$owner] = $reason; continue; } + $scoped[] = (string) $owner; + foreach ($localChanges as $i => $variant) $changes[$i][$owner] = $variant; + foreach ($localInsertions as $i => $list) $insertions[$i][$owner] = $list; + } + $output = array(); + $routeClass = static fn ($owner): string => \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($routeIdentities[$owner] ?? (string) $owner); + $emitGroups = function (array $groups) use (&$output, $routeClass): void { + $texts = array(); + foreach ($groups as $owner => $units) foreach ($units as $ordinal => $unit) { + $groupKey = serialize(array($ordinal, $unit)); + $texts[$groupKey]['unit'] = $unit; + $texts[$groupKey]['routes'][] = $routeClass($owner); + } + foreach ($texts as $group) $output[] = $this->scopeRouteUnit($group['unit'], $group['routes'], true); + }; + for ($i = 0; $i <= count($base); ++$i) { + $emitGroups($insertions[$i] ?? array()); + if ($i === count($base)) break; + $variants = $changes[$i] ?? array(); + $output[] = $variants ? $this->scopeRouteUnit($base[$i], array_map($routeClass, array_keys($variants)), false) : $base[$i]; + $emitGroups(array_map(static fn ($unit): array => null === $unit ? array() : array($unit), $variants)); + } + return array('stylesheet' => $this->serializeRuleUnits($output), 'scoped' => $scoped, 'reasons' => $reasons); + } + + /** @param array{context:list,text:string,dedupable:bool} $unit @param list $routes */ + private function scopeRouteUnit(array $unit, array $routes, bool $positive, ?string &$reason = null): array + { + $brace = strpos($unit['text'], '{'); + if (false === $brace || str_starts_with(ltrim($unit['text']), '@') || !$unit['dedupable']) { $reason = 'non-selector rule cannot be route scoped'; return $unit; } + $arms = self::splitSelectorList(trim(substr($unit['text'], 0, $brace))); + if (null === $arms) { $reason = 'unparseable selector list'; return $unit; } + $scope = ':where(:root:' . ($positive ? 'is' : 'not') . '(' . implode(',', array_map(static fn (string $route): string => '.' . $route, $routes)) . '))'; + $rootAlternatives = array(); + foreach ($arms as &$arm) { + $arm = trim($arm); + $tokens = CssSelectorTokenizer::tokenize($arm); + if (!$tokens['supported'] || str_contains($arm, '&') || str_contains($arm, '|') || str_contains($arm, '/*')) { $reason = 'unparseable or relative selector'; return $unit; } + if (preg_match('/::|:(?:before|after|first-line|first-letter)\b/i', $arm)) { $reason = 'pseudo-element selector'; return $unit; } + if (!(CssSelectorMatcher::parse($arm)['supported'] ?? false)) { $reason = 'unparseable selector'; return $unit; } + $first = $tokens['compounds'][0]; + if (preg_match('/^(?:html(?=[.#:\[]|$)|:root(?=[.#:\[]|$))/i', $first)) { + $end = $tokens['compound_spans'][0]['end']; + $arm = substr($arm, 0, $end) . $scope . substr($arm, $end); + } else { + // Root tests hidden in functional selectors can match html too. + if (preg_match('/(?,body_attributes:array} */ - public static function metadataFromHtml(string $html): array + public static function metadataFromHtml(string $html, string $routeIdentity = ''): array { $attributes = DocumentRootAttributes::forDocument($html); + if ('' !== $routeIdentity) $attributes['html']['class'] = trim(($attributes['html']['class'] ?? '') . ' ' . self::routeClass($routeIdentity)); return array('root_attributes' => $attributes['html'], 'body_attributes' => $attributes['body']); } + /** Stable document compilation identity, shared by CSS and root reconciliation. */ + public static function routeClass(string $identity): string + { + return 'be-route-' . substr(hash('sha256', $identity), 0, 12); + } + public static function assertValid(mixed $attributes): void { if (!is_array($attributes)) throw new InvalidArgumentException('Document root context must be an attribute map.'); diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php index 9ef531467..d92ace297 100644 --- a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php +++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php @@ -453,6 +453,28 @@ public function fromCompilerInput(array $data, WordPressSitePlanInput $input): a unset($template); $runtimeDeclarations = $this->canonicalEntityBindings($runtimeDeclarations, $runtimeEntityRecords, $references, $routeMap, $pages, $bindingTemplates, $paginationOwners); $pages = $this->pageHierarchy($pages, $routeMap); + // Compilation knows source identities; route reconciliation finalizes + // them here. Remap the static CSS and the existing root registry together. + $routeClasses = array(); + foreach ($pages as &$page) { + if (!empty($page['synthetic'])) continue; + $sourceClass = DocumentRootContext::routeClass(self::identity('page', $page['source_path'], $page['source_path'])); + $routeClass = DocumentRootContext::routeClass($page['reconciliation_identity']); + $routeClasses[$sourceClass] = $routeClass; + $root = $page['document_metadata']['root_attributes'] ?? array(); + $root['class'] = trim(str_replace($sourceClass, $routeClass, $root['class'] ?? '')); + if (!in_array($routeClass, explode(' ', $root['class']), true)) $root['class'] = trim($root['class'] . ' ' . $routeClass); + $page['document_metadata']['root_attributes'] = $root; + } + unset($page); + foreach ($assets as &$asset) { + if ('css' !== ($asset['kind'] ?? '') || !is_string($asset['content'] ?? null)) continue; + $asset['content'] = strtr($asset['content'], $routeClasses); + $asset['bytes'] = strlen($asset['content']); + $asset['hash'] = hash('sha256', $asset['content']); + $asset['content_hash'] = self::contentHash($asset['content']); + } + unset($asset); $templateOwners = array(); foreach ($pages as $page) if (empty($page['entrypoint'])) $templateOwners[self::routeTemplateKey($page)][] = $page['source_path']; // Colliding terminal slugs get a readable route-qualified template diff --git a/php-transformer/tests/contract/context-seeded-markers.php b/php-transformer/tests/contract/context-seeded-markers.php index a566c050c..efe9294d1 100644 --- a/php-transformer/tests/contract/context-seeded-markers.php +++ b/php-transformer/tests/contract/context-seeded-markers.php @@ -137,11 +137,7 @@ $assert(isset($indexColors['Brand'], $indexColors['Copy'], $indexColors['Foot']) && ! in_array('#f00', $indexColors, true) && '#123' === ($indexColors['Copy'] ?? null), "another page's own type rule reaches neither the chrome nor the content of this page", (string) json_encode($indexColors)); $assert('#f00' === ($aboutColors['Brand'] ?? null) && '#f00' === ($aboutColors['Foot'] ?? null), "the page's own type rule still reaches its own chrome", (string) json_encode($aboutColors)); -// 6. The site-wide copy is the projection most pages share; the pages that -// need the same extra slice share one asset, enqueued on each of them -// right after the site-wide copy. `main>.actions .btn` depends on where -// the actions sit in `main`, so its control arm stays per element and -// differs between the two layouts. +// 6. Both layouts keep their context-dependent arms in one shared asset. $sliced = $plan(array( 'index.html' => $document('

Lead

' . $chrome . '
'), 'd.html' => $document('

Lead

' . $chrome . '
'), @@ -158,18 +154,10 @@ $slices[] = $asset; } } -$assert('' !== $siteCopy && 1 === count($slices) && array( 'index.html', 'd.html' ) === array_column($slices[0]['scopes'], 'source_path'), 'the two pages that need the same slice share one published asset', (string) json_encode(array_map(static fn (array $slice): array => array_column($slice['scopes'], 'source_path'), $slices))); -foreach ( array( 'index.html', 'd.html', 'a.html', 'b.html', 'c.html' ) as $source ) { - $own = array_values(array_filter($slices, static fn (array $slice): bool => in_array($source, array_column($slice['scopes'], 'source_path'), true))); - // A stylesheet split for visitors has an editor copy; each context loads one. - foreach ( array( 'frontend', 'editor' ) as $context ) { - $rows = array_values(array_filter(WordPressSitePlan::documentStylesheets($sliced, $source), static fn (array $row): bool => in_array($row['stylesheet_target'], array( 'both', $context ), true))); - $sequence = array_column($rows, 'target_path'); - $at = array_search($siteCopy, $sequence, true); - $slice = array() === $own ? null : $own[0]['target_path']; - $sliceIn = $slice; - $assert(false !== $at && count($own) <= 1 && (null === $sliceIn || ($sequence[$at + 1] ?? null) === $sliceIn), "{$source} enqueues its slice right after the site-wide copy ({$context})", (string) json_encode($sequence)); - } +$assert('' !== $siteCopy && array() === $slices, 'different layouts publish one stylesheet without page slices'); +foreach (array('index.html', 'd.html', 'a.html', 'b.html', 'c.html') as $source) { + $sequence = array_column(WordPressSitePlan::documentStylesheets($sliced, $source), 'target_path'); + $assert(in_array($siteCopy, $sequence, true), $source . ' loads the shared route-scoped stylesheet'); } if ( 0 < $failures ) { diff --git a/php-transformer/tests/contract/document-head-runtime.php b/php-transformer/tests/contract/document-head-runtime.php index 9a00f1af6..d933aca18 100644 --- a/php-transformer/tests/contract/document-head-runtime.php +++ b/php-transformer/tests/contract/document-head-runtime.php @@ -247,7 +247,7 @@ function _block_template_viewport_meta_tag(): void { print ' strpos($orderedEmitted, ' id="author"'), 'After-author support ' . $row['handle'] . ' prints once, after the replayed head stylesheet.'); $assert(array() === array_filter($enqueuedStyles, static fn(string $src): bool => str_contains($src, 'inline')), 'The replayed head stylesheet has no remaining WordPress enqueue.'); -// #3508: projected companions must retain their positions between replayed links. +// Route variants retain source order between replayed stylesheet links. $companions = array('entrypoint' => 'index.html', 'files' => array( 'index.html' => '

Sample

', 'other.html' => '

Sample

', @@ -260,8 +260,8 @@ function _block_template_viewport_meta_tag(): void { print ' $stylesheet) { - $needle = 0 === $index % 2 ? 'href="https://example.test/theme/' . $stylesheet['target_path'] . '"' : 'data-printed-handle="' . $stylesheet['handle'] . '"'; - $assert(1 === substr_count($companionHead, $needle), 'Each source stylesheet and companion prints exactly once.'); + $needle = 'href="https://example.test/theme/' . $stylesheet['target_path'] . '"'; + $assert(1 === substr_count($companionHead, $needle), 'Each source stylesheet prints exactly once.'); $positions[] = strpos($companionHead, $needle); } $sortedPositions = $positions; sort($sortedPositions); -$assert($positions === $sortedPositions, 'Rendered stylesheet order is a.css, a.page.css, b.css, b.page.css.'); +$assert($positions === $sortedPositions, 'Rendered stylesheet order is a.css, b.css.'); $repeated = $artifact; $repeated['files']['index.html'] = str_replace('', '', $repeated['files']['index.html']); diff --git a/php-transformer/tests/contract/page-stylesheet-dead-richtext-markers.php b/php-transformer/tests/contract/page-stylesheet-dead-richtext-markers.php index fb8f30c05..94cf81f1c 100644 --- a/php-transformer/tests/contract/page-stylesheet-dead-richtext-markers.php +++ b/php-transformer/tests/contract/page-stylesheet-dead-richtext-markers.php @@ -60,7 +60,7 @@ // The reset still reaches every element, and the authored rules are unchanged. $all = implode("\n", array_map(static fn (array $asset): string => (string) $asset['content'], $stylesheets)); -$assert(str_contains($all, '*:not(:where(.blocks-engine-role-richtext)):not(.be-c-0),:where(.blocks-engine-role-richtext.blocks-engine-subject):not(.be-c-0){margin:0}'), 'The reset excludes the hidden label through the richtext role class and reaches it through the subject arm.'); +$assert(str_contains($all, '*:not(:where(.blocks-engine-role-richtext)):not(.be-c-0)') && str_contains($all, ':where(.blocks-engine-role-richtext.blocks-engine-subject):not(.be-c-0){margin:0}'), 'The route-scoped reset excludes the hidden label through the richtext role class and reaches it through the subject arm.'); $assert(str_contains($all, '.cta{border:1px solid}'), 'Authored chrome rules are unchanged.'); foreach (array('padding:2em', 'padding:1em') as $body) { $assert(str_contains($all, $body), 'The route rule `' . $body . '` is kept.'); diff --git a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php index d2e3e2159..3bd77c094 100644 --- a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php +++ b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php @@ -5,9 +5,8 @@ * Generated theme CSS must not grow with pages x elements (#2755). * * N pages sharing one layout and one stylesheet compile that stylesheet to one - * site-wide asset plus, at most, one slice shared by every page that needs the - * same extra rules. Each page still resolves the same styles under its own - * enqueue sequence. + * site-wide asset with route-scoped variants in source order. Each page still + * resolves the same styles under its own enqueue sequence. */ require_once __DIR__ . '/../../vendor/autoload.php'; @@ -65,9 +64,7 @@ // Ten pages that share a layout must not even double what two pages need. $assert($large['css_bytes'] < 2 * $small['css_bytes'], 'ten pages sharing a stylesheet need less than twice the generated CSS of two pages', $small['css_bytes'] . ' vs ' . $large['css_bytes']); -// The shared stylesheet compiles to one site-wide asset, the projection most -// pages share, whose bytes do not depend on how many pages consume it, plus at -// most one slice for the page that differs. +// Shared author CSS stays in one site-wide asset, including route differences. $stylesheetAssets = static function (array $plan): array { $site = array(); $slices = array(); @@ -86,10 +83,8 @@ [ $mediumSite, $mediumSlices ] = $stylesheetAssets($medium['plan']); [ $largeSite, $largeSlices ] = $stylesheetAssets($large['plan']); $assert(1 === count($mediumSite) && 1 === count($largeSite), 'the shared stylesheet compiles to one site-wide asset', (string) json_encode(array( count($mediumSite), count($largeSite) ))); -$assert(($mediumSite[0]['content'] ?? null) === ($largeSite[0]['content'] ?? ''), 'the site-wide asset is byte-identical however many pages consume it'); -$assert(count($largeSlices) <= 1 && count($largeSlices) === count($mediumSlices) && ($largeSlices[0]['content'] ?? null) === ($mediumSlices[0]['content'] ?? null), 'the page slice does not grow with the page count either', (string) count($largeSlices)); -$sliceScopes = array_map(static fn (array $scope): string => (string) ($scope['source_path'] ?? ''), $largeSlices[0]['scopes'] ?? array()); -$assert(array() === $largeSlices || array( 'a.html' ) === $sliceScopes, 'only the page whose own element a rule targets (#note-0) loads a slice', (string) json_encode($sliceScopes)); +$assert(!str_contains(implode('', array_column($large['plan']['assets'], 'source_path')), '.page-'), 'route variants never emit page-copy assets'); +$assert(array() === $largeSlices && array() === $mediumSlices, 'neither site needs page slices'); // The control, the list and the shared chrome carry the same per-element // markers on every page; the per-page body class does not leak into them. @@ -110,6 +105,7 @@ } $document = new DOMDocument(); @$document->loadHTML(StaticStyleParityRunner::candidateHtmlFromSerializedBlocks((string) ($planPage['canonical_block_markup'] ?? ''))); + foreach ($planPage['document_metadata']['root_attributes'] ?? array() as $name => $value) $document->documentElement->setAttribute($name, $value); $cascade = new StaticCssCascade($document, $css); $probe = array(); $button = $document->getElementsByTagName('a')->item(2) ?? $document->getElementsByTagName('button')->item(0); @@ -186,7 +182,7 @@ $twoAssets = $authoredAssets($variedTwo); $tenAssets = $authoredAssets($variedTen); $assert(1 === count($twoAssets) && 1 === count($tenAssets), 'the stylesheet compiles to one asset however different the pages are', (string) json_encode(array( array_keys($twoAssets), array_keys($tenAssets) ))); -$assert($twoAssets === $tenAssets, 'its bytes do not depend on the page count or on what the pages hold', strlen(implode('', $twoAssets)) . ' vs ' . strlen(implode('', $tenAssets))); +$assert(strlen(implode('', $tenAssets)) < 2 * strlen(implode('', $twoAssets)), 'route lists grow without copying the stylesheet', strlen(implode('', $twoAssets)) . ' vs ' . strlen(implode('', $tenAssets))); $assert(! preg_match('/blocks-engine-(?:control|semantic)-[a-f0-9]{12}-\d+/', implode('', $tenAssets)), 'no arm of the shared stylesheet names a per-element marker', (string) (preg_match('/[^{}]*blocks-engine-(?:control|semantic)-[a-f0-9]{12}-\d+[^{}]*/', implode('', $tenAssets), $named) ? $named[0] : '')); $variedAssetsByTarget = array_column($variedTen['assets'] ?? array(), null, 'target_path'); @@ -198,6 +194,7 @@ } $document = new DOMDocument(); @$document->loadHTML('' . StaticStyleParityRunner::candidateHtmlFromSerializedBlocks((string) ($planPage['canonical_block_markup'] ?? '')) . ''); + foreach ($planPage['document_metadata']['root_attributes'] ?? array() as $name => $value) $document->documentElement->setAttribute($name, $value); $cascade = new StaticCssCascade($document, $sheet); $probe = array(); foreach ( $document->getElementsByTagName('*') as $element ) { @@ -267,6 +264,7 @@ $assets = array_column($contextTen['assets'], null, 'target_path'); $css = ''; foreach (WordPressSitePlan::documentStylesheets($contextTen, $planPage['source_path']) as $sheet) $css .= "\n" . ($assets[$sheet['target_path']]['content'] ?? ''); + foreach ($planPage['document_metadata']['root_attributes'] ?? array() as $name => $value) $document->documentElement->setAttribute($name, $value); $cascade = new StaticCssCascade($document, $css); foreach ($document->getElementsByTagName('*') as $element) { if (!$element instanceof DOMElement || !str_contains($element->getAttribute('class'), 'wp-block-buttons')) continue; @@ -302,14 +300,118 @@ } // A reused identity is not proof of a shared match: both pages carry the -// marker, but only one projected the arm. Keep that page's original delta. +// marker, but only one projected the arm. Keep it out of the marker fold. $marker = 'blocks-engine-control-0123456789ab-0'; $base = '.btn-group>.btn{z-index:1}.tail{z-index:2}'; $extra = '.btn-group>.btn,:where(.' . $marker . ')>.wp-block-button__link{z-index:1}.tail{z-index:2}'; $folded = $transformer->foldPositiveMarkerArms(array($base), array('a' => array($extra), 'b' => array($base)), array('a' => '
', 'b' => '
')); $assert(!str_contains(implode('', $folded['shared']), $marker), 'a marker carried by a non-projecting page is not folded'); -$delta = $transformer->rulesAbsentFrom($folded['pages']['a'], $folded['shared'], true); -$assert(str_contains($delta, $marker) && str_contains($delta, '.tail{z-index:2}'), 'unsafe arm stays in its cascade-preserving page delta'); +$routed = $transformer->scopeRouteVariants($folded['shared'], $folded['pages']); +$assert(str_contains($routed['stylesheet'], $marker) && 2 === count($routed['scoped']), 'an arm unsafe for marker folding can still be route scoped in place'); + +// Real constant-arm variants, a dropped control override, and page-only +// image bridge rules. Identical span routes must share one positive rule. +$routeCss = 'html .sample{background-color:#123456}:root .sample{border-top-width:3px}' + . '@media(min-width:1px){button{padding:7px}.sample{color:red}.only{margin:9px}img{width:41px}}'; +for ($i = 0; $i < 100; ++$i) $routeCss .= '.cascade-tail-' . $i . '{color:#123456;padding:2px;border-top-width:1px;background-color:#fff}'; +$routeBodies = array( + '', + 'Probe', + 'Probe', + '', + 'Photo', +); +$routeFiles = array(); +foreach ($routeBodies as $i => $body) $routeFiles[] = array('path' => chr(97 + $i) . '.html', 'kind' => 'html', 'content' => 'Routes
' . $body . '
'); +$routeSheet = array('path' => 'base.css', 'kind' => 'css', 'content' => $routeCss); +$routeResult = (new ArtifactCompiler())->compile(array('entrypoint' => 'a.html', 'files' => array_merge($routeFiles, array($routeSheet))))->toArray(); +$routePlan = $routeResult['source_reports']['wordpress_site_plan']; +$routeAssets = $authoredAssets($routePlan); +$routeText = implode('', $routeAssets); +$assert(1 === count($routeAssets) && !str_contains(implode('', array_column($routePlan['assets'], 'source_path')), '.page-'), 'constant-arm variants, dropped rules and page-only rules need no copies'); +$classes = array(); +foreach ($routePlan['pages'] as $row) { + $class = Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($row['reconciliation_identity']); + $classes[$row['source_path']] = $class; + $assert(in_array($class, explode(' ', $row['document_metadata']['root_attributes']['class'] ?? ''), true), 'document root carries its reconciliation route class: ' . $row['source_path']); + $functions = array_column($routePlan['writes'], null, 'target_path')['functions.php']['payload']['data']; + $assert(str_contains($functions, $class) && str_contains($functions, 'blocksEngineDocumentContext') && str_contains($functions, 'language_attributes'), 'the same root registry reaches front end and editor: ' . $row['source_path']); +} +$group = ':where(:root:is(.' . $classes['b.html'] . ',.' . $classes['c.html'] . '))'; +$assert(str_contains($routeText, $group . ' .sample'), 'byte-identical constant-arm variants group both span routes'); +$assert(str_contains($routeText, 'html:where(:root:') && str_contains($routeText, ':root:where(:root:'), 'html and root scopes merge into the leftmost compound'); +$droppedOverride = false; +$conditionalImage = false; +$transformer->visitStyleRules($routeText, static function (string $prelude, string $body, array $context) use (&$droppedOverride, &$conditionalImage, $classes): void { + if (str_contains($body, 'padding:7px!important')) { + $droppedOverride = in_array('@media(min-width:1px)', $context, true) && str_contains($prelude, ':root:not(.' . $classes['b.html'] . ',.' . $classes['c.html'] . ')'); + } + if (str_contains($prelude, '.wp-block-image') && str_contains($prelude, ':root:is(.' . $classes['e.html'] . ')')) $conditionalImage = in_array('@media(min-width:1px)', $context, true); +}); +$assert($droppedOverride, 'the dropped override excludes both span routes at its original conditional position'); +$assert($conditionalImage, 'page-only image rules retain their at-rule context'); +$assert(str_contains($routeText, ':where(:root:is(.' . $classes['e.html'] . '))'), 'page-only image bridge has a positive route scope'); + +// Each route is compared with its independent public compilation, including +// editor wrapper selectors. Apply exactly the root attributes the theme uses. +$probeStyles = static function (array $plan, array $row, bool $editor): array { + $assets = array_column($plan['assets'], null, 'target_path'); + $css = ''; + foreach (WordPressSitePlan::documentStylesheets($plan, $row['source_path']) as $sheet) $css .= "\n" . ($assets[$sheet['target_path']]['content'] ?? ''); + $markup = StaticStyleParityRunner::candidateHtmlFromSerializedBlocks($row['canonical_block_markup']); + $dom = new DOMDocument(); + @$dom->loadHTML('' . ($editor ? '
' : '') . $markup . ($editor ? '
' : '') . ''); + foreach ($row['document_metadata']['root_attributes'] ?? array() as $name => $value) $dom->documentElement->setAttribute($name, $value); + $cascade = new StaticCssCascade($dom, $css); + $styles = array(); + foreach ($dom->getElementsByTagName('*') as $element) { + if (!$element instanceof DOMElement) continue; + if ('img' === $element->tagName) { + $styles[] = $cascade->resolve($element, array('width', 'max-width', 'display'), array()); + } elseif ('Probe' === trim($element->textContent) && in_array($element->tagName, array('p', 'span', 'a', 'button'), true)) { + $styles[] = $cascade->resolve($element, array('color', 'background-color', 'padding-top', 'border-top-width'), array()); + } + } + return $styles; +}; +foreach ($routePlan['pages'] as $row) { + $file = array_column($routeFiles, null, 'path')[$row['source_path']]; + $alone = (new ArtifactCompiler())->compile(array('entrypoint' => $file['path'], 'files' => array($file, $routeSheet)))->toArray()['source_reports']['wordpress_site_plan']; + foreach (array(false, true) as $editor) { + $actual = $probeStyles($routePlan, $row, $editor); + $expected = $probeStyles($alone, $alone['pages'][0], $editor); + $assert(array() !== $actual && $expected === $actual, 'route computed-style parity ' . $row['source_path'] . ($editor ? ' editor' : ' front end'), json_encode(array($expected, $actual))); + } +} +echo 'Route fixture author CSS: ' . strlen($routeText) . ' bytes in ' . count($routeAssets) . " assets\n"; + +// @namespace requires per-sheet fallback, without preventing another sheet +// on the same route from using in-place variants. +$unsafeSheet = array('path' => 'unsafe.css', 'kind' => 'css', 'content' => '@namespace svg url("http://www.w3.org/2000/svg");' . $routeCss); +$fallbackFiles = $routeFiles; +foreach ($fallbackFiles as &$file) $file['content'] = str_replace('', '', $file['content']); +unset($file); +$fallback = (new ArtifactCompiler())->compile(array('entrypoint' => 'a.html', 'files' => array_merge($fallbackFiles, array($routeSheet, $unsafeSheet))))->toArray(); +$findings = array_filter($fallback['diagnostics'], static fn (array $row): bool => 'author_stylesheet_route_scope_fallback' === ($row['code'] ?? '') && str_contains($row['message'], '@namespace') && str_contains($row['message'], 'unsafe.css')); +$assert(array() !== $findings, 'unsafe stylesheet emits a page-attributed reason diagnostic'); +$fallbackAssets = $fallback['source_reports']['wordpress_site_plan']['assets']; +$copies = array_filter($fallbackAssets, static fn (array $asset): bool => str_contains($asset['source_path'], '.page-')); +$assert(array() !== $copies && array() === array_filter($copies, static fn (array $asset): bool => str_starts_with($asset['source_path'], 'base.page-')), 'fallback is restricted to the unsafe sheet'); +foreach (array( + array('.x::before{color:red}', '.y::before{color:red}', 'pseudo-element'), + array('.x{color:red}', '& .x{color:red}', 'relative'), + array('.x{color:red}', '.x{color:red', 'malformed'), +) as [$base, $variant, $reason]) { + $routed = $transformer->scopeRouteVariants(array($base), array('unsafe' => array($variant))); + $assert(array() === $routed['scoped'] && str_contains($routed['reasons']['unsafe'] ?? '', $reason), 'unsafe route falls back: ' . $reason); +} + +// Identical shared units are one fold match, rewritten at every occurrence. +$marker = 'blocks-engine-control-0123456789ab-0'; +$unit = '.group>.btn{z-index:1}'; +$variant = '.group>.btn,:where(.' . $marker . '){z-index:1}'; +$duplicates = $transformer->foldPositiveMarkerArms(array($unit . '.between{z-index:2}' . $unit), array('a' => array($variant . '.between{z-index:2}' . $variant)), array('a' => '

Probe

')); +$assert(2 === substr_count(implode('', $duplicates['shared']), $marker), 'marker folding rewrites every byte-identical shared unit without moving it'); if ( 0 < $failures ) { fwrite(STDERR, "shared-author-stylesheet scaling FAILED: {$passes} passed, {$failures} failed\n"); diff --git a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php index f161d9dd5..f98b1a54e 100644 --- a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php +++ b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php @@ -604,27 +604,15 @@ $gridPageCss = implode('', array_column($gridPageAssets, 'content')); $gridBaseCss = false !== $gridBaseIndex ? (string) ($gridAssets[$gridBaseIndex]['content'] ?? '') : ''; $gridMarker = ''; -preg_match('/blocks-engine-attribute-[a-f0-9]+-\d+/', $gridPageCss, $gridMarkerMatch); +preg_match('/blocks-engine-attribute-[a-f0-9]+-\d+/', $gridBaseCss, $gridMarkerMatch); $gridMarker = $gridMarkerMatch[0] ?? ''; $gridPages = array_column($responsiveGrid['source_reports']['compiled_site']['pages'] ?? array(), null, 'source_path'); $gridMarkup = (string) ($gridPages['people.html']['block_markup'] ?? ''); -$assert(false !== $gridBaseIndex && count($gridPageAssets) === 1 && array_search($gridPageAssets[0]['path'], array_column($gridAssets, 'path'), true) > $gridBaseIndex, 'the page-specific grid projection follows its shared stylesheet'); -$gridPlanPaths = array_column($responsiveGrid['source_reports']['wordpress_site_plan']['assets'] ?? array(), 'source_path'); -$gridPlanBase = array_search('cards.css', $gridPlanPaths, true); -$gridPlanPage = array_search($gridPageAssets[0]['path'] ?? '', $gridPlanPaths, true); -$assert(false !== $gridPlanBase && false !== $gridPlanPage && $gridPlanBase < $gridPlanPage, 'the WordPress site plan preserves the shared-then-page stylesheet cascade through theme enqueue'); -$gridSource = 'cards.css'; -$gridPage = 'cards.page-123456789abc.css'; -$orderedGridStyles = ( new ReflectionMethod(\Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\WordPressSitePlan::class, 'orderPageStylesheetProjections') )->invoke(null, array( - array('kind' => 'css', 'source_path' => $gridPage, 'content' => '.cards:not([data-count="1"]){grid-template-columns:repeat(4,1fr)}'), - array('kind' => 'css', 'source_path' => $gridPage . '.shared-chrome', 'content' => '.shared{color:#456}'), - array('kind' => 'css', 'source_path' => 'unrelated.css', 'content' => '.other{color:#789}'), - array('kind' => 'css', 'source_path' => $gridSource, 'content' => '.cards:not([data-count="1"]){grid-template-columns:repeat(2,1fr)}'), - array('kind' => 'css', 'source_path' => $gridSource . '.shared-chrome', 'content' => '.shared{color:#123}'), -)); -$orderedGridSources = array_column($orderedGridStyles, 'source_path'); -$assert(array('unrelated.css', $gridSource, $gridSource . '.shared-chrome', $gridPage, $gridPage . '.shared-chrome') === $orderedGridSources, 'a linked shared stylesheet stays before its page-scoped winner after chrome factoring without moving unrelated CSS'); -$assert('' !== $gridMarker && str_contains($gridPageCss, 'grid-template-columns:repeat(4,1fr)') && str_contains($gridPageCss, '@media(max-width:600px)') && str_contains($gridBaseCss, 'grid-template-columns:repeat(2,1fr)') && ! str_contains($gridPageCss, 'grid-template-columns:repeat(2,1fr)'), 'page projection retains its own responsive grid winners without replaying an unrelated shared two-column fallback after them'); +$assert(false !== $gridBaseIndex && array() === $gridPageAssets, 'the responsive grid projection stays in its shared stylesheet'); +$gridPlan = $responsiveGrid['source_reports']['wordpress_site_plan']; +$gridPlanPages = array_column($gridPlan['pages'], null, 'source_path'); +$assert(!str_contains(implode('', array_column($gridPlan['assets'], 'source_path')), 'cards.page-'), 'the generated theme needs no page-specific grid copy'); +$assert('' !== $gridMarker && str_contains($gridBaseCss, 'grid-template-columns:repeat(4,1fr)') && str_contains($gridBaseCss, '@media(max-width:600px)') && str_contains($gridBaseCss, 'grid-template-columns:repeat(2,1fr)'), 'shared grid projection retains both layouts and their responsive winners'); $assert(str_contains(implode("\n", array_column($gridAssets, 'content')), 'grid-gap:100px 100px') && str_contains($gridMarkup, 'be-inline-geometry-'), 'the editable grid keeps its inline-authored legacy gap through the existing layout carrier'); $assert(str_contains($gridMarkup, 'blocks-engine-css-owned-grid') && str_contains($gridMarkup, $gridMarker) && 'pass' === ( new Runtime() )->validateBlockSerialization($gridMarkup)['status'], 'the attribute-selected four-card grid remains a valid editable Group document'); $gridDocument = new DOMDocument(); @@ -636,9 +624,11 @@ break; } } -$gridCascade = new StaticCssCascade($gridDocument, $gridBaseCss . $gridPageCss); +foreach ($gridPages['people.html']['document_metadata']['root_attributes'] ?? array() as $name => $value) $gridDocument->documentElement->setAttribute($name, $value); +$gridCascade = new StaticCssCascade($gridDocument, $gridBaseCss); $assert($gridElement instanceof DOMElement && 'repeat(4,1fr)' === ($gridCascade->resolve($gridElement, array( 'grid-template-columns' ), array())['grid-template-columns'] ?? ''), 'the projected four-column rule wins the shared two-column fallback in source order at the desktop reference'); -$gridCascade = new StaticCssCascade($gridDocument, implode("\n", array_column($orderedGridStyles, 'content'))); +foreach ($gridPlanPages['people.html']['document_metadata']['root_attributes'] ?? array() as $name => $value) $gridDocument->documentElement->setAttribute($name, $value); +$gridCascade = new StaticCssCascade($gridDocument, implode("\n", array_column($gridPlan['assets'], 'content'))); $assert('repeat(4,1fr)' === ($gridCascade->resolve($gridElement, array('grid-template-columns'), array())['grid-template-columns'] ?? ''), 'the final site-plan stylesheet order preserves the four-column browser winner'); $pageSubsetArtifact = array( diff --git a/php-transformer/tests/unit/document-root-font-ownership.php b/php-transformer/tests/unit/document-root-font-ownership.php index 2b3b9125e..9b9af3634 100644 --- a/php-transformer/tests/unit/document-root-font-ownership.php +++ b/php-transformer/tests/unit/document-root-font-ownership.php @@ -18,12 +18,12 @@ )))->toArray(); $plan = (new WordPressSitePlan())->fromCompilerResult($result); $pages = array_column($plan['pages'], null, 'source_path'); -$assert('type-a' === ($pages['index.html']['document_metadata']['root_attributes']['class'] ?? null), 'entry root scope travels through site plan'); -$assert('type-b' === ($pages['other/index.html']['document_metadata']['root_attributes']['class'] ?? null), 'route-specific scope stays separate'); +$assert(in_array('type-a', explode(' ', $pages['index.html']['document_metadata']['root_attributes']['class'] ?? ''), true), 'entry root scope travels through site plan'); +$assert(in_array('type-b', explode(' ', $pages['other/index.html']['document_metadata']['root_attributes']['class'] ?? ''), true), 'route-specific scope stays separate'); $assert('document' === ($pages['index.html']['document_metadata']['root_attributes']['id'] ?? null), 'root ID travels alongside classes'); $bootstrap = ''; foreach ($plan['writes'] as $write) if ('functions.php' === $write['target_path']) $bootstrap = $write['payload']['data']; -$assert(str_contains($bootstrap, "'language_attributes'") && str_contains($bootstrap, 'WP_HTML_Tag_Processor') && str_contains($bootstrap, "'type-a'") && str_contains($bootstrap, "'type-b'"), 'generated runtime adopts route-owned root identity through native HTML API'); +$assert(str_contains($bootstrap, "'language_attributes'") && str_contains($bootstrap, 'WP_HTML_Tag_Processor') && str_contains($bootstrap, 'type-a be-route-') && str_contains($bootstrap, 'type-b be-route-'), 'generated runtime adopts route-owned root identity through native HTML API'); $assert(str_contains($bootstrap, "'_blocks_engine_reconciliation_identity'") && str_contains($bootstrap, 'is_front_page()'), 'runtime selection uses destination identity with route fallback'); $css = implode("\n", array_column(array_filter($plan['assets'], static fn(array $asset): bool => 'css' === $asset['kind']), 'content')); $assert(str_contains($css, '.type-a') && str_contains($css, '.type-b') && str_contains($css, '@media'), 'authored root selector and conditional cascade stay intact'); diff --git a/php-transformer/tests/unit/wordpress-compat-nav-anchor-replay-growth.php b/php-transformer/tests/unit/wordpress-compat-nav-anchor-replay-growth.php index b2d0b6684..2ae240ebe 100644 --- a/php-transformer/tests/unit/wordpress-compat-nav-anchor-replay-growth.php +++ b/php-transformer/tests/unit/wordpress-compat-nav-anchor-replay-growth.php @@ -64,7 +64,7 @@ foreach ($result['assets'] as $asset) { if (isset($asset['stylesheet_parent'])) $parents[$asset['stylesheet_parent']] = true; } - $assert(isset($parents['site.css'], $parents['menu.css']), 'Multi-page growth fixture produces deltas for two different parent sheets.'); + $assert(!isset($parents['site.css'], $parents['menu.css']), 'Both shared parent sheets keep their route variants in place.'); } $plan = $result['source_reports']['wordpress_site_plan']; $sharedReplay = array_values(array_filter($plan['assets'], static fn (array $asset): bool => 'wordpress-compat' === ($asset['source'] ?? ''))); From 2591c905180f28049f058079b18bf30d7840984e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 26 Oct 2026 19:24:03 -0400 Subject: [PATCH 02/10] Route-scope comment-led, pseudo-class, pseudo-element and at-rule variants --- .../src/Css/CssStylesheetTransformer.php | 238 +++++++++++++++--- .../WordPressSitePlan/DocumentRootContext.php | 2 +- .../WordPressSitePlan/WordPressSitePlan.php | 22 -- .../shared-author-stylesheet-scaling.php | 55 +++- 4 files changed, 259 insertions(+), 58 deletions(-) diff --git a/php-transformer/src/Css/CssStylesheetTransformer.php b/php-transformer/src/Css/CssStylesheetTransformer.php index 8e046c29a..19b287313 100644 --- a/php-transformer/src/Css/CssStylesheetTransformer.php +++ b/php-transformer/src/Css/CssStylesheetTransformer.php @@ -326,22 +326,37 @@ public function foldPositiveMarkerArms(array $shared, array $pages, array $marku * Reconcile ordered page projections at the source cascade position. * Unsupported pages are returned separately for the legacy copy path. * + * Definition at-rules (`@keyframes`, `@font-face`, …) are inert unless a + * declaration references them, so the ones only some routes keep or add + * stay site-wide, once each. Two routes that define the same name with + * different bodies would see each other's definition, so they fall back. + * * @param list $shared * @param array> $pages - * @param array $routeIdentities Reconciliation identities by compilation owner. + * @param array $routeIdentities Route identities by compilation owner. * @return array{stylesheet:string, scoped:list, reasons:array} */ public function scopeRouteVariants(array $shared, array $pages, array $routeIdentities = array(), ?string $unsafeReason = null): array { $base = array(); foreach ($shared as $css) $this->collectRuleUnits($css, array(), $base); - $key = static function (array $unit): string { - $brace = strpos($unit['text'], '{'); - return serialize(array($unit['context'], false === $brace || str_starts_with(ltrim($unit['text']), '@') ? trim($unit['text']) : substr($unit['text'], $brace))); + $normal = static fn (array $unit): string => trim(CssSyntaxScanner::withoutComments($unit['text'])); + $key = function (array $unit) use ($normal): string { + $rule = self::splitLeadingComments($unit['text'])[1]; + $brace = $this->nextRuleBoundary($rule, 0); + $selector = null !== $brace && '{' === $rule[$brace] && !$this->isAtRule($rule); + return serialize(array($unit['context'], $selector ? substr($rule, $brace) : $normal($unit))); }; - $positions = array(); - foreach ($base as $i => $unit) $positions[$key($unit)][] = $i; - $changes = $insertions = $reasons = array(); + $value = static fn (array $unit): string => serialize(array($unit['context'], $normal($unit))); + $positions = $baseDefinitions = $defined = $baseValues = array(); + foreach ($base as $i => $unit) { + $positions[$key($unit)][] = $i; + $baseDefinitions[$i] = $this->routeIndependentDefinition($unit); + if (null === $baseDefinitions[$i]) continue; + $defined[$baseDefinitions[$i]][$value($unit)] = true; + $baseValues[$value($unit)] = true; + } + $changes = $insertions = $siteInsertions = $reasons = array(); $scoped = array(); foreach ($pages as $owner => $sheets) { $units = array(); @@ -353,21 +368,39 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden if (null !== $reason) { $reasons[$owner] = $reason; continue; } foreach ($sheets as $css) $this->collectRuleUnits($css, array(), $units); $cursor = 0; - $localChanges = $localInsertions = array(); + $localChanges = $localInsertions = $localSite = array(); foreach ($units as $unit) { $match = null; foreach ($positions[$key($unit)] ?? array() as $position) { if ($position >= $cursor) { $match = $position; break; } } if (null === $match) { - $localInsertions[$cursor][] = $unit; + if (null !== $this->routeIndependentDefinition($unit)) $localSite[$cursor][] = $unit; + else $localInsertions[$cursor][] = $unit; continue; } while ($cursor < $match) $localChanges[$cursor++] = null; - if (trim($unit['text']) !== trim($base[$match]['text'])) $localChanges[$match] = $unit; + if ($normal($unit) !== $normal($base[$match])) $localChanges[$match] = $unit; $cursor = $match + 1; } while ($cursor < count($base)) $localChanges[$cursor++] = null; + // A definition the route drops or adds stays site-wide, unless the + // route would then see a different body under the same name. + $pageDefined = $defined; + $touched = array(); + foreach ($localChanges as $i => $variant) { + if (null === $baseDefinitions[$i]) continue; + $touched[$baseDefinitions[$i]] = true; + unset($localChanges[$i]); + } + foreach ($localSite as $list) foreach ($list as $unit) { + $name = (string) $this->routeIndependentDefinition($unit); + $touched[$name] = true; + $pageDefined[$name][$value($unit)] = true; + } + foreach (array_keys($touched) as $name) { + if (count($pageDefined[$name] ?? array()) > 1) { $reason = 'conflicting ' . $name; break; } + } // Validate every affected selector before committing any route edits. foreach ($localChanges as $i => $variant) { foreach (array_filter(array($base[$i], $variant)) as $unit) { @@ -379,8 +412,14 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden } if (null !== $reason) { $reasons[$owner] = $reason; continue; } $scoped[] = (string) $owner; + $defined = $pageDefined; foreach ($localChanges as $i => $variant) $changes[$i][$owner] = $variant; foreach ($localInsertions as $i => $list) $insertions[$i][$owner] = $list; + foreach ($localSite as $i => $list) foreach ($list as $unit) { + if (isset($baseValues[$value($unit)])) continue; + $baseValues[$value($unit)] = true; + $siteInsertions[$i][] = $unit; + } } $output = array(); $routeClass = static fn ($owner): string => \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($routeIdentities[$owner] ?? (string) $owner); @@ -394,6 +433,7 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden foreach ($texts as $group) $output[] = $this->scopeRouteUnit($group['unit'], $group['routes'], true); }; for ($i = 0; $i <= count($base); ++$i) { + foreach ($siteInsertions[$i] ?? array() as $unit) $output[] = $unit; $emitGroups($insertions[$i] ?? array()); if ($i === count($base)) break; $variants = $changes[$i] ?? array(); @@ -403,40 +443,178 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden return array('stylesheet' => $this->serializeRuleUnits($output), 'scoped' => $scoped, 'reasons' => $reasons); } + /** + * The name a route-independent definition at-rule binds, or null for any + * other unit. Same-name definitions with different bodies conflict. + * + * @param array{context:list,text:string,dedupable:bool} $unit + */ + private function routeIndependentDefinition(array $unit): ?string + { + $rule = trim(CssSyntaxScanner::withoutComments($unit['text'])); + if (!$this->isAtRule($rule)) return null; + $name = self::atRuleName($rule); + // A late @charset is ignored; dedupe it by text, never conflict. + if ('charset' === $name) return '@charset ' . $rule; + $brace = $this->nextRuleBoundary($rule, 0); + if (null === $brace || '{' !== $rule[$brace] || !str_ends_with($rule, '}')) return null; + $prelude = trim(substr($rule, strlen($name) + 1, $brace - strlen($name) - 1)); + if (preg_match('/^(?:-[a-z]+-)?keyframes$/', $name)) return '@keyframes ' . trim($prelude, '"\''); + if (in_array($name, array('counter-style', 'property', 'font-palette-values'), true)) return '@' . $name . ' ' . $prelude; + if ('font-face' !== $name) return null; + $descriptors = array(); + foreach (CssRuleAnalyzer::declarations(substr($rule, $brace + 1, -1), array('font-family', 'font-style', 'font-weight', 'font-stretch', 'unicode-range')) as $declaration) { + $descriptors[$declaration['name']] = strtolower(trim($declaration['value'], '"\' ')); + } + ksort($descriptors); + return '@font-face ' . json_encode($descriptors); + } + + /** + * Split a unit into the comments before it and the rule itself. + * + * @return array{0:string,1:string} + */ + private static function splitLeadingComments(string $text): array + { + $offset = 0; + $length = strlen($text); + while (true) { + while ($offset < $length && CssSyntaxScanner::isCssWhitespace($text[$offset])) ++$offset; + if (!str_starts_with(substr($text, $offset, 2), '/*')) break; + $close = strpos($text, '*/', $offset + 2); + if (false === $close) break; + $offset = $close + 2; + } + return array(substr($text, 0, $offset), substr($text, $offset)); + } + /** @param array{context:list,text:string,dedupable:bool} $unit @param list $routes */ private function scopeRouteUnit(array $unit, array $routes, bool $positive, ?string &$reason = null): array { - $brace = strpos($unit['text'], '{'); - if (false === $brace || str_starts_with(ltrim($unit['text']), '@') || !$unit['dedupable']) { $reason = 'non-selector rule cannot be route scoped'; return $unit; } - $arms = self::splitSelectorList(trim(substr($unit['text'], 0, $brace))); + [$lead, $rule] = self::splitLeadingComments($unit['text']); + $brace = $this->nextRuleBoundary($rule, 0); + if (null === $brace || '{' !== $rule[$brace] || $this->isAtRule($rule) || !$unit['dedupable']) { $reason = 'non-selector rule cannot be route scoped'; return $unit; } + $arms = self::splitSelectorList(trim(CssSyntaxScanner::withoutComments(substr($rule, 0, $brace)))); if (null === $arms) { $reason = 'unparseable selector list'; return $unit; } $scope = ':where(:root:' . ($positive ? 'is' : 'not') . '(' . implode(',', array_map(static fn (string $route): string => '.' . $route, $routes)) . '))'; + $arms = self::scopeRouteArms($arms, $scope, $reason); + if (null === $arms) return $unit; + $unit['text'] = $lead . implode(',', $arms) . substr($rule, $brace); + return $unit; + } + + /** + * Restrict each comment-free selector arm to documents whose root matches + * $scope, a zero-specificity `:where(:root…)`. The scope prefixes the arm + * as an ancestor; arms whose first compound can be the root also keep a + * copy with the scope merged into that compound. A first compound that + * tests the root inside `:is()`/`:where()` is scoped inside that argument. + * + * @param list $arms + * @return list|null + */ + private static function scopeRouteArms(array $arms, string $scope, ?string &$reason): ?array + { $rootAlternatives = array(); foreach ($arms as &$arm) { $arm = trim($arm); $tokens = CssSelectorTokenizer::tokenize($arm); - if (!$tokens['supported'] || str_contains($arm, '&') || str_contains($arm, '|') || str_contains($arm, '/*')) { $reason = 'unparseable or relative selector'; return $unit; } - if (preg_match('/::|:(?:before|after|first-line|first-letter)\b/i', $arm)) { $reason = 'pseudo-element selector'; return $unit; } - if (!(CssSelectorMatcher::parse($arm)['supported'] ?? false)) { $reason = 'unparseable selector'; return $unit; } + $structural = preg_replace('/"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'|\[[^\]]*\]/s', '', $arm) ?? $arm; + if (!$tokens['supported'] || str_contains($structural, '&') || preg_match('/(? $attributes['html'], 'body_attributes' => $attributes['body']); } - /** Stable document compilation identity, shared by CSS and root reconciliation. */ + /** Stable per-document route class from the compile-time source identity, used by CSS and the root registry alike. */ public static function routeClass(string $identity): string { return 'be-route-' . substr(hash('sha256', $identity), 0, 12); diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php index d92ace297..9ef531467 100644 --- a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php +++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php @@ -453,28 +453,6 @@ public function fromCompilerInput(array $data, WordPressSitePlanInput $input): a unset($template); $runtimeDeclarations = $this->canonicalEntityBindings($runtimeDeclarations, $runtimeEntityRecords, $references, $routeMap, $pages, $bindingTemplates, $paginationOwners); $pages = $this->pageHierarchy($pages, $routeMap); - // Compilation knows source identities; route reconciliation finalizes - // them here. Remap the static CSS and the existing root registry together. - $routeClasses = array(); - foreach ($pages as &$page) { - if (!empty($page['synthetic'])) continue; - $sourceClass = DocumentRootContext::routeClass(self::identity('page', $page['source_path'], $page['source_path'])); - $routeClass = DocumentRootContext::routeClass($page['reconciliation_identity']); - $routeClasses[$sourceClass] = $routeClass; - $root = $page['document_metadata']['root_attributes'] ?? array(); - $root['class'] = trim(str_replace($sourceClass, $routeClass, $root['class'] ?? '')); - if (!in_array($routeClass, explode(' ', $root['class']), true)) $root['class'] = trim($root['class'] . ' ' . $routeClass); - $page['document_metadata']['root_attributes'] = $root; - } - unset($page); - foreach ($assets as &$asset) { - if ('css' !== ($asset['kind'] ?? '') || !is_string($asset['content'] ?? null)) continue; - $asset['content'] = strtr($asset['content'], $routeClasses); - $asset['bytes'] = strlen($asset['content']); - $asset['hash'] = hash('sha256', $asset['content']); - $asset['content_hash'] = self::contentHash($asset['content']); - } - unset($asset); $templateOwners = array(); foreach ($pages as $page) if (empty($page['entrypoint'])) $templateOwners[self::routeTemplateKey($page)][] = $page['source_path']; // Colliding terminal slugs get a readable route-qualified template diff --git a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php index 3bd77c094..2dde3e30e 100644 --- a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php +++ b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php @@ -331,9 +331,9 @@ $assert(1 === count($routeAssets) && !str_contains(implode('', array_column($routePlan['assets'], 'source_path')), '.page-'), 'constant-arm variants, dropped rules and page-only rules need no copies'); $classes = array(); foreach ($routePlan['pages'] as $row) { - $class = Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($row['reconciliation_identity']); + $class = Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass(WordPressSitePlan::identity('page', $row['source_path'], $row['source_path'])); $classes[$row['source_path']] = $class; - $assert(in_array($class, explode(' ', $row['document_metadata']['root_attributes']['class'] ?? ''), true), 'document root carries its reconciliation route class: ' . $row['source_path']); + $assert(in_array($class, explode(' ', $row['document_metadata']['root_attributes']['class'] ?? ''), true), 'document root carries its source route class: ' . $row['source_path']); $functions = array_column($routePlan['writes'], null, 'target_path')['functions.php']['payload']['data']; $assert(str_contains($functions, $class) && str_contains($functions, 'blocksEngineDocumentContext') && str_contains($functions, 'language_attributes'), 'the same root registry reaches front end and editor: ' . $row['source_path']); } @@ -354,7 +354,7 @@ // Each route is compared with its independent public compilation, including // editor wrapper selectors. Apply exactly the root attributes the theme uses. -$probeStyles = static function (array $plan, array $row, bool $editor): array { +$probeStyles = static function (array $plan, array $row, bool $editor, array $properties = array('color', 'background-color', 'padding-top', 'border-top-width')): array { $assets = array_column($plan['assets'], null, 'target_path'); $css = ''; foreach (WordPressSitePlan::documentStylesheets($plan, $row['source_path']) as $sheet) $css .= "\n" . ($assets[$sheet['target_path']]['content'] ?? ''); @@ -369,7 +369,7 @@ if ('img' === $element->tagName) { $styles[] = $cascade->resolve($element, array('width', 'max-width', 'display'), array()); } elseif ('Probe' === trim($element->textContent) && in_array($element->tagName, array('p', 'span', 'a', 'button'), true)) { - $styles[] = $cascade->resolve($element, array('color', 'background-color', 'padding-top', 'border-top-width'), array()); + $styles[] = $cascade->resolve($element, $properties, array()); } } return $styles; @@ -398,7 +398,9 @@ $copies = array_filter($fallbackAssets, static fn (array $asset): bool => str_contains($asset['source_path'], '.page-')); $assert(array() !== $copies && array() === array_filter($copies, static fn (array $asset): bool => str_starts_with($asset['source_path'], 'base.page-')), 'fallback is restricted to the unsafe sheet'); foreach (array( - array('.x::before{color:red}', '.y::before{color:red}', 'pseudo-element'), + array('@keyframes k{to{opacity:0}}.x{color:red}', '.x{color:red}@keyframes k{to{opacity:1}}', 'conflicting @keyframes k'), + array('@font-face{font-family:"F";src:url(a.woff2)}.x{color:red}', '.x{color:red}@font-face{font-family:F;src:url(b.woff2)}', 'conflicting @font-face'), + array('.x{color:red}', '@page{margin:0}.x{color:red}', 'non-selector'), array('.x{color:red}', '& .x{color:red}', 'relative'), array('.x{color:red}', '.x{color:red', 'malformed'), ) as [$base, $variant, $reason]) { @@ -406,6 +408,49 @@ $assert(array() === $routed['scoped'] && str_contains($routed['reasons']['unsafe'] ?? '', $reason), 'unsafe route falls back: ' . $reason); } +// Real-site shapes that used to send a whole page x stylesheet pair back to +// a page copy. Each page's `button` arms are rewritten differently from the +// span page's, so the feature rides in a route variant or a dropped rule. The +// button page's extra `!important` override aligns with `.tail`, so that page +// drops `@keyframes` at its source position and re-adds it after `.tail`. +$probeProperties = array('color', 'background-color', 'padding-top', 'border-top-width', 'margin-top', 'animation-name'); +foreach (array( + 'leading comment' => '/*# sourceMappingURL=x.map */ /*! Lib | a & b {} */ button,.sample{padding:7px}/* #global {color:red} */ .sample{margin-top:3px}', + 'interactive pseudo-classes' => 'button,select:disabled,#lien .content_site:hover .actions,.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3){border-top-width:4px}', + 'pseudo-element arm' => 'button,.sample::-webkit-date-and-time-value,::-moz-focus-inner,[list]:not([type=date])::-webkit-calendar-picker-indicator,.sample::before{padding:5px}', + 'keyframes dropped on one page' => '/*# sourceMappingURL=x.map */@charset "UTF-8";button{padding:7px}@keyframes spinner-border{to{transform:rotate(1turn)}}.tail{padding:7px!important}.sample{animation:spinner-border 1s}', + 'functional root arm' => ':where(:root .editor-styles-wrapper) button,:where(:root .editor-styles-wrapper) .sample[data-empty="true"],:where(:root) .sample{background-color:#abcdef}', +) as $label => $variantCss) { + $variantSheet = array('path' => 'base.css', 'kind' => 'css', 'content' => $variantCss . '.sample{color:#123456}'); + $variantFiles = array_slice($routeFiles, 0, 3); + $variantResult = (new ArtifactCompiler())->compile(array('entrypoint' => 'a.html', 'files' => array_merge($variantFiles, array($variantSheet))))->toArray(); + $variantPlan = $variantResult['source_reports']['wordpress_site_plan']; + $variantFallbacks = array_values(array_filter($variantResult['diagnostics'], static fn (array $row): bool => 'author_stylesheet_route_scope_fallback' === ($row['code'] ?? ''))); + $assert(array() === $variantFallbacks, $label . ': every route is scoped in place', json_encode(array_column($variantFallbacks, 'message'))); + $assert(!str_contains(implode('', array_column($variantPlan['assets'], 'source_path')), '.page-'), $label . ': no page-copy assets'); + $variantText = implode('', $authoredAssets($variantPlan)); + $assert(str_contains($variantText, ':where(:root:'), $label . ': the shared sheet carries route scopes', $variantText); + foreach ($variantPlan['pages'] as $row) { + $file = array_column($variantFiles, null, 'path')[$row['source_path']]; + $alone = (new ArtifactCompiler())->compile(array('entrypoint' => $file['path'], 'files' => array($file, $variantSheet)))->toArray()['source_reports']['wordpress_site_plan']; + foreach (array(false, true) as $editor) { + $actual = $probeStyles($variantPlan, $row, $editor, $probeProperties); + $expected = $probeStyles($alone, $alone['pages'][0], $editor, $probeProperties); + $assert(array() !== $actual && $expected === $actual, $label . ': computed-style parity ' . $row['source_path'] . ($editor ? ' editor' : ' front end'), json_encode(array($expected, $actual))); + } + } + if ('keyframes dropped on one page' === $label) { + $assert(1 === substr_count($variantText, '@keyframes spinner-border') && !preg_match('/:root:[^{]*\{[^{}]*@keyframes/', $variantText), $label . ': the definition is emitted once, unscoped', $variantText); + } + if ('functional root arm' === $label) { + $assert(str_contains($variantText, ':where(:root:where(:root:is(') || str_contains($variantText, ':where(:root:where(:root:not('), $label . ': the route class merges into the inner root compound', $variantText); + } + if ('leading comment' === $label) { + $assert(str_contains($variantText, '/*! Lib | a & b {} */'), $label . ': leading comments are kept inert', $variantText); + } + echo 'Variant fixture (' . $label . ') author CSS: ' . strlen($variantText) . ' bytes in ' . count($authoredAssets($variantPlan)) . " assets\n"; +} + // Identical shared units are one fold match, rewritten at every occurrence. $marker = 'blocks-engine-control-0123456789ab-0'; $unit = '.group>.btn{z-index:1}'; From 56047163740d16bbdf091ac1e375949888934a5f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 26 Oct 2026 20:18:27 -0400 Subject: [PATCH 03/10] Fold page-local marker variants into shared rules before route scoping --- .../src/ArtifactCompiler/ArtifactCompiler.php | 45 +++++- .../src/Css/CssSelectorListCompactor.php | 153 +++++++++++++++++- .../src/Css/CssStylesheetTransformer.php | 153 +++++++++++++++--- .../shared-author-stylesheet-scaling.php | 104 ++++++++++++ .../tests/unit/css-stylesheet-transformer.php | 14 ++ 5 files changed, 434 insertions(+), 35 deletions(-) diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 8584036ea..1fe648db9 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -22,6 +22,7 @@ use Automattic\BlocksEngine\PhpTransformer\Css\AdminBarAccommodation; use Automattic\BlocksEngine\PhpTransformer\Css\TemplatePartStickyContainment; use Automattic\BlocksEngine\PhpTransformer\Css\AuthorCascadeLayerOrder; +use Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorListCompactor; use Automattic\BlocksEngine\PhpTransformer\Css\CssStylesheetTransformer; use Automattic\BlocksEngine\PhpTransformer\Css\CssValueSplitter; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssStylesheetChunker; @@ -50,6 +51,20 @@ final class ArtifactCompiler public const MAX_PLAN_DIGEST_DEPTH = 512; + /** + * Route-scoped variants live in the one sheet every route downloads. When + * the bytes they add (scoped variants and page-only rules, plus the growth + * of shared rules that exclude routes) exceed this factor times the + * largest page copy (`.page-.css`, what one page would load + * on top of the shared sheet), that stylesheet falls back to page copies + * with an `author_stylesheet_route_scope_fallback` diagnostic. A visitor + * then never loads more than about three times the extra CSS of a copy. + */ + public const ROUTE_SCOPE_BYTES_FACTOR = 3; + + /** Route-scoping overhead always allowed, e.g. for routes that only drop rules (their copy is empty). */ + public const ROUTE_SCOPE_BYTES_FLOOR = 8192; + public const INPUT_SCHEMA = 'blocks-engine/php-transformer/site-artifact/v1'; // Plans are a published v1 transport contract. Receipts are additive: v2 // carries terminal reductions while v1 receipts remain composable. @@ -2639,17 +2654,35 @@ private function applyAuthorStylesheetProjections(array $files, array $projectio $sharedStylesheets = $folded['shared']; $fallbackSharedStylesheets = $sharedStylesheets; foreach ($pages as $owner => $_) $pages[$owner] = $folded['pages'][$owner] ?? $pages[$owner]; - $routed = $transformer->scopeRouteVariants($sharedStylesheets, $folded['pages'], $routeIdentities, preg_match('/@namespace\b/i', implode('', $preambles)) ? '@namespace' : null); - $sharedStylesheets = array($routed['stylesheet']); - foreach ($routed['scoped'] as $owner) unset($pages[$owner]); + $routed = $transformer->scopeRouteVariants($sharedStylesheets, $folded['pages'], $routeIdentities, preg_match('/@namespace\b/i', implode('', $preambles)) ? '@namespace' : null, $pageMarkup); + // Folded rules carry expanded marker arms; recompact the serialized + // sheet, merging marker arms into `:where()` lists across routes. + $recompact = static fn (string $css): string => count($folded['pages']) > 1 ? CssSelectorListCompactor::stylesheet($css, true) : $css; + // Bound what route scoping adds to the sheet every route loads by + // what the largest page copy would have added to one page. Both + // are measured before compaction. + $largestCopy = 0; + if (0 < $routed['route_bytes']) { + foreach ($routed['scoped'] as $owner) { + $largestCopy = max($largestCopy, strlen($transformer->rulesAbsentFrom($folded['pages'][$owner] ?? array(), $fallbackSharedStylesheets, true))); + } + } + $routeBudget = max(self::ROUTE_SCOPE_BYTES_FACTOR * $largestCopy, self::ROUTE_SCOPE_BYTES_FLOOR); + if (array() !== $routed['scoped'] && $routed['route_bytes'] > $routeBudget) { + $projectionDiagnostics[] = $this->diagnostic('author_stylesheet_route_scope_fallback', 'warning', sprintf('%s: route-scoped variants add %d bytes to every route, over %d (%dx the largest %d-byte page copy); emitting page copies', $path, $routed['route_bytes'], $routeBudget, self::ROUTE_SCOPE_BYTES_FACTOR, $largestCopy)); + foreach ($routed['scoped'] as $owner) $pages[$owner] ??= $folded['pages'][$owner] ?? array(); + $routed['stylesheet'] = implode("\n", $fallbackSharedStylesheets); + } else { + foreach ($routed['scoped'] as $owner) unset($pages[$owner]); + } foreach ($routed['reasons'] as $owner => $reason) { $projectionDiagnostics[] = $this->diagnostic('author_stylesheet_route_scope_fallback', 'warning', $path . ' (' . $owner . '): ' . $reason); } - $projectedFile = $this->projectedStylesheetFile($file, $path, implode("\n", array_merge($preambles, array( $transformer->concatenateWithoutRedundantRules($sharedStylesheets) )))); + $projectedFile = $this->projectedStylesheetFile($file, $path, implode("\n", array_merge($preambles, array( $recompact($transformer->concatenateWithoutRedundantRules(array($routed['stylesheet']))) )))); $projectedFile['metadata']['native_selectors'] = array_keys($native); $output[] = $projectedFile; foreach ( $pages as $owner => $pageStylesheets ) { - $delta = $transformer->rulesAbsentFrom($pageStylesheets, $fallbackSharedStylesheets, true); + $delta = $recompact($transformer->rulesAbsentFrom($pageStylesheets, $fallbackSharedStylesheets, true)); if ( '' === trim($delta) ) { continue; } @@ -2657,7 +2690,7 @@ private function applyAuthorStylesheetProjections(array $files, array $projectio $pageFile['metadata']['compilation'] = array( 'scope' => 'page', 'id' => (string) $owner ); $pageFile['metadata']['page_stylesheet_of'] = $path; $pageNative = $native; - foreach ($originalPages[$owner] as $content) { + foreach ($originalPages[$owner] ?? array() as $content) { $pageNative += $nativeByContent[$path][$content] ?? array(); } $pageFile['metadata']['native_selectors'] = array_keys($pageNative); diff --git a/php-transformer/src/Css/CssSelectorListCompactor.php b/php-transformer/src/Css/CssSelectorListCompactor.php index aad4f6331..242f815e8 100644 --- a/php-transformer/src/Css/CssSelectorListCompactor.php +++ b/php-transformer/src/Css/CssSelectorListCompactor.php @@ -10,13 +10,18 @@ final class CssSelectorListCompactor private static array $stylesheets = array(); private static int $cachedBytes = 0; - public static function stylesheet(string $css): string + /** + * @param bool $markerLists Also merge arms that differ only by one + * page-local marker class in a `:where()` into `:where(.m1,.m2,…)`. + * Used for the site-wide sheet, where routes contribute marker arms. + */ + public static function stylesheet(string $css, bool $markerLists = false): string { - $key = hash('sha256', $css); + $key = hash('sha256', $css) . ($markerLists ? ':m' : ''); if ( isset(self::$stylesheets[ $key ]) ) { return self::$stylesheets[ $key ]; } - $result = self::compactStylesheet($css); + $result = self::compactStylesheet($css, $markerLists); if ( count(self::$stylesheets) >= 128 || self::$cachedBytes + strlen($result) > 2097152 ) { self::$stylesheets = array(); self::$cachedBytes = 0; @@ -28,12 +33,150 @@ public static function stylesheet(string $css): string return $result; } - private static function compactStylesheet(string $css): string + private static function compactStylesheet(string $css, bool $markerLists = false): string { if ( ! str_contains($css, ',') || preg_match('/@namespace\b/i', $css) ) { return $css; } - return (new CssStylesheetTransformer())->transform($css, static fn(string $prelude): string => self::compact($prelude)); + return (new CssStylesheetTransformer())->transform($css, static fn(string $prelude): string => self::compact($markerLists ? self::mergeMarkerLists($prelude) : $prelude)); + } + + /** + * Merge arms identical but for the page-local marker class inside one + * top-level `:where(.marker)`: `X:where(.m1)Y,X:where(.m2)Y` becomes + * `X:where(.m1,.m2)Y`. `:where()` weighs nothing and matches when any + * argument does, and every argument is a plain class, so the list is + * equivalent. expand() splits such lists back into arms. + */ + public static function mergeMarkerLists(string $prelude): string + { + if ( ! str_contains($prelude, ',') || ! preg_match(self::PAGE_MARKER, $prelude) ) { + return $prelude; + } + $arms = CssStylesheetTransformer::splitSelectorList($prelude); + if ( null === $arms || count($arms) < 2 ) { + return $prelude; + } + $groups = array(); + $order = array(); + foreach ( $arms as $index => $arm ) { + $arm = trim($arm); + $key = "\1" . $index; + $markers = array(); + if ( ! preg_match('/::|:(?:before|after|first-line|first-letter)\b/i', $arm) && ! str_contains($arm, '/*') && false === strpbrk($arm, '\\"\'') && preg_match_all(self::MARKER_WHERE, $arm, $matches, PREG_OFFSET_CAPTURE) ) { + foreach ( $matches[0] as $index2 => [$text, $offset] ) { + $before = substr($arm, 0, $offset); + if ( substr_count($before, '(') !== substr_count($before, ')') ) { + continue; + } + $key = substr($arm, 0, $offset) . "\0" . substr($arm, $offset + strlen($text)); + $markers = explode(',', $matches[1][ $index2 ][0]); + break; + } + } + if ( ! isset($groups[ $key ]) ) { + $order[] = $key; + $groups[ $key ] = array('arm' => $arm, 'markers' => array()); + } + foreach ( $markers as $marker ) { + $groups[ $key ]['markers'][ $marker ] = true; + } + } + if ( count($order) === count($arms) ) { + return $prelude; + } + $output = array(); + foreach ( $order as $key ) { + $group = $groups[ $key ]; + if ( array() === $group['markers'] ) { + $output[] = $group['arm']; + continue; + } + [$prefix, $suffix] = explode("\0", $key, 2); + $output[] = $prefix . ':where(' . implode(',', array_keys($group['markers'])) . ')' . $suffix; + } + return implode(',', $output); + } + + /** A `:where()` whose arguments are page-local marker classes only. */ + private const MARKER_WHERE = '/:where\(((?:\.blocks-engine-[a-z-]+-[a-f0-9]{12}-[0-9]+,)*\.blocks-engine-[a-z-]+-[a-f0-9]{12}-[0-9]+)\)/'; + + /** + * Inverse of compact() for page-local marker lists: expand every whole + * compound `:is(m1,m2,…)` whose alternatives share one specificity in the + * strict grammar, and at least one names a page-local block marker, back + * into one arm per alternative. Equal specificity and strict alternatives + * make the arm list cascade-equivalent to the packed form, so two pages' + * projections of one source rule align arm by arm before reconciliation. + */ + public static function expand(string $prelude): string + { + if ( ! preg_match(self::PAGE_MARKER, $prelude) ) { + return $prelude; + } + $arms = CssStylesheetTransformer::splitSelectorList($prelude); + if ( null === $arms ) { + return $prelude; + } + $output = array(); + $changed = false; + while ( array() !== $arms ) { + $arm = trim((string) array_shift($arms)); + $expanded = self::expandArm($arm); + if ( null === $expanded ) { + $output[] = $arm; + continue; + } + $changed = true; + array_unshift($arms, ...$expanded); + } + return $changed ? implode(',', $output) : $prelude; + } + + /** Page-local block marker class (`blocks-engine---`). */ + public const PAGE_MARKER = '/(?|null */ + private static function expandArm(string $arm): ?array + { + if ( false === strpbrk($arm, '\\"\'') && preg_match_all(self::MARKER_WHERE, $arm, $matches, PREG_OFFSET_CAPTURE) ) { + foreach ( $matches[0] as $index => [$text, $offset] ) { + $before = substr($arm, 0, $offset); + if ( ! str_contains($matches[1][ $index ][0], ',') || substr_count($before, '(') !== substr_count($before, ')') ) { + continue; + } + $suffix = substr($arm, $offset + strlen($text)); + return array_map(static fn(string $marker): string => $before . ':where(' . $marker . ')' . $suffix, explode(',', $matches[1][ $index ][0])); + } + } + if ( ! str_contains($arm, ':is(') ) { + return null; + } + $tokens = CssSelectorTokenizer::tokenize($arm); + if ( ! $tokens['supported'] ) { + return null; + } + foreach ( $tokens['compound_spans'] as $position => $span ) { + $compound = $tokens['compounds'][ $position ]; + if ( ! str_starts_with($compound, ':is(') || ! str_ends_with($compound, ')') || ! preg_match(self::PAGE_MARKER, $compound) ) { + continue; + } + $alternatives = CssStylesheetTransformer::splitSelectorList(substr($compound, 4, -1)); + if ( null === $alternatives || count($alternatives) < 2 ) { + continue; + } + $weights = array(); + foreach ( $alternatives as $alternative ) { + $weights[ (string) self::plainCompoundWeight(trim($alternative)) ] = true; + } + if ( 1 !== count($weights) || isset($weights['']) ) { + continue; + } + $prefix = substr($arm, 0, $span['start']); + $suffix = substr($arm, $span['end']); + return array_map(static fn(string $alternative): string => $prefix . trim($alternative) . $suffix, $alternatives); + } + return null; } public static function compact(string $prelude): string diff --git a/php-transformer/src/Css/CssStylesheetTransformer.php b/php-transformer/src/Css/CssStylesheetTransformer.php index 19b287313..166d32535 100644 --- a/php-transformer/src/Css/CssStylesheetTransformer.php +++ b/php-transformer/src/Css/CssStylesheetTransformer.php @@ -227,6 +227,14 @@ public function rulesAbsentFrom(array $stylesheets, array $present, bool $preser * is deliberately unsafe. Return equivalent page rules using the union so * rulesAbsentFrom() can remove them without moving a cascade suffix. * + * Reconciliation runs on uncompacted arms: per-page projections pack + * marker arms into `P:is(:where(.m1),…)S`, so every compared rule is + * expanded back to one arm per marker (rules left as they are keep their + * text). A marker arm is any arm with a top-level positive + * `:where(.marker)`, wherever it sits in the selector: it cannot match an + * element that does not carry the marker. Folded rules + * are uncompacted; the caller recompacts the final serialized sheet. + * * @param list $shared * @param array> $pages All consuming page projections. * @param array $markup Canonical markup by page owner. @@ -238,25 +246,14 @@ public function foldPositiveMarkerArms(array $shared, array $pages, array $marku foreach (array_merge($shared, ...array_values($pages)) as $css) { if (!$this->isWellFormedStylesheet($css)) return $unchanged; } - $carriers = array(); - foreach ($markup as $owner => $html) { - preg_match_all('/(?+~.:#\[])/', $arm, $match) ? $match[1] : null; - }; + $carriers = self::markerCarriers($markup); + $markerOf = static fn (string $arm): ?string => self::positiveMarker($arm); $describe = static function (array $unit) use ($markerOf): ?array { $brace = strpos($unit['text'], '{'); if (!$unit['dedupable'] || false === $brace) return null; $prelude = trim(substr($unit['text'], 0, $brace)); if (str_starts_with($prelude, '@')) return null; - $arms = self::splitSelectorList($prelude); + $arms = self::splitSelectorList(CssSelectorListCompactor::expand($prelude)); if (null === $arms) return null; $arms = array_map('trim', $arms); $constant = array_values(array_filter($arms, static fn (string $arm): bool => null === $markerOf($arm))); @@ -306,14 +303,22 @@ public function foldPositiveMarkerArms(array $shared, array $pages, array $marku } if ($safe) $union[] = $arm; } - if ($union === $base['arms']) continue; - foreach ($matches as $match) $sharedUnits[$match]['text'] = implode(',', $union) . substr($sharedUnits[$match]['text'], $base['brace']); + if ($union !== $base['arms']) { + foreach ($matches as $match) $sharedUnits[$match]['text'] = implode(',', $union) . substr($sharedUnits[$match]['text'], $base['brace']); + } + // A page rule becomes the shared text when the shared rule only + // adds arms whose marker the page does not carry, whatever the + // packing of either side. foreach ($pageIndex as $owner => $rules) { + if (!isset($markup[$owner])) continue; foreach ($rules[$key] ?? array() as $j) { + if ($pageUnits[$owner][$j]['text'] === $sharedUnits[$i]['text']) continue; $description = $describe($pageUnits[$owner][$j]); - if (array() === array_diff($description['arms'], $union)) { - $pageUnits[$owner][$j]['text'] = $sharedUnits[$i]['text']; + if (array() !== array_diff($description['arms'], $union)) continue; + foreach (array_diff($union, $description['arms']) as $arm) { + if (isset($carriers[(string) $markerOf($arm)][$owner])) continue 2; } + $pageUnits[$owner][$j]['text'] = $sharedUnits[$i]['text']; } } } @@ -322,6 +327,39 @@ public function foldPositiveMarkerArms(array $shared, array $pages, array $marku return $result; } + /** + * Owners whose markup carries each page-local marker. + * + * @param array $markup + * @return array> + */ + private static function markerCarriers(array $markup): array + { + $carriers = array(); + foreach ($markup as $owner => $html) { + preg_match_all(CssSelectorListCompactor::PAGE_MARKER, $html, $matches); + foreach (array_unique($matches[0]) as $marker) $carriers[$marker][$owner] = true; + } + return $carriers; + } + + /** + * The page-local marker an arm requires: a literal `:where(.marker)` at + * selector top level, outside every `:not()`/`:is()`/`:has()` argument. + * Comments, escapes and strings are refused, so a marker negation, a + * selector-list alternative or quoted text never counts as a requirement. + */ + private static function positiveMarker(string $arm): ?string + { + if (str_contains($arm, '/*') || strpbrk($arm, '\\"\'') !== false) return null; + if (!preg_match_all('/:where\(\.(blocks-engine-[a-z-]+-[a-f0-9]{12}-[0-9]+)\)/', $arm, $matches, PREG_OFFSET_CAPTURE)) return null; + foreach ($matches[0] as $index => [, $offset]) { + $before = substr($arm, 0, $offset); + if (substr_count($before, '(') === substr_count($before, ')') && substr_count($before, '[') === substr_count($before, ']')) return $matches[1][$index][0]; + } + return null; + } + /** * Reconcile ordered page projections at the source cascade position. * Unsupported pages are returned separately for the legacy copy path. @@ -333,11 +371,39 @@ public function foldPositiveMarkerArms(array $shared, array $pages, array $marku * * @param list $shared * @param array> $pages + * Rules whose every arm requires a page-local marker (see + * positiveMarker()) need no route scope when $markup proves where the + * markers live: a route that drops such a rule and carries none of its + * markers loses nothing, and a route-only insertion whose markers only + * that route carries is emitted unscoped, merged by arm union with the + * other routes' insertions of the same rule at the same position. + * * @param array $routeIdentities Route identities by compilation owner. - * @return array{stylesheet:string, scoped:list, reasons:array} + * @param array $markup Canonical markup by compilation owner. + * `route_bytes` counts what route scoping adds: positively scoped rules + * plus the growth of negatively scoped shared rules, before compaction. + * + * @return array{stylesheet:string, scoped:list, reasons:array, route_bytes:int} */ - public function scopeRouteVariants(array $shared, array $pages, array $routeIdentities = array(), ?string $unsafeReason = null): array + public function scopeRouteVariants(array $shared, array $pages, array $routeIdentities = array(), ?string $unsafeReason = null, array $markup = array()): array { + $carriers = self::markerCarriers($markup); + // Owners that can carry every marker the unit's arms require, or null + // when some arm can match without a page-local marker. + $markerOwners = function (array $unit) use ($carriers): ?array { + [, $rule] = self::splitLeadingComments($unit['text']); + $brace = $this->nextRuleBoundary($rule, 0); + if (null === $brace || '{' !== $rule[$brace] || $this->isAtRule($rule) || !$unit['dedupable']) return null; + $arms = self::splitSelectorList(CssSelectorListCompactor::expand(substr($rule, 0, $brace))); + if (null === $arms) return null; + $owners = array(); + foreach ($arms as $arm) { + $marker = self::positiveMarker(trim($arm)); + if (null === $marker) return null; + $owners += $carriers[$marker] ?? array(); + } + return $owners; + }; $base = array(); foreach ($shared as $css) $this->collectRuleUnits($css, array(), $base); $normal = static fn (array $unit): string => trim(CssSyntaxScanner::withoutComments($unit['text'])); @@ -384,6 +450,13 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden $cursor = $match + 1; } while ($cursor < count($base)) $localChanges[$cursor++] = null; + $known = isset($markup[$owner]); + foreach ($localChanges as $i => $variant) { + if (null !== $variant || !$known) continue; + $owners = $markerOwners($base[$i]); + // Dropping a rule this route cannot match changes nothing. + if (null !== $owners && !isset($owners[$owner])) unset($localChanges[$i]); + } // A definition the route drops or adds stays site-wide, unless the // route would then see a different body under the same name. $pageDefined = $defined; @@ -407,7 +480,12 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden $this->scopeRouteUnit($unit, array('be-route-probe'), true, $reason); } } - foreach ($localInsertions as $list) foreach ($list as $unit) { + foreach ($localInsertions as $i => $list) foreach ($list as $ordinal => $unit) { + $owners = $known ? $markerOwners($unit) : null; + if (null !== $owners && array((string) $owner) === array_map('strval', array_keys($owners))) { + $localInsertions[$i][$ordinal]['own_markers'] = true; + continue; + } $this->scopeRouteUnit($unit, array('be-route-probe'), true, $reason); } if (null !== $reason) { $reasons[$owner] = $reason; continue; } @@ -423,14 +501,40 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden } $output = array(); $routeClass = static fn ($owner): string => \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($routeIdentities[$owner] ?? (string) $owner); - $emitGroups = function (array $groups) use (&$output, $routeClass): void { + $routeBytes = 0; + $emitGroups = function (array $groups) use (&$output, &$routeBytes, $routeClass): void { $texts = array(); foreach ($groups as $owner => $units) foreach ($units as $ordinal => $unit) { + if (!empty($unit['own_markers'])) { + // Inert off its route: union the arms, no route scope. + unset($unit['own_markers']); + [$lead, $rule] = self::splitLeadingComments($unit['text']); + $brace = (int) $this->nextRuleBoundary($rule, 0); + $groupKey = serialize(array($ordinal, $unit['context'], substr($rule, $brace), 'union')); + $texts[$groupKey]['ordinal'] = $ordinal; + $texts[$groupKey]['unit'] ??= $unit; + $texts[$groupKey]['lead'] ??= $lead; + $texts[$groupKey]['body'] = substr($rule, $brace); + foreach ((array) self::splitSelectorList(CssSelectorListCompactor::expand(substr($rule, 0, $brace))) as $arm) $texts[$groupKey]['arms'][trim($arm)] = true; + continue; + } $groupKey = serialize(array($ordinal, $unit)); + $texts[$groupKey]['ordinal'] = $ordinal; $texts[$groupKey]['unit'] = $unit; $texts[$groupKey]['routes'][] = $routeClass($owner); } - foreach ($texts as $group) $output[] = $this->scopeRouteUnit($group['unit'], $group['routes'], true); + // Each route has one unit per ordinal; ordinal order keeps every + // route's own insertions in its source order. + uasort($texts, static fn (array $a, array $b): int => $a['ordinal'] <=> $b['ordinal']); + foreach ($texts as $group) { + if (isset($group['arms'])) { + $group['unit']['text'] = $group['lead'] . implode(',', array_keys($group['arms'])) . $group['body']; + $output[] = $group['unit']; + continue; + } + $output[] = $this->scopeRouteUnit($group['unit'], $group['routes'], true); + $routeBytes += strlen(end($output)['text']); + } }; for ($i = 0; $i <= count($base); ++$i) { foreach ($siteInsertions[$i] ?? array() as $unit) $output[] = $unit; @@ -438,9 +542,10 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden if ($i === count($base)) break; $variants = $changes[$i] ?? array(); $output[] = $variants ? $this->scopeRouteUnit($base[$i], array_map($routeClass, array_keys($variants)), false) : $base[$i]; + if ($variants) $routeBytes += strlen($output[count($output) - 1]['text']) - strlen($base[$i]['text']); $emitGroups(array_map(static fn ($unit): array => null === $unit ? array() : array($unit), $variants)); } - return array('stylesheet' => $this->serializeRuleUnits($output), 'scoped' => $scoped, 'reasons' => $reasons); + return array('stylesheet' => $this->serializeRuleUnits($output), 'scoped' => $scoped, 'reasons' => $reasons, 'route_bytes' => $routeBytes); } /** diff --git a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php index 2dde3e30e..a15b5da72 100644 --- a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php +++ b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php @@ -451,6 +451,110 @@ echo 'Variant fixture (' . $label . ') author CSS: ' . strlen($variantText) . ' bytes in ' . count($authoredAssets($variantPlan)) . " assets\n"; } +// Bounded route scoping. One long selector list is projected three ways +// (span, button and image pages), so in-place variants would repeat every +// arm under route scopes on the sheet every route loads: several times what +// one page copy costs. That sheet falls back to page copies, with a reason. +$boundArms = array(); +for ($i = 0; $i < 60; ++$i) $boundArms[] = '.long-selector-arm-' . $i; +$boundSheet = array('path' => 'base.css', 'kind' => 'css', 'content' => '.sample{color:#123456}' . implode(',', $boundArms) . ',button,img{outline-style:solid;letter-spacing:1px;padding:3px}'); +$boundFiles = array(); +foreach (array('Probe', 'Probe', 'Probe', '', 'P', 'P') as $i => $body) { + $boundFiles[] = array('path' => 'bound-' . $i . '.html', 'kind' => 'html', 'content' => 'Bound
' . $body . '
'); +} +$bound = (new ArtifactCompiler())->compile(array('entrypoint' => 'bound-0.html', 'files' => array_merge($boundFiles, array($boundSheet))))->toArray(); +$boundPlan = $bound['source_reports']['wordpress_site_plan']; +$boundFindings = array_values(array_filter($bound['diagnostics'], static fn (array $row): bool => 'author_stylesheet_route_scope_fallback' === ($row['code'] ?? '') && str_contains($row['message'], 'largest'))); +$assert(1 === count($boundFindings) && str_contains($boundFindings[0]['message'], 'base.css'), 'over-budget route scoping emits one sheet-level fallback diagnostic', json_encode(array_column($bound['diagnostics'], 'message'))); +$boundAssets = $authoredAssets($boundPlan); +$assert(count($boundAssets) > 1 && !str_contains(implode('', $boundAssets), 'be-route-'), 'over-budget route scoping falls back to page copies without route scopes', json_encode(array_map('strlen', $boundAssets))); +foreach ($boundPlan['pages'] as $row) { + $file = array_column($boundFiles, null, 'path')[$row['source_path']]; + $alone = (new ArtifactCompiler())->compile(array('entrypoint' => $file['path'], 'files' => array($file, $boundSheet)))->toArray()['source_reports']['wordpress_site_plan']; + $boundProperties = array('color', 'outline-style', 'letter-spacing'); + $actual = $probeStyles($boundPlan, $row, false, $boundProperties); + $expected = $probeStyles($alone, $alone['pages'][0], false, $boundProperties); + $assert(array() !== $actual && $expected === $actual, 'fallback computed-style parity ' . $row['source_path'], json_encode(array($expected, $actual))); +} +echo 'Bound fixture author CSS: ' . json_encode(array_map('strlen', $boundAssets)) . "\n"; + +// Rules that differ between pages only by page-local block markers. Every +// page has its own buttons in a `.row-cols-3` grid, so `.row-cols-3>*` and +// `.row>*` project onto each page's own per-element markers, which the page +// projection packs as `>:is(:where(.m1),…)`. Those differences fold into one +// unscoped union in the shared rule: the sheet grows by marker names only, +// never by a route-scoped copy of the rule per page. +$markerCss = '.row{display:flex;flex-wrap:wrap}.row-cols-3>*{flex:0 0 auto;width:33.3%}.row>*{flex-shrink:0;max-width:100%}' + . '.btn{background-color:#123456;color:#fff;border-radius:4px;padding:8px 16px}'; +for ($i = 0; $i < 120; ++$i) $markerCss .= '.marker-tail-' . $i . '{color:#123456;margin:0;padding:2px;border-top-width:1px}'; +$markerSheet = array('path' => 'base.css', 'kind' => 'css', 'content' => $markerCss); +$markerFile = static function (int $index, bool $gaps = false): array { + $buttons = ''; + if (!$gaps || 2 !== $index % 3) for ($k = 0; $k <= $index % 3; ++$k) $buttons .= 'Buy ' . $index . '.' . $k . ''; + return array('path' => 'm' . $index . '.html', 'kind' => 'html', 'content' => 'Marker ' . $index . '

Page ' . $index . '

' . $buttons . '
'); +}; +$markerSite = static function (int $count, bool $gaps = false) use ($markerFile, $markerSheet, $authoredAssets): array { + $files = array(); + for ($i = 0; $i < $count; ++$i) $files[] = $markerFile($i, $gaps); + $result = (new ArtifactCompiler())->compile(array('entrypoint' => 'm0.html', 'files' => array_merge($files, array($markerSheet))))->toArray(); + $plan = $result['source_reports']['wordpress_site_plan']; + return array('plan' => $plan, 'assets' => $authoredAssets($plan), 'diagnostics' => $result['diagnostics']); +}; +$markerSizes = array(); +foreach (array(2, 6, 10) as $count) { + $site = $markerSite($count); + $text = implode('', $site['assets']); + $markerSizes[$count] = strlen($text); + $assert(1 === count($site['assets']) && !str_contains(implode('', array_column($site['plan']['assets'], 'source_path')), '.page-'), $count . ' marker-only pages: one site-wide asset, no page copies', json_encode(array_keys($site['assets']))); + $assert(!str_contains($text, 'be-route-'), $count . ' marker-only pages: nothing is route scoped', $text); + $assert(array() === array_filter($site['diagnostics'], static fn (array $row): bool => 'author_stylesheet_route_scope_fallback' === ($row['code'] ?? '')), $count . ' marker-only pages: no route-scope fallback'); +} +$markerText = implode('', $site['assets']); +$assert((bool) preg_match('/:where\((?:\.blocks-engine-control-[a-f0-9]{12}-\d+,){3,}/', $markerText), 'page-local markers fold into one recompacted :where() list', $markerText); +// Ten pages add only their marker names: under a fifth of the two-page +// sheet. Route-scoped copies grew it by about 2.3 KB per page. +$assert($markerSizes[10] < 1.2 * $markerSizes[2] && $markerSizes[10] - $markerSizes[6] < 1.2 * ($markerSizes[6] - $markerSizes[2]) + 512, 'site-wide sheet bytes stay roughly constant in the page count', json_encode($markerSizes)); +$markerProbe = static function (array $plan, array $row): array { + $assets = array_column($plan['assets'], null, 'target_path'); + $css = ''; + foreach (WordPressSitePlan::documentStylesheets($plan, $row['source_path']) as $sheet) $css .= "\n" . ($assets[$sheet['target_path']]['content'] ?? ''); + $dom = new DOMDocument(); + @$dom->loadHTML('' . StaticStyleParityRunner::candidateHtmlFromSerializedBlocks($row['canonical_block_markup']) . ''); + foreach ($row['document_metadata']['root_attributes'] ?? array() as $name => $value) $dom->documentElement->setAttribute($name, $value); + $cascade = new StaticCssCascade($dom, $css); + $styles = array(); + foreach ($dom->getElementsByTagName('*') as $element) { + if (!$element instanceof DOMElement) continue; + $classes = ' ' . $element->getAttribute('class') . ' '; + if (str_contains($classes, ' wp-block-buttons ') || str_contains($classes, ' wp-block-button ') || str_contains($classes, ' wp-block-button__link ')) { + $styles[] = $cascade->resolve($element, array('width', 'flex-shrink', 'max-width', 'background-color', 'color'), array()); + } + } + return $styles; +}; +// With every third page empty, the empty pages' projection is the shared +// one: the other pages' marker rules are page-only. Each is inert off its +// own page, so they join the sheet as unscoped unions; only rules whose arms +// can match without a marker carry a route scope. +$gapSite = $markerSite(9, true); +$gapText = implode('', $gapSite['assets']); +$scopedMarkerRule = false; +$transformer->visitStyleRules($gapText, static function (string $prelude) use (&$scopedMarkerRule): void { + if (str_contains($prelude, 'be-route-') && preg_match('/blocks-engine-control-[a-f0-9]{12}-\d+/', $prelude)) $scopedMarkerRule = true; +}); +$assert(1 === count($gapSite['assets']) && !$scopedMarkerRule, 'page-only marker rules fold into unscoped unions', $gapText); +foreach (array(array($site, false), array($gapSite, true)) as [$checked, $gaps]) { + foreach ($checked['plan']['pages'] as $row) { + $index = (int) substr($row['source_path'], 1); + $alone = (new ArtifactCompiler())->compile(array('entrypoint' => $row['source_path'], 'files' => array($markerFile($index, $gaps), $markerSheet)))->toArray()['source_reports']['wordpress_site_plan']; + $actual = $markerProbe($checked['plan'], $row); + $expected = $markerProbe($alone, $alone['pages'][0]); + $hasGrid = !$gaps || 2 !== $index % 3; + $assert($expected === $actual && $hasGrid === in_array('33.3%', array_column($actual, 'width'), true), 'marker-only computed-style parity ' . $row['source_path'] . ($gaps ? ' (with empty pages)' : ''), json_encode(array($expected, $actual))); + } +} +echo 'Marker-only fixture author CSS: ' . json_encode($markerSizes) . " bytes for 2/6/10 pages\n"; + // Identical shared units are one fold match, rewritten at every occurrence. $marker = 'blocks-engine-control-0123456789ab-0'; $unit = '.group>.btn{z-index:1}'; diff --git a/php-transformer/tests/unit/css-stylesheet-transformer.php b/php-transformer/tests/unit/css-stylesheet-transformer.php index 101c79af6..15a5267cd 100644 --- a/php-transformer/tests/unit/css-stylesheet-transformer.php +++ b/php-transformer/tests/unit/css-stylesheet-transformer.php @@ -213,6 +213,20 @@ static function (string $name, string $body) use (&$visitedKeyframes): void { $assert($namespaceCss === \Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorListCompactor::stylesheet($namespaceCss), 'namespace-sensitive sheets remain unchanged'); $splitRules = '.catalog .one{color:red}.catalog .other{color:blue}.catalog .two{color:red}'; $assert($splitRules === \Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorListCompactor::stylesheet($splitRules), 'identical declarations never merge across an intervening rule'); +// Page-local marker lists expand back into arms for cross-page alignment and +// merge into one zero-weight :where() list for the site-wide sheet. +$compactor = \Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorListCompactor::class; +$m1 = 'blocks-engine-control-4041d29655d8-0'; +$m2 = 'blocks-engine-control-f9b2c417568b-0'; +$packed = $compact('.row-cols-6>:where(.' . $m1 . '),.row-cols-6>:where(.' . $m2 . ')'); +$assert('.row-cols-6>:is(:where(.' . $m1 . '),:where(.' . $m2 . '))' === $packed, 'per-page projection packs marker arms'); +$assert('.row-cols-6>:where(.' . $m1 . '),.row-cols-6>:where(.' . $m2 . ')' === $compactor::expand($packed), 'packed marker arms expand back into one arm per marker'); +$merged = $compactor::mergeMarkerLists('.row-cols-6>:where(.' . $m1 . '):not(.be-c-0),#x,.row-cols-6>:where(.' . $m2 . '):not(.be-c-0)'); +$assert('.row-cols-6>:where(.' . $m1 . ',.' . $m2 . '):not(.be-c-0),#x' === $merged, 'arms differing by one marker merge into a :where() list'); +$assert('.row-cols-6>:where(.' . $m1 . '):not(.be-c-0),.row-cols-6>:where(.' . $m2 . '):not(.be-c-0),#x' === $compactor::expand($merged), 'merged marker lists expand back into arms'); +$assert('.a :is(.one,.two)' === $compactor::expand('.a :is(.one,.two)') && ':not(:where(.' . $m1 . ',.' . $m2 . ')) a,b' === $compactor::mergeMarkerLists(':not(:where(.' . $m1 . ',.' . $m2 . ')) a,b'), 'non-marker lists and negated marker lists stay as authored'); +$unequalMarkers = '.a :is(:where(.' . $m1 . '),#b)'; +$assert($unequalMarkers === $compactor::expand($unequalMarkers), 'unequal-weight alternatives are never expanded'); $before = '.catalog .one,.catalog .two,.catalog .three'; $after = $compact($before); $matcher = \Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorMatcher::class; From ff5af83c71dfc5137d123006b44bdc2404f28378 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 26 Oct 2026 22:16:00 -0400 Subject: [PATCH 04/10] Reconcile route stylesheet variants per selector arm --- .../src/ArtifactCompiler/ArtifactCompiler.php | 15 +- .../src/Css/CssSelectorListCompactor.php | 11 + .../src/Css/CssStylesheetTransformer.php | 251 +++++------------- .../shared-author-stylesheet-scaling.php | 109 ++++++-- 4 files changed, 176 insertions(+), 210 deletions(-) diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 1fe648db9..62c5422f9 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -2650,27 +2650,24 @@ private function applyAuthorStylesheetProjections(array $files, array $projectio } unset($contents); $sharedStylesheets = array($transformer->concatenateWithoutRedundantRules($sharedStylesheets)); - $folded = $transformer->foldPositiveMarkerArms($sharedStylesheets, $pageProjections, $pageMarkup); - $sharedStylesheets = $folded['shared']; $fallbackSharedStylesheets = $sharedStylesheets; - foreach ($pages as $owner => $_) $pages[$owner] = $folded['pages'][$owner] ?? $pages[$owner]; - $routed = $transformer->scopeRouteVariants($sharedStylesheets, $folded['pages'], $routeIdentities, preg_match('/@namespace\b/i', implode('', $preambles)) ? '@namespace' : null, $pageMarkup); - // Folded rules carry expanded marker arms; recompact the serialized - // sheet, merging marker arms into `:where()` lists across routes. - $recompact = static fn (string $css): string => count($folded['pages']) > 1 ? CssSelectorListCompactor::stylesheet($css, true) : $css; + foreach ($pages as $owner => $_) $pages[$owner] = $pageProjections[$owner] ?? $pages[$owner]; + $routed = $transformer->scopeRouteVariants($sharedStylesheets, $pageProjections, $routeIdentities, preg_match('/@namespace\b/i', implode('', $preambles)) ? '@namespace' : null, $pageMarkup); + // Recompact only at serialization, merging compatible marker arms. + $recompact = static fn (string $css): string => count($pageProjections) > 1 ? CssSelectorListCompactor::stylesheet($css, true) : $css; // Bound what route scoping adds to the sheet every route loads by // what the largest page copy would have added to one page. Both // are measured before compaction. $largestCopy = 0; if (0 < $routed['route_bytes']) { foreach ($routed['scoped'] as $owner) { - $largestCopy = max($largestCopy, strlen($transformer->rulesAbsentFrom($folded['pages'][$owner] ?? array(), $fallbackSharedStylesheets, true))); + $largestCopy = max($largestCopy, strlen($transformer->rulesAbsentFrom($pageProjections[$owner] ?? array(), $fallbackSharedStylesheets, true))); } } $routeBudget = max(self::ROUTE_SCOPE_BYTES_FACTOR * $largestCopy, self::ROUTE_SCOPE_BYTES_FLOOR); if (array() !== $routed['scoped'] && $routed['route_bytes'] > $routeBudget) { $projectionDiagnostics[] = $this->diagnostic('author_stylesheet_route_scope_fallback', 'warning', sprintf('%s: route-scoped variants add %d bytes to every route, over %d (%dx the largest %d-byte page copy); emitting page copies', $path, $routed['route_bytes'], $routeBudget, self::ROUTE_SCOPE_BYTES_FACTOR, $largestCopy)); - foreach ($routed['scoped'] as $owner) $pages[$owner] ??= $folded['pages'][$owner] ?? array(); + foreach ($routed['scoped'] as $owner) $pages[$owner] ??= $pageProjections[$owner] ?? array(); $routed['stylesheet'] = implode("\n", $fallbackSharedStylesheets); } else { foreach ($routed['scoped'] as $owner) unset($pages[$owner]); diff --git a/php-transformer/src/Css/CssSelectorListCompactor.php b/php-transformer/src/Css/CssSelectorListCompactor.php index 242f815e8..8b03b78b5 100644 --- a/php-transformer/src/Css/CssSelectorListCompactor.php +++ b/php-transformer/src/Css/CssSelectorListCompactor.php @@ -139,6 +139,17 @@ public static function expand(string $prelude): string /** @return list|null */ private static function expandArm(string $arm): ?array { + // Richtext carriers use style attributes rather than marker classes. + $identity = 'blocks-engine-richtext-[a-f0-9]{12}-[0-9]+'; + $richtext = '(?:mark\\[style\\*="--blocks-engine-richtext-marker:' . $identity . ';"\\]|mark\\[style\\$="--blocks-engine-richtext-marker:' . $identity . '"\\]|span\\[data-blocks-engine-richtext-marker="' . $identity . '"\\])'; + if ( ! str_contains($arm, '\\') && preg_match_all('/:where\\(((?:' . $richtext . ',)+' . $richtext . ')\\)/', $arm, $lists, PREG_OFFSET_CAPTURE) ) { + foreach ( $lists[0] as $index => [$text, $offset] ) { + $before = substr($arm, 0, $offset); + if ( substr_count($before, '(') !== substr_count($before, ')') ) continue; + $suffix = substr($arm, $offset + strlen($text)); + return array_map(static fn(string $marker): string => $before . ':where(' . $marker . ')' . $suffix, explode(',', $lists[1][$index][0])); + } + } if ( false === strpbrk($arm, '\\"\'') && preg_match_all(self::MARKER_WHERE, $arm, $matches, PREG_OFFSET_CAPTURE) ) { foreach ( $matches[0] as $index => [$text, $offset] ) { $before = substr($arm, 0, $offset); diff --git a/php-transformer/src/Css/CssStylesheetTransformer.php b/php-transformer/src/Css/CssStylesheetTransformer.php index 166d32535..088168984 100644 --- a/php-transformer/src/Css/CssStylesheetTransformer.php +++ b/php-transformer/src/Css/CssStylesheetTransformer.php @@ -220,113 +220,6 @@ public function rulesAbsentFrom(array $stylesheets, array $present, bool $preser return $this->serializeRuleUnits($kept); } - /** - * Fold page-only positive identity-marker arms into an unambiguous shared - * rule, at its original cascade position. A carrier on any consuming page - * must have projected the very same arm on that rule. Missing page evidence - * is deliberately unsafe. Return equivalent page rules using the union so - * rulesAbsentFrom() can remove them without moving a cascade suffix. - * - * Reconciliation runs on uncompacted arms: per-page projections pack - * marker arms into `P:is(:where(.m1),…)S`, so every compared rule is - * expanded back to one arm per marker (rules left as they are keep their - * text). A marker arm is any arm with a top-level positive - * `:where(.marker)`, wherever it sits in the selector: it cannot match an - * element that does not carry the marker. Folded rules - * are uncompacted; the caller recompacts the final serialized sheet. - * - * @param list $shared - * @param array> $pages All consuming page projections. - * @param array $markup Canonical markup by page owner. - * @return array{shared: list, pages: array>} - */ - public function foldPositiveMarkerArms(array $shared, array $pages, array $markup): array - { - $unchanged = array('shared' => $shared, 'pages' => $pages); - foreach (array_merge($shared, ...array_values($pages)) as $css) { - if (!$this->isWellFormedStylesheet($css)) return $unchanged; - } - $carriers = self::markerCarriers($markup); - $markerOf = static fn (string $arm): ?string => self::positiveMarker($arm); - $describe = static function (array $unit) use ($markerOf): ?array { - $brace = strpos($unit['text'], '{'); - if (!$unit['dedupable'] || false === $brace) return null; - $prelude = trim(substr($unit['text'], 0, $brace)); - if (str_starts_with($prelude, '@')) return null; - $arms = self::splitSelectorList(CssSelectorListCompactor::expand($prelude)); - if (null === $arms) return null; - $arms = array_map('trim', $arms); - $constant = array_values(array_filter($arms, static fn (string $arm): bool => null === $markerOf($arm))); - sort($constant); - return array('key' => serialize(array($unit['context'], substr($unit['text'], $brace), $constant)), 'arms' => $arms, 'brace' => $brace); - }; - $sharedUnits = array(); - foreach ($shared as $css) $this->collectRuleUnits($css, array(), $sharedUnits); - $index = array(); - foreach ($sharedUnits as $i => $unit) { - $description = $describe($unit); - if (null !== $description) $index[$description['key']][] = $i; - } - $pageUnits = array(); - $pageIndex = array(); - foreach ($pages as $owner => $sheets) { - $pageUnits[$owner] = array(); - foreach ($sheets as $css) $this->collectRuleUnits($css, array(), $pageUnits[$owner]); - foreach ($pageUnits[$owner] as $i => $unit) { - $description = $describe($unit); - if (null !== $description) $pageIndex[$owner][$description['key']][] = $i; - } - } - foreach ($index as $key => $matches) { - if (1 !== count(array_unique(array_map(static fn (int $i): string => $sharedUnits[$i]['text'], $matches)))) continue; - $i = $matches[0]; - $base = $describe($sharedUnits[$i]); - $union = $base['arms']; - $candidates = array(); - foreach ($pageIndex as $owner => $rules) { - $pageMatches = $rules[$key] ?? array(); - if (!$pageMatches || 1 !== count(array_unique(array_map(static fn (int $j): string => $pageUnits[$owner][$j]['text'], $pageMatches)))) continue; - $description = $describe($pageUnits[$owner][$pageMatches[0]]); - foreach (array_diff($description['arms'], $union) as $arm) { - $marker = $markerOf($arm); - if (null !== $marker) $candidates[$arm] = $marker; - } - } - foreach ($candidates as $arm => $marker) { - $safe = true; - if (array_diff_key($pages, $markup)) $safe = false; - foreach ($carriers[$marker] ?? array() as $owner => $_) { - $rules = $pageIndex[$owner][$key] ?? array(); - if (!$rules) $safe = false; - foreach ($rules as $j) if (!in_array($arm, $describe($pageUnits[$owner][$j])['arms'], true)) $safe = false; - if (!$safe) break; - } - if ($safe) $union[] = $arm; - } - if ($union !== $base['arms']) { - foreach ($matches as $match) $sharedUnits[$match]['text'] = implode(',', $union) . substr($sharedUnits[$match]['text'], $base['brace']); - } - // A page rule becomes the shared text when the shared rule only - // adds arms whose marker the page does not carry, whatever the - // packing of either side. - foreach ($pageIndex as $owner => $rules) { - if (!isset($markup[$owner])) continue; - foreach ($rules[$key] ?? array() as $j) { - if ($pageUnits[$owner][$j]['text'] === $sharedUnits[$i]['text']) continue; - $description = $describe($pageUnits[$owner][$j]); - if (array() !== array_diff($description['arms'], $union)) continue; - foreach (array_diff($union, $description['arms']) as $arm) { - if (isset($carriers[(string) $markerOf($arm)][$owner])) continue 2; - } - $pageUnits[$owner][$j]['text'] = $sharedUnits[$i]['text']; - } - } - } - $result = array('shared' => array($this->serializeRuleUnits($sharedUnits)), 'pages' => array()); - foreach ($pageUnits as $owner => $units) $result['pages'][$owner] = array($this->serializeRuleUnits($units)); - return $result; - } - /** * Owners whose markup carries each page-local marker. * @@ -346,16 +239,19 @@ private static function markerCarriers(array $markup): array /** * The page-local marker an arm requires: a literal `:where(.marker)` at * selector top level, outside every `:not()`/`:is()`/`:has()` argument. - * Comments, escapes and strings are refused, so a marker negation, a - * selector-list alternative or quoted text never counts as a requirement. + * Also accepts the literal richtext-marker style attribute form. Nested + * alternatives, negations, comments and escapes do not prove a carrier. */ private static function positiveMarker(string $arm): ?string { - if (str_contains($arm, '/*') || strpbrk($arm, '\\"\'') !== false) return null; - if (!preg_match_all('/:where\(\.(blocks-engine-[a-z-]+-[a-f0-9]{12}-[0-9]+)\)/', $arm, $matches, PREG_OFFSET_CAPTURE)) return null; + if (str_contains($arm, '/*') || str_contains($arm, '\\')) return null; + $marker = '(blocks-engine-[a-z-]+-[a-f0-9]{12}-[0-9]+)'; + $pattern = '/:where\((?:\.' . $marker . '|mark\[style\*="--blocks-engine-richtext-marker:' . $marker . ';"\]|mark\[style\$="--blocks-engine-richtext-marker:' . $marker . '"\]|span\[data-blocks-engine-richtext-marker="' . $marker . '"\])\)/'; + if (!preg_match_all($pattern, $arm, $matches, PREG_OFFSET_CAPTURE)) return null; foreach ($matches[0] as $index => [, $offset]) { $before = substr($arm, 0, $offset); - if (substr_count($before, '(') === substr_count($before, ')') && substr_count($before, '[') === substr_count($before, ']')) return $matches[1][$index][0]; + if (0 !== self::parenDepth($before) || substr_count($before, '[') !== substr_count($before, ']')) continue; + foreach (array_slice($matches, 1) as $captures) if ('' !== ($captures[$index][0] ?? '')) return $captures[$index][0]; } return null; } @@ -371,39 +267,21 @@ private static function positiveMarker(string $arm): ?string * * @param list $shared * @param array> $pages - * Rules whose every arm requires a page-local marker (see - * positiveMarker()) need no route scope when $markup proves where the - * markers live: a route that drops such a rule and carries none of its - * markers loses nothing, and a route-only insertion whose markers only - * that route carries is emitted unscoped, merged by arm union with the - * other routes' insertions of the same rule at the same position. + * Aligned rules are expanded into arms, each with its own route set. + * Marker arms inert outside that set stay unscoped; all others use the + * shorter positive or complementary negative scope. Only validated lists + * are split, preserving invalid-selector isolation and source order. * * @param array $routeIdentities Route identities by compilation owner. * @param array $markup Canonical markup by compilation owner. - * `route_bytes` counts what route scoping adds: positively scoped rules - * plus the growth of negatively scoped shared rules, before compaction. + * `route_bytes` counts scope and split-rule overhead beyond the unscoped + * arm union, plus scoped insertions, before final compaction. * * @return array{stylesheet:string, scoped:list, reasons:array, route_bytes:int} */ public function scopeRouteVariants(array $shared, array $pages, array $routeIdentities = array(), ?string $unsafeReason = null, array $markup = array()): array { $carriers = self::markerCarriers($markup); - // Owners that can carry every marker the unit's arms require, or null - // when some arm can match without a page-local marker. - $markerOwners = function (array $unit) use ($carriers): ?array { - [, $rule] = self::splitLeadingComments($unit['text']); - $brace = $this->nextRuleBoundary($rule, 0); - if (null === $brace || '{' !== $rule[$brace] || $this->isAtRule($rule) || !$unit['dedupable']) return null; - $arms = self::splitSelectorList(CssSelectorListCompactor::expand(substr($rule, 0, $brace))); - if (null === $arms) return null; - $owners = array(); - foreach ($arms as $arm) { - $marker = self::positiveMarker(trim($arm)); - if (null === $marker) return null; - $owners += $carriers[$marker] ?? array(); - } - return $owners; - }; $base = array(); foreach ($shared as $css) $this->collectRuleUnits($css, array(), $base); $normal = static fn (array $unit): string => trim(CssSyntaxScanner::withoutComments($unit['text'])); @@ -450,13 +328,6 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden $cursor = $match + 1; } while ($cursor < count($base)) $localChanges[$cursor++] = null; - $known = isset($markup[$owner]); - foreach ($localChanges as $i => $variant) { - if (null !== $variant || !$known) continue; - $owners = $markerOwners($base[$i]); - // Dropping a rule this route cannot match changes nothing. - if (null !== $owners && !isset($owners[$owner])) unset($localChanges[$i]); - } // A definition the route drops or adds stays site-wide, unless the // route would then see a different body under the same name. $pageDefined = $defined; @@ -480,12 +351,7 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden $this->scopeRouteUnit($unit, array('be-route-probe'), true, $reason); } } - foreach ($localInsertions as $i => $list) foreach ($list as $ordinal => $unit) { - $owners = $known ? $markerOwners($unit) : null; - if (null !== $owners && array((string) $owner) === array_map('strval', array_keys($owners))) { - $localInsertions[$i][$ordinal]['own_markers'] = true; - continue; - } + foreach ($localInsertions as $list) foreach ($list as $unit) { $this->scopeRouteUnit($unit, array('be-route-probe'), true, $reason); } if (null !== $reason) { $reasons[$owner] = $reason; continue; } @@ -502,48 +368,67 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden $output = array(); $routeClass = static fn ($owner): string => \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($routeIdentities[$owner] ?? (string) $owner); $routeBytes = 0; - $emitGroups = function (array $groups) use (&$output, &$routeBytes, $routeClass): void { - $texts = array(); - foreach ($groups as $owner => $units) foreach ($units as $ordinal => $unit) { - if (!empty($unit['own_markers'])) { - // Inert off its route: union the arms, no route scope. - unset($unit['own_markers']); - [$lead, $rule] = self::splitLeadingComments($unit['text']); - $brace = (int) $this->nextRuleBoundary($rule, 0); - $groupKey = serialize(array($ordinal, $unit['context'], substr($rule, $brace), 'union')); - $texts[$groupKey]['ordinal'] = $ordinal; - $texts[$groupKey]['unit'] ??= $unit; - $texts[$groupKey]['lead'] ??= $lead; - $texts[$groupKey]['body'] = substr($rule, $brace); - foreach ((array) self::splitSelectorList(CssSelectorListCompactor::expand(substr($rule, 0, $brace))) as $arm) $texts[$groupKey]['arms'][trim($arm)] = true; - continue; + $allRoutes = array_fill_keys(array_keys($pages), true); + $emitArms = function (array $units, array $prototype, bool $insertion = false) use (&$output, &$routeBytes, $routeClass, $allRoutes, $carriers, $markup): void { + [$lead, $rule] = self::splitLeadingComments($prototype['text']); + $brace = (int) $this->nextRuleBoundary($rule, 0); + $body = substr($rule, $brace); + $members = array(); + foreach ($units as $owner => $unit) { + if (null === $unit) continue; + [, $text] = self::splitLeadingComments($unit['text']); + $start = (int) $this->nextRuleBoundary($text, 0); + foreach ((array) self::splitSelectorList(CssSelectorListCompactor::expand(CssSyntaxScanner::withoutComments(substr($text, 0, $start)))) as $arm) { + $members[trim($arm)][$owner] = true; } - $groupKey = serialize(array($ordinal, $unit)); - $texts[$groupKey]['ordinal'] = $ordinal; - $texts[$groupKey]['unit'] = $unit; - $texts[$groupKey]['routes'][] = $routeClass($owner); } - // Each route has one unit per ordinal; ordinal order keeps every - // route's own insertions in its source order. - uasort($texts, static fn (array $a, array $b): int => $a['ordinal'] <=> $b['ordinal']); - foreach ($texts as $group) { - if (isset($group['arms'])) { - $group['unit']['text'] = $group['lead'] . implode(',', array_keys($group['arms'])) . $group['body']; - $output[] = $group['unit']; - continue; + $groups = array(); + foreach ($members as $arm => $routes) { + $outside = array_diff_key($allRoutes, $routes); + $marker = self::positiveMarker($arm); + $inert = !$outside || (null !== $marker && !array_diff_key($outside, $markup) && !array_diff_key($carriers[$marker] ?? array(), $routes)); + $scope = ''; + if (!$inert) { + $positive = ':where(:root:is(' . implode(',', array_map(static fn ($owner): string => '.' . $routeClass($owner), array_keys($routes))) . '))'; + $negative = ':where(:root:not(' . implode(',', array_map(static fn ($owner): string => '.' . $routeClass($owner), array_keys($outside))) . '))'; + $scope = strlen($positive) <= strlen($negative) ? $positive : $negative; } - $output[] = $this->scopeRouteUnit($group['unit'], $group['routes'], true); - $routeBytes += strlen(end($output)['text']); + $groups[$scope][] = $arm; } + $emittedBytes = 0; + $unionBytes = strlen($lead . implode(',', array_keys($members)) . $body); + foreach ($groups as $scope => $arms) { + $unit = $prototype; + if ('' !== $scope) { + $reason = null; + $arms = self::scopeRouteArms($arms, $scope, $reason); + } + $unit['text'] = $lead . implode(',', $arms) . $body; + $output[] = $unit; + $emittedBytes += strlen($unit['text']); + // A scoped insertion is entirely new shared-download cost, + // not just the prefix added to its unscoped selector list. + if ($insertion && '' !== $scope) $routeBytes += strlen(implode(',', $groups[$scope]) . $body); + $lead = ''; + } + $routeBytes += max(0, $emittedBytes - $unionBytes); }; for ($i = 0; $i <= count($base); ++$i) { foreach ($siteInsertions[$i] ?? array() as $unit) $output[] = $unit; - $emitGroups($insertions[$i] ?? array()); + $groups = array(); + foreach ($insertions[$i] ?? array() as $owner => $units) foreach ($units as $ordinal => $unit) { + $groupKey = serialize(array($ordinal, $key($unit))); + $groups[$groupKey]['ordinal'] = $ordinal; + $groups[$groupKey]['unit'] = $unit; + $groups[$groupKey]['routes'][$owner] = $unit; + } + uasort($groups, static fn (array $a, array $b): int => $a['ordinal'] <=> $b['ordinal']); + foreach ($groups as $group) $emitArms($group['routes'], $group['unit'], true); if ($i === count($base)) break; - $variants = $changes[$i] ?? array(); - $output[] = $variants ? $this->scopeRouteUnit($base[$i], array_map($routeClass, array_keys($variants)), false) : $base[$i]; - if ($variants) $routeBytes += strlen($output[count($output) - 1]['text']) - strlen($base[$i]['text']); - $emitGroups(array_map(static fn ($unit): array => null === $unit ? array() : array($unit), $variants)); + if (empty($changes[$i])) { $output[] = $base[$i]; continue; } + $units = array(); + foreach ($allRoutes as $owner => $_) $units[$owner] = array_key_exists($owner, $changes[$i]) ? $changes[$i][$owner] : $base[$i]; + $emitArms($units, $base[$i]); } return array('stylesheet' => $this->serializeRuleUnits($output), 'scoped' => $scoped, 'reasons' => $reasons, 'route_bytes' => $routeBytes); } diff --git a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php index a15b5da72..a836c0609 100644 --- a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php +++ b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php @@ -275,7 +275,7 @@ } echo 'Context fixture author CSS: ' . $contextBytes . " bytes\n"; -// Compare the old enqueue cascade with the folded one directly, including +// Compare the old enqueue cascade with arm reconciliation directly, including // every overlapping tail rule. Markers absent from another page are inert. $transformer = new Automattic\BlocksEngine\PhpTransformer\Css\CssStylesheetTransformer(); $shared = '.group>.btn{z-index:1}'; @@ -287,27 +287,25 @@ $projections[$i] = array(str_replace('.group>.btn', '.group>.btn,:where(.' . $marker . ')', $shared)); $markup[$i] = '

Probe

'; } -$folded = $transformer->foldPositiveMarkerArms(array($shared), $projections, $markup); +$routed = $transformer->scopeRouteVariants(array($shared), $projections, array(), null, $markup); foreach ($projections as $owner => $sheets) { $before = $shared . $transformer->rulesAbsentFrom($sheets, array($shared), true); - $after = implode('', $folded['shared']) . $transformer->rulesAbsentFrom($folded['pages'][$owner], $folded['shared'], true); + $after = $routed['stylesheet']; $document = new DOMDocument(); @$document->loadHTML($markup[$owner]); $element = $document->getElementsByTagName('p')->item(0); $oldStyle = (new StaticCssCascade($document, $before))->resolve($element, array('z-index'), array()); $newStyle = (new StaticCssCascade($document, $after))->resolve($element, array('z-index'), array()); - $assert($oldStyle === $newStyle && '2' === (string) ($newStyle['z-index'] ?? ''), 'folding preserves the original enqueue cascade on page ' . $owner); + $assert($oldStyle === $newStyle && '2' === (string) ($newStyle['z-index'] ?? ''), 'arm reconciliation preserves the original enqueue cascade on page ' . $owner); } // A reused identity is not proof of a shared match: both pages carry the -// marker, but only one projected the arm. Keep it out of the marker fold. +// marker, but only one projected the arm. Scope that arm alone. $marker = 'blocks-engine-control-0123456789ab-0'; $base = '.btn-group>.btn{z-index:1}.tail{z-index:2}'; $extra = '.btn-group>.btn,:where(.' . $marker . ')>.wp-block-button__link{z-index:1}.tail{z-index:2}'; -$folded = $transformer->foldPositiveMarkerArms(array($base), array('a' => array($extra), 'b' => array($base)), array('a' => '
', 'b' => '
')); -$assert(!str_contains(implode('', $folded['shared']), $marker), 'a marker carried by a non-projecting page is not folded'); -$routed = $transformer->scopeRouteVariants($folded['shared'], $folded['pages']); -$assert(str_contains($routed['stylesheet'], $marker) && 2 === count($routed['scoped']), 'an arm unsafe for marker folding can still be route scoped in place'); +$routed = $transformer->scopeRouteVariants(array($base), array('a' => array($extra), 'b' => array($base)), array(), null, array('a' => '
', 'b' => '
')); +$assert(str_contains($routed['stylesheet'], $marker) && 2 === count($routed['scoped']), 'a shared marker arm is route scoped in place'); // Real constant-arm variants, a dropped control override, and page-only // image bridge rules. Identical span routes must share one positive rule. @@ -451,10 +449,9 @@ echo 'Variant fixture (' . $label . ') author CSS: ' . strlen($variantText) . ' bytes in ' . count($authoredAssets($variantPlan)) . " assets\n"; } -// Bounded route scoping. One long selector list is projected three ways -// (span, button and image pages), so in-place variants would repeat every -// arm under route scopes on the sheet every route loads: several times what -// one page copy costs. That sheet falls back to page copies, with a reason. +// A long selector list projected three ways used to exhaust the byte bound +// by copying every arm into whole-rule variants. Only differing arms now +// carry scope overhead, so this fixture stays below the safety budget. $boundArms = array(); for ($i = 0; $i < 60; ++$i) $boundArms[] = '.long-selector-arm-' . $i; $boundSheet = array('path' => 'base.css', 'kind' => 'css', 'content' => '.sample{color:#123456}' . implode(',', $boundArms) . ',button,img{outline-style:solid;letter-spacing:1px;padding:3px}'); @@ -465,9 +462,9 @@ $bound = (new ArtifactCompiler())->compile(array('entrypoint' => 'bound-0.html', 'files' => array_merge($boundFiles, array($boundSheet))))->toArray(); $boundPlan = $bound['source_reports']['wordpress_site_plan']; $boundFindings = array_values(array_filter($bound['diagnostics'], static fn (array $row): bool => 'author_stylesheet_route_scope_fallback' === ($row['code'] ?? '') && str_contains($row['message'], 'largest'))); -$assert(1 === count($boundFindings) && str_contains($boundFindings[0]['message'], 'base.css'), 'over-budget route scoping emits one sheet-level fallback diagnostic', json_encode(array_column($bound['diagnostics'], 'message'))); +$assert(array() === $boundFindings, 'arm-level scoping avoids the former whole-rule byte-budget fallback', json_encode(array_column($bound['diagnostics'], 'message'))); $boundAssets = $authoredAssets($boundPlan); -$assert(count($boundAssets) > 1 && !str_contains(implode('', $boundAssets), 'be-route-'), 'over-budget route scoping falls back to page copies without route scopes', json_encode(array_map('strlen', $boundAssets))); +$assert(1 === count($boundAssets) && !str_contains(implode('', array_keys($boundAssets)), '.page-'), 'long unchanged arm lists stay in one site-wide asset', json_encode(array_map('strlen', $boundAssets))); foreach ($boundPlan['pages'] as $row) { $file = array_column($boundFiles, null, 'path')[$row['source_path']]; $alone = (new ArtifactCompiler())->compile(array('entrypoint' => $file['path'], 'files' => array($file, $boundSheet)))->toArray()['source_reports']['wordpress_site_plan']; @@ -478,6 +475,28 @@ } echo 'Bound fixture author CSS: ' . json_encode(array_map('strlen', $boundAssets)) . "\n"; +// The safety net still trips when route-list overhead itself dominates the +// largest copy, even though no whole selector lists are duplicated anymore. +$budgetFiles = array(); +for ($i = 0; $i < 24; ++$i) { + $file = $boundFiles[$i % count($boundFiles)]; + $file['path'] = 'budget-' . $i . '.html'; + $budgetFiles[] = $file; +} +$budgetCss = '.sample{color:#123456}'; +for ($i = 0; $i < 80; ++$i) $budgetCss .= 'button,img{outline-style:solid;letter-spacing:' . $i . 'px;padding:3px}'; +$budgetSheet = array('path' => 'base.css', 'kind' => 'css', 'content' => $budgetCss); +$budgetResult = (new ArtifactCompiler())->compile(array('entrypoint' => 'budget-0.html', 'files' => array_merge($budgetFiles, array($budgetSheet))))->toArray(); +$budgetFindings = array_filter($budgetResult['diagnostics'], static fn (array $row): bool => 'author_stylesheet_route_scope_fallback' === ($row['code'] ?? '') && str_contains($row['message'], 'largest')); +$budgetPlan = $budgetResult['source_reports']['wordpress_site_plan']; +$assert(1 === count($budgetFindings) && str_contains(implode('', array_column($budgetPlan['assets'], 'source_path')), '.page-'), 'excessive route-list overhead retains the sheet-level byte-budget fallback'); +foreach (array_slice($budgetPlan['pages'], 0, 2) as $row) { + $file = array_column($budgetFiles, null, 'path')[$row['source_path']]; + $alone = (new ArtifactCompiler())->compile(array('entrypoint' => $file['path'], 'files' => array($file, $budgetSheet)))->toArray()['source_reports']['wordpress_site_plan']; + $properties = array('color', 'outline-style', 'letter-spacing'); + $assert($probeStyles($alone, $alone['pages'][0], false, $properties) === $probeStyles($budgetPlan, $row, false, $properties), 'byte-budget fallback computed-style parity ' . $row['source_path']); +} + // Rules that differ between pages only by page-local block markers. Every // page has its own buttons in a `.row-cols-3` grid, so `.row-cols-3>*` and // `.row>*` project onto each page's own per-element markers, which the page @@ -555,12 +574,66 @@ } echo 'Marker-only fixture author CSS: ' . json_encode($markerSizes) . " bytes for 2/6/10 pages\n"; -// Identical shared units are one fold match, rewritten at every occurrence. +// Mixed real-site rule shapes: hundreds of safe marker arms must not inherit +// a shared-chrome arm's scope. Richtext swaps and a dropped plain rule use +// the same per-arm membership path, including complementary scopes. +$armSizes = array(); +foreach (array(2, 10, 30) as $count) { + $chrome = 'blocks-engine-control-aaaaaaaaaaaa-0'; + $richA = 'blocks-engine-richtext-bbbbbbbbbbbb-0'; + $richB = 'blocks-engine-richtext-cccccccccccc-0'; + $richArm = static fn (string $marker): string => ':where(mark[style*="--blocks-engine-richtext-marker:' . $marker . ';"])'; + $safeArms = array('.plain'); + for ($j = 0; $j < 200; ++$j) $safeArms[] = ':where(.blocks-engine-control-' . substr(hash('sha256', 'safe-' . $j), 0, 12) . '-0)'; + $common = implode(',', $safeArms); + $liveSafe = 'blocks-engine-control-' . substr(hash('sha256', 'safe-0'), 0, 12) . '-0'; + $base = '@media(min-width:1px){' . $common . ',:where(.' . $chrome . '){color:red}' . $richArm($richA) . '{background-color:blue}.drop{padding-top:7px}}'; + $pages = $markup = array(); + for ($j = 0; $j < $count; ++$j) { + $pages['arm-' . $j] = array('@media(min-width:1px){' . $common . (0 === $j ? ',:where(.' . $chrome . ')' : '') . '{color:red}' . $richArm(0 === $j ? $richA : $richB) . '{background-color:blue}' . (1 === $j ? '' : '.drop{padding-top:7px}') . '}'); + $markup['arm-' . $j] = '

Chrome

AB'; + } + $result = $transformer->scopeRouteVariants(array($base), $pages, array(), null, $markup); + $text = Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorListCompactor::stylesheet($result['stylesheet'], true); + $armSizes[$count] = strlen($text); + $assert(count($result['scoped']) === $count && array() === $result['reasons'], $count . ' mixed-arm routes reconcile without page-copy fallback'); + $assert(1 === substr_count($text, 'blocks-engine-control-' . substr(hash('sha256', 'safe-0'), 0, 12)), 'one shared-chrome arm never duplicates its safe sibling list'); + foreach ($pages as $owner => $sheets) { + $dom = new DOMDocument(); + @$dom->loadHTML('' . $markup[$owner] . ''); + $dom->documentElement->setAttribute('class', Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($owner)); + $expected = new StaticCssCascade($dom, implode('', $sheets)); + $actual = new StaticCssCascade($dom, $text); + foreach (array('p', 'mark') as $tag) foreach ($dom->getElementsByTagName($tag) as $element) { + $properties = array('color', 'background-color', 'padding-top'); + $assert($expected->resolve($element, $properties, array()) === $actual->resolve($element, $properties, array()), 'mixed-arm computed-style parity ' . $count . ' routes: ' . $owner . ' ' . $element->textContent); + } + } + $assert(str_contains($text, ':root:not(') || 2 === $count, 'the dropped rule uses the shorter complementary scope'); +} +$assert($armSizes[30] < 1.1 * $armSizes[2], 'mixed-arm site-wide bytes stay roughly constant in N', json_encode($armSizes)); + +// Private richtext carriers (including packed style$/editor-span variants) +// can be emitted unscoped, but negations and unknown carriers cannot. +$richOne = 'blocks-engine-richtext-dddddddddddd-0'; +$richTwo = 'blocks-engine-richtext-eeeeeeeeeeee-0'; +$richList = static fn (string $marker): string => ':where(mark[style*="--blocks-engine-richtext-marker:' . $marker . ';"],mark[style$="--blocks-engine-richtext-marker:' . $marker . '"],span[data-blocks-engine-richtext-marker="' . $marker . '"])'; +$richPages = array('one' => array($richList($richOne) . '{color:red}'), 'two' => array($richList($richTwo) . '{color:red}')); +$richMarkup = array('one' => 'One', 'two' => 'Two'); +$privateRich = $transformer->scopeRouteVariants($richPages['one'], $richPages, array(), null, $richMarkup); +$assert(!str_contains($privateRich['stylesheet'], 'be-route-'), 'private richtext marker swaps need no route scopes'); +$unknownRich = $transformer->scopeRouteVariants($richPages['one'], $richPages); +$assert(str_contains($unknownRich['stylesheet'], 'be-route-'), 'missing carrier evidence never proves richtext arms inert'); +$negated = ':not(:where(.blocks-engine-control-aaaaaaaaaaaa-0)){color:red}'; +$negativeMarker = $transformer->scopeRouteVariants(array($negated), array('one' => array($negated), 'two' => array('')), array(), null, array('one' => '', 'two' => '')); +$assert(str_contains($negativeMarker['stylesheet'], 'be-route-'), 'negated markers are not positive carrier requirements'); + +// Identical shared units align separately at each source occurrence. $marker = 'blocks-engine-control-0123456789ab-0'; $unit = '.group>.btn{z-index:1}'; $variant = '.group>.btn,:where(.' . $marker . '){z-index:1}'; -$duplicates = $transformer->foldPositiveMarkerArms(array($unit . '.between{z-index:2}' . $unit), array('a' => array($variant . '.between{z-index:2}' . $variant)), array('a' => '

Probe

')); -$assert(2 === substr_count(implode('', $duplicates['shared']), $marker), 'marker folding rewrites every byte-identical shared unit without moving it'); +$duplicates = $transformer->scopeRouteVariants(array($unit . '.between{z-index:2}' . $unit), array('a' => array($variant . '.between{z-index:2}' . $variant)), array(), null, array('a' => '

Probe

')); +$assert(2 === substr_count($duplicates['stylesheet'], $marker), 'arm reconciliation rewrites every aligned shared unit without moving it'); if ( 0 < $failures ) { fwrite(STDERR, "shared-author-stylesheet scaling FAILED: {$passes} passed, {$failures} failed\n"); From aaa70bd6347c77fa334c67c6aa4ffca0c823eb9d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 26 Oct 2026 23:30:34 -0400 Subject: [PATCH 05/10] Leave route variant arms unscoped where outside routes cannot match them --- .../src/ArtifactCompiler/ArtifactCompiler.php | 23 ++++- .../src/Css/CssStylesheetTransformer.php | 7 +- .../src/Css/RouteSelectorMatchIndex.php | 82 +++++++++++++++++ .../shared-author-stylesheet-scaling.php | 87 +++++++++++++++++-- 4 files changed, 185 insertions(+), 14 deletions(-) create mode 100644 php-transformer/src/Css/RouteSelectorMatchIndex.php diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 62c5422f9..ef278143e 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -2564,11 +2564,30 @@ private function applyAuthorStylesheetProjections(array $files, array $projectio } $transformer = new CssStylesheetTransformer(); $routeIdentities = array(); + $routeDocuments = array(); foreach ($files as $document) { if ('html' !== ($document['kind'] ?? '')) continue; $source = (string) $document['path']; - $routeIdentities[$this->fileOwnership($document)['id']] = \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\WordPressSitePlan::identity('page', $source, $source); + $owner = $this->fileOwnership($document)['id']; + $routeIdentities[$owner] = \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\WordPressSitePlan::identity('page', $source, $source); + if (!isset($pageMarkup[$owner])) continue; + // Parse the lowered markup once per route, never once per arm or + // stylesheet. Retain source root/body context just as projection + // does; block comments are inert to the HTML parser. + $dom = new DOMDocument(); + $previousErrors = libxml_use_internal_errors(true); + $loaded = $dom->loadHTML('' . $pageMarkup[$owner] . ''); + libxml_clear_errors(); + libxml_use_internal_errors($previousErrors); + if (!$loaded) continue; + $attributes = \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::metadataFromHtml((string) ($document['content'] ?? '')); + foreach (array('html' => 'root_attributes', 'body' => 'body_attributes') as $tag => $key) { + $element = $dom->getElementsByTagName($tag)->item(0); + foreach ($attributes[$key] ?? array() as $name => $value) $element?->setAttribute($name, $value); + } + $routeDocuments[$owner] = $dom; } + $routeMatchIndex = new \Automattic\BlocksEngine\PhpTransformer\Css\RouteSelectorMatchIndex($routeDocuments); $reserved = array_fill_keys(array_column($files, 'path'), true); $output = array(); foreach ( $files as $file ) { @@ -2652,7 +2671,7 @@ private function applyAuthorStylesheetProjections(array $files, array $projectio $sharedStylesheets = array($transformer->concatenateWithoutRedundantRules($sharedStylesheets)); $fallbackSharedStylesheets = $sharedStylesheets; foreach ($pages as $owner => $_) $pages[$owner] = $pageProjections[$owner] ?? $pages[$owner]; - $routed = $transformer->scopeRouteVariants($sharedStylesheets, $pageProjections, $routeIdentities, preg_match('/@namespace\b/i', implode('', $preambles)) ? '@namespace' : null, $pageMarkup); + $routed = $transformer->scopeRouteVariants($sharedStylesheets, $pageProjections, $routeIdentities, preg_match('/@namespace\b/i', implode('', $preambles)) ? '@namespace' : null, $pageMarkup, $routeMatchIndex); // Recompact only at serialization, merging compatible marker arms. $recompact = static fn (string $css): string => count($pageProjections) > 1 ? CssSelectorListCompactor::stylesheet($css, true) : $css; // Bound what route scoping adds to the sheet every route loads by diff --git a/php-transformer/src/Css/CssStylesheetTransformer.php b/php-transformer/src/Css/CssStylesheetTransformer.php index 088168984..029162840 100644 --- a/php-transformer/src/Css/CssStylesheetTransformer.php +++ b/php-transformer/src/Css/CssStylesheetTransformer.php @@ -268,7 +268,7 @@ private static function positiveMarker(string $arm): ?string * @param list $shared * @param array> $pages * Aligned rules are expanded into arms, each with its own route set. - * Marker arms inert outside that set stay unscoped; all others use the + * Arms proven inert outside that set stay unscoped; all others use the * shorter positive or complementary negative scope. Only validated lists * are split, preserving invalid-selector isolation and source order. * @@ -279,7 +279,7 @@ private static function positiveMarker(string $arm): ?string * * @return array{stylesheet:string, scoped:list, reasons:array, route_bytes:int} */ - public function scopeRouteVariants(array $shared, array $pages, array $routeIdentities = array(), ?string $unsafeReason = null, array $markup = array()): array + public function scopeRouteVariants(array $shared, array $pages, array $routeIdentities = array(), ?string $unsafeReason = null, array $markup = array(), ?RouteSelectorMatchIndex $matchIndex = null): array { $carriers = self::markerCarriers($markup); $base = array(); @@ -369,7 +369,7 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden $routeClass = static fn ($owner): string => \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($routeIdentities[$owner] ?? (string) $owner); $routeBytes = 0; $allRoutes = array_fill_keys(array_keys($pages), true); - $emitArms = function (array $units, array $prototype, bool $insertion = false) use (&$output, &$routeBytes, $routeClass, $allRoutes, $carriers, $markup): void { + $emitArms = function (array $units, array $prototype, bool $insertion = false) use (&$output, &$routeBytes, $routeClass, $allRoutes, $carriers, $markup, $matchIndex): void { [$lead, $rule] = self::splitLeadingComments($prototype['text']); $brace = (int) $this->nextRuleBoundary($rule, 0); $body = substr($rule, $brace); @@ -387,6 +387,7 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden $outside = array_diff_key($allRoutes, $routes); $marker = self::positiveMarker($arm); $inert = !$outside || (null !== $marker && !array_diff_key($outside, $markup) && !array_diff_key($carriers[$marker] ?? array(), $routes)); + if (!$inert && null !== $matchIndex) $inert = $matchIndex->inertOutside($arm, $outside); $scope = ''; if (!$inert) { $positive = ':where(:root:is(' . implode(',', array_map(static fn ($owner): string => '.' . $routeClass($owner), array_keys($routes))) . '))'; diff --git a/php-transformer/src/Css/RouteSelectorMatchIndex.php b/php-transformer/src/Css/RouteSelectorMatchIndex.php new file mode 100644 index 000000000..675f6ed15 --- /dev/null +++ b/php-transformer/src/Css/RouteSelectorMatchIndex.php @@ -0,0 +1,82 @@ + $documents */ + public function __construct(array $documents) + { + $this->cache = new CssSelectorMatchCache(); + foreach ($documents as $owner => $document) { + $this->elements[$owner] = array(); + $this->keys[$owner] = array(); + foreach ($document->getElementsByTagName('*') as $element) { + $this->elements[$owner][] = $element; + $keys = array('t:' . strtolower($element->tagName)); + if ($element->hasAttribute('id')) $keys[] = 'i:' . $element->getAttribute('id'); + foreach ($this->cache->classTokens($element) as $class) $keys[] = 'c:' . $class; + foreach ($keys as $key) $this->keys[$owner][$key][] = $element; + } + } + } + + /** Missing documents and unsupported selectors are never evidence of inertness. */ + public function inertOutside(string $arm, array $outside): bool + { + if (!isset($this->selectors[$arm])) { + // Match the structural subject in every state, as the projector does. + // Do not rewrite quoted attributes or functional negations: those + // require reasoning about state alternatives, so retain their scope. + $structural = $arm; + if (false === strpbrk($arm, '\\"\'') && !str_contains($arm, ':not(')) { + $structural = preg_replace('/:(?:hover|focus-visible|focus-within|focus|active|visited|any-link|link|disabled|enabled|target)(?![\w(-])/i', '', $arm) ?? $arm; + } + $parsed = CssSelectorMatcher::parse($structural); + $keys = array(); + if ($parsed['supported']) { + $leaf = $parsed['compounds'][count($parsed['compounds']) - 1]; + foreach ($leaf['classes'] as $class) $keys[] = 'c:' . $class; + // An id also matches an editor anchor carrier: either key + // can supply candidates, never require the literal id alone. + foreach ($leaf['ids'] as $id) { + $keys[] = array('i:' . $id, 'c:' . \Automattic\BlocksEngine\PhpTransformer\Support\EngineMarker::editorAnchorClass($id)); + } + if (null !== $leaf['type']) $keys[] = 't:' . strtolower($leaf['type']); + } + $this->selectors[$arm] = array($parsed, $keys); + } + [$parsed, $keys] = $this->selectors[$arm]; + if (!$parsed['supported']) return false; + foreach ($outside as $owner => $_) { + if (!isset($this->elements[$owner])) return false; + if (!isset($this->hits[$arm][$owner])) { + $candidates = $this->elements[$owner]; + foreach ($keys as $key) { + $list = array(); + foreach ((array) $key as $alternative) $list = array_merge($list, $this->keys[$owner][$alternative] ?? array()); + if (count($list) < count($candidates)) $candidates = $list; + if (!$candidates) break; + } + $hit = false; + foreach ($candidates as $element) { + $match = CssSelectorMatcher::matches($element, $parsed, true, $this->cache); + if (!$match['supported'] || $match['matches']) { $hit = true; break; } + } + $this->hits[$arm][$owner] = $hit; + } + if ($this->hits[$arm][$owner]) return false; + } + return true; + } +} diff --git a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php index a836c0609..971cc17e4 100644 --- a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php +++ b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php @@ -336,19 +336,19 @@ $assert(str_contains($functions, $class) && str_contains($functions, 'blocksEngineDocumentContext') && str_contains($functions, 'language_attributes'), 'the same root registry reaches front end and editor: ' . $row['source_path']); } $group = ':where(:root:is(.' . $classes['b.html'] . ',.' . $classes['c.html'] . '))'; -$assert(str_contains($routeText, $group . ' .sample'), 'byte-identical constant-arm variants group both span routes'); -$assert(str_contains($routeText, 'html:where(:root:') && str_contains($routeText, ':root:where(:root:'), 'html and root scopes merge into the leftmost compound'); +$assert(str_contains($routeText, '.sample{'), 'plain variants inert on other routes remain shared'); +$assert(str_contains($routeText, 'html .sample') && str_contains($routeText, ':root .sample'), 'inert html and root arms retain their original compounds'); $droppedOverride = false; $conditionalImage = false; $transformer->visitStyleRules($routeText, static function (string $prelude, string $body, array $context) use (&$droppedOverride, &$conditionalImage, $classes): void { if (str_contains($body, 'padding:7px!important')) { - $droppedOverride = in_array('@media(min-width:1px)', $context, true) && str_contains($prelude, ':root:not(.' . $classes['b.html'] . ',.' . $classes['c.html'] . ')'); + $droppedOverride = in_array('@media(min-width:1px)', $context, true); } - if (str_contains($prelude, '.wp-block-image') && str_contains($prelude, ':root:is(.' . $classes['e.html'] . ')')) $conditionalImage = in_array('@media(min-width:1px)', $context, true); + if (str_contains($prelude, '.wp-block-image')) $conditionalImage = in_array('@media(min-width:1px)', $context, true); }); -$assert($droppedOverride, 'the dropped override excludes both span routes at its original conditional position'); +$assert($droppedOverride, 'the inert dropped override retains its original conditional position'); $assert($conditionalImage, 'page-only image rules retain their at-rule context'); -$assert(str_contains($routeText, ':where(:root:is(.' . $classes['e.html'] . '))'), 'page-only image bridge has a positive route scope'); +$assert(!str_contains($routeText, ':where(:root:is(.' . $classes['e.html'] . '))'), 'page-only image bridge inert elsewhere needs no positive route scope'); // Each route is compared with its independent public compilation, including // editor wrapper selectors. Apply exactly the root attributes the theme uses. @@ -475,8 +475,8 @@ } echo 'Bound fixture author CSS: ' . json_encode(array_map('strlen', $boundAssets)) . "\n"; -// The safety net still trips when route-list overhead itself dominates the -// largest copy, even though no whole selector lists are duplicated anymore. +// This former safety-net fixture now avoids route-list overhead altogether: +// absent button/image arms are provably inert on the other routes. $budgetFiles = array(); for ($i = 0; $i < 24; ++$i) { $file = $boundFiles[$i % count($boundFiles)]; @@ -489,7 +489,7 @@ $budgetResult = (new ArtifactCompiler())->compile(array('entrypoint' => 'budget-0.html', 'files' => array_merge($budgetFiles, array($budgetSheet))))->toArray(); $budgetFindings = array_filter($budgetResult['diagnostics'], static fn (array $row): bool => 'author_stylesheet_route_scope_fallback' === ($row['code'] ?? '') && str_contains($row['message'], 'largest')); $budgetPlan = $budgetResult['source_reports']['wordpress_site_plan']; -$assert(1 === count($budgetFindings) && str_contains(implode('', array_column($budgetPlan['assets'], 'source_path')), '.page-'), 'excessive route-list overhead retains the sheet-level byte-budget fallback'); +$assert(array() === array_values($budgetFindings) && !str_contains(implode('', array_column($budgetPlan['assets'], 'source_path')), '.page-'), 'inert plain arms avoid the former route-list byte-budget fallback'); foreach (array_slice($budgetPlan['pages'], 0, 2) as $row) { $file = array_column($budgetFiles, null, 'path')[$row['source_path']]; $alone = (new ArtifactCompiler())->compile(array('entrypoint' => $file['path'], 'files' => array($file, $budgetSheet)))->toArray()['source_reports']['wordpress_site_plan']; @@ -628,6 +628,75 @@ $negativeMarker = $transformer->scopeRouteVariants(array($negated), array('one' => array($negated), 'two' => array('')), array(), null, array('one' => '', 'two' => '')); $assert(str_contains($negativeMarker['stylesheet'], 'be-route-'), 'negated markers are not positive carrier requirements'); +// Plain arms are absent because they do not match, not because of marker +// ownership. Keep the authored input fixed as N increases. A native-lowered +// arm absent from a projection but still matching its DOM MUST stay scoped. +$plainSizes = array(); +$plainStarted = microtime(true); +$plainArms = array(); +for ($k = 0; $k < 112; ++$k) $plainArms[] = '#page-only-' . $k . ' .x'; +$plainBase = '.native{z-index:9}' . implode(',', $plainArms) . '{color:red}'; +foreach (array(2, 12, 112) as $count) { + $plainPages = $plainMarkup = $plainDocuments = array(); + for ($k = 0; $k < $count; ++$k) { + $owner = 'plain-' . $k; + $plainPages[$owner] = array(0 === $k ? $plainBase : (1 === $k ? '' : '.native{z-index:9}') . '#page-only-' . $k . ' .x{color:red}'); + $plainMarkup[$owner] = '

Probe

'; + $dom = new DOMDocument(); + @$dom->loadHTML('' . $plainMarkup[$owner] . ''); + $dom->documentElement->setAttribute('class', Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($owner)); + $plainDocuments[$owner] = $dom; + } + $index = new Automattic\BlocksEngine\PhpTransformer\Css\RouteSelectorMatchIndex($plainDocuments); + $plainResult = $transformer->scopeRouteVariants(array($plainBase), $plainPages, array(), null, $plainMarkup, $index); + $plainSizes[$count] = strlen($plainResult['stylesheet']); + $assert(array() === $plainResult['reasons'] && $count === count($plainResult['scoped']), $count . ' plain routes reconcile without page copies'); + $transformer->visitStyleRules($plainResult['stylesheet'], static function (string $prelude) use ($assert): void { + if (str_contains($prelude, '#page-only-')) $assert(!str_contains($prelude, 'be-route-'), 'plain page-local arms are unscoped'); + if (str_contains($prelude, '.native')) $assert(str_contains($prelude, 'be-route-'), 'matching native-lowered arm stays scoped'); + }); + foreach ($plainDocuments as $owner => $dom) { + $element = $dom->getElementsByTagName('p')->item(0); + $expected = new StaticCssCascade($dom, implode('', $plainPages[$owner])); + $actual = new StaticCssCascade($dom, $plainResult['stylesheet']); + $assert($expected->resolve($element, array('color', 'z-index'), array()) === $actual->resolve($element, array('color', 'z-index'), array()), 'plain-arm computed-style parity ' . $owner); + } +} +$assert($plainSizes[112] < 1.02 * $plainSizes[2], 'plain-arm site-wide bytes stay constant in N', json_encode($plainSizes)); +echo 'Plain-arm fixture: ' . json_encode($plainSizes) . ' bytes; ' . round(microtime(true) - $plainStarted, 3) . " seconds including parity\n"; +// Exercise the public asset path too: unmatched plain arms must not create +// .page- companions, even when every page retains a different id ancestry. +$plainAssetSizes = array(); +foreach (array(2, 12) as $count) { + $files = array(); + for ($k = 0; $k < $count; ++$k) $files[] = array('path' => 'plain-' . $k . '.html', 'kind' => 'html', 'content' => '

Probe

'); + $sheet = array('path' => 'base.css', 'kind' => 'css', 'content' => $plainBase); + $plan = (new ArtifactCompiler())->compile(array('entrypoint' => 'plain-0.html', 'files' => array_merge($files, array($sheet))))->toArray()['source_reports']['wordpress_site_plan']; + $plainAssetSizes[$count] = strlen(implode('', $authoredAssets($plan))); + $assert(!str_contains(implode('', array_column($plan['assets'], 'source_path')), '.page-'), $count . ' plain pages emit no page-copy assets'); + foreach ($plan['pages'] as $row) { + $alone = (new ArtifactCompiler())->compile(array('entrypoint' => $row['source_path'], 'files' => array(array_column($files, null, 'path')[$row['source_path']], $sheet)))->toArray()['source_reports']['wordpress_site_plan']; + $assert($probeStyles($alone, $alone['pages'][0], false) === $probeStyles($plan, $row, false), 'public plain-arm computed-style parity ' . $row['source_path']); + } +} +$assert($plainAssetSizes[12] < 1.1 * $plainAssetSizes[2], 'plain-arm published bytes stay roughly constant', json_encode($plainAssetSizes)); + +// Matching dropped arms still contribute to the unchanged byte safety net. +$costlyBase = ''; +for ($k = 0; $k < 80; ++$k) $costlyBase .= '.native{z-index:' . $k . '}'; +$costlyPages = array(); +foreach ($plainDocuments as $owner => $_) $costlyPages[$owner] = array(0 === ((int) substr($owner, 6)) % 2 ? $costlyBase : ''); +$costly = $transformer->scopeRouteVariants(array($costlyBase), $costlyPages, array(), null, $plainMarkup, $index); +$assert($costly['route_bytes'] > 8192 && $costly['route_bytes'] > 3 * strlen($costlyBase), 'matching dropped arms retain over-budget scope accounting'); + +// Unknown, negated-state and missing-document evidence is conservative. +foreach (array('.x:unknown', '.x:not(:hover)', '.x:has(> .y)') as $unknownArm) { + $assert(!$index->inertOutside($unknownArm, array('plain-1' => true)), 'undecidable arm stays scoped: ' . $unknownArm); +} +$assert(!$index->inertOutside('.absent', array('missing' => true)), 'missing document never proves inertness'); +$assert($index->inertOutside('.absent:hover', array('plain-1' => true)), 'state-independent non-match is inert'); +$assert(!$index->inertOutside('.x:hover', array('plain-1' => true)), 'stateful structural match stays scoped'); + // Identical shared units align separately at each source occurrence. $marker = 'blocks-engine-control-0123456789ab-0'; $unit = '.group>.btn{z-index:1}'; From 0dd90c71495e2f3d20a251be2d67e01d1da9e1e4 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 27 Oct 2026 01:06:17 -0400 Subject: [PATCH 06/10] Keep shared @import site-wide, shorten route classes, budget route bytes by total copy cost --- .../src/ArtifactCompiler/ArtifactCompiler.php | 54 ++++++++----- .../src/Css/CssStylesheetTransformer.php | 38 ++++++++- .../WordPressSitePlan/DocumentRootContext.php | 41 +++++++++- .../shared-author-stylesheet-scaling.php | 77 +++++++++++++++++-- .../unit/document-root-font-ownership.php | 2 +- 5 files changed, 178 insertions(+), 34 deletions(-) diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index ef278143e..f4b93d79d 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -52,16 +52,17 @@ final class ArtifactCompiler public const MAX_PLAN_DIGEST_DEPTH = 512; /** - * Route-scoped variants live in the one sheet every route downloads. When - * the bytes they add (scoped variants and page-only rules, plus the growth - * of shared rules that exclude routes) exceed this factor times the - * largest page copy (`.page-.css`, what one page would load - * on top of the shared sheet), that stylesheet falls back to page copies - * with an `author_stylesheet_route_scope_fallback` diagnostic. A visitor - * then never loads more than about three times the extra CSS of a copy. + * The shared sheet is downloaded once and cached across routes; page + * copies cost a separate download per page. Allow the greater of this + * factor times the largest copy, one tenth of total copy bytes, and the + * floor below. Measure scoped variants and split-rule overhead before + * compaction; exceeding the bound falls back to page copies. */ public const ROUTE_SCOPE_BYTES_FACTOR = 3; + /** Cached shared overhead may replace up to 10% of total per-page copy download bytes. */ + public const ROUTE_SCOPE_COPY_TOTAL_DIVISOR = 10; + /** Route-scoping overhead always allowed, e.g. for routes that only drop rules (their copy is empty). */ public const ROUTE_SCOPE_BYTES_FLOOR = 8192; @@ -87,6 +88,9 @@ final class ArtifactCompiler /** @var array>> */ private array $stylesheetOccurrenceCache = array(); + /** @var array Collision-free root classes keyed by site route identity. */ + private array $routeClasses = array(); + /** @var array Source resources imported into an authored layer. */ private array $layeredNavigationImports = array(); @@ -289,6 +293,13 @@ private function finalizeArtifact(array $artifact, array $reduction): Transforme { $startedAt = hrtime(true); $normalized = $reduction['normalized']; + $routeIdentities = array(); + foreach ($normalized['files'] as $file) { + if ('html' !== ($file['kind'] ?? '')) continue; + $source = (string) $file['path']; + $routeIdentities[] = \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\WordPressSitePlan::identity('page', $source, $source); + } + $this->routeClasses = \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClasses($routeIdentities); $capturedDialogs = is_array($reduction['captured_dialogs'] ?? null) ? $reduction['captured_dialogs'] : array('diagnostics' => array(), 'projected_count' => 0); $entry = $this->entryFile($normalized['files'], $normalized['entrypoints']); $documents = is_array($reduction['source_documents'] ?? null) ? $reduction['source_documents'] : $this->compileSourceDocuments($normalized); @@ -2645,11 +2656,13 @@ private function applyAuthorStylesheetProjections(array $files, array $projectio $stylesheets = array(); foreach ( $authoritativeContent as $stylesheet ) { $split = $transformer->splitLeadingAtRulePreamble($stylesheet); - if ( '' !== trim($split['preamble']) ) { - $preambles[] = $split['preamble']; + $imports = ''; + foreach ($split['statements'] as $statement) { + if (preg_match('/^\s*(?:\/\*.*?\*\/\s*)*@import\b/is', $statement)) $imports .= $statement; + else $preambles[] = $statement; } - if ( '' !== trim($split['stylesheet']) ) { - $stylesheets[] = $split['stylesheet']; + if ( '' !== trim($imports . $split['stylesheet']) ) { + $stylesheets[] = $imports . $split['stylesheet']; } } $sharedStylesheets = array_merge(array_map('strval', array_keys($sharedByPath[$path] ?? array())), $stylesheets); @@ -2671,21 +2684,22 @@ private function applyAuthorStylesheetProjections(array $files, array $projectio $sharedStylesheets = array($transformer->concatenateWithoutRedundantRules($sharedStylesheets)); $fallbackSharedStylesheets = $sharedStylesheets; foreach ($pages as $owner => $_) $pages[$owner] = $pageProjections[$owner] ?? $pages[$owner]; - $routed = $transformer->scopeRouteVariants($sharedStylesheets, $pageProjections, $routeIdentities, preg_match('/@namespace\b/i', implode('', $preambles)) ? '@namespace' : null, $pageMarkup, $routeMatchIndex); + $routed = $transformer->scopeRouteVariants($sharedStylesheets, $pageProjections, $routeIdentities, preg_match('/@namespace\b/i', implode('', $preambles)) ? '@namespace' : null, $pageMarkup, $routeMatchIndex, $this->routeClasses); // Recompact only at serialization, merging compatible marker arms. $recompact = static fn (string $css): string => count($pageProjections) > 1 ? CssSelectorListCompactor::stylesheet($css, true) : $css; - // Bound what route scoping adds to the sheet every route loads by - // what the largest page copy would have added to one page. Both - // are measured before compaction. - $largestCopy = 0; + // A cached shared download replaces separate page-copy downloads. + // Both costs are measured before compaction. + $largestCopy = $totalCopyBytes = 0; if (0 < $routed['route_bytes']) { foreach ($routed['scoped'] as $owner) { - $largestCopy = max($largestCopy, strlen($transformer->rulesAbsentFrom($pageProjections[$owner] ?? array(), $fallbackSharedStylesheets, true))); + $copyBytes = strlen($transformer->rulesAbsentFrom($pageProjections[$owner] ?? array(), $fallbackSharedStylesheets, true)); + $largestCopy = max($largestCopy, $copyBytes); + $totalCopyBytes += $copyBytes; } } - $routeBudget = max(self::ROUTE_SCOPE_BYTES_FACTOR * $largestCopy, self::ROUTE_SCOPE_BYTES_FLOOR); + $routeBudget = max(self::ROUTE_SCOPE_BYTES_FACTOR * $largestCopy, intdiv($totalCopyBytes, self::ROUTE_SCOPE_COPY_TOTAL_DIVISOR), self::ROUTE_SCOPE_BYTES_FLOOR); if (array() !== $routed['scoped'] && $routed['route_bytes'] > $routeBudget) { - $projectionDiagnostics[] = $this->diagnostic('author_stylesheet_route_scope_fallback', 'warning', sprintf('%s: route-scoped variants add %d bytes to every route, over %d (%dx the largest %d-byte page copy); emitting page copies', $path, $routed['route_bytes'], $routeBudget, self::ROUTE_SCOPE_BYTES_FACTOR, $largestCopy)); + $projectionDiagnostics[] = $this->diagnostic('author_stylesheet_route_scope_fallback', 'warning', sprintf('%s: route-scoped variants add %d cached-sheet bytes, over budget %d = max(%dx largest copy %d, total copies %d / %d, floor %d); emitting page copies', $path, $routed['route_bytes'], $routeBudget, self::ROUTE_SCOPE_BYTES_FACTOR, $largestCopy, $totalCopyBytes, self::ROUTE_SCOPE_COPY_TOTAL_DIVISOR, self::ROUTE_SCOPE_BYTES_FLOOR)); foreach ($routed['scoped'] as $owner) $pages[$owner] ??= $pageProjections[$owner] ?? array(); $routed['stylesheet'] = implode("\n", $fallbackSharedStylesheets); } else { @@ -4226,7 +4240,7 @@ private function fullDocumentMetadata(string $html, string $sourcePath, array $f $titles = HtmlTagScanner::scan($html, 'title'); $title = isset($titles[0]) ? trim(html_entity_decode(strip_tags($titles[0]['content']), ENT_QUOTES | ENT_HTML5, 'UTF-8')) : $this->titleFromHtml($html, $sourcePath); $routeIdentity = \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\WordPressSitePlan::identity('page', $sourcePath, $sourcePath); - $metadata = array('source_context' => array('source_path' => $sourcePath, 'kind' => 'html'), ...\Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::metadataFromHtml($html, $routeIdentity), 'title' => $title, 'title_declaration' => array('order' => 0, 'placement' => 'head'), 'meta' => $meta, 'links' => $links, 'scripts' => $scripts); + $metadata = array('source_context' => array('source_path' => $sourcePath, 'kind' => 'html'), ...\Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::metadataFromHtml($html, $routeIdentity, $this->routeClasses[$routeIdentity] ?? null), 'title' => $title, 'title_declaration' => array('order' => 0, 'placement' => 'head'), 'meta' => $meta, 'links' => $links, 'scripts' => $scripts); if ( array() !== $projectedRootMarkers ) { $rootClasses = array_values(array_filter(preg_split('/\s+/', trim((string) ($metadata['root_attributes']['class'] ?? ''))) ?: array(), static fn(string $class): bool => '' !== $class)); $metadata['root_attributes']['class'] = implode(' ', array_unique(array_merge($rootClasses, $projectedRootMarkers))); diff --git a/php-transformer/src/Css/CssStylesheetTransformer.php b/php-transformer/src/Css/CssStylesheetTransformer.php index 029162840..656885d9c 100644 --- a/php-transformer/src/Css/CssStylesheetTransformer.php +++ b/php-transformer/src/Css/CssStylesheetTransformer.php @@ -264,6 +264,8 @@ private static function positiveMarker(string $arm): ?string * declaration references them, so the ones only some routes keep or add * stay site-wide, once each. Two routes that define the same name with * different bodies would see each other's definition, so they fall back. + * Leading @imports stay shared only when every route retains the exact + * same ordered text; differing import lists use the copy fallback. * * @param list $shared * @param array> $pages @@ -274,16 +276,18 @@ private static function positiveMarker(string $arm): ?string * * @param array $routeIdentities Route identities by compilation owner. * @param array $markup Canonical markup by compilation owner. + * @param array $routeClasses Site-wide collision-free classes by route identity. * `route_bytes` counts scope and split-rule overhead beyond the unscoped * arm union, plus scoped insertions, before final compaction. * * @return array{stylesheet:string, scoped:list, reasons:array, route_bytes:int} */ - public function scopeRouteVariants(array $shared, array $pages, array $routeIdentities = array(), ?string $unsafeReason = null, array $markup = array(), ?RouteSelectorMatchIndex $matchIndex = null): array + public function scopeRouteVariants(array $shared, array $pages, array $routeIdentities = array(), ?string $unsafeReason = null, array $markup = array(), ?RouteSelectorMatchIndex $matchIndex = null, array $routeClasses = array()): array { $carriers = self::markerCarriers($markup); $base = array(); foreach ($shared as $css) $this->collectRuleUnits($css, array(), $base); + $baseImports = self::routeImports($base); $normal = static fn (array $unit): string => trim(CssSyntaxScanner::withoutComments($unit['text'])); $key = function (array $unit) use ($normal): string { $rule = self::splitLeadingComments($unit['text'])[1]; @@ -311,6 +315,10 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden } if (null !== $reason) { $reasons[$owner] = $reason; continue; } foreach ($sheets as $css) $this->collectRuleUnits($css, array(), $units); + if (null === $baseImports || self::routeImports($units) !== $baseImports) { + $reasons[$owner] = 'different or non-leading @import rules'; + continue; + } $cursor = 0; $localChanges = $localInsertions = $localSite = array(); foreach ($units as $unit) { @@ -366,7 +374,10 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden } } $output = array(); - $routeClass = static fn ($owner): string => \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($routeIdentities[$owner] ?? (string) $owner); + $identities = array(); + foreach (array_keys($pages) as $owner) $identities[] = $routeIdentities[$owner] ?? (string) $owner; + $routeClasses = $routeClasses ?: \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClasses($identities); + $routeClass = static fn ($owner): string => $routeClasses[$routeIdentities[$owner] ?? (string) $owner]; $routeBytes = 0; $allRoutes = array_fill_keys(array_keys($pages), true); $emitArms = function (array $units, array $prototype, bool $insertion = false) use (&$output, &$routeBytes, $routeClass, $allRoutes, $carriers, $markup, $matchIndex): void { @@ -434,6 +445,29 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden return array('stylesheet' => $this->serializeRuleUnits($output), 'scoped' => $scoped, 'reasons' => $reasons, 'route_bytes' => $routeBytes); } + /** + * Imports must match byte-for-byte, in order, at the sheet top. Unlike + * definitions they cannot be added or dropped independently by a route. + * + * @param list,text:string,dedupable:bool}> $units + * @return list|null + */ + private static function routeImports(array $units): ?array + { + $imports = array(); + $top = true; + foreach ($units as $unit) { + [, $rule] = self::splitLeadingComments($unit['text']); + if (preg_match('/^@import\b/i', $rule)) { + if (!$top || $unit['context']) return null; + $imports[] = $unit['text']; + } elseif ('' !== trim($rule) && !preg_match('/^@charset\b/i', $rule)) { + $top = false; + } + } + return $imports; + } + /** * The name a route-independent definition at-rule binds, or null for any * other unit. Same-name definitions with different bodies conflict. diff --git a/php-transformer/src/WordPressSitePlan/DocumentRootContext.php b/php-transformer/src/WordPressSitePlan/DocumentRootContext.php index a054f60e1..292d54933 100644 --- a/php-transformer/src/WordPressSitePlan/DocumentRootContext.php +++ b/php-transformer/src/WordPressSitePlan/DocumentRootContext.php @@ -17,17 +17,50 @@ public static function fromHtml(string $html, string $tagName = 'html'): array } /** @return array{root_attributes:array,body_attributes:array} */ - public static function metadataFromHtml(string $html, string $routeIdentity = ''): array + public static function metadataFromHtml(string $html, string $routeIdentity = '', ?string $routeClass = null): array { $attributes = DocumentRootAttributes::forDocument($html); - if ('' !== $routeIdentity) $attributes['html']['class'] = trim(($attributes['html']['class'] ?? '') . ' ' . self::routeClass($routeIdentity)); + if ('' !== $routeIdentity) $attributes['html']['class'] = trim(($attributes['html']['class'] ?? '') . ' ' . ($routeClass ?? self::routeClass($routeIdentity))); return array('root_attributes' => $attributes['html'], 'body_attributes' => $attributes['body']); } /** Stable per-document route class from the compile-time source identity, used by CSS and the root registry alike. */ - public static function routeClass(string $identity): string + public static function routeClass(string $identity, int $length = 8): string { - return 'be-route-' . substr(hash('sha256', $identity), 0, 12); + return 'be-r-' . substr(hash('sha256', $identity), 0, $length); + } + + /** + * Allocate deterministic classes for the whole site, extending only colliding + * hashes. Sorting makes the result independent of route discovery order. + * + * @param list $identities + * @return array Classes keyed by route identity. + */ + public static function routeClasses(array $identities): array + { + $identities = array_values(array_unique($identities)); + sort($identities); + $lengths = array_fill_keys($identities, 8); + do { + $groups = $classes = array(); + foreach ($identities as $identity) { + $class = self::routeClass($identity, $lengths[$identity]); + $classes[$identity] = $class; + $groups[$class][] = $identity; + } + $collision = false; + foreach ($groups as $group) { + if (count($group) < 2) continue; + foreach ($group as $identity) { + if ($lengths[$identity] >= 64) throw new \LogicException('Route identity hash collision.'); + ++$lengths[$identity]; + } + $collision = true; + } + } while ($collision); + if (count(array_unique($classes)) !== count($identities)) throw new \LogicException('Route classes must be unique.'); + return $classes; } public static function assertValid(mixed $attributes): void diff --git a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php index 971cc17e4..c7e983d18 100644 --- a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php +++ b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php @@ -525,7 +525,7 @@ $text = implode('', $site['assets']); $markerSizes[$count] = strlen($text); $assert(1 === count($site['assets']) && !str_contains(implode('', array_column($site['plan']['assets'], 'source_path')), '.page-'), $count . ' marker-only pages: one site-wide asset, no page copies', json_encode(array_keys($site['assets']))); - $assert(!str_contains($text, 'be-route-'), $count . ' marker-only pages: nothing is route scoped', $text); + $assert(!str_contains($text, 'be-r-'), $count . ' marker-only pages: nothing is route scoped', $text); $assert(array() === array_filter($site['diagnostics'], static fn (array $row): bool => 'author_stylesheet_route_scope_fallback' === ($row['code'] ?? '')), $count . ' marker-only pages: no route-scope fallback'); } $markerText = implode('', $site['assets']); @@ -559,7 +559,7 @@ $gapText = implode('', $gapSite['assets']); $scopedMarkerRule = false; $transformer->visitStyleRules($gapText, static function (string $prelude) use (&$scopedMarkerRule): void { - if (str_contains($prelude, 'be-route-') && preg_match('/blocks-engine-control-[a-f0-9]{12}-\d+/', $prelude)) $scopedMarkerRule = true; + if (str_contains($prelude, 'be-r-') && preg_match('/blocks-engine-control-[a-f0-9]{12}-\d+/', $prelude)) $scopedMarkerRule = true; }); $assert(1 === count($gapSite['assets']) && !$scopedMarkerRule, 'page-only marker rules fold into unscoped unions', $gapText); foreach (array(array($site, false), array($gapSite, true)) as [$checked, $gaps]) { @@ -621,12 +621,12 @@ $richPages = array('one' => array($richList($richOne) . '{color:red}'), 'two' => array($richList($richTwo) . '{color:red}')); $richMarkup = array('one' => 'One', 'two' => 'Two'); $privateRich = $transformer->scopeRouteVariants($richPages['one'], $richPages, array(), null, $richMarkup); -$assert(!str_contains($privateRich['stylesheet'], 'be-route-'), 'private richtext marker swaps need no route scopes'); +$assert(!str_contains($privateRich['stylesheet'], 'be-r-'), 'private richtext marker swaps need no route scopes'); $unknownRich = $transformer->scopeRouteVariants($richPages['one'], $richPages); -$assert(str_contains($unknownRich['stylesheet'], 'be-route-'), 'missing carrier evidence never proves richtext arms inert'); +$assert(str_contains($unknownRich['stylesheet'], 'be-r-'), 'missing carrier evidence never proves richtext arms inert'); $negated = ':not(:where(.blocks-engine-control-aaaaaaaaaaaa-0)){color:red}'; $negativeMarker = $transformer->scopeRouteVariants(array($negated), array('one' => array($negated), 'two' => array('')), array(), null, array('one' => '', 'two' => '')); -$assert(str_contains($negativeMarker['stylesheet'], 'be-route-'), 'negated markers are not positive carrier requirements'); +$assert(str_contains($negativeMarker['stylesheet'], 'be-r-'), 'negated markers are not positive carrier requirements'); // Plain arms are absent because they do not match, not because of marker // ownership. Keep the authored input fixed as N increases. A native-lowered @@ -652,8 +652,8 @@ $plainSizes[$count] = strlen($plainResult['stylesheet']); $assert(array() === $plainResult['reasons'] && $count === count($plainResult['scoped']), $count . ' plain routes reconcile without page copies'); $transformer->visitStyleRules($plainResult['stylesheet'], static function (string $prelude) use ($assert): void { - if (str_contains($prelude, '#page-only-')) $assert(!str_contains($prelude, 'be-route-'), 'plain page-local arms are unscoped'); - if (str_contains($prelude, '.native')) $assert(str_contains($prelude, 'be-route-'), 'matching native-lowered arm stays scoped'); + if (str_contains($prelude, '#page-only-')) $assert(!str_contains($prelude, 'be-r-'), 'plain page-local arms are unscoped'); + if (str_contains($prelude, '.native')) $assert(str_contains($prelude, 'be-r-'), 'matching native-lowered arm stays scoped'); }); foreach ($plainDocuments as $owner => $dom) { $element = $dom->getElementsByTagName('p')->item(0); @@ -704,6 +704,69 @@ $duplicates = $transformer->scopeRouteVariants(array($unit . '.between{z-index:2}' . $unit), array('a' => array($variant . '.between{z-index:2}' . $variant)), array(), null, array('a' => '

Probe

')); $assert(2 === substr_count($duplicates['stylesheet'], $marker), 'arm reconciliation rewrites every aligned shared unit without moving it'); +// Identical leading imports remain site-wide despite non-dedupable units. +$import = '@import url(/external/8e2e7efb6cf0362d/css-659beb8e13af.css);'; +$importSheet = $boundSheet; +$importSheet['content'] = $import . $boundSheet['content']; +$importResult = (new ArtifactCompiler())->compile(array('entrypoint' => 'bound-0.html', 'files' => array_merge($boundFiles, array($importSheet))))->toArray(); +$importPlan = $importResult['source_reports']['wordpress_site_plan']; +$assert(!str_contains(implode('', array_column($importPlan['assets'], 'source_path')), '.page-'), 'identical leading imports emit no page copies'); +$assert(1 === substr_count(implode('', $authoredAssets($importPlan)), $import), 'identical import stays site-wide once'); +foreach (array('@import url(other.css);', '', $import . '@import url(extra.css);') as $otherImport) { + $result = $transformer->scopeRouteVariants(array($import . '.a{color:red}'), array('a' => array($otherImport . '.a{color:blue}'))); + $assert(isset($result['reasons']['a']), 'different import lists fall back'); +} +$result = $transformer->scopeRouteVariants(array($import . '@import url(second.css);.a{color:red}'), array('a' => array('@import url(second.css);' . $import . '.a{color:blue}'))); +$assert(isset($result['reasons']['a']), 'reordered imports fall back'); + +$rootContext = Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::class; +$classes = array(); +foreach ($importPlan['pages'] as $row) { + preg_match('/\\bbe-r-[a-f0-9]+\\b/', $row['document_metadata']['root_attributes']['class'], $match); + $classes[] = $match[0] ?? ''; +} +$assert(count(array_unique($classes)) === count($classes) && !in_array('', $classes, true), 'route classes are unique across the plan'); +$assert(array(13) === array_values(array_unique(array_map('strlen', $classes))), 'normal route classes use the readable eight-hex minimum'); +$collisionIdentities = array('collision-55045', 'collision-70885', 'ordinary-route'); +$collisionClasses = $rootContext::routeClasses($collisionIdentities); +$assert(3 === count(array_unique($collisionClasses)) && 14 === strlen($collisionClasses['collision-55045']) && 13 === strlen($collisionClasses['ordinary-route']), 'colliding hashes lengthen only as needed'); +$assert($collisionClasses === $rootContext::routeClasses(array_reverse($collisionIdentities)), 'class allocation is independent of route order'); + +// Exercise the compiler budget with explicit projections: all routes carry +// the subject, so dropping a rule cannot be proven inert. Long unchanged +// cascade tails make legacy copies costly in aggregate, not individually. +$copyBase = '.native{z-index:1}'; +for ($k = 0; $k < 160; ++$k) $copyBase .= '.tail-' . $k . '{z-index:2;color:#123456;margin:0;padding:0}'; +$copyVariant = str_replace('.native{z-index:1}', '.native{z-index:3}', $copyBase); +$copyPages = $copyFiles = $copyProjections = $copyMarkup = array(); +for ($k = 0; $k < 112; ++$k) { + $owner = 'copy-' . $k; + $copyPages[$owner] = array($k % 2 ? $copyVariant : $copyBase); + $copyMarkup[$owner] = '
Probe
'; + $copyProjections[] = array('path' => 'base.css', 'content' => $copyPages[$owner][0], 'owner' => $owner); +} +// Many repeated differing units make the scope overhead exceed the old 3x +// limit while the shared cascade tail dominates each complete page copy. +$copyBase = str_repeat('.native{z-index:1}', 10) . $copyBase; +$copyVariant = str_replace('z-index:1}', 'z-index:3}', $copyBase); +foreach ($copyPages as $owner => &$sheets) $sheets = array((int) substr($owner, 5) % 2 ? $copyVariant : $copyBase); +unset($sheets); +foreach ($copyProjections as &$projection) $projection['content'] = $copyPages[$projection['owner']][0]; +unset($projection); +$copyRoute = $transformer->scopeRouteVariants(array($copyBase), $copyPages, array(), null, $copyMarkup); +$totalCopies = $largestCopy = 0; +foreach ($copyPages as $sheets) { + $bytes = strlen($transformer->rulesAbsentFrom($sheets, array($copyBase), true)); + $totalCopies += $bytes; + $largestCopy = max($largestCopy, $bytes); +} +$assert($copyRoute['route_bytes'] > max(8192, 3 * $largestCopy) && $copyRoute['route_bytes'] <= intdiv($totalCopies, 10), 'total-copy budget accepts overhead rejected by the old largest-copy bound', json_encode(array($copyRoute['route_bytes'], $largestCopy, $totalCopies))); +$copyFiles[] = array('path' => 'base.css', 'kind' => 'css', 'content' => $copyBase); +$method = new ReflectionMethod(ArtifactCompiler::class, 'applyAuthorStylesheetProjections'); +$copyDiagnostics = array(); +$copyOutput = $method->invokeArgs(new ArtifactCompiler(), array($copyFiles, $copyProjections, array(array('path' => 'base.css', 'content' => $copyBase)), '', $copyMarkup, &$copyDiagnostics)); +$assert(!str_contains(implode('', array_column($copyOutput, 'path')), '.page-') && !$copyDiagnostics, 'compiler applies total-copy budget without fallback', json_encode($copyDiagnostics)); + if ( 0 < $failures ) { fwrite(STDERR, "shared-author-stylesheet scaling FAILED: {$passes} passed, {$failures} failed\n"); exit(1); diff --git a/php-transformer/tests/unit/document-root-font-ownership.php b/php-transformer/tests/unit/document-root-font-ownership.php index 9b9af3634..69765057a 100644 --- a/php-transformer/tests/unit/document-root-font-ownership.php +++ b/php-transformer/tests/unit/document-root-font-ownership.php @@ -23,7 +23,7 @@ $assert('document' === ($pages['index.html']['document_metadata']['root_attributes']['id'] ?? null), 'root ID travels alongside classes'); $bootstrap = ''; foreach ($plan['writes'] as $write) if ('functions.php' === $write['target_path']) $bootstrap = $write['payload']['data']; -$assert(str_contains($bootstrap, "'language_attributes'") && str_contains($bootstrap, 'WP_HTML_Tag_Processor') && str_contains($bootstrap, 'type-a be-route-') && str_contains($bootstrap, 'type-b be-route-'), 'generated runtime adopts route-owned root identity through native HTML API'); +$assert(str_contains($bootstrap, "'language_attributes'") && str_contains($bootstrap, 'WP_HTML_Tag_Processor') && str_contains($bootstrap, 'type-a be-r-') && str_contains($bootstrap, 'type-b be-r-'), 'generated runtime adopts route-owned root identity through native HTML API'); $assert(str_contains($bootstrap, "'_blocks_engine_reconciliation_identity'") && str_contains($bootstrap, 'is_front_page()'), 'runtime selection uses destination identity with route fallback'); $css = implode("\n", array_column(array_filter($plan['assets'], static fn(array $asset): bool => 'css' === $asset['kind']), 'content')); $assert(str_contains($css, '.type-a') && str_contains($css, '.type-b') && str_contains($css, '@media'), 'authored root selector and conditional cascade stay intact'); From b03d1a8c961b28abdff73da55bf053b505184ce2 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 27 Oct 2026 01:48:07 -0400 Subject: [PATCH 07/10] Ignore @import rules CSS drops after other rules when route scoping --- .../src/Css/CssStylesheetTransformer.php | 25 +++++++++++++++++++ .../shared-author-stylesheet-scaling.php | 4 +++ 2 files changed, 29 insertions(+) diff --git a/php-transformer/src/Css/CssStylesheetTransformer.php b/php-transformer/src/Css/CssStylesheetTransformer.php index 656885d9c..eb24a550a 100644 --- a/php-transformer/src/Css/CssStylesheetTransformer.php +++ b/php-transformer/src/Css/CssStylesheetTransformer.php @@ -287,6 +287,7 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden $carriers = self::markerCarriers($markup); $base = array(); foreach ($shared as $css) $this->collectRuleUnits($css, array(), $base); + $base = self::withoutIgnoredImports($base); $baseImports = self::routeImports($base); $normal = static fn (array $unit): string => trim(CssSyntaxScanner::withoutComments($unit['text'])); $key = function (array $unit) use ($normal): string { @@ -315,6 +316,7 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden } if (null !== $reason) { $reasons[$owner] = $reason; continue; } foreach ($sheets as $css) $this->collectRuleUnits($css, array(), $units); + $units = self::withoutIgnoredImports($units); if (null === $baseImports || self::routeImports($units) !== $baseImports) { $reasons[$owner] = 'different or non-leading @import rules'; continue; @@ -468,6 +470,29 @@ private static function routeImports(array $units): ?array return $imports; } + /** + * Drop top-level @import rules that follow other rules. CSS ignores them, + * so they are inert on every route; concatenated sheets often repeat them. + * + * @param list,text:string,dedupable:bool}> $units + * @return list,text:string,dedupable:bool}> + */ + private static function withoutIgnoredImports(array $units): array + { + $top = true; + $kept = array(); + foreach ($units as $unit) { + [, $rule] = self::splitLeadingComments($unit['text']); + if (preg_match('/^@import\b/i', $rule) && !$unit['context']) { + if ($top) $kept[] = $unit; + continue; + } + if ('' !== trim($rule) && !preg_match('/^@charset\b/i', $rule)) $top = false; + $kept[] = $unit; + } + return $kept; + } + /** * The name a route-independent definition at-rule binds, or null for any * other unit. Same-name definitions with different bodies conflict. diff --git a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php index c7e983d18..1f594cac1 100644 --- a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php +++ b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php @@ -718,6 +718,10 @@ } $result = $transformer->scopeRouteVariants(array($import . '@import url(second.css);.a{color:red}'), array('a' => array('@import url(second.css);' . $import . '.a{color:blue}'))); $assert(isset($result['reasons']['a']), 'reordered imports fall back'); +// CSS ignores @import after other rules, so repeated trailing imports (concatenated sheets) are inert on every route. +$repeated = $import . '.a{color:red}' . $import; +$result = $transformer->scopeRouteVariants(array($repeated), array('a' => array($import . '.a{color:blue}' . $import))); +$assert(!isset($result['reasons']['a']) && 1 === substr_count($result['stylesheet'], $import), 'ignored trailing imports neither block route scoping nor survive'); $rootContext = Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::class; $classes = array(); From e0393f3e15e79e824fc2d35d92f3947198ba66bb Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 27 Oct 2026 09:38:28 -0400 Subject: [PATCH 08/10] Keep render-time route state and non-plan pages correct under route scoping --- .../src/Css/CssStylesheetTransformer.php | 25 +++-- .../src/Css/RouteSelectorMatchIndex.php | 20 ++++ .../shared-author-stylesheet-scaling.php | 96 ++++++++++++++----- 3 files changed, 110 insertions(+), 31 deletions(-) diff --git a/php-transformer/src/Css/CssStylesheetTransformer.php b/php-transformer/src/Css/CssStylesheetTransformer.php index eb24a550a..ece2798ac 100644 --- a/php-transformer/src/Css/CssStylesheetTransformer.php +++ b/php-transformer/src/Css/CssStylesheetTransformer.php @@ -271,7 +271,8 @@ private static function positiveMarker(string $arm): ?string * @param array> $pages * Aligned rules are expanded into arms, each with its own route set. * Arms proven inert outside that set stay unscoped; all others use the - * shorter positive or complementary negative scope. Only validated lists + * positive scope for variant-only arms, or a complementary negative scope + * for arms retained by the virtual no-route-class base document. Only validated lists * are split, preserving invalid-selector isolation and source order. * * @param array $routeIdentities Route identities by compilation owner. @@ -382,7 +383,12 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden $routeClass = static fn ($owner): string => $routeClasses[$routeIdentities[$owner] ?? (string) $owner]; $routeBytes = 0; $allRoutes = array_fill_keys(array_keys($pages), true); - $emitArms = function (array $units, array $prototype, bool $insertion = false) use (&$output, &$routeBytes, $routeClass, $allRoutes, $carriers, $markup, $matchIndex): void { + // Non-plan WordPress pages receive the unprojected shared sheet. They + // have no route class and no compiled markup, so can never prove inertness. + $baseOwner = ''; + while (isset($allRoutes[$baseOwner])) $baseOwner .= '#'; + $allRoutes[$baseOwner] = true; + $emitArms = function (array $units, array $prototype, bool $insertion = false) use (&$output, &$routeBytes, $routeClass, $allRoutes, $baseOwner, $carriers, $markup, $matchIndex): void { [$lead, $rule] = self::splitLeadingComments($prototype['text']); $brace = (int) $this->nextRuleBoundary($rule, 0); $body = substr($rule, $brace); @@ -399,13 +405,18 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden foreach ($members as $arm => $routes) { $outside = array_diff_key($allRoutes, $routes); $marker = self::positiveMarker($arm); - $inert = !$outside || (null !== $marker && !array_diff_key($outside, $markup) && !array_diff_key($carriers[$marker] ?? array(), $routes)); - if (!$inert && null !== $matchIndex) $inert = $matchIndex->inertOutside($arm, $outside); + $inert = !$outside; + if (!$inert && !isset($outside[$baseOwner]) && !RouteSelectorMatchIndex::dependsOnRouteState($arm)) { + $inert = null !== $marker && !array_diff_key($outside, $markup) && !array_diff_key($carriers[$marker] ?? array(), $routes); + if (!$inert && null !== $matchIndex) $inert = $matchIndex->inertOutside($arm, $outside); + } $scope = ''; if (!$inert) { - $positive = ':where(:root:is(' . implode(',', array_map(static fn ($owner): string => '.' . $routeClass($owner), array_keys($routes))) . '))'; - $negative = ':where(:root:not(' . implode(',', array_map(static fn ($owner): string => '.' . $routeClass($owner), array_keys($outside))) . '))'; - $scope = strlen($positive) <= strlen($negative) ? $positive : $negative; + if (isset($routes[$baseOwner])) { + $scope = ':where(:root:not(' . implode(',', array_map(static fn ($owner): string => '.' . $routeClass($owner), array_keys($outside))) . '))'; + } else { + $scope = ':where(:root:is(' . implode(',', array_map(static fn ($owner): string => '.' . $routeClass($owner), array_keys($routes))) . '))'; + } } $groups[$scope][] = $arm; } diff --git a/php-transformer/src/Css/RouteSelectorMatchIndex.php b/php-transformer/src/Css/RouteSelectorMatchIndex.php index 675f6ed15..bece7998c 100644 --- a/php-transformer/src/Css/RouteSelectorMatchIndex.php +++ b/php-transformer/src/Css/RouteSelectorMatchIndex.php @@ -31,9 +31,29 @@ public function __construct(array $documents) } } + /** + * Compiled markup cannot disprove state WordPress restores at render time. + * Use the same class vocabulary as RouteStateProjector's route binding, + * including Core's current-menu/current_page classes and BE's current item. + * Scan the whole arm, including functional pseudo-class arguments. + */ + public static function dependsOnRouteState(string $arm): bool + { + if (preg_match('/\[\s*(?:aria-current|data-status|data-state)\b/i', $arm)) return true; + // Escapes cannot safely be interpreted by this lightweight proof. + if (str_contains($arm, '\\')) return true; + preg_match_all('/\.([A-Za-z0-9_-]+)/', $arm, $classes); + foreach ($classes[1] as $class) { + if (\Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Classification\MenuVocabulary::containsCurrentStateToken($class)) return true; + } + // Class attribute selectors may name the same mutable state tokens. + return 1 === preg_match('/\[\s*class\b/i', $arm); + } + /** Missing documents and unsupported selectors are never evidence of inertness. */ public function inertOutside(string $arm, array $outside): bool { + if (self::dependsOnRouteState($arm)) return false; if (!isset($this->selectors[$arm])) { // Match the structural subject in every state, as the projector does. // Do not rewrite quoted attributes or functional negations: those diff --git a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php index 1f594cac1..0f277146e 100644 --- a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php +++ b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php @@ -256,7 +256,7 @@ $contextBytes = strlen(implode('', $contextTenAssets)); // Baseline measured with folding disabled: 142,268 bytes for ten pages. $assert(1 === count($contextTwoAssets) && 1 === count($contextTenAssets), 'context-dependent arms fold without page copies', (string) json_encode(array_keys($contextTenAssets))); -$assert($contextBytes - strlen(implode('', $contextTwoAssets)) < 6000, 'only per-page arm bytes grow, not the overlapping cascade suffix', (string) $contextBytes); +$assert($contextBytes - strlen(implode('', $contextTwoAssets)) < 12000, 'per-page arm and no-route scope bytes grow, not the overlapping cascade suffix', (string) $contextBytes); $assert((bool) preg_match('/blocks-engine-control-[a-f0-9]{12}-[0-9]+/', implode('', $contextTenAssets)), 'fixture exercises positive identity-marker arms'); foreach ($contextTen['pages'] as $planPage) { $document = new DOMDocument(); @@ -337,7 +337,7 @@ } $group = ':where(:root:is(.' . $classes['b.html'] . ',.' . $classes['c.html'] . '))'; $assert(str_contains($routeText, '.sample{'), 'plain variants inert on other routes remain shared'); -$assert(str_contains($routeText, 'html .sample') && str_contains($routeText, ':root .sample'), 'inert html and root arms retain their original compounds'); +$assert(str_contains($routeText, 'html:where(') && str_contains($routeText, ':root:where('), 'variant-only html and root arms merge their positive route scope'); $droppedOverride = false; $conditionalImage = false; $transformer->visitStyleRules($routeText, static function (string $prelude, string $body, array $context) use (&$droppedOverride, &$conditionalImage, $classes): void { @@ -348,7 +348,7 @@ }); $assert($droppedOverride, 'the inert dropped override retains its original conditional position'); $assert($conditionalImage, 'page-only image rules retain their at-rule context'); -$assert(!str_contains($routeText, ':where(:root:is(.' . $classes['e.html'] . '))'), 'page-only image bridge inert elsewhere needs no positive route scope'); +$assert(str_contains($routeText, ':where(:root:is(.' . $classes['e.html'] . '))'), 'page-only image bridge excludes the unknown non-plan document'); // Each route is compared with its independent public compilation, including // editor wrapper selectors. Apply exactly the root attributes the theme uses. @@ -500,9 +500,9 @@ // Rules that differ between pages only by page-local block markers. Every // page has its own buttons in a `.row-cols-3` grid, so `.row-cols-3>*` and // `.row>*` project onto each page's own per-element markers, which the page -// projection packs as `>:is(:where(.m1),…)`. Those differences fold into one -// unscoped union in the shared rule: the sheet grows by marker names only, -// never by a route-scoped copy of the rule per page. +// projection packs as `>:is(:where(.m1),…)`. With the virtual base route, +// variant-only markers must now be scoped even if private to one plan page. +// Small cases reconcile; larger ones may hit the unchanged byte-budget fallback. $markerCss = '.row{display:flex;flex-wrap:wrap}.row-cols-3>*{flex:0 0 auto;width:33.3%}.row>*{flex-shrink:0;max-width:100%}' . '.btn{background-color:#123456;color:#fff;border-radius:4px;padding:8px 16px}'; for ($i = 0; $i < 120; ++$i) $markerCss .= '.marker-tail-' . $i . '{color:#123456;margin:0;padding:2px;border-top-width:1px}'; @@ -524,15 +524,17 @@ $site = $markerSite($count); $text = implode('', $site['assets']); $markerSizes[$count] = strlen($text); - $assert(1 === count($site['assets']) && !str_contains(implode('', array_column($site['plan']['assets'], 'source_path')), '.page-'), $count . ' marker-only pages: one site-wide asset, no page copies', json_encode(array_keys($site['assets']))); - $assert(!str_contains($text, 'be-r-'), $count . ' marker-only pages: nothing is route scoped', $text); - $assert(array() === array_filter($site['diagnostics'], static fn (array $row): bool => 'author_stylesheet_route_scope_fallback' === ($row['code'] ?? '')), $count . ' marker-only pages: no route-scope fallback'); + $fallbacks = array_filter($site['diagnostics'], static fn (array $row): bool => 'author_stylesheet_route_scope_fallback' === ($row['code'] ?? '')); + if (2 === $count) { + $assert(1 === count($site['assets']) && str_contains($text, 'be-r-') && !$fallbacks, 'small marker fixture scopes variants without copies'); + } else { + $assert(count($site['assets']) > 1 && count($fallbacks) === 1, $count . ' marker pages: unknown non-plan markup retains the unchanged byte-budget fallback'); + } } $markerText = implode('', $site['assets']); -$assert((bool) preg_match('/:where\((?:\.blocks-engine-control-[a-f0-9]{12}-\d+,){3,}/', $markerText), 'page-local markers fold into one recompacted :where() list', $markerText); -// Ten pages add only their marker names: under a fifth of the two-page -// sheet. Route-scoped copies grew it by about 2.3 KB per page. -$assert($markerSizes[10] < 1.2 * $markerSizes[2] && $markerSizes[10] - $markerSizes[6] < 1.2 * ($markerSizes[6] - $markerSizes[2]) + 512, 'site-wide sheet bytes stay roughly constant in the page count', json_encode($markerSizes)); +$assert((bool) preg_match('/:where\((?:\.blocks-engine-control-[a-f0-9]{12}-\d+,)+/', $markerText), 'same-route page-local markers still recompact into :where() lists'); +// Even the budget fallback must not duplicate the long unchanged tail. +$assert($markerSizes[10] < 1.4 * $markerSizes[2] && $markerSizes[10] - $markerSizes[6] < 1.2 * ($markerSizes[6] - $markerSizes[2]) + 512, 'site-wide sheet bytes stay roughly constant in the page count', json_encode($markerSizes)); $markerProbe = static function (array $plan, array $row): array { $assets = array_column($plan['assets'], null, 'target_path'); $css = ''; @@ -551,17 +553,15 @@ } return $styles; }; -// With every third page empty, the empty pages' projection is the shared -// one: the other pages' marker rules are page-only. Each is inert off its -// own page, so they join the sheet as unscoped unions; only rules whose arms -// can match without a marker carry a route scope. +// With every third page empty, its projection is the shared one. Other +// pages' marker rules are page-only and must exclude unknown non-plan pages. $gapSite = $markerSite(9, true); $gapText = implode('', $gapSite['assets']); $scopedMarkerRule = false; $transformer->visitStyleRules($gapText, static function (string $prelude) use (&$scopedMarkerRule): void { if (str_contains($prelude, 'be-r-') && preg_match('/blocks-engine-control-[a-f0-9]{12}-\d+/', $prelude)) $scopedMarkerRule = true; }); -$assert(1 === count($gapSite['assets']) && !$scopedMarkerRule, 'page-only marker rules fold into unscoped unions', $gapText); +$assert(1 === count($gapSite['assets']) && $scopedMarkerRule, 'page-only marker rules stay shared but exclude non-plan pages'); foreach (array(array($site, false), array($gapSite, true)) as [$checked, $gaps]) { foreach ($checked['plan']['pages'] as $row) { $index = (int) substr($row['source_path'], 1); @@ -574,7 +574,7 @@ } echo 'Marker-only fixture author CSS: ' . json_encode($markerSizes) . " bytes for 2/6/10 pages\n"; -// Mixed real-site rule shapes: hundreds of safe marker arms must not inherit +// Mixed real-site rule shapes: hundreds of base marker arms must not inherit // a shared-chrome arm's scope. Richtext swaps and a dropped plain rule use // the same per-arm membership path, including complementary scopes. $armSizes = array(); @@ -609,19 +609,19 @@ $assert($expected->resolve($element, $properties, array()) === $actual->resolve($element, $properties, array()), 'mixed-arm computed-style parity ' . $count . ' routes: ' . $owner . ' ' . $element->textContent); } } - $assert(str_contains($text, ':root:not(') || 2 === $count, 'the dropped rule uses the shorter complementary scope'); + $assert(str_contains($text, ':root:not(') || 2 === $count, 'the dropped base rule uses complementary scope'); } -$assert($armSizes[30] < 1.1 * $armSizes[2], 'mixed-arm site-wide bytes stay roughly constant in N', json_encode($armSizes)); +$assert($armSizes[30] < 1.4 * $armSizes[2], 'mixed-arm site-wide bytes stay roughly constant in N', json_encode($armSizes)); // Private richtext carriers (including packed style$/editor-span variants) -// can be emitted unscoped, but negations and unknown carriers cannot. +// still need positive scope when absent from the virtual base projection. $richOne = 'blocks-engine-richtext-dddddddddddd-0'; $richTwo = 'blocks-engine-richtext-eeeeeeeeeeee-0'; $richList = static fn (string $marker): string => ':where(mark[style*="--blocks-engine-richtext-marker:' . $marker . ';"],mark[style$="--blocks-engine-richtext-marker:' . $marker . '"],span[data-blocks-engine-richtext-marker="' . $marker . '"])'; $richPages = array('one' => array($richList($richOne) . '{color:red}'), 'two' => array($richList($richTwo) . '{color:red}')); $richMarkup = array('one' => 'One', 'two' => 'Two'); $privateRich = $transformer->scopeRouteVariants($richPages['one'], $richPages, array(), null, $richMarkup); -$assert(!str_contains($privateRich['stylesheet'], 'be-r-'), 'private richtext marker swaps need no route scopes'); +$assert(str_contains($privateRich['stylesheet'], ':root:is('), 'variant-only private richtext excludes non-plan pages'); $unknownRich = $transformer->scopeRouteVariants($richPages['one'], $richPages); $assert(str_contains($unknownRich['stylesheet'], 'be-r-'), 'missing carrier evidence never proves richtext arms inert'); $negated = ':not(:where(.blocks-engine-control-aaaaaaaaaaaa-0)){color:red}'; @@ -697,12 +697,60 @@ $assert($index->inertOutside('.absent:hover', array('plain-1' => true)), 'state-independent non-match is inert'); $assert(!$index->inertOutside('.x:hover', array('plain-1' => true)), 'stateful structural match stays scoped'); +// Route state is restored after compilation. Neither DOM non-matches nor +// private marker carriers can prove these arms inert on another route. +$navMarkup = ''; +$navDocuments = array(); +foreach (array('kept', 'outside') as $owner) { + $dom = new DOMDocument(); + @$dom->loadHTML('' . $navMarkup . ''); + $dom->documentElement->setAttribute('class', Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($owner)); + $navDocuments[$owner] = $dom; +} +$navIndex = new Automattic\BlocksEngine\PhpTransformer\Css\RouteSelectorMatchIndex($navDocuments); +$navArm = ':root .wp-block-navigation .wp-block-navigation-item .wp-block-navigation-item__content[aria-current="page"]:not(.be-c-0):not(.be-c-0):not(.be-c-0):not(.be-c-0)'; +$navRule = $navArm . '{background:rgb(245,245,245);color:black}'; +$navResult = $transformer->scopeRouteVariants(array($navRule), array('kept' => array($navRule), 'outside' => array('')), array(), null, array('kept' => $navMarkup, 'outside' => $navMarkup), $navIndex); +$assert(str_contains($navResult['stylesheet'], 'be-r-'), 'render-time aria-current rule stays route scoped'); +foreach ($navDocuments as $owner => $dom) { + $anchor = $dom->getElementsByTagName('a')->item(0); + $anchor->setAttribute('aria-current', 'page'); + $expected = new StaticCssCascade($dom, 'kept' === $owner ? $navRule : ''); + $actual = new StaticCssCascade($dom, $navResult['stylesheet']); + $assert($expected->resolve($anchor, array('background', 'color'), array()) === $actual->resolve($anchor, array('background', 'color'), array()), 'rendered current navigation parity: ' . $owner); +} +foreach (array('current-menu-item', 'current_page_item', 'current-menu-parent', 'current_page_parent', 'current-menu-ancestor', 'current_page_ancestor', 'blocks-engine-current-navigation-item', 'active', 'is-selected') as $stateClass) { + $assert(!$navIndex->inertOutside('.' . $stateClass . ' a', array('outside' => true)), 'render-time class cannot prove inertness: ' . $stateClass); +} +$stateMarker = 'blocks-engine-control-0123456789ab-99'; +$stateRule = ':where(.' . $stateMarker . ') [aria-current="page"]{color:red}'; +$stateResult = $transformer->scopeRouteVariants(array($stateRule), array('kept' => array($stateRule), 'outside' => array('')), array(), null, array('kept' => '
', 'outside' => $navMarkup), $navIndex); +$assert(str_contains($stateResult['stylesheet'], 'be-r-'), 'marker shortcut cannot bypass render-time route state'); + +// A non-plan page has no route hook: its projection is the shared base, not +// the union of page variants. A base arm must use exclusions even when a +// positive list would be shorter. Variant-only arms must not leak either. +$unplannedBase = 'body{font-family:Arial;line-height:1.5;color:red}'; +$unplannedPages = array('kept' => array($unplannedBase . 'body{background:blue}')); +for ($k = 0; $k < 8; ++$k) $unplannedPages['dropped-' . $k] = array(''); +$unplannedResult = $transformer->scopeRouteVariants(array($unplannedBase), $unplannedPages); +$assert(str_contains($unplannedResult['stylesheet'], ':root:not('), 'base rules exclude dropped plan routes rather than requiring a route class'); +foreach (array_merge(array(null), array_keys($unplannedPages)) as $owner) { + $dom = new DOMDocument(); + @$dom->loadHTML('Non-plan page'); + if (null !== $owner) $dom->documentElement->setAttribute('class', Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($owner)); + $expected = new StaticCssCascade($dom, null === $owner ? $unplannedBase : implode('', $unplannedPages[$owner])); + $actual = new StaticCssCascade($dom, $unplannedResult['stylesheet']); + $body = $dom->getElementsByTagName('body')->item(0); + $assert($expected->resolve($body, array('font-family', 'line-height', 'color', 'background'), array()) === $actual->resolve($body, array('font-family', 'line-height', 'color', 'background'), array()), 'base/variant parity with route class: ' . ($owner ?? 'none')); +} + // Identical shared units align separately at each source occurrence. $marker = 'blocks-engine-control-0123456789ab-0'; $unit = '.group>.btn{z-index:1}'; $variant = '.group>.btn,:where(.' . $marker . '){z-index:1}'; $duplicates = $transformer->scopeRouteVariants(array($unit . '.between{z-index:2}' . $unit), array('a' => array($variant . '.between{z-index:2}' . $variant)), array(), null, array('a' => '

Probe

')); -$assert(2 === substr_count($duplicates['stylesheet'], $marker), 'arm reconciliation rewrites every aligned shared unit without moving it'); +$assert(4 === substr_count($duplicates['stylesheet'], '{z-index:1}') && 4 === substr_count($duplicates['stylesheet'], $marker), 'arm reconciliation retains both occurrences with descendant/root route alternatives'); // Identical leading imports remain site-wide despite non-dedupable units. $import = '@import url(/external/8e2e7efb6cf0362d/css-659beb8e13af.css);'; From 91ed1d394d806c0bdcfccc6abfadcb9714649810 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 27 Oct 2026 10:52:40 -0400 Subject: [PATCH 09/10] Prove non-plan inertness from template-part engine classes --- .../src/ArtifactCompiler/ArtifactCompiler.php | 15 ++++-- .../src/Css/CssStylesheetTransformer.php | 40 ++++++++++++--- .../shared-author-stylesheet-scaling.php | 50 ++++++++++++++----- 3 files changed, 82 insertions(+), 23 deletions(-) diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index f4b93d79d..bc142b679 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -381,8 +381,17 @@ private function finalizeArtifact(array $artifact, array $reduction): Transforme foreach ($compiledHtmlDocuments as $sourcePath => $document) { $pageMarkup[(string) ($ownedBy(array(array()), (string) $sourcePath)[0]['owner'] ?? '')] = $document['serialized_blocks']; } + // These are the template-part candidates consumed by compiledSiteReport + // and WordPressSitePlan's shell extraction. serialized_blocks alone omits + // extracted chrome. Use a conservative union of all candidate parts. + $sharedMarkup = ''; + $parts = array_merge($this->compiledSiteTemplateParts($normalized['files']), $entryBlocks['shell_artifacts'], $inlineShellCompilation['artifacts']); + foreach ($compiledHtmlDocuments as $document) $parts = array_merge($parts, $document['shell_artifacts'] ?? array()); + foreach ($parts as $part) $sharedMarkup .= "\n" . (string) ($part['block_markup'] ?? ''); + foreach ($pageMarkup as &$markup) $markup .= "\n" . $sharedMarkup; + unset($markup); $authoredFiles = $normalized['files']; - $normalized['files'] = $this->applyAuthorStylesheetProjections($normalized['files'], $authorStylesheetProjections, $entryBlocks['author_stylesheet_projections'], (string) ($ownedBy(array(array()), $entryPath)[0]['owner'] ?? ''), $pageMarkup, $allDiagnostics); + $normalized['files'] = $this->applyAuthorStylesheetProjections($normalized['files'], $authorStylesheetProjections, $entryBlocks['author_stylesheet_projections'], (string) ($ownedBy(array(array()), $entryPath)[0]['owner'] ?? ''), $pageMarkup, $allDiagnostics, $sharedMarkup); foreach ($normalized['files'] as $document) { if ('html' !== ($document['kind'] ?? '') || !is_string($document['content'] ?? null) || !empty($document['metadata']['compilation']['included_component'])) continue; $documentFiles = $normalized['files']; @@ -2519,7 +2528,7 @@ private function allocateStylesheetOccurrencePath(string $candidate, array &$res * @param string $primaryOwner The page that produced the primary projections, '' when they are site-wide. * @return array> */ - private function applyAuthorStylesheetProjections(array $files, array $projections, array $primaryProjections = array(), string $primaryOwner = '', array $pageMarkup = array(), array &$projectionDiagnostics = array()): array + private function applyAuthorStylesheetProjections(array $files, array $projections, array $primaryProjections = array(), string $primaryOwner = '', array $pageMarkup = array(), array &$projectionDiagnostics = array(), string $sharedMarkup = ''): array { $attributeStateMarkers = array(); foreach ($projections as $projection) { @@ -2684,7 +2693,7 @@ private function applyAuthorStylesheetProjections(array $files, array $projectio $sharedStylesheets = array($transformer->concatenateWithoutRedundantRules($sharedStylesheets)); $fallbackSharedStylesheets = $sharedStylesheets; foreach ($pages as $owner => $_) $pages[$owner] = $pageProjections[$owner] ?? $pages[$owner]; - $routed = $transformer->scopeRouteVariants($sharedStylesheets, $pageProjections, $routeIdentities, preg_match('/@namespace\b/i', implode('', $preambles)) ? '@namespace' : null, $pageMarkup, $routeMatchIndex, $this->routeClasses); + $routed = $transformer->scopeRouteVariants($sharedStylesheets, $pageProjections, $routeIdentities, preg_match('/@namespace\b/i', implode('', $preambles)) ? '@namespace' : null, $pageMarkup, $routeMatchIndex, $this->routeClasses, $sharedMarkup); // Recompact only at serialization, merging compatible marker arms. $recompact = static fn (string $css): string => count($pageProjections) > 1 ? CssSelectorListCompactor::stylesheet($css, true) : $css; // A cached shared download replaces separate page-copy downloads. diff --git a/php-transformer/src/Css/CssStylesheetTransformer.php b/php-transformer/src/Css/CssStylesheetTransformer.php index ece2798ac..7edcc09ab 100644 --- a/php-transformer/src/Css/CssStylesheetTransformer.php +++ b/php-transformer/src/Css/CssStylesheetTransformer.php @@ -256,6 +256,21 @@ private static function positiveMarker(string $arm): ?string return null; } + /** Required literal classes, never alternatives, negations or attributes. */ + private static function requiredPageMarkers(string $arm): array + { + $markers = array(); + if (str_contains($arm, '/*') || str_contains($arm, '\\')) return $markers; + $positive = self::positiveMarker($arm); + if (null !== $positive) $markers[$positive] = true; + preg_match_all('/\.(blocks-engine-[a-z-]+-[a-f0-9]{12}-[0-9]+)(?![a-zA-Z0-9_-])/', $arm, $matches, PREG_OFFSET_CAPTURE); + foreach ($matches[1] as [$marker, $offset]) { + $before = substr($arm, 0, $offset - 1); + if (0 === self::parenDepth($before) && substr_count($before, '[') === substr_count($before, ']')) $markers[$marker] = true; + } + return array_keys($markers); + } + /** * Reconcile ordered page projections at the source cascade position. * Unsupported pages are returned separately for the legacy copy path. @@ -278,14 +293,14 @@ private static function positiveMarker(string $arm): ?string * @param array $routeIdentities Route identities by compilation owner. * @param array $markup Canonical markup by compilation owner. * @param array $routeClasses Site-wide collision-free classes by route identity. + * @param ?string $sharedMarkup Shared template-part markup; null means no carrier evidence. * `route_bytes` counts scope and split-rule overhead beyond the unscoped * arm union, plus scoped insertions, before final compaction. * * @return array{stylesheet:string, scoped:list, reasons:array, route_bytes:int} */ - public function scopeRouteVariants(array $shared, array $pages, array $routeIdentities = array(), ?string $unsafeReason = null, array $markup = array(), ?RouteSelectorMatchIndex $matchIndex = null, array $routeClasses = array()): array + public function scopeRouteVariants(array $shared, array $pages, array $routeIdentities = array(), ?string $unsafeReason = null, array $markup = array(), ?RouteSelectorMatchIndex $matchIndex = null, array $routeClasses = array(), ?string $sharedMarkup = null): array { - $carriers = self::markerCarriers($markup); $base = array(); foreach ($shared as $css) $this->collectRuleUnits($css, array(), $base); $base = self::withoutIgnoredImports($base); @@ -383,11 +398,13 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden $routeClass = static fn ($owner): string => $routeClasses[$routeIdentities[$owner] ?? (string) $owner]; $routeBytes = 0; $allRoutes = array_fill_keys(array_keys($pages), true); - // Non-plan WordPress pages receive the unprojected shared sheet. They - // have no route class and no compiled markup, so can never prove inertness. + // Non-plan content is unknown, but imported engine classes can only + // come from shared template parts. Never run a full matcher here. $baseOwner = ''; while (isset($allRoutes[$baseOwner])) $baseOwner .= '#'; $allRoutes[$baseOwner] = true; + if (null !== $sharedMarkup) $markup[$baseOwner] = $sharedMarkup; + $carriers = self::markerCarriers($markup); $emitArms = function (array $units, array $prototype, bool $insertion = false) use (&$output, &$routeBytes, $routeClass, $allRoutes, $baseOwner, $carriers, $markup, $matchIndex): void { [$lead, $rule] = self::splitLeadingComments($prototype['text']); $brace = (int) $this->nextRuleBoundary($rule, 0); @@ -404,11 +421,18 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden $groups = array(); foreach ($members as $arm => $routes) { $outside = array_diff_key($allRoutes, $routes); - $marker = self::positiveMarker($arm); $inert = !$outside; - if (!$inert && !isset($outside[$baseOwner]) && !RouteSelectorMatchIndex::dependsOnRouteState($arm)) { - $inert = null !== $marker && !array_diff_key($outside, $markup) && !array_diff_key($carriers[$marker] ?? array(), $routes); - if (!$inert && null !== $matchIndex) $inert = $matchIndex->inertOutside($arm, $outside); + if (!$inert && !RouteSelectorMatchIndex::dependsOnRouteState($arm)) { + $unproven = $outside; + foreach (self::requiredPageMarkers($arm) as $marker) { + // A missing required carrier disproves the arm on that + // document, including required ancestors and subjects. + foreach ($unproven as $owner => $_) { + if (isset($markup[$owner]) && !isset($carriers[$marker][$owner])) unset($unproven[$owner]); + } + } + $inert = !$unproven; + if (!$inert && !isset($unproven[$baseOwner]) && null !== $matchIndex) $inert = $matchIndex->inertOutside($arm, $unproven); } $scope = ''; if (!$inert) { diff --git a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php index 0f277146e..ce8191737 100644 --- a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php +++ b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php @@ -500,9 +500,8 @@ // Rules that differ between pages only by page-local block markers. Every // page has its own buttons in a `.row-cols-3` grid, so `.row-cols-3>*` and // `.row>*` project onto each page's own per-element markers, which the page -// projection packs as `>:is(:where(.m1),…)`. With the virtual base route, -// variant-only markers must now be scoped even if private to one plan page. -// Small cases reconcile; larger ones may hit the unchanged byte-budget fallback. +// projection packs as `>:is(:where(.m1),…)`. Private markers are absent +// from shared template parts, so even variant-only arms stay unscoped. $markerCss = '.row{display:flex;flex-wrap:wrap}.row-cols-3>*{flex:0 0 auto;width:33.3%}.row>*{flex-shrink:0;max-width:100%}' . '.btn{background-color:#123456;color:#fff;border-radius:4px;padding:8px 16px}'; for ($i = 0; $i < 120; ++$i) $markerCss .= '.marker-tail-' . $i . '{color:#123456;margin:0;padding:2px;border-top-width:1px}'; @@ -525,16 +524,12 @@ $text = implode('', $site['assets']); $markerSizes[$count] = strlen($text); $fallbacks = array_filter($site['diagnostics'], static fn (array $row): bool => 'author_stylesheet_route_scope_fallback' === ($row['code'] ?? '')); - if (2 === $count) { - $assert(1 === count($site['assets']) && str_contains($text, 'be-r-') && !$fallbacks, 'small marker fixture scopes variants without copies'); - } else { - $assert(count($site['assets']) > 1 && count($fallbacks) === 1, $count . ' marker pages: unknown non-plan markup retains the unchanged byte-budget fallback'); - } + $assert(1 === count($site['assets']) && !str_contains($text, 'be-r-') && !$fallbacks, $count . ' private marker pages: unscoped shared asset without fallback'); } $markerText = implode('', $site['assets']); $assert((bool) preg_match('/:where\((?:\.blocks-engine-control-[a-f0-9]{12}-\d+,)+/', $markerText), 'same-route page-local markers still recompact into :where() lists'); -// Even the budget fallback must not duplicate the long unchanged tail. -$assert($markerSizes[10] < 1.4 * $markerSizes[2] && $markerSizes[10] - $markerSizes[6] < 1.2 * ($markerSizes[6] - $markerSizes[2]) + 512, 'site-wide sheet bytes stay roughly constant in the page count', json_encode($markerSizes)); +// Growth is limited to marker names, not route scopes or cascade copies. +$assert($markerSizes[10] < 1.2 * $markerSizes[2] && $markerSizes[10] - $markerSizes[6] < 1.2 * ($markerSizes[6] - $markerSizes[2]) + 512, 'site-wide sheet bytes stay roughly constant in the page count', json_encode($markerSizes)); $markerProbe = static function (array $plan, array $row): array { $assets = array_column($plan['assets'], null, 'target_path'); $css = ''; @@ -554,14 +549,14 @@ return $styles; }; // With every third page empty, its projection is the shared one. Other -// pages' marker rules are page-only and must exclude unknown non-plan pages. +// pages' marker rules are page-only but absent from shared chrome. $gapSite = $markerSite(9, true); $gapText = implode('', $gapSite['assets']); $scopedMarkerRule = false; $transformer->visitStyleRules($gapText, static function (string $prelude) use (&$scopedMarkerRule): void { if (str_contains($prelude, 'be-r-') && preg_match('/blocks-engine-control-[a-f0-9]{12}-\d+/', $prelude)) $scopedMarkerRule = true; }); -$assert(1 === count($gapSite['assets']) && $scopedMarkerRule, 'page-only marker rules stay shared but exclude non-plan pages'); +$assert(1 === count($gapSite['assets']) && !$scopedMarkerRule, 'page-only private marker rules stay shared and unscoped'); foreach (array(array($site, false), array($gapSite, true)) as [$checked, $gaps]) { foreach ($checked['plan']['pages'] as $row) { $index = (int) substr($row['source_path'], 1); @@ -697,6 +692,37 @@ $assert($index->inertOutside('.absent:hover', array('plain-1' => true)), 'state-independent non-match is inert'); $assert(!$index->inertOutside('.x:hover', array('plain-1' => true)), 'stateful structural match stays scoped'); +// Virtual-base proofs use required carriers only, not arbitrary DOM matches. +// Exercise literal subjects, required ancestors, and the positiveMarker form. +foreach (array('.%s', '.%s a', ':where(.%s)') as $shape) { + $sizes = array(); + foreach (array(2, 12, 112) as $count) { + $pages = $markup = array(); + for ($k = 0; $k < $count; ++$k) { + $marker = 'blocks-engine-control-' . substr(hash('sha256', (string) $k), 0, 12) . '-0'; + $pages['route-' . $k] = array(sprintf($shape, $marker) . '{color:red}'); + $markup['route-' . $k] = ''; + } + $private = $transformer->scopeRouteVariants(array(), $pages, array(), null, $markup, null, array(), '
Shared
'); + $assert(!str_contains($private['stylesheet'], 'be-r-') && 0 === $private['route_bytes'], $shape . ': N variant-only private classes stay unscoped: ' . $count); + // Serialized arm names grow; scope overhead stays constant at zero. + $sizes[$count] = $private['route_bytes']; + $header = '
Shared
'; + $carried = $transformer->scopeRouteVariants(array(), $pages, array(), null, $markup, null, array(), $header); + $assert(str_contains($carried['stylesheet'], 'be-r-'), $shape . ': shared-header carrier prevents base inertness: ' . $count); + $dom = new DOMDocument(); + @$dom->loadHTML('' . $header . ''); + $cascade = new StaticCssCascade($dom, $carried['stylesheet']); + $target = str_ends_with($shape, ' a') ? $dom->getElementsByTagName('a')->item(0) : $dom->getElementsByTagName('header')->item(0); + $assert(!isset($cascade->resolve($target, array('color'), array())['color']), 'variant does not paint shared header on a no-route page'); + } + $assert(1 === count(array_unique($sizes)), 'private marker scope overhead is constant in N'); +} +foreach (array(':not(.blocks-engine-control-aaaaaaaaaaaa-0)', ':is(.blocks-engine-control-aaaaaaaaaaaa-0,a)', '[title=".blocks-engine-control-aaaaaaaaaaaa-0"]') as $arm) { + $result = $transformer->scopeRouteVariants(array(), array('one' => array($arm . '{color:red}')), array(), null, array('one' => ''), null, array(), ''); + $assert(str_contains($result['stylesheet'], 'be-r-'), 'optional or quoted markers do not prove base inertness: ' . $arm); +} + // Route state is restored after compilation. Neither DOM non-matches nor // private marker carriers can prove these arms inert on another route. $navMarkup = ''; From b960d39e6d1e9a486ce185b47710e3df7597d1a3 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 27 Oct 2026 12:55:59 -0400 Subject: [PATCH 10/10] Use the shortest base-excluding route scope and budget shipped bytes --- .../src/Css/CssStylesheetTransformer.php | 42 ++++++++++------- .../WordPressSitePlan/DocumentRootContext.php | 2 +- .../shared-author-stylesheet-scaling.php | 47 ++++++++++++++++--- .../unit/document-root-font-ownership.php | 2 +- 4 files changed, 67 insertions(+), 26 deletions(-) diff --git a/php-transformer/src/Css/CssStylesheetTransformer.php b/php-transformer/src/Css/CssStylesheetTransformer.php index 7edcc09ab..f758eb258 100644 --- a/php-transformer/src/Css/CssStylesheetTransformer.php +++ b/php-transformer/src/Css/CssStylesheetTransformer.php @@ -286,16 +286,16 @@ private static function requiredPageMarkers(string $arm): array * @param array> $pages * Aligned rules are expanded into arms, each with its own route set. * Arms proven inert outside that set stay unscoped; all others use the - * positive scope for variant-only arms, or a complementary negative scope - * for arms retained by the virtual no-route-class base document. Only validated lists + * shortest base-excluding scope for variant-only arms, or a complementary + * negative scope for arms retained by the virtual base document. Only validated lists * are split, preserving invalid-selector isolation and source order. * * @param array $routeIdentities Route identities by compilation owner. * @param array $markup Canonical markup by compilation owner. * @param array $routeClasses Site-wide collision-free classes by route identity. * @param ?string $sharedMarkup Shared template-part markup; null means no carrier evidence. - * `route_bytes` counts scope and split-rule overhead beyond the unscoped - * arm union, plus scoped insertions, before final compaction. + * `route_bytes` counts shipped growth after serialization and recompaction + * over the unscoped arm-union sheet (including inserted arms). * * @return array{stylesheet:string, scoped:list, reasons:array, route_bytes:int} */ @@ -396,7 +396,7 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden foreach (array_keys($pages) as $owner) $identities[] = $routeIdentities[$owner] ?? (string) $owner; $routeClasses = $routeClasses ?: \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClasses($identities); $routeClass = static fn ($owner): string => $routeClasses[$routeIdentities[$owner] ?? (string) $owner]; - $routeBytes = 0; + $unionOutput = array(); $allRoutes = array_fill_keys(array_keys($pages), true); // Non-plan content is unknown, but imported engine classes can only // come from shared template parts. Never run a full matcher here. @@ -405,7 +405,7 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden $allRoutes[$baseOwner] = true; if (null !== $sharedMarkup) $markup[$baseOwner] = $sharedMarkup; $carriers = self::markerCarriers($markup); - $emitArms = function (array $units, array $prototype, bool $insertion = false) use (&$output, &$routeBytes, $routeClass, $allRoutes, $baseOwner, $carriers, $markup, $matchIndex): void { + $emitArms = function (array $units, array $prototype) use (&$output, &$unionOutput, $routeClass, $allRoutes, $baseOwner, $carriers, $markup, $matchIndex): void { [$lead, $rule] = self::splitLeadingComments($prototype['text']); $brace = (int) $this->nextRuleBoundary($rule, 0); $body = substr($rule, $brace); @@ -439,13 +439,19 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden if (isset($routes[$baseOwner])) { $scope = ':where(:root:not(' . implode(',', array_map(static fn ($owner): string => '.' . $routeClass($owner), array_keys($outside))) . '))'; } else { - $scope = ':where(:root:is(' . implode(',', array_map(static fn ($owner): string => '.' . $routeClass($owner), array_keys($routes))) . '))'; + $positive = ':where(:root:is(' . implode(',', array_map(static fn ($owner): string => '.' . $routeClass($owner), array_keys($routes))) . '))'; + unset($outside[$baseOwner]); + // The exact marker excludes non-plan documents even when + // every plan route keeps this variant-only arm. + $negative = ':where(:root.be-r' . ($outside ? ':not(' . implode(',', array_map(static fn ($owner): string => '.' . $routeClass($owner), array_keys($outside))) . ')' : '') . ')'; + $scope = strlen($positive) <= strlen($negative) ? $positive : $negative; } } $groups[$scope][] = $arm; } - $emittedBytes = 0; - $unionBytes = strlen($lead . implode(',', array_keys($members)) . $body); + $union = $prototype; + $union['text'] = $lead . implode(',', array_keys($members)) . $body; + $unionOutput[] = $union; foreach ($groups as $scope => $arms) { $unit = $prototype; if ('' !== $scope) { @@ -454,16 +460,11 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden } $unit['text'] = $lead . implode(',', $arms) . $body; $output[] = $unit; - $emittedBytes += strlen($unit['text']); - // A scoped insertion is entirely new shared-download cost, - // not just the prefix added to its unscoped selector list. - if ($insertion && '' !== $scope) $routeBytes += strlen(implode(',', $groups[$scope]) . $body); $lead = ''; } - $routeBytes += max(0, $emittedBytes - $unionBytes); }; for ($i = 0; $i <= count($base); ++$i) { - foreach ($siteInsertions[$i] ?? array() as $unit) $output[] = $unit; + foreach ($siteInsertions[$i] ?? array() as $unit) { $output[] = $unit; $unionOutput[] = $unit; } $groups = array(); foreach ($insertions[$i] ?? array() as $owner => $units) foreach ($units as $ordinal => $unit) { $groupKey = serialize(array($ordinal, $key($unit))); @@ -472,14 +473,19 @@ public function scopeRouteVariants(array $shared, array $pages, array $routeIden $groups[$groupKey]['routes'][$owner] = $unit; } uasort($groups, static fn (array $a, array $b): int => $a['ordinal'] <=> $b['ordinal']); - foreach ($groups as $group) $emitArms($group['routes'], $group['unit'], true); + foreach ($groups as $group) $emitArms($group['routes'], $group['unit']); if ($i === count($base)) break; - if (empty($changes[$i])) { $output[] = $base[$i]; continue; } + if (empty($changes[$i])) { $output[] = $base[$i]; $unionOutput[] = $base[$i]; continue; } $units = array(); foreach ($allRoutes as $owner => $_) $units[$owner] = array_key_exists($owner, $changes[$i]) ? $changes[$i][$owner] : $base[$i]; $emitArms($units, $base[$i]); } - return array('stylesheet' => $this->serializeRuleUnits($output), 'scoped' => $scoped, 'reasons' => $reasons, 'route_bytes' => $routeBytes); + $stylesheet = $this->serializeRuleUnits($output); + // Match ArtifactCompiler's final serialization, not the expanded arms: + // identical scope prefixes can ship once for an entire selector group. + $recompact = static fn (string $css): string => count($pages) > 1 ? CssSelectorListCompactor::stylesheet($css, true) : $css; + $routeBytes = max(0, strlen($recompact($stylesheet)) - strlen($recompact($this->serializeRuleUnits($unionOutput)))); + return array('stylesheet' => $stylesheet, 'scoped' => $scoped, 'reasons' => $reasons, 'route_bytes' => $routeBytes); } /** diff --git a/php-transformer/src/WordPressSitePlan/DocumentRootContext.php b/php-transformer/src/WordPressSitePlan/DocumentRootContext.php index 292d54933..9b229e532 100644 --- a/php-transformer/src/WordPressSitePlan/DocumentRootContext.php +++ b/php-transformer/src/WordPressSitePlan/DocumentRootContext.php @@ -20,7 +20,7 @@ public static function fromHtml(string $html, string $tagName = 'html'): array public static function metadataFromHtml(string $html, string $routeIdentity = '', ?string $routeClass = null): array { $attributes = DocumentRootAttributes::forDocument($html); - if ('' !== $routeIdentity) $attributes['html']['class'] = trim(($attributes['html']['class'] ?? '') . ' ' . ($routeClass ?? self::routeClass($routeIdentity))); + if ('' !== $routeIdentity) $attributes['html']['class'] = trim(($attributes['html']['class'] ?? '') . ' be-r ' . ($routeClass ?? self::routeClass($routeIdentity))); return array('root_attributes' => $attributes['html'], 'body_attributes' => $attributes['body']); } diff --git a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php index ce8191737..9a0666663 100644 --- a/php-transformer/tests/contract/shared-author-stylesheet-scaling.php +++ b/php-transformer/tests/contract/shared-author-stylesheet-scaling.php @@ -331,7 +331,8 @@ foreach ($routePlan['pages'] as $row) { $class = Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass(WordPressSitePlan::identity('page', $row['source_path'], $row['source_path'])); $classes[$row['source_path']] = $class; - $assert(in_array($class, explode(' ', $row['document_metadata']['root_attributes']['class'] ?? ''), true), 'document root carries its source route class: ' . $row['source_path']); + $rootClasses = explode(' ', $row['document_metadata']['root_attributes']['class'] ?? ''); + $assert(in_array($class, $rootClasses, true) && in_array('be-r', $rootClasses, true), 'every plan document root carries exact plan marker and source route class: ' . $row['source_path']); $functions = array_column($routePlan['writes'], null, 'target_path')['functions.php']['payload']['data']; $assert(str_contains($functions, $class) && str_contains($functions, 'blocksEngineDocumentContext') && str_contains($functions, 'language_attributes'), 'the same root registry reaches front end and editor: ' . $row['source_path']); } @@ -596,7 +597,7 @@ foreach ($pages as $owner => $sheets) { $dom = new DOMDocument(); @$dom->loadHTML('' . $markup[$owner] . ''); - $dom->documentElement->setAttribute('class', Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($owner)); + $dom->documentElement->setAttribute('class', 'be-r ' . Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($owner)); $expected = new StaticCssCascade($dom, implode('', $sheets)); $actual = new StaticCssCascade($dom, $text); foreach (array('p', 'mark') as $tag) foreach ($dom->getElementsByTagName($tag) as $element) { @@ -639,7 +640,7 @@ $plainMarkup[$owner] = '

Probe

'; $dom = new DOMDocument(); @$dom->loadHTML('' . $plainMarkup[$owner] . ''); - $dom->documentElement->setAttribute('class', Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($owner)); + $dom->documentElement->setAttribute('class', 'be-r ' . Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($owner)); $plainDocuments[$owner] = $dom; } $index = new Automattic\BlocksEngine\PhpTransformer\Css\RouteSelectorMatchIndex($plainDocuments); @@ -720,7 +721,7 @@ } foreach (array(':not(.blocks-engine-control-aaaaaaaaaaaa-0)', ':is(.blocks-engine-control-aaaaaaaaaaaa-0,a)', '[title=".blocks-engine-control-aaaaaaaaaaaa-0"]') as $arm) { $result = $transformer->scopeRouteVariants(array(), array('one' => array($arm . '{color:red}')), array(), null, array('one' => ''), null, array(), ''); - $assert(str_contains($result['stylesheet'], 'be-r-'), 'optional or quoted markers do not prove base inertness: ' . $arm); + $assert(str_contains($result['stylesheet'], ':root.be-r'), 'optional or quoted markers do not prove base inertness: ' . $arm); } // Route state is restored after compilation. Neither DOM non-matches nor @@ -730,7 +731,7 @@ foreach (array('kept', 'outside') as $owner) { $dom = new DOMDocument(); @$dom->loadHTML('' . $navMarkup . ''); - $dom->documentElement->setAttribute('class', Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($owner)); + $dom->documentElement->setAttribute('class', 'be-r ' . Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($owner)); $navDocuments[$owner] = $dom; } $navIndex = new Automattic\BlocksEngine\PhpTransformer\Css\RouteSelectorMatchIndex($navDocuments); @@ -764,7 +765,7 @@ foreach (array_merge(array(null), array_keys($unplannedPages)) as $owner) { $dom = new DOMDocument(); @$dom->loadHTML('Non-plan page'); - if (null !== $owner) $dom->documentElement->setAttribute('class', Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($owner)); + if (null !== $owner) $dom->documentElement->setAttribute('class', 'be-r ' . Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::routeClass($owner)); $expected = new StaticCssCascade($dom, null === $owner ? $unplannedBase : implode('', $unplannedPages[$owner])); $actual = new StaticCssCascade($dom, $unplannedResult['stylesheet']); $body = $dom->getElementsByTagName('body')->item(0); @@ -810,6 +811,40 @@ $assert(3 === count(array_unique($collisionClasses)) && 14 === strlen($collisionClasses['collision-55045']) && 13 === strlen($collisionClasses['ordinary-route']), 'colliding hashes lengthen only as needed'); $assert($collisionClasses === $rootContext::routeClasses(array_reverse($collisionIdentities)), 'class allocation is independent of route order'); +// A majority variant uses exact plan membership plus short exclusions. +$majorityPages = array(); +for ($k = 0; $k < 32; ++$k) $majorityPages['majority-' . $k] = array($k ? '.majority{color:red}' : ''); +$majority = $transformer->scopeRouteVariants(array(), $majorityPages); +$assert(str_contains($majority['stylesheet'], ':root.be-r:not('), 'majority variant chooses short exact exclusion scope'); +foreach (array_merge(array(null, 'pretend'), array_keys($majorityPages)) as $owner) { + $dom = new DOMDocument(); + @$dom->loadHTML('

Probe

'); + if (null !== $owner) { + $metadata = Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\DocumentRootContext::metadataFromHtml('', $owner); + $dom->documentElement->setAttribute('class', 'pretend' === $owner ? 'be-r-impostor' : $metadata['root_attributes']['class']); + } + $cascade = new StaticCssCascade($dom, $majority['stylesheet']); + $painted = isset($cascade->resolve($dom->getElementsByTagName('p')->item(0), array('color'), array())['color']); + $assert($painted === (null !== $owner && 'pretend' !== $owner && 'majority-0' !== $owner), 'majority exclusion parity: ' . ($owner ?? 'no route')); +} + +// Many arms share one scope: expanded accounting exceeds the budget, but +// the serialized common prefix ships once and must not trigger page copies. +$groupArms = array(); +for ($k = 0; $k < 400; ++$k) $groupArms[] = 'p.a' . $k; +$groupRule = implode(',', $groupArms) . '{color:red}'; +$groupPages = array('kept' => array($groupRule), 'dropped' => array('')); +$grouped = $transformer->scopeRouteVariants(array($groupRule), $groupPages); +$compact = static fn (string $css): string => Automattic\BlocksEngine\PhpTransformer\Css\CssSelectorListCompactor::stylesheet($css, true); +$shippedGrowth = strlen($compact($grouped['stylesheet'])) - strlen($compact($groupRule)); +$groupBudget = max(8192, 3 * strlen($groupRule)); +$assert($grouped['route_bytes'] === $shippedGrowth && $shippedGrowth < $groupBudget && strlen($grouped['stylesheet']) - strlen($groupRule) > $groupBudget, 'budget counts shipped common scope once, not per expanded arm'); +$groupDiagnostics = array(); +$method = new ReflectionMethod(ArtifactCompiler::class, 'applyAuthorStylesheetProjections'); +$groupFiles = array(array('path' => 'group.css', 'kind' => 'css', 'content' => $groupRule)); +$groupOutput = $method->invokeArgs(new ArtifactCompiler(), array($groupFiles, array(array('path' => 'group.css', 'content' => $groupRule, 'owner' => 'kept'), array('path' => 'group.css', 'content' => '', 'owner' => 'dropped')), array(array('path' => 'group.css', 'content' => $groupRule)), '', array(), &$groupDiagnostics)); +$assert(!$groupDiagnostics && !str_contains(implode('', array_column($groupOutput, 'path')), '.page-'), 'compiler accepts grouped shipped scope below budget'); + // Exercise the compiler budget with explicit projections: all routes carry // the subject, so dropping a rule cannot be proven inert. Long unchanged // cascade tails make legacy copies costly in aggregate, not individually. diff --git a/php-transformer/tests/unit/document-root-font-ownership.php b/php-transformer/tests/unit/document-root-font-ownership.php index 69765057a..93efe7b91 100644 --- a/php-transformer/tests/unit/document-root-font-ownership.php +++ b/php-transformer/tests/unit/document-root-font-ownership.php @@ -23,7 +23,7 @@ $assert('document' === ($pages['index.html']['document_metadata']['root_attributes']['id'] ?? null), 'root ID travels alongside classes'); $bootstrap = ''; foreach ($plan['writes'] as $write) if ('functions.php' === $write['target_path']) $bootstrap = $write['payload']['data']; -$assert(str_contains($bootstrap, "'language_attributes'") && str_contains($bootstrap, 'WP_HTML_Tag_Processor') && str_contains($bootstrap, 'type-a be-r-') && str_contains($bootstrap, 'type-b be-r-'), 'generated runtime adopts route-owned root identity through native HTML API'); +$assert(str_contains($bootstrap, "'language_attributes'") && str_contains($bootstrap, 'WP_HTML_Tag_Processor') && str_contains($bootstrap, 'type-a be-r be-r-') && str_contains($bootstrap, 'type-b be-r be-r-'), 'generated runtime adopts route-owned root identity through native HTML API'); $assert(str_contains($bootstrap, "'_blocks_engine_reconciliation_identity'") && str_contains($bootstrap, 'is_front_page()'), 'runtime selection uses destination identity with route fallback'); $css = implode("\n", array_column(array_filter($plan['assets'], static fn(array $asset): bool => 'css' === $asset['kind']), 'content')); $assert(str_contains($css, '.type-a') && str_contains($css, '.type-b') && str_contains($css, '@media'), 'authored root selector and conditional cascade stay intact');