openwriter 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-BlLnLdoc.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-BxI3DazW.js"></script>
14
14
  <link rel="stylesheet" crossorigin href="/assets/index-OV13QtgQ.css">
15
15
  </head>
16
16
  <body>
@@ -7,7 +7,7 @@ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSy
7
7
  import { join } from 'path';
8
8
  import matter from 'gray-matter';
9
9
  import trash from 'trash';
10
- import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
10
+ import { tiptapToMarkdownChecked, markdownToTiptap } from './markdown.js';
11
11
  import { parseMarkdownContent } from './compact.js';
12
12
  import { getDocument, getTitle, getFilePath, getIsTemp, getMetadata, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, removePendingCacheEntry, resetDocVersion, } from './state.js';
13
13
  import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
@@ -122,7 +122,7 @@ export function listDocuments() {
122
122
  ...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
123
123
  ...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
124
124
  ...(data.variantType ? { variantType: data.variantType } : {}),
125
- ...(data.autoAccept === true ? { autoAccept: true } : {}),
125
+ ...(typeof data.autoAccept === 'boolean' ? { autoAccept: data.autoAccept } : {}),
126
126
  };
127
127
  }
128
128
  catch {
@@ -163,7 +163,7 @@ export function listDocuments() {
163
163
  ...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
164
164
  ...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
165
165
  ...(data.variantType ? { variantType: data.variantType } : {}),
166
- ...(data.autoAccept === true ? { autoAccept: true } : {}),
166
+ ...(typeof data.autoAccept === 'boolean' ? { autoAccept: data.autoAccept } : {}),
167
167
  });
168
168
  }
169
169
  catch { /* skip unreadable external files */ }
@@ -276,7 +276,7 @@ export function archiveDocument(filename) {
276
276
  const next = remaining[0];
277
277
  const raw = readFileSync(next.path, 'utf-8');
278
278
  const parsed = markdownToTiptap(raw);
279
- setActiveDocument(parsed.document, parsed.title, next.path, next.name.startsWith(TEMP_PREFIX), new Date(next.mtime), parsed.metadata);
279
+ setActiveDocument(parsed.document, parsed.title, next.path, next.name.startsWith(TEMP_PREFIX), new Date(next.mtime), parsed.metadata, undefined);
280
280
  return { switched: true, newDoc: { document: getDocument(), title: getTitle(), filename: next.name } };
281
281
  }
282
282
  }
@@ -471,7 +471,7 @@ export function createDocument(title, content, path) {
471
471
  const metadata = { title: docTitle, docId: generateNodeId() };
472
472
  setActiveDocument(newDoc, docTitle, filePath, isTemp, undefined, metadata);
473
473
  // Write doc to disk
474
- const markdown = tiptapToMarkdown(newDoc, docTitle, metadata);
474
+ const { markdown } = tiptapToMarkdownChecked(newDoc, docTitle, metadata);
475
475
  ensureDataDir();
476
476
  atomicWriteFileSync(filePath, markdown);
477
477
  // Prepend to doc order so new docs appear at top and stay put after edits
@@ -519,7 +519,7 @@ export function createDocumentFile(title, path, extraMeta) {
519
519
  }
520
520
  const newDoc = { type: 'doc', content: [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }] };
521
521
  const metadata = { title: docTitle, docId: generateNodeId(), agentCreated: true, ...extraMeta };
522
- const markdown = tiptapToMarkdown(newDoc, docTitle, metadata);
522
+ const { markdown } = tiptapToMarkdownChecked(newDoc, docTitle, metadata);
523
523
  ensureDataDir();
524
524
  atomicWriteFileSync(filePath, markdown);
525
525
  // Prepend to doc order so new docs appear at top and stay put after edits
@@ -557,7 +557,7 @@ export async function deleteDocument(filename) {
557
557
  const next = remaining[0];
558
558
  const raw = readFileSync(next.path, 'utf-8');
559
559
  const parsed = markdownToTiptap(raw);
560
- setActiveDocument(parsed.document, parsed.title, next.path, next.name.startsWith(TEMP_PREFIX), new Date(next.mtime), parsed.metadata);
560
+ setActiveDocument(parsed.document, parsed.title, next.path, next.name.startsWith(TEMP_PREFIX), new Date(next.mtime), parsed.metadata, undefined);
561
561
  return { switched: true, newDoc: { document: getDocument(), title: getTitle(), filename: next.name } };
562
562
  }
563
563
  }
@@ -574,7 +574,7 @@ export function reloadDocument() {
574
574
  const raw = readFileSync(filePath, 'utf-8');
575
575
  const parsed = markdownToTiptap(raw);
576
576
  const mtime = new Date(statSync(filePath).mtimeMs);
577
- setActiveDocument(parsed.document, parsed.title, filePath, filename.startsWith(TEMP_PREFIX), mtime, parsed.metadata);
577
+ setActiveDocument(parsed.document, parsed.title, filePath, filename.startsWith(TEMP_PREFIX), mtime, parsed.metadata, undefined);
578
578
  return { document: getDocument(), title: getTitle(), filename };
579
579
  }
580
580
  export function updateDocumentTitle(filename, newTitle) {
@@ -586,7 +586,7 @@ export function updateDocumentTitle(filename, newTitle) {
586
586
  const raw = readFileSync(filePath, 'utf-8');
587
587
  const parsed = markdownToTiptap(raw);
588
588
  const metadata = { ...parsed.metadata, title: newTitle };
589
- const markdown = tiptapToMarkdown(parsed.document, newTitle, metadata);
589
+ const { markdown } = tiptapToMarkdownChecked(parsed.document, newTitle, metadata);
590
590
  atomicWriteFileSync(filePath, markdown);
591
591
  // Update state if this is the active document
592
592
  const baseName = filePath.split(/[/\\]/).pop() || '';
@@ -654,7 +654,7 @@ export function duplicateDocument(filename) {
654
654
  }
655
655
  const metadata = { ...parsed.metadata, title: newTitle, docId: generateNodeId() };
656
656
  setActiveDocument(parsed.document, newTitle, filePath, false, undefined, metadata);
657
- const markdown = tiptapToMarkdown(parsed.document, newTitle, metadata);
657
+ const { markdown } = tiptapToMarkdownChecked(parsed.document, newTitle, metadata);
658
658
  ensureDataDir();
659
659
  atomicWriteFileSync(filePath, markdown);
660
660
  const newFilename = filePath.split(/[/\\]/).pop();
@@ -788,7 +788,7 @@ function resolveDocFile(filePath, action) {
788
788
  if (count === 0)
789
789
  return 0;
790
790
  // Re-serialize — pending attrs are cleared so pending key will be removed from frontmatter
791
- const newRaw = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
791
+ const { markdown: newRaw } = tiptapToMarkdownChecked(doc, parsed.title, parsed.metadata);
792
792
  atomicWriteFileSync(filePath, newRaw);
793
793
  return count;
794
794
  }
@@ -167,12 +167,9 @@ export async function startHttpServer(options = {}) {
167
167
  if (isActiveDoc) {
168
168
  if (enabled) {
169
169
  stripPendingAttrs(); // accept any pending changes
170
- setMetadata({ autoAccept: true });
171
- }
172
- else {
173
- const meta = getMetadata();
174
- delete meta.autoAccept;
175
170
  }
171
+ // Explicit boolean (not delete) — false overrides workspace inheritance.
172
+ setMetadata({ autoAccept: enabled });
176
173
  save();
177
174
  updatePendingCacheForActiveDoc();
178
175
  broadcastMetadataChanged(getMetadata());
@@ -1,6 +1,15 @@
1
1
  /**
2
2
  * Markdown -> TipTap JSON parsing.
3
3
  * Parses markdown (with optional YAML frontmatter) into TipTap document JSON.
4
+ *
5
+ * Node identity is reassigned via the matcher when the frontmatter carries a
6
+ * `nodes` array (the new path). For legacy docs without `nodes`, trailing
7
+ * `^id` caret anchors and `<!-- ^id -->` empty-paragraph markers are still
8
+ * recognized as ID sources so existing files keep their identities through
9
+ * the migration. Once a migrated doc is saved, the body is clean and all
10
+ * identity lives in frontmatter.
11
+ *
12
+ * adr: adr/node-identity-matcher.md
4
13
  */
5
14
  import MarkdownIt from 'markdown-it';
6
15
  import matter from 'gray-matter';
@@ -10,6 +19,8 @@ import markdownItSub from 'markdown-it-sub';
10
19
  import markdownItSup from 'markdown-it-sup';
11
20
  import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
12
21
  import { nodeText } from './markdown-serialize.js';
22
+ import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
23
+ import { matchNodes } from './node-matcher.js';
13
24
  // ============================================================================
14
25
  // Markdown -> TipTap
15
26
  // ============================================================================
@@ -19,24 +30,152 @@ md.use(markdownItIns);
19
30
  md.use(markdownItMark);
20
31
  md.use(markdownItSub);
21
32
  md.use(markdownItSup);
33
+ /**
34
+ * Normalize blank lines INSIDE markdown tables before parsing.
35
+ *
36
+ * Per CommonMark, a blank line terminates a table block. Agents writing
37
+ * markdown content frequently insert blank lines between table rows for
38
+ * readability (e.g. `| row |\n\n| row |`), which the strict parser then
39
+ * splits into "table with 1 header row" + N orphan paragraphs that happen
40
+ * to contain pipe characters. The broken structure persists across saves
41
+ * because every serialize → re-parse cycle re-breaks it.
42
+ *
43
+ * This pre-pass detects "table region" (saw a separator row `| --- |`,
44
+ * haven't hit a non-pipe-row yet) and strips blank lines between pipe-rows
45
+ * inside that region. Code fences (` ``` `, `~~~`) are honored — pipes
46
+ * inside them stay untouched.
47
+ *
48
+ * Self-healing: a doc on disk with blank-separated rows loads as a proper
49
+ * N-row table; the next save writes contiguous markdown. No migration
50
+ * script needed.
51
+ */
52
+ function normalizeTableBlankLines(markdown) {
53
+ if (!markdown.includes('|'))
54
+ return markdown;
55
+ const lines = markdown.split('\n');
56
+ const out = [];
57
+ let inFence = false;
58
+ let inTable = false; // true between a separator row and the next non-pipe-row
59
+ const isPipeRow = (s) => /^\s*\|.*\|\s*$/.test(s);
60
+ const isSeparator = (s) => /^\s*\|[\s:|-]+\|\s*$/.test(s);
61
+ const isBlank = (s) => s.trim() === '';
62
+ for (let i = 0; i < lines.length; i++) {
63
+ const line = lines[i];
64
+ // Code-fence toggle — pipes inside fences stay verbatim
65
+ if (/^[`~]{3,}/.test(line)) {
66
+ inFence = !inFence;
67
+ inTable = false;
68
+ out.push(line);
69
+ continue;
70
+ }
71
+ if (inFence) {
72
+ out.push(line);
73
+ continue;
74
+ }
75
+ if (isSeparator(line)) {
76
+ // Separator confirms we're in a table region from here on
77
+ inTable = true;
78
+ out.push(line);
79
+ continue;
80
+ }
81
+ if (inTable && isBlank(line)) {
82
+ // Look ahead past additional blanks for the next non-blank line
83
+ let j = i + 1;
84
+ while (j < lines.length && lines[j].trim() === '')
85
+ j++;
86
+ // If the next non-blank line is another pipe-row, it's EITHER a
87
+ // continuation row (merge across the blank) OR the header of a NEW
88
+ // table (the blank is the boundary, don't merge). We tell them apart
89
+ // by peeking one further: if line j+1 is a separator row, j is a new
90
+ // table's header — preserve the blank and exit the current table region.
91
+ if (j < lines.length && isPipeRow(lines[j])) {
92
+ if (j + 1 < lines.length && isSeparator(lines[j + 1])) {
93
+ // New table starting — keep the boundary blank, end current region
94
+ inTable = false;
95
+ out.push(line);
96
+ continue;
97
+ }
98
+ // Continuation row — drop the blank
99
+ continue;
100
+ }
101
+ // Lookahead found non-pipe content — table region is ending
102
+ inTable = false;
103
+ out.push(line);
104
+ continue;
105
+ }
106
+ if (inTable && !isPipeRow(line)) {
107
+ // Non-pipe content ends the table region
108
+ inTable = false;
109
+ }
110
+ out.push(line);
111
+ }
112
+ return out.join('\n');
113
+ }
22
114
  export function markdownToTiptap(markdown) {
23
115
  const result = matter(markdown);
24
116
  const { data, content } = result;
25
117
  const title = data.title || 'Untitled';
26
- const tokens = md.parse(content, {});
118
+ const normalizedContent = normalizeTableBlankLines(content);
119
+ const tokens = md.parse(normalizedContent, {});
27
120
  const docContent = tokensToTiptap(tokens);
28
121
  const doc = {
29
122
  type: 'doc',
30
123
  content: docContent.length > 0 ? docContent : [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }],
31
124
  };
125
+ // Extract identity graph from frontmatter — these become the matcher's
126
+ // previousNodes input on both the load-time pass below AND on every
127
+ // subsequent save-time pass while the doc stays loaded.
128
+ const previousNodes = normalizeNodeEntries(data.nodes);
129
+ const graveyard = normalizeNodeEntries(data.graveyard);
130
+ // Load-time matcher pass — when frontmatter carries `nodes`, reassign IDs
131
+ // based on fingerprint match. Legacy docs (no `nodes` field) keep whatever
132
+ // IDs the body parser extracted from caret anchors or minted fresh.
133
+ if (previousNodes.length > 0) {
134
+ applyMatcher(doc, previousNodes, graveyard);
135
+ }
32
136
  // Rehydrate pending state from frontmatter into node attrs
33
137
  if (data.pending) {
34
138
  rehydratePendingState(doc, data.pending);
35
139
  }
36
- // Strip pending from returned metadata (consumed into node attrs)
140
+ // Strip consumed keys from returned metadata
37
141
  const metadata = { ...data };
38
142
  delete metadata.pending;
39
- return { title, metadata, document: doc, rawFrontmatter: result.matter || null };
143
+ delete metadata.nodes;
144
+ delete metadata.graveyard;
145
+ return {
146
+ title,
147
+ metadata,
148
+ document: doc,
149
+ rawFrontmatter: result.matter || null,
150
+ graveyard,
151
+ previousNodes,
152
+ };
153
+ }
154
+ /** Defensive parse of frontmatter node entries — drops any malformed rows. */
155
+ function normalizeNodeEntries(raw) {
156
+ if (!Array.isArray(raw))
157
+ return [];
158
+ return raw
159
+ .filter((entry) => entry && typeof entry === 'object' && entry.id && entry.fp)
160
+ .map((entry) => ({ id: String(entry.id), fingerprint: entry.fp }));
161
+ }
162
+ /**
163
+ * Run the matcher: compare frontmatter `nodes` (previous fingerprints) to
164
+ * the current TipTap tree's blocks, then apply pinned IDs back onto the tree.
165
+ *
166
+ * Graveyard is passed through so paste-back of recently-deleted content
167
+ * restores the original ID (matched by exact fingerprint).
168
+ */
169
+ function applyMatcher(doc, previousNodes, graveyard) {
170
+ if (previousNodes.length === 0)
171
+ return;
172
+ const newBlocks = tiptapToBlocks(doc);
173
+ const matchResult = matchNodes(previousNodes, newBlocks, { graveyard });
174
+ const pinnedByPosition = new Map();
175
+ for (const p of matchResult.pinned) {
176
+ pinnedByPosition.set(p.position, p.id);
177
+ }
178
+ applyIdsToTiptap(doc, pinnedByPosition);
40
179
  }
41
180
  /**
42
181
  * Rehydrate pending state from frontmatter into leaf block node attrs.
@@ -453,8 +592,8 @@ function popMarkByType(stack, type) {
453
592
  /**
454
593
  * Extract a trailing nodeId anchor from inline content.
455
594
  * Format: ` ^abc12345` (space + caret + 8 lowercase hex chars at end of line).
456
- * Matches Obsidian's block-reference convention. Strips the marker from the
457
- * visible text and returns the captured id. Returns id=null if no anchor found.
595
+ * Strips the marker from the visible text and returns the captured id.
596
+ * Returns id=null if no anchor found.
458
597
  *
459
598
  * Known limit: prose ending with the literal pattern ` ^[8 lowercase hex]`
460
599
  * will be interpreted as an anchor. Vanishingly rare in real writing.
@@ -1,8 +1,24 @@
1
1
  /**
2
2
  * TipTap JSON -> Markdown serialization.
3
3
  * Converts TipTap document to markdown with YAML frontmatter.
4
+ *
5
+ * Node identity persistence:
6
+ * - Frontmatter `nodes` array carries the (id, fingerprint) pair for every
7
+ * block in the document. On load, markdown-parse.ts runs the matcher
8
+ * against this array to reassign IDs to surviving blocks.
9
+ * - Markdown body is COMPLETELY UNDISTURBED — no anchors, no comment
10
+ * sentinels (except the legacy `<!-- -->` empty-paragraph marker which
11
+ * has no semantic ID attached anymore).
12
+ * - Legacy `^id` caret anchors are no longer emitted. Old docs that still
13
+ * contain them are migrated transparently on the next save: parse reads
14
+ * the anchors, matcher pins them, serialize emits the new `nodes`
15
+ * frontmatter and a clean body.
16
+ *
17
+ * adr: adr/node-identity-matcher.md
4
18
  */
5
- import { LEAF_BLOCK_TYPES } from './helpers.js';
19
+ import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
20
+ import { tiptapToBlocks } from './node-blocks.js';
21
+ import { fingerprintAll } from './node-fingerprint.js';
6
22
  // ============================================================================
7
23
  // TipTap -> Markdown
8
24
  // ============================================================================
@@ -57,11 +73,67 @@ function collectPendingState(doc) {
57
73
  walk(doc.content || []);
58
74
  return Object.keys(pending).length > 0 ? pending : undefined;
59
75
  }
76
+ /**
77
+ * Build the `nodes` frontmatter entry — one (id, fingerprint) per block
78
+ * in pre-order traversal of the TipTap tree.
79
+ *
80
+ * Each block's ID comes from its TipTap node's `attrs.id`. Fingerprints are
81
+ * computed from the walker-style block list derived directly from the TipTap
82
+ * tree (no separate markdown re-parse — same source of truth that builds
83
+ * the visible doc).
84
+ */
85
+ function collectNodesFrontmatter(doc) {
86
+ const blocks = tiptapToBlocks(doc);
87
+ const fingerprints = fingerprintAll(blocks);
88
+ const ids = collectBlockIds(doc);
89
+ // ids array is parallel to blocks array — same pre-order traversal.
90
+ const entries = [];
91
+ for (let i = 0; i < blocks.length; i++) {
92
+ const id = ids[i] || generateNodeId();
93
+ entries.push({ id, fp: fingerprints[i] });
94
+ }
95
+ return entries;
96
+ }
97
+ /**
98
+ * Cap graveyard size to avoid frontmatter bloat on docs with many edits.
99
+ * Newest entries (highest position) win — the ones most likely to be
100
+ * paste-back targets. Older entries expire silently.
101
+ */
102
+ const GRAVEYARD_MAX = 50;
103
+ /** Walk the TipTap tree in the SAME pre-order as tiptapToBlocks, collect IDs. */
104
+ function collectBlockIds(doc) {
105
+ const ids = [];
106
+ const blockTypes = new Set([
107
+ 'heading', 'paragraph', 'bulletList', 'orderedList', 'taskList',
108
+ 'listItem', 'taskItem', 'blockquote', 'codeBlock', 'horizontalRule',
109
+ 'table', 'image', 'tableRow', 'tableCell', 'tableHeader',
110
+ ]);
111
+ const containerTypes = new Set([
112
+ 'bulletList', 'orderedList', 'taskList', 'listItem', 'taskItem', 'blockquote',
113
+ ]);
114
+ function walk(nodes) {
115
+ if (!nodes)
116
+ return;
117
+ for (const node of nodes) {
118
+ if (blockTypes.has(node.type)) {
119
+ ids.push(node.attrs?.id || '');
120
+ if (containerTypes.has(node.type) && node.content)
121
+ walk(node.content);
122
+ }
123
+ else if (node.content) {
124
+ walk(node.content);
125
+ }
126
+ }
127
+ }
128
+ walk(doc.content || []);
129
+ return ids;
130
+ }
60
131
  /**
61
132
  * Convert TipTap document to markdown with JSON frontmatter.
62
133
  * Metadata stored as minified JSON between --- delimiters (valid YAML).
63
134
  * Editor never sees frontmatter — it's stripped on load, regenerated on save.
64
135
  * Pending state is persisted in frontmatter `pending` key.
136
+ * Node identity persisted in frontmatter `nodes` key (id + fingerprint per block).
65
137
  */
66
138
  export function tiptapToMarkdown(doc, title, metadata) {
67
139
  const meta = { ...metadata, title };
@@ -73,6 +145,24 @@ export function tiptapToMarkdown(doc, title, metadata) {
73
145
  else {
74
146
  delete meta.pending;
75
147
  }
148
+ // Collect node identity graph (id + fingerprint per block) for next-load matcher
149
+ const nodes = collectNodesFrontmatter(doc);
150
+ if (nodes.length > 0) {
151
+ meta.nodes = nodes;
152
+ }
153
+ else {
154
+ delete meta.nodes;
155
+ }
156
+ // Graveyard: recently-orphaned (id, fingerprint) entries kept across saves so
157
+ // paste-back/undo can restore the original ID via exact fingerprint match.
158
+ // The caller (writeToDisk) puts the matcher's nextGraveyard into metadata.graveyard;
159
+ // we cap it here to keep the file small.
160
+ if (Array.isArray(meta.graveyard) && meta.graveyard.length > 0) {
161
+ meta.graveyard = meta.graveyard.slice(0, GRAVEYARD_MAX);
162
+ }
163
+ else {
164
+ delete meta.graveyard;
165
+ }
76
166
  // Strip undefined/null values
77
167
  for (const key of Object.keys(meta)) {
78
168
  if (meta[key] === undefined || meta[key] === null)
@@ -98,19 +188,16 @@ function nodeToMarkdown(node, indent) {
98
188
  case 'heading': {
99
189
  const level = node.attrs?.level || 1;
100
190
  const prefix = '#'.repeat(level);
101
- const idSuffix = node.attrs?.id ? ` ^${node.attrs.id}` : '';
102
- return `${prefix} ${inlineToMarkdown(node.content)}${idSuffix}\n\n`;
191
+ // Body stays undisturbed — node ID is persisted in frontmatter `nodes`, not as a trailing anchor.
192
+ return `${prefix} ${inlineToMarkdown(node.content)}\n\n`;
103
193
  }
104
194
  case 'paragraph': {
105
195
  const text = inlineToMarkdown(node.content);
106
- const id = node.attrs?.id;
107
196
  if (text) {
108
- const idSuffix = id ? ` ^${id}` : '';
109
- return `${indent}${text}${idSuffix}\n\n`;
197
+ return `${indent}${text}\n\n`;
110
198
  }
111
- // Empty paragraph: embed id in the existing sentinel comment
112
- const emptyMarker = id ? `<!-- ^${id} -->` : '<!-- -->';
113
- return `${indent}${emptyMarker}\n\n`;
199
+ // Empty paragraph: use plain sentinel (frontmatter `nodes` carries the ID).
200
+ return `${indent}<!-- -->\n\n`;
114
201
  }
115
202
  case 'bulletList':
116
203
  return listToMarkdown(node.content, '- ', indent);
@@ -1,6 +1,38 @@
1
1
  /**
2
2
  * Barrel re-export for markdown serialization and parsing.
3
3
  * All existing imports from './markdown.js' continue to work unchanged.
4
+ *
5
+ * Also exposes `tiptapToMarkdownChecked` — a sync-observed wrapper that
6
+ * verifies the TipTap → markdown → TipTap round-trip preserves document
7
+ * shape. Use this when you want to catch silent drift; the unchecked
8
+ * `tiptapToMarkdown` stays the cheap path for hot loops.
4
9
  */
10
+ import { tiptapToMarkdown } from './markdown-serialize.js';
11
+ import { markdownToTiptap } from './markdown-parse.js';
12
+ import { shapeOfTiptap, compareShapes, formatSyncReport, } from './node-sync-check.js';
5
13
  export { tiptapToMarkdown, tiptapToBody, nodeText, inlineToMarkdown } from './markdown-serialize.js';
6
14
  export { markdownToTiptap, markdownToNodes } from './markdown-parse.js';
15
+ export { shapeOfTiptap, computeShape, compareShapes, formatSyncReport, } from './node-sync-check.js';
16
+ /**
17
+ * Serialize TipTap to markdown, then re-parse and verify that the round-trip
18
+ * preserved document shape. If the shapes diverge, a node was misapplied —
19
+ * content ended up in the wrong block on save, or the parse interpreted
20
+ * something differently than the serialize emitted.
21
+ *
22
+ * Returns the markdown AND the sync report. Callers decide whether a failed
23
+ * report should block the save (throw) or just log and continue.
24
+ *
25
+ * Cost: one extra parse + two shape computations per save. Acceptable for
26
+ * any user-driven save; consider the unchecked path inside tight loops.
27
+ */
28
+ export function tiptapToMarkdownChecked(doc, title, metadata) {
29
+ const markdown = tiptapToMarkdown(doc, title, metadata);
30
+ const expectedShape = shapeOfTiptap(doc);
31
+ const reparsed = markdownToTiptap(markdown);
32
+ const actualShape = shapeOfTiptap(reparsed.document);
33
+ const syncReport = compareShapes(expectedShape, actualShape);
34
+ if (!syncReport.ok) {
35
+ console.error(formatSyncReport(syncReport, `serialize:${title}`));
36
+ }
37
+ return { markdown, syncReport };
38
+ }