yamlover 0.3.4 → 0.3.5

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