diff --git a/php-transformer/CHANGELOG.md b/php-transformer/CHANGELOG.md index b37ce6c4..a8196fdc 100644 --- a/php-transformer/CHANGELOG.md +++ b/php-transformer/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project will be documented in this file. Entries are generated by homeboy from git commits. +## [0.4.17] - 2026-08-14 + +### Added +- Expose separate `source: engine-support` CSS assets with `stylesheet_placement` values `before-author` and `after-author`; mark `source: author-css` assets with `author` + +### Fixed +- Neutralize constrained-layout geometry on direct children of `blocks-engine-css-owned-layout` +- Make rich-text marker inline color and background resets self-sufficient + ## [0.4.16] - 2026-08-12 ### Added diff --git a/php-transformer/VERSION b/php-transformer/VERSION index 5745cc7b..7040b811 100644 --- a/php-transformer/VERSION +++ b/php-transformer/VERSION @@ -1 +1 @@ -0.4.16 +0.4.17 diff --git a/php-transformer/composer.json b/php-transformer/composer.json index c90b7c89..d1388197 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -77,6 +77,7 @@ "php tests/unit/css-stylesheet-transformer.php", "php tests/unit/css-selector-matcher.php", "php tests/unit/author-selector-semantics.php", + "php tests/unit/engine-support-css-asset.php", "php tests/unit/artifact-author-stylesheet-projection.php", "php tests/unit/fallback-finding-normalizer.php", "php tests/unit/navigation-underline-color-resolver.php", diff --git a/php-transformer/php-transformer.php b/php-transformer/php-transformer.php index 56b293c3..d8fce56f 100644 --- a/php-transformer/php-transformer.php +++ b/php-transformer/php-transformer.php @@ -3,7 +3,7 @@ * Plugin Name: Blocks Engine PHP Transformer * Plugin URI: https://github.com/Automattic/blocks-engine/tree/trunk/php-transformer * Description: Canonical PHP primitives for transforming HTML, Markdown, and website artifacts into WordPress block outputs. - * Version: 0.4.16 + * Version: 0.4.17 * Requires PHP: 8.1 * Author: Automattic * License: GPL-3.0-or-later @@ -15,7 +15,7 @@ declare(strict_types=1); if ( ! defined('BLOCKS_ENGINE_PHP_TRANSFORMER_VERSION') ) { - define('BLOCKS_ENGINE_PHP_TRANSFORMER_VERSION', '0.4.16'); + define('BLOCKS_ENGINE_PHP_TRANSFORMER_VERSION', '0.4.17'); } if ( ! defined('BLOCKS_ENGINE_PHP_TRANSFORMER_FILE') ) { diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 88f6c520..38c77dbd 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -219,18 +219,14 @@ public function compile(array $artifact): TransformerResult $wordpressCompatAsset = $this->wordpressCompatAsset($normalized['files']); $referenceReports = $this->referenceReports($normalized['files']); $manifestAssets = $this->assetManifest($normalized['files'], $entryPath, $referenceReports['asset_references'], $html); - $geometryAssets = array_values(array_filter($entryBlocks['assets'], static fn (array $asset): bool => 'css' === ($asset['kind'] ?? '') && str_contains((string) ($asset['content'] ?? ''), '.be-inline-geometry-'))); - $otherGeneratedAssets = array_values(array_filter($entryBlocks['assets'], static fn (array $asset): bool => ! in_array($asset, $geometryAssets, true))); - if ( is_array($entry) ) { - $entryOwnership = $this->fileOwnership($entry); - foreach ( $geometryAssets as &$generatedAsset ) $generatedAsset['compilation'] ??= $entryOwnership; - unset($generatedAsset); - foreach ( $otherGeneratedAssets as &$generatedAsset ) if ('css' === ($generatedAsset['kind'] ?? null)) $generatedAsset['compilation'] ??= $entryOwnership; - unset($generatedAsset); - } - // Runtime loads the manifest in array order. Put carrier CSS before - // authored assets so authored !important declarations preserve cascade. - $assets = array_merge($geometryAssets, $manifestAssets, $otherGeneratedAssets); + $entryOwnership = is_array($entry) ? $this->fileOwnership($entry) : array('scope' => 'page', 'id' => $entryPath); + $generatedAssets = $this->generatedAssetsForDocuments($entryBlocks['assets'], $entryOwnership, $compiledHtmlDocuments, $normalized['files']); + $beforeAuthorAssets = array_values(array_filter($generatedAssets, static fn (array $asset): bool => 'before-author' === ($asset['stylesheet_placement'] ?? ''))); + $afterAuthorAssets = array_values(array_filter($generatedAssets, static fn (array $asset): bool => 'after-author' === ($asset['stylesheet_placement'] ?? ''))); + $otherGeneratedAssets = array_values(array_filter($generatedAssets, static fn (array $asset): bool => ! in_array($asset, $beforeAuthorAssets, true) && ! in_array($asset, $afterAuthorAssets, true))); + // Runtime loads the manifest in array order. Placement metadata keeps + // engine support on its intended side of the authored stylesheets. + $assets = array_merge($beforeAuthorAssets, $manifestAssets, $otherGeneratedAssets, $afterAuthorAssets); if ( null !== $wordpressCompatAsset ) { $assets[] = $wordpressCompatAsset; } @@ -434,6 +430,60 @@ private function fileOwnership(array $file): array return array('scope' => 'page', 'id' => $ownership['id']); } + /** + * @param array> $entryAssets + * @param array{scope:string,id:string} $entryOwnership + * @param array> $compiledHtmlDocuments + * @param array> $files + * @return array> + */ + private function generatedAssetsForDocuments(array $entryAssets, array $entryOwnership, array $compiledHtmlDocuments, array $files): array + { + $assets = array(); + $assetIndexes = array(); + $append = static function (array $documentAssets, array $ownership) use (&$assets, &$assetIndexes): void { + foreach ( $documentAssets as $asset ) { + if ( ! is_array($asset) ) { + continue; + } + if ( 'css' === ($asset['kind'] ?? null) ) { + $asset['compilation'] ??= $ownership; + } + $payload = is_string($asset['content_base64'] ?? null) ? $asset['content_base64'] : (string) ($asset['content'] ?? ''); + $identity = hash('sha256', (string) ($asset['path'] ?? '') . "\0" . $payload); + if ( ! isset($assetIndexes[$identity]) ) { + $assetIndexes[$identity] = count($assets); + $assets[] = $asset; + continue; + } + $index = $assetIndexes[$identity]; + if ( 'css' !== ($asset['kind'] ?? null) ) { + continue; + } + $existingOwnership = $assets[$index]['compilation'] ?? null; + $assetOwnership = $asset['compilation'] ?? null; + if ( $existingOwnership !== $assetOwnership ) { + $assets[$index]['compilation'] = array('scope' => 'shared'); + } + } + }; + + $append($entryAssets, $entryOwnership); + $filesByPath = array_column($files, null, 'path'); + foreach ( $compiledHtmlDocuments as $sourcePath => $compiledHtmlDocument ) { + $file = $filesByPath[$sourcePath] ?? array('path' => $sourcePath, 'kind' => 'html'); + $append( + array_values(array_filter( + is_array($compiledHtmlDocument['assets'] ?? null) ? $compiledHtmlDocument['assets'] : array(), + static fn (array $asset): bool => 'css' === ($asset['kind'] ?? null) + )), + $this->fileOwnership($file) + ); + } + + return $assets; + } + /** * @param array{entrypoints:array,limits:array,runtime_declarations:array>,schema:string,input_keys:array} $partition * @param array> $files @@ -3404,6 +3454,7 @@ private function compiledSiteAssets(array $assets): array 'target_path' => $asset['target_path'] ?? $asset['path'] ?? '', 'kind' => $asset['kind'] ?? '', 'role' => $asset['role'] ?? '', + 'stylesheet_placement' => $asset['stylesheet_placement'] ?? '', 'intent' => $asset['intent'] ?? '', 'media_type' => $asset['media_type'] ?? $asset['mime_type'] ?? '', 'media' => $asset['media'] ?? '', diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index f28cc488..18c3a8c6 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -1033,13 +1033,15 @@ private function headMetadataReport(string $html): array private function materializeAuthorStylesheet(string $html, string $staticCss, bool $includeAuthorStyles = true, string $serializedBlocks = ''): void { - $cssParts = array(); + $beforeAuthorCssParts = array(); + $authorCssParts = array(); + $afterAuthorCssParts = array(); $authorCss = ''; if ( $includeAuthorStyles && '' !== $this->combinedAuthorCss ) { $authorCss = $this->rewriteAuthorStylesheet($this->combinedAuthorCss); $split = ( new CssStylesheetTransformer() )->splitLeadingAtRulePreamble($authorCss); if ( '' !== trim($split['preamble']) ) { - $cssParts[] = $split['preamble']; + $authorCssParts[] = $split['preamble']; } $authorCss = $split['stylesheet']; } @@ -1048,79 +1050,87 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo // Important carrier rules precede author CSS: they retain inline // precedence over normal selectors while authored !important rules // remain able to override them. - $cssParts[] = $geometryCss; + $beforeAuthorCssParts[] = $geometryCss; } $markerReset = $this->richTextMarkerResetCss(); if ( '' !== $markerReset ) { - $cssParts[] = $markerReset; + $beforeAuthorCssParts[] = $markerReset; } if ( str_contains($serializedBlocks, self::SYNTHETIC_PARAGRAPH_CLASS) ) { // A paragraph is required for valid block markup, but phrasing content // did not have paragraph margins in the source document. - $cssParts[] = ':where(.' . self::SYNTHETIC_PARAGRAPH_CLASS . '){margin-top:0;margin-bottom:0}' + $beforeAuthorCssParts[] = ':where(.' . self::SYNTHETIC_PARAGRAPH_CLASS . '){margin-top:0;margin-bottom:0}' . "\n" . ':where(p.' . self::SYNTHETIC_PARAGRAPH_CLASS . ')>a{text-decoration:underline}' . "\n" . ':where(p.' . self::SYNTHETIC_PARAGRAPH_CLASS . '.' . self::SYNTHETIC_ANCHOR_UNDECORATED_CLASS . ')>a{text-decoration:none}'; } if ( str_contains($serializedBlocks, self::INLINE_LAYOUT_CARRIER_CLASS) ) { - $cssParts[] = ':where(p.' . self::INLINE_LAYOUT_CARRIER_CLASS . '){display:contents;margin:0!important;padding:0!important;border:0!important}'; + $beforeAuthorCssParts[] = ':where(p.' . self::INLINE_LAYOUT_CARRIER_CLASS . '){display:contents;margin:0!important;padding:0!important;border:0!important}'; } if ( str_contains($serializedBlocks, self::CSS_OWNED_FLOW_CLASS) ) { - $cssParts[] = ':where(.' . self::CSS_OWNED_FLOW_CLASS . ')>p{margin-top:0;margin-bottom:0}'; + $beforeAuthorCssParts[] = ':where(.' . self::CSS_OWNED_FLOW_CLASS . ')>p{margin-top:0;margin-bottom:0}'; } if ( str_contains($serializedBlocks, self::POSITIONED_FRAGMENT_LINK_CARRIER_CLASS) ) { // Positioned fragment links retain their source anchor and selectors; // their valid paragraph host must not create a line box in document flow. - $cssParts[] = ':where(.' . self::POSITIONED_FRAGMENT_LINK_CARRIER_CLASS . '){display:contents!important}'; + $beforeAuthorCssParts[] = ':where(.' . self::POSITIONED_FRAGMENT_LINK_CARRIER_CLASS . '){display:contents!important}'; } if ( str_contains($serializedBlocks, self::EMPTY_FLEX_ITEM_CLASS) ) { - $cssParts[] = ':where(.' . self::EMPTY_FLEX_ITEM_CLASS . '){flex:0 0 0!important;width:0!important;min-width:0!important;margin-left:0!important;margin-right:0!important}'; + $beforeAuthorCssParts[] = ':where(.' . self::EMPTY_FLEX_ITEM_CLASS . '){flex:0 0 0!important;width:0!important;min-width:0!important;margin-left:0!important;margin-right:0!important}'; } if ( str_contains($serializedBlocks, self::CSS_OWNED_FLOW_CLASS) ) { // Core flow spacing is not part of a source grid or flex contract. // This precedes author CSS so source child margins remain authoritative. - $cssParts[] = ':where(.wp-block-group.' . self::CSS_OWNED_FLOW_CLASS . ')>*{margin-block-start:0;margin-block-end:0}'; + $beforeAuthorCssParts[] = ':where(.wp-block-group.' . self::CSS_OWNED_FLOW_CLASS . ')>*{margin-block-start:0;margin-block-end:0}'; } if ( str_contains($serializedBlocks, self::CSS_OWNED_GRID_CLASS) ) { // Core flow margins are not part of a source grid contract; the // carried grid geometry (gap) owns the spacing between items. The // carrier rides groups and lists, so the reset is class-scoped. - $cssParts[] = ':where(.' . self::CSS_OWNED_GRID_CLASS . ')>*{margin-block-start:0;margin-block-end:0}'; + $beforeAuthorCssParts[] = ':where(.' . self::CSS_OWNED_GRID_CLASS . ')>*{margin-block-start:0;margin-block-end:0}'; } if ( str_contains($serializedBlocks, self::CSS_OWNED_LAYOUT_ITEM_CLASS) ) { // A semantic Group used as a direct grid/flex item contains native // paragraph blocks. Neutralize only those generated inner defaults. - $cssParts[] = ':where(.wp-block-group.' . self::CSS_OWNED_LAYOUT_ITEM_CLASS . ')>*{margin-block-start:0;margin-block-end:0}'; + $beforeAuthorCssParts[] = ':where(.wp-block-group.' . self::CSS_OWNED_LAYOUT_ITEM_CLASS . ')>*{margin-block-start:0;margin-block-end:0}'; } if ( str_contains($serializedBlocks, 'blocks-engine-list-navigation') ) { - $cssParts[] = '.wp-block-navigation.blocks-engine-list-navigation .wp-block-navigation-item.wp-block-navigation-link{display:list-item;font:inherit}' + $beforeAuthorCssParts[] = '.wp-block-navigation.blocks-engine-list-navigation .wp-block-navigation-item.wp-block-navigation-link{display:list-item;font:inherit}' . "\n" . '.wp-block-navigation.blocks-engine-list-navigation .wp-block-navigation-item__content{display:inline}'; } if ( array() !== $this->nativeSearchTriggerCssRules ) { - $cssParts[] = implode("\n", $this->nativeSearchTriggerCssRules); + $beforeAuthorCssParts[] = implode("\n", $this->nativeSearchTriggerCssRules); } if ( '' !== trim($authorCss) ) { - $cssParts[] = $authorCss; + $authorCssParts[] = $authorCss; } if ( str_contains($serializedBlocks, 'blocks-engine-list-navigation') ) { // The source mobile menu hides its desktop list container. Core // navigation owns that responsive swap now, so keep the block host // visible and let core hide only its responsive inner container. - $cssParts[] = '.wp-block-navigation.blocks-engine-list-navigation{display:flex!important}'; + $afterAuthorCssParts[] = '.wp-block-navigation.blocks-engine-list-navigation{display:flex!important}'; $mobileOverlayBackground = $this->sourceMobileNavigationOverlayBackground(); if ( '' !== $mobileOverlayBackground ) { - $cssParts[] = '.wp-block-navigation.blocks-engine-list-navigation .wp-block-navigation__responsive-container.is-menu-open{background:' . $mobileOverlayBackground . '!important}'; + $afterAuthorCssParts[] = '.wp-block-navigation.blocks-engine-list-navigation .wp-block-navigation__responsive-container.is-menu-open{background:' . $mobileOverlayBackground . '!important}'; } } if ( array() !== $this->nativeButtonStyleRules ) { - $cssParts[] = implode("\n", $this->nativeButtonStyleRules); + $afterAuthorCssParts[] = implode("\n", $this->nativeButtonStyleRules); } if ( array() !== $this->directFlexButtonStyleRules ) { - $cssParts[] = implode("\n", $this->directFlexButtonStyleRules); + $afterAuthorCssParts[] = implode("\n", $this->directFlexButtonStyleRules); } if ( array() !== $this->fullWidthButtonStyleRules ) { - $cssParts[] = implode("\n", $this->fullWidthButtonStyleRules); + $afterAuthorCssParts[] = implode("\n", $this->fullWidthButtonStyleRules); } + $this->materializeStylesheetAsset($beforeAuthorCssParts, 'engine-support', 'before-author', 'engine-support-before-author'); + $this->materializeStylesheetAsset($authorCssParts, 'author-css', 'author', 'source-author'); + $this->materializeStylesheetAsset($afterAuthorCssParts, 'engine-support', 'after-author', 'engine-support-after-author'); + } + + /** @param array $cssParts */ + private function materializeStylesheetAsset(array $cssParts, string $source, string $placement, string $pathPrefix): void + { $css = trim(implode("\n\n", $cssParts)); if ( '' === $css ) { return; @@ -1128,15 +1138,16 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo $content = $css . "\n"; $hash = hash('sha256', $content); - $path = 'assets/css/source-author-' . substr($hash, 0, 16) . '.css'; + $path = 'assets/css/' . $pathPrefix . '-' . substr($hash, 0, 16) . '.css'; $this->generatedAssets[$path] = array( - 'source' => 'author-css', + 'source' => $source, 'source_path' => '', 'path' => $path, 'target_path' => $path, 'kind' => 'css', 'role' => 'stylesheet', + 'stylesheet_placement' => $placement, 'mime_type' => 'text/css', 'media_type' => 'text/css', 'content' => $content, @@ -1435,14 +1446,8 @@ private function authorStylesheetAssetsFromOptions(array $options): array private function authorStylesheetProjections(): array { $projections = array(); - $markerReset = $this->richTextMarkerResetCss(); foreach ( $this->authorStylesheetAssets as $asset ) { $content = $this->rewriteAuthorStylesheet($asset['content']); - if ( '' !== $markerReset ) { - $split = ( new CssStylesheetTransformer() )->splitLeadingAtRulePreamble($content); - $content = $split['preamble'] . $markerReset . "\n" . $split['stylesheet']; - $markerReset = ''; - } $hash = hash('sha256', $content); $projections[] = array( 'path' => $asset['path'], diff --git a/php-transformer/src/StaticSite/MaterializationPlanBuilder.php b/php-transformer/src/StaticSite/MaterializationPlanBuilder.php index e8bdc161..effd2e13 100644 --- a/php-transformer/src/StaticSite/MaterializationPlanBuilder.php +++ b/php-transformer/src/StaticSite/MaterializationPlanBuilder.php @@ -282,6 +282,7 @@ private function assets(array $assets): array 'target_path' => (string) ($asset['target_path'] ?? $asset['path'] ?? ''), 'kind' => (string) ($asset['kind'] ?? ''), 'role' => (string) ($asset['role'] ?? ''), + 'stylesheet_placement' => (string) ($asset['stylesheet_placement'] ?? ''), 'intent' => (string) ($asset['intent'] ?? ''), 'media_type' => (string) ($asset['media_type'] ?? $asset['mime_type'] ?? ''), 'media' => (string) ($asset['media'] ?? ''), diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php index 994ece20..a1da06a3 100644 --- a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php +++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php @@ -497,7 +497,7 @@ private function assets(mixed $assets): array $reference = self::payloadReference($asset['payload_reference'] ?? null); if (null !== $reference && !self::referenceBackedBinaryAsset($asset)) throw new InvalidArgumentException('WordPress site plan payload references are limited to non-SVG binary assets.'); $transportHash = is_string($asset['content_base64'] ?? null) ? self::contentHash($asset['content_base64']) : null; - $rows[] = array_filter(array('source_path' => $asset['path'], 'target_path' => $target, 'token' => 'asset-' . substr(hash('sha256', $target), 0, 16), 'source' => self::value($asset, 'source'), 'kind' => self::value($asset, 'kind'), 'role' => self::value($asset, 'role'), 'intent' => self::value($asset, 'intent'), 'mime_type' => self::value($asset, 'mime_type'), 'media' => self::value($asset, 'media'), 'bytes' => (int) ($asset['bytes'] ?? 0), 'hash' => self::value($asset, 'hash'), 'content' => $asset['content'] ?? null, 'content_base64' => $asset['content_base64'] ?? null, 'payload_reference' => $reference, 'raw_sha256' => $reference['sha256'] ?? ($asset['raw_sha256'] ?? null), 'transport_sha256' => $transportHash, 'binary' => ! empty($asset['binary']), 'compilation' => is_array($asset['compilation'] ?? null) ? $asset['compilation'] : null, 'reconciliation_identity' => self::identity('asset', $asset['path'], $target), 'content_hash' => $reference['sha256'] ?? self::contentHash($payload)), static fn(mixed $value): bool => null !== $value); + $rows[] = array_filter(array('source_path' => $asset['path'], 'target_path' => $target, 'token' => 'asset-' . substr(hash('sha256', $target), 0, 16), 'source' => self::value($asset, 'source'), 'kind' => self::value($asset, 'kind'), 'role' => self::value($asset, 'role'), 'stylesheet_placement' => self::value($asset, 'stylesheet_placement'), 'intent' => self::value($asset, 'intent'), 'mime_type' => self::value($asset, 'mime_type'), 'media' => self::value($asset, 'media'), 'bytes' => (int) ($asset['bytes'] ?? 0), 'hash' => self::value($asset, 'hash'), 'content' => $asset['content'] ?? null, 'content_base64' => $asset['content_base64'] ?? null, 'payload_reference' => $reference, 'raw_sha256' => $reference['sha256'] ?? ($asset['raw_sha256'] ?? null), 'transport_sha256' => $transportHash, 'binary' => ! empty($asset['binary']), 'compilation' => is_array($asset['compilation'] ?? null) ? $asset['compilation'] : null, 'reconciliation_identity' => self::identity('asset', $asset['path'], $target), 'content_hash' => $reference['sha256'] ?? self::contentHash($payload)), static fn(mixed $value): bool => null !== $value); } return $rows; } diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index b76e6aed..6f6258ef 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -2743,8 +2743,9 @@ public function match(DOMElement $element, PatternContext $context): ?array )->toArray(); $artifactNonEntryInlineSvgPage = $artifactNonEntryInlineSvg['source_reports']['materialization_plan']['pages'][1] ?? array(); $artifactNonEntryInlineSvgAssets = $artifactNonEntryInlineSvg['source_reports']['materialization_plan']['assets'] ?? array(); +$artifactNonEntryInlineSvgImageAssets = array_values(array_filter($artifactNonEntryInlineSvgAssets, static fn (array $asset): bool => 'svg' === ($asset['kind'] ?? ''))); $assert(str_contains((string) ($artifactNonEntryInlineSvgPage['block_markup'] ?? ''), '