fix(git-sync): complete A1 heading alignment — green suite + nested path (on 5d45f5a8)

QA follow-up on 5d45f5a8: that commit taught the converter to export heading
textAlign (<hN style>) but left the converter-gate heading test still asserting
the OLD dropped behavior (expects a bare '## text'), so jest was RED — the G1
green-suite gate was not actually met. Two gaps closed:

1. Flip the heading KNOWN-DIVERGENCE gate test to assert the round trip now
   PRESERVES alignment (exported as <h2 style="text-align:center"> and recovered
   on import), matching the shipped converter behavior. Suite is green again.

2. blockToHtml (the nested-container path: heading/paragraph inside a
   column/table/callout) still emitted bare <hN>/<p>, dropping textAlign for
   nested blocks. Carry the style there too, symmetric with the processNode path.

Also add #7 (table inside a column) and #8 (multi-block table cell) to the
lossless round-trip CORPUS so both survive export->import through the real
editor-ext schema (columns widthMode pre-authored at its normalize fixpoint).

Verified: server jest 193 suites / 2142 tests green, git-sync vitest 704 green,
no type errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 00:25:52 +03:00
parent 5d45f5a85e
commit 67dca8c10e
2 changed files with 94 additions and 21 deletions
@@ -248,6 +248,67 @@ const CORPUS: Record<string, any> = {
],
}),
// #8 — a table with a MULTI-BLOCK cell (two paragraphs). A GFM pipe table
// cannot hold two blocks without flattening them; the converter emits a
// lossless HTML <table> instead, and the two blocks must survive the round trip.
'table (multi-block cell, #8)': doc({
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableHeader',
attrs: { colspan: 1, rowspan: 1, colwidth: null },
content: [para(text('H'))],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: { colspan: 1, rowspan: 1, colwidth: null },
content: [para(text('first')), para(text('second'))],
},
],
},
],
}),
// #7 — a table nested inside a column. Columns render as HTML containers, and a
// table inside one must stay an HTML <table> (a GFM pipe table cannot live
// inside an HTML block), round-tripping without being unwrapped or lost.
// `widthMode` is pre-authored at its materialized `normal` default (SPEC §11).
'table inside a column (#7)': doc({
type: 'columns',
attrs: { layout: 'two', widthMode: 'normal' },
content: [
{
type: 'column',
content: [
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableHeader',
attrs: { colspan: 1, rowspan: 1, colwidth: null },
content: [para(text('C7'))],
},
],
},
],
},
],
},
{ type: 'column', content: [para(text('right'))] },
],
}),
// --- editor-ext nodes/marks beyond the original corpus (item #7) ----------
// Each of these was verified to round-trip CLEANLY through the real gate
// (export -> markdown -> import -> editor-ext Yjs write path). Fixtures are
@@ -457,21 +518,14 @@ describe('git-sync converter §13.1 image dimensions preserved (was KNOWN DIVERG
});
// ---------------------------------------------------------------------------
// KNOWN DIVERGENCE — HEADING text alignment (item #7; isolated, not silently
// dropped). PARAGRAPH alignment now round-trips (exported as
// `<p style="text-align:...">`, re-parsed by the paragraph parseHTML) and lives
// in the green CORPUS above; only HEADING alignment still diverges:
//
// • A heading's `textAlign` is NOT exported at all — a heading emits plain
// markdown `## text` with no alignment syntax — so any non-default heading
// alignment is dropped on a full round trip.
//
// If the converter is ever taught to export + re-parse heading alignment, this
// assertion flips and an aligned-heading fixture should be promoted into the
// green CORPUS above.
// HEADING text alignment — now round-trips (item A1; formerly a KNOWN DIVERGENCE).
// Symmetric with the paragraph fix: a heading's non-default `textAlign` is
// exported as a styled `<hN style="text-align:…">` (was a bare ATX `## text`
// that dropped it) and re-parsed by the heading + textAlign parseHTML on import,
// so a non-default heading alignment SURVIVES a full round trip.
// ---------------------------------------------------------------------------
describe('git-sync converter §13.1 KNOWN DIVERGENCE (heading text alignment dropped)', () => {
it('drops a heading textAlign (headings do not export alignment at all)', async () => {
describe('git-sync converter §13.1 heading text alignment round-trips', () => {
it('preserves a heading textAlign across the markdown round trip', async () => {
const alignedHeading = doc({
type: 'heading',
attrs: { level: 2, textAlign: 'center' },
@@ -480,9 +534,11 @@ describe('git-sync converter §13.1 KNOWN DIVERGENCE (heading text alignment dro
const { md, canonNormalized } = await runGate(alignedHeading);
// Export is a plain markdown heading — no alignment syntax.
expect(md.trim()).toBe('## centered heading');
expect(docsCanonicallyEqual(alignedHeading, canonNormalized)).toBe(false);
// Export is a styled <h2> (was a lossy bare `## centered heading`).
expect(md.trim()).toBe(
'<h2 style="text-align:center">centered heading</h2>',
);
expect(docsCanonicallyEqual(alignedHeading, canonNormalized)).toBe(true);
});
});
@@ -955,11 +955,28 @@ export function convertProseMirrorToMarkdown(content: any): string {
const blockToHtml = (block: any): string => {
const children = block.content || [];
switch (block.type) {
case "paragraph":
return `<p>${inlineToHtml(children)}</p>`;
case "paragraph": {
// Carry textAlign here too (symmetric with the processNode paragraph
// case): a paragraph nested inside an HTML container (column/table/
// callout) would otherwise drop its alignment on the round trip.
const pAlign = block.attrs?.textAlign;
const pStyle =
pAlign && pAlign !== "left"
? ` style="text-align:${escapeAttr(pAlign)}"`
: "";
return `<p${pStyle}>${inlineToHtml(children)}</p>`;
}
case "heading": {
const level = block.attrs?.level || 1;
return `<h${level}>${inlineToHtml(children)}</h${level}>`;
// Same for a heading nested in an HTML container: emit the alignment as
// an inline style (symmetric with the processNode heading case) so it is
// not silently dropped. Clamp the level to a valid HTML heading tag.
const level = Math.min(6, Math.max(1, block.attrs?.level || 1));
const hAlign = block.attrs?.textAlign;
const hStyle =
hAlign && hAlign !== "left"
? ` style="text-align:${escapeAttr(hAlign)}"`
: "";
return `<h${level}${hStyle}>${inlineToHtml(children)}</h${level}>`;
}
case "bulletList":
return `<ul>${children