From 84361e234d90459ee6310ea72a3ee3b839268ae5 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 27 Jun 2026 00:28:43 -0400 Subject: [PATCH] Limit canvas fallbacks to runtime targets --- .../src/HtmlToBlocks/HtmlTransformer.php | 37 ++++++---------- php-transformer/tests/contract/run.php | 42 ++++++++++++++----- .../fixtures/parity/unsupported-fallback.json | 14 +++++-- 3 files changed, 54 insertions(+), 39 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 3b9ff5a..751e6f5 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -1244,16 +1244,13 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca } if ( 'canvas' === $tagName ) { - if ( $this->isDecorativeCanvas($element) && ! $this->isRuntimeCanvasTarget($element) ) { + if ( ! $this->isRuntimeCanvasTarget($element) ) { return null; } $this->captureCanvasFallback($element, $fallbacks); - if ( $this->isRuntimeCanvasTarget($element) ) { - $boundedHtml = $this->boundedFallbackHtml($this->safeFallbackHtml($element)); - return $this->createBlock('core/html', array( 'content' => $boundedHtml['html'] ), array(), $element); - } - return null; + $boundedHtml = $this->boundedFallbackHtml($this->safeFallbackHtml($element)); + return $this->createBlock('core/html', array( 'content' => $boundedHtml['html'] ), array(), $element); } if ( 'script' === $tagName ) { @@ -3094,16 +3091,18 @@ private function captureInlineSvgFallback(DOMElement $element, array &$fallbacks */ private function captureCanvasFallback(DOMElement $element, array &$fallbacks): void { + if ( ! $this->isRuntimeCanvasTarget($element) ) { + return; + } + $boundedHtml = $this->boundedFallbackHtml($this->safeFallbackHtml($element)); $id = trim($this->attr($element, 'id')); - if ( $this->isRuntimeCanvasTarget($element) ) { - $this->recordRuntimeIsland($element, 'canvas', 'canvas_requires_runtime', 'canvas_element_and_client_script_execution', array( - 'script_dependency_hint' => '' !== $id - ? 'Scripts may target #' . $id . ' and call canvas APIs such as getContext(); replacing it with a wrapper block changes runtime behavior.' - : 'Scripts may target this canvas by selector and call canvas APIs such as getContext(); replacing it with a wrapper block changes runtime behavior.', - 'required_scripts' => $this->requiredScriptsForElement($element), - )); - } + $this->recordRuntimeIsland($element, 'canvas', 'canvas_requires_runtime', 'canvas_element_and_client_script_execution', array( + 'script_dependency_hint' => '' !== $id + ? 'Scripts may target #' . $id . ' and call canvas APIs such as getContext(); replacing it with a wrapper block changes runtime behavior.' + : 'Scripts may target this canvas by selector and call canvas APIs such as getContext(); replacing it with a wrapper block changes runtime behavior.', + 'required_scripts' => $this->requiredScriptsForElement($element), + )); $fallbacks[] = FallbackDiagnostic::build(array_filter(array( 'type' => 'html', @@ -3143,16 +3142,6 @@ private function isRuntimeCanvasTarget(DOMElement $element): bool return false; } - private function isDecorativeCanvas(DOMElement $element): bool - { - if ( '' !== trim($element->textContent ?? '') || $this->childElementCount($element) > 0 ) { - return false; - } - - return 'true' === strtolower($this->attr($element, 'aria-hidden')) - || in_array(strtolower($this->attr($element, 'role')), array('presentation', 'none'), true); - } - /** * @param array $options * @return array diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 4b62033..34bfec4 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -260,12 +260,13 @@ public function match(DOMElement $element, PatternContext $context): ?array $assertInvalidCanonicalEnvelope($result, 'source_reports.materialization_plan', 'canonical validation can require materialization plans for downstream artifact consumers', true); $contextual = ( new HtmlTransformer() )->transform( - '

Context

Fallback
', + '

Context

Fallback
', array( 'source' => 'fixture:contextual-html', 'source_scope' => 'contract-test', 'strict' => true, 'allow_fallbacks' => false, + 'runtime_canvas_selectors' => array('#runtime-context'), ) )->toArray(); $assert('failed' === $contextual['status'], 'strict HTML transform fails when fallbacks are disallowed', (string) $contextual['status']); @@ -573,12 +574,13 @@ public function match(DOMElement $element, PatternContext $context): ?array } $assertNormalizedFallbackDiagnostic($diagnosticsByCode['html_unsafe_inline_svg'] ?? array(), 'html_unsafe_inline_svg', 'warning', 'sanitization_review', 'image_asset'); $assertNormalizedFallbackDiagnostic($diagnosticsByCode['html_script_fallback'] ?? array(), 'html_script_fallback', 'warning', 'client_script_execution', 'script_asset'); -$assertNormalizedFallbackDiagnostic($diagnosticsByCode['html_canvas_runtime_fallback'] ?? array(), 'html_canvas_runtime_fallback', 'warning', 'canvas_element_and_client_script_execution', 'runtime_canvas', 'runtime_island_preserved'); $assertNormalizedFallbackDiagnostic($diagnosticsByCode['html_iframe_embed_fallback'] ?? array(), 'html_iframe_embed_fallback', 'warning', 'third_party_embed_runtime', 'embed'); $assert(! isset($diagnosticsByCode['html_inline_svg_fallback']), 'safe inline SVGs convert to image blocks instead of fallback diagnostics'); +$assert(! isset($diagnosticsByCode['html_canvas_runtime_fallback']), 'non-runtime canvas does not emit runtime canvas fallback diagnostics'); $canvasFallback = ( new HtmlTransformer() )->transform( - '
Fallback
' + '
Fallback
', + array('runtime_canvas_selectors' => array('#bonsai')) )->toArray(); $canvasDiagnostic = $canvasFallback['source_reports']['conversion_report']['fallback_diagnostics'][0] ?? array(); $assertNormalizedFallbackDiagnostic($canvasDiagnostic, 'html_canvas_runtime_fallback', 'warning', 'canvas_element_and_client_script_execution', 'runtime_canvas', 'runtime_island_preserved'); @@ -586,8 +588,8 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert('bonsai' === ($canvasFallback['fallbacks'][0]['attributes']['id'] ?? ''), 'canvas fallback preserves id for runtime mapping'); $assert(str_contains((string) ($canvasFallback['fallbacks'][0]['html'] ?? ''), 'transform( '
', @@ -624,6 +626,24 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(array() === ($decorativeCanvas['fallbacks'] ?? array()), 'decorative canvas without runtime selectors is omitted instead of reported as runtime fallback'); $assert(! str_contains((string) ($decorativeCanvas['serialized_blocks'] ?? ''), 'transform( + '

Static preview

', + array( + 'strict' => true, + 'allow_fallbacks' => false, + ) +)->toArray(); +$assert('success' === ($staticCanvas['status'] ?? ''), 'static canvas without runtime selectors does not trip strict fallback gates', (string) ($staticCanvas['status'] ?? '')); +$assert(array() === ($staticCanvas['fallbacks'] ?? array()), 'static canvas without runtime selectors is omitted instead of reported as runtime fallback'); +$assert(! str_contains((string) ($staticCanvas['serialized_blocks'] ?? ''), 'transform( + '

Night sky

' +)->toArray(); +$assert(array() === ($starfieldCanvas['source_reports']['runtime_islands'] ?? array()), 'decorative starfield canvas without runtime selectors is not reported as a runtime island'); +$assert(array() === ($starfieldCanvas['fallbacks'] ?? array()), 'decorative starfield canvas without runtime selectors does not emit runtime fallback diagnostics'); +$assert(! str_contains((string) ($starfieldCanvas['serialized_blocks'] ?? ''), 'starfield'), 'decorative starfield canvas without runtime selectors is omitted from serialized blocks'); + $safeDecorativeSvg = ( new HtmlTransformer() )->transform( '
' )->toArray(); @@ -1299,17 +1319,17 @@ public function match(DOMElement $element, PatternContext $context): ?array assertSame('core/paragraph', $result['blocks'][0]['innerBlocks'][1]['blockName'], 'p should convert to a paragraph block.'); assertSame('core/list', $result['blocks'][1]['blockName'], 'ul should convert to a list block.'); assertSame('core/list-item', $result['blocks'][1]['innerBlocks'][0]['blockName'], 'li should convert to list-item blocks.'); -assertSame('html', $result['fallbacks'][0]['type'], 'canvas elements should be reported as HTML runtime fallbacks.'); -assertSame('canvas_requires_runtime', $result['fallbacks'][0]['reason'], 'canvas fallbacks should expose a runtime-specific reason.'); -assertSame('html_canvas_runtime_fallback', $result['fallbacks'][0]['diagnostic_code'], 'canvas fallbacks should expose a runtime-specific diagnostic code for cross-process consumers.'); -assertSame('html', $result['fallbacks'][0]['source_format'], 'fallbacks should expose the source format.'); -assertSame('canvas', $result['fallbacks'][0]['tag'], 'fallback should identify the unsupported tag.'); +assertSame('html', $runtimeCanvasResult['fallbacks'][0]['type'], 'runtime-targeted canvas elements should be reported as HTML runtime fallbacks.'); +assertSame('canvas_requires_runtime', $runtimeCanvasResult['fallbacks'][0]['reason'], 'runtime-targeted canvas fallbacks should expose a runtime-specific reason.'); +assertSame('html_canvas_runtime_fallback', $runtimeCanvasResult['fallbacks'][0]['diagnostic_code'], 'runtime-targeted canvas fallbacks should expose a runtime-specific diagnostic code for cross-process consumers.'); +assertSame('html', $runtimeCanvasResult['fallbacks'][0]['source_format'], 'fallbacks should expose the source format.'); +assertSame('canvas', $runtimeCanvasResult['fallbacks'][0]['tag'], 'fallback should identify the unsupported tag.'); assertContains('html_to_blocks_core_slice', array_column($result['diagnostics'], 'code'), 'expanded core-slice conversion diagnostic should be present.'); assertSame('html', $result['provenance'][0]['source_format'], 'source provenance should identify HTML input.'); assertSame(strlen($fixture . "\n
  • One
  • Two
Fallback"), $result['metrics']['input_bytes'], 'HTML metrics should expose input bytes.'); assertSame(strlen($result['serialized_blocks']), $result['metrics']['output_bytes'], 'HTML metrics should expose output bytes.'); assertSame(6, $result['metrics']['block_count'], 'HTML metrics should count nested blocks.'); -assertSame(1, $result['metrics']['fallback_count'], 'HTML metrics should expose fallback count.'); +assertSame(0, $result['metrics']['fallback_count'], 'HTML metrics should not count non-runtime canvas as a runtime fallback.'); assertSame(count($result['diagnostics']), $result['metrics']['diagnostic_count'], 'HTML metrics should expose diagnostic count.'); $assert(is_float($result['metrics']['transform_duration_ms'] ?? null), 'HTML metrics expose transform duration'); diff --git a/php-transformer/tests/fixtures/parity/unsupported-fallback.json b/php-transformer/tests/fixtures/parity/unsupported-fallback.json index e1113c0..5c06dd2 100644 --- a/php-transformer/tests/fixtures/parity/unsupported-fallback.json +++ b/php-transformer/tests/fixtures/parity/unsupported-fallback.json @@ -1,7 +1,7 @@ { "schema": "blocks-engine/php-transformer/parity-fixture/v1", "name": "unsupported-html-fallback", - "description": "Reports native canvas HTML as runtime-critical fallback metadata without emitting raw HTML blocks.", + "description": "Reports runtime-targeted native canvas HTML as runtime-critical fallback metadata.", "source_reference": { "repo": "php-transformer", "path": "tests/smoke-unsupported-html-fallback-hook.php", @@ -13,15 +13,21 @@ }, "operation": "html_transformer.transform", "input": { - "content": "

Fallback content

" + "content": "

Fallback content

", + "options": { + "runtime_canvas_selectors": ["#runtime-canvas"] + } }, - "expected_blocks": [], + "expected_blocks": [ + { "path": "blocks.0", "name": "core/html", "attrs": { "content": "

Fallback content

" } } + ], "expected_fallbacks": [ { "type": "html", "reason": "canvas_requires_runtime", "diagnostic_code": "html_canvas_runtime_fallback", "tag": "canvas", "selector": "canvas:nth-of-type(1)", "child_count": 1 } ], "expect": [ { "path": "status", "assert": "equals", "value": "success" }, - { "path": "blocks", "assert": "count", "count": 0 }, + { "path": "blocks", "assert": "count", "count": 1 }, + { "path": "blocks.0.blockName", "assert": "equals", "value": "core/html" }, { "path": "fallbacks", "assert": "count", "count": 1 }, { "path": "fallbacks.0.type", "assert": "equals", "value": "html" }, { "path": "fallbacks.0.reason", "assert": "equals", "value": "canvas_requires_runtime" },