Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions php-transformer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion php-transformer/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.4.16
0.4.17
1 change: 1 addition & 0 deletions php-transformer/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions php-transformer/php-transformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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') ) {
Expand Down
75 changes: 63 additions & 12 deletions php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -434,6 +430,60 @@ private function fileOwnership(array $file): array
return array('scope' => 'page', 'id' => $ownership['id']);
}

/**
* @param array<int,array<string,mixed>> $entryAssets
* @param array{scope:string,id:string} $entryOwnership
* @param array<string,array<string,mixed>> $compiledHtmlDocuments
* @param array<int,array<string,mixed>> $files
* @return array<int,array<string,mixed>>
*/
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<int,string>,limits:array<string,int>,runtime_declarations:array<int,array<string,mixed>>,schema:string,input_keys:array<int,string>} $partition
* @param array<int,array<string,mixed>> $files
Expand Down Expand Up @@ -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'] ?? '',
Expand Down
61 changes: 33 additions & 28 deletions php-transformer/src/HtmlToBlocks/HtmlTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
}
Expand All @@ -1048,95 +1050,104 @@ 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<int, string> $cssParts */
private function materializeStylesheetAsset(array $cssParts, string $source, string $placement, string $pathPrefix): void
{
$css = trim(implode("\n\n", $cssParts));
if ( '' === $css ) {
return;
}

$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,
Expand Down Expand Up @@ -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'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? ''),
Expand Down
Loading
Loading