openwriter 0.15.0 → 0.17.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-0ttVnjRp.css +1 -0
- package/dist/client/assets/{index-B5MXw2pg.js → index-BZ7LCzrR.js} +64 -64
- 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/compact.js +28 -2
- package/dist/server/documents.js +234 -3
- package/dist/server/enrichment.js +125 -0
- package/dist/server/export-routes.js +2 -0
- package/dist/server/install-skill.js +15 -0
- package/dist/server/markdown-parse.js +153 -14
- package/dist/server/markdown-serialize.js +100 -17
- package/dist/server/mcp.js +291 -25
- package/dist/server/node-blocks.js +41 -1
- package/dist/server/node-fingerprint.js +347 -73
- package/dist/server/node-matcher.js +19 -44
- package/dist/server/pending-overlay.js +21 -4
- package/dist/server/state.js +225 -41
- package/dist/server/workspaces.js +27 -5
- package/dist/server/ws.js +10 -0
- package/package.json +2 -1
- package/skill/SKILL.md +38 -7
- package/skill/agents/openwriter-enrichment-minion.md +177 -0
- package/skill/docs/enrichment.md +179 -0
- package/skill/docs/footnotes.md +178 -0
- package/dist/client/assets/index-B3iORmCT.css +0 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontmatter enrichment staleness detection.
|
|
3
|
+
*
|
|
4
|
+
* The matcher already splits every block into sentences and hashes each one on
|
|
5
|
+
* every save (see node-fingerprint.ts). We reuse that machinery here — no new
|
|
6
|
+
* algorithm, no new splitter. Save-time staleness is a small tag-on after the
|
|
7
|
+
* matcher: harvest the current sentence-hash set + char count, compare against
|
|
8
|
+
* the at-enrichment baseline stored in frontmatter, set `enrichmentStale: true`
|
|
9
|
+
* when either threshold trips.
|
|
10
|
+
*
|
|
11
|
+
* Volume ratio captures growth and shrinkage symmetrically. Jaccard distance
|
|
12
|
+
* over the sentence-hash set captures rewrites at constant length. Either
|
|
13
|
+
* tripping flags the doc.
|
|
14
|
+
*
|
|
15
|
+
* OpenWriter owns "is this doc stale". The agent clears the flag via
|
|
16
|
+
* mark_enriched (Phase 4). Both sides read the same field, never compute it
|
|
17
|
+
* independently.
|
|
18
|
+
*
|
|
19
|
+
* See brief: 2026-05-18-frontmatter-enrichment-system.
|
|
20
|
+
*/
|
|
21
|
+
import { splitSentences, simpleHash } from './node-fingerprint.js';
|
|
22
|
+
/** Volume-ratio threshold above which a doc is flagged stale by size delta. */
|
|
23
|
+
export const DEFAULT_ENRICHMENT_VOLUME_THRESHOLD = 1.5;
|
|
24
|
+
/**
|
|
25
|
+
* Jaccard-distance threshold above which a doc is flagged stale by drift.
|
|
26
|
+
*
|
|
27
|
+
* 0.10 catches in-place architectural rewrites that volume-ratio misses —
|
|
28
|
+
* e.g. inserting a Status Update section into a 200-sentence doc adds
|
|
29
|
+
* ~25 new sentences, ~0 removed: distance = 25/225 ≈ 0.11, trips at 0.10.
|
|
30
|
+
* Won't false-positive on routine editing — a 3-sentence paragraph addition
|
|
31
|
+
* to a 200-sentence doc is 3/203 ≈ 0.015, well below.
|
|
32
|
+
*
|
|
33
|
+
* Tightened from 0.3 → 0.10 after 2026-05-19 brief reported the Argument
|
|
34
|
+
* Arc class of rewrites slipping through.
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_ENRICHMENT_DRIFT_THRESHOLD = 0.10;
|
|
37
|
+
/**
|
|
38
|
+
* Flatten every block's per-sentence hashes into one sorted unique set.
|
|
39
|
+
* Sorted so the on-disk representation is stable across saves (no spurious
|
|
40
|
+
* frontmatter diffs from set-order drift). Unique so duplicate sentences
|
|
41
|
+
* in the same doc don't double-count in the Jaccard math.
|
|
42
|
+
*/
|
|
43
|
+
export function harvestSentenceHashes(blocks) {
|
|
44
|
+
const set = new Set();
|
|
45
|
+
for (const block of blocks) {
|
|
46
|
+
const sentences = splitSentences(block.text || '');
|
|
47
|
+
for (const s of sentences) {
|
|
48
|
+
set.add(simpleHash(s.text + s.terminator));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return Array.from(set).sort();
|
|
52
|
+
}
|
|
53
|
+
/** Total char count across all blocks' text — the volume signal. */
|
|
54
|
+
export function harvestCharCount(blocks) {
|
|
55
|
+
let n = 0;
|
|
56
|
+
for (const b of blocks)
|
|
57
|
+
n += (b.text || '').length;
|
|
58
|
+
return n;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Symmetric size delta. Returns 1 when sizes match, grows toward infinity as
|
|
62
|
+
* they diverge in either direction. Handles zero-size docs safely.
|
|
63
|
+
*/
|
|
64
|
+
export function volumeRatio(current, baseline) {
|
|
65
|
+
if (current === 0 && baseline === 0)
|
|
66
|
+
return 1;
|
|
67
|
+
if (current === 0 || baseline === 0)
|
|
68
|
+
return Infinity;
|
|
69
|
+
return Math.max(current, baseline) / Math.min(current, baseline);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Jaccard distance over two sentence-hash sets. 0 = identical, 1 = disjoint.
|
|
73
|
+
* (union - intersection) / union. Empty-vs-empty returns 0.
|
|
74
|
+
*/
|
|
75
|
+
export function jaccardDistance(a, b) {
|
|
76
|
+
if (a.length === 0 && b.length === 0)
|
|
77
|
+
return 0;
|
|
78
|
+
const setA = new Set(a);
|
|
79
|
+
const setB = new Set(b);
|
|
80
|
+
let intersection = 0;
|
|
81
|
+
for (const x of setA)
|
|
82
|
+
if (setB.has(x))
|
|
83
|
+
intersection++;
|
|
84
|
+
const union = setA.size + setB.size - intersection;
|
|
85
|
+
return union === 0 ? 0 : (union - intersection) / union;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Compute staleness for a single doc given current matcher-derived signals
|
|
89
|
+
* and the at-enrichment baseline stored in its frontmatter.
|
|
90
|
+
*
|
|
91
|
+
* Returns true when:
|
|
92
|
+
* - the doc has never been enriched (no lastEnrichedAt) — brief: "absent flag = stale"
|
|
93
|
+
* - volumeRatio trips its threshold
|
|
94
|
+
* - Jaccard drift trips its threshold
|
|
95
|
+
*
|
|
96
|
+
* Thresholds: doc-level overrides first, then global defaults. Workspace-level
|
|
97
|
+
* overrides (per the brief) will be layered in when the surfacing handlers
|
|
98
|
+
* (Phase 6) get a workspace pointer — for now the doc carries no workspace
|
|
99
|
+
* reference in writeToDisk's scope.
|
|
100
|
+
*/
|
|
101
|
+
export function isEnrichmentStale(currentSentenceHashes, currentCharCount, metadata, workspaceOverrides) {
|
|
102
|
+
// Never enriched → stale by default. New docs land here.
|
|
103
|
+
if (!metadata.lastEnrichedAt)
|
|
104
|
+
return true;
|
|
105
|
+
const baselineHashes = Array.isArray(metadata.lastEnrichedSentences)
|
|
106
|
+
? metadata.lastEnrichedSentences
|
|
107
|
+
: [];
|
|
108
|
+
const baselineChars = typeof metadata.lastEnrichedCharCount === 'number'
|
|
109
|
+
? metadata.lastEnrichedCharCount
|
|
110
|
+
: 0;
|
|
111
|
+
const volTh = pickThreshold(metadata.enrichmentVolumeThreshold, workspaceOverrides?.volume, DEFAULT_ENRICHMENT_VOLUME_THRESHOLD);
|
|
112
|
+
const driftTh = pickThreshold(metadata.enrichmentDriftThreshold, workspaceOverrides?.drift, DEFAULT_ENRICHMENT_DRIFT_THRESHOLD);
|
|
113
|
+
if (volumeRatio(currentCharCount, baselineChars) >= volTh)
|
|
114
|
+
return true;
|
|
115
|
+
if (jaccardDistance(currentSentenceHashes, baselineHashes) >= driftTh)
|
|
116
|
+
return true;
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
function pickThreshold(docLevel, wsLevel, fallback) {
|
|
120
|
+
if (typeof docLevel === 'number' && docLevel > 0)
|
|
121
|
+
return docLevel;
|
|
122
|
+
if (typeof wsLevel === 'number' && wsLevel > 0)
|
|
123
|
+
return wsLevel;
|
|
124
|
+
return fallback;
|
|
125
|
+
}
|
|
@@ -8,6 +8,7 @@ import markdownItIns from 'markdown-it-ins';
|
|
|
8
8
|
import markdownItMark from 'markdown-it-mark';
|
|
9
9
|
import markdownItSub from 'markdown-it-sub';
|
|
10
10
|
import markdownItSup from 'markdown-it-sup';
|
|
11
|
+
import markdownItFootnote from 'markdown-it-footnote';
|
|
11
12
|
import { tiptapToMarkdown } from './markdown.js';
|
|
12
13
|
import { getDocument, getTitle, getPlainText, getMetadata } from './state.js';
|
|
13
14
|
import { buildExportHtml } from './export-html-template.js';
|
|
@@ -18,6 +19,7 @@ md.use(markdownItIns);
|
|
|
18
19
|
md.use(markdownItMark);
|
|
19
20
|
md.use(markdownItSub);
|
|
20
21
|
md.use(markdownItSup);
|
|
22
|
+
md.use(markdownItFootnote);
|
|
21
23
|
/** Strip YAML frontmatter (---\n...\n---\n\n) from markdown output. */
|
|
22
24
|
function stripFrontmatter(markdown) {
|
|
23
25
|
const match = markdown.match(/^---\n[\s\S]*?\n---\n\n/);
|
|
@@ -136,6 +136,21 @@ export function installSkill() {
|
|
|
136
136
|
}
|
|
137
137
|
log(` ✓ Skill docs copied to ${docsTarget}`);
|
|
138
138
|
}
|
|
139
|
+
// Install custom Claude Code subagents to ~/.claude/agents/. These have
|
|
140
|
+
// allowlist-restricted tools so the main agent can dispatch them without
|
|
141
|
+
// loading the full MCP tool registry into the subagent's context
|
|
142
|
+
// (~50K tokens of overhead avoided per spawn).
|
|
143
|
+
const agentsSource = path.join(__dirname, '../../skill/agents');
|
|
144
|
+
if (fs.existsSync(agentsSource)) {
|
|
145
|
+
const agentsTarget = path.join(os.homedir(), '.claude', 'agents');
|
|
146
|
+
fs.mkdirSync(agentsTarget, { recursive: true });
|
|
147
|
+
for (const file of fs.readdirSync(agentsSource)) {
|
|
148
|
+
if (!file.endsWith('.md'))
|
|
149
|
+
continue;
|
|
150
|
+
fs.copyFileSync(path.join(agentsSource, file), path.join(agentsTarget, file));
|
|
151
|
+
log(` ✓ Subagent installed: ${file}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
139
154
|
// Step 2: Global install or update
|
|
140
155
|
let useNpx = false;
|
|
141
156
|
const currentVersion = getGlobalVersion();
|
|
@@ -17,10 +17,12 @@ import markdownItIns from 'markdown-it-ins';
|
|
|
17
17
|
import markdownItMark from 'markdown-it-mark';
|
|
18
18
|
import markdownItSub from 'markdown-it-sub';
|
|
19
19
|
import markdownItSup from 'markdown-it-sup';
|
|
20
|
+
import markdownItFootnote from 'markdown-it-footnote';
|
|
20
21
|
import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
|
|
21
22
|
import { nodeText } from './markdown-serialize.js';
|
|
22
23
|
import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
|
|
23
24
|
import { matchNodes } from './node-matcher.js';
|
|
25
|
+
import { enrichEntries, enrichSlimArray, fingerprintAll, isLegacyRawEntry, anyLegacyRaw, } from './node-fingerprint.js';
|
|
24
26
|
// ============================================================================
|
|
25
27
|
// Markdown -> TipTap
|
|
26
28
|
// ============================================================================
|
|
@@ -30,6 +32,7 @@ md.use(markdownItIns);
|
|
|
30
32
|
md.use(markdownItMark);
|
|
31
33
|
md.use(markdownItSub);
|
|
32
34
|
md.use(markdownItSup);
|
|
35
|
+
md.use(markdownItFootnote);
|
|
33
36
|
/**
|
|
34
37
|
* Normalize blank lines INSIDE markdown tables before parsing.
|
|
35
38
|
*
|
|
@@ -122,14 +125,15 @@ export function markdownToTiptap(markdown) {
|
|
|
122
125
|
type: 'doc',
|
|
123
126
|
content: docContent.length > 0 ? docContent : [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }],
|
|
124
127
|
};
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
128
|
+
// Resolve identity graph from frontmatter. Two on-disk formats live in the
|
|
129
|
+
// wild: ultra-lean slim tuples (current) and legacy verbose objects (v0.14
|
|
130
|
+
// and v0.15). Legacy entries get positionally re-fingerprinted from the
|
|
131
|
+
// freshly-parsed body — the body IS the previous state at load time, and
|
|
132
|
+
// re-fingerprinting produces hashes the matcher can pin against cleanly.
|
|
133
|
+
// adr: adr/node-identity-matcher.md
|
|
134
|
+
const blocksForEnrich = tiptapToBlocks(doc);
|
|
135
|
+
const previousNodes = resolvePreviousNodes(data.nodes, blocksForEnrich);
|
|
136
|
+
const graveyard = resolveGraveyard(data.graveyard);
|
|
133
137
|
if (previousNodes.length > 0) {
|
|
134
138
|
applyMatcher(doc, previousNodes, graveyard);
|
|
135
139
|
}
|
|
@@ -151,13 +155,68 @@ export function markdownToTiptap(markdown) {
|
|
|
151
155
|
previousNodes,
|
|
152
156
|
};
|
|
153
157
|
}
|
|
154
|
-
/**
|
|
155
|
-
|
|
156
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Resolve `nodes:` frontmatter into rich NodeEntry[] suitable for the matcher.
|
|
160
|
+
*
|
|
161
|
+
* Two on-disk formats:
|
|
162
|
+
* - Ultra-lean: each entry is an array tuple. enrichSlimArray derives all
|
|
163
|
+
* positional/structural fields from the slim array itself — no body parse
|
|
164
|
+
* needed. The slim array IS the previous state (position = array index,
|
|
165
|
+
* parent = most-recent unfilled container, neighbors = slim[i±1]).
|
|
166
|
+
* - Legacy (v0.14/v0.15): each entry is an object with `id` and `fp` keys.
|
|
167
|
+
* We re-fingerprint positionally from the body — the body IS the previous
|
|
168
|
+
* state at load time, and a fresh fingerprint over the same body produces
|
|
169
|
+
* hashes the matcher can pin against. After the next save, disk is in the
|
|
170
|
+
* ultra-lean format and the body-parse cost drops away.
|
|
171
|
+
*
|
|
172
|
+
* `blocks` is only consulted for the legacy path; slim path ignores it. Pass
|
|
173
|
+
* an empty array when you only have slim input to avoid the body parse cost.
|
|
174
|
+
*/
|
|
175
|
+
export function resolvePreviousNodes(raw, blocks) {
|
|
176
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
177
|
+
return [];
|
|
178
|
+
if (anyLegacyRaw(raw)) {
|
|
179
|
+
// Positional re-fingerprint: take each legacy entry's id, assign it to a
|
|
180
|
+
// freshly-computed fingerprint at the same position in the body.
|
|
181
|
+
const freshFps = fingerprintAll(blocks);
|
|
182
|
+
const out = [];
|
|
183
|
+
for (let i = 0; i < raw.length; i++) {
|
|
184
|
+
const r = raw[i];
|
|
185
|
+
const id = isLegacyRawEntry(r) ? r.id : (Array.isArray(r) ? r[0] : null);
|
|
186
|
+
if (!id || typeof id !== 'string' || !freshFps[i])
|
|
187
|
+
continue;
|
|
188
|
+
out.push({ id, fingerprint: freshFps[i] });
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
// Ultra-lean: walk the slim array directly. No body parse required.
|
|
193
|
+
return enrichSlimArray(raw).map((e) => ({
|
|
194
|
+
id: e.id,
|
|
195
|
+
fingerprint: e.fingerprint,
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Resolve `graveyard:` frontmatter into rich NodeEntry[].
|
|
200
|
+
*
|
|
201
|
+
* Ultra-lean tuples enrich without block context (deleted blocks have no
|
|
202
|
+
* body). Derived fields default to safe values; matcher rules for graveyard
|
|
203
|
+
* restore only consult type + sentences + structureSig + childTypes, all
|
|
204
|
+
* carried in slim. Legacy graveyard entries are dropped — their stored
|
|
205
|
+
* fingerprints don't translate to the new hash semantics (terminator is now
|
|
206
|
+
* folded into the hash), so they'd never match a fresh paste-back anyway.
|
|
207
|
+
*/
|
|
208
|
+
export function resolveGraveyard(raw) {
|
|
209
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
157
210
|
return [];
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
.
|
|
211
|
+
if (anyLegacyRaw(raw)) {
|
|
212
|
+
// Mixed input: drop legacy entries, enrich slim ones.
|
|
213
|
+
const slimOnly = raw.filter((r) => Array.isArray(r));
|
|
214
|
+
return enrichEntries(slimOnly, []).map((e) => ({ id: e.id, fingerprint: e.fingerprint }));
|
|
215
|
+
}
|
|
216
|
+
return enrichEntries(raw, []).map((e) => ({
|
|
217
|
+
id: e.id,
|
|
218
|
+
fingerprint: e.fingerprint,
|
|
219
|
+
}));
|
|
161
220
|
}
|
|
162
221
|
/**
|
|
163
222
|
* Run the matcher: compare frontmatter `nodes` (previous fingerprints) to
|
|
@@ -329,12 +388,81 @@ function tokensToTiptap(tokens) {
|
|
|
329
388
|
nodes.push(tableNode);
|
|
330
389
|
i = end + 1;
|
|
331
390
|
}
|
|
391
|
+
else if (token.type === 'footnote_block_open') {
|
|
392
|
+
// Footnote definitions section. markdown-it-footnote always emits
|
|
393
|
+
// this block at end-of-doc with all `[^N]: ...` definitions inside,
|
|
394
|
+
// regardless of where in the source the `[^N]:` lines appeared.
|
|
395
|
+
// Parse accepts flexibly; serializer always emits at end-of-doc.
|
|
396
|
+
// adr: adr/footnote-system.md
|
|
397
|
+
const end = findClosingToken(tokens, i, 'footnote_block');
|
|
398
|
+
const definitions = parseFootnoteDefinitions(tokens.slice(i + 1, end));
|
|
399
|
+
if (definitions.length > 0) {
|
|
400
|
+
nodes.push({
|
|
401
|
+
type: 'footnoteSection',
|
|
402
|
+
attrs: { id: generateNodeId() },
|
|
403
|
+
content: definitions,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
i = end + 1;
|
|
407
|
+
}
|
|
332
408
|
else {
|
|
333
409
|
i += 1;
|
|
334
410
|
}
|
|
335
411
|
}
|
|
336
412
|
return nodes;
|
|
337
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* Parse the contents of a footnote_block (between footnote_block_open and
|
|
416
|
+
* footnote_block_close). Each footnote is a footnote_open ... footnote_close
|
|
417
|
+
* range containing block-level tokens (paragraphs, lists, etc).
|
|
418
|
+
*
|
|
419
|
+
* Strips the trailing `footnote_anchor` back-link that markdown-it-footnote
|
|
420
|
+
* appends to each footnote's last paragraph — it's a renderer artifact, not
|
|
421
|
+
* source content.
|
|
422
|
+
*/
|
|
423
|
+
function parseFootnoteDefinitions(tokens) {
|
|
424
|
+
const definitions = [];
|
|
425
|
+
let i = 0;
|
|
426
|
+
while (i < tokens.length) {
|
|
427
|
+
if (tokens[i].type === 'footnote_open') {
|
|
428
|
+
const label = tokens[i].meta?.label || String(tokens[i].meta?.id ?? '');
|
|
429
|
+
const end = findClosingToken(tokens, i, 'footnote');
|
|
430
|
+
const innerTokens = stripFootnoteAnchors(tokens.slice(i + 1, end));
|
|
431
|
+
const content = tokensToTiptap(innerTokens);
|
|
432
|
+
definitions.push({
|
|
433
|
+
type: 'footnoteDefinition',
|
|
434
|
+
attrs: { id: generateNodeId(), label },
|
|
435
|
+
content: content.length > 0
|
|
436
|
+
? content
|
|
437
|
+
: [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }],
|
|
438
|
+
});
|
|
439
|
+
i = end + 1;
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
i += 1;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return definitions;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Remove `footnote_anchor` tokens from inline children. markdown-it-footnote
|
|
449
|
+
* appends an anchor (rendered as ↩︎) to the last paragraph of each footnote
|
|
450
|
+
* to back-link to the reference. We strip it on parse because it's a
|
|
451
|
+
* rendering artifact, not source content the author wrote.
|
|
452
|
+
*/
|
|
453
|
+
function stripFootnoteAnchors(tokens) {
|
|
454
|
+
return tokens.map((token) => {
|
|
455
|
+
if (token.type !== 'inline' || !token.children)
|
|
456
|
+
return token;
|
|
457
|
+
const filtered = token.children.filter((c) => c.type !== 'footnote_anchor');
|
|
458
|
+
if (filtered.length === token.children.length)
|
|
459
|
+
return token;
|
|
460
|
+
// Clone token with filtered children
|
|
461
|
+
const cloned = Object.assign(Object.create(Object.getPrototypeOf(token)), token);
|
|
462
|
+
cloned.children = filtered;
|
|
463
|
+
return cloned;
|
|
464
|
+
});
|
|
465
|
+
}
|
|
338
466
|
function findClosingToken(tokens, startIndex, type) {
|
|
339
467
|
let depth = 0;
|
|
340
468
|
for (let i = startIndex; i < tokens.length; i++) {
|
|
@@ -567,6 +695,17 @@ function inlineTokensToTiptap(tokens) {
|
|
|
567
695
|
else if (token.type === 'softbreak') {
|
|
568
696
|
nodes.push({ type: 'text', text: ' ' });
|
|
569
697
|
}
|
|
698
|
+
else if (token.type === 'footnote_ref') {
|
|
699
|
+
// `[^N]` inline reference. Carry the author-written label verbatim so
|
|
700
|
+
// mnemonic labels (`[^sapolsky2017]`) survive round-trip. Display
|
|
701
|
+
// numbering is recomputed by the renderer.
|
|
702
|
+
// adr: adr/footnote-system.md
|
|
703
|
+
const label = token.meta?.label || String(token.meta?.id ?? '');
|
|
704
|
+
nodes.push({
|
|
705
|
+
type: 'footnoteReference',
|
|
706
|
+
attrs: { label },
|
|
707
|
+
});
|
|
708
|
+
}
|
|
570
709
|
}
|
|
571
710
|
return nodes;
|
|
572
711
|
}
|
|
@@ -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.
|
|
@@ -107,9 +105,11 @@ function collectBlockIds(doc) {
|
|
|
107
105
|
'heading', 'paragraph', 'bulletList', 'orderedList', 'taskList',
|
|
108
106
|
'listItem', 'taskItem', 'blockquote', 'codeBlock', 'horizontalRule',
|
|
109
107
|
'table', 'image', 'tableRow', 'tableCell', 'tableHeader',
|
|
108
|
+
'footnoteSection', 'footnoteDefinition',
|
|
110
109
|
]);
|
|
111
110
|
const containerTypes = new Set([
|
|
112
111
|
'bulletList', 'orderedList', 'taskList', 'listItem', 'taskItem', 'blockquote',
|
|
112
|
+
'footnoteSection', 'footnoteDefinition',
|
|
113
113
|
]);
|
|
114
114
|
function walk(nodes) {
|
|
115
115
|
if (!nodes)
|
|
@@ -156,12 +156,12 @@ export function tiptapToMarkdown(doc, title, metadata) {
|
|
|
156
156
|
else {
|
|
157
157
|
delete meta.nodes;
|
|
158
158
|
}
|
|
159
|
-
// Graveyard: recently-orphaned
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
// we cap it here to keep the file small.
|
|
159
|
+
// Graveyard: recently-orphaned entries kept across saves so paste-back/undo
|
|
160
|
+
// can restore the original ID via exact fingerprint match. Caller passes
|
|
161
|
+
// `{id, fingerprint}` objects (matcher output); we cap, slim, and emit.
|
|
163
162
|
if (Array.isArray(meta.graveyard) && meta.graveyard.length > 0) {
|
|
164
|
-
|
|
163
|
+
const capped = meta.graveyard.slice(0, GRAVEYARD_MAX);
|
|
164
|
+
meta.graveyard = capped.map((g) => Array.isArray(g) ? g : slimEntry(g.id, g.fingerprint || g.fp));
|
|
165
165
|
}
|
|
166
166
|
else {
|
|
167
167
|
delete meta.graveyard;
|
|
@@ -226,12 +226,84 @@ function revertPendingForSerialization(doc) {
|
|
|
226
226
|
return { type: 'doc', content: walk(doc?.content || []) };
|
|
227
227
|
}
|
|
228
228
|
function nodesToMarkdown(nodes) {
|
|
229
|
-
|
|
229
|
+
// Constrained model: `footnoteSection` is always emitted last, regardless of
|
|
230
|
+
// its position in the tree. Authors / agents / editor drag operations may
|
|
231
|
+
// place it anywhere; the serializer normalizes. Parse accepts flexibly;
|
|
232
|
+
// serialize produces strictly. First save of any non-canonical file becomes
|
|
233
|
+
// the one-time migration.
|
|
234
|
+
//
|
|
235
|
+
// Trailing-empty-paragraph stripping: TipTap inserts an empty paragraph at
|
|
236
|
+
// end-of-doc as a cursor-landing artifact. It serializes to a stray
|
|
237
|
+
// `<!-- -->` marker — visual cruft with no semantic value, and the next
|
|
238
|
+
// load just re-creates the cursor-landing paragraph anyway. Drop trailing
|
|
239
|
+
// empty paragraphs unconditionally (whether or not a footnoteSection is
|
|
240
|
+
// present): they're editor state, not on-disk content.
|
|
241
|
+
//
|
|
242
|
+
// Empty paragraphs in the MIDDLE of the doc are preserved — those are
|
|
243
|
+
// authored blank lines between sections and carry intent. Only the trailing
|
|
244
|
+
// run of empties at end-of-body gets stripped.
|
|
245
|
+
//
|
|
246
|
+
// adr: adr/footnote-system.md
|
|
247
|
+
let deferredSection = null;
|
|
248
|
+
const body = [];
|
|
230
249
|
for (const node of nodes) {
|
|
250
|
+
if (node.type === 'footnoteSection') {
|
|
251
|
+
deferredSection = node;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
body.push(node);
|
|
255
|
+
}
|
|
256
|
+
while (body.length > 0) {
|
|
257
|
+
const last = body[body.length - 1];
|
|
258
|
+
if (last.type === 'paragraph' && (!last.content || last.content.length === 0)) {
|
|
259
|
+
body.pop();
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
let result = '';
|
|
266
|
+
for (const node of body) {
|
|
231
267
|
result += nodeToMarkdown(node, '');
|
|
232
268
|
}
|
|
269
|
+
if (deferredSection) {
|
|
270
|
+
result += footnoteSectionToMarkdown(deferredSection);
|
|
271
|
+
}
|
|
233
272
|
return result;
|
|
234
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Serialize the footnoteSection block. Each definition emits as
|
|
276
|
+
* `[^label]: first-paragraph-content` with continuation paragraphs indented
|
|
277
|
+
* 4 spaces (Pandoc continuation convention). Empty section emits nothing.
|
|
278
|
+
*
|
|
279
|
+
* Definitions with no content still emit a `[^label]:` line so the reference
|
|
280
|
+
* doesn't dangle on round-trip.
|
|
281
|
+
*
|
|
282
|
+
* adr: adr/footnote-system.md
|
|
283
|
+
*/
|
|
284
|
+
function footnoteSectionToMarkdown(section) {
|
|
285
|
+
const definitions = (section.content || []).filter((d) => d.type === 'footnoteDefinition');
|
|
286
|
+
if (definitions.length === 0)
|
|
287
|
+
return '';
|
|
288
|
+
const lines = [];
|
|
289
|
+
for (const def of definitions) {
|
|
290
|
+
const label = def.attrs?.label || '';
|
|
291
|
+
const paragraphs = (def.content || []).filter((c) => c.type === 'paragraph');
|
|
292
|
+
if (paragraphs.length === 0) {
|
|
293
|
+
lines.push(`[^${label}]: `);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const firstText = inlineToMarkdown(paragraphs[0].content || []);
|
|
297
|
+
lines.push(`[^${label}]: ${firstText}`);
|
|
298
|
+
// Continuation paragraphs: 4-space indent per Pandoc convention.
|
|
299
|
+
for (let i = 1; i < paragraphs.length; i++) {
|
|
300
|
+
const text = inlineToMarkdown(paragraphs[i].content || []);
|
|
301
|
+
lines.push('');
|
|
302
|
+
lines.push(` ${text}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return lines.join('\n') + '\n';
|
|
306
|
+
}
|
|
235
307
|
function nodeToMarkdown(node, indent) {
|
|
236
308
|
switch (node.type) {
|
|
237
309
|
case 'heading': {
|
|
@@ -409,6 +481,17 @@ export function inlineToMarkdown(nodes) {
|
|
|
409
481
|
result += '<br>';
|
|
410
482
|
continue;
|
|
411
483
|
}
|
|
484
|
+
if (node.type === 'footnoteReference') {
|
|
485
|
+
// Close any open marks so `[^N]` lands at the prose level, not inside
|
|
486
|
+
// a bold/italic span. Footnote references are visually distinct chips;
|
|
487
|
+
// wrapping them in bold or italic doesn't make sense.
|
|
488
|
+
// adr: adr/footnote-system.md
|
|
489
|
+
result += closeAllMarks(openMarks);
|
|
490
|
+
openMarks = [];
|
|
491
|
+
const label = node.attrs?.label || '';
|
|
492
|
+
result += `[^${label}]`;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
412
495
|
if (node.type !== 'text')
|
|
413
496
|
continue;
|
|
414
497
|
const targetMarks = (node.marks || [])
|