diff --git a/packages/git-sync/test/stabilize.test.ts b/packages/git-sync/test/stabilize.test.ts index 8a70e1b9..d5303e9f 100644 --- a/packages/git-sync/test/stabilize.test.ts +++ b/packages/git-sync/test/stabilize.test.ts @@ -22,16 +22,27 @@ const meta: PageMeta = { describe('stabilizePageFile — normalize-on-write fixpoint (SPEC §11)', () => { it('reaches a byte-identical fixpoint after one extra export/import/export pass', async () => { - // A diagram is the canonical one-pass asymmetry: drawio's `align` default of - // "center" materializes on import, so a NAIVE export differs on the second - // export. stabilizePageFile runs the convergence pass at write time, so the - // written body must already be at the fixpoint: re-importing its body and + // A diagram inside a column is the canonical one-pass asymmetry: on the + // raw-HTML/columns path a diagram's `align` default of "center" materializes + // on import, so a NAIVE export differs on the second export. (#293 canon #8 + // made the TOP-LEVEL diagram form — `![](src)` — byte-stable by + // omitting the default, so the asymmetry now lives only on the columns path + // where the schema `
` form is retained.) + // stabilizePageFile runs the convergence pass at write time, so the written + // body must already be at the fixpoint: re-importing its body and // re-stabilizing yields the exact same bytes. const content = { type: 'doc', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'intro' }] }, - { type: 'drawio', attrs: { src: '/d.drawio' } }, + { + type: 'columns', + attrs: { layout: 'two_equal' }, + content: [ + { type: 'column', content: [{ type: 'drawio', attrs: { src: '/d.drawio' } }] }, + { type: 'column', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'side' }] }] }, + ], + }, { type: 'paragraph', content: [{ type: 'text', text: 'outro' }] }, ], }; diff --git a/packages/prosemirror-markdown/src/lib/markdown-converter.ts b/packages/prosemirror-markdown/src/lib/markdown-converter.ts index 60260bfa..17ab21db 100644 --- a/packages/prosemirror-markdown/src/lib/markdown-converter.ts +++ b/packages/prosemirror-markdown/src/lib/markdown-converter.ts @@ -3,6 +3,17 @@ import { attachedCommentFor, standaloneCommentFor, } from "./attached-comment.js"; +import { + attachmentToHtml, + audioToHtml, + diagramToHtml, + embedToHtml, + pageEmbedToHtml, + pdfToHtml, + transclusionReferenceToHtml, + videoToHtml, + youtubeToHtml, +} from "./media-html.js"; /** * Hard cap on processNode recursion depth (see the depth guard below). @@ -62,6 +73,24 @@ export function convertProseMirrorToMarkdown(content: any): string { .replace(/\(/g, "%28") .replace(/\)/g, "%29"); + // Backslash-escape every character a markdown link's `[text]` label would + // otherwise INTERPRET, so a link-form media node's visible text (a filename or + // provider carried in `attrs.name`/`attrs.provider`) round-trips byte-exact + // (#293 canon #8 link-form). Escaping only `[ ] \` is NOT enough: the label is + // parsed as inline content, so emphasis (`* _`), code (`` ` ``), strikethrough + // (`~`), autolinks/raw-HTML (`<`), HTML entities (`&`), and image markers (`!`) + // would all be consumed and lost when the importer reads `a.textContent` back + // (e.g. `report *v2*.pdf` -> `report v2.pdf`). CommonMark treats a backslash + // before ANY ASCII punctuation as that literal char, so escaping this active + // set is always lossless on re-parse; the old `
` + // form carried arbitrary strings via escapeAttr, so anything less is a + // data-loss regression on the git-sync data path. `( )` are escaped too: even + // with `[ ]` escaped, an unescaped `](x)` sequence inside the label (e.g. a + // name like `![shot](x).pdf`) forms a false nested-link destination and + // fragments the parse — escaping the parens removes that ambiguity entirely. + const escapeLinkText = (value: unknown): string => + String(value ?? "").replace(/[\\`*_~[\]<&!()]/g, (c: string) => `\\${c}`); + // Recursion depth guard. processNode is mutually recursive (directly and via // processListItem/processTaskItem/blockToHtml), and a pathologically nested // document (e.g. tens of thousands of nested blockquotes) would otherwise @@ -410,48 +439,44 @@ export function convertProseMirrorToMarkdown(content: any): string { } case "video": { - // Emit the schema-matching