openwriter 0.14.0 → 0.16.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-CbSQ8xxn.css +1 -0
- package/dist/client/assets/index-JMMJM_G_.js +212 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
- package/dist/plugins/authors-voice/dist/index.js +206 -0
- package/dist/plugins/authors-voice/package.json +23 -0
- package/dist/plugins/image-gen/dist/index.d.ts +35 -0
- package/dist/plugins/image-gen/dist/index.js +141 -0
- package/dist/plugins/image-gen/package.json +26 -0
- package/dist/plugins/publish/dist/helpers.d.ts +66 -0
- package/dist/plugins/publish/dist/helpers.js +199 -0
- package/dist/plugins/publish/dist/index.d.ts +3 -0
- package/dist/plugins/publish/dist/index.js +1130 -0
- package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
- package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
- package/dist/plugins/publish/package.json +31 -0
- package/dist/plugins/x-api/dist/index.d.ts +27 -0
- package/dist/plugins/x-api/dist/index.js +240 -0
- package/dist/plugins/x-api/package.json +27 -0
- package/dist/server/comments.js +256 -0
- package/dist/server/documents.js +293 -20
- package/dist/server/enrichment.js +114 -0
- package/dist/server/helpers.js +63 -8
- package/dist/server/index.js +94 -40
- package/dist/server/install-skill.js +15 -0
- package/dist/server/logger.js +246 -0
- package/dist/server/markdown-parse.js +71 -14
- package/dist/server/markdown-serialize.js +136 -41
- package/dist/server/mcp.js +538 -99
- package/dist/server/node-blocks.js +22 -4
- package/dist/server/node-fingerprint.js +347 -73
- package/dist/server/node-matcher.js +76 -49
- package/dist/server/pending-overlay.js +862 -0
- package/dist/server/state.js +1178 -98
- package/dist/server/versions.js +18 -0
- package/dist/server/workspaces.js +42 -5
- package/dist/server/ws.js +194 -37
- package/package.json +1 -1
- package/skill/SKILL.md +51 -21
- package/skill/agents/openwriter-enrichment-minion.md +184 -0
- package/skill/docs/enrichment.md +179 -0
- package/dist/client/assets/index-BxI3DazW.js +0 -212
- package/dist/client/assets/index-OV13QtgQ.css +0 -1
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
|
|
20
20
|
import { tiptapToBlocks } from './node-blocks.js';
|
|
21
|
-
import { fingerprintAll } from './node-fingerprint.js';
|
|
21
|
+
import { fingerprintAll, slimEntry } from './node-fingerprint.js';
|
|
22
22
|
// ============================================================================
|
|
23
23
|
// TipTap -> Markdown
|
|
24
24
|
// ============================================================================
|
|
@@ -74,25 +74,23 @@ function collectPendingState(doc) {
|
|
|
74
74
|
return Object.keys(pending).length > 0 ? pending : undefined;
|
|
75
75
|
}
|
|
76
76
|
/**
|
|
77
|
-
* Build the `nodes` frontmatter entry — one
|
|
78
|
-
*
|
|
77
|
+
* Build the `nodes` frontmatter entry — one slim tuple per block in
|
|
78
|
+
* pre-order traversal of the TipTap tree.
|
|
79
79
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* the visible doc).
|
|
80
|
+
* Disk shape is the ultra-lean tuple form from node-fingerprint.ts. Derived
|
|
81
|
+
* fields (position, parent indices, neighbor types, char/word counts) are
|
|
82
|
+
* recomputed at load time from the block tree itself — they don't go to disk.
|
|
84
83
|
*/
|
|
85
84
|
function collectNodesFrontmatter(doc) {
|
|
86
85
|
const blocks = tiptapToBlocks(doc);
|
|
87
86
|
const fingerprints = fingerprintAll(blocks);
|
|
88
87
|
const ids = collectBlockIds(doc);
|
|
89
|
-
|
|
90
|
-
const entries = [];
|
|
88
|
+
const out = [];
|
|
91
89
|
for (let i = 0; i < blocks.length; i++) {
|
|
92
90
|
const id = ids[i] || generateNodeId();
|
|
93
|
-
|
|
91
|
+
out.push(slimEntry(id, fingerprints[i]));
|
|
94
92
|
}
|
|
95
|
-
return
|
|
93
|
+
return out;
|
|
96
94
|
}
|
|
97
95
|
/**
|
|
98
96
|
* Cap graveyard size to avoid frontmatter bloat on docs with many edits.
|
|
@@ -137,28 +135,31 @@ function collectBlockIds(doc) {
|
|
|
137
135
|
*/
|
|
138
136
|
export function tiptapToMarkdown(doc, title, metadata) {
|
|
139
137
|
const meta = { ...metadata, title };
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
138
|
+
// Disk is canonical only — never emit `pending:` frontmatter. Pending
|
|
139
|
+
// state lives in the sidecar at `_pending/{docId}.json`, separated from
|
|
140
|
+
// the .md file so external markdown editors see clean canonical content.
|
|
141
|
+
//
|
|
142
|
+
// If the caller passed a doc that still has in-memory pending attrs,
|
|
143
|
+
// serialize from a reverted clone so the body is canonical. Callers
|
|
144
|
+
// that have already done the split (writeToDisk's overlay path) pass
|
|
145
|
+
// an already-canonical doc; this revert is a no-op for them.
|
|
146
|
+
// adr: adr/pending-overlay-model.md
|
|
147
|
+
delete meta.pending;
|
|
148
|
+
const canonicalDoc = revertPendingForSerialization(doc);
|
|
148
149
|
// Collect node identity graph (id + fingerprint per block) for next-load matcher
|
|
149
|
-
const nodes = collectNodesFrontmatter(
|
|
150
|
+
const nodes = collectNodesFrontmatter(canonicalDoc);
|
|
150
151
|
if (nodes.length > 0) {
|
|
151
152
|
meta.nodes = nodes;
|
|
152
153
|
}
|
|
153
154
|
else {
|
|
154
155
|
delete meta.nodes;
|
|
155
156
|
}
|
|
156
|
-
// Graveyard: recently-orphaned
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
// we cap it here to keep the file small.
|
|
157
|
+
// Graveyard: recently-orphaned entries kept across saves so paste-back/undo
|
|
158
|
+
// can restore the original ID via exact fingerprint match. Caller passes
|
|
159
|
+
// `{id, fingerprint}` objects (matcher output); we cap, slim, and emit.
|
|
160
160
|
if (Array.isArray(meta.graveyard) && meta.graveyard.length > 0) {
|
|
161
|
-
|
|
161
|
+
const capped = meta.graveyard.slice(0, GRAVEYARD_MAX);
|
|
162
|
+
meta.graveyard = capped.map((g) => Array.isArray(g) ? g : slimEntry(g.id, g.fingerprint || g.fp));
|
|
162
163
|
}
|
|
163
164
|
else {
|
|
164
165
|
delete meta.graveyard;
|
|
@@ -169,12 +170,58 @@ export function tiptapToMarkdown(doc, title, metadata) {
|
|
|
169
170
|
delete meta[key];
|
|
170
171
|
}
|
|
171
172
|
const frontmatter = `---\n${JSON.stringify(meta)}\n---\n\n`;
|
|
172
|
-
|
|
173
|
+
// Serialize the body from the canonical (reverted) clone — never from the
|
|
174
|
+
// pending-modified live doc, otherwise the on-disk body would contain
|
|
175
|
+
// rewritten prose without the original anywhere to revert to.
|
|
176
|
+
const body = nodesToMarkdown(canonicalDoc.content || []);
|
|
173
177
|
return frontmatter + body;
|
|
174
178
|
}
|
|
175
|
-
/** Convert TipTap document to markdown body only (no frontmatter).
|
|
179
|
+
/** Convert TipTap document to markdown body only (no frontmatter).
|
|
180
|
+
* Like tiptapToMarkdown, the body is canonical (pending reverted). */
|
|
176
181
|
export function tiptapToBody(doc) {
|
|
177
|
-
|
|
182
|
+
const canonicalDoc = revertPendingForSerialization(doc);
|
|
183
|
+
return nodesToMarkdown(canonicalDoc.content || []);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Deep clone of `doc` with pending decorations reverted, used by the
|
|
187
|
+
* markdown serializer to ensure disk content is canonical. Mirrors
|
|
188
|
+
* state.cloneWithPendingReverted but is local to the serializer to
|
|
189
|
+
* avoid a state.ts → markdown-serialize.ts cycle.
|
|
190
|
+
*
|
|
191
|
+
* - status='insert' → drop the node
|
|
192
|
+
* - status='rewrite' → restore from pendingOriginalContent (or drop if absent)
|
|
193
|
+
* - status='delete' → keep but clear pending attrs
|
|
194
|
+
* - no status → keep, strip stray pending attrs
|
|
195
|
+
*/
|
|
196
|
+
const PENDING_KEYS = ['pendingStatus', 'pendingOriginalContent', 'pendingGroupId', 'pendingTextEdits', 'pendingSelectionFrom', 'pendingSelectionTo', 'pendingOriginalFrom', 'pendingOriginalTo', 'pendingOrphan', 'pendingStaleBaseline'];
|
|
197
|
+
function revertPendingForSerialization(doc) {
|
|
198
|
+
function clean(node) {
|
|
199
|
+
const clone = JSON.parse(JSON.stringify(node));
|
|
200
|
+
if (clone.attrs) {
|
|
201
|
+
for (const k of PENDING_KEYS)
|
|
202
|
+
delete clone.attrs[k];
|
|
203
|
+
}
|
|
204
|
+
if (clone.content)
|
|
205
|
+
clone.content = walk(clone.content);
|
|
206
|
+
return clone;
|
|
207
|
+
}
|
|
208
|
+
function walk(nodes) {
|
|
209
|
+
const result = [];
|
|
210
|
+
for (const node of nodes || []) {
|
|
211
|
+
const status = node?.attrs?.pendingStatus;
|
|
212
|
+
if (status === 'insert')
|
|
213
|
+
continue;
|
|
214
|
+
if (status === 'rewrite') {
|
|
215
|
+
const original = node.attrs?.pendingOriginalContent;
|
|
216
|
+
if (original)
|
|
217
|
+
result.push(clean(original));
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
result.push(clean(node));
|
|
221
|
+
}
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
return { type: 'doc', content: walk(doc?.content || []) };
|
|
178
225
|
}
|
|
179
226
|
function nodesToMarkdown(nodes) {
|
|
180
227
|
let result = '';
|
|
@@ -275,28 +322,76 @@ function taskListToMarkdown(items, indent) {
|
|
|
275
322
|
}
|
|
276
323
|
return result + '\n';
|
|
277
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* Serialize a TipTap table node to GFM markdown.
|
|
327
|
+
*
|
|
328
|
+
* Critical invariants (each one's absence causes silent table → paragraph
|
|
329
|
+
* loss on round-trip — observed live as `sync-check FAIL: expected table,
|
|
330
|
+
* got paragraph` on the Beat Sheet doc):
|
|
331
|
+
*
|
|
332
|
+
* 1. ALWAYS emit the header-separator row `| --- | --- |` after the first
|
|
333
|
+
* row, regardless of whether any cell is a `tableHeader`. GFM table
|
|
334
|
+
* recognition requires the delimiter row — without it, markdown-it
|
|
335
|
+
* parses each `| ... |` line as a paragraph and the entire table is
|
|
336
|
+
* dropped. (One-time consequence: a header-less table's first row
|
|
337
|
+
* becomes `tableHeader` cells after the first round-trip. Stable
|
|
338
|
+
* thereafter.)
|
|
339
|
+
*
|
|
340
|
+
* 2. Escape `|` inside cell text as `\|` so it doesn't terminate the cell
|
|
341
|
+
* column.
|
|
342
|
+
*
|
|
343
|
+
* 3. Collapse multi-paragraph cells with `<br>` joiners. The inline
|
|
344
|
+
* cell format can't represent multiple block paragraphs; without
|
|
345
|
+
* collapsing, only the first paragraph round-trips and the rest are
|
|
346
|
+
* silently lost.
|
|
347
|
+
*
|
|
348
|
+
* 4. Ensure a blank line precedes the table block (caller does `\n\n`
|
|
349
|
+
* tailing on prior nodes; we keep the leading newline minimal).
|
|
350
|
+
*/
|
|
278
351
|
function tableToMarkdown(node) {
|
|
279
352
|
const rows = node.content || [];
|
|
280
353
|
if (rows.length === 0)
|
|
281
354
|
return '';
|
|
355
|
+
function cellContentToText(cell) {
|
|
356
|
+
const content = cell.content || [];
|
|
357
|
+
if (content.length === 0)
|
|
358
|
+
return '';
|
|
359
|
+
// Each cell typically holds one paragraph, but a TipTap table can carry
|
|
360
|
+
// multi-paragraph cells (and arbitrary blocks). Concatenate paragraphs
|
|
361
|
+
// with <br> so no inline content is dropped.
|
|
362
|
+
const parts = [];
|
|
363
|
+
for (const child of content) {
|
|
364
|
+
if (child.type === 'paragraph') {
|
|
365
|
+
parts.push(inlineToMarkdown(child.content));
|
|
366
|
+
}
|
|
367
|
+
else if (child.content) {
|
|
368
|
+
// Non-paragraph block (rare in tables) — fall through to inline.
|
|
369
|
+
parts.push(inlineToMarkdown(child.content));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// Escape pipes and replace newlines with <br>.
|
|
373
|
+
return parts.join('<br>').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
|
|
374
|
+
}
|
|
282
375
|
const lines = [];
|
|
283
|
-
|
|
284
|
-
|
|
376
|
+
const firstRowCells = rows[0]?.content || [];
|
|
377
|
+
const columnCount = firstRowCells.length;
|
|
378
|
+
for (let r = 0; r < rows.length; r++) {
|
|
379
|
+
const row = rows[r];
|
|
285
380
|
const cells = row.content || [];
|
|
286
|
-
const cellTexts = cells.map(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
381
|
+
const cellTexts = cells.map(cellContentToText);
|
|
382
|
+
// Pad short rows so the markdown table has consistent column count.
|
|
383
|
+
while (cellTexts.length < columnCount)
|
|
384
|
+
cellTexts.push('');
|
|
290
385
|
lines.push(`| ${cellTexts.join(' | ')} |`);
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
isFirstRow = false;
|
|
386
|
+
if (r === 0) {
|
|
387
|
+
// ALWAYS emit the separator — GFM parsing requires it for table
|
|
388
|
+
// recognition. This is the load-bearing invariant.
|
|
389
|
+
lines.push(`| ${Array(columnCount).fill('---').join(' | ')} |`);
|
|
297
390
|
}
|
|
298
391
|
}
|
|
299
|
-
|
|
392
|
+
// Leading blank line ensures we're not glued to the prior block (which
|
|
393
|
+
// would cause the table to be consumed as a paragraph continuation).
|
|
394
|
+
return '\n' + lines.join('\n') + '\n\n';
|
|
300
395
|
}
|
|
301
396
|
// ---- Inline mark serialization ----
|
|
302
397
|
const SERIALIZED_MARKS = ['bold', 'italic', 'code', 'strike', 'underline', 'highlight', 'subscript', 'superscript', 'link'];
|