yamlover 0.3.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/bin/yamlover.js +202 -0
  4. package/dist/server.js +4681 -0
  5. package/index.html +12 -0
  6. package/package.json +72 -0
  7. package/src/client/App.tsx +372 -0
  8. package/src/client/NodeView.tsx +422 -0
  9. package/src/client/TaskStrip.tsx +34 -0
  10. package/src/client/Tree.tsx +97 -0
  11. package/src/client/api.ts +186 -0
  12. package/src/client/icons.ts +91 -0
  13. package/src/client/links.tsx +108 -0
  14. package/src/client/live.ts +42 -0
  15. package/src/client/main.tsx +10 -0
  16. package/src/client/paste-html.ts +228 -0
  17. package/src/client/paste-links.ts +42 -0
  18. package/src/client/paths.ts +109 -0
  19. package/src/client/render.tsx +326 -0
  20. package/src/client/renderers/annotate.tsx +507 -0
  21. package/src/client/renderers/asciidoc.tsx +35 -0
  22. package/src/client/renderers/chapter.tsx +138 -0
  23. package/src/client/renderers/csv.tsx +233 -0
  24. package/src/client/renderers/decoded.tsx +72 -0
  25. package/src/client/renderers/djvu.tsx +97 -0
  26. package/src/client/renderers/doc.tsx +40 -0
  27. package/src/client/renderers/docx.tsx +49 -0
  28. package/src/client/renderers/epub.tsx +147 -0
  29. package/src/client/renderers/explorer.tsx +209 -0
  30. package/src/client/renderers/fb2.tsx +149 -0
  31. package/src/client/renderers/headings.ts +69 -0
  32. package/src/client/renderers/heic.tsx +23 -0
  33. package/src/client/renderers/imagemap.tsx +157 -0
  34. package/src/client/renderers/kml.ts +46 -0
  35. package/src/client/renderers/latex.tsx +36 -0
  36. package/src/client/renderers/map.tsx +205 -0
  37. package/src/client/renderers/marklower.tsx +119 -0
  38. package/src/client/renderers/markup.tsx +64 -0
  39. package/src/client/renderers/media.tsx +19 -0
  40. package/src/client/renderers/panzoom.ts +101 -0
  41. package/src/client/renderers/pdf.tsx +176 -0
  42. package/src/client/renderers/plaintext.tsx +120 -0
  43. package/src/client/renderers/plantuml.tsx +82 -0
  44. package/src/client/renderers/psd.tsx +25 -0
  45. package/src/client/renderers/registry.tsx +389 -0
  46. package/src/client/renderers/rtf.tsx +210 -0
  47. package/src/client/renderers/spreadsheet.tsx +105 -0
  48. package/src/client/renderers/tag.tsx +113 -0
  49. package/src/client/renderers/text.tsx +41 -0
  50. package/src/client/renderers/tiff.tsx +33 -0
  51. package/src/client/styles.css +1115 -0
  52. package/src/client/vendor/README.md +30 -0
  53. package/src/client/vendor/djvu.js +15535 -0
  54. package/src/client/vite-env.d.ts +31 -0
  55. package/src/server/api.ts +147 -0
  56. package/src/server/engine-api.ts +1442 -0
  57. package/src/server/gitignore.ts +81 -0
  58. package/src/server/node-kind.ts +48 -0
  59. package/src/server/tasks.ts +83 -0
  60. package/src/server/yamlover.ts +1133 -0
@@ -0,0 +1,1442 @@
1
+ /**
2
+ * engine-api.ts — the JSON API, backed by the new yamlover ENGINE.
3
+ *
4
+ * This replaces the legacy `loadEntity` materializer (./yamlover.ts) with the engine:
5
+ * `walkDir` (directory concrete → IR) + `Store` (SQLite property-graph index). It emits the
6
+ * SAME response shapes the React client already consumes (TreeNode, the `$yamloverLink` /
7
+ * `$yamloverRef` / `$yamloverBinary` markers, the schema view), so the UI works "as it was".
8
+ *
9
+ * Endpoints (path is JSON-space: `/key[0]/sub`):
10
+ * GET /api/info breadcrumb head (root label)
11
+ * GET /api/tree?path&depth the TOC subtree
12
+ * GET /api/json?path&depth&binary the node value (depth-limited; nested = link markers)
13
+ * GET /api/schema?path&depth the instance schema
14
+ * GET /api/blob?path a file-backed node's raw bytes
15
+ * GET /api/tagged?path the materials filed under a tag (annotations → targets)
16
+ * GET /api/events SSE: {type:"diff",…} reindex diffs + {type:"task",…} progress
17
+ * GET /api/tasks long-running tasks in flight (snapshot for a fresh page)
18
+ * GET /api/query?q&path the 3g query evaluator (colon match templates)
19
+ * GET /api/dangling pointers that did not resolve at index time
20
+ * POST /api/reindex manual reconcile (the watcher's fallback)
21
+ *
22
+ * The on-disk index lives at <root>/.yamlover/index.db. It is a derived cache with a persistent
23
+ * FILE MANIFEST (path + hash + size + mtime): startup re-indexes against it (the offline
24
+ * reconcile — unchanged blobs are never re-read, so it is cheap), and an FS watcher re-indexes
25
+ * on external edits (the watched-live tier), broadcasting what changed over /api/events.
26
+ *
27
+ * LONG-RUNNING WORK runs as background tasks (./tasks.ts): the initial index starts the moment
28
+ * createHandlers returns (the HTTP server can listen immediately and serve the PREVIOUS index —
29
+ * or an empty one on a cold start), and the background hasher then fills in content hashes for
30
+ * the large blobs the walk no longer reads. Store-mutating jobs (index, mv, paste, annotate)
31
+ * serialize through one writer queue; reads never wait.
32
+ */
33
+
34
+ import path from "node:path";
35
+ import fs from "node:fs";
36
+ import type { IncomingMessage, ServerResponse } from "node:http";
37
+ import { Store, reindex, reindexAsync, hashFileAsync, watchTree, loadSettings, mv, relinkMoved, evalQuery } from "../../../engine/ts/src/index.ts";
38
+ import type { NodeRow, EdgeRow, Settings, IndexDiff } from "../../../engine/ts/src/index.ts";
39
+ import { parseYamlover } from "../../../parser/ts/src/yamlover.ts";
40
+ import { pointerToken, anchorToken } from "../../../parser/ts/src/serialize-yamlover.ts";
41
+ import { colonSegment } from "../../../parser/ts/src/pointer.ts";
42
+ import { isPointer } from "../../../parser/ts/src/ir.ts";
43
+ import type { Node as IrNode } from "../../../parser/ts/src/ir.ts";
44
+ import { buildGitIgnore } from "./gitignore.js";
45
+ import { displayKind, ownedEntries, typeName } from "./node-kind.js";
46
+ import { TaskRegistry } from "./tasks.js";
47
+ import type { TaskHandle } from "./tasks.js";
48
+
49
+ type Handler = (req: IncomingMessage, res: ServerResponse, url: URL) => void;
50
+ interface Options {
51
+ gitignore?: boolean; // honor .gitignore for stray files (default: true)
52
+ watch?: boolean; // watch the tree and re-index on external edits (default: false; bin turns it on)
53
+ log?: (line: string) => void; // server-side progress lines (the bin wires console.log; tests stay silent)
54
+ }
55
+
56
+ // Marker keys + types the client recognizes (must match src/client expectations).
57
+ const LINK_KEY = "$yamloverLink";
58
+ const BINARY_KEY = "$yamloverBinary";
59
+ const MIXED_KEY = "$yamloverMixed"; // an omni/mix node: a self-value and/or interleaved items+fields
60
+ type Seg = string | number;
61
+ // Node-KIND classification (object|array|scalar|binary|omni|mix → the client `type:`) lives in
62
+ // ./node-kind.ts so it can be unit-tested against a Store without the HTTP layer.
63
+
64
+ export function createHandlers(dataRoot: string, opts: Options = {}): Handler & { close: () => void; ready: Promise<IndexDiff> } {
65
+ const rootName = path.basename(path.resolve(dataRoot)) || "/";
66
+ const dbPath = path.join(dataRoot, ".yamlover", "index.db");
67
+ // Project configuration (<root>/.yamlover/settings.yamlover) — defaults for WRITE paths
68
+ // (e.g. where new annotations are created). Read once at startup, like the index.
69
+ const settings: Settings = loadSettings(dataRoot);
70
+ // Skip git-ignored strays (node_modules, build output, …) so serving the project root works.
71
+ const ignore = opts.gitignore === false ? undefined : buildGitIgnore(dataRoot);
72
+
73
+ // ONE Store, open for the server's lifetime; every request is answered from it (indexed
74
+ // lookups — sub-millisecond). Freshness is the reconcile loop, not a per-request re-walk:
75
+ // `reindex` re-walks against the persisted file manifest (an unchanged blob is never
76
+ // re-read — the cost that once made refresh block on a click), swaps the tables in one
77
+ // transaction, and reports what changed. It runs at startup (the OFFLINE reconcile: external
78
+ // edits made while the server was down show up immediately) and on every FS-watcher batch
79
+ // (the WATCHED-LIVE tier), with POST /api/reindex as the manual fallback. Changes are pushed
80
+ // to clients over GET /api/events (SSE). Move inference / relinking waits on the serializers.
81
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
82
+ const store0 = new Store(dbPath);
83
+ const store = (): Store => store0;
84
+ const log = opts.log ?? ((): void => {});
85
+ let closed = false;
86
+
87
+ // SSE subscribers. Frames are typed: `{type:"diff", added,changed,removed,moved}` (a reindex
88
+ // that found changes, as client JSON paths) and `{type:"task", task}` (long-running task
89
+ // lifecycle — see ./tasks.ts).
90
+ const sseClients = new Set<ServerResponse>();
91
+ const sseWrite = (frame: unknown): void => {
92
+ const payload = JSON.stringify(frame);
93
+ for (const res of sseClients) res.write(`data: ${payload}\n\n`);
94
+ };
95
+ const broadcast = (diff: IndexDiff): void => {
96
+ if (diff.added.length + diff.changed.length + diff.removed.length + diff.moved.length === 0) return;
97
+ const toClient = (rel: string): string => segsToStr(rel.split("/"));
98
+ sseWrite({
99
+ type: "diff",
100
+ added: diff.added.map(toClient), changed: diff.changed.map(toClient), removed: diff.removed.map(toClient),
101
+ moved: diff.moved.map((m) => ({ from: toClient(m.from), to: toClient(m.to) })),
102
+ });
103
+ };
104
+ // ONE change currency for every write path: a mediated endpoint announces the file-level
105
+ // change it just made in the same IndexDiff shape the reconcile broadcasts, so every client
106
+ // surface (TOC, node pane, marks, tag pages) refreshes through the SAME SSE flow — never a
107
+ // per-endpoint push path. Incremental writes (annotate, tag) call this with the one file
108
+ // they touched; full-reindex writes (paste, mv) broadcast their reconcile diff directly.
109
+ const announce = (d: Partial<IndexDiff>): void => broadcast({ added: [], changed: [], removed: [], moved: [], ...d });
110
+ // a client JSON path (keys percent-encoded) as the root-relative FILE path diffs speak
111
+ const relFileOf = (clientPath: string): string => strToSegs(clientPath).map(String).join("/");
112
+ const tasks = new TaskRegistry((t) => sseWrite({ type: "task", task: t }));
113
+
114
+ // ONE WRITER at a time: every job that mutates the Store or needs a consistent manifest
115
+ // (indexing, mv, paste, annotations) chains here, so e.g. an annotation cannot be swallowed
116
+ // by a concurrently-committing full walk whose disk snapshot predates it. Read endpoints
117
+ // never queue — they answer from the current index (stale-but-instant during a reindex).
118
+ let chain: Promise<unknown> = Promise.resolve();
119
+ const enqueue = <T,>(fn: () => T | Promise<T>): Promise<T> => {
120
+ const p = chain.then(fn);
121
+ chain = p.catch(() => {}); // a failed job must not poison the queue
122
+ return p;
123
+ };
124
+
125
+ // The background HASHER: fills in content hashes the walk skipped (blobs over the inline
126
+ // limit), smallest-first, as a visible task. A singleton loop OUTSIDE the write queue — it
127
+ // only reads bytes; each tiny manifest update enqueues on its own, so a multi-GB file never
128
+ // holds the queue. It re-queries the store every step, so files added by later reconciles
129
+ // are picked up; a file that changed or vanished mid-hash fails the (size, mtime) guard and
130
+ // is skipped (the next reconcile re-queues it with fresh identity).
131
+ const gib = (b: number): string => (b / 2 ** 30).toFixed(1);
132
+ const BIG_FILE_BYTES = 256 * 2 ** 20; // show within-file byte progress above this
133
+ let hashing = false;
134
+ const scheduleHasher = (): void => {
135
+ if (hashing || closed) return;
136
+ if (store0.unhashedFiles(1).length === 0) return;
137
+ hashing = true;
138
+ void (async () => {
139
+ const skip = new Set<string>();
140
+ let done = 0;
141
+ let lastLog = 0;
142
+ const t0 = Date.now();
143
+ let h: TaskHandle | null = null;
144
+ try {
145
+ for (;;) {
146
+ if (closed) break;
147
+ const pending = store0.unhashedFiles().filter((f) => !skip.has(f.path));
148
+ if (pending.length === 0) break;
149
+ h ??= tasks.start("hashing large files");
150
+ const next = pending[0];
151
+ const total = done + pending.length;
152
+ h.progress(done, total, next.path);
153
+ const abs = path.join(dataRoot, ...next.path.split("/"));
154
+ let hash: string | null = null;
155
+ try {
156
+ hash = await hashFileAsync(abs, (bytes) => {
157
+ if (next.size >= BIG_FILE_BYTES) h?.progress(done, total, `${next.path} — ${gib(bytes)}/${gib(next.size)} GiB`);
158
+ });
159
+ } catch {
160
+ // unreadable or vanished — skip; a later reconcile re-queues it if it still exists
161
+ }
162
+ const st = hash !== null ? fs.statSync(abs, { throwIfNoEntry: false }) : undefined;
163
+ const fresh = st !== undefined && st.size === next.size && st.mtimeMs === next.mtimeMs;
164
+ const ok = hash !== null && fresh && !closed
165
+ ? await enqueue(() => store0.setFileHash(next.path, hash, next.size, next.mtimeMs))
166
+ : false;
167
+ if (!ok) {
168
+ skip.add(next.path);
169
+ continue;
170
+ }
171
+ done++;
172
+ const now = Date.now();
173
+ if (now - lastLog >= 500) {
174
+ lastLog = now;
175
+ log(`hashing ${done}/${total} — ${next.path}`);
176
+ }
177
+ }
178
+ h?.done();
179
+ if (h) log(`hashing done — ${done} file(s) in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
180
+ } catch (e) {
181
+ h?.fail(e);
182
+ log(`hashing FAILED — ${String((e as Error)?.message ?? e)}`);
183
+ } finally {
184
+ hashing = false;
185
+ }
186
+ })();
187
+ };
188
+
189
+ // A reindex usable inside an already-queued job (NOT queued itself — callers queue).
190
+ const doReindex = (): Promise<IndexDiff> => reindexAsync(store0, dataRoot, { ignore });
191
+
192
+ // The INITIAL index, as a background task: the server listens (and serves the previous
193
+ // on-disk index — or an empty one, cold) while the walk runs. Progress is determinate
194
+ // (an enumeration pre-pass counts the tree) and lands in SSE + the log.
195
+ const runIndexTask = (label: string): Promise<IndexDiff> =>
196
+ enqueue(async () => {
197
+ const h = tasks.start(label);
198
+ const t0 = Date.now();
199
+ let lastLog = 0;
200
+ log(`${label}…`);
201
+ try {
202
+ const diff = await reindexAsync(store0, dataRoot, {
203
+ ignore,
204
+ onProgress: (p) => {
205
+ h.progress(p.done, p.total, p.message);
206
+ const now = Date.now();
207
+ if (now - lastLog >= 500) {
208
+ lastLog = now;
209
+ log(`${label} ${p.done}/${p.total ?? "?"}${p.message ? ` — ${p.message}` : ""}`);
210
+ }
211
+ },
212
+ });
213
+ h.done();
214
+ log(
215
+ `${label} done in ${((Date.now() - t0) / 1000).toFixed(1)}s` +
216
+ ` (+${diff.added.length} ~${diff.changed.length} −${diff.removed.length} →${diff.moved.length})`,
217
+ );
218
+ broadcast(diff);
219
+ scheduleHasher();
220
+ return diff;
221
+ } catch (e) {
222
+ h.fail(e);
223
+ log(`${label} FAILED — ${String((e as Error)?.message ?? e)}`);
224
+ throw e;
225
+ }
226
+ });
227
+
228
+ // An UNMEDIATED move (mv in a shell, a file manager) shows up as an inferred `moved` —
229
+ // relink the inbound refs the way the mediated tier would (ENGINE.md tier 2: "inferred
230
+ // as a move and relinked"), then reconcile once more so the rewritten files re-index.
231
+ const reconcile = (): Promise<IndexDiff> =>
232
+ enqueue(async () => {
233
+ const h = tasks.start("reconciling");
234
+ try {
235
+ const diff = await doReindex();
236
+ if (diff.moved.length > 0) {
237
+ const r = relinkMoved(dataRoot, diff.moved, { ignore });
238
+ if (r.editedFiles.length > 0) {
239
+ const follow = reindex(store0, dataRoot, { ignore });
240
+ diff.changed = [...new Set([...diff.changed, ...follow.changed])];
241
+ }
242
+ }
243
+ h.done();
244
+ broadcast(diff);
245
+ scheduleHasher();
246
+ return diff;
247
+ } catch (e) {
248
+ h.fail(e);
249
+ throw e;
250
+ }
251
+ });
252
+
253
+ const ready = runIndexTask(`indexing ${rootName}`);
254
+ const stopWatch = opts.watch
255
+ ? watchTree(dataRoot, () => {
256
+ reconcile().catch((e) => log(`reconcile FAILED — ${String((e as Error)?.message ?? e)}`));
257
+ }, { ignore })
258
+ : null;
259
+
260
+ const handler: Handler = (req, res, url) => {
261
+ try {
262
+ const s = store();
263
+
264
+ // Server-pushed change notifications: an SSE stream of reindex diffs (client JSON
265
+ // paths). The comment pings keep idle proxies from reaping the connection.
266
+ if (req.method === "GET" && url.pathname === "/api/events") {
267
+ res.statusCode = 200;
268
+ res.setHeader("Content-Type", "text/event-stream");
269
+ res.setHeader("Cache-Control", "no-cache");
270
+ res.setHeader("Connection", "keep-alive");
271
+ res.write(": connected\n\n");
272
+ sseClients.add(res);
273
+ const ping = setInterval(() => res.write(": ping\n\n"), 30_000);
274
+ req.on("close", () => { clearInterval(ping); sseClients.delete(res); });
275
+ return;
276
+ }
277
+
278
+ // Manual reconcile — the watcher's fallback; responds with what changed (inferred
279
+ // moves are relinked, like the watcher path). Queued behind any in-flight index.
280
+ if (req.method === "POST" && url.pathname === "/api/reindex") {
281
+ reconcile()
282
+ .then((diff) => sendJson(res, 200, diff))
283
+ .catch((e) => sendJson(res, 500, { error: String((e as Error).message || e) }));
284
+ return;
285
+ }
286
+
287
+ // Long-running server tasks (indexing, hashing, …) currently in flight (or just
288
+ // finished) — the snapshot a freshly loaded page needs; updates ride /api/events.
289
+ if (url.pathname === "/api/tasks") {
290
+ sendJson(res, 200, tasks.list());
291
+ return;
292
+ }
293
+
294
+ // The QUERY evaluator (PLAN.md 3g / QUERY.md): a colon-grammar match template,
295
+ // evaluated at `path` (default: the root). Results are client JSON paths.
296
+ if (url.pathname === "/api/query") {
297
+ const q = url.searchParams.get("q") || "";
298
+ const at = storePath(strToSegs(url.searchParams.get("path") || ":"));
299
+ try {
300
+ const results = evalQuery(s, q, at).map((p) => segsToStr(storePathToSegs(p)));
301
+ sendJson(res, 200, { results });
302
+ } catch (e) {
303
+ sendJson(res, 400, { error: String((e as Error).message || e) });
304
+ }
305
+ return;
306
+ }
307
+
308
+ // Pointers that did not resolve at index time (ENGINE.md: reported, never dropped).
309
+ if (url.pathname === "/api/dangling") {
310
+ sendJson(res, 200, s.dangling().map((d) => ({ from: segsToStr(storePathToSegs(d.from)), raw: d.raw, reason: d.reason })));
311
+ return;
312
+ }
313
+
314
+ // Create an annotation — ONE TAG APPLICATION (a WRITE path): persist it as a yamlover file
315
+ // under the project's default annotation location (settings.yamlover; `/annotations` by
316
+ // default), then index it so it joins the graph (reverse-linked to its material, member of
317
+ // its tag). An annotation is NOT tied to that location — it may be moved to (or authored
318
+ // in) any directory and keeps working; the setting only says where NEW ones land. Body:
319
+ // { target, tag, selector?, description? } — target/tag are the material's and the applied
320
+ // tag's JSON paths; no selector applies the tag to the WHOLE node.
321
+ if (req.method === "POST" && url.pathname === "/api/annotate") {
322
+ readBody(req)
323
+ .then((data) =>
324
+ // Queued: an incremental row added while a full walk (whose disk snapshot predates
325
+ // this annotation) is committing would be silently swapped away.
326
+ enqueue(() => {
327
+ const a = data as AnnotationInput;
328
+ const tagStore = storePath(strToSegs(a.tag ?? ""));
329
+ if (!a?.tag || s.node(tagStore)?.format !== TAG_FORMAT) {
330
+ throw new Error("annotation needs a `tag` that is an x-yamlover-tag node");
331
+ }
332
+ const annPath = writeAnnotation(dataRoot, settings.annotations.location, a);
333
+ // Update the index INCREMENTALLY (not a full rebuild — that re-reads every changed
334
+ // file and blocks the next click). Add just this annotation's nodes + its edges.
335
+ const doc = parseYamlover(fs.readFileSync(path.join(dataRoot, ...strToSegs(annPath).map(String)), "utf8"), annPath);
336
+ s.addAnnotation(storePath(strToSegs(annPath)), storePath(strToSegs(a.target)), doc, tagStore);
337
+ announce({ added: [relFileOf(annPath)] });
338
+ return { path: annPath };
339
+ }),
340
+ )
341
+ .then((body) => sendJson(res, 201, body))
342
+ .catch((e) => sendJson(res, 400, { error: String((e as Error).message || e) }));
343
+ return;
344
+ }
345
+
346
+ // Delete an annotation by its node path (recolor = delete + create, client-side). Removes
347
+ // the annotation FILE and its index rows — incrementally. Works wherever the annotation
348
+ // lives: the guard is its schema (`x-yamlover-annotation`), not a directory.
349
+ if (req.method === "DELETE" && url.pathname === "/api/annotate") {
350
+ const annPath = url.searchParams.get("path") || "";
351
+ enqueue(() => {
352
+ deleteAnnotation(dataRoot, s, annPath);
353
+ s.removeAnnotation(storePath(strToSegs(annPath)));
354
+ announce({ removed: [relFileOf(annPath)] });
355
+ })
356
+ .then(() => sendJson(res, 200, { ok: true }))
357
+ .catch((e) => sendJson(res, 400, { error: String((e as Error).message || e) }));
358
+ return;
359
+ }
360
+
361
+ // Create a NAMED TAG (a WRITE path — the picker's create-on-miss): add
362
+ // `<name>: !!<*yamlover/$defs/tag>` to the taxonomy body at the project's default tags
363
+ // location (settings.yamlover; `/tags` by default → `<location>/.yamlover/body.yamlover`),
364
+ // then reconcile so it joins the graph. The direct schema attach makes the node an
365
+ // `x-yamlover-tag` wherever the taxonomy lives — like an annotation, a created tag may be
366
+ // moved anywhere and keeps working. Idempotent: a tag already at that path is returned
367
+ // as-is. Body: { name }.
368
+ if (req.method === "POST" && url.pathname === "/api/tag") {
369
+ readBody(req)
370
+ .then((data) =>
371
+ enqueue(async () => {
372
+ const name = String((data as { name?: unknown })?.name ?? "").trim();
373
+ if (!name) throw new Error("tag needs a non-empty name");
374
+ const segs = [...strToSegs(settings.tags.location), name];
375
+ const tagPath = segsToStr(segs);
376
+ const existing = s.node(storePath(segs));
377
+ if (existing) {
378
+ if (existing.format !== TAG_FORMAT) throw new Error(`a node already exists at ${tagPath} and is not a tag`);
379
+ const color = s.node(storePath(segs) + ":color")?.value;
380
+ return { path: tagPath, name, color: typeof color === "string" ? color : null, created: false };
381
+ }
382
+ // Index INCREMENTALLY (the annotate pattern — not a full rebuild, which stats the
383
+ // whole tree and blocks the picker for seconds on a big root); the watcher's
384
+ // reconcile re-walks the edited body and trues the rows up moments later.
385
+ const written = writeTag(dataRoot, settings.tags.location, name);
386
+ s.addTag(storePath(strToSegs(settings.tags.location)), name, written.pos, written.node);
387
+ if (s.node(storePath(segs))?.format !== TAG_FORMAT) throw new Error(`the created tag did not index as a tag: ${tagPath}`);
388
+ announce(written.createdFile ? { added: [written.file] } : { changed: [written.file] });
389
+ return { path: tagPath, name, color: null, created: true };
390
+ }),
391
+ )
392
+ .then((body) => sendJson(res, 201, body))
393
+ .catch((e) => sendJson(res, 400, { error: String((e as Error).message || e) }));
394
+ return;
395
+ }
396
+
397
+ // Upload a pasted file, TEXT, or RICH content (a WRITE path). A file onto a DIRECTORY
398
+ // page → it lands in that directory; onto a CHAPTER page → it lands in the chapter's
399
+ // owning directory AND a `*…` pointer to it is appended as the chapter's last chunk.
400
+ // TEXT onto a chapter → the text itself is appended as a new chunk (no file); anywhere
401
+ // else → a new chapter .yamlover file in the nearest directory. RICH (an HTML selection:
402
+ // text + image chunks + heading-nested subchapters) onto a chapter → chunks append to
403
+ // `chunks:`, subchapters to `children:`; anywhere else → a new chapter (directory-backed
404
+ // when it carries files). Body: { path, filename, contentBase64 } | { path, text } |
405
+ // { path, rich }. A new file / edited chapter source needs the graph re-walked — a
406
+ // manifest-cached reconcile, so only the new/edited files are read.
407
+ if (req.method === "POST" && url.pathname === "/api/paste") {
408
+ readBody(req)
409
+ .then((data) =>
410
+ enqueue(async () => {
411
+ const result = handlePaste(dataRoot, s, data as PasteInput);
412
+ broadcast(await doReindex());
413
+ scheduleHasher();
414
+ return result;
415
+ }),
416
+ )
417
+ .then((result) => sendJson(res, 201, result))
418
+ .catch((e) => sendJson(res, 400, { error: String((e as Error).message || e) }));
419
+ return;
420
+ }
421
+
422
+ // Move/rename a file or directory (a WRITE path — the engine-MEDIATED tier): the engine
423
+ // relocates the FS object AND rewrites every inbound `*`/`~` pointer in the source files
424
+ // (surgical span edits; ENGINE.md "a move rewrites references"). Body: { from, to } as
425
+ // JSON paths addressing FS-level nodes (keyed segments only — no positions).
426
+ if (req.method === "POST" && url.pathname === "/api/mv") {
427
+ readBody(req)
428
+ .then((data) =>
429
+ enqueue(async () => {
430
+ const { from, to } = data as { from?: string; to?: string };
431
+ const rel = (p: string, what: string): string => {
432
+ const segs = strToSegs(p);
433
+ if (segs.length === 0) throw new Error(`mv: ${what} must name a file or directory`);
434
+ if (segs.some((g) => typeof g === "number")) throw new Error(`mv: ${what} must be a file/directory path (no positions)`);
435
+ return segs.join("/");
436
+ };
437
+ const report = mv(dataRoot, rel(from ?? "", "from"), rel(to ?? "", "to"), { ignore });
438
+ const diff = await doReindex();
439
+ broadcast(diff);
440
+ return { ...report, diff };
441
+ }),
442
+ )
443
+ .then((body) => sendJson(res, 200, body))
444
+ .catch((e) => sendJson(res, 400, { error: String((e as Error).message || e) }));
445
+ return;
446
+ }
447
+
448
+ const segs = strToSegs(url.searchParams.get("path") || ":");
449
+ const p = storePath(segs);
450
+ const depth = parseDepth(url.searchParams.get("depth"));
451
+
452
+ if (url.pathname === "/api/info") {
453
+ sendJson(res, 200, { root: rootName });
454
+ return;
455
+ }
456
+
457
+ // The annotations whose `target` is this material (the engine's reverse link).
458
+ if (url.pathname === "/api/annotations") {
459
+ sendJson(res, 200, annotationsFor(dataRoot, s, segs));
460
+ return;
461
+ }
462
+
463
+ // The materials filed under this tag (annotations resolved to their `target`; deduped) —
464
+ // the explorer renderer's member list for a tag page.
465
+ if (url.pathname === "/api/tagged") {
466
+ const row = s.node(p);
467
+ if (!row || row.format !== TAG_FORMAT) return notFound(res, url);
468
+ sendJson(res, 200, taggedMaterials(dataRoot, s, p));
469
+ return;
470
+ }
471
+
472
+ if (url.pathname === "/api/tree") {
473
+ const row = s.node(p);
474
+ if (!row) return notFound(res, url);
475
+ const label = segs.length === 0 ? rootName : labelFor(s, p, segs[segs.length - 1]);
476
+ sendJson(res, 200, buildTree(dataRoot, s, segs, label, depth ?? 3));
477
+ return;
478
+ }
479
+
480
+ if (url.pathname === "/api/blob") {
481
+ const file = path.join(dataRoot, ...segs.map(String));
482
+ if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) return notFound(res, url);
483
+ // STREAM the bytes — a readFileSync of a big PDF/video would block the event loop
484
+ // (and with it every other request and the Vite HMR socket) for its whole read.
485
+ res.statusCode = 200;
486
+ res.setHeader("Content-Type", s.node(p)?.format ?? formatFromExt(file) ?? "application/octet-stream");
487
+ res.setHeader("Content-Length", String(fs.statSync(file).size));
488
+ const stream = fs.createReadStream(file);
489
+ stream.on("error", () => res.destroy());
490
+ stream.pipe(res);
491
+ return;
492
+ }
493
+
494
+ const row = s.node(p);
495
+ if (!row) return notFound(res, url);
496
+ const viewDepth = depth ?? 1;
497
+ const kind = displayKind(s, p, row);
498
+
499
+ if (url.pathname === "/api/json") {
500
+ const wantBytes = kind === "binary" && url.searchParams.get("binary") === "1";
501
+ sendJson(res, 200, {
502
+ path: segsToStr(segs),
503
+ type: tocType(s, p, row),
504
+ format: row.format ?? null,
505
+ concrete: concreteOf(dataRoot, segs, row), // dir | yamlover | null (stat-derived; engine tracks no per-node concrete yet)
506
+ documentPath: documentPath(s, segs), // nearest enclosing document root (for `/…` links)
507
+ title: titleOf(s, p),
508
+ description: null,
509
+ value: wantBytes ? binaryContent(dataRoot, segs, row) : projectValue(dataRoot, s, segs, viewDepth, true),
510
+ relations: buildRelations(dataRoot, s, segs),
511
+ });
512
+ } else if (url.pathname === "/api/schema") {
513
+ sendJson(res, 200, projectSchema(dataRoot, s, segs, viewDepth, true));
514
+ } else {
515
+ notFound(res, url);
516
+ }
517
+ } catch (exc) {
518
+ sendJson(res, 400, { error: (exc as Error).message || String(exc) });
519
+ }
520
+ };
521
+ // Tear-down for embedders/tests: stop the watcher + hasher, drop SSE subscribers, close the
522
+ // DB. `ready` resolves when the initial background index lands (tests await it; the bin
523
+ // catches it so a failed index cannot crash as an unhandled rejection).
524
+ return Object.assign(handler, {
525
+ ready,
526
+ close: (): void => {
527
+ closed = true;
528
+ stopWatch?.();
529
+ for (const r of sseClients) r.end();
530
+ sseClients.clear();
531
+ store0.close();
532
+ },
533
+ });
534
+ }
535
+
536
+ // --------------------------------------------------------------------------- //
537
+ // Projection (Store rows → the client's value / schema / tree / marker shapes)
538
+ // --------------------------------------------------------------------------- //
539
+
540
+ /** The (type) label shown in the TOC/header — the schema-style {@link typeName}. */
541
+ function tocType(s: Store, p: string, row: NodeRow): string {
542
+ return typeName(s, p, row);
543
+ }
544
+
545
+ /** How the node at `segs` is stored on disk, as far as a stat can tell: `"yamlover"` (a directory
546
+ * with a `.yamlover/` marker), `"dir"` (a plain folder), or null (not a filesystem directory —
547
+ * files and interior nodes alike; the engine does not track per-node concrete yet). Only a
548
+ * mapping can be a directory, and positional segments never name FS entries, so most nodes
549
+ * short-circuit without touching the disk. */
550
+ function concreteOf(dataRoot: string, segs: Seg[], row: NodeRow): "dir" | "yamlover" | null {
551
+ if (row.type !== "mapping") return null;
552
+ if (segs.some((g) => typeof g === "number")) return null;
553
+ const abs = path.resolve(dataRoot, ...segs.map(String));
554
+ let st: fs.Stats | undefined;
555
+ try { st = fs.statSync(abs); } catch { return null; }
556
+ if (!st.isDirectory()) return null;
557
+ return fs.existsSync(path.join(abs, ".yamlover")) ? "yamlover" : "dir";
558
+ }
559
+
560
+ // --------------------------------------------------------------------------- //
561
+ // Relation direction. A relation has ONE natural direction (upstream → downstream), regardless of
562
+ // which side authored it: a forward `*` ref / containment runs from→to; a `~` back-edge is stored
563
+ // reversed (it is authored on the downstream side, pointing back up), so its nature is to→from.
564
+ // A node's DOWNSTREAM relations (it is the natural source) are its children/value, shown below the
565
+ // <hr>; its UPSTREAM relations (it is the natural target) are shown above it. Authoring a relation
566
+ // both ways (forward at the parent AND `~` at the child) yields two stored edges for ONE relation,
567
+ // so each direction is de-duplicated by (label, other end). This split is used everywhere — the
568
+ // value/schema projections and the relations panel — so nothing has to special-case `~`.
569
+ // --------------------------------------------------------------------------- //
570
+
571
+ const relKey = (label: string | null, other: string): string => `${label ?? ""}${other}`;
572
+
573
+ /** A node's DOWNSTREAM entries (it is the natural source), in source order: its containment
574
+ * children and forward `*` refs (authored here, positioned), then any `~` back-edges that target
575
+ * it from elsewhere (authored on the downstream node, so unpositioned → appended, ordered
576
+ * lexicographically by the member's path — URIs.md §`~-`).
577
+ *
578
+ * Dedup is by identity, which only a LABEL provides: a same-label both-ways pair (`L: *x` +
579
+ * `~L: …`) is one relation authored twice → one entry. A KEYLESS membership (label null, the
580
+ * `~-` form) has no identity and is ADDITIVE — every declaration appends an element, even
581
+ * alongside a forward `- *member` (lists repeat) — unless the container is a `!!set` /
582
+ * `uniqueItems: true` (NodeMeta.set), where membership is by target and ALL duplicates
583
+ * (forward+forward, forward+reverse, reverse+reverse) collapse. */
584
+ function downstreamEntries(s: Store, p: string): { to: string; label: string | null; pos: number | null; kind: EdgeRow["kind"] }[] {
585
+ const isSet = !!s.node(p)?.meta?.set;
586
+ let own = s.entries(p).filter((e) => e.kind !== "back"); // contain + forward ref, ordered by pos
587
+ const seen = new Set(own.map((e) => relKey(e.label, e.to)));
588
+ if (isSet) {
589
+ const kept = new Set<string>(); // set semantics: an element appears at most once
590
+ own = own.filter((e) => { const k = relKey(e.label, e.to); if (kept.has(k)) return false; kept.add(k); return true; });
591
+ }
592
+ const out: { to: string; label: string | null; pos: number | null; kind: EdgeRow["kind"] }[] = [...own];
593
+ const backs = s.relationships(p).in
594
+ .filter((e) => e.kind === "back" && e.from)
595
+ .sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0)); // lexicographic by member path
596
+ for (const e of backs) {
597
+ const k = relKey(e.label, e.from); // natural target of a back-edge is its `from`
598
+ if (e.label != null || isSet) {
599
+ if (seen.has(k)) continue;
600
+ seen.add(k);
601
+ }
602
+ out.push({ to: e.from, label: e.label, pos: null, kind: "ref" });
603
+ }
604
+ return out;
605
+ }
606
+
607
+ /** A node value as plain JSON-able data. `depth` limits nesting; a container past the budget,
608
+ * or any non-top binary, becomes a `$yamloverLink` marker the client navigates on click. */
609
+ function projectValue(dataRoot: string, s: Store, segs: Seg[], depth: number, top: boolean): unknown {
610
+ const p = storePath(segs);
611
+ const row = s.node(p)!;
612
+ const k = displayKind(s, p, row);
613
+ if (!top && depth <= 0) return linkMarker(dataRoot, s, segs);
614
+ if (k === "binary" && !top) return linkMarker(dataRoot, s, segs);
615
+ if (k === "binary") return { size: row.size, format: row.format }; // top binary header
616
+ // DOWNSTREAM entries in order — containment recursed, a forward `*` ref or an incoming `~`
617
+ // back-edge shown as a link marker to the downstream node (so a `chunks` array mixing inline
618
+ // blocks and `*sample.png` pointers is whole, and a child reached only by `~` still appears).
619
+ const kids = downstreamEntries(s, p);
620
+ const project = (c: { to: string; label: string | null; pos: number | null; kind: string }) =>
621
+ c.kind === "contain"
622
+ ? projectValue(dataRoot, s, [...segs, c.label ?? c.pos ?? 0], depth - 1, false)
623
+ : linkMarker(dataRoot, s, storePathToSegs(c.to)); // pointer → a marker to where it resolves
624
+ if (k === "array") return kids.map(project);
625
+ if (k === "omni" || k === "mix") {
626
+ // A `$yamloverMixed` marker preserving source order: each entry is positional (`key: null` →
627
+ // a `- item`) or keyed (`key: "scale"` → `scale: …`); an omni also carries its self-value.
628
+ const entries = kids.map((c) => ({ key: c.label, value: project(c) }));
629
+ const marker: Record<string, unknown> = { kind: k, entries };
630
+ if (k === "omni") marker.value = row.value; // the node's own scalar self-value (the `!!omni 5`)
631
+ return { [MIXED_KEY]: marker };
632
+ }
633
+ if (k === "object") {
634
+ const out: Record<string, unknown> = {};
635
+ for (const c of kids) out[c.label ?? String(c.pos)] = project(c);
636
+ return out;
637
+ }
638
+ return row.value; // scalar
639
+ }
640
+
641
+ /** The instance schema (every value `v` → `{const: v}`); containers past depth = link markers. */
642
+ function projectSchema(dataRoot: string, s: Store, segs: Seg[], depth: number, top: boolean): unknown {
643
+ const p = storePath(segs);
644
+ const row = s.node(p)!;
645
+ const k = displayKind(s, p, row);
646
+ if ((k === "object" || k === "array" || k === "mix" || k === "omni") && depth <= 0) return linkMarker(dataRoot, s, segs);
647
+ if (k === "binary" && !top) return linkMarker(dataRoot, s, segs);
648
+ const schema: Record<string, unknown> = { type: typeName(s, p, row) }; // object|array|binary|mixed|variant|<scalar>
649
+ if (row.format) schema.format = row.format;
650
+ const kids = downstreamEntries(s, p);
651
+ const sub = (c: { to: string; label: string | null; pos: number | null; kind: string }) =>
652
+ c.kind === "contain" ? projectSchema(dataRoot, s, [...segs, c.label ?? c.pos ?? 0], depth - 1, false) : linkMarker(dataRoot, s, storePathToSegs(c.to));
653
+ if (k === "object" || k === "mix" || k === "omni") {
654
+ // mixed/variant fields: keyless entries keep their `[pos]` key, keyed ones their name; a
655
+ // variant (omni) also pins its self-value. (Order is the property insertion order.)
656
+ const props: Record<string, unknown> = {};
657
+ for (const c of kids) props[c.label ?? `[${c.pos}]`] = sub(c);
658
+ schema.properties = props;
659
+ if (k === "omni") schema.value = row.value;
660
+ } else if (k === "array") {
661
+ schema.prefixItems = kids.map(sub);
662
+ schema.items = false;
663
+ } else if (k === "binary") {
664
+ schema.const = { size: row.size, format: row.format };
665
+ } else {
666
+ schema.const = row.value;
667
+ }
668
+ const t = titleOf(s, p);
669
+ if (t) schema.title = t;
670
+ return schema;
671
+ }
672
+
673
+ /** A `$yamloverLink` marker for the node at `segs` (a navigable summary). */
674
+ function linkMarker(dataRoot: string, s: Store, segs: Seg[]): Record<string, unknown> {
675
+ const p = storePath(segs);
676
+ const row = s.node(p)!;
677
+ const k = displayKind(s, p, row);
678
+ const info: Record<string, unknown> = { kind: k, type: tocType(s, p, row), path: segsToStr(segs) };
679
+ if (row.format) info.format = row.format;
680
+ const concrete = concreteOf(dataRoot, segs, row);
681
+ if (concrete) info.concrete = concrete; // a folder child renders with a folder icon
682
+ const title = titleOf(s, p);
683
+ if (title) info.title = title;
684
+ if (k === "binary") info.size = row.size;
685
+ else if (k === "scalar") info.value = row.value;
686
+ else if (k === "omni" || k === "mix") {
687
+ info.count = ownedEntries(s, p).length; // owned items + fields (reverse members excluded)
688
+ if (k === "omni") info.value = row.value; // the self-scalar, for the link label
689
+ } else info.count = s.children(p).length;
690
+ if (row.format === TAG_FORMAT) {
691
+ // a pure color tag's explicit color rides the link, so badges color correctly everywhere
692
+ const c = s.node(p + ":color")?.value;
693
+ if (typeof c === "string") info.color = c;
694
+ }
695
+ return { [LINK_KEY]: info };
696
+ }
697
+
698
+ const segsEqual = (a: Seg[], b: Seg[]): boolean => a.length === b.length && a.every((x, i) => x === b[i]);
699
+
700
+ /** An upstream node's path written in the scope it has FROM the current node's document frame:
701
+ * document-relative (`:eve`) when it lives in the same document, else a project-scope link
702
+ * (`::examples:…`) — mirroring the colon scope ladder (SEPARATOR.md: `:` = document root,
703
+ * `::` = project). */
704
+ function scopedPath(s: Store, src: Seg[], currentDoc: Seg[]): string {
705
+ if (segsEqual(documentRootSegs(s, src), currentDoc)) return segsToStr(src.slice(currentDoc.length)); // `:…`
706
+ return "::" + segsToStr(src).slice(1); // `::…` — a project-scope link
707
+ }
708
+
709
+ /** The relations panel: this node's UPSTREAM relations — those for which it is the natural target.
710
+ * Led by the containment parent as `..`, then each `*`/`~` upstream source: a forward ref authored
711
+ * AT the source (stored into this node) or a `~` back-edge authored here pointing at the source
712
+ * (stored out of it) — the same relation either way, so deduped by source + label. Each is keyed
713
+ * by the path it has from this node's document frame, with a link to its summary; a source that is
714
+ * a tag node is peeled into a header badge by splitTagRefs. (A tag is upstream of what it files —
715
+ * the membership `~tag` back-edge lands here naturally, no special-casing.) */
716
+ function buildRelations(dataRoot: string, s: Store, segs: Seg[]): Record<string, unknown> {
717
+ const p = storePath(segs);
718
+ const out: Record<string, unknown> = {};
719
+ const put = (label: string, marker: unknown) => {
720
+ let k = label;
721
+ for (let i = 2; k in out; i++) k = `${label} (${i})`;
722
+ out[k] = marker;
723
+ };
724
+
725
+ // The containment parent — the upstream containment relation, always the primary way up.
726
+ if (segs.length > 0) put("..", linkMarker(dataRoot, s, segs.slice(0, -1)));
727
+
728
+ // Upstream `*`/`~` sources (this node is the natural target), deduped across forward+reverse
729
+ // authoring. A forward ref INTO p has its source at `from`; a `~` back-edge OUT of p (stored
730
+ // reversed) has its source at `to`.
731
+ const currentDoc = documentRootSegs(s, segs);
732
+ const { out: outEdges, in: inEdges } = s.relationships(p);
733
+ const upstream = new Map<string, string>(); // relKey → source store-path
734
+ const addUp = (src: string | null, label: string | null) => {
735
+ if (src) upstream.set(relKey(label, src), src);
736
+ };
737
+ for (const e of inEdges) if (e.kind === "ref") addUp(e.from, e.label); // forward ref INTO p
738
+ for (const e of outEdges) if (e.kind === "back") addUp(e.to, e.label); // `~` back-edge OUT of p
739
+ for (const src of upstream.values()) {
740
+ const segs2 = storePathToSegs(src);
741
+ put(scopedPath(s, segs2, currentDoc), linkMarker(dataRoot, s, segs2));
742
+ }
743
+ return out;
744
+ }
745
+
746
+ /** A binary leaf's bytes as a base64 payload (only when the leaf itself is selected). */
747
+ function binaryContent(dataRoot: string, segs: Seg[], row: NodeRow): Record<string, unknown> {
748
+ const file = path.join(dataRoot, ...segs.map(String));
749
+ const bytes = fs.existsSync(file) ? fs.readFileSync(file) : Buffer.alloc(0);
750
+ return { [BINARY_KEY]: { format: row.format ?? null, size: row.size ?? bytes.length, base64: bytes.toString("base64") } };
751
+ }
752
+
753
+ interface TreeNode {
754
+ path: string; label: string; type: string; format: string | null;
755
+ concrete: string | null; hasChildren: boolean; children: TreeNode[];
756
+ }
757
+
758
+ /** The TOC subtree rooted at `segs`, `depth` levels deep (every node listed). */
759
+ function buildTree(dataRoot: string, s: Store, segs: Seg[], label: string, depth: number): TreeNode {
760
+ const p = storePath(segs);
761
+ const row = s.node(p)!;
762
+ const node: TreeNode = {
763
+ path: segsToStr(segs),
764
+ label,
765
+ type: tocType(s, p, row),
766
+ format: row.format ?? null,
767
+ concrete: concreteOf(dataRoot, segs, row),
768
+ hasChildren: s.hasChildren(p),
769
+ children: [],
770
+ };
771
+ if (s.hasChildren(p) && depth > 0) {
772
+ for (const c of s.children(p)) {
773
+ const seg = c.label ?? c.pos ?? 0;
774
+ node.children.push(buildTree(dataRoot, s, [...segs, seg], labelFor(s, c.to, seg), depth - 1));
775
+ }
776
+ }
777
+ return node;
778
+ }
779
+
780
+ /** A node's tree label: an instance `title` child, else the key / `[index]`. */
781
+ function labelFor(s: Store, p: string, keyOrIdx: Seg): string {
782
+ const t = titleOf(s, p);
783
+ if (t) return t;
784
+ return typeof keyOrIdx === "number" ? `[${keyOrIdx}]` : keyOrIdx;
785
+ }
786
+
787
+ // --------------------------------------------------------------------------- //
788
+ // Annotations — graph-native TAG APPLICATIONS: each is a yamlover object under
789
+ // `<root>/annotations/`, pointing (`target: *//…`) at its material and holding a keyless `~-`
790
+ // membership in its applied tag; a material's annotations are the inverse of the target edges.
791
+ // --------------------------------------------------------------------------- //
792
+
793
+ const TAG_FORMAT = "x-yamlover-tag";
794
+
795
+ interface AnnotationInput {
796
+ target: string; // the material's JSON path (e.g. "/60-simple-chapter.yamlover")
797
+ tag: string; // the applied tag's JSON path (e.g. "/yamlover/tags/colors/yellow")
798
+ selector?: Record<string, unknown>; // { type: "text", exact, prefix, suffix } | { type:"rect", … }; absent = whole node
799
+ description?: string; // the per-application comment
800
+ }
801
+
802
+ /** The tag an annotation applies — its keyless `back` edge to an `x-yamlover-tag` node —
803
+ * projected as { path, name, color } (color = the tag's explicit `color`, else null: the
804
+ * client derives a hue from the name). Null for a legacy annotation with no tag. */
805
+ function appliedTag(s: Store, annStorePath: string): { path: string; name: string; color: string | null } | null {
806
+ const e = s.relationships(annStorePath).out.find(
807
+ (t) => t.kind === "back" && t.label === null && s.node(t.to)?.format === TAG_FORMAT,
808
+ );
809
+ if (!e) return null;
810
+ const segs = storePathToSegs(e.to);
811
+ const color = s.node(e.to + ":color")?.value;
812
+ return { path: segsToStr(segs), name: String(segs[segs.length - 1] ?? ""), color: typeof color === "string" ? color : null };
813
+ }
814
+
815
+ /** The annotations whose `target` resolves to this material — the incoming `ref` edges from
816
+ * `x-yamlover-annotation` nodes — each projected to its full object (selector, description,
817
+ * created) plus its applied `tag` { path, name, color }. */
818
+ function annotationsFor(dataRoot: string, s: Store, segs: Seg[]): unknown[] {
819
+ const p = storePath(segs);
820
+ const out: unknown[] = [];
821
+ for (const e of s.relationships(p).in) {
822
+ if (e.kind !== "ref") continue;
823
+ const src = s.node(e.from);
824
+ if (src?.format !== "x-yamlover-annotation") continue;
825
+ const aSegs = storePathToSegs(e.from);
826
+ out.push({
827
+ path: segsToStr(aSegs),
828
+ tag: appliedTag(s, e.from),
829
+ ...(projectValue(dataRoot, s, aSegs, 6, true) as Record<string, unknown>),
830
+ });
831
+ }
832
+ return out;
833
+ }
834
+
835
+ /** The MATERIALS filed under a tag — every node holding a `~` membership in it, with an
836
+ * annotation (one tag APPLICATION) resolved to its `target` material. Deduped by material:
837
+ * two annotations applying the same tag to one node, or a direct `~- *tag` alongside an
838
+ * annotation, show the material once. Subtags are containment children, not memberships —
839
+ * they never appear here. Ordered lexicographically by the member's path, like
840
+ * {@link downstreamEntries}' back-edge tail. */
841
+ function taggedMaterials(dataRoot: string, s: Store, tagStorePath: string): unknown[] {
842
+ const seen = new Set<string>();
843
+ const out: unknown[] = [];
844
+ const backs = s.relationships(tagStorePath).in
845
+ .filter((e) => e.kind === "back" && e.from)
846
+ .sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
847
+ for (const e of backs) {
848
+ let material = e.from;
849
+ if (s.node(e.from)?.format === "x-yamlover-annotation") {
850
+ const t = s.relationships(e.from).out.find((o) => o.kind === "ref" && o.label === "target");
851
+ if (!t) continue; // a dangling annotation — no resolvable material
852
+ material = t.to;
853
+ }
854
+ if (seen.has(material) || !s.node(material)) continue;
855
+ seen.add(material);
856
+ out.push(linkMarker(dataRoot, s, storePathToSegs(material)));
857
+ }
858
+ return out;
859
+ }
860
+
861
+ /** A client JSON path (`:key[0]:x`, keys PERCENT-ENCODED) as project-scoped COLON pointer
862
+ * raw text (`::key[0]:x`, keys RAW — quoted when spacey): pointer steps are matched against
863
+ * store keys verbatim — an encoded key would go dangling on the next re-walk. */
864
+ function pointerRaw(clientPath: string): string {
865
+ let out = "";
866
+ for (const seg of strToSegs(clientPath)) {
867
+ out += typeof seg === "number" ? `[${seg}]` : (out === "" ? "" : ":") + colonSegment(seg);
868
+ }
869
+ return "::" + out;
870
+ }
871
+
872
+ /** Serialize a value as a yamlover scalar (double-quoted strings round-trip through the parser). */
873
+ function yScalar(v: unknown): string {
874
+ return typeof v === "number" || typeof v === "boolean" ? String(v) : JSON.stringify(String(v ?? ""));
875
+ }
876
+
877
+ /** Persist a new annotation (one tag application) as a yamlover file under the project's default
878
+ * annotation location (`settings.yamlover`; `/annotations` unless configured); returns its node
879
+ * path. The material and the applied tag are referenced with project-scoped deref pointers so
880
+ * the engine reverse-links them on re-index (a leading star + a "//path" project path; the tag
881
+ * as an ordinal path anchor `&//path/to/tag[]` — the deprecated `~-` spelling still parses).
882
+ * The location is only the CREATION default — an annotation file works from any directory. */
883
+ function writeAnnotation(dataRoot: string, location: string, a: AnnotationInput): string {
884
+ if (!a?.target || !a?.tag) throw new Error("annotation needs a target and a tag");
885
+ const dir = path.resolve(dataRoot, ...strToSegs(location).map(String));
886
+ const root = path.resolve(dataRoot);
887
+ if (dir !== root && !dir.startsWith(root + path.sep)) throw new Error("annotation location escapes the data root");
888
+ fs.mkdirSync(dir, { recursive: true });
889
+ const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
890
+ const file = `${id}.yamlover`;
891
+ const lines = [
892
+ "!!<*yamlover/$defs/annotation>",
893
+ `target: ${pointerToken(pointerRaw(a.target))}`,
894
+ anchorToken(`${pointerRaw(a.tag)}[]`), // the applied tag holds me (ordinal path anchor)
895
+ ];
896
+ if (a.selector) lines.push("selector:", ...Object.entries(a.selector).map(([k, v]) => ` ${k}: ${yScalar(v)}`));
897
+ if (a.description) lines.push(`description: ${yScalar(a.description)}`);
898
+ lines.push(`created: ${new Date().toISOString()}`, "");
899
+ fs.writeFileSync(path.join(dir, file), lines.join("\n"));
900
+ return `${location}:${file}`;
901
+ }
902
+
903
+ /** Persist a NEW named tag as a key of the tag-taxonomy body at the project's default tags
904
+ * location (`settings.yamlover`; `/tags` unless configured): `<location>/.yamlover/body.yamlover`
905
+ * gains a `<name>: !!<*yamlover/$defs/tag>` entry. The would-be body is PARSED before
906
+ * committing, so a name the yamlover syntax cannot hold as a plain key (one that vanishes into
907
+ * a comment, say) is refused instead of corrupting the taxonomy. */
908
+ function writeTag(
909
+ dataRoot: string,
910
+ location: string,
911
+ name: string,
912
+ ): { node: IrNode; pos: number; file: string; createdFile: boolean } {
913
+ if (/[/\\\r\n:]/.test(name)) throw new Error("a tag name cannot contain '/', '\\', ':' or line breaks");
914
+ const root = path.resolve(dataRoot);
915
+ const dir = path.resolve(dataRoot, ...strToSegs(location).map(String), ".yamlover");
916
+ if (!dir.startsWith(root + path.sep)) throw new Error("tags location escapes the data root");
917
+ const file = path.join(dir, "body.yamlover");
918
+ const createdFile = !fs.existsSync(file);
919
+ const head = "# Named tags created from the annotation picker (settings.yamlover: tags.location).\n";
920
+ const existing = createdFile ? head : fs.readFileSync(file, "utf8");
921
+ const body = (existing === "" || existing.endsWith("\n") ? existing : existing + "\n") + `${name}: !!<*yamlover/$defs/tag>\n`;
922
+ const entries = parseYamlover(body, file).root.entries ?? [];
923
+ const pos = entries.findIndex((e) => e.key === name);
924
+ const entry = pos >= 0 ? entries[pos] : undefined;
925
+ if (!entry || isPointer(entry.value) || entry.value.meta?.schema === undefined) {
926
+ throw new Error(`cannot write a tag named ${JSON.stringify(name)}`);
927
+ }
928
+ fs.mkdirSync(dir, { recursive: true });
929
+ fs.writeFileSync(file, body);
930
+ return { node: entry.value, pos, file: [...strToSegs(location).map(String), ".yamlover", "body.yamlover"].join("/"), createdFile };
931
+ }
932
+
933
+ /** Delete an annotation file given its node path. The guard is the GRAPH, not a directory: the
934
+ * node must be indexed as an `x-yamlover-annotation` and be a whole standalone `.yamlover` file
935
+ * (an annotation authored inline in a shared document cannot be deleted this way), inside the
936
+ * served root. So an annotation moved to any directory remains deletable. */
937
+ function deleteAnnotation(dataRoot: string, s: Store, annPath: string): void {
938
+ const segs = strToSegs(annPath);
939
+ if (!String(segs[segs.length - 1] ?? "").endsWith(".yamlover")) throw new Error("not an annotation file");
940
+ if (s.node(storePath(segs))?.format !== "x-yamlover-annotation") throw new Error("not an annotation node");
941
+ const root = path.resolve(dataRoot);
942
+ const file = path.resolve(dataRoot, ...segs.map(String));
943
+ if (!file.startsWith(root + path.sep)) throw new Error("outside the served root");
944
+ if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) throw new Error("not an annotation file");
945
+ fs.rmSync(file, { force: true });
946
+ }
947
+
948
+ // --------------------------------------------------------------------------- //
949
+ // Paste / upload — drop a clipboard file OR plain text into the tree. A file: a directory target
950
+ // takes it as a new child; a chapter target takes it into its owning directory and gains a `*…`
951
+ // pointer chunk. Text: a chapter target gains it as an inline chunk (no file); any other target
952
+ // gets a new chapter .yamlover file in the nearest directory, the text as its one chunk.
953
+ // --------------------------------------------------------------------------- //
954
+
955
+ interface PasteInput {
956
+ path: string; // the page's node path (a directory or a chapter)
957
+ filename?: string; // file mode: the source filename (sanitized + de-duplicated server-side)
958
+ contentBase64?: string; // file mode: the file bytes, base64
959
+ text?: string; // text mode: the clipboard's plain text
960
+ rich?: unknown; // rich mode: an HTML selection as a chapter tree (see parseRich) — text +
961
+ // inline-file chunks, heading-nested children; the modes are mutually exclusive
962
+ }
963
+
964
+ /** Handle a paste/upload onto the node at `input.path`. Returns the new file's node path and,
965
+ * for a chapter, the chapter path + the chunk pointer appended to it. */
966
+ function handlePaste(dataRoot: string, s: Store, input: PasteInput): Record<string, unknown> {
967
+ const segs = strToSegs(input.path || ":");
968
+ const row = s.node(storePath(segs));
969
+ if (!row) throw new Error(`no such node: ${input.path}`);
970
+
971
+ if (input.rich != null) {
972
+ const rich = parseRich(input.rich);
973
+ if (row.format === "x-yamlover-chapter") return pasteRichIntoChapter(dataRoot, s, segs, rich);
974
+ return pasteRichAsChapter(dataRoot, segs, rich);
975
+ }
976
+
977
+ if (typeof input.text === "string") {
978
+ const text = input.text.replace(/\r\n?/g, "\n");
979
+ if (text.trim().length === 0) throw new Error("empty paste (no text)");
980
+ if (row.format === "x-yamlover-chapter") return pasteTextIntoChapter(dataRoot, s, segs, text);
981
+ return pasteTextAsChapterFile(dataRoot, segs, text);
982
+ }
983
+
984
+ const bytes = Buffer.from(input.contentBase64 || "", "base64");
985
+ if (bytes.length === 0) throw new Error("empty paste (no file bytes)");
986
+ const name = sanitizeName(input.filename ?? "");
987
+
988
+ if (row.format === "x-yamlover-chapter") return pasteIntoChapter(dataRoot, s, segs, name, bytes);
989
+
990
+ // a directory page, or a MEMBER of one (any non-chapter node): the file lands in the nearest
991
+ // enclosing directory. `open` marks the member case — the page is not the directory, so the
992
+ // client opens the new file (on a directory page it just refreshes in place).
993
+ const dirSegs = nearestDirSegs(dataRoot, segs);
994
+ if (!dirSegs) throw new Error("no enclosing directory to paste into");
995
+ const dir = path.resolve(dataRoot, ...dirSegs.map(String));
996
+ const final = uniqueName(dir, name);
997
+ writeInside(dataRoot, dir, final, bytes);
998
+ return { path: segsToStr([...dirSegs, final]), dir: segsToStr(dirSegs), open: dirSegs.length !== segs.length };
999
+ }
1000
+
1001
+ /** The nearest enclosing filesystem directory at or above `segs` (the node itself when it is a
1002
+ * directory, else its closest ancestor that is one), as segments; null if none under the root. */
1003
+ function nearestDirSegs(dataRoot: string, segs: Seg[]): Seg[] | null {
1004
+ for (let i = segs.length; i >= 0; i--) {
1005
+ const sub = segs.slice(0, i);
1006
+ const abs = path.resolve(dataRoot, ...sub.map(String));
1007
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) return sub;
1008
+ }
1009
+ return null;
1010
+ }
1011
+
1012
+ /** The .yamlover source holding the chapter at `segs` — directory-backed
1013
+ * (`.yamlover/body.yamlover`) or a standalone `*.yamlover` file — plus its document root. */
1014
+ function chapterSource(dataRoot: string, s: Store, segs: Seg[]): { docSegs: Seg[]; bodyFile: string; dirBacked: boolean } {
1015
+ const docSegs = documentRootSegs(s, segs);
1016
+ const docFs = path.resolve(dataRoot, ...docSegs.map(String));
1017
+ const dirBacked = fs.existsSync(docFs) && fs.statSync(docFs).isDirectory();
1018
+ const bodyFile = dirBacked ? path.join(docFs, ".yamlover", "body.yamlover") : docFs;
1019
+ if (!bodyFile.endsWith(".yamlover") || !fs.existsSync(bodyFile)) {
1020
+ throw new Error("unsupported chapter source (need a .yamlover body)");
1021
+ }
1022
+ return { docSegs, bodyFile, dirBacked };
1023
+ }
1024
+
1025
+ /** A chapter paste: write the file into the chapter's owning directory, then append a pointer to
1026
+ * it as the chapter's last chunk (editing the .yamlover source). */
1027
+ function pasteIntoChapter(dataRoot: string, s: Store, segs: Seg[], name: string, bytes: Buffer): Record<string, unknown> {
1028
+ const { docSegs, bodyFile, dirBacked } = chapterSource(dataRoot, s, segs);
1029
+ // the file lands in the doc-root dir (directory-backed) or beside the standalone chapter file.
1030
+ const writeDirSegs = dirBacked ? docSegs : docSegs.slice(0, -1);
1031
+ const writeDir = path.resolve(dataRoot, ...writeDirSegs.map(String));
1032
+ const final = uniqueName(writeDir, name);
1033
+ writeInside(dataRoot, writeDir, final, bytes);
1034
+
1035
+ // The chunk pointer: document-scoped (`*/file`) when the file sits inside the chapter's own
1036
+ // document (directory-backed); else a project-root link (`*//dir/file`) reaching the sibling.
1037
+ const fileSegs = [...writeDirSegs, final];
1038
+ const pointer = dirBacked ? `*/${final}` : `*/${segsToStr(fileSegs)}`;
1039
+ // The chapter's location WITHIN its document — alternating `children`,N pairs (empty = top-level).
1040
+ const within = segs.slice(docSegs.length);
1041
+ const src = fs.readFileSync(bodyFile, "utf8");
1042
+ fs.writeFileSync(bodyFile, appendToList(src, within, "chunks", (indent) => [`${" ".repeat(indent)}- ${pointer}`]));
1043
+ return { path: segsToStr(fileSegs), chapter: segsToStr(segs), pointer };
1044
+ }
1045
+
1046
+ /** A text paste onto a chapter: the text itself becomes the chapter's last chunk — no file is
1047
+ * written, only the .yamlover source gains an item. */
1048
+ function pasteTextIntoChapter(dataRoot: string, s: Store, segs: Seg[], text: string): Record<string, unknown> {
1049
+ const { docSegs, bodyFile } = chapterSource(dataRoot, s, segs);
1050
+ const within = segs.slice(docSegs.length);
1051
+ const src = fs.readFileSync(bodyFile, "utf8");
1052
+ fs.writeFileSync(bodyFile, appendToList(src, within, "chunks", (indent) => textChunkLines(text, indent)));
1053
+ return { path: segsToStr(segs), chapter: segsToStr(segs) };
1054
+ }
1055
+
1056
+ /** A text paste onto anything that is NOT a chapter: a new chapter .yamlover file lands in the
1057
+ * nearest enclosing directory — title from the text's first line, the text as its one chunk. */
1058
+ function pasteTextAsChapterFile(dataRoot: string, segs: Seg[], text: string): Record<string, unknown> {
1059
+ const dirSegs = nearestDirSegs(dataRoot, segs);
1060
+ if (!dirSegs) throw new Error("no enclosing directory to paste into");
1061
+ const dir = path.resolve(dataRoot, ...dirSegs.map(String));
1062
+ const title = titleFromText(text);
1063
+ const final = uniqueName(dir, chapterFileName(title));
1064
+ const src = ["!!<*yamlover/$defs/chapter>", `title: ${JSON.stringify(title)}`, "chunks:", ...textChunkLines(text, 0), ""].join("\n");
1065
+ writeInside(dataRoot, dir, final, Buffer.from(src, "utf8"));
1066
+ return { path: segsToStr([...dirSegs, final]), dir: segsToStr(dirSegs), open: dirSegs.length !== segs.length };
1067
+ }
1068
+
1069
+ // --- rich paste: an HTML selection as a chapter tree (text + image chunks, subchapters) ----- //
1070
+
1071
+ type RichItem = { text: string } | { name: string; bytes: Buffer };
1072
+ interface Rich {
1073
+ title: string | null;
1074
+ chunks: RichItem[];
1075
+ children: Array<Rich & { title: string }>;
1076
+ }
1077
+
1078
+ /** Validate + normalize the wire `rich` payload: chunks are {text} or {file:{name,
1079
+ * contentBase64}}, children recurse (each titled). Whitespace-only texts are dropped. */
1080
+ function parseRich(raw: unknown, depth = 0): Rich {
1081
+ if (depth > 8) throw new Error("rich paste: nesting too deep");
1082
+ const r = (raw ?? {}) as { title?: unknown; chunks?: unknown; children?: unknown };
1083
+ const chunks: RichItem[] = [];
1084
+ for (const c of Array.isArray(r.chunks) ? (r.chunks as Array<Record<string, unknown>>) : []) {
1085
+ if (typeof c?.text === "string") {
1086
+ if (c.text.trim()) chunks.push({ text: c.text.replace(/\r\n?/g, "\n") });
1087
+ continue;
1088
+ }
1089
+ const f = c?.file as { name?: unknown; contentBase64?: unknown } | undefined;
1090
+ if (f && typeof f.name === "string") {
1091
+ const bytes = Buffer.from(String(f.contentBase64 ?? ""), "base64");
1092
+ if (bytes.length === 0) throw new Error("rich paste: empty file chunk");
1093
+ chunks.push({ name: sanitizeName(f.name), bytes });
1094
+ continue;
1095
+ }
1096
+ throw new Error("rich paste: a chunk must be {text} or {file}");
1097
+ }
1098
+ const children = (Array.isArray(r.children) ? r.children : []).map((k) => {
1099
+ const sub = parseRich(k, depth + 1);
1100
+ const title = typeof (k as { title?: unknown })?.title === "string" ? String((k as { title: string }).title).trim() : "";
1101
+ return { ...sub, title: title || "Untitled" };
1102
+ });
1103
+ if (depth === 0 && chunks.length === 0 && children.length === 0) throw new Error("empty rich paste");
1104
+ return { title: typeof r.title === "string" && r.title.trim() ? r.title.trim() : null, chunks, children };
1105
+ }
1106
+
1107
+ /** One chunk item's source lines: a text becomes a block scalar, a file is written through
1108
+ * `pointerFor` (which yields its `*…` pointer). */
1109
+ function richItemLines(item: RichItem, indent: number, pointerFor: (name: string, bytes: Buffer) => string): string[] {
1110
+ if ("text" in item) return textChunkLines(item.text, indent);
1111
+ return [`${" ".repeat(indent)}- ${pointerFor(item.name, item.bytes)}`];
1112
+ }
1113
+
1114
+ /** A subchapter as a `children:` list item (title + chunks + recursive children), at the
1115
+ * list's indent — the item body keys sit 2 deeper, matching the chapter examples. */
1116
+ function richChildLines(node: Rich & { title: string }, indent: number, pointerFor: (name: string, bytes: Buffer) => string): string[] {
1117
+ const pad = " ".repeat(indent);
1118
+ const lines = [`${pad}- title: ${JSON.stringify(node.title)}`];
1119
+ if (node.chunks.length) lines.push(`${pad} chunks:`, ...node.chunks.flatMap((c) => richItemLines(c, indent + 2, pointerFor)));
1120
+ if (node.children.length) lines.push(`${pad} children:`, ...node.children.flatMap((k) => richChildLines(k, indent + 2, pointerFor)));
1121
+ return lines;
1122
+ }
1123
+
1124
+ /** A rich paste onto a chapter: files land in the chapter's owning directory, the chunks
1125
+ * (text + pointers, order kept) append to `chunks:` and the subchapters to `children:` —
1126
+ * either list is created when the chapter source lacks it. */
1127
+ function pasteRichIntoChapter(dataRoot: string, s: Store, segs: Seg[], rich: Rich): Record<string, unknown> {
1128
+ const { docSegs, bodyFile, dirBacked } = chapterSource(dataRoot, s, segs);
1129
+ const writeDirSegs = dirBacked ? docSegs : docSegs.slice(0, -1);
1130
+ const writeDir = path.resolve(dataRoot, ...writeDirSegs.map(String));
1131
+ const files: string[] = [];
1132
+ const pointerFor = (name: string, bytes: Buffer): string => {
1133
+ const final = uniqueName(writeDir, name);
1134
+ writeInside(dataRoot, writeDir, final, bytes);
1135
+ files.push(segsToStr([...writeDirSegs, final]));
1136
+ return dirBacked ? `*/${final}` : `*/${segsToStr([...writeDirSegs, final])}`;
1137
+ };
1138
+ const within = segs.slice(docSegs.length);
1139
+ let src = fs.readFileSync(bodyFile, "utf8");
1140
+ if (rich.chunks.length) src = appendToList(src, within, "chunks", (ind) => rich.chunks.flatMap((c) => richItemLines(c, ind, pointerFor)));
1141
+ if (rich.children.length) src = appendToList(src, within, "children", (ind) => rich.children.flatMap((k) => richChildLines(k, ind, pointerFor)));
1142
+ fs.writeFileSync(bodyFile, src);
1143
+ return { path: segsToStr(segs), chapter: segsToStr(segs), files };
1144
+ }
1145
+
1146
+ /** A rich paste onto anything that is NOT a chapter: a new chapter in the nearest enclosing
1147
+ * directory — DIRECTORY-BACKED when it carries files (the images live inside it), else a
1148
+ * standalone .yamlover file. A selection that STARTS with its own heading IS the chapter:
1149
+ * the sole top child is promoted to the root (its title names the chapter). */
1150
+ function pasteRichAsChapter(dataRoot: string, segs: Seg[], rich: Rich): Record<string, unknown> {
1151
+ const dirSegs = nearestDirSegs(dataRoot, segs);
1152
+ if (!dirSegs) throw new Error("no enclosing directory to paste into");
1153
+ const dir = path.resolve(dataRoot, ...dirSegs.map(String));
1154
+ if (!rich.title && rich.chunks.length === 0 && rich.children.length === 1) rich = rich.children[0];
1155
+ const firstText = rich.chunks.find((c): c is { text: string } => "text" in c);
1156
+ const title = rich.title ?? (firstText ? titleFromText(firstText.text) : rich.children[0]?.title ?? "Pasted content");
1157
+
1158
+ if (!richHasFiles(rich)) {
1159
+ const final = uniqueName(dir, chapterFileName(title));
1160
+ const src = renderChapterSource(title, rich, () => {
1161
+ throw new Error("unreachable: no files");
1162
+ });
1163
+ writeInside(dataRoot, dir, final, Buffer.from(src, "utf8"));
1164
+ return { path: segsToStr([...dirSegs, final]), dir: segsToStr(dirSegs), open: dirSegs.length !== segs.length };
1165
+ }
1166
+
1167
+ // directory-backed: <name>/.yamlover/body.yamlover + the image files inside <name>/
1168
+ const name = uniqueName(dir, chapterFileName(title).replace(/\.yamlover$/, ""));
1169
+ const chDir = path.join(dir, name);
1170
+ if (!path.resolve(chDir).startsWith(path.resolve(dataRoot) + path.sep)) throw new Error("target escapes the data root");
1171
+ fs.mkdirSync(path.join(chDir, ".yamlover"), { recursive: true });
1172
+ const pointerFor = (fname: string, bytes: Buffer): string => {
1173
+ const final = uniqueName(chDir, fname);
1174
+ writeInside(dataRoot, chDir, final, bytes);
1175
+ return `*/${final}`;
1176
+ };
1177
+ const src = renderChapterSource(title, rich, pointerFor);
1178
+ writeInside(dataRoot, path.join(chDir, ".yamlover"), "body.yamlover", Buffer.from(src, "utf8"));
1179
+ return { path: segsToStr([...dirSegs, name]), dir: segsToStr(dirSegs), open: dirSegs.length !== segs.length };
1180
+ }
1181
+
1182
+ /** The whole .yamlover source of a new rich chapter (the tag, the title, chunks, children). */
1183
+ function renderChapterSource(title: string, rich: Rich, pointerFor: (name: string, bytes: Buffer) => string): string {
1184
+ const lines = ["!!<*yamlover/$defs/chapter>", `title: ${JSON.stringify(title)}`];
1185
+ if (rich.chunks.length) lines.push("chunks:", ...rich.chunks.flatMap((c) => richItemLines(c, 0, pointerFor)));
1186
+ if (rich.children.length) lines.push("children:", ...rich.children.flatMap((k) => richChildLines(k, 0, pointerFor)));
1187
+ return lines.join("\n") + "\n";
1188
+ }
1189
+
1190
+ function richHasFiles(rich: Rich): boolean {
1191
+ return rich.chunks.some((c) => "bytes" in c) || rich.children.some(richHasFiles);
1192
+ }
1193
+
1194
+ /** A title for a pasted-text chapter: the first content line, sans any markdown heading
1195
+ * marker, clipped to 80 chars. */
1196
+ function titleFromText(text: string): string {
1197
+ const first = text.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
1198
+ const t = first.replace(/^#{1,6}\s+/, "").trim();
1199
+ return (t.length > 80 ? t.slice(0, 79).trimEnd() + "…" : t) || "Pasted text";
1200
+ }
1201
+
1202
+ /** A filename for a new chapter file, from its title: unicode letters/digits/space/dot/dash kept
1203
+ * (non-ASCII names are first-class — see uniqueName for collisions), never hidden. */
1204
+ function chapterFileName(title: string): string {
1205
+ const base = title.replace(/[^\p{L}\p{N} ._-]+/gu, " ").replace(/\s+/g, " ").trim().slice(0, 60).trim().replace(/^\.+/, "");
1206
+ return `${base || "pasted"}.yamlover`;
1207
+ }
1208
+
1209
+ /** A safe filename: basename only, restricted charset, never hidden; defaults when empty. */
1210
+ function sanitizeName(raw: string): string {
1211
+ const base = path.basename(String(raw || "")).replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "");
1212
+ return base || "pasted";
1213
+ }
1214
+
1215
+ /** `name`, or `name-1`/`name-2`/… if it already exists in `dir` (extension kept). */
1216
+ function uniqueName(dir: string, name: string): string {
1217
+ if (!fs.existsSync(path.join(dir, name))) return name;
1218
+ const ext = path.extname(name);
1219
+ const stem = name.slice(0, name.length - ext.length);
1220
+ for (let i = 1; ; i++) {
1221
+ const cand = `${stem}-${i}${ext}`;
1222
+ if (!fs.existsSync(path.join(dir, cand))) return cand;
1223
+ }
1224
+ }
1225
+
1226
+ /** Write `bytes` to `dir/name`, refusing any path that escapes the served root. */
1227
+ function writeInside(dataRoot: string, dir: string, name: string, bytes: Buffer): void {
1228
+ const root = path.resolve(dataRoot);
1229
+ const target = path.resolve(dir, name);
1230
+ if (target !== root && !target.startsWith(root + path.sep)) throw new Error("target escapes the data root");
1231
+ fs.writeFileSync(target, bytes);
1232
+ }
1233
+
1234
+ // --- chapter list insertion (indentation-aware; the parser does not track spans) ------------- //
1235
+ // A directory body / standalone chapter is YAML-shaped: a mapping's keys at one indent, a
1236
+ // sequence's `- ` items at the SAME indent as their key, an item's mapping body at key-indent+2.
1237
+ // To reach a subchapter we descend `children:` sequences by index; then we append to a list key
1238
+ // (`chunks:` for content, `children:` for pasted subchapters), creating it when absent.
1239
+
1240
+ const indentOf = (line: string): number => { let i = 0; while (line[i] === " ") i++; return i; };
1241
+ const isContentLine = (line: string): boolean => { const t = line.trim(); return t.length > 0 && !t.startsWith("#"); };
1242
+
1243
+ /** Render a pasted text as the lines of one `- ` chunk item at `indent`. A literal block scalar
1244
+ * when the text round-trips — the parser detects the block indent from the FIRST content line,
1245
+ * so it must be unindented; else one double-quoted line (JSON escapes — exactly the subset
1246
+ * quotedScalar reads back). */
1247
+ function textChunkLines(text: string, indent: number): string[] {
1248
+ const pad = " ".repeat(indent);
1249
+ const first = text.split("\n").find((l) => l.trim().length > 0);
1250
+ if (!first || /^\s/.test(first)) return [`${pad}- ${JSON.stringify(text)}`];
1251
+ const body = text.endsWith("\n") ? text.slice(0, -1) : text;
1252
+ const head = text.endsWith("\n") ? "|" : "|-"; // the chomping matches the text's own ending
1253
+ return [`${pad}- ${head}`, ...body.split("\n").map((l) => (l.trim().length ? `${pad} ${l}` : ""))];
1254
+ }
1255
+
1256
+ /** Append items (rendered by `renderItems` at the list's indent) to the `key:` list of the
1257
+ * chapter at `chapterPath` (alternating ["children", N, …] pairs; empty = the top-level
1258
+ * chapter) within a .yamlover source. A chapter authored without the list gains the key at
1259
+ * the end of its mapping. */
1260
+ function appendToList(text: string, chapterPath: Seg[], key: string, renderItems: (indent: number) => string[]): string {
1261
+ const lines = text.split("\n");
1262
+ let lo = 0;
1263
+ let hi = lines.length;
1264
+ let indent = firstContentIndent(lines); // the chapter mapping's key indent
1265
+
1266
+ for (let i = 0; i < chapterPath.length; i += 2) {
1267
+ const idx = Number(chapterPath[i + 1]);
1268
+ const kids = findKeyLine(lines, lo, hi, indent, "children");
1269
+ if (kids < 0) throw new Error(`no 'children:' at indent ${indent}`);
1270
+ const items = seqItems(lines, kids + 1, hi, indent);
1271
+ if (!(idx >= 0 && idx < items.length)) throw new Error(`children[${idx}] out of range (${items.length})`);
1272
+ hi = idx + 1 < items.length ? items[idx + 1] : seqEnd(lines, kids + 1, hi, indent);
1273
+ lo = items[idx] + 1; // body starts past the `- ` marker (its inline key sits at the parent indent)
1274
+ indent += 2;
1275
+ }
1276
+
1277
+ const keyLine = findKeyLine(lines, lo, hi, indent, key);
1278
+ if (keyLine < 0) {
1279
+ const end = trimBack(lines, lo - 1, hi); // the chapter mapping's end, sans trailing blanks
1280
+ lines.splice(end, 0, `${" ".repeat(indent)}${key}:`, ...renderItems(indent));
1281
+ } else {
1282
+ const end = seqEnd(lines, keyLine + 1, hi, indent);
1283
+ lines.splice(end, 0, ...renderItems(indent));
1284
+ }
1285
+ return lines.join("\n");
1286
+ }
1287
+
1288
+ /** The indent of the first content line — the chapter mapping's key column. */
1289
+ function firstContentIndent(lines: string[]): number {
1290
+ for (const l of lines) if (isContentLine(l)) return indentOf(l);
1291
+ return 0;
1292
+ }
1293
+
1294
+ /** Line index of `key:` at exactly `indent` within [lo,hi); -1 once the mapping ends (a dedent). */
1295
+ function findKeyLine(lines: string[], lo: number, hi: number, indent: number, key: string): number {
1296
+ for (let i = lo; i < hi; i++) {
1297
+ if (!isContentLine(lines[i])) continue;
1298
+ const ind = indentOf(lines[i]);
1299
+ if (ind < indent) return -1; // left the mapping
1300
+ if (ind !== indent) continue; // deeper (a nested value / block scalar)
1301
+ const t = lines[i].trim();
1302
+ if (t === `${key}:` || t.startsWith(`${key}:`)) return i;
1303
+ }
1304
+ return -1;
1305
+ }
1306
+
1307
+ /** Start lines of the `- ` items of a sequence whose items sit at `indent`, from `from`. */
1308
+ function seqItems(lines: string[], from: number, hi: number, indent: number): number[] {
1309
+ const out: number[] = [];
1310
+ for (let i = from; i < hi; i++) {
1311
+ if (!isContentLine(lines[i])) continue;
1312
+ const ind = indentOf(lines[i]);
1313
+ if (ind < indent) break; // dedent → sequence ended
1314
+ if (ind !== indent) continue; // deeper → the current item's body
1315
+ const t = lines[i].trim();
1316
+ if (t === "-" || t.startsWith("- ")) out.push(i);
1317
+ else break; // a sibling key at the same indent → sequence ended
1318
+ }
1319
+ return out;
1320
+ }
1321
+
1322
+ /** The line index that ends the sequence starting at `from` (a dedent below `indent`, or a
1323
+ * non-item sibling key at `indent`), skipping back over trailing blank lines. */
1324
+ function seqEnd(lines: string[], from: number, hi: number, indent: number): number {
1325
+ let last = from;
1326
+ for (let i = from; i < hi; i++) {
1327
+ if (!isContentLine(lines[i])) continue;
1328
+ const ind = indentOf(lines[i]);
1329
+ if (ind < indent) return trimBack(lines, last, i);
1330
+ if (ind === indent) {
1331
+ const t = lines[i].trim();
1332
+ if (t === "-" || t.startsWith("- ")) { last = i; continue; }
1333
+ return trimBack(lines, last, i); // sibling key
1334
+ }
1335
+ last = i; // deeper: part of the current item
1336
+ }
1337
+ return trimBack(lines, last, hi);
1338
+ }
1339
+
1340
+ /** Walk an end index back over trailing blank lines, so we insert right after the last item. */
1341
+ function trimBack(lines: string[], lastItemLine: number, end: number): number {
1342
+ let e = end;
1343
+ while (e > lastItemLine + 1 && !isContentLine(lines[e - 1])) e--;
1344
+ return e;
1345
+ }
1346
+
1347
+ /** Read a request body and parse it as JSON. */
1348
+ function readBody(req: IncomingMessage): Promise<unknown> {
1349
+ return new Promise((resolve, reject) => {
1350
+ const chunks: Buffer[] = [];
1351
+ req.on("data", (c: Buffer) => chunks.push(c));
1352
+ req.on("end", () => {
1353
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}")); }
1354
+ catch (e) { reject(e); }
1355
+ });
1356
+ req.on("error", reject);
1357
+ });
1358
+ }
1359
+
1360
+ /** The nearest enclosing DOCUMENT root for `segs` — the closest ancestor (or self) whose node
1361
+ * is flagged `documentRoot` (a parsed file / `.yamlover` dir / served root), as segments. It is
1362
+ * the anchor a document-relative (`/…`) pointer resolves against, mirroring the `/` pointer scope. */
1363
+ function documentRootSegs(s: Store, segs: Seg[]): Seg[] {
1364
+ for (let i = segs.length; i >= 0; i--) {
1365
+ const anc = segs.slice(0, i);
1366
+ if (s.node(storePath(anc))?.meta?.documentRoot) return anc;
1367
+ }
1368
+ return [];
1369
+ }
1370
+
1371
+ /** The nearest enclosing document root as a client JSON path (`/…`). */
1372
+ function documentPath(s: Store, segs: Seg[]): string {
1373
+ return segsToStr(documentRootSegs(s, segs));
1374
+ }
1375
+
1376
+ /** A node's `title` child value (a scalar), if any — used as a friendly label. */
1377
+ function titleOf(s: Store, p: string): string | null {
1378
+ const titlePath = (p === ":" ? "" : p) + ":title";
1379
+ const t = s.node(titlePath);
1380
+ if (t && t.type === "scalar" && !s.hasChildren(titlePath) && t.value != null) return String(t.value);
1381
+ return null;
1382
+ }
1383
+
1384
+ // --------------------------------------------------------------------------- //
1385
+ // Path handling (JSON space; matches the client + the Store path scheme)
1386
+ // --------------------------------------------------------------------------- //
1387
+
1388
+ const PATH_TOKEN = /\[\d+\]|[^:\[\]]+/g;
1389
+
1390
+ /** Render segments as a client-facing JSON path (`:key[0]:x`, colon-form — SEPARATOR.md M4),
1391
+ * percent-encoding keys. */
1392
+ function segsToStr(segs: Seg[]): string {
1393
+ return segs.map((seg) => (typeof seg === "number" ? `[${seg}]` : `:${encodeURIComponent(seg)}`)).join("") || ":";
1394
+ }
1395
+
1396
+ /** Parse a client JSON path into segments (`[n]` → number, else a decoded key). */
1397
+ function strToSegs(str: string): Seg[] {
1398
+ const out: Seg[] = [];
1399
+ for (const tok of str.match(PATH_TOKEN) || []) out.push(/^\[\d+\]$/.test(tok) ? Number(tok.slice(1, -1)) : safeDecode(tok));
1400
+ return out;
1401
+ }
1402
+
1403
+ /** Build the raw Store path (un-encoded keys) the index uses, from decoded segments. */
1404
+ function storePath(segs: Seg[]): string {
1405
+ return segs.map((seg) => (typeof seg === "number" ? `[${seg}]` : `:${seg}`)).join("") || ":";
1406
+ }
1407
+
1408
+ /** Parse a raw Store path back into segments (keys are raw — no decode). */
1409
+ function storePathToSegs(p: string): Seg[] {
1410
+ const out: Seg[] = [];
1411
+ for (const tok of p.match(PATH_TOKEN) || []) out.push(/^\[\d+\]$/.test(tok) ? Number(tok.slice(1, -1)) : tok);
1412
+ return out;
1413
+ }
1414
+
1415
+ function safeDecode(s: string): string {
1416
+ try { return decodeURIComponent(s); } catch { return s; }
1417
+ }
1418
+
1419
+ // extension → Content-Type for the blob endpoint (mirrors the engine walker's table subset).
1420
+ const EXT_CT: Record<string, string> = {
1421
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
1422
+ ".webp": "image/webp", ".svg": "image/svg+xml", ".bmp": "image/bmp", ".ico": "image/x-icon",
1423
+ ".pdf": "application/pdf", ".tiff": "image/tiff", ".tif": "image/tiff", ".html": "text/html",
1424
+ ".md": "text/markdown", ".csv": "text/csv", ".epub": "application/epub+zip",
1425
+ };
1426
+ function formatFromExt(file: string): string | null {
1427
+ return EXT_CT[path.extname(file).toLowerCase()] ?? null;
1428
+ }
1429
+
1430
+ function parseDepth(raw: string | null): number | null {
1431
+ if (raw == null || raw === "") return null;
1432
+ const n = Number(raw);
1433
+ return Number.isInteger(n) && n >= 0 ? n : null;
1434
+ }
1435
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
1436
+ res.statusCode = status;
1437
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
1438
+ res.end(JSON.stringify(body, null, 2));
1439
+ }
1440
+ function notFound(res: ServerResponse, url: URL): void {
1441
+ sendJson(res, 404, { error: `no such node/endpoint: ${url.pathname}?${url.searchParams}` });
1442
+ }