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 3b859e45e8
commit afaafe11e1
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