centres a nested table as a block', () => {
const out = roundTripOn(
''
);
expect(parse(out, 'table')!.style.marginLeft).to.equal('auto');
});
// Case 3: div[style text-align].
it('C3.1. div style text-align:center centres a loose text child', () => {
const out = roundTripOn('Powered by Aquera
');
expect(parse(out, 'p')!.style.textAlign).to.equal('center');
});
it('C3.2. CSS text-align on a div does NOT centre a nested table as a block', () => {
const out = roundTripOn(
''
);
expect(parse(out, 'table')!.style.marginLeft).to.not.equal('auto');
});
// Case 4: div[align].
it('C4.1. div align="center" centres a loose text child', () => {
const out = roundTripOn('Powered by Aquera
');
expect(parse(out, 'p')!.style.textAlign).to.equal('center');
});
it('C4.2. div align="center" centres a nested table as a block', () => {
const out = roundTripOn(
''
);
expect(parse(out, 'table')!.style.marginLeft).to.equal('auto');
});
// Case 5: CSS text-align on a table.
it('C5.1. table style text-align:center centres its direct cells', () => {
const out = roundTripOn(
''
);
expect(parse(out, 'td')!.style.textAlign).to.equal('center');
});
it('C5.2. a cell with its own alignment overrides the table text-align', () => {
const out = roundTripOn(
''
);
expect(parse(out, 'td')!.style.textAlign).to.equal('left');
});
// Case 6: tr[align].
it('C6.1. tr align="center" centres its direct cells', () => {
const out = roundTripOn(
''
);
expect(parse(out, 'td')!.style.textAlign).to.equal('center');
});
it('C7.1. flag off: none of the container-level centring survives', () => {
expect(parse(roundTripOff('x
'), 'p')!.style.textAlign).to.equal('');
expect(
parse(roundTripOff('x
'), 'p')?.style.textAlign ?? ''
).to.equal('');
expect(
parse(
roundTripOff(
''
),
'td'
)!.style.textAlign
).to.equal('');
});
it('C7.2. each case is a serialize → parse → serialize fixpoint', () => {
for (const src of [
CASE1,
'x
',
'x
',
'x
',
'',
'',
]) {
const once = roundTripOn(src);
expect(roundTripOn(once), src).to.equal(once);
}
});
});
describe('fix 3: table-level font/colour (email-fidelity only)', () => {
const TABLE =
'';
it('F3.1. keeps font-family/font-size/color/line-height on the table', () => {
const table = parse(roundTripOn(TABLE), 'table')!;
expect(table.style.fontFamily).to.include('Verdana');
expect(table.style.fontSize).to.equal('16px');
expect(table.style.color).to.not.equal('');
expect(table.style.lineHeight).to.not.equal('');
});
it('F3.2. accepts an hsl() colour', () => {
const table = parse(roundTripOn(TABLE), 'table')!;
// Browsers normalize hsl() to rgb().
expect(table.style.color).to.match(/rgb\(|hsl\(/);
});
it('F3.3. rejects a colour that smuggles a var()', () => {
const out = roundTripOn(
''
);
expect(parse(out, 'table')!.style.color).to.equal('');
expect(out).to.not.include('var(');
});
it('F3.4. no per-run span carries the table font-family or colour', () => {
const out = roundTripOn(TABLE);
for (const span of parseAll(out, 'span')) {
expect(span.style.fontFamily, span.outerHTML).to.not.include('Verdana');
}
});
it('F3.5. flag off: table font-family/colour are dropped (unchanged behaviour)', () => {
const table = parse(roundTripOff(TABLE), 'table')!;
expect(table.style.fontFamily).to.equal('');
expect(table.style.color).to.equal('');
});
it('F3.6. serialize → parse → serialize is a fixpoint', () => {
const once = roundTripOn(TABLE);
expect(roundTripOn(once)).to.equal(once);
});
});
describe('fix 4a: colgroup column widths (email-fidelity only)', () => {
const THREE_COL =
'';
it('F4a.1. copies each col width onto the matching cell', () => {
const tds = parseAll(roundTripOn(THREE_COL), 'td');
expect(tds[0].style.width).to.equal('33.33%');
expect(tds[1].style.width).to.equal('33.34%');
expect(tds[2].style.width).to.equal('33.33%');
});
it('F4a.2. a cell with its own width keeps it', () => {
const out = roundTripOn(
''
);
const tds = parseAll(out, 'td');
expect(tds[0].style.width).to.equal('120px');
expect(tds[1].style.width).to.equal('33.34%');
});
it('F4a.3. a row containing a colspan>1 cell is skipped', () => {
const out = roundTripOn(
''
);
for (const td of parseAll(out, 'td')) {
expect(td.style.width).to.equal('');
}
});
it('F4a.4. an outer colgroup does not touch a nested table\'s cells', () => {
const out = roundTripOn(
''
);
const nested = parseAll(out, 'table')[1];
const nestedCell = nested.querySelector('td')!;
expect(nestedCell.style.width).to.equal('');
});
it('F4a.5. flag off: colgroup widths are not propagated', () => {
const tds = parseAll(roundTripOff(THREE_COL), 'td');
expect(tds[0].style.width).to.equal('');
});
it('F4a.6. serialize → parse → serialize is a fixpoint', () => {
const once = roundTripOn(THREE_COL);
expect(roundTripOn(once)).to.equal(once);
});
});
describe('fix 4b: margin shorthand centring (email-fidelity only)', () => {
it('F4b.1. margin:0px auto centres the table', () => {
const out = roundTripOn('');
const table = parse(out, 'table')!;
expect(table.style.marginLeft).to.equal('auto');
expect(table.style.marginRight).to.equal('auto');
});
it('F4b.2. margin:0 10px does not centre the table', () => {
const out = roundTripOn('');
const table = parse(out, 'table')!;
expect(table.style.marginLeft).to.not.equal('auto');
expect(table.style.marginRight).to.not.equal('auto');
});
it('F4b.3. flag off: margin:0px auto does not centre', () => {
const out = roundTripOff('');
expect(parse(out, 'table')!.style.marginLeft).to.not.equal('auto');
});
it('F4b.4. serialize → parse → serialize is a fixpoint', () => {
const src = '';
const once = roundTripOn(src);
expect(roundTripOn(once)).to.equal(once);
});
});
describe('ZenLeaf integration (email-fidelity on)', () => {
// A trimmed but representative copy of the CKEditor "ZenLeaf" email: an outer
// presentation table carrying Verdana/colour/line-height and margin:0px auto,
// a 100%-width hero image, centred heading/offer cells, and a 3-column
// colgroup table whose cells must inherit the column widths.
const ZENLEAF = `
|
The Magic of Green Tea
|
|
Get 20% off our premium green tea collection!
|
Boosts energy |
Antioxidants |
Immune support |
|
`;
it('Z1. the first image keeps width: 100%', () => {
const out = roundTripOn(ZENLEAF);
expect(parseAll(out, 'img')[0].style.width).to.equal('100%');
});
it('Z2. at least one cell is text-align: center', () => {
const out = roundTripOn(ZENLEAF);
const centred = parseAll(out, 'td').filter(t => t.style.textAlign === 'center');
expect(centred.length).to.be.greaterThan(0);
});
it('Z3. the outer table is centred and keeps a Verdana font-family', () => {
const out = roundTripOn(ZENLEAF);
const outer = parseAll(out, 'table')[0];
expect(outer.style.marginLeft).to.equal('auto');
expect(outer.style.marginRight).to.equal('auto');
expect(outer.style.fontFamily).to.include('Verdana');
});
it('Z4. the 3-column table cells carry 33.33%/33.34% widths', () => {
const out = roundTripOn(ZENLEAF);
const widths = parseAll(out, 'td').map(t => t.style.width);
expect(widths).to.include('33.33%');
expect(widths).to.include('33.34%');
});
it('Z5. the fidelity-on output is still smaller than the flag-off output', () => {
const on = roundTripOn(ZENLEAF);
const off = roundTripOff(ZENLEAF);
expect(on.length).to.be.lessThan(off.length);
});
it('Z6. serialize → parse → serialize is a fixpoint', () => {
const once = roundTripOn(ZENLEAF);
expect(roundTripOn(once)).to.equal(once);
});
});
// ---------------------------------------------------------------------------
// Button links → one-cell table buttons (email-fidelity only)
// (plan: button-links-plan.md — option A, 2026-10-13)
//
// Email builders make a "button" from ONE styled (display:inline-block +
// background-color/padding/border-radius/width). The editor stores links as a
// mark on text, so the box styling is dropped and background-color becomes a
// thin highlight strip. The email-fidelity pre-pass converts such a link, when
// it is the only content of its block, into:
//
// with the box styles on the cell and the text styles kept on the link.
// ---------------------------------------------------------------------------
/** The ZenLeaf "Get discount" button: one display:inline-block styled link in a centred td. */
const BTN_GET_DISCOUNT = `
`;
/** The ZenLeaf "Shop now" button (darker background, same shape). */
const BTN_SHOP_NOW = `
`;
/** Find the that was produced for a button link: the innermost td holding the link. */
function buttonCell(html: string): HTMLElement {
const link = parse(html, 'a')!;
return link.closest('td') as HTMLElement;
}
describe('button links: ZenLeaf "Get discount" (email-fidelity on)', () => {
it('BL1. produces a role=presentation table around the link', () => {
const out = roundTripOn(BTN_GET_DISCOUNT);
const link = parse(out, 'a')!;
const table = link.closest('table');
expect(table, 'link sits inside a table').to.exist;
expect(table!.getAttribute('role')).to.equal('presentation');
});
it('BL2. the button cell carries the link background-color', () => {
const out = roundTripOn(BTN_GET_DISCOUNT);
expect(buttonCell(out).style.backgroundColor).to.equal('rgb(64, 107, 30)');
});
it('BL3. the button cell carries the padding (any per-side equivalent of 12px)', () => {
const out = roundTripOn(BTN_GET_DISCOUNT);
const td = buttonCell(out);
const uniform = td.style.padding === '12px';
const perSide =
td.style.paddingTop === '12px' &&
td.style.paddingRight === '12px' &&
td.style.paddingBottom === '12px' &&
td.style.paddingLeft === '12px';
expect(uniform || perSide, `padding was "${td.style.cssText}"`).to.equal(true);
});
it('BL4. the button cell carries border-radius and width', () => {
const td = buttonCell(roundTripOn(BTN_GET_DISCOUNT));
expect(td.style.borderRadius).to.equal('8px');
expect(td.style.width).to.equal('260px');
});
it('BL5. the button cell is text-align: center', () => {
expect(buttonCell(roundTripOn(BTN_GET_DISCOUNT)).style.textAlign).to.equal('center');
});
it('BL6. the table is centred (auto side margins) from the link margin:0 auto', () => {
const out = roundTripOn(BTN_GET_DISCOUNT);
const table = parse(out, 'a')!.closest('table')!;
const centredByMargin =
table.style.marginLeft === 'auto' && table.style.marginRight === 'auto';
const centredByAttr = table.getAttribute('align') === 'center';
expect(centredByMargin || centredByAttr, `table was "${table.outerHTML}"`).to.equal(true);
});
it('BL7. the link keeps its href and text', () => {
const out = roundTripOn(BTN_GET_DISCOUNT);
const link = parse(out, 'a')!;
expect(link.getAttribute('href')).to.equal('#');
expect(link.textContent).to.equal('Get discount');
});
it('BL8. no span wraps the link text with a background-color highlight', () => {
const out = roundTripOn(BTN_GET_DISCOUNT);
for (const span of parseAll(out, 'span')) {
expect(span.style.backgroundColor, span.outerHTML).to.equal('');
}
});
it('BL9. the link keeps white colour, 24px, bold as text styling', () => {
const out = roundTripOn(BTN_GET_DISCOUNT);
const link = parse(out, 'a')!;
// white / 24px / bold may land on the link itself or a mark span inside it.
expect(link.innerHTML, 'white').to.match(/rgb\(255, 255, 255\)|#ffffff|#fff\b/i);
expect(link.innerHTML, '24px').to.include('24px');
expect(link.innerHTML, 'bold').to.match(/font-weight:\s*(?:700|bold)| {
const link = parse(roundTripOn(BTN_GET_DISCOUNT), 'a')!;
expect(link.style.display, 'display').to.equal('');
expect(link.style.backgroundColor, 'background').to.equal('');
expect(link.style.padding, 'padding').to.equal('');
expect(link.style.paddingTop, 'padding-top').to.equal('');
expect(link.style.width, 'width').to.equal('');
expect(link.style.borderRadius, 'border-radius').to.equal('');
});
});
describe('button links: ZenLeaf "Shop now" (email-fidelity on)', () => {
it('BL11. the button cell carries the darker background-color', () => {
expect(buttonCell(roundTripOn(BTN_SHOP_NOW)).style.backgroundColor).to.equal('rgb(20, 21, 23)');
});
it('BL12. the button cell carries border-radius and width', () => {
const td = buttonCell(roundTripOn(BTN_SHOP_NOW));
expect(td.style.borderRadius).to.equal('6px');
expect(td.style.width).to.equal('200px');
});
it('BL13. the button cell keeps the 14px/28px padding per side', () => {
const td = buttonCell(roundTripOn(BTN_SHOP_NOW));
const uniform = td.style.padding === '14px 28px';
const perSide =
td.style.paddingTop === '14px' &&
td.style.paddingRight === '28px' &&
td.style.paddingBottom === '14px' &&
td.style.paddingLeft === '28px';
expect(uniform || perSide, `padding was "${td.style.cssText}"`).to.equal(true);
});
it('BL14. no span highlights the link text', () => {
for (const span of parseAll(roundTripOn(BTN_SHOP_NOW), 'span')) {
expect(span.style.backgroundColor, span.outerHTML).to.equal('');
}
});
});
describe('button links: not-a-button cases (email-fidelity on)', () => {
it('BL15. a plain coloured link in a paragraph is left inline (no table)', () => {
const out = roundTripOn(' link ');
expect(parse(out, 'p a'), 'link stays in the paragraph').to.exist;
expect(parse(out, 'p table'), 'no table inside the paragraph').to.not.exist;
});
it('BL16. display:inline-block with no bg/padding is not converted', () => {
const out = roundTripOn(
'link '
);
expect(parse(out, 'p table'), 'no table').to.not.exist;
});
it('BL17. a button-styled link sharing a paragraph with other text stays inline', () => {
const out = roundTripOn(
'before btn after '
);
expect(parse(out, 'p table'), 'no table; link is not the only content').to.not.exist;
expect(parse(out, 'p')!.textContent).to.include('before');
expect(parse(out, 'p')!.textContent).to.include('after');
});
});
describe('button links: block replacement (email-fidelity on)', () => {
it('BL18. a whose only content is a button link is replaced by the table', () => {
const out = roundTripOn(
' Get discount '
);
const link = parse(out, 'a')!;
const table = link.closest('table');
expect(table, 'link now inside a table').to.exist;
// The original is replaced by the table (the cell's own paragraph is just
// ProseMirror's block+ cell content model).
expect(table!.closest('p'), 'table not nested in the old paragraph').to.not.exist;
expect(out.trim().startsWith(' {
// First-email idiom: a navy cell wrapping an inline-block padded link.
const out = roundTripOn(
''
);
const link = parse(out, 'a')!;
const inner = link.closest('td')!;
// The link's immediate cell now carries the padding...
const uniform = inner.style.padding === '13px 28px';
const perSide =
inner.style.paddingTop === '13px' &&
inner.style.paddingRight === '28px' &&
inner.style.paddingBottom === '13px' &&
inner.style.paddingLeft === '28px';
expect(uniform || perSide, `inner padding was "${inner.style.cssText}"`).to.equal(true);
// ...and the outer navy cell background survives somewhere above it.
const navy = parseAll(out, 'td').some(
t => t.style.backgroundColor === 'rgb(31, 58, 95)'
);
expect(navy, 'navy background retained').to.equal(true);
});
});
describe('button links: flag off (unchanged 2.0.16 behaviour)', () => {
it('BL20. flag off: the button link is NOT turned into a table', () => {
const out = roundTripOff(BTN_GET_DISCOUNT);
// The only table is the original wrapper; the link is not wrapped in a new one.
const link = parse(out, 'a')!;
const cell = link.closest('td')!;
expect(cell.querySelector('table'), 'no nested button table').to.not.exist;
});
it('BL21. flag off: no cell carries the button background-color', () => {
const out = roundTripOff(BTN_GET_DISCOUNT);
const anyBg = parseAll(out, 'td').some(
t => t.style.backgroundColor === 'rgb(64, 107, 30)'
);
expect(anyBg).to.equal(false);
});
});
describe('button links: security (email-fidelity on)', () => {
it('BL22. a hostile padding:url(x) never reaches the button cell', () => {
const out = roundTripOn(
'btn '
);
expect(out).to.not.include('url(');
const bg = parseAll(out, 'td').some(t => t.style.backgroundColor === 'rgb(64, 107, 30)');
if (bg) {
const td = buttonCell(out);
expect(td.style.padding, 'padding must be empty').to.equal('');
}
});
it('BL23. a background-color:expression(...) is dropped', () => {
const out = roundTripOn(
'btn '
);
expect(out).to.not.include('expression(');
});
it('BL24. a javascript: href on a button link is stripped', () => {
const out = roundTripOn(
'btn '
);
const link = parse(out, 'a');
if (link) {
expect((link.getAttribute('href') ?? '').toLowerCase()).to.not.include('javascript:');
}
expect(out.toLowerCase()).to.not.include('javascript:');
});
});
describe('button links: fixpoint (email-fidelity on)', () => {
it('BL25. "Get discount" is a serialize → parse → serialize fixpoint', () => {
const once = roundTripOn(BTN_GET_DISCOUNT);
expect(roundTripOn(once)).to.equal(once);
});
it('BL26. the reloaded button is not re-detected (link no longer has box styles)', () => {
const once = roundTripOn(BTN_GET_DISCOUNT);
// After one pass the link has no display/padding, so a second pass must not
// add another nested table layer.
const tablesOnce = parseAll(once, 'table').length;
const tablesTwice = parseAll(roundTripOn(once), 'table').length;
expect(tablesTwice).to.equal(tablesOnce);
});
it('BL27. "Shop now" is a serialize → parse → serialize fixpoint', () => {
const once = roundTripOn(BTN_SHOP_NOW);
expect(roundTripOn(once)).to.equal(once);
});
});
|