diff --git a/php-transformer/src/Contract/ConversionReportProjection.php b/php-transformer/src/Contract/ConversionReportProjection.php index d05eab9..1e228a5 100644 --- a/php-transformer/src/Contract/ConversionReportProjection.php +++ b/php-transformer/src/Contract/ConversionReportProjection.php @@ -129,19 +129,20 @@ private static function fallbackDiagnostics(array $fallbacks): array 'source_format' => $fallback['source_format'] ?? '', 'source' => $fallback['source'] ?? '', 'scope' => $fallback['scope'] ?? '', - 'source_path' => $fallback['source_path'] ?? '', - 'tag' => $fallback['tag'] ?? '', - 'selector' => $fallback['selector'] ?? '', - 'child_count' => $fallback['child_count'] ?? null, - 'control_count' => $fallback['control_count'] ?? null, - 'form' => $fallback['form'] ?? array(), - 'control' => $fallback['control'] ?? array(), - 'controls' => $fallback['controls'] ?? array(), - 'context' => $fallback['context'] ?? array(), - 'events' => $fallback['events'] ?? array(), - 'readable_blocks' => $fallback['readable_blocks'] ?? array(), - 'html_bytes' => $fallback['html_bytes'] ?? (isset($fallback['html']) && is_string($fallback['html']) ? strlen($fallback['html']) : null), - 'html_truncated' => $fallback['html_truncated'] ?? null, + 'source_path' => $fallback['source_path'] ?? '', + 'tag' => $fallback['tag'] ?? '', + 'selector' => $fallback['selector'] ?? '', + 'child_count' => $fallback['child_count'] ?? null, + 'control_count' => $fallback['control_count'] ?? null, + 'form' => $fallback['form'] ?? array(), + 'control' => $fallback['control'] ?? array(), + 'controls' => $fallback['controls'] ?? array(), + 'context' => $fallback['context'] ?? array(), + 'events' => $fallback['events'] ?? array(), + 'script_dependency_hint' => $fallback['script_dependency_hint'] ?? '', + 'readable_blocks' => $fallback['readable_blocks'] ?? array(), + 'html_bytes' => $fallback['html_bytes'] ?? (isset($fallback['html']) && is_string($fallback['html']) ? strlen($fallback['html']) : null), + 'html_truncated' => $fallback['html_truncated'] ?? null, ), static fn (mixed $value): bool => null !== $value && '' !== $value ); diff --git a/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php b/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php index 45036b1..da84290 100644 --- a/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php +++ b/php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php @@ -64,6 +64,14 @@ private static function defaults(array $fields): array 'suggested_primitive' => 'embed', 'materialization_hint' => 'convert_supported_src_to_core_embed_or_preserve_sanitized_iframe_html', ), + 'html_canvas_runtime_fallback' => array( + 'severity' => 'warning', + 'runtime_requirement' => 'canvas_element_and_client_script_execution', + 'recoverability' => 'recoverable_with_canvas_markup_preservation_or_rebuilt_interactive_block', + 'actionability' => 'preserve_canvas_markup_with_matching_script_runtime_or_rebuild_canvas_behavior', + 'suggested_primitive' => 'runtime_canvas', + 'materialization_hint' => 'core_blocks_cannot_emit_a_native_canvas_element_without_raw_html; preserve_bounded_canvas_metadata_for_runtime_mapping', + ), 'html_unsupported_element' => array( 'severity' => 'info', 'runtime_requirement' => 'unknown', diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 28db573..85d0a40 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -189,12 +189,14 @@ public function transform(string $html, array $options = array()): TransformerRe foreach ( $fallbacks as $fallback ) { if ( ! empty($fallback['diagnostic_code']) ) { $diagnostics[] = array( - 'code' => $fallback['diagnostic_code'], - 'message' => $fallback['message'] ?? 'HTML element preserved as fallback metadata.', - 'source' => self::class, - 'reason' => $fallback['reason'] ?? null, - 'tag' => $fallback['tag'] ?? null, - 'selector' => $fallback['selector'] ?? null, + 'code' => $fallback['diagnostic_code'], + 'message' => $fallback['message'] ?? 'HTML element preserved as fallback metadata.', + 'source' => self::class, + 'reason' => $fallback['reason'] ?? null, + 'severity' => $fallback['severity'] ?? null, + 'runtime_requirement' => $fallback['runtime_requirement'] ?? null, + 'tag' => $fallback['tag'] ?? null, + 'selector' => $fallback['selector'] ?? null, ); } } @@ -1143,6 +1145,11 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca return null; } + if ( 'canvas' === $tagName ) { + $this->captureCanvasFallback($element, $fallbacks); + return null; + } + if ( 'script' === $tagName ) { if ( $this->captureStaticScriptMetadata($element) ) { return null; @@ -2857,6 +2864,36 @@ private function captureInlineSvgFallback(DOMElement $element, array &$fallbacks ), $this->fallbackProvenance); } + /** + * @param array> $fallbacks + */ + private function captureCanvasFallback(DOMElement $element, array &$fallbacks): void + { + $boundedHtml = $this->boundedFallbackHtml($this->safeFallbackHtml($element)); + $id = trim($this->attr($element, 'id')); + + $fallbacks[] = FallbackDiagnostic::build(array_filter(array( + 'type' => 'html', + 'reason' => 'canvas_requires_runtime', + 'diagnostic_code' => 'html_canvas_runtime_fallback', + 'message' => 'Canvas HTML requires a native canvas element and client script runtime; core blocks cannot preserve it without raw HTML.', + 'source_format' => 'html', + 'tag' => 'canvas', + 'selector' => $this->elementSelector($element), + 'attributes' => $this->safeCanvasAttributes($element), + 'context' => $this->sourceContext($element), + 'events' => $this->eventMetadata($element), + '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.', + 'text_length' => strlen(trim($element->textContent ?? '')), + 'child_count' => $this->childElementCount($element), + 'html' => $boundedHtml['html'], + 'html_bytes' => $boundedHtml['bytes'], + 'html_truncated' => $boundedHtml['truncated'], + ), static fn (mixed $value): bool => '' !== $value && array() !== $value), $this->fallbackProvenance); + } + /** * @param array> $fallbacks */ @@ -2953,6 +2990,22 @@ private function safeScriptAttributes(DOMElement $element): array return $safe; } + /** + * @return array + */ + private function safeCanvasAttributes(DOMElement $element): array + { + $safe = array(); + $allowed = array_flip(array( 'aria-label', 'class', 'height', 'id', 'role', 'style', 'title', 'width' )); + foreach ( $this->htmlAttributes($element) as $name => $value ) { + if ( isset($allowed[$name]) ) { + $safe[$name] = strlen($value) > 300 ? substr($value, 0, 300) . '...' : $value; + } + } + + return $safe; + } + /** * @return array{html: string, bytes: int, truncated: bool} */ diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index d440929..d77791d 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -412,10 +412,22 @@ function serialize_blocks(array $blocks): string } $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_unsupported_element'] ?? array(), 'html_unsupported_element', 'info', 'unknown', 'core/html'); +$assertNormalizedFallbackDiagnostic($diagnosticsByCode['html_canvas_runtime_fallback'] ?? array(), 'html_canvas_runtime_fallback', 'warning', 'canvas_element_and_client_script_execution', 'runtime_canvas'); $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'); +$canvasFallback = ( new HtmlTransformer() )->transform( + '
Fallback
' +)->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'); +$assert('canvas_requires_runtime' === ($canvasDiagnostic['reason'] ?? ''), 'canvas fallback exposes runtime-specific reason'); +$assert('bonsai' === ($canvasFallback['fallbacks'][0]['attributes']['id'] ?? ''), 'canvas fallback preserves id for runtime mapping'); +$assert(str_contains((string) ($canvasFallback['fallbacks'][0]['html'] ?? ''), 'transform( '
' )->toArray(); @@ -930,9 +942,9 @@ function serialize_blocks(array $blocks): string 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('unsupported_element', $result['fallbacks'][0]['type'], 'unsupported top-level elements should be reported as fallbacks.'); -assertSame('unsupported_element', $result['fallbacks'][0]['reason'], 'fallbacks should expose a stable generic reason.'); -assertSame('html_unsupported_element', $result['fallbacks'][0]['diagnostic_code'], 'fallbacks should expose a diagnostic code for cross-process consumers.'); +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.'); assertContains('html_to_blocks_core_slice', array_column($result['diagnostics'], 'code'), 'expanded core-slice conversion diagnostic should be present.'); diff --git a/php-transformer/tests/fixtures/parity/unsupported-fallback.json b/php-transformer/tests/fixtures/parity/unsupported-fallback.json index 61768e6..e1113c0 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 unsupported top-level HTML as fallback metadata without failing the transform.", + "description": "Reports native canvas HTML as runtime-critical fallback metadata without emitting raw HTML blocks.", "source_reference": { "repo": "php-transformer", "path": "tests/smoke-unsupported-html-fallback-hook.php", @@ -17,13 +17,15 @@ }, "expected_blocks": [], "expected_fallbacks": [ - { "type": "unsupported_element", "reason": "unsupported_element", "tag": "canvas", "selector": "canvas:nth-of-type(1)", "child_count": 1 } + { "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": "fallbacks", "assert": "count", "count": 1 }, - { "path": "fallbacks.0.type", "assert": "equals", "value": "unsupported_element" }, + { "path": "fallbacks.0.type", "assert": "equals", "value": "html" }, + { "path": "fallbacks.0.reason", "assert": "equals", "value": "canvas_requires_runtime" }, + { "path": "fallbacks.0.diagnostic_code", "assert": "equals", "value": "html_canvas_runtime_fallback" }, { "path": "fallbacks.0.tag", "assert": "equals", "value": "canvas" }, { "path": "fallbacks.0.selector", "assert": "equals", "value": "canvas:nth-of-type(1)" }, { "path": "diagnostics.1.selector", "assert": "equals", "value": "canvas:nth-of-type(1)" },