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
8 changes: 4 additions & 4 deletions packages/blocks-engine/src/__fixtures__/cases.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,13 @@
"id": "compose.generic-details",
"op": "compose",
"input": "<details><summary>Q</summary><p>A</p></details>",
"expected": "<!-- wp:details -->\n<details class=\"wp-block-details\"><summary>Q</summary><p>A</p></details>\n<!-- /wp:details -->"
"expected": "<!-- wp:details -->\n<details class=\"wp-block-details\"><summary>Q</summary>\n<!-- wp:html {\"metadata\":{\"name\":\"lib-coverage-island\"}} -->\n<p>A</p>\n<!-- /wp:html -->\n</details>\n<!-- /wp:details -->"
},
{
"id": "compose.generic-callout",
"op": "compose",
"input": "<div class=\"callout\"><h3>Note</h3><p>body</p></div>",
"expected": "<!-- wp:group {\"className\":\"wp-block-group is-style-callout\",\"layout\":{\"type\":\"constrained\"}} -->\n<div class=\"wp-block-group is-style-callout\"><h3>Note</h3><p>body</p></div>\n<!-- /wp:group -->"
"expected": "<!-- wp:group {\"className\":\"callout\",\"layout\":{\"type\":\"constrained\"}} -->\n<div class=\"wp-block-group callout\">\n<!-- wp:html {\"metadata\":{\"name\":\"lib-coverage-island\"}} -->\n<h3>Note</h3><p>body</p>\n<!-- /wp:html -->\n</div>\n<!-- /wp:group -->"
},
{
"id": "compose.generic-pullquote",
Expand All @@ -133,7 +133,7 @@
"id": "compose.generic-media-text",
"op": "compose",
"input": "<div class=\"media-text\"><figure><img src=\"https://cdn.test/a.jpg\" alt=\"A\"/></figure><div><h3>Title</h3><p>copy</p></div></div>",
"expected": "<!-- wp:media-text {\"mediaType\":\"image\"} -->\n<div class=\"wp-block-media-text is-stacked-on-mobile\"><figure class=\"wp-block-media-text__media\"><img src=\"https://cdn.test/a.jpg\" alt=\"A\"/></figure><div class=\"wp-block-media-text__content\"><h3>Title</h3><p>copy</p></div></div>\n<!-- /wp:media-text -->"
"expected": "<!-- wp:media-text {\"mediaType\":\"image\"} -->\n<div class=\"wp-block-media-text is-stacked-on-mobile\"><figure class=\"wp-block-media-text__media\"><img src=\"https://cdn.test/a.jpg\" alt=\"A\"/></figure><div class=\"wp-block-media-text__content\"><!-- wp:html {\"metadata\":{\"name\":\"lib-coverage-island\"}} -->\n<h3>Title</h3><p>copy</p>\n<!-- /wp:html --></div></div>\n<!-- /wp:media-text -->"
},
{
"id": "compose.sample-youtube-iframe",
Expand All @@ -145,7 +145,7 @@
"id": "compose.sample-callout-div",
"op": "compose",
"input": "<div class=\"notice callout\"><h3>Shipping pause</h3><p>Orders resume Monday.</p></div>",
"expected": "<!-- wp:group {\"className\":\"wp-block-group is-style-notice\",\"layout\":{\"type\":\"constrained\"}} -->\n<div class=\"wp-block-group is-style-notice\"><h3>Shipping pause</h3><p>Orders resume Monday.</p></div>\n<!-- /wp:group -->"
"expected": "<!-- wp:group {\"className\":\"notice callout\",\"layout\":{\"type\":\"constrained\"}} -->\n<div class=\"wp-block-group notice callout\">\n<!-- wp:html {\"metadata\":{\"name\":\"lib-coverage-island\"}} -->\n<h3>Shipping pause</h3><p>Orders resume Monday.</p>\n<!-- /wp:html -->\n</div>\n<!-- /wp:group -->"
},
{
"id": "compose.sample-semantic-section-fallback",
Expand Down
108 changes: 108 additions & 0 deletions packages/blocks-engine/src/__tests__/content-preservation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { afterAll, describe, expect, it } from 'vitest';

import cases from '../__fixtures__/cases.json' with { type: 'json' };
import { convertReport } from '../index.js';
import { createWorker } from '../pool/pool.js';
import { canonicalize } from '../wp/canonicalize';

type Fixture = { id: string; op: string; input: string };

// What a reader of the page sees: its words, and the media it loads (a
// source may become an embed URL, so any occurrence of it counts).
function words(html: string): string[] {
return html
.replace(/<!--[\s\S]*?-->/gu, ' ')
.replace(/<[^>]+>/gu, ' ')
.split(/\s+/u)
.filter(Boolean);
}

function missing(input: string, output: string): string[] {
const left = words(output);
const lostWords = words(input).filter((word) => {
const at = left.indexOf(word);
if (at === -1) return true;
left.splice(at, 1);
return false;
});
const lostSources = Array.from(input.matchAll(/\ssrc="([^"]+)"/gu), (match) => match[1]).filter(
(source) => !output.includes(source),
);
return [...lostWords, ...lostSources];
}

const pool = createWorker({ size: 1 });
afterAll(() => pool.stop());

const card =
'<div class="paw-card"><img src="https://example.com/a.png" alt="A"><h3>Coco Rope Tug</h3><p>Braided cotton rope.</p></div>';

const inputs: Array<[string, string]> = [
['a card', card],
['a card with a price and a link', '<div class="paw-card"><h3>Tug</h3><p class="price">$12</p><a class="btn" href="/x">Add</a></div>'],
['cards in a grid', `<div class="card-grid">${card}${card}</div>`],
['a callout', '<div class="callout"><p>Heads up.</p></div>'],
['an accordion item', '<div class="faq-item"><h3>Shipping?</h3><div><p>Two days.</p></div></div>'],
...(cases as Fixture[])
.filter((fixture) => fixture.op === 'compose')
.map((fixture): [string, string] => [fixture.id, fixture.input]),
];

describe('conversion keeps what the page shows', () => {
it.each(inputs)('%s', async (_name, html) => {
const report = await convertReport(html, { url: 'https://x.test/' }, { pool });

expect(missing(html, report.blockMarkup)).toEqual([]);
// Converted blocks hold their content themselves; none rely on the
// canonicalize guard keeping an invalid block.
expect(report.diagnostics.map((finding) => finding.code)).not.toContain('invalid_block_preserved');
});

it('keeps a card class the site styles and invents no block style', async () => {
const report = await convertReport(card, { url: 'https://x.test/' }, { pool });

expect(report.blockMarkup).toContain('paw-card');
expect(report.blockMarkup).not.toContain('is-style-');
expect(report.status).toBe('success_with_warnings');
});
});

describe('canonicalize keeps an invalid block whose re-save drops content', () => {
const lossy: Array<[string, string, string[]]> = [
[
'a group holding raw HTML',
'<!-- wp:group -->\n<div class="wp-block-group"><p>Kept words</p><img src="https://example.com/k.png" alt=""></div>\n<!-- /wp:group -->',
['core/group'],
],
[
'the same group inside columns',
'<!-- wp:columns -->\n<div class="wp-block-columns"><!-- wp:column -->\n<div class="wp-block-column"><!-- wp:group -->\n<div class="wp-block-group"><p>Kept words</p></div>\n<!-- /wp:group --></div>\n<!-- /wp:column --></div>\n<!-- /wp:columns -->',
['core/group'],
],
];

it.each(lossy)('%s', (_name, markup, preserved) => {
const result = canonicalize(markup);

expect(missing(markup, result.html)).toEqual([]);
expect(result.preservedBlocks).toEqual(preserved);
});

it('still fixes an invalid block whose re-save keeps its content', () => {
const markup = '<!-- wp:paragraph {"className":"lead"} -->\n<p>Hello there</p>\n<!-- /wp:paragraph -->';
const result = canonicalize(markup);

expect(result.html).toContain('<p class="lead">Hello there</p>');
expect(result.preservedBlocks).toEqual([]);
});

it('reports a kept block as a warning', async () => {
const markup = lossy[0][1];
const report = await convertReport(markup, { url: '' }, { pool });

expect(report.status).toBe('success_with_warnings');
expect(report.diagnostics).toContainEqual(
expect.objectContaining({ code: 'invalid_block_preserved', severity: 'warning', selector: 'core/group' }),
);
});
});
1 change: 1 addition & 0 deletions packages/blocks-engine/src/__tests__/pool.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe('worker pool contract', () => {
}[];
htmlIslandDistinctCount?: number;
htmlIslandOccurrencesTruncated?: boolean;
preservedBlocks?: string[];
degraded: boolean;
}>();

Expand Down
15 changes: 8 additions & 7 deletions packages/blocks-engine/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,11 @@ function recurseInner(
ctx: ConversionContext,
htmlFallback: HtmlFallbackEmitter,
): string {
const nested = genericHtmlToBlocks(html, ctx, htmlFallback);
if (nested && nested.trim()) return `\n${nested}\n`;
const clean = sanitize(html).trim();
return clean ? clean : '';
if (!sanitize(html).trim()) return '';
// Inner content is always block markup: a container block saves only its
// inner blocks, so raw HTML inside it is discarded on the editor's re-save.
const nested = genericHtmlToBlocks(html, ctx, htmlFallback) ?? htmlFallback(html);
return `\n${nested}\n`;
}

const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
Expand Down Expand Up @@ -189,11 +190,11 @@ function tryCallout(
if (!CALLOUT_RE.test(cls)) return null;
const inner = recurseInner($el.html() ?? '', ctx, htmlFallback);
if (!inner.trim()) return null;
const matchedClass = (cls.match(CALLOUT_RE) || [])[0] ?? 'callout';
const className = `wp-block-group is-style-${matchedClass}`;
// The authored classes are what the site's CSS targets; keep them as is.
const className = cls.split(/\s+/).filter((name) => name && name !== 'wp-block-group').join(' ');
return (
`<!-- wp:group {"className":${JSON.stringify(className)},"layout":{"type":"constrained"}} -->\n` +
`<div class="${escapeAttr(className)}">${inner}</div>\n` +
`<div class="${escapeAttr(`wp-block-group ${className}`)}">${inner}</div>\n` +
`<!-- /wp:group -->`
);
}
Expand Down
1 change: 1 addition & 0 deletions packages/blocks-engine/src/pool/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface FixResult {
htmlIslandOccurrences?: HtmlIslandOccurrence[];
htmlIslandDistinctCount?: number;
htmlIslandOccurrencesTruncated?: boolean;
preservedBlocks?: string[];
degraded: boolean;
}

Expand Down
9 changes: 9 additions & 0 deletions packages/blocks-engine/src/report/findings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,15 @@ export function buildReport({
message: issue,
}));

for (const name of fixResult.preservedBlocks ?? []) {
diagnostics.push({
code: 'invalid_block_preserved',
severity: 'warning',
message: `${name} kept as written: the editor's re-save would drop content it shows.`,
selector: name,
});
}

if (fixResult.degraded) {
diagnostics.push({
code: 'conversion_degraded',
Expand Down
1 change: 1 addition & 0 deletions packages/blocks-engine/src/report/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const CONVERSION_FINDING_CODES = [
'conversion_degraded',
'fallback_inventory_truncated',
'content_dropped',
'invalid_block_preserved',
'hero_image_layering_risk',
'body_text_promoted_to_heading',
'heading_inside_list_item',
Expand Down
64 changes: 50 additions & 14 deletions packages/blocks-engine/src/wp/canonicalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export type CanonicalizeResult = {
htmlIslandOccurrences: HtmlIslandOccurrence[];
htmlIslandDistinctCount: number;
htmlIslandOccurrencesTruncated: boolean;
/** Invalid blocks kept as written because their re-save drops content. */
preservedBlocks: string[];
degraded: boolean;
};

Expand Down Expand Up @@ -271,12 +273,48 @@ function commentAttrsAltered(
// attributes (content etc.) never appear in comment attrs, so author intent
// can't clobber them; createBlock() drops keys unknown to the current schema.
//
// The words and media sources a reader sees in serialized markup, counted.
function visibleContent(markup: string): Map<string, number> {
const counts = new Map<string, number>();
const sources = Array.from(markup.matchAll(/\ssrc="([^"]+)"/gu), (match) => `src:${match[1]}`);
const words = markup
.replace(/<!--[\s\S]*?-->/gu, ' ')
.replace(/<[^>]+>/gu, ' ')
.split(/\s+/u)
.filter(Boolean);
for (const item of [...sources, ...words]) counts.set(item, (counts.get(item) ?? 0) + 1);
return counts;
}

function dropsContent(before: string, after: string): boolean {
const kept = visibleContent(after);
for (const [item, count] of visibleContent(before)) {
if ((kept.get(item) ?? 0) < count) return true;
}
return false;
}

type BlockFixer = {
getBlockAttributes: WordpressBlocks['getBlockAttributes'];
createBlock: WordpressBlocks['createBlock'];
serialize: WordpressBlocks['serialize'];
preserved: string[];
};

//
// An invalid block's re-save is rebuilt from its attributes and inner
// blocks, so anything else its markup held is lost (a container's raw inner
// HTML, for one). When that re-save would drop words or media the block
// showed, the block is kept as written: serialize() emits an invalid block's
// original source, which is what the editor itself does until a person
// chooses to recover it.
//
function fixBlockRecursively(
block: ParsedBlock,
rawBlock: RawBlock | undefined,
getBlockAttributes: WordpressBlocks['getBlockAttributes'],
createBlock: WordpressBlocks['createBlock'],
fixer: BlockFixer,
): { block: ParsedBlock; wasFixed: boolean } {
const { getBlockAttributes, createBlock, serialize } = fixer;
const fixedInnerBlocks = [];

if (block.innerBlocks && block.innerBlocks.length > 0) {
Expand All @@ -295,12 +333,7 @@ function fixBlockRecursively(
}
const rawInnerBlock =
rawIndex < rawInner.length ? rawInner[rawIndex++] : undefined;
const result = fixBlockRecursively(
innerBlock,
rawInnerBlock,
getBlockAttributes,
createBlock,
);
const result = fixBlockRecursively(innerBlock, rawInnerBlock, fixer);
fixedInnerBlocks.push(result.block);
}
}
Expand Down Expand Up @@ -328,6 +361,11 @@ function fixBlockRecursively(
fixedInnerBlocks.length > 0 ? fixedInnerBlocks : undefined,
);

if (block.isValid === false && dropsContent(serialize([block]), serialize([fixedBlock]))) {
fixer.preserved.push(block.name);
return { block, wasFixed: false };
}

return { block: fixedBlock, wasFixed: true };
}

Expand Down Expand Up @@ -464,6 +502,7 @@ export function canonicalize(markup: string): CanonicalizeResult {
const fixedIssues: string[] = [];
collectIssues(blocks, fixedIssues);

const fixer: BlockFixer = { getBlockAttributes, createBlock, serialize, preserved: [] };
let rawIndex = 0;
const fixedBlocks = blocks.map((block) => {
while (
Expand All @@ -474,12 +513,7 @@ export function canonicalize(markup: string): CanonicalizeResult {
}
const rawBlock =
rawIndex < rawBlocks.length ? rawBlocks[rawIndex++] : undefined;
return fixBlockRecursively(
block,
rawBlock,
getBlockAttributes,
createBlock,
).block;
return fixBlockRecursively(block, rawBlock, fixer).block;
});

let fixedHtml = serialize(fixedBlocks);
Expand Down Expand Up @@ -512,6 +546,7 @@ export function canonicalize(markup: string): CanonicalizeResult {
changed: wasChanged,
fixedIssues,
...inventory,
preservedBlocks: fixer.preserved,
degraded: false,
};
} catch (error) {
Expand All @@ -527,6 +562,7 @@ export function canonicalize(markup: string): CanonicalizeResult {
htmlIslandOccurrences: [],
htmlIslandDistinctCount: 0,
htmlIslandOccurrencesTruncated: false,
preservedBlocks: [],
degraded: true,
};
}
Expand Down
Loading