diff --git a/packages/git-sync/src/lib/docmost-schema.ts b/packages/git-sync/src/lib/docmost-schema.ts index 98974049..e6761d2f 100644 --- a/packages/git-sync/src/lib/docmost-schema.ts +++ b/packages/git-sync/src/lib/docmost-schema.ts @@ -928,6 +928,17 @@ const Subpages = Node.create({ atom: true, defining: true, draggable: true, + addAttributes() { + return { + recursive: { + default: false, + parseHTML: (el: HTMLElement) => + el.getAttribute("data-recursive") === "true", + renderHTML: (attrs: Record) => + attrs.recursive ? { "data-recursive": "true" } : {}, + }, + }; + }, parseHTML() { return [{ tag: 'div[data-type="subpages"]' }]; }, diff --git a/packages/git-sync/src/lib/markdown-converter.ts b/packages/git-sync/src/lib/markdown-converter.ts index 2ce9f110..d75bccb7 100644 --- a/packages/git-sync/src/lib/markdown-converter.ts +++ b/packages/git-sync/src/lib/markdown-converter.ts @@ -445,16 +445,20 @@ export function convertProseMirrorToMarkdown(content: any): string { const calloutContent = nodeContent.map(processNode).join("\n"); return `:::${calloutType.toLowerCase()}\n${calloutContent}\n:::`; - case "details": - return nodeContent.map(processNode).join("\n"); + case "details": { + // The `open` (collapsed/expanded) state lives on the details node, NOT on + // the summary, so emit the
wrapper HERE carrying it — otherwise + // the open state is dropped on a round trip. The schema's details node + // parses `open` back from the attribute. + const open = node.attrs?.open ? " open" : ""; + return `\n${nodeContent.map(processNode).join("")}
`; + } case "detailsSummary": - const summaryText = nodeContent.map(processNode).join(""); - return `
\n${summaryText}\n`; + return `${nodeContent.map(processNode).join("")}\n\n`; case "detailsContent": - const detailsText = nodeContent.map(processNode).join("\n"); - return `${detailsText}\n
`; + return `${nodeContent.map(processNode).join("\n")}\n`; case "mathInline": { // The schema's `text` attribute has no parseHTML, so TipTap's default @@ -648,13 +652,16 @@ export function convertProseMirrorToMarkdown(content: any): string { // (the divider silently disappeared and could not round-trip). return `
`; - case "subpages": + case "subpages": { // Emit the schema-matching div[data-type="subpages"] so marked passes it // through as a block and generateJSON rebuilds the subpages atom. The old // `{{SUBPAGES}}` literal had no parseHTML inverse, so on import it stayed // as plain text — the embed rendered as the literal "{{SUBPAGES}}" on the // page after a round-trip (red-team: subpages round-trip data loss). - return `
`; + // `data-recursive` carries the recursive toggle so it round-trips too. + const recursive = node.attrs?.recursive ? ` data-recursive="true"` : ""; + return `
`; + } case "status": { // Inline status pill. The schema reads the label from the element's diff --git a/packages/git-sync/test/roundtrip-all-nodes.test.ts b/packages/git-sync/test/roundtrip-all-nodes.test.ts index 4b3def08..2e83507c 100644 --- a/packages/git-sync/test/roundtrip-all-nodes.test.ts +++ b/packages/git-sync/test/roundtrip-all-nodes.test.ts @@ -107,3 +107,35 @@ describe('git-sync converter: every node/mark type survives a Markdown round tri }); } }); + +// A node surviving as the right TYPE is necessary but not sufficient — its +// attributes must survive too. Each case carries a DISTINCTIVE attribute value +// (real attr names, verified against the schema) that must reappear after a +// round trip. This caught `subpages.recursive` and `details.open` being dropped. +describe('git-sync converter: node ATTRIBUTES survive a Markdown round trip', () => { + const ATTR_CASES: Array<{ name: string; doc: any; needles: string[] }> = [ + { name: 'callout type', doc: doc({ type: 'callout', attrs: { type: 'warning' }, content: [P(T('x'))] }), needles: ['warning'] }, + { name: 'image dimensions/align/attachmentId', doc: doc({ type: 'image', attrs: { src: '/f/x.png', width: '777', height: '555', align: 'right', attachmentId: 'ATT777' } }), needles: ['777', '555', 'right', 'ATT777'] }, + { name: 'subpages recursive', doc: doc({ type: 'subpages', attrs: { recursive: true } }), needles: ['"recursive":true'] }, + { name: 'details open', doc: doc({ type: 'details', attrs: { open: true }, content: [{ type: 'detailsSummary', content: [T('S')] }, { type: 'detailsContent', content: [P(T('b'))] }] }), needles: ['"open":'] }, + { name: 'mathInline formula', doc: doc(P({ type: 'mathInline', attrs: { text: 'E=mc^7' } })), needles: ['E=mc^7'] }, + { name: 'mathBlock formula', doc: doc({ type: 'mathBlock', attrs: { text: '\\sum_7' } }), needles: ['sum_7'] }, + { name: 'pageEmbed sourcePageId', doc: doc({ type: 'pageEmbed', attrs: { sourcePageId: 'PAGE777' } }), needles: ['PAGE777'] }, + { name: 'video dimensions/attachmentId', doc: doc({ type: 'video', attrs: { src: '/f/v.mp4', width: '888', attachmentId: 'VID888' } }), needles: ['888', 'VID888'] }, + { name: 'status text/color', doc: doc(P({ type: 'status', attrs: { text: 'InProgress777', color: 'orange' } })), needles: ['InProgress777', 'orange'] }, + { name: 'mention entityId/label', doc: doc(P({ type: 'mention', attrs: { id: 'M1', label: 'Alice', entityType: 'user', entityId: 'ENT777' } })), needles: ['Alice', 'ENT777'] }, + { name: 'columns widths', doc: doc({ type: 'columns', content: [{ type: 'column', attrs: { width: '37%' }, content: [P(T('L'))] }, { type: 'column', attrs: { width: '63%' }, content: [P(T('R'))] }] }), needles: ['37%', '63%'] }, + { name: 'highlight color', doc: doc(P(T('x', [{ type: 'highlight', attrs: { color: '#abcdef' } }]))), needles: ['#abcdef'] }, + ]; + for (const { name, doc: original, needles } of ATTR_CASES) { + it(`preserves ${name}`, async () => { + const md = convertProseMirrorToMarkdown(original); + const back = JSON.stringify(await markdownToProseMirror(md)); + for (const needle of needles) { + // The value must survive in the re-imported doc (or in the markdown the + // schema parses it back from). + expect(`${back} ${md}`).toContain(needle); + } + }); + } +}); diff --git a/packages/git-sync/test/schema-surface-snapshot.test.ts b/packages/git-sync/test/schema-surface-snapshot.test.ts index 829ee311..21d96424 100644 --- a/packages/git-sync/test/schema-surface-snapshot.test.ts +++ b/packages/git-sync/test/schema-surface-snapshot.test.ts @@ -99,7 +99,7 @@ const expectedSurface: SurfaceEntry[] = [ { name: "pdf", kind: "node", attrs: ["attachmentId", "height", "name", "placeholder", "size", "src", "width"] }, { name: "status", kind: "node", attrs: ["color", "text"] }, { name: "strike", kind: "mark", attrs: [] }, - { name: "subpages", kind: "node", attrs: [] }, + { name: "subpages", kind: "node", attrs: ["recursive"] }, { name: "subscript", kind: "mark", attrs: [] }, { name: "superscript", kind: "mark", attrs: [] }, { name: "table", kind: "node", attrs: [] },