yamlover 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/bin/yamlover.js +202 -0
  4. package/dist/server.js +4681 -0
  5. package/index.html +12 -0
  6. package/package.json +72 -0
  7. package/src/client/App.tsx +372 -0
  8. package/src/client/NodeView.tsx +422 -0
  9. package/src/client/TaskStrip.tsx +34 -0
  10. package/src/client/Tree.tsx +97 -0
  11. package/src/client/api.ts +186 -0
  12. package/src/client/icons.ts +91 -0
  13. package/src/client/links.tsx +108 -0
  14. package/src/client/live.ts +42 -0
  15. package/src/client/main.tsx +10 -0
  16. package/src/client/paste-html.ts +228 -0
  17. package/src/client/paste-links.ts +42 -0
  18. package/src/client/paths.ts +109 -0
  19. package/src/client/render.tsx +326 -0
  20. package/src/client/renderers/annotate.tsx +507 -0
  21. package/src/client/renderers/asciidoc.tsx +35 -0
  22. package/src/client/renderers/chapter.tsx +138 -0
  23. package/src/client/renderers/csv.tsx +233 -0
  24. package/src/client/renderers/decoded.tsx +72 -0
  25. package/src/client/renderers/djvu.tsx +97 -0
  26. package/src/client/renderers/doc.tsx +40 -0
  27. package/src/client/renderers/docx.tsx +49 -0
  28. package/src/client/renderers/epub.tsx +147 -0
  29. package/src/client/renderers/explorer.tsx +209 -0
  30. package/src/client/renderers/fb2.tsx +149 -0
  31. package/src/client/renderers/headings.ts +69 -0
  32. package/src/client/renderers/heic.tsx +23 -0
  33. package/src/client/renderers/imagemap.tsx +157 -0
  34. package/src/client/renderers/kml.ts +46 -0
  35. package/src/client/renderers/latex.tsx +36 -0
  36. package/src/client/renderers/map.tsx +205 -0
  37. package/src/client/renderers/marklower.tsx +119 -0
  38. package/src/client/renderers/markup.tsx +64 -0
  39. package/src/client/renderers/media.tsx +19 -0
  40. package/src/client/renderers/panzoom.ts +101 -0
  41. package/src/client/renderers/pdf.tsx +176 -0
  42. package/src/client/renderers/plaintext.tsx +120 -0
  43. package/src/client/renderers/plantuml.tsx +82 -0
  44. package/src/client/renderers/psd.tsx +25 -0
  45. package/src/client/renderers/registry.tsx +389 -0
  46. package/src/client/renderers/rtf.tsx +210 -0
  47. package/src/client/renderers/spreadsheet.tsx +105 -0
  48. package/src/client/renderers/tag.tsx +113 -0
  49. package/src/client/renderers/text.tsx +41 -0
  50. package/src/client/renderers/tiff.tsx +33 -0
  51. package/src/client/styles.css +1115 -0
  52. package/src/client/vendor/README.md +30 -0
  53. package/src/client/vendor/djvu.js +15535 -0
  54. package/src/client/vite-env.d.ts +31 -0
  55. package/src/server/api.ts +147 -0
  56. package/src/server/engine-api.ts +1442 -0
  57. package/src/server/gitignore.ts +81 -0
  58. package/src/server/node-kind.ts +48 -0
  59. package/src/server/tasks.ts +83 -0
  60. package/src/server/yamlover.ts +1133 -0
@@ -0,0 +1,233 @@
1
+ import { NodeJson } from "../api";
2
+ import { Chunk } from "./registry";
3
+
4
+ /**
5
+ * The renderer for a `string`/`text/csv` (or `text/tab-separated-values`) node:
6
+ * delimited text shown as a table. Like the markdown/asciidoc renderers it works
7
+ * from the node's string value, so it serves both a whole `.csv`/`.tsv` file
8
+ * (`render`) and a single inline chunk (`renderChunk`).
9
+ *
10
+ * The **main parsing parameters live in the URL**, alongside `?format=csv`, so a
11
+ * particular reading of a file is a shareable link:
12
+ *
13
+ * - `sep` — the field separator. `,` `;` `|`, the word `tab` (or `space`), or
14
+ * empty/absent for **auto-detect** (the most frequent candidate on
15
+ * the first line; `\t` for a `.tsv`).
16
+ * - `header` — whether the first row is a header (default true; `false`/`0` off).
17
+ *
18
+ * The full-page view exposes these in the node bar beside the renderer's tab (the
19
+ * {@link CsvControls} `config` control, like the markdown/asciidoc width input),
20
+ * writing the same query params (via `history.replaceState`, preserving the path +
21
+ * `format`), so the URL stays the single source of truth — editing it by hand and
22
+ * reloading is equivalent to using the controls.
23
+ */
24
+
25
+ /** Parse delimited text into rows of fields, RFC-4180-ish: fields may be wrapped in
26
+ * `quote`, a doubled quote is a literal one, and separators/newlines inside quotes
27
+ * are data. Handles `\n` and `\r\n`; a trailing newline does not yield an empty
28
+ * row. */
29
+ export function parseDelimited(text: string, sep: string, quote = '"'): string[][] {
30
+ const rows: string[][] = [];
31
+ let row: string[] = [];
32
+ let field = "";
33
+ let inQuotes = false;
34
+ let i = 0;
35
+ const pushField = () => {
36
+ row.push(field);
37
+ field = "";
38
+ };
39
+ const pushRow = () => {
40
+ pushField();
41
+ rows.push(row);
42
+ row = [];
43
+ };
44
+ while (i < text.length) {
45
+ const c = text[i];
46
+ if (inQuotes) {
47
+ if (c === quote) {
48
+ if (text[i + 1] === quote) {
49
+ field += quote;
50
+ i += 2;
51
+ } else {
52
+ inQuotes = false;
53
+ i++;
54
+ }
55
+ } else {
56
+ field += c;
57
+ i++;
58
+ }
59
+ continue;
60
+ }
61
+ if (c === quote) {
62
+ inQuotes = true;
63
+ i++;
64
+ } else if (c === sep) {
65
+ pushField();
66
+ i++;
67
+ } else if (c === "\n" || c === "\r") {
68
+ if (c === "\r" && text[i + 1] === "\n") i++;
69
+ pushRow();
70
+ i++;
71
+ } else {
72
+ field += c;
73
+ i++;
74
+ }
75
+ }
76
+ // flush the final field/row unless the text ended exactly on a row break
77
+ if (field !== "" || row.length) pushRow();
78
+ return rows;
79
+ }
80
+
81
+ /** Decode a `sep` URL value to the actual separator character, or null for
82
+ * auto-detect (empty/absent). `tab`/`space` are spelled out for URL-friendliness. */
83
+ function decodeSep(v: string | null): string | null {
84
+ if (!v) return null;
85
+ if (v === "tab" || v === "\\t") return "\t";
86
+ if (v === "space") return " ";
87
+ return v[0];
88
+ }
89
+
90
+ /** Auto-detect a separator from the first line: a `.tsv` is tab; otherwise the most
91
+ * frequent of `, ; \t |` (comma when none appears). */
92
+ function autoSep(text: string, format: string | null): string {
93
+ if (format === "text/tab-separated-values") return "\t";
94
+ const firstLine = text.split(/\r?\n/, 1)[0] ?? "";
95
+ let best = ",";
96
+ let bestN = 0;
97
+ for (const c of [",", ";", "\t", "|"]) {
98
+ const n = firstLine.split(c).length - 1;
99
+ if (n > bestN) {
100
+ bestN = n;
101
+ best = c;
102
+ }
103
+ }
104
+ return best;
105
+ }
106
+
107
+ const SEP_OPTIONS: { label: string; value: string }[] = [
108
+ { label: "auto", value: "" },
109
+ { label: "comma ,", value: "," },
110
+ { label: "semicolon ;", value: ";" },
111
+ { label: "tab", value: "tab" },
112
+ { label: "pipe |", value: "|" },
113
+ ];
114
+
115
+ const params = () => new URLSearchParams(window.location.search);
116
+
117
+ /** Read the (header) flag from the URL — default true, off for `false`/`0`. */
118
+ function headerOn(p: URLSearchParams): boolean {
119
+ const h = p.get("header");
120
+ return !(h === "false" || h === "0");
121
+ }
122
+
123
+ /** Pad a row to `cols` cells so every `<tr>` is rectangular. */
124
+ function pad(row: string[], cols: number): string[] {
125
+ return row.length >= cols ? row : [...row, ...Array(cols - row.length).fill("")];
126
+ }
127
+
128
+ /** The table itself — shared by the full page and the inline chunk. */
129
+ function Table({ rows, header }: { rows: string[][]; header: boolean }) {
130
+ if (!rows.length) return <p className="csv-empty">(empty)</p>;
131
+ const cols = rows.reduce((m, r) => Math.max(m, r.length), 0);
132
+ const head = header ? rows[0] : null;
133
+ const body = header ? rows.slice(1) : rows;
134
+ return (
135
+ <div className="csv-scroll">
136
+ <table className="csv-table">
137
+ {head && (
138
+ <thead>
139
+ <tr>
140
+ {pad(head, cols).map((c, i) => (
141
+ <th key={i}>{c}</th>
142
+ ))}
143
+ </tr>
144
+ </thead>
145
+ )}
146
+ <tbody>
147
+ {body.map((r, ri) => (
148
+ <tr key={ri}>
149
+ {pad(r, cols).map((c, ci) => (
150
+ <td key={ci}>{c}</td>
151
+ ))}
152
+ </tr>
153
+ ))}
154
+ </tbody>
155
+ </table>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ export function CsvView({ node }: { node: NodeJson }) {
161
+ // Options are read straight from the URL each render; the node bar's CsvControls
162
+ // (see registry `config`) write them and re-render the node view, so the URL stays
163
+ // the single source of truth — this view holds no parsing state of its own.
164
+ const p = params();
165
+ const header = headerOn(p);
166
+ const text = String(node.value ?? "");
167
+ const sep = decodeSep(p.get("sep")) ?? autoSep(text, node.format ?? null);
168
+ const rows = parseDelimited(text, sep);
169
+
170
+ return (
171
+ <div className="csv">
172
+ {node.title && <h1 className="chapter-title">{node.title}</h1>}
173
+ {node.description && <p className="chapter-subtitle">{node.description}</p>}
174
+ <Table rows={rows} header={header} />
175
+ </div>
176
+ );
177
+ }
178
+
179
+ /**
180
+ * The CSV parsing controls (separator + header row) shown in the node bar beside
181
+ * the renderer's tab — the `config` hook, mirroring the markdown/asciidoc width
182
+ * input. Each writes a query param (preserving the path + other params) and calls
183
+ * `rerender` so {@link CsvView} re-reads the URL and re-parses.
184
+ */
185
+ export function CsvControls({ rerender }: { rerender: () => void }) {
186
+ const p = params();
187
+ const sepParam = p.get("sep") ?? "";
188
+ const header = headerOn(p);
189
+
190
+ const setParam = (key: string, value: string) => {
191
+ const q = params();
192
+ if (value) q.set(key, value);
193
+ else q.delete(key);
194
+ const qs = q.toString();
195
+ window.history.replaceState({}, "", window.location.pathname + (qs ? "?" + qs : ""));
196
+ rerender();
197
+ };
198
+
199
+ return (
200
+ <div className="csv-toolbar">
201
+ <label>
202
+ separator{" "}
203
+ <select value={sepParam} onChange={(e) => setParam("sep", e.target.value)}>
204
+ {SEP_OPTIONS.map((o) => (
205
+ <option key={o.value} value={o.value}>
206
+ {o.label}
207
+ </option>
208
+ ))}
209
+ </select>
210
+ </label>
211
+ <label>
212
+ <input
213
+ type="checkbox"
214
+ checked={header}
215
+ onChange={(e) => setParam("header", e.target.checked ? "" : "false")}
216
+ />{" "}
217
+ header row
218
+ </label>
219
+ </div>
220
+ );
221
+ }
222
+
223
+ /** A CSV chunk embedded inline in a chapter: just the table, auto-detecting the
224
+ * separator and treating the first row as a header (no per-chunk URL controls). */
225
+ export function CsvChunk({ chunk }: { chunk: Chunk }) {
226
+ const text = String(chunk.value ?? "");
227
+ const rows = parseDelimited(text, autoSep(text, chunk.format ?? null));
228
+ return (
229
+ <div className="csv">
230
+ <Table rows={rows} header />
231
+ </div>
232
+ );
233
+ }
@@ -0,0 +1,72 @@
1
+ import { useEffect, useState } from "react";
2
+ import { NodeJson, blobUrl } from "../api";
3
+ import { PanZoomImage } from "./imagemap";
4
+
5
+ /**
6
+ * Shared scaffold for the file formats the browser cannot display natively but
7
+ * that we can decode client-side to ordinary raster images (PSD, TIFF, HEIC).
8
+ * It fetches the node's bytes from `/api/blob`, hands them to a format-specific
9
+ * `decode` that returns one PNG `Blob` per page, and shows each in the same
10
+ * pan/zoom viewer as a native image (each decoded page is its own object-URL).
11
+ * Object-URLs are revoked on unmount / path change so decoded pages don't leak.
12
+ */
13
+ export function DecodedImageView({
14
+ node,
15
+ label,
16
+ decode,
17
+ }: {
18
+ node: NodeJson;
19
+ label: string;
20
+ decode: (buf: ArrayBuffer) => Promise<Blob[]>;
21
+ }) {
22
+ const [urls, setUrls] = useState<string[]>([]);
23
+ const [error, setError] = useState<string | null>(null);
24
+
25
+ useEffect(() => {
26
+ let cancelled = false;
27
+ const created: string[] = [];
28
+ setUrls([]);
29
+ setError(null);
30
+ (async () => {
31
+ const buf = await fetch(blobUrl(node.path)).then((r) => r.arrayBuffer());
32
+ const blobs = await decode(buf);
33
+ if (cancelled) return;
34
+ for (const b of blobs) created.push(URL.createObjectURL(b));
35
+ if (!cancelled) setUrls(created);
36
+ })().catch((e) => !cancelled && setError(String((e as Error).message || e)));
37
+ return () => {
38
+ cancelled = true;
39
+ created.forEach(URL.revokeObjectURL);
40
+ };
41
+ }, [node.path]);
42
+
43
+ if (error) return <div className="error">{label}: {error}</div>;
44
+ if (!urls.length) return <div className="loading">decoding {label}…</div>;
45
+ return (
46
+ <>
47
+ {urls.map((url, i) => (
48
+ <PanZoomImage key={i} src={url} className="filemap fileimagemap" />
49
+ ))}
50
+ </>
51
+ );
52
+ }
53
+
54
+ /** Paint RGBA pixels onto a canvas and export it as a PNG blob. */
55
+ export async function rgbaToPng(rgba: Uint8ClampedArray | Uint8Array, width: number, height: number): Promise<Blob> {
56
+ const canvas = document.createElement("canvas");
57
+ canvas.width = width;
58
+ canvas.height = height;
59
+ const ctx = canvas.getContext("2d");
60
+ if (!ctx) throw new Error("no 2d context");
61
+ const img = ctx.createImageData(width, height);
62
+ img.data.set(rgba);
63
+ ctx.putImageData(img, 0, 0);
64
+ return canvasToPng(canvas);
65
+ }
66
+
67
+ /** Export a canvas as a PNG blob (Promise wrapper over the callback API). */
68
+ export function canvasToPng(canvas: HTMLCanvasElement): Promise<Blob> {
69
+ return new Promise((resolve, reject) =>
70
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("canvas export failed"))), "image/png"),
71
+ );
72
+ }
@@ -0,0 +1,97 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { NodeJson, blobUrl } from "../api";
3
+ // The DjVu.js library (GPL-v2), vendored as a prebuilt IIFE bundle. Loaded as a
4
+ // classic <script> so its `var DjVu = (…)()` lands on the global scope; see
5
+ // vendor/README.md for provenance and license.
6
+ import djvuScriptUrl from "../vendor/djvu.js?url";
7
+
8
+ declare global {
9
+ interface Window {
10
+ DjVu?: any;
11
+ }
12
+ }
13
+
14
+ let loading: Promise<any> | null = null;
15
+ /** Inject the vendored bundle once; resolve with the global `DjVu` namespace. */
16
+ function loadDjVu(): Promise<any> {
17
+ if (window.DjVu) return Promise.resolve(window.DjVu);
18
+ if (!loading) {
19
+ loading = new Promise((resolve, reject) => {
20
+ const s = document.createElement("script");
21
+ s.src = djvuScriptUrl;
22
+ s.onload = () => (window.DjVu ? resolve(window.DjVu) : reject(new Error("DjVu failed to load")));
23
+ s.onerror = () => reject(new Error("could not load djvu.js"));
24
+ document.head.appendChild(s);
25
+ });
26
+ }
27
+ return loading;
28
+ }
29
+
30
+ /**
31
+ * Renders an `image/vnd.djvu` document. The browser has no native DjVu support,
32
+ * so we decode it client-side with DjVu.js: fetch the bytes from `/api/blob`,
33
+ * build a `DjVu.Document`, and render each page to a PNG object-URL shown as an
34
+ * `<img>` (cheaper than holding every page as a full-resolution canvas). A plain
35
+ * wheel scrolls the document; ctrl/alt-wheel zooms (scales the page width), matching
36
+ * the image/map/pdf viewers (see the UI guide).
37
+ */
38
+ export function DjvuView({ node }: { node: NodeJson }) {
39
+ const ref = useRef<HTMLDivElement>(null);
40
+ const [pages, setPages] = useState<string[]>([]);
41
+ const [count, setCount] = useState(0);
42
+ const [zoom, setZoom] = useState(1);
43
+ const [error, setError] = useState<string | null>(null);
44
+
45
+ // ctrl/alt-wheel zooms; a plain wheel is left alone so the pane keeps scrolling.
46
+ useEffect(() => {
47
+ const el = ref.current;
48
+ if (!el) return;
49
+ const onWheel = (e: WheelEvent) => {
50
+ if (!(e.ctrlKey || e.altKey || e.metaKey)) return;
51
+ e.preventDefault();
52
+ setZoom((z) => Math.min(5, Math.max(0.4, z * (e.deltaY < 0 ? 1.1 : 1 / 1.1))));
53
+ };
54
+ el.addEventListener("wheel", onWheel, { passive: false });
55
+ return () => el.removeEventListener("wheel", onWheel);
56
+ }, []);
57
+
58
+ useEffect(() => {
59
+ let cancelled = false;
60
+ const created: string[] = [];
61
+ setPages([]);
62
+ setCount(0);
63
+ setError(null);
64
+ (async () => {
65
+ const DjVu = await loadDjVu();
66
+ const buf = await fetch(blobUrl(node.path)).then((r) => r.arrayBuffer());
67
+ const doc = new DjVu.Document(buf);
68
+ const total = doc.getPagesQuantity();
69
+ if (!cancelled) setCount(total);
70
+ // Decode page-by-page and reveal each as it's ready, so a long scanned
71
+ // document shows its first page quickly instead of blocking on the whole.
72
+ for (let i = 1; i <= total && !cancelled; i++) {
73
+ const page = await doc.getPage(i);
74
+ const { url } = await page.createPngObjectUrl();
75
+ created.push(url);
76
+ if (!cancelled) setPages((prev) => [...prev, url]);
77
+ }
78
+ })().catch((e) => !cancelled && setError(String((e as Error).message || e)));
79
+ return () => {
80
+ cancelled = true;
81
+ created.forEach(URL.revokeObjectURL);
82
+ };
83
+ }, [node.path]);
84
+
85
+ if (error) return <div className="error">djvu: {error}</div>;
86
+ return (
87
+ <div className="filedjvu yo-zoomable" ref={ref}>
88
+ {count > 0 && pages.length < count && (
89
+ <div className="loading">decoding djvu… page {pages.length + 1} of {count}</div>
90
+ )}
91
+ {count === 0 && pages.length === 0 && <div className="loading">decoding djvu…</div>}
92
+ {pages.map((url, i) => (
93
+ <img key={i} className="djvu-page" style={{ width: `${zoom * 100}%` }} src={url} alt={`page ${i + 1}`} />
94
+ ))}
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,40 @@
1
+ import { NodeJson, blobUrl } from "../api";
2
+ import { Chunk } from "./registry";
3
+
4
+ /**
5
+ * Renderer for a legacy `.doc` (Word 97–2003 binary / OLE compound file). Unlike
6
+ * `.docx`, the old binary format has no reliable pure-browser parser, so rather
7
+ * than show a broken conversion we present it honestly: a note and a download link
8
+ * to the raw bytes (served by `/api/blob`). Faithful in-browser rendering would
9
+ * need a server-side conversion step (e.g. LibreOffice), which is out of scope here.
10
+ */
11
+ function DocNote({ path }: { path: string }) {
12
+ return (
13
+ <div className="office-fallback">
14
+ <p>
15
+ Legacy <code>.doc</code> (Word 97–2003) isn’t rendered in the browser — the old binary format has no
16
+ reliable client-side parser. Newer <code>.docx</code> files render in full.
17
+ </p>
18
+ <p>
19
+ <a className="descend" href={blobUrl(path)} download>
20
+ ⤓ Download the document
21
+ </a>{" "}
22
+ to open it in an office application.
23
+ </p>
24
+ </div>
25
+ );
26
+ }
27
+
28
+ export function DocView({ node }: { node: NodeJson }) {
29
+ return (
30
+ <div className="text">
31
+ {node.title && <h1 className="chapter-title">{node.title}</h1>}
32
+ {node.description && <p className="chapter-subtitle">{node.description}</p>}
33
+ <DocNote path={node.path} />
34
+ </div>
35
+ );
36
+ }
37
+
38
+ export function DocChunk({ chunk }: { chunk: Chunk }) {
39
+ return <DocNote path={chunk.path} />;
40
+ }
@@ -0,0 +1,49 @@
1
+ import { useEffect, useState } from "react";
2
+ import mammoth from "mammoth/mammoth.browser";
3
+ import { NodeJson, blobUrl } from "../api";
4
+ import { Chunk } from "./registry";
5
+
6
+ /**
7
+ * Renderer for a `.docx` (Office Open XML word document). The file is served as
8
+ * bytes; mammoth converts the document body to clean semantic HTML (headings,
9
+ * lists, bold/italic, tables, …), shown in the shared `.markup` body. mammoth is
10
+ * heavy and browser-only, so the registry loads this module lazily.
11
+ */
12
+ function useDocxHtml(path: string): { html: string | null; error: string | null } {
13
+ const [html, setHtml] = useState<string | null>(null);
14
+ const [error, setError] = useState<string | null>(null);
15
+ useEffect(() => {
16
+ let cancelled = false;
17
+ setHtml(null);
18
+ setError(null);
19
+ fetch(blobUrl(path))
20
+ .then((r) => r.arrayBuffer())
21
+ .then((buf) => mammoth.convertToHtml({ arrayBuffer: buf }))
22
+ .then((res) => !cancelled && setHtml(res.value))
23
+ .catch((e) => !cancelled && setError(String((e as Error).message || e)));
24
+ return () => {
25
+ cancelled = true;
26
+ };
27
+ }, [path]);
28
+ return { html, error };
29
+ }
30
+
31
+ export function DocxView({ node }: { node: NodeJson }) {
32
+ const { html, error } = useDocxHtml(node.path);
33
+ if (error) return <div className="error">docx: {error}</div>;
34
+ if (html == null) return <div className="loading">converting document…</div>;
35
+ return (
36
+ <div className="text">
37
+ {node.title && <h1 className="chapter-title">{node.title}</h1>}
38
+ {node.description && <p className="chapter-subtitle">{node.description}</p>}
39
+ <div className="markup" dangerouslySetInnerHTML={{ __html: html }} />
40
+ </div>
41
+ );
42
+ }
43
+
44
+ export function DocxChunk({ chunk }: { chunk: Chunk }) {
45
+ const { html, error } = useDocxHtml(chunk.path);
46
+ if (error) return <div className="error">docx: {error}</div>;
47
+ if (html == null) return <div className="loading">converting document…</div>;
48
+ return <div className="markup" dangerouslySetInnerHTML={{ __html: html }} />;
49
+ }
@@ -0,0 +1,147 @@
1
+ import { useEffect, useState } from "react";
2
+ import { unzipSync, strFromU8 } from "fflate";
3
+ import { NodeJson, blobUrl } from "../api";
4
+
5
+ const XLINK = "http://www.w3.org/1999/xlink";
6
+
7
+ /** Normalize a zip path: resolve `rel` (which may contain `..`/`.`) against the
8
+ * directory `baseDir`, dropping any fragment/query. */
9
+ function resolvePath(baseDir: string, rel: string): string {
10
+ rel = decodeURIComponent(rel.split("#")[0].split("?")[0]);
11
+ const parts = (baseDir ? baseDir.split("/") : []).concat(rel.split("/"));
12
+ const out: string[] = [];
13
+ for (const p of parts) {
14
+ if (p === "" || p === ".") continue;
15
+ if (p === "..") out.pop();
16
+ else out.push(p);
17
+ }
18
+ return out.join("/");
19
+ }
20
+
21
+ function dirOf(p: string): string {
22
+ const i = p.lastIndexOf("/");
23
+ return i < 0 ? "" : p.slice(0, i);
24
+ }
25
+
26
+ interface Book {
27
+ title?: string;
28
+ author?: string;
29
+ cover?: string;
30
+ html: string;
31
+ }
32
+
33
+ /**
34
+ * Renderer for an `application/epub+zip` (`.epub`) ebook. The file is served as
35
+ * bytes; here we unzip it, read the package document (OPF) for its metadata,
36
+ * manifest and spine, then render the spine's XHTML documents in order — with
37
+ * internal images rewired to object URLs and scripts/styles stripped.
38
+ */
39
+ export function EpubView({ node }: { node: NodeJson }) {
40
+ const [book, setBook] = useState<Book | null>(null);
41
+ const [error, setError] = useState<string | null>(null);
42
+
43
+ useEffect(() => {
44
+ let cancelled = false;
45
+ const urls: string[] = [];
46
+ setBook(null);
47
+ setError(null);
48
+
49
+ fetch(blobUrl(node.path))
50
+ .then((r) => r.arrayBuffer())
51
+ .then((buf) => {
52
+ if (cancelled) return;
53
+ const files = unzipSync(new Uint8Array(buf));
54
+ const text = (p: string) => (files[p] ? strFromU8(files[p]) : "");
55
+ const parseXml = (p: string) => new DOMParser().parseFromString(text(p), "application/xml");
56
+
57
+ // container.xml → the OPF package document
58
+ const opfPath = parseXml("META-INF/container.xml")
59
+ .getElementsByTagNameNS("*", "rootfile")[0]
60
+ ?.getAttribute("full-path");
61
+ if (!opfPath || !files[opfPath]) throw new Error("no OPF package document");
62
+ const opf = parseXml(opfPath);
63
+ const opfDir = dirOf(opfPath);
64
+
65
+ // object URL for a zip entry, memoized (and tracked for revocation)
66
+ const cache = new Map<string, string>();
67
+ const objUrl = (path: string, type = ""): string => {
68
+ if (!files[path]) return "";
69
+ const hit = cache.get(path);
70
+ if (hit) return hit;
71
+ const u = URL.createObjectURL(new Blob([files[path]], { type }));
72
+ cache.set(path, u);
73
+ urls.push(u);
74
+ return u;
75
+ };
76
+
77
+ // metadata
78
+ const title = opf.getElementsByTagNameNS("*", "title")[0]?.textContent?.trim();
79
+ const author = Array.from(opf.getElementsByTagNameNS("*", "creator"))
80
+ .map((c) => c.textContent?.trim())
81
+ .filter(Boolean)
82
+ .join(", ");
83
+
84
+ // manifest: id → { href (zip path), type }
85
+ const manifest = new Map<string, { path: string; type: string }>();
86
+ for (const it of Array.from(opf.getElementsByTagNameNS("*", "item"))) {
87
+ const id = it.getAttribute("id");
88
+ const href = it.getAttribute("href");
89
+ if (id && href) manifest.set(id, { path: resolvePath(opfDir, href), type: it.getAttribute("media-type") || "" });
90
+ }
91
+
92
+ // cover image (EPUB2 `<meta name="cover">`)
93
+ const coverId = Array.from(opf.getElementsByTagNameNS("*", "meta")).find(
94
+ (m) => m.getAttribute("name") === "cover",
95
+ )?.getAttribute("content");
96
+ const coverItem = coverId ? manifest.get(coverId) : undefined;
97
+ const cover = coverItem ? objUrl(coverItem.path, coverItem.type) : undefined;
98
+
99
+ // spine: render each XHTML document in reading order
100
+ const out: string[] = [];
101
+ for (const ref of Array.from(opf.getElementsByTagNameNS("*", "itemref"))) {
102
+ const item = manifest.get(ref.getAttribute("idref") || "");
103
+ if (!item || !/html/.test(item.type) || !files[item.path]) continue;
104
+ const docDir = dirOf(item.path);
105
+ const dom = new DOMParser().parseFromString(strFromU8(files[item.path]), "text/html");
106
+ const body = dom.body;
107
+ if (!body) continue;
108
+ body.querySelectorAll("script, style, link").forEach((e) => e.remove());
109
+ for (const img of Array.from(body.querySelectorAll("img"))) {
110
+ const src = img.getAttribute("src");
111
+ const u = src ? objUrl(resolvePath(docDir, src)) : "";
112
+ if (u) img.setAttribute("src", u);
113
+ else img.removeAttribute("src");
114
+ }
115
+ for (const im of Array.from(body.querySelectorAll("image"))) {
116
+ const href = im.getAttributeNS(XLINK, "href") || im.getAttribute("href") || im.getAttribute("xlink:href");
117
+ const u = href ? objUrl(resolvePath(docDir, href)) : "";
118
+ if (u) im.setAttributeNS(XLINK, "xlink:href", u);
119
+ }
120
+ out.push(`<section class="epub-doc">${body.innerHTML}</section>`);
121
+ }
122
+
123
+ if (cancelled) {
124
+ urls.forEach(URL.revokeObjectURL);
125
+ return;
126
+ }
127
+ setBook({ title, author: author || undefined, cover, html: out.join("\n") });
128
+ })
129
+ .catch((e) => !cancelled && setError(String((e as Error).message || e)));
130
+
131
+ return () => {
132
+ cancelled = true;
133
+ urls.forEach(URL.revokeObjectURL);
134
+ };
135
+ }, [node.path]);
136
+
137
+ if (error) return <div className="error">epub: {error}</div>;
138
+ if (!book) return <div className="loading">unpacking EPUB…</div>;
139
+ return (
140
+ <div className="text epub">
141
+ {book.cover && <img className="fb2-cover" src={book.cover} alt="" />}
142
+ {book.title && <h1 className="chapter-title">{book.title}</h1>}
143
+ {book.author && <p className="chapter-subtitle">{book.author}</p>}
144
+ <div className="markup epub-body" dangerouslySetInnerHTML={{ __html: book.html }} />
145
+ </div>
146
+ );
147
+ }