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,105 @@
1
+ import { useEffect, useState } from "react";
2
+ import * as XLSX from "xlsx";
3
+ import { NodeJson, blobUrl } from "../api";
4
+ import { Chunk } from "./registry";
5
+
6
+ /**
7
+ * Renderer for Excel workbooks — `.xlsx` (Office Open XML) and legacy `.xls`
8
+ * (BIFF). SheetJS reads both from the served bytes; each sheet is shown as a table
9
+ * (reusing the `.csv-table` styling), with a tab row to switch sheets in a
10
+ * multi-sheet workbook. SheetJS is heavy, so the registry loads this module lazily.
11
+ */
12
+ function useWorkbook(path: string): { wb: XLSX.WorkBook | null; error: string | null } {
13
+ const [wb, setWb] = useState<XLSX.WorkBook | null>(null);
14
+ const [error, setError] = useState<string | null>(null);
15
+ useEffect(() => {
16
+ let cancelled = false;
17
+ setWb(null);
18
+ setError(null);
19
+ fetch(blobUrl(path))
20
+ .then((r) => r.arrayBuffer())
21
+ .then((buf) => {
22
+ if (cancelled) return;
23
+ setWb(XLSX.read(new Uint8Array(buf), { type: "array" }));
24
+ })
25
+ .catch((e) => !cancelled && setError(String((e as Error).message || e)));
26
+ return () => {
27
+ cancelled = true;
28
+ };
29
+ }, [path]);
30
+ return { wb, error };
31
+ }
32
+
33
+ /** One sheet → a 2-D array of cell strings, then a bordered table (first row as a
34
+ * header, matching the CSV renderer's look). */
35
+ function SheetTable({ sheet }: { sheet: XLSX.WorkSheet }) {
36
+ const rows = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, blankrows: false, defval: "" });
37
+ if (!rows.length) return <p className="csv-empty">(empty sheet)</p>;
38
+ const cols = rows.reduce((m, r) => Math.max(m, r.length), 0);
39
+ const pad = (r: string[]) => (r.length >= cols ? r : [...r, ...Array(cols - r.length).fill("")]);
40
+ const [head, ...body] = rows;
41
+ return (
42
+ <div className="csv-scroll">
43
+ <table className="csv-table">
44
+ <thead>
45
+ <tr>
46
+ {pad(head).map((c, k) => (
47
+ <th key={k}>{String(c)}</th>
48
+ ))}
49
+ </tr>
50
+ </thead>
51
+ <tbody>
52
+ {body.map((r, ri) => (
53
+ <tr key={ri}>
54
+ {pad(r).map((c, ci) => (
55
+ <td key={ci}>{String(c)}</td>
56
+ ))}
57
+ </tr>
58
+ ))}
59
+ </tbody>
60
+ </table>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ /** The workbook body: a sheet-tab row (when there is more than one) and the active
66
+ * sheet's table. Shared by the full page and the inline chunk. */
67
+ function Workbook({ wb }: { wb: XLSX.WorkBook }) {
68
+ const [active, setActive] = useState(0);
69
+ const names = wb.SheetNames;
70
+ const name = names[Math.min(active, names.length - 1)];
71
+ return (
72
+ <div className="spreadsheet">
73
+ {names.length > 1 && (
74
+ <div className="sheet-tabs">
75
+ {names.map((nm, k) => (
76
+ <button key={nm} className={"sheet-tab" + (k === active ? " active" : "")} onClick={() => setActive(k)}>
77
+ {nm}
78
+ </button>
79
+ ))}
80
+ </div>
81
+ )}
82
+ <SheetTable sheet={wb.Sheets[name]} />
83
+ </div>
84
+ );
85
+ }
86
+
87
+ export function SpreadsheetView({ node }: { node: NodeJson }) {
88
+ const { wb, error } = useWorkbook(node.path);
89
+ if (error) return <div className="error">spreadsheet: {error}</div>;
90
+ if (!wb) return <div className="loading">reading workbook…</div>;
91
+ return (
92
+ <div className="text">
93
+ {node.title && <h1 className="chapter-title">{node.title}</h1>}
94
+ {node.description && <p className="chapter-subtitle">{node.description}</p>}
95
+ <Workbook wb={wb} />
96
+ </div>
97
+ );
98
+ }
99
+
100
+ export function SpreadsheetChunk({ chunk }: { chunk: Chunk }) {
101
+ const { wb, error } = useWorkbook(chunk.path);
102
+ if (error) return <div className="error">spreadsheet: {error}</div>;
103
+ if (!wb) return <div className="loading">reading workbook…</div>;
104
+ return <Workbook wb={wb} />;
105
+ }
@@ -0,0 +1,113 @@
1
+ import { asLink } from "../render";
2
+ import { strToSegs } from "../paths";
3
+
4
+ export const TAG_FORMAT = "x-yamlover-tag";
5
+
6
+ export interface TagLink {
7
+ path: string;
8
+ label: string;
9
+ color?: string | null; // a pure color tag's explicit color (else the hue derives from label)
10
+ }
11
+
12
+ /**
13
+ * Split a node's `relations` into **tag references** (any up-edge that resolves to
14
+ * an `x-yamlover-tag` node) and everything else. This includes the structural
15
+ * `..` when the containment parent is itself a tag — so a tag shows its *parent
16
+ * tag* in the bar, exactly as a paper shows the tags it is filed under. Tag
17
+ * references render as badges (see {@link TagBadges}); the rest stay in the
18
+ * ordinary relations panel.
19
+ */
20
+ export function splitTagRefs(relations?: Record<string, unknown>): {
21
+ tags: TagLink[];
22
+ rest: Record<string, unknown>;
23
+ } {
24
+ const tags: TagLink[] = [];
25
+ const rest: Record<string, unknown> = {};
26
+ for (const [name, v] of Object.entries(relations || {})) {
27
+ const link = asLink(v);
28
+ if (link && link.format === TAG_FORMAT) {
29
+ tags.push({ path: link.path, label: tagLabel(link.path, link.title), color: link.color ?? null });
30
+ } else {
31
+ rest[name] = v;
32
+ }
33
+ }
34
+ return { tags, rest };
35
+ }
36
+
37
+ /** A stable color for a tag, derived from its name — so a tag is the same hue
38
+ * everywhere it appears. Mid lightness keeps white label text legible. */
39
+ export function tagColor(name: string): string {
40
+ let h = 0;
41
+ for (let i = 0; i < name.length; i++) h = (Math.imul(h, 31) + name.charCodeAt(i)) >>> 0;
42
+ return `hsl(${h % 360} 52% 42%)`;
43
+ }
44
+
45
+ /** A tag's display color: its explicit `color` (a "pure color tag"), else the
46
+ * stable hue derived from its name. */
47
+ export function resolveTagColor(t: { name: string; color?: string | null }): string {
48
+ return t.color ?? tagColor(t.name);
49
+ }
50
+
51
+ // A tag node's projected value arrives in one of two shapes: a plain object (a tag with only
52
+ // fields), or a `$yamloverMixed` marker (a tag whose description is its BODY — the variant/omni
53
+ // shape: `{kind:"omni", value: <body>, entries: [{key, value}]}`). The helpers below read both.
54
+ const MIXED_KEY = "$yamloverMixed";
55
+ type MixedMarker = { kind?: string; value?: unknown; entries?: { key: string | null; value: unknown }[] };
56
+
57
+ /** A tag value's keyed fields as [key, value] pairs — from either projection shape. */
58
+ export function tagFields(value: unknown): [string, unknown][] {
59
+ if (!value || typeof value !== "object" || Array.isArray(value)) return [];
60
+ const marker = (value as Record<string, unknown>)[MIXED_KEY] as MixedMarker | undefined;
61
+ if (marker?.entries) return marker.entries.filter((e) => e.key != null).map((e) => [e.key!, e.value]);
62
+ return Object.entries(value as Record<string, unknown>);
63
+ }
64
+
65
+ /** A tag's BODY (its description — the node's own scalar value), or null. */
66
+ export function tagBody(value: unknown): string | null {
67
+ if (typeof value === "string") return value;
68
+ const marker = (value as Record<string, unknown> | null | undefined)?.[MIXED_KEY] as MixedMarker | undefined;
69
+ return typeof marker?.value === "string" ? marker.value : null;
70
+ }
71
+
72
+ /** A tag node value's explicit `color`, if any. Depth-limited projection may hand the color
73
+ * scalar as a `$yamloverLink` marker instead of a plain string — both shapes are read. */
74
+ export function explicitColor(value: unknown): string | null {
75
+ const raw = tagFields(value).find(([k]) => k === "color")?.[1];
76
+ if (typeof raw === "string") return raw;
77
+ const linked = (raw as { $yamloverLink?: { value?: unknown } } | null | undefined)?.$yamloverLink?.value;
78
+ return typeof linked === "string" ? linked : null;
79
+ }
80
+
81
+ /** The tags a node is classified under, each a colored luggage-tag shape (one end
82
+ * rectangular, the other a pierced triangular point), shown inline in the node's
83
+ * header bar on every representation. Returns the tags directly (a fragment) so
84
+ * they flow among the header's type/format chips. */
85
+ export function TagBadges({ tags, onNavigate }: { tags: TagLink[]; onNavigate: (path: string) => void }) {
86
+ if (tags.length === 0) return null;
87
+ return (
88
+ <>
89
+ {tags.map((t) => (
90
+ <a
91
+ key={t.path}
92
+ className="tagtag"
93
+ style={{ background: resolveTagColor({ name: t.label, color: t.color }) }}
94
+ href={t.path}
95
+ title={t.label}
96
+ onClick={(e) => {
97
+ e.preventDefault();
98
+ onNavigate(t.path);
99
+ }}
100
+ >
101
+ {t.label}
102
+ </a>
103
+ ))}
104
+ </>
105
+ );
106
+ }
107
+
108
+ /** A node's display name: its schema title, else its last path segment. */
109
+ export function tagLabel(path: string, title?: string | null): string {
110
+ if (title) return title;
111
+ const segs = strToSegs(path);
112
+ return segs.length ? String(segs[segs.length - 1]) : path;
113
+ }
@@ -0,0 +1,41 @@
1
+ import { marked } from "marked";
2
+ import { NodeJson } from "../api";
3
+ import { Chunk } from "./registry";
4
+ import { anchorizeHeadings, useHashScroll } from "./headings";
5
+ import { Markup } from "./markup";
6
+
7
+ /**
8
+ * The renderer for a `string`/`text/markdown` node: prose shown as rendered
9
+ * Markdown rather than a quoted scalar. It serves in two contexts via the one
10
+ * (type, format) routing key:
11
+ *
12
+ * - as a full RHS page (`render`) — a standalone prose node (e.g. a `.md`
13
+ * file, read as a string by the server), and
14
+ * - inline (`renderChunk`) — a single chunk embedded in another renderer's
15
+ * page, e.g. one paragraph of a chapter.
16
+ *
17
+ * Markdown is parsed with `marked`; the value is whatever string the node holds,
18
+ * so the same renderer covers an inline `const` chunk and a whole `.md` file. Each
19
+ * heading is then given an id and a `§` anchor link (see {@link anchorizeHeadings})
20
+ * so a section is addressable as `<page>#<slug>`.
21
+ */
22
+ function md(value: unknown): string {
23
+ return anchorizeHeadings(marked.parse(String(value ?? ""), { async: false }) as string);
24
+ }
25
+
26
+ export function TextView({ node }: { node: NodeJson }) {
27
+ useHashScroll(node);
28
+ return (
29
+ <div className="text">
30
+ {node.title && <h1 className="chapter-title">{node.title}</h1>}
31
+ {node.description && <p className="chapter-subtitle">{node.description}</p>}
32
+ <Markup html={md(node.value)} />
33
+ </div>
34
+ );
35
+ }
36
+
37
+ /** A prose chunk embedded inline: just the rendered Markdown (the chapter
38
+ * supplies the surrounding number + anchor). */
39
+ export function TextChunk({ chunk }: { chunk: Chunk }) {
40
+ return <div className="markup" dangerouslySetInnerHTML={{ __html: md(chunk.value) }} />;
41
+ }
@@ -0,0 +1,33 @@
1
+ import UTIF from "utif";
2
+ import { NodeJson } from "../api";
3
+ import { DecodedImageView, rgbaToPng } from "./decoded";
4
+
5
+ /**
6
+ * Renders a TIFF image (`image/tiff`, `.tif`/`.tiff`). Browsers don't display
7
+ * TIFF, so we decode it with UTIF.js: each top-level IFD is one page (multi-page
8
+ * TIFFs — common for scans — render every page), decoded to RGBA and painted to a
9
+ * canvas/PNG. IFDs without dimensions (e.g. stray metadata directories) are skipped.
10
+ */
11
+ export function TiffView({ node }: { node: NodeJson }) {
12
+ return (
13
+ <DecodedImageView
14
+ node={node}
15
+ label="tiff"
16
+ decode={async (buf) => {
17
+ const view = new Uint8Array(buf);
18
+ const ifds = UTIF.decode(view);
19
+ const pages: Blob[] = [];
20
+ for (const ifd of ifds) {
21
+ UTIF.decodeImage(view, ifd);
22
+ const w = ifd.width as number;
23
+ const h = ifd.height as number;
24
+ if (!w || !h) continue;
25
+ const rgba = UTIF.toRGBA8(ifd);
26
+ pages.push(await rgbaToPng(rgba, w, h));
27
+ }
28
+ if (!pages.length) throw new Error("no decodable image in this TIFF");
29
+ return pages;
30
+ }}
31
+ />
32
+ );
33
+ }