From 61ab2336392f897283b91bb3c5e555ce89c555ef Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 13 Aug 2026 16:55:45 -0400 Subject: [PATCH 1/8] fix: preserve native media editor validity --- .../src/HtmlToBlocks/BlockFactory.php | 18 +++++++++------ .../src/HtmlToBlocks/HtmlTransformer.php | 14 ++++++++--- php-transformer/src/WordPress/Runtime.php | 2 +- .../AssetReferenceCanonicalizer.php | 7 +++++- php-transformer/tests/contract/run.php | 23 +++++++++++++++++++ .../tests/contract/runtime-no-wordpress.php | 11 +++++++++ .../tests/contract/wordpress-site-plan.php | 4 ++++ .../artifact-responsive-image-assets.json | 19 ++++++--------- ...nked-media-background-runtime-targets.json | 2 +- .../parity/html-picture-gallery-media.json | 4 ++-- .../site-artifact-report-consistency.json | 3 +-- 11 files changed, 78 insertions(+), 29 deletions(-) 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/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 5fbb9cec..4f34ca2b 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -11013,6 +11013,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); } @@ -11117,7 +11128,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 +11136,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); 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/tests/contract/run.php b/php-transformer/tests/contract/run.php index 7f782d44..98ea6c75 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -63,6 +63,29 @@ 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( + ! isset($responsiveImageResult['blocks'][0]['attrs']['srcset'], $responsiveImageResult['blocks'][0]['attrs']['sizes']) + && ! str_contains($responsiveImageResult['blocks'][0]['innerHTML'] ?? '', 'srcset=') + && ! str_contains($responsiveImageResult['blocks'][0]['innerHTML'] ?? '', 'sizes='), + 'native image save markup should omit responsive attributes that core/image cannot serialize' +); + $referenceAnalyzer = new ReferenceAnalyzer(); $htmlCandidates = $referenceAnalyzer->htmlReferenceCandidates('AboutLogo', 'index.html'); $assert('href' === ($htmlCandidates[0]['attribute'] ?? ''), 'reference analyzer extracts HTML href references'); diff --git a/php-transformer/tests/contract/runtime-no-wordpress.php b/php-transformer/tests/contract/runtime-no-wordpress.php index dbb5b534..d1870c62 100644 --- a/php-transformer/tests/contract/runtime-no-wordpress.php +++ b/php-transformer/tests/contract/runtime-no-wordpress.php @@ -21,6 +21,17 @@ assertSame('

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/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php index e701cba6..5a4621b6 100644 --- a/php-transformer/tests/contract/wordpress-site-plan.php +++ b/php-transformer/tests/contract/wordpress-site-plan.php @@ -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-responsive-image-assets.json b/php-transformer/tests/fixtures/parity/artifact-responsive-image-assets.json index fd2ad198..d7618951 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": "Compiles nested artifact HTML images into editor-valid core/image markup while retaining responsive candidates in source asset analysis.", "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 and source srcset asset analysis without projecting unsupported responsive attributes into core/image save markup." }, "legacy_comparison": { "skip": true, @@ -36,16 +36,12 @@ } }, "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/image", "attrs": { "url": "assets/images/hero.png", "alt": "Hero", "width": "600", "height": "400", "caption": "Hero image", "className": "product-card is-resized" } } ], "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": "status", "assert": "equals", "value": "success" }, + { "path": "diagnostics", "assert": "count", "count": 0 }, { "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" }, @@ -55,9 +51,8 @@ { "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": "../../missing/ghost.png" }, + { "path": "serialized_blocks", "assert": "not_contains", "value": "srcset=" }, + { "path": "serialized_blocks", "assert": "not_contains", "value": "sizes=" }, { "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..5015887c 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 @@ -24,7 +24,7 @@ { "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.0", "name": "core/image", "attrs": { "url": "/assets/one.jpg", "alt": "One", "caption": "One caption", "href": "/one", "linkDestination": "custom" } }, { "path": "blocks.2.innerBlocks.1", "name": "core/image", "attrs": { "url": "/assets/two.jpg", "alt": "Two", "caption": "Two caption", "href": "/two", "linkDestination": "custom" } }, { "path": "blocks.3", "name": "core/html", "attrs": { "content": "" } } ], diff --git a/php-transformer/tests/fixtures/parity/html-picture-gallery-media.json b/php-transformer/tests/fixtures/parity/html-picture-gallery-media.json index 77f4777b..6af19c2d 100644 --- a/php-transformer/tests/fixtures/parity/html-picture-gallery-media.json +++ b/php-transformer/tests/fixtures/parity/html-picture-gallery-media.json @@ -16,9 +16,9 @@ "content": "\"Hero\"
\"One\"
One caption
\"Two\"
Two caption
Gallery caption
" }, "expected_blocks": [ - { "path": "blocks.0", "name": "core/image", "attrs": { "className": "hero-picture", "url": "https://example.com/hero.jpg", "alt": "Hero", "srcset": "https://example.com/hero-large.jpg 1200w", "sizes": "100vw" } }, + { "path": "blocks.0", "name": "core/image", "attrs": { "className": "hero-picture", "url": "https://example.com/hero.jpg", "alt": "Hero" } }, { "path": "blocks.1", "name": "core/gallery", "attrs": { "className": "gallery-grid", "caption": "Gallery caption" } }, - { "path": "blocks.1.innerBlocks.0", "name": "core/image", "attrs": { "url": "https://example.com/one.jpg", "alt": "One", "srcset": "https://example.com/one-large.jpg 900w", "caption": "One caption" } }, + { "path": "blocks.1.innerBlocks.0", "name": "core/image", "attrs": { "url": "https://example.com/one.jpg", "alt": "One", "caption": "One caption" } }, { "path": "blocks.1.innerBlocks.1", "name": "core/image", "attrs": { "className": "tile is-resized", "url": "https://example.com/two.jpg", "alt": "Two", "width": "400", "height": "300", "caption": "Two caption" } } ], "expected_fallbacks": [], diff --git a/php-transformer/tests/fixtures/parity/site-artifact-report-consistency.json b/php-transformer/tests/fixtures/parity/site-artifact-report-consistency.json index aeefc0b7..cd6f10cb 100644 --- a/php-transformer/tests/fixtures/parity/site-artifact-report-consistency.json +++ b/php-transformer/tests/fixtures/parity/site-artifact-report-consistency.json @@ -110,9 +110,8 @@ { "path": "source_reports.materialization_plan.menus.0.items", "assert": "equals", "value": 3 }, { "path": "source_reports.materialization_plan.template_part_writes.0.type", "assert": "equals", "value": "wp_template_part" }, { "path": "source_reports.materialization_plan.assets", "assert": "count", "count": 6 }, - { "path": "source_reports.materialization_plan.asset_rewrite_candidates", "assert": "count", "count": 2 }, + { "path": "source_reports.materialization_plan.asset_rewrite_candidates", "assert": "count", "count": 1 }, { "path": "source_reports.materialization_plan.asset_rewrite_candidates.0.asset_path", "assert": "equals", "value": "public/assets/hero.png" }, - { "path": "source_reports.materialization_plan.asset_rewrite_candidates.1.asset_path", "assert": "equals", "value": "public/assets/hero@2x.png" }, { "path": "source_reports.materialization_plan.theme.stylesheets.0", "assert": "equals", "value": "public/assets/app.css" }, { "path": "source_reports.materialization_plan.theme.images", "assert": "count", "count": 3 }, { "path": "source_reports.materialization_plan.visual_repair_css", "assert": "contains", "value": "min-height:100vh" }, From 44f45e69e4e4dced5e86d49220d1c7d4ba2a0b8f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 13 Aug 2026 18:38:15 -0400 Subject: [PATCH 2/8] fix: load presentation styles in block editor --- .../WordPressSitePlan/WordPressSitePlan.php | 18 ++++++++++++++++++ .../contract/staged-artifact-compilation.php | 1 + .../tests/contract/wordpress-site-plan.php | 2 +- .../tests/integration/wordpress-site-plan.php | 11 +++++++++-- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php index 2e948622..db8a6ad1 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' => '/* 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/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 5a4621b6..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(); diff --git a/php-transformer/tests/integration/wordpress-site-plan.php b/php-transformer/tests/integration/wordpress-site-plan.php index d6c56d62..0e9160a7 100644 --- a/php-transformer/tests/integration/wordpress-site-plan.php +++ b/php-transformer/tests/integration/wordpress-site-plan.php @@ -31,15 +31,16 @@ try { if (!is_dir($themeDir) && !mkdir($themeDir, 0777, true) && !is_dir($themeDir)) throw new RuntimeException('Could not create integration theme directory.'); $result = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array( - 'index.html' => '

Integration Header

Home

Integration Footer

', + 'index.html' => '

Integration Header

Home

Integration Footer

', 'assets/logo.svg' => '', + 'assets/global.css' => '.global-presentation{display:block}', 'assets/head.js' => 'window.headAsset=true;', 'assets/defer.js' => 'window.deferAsset=true;', 'assets/async.js' => 'window.asyncAsset=true;', 'assets/module.js' => 'window.moduleAsset=true;', 'assets/legacy.js' => 'window.legacyAsset=true;', 'about.html' => '

Integration Header

Root About

Integration Footer

', - 'nested/about.html' => '

Integration Header

About

Integration Footer

', + 'nested/about.html' => '

Integration Header

About

Integration Footer

', 'nested/deep/about.html' => '

Integration Header

Deep About

Integration Footer

', array('path' => 'notes/essay.html', 'content' => '
Essay
'), 'assets/about-head.js' => 'window.aboutHeadAsset=true;', @@ -81,6 +82,12 @@ $essay = get_post($pagesBySource['notes/essay.html'] ?? 0); $essayPlan = $pageDeclarations['notes/essay.html'] ?? array(); $assert($essay && 'post' === $essay->post_type && 0 === (int) $essay->post_parent && ($essayPlan['reconciliation_identity'] ?? null) === get_post_meta($essay->ID, '_blocks_engine_reconciliation_identity', true), 'Reference materialization honors operation post_type, keeps posts parentless, and persists the runtime reconciliation identity.'); $frontPage = get_post((int) get_option('page_on_front')); if (!$frontPage) throw new RuntimeException('Could not load front page.'); +$editorCss = static function (WP_Post $post): string { $settings = apply_filters('block_editor_settings_all', array('styles' => array()), new WP_Block_Editor_Context(array('name' => 'core/edit-post', 'post' => $post))); return implode("\n", array_map(static fn(array $style): string => (string) ($style['css'] ?? ''), $settings['styles'] ?? array())); }; +$frontEditorCss = $editorCss($frontPage); +$nestedAbout = get_post($pagesBySource['nested/about.html']); if (!$nestedAbout) throw new RuntimeException('Could not load nested about page.'); +$aboutEditorCss = $editorCss($nestedAbout); +$assert(str_contains($frontEditorCss, '.global-presentation{display:block}') && str_contains($frontEditorCss, '.home-owned{color:#123456}') && !str_contains($frontEditorCss, '.about-owned{color:#654321}') && str_contains($frontEditorCss, 'blocks-engine-presentation:'), 'Front-page editor receives global and front-page presentation assets with content-addressed evidence.'); +$assert(str_contains($aboutEditorCss, '.global-presentation{display:block}') && str_contains($aboutEditorCss, '.about-owned{color:#654321}') && !str_contains($aboutEditorCss, '.home-owned{color:#123456}') && str_contains($aboutEditorCss, 'blocks-engine-presentation:'), 'Nested-page editor receives global and route-owned presentation assets while excluding unrelated route CSS.'); global $wp_query; $setRequest = static function (WP_Post $post, bool $frontPage) use (&$wp_query): void { $page = 'page' === $post->post_type; $uri = $page ? get_page_uri($post) : ''; $wp_query->is_front_page = $frontPage; $wp_query->is_page = $page; $wp_query->is_single = !$page; $wp_query->is_home = false; $wp_query->is_singular = true; $wp_query->post = $post; $wp_query->posts = array($post); $wp_query->queried_object = $post; $wp_query->queried_object_id = $post->ID; $wp_query->query_vars = $page ? array('page_id' => $post->ID, 'pagename' => $uri) : array('p' => $post->ID, 'post_type' => 'post'); setup_postdata($post); }; $resetScripts = static function (): WP_Scripts { $scripts = wp_scripts(); $scripts->queue = array(); $scripts->to_do = array(); $scripts->done = array(); $scripts->in_footer = array(); $scripts->groups = array(); return $scripts; }; From 0e43bb1a859471b22fd2ca8ecfc175311a16297a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 13 Aug 2026 21:54:51 -0400 Subject: [PATCH 3/8] fix: preserve editor style identities --- php-transformer/src/WordPressSitePlan/WordPressSitePlan.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php index db8a6ad1..6806e122 100644 --- a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php +++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php @@ -869,7 +869,7 @@ private static function bootstrap(array $assets, array $scripts = array()): stri $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' => '/* blocks-engine-presentation:' . \$style['content_hash'] . ' */' . \"\\n\" . \$css, '__unstableType' => 'theme' );"; + $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 );"; From e1c43046ad7f6e66e8aaa686da2c93a2fef7dfdd Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 14 Aug 2026 03:47:43 -0400 Subject: [PATCH 4/8] fix: preserve responsive image sources --- .../src/HtmlToBlocks/FallbackDiagnostic.php | 13 ++ .../src/HtmlToBlocks/HtmlTransformer.php | 113 +++++++++++++++++- .../HtmlToBlocks/Support/DomHelpersTrait.php | 48 ++++++++ php-transformer/tests/contract/run.php | 27 ++++- .../artifact-local-link-asset-reports.json | 5 + .../artifact-responsive-image-assets.json | 20 ++-- ...nked-media-background-runtime-targets.json | 16 +-- .../fixtures/parity/html-media-text.json | 22 ++-- .../parity/html-picture-gallery-media.json | 21 ++-- .../site-artifact-report-consistency.json | 3 +- .../tests/integration/wordpress-site-plan.php | 16 +++ 11 files changed, 261 insertions(+), 43 deletions(-) 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 4f34ca2b..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> */ @@ -11073,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); } @@ -11116,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 ) { @@ -11164,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/tests/contract/run.php b/php-transformer/tests/contract/run.php index 98ea6c75..8fe6f66c 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -80,10 +80,29 @@ function serialize_blocks(array $blocks): string $responsiveImageResult = ( new HtmlTransformer() )->transform('Hero')->toArray(); $assert( - ! isset($responsiveImageResult['blocks'][0]['attrs']['srcset'], $responsiveImageResult['blocks'][0]['attrs']['sizes']) - && ! str_contains($responsiveImageResult['blocks'][0]['innerHTML'] ?? '', 'srcset=') - && ! str_contains($responsiveImageResult['blocks'][0]['innerHTML'] ?? '', 'sizes='), - 'native image save markup should omit responsive attributes that core/image cannot serialize' + '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'] ?? ''), '