Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,48 @@ describe('preserveDomStrategy', () => {
expect(aggregate.provenanceFlags).toEqual([]);
});

it('converts explicitly decorative inline SVGs to native image blocks instead of html fallback islands', () => {
const aggregate = reconstructNativeAggregate(
[
sectionSpec({
sectionIndex: 6,
headings: ['Fast care'],
sectionHtml:
'<section class="features"><div class="feature"><svg class="benefit-icon" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12l5 5L20 6"></path></svg><h3>Fast care</h3></div></section>',
}),
],
{ strategy: preserveDomStrategy },
);

const markup = aggregate.sectionMarkup[0] ?? '';
expect(markup).toContain('<!-- wp:image {"className":"benefit-icon"} -->');
expect(markup).toContain('<figure class="wp-block-image benefit-icon"><img src="data:image/svg+xml,');
expect(markup).toContain('alt="" style="width:24px;height:24px"/></figure>');
expect(markup).not.toContain('<!-- wp:html');
expect(markup).toContain('>Fast care<');
expect(aggregate.sections[0]?.coverage.lost).toBe(false);
});

it('keeps ambiguous unlabeled inline SVGs as html fallback islands', () => {
const aggregate = reconstructNativeAggregate(
[
sectionSpec({
sectionIndex: 7,
headings: ['Chart'],
sectionHtml:
'<section class="report"><svg class="chart" viewBox="0 0 100 20"><path d="M0 20L50 0L100 10"></path></svg><h2>Chart</h2></section>',
}),
],
{ strategy: preserveDomStrategy },
);

const markup = aggregate.sectionMarkup[0] ?? '';
expect(markup).toContain('<!-- wp:html');
expect(markup).toContain('<svg class="chart" viewBox="0 0 100 20">');
expect(markup).toContain('>Chart<');
expect(aggregate.sections[0]?.coverage.lost).toBe(false);
});

it('freezes lib-i hash parity for max-width declarations', () => {
const sheet = new InstanceStyleSheet();
const cls = sheet.classFor('max-width:46ch');
Expand Down
54 changes: 54 additions & 0 deletions packages/blocks-engine/src/theme/preserve-dom/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { isTag, isText } from 'domhandler';
import type { Element } from 'domhandler';
import { escapeHtmlAttr as escapeHtml } from '../../escape.js';
import type { NativeSectionDecision, SectionStrategy, StrategyState } from '../native-reconstruct-types.js';
import { sanitizeSvgAsset } from '../page-reconstruct-helpers.js';
import type { SectionSpec } from '../section-spec.js';
import { InstanceStyleSheet } from './instance-styles.js';
import { buildHtmlFallbackBlock } from '../html-fallback.js';
Expand Down Expand Up @@ -91,6 +92,54 @@ function imageBlock($: CheerioAPI, imgEl: Element, sheet: InstanceStyleSheet): s
return `<!-- wp:image${attrs} -->\n<figure class="${escapeHtml(figCls)}"><img src="${src}" alt="${alt}"/></figure>\n<!-- /wp:image -->`;
}

function svgAltText($: CheerioAPI, svgEl: Element): string | null {
const $svg = $(svgEl);
if (($svg.attr('aria-hidden') ?? '').trim().toLowerCase() === 'true') return '';
const role = ($svg.attr('role') ?? '').trim().toLowerCase();
if (role === 'presentation' || role === 'none') return '';

const labelledBy = ($svg.attr('aria-labelledby') ?? '').trim();
if (labelledBy) {
const text = labelledBy
.split(/\s+/)
.map((id) => $(`#${id}`).text().trim())
.filter(Boolean)
.join(' ');
if (text) return text;
}

const label = ($svg.attr('aria-label') ?? '').trim();
if (label) return label;

const title = $svg.children('title').first().text().trim();
return title || null;
}

function dimensionAttr($el: Cheerio<Element>, name: 'width' | 'height'): string | null {
const value = ($el.attr(name) ?? '').trim();
const match = /^\d+(?:\.\d+)?(?:px)?$/i.exec(value);
return match ? match[0].replace(/px$/i, '') : null;
}

function svgImageBlock($: CheerioAPI, svgEl: Element, sheet: InstanceStyleSheet): string | null {
const alt = svgAltText($, svgEl);
if (alt === null) return null;

const svg = sanitizeSvgAsset($.html(svgEl).trim());
if (!svg || !/<svg[\s>]/i.test(svg)) return null;

const $svg = $(svgEl);
const cls = classNameWithInstance($svg, sheet);
const attrs = blockAttrs([], cls);
const figCls = ['wp-block-image', cls].filter(Boolean).join(' ');
const width = dimensionAttr($svg, 'width');
const height = dimensionAttr($svg, 'height');
const style = [width ? `width:${width}px` : '', height ? `height:${height}px` : ''].filter(Boolean).join(';');
const styleAttr = style ? ` style="${escapeHtml(style)}"` : '';
const src = `data:image/svg+xml,${encodeURIComponent(svg)}`;
return `<!-- wp:image${attrs} -->\n<figure class="${escapeHtml(figCls)}"><img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}"${styleAttr}/></figure>\n<!-- /wp:image -->`;
}

function emitChild($: CheerioAPI, el: Element, sheet: InstanceStyleSheet): ChildResult {
const tag = el.tagName?.toLowerCase() ?? '';
const $el = $(el);
Expand Down Expand Up @@ -121,6 +170,11 @@ function emitChild($: CheerioAPI, el: Element, sheet: InstanceStyleSheet): Child
return { markup: imageBlock($, el, sheet), clean: true };
}

if (tag === 'svg') {
const markup = svgImageBlock($, el, sheet);
if (markup) return { markup, clean: true };
}

const text = $el.text().trim();
const elementChildren = $el.children().toArray();
if ((tag === 'div' || tag === 'span') && !$el.attr('id') && elementChildren.length === 0 && text) {
Expand Down
Loading