fix(git-sync): preserve subpages.recursive and details.open on round trip

Found proactively by deepening the round-trip test from node-TYPE survival to
ATTRIBUTE fidelity (distinctive attr values per node). Two real losses (the
other 3 candidates — mathInline/mathBlock/pageEmbed — were verified to be
correct; the probe had used wrong attr names):

- subpages `recursive`: the converter emitted a bare div and the schema mirror
  didn't model the attr, so a recursive subpages reverted to non-recursive on a
  round trip. Now emits `data-recursive="true"` and the mirror parses it back
  (matching @docmost/editor-ext).
- details `open`: the `open` (collapsed/expanded) state lives on the details
  node, but the converter emitted the `<details>` wrapper from the summary case
  without it, so the state was dropped. The wrapper now carries `open`.

The round-trip test now also asserts attribute fidelity (12 cases) so these are
locked. Schema-surface snapshot updated for the new subpages attr.

git-sync vitest 671 (+1 expected-fail), §13.1 gate 27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-26 03:52:59 +03:00
parent 87343f241a
commit f4651f554d
4 changed files with 59 additions and 9 deletions

View File

@@ -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<string, any>) =>
attrs.recursive ? { "data-recursive": "true" } : {},
},
};
},
parseHTML() {
return [{ tag: 'div[data-type="subpages"]' }];
},

View File

@@ -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 <details> 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 `<details${open}>\n${nodeContent.map(processNode).join("")}</details>`;
}
case "detailsSummary":
const summaryText = nodeContent.map(processNode).join("");
return `<details>\n<summary>${summaryText}</summary>\n`;
return `<summary>${nodeContent.map(processNode).join("")}</summary>\n\n`;
case "detailsContent":
const detailsText = nodeContent.map(processNode).join("\n");
return `${detailsText}\n</details>`;
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 `<div data-type="pageBreak"></div>`;
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 `<div data-type="subpages"></div>`;
// `data-recursive` carries the recursive toggle so it round-trips too.
const recursive = node.attrs?.recursive ? ` data-recursive="true"` : "";
return `<div data-type="subpages"${recursive}></div>`;
}
case "status": {
// Inline status pill. The schema reads the label from the element's

View File

@@ -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);
}
});
}
});

View File

@@ -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: [] },