yamlover 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/bin/yamlover.js +202 -0
  4. package/dist/server.js +4681 -0
  5. package/index.html +12 -0
  6. package/package.json +72 -0
  7. package/src/client/App.tsx +372 -0
  8. package/src/client/NodeView.tsx +422 -0
  9. package/src/client/TaskStrip.tsx +34 -0
  10. package/src/client/Tree.tsx +97 -0
  11. package/src/client/api.ts +186 -0
  12. package/src/client/icons.ts +91 -0
  13. package/src/client/links.tsx +108 -0
  14. package/src/client/live.ts +42 -0
  15. package/src/client/main.tsx +10 -0
  16. package/src/client/paste-html.ts +228 -0
  17. package/src/client/paste-links.ts +42 -0
  18. package/src/client/paths.ts +109 -0
  19. package/src/client/render.tsx +326 -0
  20. package/src/client/renderers/annotate.tsx +507 -0
  21. package/src/client/renderers/asciidoc.tsx +35 -0
  22. package/src/client/renderers/chapter.tsx +138 -0
  23. package/src/client/renderers/csv.tsx +233 -0
  24. package/src/client/renderers/decoded.tsx +72 -0
  25. package/src/client/renderers/djvu.tsx +97 -0
  26. package/src/client/renderers/doc.tsx +40 -0
  27. package/src/client/renderers/docx.tsx +49 -0
  28. package/src/client/renderers/epub.tsx +147 -0
  29. package/src/client/renderers/explorer.tsx +209 -0
  30. package/src/client/renderers/fb2.tsx +149 -0
  31. package/src/client/renderers/headings.ts +69 -0
  32. package/src/client/renderers/heic.tsx +23 -0
  33. package/src/client/renderers/imagemap.tsx +157 -0
  34. package/src/client/renderers/kml.ts +46 -0
  35. package/src/client/renderers/latex.tsx +36 -0
  36. package/src/client/renderers/map.tsx +205 -0
  37. package/src/client/renderers/marklower.tsx +119 -0
  38. package/src/client/renderers/markup.tsx +64 -0
  39. package/src/client/renderers/media.tsx +19 -0
  40. package/src/client/renderers/panzoom.ts +101 -0
  41. package/src/client/renderers/pdf.tsx +176 -0
  42. package/src/client/renderers/plaintext.tsx +120 -0
  43. package/src/client/renderers/plantuml.tsx +82 -0
  44. package/src/client/renderers/psd.tsx +25 -0
  45. package/src/client/renderers/registry.tsx +389 -0
  46. package/src/client/renderers/rtf.tsx +210 -0
  47. package/src/client/renderers/spreadsheet.tsx +105 -0
  48. package/src/client/renderers/tag.tsx +113 -0
  49. package/src/client/renderers/text.tsx +41 -0
  50. package/src/client/renderers/tiff.tsx +33 -0
  51. package/src/client/styles.css +1115 -0
  52. package/src/client/vendor/README.md +30 -0
  53. package/src/client/vendor/djvu.js +15535 -0
  54. package/src/client/vite-env.d.ts +31 -0
  55. package/src/server/api.ts +147 -0
  56. package/src/server/engine-api.ts +1442 -0
  57. package/src/server/gitignore.ts +81 -0
  58. package/src/server/node-kind.ts +48 -0
  59. package/src/server/tasks.ts +83 -0
  60. package/src/server/yamlover.ts +1133 -0
package/index.html ADDED
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>yamlover</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/client/main.tsx"></script>
11
+ </body>
12
+ </html>
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "yamlover",
3
+ "version": "0.3.0",
4
+ "description": "Browse a yamlover tree in the web: npx yamlover <root> serves a React SPA over a directory.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "dims",
8
+ "keywords": [
9
+ "yamlover",
10
+ "yaml",
11
+ "json5",
12
+ "viewer",
13
+ "browser",
14
+ "spa",
15
+ "knowledge-graph",
16
+ "pointers"
17
+ ],
18
+ "bin": {
19
+ "yamlover": "bin/yamlover.js"
20
+ },
21
+ "files": [
22
+ "bin",
23
+ "dist",
24
+ "src",
25
+ "index.html"
26
+ ],
27
+ "scripts": {
28
+ "start": "node bin/yamlover.js",
29
+ "build": "node scripts/build.mjs",
30
+ "prepack": "node scripts/build.mjs",
31
+ "test": "vitest run",
32
+ "test:watch": "vitest"
33
+ },
34
+ "engines": {
35
+ "node": ">=22"
36
+ },
37
+ "dependencies": {
38
+ "@asciidoctor/core": "^3.0.4",
39
+ "@tmcw/togeojson": "^7.1.2",
40
+ "@vitejs/plugin-react": "^4.3.4",
41
+ "ag-psd": "^28.5.1",
42
+ "fflate": "^0.8.3",
43
+ "heic2any": "^0.0.4",
44
+ "ignore": "^6.0.2",
45
+ "js-yaml": "^4.1.0",
46
+ "katex": "^0.16.47",
47
+ "leaflet": "^1.9.4",
48
+ "mammoth": "^1.12.0",
49
+ "marked": "^18.0.4",
50
+ "react": "^18.3.1",
51
+ "react-dom": "^18.3.1",
52
+ "react-pdf": "^10.4.1",
53
+ "utif": "^3.1.0",
54
+ "vite": "^5.4.11",
55
+ "xlsx": "^0.18.5"
56
+ },
57
+ "devDependencies": {
58
+ "@testing-library/dom": "^10.4.0",
59
+ "@testing-library/react": "^16.1.0",
60
+ "@types/js-yaml": "^4.0.9",
61
+ "esbuild": "^0.21.5",
62
+ "xxhash-wasm": "^1.1.0",
63
+ "@types/katex": "^0.16.8",
64
+ "@types/leaflet": "^1.9.21",
65
+ "@types/node": "^22.10.1",
66
+ "@types/react": "^18.3.12",
67
+ "@types/react-dom": "^18.3.1",
68
+ "jsdom": "^25.0.1",
69
+ "typescript": "^5.6.3",
70
+ "vitest": "^2.1.8"
71
+ }
72
+ }
@@ -0,0 +1,372 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { fetchInfo, fetchTasks, fetchTree, PasteResult, TaskInfo, TreeNode } from "./api";
3
+ import { Tree } from "./Tree";
4
+ import { TaskStrip } from "./TaskStrip";
5
+ import { NodeView, Format, FORMATS, DEFAULT_FORMAT } from "./NodeView";
6
+ import { rendererName } from "./renderers/registry";
7
+
8
+ const isStandardFormat = (f: Format) => (FORMATS as string[]).includes(f);
9
+ import { crumbs, formatFromUrl, isAncestorPath, pathFromUrl, segsToStr, strToSegs, writeUrl } from "./paths";
10
+ import { broadcastDiff } from "./live";
11
+
12
+ // Levels of the TOC fetched at once — initially and on each lazy expand. One
13
+ // level keeps every fetch cheap on a huge/slow tree (a fetch only reads the
14
+ // directories it actually shows); deeper levels load instantly on expand.
15
+ const INITIAL_DEPTH = 1;
16
+
17
+ /** Return a copy of `tree` with the children of the node at `path` replaced. */
18
+ function replaceChildren(tree: TreeNode, path: string, children: TreeNode[]): TreeNode {
19
+ if (tree.path === path) return { ...tree, children };
20
+ if (!tree.children.length) return tree;
21
+ return { ...tree, children: tree.children.map((c) => replaceChildren(c, path, children)) };
22
+ }
23
+
24
+ /** Merge a freshly fetched branch over the old one at the same path: the fresh rows win
25
+ * (labels, flags, order, additions/removals), but a row that already had its children
26
+ * loaded keeps them (recursively) when the fresh fetch didn't reach that deep — so a live
27
+ * refresh never collapses what the user has expanded. */
28
+ function mergeBranch(old: TreeNode | undefined, fresh: TreeNode): TreeNode {
29
+ if (!old) return fresh;
30
+ const byPath = new Map(old.children.map((c) => [c.path, c] as const));
31
+ const children = fresh.children.length
32
+ ? fresh.children.map((c) => mergeBranch(byPath.get(c.path), c))
33
+ : fresh.hasChildren
34
+ ? old.children // past the fetch depth — keep the loaded subtree
35
+ : [];
36
+ return { ...fresh, children };
37
+ }
38
+
39
+ /** Return a copy of `tree` with the fresh subtree merged in at `path` (see mergeBranch). */
40
+ function mergeAt(tree: TreeNode, path: string, fresh: TreeNode): TreeNode {
41
+ if (tree.path === path) return mergeBranch(tree, fresh);
42
+ if (!tree.children.length) return tree;
43
+ return { ...tree, children: tree.children.map((c) => mergeAt(c, path, fresh)) };
44
+ }
45
+
46
+ /** Find the node at `path` in the (partially loaded) tree. */
47
+ function findNode(tree: TreeNode, path: string): TreeNode | null {
48
+ if (tree.path === path) return tree;
49
+ for (const c of tree.children) {
50
+ const f = findNode(c, path);
51
+ if (f) return f;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** The shallowest ancestor of `current` that is loaded but whose children are
57
+ * not yet fetched — the next branch to load while revealing `current`. */
58
+ function nextToLoad(tree: TreeNode, current: string): string | null {
59
+ const segs = strToSegs(current);
60
+ for (let k = 0; k < segs.length; k++) {
61
+ const ancestor = segsToStr(segs.slice(0, k));
62
+ const node = findNode(tree, ancestor);
63
+ if (!node) return null; // the path diverges from the tree (e.g. invalid) — stop
64
+ if (node.hasChildren && node.children.length === 0) return ancestor;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ export function App() {
70
+ const [tree, setTree] = useState<TreeNode | null>(null);
71
+ const [error, setError] = useState<string | null>(null);
72
+ const [current, setCurrent] = useState<string>(pathFromUrl());
73
+ const [format, setFormat] = useState<Format>(formatFromUrl(DEFAULT_FORMAT) as Format);
74
+ const [rootLabel, setRootLabel] = useState<string>(""); // CLI ROOT (breadcrumb head)
75
+ const [leftWidth, setLeftWidth] = useState<number>(320);
76
+
77
+ // The breadcrumb head is the ROOT given on the command line (blank if omitted).
78
+ useEffect(() => {
79
+ fetchInfo().then((i) => setRootLabel(i.root)).catch(() => {});
80
+ }, []);
81
+
82
+ // Long-running server tasks (indexing, hashing, …): seeded from /api/tasks (a page loaded
83
+ // mid-task), updated by `{type:"task"}` SSE frames; finished ones linger ~3s then drop.
84
+ const [tasks, setTasks] = useState<TaskInfo[]>([]);
85
+ useEffect(() => {
86
+ fetchTasks().then(setTasks).catch(() => {});
87
+ }, []);
88
+ const upsertTask = useCallback((t: TaskInfo) => {
89
+ setTasks((prev) => [...prev.filter((x) => x.id !== t.id), t].sort((a, b) => a.startedAt - b.startedAt));
90
+ if (t.state !== "running") {
91
+ setTimeout(() => setTasks((prev) => prev.filter((x) => x.id !== t.id)), 3000);
92
+ }
93
+ }, []);
94
+
95
+ // Whether the initial URL pinned a representation; if not, a landing node that
96
+ // has a renderer opens in its rendered view (decided once the TOC loads).
97
+ const explicitFormat = useRef(new URLSearchParams(window.location.search).has("format"));
98
+
99
+ // Reflect the initial path+format back into the URL (so ?format= is present).
100
+ useEffect(() => {
101
+ writeUrl(current, format, true);
102
+ // eslint-disable-next-line react-hooks/exhaustive-deps
103
+ }, []);
104
+
105
+ // Load the TOC's first few levels; deeper branches load on expand.
106
+ useEffect(() => {
107
+ fetchTree(":", INITIAL_DEPTH)
108
+ .then(setTree)
109
+ .catch((e) => setError(e.message));
110
+ // eslint-disable-next-line react-hooks/exhaustive-deps
111
+ }, []);
112
+
113
+ // First time the landing node appears in the TOC (immediately when shallow,
114
+ // after lazy-loading the path when deep-linked), open it in its renderer's
115
+ // view — unless the URL pinned a representation. Runs once; later tab switches
116
+ // and in-app navigation make their own choice.
117
+ const resolvedLanding = useRef(false);
118
+ useEffect(() => {
119
+ if (explicitFormat.current || resolvedLanding.current || !tree) return;
120
+ const n = findNode(tree, current);
121
+ if (!n) return; // not loaded along the path yet — wait for the next pass
122
+ resolvedLanding.current = true;
123
+ const rn = rendererName(n.type, n.format, n.concrete);
124
+ if (rn) {
125
+ setFormat(rn);
126
+ writeUrl(current, rn, true);
127
+ }
128
+ }, [tree, current]);
129
+
130
+ // Fetch a collapsed branch's children and splice them into the tree in place.
131
+ // `levels` is how deep to pull (default one): a renderer whose TOC rows sit
132
+ // deeper than its direct children (a chapter, surfacing subchapters from under
133
+ // its `children` wrapper) asks for more, so one expand reveals them.
134
+ const loadChildren = useCallback(async (path: string, levels = INITIAL_DEPTH) => {
135
+ const sub = await fetchTree(path, levels);
136
+ setTree((t) => (t ? replaceChildren(t, path, sub.children) : t));
137
+ }, []);
138
+
139
+ // Reveal the current node: lazily load the branches along its path (one level
140
+ // per pass; each load re-runs this until the whole path is present), so a
141
+ // deep-linked / pasted URL gets its TOC entry expanded and selected.
142
+ useEffect(() => {
143
+ if (!tree) return;
144
+ const next = nextToLoad(tree, current);
145
+ if (next) loadChildren(next).catch(() => {});
146
+ }, [tree, current, loadChildren]);
147
+
148
+ // Live refresh: the server watches the filesystem and pushes each reindex diff over SSE
149
+ // (/api/events). For every changed path we re-fetch its deepest LOADED tree branch (merged
150
+ // in, so expanded subtrees stay open), and re-fetch the node pane when the current node is
151
+ // touched. Unloaded branches need nothing — they fetch fresh on expand anyway.
152
+ const treeRef = useRef(tree);
153
+ treeRef.current = tree;
154
+ const currentRef = useRef(current);
155
+ currentRef.current = current;
156
+ const [refreshSignal, setRefreshSignal] = useState(0);
157
+
158
+ const refreshBranches = useCallback(async (paths: string[]) => {
159
+ const t = treeRef.current;
160
+ if (!t) return;
161
+ const targets = new Set<string>();
162
+ for (const p of paths) {
163
+ const segs = strToSegs(p);
164
+ let best = ":";
165
+ for (let k = 0; k < segs.length; k++) {
166
+ const anc = segsToStr(segs.slice(0, k));
167
+ const node = findNode(t, anc);
168
+ if (!node) break;
169
+ if (anc === ":" || node.children.length > 0) best = anc;
170
+ }
171
+ targets.add(best);
172
+ }
173
+ const list = [...targets].filter((a) => ![...targets].some((b) => b !== a && isAncestorPath(b, a)));
174
+ await Promise.all(
175
+ list.map(async (p) => {
176
+ const sub = await fetchTree(p, INITIAL_DEPTH);
177
+ setTree((prev) => (prev ? mergeAt(prev, p, sub) : prev));
178
+ }),
179
+ );
180
+ }, []);
181
+
182
+ // Cold-start recovery: a page opened before the FIRST index landed has no tree (the initial
183
+ // fetch 404'd on an empty store). When the index task finishes — or any diff arrives —
184
+ // retry the initial fetches and clear the stale error.
185
+ const retryInitial = useCallback(() => {
186
+ if (treeRef.current) return;
187
+ fetchTree(":", INITIAL_DEPTH)
188
+ .then((t) => {
189
+ setTree(t);
190
+ setError(null);
191
+ })
192
+ .catch(() => {});
193
+ fetchInfo().then((i) => setRootLabel(i.root)).catch(() => {});
194
+ }, []);
195
+
196
+ useEffect(() => {
197
+ if (typeof EventSource === "undefined") return; // test envs (jsdom) have no SSE
198
+ const es = new EventSource("/api/events");
199
+ es.onmessage = (ev) => {
200
+ try {
201
+ const msg = JSON.parse(ev.data) as { type?: string };
202
+ if (msg.type === "task") {
203
+ const t = (msg as unknown as { task: TaskInfo }).task;
204
+ upsertTask(t);
205
+ if (t.state === "done") retryInitial(); // no-op once the tree is loaded
206
+ return;
207
+ }
208
+ const diff = msg as unknown as {
209
+ added: string[]; changed: string[]; removed: string[];
210
+ moved?: { from: string; to: string }[]; // inferred moves (frame-compatible: optional)
211
+ };
212
+ retryInitial(); // no-op once the tree is loaded
213
+ const paths = [...diff.added, ...diff.changed, ...diff.removed,
214
+ ...(diff.moved ?? []).flatMap((m) => [m.from, m.to])];
215
+ if (!paths.length) return;
216
+ // Re-broadcast for the useDiffBump subscribers (live.ts — the unified change flow):
217
+ // hooks outside this component's prop reach refetch from the same diff currency.
218
+ broadcastDiff({ paths, removed: diff.removed });
219
+ refreshBranches(paths).catch(() => {});
220
+ const cur = currentRef.current;
221
+ if (paths.some((p) => p === cur || isAncestorPath(p, cur) || isAncestorPath(cur, p))) {
222
+ setRefreshSignal((s) => s + 1);
223
+ }
224
+ } catch {
225
+ // a malformed frame — ignore
226
+ }
227
+ };
228
+ return () => es.close();
229
+ }, [refreshBranches, upsertTask, retryInitial]);
230
+
231
+ // Keep state in sync with the browser's back/forward buttons.
232
+ useEffect(() => {
233
+ const onPop = () => {
234
+ setCurrent(pathFromUrl());
235
+ setFormat(formatFromUrl(DEFAULT_FORMAT) as Format);
236
+ };
237
+ window.addEventListener("popstate", onPop);
238
+ return () => window.removeEventListener("popstate", onPop);
239
+ }, []);
240
+
241
+ // Navigating changes the path. The format carries over, except: a target that
242
+ // has a renderer opens in that renderer's view (so e.g. a chapter reads as a
243
+ // page, with its tab active), and leaving a renderer node drops a stale renderer
244
+ // format back to the default. A target absent from the TOC keeps the current
245
+ // format — reading deeper into a rendered tree stays rendered. NodeView makes
246
+ // the final call from the fetched node's (type, format).
247
+ const navigate = useCallback(
248
+ (p: string) => {
249
+ const target = tree ? findNode(tree, p) : null;
250
+ let f: Format = format;
251
+ if (target) {
252
+ const rn = rendererName(target.type, target.format, target.concrete);
253
+ f = rn ?? (isStandardFormat(format) ? format : DEFAULT_FORMAT);
254
+ }
255
+ writeUrl(p, f, false);
256
+ setCurrent(p);
257
+ if (f !== format) setFormat(f);
258
+ },
259
+ [format, tree],
260
+ );
261
+
262
+ const changeFormat = useCallback(
263
+ (f: Format) => {
264
+ writeUrl(current, f, true);
265
+ setFormat(f);
266
+ },
267
+ [current],
268
+ );
269
+
270
+ // A paste/upload added a file (and maybe a chapter chunk) at `p`. Reload that branch of the TOC
271
+ // so a new directory child shows up; fall back to the root when the branch isn't loaded yet.
272
+ const onContentChanged = useCallback(
273
+ (p: string) => {
274
+ const target = tree ? findNode(tree, p) : null;
275
+ loadChildren(target ? p : ":").catch(() => {});
276
+ },
277
+ [tree, loadChildren],
278
+ );
279
+
280
+ // A file pasted/dropped onto a directory MEMBER landed in the enclosing dir — open it. We fetch
281
+ // the dir's branch first (so the TOC shows the file AND we learn its type/format), then navigate
282
+ // to it in its renderer's view (an image opens as an image, not as data). Falls back to a plain
283
+ // navigate if the branch fetch fails.
284
+ const onOpenUploaded = useCallback(
285
+ async (result: PasteResult) => {
286
+ const dir = result.dir ?? ":";
287
+ try {
288
+ const sub = await fetchTree(dir, INITIAL_DEPTH);
289
+ setTree((t) => (t ? replaceChildren(t, dir, sub.children) : t));
290
+ const fileNode = sub.children.find((c) => c.path === result.path);
291
+ const f: Format = (fileNode ? rendererName(fileNode.type, fileNode.format, fileNode.concrete) : null) ?? DEFAULT_FORMAT;
292
+ writeUrl(result.path, f, false);
293
+ setCurrent(result.path);
294
+ setFormat(f);
295
+ } catch {
296
+ navigate(result.path);
297
+ }
298
+ },
299
+ [navigate],
300
+ );
301
+
302
+ // --- draggable splitter -------------------------------------------------- //
303
+ const dragging = useRef(false);
304
+ useEffect(() => {
305
+ const onMove = (e: MouseEvent) => {
306
+ if (!dragging.current) return;
307
+ setLeftWidth(Math.min(Math.max(e.clientX, 160), window.innerWidth - 240));
308
+ };
309
+ const onUp = () => {
310
+ dragging.current = false;
311
+ document.body.style.userSelect = "";
312
+ };
313
+ window.addEventListener("mousemove", onMove);
314
+ window.addEventListener("mouseup", onUp);
315
+ return () => {
316
+ window.removeEventListener("mousemove", onMove);
317
+ window.removeEventListener("mouseup", onUp);
318
+ };
319
+ }, []);
320
+
321
+ return (
322
+ <div className="app">
323
+ <header className="topbar">
324
+ <nav className="crumbs">
325
+ {crumbs(current, rootLabel).map((c, i) => (
326
+ <span key={c.path}>
327
+ {i > 0 && <span className="crumb-sep">:</span>}
328
+ <a
329
+ className="crumb"
330
+ href={c.path}
331
+ onClick={(e) => {
332
+ e.preventDefault();
333
+ navigate(c.path);
334
+ }}
335
+ >
336
+ {c.label}
337
+ </a>
338
+ </span>
339
+ ))}
340
+ </nav>
341
+ <TaskStrip tasks={tasks} />
342
+ </header>
343
+
344
+ <div className="body">
345
+ <aside className="pane left" style={{ width: leftWidth }}>
346
+ {(() => {
347
+ // no tree yet + a server task running ⇒ the index is still being built — show
348
+ // its progress instead of a stale fetch error / a bare "loading…"
349
+ const running = !tree ? tasks.find((t) => t.state === "running") : undefined;
350
+ if (running) {
351
+ const { done, total } = running.progress;
352
+ return <div className="loading">{running.label}… {total ? `${done}/${total}` : ""}</div>;
353
+ }
354
+ if (error) return <div className="error">{error}</div>;
355
+ if (!tree) return <div className="loading">loading…</div>;
356
+ return <Tree node={tree} current={current} onSelect={navigate} onLoadChildren={loadChildren} />;
357
+ })()}
358
+ </aside>
359
+ <div
360
+ className="splitter"
361
+ onMouseDown={() => {
362
+ dragging.current = true;
363
+ document.body.style.userSelect = "none";
364
+ }}
365
+ />
366
+ <main className="pane right">
367
+ <NodeView path={current} format={format} refreshSignal={refreshSignal} onFormat={changeFormat} onNavigate={navigate} onContentChanged={onContentChanged} onOpenUploaded={onOpenUploaded} />
368
+ </main>
369
+ </div>
370
+ </div>
371
+ );
372
+ }