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.
- package/dist/client/assets/{index-BlLnLdoc.js → index-BxI3DazW.js} +2 -2
- package/dist/client/index.html +1 -1
- package/dist/server/documents.js +11 -11
- package/dist/server/index.js +2 -5
- package/dist/server/markdown-parse.js +144 -5
- package/dist/server/markdown-serialize.js +96 -9
- package/dist/server/markdown.js +32 -0
- package/dist/server/node-blocks.js +256 -0
- package/dist/server/node-fingerprint.js +264 -0
- package/dist/server/node-matcher.js +564 -0
- package/dist/server/node-sync-check.js +110 -0
- package/dist/server/state.js +172 -46
- package/package.json +1 -1
- package/skill/SKILL.md +1 -0
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
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>
|
package/dist/server/documents.js
CHANGED
|
@@ -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 {
|
|
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 ===
|
|
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 ===
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
791
|
+
const { markdown: newRaw } = tiptapToMarkdownChecked(doc, parsed.title, parsed.metadata);
|
|
792
792
|
atomicWriteFileSync(filePath, newRaw);
|
|
793
793
|
return count;
|
|
794
794
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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
|
|
140
|
+
// Strip consumed keys from returned metadata
|
|
37
141
|
const metadata = { ...data };
|
|
38
142
|
delete metadata.pending;
|
|
39
|
-
|
|
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
|
-
*
|
|
457
|
-
*
|
|
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
|
-
|
|
102
|
-
return `${prefix} ${inlineToMarkdown(node.content)}
|
|
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
|
-
|
|
109
|
-
return `${indent}${text}${idSuffix}\n\n`;
|
|
197
|
+
return `${indent}${text}\n\n`;
|
|
110
198
|
}
|
|
111
|
-
// Empty paragraph:
|
|
112
|
-
|
|
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);
|
package/dist/server/markdown.js
CHANGED
|
@@ -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
|
+
}
|