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,389 @@
1
+ import { lazy, Suspense } from "react";
2
+ import { NodeJson, TreeNode } from "../api";
3
+ import { ChapterView } from "./chapter";
4
+ import { TextView, TextChunk } from "./text";
5
+ import { MarklowerView, MarklowerChunk } from "./marklower";
6
+ import { LatexView, LatexChunk } from "./latex";
7
+ import { AsciidocView, AsciidocChunk } from "./asciidoc";
8
+ import { CsvView, CsvChunk, CsvControls } from "./csv";
9
+ import { PlaintextView, PlaintextChunk, EncodingControl } from "./plaintext";
10
+ import { RtfView, RtfChunk } from "./rtf";
11
+ import { DocView, DocChunk } from "./doc";
12
+ import { PlantumlView, PlantumlChunk } from "./plantuml";
13
+ import { ExplorerView, ExplorerViewControl } from "./explorer";
14
+ import { Fb2View } from "./fb2";
15
+ import { EpubView } from "./epub";
16
+ import { HtmlView } from "./media";
17
+ import { MarkupWidthControl } from "./markup";
18
+
19
+ // pdf.js and DjVu.js are heavy and browser-only (they reach for canvas globals at
20
+ // import time). Load them lazily so the registry — imported by the TOC and by
21
+ // tests — never pulls them in until a PDF/DjVu node is actually rendered.
22
+ const PdfView = lazy(() => import("./pdf").then((m) => ({ default: m.PdfView })));
23
+ const DjvuView = lazy(() => import("./djvu").then((m) => ({ default: m.DjvuView })));
24
+ const PsdView = lazy(() => import("./psd").then((m) => ({ default: m.PsdView })));
25
+ const TiffView = lazy(() => import("./tiff").then((m) => ({ default: m.TiffView })));
26
+ const HeicView = lazy(() => import("./heic").then((m) => ({ default: m.HeicView })));
27
+ // mammoth (.docx) and SheetJS (.xls/.xlsx) are heavy; load each on first use.
28
+ const DocxView = lazy(() => import("./docx").then((m) => ({ default: m.DocxView })));
29
+ const DocxChunk = lazy(() => import("./docx").then((m) => ({ default: m.DocxChunk })));
30
+ const SpreadsheetView = lazy(() => import("./spreadsheet").then((m) => ({ default: m.SpreadsheetView })));
31
+ const SpreadsheetChunk = lazy(() => import("./spreadsheet").then((m) => ({ default: m.SpreadsheetChunk })));
32
+ // Leaflet (KML/KMZ maps; and the pan/zoom image viewer) is heavy and browser-only; lazy-load.
33
+ const MapView = lazy(() => import("./map").then((m) => ({ default: m.MapView })));
34
+ const MapChunk = lazy(() => import("./map").then((m) => ({ default: m.MapChunk })));
35
+ const ImageView = lazy(() => import("./imagemap").then((m) => ({ default: m.ImageView })));
36
+ const ImageChunk = lazy(() => import("./imagemap").then((m) => ({ default: m.ImageChunk })));
37
+ const lazily = (el: JSX.Element) => <Suspense fallback={<div className="loading">…</div>}>{el}</Suspense>;
38
+
39
+ /** Synthesize a minimal `NodeJson` from a chunk so a file-backed renderer (which
40
+ * only needs the node's `path`/`value`) can be reused *inline* as a chapter
41
+ * chunk — the same view, addressed by the chunk's own node path. This is how a
42
+ * PDF / DjVu / PSD / TIFF / HEIC / FB2 / EPUB / HTML chunk renders in a chapter
43
+ * body, not just a full page. */
44
+ const chunkNode = (chunk: Chunk): NodeJson => ({
45
+ path: chunk.path,
46
+ type: chunk.type,
47
+ format: chunk.format,
48
+ concrete: null,
49
+ title: null,
50
+ description: null,
51
+ value: chunk.value,
52
+ });
53
+
54
+ /**
55
+ * A renderer turns a node into a React element for the RHS pane. It is selected
56
+ * by the node's **(type, format)** tuple — the same key the TOC icons and the
57
+ * link markers carry — so a renderer can claim, say, every `string`/`text/markdown`,
58
+ * every `object`/`x-yamlover-chapter`, or a bare `(type, None)`. Our own custom
59
+ * formats are prefixed `x-yamlover-`.
60
+ *
61
+ * The registry is the single extension point: add an entry here to teach the UI a
62
+ * new renderable shape. A renderer's `name` is also its representation key — the
63
+ * label of its tab and the `?format=` value (e.g. `chapter`).
64
+ *
65
+ * A renderer participates in the UI three ways, all keyed by the same tuple:
66
+ * - `render` — the full RHS page.
67
+ * - `renderChunk` — its *inline* form, when embedded in another renderer's page
68
+ * (a chapter renders each chunk by routing to the chunk's own renderer here).
69
+ * - `tocView` — how the node appears in the TOC: which children are navigable,
70
+ * whether it expands, and whether they are loaded. The chapter unwraps its
71
+ * `children` array (subchapters become its direct TOC entries) and keeps its
72
+ * `chunks` off the tree (prose is read on the page, not browsed).
73
+ */
74
+
75
+ /** A single chunk handed to a renderer's `renderChunk` — its value plus the
76
+ * (type, format) it was routed on and its JSON path (the anchor target). */
77
+ export interface Chunk {
78
+ value: unknown;
79
+ path: string;
80
+ type: string;
81
+ format: string | null;
82
+ /** The JSON-space path of the document this chunk belongs to (the enclosing
83
+ * chapter's `documentPath`) — the anchor a document-relative (`/…`) marklower
84
+ * link in the chunk resolves against. */
85
+ documentPath?: string;
86
+ }
87
+
88
+ /** How a node appears in the TOC. `children` are the rows shown beneath it;
89
+ * `expandable` shows a chevron; `loaded` false means the children must be
90
+ * fetched (by `node.path`) on first expand. `loadDepth` is how many levels that
91
+ * expand fetch must pull (default 1) — more when a renderer's TOC rows live
92
+ * deeper than the node's direct children (a chapter surfaces its subchapters
93
+ * from *under* its `children` wrapper, and fetches one further level so each
94
+ * revealed subchapter's own chevron is accurate — so it needs 3). */
95
+ export interface TocView {
96
+ children: TreeNode[];
97
+ expandable: boolean;
98
+ loaded: boolean;
99
+ loadDepth?: number;
100
+ }
101
+
102
+ export interface Renderer {
103
+ name: string;
104
+ /** The (type, format) tuples this renderer claims. A `null` format matches a
105
+ * node that carries no `format`; a string format matches that format exactly. */
106
+ accepts: ReadonlyArray<readonly [type: string, format: string | null]>;
107
+ /** Value depth `NodeView` must fetch for this renderer (default 1). A chapter
108
+ * needs 2: its `chunks`/`children` arrays one level, their elements the next. */
109
+ depth?: number;
110
+ /** This node's TOC presentation (default: its own children, lazily loaded). */
111
+ tocView?: (node: TreeNode) => TocView;
112
+ render: (node: NodeJson, onNavigate: (path: string) => void) => JSX.Element;
113
+ /** This renderer's inline form, for embedding a single value in another page. */
114
+ renderChunk?: (chunk: Chunk, onNavigate: (path: string) => void) => JSX.Element;
115
+ /** An optional control shown in the tab bar beside this renderer's button (only while its
116
+ * view is active) — e.g. the markdown/asciidoc reading-width input. `rerender` refreshes
117
+ * the node view after the control changes a URL parameter. */
118
+ config?: (rerender: () => void) => JSX.Element;
119
+ }
120
+
121
+ /**
122
+ * The EXPLORER — a file-manager "small icons" grid of a node's members, uplinks first. It is
123
+ * claimed two ways: by (type, format) for tags (a tag projects as `object` — fields only,
124
+ * `variant` — description BODY + fields, `string` — a leaf tag that is just its description,
125
+ * or `null` — a bare tag with neither, the shape the picker's create-on-miss writes;
126
+ * the grid shows the tagged MATERIALS), and as the CONCRETE fallback for any node stored as a
127
+ * filesystem directory (`dir`/`yamlover`) that no (type, format) renderer claims — see
128
+ * {@link getRenderer}. Hoisted so the fallback can reference the same instance.
129
+ */
130
+ const EXPLORER: Renderer = {
131
+ name: "explorer",
132
+ accepts: [
133
+ ["object", "x-yamlover-tag"],
134
+ ["variant", "x-yamlover-tag"],
135
+ ["string", "x-yamlover-tag"],
136
+ ["null", "x-yamlover-tag"],
137
+ ],
138
+ render: (node, onNavigate) => <ExplorerView node={node} onNavigate={onNavigate} />,
139
+ config: (rerender) => <ExplorerViewControl rerender={rerender} />, // large/small icons (`?view=`)
140
+ };
141
+
142
+ /** A directory-stored node (`dir` = a plain folder, `yamlover` = a folder with `.yamlover/`). */
143
+ const isDirConcrete = (concrete: string | null | undefined): boolean => concrete === "dir" || concrete === "yamlover";
144
+
145
+ const REGISTRY: Renderer[] = [
146
+ {
147
+ name: "chapter",
148
+ accepts: [["object", "x-yamlover-chapter"]],
149
+ depth: 2, // reach the chunk/subchapter elements (arrays one level, items the next)
150
+ tocView: chapterTocView,
151
+ render: (node, onNavigate) => <ChapterView node={node} onNavigate={onNavigate} />,
152
+ },
153
+ {
154
+ // Our default for a bare, format-less string: marklower, a markup language a
155
+ // notch below Markdown. A chapter's prose chunks route here — both the bare
156
+ // (string, null) form and the explicit `text/marklower` the chunk schema applies.
157
+ name: "marklower",
158
+ accepts: [
159
+ ["string", null],
160
+ ["string", "text/marklower"],
161
+ ],
162
+ render: (node, onNavigate) => <MarklowerView node={node} onNavigate={onNavigate} />,
163
+ renderChunk: (chunk, onNavigate) => <MarklowerChunk chunk={chunk} onNavigate={onNavigate} />,
164
+ },
165
+ {
166
+ // Markdown (the component file is text.tsx for historical reasons; the renderer —
167
+ // its tab label and `?format=` key — is named for what it renders).
168
+ name: "markdown",
169
+ accepts: [["string", "text/markdown"]],
170
+ render: (node) => <TextView node={node} />,
171
+ renderChunk: (chunk) => <TextChunk chunk={chunk} />,
172
+ config: (rerender) => <MarkupWidthControl rerender={rerender} />,
173
+ },
174
+ {
175
+ name: "asciidoc",
176
+ accepts: [["string", "text/asciidoc"]],
177
+ render: (node) => <AsciidocView node={node} />,
178
+ renderChunk: (chunk) => <AsciidocChunk chunk={chunk} />,
179
+ config: (rerender) => <MarkupWidthControl rerender={rerender} />,
180
+ },
181
+ {
182
+ // Delimited text (CSV/TSV, a string) shown as a table; its parsing options
183
+ // (separator, header) ride in the URL query — see csv.tsx.
184
+ name: "csv",
185
+ accepts: [
186
+ ["string", "text/csv"],
187
+ ["string", "text/tab-separated-values"],
188
+ ],
189
+ render: (node) => <CsvView node={node} />,
190
+ renderChunk: (chunk) => <CsvChunk chunk={chunk} />,
191
+ config: (rerender) => <CsvControls rerender={rerender} />,
192
+ },
193
+ {
194
+ // Plain text shown verbatim (no markup), with a node-bar encoding selector —
195
+ // CP866 / Windows-1251 / KOI8-R / UTF-8 (see plaintext.tsx). Served as raw
196
+ // bytes so the encoding is the client's to choose.
197
+ name: "plaintext",
198
+ accepts: [["binary", "text/plain"]],
199
+ render: (node) => <PlaintextView node={node} />,
200
+ renderChunk: (chunk) => <PlaintextChunk chunk={chunk} />,
201
+ config: (rerender) => <EncodingControl rerender={rerender} />,
202
+ },
203
+ {
204
+ // RTF — a dependency-free converter to HTML (see rtf.tsx).
205
+ name: "rtf",
206
+ accepts: [["binary", "application/rtf"]],
207
+ render: (node) => <RtfView node={node} />,
208
+ renderChunk: (chunk) => <RtfChunk chunk={chunk} />,
209
+ },
210
+ {
211
+ // .docx (Office Open XML) via mammoth, lazily loaded.
212
+ name: "docx",
213
+ accepts: [["binary", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"]],
214
+ render: (node) => lazily(<DocxView node={node} />),
215
+ renderChunk: (chunk) => lazily(<DocxChunk chunk={chunk} />),
216
+ },
217
+ {
218
+ // Excel workbooks — .xlsx and legacy .xls — via SheetJS, lazily loaded.
219
+ name: "spreadsheet",
220
+ accepts: [
221
+ ["binary", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
222
+ ["binary", "application/vnd.ms-excel"],
223
+ ],
224
+ render: (node) => lazily(<SpreadsheetView node={node} />),
225
+ renderChunk: (chunk) => lazily(<SpreadsheetChunk chunk={chunk} />),
226
+ },
227
+ {
228
+ // Legacy .doc (Word 97–2003 binary) — no in-browser parser; download fallback.
229
+ name: "doc",
230
+ accepts: [["binary", "application/msword"]],
231
+ render: (node) => <DocView node={node} />,
232
+ renderChunk: (chunk) => <DocChunk chunk={chunk} />,
233
+ },
234
+ {
235
+ // KML / KMZ geographic overlays drawn on a Leaflet map (lazily loaded).
236
+ name: "map",
237
+ accepts: [
238
+ ["binary", "application/vnd.google-earth.kml+xml"],
239
+ ["binary", "application/vnd.google-earth.kmz"],
240
+ ],
241
+ render: (node) => lazily(<MapView node={node} />),
242
+ renderChunk: (chunk) => lazily(<MapChunk chunk={chunk} />),
243
+ },
244
+ {
245
+ // LaTeX math (a string) typeset with KaTeX, both whole and inline. marklower
246
+ // reuses the same engine for its `$$…$$` spans.
247
+ name: "latex",
248
+ accepts: [["string", "text/x-latex"]],
249
+ render: (node) => <LatexView node={node} />,
250
+ renderChunk: (chunk) => <LatexChunk chunk={chunk} />,
251
+ },
252
+ {
253
+ // PlantUML source (a string) shown as the diagram it compiles to, both as a
254
+ // whole node and inline as a chapter chunk.
255
+ name: "plantuml",
256
+ accepts: [["string", "text/x-plantuml"]],
257
+ render: (node) => <PlantumlView node={node} />,
258
+ renderChunk: (chunk) => <PlantumlChunk chunk={chunk} />,
259
+ },
260
+ EXPLORER,
261
+ {
262
+ // File-backed binaries the server tags with an inferred image format.
263
+ name: "image",
264
+ accepts: [
265
+ ["binary", "image/png"],
266
+ ["binary", "image/jpeg"],
267
+ ["binary", "image/gif"],
268
+ ["binary", "image/webp"],
269
+ ["binary", "image/avif"],
270
+ ["binary", "image/bmp"],
271
+ ["binary", "image/x-icon"],
272
+ ["binary", "image/svg+xml"],
273
+ ],
274
+ render: (node) => lazily(<ImageView node={node} />),
275
+ renderChunk: (chunk) => lazily(<ImageChunk chunk={chunk} />),
276
+ },
277
+ {
278
+ name: "html",
279
+ accepts: [["binary", "text/html"]],
280
+ render: (node) => <HtmlView node={node} />,
281
+ renderChunk: (chunk) => <HtmlView node={chunkNode(chunk)} />,
282
+ },
283
+ {
284
+ name: "fb2",
285
+ accepts: [["binary", "application/x-fictionbook+xml"]],
286
+ render: (node) => <Fb2View node={node} />,
287
+ renderChunk: (chunk) => <Fb2View node={chunkNode(chunk)} />,
288
+ },
289
+ {
290
+ name: "epub",
291
+ accepts: [["binary", "application/epub+zip"]],
292
+ render: (node) => <EpubView node={node} />,
293
+ renderChunk: (chunk) => <EpubView node={chunkNode(chunk)} />,
294
+ },
295
+ {
296
+ name: "pdf",
297
+ accepts: [["binary", "application/pdf"]],
298
+ render: (node) => lazily(<PdfView node={node} />),
299
+ renderChunk: (chunk) => lazily(<PdfView node={chunkNode(chunk)} />),
300
+ },
301
+ {
302
+ name: "djvu",
303
+ accepts: [["binary", "image/vnd.djvu"]],
304
+ render: (node) => lazily(<DjvuView node={node} />),
305
+ renderChunk: (chunk) => lazily(<DjvuView node={chunkNode(chunk)} />),
306
+ },
307
+ {
308
+ name: "psd",
309
+ accepts: [["binary", "image/vnd.adobe.photoshop"]],
310
+ render: (node) => lazily(<PsdView node={node} />),
311
+ renderChunk: (chunk) => lazily(<PsdView node={chunkNode(chunk)} />),
312
+ },
313
+ {
314
+ name: "tiff",
315
+ accepts: [["binary", "image/tiff"]],
316
+ render: (node) => lazily(<TiffView node={node} />),
317
+ renderChunk: (chunk) => lazily(<TiffView node={chunkNode(chunk)} />),
318
+ },
319
+ {
320
+ name: "heic",
321
+ accepts: [["binary", "image/heic"]],
322
+ render: (node) => lazily(<HeicView node={node} />),
323
+ renderChunk: (chunk) => lazily(<HeicView node={chunkNode(chunk)} />),
324
+ },
325
+ ];
326
+
327
+ /** The last path segment (a property key or `[index]`) of a colon-form client path. */
328
+ function basename(path: string): string {
329
+ const i = path.lastIndexOf(":");
330
+ return i < 0 ? path : path.slice(i + 1);
331
+ }
332
+
333
+ /** A chapter's TOC view: its subchapters (the items of its `children` array)
334
+ * surfaced directly, with its `chunks` kept off the tree. The `children` wrapper
335
+ * sits one level below the chapter, so its items are loaded one level deeper —
336
+ * expandability follows the wrapper's own `hasChildren`. */
337
+ function chapterTocView(node: TreeNode): TocView {
338
+ // Subchapters live under the `children` wrapper, one level below the chapter, so
339
+ // revealing them costs a level (chapter → children → subchapters). We fetch one
340
+ // more (→ each subchapter's own `children` wrapper) so a revealed subchapter's
341
+ // chevron is decided from its real subchapter list, not the generic `hasChildren`
342
+ // hint (which is always true for a chapter — it has `chunks`/`children` arrays).
343
+ // Hence 3, so a childless chapter (only chunks) shows no chevron from the start.
344
+ const wrap = node.children.find((c) => basename(c.path) === "children");
345
+ if (!wrap) {
346
+ // the chapter itself is not loaded yet — defer to the server's hint
347
+ return { children: [], expandable: node.hasChildren, loaded: node.children.length > 0, loadDepth: 3 };
348
+ }
349
+ // expandable iff the `children` wrapper actually holds subchapters (chunks-only
350
+ // chapters have an empty wrapper → no chevron)
351
+ return {
352
+ children: wrap.children,
353
+ expandable: wrap.hasChildren,
354
+ loaded: wrap.children.length > 0 || !wrap.hasChildren,
355
+ loadDepth: 3,
356
+ };
357
+ }
358
+
359
+ /** The renderer whose `accepts` covers `(type, format)`, or null when none does. */
360
+ export function rendererFor(type: string, format: string | null): Renderer | null {
361
+ return REGISTRY.find((r) => r.accepts.some(([t, f]) => t === type && f === format)) ?? null;
362
+ }
363
+
364
+ /** The renderer for a node: its (type, format) claim, else — for a node stored as a
365
+ * filesystem directory that no format renderer claims (a dir-backed chapter stays a
366
+ * chapter) — the explorer, else null → the default tabbed view. */
367
+ export function getRenderer(node: NodeJson): Renderer | null {
368
+ const r = rendererFor(node.type, node.format ?? null);
369
+ if (r) return r;
370
+ return isDirConcrete(node.concrete) ? EXPLORER : null;
371
+ }
372
+
373
+ /** The name (= representation key / `?format=` value) of the renderer for
374
+ * `(type, format)` — with the same directory-concrete explorer fallback as
375
+ * {@link getRenderer} — or null when none claims it. */
376
+ export function rendererName(type: string, format: string | null, concrete?: string | null): string | null {
377
+ const r = rendererFor(type, format);
378
+ if (r) return r.name;
379
+ return isDirConcrete(concrete) ? EXPLORER.name : null;
380
+ }
381
+
382
+ /** How `node` appears in the TOC: its renderer's `tocView`, or — when no renderer
383
+ * claims it — its own children, lazily loaded (the passive default). */
384
+ export function tocView(node: TreeNode): TocView {
385
+ const r = rendererFor(node.type, node.format);
386
+ if (r?.tocView) return r.tocView(node);
387
+ const loaded = node.children.length > 0;
388
+ return { children: node.children, expandable: loaded ? node.children.length > 0 : node.hasChildren, loaded };
389
+ }
@@ -0,0 +1,210 @@
1
+ import { useEffect, useState } from "react";
2
+ import { NodeJson, blobUrl } from "../api";
3
+ import { Chunk } from "./registry";
4
+
5
+ /**
6
+ * Renderer for an `application/rtf` (`.rtf`) document. RTF is a plain-text control
7
+ * language; rather than pull in a heavy dependency we walk it with a compact,
8
+ * dependency-free converter ({@link rtfToHtml}) that covers the common content:
9
+ * paragraphs, bold/italic/underline runs, tabs/line breaks, hex (`\'xx`, CP-1252)
10
+ * and `\uN` Unicode escapes — skipping the non-content destination groups
11
+ * (`fonttbl`, `colortbl`, `stylesheet`, `info`, pictures, …). The result is shown
12
+ * in the shared `.markup` body, like the Markdown/AsciiDoc renderers.
13
+ */
14
+
15
+ // The handful of Windows-1252 code points that differ from Latin-1 (0x80–0x9F),
16
+ // used to decode `\'xx` bytes the way most RTF producers mean them.
17
+ const CP1252: Record<number, string> = {
18
+ 0x80: "€", 0x82: "‚", 0x83: "ƒ", 0x84: "„", 0x85: "…", 0x86: "†", 0x87: "‡",
19
+ 0x88: "ˆ", 0x89: "‰", 0x8a: "Š", 0x8b: "‹", 0x8c: "Œ", 0x8e: "Ž", 0x91: "‘",
20
+ 0x92: "’", 0x93: "“", 0x94: "”", 0x95: "•", 0x96: "–", 0x97: "—", 0x98: "˜",
21
+ 0x99: "™", 0x9a: "š", 0x9b: "›", 0x9c: "œ", 0x9e: "ž", 0x9f: "Ÿ",
22
+ };
23
+ const byteToChar = (b: number) => (b < 0x80 ? String.fromCharCode(b) : CP1252[b] ?? String.fromCharCode(b));
24
+
25
+ // Group destinations whose contents are not document text — skipped wholesale.
26
+ const SKIP_DESTS = new Set([
27
+ "fonttbl", "colortbl", "stylesheet", "info", "pict", "header", "footer",
28
+ "footnote", "xmlnstbl", "themedata", "colorschememapping", "datastore",
29
+ "latentstyles", "listtable", "listoverridetable", "rsidtbl", "generator",
30
+ "operator", "creatim", "revtim", "printim", "buptim", "author", "title",
31
+ "subject", "keywords", "comment", "company", "manager", "category", "doccomm",
32
+ "hlinkbase", "filetbl", "mmathPr", "fldinst", "object", "nonshppict",
33
+ ]);
34
+
35
+ interface Style {
36
+ b: boolean;
37
+ i: boolean;
38
+ u: boolean;
39
+ uc: number; // Unicode fallback skip count (\ucN)
40
+ }
41
+ interface Run {
42
+ text: string;
43
+ b: boolean;
44
+ i: boolean;
45
+ u: boolean;
46
+ }
47
+
48
+ function escapeHtml(s: string): string {
49
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
50
+ }
51
+
52
+ /** Convert RTF source to an HTML string (a sequence of `<p>` paragraphs with
53
+ * `<strong>`/`<em>`/`<u>` runs). Best-effort: unknown control words are ignored. */
54
+ export function rtfToHtml(rtf: string): string {
55
+ const paragraphs: Run[][] = [];
56
+ let runs: Run[] = [];
57
+ let st: Style = { b: false, i: false, u: false, uc: 1 };
58
+ const stack: Style[] = [];
59
+ let depth = 0;
60
+ let skipDepth = Infinity; // skip content while depth >= skipDepth
61
+ const n = rtf.length;
62
+ let i = 0;
63
+
64
+ const active = () => depth < skipDepth;
65
+ const emit = (t: string) => {
66
+ if (!t || !active()) return;
67
+ const last = runs[runs.length - 1];
68
+ if (last && last.b === st.b && last.i === st.i && last.u === st.u) last.text += t;
69
+ else runs.push({ text: t, b: st.b, i: st.i, u: st.u });
70
+ };
71
+ const endPara = () => {
72
+ if (!active()) return;
73
+ paragraphs.push(runs);
74
+ runs = [];
75
+ };
76
+
77
+ const control = (word: string, param: number | null) => {
78
+ switch (word) {
79
+ case "b": st.b = param !== 0; break;
80
+ case "i": st.i = param !== 0; break;
81
+ case "ul": st.u = param !== 0; break;
82
+ case "ulnone": st.u = false; break;
83
+ case "uc": st.uc = param ?? 1; break;
84
+ case "par": case "row": endPara(); break;
85
+ case "line": case "sect": emit("\n"); break;
86
+ case "tab": case "cell": emit("\t"); break;
87
+ case "pard": case "plain": st.b = st.i = st.u = false; break;
88
+ default:
89
+ if (SKIP_DESTS.has(word)) skipDepth = Math.min(skipDepth, depth);
90
+ break;
91
+ }
92
+ };
93
+
94
+ while (i < n) {
95
+ const c = rtf[i];
96
+ if (c === "{") {
97
+ stack.push({ ...st });
98
+ depth++;
99
+ i++;
100
+ } else if (c === "}") {
101
+ depth--;
102
+ if (depth < skipDepth) skipDepth = Infinity;
103
+ st = stack.pop() ?? st;
104
+ i++;
105
+ } else if (c === "\\") {
106
+ const next = rtf[i + 1];
107
+ if (next === "\\" || next === "{" || next === "}") {
108
+ emit(next);
109
+ i += 2;
110
+ } else if (next === "*") {
111
+ skipDepth = Math.min(skipDepth, depth); // ignorable destination
112
+ i += 2;
113
+ } else if (next === "'") {
114
+ const b = parseInt(rtf.substr(i + 2, 2), 16);
115
+ if (!isNaN(b)) emit(byteToChar(b));
116
+ i += 4;
117
+ } else if (next === "~") {
118
+ emit(" "); i += 2; // non-breaking space
119
+ } else if (next === "-") {
120
+ i += 2; // optional hyphen — drop
121
+ } else if (/[a-zA-Z]/.test(next ?? "")) {
122
+ let j = i + 1;
123
+ while (j < n && /[a-zA-Z]/.test(rtf[j])) j++;
124
+ const word = rtf.slice(i + 1, j);
125
+ let num = "";
126
+ if (rtf[j] === "-") { num += "-"; j++; }
127
+ while (j < n && /[0-9]/.test(rtf[j])) num += rtf[j++];
128
+ const param = num === "" ? null : parseInt(num, 10);
129
+ if (rtf[j] === " ") j++; // a single trailing space delimits, and is consumed
130
+ i = j;
131
+ if (word === "u" && param !== null) {
132
+ if (active()) emit(String.fromCodePoint(param < 0 ? param + 0x10000 : param));
133
+ // skip the \ucN fallback characters that follow the \u
134
+ for (let skip = st.uc; skip > 0 && i < n; skip--) {
135
+ if (rtf[i] === "\\" && rtf[i + 1] === "'") i += 4;
136
+ else if (rtf[i] === "\\") i += 2;
137
+ else if (rtf[i] === "{" || rtf[i] === "}") break;
138
+ else i++;
139
+ }
140
+ } else {
141
+ control(word, param);
142
+ }
143
+ } else {
144
+ i++; // a lone backslash before something unexpected
145
+ }
146
+ } else if (c === "\r" || c === "\n") {
147
+ i++; // raw newlines in the source are not content
148
+ } else {
149
+ emit(c);
150
+ i++;
151
+ }
152
+ }
153
+ endPara();
154
+
155
+ const renderRun = (r: Run) => {
156
+ let t = escapeHtml(r.text).replace(/\t/g, "&nbsp;&nbsp;&nbsp;&nbsp;").replace(/\n/g, "<br>");
157
+ if (r.u) t = `<u>${t}</u>`;
158
+ if (r.i) t = `<em>${t}</em>`;
159
+ if (r.b) t = `<strong>${t}</strong>`;
160
+ return t;
161
+ };
162
+ return paragraphs
163
+ .filter((p) => p.some((r) => r.text.trim() || r.text.includes("\n")))
164
+ .map((p) => `<p>${p.map(renderRun).join("")}</p>`)
165
+ .join("\n");
166
+ }
167
+
168
+ /** Fetch the `.rtf` bytes, decode as Windows-1252 (RTF source is 7-bit with
169
+ * `\'xx` for the rest), and convert to HTML. Shared by the page and chunk forms. */
170
+ function useRtfHtml(path: string): { html: string | null; error: string | null } {
171
+ const [html, setHtml] = useState<string | null>(null);
172
+ const [error, setError] = useState<string | null>(null);
173
+ useEffect(() => {
174
+ let cancelled = false;
175
+ setHtml(null);
176
+ setError(null);
177
+ fetch(blobUrl(path))
178
+ .then((r) => r.arrayBuffer())
179
+ .then((buf) => {
180
+ if (cancelled) return;
181
+ const src = new TextDecoder("windows-1252").decode(new Uint8Array(buf));
182
+ setHtml(rtfToHtml(src));
183
+ })
184
+ .catch((e) => !cancelled && setError(String((e as Error).message || e)));
185
+ return () => {
186
+ cancelled = true;
187
+ };
188
+ }, [path]);
189
+ return { html, error };
190
+ }
191
+
192
+ export function RtfView({ node }: { node: NodeJson }) {
193
+ const { html, error } = useRtfHtml(node.path);
194
+ if (error) return <div className="error">rtf: {error}</div>;
195
+ if (html == null) return <div className="loading">reading RTF…</div>;
196
+ return (
197
+ <div className="text">
198
+ {node.title && <h1 className="chapter-title">{node.title}</h1>}
199
+ {node.description && <p className="chapter-subtitle">{node.description}</p>}
200
+ <div className="markup" dangerouslySetInnerHTML={{ __html: html }} />
201
+ </div>
202
+ );
203
+ }
204
+
205
+ export function RtfChunk({ chunk }: { chunk: Chunk }) {
206
+ const { html, error } = useRtfHtml(chunk.path);
207
+ if (error) return <div className="error">rtf: {error}</div>;
208
+ if (html == null) return <div className="loading">reading RTF…</div>;
209
+ return <div className="markup" dangerouslySetInnerHTML={{ __html: html }} />;
210
+ }