diff --git a/apps/server/src/collaboration/git-sync-converter-gate.spec.ts b/apps/server/src/collaboration/git-sync-converter-gate.spec.ts index 73a3a0b8..895ea54c 100644 --- a/apps/server/src/collaboration/git-sync-converter-gate.spec.ts +++ b/apps/server/src/collaboration/git-sync-converter-gate.spec.ts @@ -248,6 +248,67 @@ const CORPUS: Record = { ], }), + // #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 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
(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 -// `

`, 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 `` (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

(was a lossy bare `## centered heading`). + expect(md.trim()).toBe( + '

centered heading

', + ); + expect(docsCanonicallyEqual(alignedHeading, canonNormalized)).toBe(true); }); }); diff --git a/packages/git-sync/src/lib/markdown-converter.ts b/packages/git-sync/src/lib/markdown-converter.ts index c8227cbf..013a54f3 100644 --- a/packages/git-sync/src/lib/markdown-converter.ts +++ b/packages/git-sync/src/lib/markdown-converter.ts @@ -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 `

${inlineToHtml(children)}

`; + 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 `${inlineToHtml(children)}

`; + } case "heading": { - const level = block.attrs?.level || 1; - return `${inlineToHtml(children)}`; + // 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 `${inlineToHtml(children)}`; } case "bulletList": return `
    ${children