From bd8a8af3032b776cee93e898f7fb766f4fd52a69 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 4 Jul 2026 09:21:30 -0400 Subject: [PATCH 1/2] fix(php-transformer): convert inline svg to native icons --- .../src/HtmlToBlocks/HtmlTransformer.php | 50 +++++++++++++------ php-transformer/tests/contract/run.php | 32 ++++++------ .../html-background-icon-media-patterns.json | 8 +-- .../parity/html-flex-media-text-columns.json | 8 +-- .../html-inline-svg-artwork-preserved.json | 26 +++++----- ...html-inline-svg-benefit-icon-faithful.json | 10 ++-- .../parity/html-inline-svg-decorative.json | 8 +-- .../html-inline-svg-diagram-preserved.json | 28 +++++------ .../parity/html-inline-svg-fallback.json | 8 +-- .../html-inline-svg-local-use-symbol.json | 12 ++--- .../html-inline-svg-native-icon-sizing.json | 42 ++++++++++++++++ .../parity/html-inline-svg-unsafe.json | 18 +++---- .../html-product-card-svg-price-grid.json | 10 ++-- .../html-single-child-flex-svg-address.json | 8 +-- .../parity/html-single-child-flex-svg.json | 8 +-- 15 files changed, 169 insertions(+), 107 deletions(-) create mode 100644 php-transformer/tests/fixtures/parity/html-inline-svg-native-icon-sizing.json diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 8a694a59..5e8cd72a 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -1552,14 +1552,10 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca } } - // Imported inline SVGs are preserved faithfully as raw markup - // (core/html via inlineSvgBlockFromElement). They are never routed - // through core/icon: that block is a dynamic block keyed on a - // registry slug (its `icon` attribute) and discards arbitrary inline - // SVG markup, so render_block_core_icon() returns empty output for - // imported SVGs. Faithful passthrough keeps the original element, - // its sizing class(es), and correct-case viewBox so the - // materialized source CSS can size it. + // Imported inline SVGs are represented as core/icon with the + // sanitized source stored verbatim in its svg attribute. This + // keeps the actual SVG element, sizing classes/attributes, and + // correct-case viewBox while avoiding core/html fallback blocks. if ( $this->isSafeDecorativeSvgElement($element) ) { // Faithfully preserve any inline SVG that carries real drawable // artwork — icons, diagrams, illustrations — even when it is @@ -4247,14 +4243,38 @@ private function inlineSvgBlockFromElement(DOMElement $element): ?array return null; } - // Keep illustrative/decorative inline SVG inline as a core/html block. // Externalizing to an `assets/*.svg` file + core/image would be lost in - // WordPress, which blocks SVG uploads by default. The markup is already - // safe-sanitized above (scripts, event handlers, foreignObject, and - // javascript: URLs stripped via sanitizeInlineSvgMarkup + verified by - // isSafeSvgContent), and the original outer SVG preserves - // viewBox/role/aria-label/class. - return $this->createBlock('core/html', array( 'content' => $this->restoreSvgCasing($html) ), array(), $element); + // WordPress, which blocks SVG uploads by default. core/icon is the + // native block in this runtime that can carry inline SVG markup, and the + // original outer SVG preserves viewBox/width/height/preserveAspectRatio, + // role/aria-label, and CSS sizing classes on the actual rendered . + return $this->createBlock('core/icon', $this->inlineSvgIconAttributes($element, $html), array(), $element); + } + + /** + * @return array + */ + private function inlineSvgIconAttributes(DOMElement $element, string $html): array + { + $attrs = $this->presentationAttributes($element); + $attrs['svg'] = $this->restoreSvgCasing($html); + + $label = trim($this->attr($element, 'aria-label')); + if ( '' === $label ) { + $title = $element->getElementsByTagName('title')->item(0); + if ( $title instanceof DOMElement ) { + $label = trim($title->textContent ?? ''); + } + } + if ( '' !== $label ) { + $attrs['label'] = $label; + } + + if ( 'true' === strtolower(trim($this->attr($element, 'aria-hidden'))) || in_array(strtolower(trim($this->attr($element, 'role'))), array( 'presentation', 'none' ), true) ) { + $attrs['ariaHidden'] = true; + } + + return $attrs; } /** diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 3306df7e..b62b9485 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -498,8 +498,8 @@ public function match(DOMElement $element, PatternContext $context): ?array '
' )->toArray(); $inlineSvgMarkup = (string) ($inlineSvgArtwork['serialized_blocks'] ?? ''); -$assert('core/html' === ($inlineSvgArtwork['blocks'][0]['blockName'] ?? ''), 'meaningful inline SVG artwork materializes as sanitized inline HTML'); -$assert(str_contains($inlineSvgMarkup, '" }, - { "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "" }, - { "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "" }, - { "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "" }, - { "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "" }, - { "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "Between the Fog and the Fire" }, - { "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "not_contains", "value": "data:image/svg+xml" }, - { "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "not_contains", "value": "" }, + { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "" }, + { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "" }, + { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "" }, + { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "" }, + { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "" }, + { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "Between the Fog and the Fire" }, + { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "not_contains", "value": "data:image/svg+xml" }, + { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "not_contains", "value": " element, its sizing class (e.g. benefit-icon), and its correct-case viewBox survive rendering. They are never routed through the dynamic core/icon block, which is keyed on a registry slug and discards inline SVG markup (rendering empty).", + "description": "Imported feature/benefit inline SVGs are preserved as core/icon so the verbatim element, its sizing class (e.g. benefit-icon), and its correct-case viewBox survive rendering.", "source_reference": { "repo": "php-transformer", "path": "tests/fixtures/parity/html-inline-svg-benefit-icon-faithful.json", - "notes": "Regression guard: core/icon discards arbitrary inline SVG (render_block_core_icon returns empty without a registry slug), so imported feature icons must use faithful core/html passthrough that keeps element + class + viewBox case." + "notes": "Regression guard: imported feature icons use core/icon with verbatim sanitized SVG markup so element + class + viewBox case survive." }, "legacy_comparison": { "skip": true, @@ -18,7 +18,7 @@ "expected_blocks": [ { "path": "blocks.0", "name": "core/group", "attrs": { "className": "features" } }, { "path": "blocks.0.innerBlocks.0", "name": "core/group", "attrs": { "className": "feature" } }, - { "path": "blocks.0.innerBlocks.0.innerBlocks.0", "name": "core/html" }, + { "path": "blocks.0.innerBlocks.0.innerBlocks.0", "name": "core/icon" }, { "path": "blocks.0.innerBlocks.0.innerBlocks.1", "name": "core/heading", "attrs": { "content": "Fast", "level": 3 } } ], "expect": [ @@ -26,8 +26,8 @@ { "path": "blocks", "assert": "count", "count": 1 }, { "path": "fallbacks", "assert": "count", "count": 0 }, { "path": "assets", "assert": "count", "count": 0 }, - { "path": "blocks.0.innerBlocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "class=\"benefit-icon\"" }, - { "path": "blocks.0.innerBlocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "viewBox=\"0 0 24 24\"" }, + { "path": "blocks.0.innerBlocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "class=\"benefit-icon\"" }, + { "path": "blocks.0.innerBlocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "viewBox=\"0 0 24 24\"" }, { "path": "serialized_blocks", "assert": "contains", "value": " and an event-handler attribute) are stripped while the artwork is kept.", + "description": "Preserves an aria-hidden inline SVG diagram built from the full set of safe structure/geometry elements (defs/linearGradient/stop, line/polyline/polygon/ellipse/circle/rect) plus HTML comments as a core/icon block. aria-hidden marks the diagram decorative for assistive tech but never makes the artwork disposable: the SVG passes the safe-decorative classification yet must still survive instead of collapsing to an empty block or a comments-only shell. Unsafe parts carried on the same SVG (an inline
Reserve" + }, + "expected_blocks": [ + { "path": "blocks.0", "name": "core/group" }, + { "path": "blocks.0.innerBlocks.0", "name": "core/icon" }, + { "path": "blocks.0.innerBlocks.1", "name": "core/icon" }, + { "path": "blocks.0.innerBlocks.2", "name": "core/buttons" }, + { "path": "blocks.0.innerBlocks.2.innerBlocks.0", "name": "core/button", "attrs": { "className": "icon-cta", "text": "Reserve", "url": "/reserve" } } + ], + "expected_fallbacks": [], + "expect": [ + { "path": "status", "assert": "equals", "value": "success" }, + { "path": "fallbacks", "assert": "count", "count": 0 }, + { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"" }, + { "path": "blocks.0.innerBlocks.0.attrs.ariaHidden", "assert": "equals", "value": true }, + { "path": "blocks.0.innerBlocks.1.attrs.svg", "assert": "contains", "value": "class=\"hero-flame-svg\"" }, + { "path": "blocks.0.innerBlocks.1.attrs.svg", "assert": "contains", "value": "viewBox=\"0 0 100 160\"" }, + { "path": "blocks.0.innerBlocks.1.attrs.svg", "assert": "contains", "value": "style=\"width:120px;height:auto\"" }, + { "path": "blocks.0.innerBlocks.1.attrs.label", "assert": "equals", "value": "Hero flame" }, + { "path": "serialized_blocks", "assert": "contains", "value": "" }, - { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "" }, - { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "" }, - { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "" }, - { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "" }, - { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "Between the Fog and the Fire" }, - { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "not_contains", "value": "data:image/svg+xml" }, - { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "not_contains", "value": "" }, + { "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "" }, + { "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "" }, + { "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "" }, + { "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "" }, + { "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "" }, + { "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "Between the Fog and the Fire" }, + { "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "not_contains", "value": "data:image/svg+xml" }, + { "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "not_contains", "value": " element, its sizing class (e.g. benefit-icon), and its correct-case viewBox survive rendering.", + "description": "Imported feature/benefit inline SVGs are preserved faithfully as core/html so the verbatim element, its sizing class (e.g. benefit-icon), and its correct-case viewBox survive rendering. They are never routed through the dynamic core/icon block, which is keyed on a registry slug and discards inline SVG markup (rendering empty).", "source_reference": { "repo": "php-transformer", "path": "tests/fixtures/parity/html-inline-svg-benefit-icon-faithful.json", - "notes": "Regression guard: imported feature icons use core/icon with verbatim sanitized SVG markup so element + class + viewBox case survive." + "notes": "Regression guard: core/icon discards arbitrary inline SVG (render_block_core_icon returns empty without a registry slug), so imported feature icons must use faithful core/html passthrough that keeps element + class + viewBox case." }, "legacy_comparison": { "skip": true, @@ -18,7 +18,7 @@ "expected_blocks": [ { "path": "blocks.0", "name": "core/group", "attrs": { "className": "features" } }, { "path": "blocks.0.innerBlocks.0", "name": "core/group", "attrs": { "className": "feature" } }, - { "path": "blocks.0.innerBlocks.0.innerBlocks.0", "name": "core/icon" }, + { "path": "blocks.0.innerBlocks.0.innerBlocks.0", "name": "core/html" }, { "path": "blocks.0.innerBlocks.0.innerBlocks.1", "name": "core/heading", "attrs": { "content": "Fast", "level": 3 } } ], "expect": [ @@ -26,8 +26,8 @@ { "path": "blocks", "assert": "count", "count": 1 }, { "path": "fallbacks", "assert": "count", "count": 0 }, { "path": "assets", "assert": "count", "count": 0 }, - { "path": "blocks.0.innerBlocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "class=\"benefit-icon\"" }, - { "path": "blocks.0.innerBlocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "viewBox=\"0 0 24 24\"" }, + { "path": "blocks.0.innerBlocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "class=\"benefit-icon\"" }, + { "path": "blocks.0.innerBlocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "viewBox=\"0 0 24 24\"" }, { "path": "serialized_blocks", "assert": "contains", "value": " and an event-handler attribute) are stripped while the artwork is kept.", + "description": "Preserves an aria-hidden inline SVG diagram built from the full set of safe structure/geometry elements (defs/linearGradient/stop, line/polyline/polygon/ellipse/circle/rect) plus HTML comments, faithfully as a core/html block. aria-hidden marks the diagram decorative for assistive tech but never makes the artwork disposable: the SVG passes the safe-decorative classification yet must still survive instead of collapsing to an empty block or a comments-only shell. Unsafe parts carried on the same SVG (an inline Reserve" - }, - "expected_blocks": [ - { "path": "blocks.0", "name": "core/group" }, - { "path": "blocks.0.innerBlocks.0", "name": "core/icon" }, - { "path": "blocks.0.innerBlocks.1", "name": "core/icon" }, - { "path": "blocks.0.innerBlocks.2", "name": "core/buttons" }, - { "path": "blocks.0.innerBlocks.2.innerBlocks.0", "name": "core/button", "attrs": { "className": "icon-cta", "text": "Reserve", "url": "/reserve" } } - ], - "expected_fallbacks": [], - "expect": [ - { "path": "status", "assert": "equals", "value": "success" }, - { "path": "fallbacks", "assert": "count", "count": 0 }, - { "path": "blocks.0.innerBlocks.0.attrs.svg", "assert": "contains", "value": "width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"" }, - { "path": "blocks.0.innerBlocks.0.attrs.ariaHidden", "assert": "equals", "value": true }, - { "path": "blocks.0.innerBlocks.1.attrs.svg", "assert": "contains", "value": "class=\"hero-flame-svg\"" }, - { "path": "blocks.0.innerBlocks.1.attrs.svg", "assert": "contains", "value": "viewBox=\"0 0 100 160\"" }, - { "path": "blocks.0.innerBlocks.1.attrs.svg", "assert": "contains", "value": "style=\"width:120px;height:auto\"" }, - { "path": "blocks.0.innerBlocks.1.attrs.label", "assert": "equals", "value": "Hero flame" }, - { "path": "serialized_blocks", "assert": "contains", "value": "