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) . '>' . $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('//', '\\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('Hero')->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": "