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,186 @@
1
+ // Typed wrappers over the server's JSON API.
2
+
3
+ export interface TreeNode {
4
+ path: string;
5
+ label: string;
6
+ type: string;
7
+ format: string | null;
8
+ concrete: string | null; // how it is stored; `dir` → a plain-folder icon
9
+ hasChildren: boolean;
10
+ children: TreeNode[];
11
+ }
12
+
13
+ export interface NodeJson {
14
+ path: string;
15
+ type: string;
16
+ format?: string | null; // schema `format`; with `type` it keys the renderer
17
+ concrete: string | null;
18
+ documentPath?: string; // the document (nearest yamlover entity) this node is in —
19
+ // the anchor a document-relative (`/…`) link resolves against
20
+ title: string | null;
21
+ description: string | null;
22
+ value: unknown;
23
+ relations?: Record<string, unknown>; // named up-edges (+ `..`) as ref markers
24
+ }
25
+
26
+ async function getJson<T>(url: string): Promise<T> {
27
+ const res = await fetch(url);
28
+ const body = await res.json();
29
+ if (!res.ok) throw new Error((body && body.error) || `HTTP ${res.status}`);
30
+ return body as T;
31
+ }
32
+
33
+ /** Server info: the ROOT path as given on the CLI (breadcrumb head; "" if omitted). */
34
+ export function fetchInfo(): Promise<{ root: string }> {
35
+ return getJson<{ root: string }>("/api/info");
36
+ }
37
+
38
+ /** A long-running server task (indexing, hashing, …) — mirrors server/tasks.ts. Updates ride
39
+ * /api/events as `{type:"task", task}` frames; this shape is also what GET /api/tasks lists. */
40
+ export interface TaskInfo {
41
+ id: string;
42
+ label: string;
43
+ state: "running" | "done" | "error";
44
+ progress: { done: number; total?: number; message?: string };
45
+ startedAt: number;
46
+ finishedAt?: number;
47
+ error?: string;
48
+ }
49
+
50
+ /** Server tasks in flight (or just finished) — the snapshot a freshly loaded page needs. */
51
+ export function fetchTasks(): Promise<TaskInfo[]> {
52
+ return getJson<TaskInfo[]>("/api/tasks");
53
+ }
54
+
55
+ /** The TOC subtree rooted at `path`, `depth` levels deep (server default 3). */
56
+ export function fetchTree(path = ":", depth?: number): Promise<TreeNode> {
57
+ const q = new URLSearchParams({ path });
58
+ if (depth != null) q.set("depth", String(depth));
59
+ return getJson<TreeNode>(`/api/tree?${q}`);
60
+ }
61
+
62
+ export function fetchNode(
63
+ path: string,
64
+ depth?: number,
65
+ opts?: { binary?: boolean },
66
+ ): Promise<NodeJson> {
67
+ const q = new URLSearchParams({ path });
68
+ if (depth != null) q.set("depth", String(depth));
69
+ if (opts?.binary) q.set("binary", "1"); // request a binary leaf's base64 bytes
70
+ return getJson<NodeJson>(`/api/json?${q}`);
71
+ }
72
+
73
+ /** URL of a file-backed node's raw bytes (image / pdf / html / djvu source). */
74
+ export function blobUrl(path: string): string {
75
+ return `/api/blob?path=${encodeURIComponent(path)}`;
76
+ }
77
+
78
+ /** The node's instance schema, one level deep (nested containers as link markers). */
79
+ export function fetchSchema(path: string, depth?: number): Promise<unknown> {
80
+ const q = new URLSearchParams({ path });
81
+ if (depth != null) q.set("depth", String(depth));
82
+ return getJson<unknown>(`/api/schema?${q}`);
83
+ }
84
+
85
+ /** A tag as the annotation API hands it around: its node path, display name (the taxonomy key),
86
+ * and explicit color — null for a named tag, whose hue the client derives from the name. */
87
+ export interface TagRef {
88
+ path: string;
89
+ name: string;
90
+ color: string | null;
91
+ }
92
+
93
+ /** An annotation of a material — ONE TAG APPLICATION: a marked segment (or the whole node, when
94
+ * `selector` is absent) tagged by `tag`, with an optional per-application comment. `tag` is
95
+ * null only for legacy annotations saved before tags carried the color. */
96
+ export interface Annotation {
97
+ path: string; // the annotation's own node path
98
+ tag?: TagRef | null;
99
+ selector?: { type?: string; exact?: string; prefix?: string; suffix?: string; [k: string]: unknown };
100
+ description?: string;
101
+ created?: string;
102
+ }
103
+
104
+ /** The annotations whose `target` is the material at `path` (the engine's reverse link). */
105
+ export function fetchAnnotations(path: string): Promise<Annotation[]> {
106
+ return getJson<Annotation[]>(`/api/annotations?path=${encodeURIComponent(path)}`);
107
+ }
108
+
109
+ /** The materials filed under the tag at `path` — `$yamloverLink` markers, annotations already
110
+ * resolved to their `target` and deduped (the explorer's member list for a tag page). */
111
+ export function fetchTagged(path: string): Promise<unknown[]> {
112
+ return getJson<unknown[]>(`/api/tagged?path=${encodeURIComponent(path)}`);
113
+ }
114
+
115
+ /** Save a new annotation — apply the tag at `tag` to the material at `target` (JSON paths),
116
+ * optionally narrowed to `selector` and commented by `description`; returns the created path. */
117
+ export function saveAnnotation(a: { target: string; tag: string; selector?: Record<string, unknown>; description?: string }): Promise<{ path: string }> {
118
+ return fetch("/api/annotate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(a) }).then(
119
+ async (res) => {
120
+ const body = await res.json();
121
+ if (!res.ok) throw new Error((body && body.error) || `HTTP ${res.status}`);
122
+ return body as { path: string };
123
+ },
124
+ );
125
+ }
126
+
127
+ /** Create a named tag at the project's default tags location (settings.yamlover; `/tags` by
128
+ * default) — the picker's create-on-miss. Idempotent: an existing tag at that path is returned
129
+ * as-is; a non-tag node already occupying the path is an error. */
130
+ export function createTag(name: string): Promise<TagRef> {
131
+ return fetch("/api/tag", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }).then(
132
+ async (res) => {
133
+ const body = await res.json();
134
+ if (!res.ok) throw new Error((body && body.error) || `HTTP ${res.status}`);
135
+ return body as TagRef;
136
+ },
137
+ );
138
+ }
139
+
140
+ /** The result of pasting/uploading a file or text: the new file's node path (for a text chunk,
141
+ * the chapter it joined), and (for a chapter) the chapter path plus any chunk pointer appended. */
142
+ export interface PasteResult {
143
+ path: string; // the new file's node path (a text chunk: the chapter's own path)
144
+ chapter?: string; // the chapter the chunk was appended to (chapter paste only)
145
+ pointer?: string; // the `*…` chunk pointer appended (chapter FILE paste only)
146
+ dir?: string; // the enclosing directory the file landed in (directory/member paste)
147
+ open?: boolean; // true when the page was a MEMBER of a directory → open the new file
148
+ }
149
+
150
+ function postPaste(body: Record<string, unknown>): Promise<PasteResult> {
151
+ return fetch("/api/paste", {
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/json" },
154
+ body: JSON.stringify(body),
155
+ }).then(async (res) => {
156
+ const json = await res.json();
157
+ if (!res.ok) throw new Error((json && json.error) || `HTTP ${res.status}`);
158
+ return json as PasteResult;
159
+ });
160
+ }
161
+
162
+ /** Upload a pasted file onto the page at `target` (a directory or a chapter). */
163
+ export function pasteFile(target: string, filename: string, contentBase64: string): Promise<PasteResult> {
164
+ return postPaste({ path: target, filename, contentBase64 });
165
+ }
166
+
167
+ /** Paste plain TEXT onto the page at `target`: a chapter gains it as a new chunk; anywhere else
168
+ * it becomes a new chapter .yamlover file in the nearest enclosing directory. */
169
+ export function pasteText(target: string, text: string): Promise<PasteResult> {
170
+ return postPaste({ path: target, text });
171
+ }
172
+
173
+ /** Paste RICH content (an HTML selection: text + images + heading-nested subchapters) onto the
174
+ * page at `target`. A chapter appends the chunks and subchapters; anywhere else a new chapter
175
+ * is created — directory-backed when files are present, a standalone .yamlover file otherwise.
176
+ * `rich` is the RichNode tree from paste-html.ts (images already inline as base64 files). */
177
+ export function pasteRich(target: string, rich: unknown): Promise<PasteResult> {
178
+ return postPaste({ path: target, rich });
179
+ }
180
+
181
+ /** Delete the annotation at its node path (a standalone `<…>.yamlover` file, any directory). */
182
+ export function deleteAnnotation(path: string): Promise<void> {
183
+ return fetch(`/api/annotate?path=${encodeURIComponent(path)}`, { method: "DELETE" }).then(async (res) => {
184
+ if (!res.ok) throw new Error(((await res.json().catch(() => null))?.error) || `HTTP ${res.status}`);
185
+ });
186
+ }
@@ -0,0 +1,91 @@
1
+ // Type/format icon for a TOC node — chosen by the schema `format`, falling back
2
+ // to `type`. One exception to being concrete-agnostic: a node stored as an
3
+ // on-disk directory gets a folder icon, since it really is a filesystem folder —
4
+ // plain (`dir`, no `.yamlover/` marker) or a yamlover entity (`yamlover`).
5
+
6
+ export interface Glyph {
7
+ glyph: string;
8
+ cls: string; // CSS class (color)
9
+ title: string; // tooltip
10
+ }
11
+
12
+ // Type → a monochrome glyph, colored by category (matches the value highlighting).
13
+ const TYPE: Record<string, { glyph: string; cls: string }> = {
14
+ object: { glyph: "{}", cls: "t-struct" },
15
+ array: { glyph: "[]", cls: "t-struct" },
16
+ string: { glyph: "“”", cls: "t-str" },
17
+ integer: { glyph: "#", cls: "t-num" },
18
+ number: { glyph: "½", cls: "t-num" },
19
+ boolean: { glyph: "◧", cls: "t-bool" },
20
+ null: { glyph: "∅", cls: "t-null" },
21
+ binary: { glyph: "0110", cls: "t-bin binsq" }, // bits in a little square
22
+ };
23
+
24
+ // Exact-match formats → an icon.
25
+ const FORMAT: Record<string, string> = {
26
+ "date-time": "🕑",
27
+ date: "📅",
28
+ time: "🕑",
29
+ duration: "⏳",
30
+ email: "✉️",
31
+ "idn-email": "✉️",
32
+ hostname: "🖥️",
33
+ "idn-hostname": "🖥️",
34
+ ipv4: "🌐",
35
+ ipv6: "🌐",
36
+ uri: "🔗",
37
+ iri: "🔗",
38
+ "uri-reference": "🔗",
39
+ "iri-reference": "🔗",
40
+ "uri-template": "🔗",
41
+ url: "🔗",
42
+ uuid: "🆔",
43
+ regex: "🔣",
44
+ "json-pointer": "📍",
45
+ "relative-json-pointer": "📍",
46
+ password: "🔑",
47
+ color: "🎨",
48
+ };
49
+
50
+ // Media-type / binary-encoding / custom formats → an icon, chosen by prefix.
51
+ function mediaIcon(format: string): string | null {
52
+ if (format === "x-yamlover-chapter") return "§"; // a chapter — the section sign
53
+ if (format === "x-yamlover-tag") return "🏷️";
54
+ if (format.startsWith("x-yamlover-")) return "🧩"; // a custom yamlover renderer
55
+ if (format === "application/pdf") return "📕";
56
+ if (format === "application/x-fictionbook+xml") return "📘";
57
+ if (format === "application/epub+zip") return "📗";
58
+ if (format === "image/vnd.djvu") return "📓";
59
+ if (format.startsWith("image/")) return "🖼️";
60
+ if (format === "text/markdown") return "📝";
61
+ if (format === "text/asciidoc") return "📃";
62
+ if (format === "text/csv" || format === "text/tab-separated-values") return "▦"; // a table
63
+ if (format === "text/x-plantuml") return "📊"; // source that compiles to a diagram
64
+ if (format === "application/vnd.ms-excel") return "▦"; // legacy .xls workbook
65
+ if (format === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") return "▦"; // .xlsx
66
+ if (format === "application/rtf") return "📄";
67
+ if (format === "application/msword") return "📄"; // legacy .doc
68
+ if (format === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") return "📄"; // .docx
69
+ if (format === "application/vnd.google-earth.kml+xml" || format === "application/vnd.google-earth.kmz") return "🗺️"; // map overlay
70
+ if (format.startsWith("text/")) return "📄";
71
+ if (format.startsWith("audio/")) return "🔊";
72
+ if (format.startsWith("video/")) return "🎬";
73
+ if (/^(u?int|float)\d/.test(format)) return "💾"; // int32/le, float64, …
74
+ return null;
75
+ }
76
+
77
+ /** The type/format icon for a node — `format` wins, then a directory concrete
78
+ * (`dir`/`yamlover`) shows a folder, else `type`. */
79
+ export function typeIcon(type: string, format: string | null, concrete?: string | null): Glyph {
80
+ if (format) {
81
+ const g = FORMAT[format] ?? mediaIcon(format);
82
+ if (g) return { glyph: g, cls: "t-fmt", title: format };
83
+ }
84
+ // a plain directory (no `.yamlover/`) — a real OS folder
85
+ if (concrete === "dir") return { glyph: "📁", cls: "t-struct", title: "folder" };
86
+ // a yamlover entity stored as a directory (a folder with a `.yamlover/` marker)
87
+ if (concrete === "yamlover") return { glyph: "🗂️", cls: "t-struct", title: "yamlover folder" };
88
+ const t = TYPE[type];
89
+ if (t) return { glyph: t.glyph, cls: t.cls, title: type };
90
+ return { glyph: "•", cls: "t-bin", title: type || "unknown" };
91
+ }
@@ -0,0 +1,108 @@
1
+ import { ReactNode } from "react";
2
+ import { segsToStr, strToSegs } from "./paths";
3
+
4
+ /**
5
+ * The shared **link** concept: one place that decides what a link *target* means
6
+ * and how a link is made clickable. Every renderer that emits links routes through
7
+ * here, so link behaviour is defined once.
8
+ *
9
+ * A target is addressed in the app's JSON instance space — the same space the
10
+ * tree, breadcrumbs, and the URL all navigate — with two anchors, mirroring how an
11
+ * `x-yamlover` `rel` pointer is written:
12
+ *
13
+ * - **`/some/path`** — relative to the *document* the link appears in (the
14
+ * nearest yamlover entity; "document" meaning the literal file/entity, overlays
15
+ * and all). Resolved against the `documentPath` the server reports for the node.
16
+ * - **`//some/path`** — relative to the *project root* (the location given at
17
+ * yamlover startup), i.e. the served root → browser path `/some/path`.
18
+ * - **`scheme://…` / `mailto:…`** — an ordinary external link.
19
+ *
20
+ * `resolveLink` is deliberately the single seam for interpretation: it is where
21
+ * refs and rels are expected to plug in later (gaining the full pointer grammar —
22
+ * `..`, `^name`, virtual children), rather than each renderer re-deciding what a
23
+ * target points at. (Refs/rels keep their own server-side interpretation for now;
24
+ * this powers marklower links.)
25
+ */
26
+
27
+ /** A link's resolved destination. Exactly one of `path` (an in-app JSON-space path
28
+ * for SPA navigation) or `href` (an external URL) is set; both null means the
29
+ * target did not resolve and the link renders as plain text. */
30
+ export interface ResolvedLink {
31
+ path: string | null;
32
+ href: string | null;
33
+ }
34
+
35
+ const UNRESOLVED: ResolvedLink = { path: null, href: null };
36
+
37
+ /** True for an external target carrying a URI scheme (`http:`, `https:`, `mailto:`,
38
+ * …). A `//`-rooted project path is *not* a scheme (no leading `scheme:`). */
39
+ const hasScheme = (s: string) => /^[a-z][a-z0-9+.-]*:/i.test(s);
40
+
41
+ /** Join a document base path with a document-relative path (both JSON-space),
42
+ * canonicalizing the result. */
43
+ function joinDoc(documentPath: string, rel: string): string {
44
+ return segsToStr([...strToSegs(documentPath), ...strToSegs(rel)]);
45
+ }
46
+
47
+ /** Tokenize a LEGACY slash-spelled link target (`/a/b[0]`) into segments. */
48
+ function slashSegs(str: string): (string | number)[] {
49
+ const out: (string | number)[] = [];
50
+ for (const tok of str.match(/\[\d+\]|[^/\[\]]+/g) || []) {
51
+ out.push(/^\[\d+\]$/.test(tok) ? Number(tok.slice(1, -1)) : tok);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** Interpret a link `target` against the `documentPath` it appears in (the JSON-space
57
+ * path of its document; defaults to root). Colon spellings (`:a:b`, `::a:b` —
58
+ * SEPARATOR.md) are canonical; legacy slash spellings (`/a/b`, `//a/b`) still parse. */
59
+ export function resolveLink(target: string, documentPath = ":"): ResolvedLink {
60
+ const raw = target.trim();
61
+ if (!raw) return UNRESOLVED;
62
+ if (raw.startsWith("::")) return { path: segsToStr(strToSegs(raw.slice(2))), href: null }; // project root
63
+ if (raw.startsWith(":")) return { path: joinDoc(documentPath, raw), href: null }; // document-relative
64
+ if (raw.startsWith("//")) return { path: segsToStr(slashSegs(raw)), href: null }; // legacy project root
65
+ if (hasScheme(raw)) return { path: null, href: raw }; // external (http(s)/mailto/…)
66
+ if (raw.startsWith("/")) return { path: segsToStr([...strToSegs(documentPath), ...slashSegs(raw)]), href: null }; // legacy doc-relative
67
+ return UNRESOLVED; // anything else is not (yet) a recognized link target
68
+ }
69
+
70
+ /** Render a link as the right kind of anchor: an in-app `.descend` link that calls
71
+ * `onNavigate` for an internal target, an ordinary external `.extlink` for a URL,
72
+ * or plain children when the target doesn't resolve. The single place a link
73
+ * becomes clickable — shared by every renderer that emits links. */
74
+ export function NavLink({
75
+ target,
76
+ documentPath,
77
+ onNavigate,
78
+ children,
79
+ }: {
80
+ target: string;
81
+ documentPath?: string;
82
+ onNavigate: (path: string) => void;
83
+ children: ReactNode;
84
+ }) {
85
+ const { path, href } = resolveLink(target, documentPath);
86
+ if (href) {
87
+ return (
88
+ <a className="extlink" href={href} target="_blank" rel="noopener noreferrer">
89
+ {children}
90
+ </a>
91
+ );
92
+ }
93
+ if (path) {
94
+ return (
95
+ <a
96
+ className="descend"
97
+ href={path}
98
+ onClick={(e) => {
99
+ e.preventDefault();
100
+ onNavigate(path);
101
+ }}
102
+ >
103
+ {children}
104
+ </a>
105
+ );
106
+ }
107
+ return <>{children}</>;
108
+ }
@@ -0,0 +1,42 @@
1
+ // live.ts — the client side of the UNIFIED change flow. Every change to the served tree —
2
+ // mediated writes (annotate, tag, paste, mv) and external edits (the FS watcher) — reaches the
3
+ // client as ONE currency: a file-level IndexDiff over /api/events, which App re-broadcasts as a
4
+ // `yamlover:diff` window event. Hooks that hold server-derived state subscribe HERE instead of
5
+ // inventing per-feature push paths; a new surface gets live refresh by adding one useDiffBump.
6
+
7
+ import { useEffect, useState } from "react";
8
+
9
+ /** A reindex/write diff as App re-broadcasts it: client JSON paths of the touched FILES
10
+ * (added + changed + removed + both ends of moves), with the removals also listed alone. */
11
+ export interface DiffDetail {
12
+ paths: string[];
13
+ removed: string[];
14
+ }
15
+
16
+ export const DIFF_EVENT = "yamlover:diff";
17
+
18
+ /** Re-broadcast a server diff to the window (App's SSE handler is the only caller). */
19
+ export function broadcastDiff(detail: DiffDetail): void {
20
+ window.dispatchEvent(new CustomEvent(DIFF_EVENT, { detail }));
21
+ }
22
+
23
+ /** Diffs that can affect graph-derived state (annotations, tags, document bodies) — any touched
24
+ * `.yamlover` source. A binary-only diff (a photo import, say) passes nothing here. */
25
+ export const touchesYamlover = (d: DiffDetail): boolean => d.paths.some((p) => p.endsWith(".yamlover"));
26
+
27
+ /** A counter that bumps whenever a diff matches `match` (default: every diff) — put it in a
28
+ * fetch effect's dependency list and the data refetches on relevant changes. `match` must be
29
+ * a stable (module-level) predicate; it is deliberately not a re-subscribe dependency. */
30
+ export function useDiffBump(match: (d: DiffDetail) => boolean = () => true): number {
31
+ const [bump, setBump] = useState(0);
32
+ useEffect(() => {
33
+ const on = (e: Event) => {
34
+ const det = (e as CustomEvent).detail as DiffDetail | undefined;
35
+ if (det && match(det)) setBump((b) => b + 1);
36
+ };
37
+ window.addEventListener(DIFF_EVENT, on);
38
+ return () => window.removeEventListener(DIFF_EVENT, on);
39
+ // eslint-disable-next-line react-hooks/exhaustive-deps
40
+ }, []);
41
+ return bump;
42
+ }
@@ -0,0 +1,10 @@
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./App";
4
+ import "./styles.css";
5
+
6
+ createRoot(document.getElementById("root")!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ );
@@ -0,0 +1,228 @@
1
+ // HTML-clipboard paste: a selection copied from a web page (Wikipedia, docs, …) arrives as
2
+ // text/html alongside the plain text. When it carries STRUCTURE the plain flavor loses —
3
+ // images, headings — it becomes a RICH paste: headings nest subchapters (by level), images
4
+ // become separate chunks (downloaded in the browser — e.g. Wikimedia sends
5
+ // `access-control-allow-origin: *`), text blocks become marklower prose. Formatted text with
6
+ // no images/headings stays a plain text paste. Used by NodeView's paste listener.
7
+
8
+ /** The client-side draft: images still by URL (downloaded later by resolveImages). */
9
+ export interface RichDraft {
10
+ title?: string;
11
+ chunks: Array<{ text: string } | { image: { url: string; alt: string } }>;
12
+ children: RichDraft[];
13
+ }
14
+
15
+ /** The wire payload for POST /api/paste {rich}: images resolved to inline file bytes. */
16
+ export interface RichNode {
17
+ title?: string;
18
+ chunks: Array<{ text: string } | { file: { name: string; contentBase64: string } }>;
19
+ children: RichNode[];
20
+ }
21
+
22
+ type Block =
23
+ | { kind: "heading"; level: number; text: string }
24
+ | { kind: "image"; url: string; alt: string }
25
+ | { kind: "text"; text: string };
26
+
27
+ const SKIP = new Set(["script", "style", "noscript", "template", "iframe", "svg", "head", "title"]);
28
+ const BLOCKS = new Set(["p", "div", "section", "article", "main", "aside", "header", "footer", "figure", "figcaption", "ul", "ol", "table", "thead", "tbody", "tr", "dl", "dt", "dd", "nav", "form", "body", "html"]);
29
+
30
+ /** Parse an HTML clipboard fragment into a chapter draft — or null when it has no images and
31
+ * no headings (plain formatted text: the normal text paste serves it better). */
32
+ export function htmlToRich(html: string): RichDraft | null {
33
+ const doc = new DOMParser().parseFromString(html, "text/html");
34
+ const blocks = mergeBullets(blocksOf(doc.body));
35
+ if (!blocks.some((b) => b.kind !== "text")) return null;
36
+
37
+ // headings nest by level: deeper headings open children of the nearest shallower one
38
+ const root: RichDraft = { chunks: [], children: [] };
39
+ const stack: Array<{ node: RichDraft; level: number }> = [{ node: root, level: 0 }];
40
+ for (const b of blocks) {
41
+ if (b.kind === "heading") {
42
+ while (stack.length > 1 && stack[stack.length - 1].level >= b.level) stack.pop();
43
+ const child: RichDraft = { title: b.text || "Untitled", chunks: [], children: [] };
44
+ stack[stack.length - 1].node.children.push(child);
45
+ stack.push({ node: child, level: b.level });
46
+ } else {
47
+ const top = stack[stack.length - 1].node;
48
+ top.chunks.push(b.kind === "image" ? { image: { url: b.url, alt: b.alt } } : { text: b.text });
49
+ }
50
+ }
51
+ return root;
52
+ }
53
+
54
+ /** Walk the fragment in document order, flushing inline text at block boundaries. */
55
+ function blocksOf(body: HTMLElement): Block[] {
56
+ const out: Block[] = [];
57
+ let buf = "";
58
+ const flush = () => {
59
+ const t = buf.replace(/[ \t]+/g, " ").replace(/ ?\n ?/g, "\n").replace(/\n{2,}/g, "\n").trim();
60
+ buf = "";
61
+ if (t) out.push({ kind: "text", text: t });
62
+ };
63
+ const walk = (n: Node): void => {
64
+ if (n.nodeType === Node.TEXT_NODE) {
65
+ buf += n.textContent ?? "";
66
+ return;
67
+ }
68
+ if (!(n instanceof Element)) return;
69
+ const tag = n.tagName.toLowerCase();
70
+ if (SKIP.has(tag)) return;
71
+ if (/^h[1-6]$/.test(tag)) {
72
+ flush();
73
+ // Wikipedia headings carry an "[edit]" section link — noise in a title
74
+ out.push({ kind: "heading", level: Number(tag[1]), text: (n.textContent ?? "").replace(/\[edit\]/gi, "").trim() });
75
+ return;
76
+ }
77
+ if (tag === "img") {
78
+ flush();
79
+ const url = imageUrl(n);
80
+ if (url) out.push({ kind: "image", url, alt: n.getAttribute("alt") ?? "" });
81
+ return;
82
+ }
83
+ if (tag === "li") {
84
+ // one bullet per line; images inside the item still surface as their own chunks
85
+ flush();
86
+ n.childNodes.forEach(walk);
87
+ const t = buf.replace(/\s+/g, " ").trim();
88
+ buf = "";
89
+ if (t) out.push({ kind: "text", text: "- " + t });
90
+ return;
91
+ }
92
+ if (tag === "pre") {
93
+ flush();
94
+ const t = (n.textContent ?? "").replace(/\n+$/, "");
95
+ if (t.trim()) out.push({ kind: "text", text: "```\n" + t + "\n```" });
96
+ return;
97
+ }
98
+ if (tag === "blockquote") {
99
+ flush();
100
+ n.childNodes.forEach(walk);
101
+ const t = buf.trim();
102
+ buf = "";
103
+ if (t) out.push({ kind: "text", text: t.split("\n").map((l) => "> " + l).join("\n") });
104
+ return;
105
+ }
106
+ if (tag === "br") {
107
+ buf += "\n";
108
+ return;
109
+ }
110
+ if (tag === "td" || tag === "th") {
111
+ n.childNodes.forEach(walk);
112
+ buf += " ";
113
+ return;
114
+ }
115
+ if (BLOCKS.has(tag)) {
116
+ flush();
117
+ n.childNodes.forEach(walk);
118
+ flush();
119
+ return;
120
+ }
121
+ // an inline element: render to marklower — unless an image hides inside (then descend, so
122
+ // the image becomes its own chunk rather than vanishing into the text)
123
+ if (!n.querySelector("img")) {
124
+ buf += inlineMd(n);
125
+ return;
126
+ }
127
+ n.childNodes.forEach(walk);
128
+ };
129
+ walk(body);
130
+ flush();
131
+ return out;
132
+ }
133
+
134
+ /** Inline content → marklower: links, emphasis, code; everything else passes through as text. */
135
+ function inlineMd(n: Node): string {
136
+ if (n.nodeType === Node.TEXT_NODE) return n.textContent ?? "";
137
+ if (!(n instanceof Element)) return "";
138
+ const tag = n.tagName.toLowerCase();
139
+ if (SKIP.has(tag)) return "";
140
+ if (tag === "br") return "\n";
141
+ const inner = Array.from(n.childNodes).map(inlineMd).join("");
142
+ if (tag === "a") {
143
+ const href = n.getAttribute("href") ?? "";
144
+ const t = inner.trim();
145
+ return /^https?:\/\//.test(href) && t ? `[${t}](${href})` : inner;
146
+ }
147
+ if (tag === "strong" || tag === "b") return inner.trim() ? `**${inner.trim()}**` : "";
148
+ if (tag === "em" || tag === "i") return inner.trim() ? `*${inner.trim()}*` : "";
149
+ if (tag === "code") return inner.trim() ? "`" + inner.trim() + "`" : "";
150
+ return inner;
151
+ }
152
+
153
+ /** A usable image URL: absolute http(s) or data:; protocol-relative gains https:; lazy-load
154
+ * attributes win over a missing/placeholder src; anything relative is dropped (a clipboard
155
+ * fragment has no base to resolve it against). */
156
+ function imageUrl(img: Element): string | null {
157
+ const raw = img.getAttribute("src") || img.getAttribute("data-src") || "";
158
+ if (raw.startsWith("//")) return "https:" + raw;
159
+ if (/^https?:\/\//.test(raw) || raw.startsWith("data:image/")) return raw;
160
+ return null;
161
+ }
162
+
163
+ /** Consecutive single-bullet blocks (one per <li>) merge into one list chunk. */
164
+ function mergeBullets(blocks: Block[]): Block[] {
165
+ const out: Block[] = [];
166
+ for (const b of blocks) {
167
+ const prev = out[out.length - 1];
168
+ if (b.kind === "text" && b.text.startsWith("- ") && prev?.kind === "text" && prev.text.startsWith("- ")) {
169
+ prev.text += "\n" + b.text;
170
+ } else out.push(b);
171
+ }
172
+ return out;
173
+ }
174
+
175
+ const MIME_EXT: Record<string, string> = {
176
+ "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp",
177
+ "image/svg+xml": "svg", "image/bmp": "bmp", "image/tiff": "tiff", "image/avif": "avif",
178
+ };
179
+
180
+ /** Download every image of a draft (order kept) into inline file chunks; a failed fetch
181
+ * degrades to a marklower image link, so the reference survives even when the bytes don't. */
182
+ export async function resolveImages(draft: RichDraft): Promise<RichNode> {
183
+ return {
184
+ title: draft.title,
185
+ chunks: await Promise.all(
186
+ draft.chunks.map(async (c) => {
187
+ if ("text" in c) return { text: c.text };
188
+ try {
189
+ const res = await fetch(c.image.url);
190
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
191
+ const blob = await res.blob();
192
+ return { file: { name: imageName(c.image.url, blob.type), contentBase64: await blobBase64(blob) } };
193
+ } catch {
194
+ return { text: `![${c.image.alt}](${c.image.url})` };
195
+ }
196
+ }),
197
+ ),
198
+ children: await Promise.all(draft.children.map(resolveImages)),
199
+ };
200
+ }
201
+
202
+ /** A filename for a downloaded image: the URL path's basename, extension from the MIME type
203
+ * when the URL has none (data: URLs, extensionless CDNs). */
204
+ function imageName(url: string, mime: string): string {
205
+ let base = "";
206
+ try {
207
+ base = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? "");
208
+ } catch {
209
+ /* data: or malformed — synthesize below */
210
+ }
211
+ if (!base || url.startsWith("data:")) base = "image";
212
+ if (!/\.[A-Za-z0-9]{2,5}$/.test(base)) base += "." + (MIME_EXT[mime] || "bin");
213
+ return base;
214
+ }
215
+
216
+ function blobBase64(blob: Blob): Promise<string> {
217
+ return new Promise((resolve, reject) => {
218
+ const r = new FileReader();
219
+ r.onload = () => resolve(String(r.result).split(",")[1] || "");
220
+ r.onerror = () => reject(new Error("could not read image"));
221
+ r.readAsDataURL(blob);
222
+ });
223
+ }
224
+
225
+ /** How many images a draft holds (for the progress toast). */
226
+ export function countImages(draft: RichDraft): number {
227
+ return draft.chunks.filter((c) => "image" in c).length + draft.children.reduce((n, k) => n + countImages(k), 0);
228
+ }