diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 29d573d7b..11eb9cac0 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -95,6 +95,7 @@ "@test:parity", "@test:packaging" ], + "test:canonical": "@test:scripts", "test:scripts": [ "Composer\\Config::disableProcessTimeout", "php tests/run.php" diff --git a/php-transformer/src/WordPressSitePlan/EditorStaticStateAssets.php b/php-transformer/src/WordPressSitePlan/EditorStaticStateAssets.php index eecfe7d43..b1c0e6a3f 100644 --- a/php-transformer/src/WordPressSitePlan/EditorStaticStateAssets.php +++ b/php-transformer/src/WordPressSitePlan/EditorStaticStateAssets.php @@ -75,10 +75,13 @@ public static function share(array $assets, AssetReferenceCanonicalizer $referen unset($resource['stylesheet_instances'], $resource['stylesheet_parent'], $resource['frame_fallback'], $resource['reference_origin'], $resource['references']); $resources[$path] = $resource; } - // The original resource remains the enqueue owner, with no duplicated payload. - $assets[$index] = WordPressSitePlan::withRewrittenTextContent($asset, ''); - $assets[$index]['stylesheet_parts'] = $paths; + // Keep occurrence ownership as metadata on a real resource, never + // as an empty CSS file that would also be published to disk. + $asset['stylesheet_parts'] = $paths; + unset($asset['content'], $asset['content_hash'], $asset['size_bytes']); + $resources[$paths[0]]['stylesheet_replays'][] = $asset; + unset($assets[$index]); } - return array_merge($assets, array_values($resources)); + return array_merge(array_values($assets), array_values($resources)); } } diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php index 48fd2810f..1a1908d10 100644 --- a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php +++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php @@ -570,6 +570,8 @@ public function fromCompilerInput(array $data, WordPressSitePlanInput $input): a $liveElementMarkers = preg_match_all(self::ELEMENT_MARKER_PATTERN, (string) json_encode(array($emitted($pages), $emitted($parts), $frameParts, $shellFrames['frames'] ?? array(), $shells['runtime_declarations'] ?? array(), $runtimeEntityRecords)), $liveMatches) ? array_fill_keys($liveMatches[0], true) : array(); $assets = self::projectSharedChromeStylesheets($assets, array_merge($parts, $frameParts), $pages, $references, $assetTargetCollisions, $liveElementMarkers); $assets = self::withoutDeadElementMarkerSelectors($assets, $liveElementMarkers); + // Removing dead selectors can consume a support sheet's last rule. + $assets = array_values(array_filter($assets, static fn(array $asset): bool => !in_array($asset['source'] ?? '', array('engine-support', 'editor-static-state'), true) || !is_string($asset['content'] ?? null) || '' !== trim($asset['content']))); foreach ($assetTargetCollisions as $collision) $this->recordUnitFinding(array( 'code' => 'wordpress_site_plan_colliding_asset_target', 'unit' => 'asset', @@ -1621,7 +1623,70 @@ private function scopeAssets(array $assets, array $documentsBySource): array $asset['scopes'] = array(self::stylesheetDocumentScope($page)); } unset($asset); - return $assets; + return self::allocateDocumentSupport($assets, array_keys(array_filter($documentsBySource, static fn(array $page): bool => empty($page['synthetic'])))); + } + + /** + * Partition generated rules by their exact consumers and delivery side. + * Opaque statements and URL-bearing sheets retain their original resource. + * + * @param array> $assets + * @param list $canonicalSources + * @return array> + */ + private static function allocateDocumentSupport(array $assets, array $canonicalSources): array + { + $rules = $eligible = array(); + foreach ($assets as $index => $asset) { + if ('engine-support' !== ($asset['source'] ?? '') || 'css' !== ($asset['kind'] ?? '') || in_array('global', array_column($asset['scopes'], 'kind'), true)) continue; + $content = (string) ($asset['content'] ?? ''); + if (false !== stripos($content, 'url(')) continue; + $tree = new \Automattic\BlocksEngine\PhpTransformer\Css\CssRuleTree($content); + if (!$tree->wellFormed) continue; + $parts = array(); + foreach ($tree->rules() as $rule) { + $text = trim(substr($content, $rule['start'], $rule['end'] - $rule['start'] + 1)); + if (';' === $content[$rule['boundary']]) { $parts = array(); break; } + $parts[] = $text; + } + if (array() === $parts) continue; + $eligible[$index] = true; + $delivery = serialize(array($asset['stylesheet_placement'] ?? '', $asset['stylesheet_target'] ?? 'both', $asset['media'] ?? '')); + foreach ($parts as $text) { + $key = $delivery . "\0" . $text; + if (!isset($rules[$key])) $rules[$key] = array('text' => $text, 'asset' => $asset, 'index' => $index, 'scopes' => array()); + foreach ($asset['scopes'] as $scope) $rules[$key]['scopes'][$scope['source_path']] = $scope; + } + } + $groups = array(); + foreach ($rules as $rule) { + ksort($rule['scopes']); + $asset = $rule['asset']; + $key = serialize(array($asset['stylesheet_placement'] ?? '', $asset['stylesheet_target'] ?? 'both', $asset['media'] ?? '', $rule['scopes'])); + if (!isset($groups[$key])) $groups[$key] = array('asset' => $asset, 'index' => $rule['index'], 'scopes' => array_values($rule['scopes']), 'rules' => array()); + $groups[$key]['rules'][] = $rule['text']; + } + // Each allocated sheet takes the slot of the first sheet it draws + // rules from, so the inventory keeps its cascade order. + $allocated = array(); + foreach ($groups as $key => $group) { + $asset = self::withRewrittenTextContent($group['asset'], implode("\n", $group['rules'])); + $path = 'assets/css/engine-support-' . ($asset['stylesheet_placement'] ?? 'after-author') . '-' . substr(hash('sha256', $key . $asset['content']), 0, 16) . '.css'; + $asset['source_path'] = $path; + $asset['target_path'] = 'assets/' . $path; + $asset['token'] = 'asset-' . substr(hash('sha256', $asset['target_path']), 0, 16); + $asset['reconciliation_identity'] = self::identity('asset', $path, $asset['target_path']); + $sources = array_column($group['scopes'], 'source_path'); + $asset['scopes'] = count($sources) === count($canonicalSources) && array() === array_diff($canonicalSources, $sources) + ? array(array('kind' => 'global')) : $group['scopes']; + $allocated[$group['index']][] = $asset; + } + $result = array(); + foreach ($assets as $index => $asset) { + if (!isset($eligible[$index])) $result[] = $asset; + foreach ($allocated[$index] ?? array() as $sheet) $result[] = $sheet; + } + return $result; } /** @@ -6864,6 +6929,9 @@ private static function bootstrap(array $assets, array $scripts = array(), array $lines[] = "add_filter( 'render_block_core/read-more', static function ( string \$content, array \$block ): string { if ( ! in_array( 'blocks-engine-listing-overlay', preg_split( '/\\s+/', (string) ( \$block['attrs']['className'] ?? '' ) ) ?: array(), true ) ) return \$content; \$tag = new WP_HTML_Tag_Processor( \$content ); if ( \$tag->next_tag( 'A' ) ) { \$tag->set_attribute( 'aria-hidden', 'true' ); \$tag->set_attribute( 'tabindex', '-1' ); } return \$tag->get_updated_html(); }, 10, 2 );"; } $lines[] = "add_action( 'wp_enqueue_scripts', static function (): void {"; + $lines[] = ' $blocks_engine_page_id = ' . self::DOCUMENT_ID_EXPRESSION . ';'; + $lines[] = " \$blocks_engine_page_identity = \$blocks_engine_page_id ? get_post_meta( \$blocks_engine_page_id, '_blocks_engine_reconciliation_identity', true ) : '';"; + $lines[] = " \$blocks_engine_page_uri = \$blocks_engine_page_id && '' === \$blocks_engine_page_identity && 'page' === get_post_type( \$blocks_engine_page_id ) ? trim( get_page_uri( \$blocks_engine_page_id ), '/' ) : null;"; $importLoaded = self::importLoadedStylesheets($assets); $stylesheetAttributes = array(); $stylesheetEnqueues = self::stylesheetEnqueues($assets, $pages, $rootDocuments); @@ -6947,8 +7015,8 @@ private static function bootstrap(array $assets, array $scripts = array(), array $asset = $enqueue['asset']; if (!StylesheetActivation::active($asset) || 'frontend' === ($asset['stylesheet_target'] ?? 'both') || isset($importLoaded[$asset['target_path']])) continue; $partSlugs = array(); - foreach ($partSlugsBySource[(string) ($enqueue['scope']['source_path'] ?? '')] ?? array() as $slug) $partSlugs[$slug] = true; - $editorStyles[] = array_filter(array('target_path' => $asset['target_path'], 'handle' => $enqueue['handle'], 'content_hash' => $asset['content_hash'], 'scopes' => array($enqueue['scope']), 'template_part_slugs' => array_keys($partSlugs), 'media' => $asset['source_media'] ?? $enqueue['media'], 'author_css' => 'engine-support' !== ($asset['source'] ?? ''), 'editor_only' => 'editor' === ($asset['stylesheet_target'] ?? 'both'), 'fallback' => empty($enqueue['fallback']) ? null : true), static fn(mixed $value): bool => null !== $value); + foreach ($enqueue['scopes'] ?? array($enqueue['scope']) as $owner) foreach ($partSlugsBySource[(string) ($owner['source_path'] ?? '')] ?? array() as $slug) $partSlugs[$slug] = true; + foreach ($enqueue['scopes'] ?? array($enqueue['scope']) as $editorScope) $editorStyles[] = array_filter(array('target_path' => $asset['target_path'], 'handle' => $enqueue['handle'], 'content_hash' => $asset['content_hash'], 'scopes' => array($editorScope), 'template_part_slugs' => array_keys($partSlugs), 'media' => $asset['source_media'] ?? $enqueue['media'], 'author_css' => 'engine-support' !== ($asset['source'] ?? ''), 'editor_only' => 'editor' === ($asset['stylesheet_target'] ?? 'both'), 'fallback' => empty($enqueue['fallback']) ? null : true), static fn(mixed $value): bool => null !== $value); } if (array() !== $editorStyles) { // Files and scopes own shared data; replay owns occurrence order, @@ -7275,6 +7343,16 @@ private static function stylesheetDeviceAttributes(array $occurrence): array private static function stylesheetEnqueues(array $assets, array $pages, array $rootDocuments = array()): array { $documentsBySource = self::assetOwnerDocuments($pages, $rootDocuments); + // Replay owners are delivery metadata, not empty stylesheet resources. + $deliveryAssets = array(); + foreach ($assets as $asset) { + if (!isset($asset['stylesheet_replays'])) { $deliveryAssets[] = $asset; continue; } + foreach ($asset['stylesheet_replays'] as $replay) $deliveryAssets[] = array_replace($asset, $replay); + $resource = $asset; + unset($resource['stylesheet_replays']); + $deliveryAssets[] = $resource; + } + $assets = $deliveryAssets; $assetsBySource = array_column($assets, null, 'source_path'); $before = $after = $instances = array(); $firstManaged = count($assets); @@ -7291,19 +7369,24 @@ private static function stylesheetEnqueues(array $assets, array $pages, array $r if (!isset($documentsBySource[$source])) continue; if (!array_filter($asset['scopes'], static fn(array $scope): bool => 'global' === $scope['kind'] || ($scope['source_path'] ?? null) === $source)) continue; $scope = self::stylesheetDocumentScope($documentsBySource[$source]); - $instances[$source][] = array('asset' => $asset, 'scope' => $scope, 'route' => $source, 'condition' => self::bootstrapScopeCondition($scope), 'media' => $occurrence['media'], 'handle' => 'blocks-engine-instance-' . substr(hash('sha256', $asset['target_path'] . "\0" . $source . "\0" . $occurrence['order']), 0, 16), 'order' => $occurrence['order'], 'resource_order' => $index) + self::stylesheetDeviceAttributes($occurrence); + $instances[$source][] = array('asset' => $asset, 'scope' => $scope, 'route' => $source, 'condition' => self::bootstrapScopeCondition($scope, true), 'media' => $occurrence['media'], 'handle' => 'blocks-engine-instance-' . substr(hash('sha256', $asset['target_path'] . "\0" . $source . "\0" . $occurrence['order']), 0, 16), 'order' => $occurrence['order'], 'resource_order' => $index) + self::stylesheetDeviceAttributes($occurrence); } if (!empty($asset['frame_fallback'])) { $row = array('asset' => $asset, 'scope' => array('kind' => 'global'), 'condition' => 'true', 'media' => (string) ($asset['media'] ?? ''), 'handle' => 'blocks-engine-frame-fallback-' . substr(hash('sha256', $asset['target_path']), 0, 12), 'fallback' => true); if ('before-author' === ($asset['stylesheet_placement'] ?? '')) $before[] = $row; else $after[] = $row; } - foreach ($asset['scopes'] as $scope) { + foreach ($asset['scopes'] as $scopeIndex => $scope) { + if (array() === $occurrences && 0 !== $scopeIndex) continue; if (array() !== $occurrences && 'global' !== $scope['kind']) continue; // A global resource with document instances still styles // WordPress-native routes (archives, search) that no source // document owns; the bootstrap guards it with the route flag. $fallback = array() !== $occurrences; - $row = array('asset' => $asset, 'scope' => $scope, 'condition' => self::bootstrapScopeCondition($scope), 'media' => (string) ($asset['media'] ?? ''), 'handle' => 'blocks-engine-' . substr(hash('sha256', $asset['target_path']), 0, 12), 'fallback' => $fallback); + $row = array('asset' => $asset, 'scope' => $scope, 'condition' => self::bootstrapScopeCondition($scope, true), 'media' => (string) ($asset['media'] ?? ''), 'handle' => 'blocks-engine-' . substr(hash('sha256', $asset['target_path']), 0, 12), 'fallback' => $fallback); + if (!$fallback) { + $row['scopes'] = $asset['scopes']; + $row['condition'] = self::compactStylesheetCondition($asset['scopes']); + } if (!$fallback && ('before-author' === ($asset['stylesheet_placement'] ?? '') || ($index < $firstManaged && 'after-author' !== ($asset['stylesheet_placement'] ?? '') && 'wordpress-compat' !== ($asset['source'] ?? '')))) $before[] = $row; else $after[] = $row; } @@ -7349,6 +7432,7 @@ public static function documentStylesheets(array $plan, string $sourcePath): arr $asset = $enqueue['asset']; if (!empty($enqueue['fallback']) || isset($importLoaded[$asset['target_path']])) continue; $owned = isset($enqueue['route']) ? $sourcePath === $enqueue['route'] : ('global' === $enqueue['scope']['kind'] || $sourcePath === ($enqueue['scope']['source_path'] ?? null)); + if (isset($enqueue['scopes'])) $owned = in_array('global', array_column($enqueue['scopes'], 'kind'), true) || in_array($sourcePath, array_column($enqueue['scopes'], 'source_path'), true); if ($owned) $sequence[] = array('target_path' => (string) $asset['target_path'], 'handle' => (string) $enqueue['handle'], 'media' => (string) $enqueue['media'], 'stylesheet_target' => (string) ($asset['stylesheet_target'] ?? 'both')); } return $sequence; @@ -7404,13 +7488,29 @@ private static function importLoadedStylesheets(array $assets): array */ public const DOCUMENT_ID_EXPRESSION = "(int) apply_filters( 'blocks_engine_document_id', is_singular() ? get_queried_object_id() : 0 )"; + /** @param list> $scopes */ + private static function compactStylesheetCondition(array $scopes): string + { + if (1 === count($scopes)) return self::bootstrapScopeCondition($scopes[0], true); + $conditions = $identities = $paths = array(); + foreach ($scopes as $scope) { + if ('page' === $scope['kind'] && empty($scope['front_page'])) { + $identities[] = $scope['reconciliation_identity']; + $paths[] = $scope['route_path']; + } else $conditions[] = '(' . self::bootstrapScopeCondition($scope, true) . ')'; + } + if (array() !== $identities) $conditions[] = "( 0 !== \$blocks_engine_page_id && ( '' !== \$blocks_engine_page_identity ? in_array( \$blocks_engine_page_identity, " . BootstrapData::literal($identities) . ", true ) : in_array( \$blocks_engine_page_uri, " . BootstrapData::literal($paths) . ", true ) ) )"; + return implode(' || ', $conditions); + } + /** @param array $scope */ - private static function bootstrapScopeCondition(array $scope): string + private static function bootstrapScopeCondition(array $scope, bool $compact = false): string { if ('global' === ($scope['kind'] ?? null)) return 'true'; if ('archive' === ($scope['kind'] ?? null)) return var_export($scope['route_path'], true) . ' === blocks_engine_archive_path()'; if (!empty($scope['front_page'])) return 'is_front_page()'; if ('post' === ($scope['kind'] ?? null)) return "is_singular( 'post' ) && " . var_export($scope['reconciliation_identity'], true) . " === get_post_meta( get_queried_object_id(), '_blocks_engine_reconciliation_identity', true )"; + if ($compact) return "0 !== \$blocks_engine_page_id && ( '' !== \$blocks_engine_page_identity ? " . var_export($scope['reconciliation_identity'], true) . " === \$blocks_engine_page_identity : " . var_export($scope['route_path'], true) . " === \$blocks_engine_page_uri )"; return "0 !== ( \$blocks_engine_page_id = " . self::DOCUMENT_ID_EXPRESSION . " ) && ( '' !== ( \$blocks_engine_page_identity = get_post_meta( \$blocks_engine_page_id, '_blocks_engine_reconciliation_identity', true ) ) ? " . var_export($scope['reconciliation_identity'], true) . " === \$blocks_engine_page_identity : 'page' === get_post_type( \$blocks_engine_page_id ) && " . var_export($scope['route_path'], true) . " === trim( get_page_uri( \$blocks_engine_page_id ), '/' ) )"; } private static function assertAssetScopes(mixed $scopes): void diff --git a/php-transformer/tests/contract/declaration-addressed-chrome.php b/php-transformer/tests/contract/declaration-addressed-chrome.php index 33b7bbf8f..475c4fb49 100644 --- a/php-transformer/tests/contract/declaration-addressed-chrome.php +++ b/php-transformer/tests/contract/declaration-addressed-chrome.php @@ -92,7 +92,7 @@ $assert($normalize($states['index']['content']) === $normalize($states['about']['content']) && $states['index']['content'] !== $states['about']['content'], $case . ': editor-state CSS differs only in finalized declaration addresses.'); $assert($states['index']['path'] !== $states['about']['path'] && $states['index']['path'] === $states['copy']['path'], $case . ': different bytes diverge and identical bytes share a path.'); $stateAssets = array_values(array_filter($sitePlan['assets'], static fn (array $asset): bool => 'editor-static-state' === ($asset['source'] ?? ''))); - $assert(2 === count($stateAssets), $case . ': identical final editor-state assets coalesce across routes.'); + $assert(array() === $stateAssets, $case . ': editor-state sheets whose control selectors were removed with extracted chrome are not published as empty resources.'); } // Use the public site's materialized theme presets and CSS, not compiler state. diff --git a/php-transformer/tests/contract/native-view-site-stylesheets.php b/php-transformer/tests/contract/native-view-site-stylesheets.php index 7a3610f99..ab554cb5a 100644 --- a/php-transformer/tests/contract/native-view-site-stylesheets.php +++ b/php-transformer/tests/contract/native-view-site-stylesheets.php @@ -98,7 +98,7 @@ $nativeOrder = array_map(static fn (array $line): string => $line[2], $guarded); $frontCondition = (string) (array_values(array_filter(array_keys($routes), static fn (string $condition): bool => str_contains($condition, 'is_front_page()')))[0] ?? ''); -$aboutCondition = (string) (array_values(array_filter(array_keys($routes), static fn (string $condition): bool => str_contains($condition, "'about' === trim( get_page_uri")))[0] ?? ''); +$aboutCondition = (string) (array_values(array_filter(array_keys($routes), static fn (string $condition): bool => str_contains($condition, "'about' === \$blocks_engine_page_uri")))[0] ?? ''); $assert('' !== $frontCondition && '' !== $aboutCondition && 2 === count($routes), 'Each canonical document keeps its own route block.'); foreach (array($frontCondition, $aboutCondition) as $condition) { $files = $routes[$condition]; diff --git a/php-transformer/tests/contract/shared-chrome-stylesheet-scope.php b/php-transformer/tests/contract/shared-chrome-stylesheet-scope.php index d856e3560..ff73f8e17 100644 --- a/php-transformer/tests/contract/shared-chrome-stylesheet-scope.php +++ b/php-transformer/tests/contract/shared-chrome-stylesheet-scope.php @@ -135,7 +135,7 @@ $frontCondition = 'is_front_page()'; // Page routes match reconciliation identity first, with the canonical route // path as fallback; locate each block by that route comparison. -$routeCondition = static fn(string $route): string => (string) (array_values(array_filter(array_keys($published['routes']), static fn(string $condition): bool => str_contains($condition, "'" . $route . "' === trim( get_page_uri( \$blocks_engine_page_id ), '/' )")))[0] ?? $route); +$routeCondition = static fn(string $route): string => (string) (array_values(array_filter(array_keys($published['routes']), static fn(string $condition): bool => str_contains($condition, "'" . $route . "' === \$blocks_engine_page_uri")))[0] ?? $route); $aboutCondition = $routeCondition('guides/about'); $teamCondition = $routeCondition('guides/team'); foreach (array($frontCondition, $aboutCondition, $teamCondition) as $condition) { diff --git a/php-transformer/tests/contract/staged-artifact-compilation.php b/php-transformer/tests/contract/staged-artifact-compilation.php index 9ce2cf30e..4ed47dfe9 100644 --- a/php-transformer/tests/contract/staged-artifact-compilation.php +++ b/php-transformer/tests/contract/staged-artifact-compilation.php @@ -340,7 +340,7 @@ $bootstrap = (string) ($siteWrites['functions.php']['payload']['data'] ?? ''); $bootstrap = \Automattic\BlocksEngine\PhpTransformer\Tests\Support\BootstrapStyles::expand($bootstrap); $assert(array(array('kind' => 'global')) === ($siteAssets['assets/site.css']['scopes'] ?? null), 'Shared stylesheets retain an explicit global runtime scope.'); -$assert('about.html' === ($siteAssets['assets/about.css']['scopes'][0]['source_path'] ?? null) && 1 === preg_match("~'about' === trim\\( get_page_uri\\( \\\$blocks_engine_page_id \\), '/' \\) \\) \\) \\{\\n \\\$blocks_engine_route_styles = true;\\n( wp_enqueue_style\\([^\\n]+\\n)* wp_enqueue_style\\( '[^']+', get_theme_file_uri\\( 'assets/assets/about\\.css' \\)~", $bootstrap) && 1 === substr_count($bootstrap, "get_theme_file_uri( 'assets/assets/about.css' )"), 'Page-owned stylesheets enqueue only inside their canonical WordPress route block, with the route comparison as the fallback after reconciliation identity.'); +$assert('about.html' === ($siteAssets['assets/about.css']['scopes'][0]['source_path'] ?? null) && 1 === preg_match("~'about' === \\\$blocks_engine_page_uri \\) \\) \\{\\n \\\$blocks_engine_route_styles = true;\\n( wp_enqueue_style\\([^\\n]+\\n)* wp_enqueue_style\\( '[^']+', get_theme_file_uri\\( 'assets/assets/about\\.css' \\)~", $bootstrap) && 1 === substr_count($bootstrap, "get_theme_file_uri( 'assets/assets/about.css' )"), 'Page-owned stylesheets enqueue only inside their canonical WordPress route block, with the route comparison as the fallback after reconciliation identity.'); $assert('(min-width: 48rem)' === ($siteAssets['assets/about.css']['media'] ?? null) && str_contains($bootstrap, "array(), null, '(min-width: 48rem)'"), 'Stylesheet media conditions are retained as canonical frontend enqueue arguments.'); $assert(!str_starts_with((string) ($siteAssets['assets/about.css']['content'] ?? ''), '@media') && str_contains((string) ($siteAssets['assets/about.css']['content'] ?? ''), '.about-grid{display:grid') && '(min-width: 48rem)' === ($siteAssets['assets/about.css']['stylesheet_instances'][0]['media'] ?? null) && 'about.html' === ($siteAssets['assets/about.css']['stylesheet_instances'][0]['source_path'] ?? null), 'One stylesheet resource retains its original rule stream while its canonical document instance carries the enqueue condition.'); $assert(str_contains($bootstrap, "\$canvas = ! wp_should_load_block_editor_scripts_and_styles();") && str_contains($bootstrap, "( \$canvas || ( empty( \$style['author_css'] ) && empty( \$style['editor_only'] ) ) ) && \$blocks_engine_presentation_matches(") && ! str_contains($bootstrap, "add_editor_style(") && str_contains($bootstrap, "\$style['content_hash']") && str_contains($bootstrap, "\$style['media'] ?? 'all'") && str_contains($bootstrap, "get_theme_file_uri( \$style['target_path'] )"), 'Editor-only support styles and authored styles are enqueued by URL only on Core\'s editor-canvas iframe asset pass, route-scoped so an edited post loads only its own.'); diff --git a/php-transformer/tests/contract/staged-shared-inline-projection.php b/php-transformer/tests/contract/staged-shared-inline-projection.php index 987d8074e..28d4020cb 100644 --- a/php-transformer/tests/contract/staged-shared-inline-projection.php +++ b/php-transformer/tests/contract/staged-shared-inline-projection.php @@ -60,7 +60,7 @@ $enqueues = (new ReflectionMethod(WordPressSitePlan::class, 'stylesheetEnqueues'))->invoke(null, $plan['assets'], $plan['pages']); foreach ($enqueues as $enqueue) { $asset = $enqueue['asset']; - $expected[] = array($asset['target_path'], $enqueue['scope'], $enqueue['handle'], $asset['source_media'] ?? $enqueue['media'], !empty($enqueue['fallback']), array()); + foreach ($enqueue['scopes'] ?? array($enqueue['scope']) as $scope) $expected[] = array($asset['target_path'], $scope, $enqueue['handle'], $asset['source_media'] ?? $enqueue['media'], !empty($enqueue['fallback']), array()); } if ($expected !== $actual) throw new RuntimeException('Presentation replay changed per-route enqueue behavior.'); foreach ($deltas as $delta) { diff --git a/php-transformer/tests/contract/support-owner-set-allocation.php b/php-transformer/tests/contract/support-owner-set-allocation.php new file mode 100644 index 000000000..66bf8a0d5 --- /dev/null +++ b/php-transformer/tests/contract/support-owner-set-allocation.php @@ -0,0 +1,80 @@ + '.all-copy{width:601px;padding:13px}.subset-copy{width:602px;padding:14px}.unique-copy{width:603px;padding:15px}.other-subset{width:604px;padding:16px}'); +$owners = array(); +for ($i = 0; $i < 6; ++$i) { + $path = $i ? 'page-' . $i . '.html' : 'index.html'; + $owners[] = $path; + $files[$path] = '

All pages

' . ($i < 3 ? '

Subset

' : '') . ($i >= 2 && $i <= 4 ? '

Other subset

' : '') . (1 === $i ? '

Unique

' : '') . '
'; +} +$result = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => $files))->toArray(); +$plan = $result['source_reports']['wordpress_site_plan']; +WordPressSitePlan::assertValid($plan); +$support = array_values(array_filter($plan['assets'], static fn(array $asset): bool => 'engine-support' === ($asset['source'] ?? ''))); +$bootstrap = ''; +foreach ($plan['writes'] as $write) if ('functions.php' === $write['target_path']) $bootstrap = $write['payload']['data']; +echo 'Reduction: ' . count($support) . ' support files, ' . array_sum(array_map(static fn(array $asset): int => strlen($asset['content']), $support)) . ' support bytes, ' . strlen($bootstrap) . " functions.php bytes\n"; +foreach (array('all-copy' => $owners, 'subset-copy' => array_slice($owners, 0, 3), 'other-subset' => array_slice($owners, 2, 3), 'unique-copy' => array($owners[1])) as $class => $consumers) { + $rule = '.' . $class . '{box-sizing:content-box}'; + $sheets = array_values(array_filter($support, static fn(array $asset): bool => str_contains($asset['content'], $rule))); + $assert(1 === count($sheets), 'Each generated rule is written exactly once: ' . $class); + $actual = array_column($sheets[0]['scopes'], 'source_path'); + sort($actual); + sort($consumers); + $assert(count($consumers) === count($owners) ? array(array('kind' => 'global')) === $sheets[0]['scopes'] : $actual === $consumers, 'Resource scopes equal exact rule consumers: ' . $class); +} +$globalSheets = array_values(array_filter($support, static fn(array $asset): bool => array(array('kind' => 'global')) === $asset['scopes'])); +$assert(1 === count($globalSheets), 'All-pages rules share one global resource.'); +$assert(str_contains($bootstrap, "if ( true )"), 'Global support also loads on WordPress-native routes.'); +$assert(str_contains($bootstrap, "in_array( \$blocks_engine_page_uri, ['page-2','page-3','page-4'], true )"), 'A subset membership condition includes exactly its three owning routes.'); +$seen = array(); +foreach ($support as $asset) { + $assert('' !== trim($asset['content']), 'Support resources are never empty.'); + if (1 === count($asset['scopes']) && 'global' !== $asset['scopes'][0]['kind']) $assert($owners[1] === $asset['scopes'][0]['source_path'], 'Pages without unique rules have no unique resource.'); + $tree = new Automattic\BlocksEngine\PhpTransformer\Css\CssRuleTree($asset['content']); + foreach ($tree->rules() as $row) { + $rule = trim(substr($asset['content'], $row['start'], $row['end'] - $row['start'] + 1)); + $assert(!isset($seen[$rule]), 'No support rule is written twice.'); + $seen[$rule] = true; + } +} +foreach ($plan['assets'] as $asset) if (in_array($asset['source'] ?? '', array('engine-support', 'editor-static-state'), true)) { + $assert('' !== trim($asset['content']), 'Generated CSS resources are never empty.'); + $writes = array_column($plan['writes'], null, 'target_path'); + $assert('' !== trim($writes[$asset['target_path']]['payload']['data'] ?? ''), 'Generated CSS writes are never empty.'); +} +$byTarget = array_column($plan['assets'], null, 'target_path'); +foreach ($owners as $owner) { + $phase = 0; + foreach (WordPressSitePlan::documentStylesheets($plan, $owner) as $sheet) { + $asset = $byTarget[$sheet['target_path']]; + $next = 'engine-support' !== ($asset['source'] ?? '') ? 1 : ('before-author' === ($asset['stylesheet_placement'] ?? '') ? 0 : 2); + $assert($next >= $phase, 'Document cascade remains before support, author, after support.'); + $phase = $next; + if ('engine-support' === ($asset['source'] ?? '')) $assert(in_array('global', array_column($asset['scopes'], 'kind'), true) || in_array($owner, array_column($asset['scopes'], 'source_path'), true), 'No unrelated support loads.'); + } +} +preg_match("/add_action\( 'wp_enqueue_scripts', static function \(\): void \{(.*?)\n\}, 1 \);/s", $bootstrap, $callback); +$assert(1 === substr_count($callback[1] ?? '', WordPressSitePlan::DOCUMENT_ID_EXPRESSION), 'Styles resolve the current document exactly once.'); +$realisticFiles = require __DIR__ . '/support-owner-set-realistic-fixture.php'; +$realistic = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => $realisticFiles))->toArray()['source_reports']['wordpress_site_plan']; +WordPressSitePlan::assertValid($realistic); +$writes = array_column($realistic['writes'], null, 'target_path'); +$repairs = 0; +foreach ($realistic['assets'] as $asset) { + if (!in_array($asset['source'] ?? '', array('engine-support', 'editor-static-state'), true)) continue; + if ('editor-static-state' === $asset['source']) ++$repairs; + $assert('' !== trim($asset['content']), 'Realistic support and repair resources are never empty.'); + $assert('' !== trim($writes[$asset['target_path']]['payload']['data'] ?? ''), 'Realistic support and repair writes are never empty.'); +} +$assert($repairs > 0, 'The realistic fixture exercises editor repair sharing.'); +echo "Support owner-set allocation passed\n"; diff --git a/php-transformer/tests/contract/support-owner-set-realistic-fixture.php b/php-transformer/tests/contract/support-owner-set-realistic-fixture.php new file mode 100644 index 000000000..a338dc426 --- /dev/null +++ b/php-transformer/tests/contract/support-owner-set-realistic-fixture.php @@ -0,0 +1,13 @@ + 'body{margin:0;color:#243040;font-family:sans-serif}.site-nav{padding:24px;width:960px}.site-footer{padding:32px;width:960px}.hero{width:920px;padding:40px}.product-card{width:420px;padding:20px}.service-card{width:620px;padding:28px}#details{padding:13px}@media(max-width:700px){.hero{width:90%;padding:20px}}'); +foreach (array('index', 'products', 'pricing', 'services', 'team', 'contact') as $index => $slug) { + $unique = 'feature-' . $slug; + $files['site.css'] .= '.' . $unique . '{width:' . (700 + $index) . 'px;padding:' . (16 + $index) . 'px}'; + $family = $index < 3 ? 'product-card' : 'service-card'; + $files[$slug . '.html'] = '' . ucfirst($slug) . '

' . ucfirst($slug) . '

Practical products and services for growing teams.

Built for your business

Discover our approach and explore the options.

More information

Read details

Talk to our team about your next project.

'; +} +return $files; diff --git a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php index def8b1ce7..7fa405a6b 100644 --- a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php +++ b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php @@ -662,7 +662,7 @@ $assert(array('services.html', 'portfolio.html') === $pageSubsetScopes, 'a stylesheet linked by a subset of pages retains every owning page scope without becoming global'); $pageSubsetWrites = array_column($pageSubsetStylesheet['source_reports']['wordpress_site_plan']['writes'] ?? array(), null, 'target_path'); $pageSubsetBootstrap = (string) ($pageSubsetWrites['functions.php']['payload']['data'] ?? ''); -$assert(str_contains($pageSubsetBootstrap, "'services' === trim( get_page_uri( \$blocks_engine_page_id ), '/' )") && str_contains($pageSubsetBootstrap, "'portfolio' === trim( get_page_uri( \$blocks_engine_page_id ), '/' )"), 'a subset-scoped stylesheet bootstrap matches every owning WordPress route'); +$assert(str_contains($pageSubsetBootstrap, "'services' === \$blocks_engine_page_uri") && str_contains($pageSubsetBootstrap, "'portfolio' === \$blocks_engine_page_uri"), 'a subset-scoped stylesheet bootstrap matches every owning WordPress route'); $assert(array(array('kind' => 'global')) === ($pageSubsetAssets['explicit-shared.css']['scopes'] ?? null), 'explicitly shared linked stylesheets retain global scope'); $assert('asset' === ($pageSubsetAssets['unreferenced.css']['kind'] ?? null) && 'source-provenance' === ($pageSubsetAssets['unreferenced.css']['role'] ?? null), 'unreferenced author stylesheets are retained as provenance, not active CSS'); $pageSubsetCompiledAssets = array_column($pageSubsetStylesheet['source_reports']['compiled_site']['assets'] ?? array(), null, 'path'); diff --git a/php-transformer/tests/unit/unlinked-author-stylesheet-provenance.php b/php-transformer/tests/unit/unlinked-author-stylesheet-provenance.php index 98f46076e..bea0e85f7 100644 --- a/php-transformer/tests/unit/unlinked-author-stylesheet-provenance.php +++ b/php-transformer/tests/unit/unlinked-author-stylesheet-provenance.php @@ -27,7 +27,7 @@ foreach ($plan['writes'] as $write) { if ('functions.php' === ($write['target_path'] ?? null)) $bootstrap = BootstrapStyles::expand((string) $write['payload']['data']); } -foreach (array('is_front_page()', "'about' === trim( get_page_uri( \$blocks_engine_page_id ), '/'") as $route) { +foreach (array('is_front_page()', "'about' === \$blocks_engine_page_uri") as $route) { $inRoute = false; $linked = false; foreach (explode("\n", $bootstrap) as $line) { diff --git a/php-transformer/tests/unit/wordpress-site-plan-inline-stylesheet-order.php b/php-transformer/tests/unit/wordpress-site-plan-inline-stylesheet-order.php index 4a107f5a5..389a04fdd 100644 --- a/php-transformer/tests/unit/wordpress-site-plan-inline-stylesheet-order.php +++ b/php-transformer/tests/unit/wordpress-site-plan-inline-stylesheet-order.php @@ -67,7 +67,7 @@ $containing = static fn(string $needle): callable => static fn(string $target): bool => str_contains($contents[$target] ?? '', $needle); $named = static fn(string $name): callable => static fn(string $target): bool => str_ends_with($target, '/' . $name); -foreach (array('home' => 'if ( is_front_page() ) {', 'post' => "'post' === trim( get_page_uri( \$blocks_engine_page_id ), '/'") as $route => $needle) { +foreach (array('home' => 'if ( is_front_page() ) {', 'post' => "'post' === \$blocks_engine_page_uri") as $route => $needle) { $targets = $routeEnqueues($needle); $base = $position($targets, $named('base.css')); $inline = $position($targets, $containing('padding:56px')); @@ -76,7 +76,7 @@ $assert($base < $inline && $inline < $responsive, "{$route}: inline