diff --git a/.github/workflows/php-transformer.yml b/.github/workflows/php-transformer.yml
index 66068c30..a652b5b7 100644
--- a/.github/workflows/php-transformer.yml
+++ b/.github/workflows/php-transformer.yml
@@ -106,9 +106,8 @@ jobs:
WORDPRESS_DEVELOP_DIR: ${{ runner.temp }}/wordpress-develop
WORDPRESS_TEST_DB: wordpress_site_plan_test
run: |
- git clone --depth=1 --branch 6.7.4 https://github.com/WordPress/wordpress-develop.git "$WORDPRESS_DEVELOP_DIR"
- composer config --working-dir="$WORDPRESS_DEVELOP_DIR" --json policy.advisories.ignore-id '{"PKSA-mh9b-91zm-m1gy":{"on-audit":false,"reason":"Pinned WordPress 6.7.4 test runtime; WPCS is not loaded by the integration test."},"PKSA-kh6k-gs3g-dgr6":{"on-audit":false,"reason":"Pinned WordPress 6.7.4 test runtime; PHPCSUtils is not loaded by the integration test."},"PKSA-rdkp-vv9z-mjkg":{"on-audit":false,"reason":"Pinned WordPress 6.7.4 test runtime; affected build tooling is not loaded by the integration test."},"PKSA-6vdd-n4sx-knhy":{"on-audit":false,"reason":"Pinned WordPress 6.7.4 test runtime; affected build tooling is not loaded by the integration test."}}'
- composer require --working-dir="$WORDPRESS_DEVELOP_DIR" --dev phpcsstandards/phpcsutils:1.0.12 --no-update
+ git clone --depth=1 --branch 7.0.4 https://github.com/WordPress/wordpress-develop.git "$WORDPRESS_DEVELOP_DIR"
+ composer config --working-dir="$WORDPRESS_DEVELOP_DIR" --json policy.advisories.ignore-id '{"PKSA-mh9b-91zm-m1gy":{"on-audit":false,"reason":"Pinned WordPress 7.0.4 test runtime; WPCS is not loaded by the integration test."},"PKSA-kh6k-gs3g-dgr6":{"on-audit":false,"reason":"Pinned WordPress 7.0.4 test runtime; PHPCSUtils is not loaded by the integration test."},"PKSA-rdkp-vv9z-mjkg":{"on-audit":false,"reason":"Pinned WordPress 7.0.4 test runtime; affected build tooling is not loaded by the integration test."},"PKSA-6vdd-n4sx-knhy":{"on-audit":false,"reason":"Pinned WordPress 7.0.4 test runtime; affected build tooling is not loaded by the integration test."}}'
composer install --working-dir="$WORDPRESS_DEVELOP_DIR" --no-interaction --prefer-dist --no-progress
for attempt in $(seq 1 30); do
if mysql --host=127.0.0.1 --port=3306 --user=root --execute='SELECT 1'; then break; fi
diff --git a/php-transformer/README.md b/php-transformer/README.md
index e159bd5c..598a3fca 100644
--- a/php-transformer/README.md
+++ b/php-transformer/README.md
@@ -161,7 +161,7 @@ Unsupported or unsafe artifact inputs are reported through diagnostics instead o
Run the package contract, parity fixtures, and clean package-install proof with `composer test`. The checked-in fixtures assert current transformer behavior, and the install proof verifies that Composer can install `automattic/blocks-engine-php-transformer` from the `php-transformer/` package root without symlinking back to the working tree.
-Run the real WordPress materialization integration against a standard WordPress test-suite runtime with `REQUIRE_WP_TESTS=1 WP_TESTS_DIR=/path/to/wordpress-develop/tests/phpunit composer test:wordpress-integration`. Without `REQUIRE_WP_TESTS=1`, the command reports an explicit local skip when that runtime is unavailable. CI provisions WordPress `6.7.4`, MySQL `8.0.36`, and runs the required form of this command in `.github/workflows/php-transformer.yml`.
+Run the real WordPress materialization integration against a standard WordPress test-suite runtime with `REQUIRE_WP_TESTS=1 WP_TESTS_DIR=/path/to/wordpress-develop/tests/phpunit composer test:wordpress-integration`. Without `REQUIRE_WP_TESTS=1`, the command reports an explicit local skip when that runtime is unavailable. CI provisions WordPress `7.0.4`, MySQL `8.0.36`, and runs the required form of this command in `.github/workflows/php-transformer.yml`.
## Release Consumption
diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php
index d2aaa6ba..d310be21 100644
--- a/php-transformer/src/HtmlToBlocks/BlockFactory.php
+++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php
@@ -735,13 +735,11 @@ private function imageHtml(array $attrs): string
}
$imageAttrs = array(
- 'src' => $attrs['url'] ?? '',
- 'alt' => $attrs['alt'] ?? '',
- 'title' => $attrs['title'] ?? '',
- 'srcset' => $attrs['srcset'] ?? '',
- 'sizes' => $attrs['sizes'] ?? '',
- 'class' => $this->mergeClassNames(! empty($attrs['id']) ? 'wp-image-' . (string) $attrs['id'] : '', $borderSupport['classes']),
- 'style' => trim($this->imageDimensionStyle($attrs) . ';' . $borderSupport['style'], ';'),
+ 'src' => $attrs['url'] ?? '',
+ 'alt' => $attrs['alt'] ?? '',
+ 'title' => $attrs['title'] ?? '',
+ 'class' => $this->mergeClassNames(! empty($attrs['id']) ? 'wp-image-' . (string) $attrs['id'] : '', $borderSupport['classes']),
+ 'style' => trim($this->imageDimensionStyle($attrs) . ';' . $borderSupport['style'], ';'),
);
$img = '
htmlAttrs($imageAttrs, array( 'alt' )) . '/>';
@@ -879,6 +877,12 @@ private function mediaHtml(string $tagName, array $attrs): string
'height' => (string) ($attrs['height'] ?? ''),
'controls' => ! empty($attrs['controls']) ? 'controls' : '',
);
+ if ( 'video' === $tagName ) {
+ $mediaAttrs['autoplay'] = ! empty($attrs['autoplay']) ? 'autoplay' : '';
+ $mediaAttrs['loop'] = ! empty($attrs['loop']) ? 'loop' : '';
+ $mediaAttrs['muted'] = ! empty($attrs['muted']) ? 'muted' : '';
+ $mediaAttrs['playsinline'] = ! empty($attrs['playsInline']) ? 'playsinline' : '';
+ }
$caption = ! empty($attrs['caption']) ? '' . $this->preserveRichTextPunctuation((string) $attrs['caption']) . '' : '';
return 'blockSupportAttrs($attrs, 'wp-block-' . $tagName) . '><' . $tagName . $this->htmlAttrs($mediaAttrs) . '>' . $tagName . '>' . $caption . '';
diff --git a/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php b/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php
index 8813811b..8453176f 100644
--- a/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php
+++ b/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php
@@ -127,6 +127,19 @@ private static function defaults(array $fields): array
'suggested_primitive' => 'image_asset',
'materialization_hint' => 'sanitize_svg_before_materializing_asset',
),
+ 'html_responsive_image_fallback' => array(
+ 'severity' => 'warning',
+ 'conversion_classification' => 'editable_approximation',
+ 'loss_class' => 'native_block_gap_preserved',
+ 'diagnostic_class' => 'responsive_image_preserved',
+ 'preservation_strategy' => 'sanitized_core_html',
+ 'runtime_requirement' => 'none',
+ 'recoverability' => 'recoverable_with_native_responsive_image_block_support',
+ 'actionability' => 'retain_core_html_or_materialize_responsive_sources_as_media_attachments',
+ 'suggested_repair_class' => 'preserve_responsive_image_markup',
+ 'suggested_primitive' => 'core/html',
+ 'materialization_hint' => 'preserve_picture_and_srcset_markup_until_core_image_can_serialize_the_source_selection',
+ ),
'html_iframe_embed_fallback' => array(
'severity' => 'warning',
'conversion_classification' => 'runtime_island_preserved',
diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
index 5fbb9cec..3c5c7822 100644
--- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
+++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
@@ -224,6 +224,18 @@ final class HtmlTransformer
private readonly FallbackEmitter $fallbackEmitter;
+ /**
+ * Responsive image markup core/image cannot represent without invalidating
+ * its native save shape. Collected separately because image conversion is
+ * also used by pattern callbacks that do not receive the fallback accumulator.
+ *
+ * @var array>
+ */
+ private array $responsiveImageFallbacks = array();
+
+ /** @var array */
+ private array $responsiveImageFallbackSelectors = array();
+
/**
* @var array
*/
@@ -591,6 +603,8 @@ public function transform(string $html, array $options = array()): TransformerRe
$this->formSelectBlockGenerated = false;
$this->formInputBlockGenerated = false;
$this->formControlEchoTexts = array();
+ $this->responsiveImageFallbacks = array();
+ $this->responsiveImageFallbackSelectors = array();
$this->generatedBlockNamespace = $this->generatedBlockNamespaceFromOptions($options);
$this->preserveShellLandmarks = !empty($options['extract_global_shell']);
$this->fallbackEmitter->resetGeneratedBlocks();
@@ -722,6 +736,7 @@ public function transform(string $html, array $options = array()): TransformerRe
$this->collectSupersededNavToggleSelectors($body);
$shellArtifacts = !array_key_exists('extract_global_shell', $options) || !empty($options['extract_global_shell']) ? $this->globalShellArtifacts($body, (string) ($options['source'] ?? 'html')) : array();
$blocks = $this->deduplicateNavigationBlocks($this->convertChildren($body, $fallbacks, true));
+ $fallbacks = array_merge($fallbacks, $this->responsiveImageFallbacks);
$this->recordRuntimeIslandsForPreservedHtmlBlocks($blocks);
$this->appendInteractiveControlBehaviorLossFallbacks($body, $fallbacks);
$this->appendProductGridFallbacks($body, $fallbacks, $blocks);
@@ -3038,11 +3053,18 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
$this->captureDivBasedPseudoFormFallback($element, $fallbacks);
+ // A gallery can only contain native image blocks. Preserve the
+ // complete media collection before author-layout recognition can
+ // create a core/gallery with a responsive core/html child.
+ if ( $this->hasResponsiveImageSources($element) && $this->hasGalleryMediaItems($element) ) {
+ return $this->responsiveImageFallbackBlock($element);
+ }
+
if ( $this->isDirectChildOfAuthorOwnedLayout($element) && '' !== $this->attr($element, 'role') ) {
return $this->authorLayoutBlockFromElement($element, $fallbacks);
}
- if ( in_array($tagName, array( 'div', 'section', 'article' ), true) ) {
+ if ( in_array($tagName, array( 'div', 'section', 'article' ), true) && ! $this->hasResponsiveImageSources($element) ) {
// A strict two-pane media/text candidate is a more specific
// recognition than generic author-owned layout preservation:
// media-text candidates are by definition authored flex/grid
@@ -3362,6 +3384,12 @@ private function mediaGalleryBlockFromElement(DOMElement $element): ?array
return null;
}
+ if ( $this->hasResponsiveImageSources($element) ) {
+ // GalleryPattern probes child conversions before it knows whether it
+ // has enough images. Avoid emitting speculative child fallbacks.
+ return $this->hasGalleryMediaItems($element) ? $this->responsiveImageFallbackBlock($element) : null;
+ }
+
return $this->galleryPattern->match(
$element,
fn (DOMElement $image, ?DOMElement $figure = null, ?DOMElement $picture = null, ?DOMElement $link = null): ?array => $this->convertImageElement($image, $figure, $picture, $link),
@@ -3404,6 +3432,28 @@ private function isGalleryCompatibleMediaLayout(DOMElement $element): bool
return true;
}
+ private function hasGalleryMediaItems(DOMElement $element): bool
+ {
+ $items = 0;
+ foreach ( $element->childNodes as $child ) {
+ if ( XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent ?? '') ) {
+ continue;
+ }
+ if ( ! $child instanceof DOMElement || 'figcaption' === strtolower($child->tagName) ) {
+ if ( ! $child instanceof DOMElement ) {
+ return false;
+ }
+ continue;
+ }
+ if ( ! in_array(strtolower($child->tagName), array( 'figure', 'img', 'picture' ), true) ) {
+ return false;
+ }
+ ++$items;
+ }
+
+ return $items >= 2;
+ }
+
/**
* @return array>
*/
@@ -11013,6 +11063,17 @@ private function convertMediaElement(DOMElement $element): ?array
'controls' => $element->hasAttribute('controls'),
)), static fn (mixed $value): bool => is_bool($value) ? $value : '' !== $value);
+ if ( 'video' === $tagName ) {
+ foreach ( array( 'autoplay', 'loop', 'muted' ) as $attribute ) {
+ if ( $element->hasAttribute($attribute) ) {
+ $attrs[$attribute] = true;
+ }
+ }
+ if ( $element->hasAttribute('playsinline') ) {
+ $attrs['playsInline'] = true;
+ }
+ }
+
return $this->createBlock('core/' . $tagName, $attrs, array(), $element);
}
@@ -11062,6 +11123,10 @@ private function convertPictureElement(DOMElement $picture, ?DOMElement $figure
return null;
}
+ if ( $this->hasResponsiveImageSources($picture) ) {
+ return $this->responsiveImageFallbackBlock($figure ?? $picture);
+ }
+
return $this->convertImageElement($image, $figure ?? $picture, $picture, $link);
}
@@ -11105,6 +11170,10 @@ private function isImageOnlyAnchor(DOMElement $anchor): bool
private function convertImageElement(DOMElement $image, ?DOMElement $figure = null, ?DOMElement $picture = null, ?DOMElement $link = null): ?array
{
+ if ( $this->hasResponsiveImageSources($picture ?? $image) ) {
+ return $this->responsiveImageFallbackBlock($figure ?? $picture ?? $image);
+ }
+
$originalUrl = $this->safeImageUrl($this->attr($image, 'src'));
$url = $this->resolvedAssetImageUrl($originalUrl);
if ( '' === $url ) {
@@ -11117,7 +11186,6 @@ private function convertImageElement(DOMElement $image, ?DOMElement $figure = nu
}
$width = $this->attr($image, 'width');
$height = $this->attr($image, 'height');
- $sourceAttrs = $picture instanceof DOMElement ? $this->pictureSourceAttributes($picture) : array();
if ( '' !== $width || '' !== $height ) {
$attrs['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), 'is-resized');
}
@@ -11126,8 +11194,6 @@ private function convertImageElement(DOMElement $image, ?DOMElement $figure = nu
'url' => $url,
'alt' => $this->attr($image, 'alt'),
'title' => $this->attr($image, 'title'),
- 'srcset' => $this->resolvedAssetImageSrcset('' !== $this->attr($image, 'srcset') ? $this->attr($image, 'srcset') : (string) ($sourceAttrs['srcset'] ?? '')),
- 'sizes' => '' !== $this->attr($image, 'sizes') ? $this->attr($image, 'sizes') : (string) ($sourceAttrs['sizes'] ?? ''),
'width' => $width,
'height' => $height,
)), static fn ($value): bool => '' !== $value);
@@ -11156,6 +11222,59 @@ private function convertImageElement(DOMElement $image, ?DOMElement $figure = nu
return $this->createBlock('core/image', $attrs, array(), $figure ?? $image);
}
+ private function hasResponsiveImageSources(DOMElement $element): bool
+ {
+ if ( 'img' === strtolower($element->tagName) ) {
+ return '' !== $this->attr($element, 'srcset') || '' !== $this->attr($element, 'sizes');
+ }
+
+ foreach ( $element->getElementsByTagName('source') as $source ) {
+ if ( $source instanceof DOMElement && '' !== $this->attr($source, 'srcset') ) {
+ return true;
+ }
+ }
+
+ foreach ( $element->getElementsByTagName('img') as $image ) {
+ if ( $image instanceof DOMElement && ( '' !== $this->attr($image, 'srcset') || '' !== $this->attr($image, 'sizes') ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Preserve responsive sources as valid raw HTML rather than placing
+ * unsupported attributes in a core/image save shape.
+ *
+ * @return array
+ */
+ private function responsiveImageFallbackBlock(DOMElement $element): array
+ {
+ $boundedHtml = $this->boundedFallbackHtml($this->safeFallbackHtml($element));
+ $selector = $this->elementSelector($element);
+ if ( ! isset($this->responsiveImageFallbackSelectors[$selector]) ) {
+ $this->responsiveImageFallbackSelectors[$selector] = true;
+ $this->responsiveImageFallbacks[] = FallbackDiagnostic::build(array(
+ 'type' => 'html',
+ 'reason' => 'responsive_image_fallback',
+ 'diagnostic_code' => 'html_responsive_image_fallback',
+ 'message' => 'Responsive image sources were preserved as sanitized core/html because core/image cannot serialize srcset, sizes, or picture source selection.',
+ 'source_format' => 'html',
+ 'tag' => strtolower($element->tagName),
+ 'selector' => $selector,
+ 'attributes' => $this->htmlAttributes($element),
+ 'context' => $this->sourceContext($element),
+ 'classification' => $this->fallbackEmitter->classifyFallbackSubtree($element),
+ 'html' => $boundedHtml['html'],
+ 'html_bytes' => $boundedHtml['bytes'],
+ 'html_truncated' => $boundedHtml['truncated'],
+ ), $this->fallbackProvenance);
+ }
+
+ return $this->createBlock('core/html', array( 'content' => $this->safeFallbackHtml($element) ), array(), $element);
+ }
+
/**
* @return array
*/
diff --git a/php-transformer/src/HtmlToBlocks/Support/DomHelpersTrait.php b/php-transformer/src/HtmlToBlocks/Support/DomHelpersTrait.php
index ff391778..45607e2a 100644
--- a/php-transformer/src/HtmlToBlocks/Support/DomHelpersTrait.php
+++ b/php-transformer/src/HtmlToBlocks/Support/DomHelpersTrait.php
@@ -216,6 +216,12 @@ private function safeFallbackHtml(DOMElement $element): string
function (array $matches): string {
$attribute = strtolower($matches[1]);
$value = $matches[3] ?? $matches[4] ?? $matches[5] ?? '';
+ if ( 'srcset' === $attribute ) {
+ $srcset = $this->safeFallbackSrcset($value);
+ return '' === $srcset
+ ? ''
+ : ' ' . $matches[1] . '="' . htmlspecialchars($srcset, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"';
+ }
return $this->isFallbackUrlAttribute($attribute) && ! $this->safeFallbackUrl($value, $attribute)
? ''
: $matches[0];
@@ -244,6 +250,48 @@ private function isFallbackUrlAttribute(string $attribute): bool
), true);
}
+ /**
+ * Keep only safe srcset candidates while retaining their source-selection
+ * descriptors. The URL policy deliberately matches fallback image `src`.
+ */
+ private function safeFallbackSrcset(string $srcset): string
+ {
+ $candidates = array();
+ $length = strlen($srcset);
+ $offset = 0;
+
+ while ( $offset < $length ) {
+ while ( $offset < $length && ( ctype_space($srcset[$offset]) || ',' === $srcset[$offset] ) ) {
+ ++$offset;
+ }
+ if ( $offset >= $length ) {
+ break;
+ }
+
+ $start = $offset;
+ $isDataUrl = str_starts_with(strtolower(substr($srcset, $offset)), 'data:');
+ while ( $offset < $length && ! ctype_space($srcset[$offset]) && ( $isDataUrl || ',' !== $srcset[$offset] ) ) {
+ ++$offset;
+ }
+ $url = substr($srcset, $start, $offset - $start);
+
+ while ( $offset < $length && ctype_space($srcset[$offset]) ) {
+ ++$offset;
+ }
+ $descriptorStart = $offset;
+ while ( $offset < $length && ',' !== $srcset[$offset] ) {
+ ++$offset;
+ }
+ $descriptor = trim(substr($srcset, $descriptorStart, $offset - $descriptorStart));
+
+ if ( $this->safeFallbackUrl($url, 'src') ) {
+ $candidates[] = $url . ( '' !== $descriptor ? ' ' . $descriptor : '' );
+ }
+ }
+
+ return implode(', ', $candidates);
+ }
+
private function safeFallbackUrl(string $url, string $attribute): bool
{
$normalized = strtolower(preg_replace('/[\x00-\x20\x7f]+/', '', html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8')) ?? '');
diff --git a/php-transformer/src/WordPress/Runtime.php b/php-transformer/src/WordPress/Runtime.php
index 9cc985a1..36cdd20f 100644
--- a/php-transformer/src/WordPress/Runtime.php
+++ b/php-transformer/src/WordPress/Runtime.php
@@ -475,7 +475,7 @@ private function serializeBlock(array $block): string
private function serializeBlockAttributes(array $attrs): string
{
$encoded = $this->encodeJson($attrs);
- $encoded = preg_replace('/--/', '\\u002d\\u002d', $encoded) ?? $encoded;
+ $encoded = str_replace('--', '\\u002d\\u002d', $encoded);
$encoded = preg_replace('/', '\\u003c', $encoded) ?? $encoded;
$encoded = preg_replace('/>/', '\\u003e', $encoded) ?? $encoded;
$encoded = preg_replace('/&/', '\\u0026', $encoded) ?? $encoded;
diff --git a/php-transformer/src/WordPressSitePlan/AssetReferenceCanonicalizer.php b/php-transformer/src/WordPressSitePlan/AssetReferenceCanonicalizer.php
index 1356b77c..eef39efd 100644
--- a/php-transformer/src/WordPressSitePlan/AssetReferenceCanonicalizer.php
+++ b/php-transformer/src/WordPressSitePlan/AssetReferenceCanonicalizer.php
@@ -139,7 +139,12 @@ private static function css(string $css, callable $replace): string
private static function json(string $comment, callable $replace): string
{
return preg_replace_callback('~((?:"|\\\\u0022)(url|src|href|poster|action|srcset)(?:"|\\\\u0022)\s*:\s*(?:"|\\\\u0022))(.*?)(?:"|\\\\u0022)~is', static function (array $match) use ($replace): string {
- $value = 'srcset' === strtolower($match[2]) ? self::srcset($match[3], $replace) : $replace($match[3]);
+ $jsonReplace = static function (string $reference) use ($replace): string {
+ $normalized = str_replace('\\/', '/', $reference);
+ $value = $replace($normalized);
+ return $normalized === $value ? $reference : $value;
+ };
+ $value = 'srcset' === strtolower($match[2]) ? self::srcset($match[3], $jsonReplace) : $jsonReplace($match[3]);
return $match[1] . $value . (str_contains($match[0], '\\u0022') ? '\\u0022' : '"');
}, $comment) ?? $comment;
}
diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php
index 2e948622..6806e122 100644
--- a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php
+++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php
@@ -856,6 +856,24 @@ private static function bootstrap(array $assets, array $scripts = array()): stri
$attributes[$handle] = array_filter(array('type' => $script['type'], 'nomodule' => $script['nomodule'], 'integrity' => $script['integrity'], 'crossorigin' => $script['crossorigin'], 'referrerpolicy' => $script['referrerpolicy'], 'fetchpriority' => $script['fetchpriority'], 'async' => $script['async'] && $script['module'], 'defer' => $script['defer'] && ($script['async'] || $script['module'])), static fn(mixed $value): bool => false !== $value && null !== $value);
}
$lines[] = "}, 1 );";
+ $editorStyles = array();
+ foreach ($assets as $asset) if ('css' === $asset['kind']) $editorStyles[] = array('target_path' => $asset['target_path'], 'content_hash' => $asset['content_hash'], 'scopes' => $asset['scopes']);
+ if (array() !== $editorStyles) {
+ $lines[] = "add_filter( 'block_editor_settings_all', static function ( array \$settings, \$context ): array {";
+ $lines[] = " \$post = \$context->post ?? null; if ( ! \$post instanceof WP_Post ) return \$settings;";
+ $lines[] = ' $styles = ' . var_export($editorStyles, true) . ';';
+ $lines[] = " foreach ( \$styles as \$style ) {";
+ $lines[] = " \$matches = false; foreach ( \$style['scopes'] as \$scope ) {";
+ $lines[] = " if ( 'global' === \$scope['kind'] ) { \$matches = true; break; }";
+ $lines[] = " if ( 'post' === \$scope['kind'] && 'post' === \$post->post_type && \$scope['reconciliation_identity'] === get_post_meta( \$post->ID, '_blocks_engine_reconciliation_identity', true ) ) { \$matches = true; break; }";
+ $lines[] = " if ( 'page' === \$scope['kind'] && 'page' === \$post->post_type && ( ( \$scope['front_page'] && (int) get_option( 'page_on_front' ) === (int) \$post->ID ) || \$scope['route_path'] === trim( get_page_uri( \$post ), '/' ) ) ) { \$matches = true; break; }";
+ $lines[] = " }";
+ $lines[] = " if ( ! \$matches ) continue; \$css = file_get_contents( get_theme_file_path( \$style['target_path'] ) );";
+ $lines[] = " if ( false !== \$css ) \$settings['styles'][] = array( 'css' => ':root{--blocks-engine-presentation:' . \$style['content_hash'] . ';}' . \"\\n\" . \$css, '__unstableType' => 'theme' );";
+ $lines[] = " }";
+ $lines[] = " return \$settings;";
+ $lines[] = "}, 10, 2 );";
+ }
foreach ($scripts as $script) {
$handle = 'blocks-engine-script-' . substr(hash('sha256', $script['identity']), 0, 12);
foreach ($script['scopes'] as $scope) {
diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php
index 7f782d44..8fe6f66c 100644
--- a/php-transformer/tests/contract/run.php
+++ b/php-transformer/tests/contract/run.php
@@ -63,6 +63,48 @@ function serialize_blocks(array $blocks): string
exit(1);
};
+$videoResult = ( new HtmlTransformer() )->transform('')->toArray();
+$assert(
+ array(
+ 'autoplay' => true,
+ 'loop' => true,
+ 'muted' => true,
+ 'playsInline' => true,
+ ) === array_intersect_key($videoResult['blocks'][0]['attrs'] ?? array(), array_flip(array( 'autoplay', 'loop', 'muted', 'playsInline' ))),
+ 'video playback attributes should map to canonical core/video attributes'
+);
+$assert(
+ str_contains($videoResult['blocks'][0]['innerHTML'] ?? '', ''),
+ 'video playback attributes should be preserved in native save markup'
+);
+
+$responsiveImageResult = ( new HtmlTransformer() )->transform('
')->toArray();
+$assert(
+ 'core/html' === ($responsiveImageResult['blocks'][0]['blockName'] ?? null)
+ && str_contains($responsiveImageResult['blocks'][0]['innerHTML'] ?? '', 'srcset="hero.jpg 1x, hero-2x.jpg 2x"')
+ && str_contains($responsiveImageResult['blocks'][0]['innerHTML'] ?? '', 'sizes="100vw"')
+ && 'html_responsive_image_fallback' === ($responsiveImageResult['fallbacks'][0]['diagnostic_code'] ?? null),
+ 'responsive image sources should use the valid core/html fallback instead of lossy core/image markup'
+);
+$responsiveSrcsetSanitization = ( new HtmlTransformer() )->transform('
')->toArray();
+$responsiveSrcsetMarkup = (string) ($responsiveSrcsetSanitization['serialized_blocks'] ?? '');
+$assert(
+ str_contains($responsiveSrcsetMarkup, 'safe.webp 2x')
+ && str_contains($responsiveSrcsetMarkup, 'data:image/png;base64,aGVsbG8= 1x')
+ && str_contains($responsiveSrcsetMarkup, 'hero-2x.jpg 3x')
+ && ! str_contains($responsiveSrcsetMarkup, 'javascript:')
+ && ! str_contains($responsiveSrcsetMarkup, 'blob:'),
+ 'responsive core/html fallback strips unsafe srcset candidates while retaining safe URLs and descriptors'
+);
+$responsiveGallery = ( new HtmlTransformer() )->transform('')->toArray();
+$assert(
+ 'core/html' === ($responsiveGallery['blocks'][0]['blockName'] ?? null)
+ && 1 === count($responsiveGallery['fallbacks'] ?? array())
+ && 'div' === ($responsiveGallery['fallbacks'][0]['tag'] ?? null)
+ && ! str_contains((string) ($responsiveGallery['serialized_blocks'] ?? ''), 'Hello
', $serialized, 'Fallback serializer should preserve block comments and inner HTML.');
assertSame('wordpress_serialize_blocks_unavailable', $runtime->diagnostics()[0]['code'] ?? null, 'Fallback serializer should expose a diagnostic.');
+$customPropertyMarkup = $runtime->serializeBlocks(array(array(
+ 'blockName' => 'core/paragraph',
+ 'attrs' => array('style' => array('typography' => array('fontSize' => 'var(--responsive-font-size,20px)'))),
+ 'innerBlocks' => array(),
+ 'innerHTML' => 'Text
',
+ 'innerContent' => array('Text
'),
+)));
+$escapedHyphen = chr(92) . 'u002d';
+assertSame(true, str_contains($customPropertyMarkup, 'var(' . $escapedHyphen . $escapedHyphen . 'responsive-font-size,20px)'), 'Fallback serializer should retain escaped CSS custom-property hyphens.');
+assertSame(false, str_contains($customPropertyMarkup, 'var(u002du002dresponsive-font-size,20px)'), 'Fallback serializer should not drop custom-property escape backslashes.');
+
// Dynamic/nested blocks (core/navigation et al.) save() to null inner HTML, so
// the WordPress-free fallback serializer must emit canonical comment-delimited
// markup recursively rather than rendering static HTML. A standalone navigation
diff --git a/php-transformer/tests/contract/staged-artifact-compilation.php b/php-transformer/tests/contract/staged-artifact-compilation.php
index eec6c07c..88ab548c 100644
--- a/php-transformer/tests/contract/staged-artifact-compilation.php
+++ b/php-transformer/tests/contract/staged-artifact-compilation.php
@@ -54,6 +54,7 @@
$bootstrap = (string) ($siteWrites['functions.php']['payload']['data'] ?? '');
$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) && str_contains($bootstrap, "if ( is_page() && 'about' === trim( get_page_uri( get_queried_object_id() ), '/' ) ) wp_enqueue_style"), 'Page-owned stylesheets enqueue only on their canonical WordPress route.');
+$assert(str_contains($bootstrap, "add_filter( 'block_editor_settings_all'") && str_contains($bootstrap, "blocks-engine-presentation:") && str_contains($bootstrap, "get_theme_file_path( \$style['target_path'] )") && str_contains($bootstrap, "\$context->post") && str_contains($bootstrap, "get_page_uri( \$post )"), 'Canonical bootstrap loads content-addressed route styles into the edited post iframe.');
$inlineEntryArtifact = $inlineArtifact;
$inlineEntryArtifact['entrypoints'] = array('about.html');
$inlineSitePlan = $compiler->compile($inlineEntryArtifact)->toArray()['source_reports']['wordpress_site_plan'] ?? array();
diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php
index e701cba6..d5c92b23 100644
--- a/php-transformer/tests/contract/wordpress-site-plan.php
+++ b/php-transformer/tests/contract/wordpress-site-plan.php
@@ -355,7 +355,7 @@
$assert(true === ($scripts[0]['async'] ?? null) && false === ($scripts[0]['defer'] ?? null) && 'async' === ($scripts[0]['effective_loading'] ?? null) && 'anonymous' === ($scripts[0]['crossorigin'] ?? null) && '' === ($scripts[0]['referrerpolicy'] ?? null) && '' === ($scripts[0]['fetchpriority'] ?? null) && false === ($scripts[1]['async'] ?? null) && true === ($scripts[1]['defer'] ?? null) && 'defer' === ($scripts[1]['effective_loading'] ?? null) && true === ($scripts[2]['async'] ?? null) && true === ($scripts[2]['defer'] ?? null) && 'async' === ($scripts[2]['effective_loading'] ?? null) && true === ($scripts[3]['module'] ?? null) && 'defer' === ($scripts[3]['effective_loading'] ?? null) && true === ($scripts[4]['nomodule'] ?? null) && str_starts_with((string) ($scripts[5]['asset_reference'] ?? ''), WordPressSitePlan::TOKEN_PREFIX), 'Plan preserves loading attributes and projects generated inline scripts as declared local assets.');
$assert(count($plan['pages']) === ($plan['reporting']['metrics']['source_document_count'] ?? null) && count($plan['pages']) === ($plan['reporting']['metrics']['block_document_count'] ?? null) && is_array($plan['reporting']['diagnostic_codes'] ?? null), 'Plan carries route-complete reporting summaries and diagnostic linkage.');
$bootstrap = (string) $writes['functions.php']['payload']['data'];
-$assert(str_contains($bootstrap, "wp_register_script") && str_contains($bootstrap, "get_theme_file_uri(") && str_contains($bootstrap, "https://cdn.example.test/external.js") && str_contains($bootstrap, "'strategy' => 'async'") && str_contains($bootstrap, "script_loader_tag") && str_contains($bootstrap, "'nomodule' => true") && str_contains($bootstrap, "'type' => 'module'") && str_contains($bootstrap, 'is_front_page()') && str_contains($bootstrap, "'nested/about' === trim( get_page_uri( get_queried_object_id() ), '/' )") && !str_contains($bootstrap, "array (\n 0 => 'blocks-engine-script-"), 'Canonical functions.php registers dependency-free local and external scripts with exact attributes and canonical front-page/page URI scope conditions.');
+$assert(str_contains($bootstrap, "wp_register_script") && str_contains($bootstrap, "get_theme_file_uri(") && str_contains($bootstrap, "https://cdn.example.test/external.js") && str_contains($bootstrap, "'strategy' => 'async'") && str_contains($bootstrap, "script_loader_tag") && str_contains($bootstrap, "'nomodule' => true") && str_contains($bootstrap, "'type' => 'module'") && str_contains($bootstrap, 'is_front_page()') && str_contains($bootstrap, "'nested/about' === trim( get_page_uri( get_queried_object_id() ), '/' )") && str_contains($bootstrap, "add_filter( 'block_editor_settings_all'") && str_contains($bootstrap, "get_option( 'page_on_front' )") && str_contains($bootstrap, "'_blocks_engine_reconciliation_identity'") && !str_contains($bootstrap, "array (\n 0 => 'blocks-engine-script-"), 'Canonical functions.php registers frontend scripts and route-scoped content-addressed editor styles using canonical page and post identities.');
$unsupportedScripts = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => 'Unsupported scripts', 'assets/unused.js' => 'window.unused=true;')))->toArray();
$unsupportedPlan = $unsupportedScripts['source_reports']['wordpress_site_plan'] ?? array();
@@ -548,6 +548,10 @@
$assert(is_string($rootLogo) && str_ends_with($rootLogo, '?width=40#hero') && str_contains($markupReferences, WordPressSitePlan::TOKEN_PREFIX) && str_contains($markupReferences, '?two=2#two') && str_contains($markupReferences, '/application-route#anchor') && null !== $canonicalizer->reference('../assets/logo.svg', 'nested/index.html') && null === $canonicalizer->reference('/application-route#anchor', 'nested/index.html') && null === $canonicalizer->reference('#local', 'nested/index.html') && null === $canonicalizer->reference('https://example.test/external', 'nested/index.html') && null === $canonicalizer->reference('//cdn.example.test/library.js', 'nested/index.html') && null === $canonicalizer->reference('data:image/svg+xml,svg', 'nested/index.html') && null === $canonicalizer->reference('blob:https://example.test/blob', 'nested/index.html') && null === $canonicalizer->reference('mailto:test@example.test', 'nested/index.html') && null === $canonicalizer->reference('tel:+15551212', 'nested/index.html') && null === $canonicalizer->reference('/assets%2flogo.svg', 'nested/index.html') && null === $canonicalizer->reference('/../assets/logo.svg', 'nested/index.html'), 'Canonical matching resolves root-relative and nested markup asset identities only, preserving browser routes, anchors, external schemes, encoded separators, and traversal references.');
$siteRootCanonicalizer = new AssetReferenceCanonicalizer(array(array('source_path' => 'website/js/main.js', 'token' => 'asset-0123456789abcdef'), array('source_path' => 'secret.svg', 'token' => 'asset-fedcba9876543210')), 'website');
$assert(WordPressSitePlan::TOKEN_PREFIX . 'asset-0123456789abcdef}}' === $siteRootCanonicalizer->reference('/nested/../js/main.js', 'website/index.html') && null === $siteRootCanonicalizer->reference('/../secret.svg', 'website/index.html'), 'Site-root normalization retains in-root references while rejecting traversal to artifact siblings.');
+$videoCanonicalizer = new AssetReferenceCanonicalizer(array(array('source_path' => 'website/_videos/v1/hero.mp4', 'token' => 'asset-0123456789abcdef')), 'website');
+$escapedVideoBlock = '';
+$canonicalVideoBlock = $videoCanonicalizer->content($escapedVideoBlock, 'website/index.html');
+$assert(2 === substr_count($canonicalVideoBlock, WordPressSitePlan::TOKEN_PREFIX . 'asset-0123456789abcdef}}') && ! str_contains($canonicalVideoBlock, '\\/_videos\\/v1\\/hero.mp4'), 'Escaped root-relative URLs in native block JSON and save markup resolve through the same site-root asset token.');
$unicodeCanonicalizer = new AssetReferenceCanonicalizer(array(array('source_path' => 'assets/JOHN-OATES-‘ARKANSAS.jpg', 'token' => 'asset-0123456789abcdef')));
$assert(WordPressSitePlan::TOKEN_PREFIX . 'asset-0123456789abcdef}}' === $unicodeCanonicalizer->reference('../assets/JOHN-OATES-%E2%80%98ARKANSAS.jpg', 'pages/home.html'), 'Canonical matching resolves percent-encoded Unicode references to declared artifact paths.');
$authorLayoutReference = array('source_path' => 'assets/materialized-svg/logo.svg', 'target_path' => 'assets/materialized-svg/logo.svg', 'token' => 'materialized-svg');
diff --git a/php-transformer/tests/fixtures/parity/artifact-local-link-asset-reports.json b/php-transformer/tests/fixtures/parity/artifact-local-link-asset-reports.json
index f47c002a..bc3afb53 100644
--- a/php-transformer/tests/fixtures/parity/artifact-local-link-asset-reports.json
+++ b/php-transformer/tests/fixtures/parity/artifact-local-link-asset-reports.json
@@ -101,6 +101,11 @@
"diagnostic_code": "html_unsupported_element",
"tag": "link",
"reason": "unsupported_element"
+ },
+ {
+ "diagnostic_code": "html_responsive_image_fallback",
+ "tag": "img",
+ "reason": "responsive_image_fallback"
}
]
}
diff --git a/php-transformer/tests/fixtures/parity/artifact-responsive-image-assets.json b/php-transformer/tests/fixtures/parity/artifact-responsive-image-assets.json
index fd2ad198..832c20c9 100644
--- a/php-transformer/tests/fixtures/parity/artifact-responsive-image-assets.json
+++ b/php-transformer/tests/fixtures/parity/artifact-responsive-image-assets.json
@@ -1,11 +1,11 @@
{
"schema": "blocks-engine/php-transformer/parity-fixture/v1",
"name": "artifact-responsive-image-assets",
- "description": "Compiles nested artifact HTML images with source-relative local src/srcset references into materializable asset paths without hiding missing candidates.",
+ "description": "Preserves nested artifact responsive image sources through a valid core/html fallback when core/image cannot serialize them.",
"source_reference": {
"repo": "php-transformer",
"path": "tests/fixtures/parity/artifact-responsive-image-assets.json",
- "notes": "Covers generic nested relative image path resolution, srcset descriptor preservation, sizes preservation, and missing-candidate visibility."
+ "notes": "Covers generic nested relative image path resolution, source srcset analysis, and valid fallback preservation of responsive image selection."
},
"legacy_comparison": {
"skip": true,
@@ -36,16 +36,13 @@
}
},
"expected_blocks": [
- { "path": "blocks.0", "name": "core/image", "attrs": { "url": "assets/images/hero.png", "alt": "Hero", "srcset": "assets/images/hero-small.png 480w, ../../missing/ghost.png 960w, https://cdn.example.test/hero.png 1200w", "sizes": "(max-width: 600px) 100vw, 600px", "width": "600", "height": "400", "caption": "Hero image", "className": "product-card is-resized" } }
+ { "path": "blocks.0", "name": "core/html" }
+ ],
+ "expected_fallbacks": [
+ { "diagnostic_code": "html_responsive_image_fallback", "tag": "figure", "reason": "responsive_image_fallback" }
],
- "expected_fallbacks": [],
"expect": [
{ "path": "status", "assert": "equals", "value": "failed" },
- { "path": "diagnostics", "assert": "count", "count": 1 },
- { "path": "diagnostics.0.code", "assert": "equals", "value": "wordpress_site_plan_invalid_declaration" },
- { "path": "diagnostics.0.severity", "assert": "equals", "value": "error" },
- { "path": "diagnostics.0.source", "assert": "equals", "value": "Automattic\\BlocksEngine\\PhpTransformer\\ArtifactCompiler\\ArtifactCompiler" },
- { "path": "source_reports.wordpress_site_plan_diagnostics", "assert": "equals", "value": [{ "code": "wordpress_site_plan_invalid_declaration", "message": "WordPress site plan contains unresolved local browser reference ../../missing/ghost.png.", "source_path": "pages/products/detail.html", "document_kind": "page", "declaration_kind": "browser_reference", "declaration_index": 0, "reason": "unresolved_local_browser_reference", "fields": { "attribute": "json:srcset", "context": "page", "value": "../../missing/ghost.png" }, "severity": "error", "source": "Automattic\\BlocksEngine\\PhpTransformer\\ArtifactCompiler\\ArtifactCompiler" }] },
{ "path": "source_reports.artifact.asset_references", "assert": "count", "count": 2 },
{ "path": "source_reports.artifact.image_references", "assert": "count", "count": 2 },
{ "path": "source_reports.artifact.image_references.0.resolved_path", "assert": "equals", "value": "assets/images/hero.png" },
@@ -54,9 +51,9 @@
{ "path": "source_reports.materialization_plan.assets", "assert": "count", "count": 2 },
{ "path": "source_reports.materialization_plan.assets.0.path", "assert": "equals", "value": "assets/images/hero.png" },
{ "path": "source_reports.materialization_plan.assets.1.path", "assert": "equals", "value": "assets/images/hero-small.png" },
- { "path": "serialized_blocks", "assert": "contains", "value": "src=\"assets/images/hero.png\"" },
- { "path": "serialized_blocks", "assert": "contains", "value": "srcset=\"assets/images/hero-small.png 480w, ../../missing/ghost.png 960w, https://cdn.example.test/hero.png 1200w\"" },
- { "path": "serialized_blocks", "assert": "contains", "value": "sizes=\"(max-width: 600px) 100vw, 600px\"" },
+ { "path": "serialized_blocks", "assert": "contains", "value": "src=\"../../assets/images/hero.png\"" },
+ { "path": "serialized_blocks", "assert": "contains", "value": "srcset=" },
+ { "path": "serialized_blocks", "assert": "contains", "value": "sizes=" },
{ "path": "serialized_blocks", "assert": "contains", "value": "../../missing/ghost.png" },
{ "path": "metrics.block_count", "assert": "equals", "value": 1 }
]
diff --git a/php-transformer/tests/fixtures/parity/html-linked-media-background-runtime-targets.json b/php-transformer/tests/fixtures/parity/html-linked-media-background-runtime-targets.json
index 9a4243d5..71260fbf 100644
--- a/php-transformer/tests/fixtures/parity/html-linked-media-background-runtime-targets.json
+++ b/php-transformer/tests/fixtures/parity/html-linked-media-background-runtime-targets.json
@@ -1,7 +1,7 @@
{
"schema": "blocks-engine/php-transformer/parity-fixture/v1",
"name": "html-linked-media-background-runtime-targets",
- "description": "Converts linked figure/gallery media and a gated CSS background hero while preserving empty runtime icon targets without losing generic DOM targets.",
+ "description": "Converts native linked media and a gated CSS background hero while preserving responsive gallery source selection through a valid HTML fallback.",
"source_reference": {
"repo": "php-transformer",
"path": "tests/fixtures/parity/html-linked-media-background-runtime-targets.json",
@@ -23,21 +23,21 @@
{ "path": "blocks.0", "name": "core/cover", "attrs": { "className": "hero-media" } },
{ "path": "blocks.0.innerBlocks.0", "name": "core/heading", "attrs": { "content": "Retreats", "level": 1 } },
{ "path": "blocks.1", "name": "core/image", "attrs": { "url": "/assets/retreat.jpg", "alt": "Retreat house", "caption": "Explore the retreat", "href": "/retreats", "linkDestination": "custom", "linkClass": "media-link", "className": "featured-card card-image" } },
- { "path": "blocks.2", "name": "core/gallery", "attrs": { "anchor": "pattern-gallery", "className": "gallery-cards" } },
- { "path": "blocks.2.innerBlocks.0", "name": "core/image", "attrs": { "url": "/assets/one.jpg", "alt": "One", "caption": "One caption", "href": "/one", "linkDestination": "custom", "srcset": "/assets/one-large.jpg 900w" } },
- { "path": "blocks.2.innerBlocks.1", "name": "core/image", "attrs": { "url": "/assets/two.jpg", "alt": "Two", "caption": "Two caption", "href": "/two", "linkDestination": "custom" } },
+ { "path": "blocks.2", "name": "core/html" },
{ "path": "blocks.3", "name": "core/html", "attrs": { "content": "" } }
],
- "expected_fallbacks": [],
+ "expected_fallbacks": [
+ { "diagnostic_code": "html_responsive_image_fallback", "tag": "div", "reason": "responsive_image_fallback" }
+ ],
"expect": [
{ "path": "status", "assert": "equals", "value": "success" },
- { "path": "fallbacks", "assert": "count", "count": 0 },
+ { "path": "fallbacks", "assert": "count", "count": 1 },
{ "path": "serialized_blocks", "assert": "contains", "value": "