diff --git a/packages/blocks-engine/src/theme/preserve-dom/__tests__/strategy.test.ts b/packages/blocks-engine/src/theme/preserve-dom/__tests__/strategy.test.ts index 0ec48287..14591cfa 100644 --- a/packages/blocks-engine/src/theme/preserve-dom/__tests__/strategy.test.ts +++ b/packages/blocks-engine/src/theme/preserve-dom/__tests__/strategy.test.ts @@ -163,4 +163,37 @@ describe('preserveDomStrategy', () => { expect(cls).toBe('lib-i91a84cc172'); expect(sheet.toCss()).toBe('.lib-i91a84cc172{max-width:46ch}'); }); + + it('sanitizes out-of-flow and oversized fallback layout declarations without changing route or assets', () => { + const aggregate = reconstructNativeAggregate( + [ + sectionSpec({ + sectionIndex: 6, + headings: ['Pinned panel'], + images: [ + { url: '/hero.jpg', sourceUrl: '/hero.jpg', alt: 'Hero', kind: 'img', width: 1200, height: 800 }, + ], + sectionHtml: + '
' + + '

Pinned panel

' + + 'Hero
', + }), + ], + { strategy: preserveDomStrategy }, + ); + + expect(aggregate.sections[0]?.decision).toBe('native'); + expect(aggregate.sections[0]?.coverage.lost).toBe(false); + expect(aggregate.sections[0]?.coverage.missingImages).toEqual([]); + expect(aggregate.sections[0]?.expectedAssets).toEqual(['/hero.jpg']); + expect(aggregate.sectionMarkup[0]).toContain('className":"hero shell lib-i1e1b353447"'); + expect(aggregate.sectionMarkup[0]).toContain('panel'); + expect(aggregate.dedup).toEqual({ + cssRules: [ + '.lib-i1e1b353447{width:100%;color:red;max-width:46ch}', + '.lib-i641b5be562{color:blue;max-width:20ch}', + ], + }); + expect((aggregate.dedup?.cssRules ?? []).join('\n')).not.toMatch(/position|top:|left:|height:6000px/); + }); }); diff --git a/packages/blocks-engine/src/theme/preserve-dom/instance-styles.ts b/packages/blocks-engine/src/theme/preserve-dom/instance-styles.ts index 7d6a5715..a4b38e0c 100644 --- a/packages/blocks-engine/src/theme/preserve-dom/instance-styles.ts +++ b/packages/blocks-engine/src/theme/preserve-dom/instance-styles.ts @@ -7,18 +7,56 @@ import { createHash } from 'node:crypto'; * and 'a:1px;b:2px' produce the same key. */ export function normalizeDeclarations(style: string): string { - return style + const declarations = style .split(';') .map((decl) => decl.trim().replace(/\s+/g, ' ')) .filter(Boolean) .map((decl) => { const i = decl.indexOf(':'); - if (i < 0) return decl; - return `${decl.slice(0, i).trim()}:${decl.slice(i + 1).trim()}`; - }) + if (i < 0) return { prop: '', value: '', text: decl }; + const prop = decl.slice(0, i).trim().toLowerCase(); + const value = decl.slice(i + 1).trim(); + return { prop, value, text: `${prop}:${value}` }; + }); + const outOfFlow = declarations.some( + ({ prop, value }) => prop === 'position' && /^(absolute|fixed)\b/i.test(value), + ); + + return declarations + .filter(({ prop, value }) => !isRiskyLayoutDeclaration(prop, value, outOfFlow)) + .map(({ text }) => text) .join(';'); } +const OUT_OF_FLOW_OFFSET_PROPS = new Set(['inset', 'inset-block', 'inset-inline', 'top', 'right', 'bottom', 'left']); +const BOX_SIZE_PROPS = new Set(['height', 'min-height', 'max-height', 'width', 'min-width', 'max-width']); +const MAX_SAFE_BOX_PX = 1600; +const MAX_SAFE_VIEWPORT_UNIT = 120; + +function isRiskyLayoutDeclaration(prop: string, value: string, outOfFlow: boolean): boolean { + if (!prop) return false; + if (prop === 'position' && /^(absolute|fixed)\b/i.test(value)) return true; + if (outOfFlow && OUT_OF_FLOW_OFFSET_PROPS.has(prop)) return true; + if (BOX_SIZE_PROPS.has(prop) && isOversizedBoxValue(value)) return true; + return false; +} + +function isOversizedBoxValue(value: string): boolean { + return value + .split(/\s+/) + .some((part) => oversizedPx(part) || oversizedViewportUnit(part)); +} + +function oversizedPx(value: string): boolean { + const match = /^(-?\d+(?:\.\d+)?)px$/i.exec(value); + return !!match && Number(match[1]) > MAX_SAFE_BOX_PX; +} + +function oversizedViewportUnit(value: string): boolean { + const match = /^(-?\d+(?:\.\d+)?)(vh|vw|vmin|vmax)$/i.exec(value); + return !!match && Number(match[1]) > MAX_SAFE_VIEWPORT_UNIT; +} + export class InstanceStyleSheet { private readonly rules = new Map(); diff --git a/packages/blocks-engine/src/theme/preserve-dom/strategy.ts b/packages/blocks-engine/src/theme/preserve-dom/strategy.ts index c2ec7953..689bcca4 100644 --- a/packages/blocks-engine/src/theme/preserve-dom/strategy.ts +++ b/packages/blocks-engine/src/theme/preserve-dom/strategy.ts @@ -51,7 +51,7 @@ function paragraphBlock(inner: string): string { const HEADING = /^h([1-6])$/; const INLINE_ALLOWED = new Set(['a', 'strong', 'em', 'b', 'i', 'br', 'span']); -function inlineHtml($: CheerioAPI, el: Element): string { +function inlineHtml($: CheerioAPI, el: Element, sheet: InstanceStyleSheet): string { let out = ''; for (const node of $(el).contents().get()) { if (isText(node)) { @@ -63,19 +63,19 @@ function inlineHtml($: CheerioAPI, el: Element): string { continue; } const cls = ($(node).attr('class') ?? '').trim(); - const styleA = ($(node).attr('style') ?? '').trim(); if (INLINE_ALLOWED.has(tag)) { - const inner = inlineHtml($, node); - const clsAttr = cls ? ` class="${escapeHtml(cls)}"` : ''; + const inner = inlineHtml($, node, sheet); + const instance = sheet.classFor($(node).attr('style')); + const inlineCls = [cls, instance].filter(Boolean).join(' '); + const clsAttr = inlineCls ? ` class="${escapeHtml(inlineCls)}"` : ''; if (tag === 'a') { const href = escapeHtml($(node).attr('href') ?? ''); out += `${inner}`; } else { - const styleAttr = styleA ? ` style="${escapeHtml(styleA)}"` : ''; - out += `<${tag}${clsAttr}${styleAttr}>${inner}`; + out += `<${tag}${clsAttr}>${inner}`; } } else { - out += inlineHtml($, node); + out += inlineHtml($, node, sheet); } } } @@ -101,7 +101,7 @@ function emitChild($: CheerioAPI, el: Element, sheet: InstanceStyleSheet): Child const cls = classNameWithInstance($el, sheet); const attrs = blockAttrs(level === 2 ? [] : [`"level":${level}`], cls); const htmlCls = ['wp-block-heading', cls].filter(Boolean).join(' '); - const inner = inlineHtml($, el).trim(); + const inner = inlineHtml($, el, sheet).trim(); return { markup: `\n${inner}\n`, clean: true, @@ -111,7 +111,7 @@ function emitChild($: CheerioAPI, el: Element, sheet: InstanceStyleSheet): Child if (tag === 'p') { const cls = classNameWithInstance($el, sheet); const attrs = blockAttrs([], cls); - const inner = inlineHtml($, el).trim(); + const inner = inlineHtml($, el, sheet).trim(); const clsPart = cls ? ` class="${escapeHtml(cls)}"` : ''; const open = ``; return { markup: `\n${open}${inner}

\n`, clean: true }; @@ -128,7 +128,7 @@ function emitChild($: CheerioAPI, el: Element, sheet: InstanceStyleSheet): Child const attrs = blockAttrs([], cls); const clsPart = cls ? ` class="${escapeHtml(cls)}"` : ''; return { - markup: `\n${inlineHtml($, el).trim()}

\n`, + markup: `\n${inlineHtml($, el, sheet).trim()}

\n`, clean: true, }; }