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,1133 @@
1
+ /**
2
+ * yamlover.ts — a TypeScript port of the read side of `tools/walker`.
3
+ *
4
+ * It materializes the single *logical* node of a yamlover entity from any of its
5
+ * concrete representations (a plain file, a plain directory, or a directory
6
+ * carrying `.yamlover/schema.yaml`), resolving where every value actually lives —
7
+ * inline `const`, a `file/yaml` / `file/json` / `file/binary` child, a collapsed
8
+ * file, an expanded subdirectory, a `$ref` into `$defs`. The result is one tree
9
+ * of {@link YNode}s the web server serves as JSON, JSON Schema, and a TOC.
10
+ *
11
+ * It mirrors `walker.py` closely; see that file for the prose explanation of each
12
+ * step. What this port adds is capturing each node's schema `title`/`description`
13
+ * annotations (used for tree labels), which the walker — being a shell — ignores.
14
+ */
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import yaml from "js-yaml";
19
+
20
+ const YAMLOVER_DIR = ".yamlover";
21
+ const SCHEMA_FILE = "schema.yaml";
22
+
23
+ // A value pinned in the schema (via `const`, or built from `const` leaves) is
24
+ // instantiated from the schema; `.yamlover/schema.yaml` is YAML, hence this tag.
25
+ const SCHEMA_INSTANTIATE = "yaml-schema/instantiate";
26
+
27
+ /** A binary leaf value (a `file/binary` child) we do not expand inline. We keep
28
+ * only its `size` (cheap, via `stat`); the bytes are read lazily and only when a
29
+ * format is actually decodable, so a large blob never has to be slurped just to
30
+ * be listed. */
31
+ export class Binary {
32
+ constructor(
33
+ public size: number,
34
+ public fmt: string | null = null,
35
+ public decoded: unknown = null,
36
+ public data: Buffer | null = null,
37
+ ) {}
38
+
39
+ repr(): string {
40
+ let info = `<binary ${this.fmt || "bytes"}, ${this.size} bytes`;
41
+ if (this.decoded !== null && this.decoded !== undefined)
42
+ info += `, = ${JSON.stringify(this.decoded)}`;
43
+ return info + ">";
44
+ }
45
+ }
46
+
47
+ export type NodeValue =
48
+ | Record<string, YNode>
49
+ | YNode[]
50
+ | string
51
+ | number
52
+ | boolean
53
+ | null
54
+ | Binary;
55
+
56
+ export type Kind = "object" | "array" | "scalar" | "binary";
57
+
58
+ /**
59
+ * A logical node: a value plus the concrete representation it came from.
60
+ *
61
+ * `value` is an object (`Record<string, YNode>`), an array (`YNode[]`), or a
62
+ * scalar / {@link Binary} leaf. `concrete` records how it is stored; `path` is
63
+ * the on-disk path for filesystem-backed nodes (else null). `title`/`description`
64
+ * carry the schema annotations used for tree labels.
65
+ *
66
+ * The value can be **lazy**: a file-backed node is created with a loader and its
67
+ * `kind` (known from the schema), and the file is only read when `.value` is
68
+ * actually accessed. So listing or eliding a node — which needs its kind, not its
69
+ * bytes — never reads the file; the bytes of `value: 30` (or a binary blob) load
70
+ * only when that node is itself serialized. Use {@link nodeKind} for the type
71
+ * when you want to avoid forcing a load.
72
+ */
73
+ export class YNode {
74
+ title?: string;
75
+ description?: string;
76
+ rel?: Record<string, unknown> | null;
77
+ kind?: Kind; // known up front for lazy nodes; lets us elide/list without loading
78
+ schemaType?: string; // the schema's `type` (no file read needed to know it)
79
+ format?: string; // the schema's `format` — half of the (type, format) renderer key
80
+
81
+ private _value: NodeValue | undefined;
82
+ private _loader?: () => NodeValue;
83
+
84
+ constructor(value: NodeValue, public concrete: string | null = null, public path: string | null = null) {
85
+ this._value = value;
86
+ }
87
+
88
+ /** A node whose value is read on first access (see the class note). `kind` may
89
+ * be omitted when the schema doesn't pin it (an untyped `file/yaml`): then
90
+ * {@link nodeKind} reads the file to find out. */
91
+ static lazy(loader: () => NodeValue, concrete: string | null, path: string | null, kind?: Kind): YNode {
92
+ const node = new YNode(null, concrete, path);
93
+ node._value = undefined;
94
+ node._loader = loader;
95
+ node.kind = kind;
96
+ return node;
97
+ }
98
+
99
+ /** Whether the value is already in memory (true for eager nodes and once a
100
+ * lazy node has been read) — lets callers avoid forcing a file read. */
101
+ get loaded(): boolean {
102
+ return this._loader === undefined;
103
+ }
104
+
105
+ get value(): NodeValue {
106
+ if (this._loader) {
107
+ this._value = this._loader();
108
+ this._loader = undefined;
109
+ }
110
+ return this._value as NodeValue;
111
+ }
112
+
113
+ set value(v: NodeValue) {
114
+ this._value = v;
115
+ this._loader = undefined;
116
+ }
117
+ }
118
+
119
+ // --------------------------------------------------------------------------- //
120
+ // Loading / materialization
121
+ // --------------------------------------------------------------------------- //
122
+
123
+ /** Materialize the logical node of the yamlover entity at `entityPath`.
124
+ * `knownDir` lets a caller that already learned the type (e.g. from a
125
+ * `readdir({withFileTypes})` Dirent) skip a redundant `stat`. */
126
+ export function loadEntity(entityPath: string, knownDir?: boolean): YNode {
127
+ if (knownDir ?? isDir(entityPath)) {
128
+ const schemaPath = path.join(entityPath, YAMLOVER_DIR, SCHEMA_FILE);
129
+ if (isFile(schemaPath)) {
130
+ const schema = yaml.load(fs.readFileSync(schemaPath, "utf-8")) as Schema;
131
+ const node = resolve(schema, entityPath, null, true, schema);
132
+ node.concrete = "yamlover"; // this directory is itself a yamlover node
133
+ node.path = entityPath;
134
+ annotate(node, schema);
135
+ return node;
136
+ }
137
+ // plain directory (no .yamlover/): a *lazy* object of its visible entries.
138
+ // The entries (and any files among them) are read only when this directory
139
+ // is actually descended into — its subdirectories are themselves lazy — so a
140
+ // huge tree is never walked whole; only the levels the TOC shows are read.
141
+ return YNode.lazy(() => extraEntries(entityPath, new Set(), {}), "dir", entityPath, "object");
142
+ }
143
+ // A plain file with no schema, but whose extension names a renderable format.
144
+ // Binary-rendered formats (image, pdf, djvu, html) stay raw bytes, served as
145
+ // such and routed to their renderer by `(binary, format)`. Text formats
146
+ // (markdown, asciidoc) read as a string — their renderer takes the text value.
147
+ const fmt = formatFromExt(entityPath);
148
+ if (fmt && !TEXT_FORMATS.has(fmt)) {
149
+ const node = fromFile(entityPath, "file/binary", null, "binary");
150
+ node.format = fmt;
151
+ return node;
152
+ }
153
+ if (fmt) {
154
+ const node = new YNode(fs.readFileSync(entityPath, "utf-8"), "file", entityPath);
155
+ node.format = fmt;
156
+ node.kind = "scalar";
157
+ return node;
158
+ }
159
+ // Unknown extension: an opaque file (binary, or simply large) is surfaced as a
160
+ // *binary link* — stat-only, never read during materialization, fetched on
161
+ // demand (a directory full of archives/scans must not be slurped into memory).
162
+ // Only a small, text-looking file is read so its YAML/JSON/raw value can show.
163
+ if (looksBinary(entityPath)) {
164
+ const node = fromFile(entityPath, "file/binary", null, "binary");
165
+ node.path = entityPath;
166
+ return node;
167
+ }
168
+ // A small text file: read it now (we cannot know its kind otherwise).
169
+ const value = decodeFile(entityPath, "file/yaml", null) as NodeValue;
170
+ const node = wrap(value, "yaml");
171
+ node.concrete = "file";
172
+ node.path = entityPath;
173
+ node.kind = valueKind(node.value);
174
+ return node;
175
+ }
176
+
177
+ // The largest stray file read as text during materialization; above this it is
178
+ // treated as opaque bytes (a binary link) regardless of content.
179
+ const MAX_TEXT_BYTES = 1 << 20; // 1 MiB
180
+
181
+ /** Whether a stray file should be treated as opaque bytes rather than read as
182
+ * text: true when it is large, or when a NUL byte in its head marks it binary.
183
+ * Cheap — it stats and reads at most an 8 KiB prefix, never the whole file. */
184
+ function looksBinary(filePath: string): boolean {
185
+ let fd: number | undefined;
186
+ try {
187
+ if (fs.statSync(filePath).size > MAX_TEXT_BYTES) return true;
188
+ fd = fs.openSync(filePath, "r");
189
+ const buf = Buffer.alloc(8192);
190
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
191
+ return buf.subarray(0, n).includes(0); // a NUL byte ⇒ binary
192
+ } catch {
193
+ return false;
194
+ } finally {
195
+ if (fd !== undefined) fs.closeSync(fd);
196
+ }
197
+ }
198
+
199
+ type Schema = Record<string, any>;
200
+
201
+ /** Resolve a `$ref` JSON Pointer (`#/...`) within the schema document. */
202
+ function resolveRef(ref: string, root: Schema): Schema {
203
+ if (!ref.startsWith("#"))
204
+ throw new Error(`only same-document $ref is supported, got ${ref}`);
205
+ let target: any = root;
206
+ for (let part of ref.slice(1).split("/")) {
207
+ if (part === "") continue;
208
+ part = part.replace(/~1/g, "/").replace(/~0/g, "~"); // JSON Pointer unescape
209
+ target = Array.isArray(target) ? target[Number(part)] : target?.[part];
210
+ if (target === undefined) throw new Error(`$ref target not found: ${ref}`);
211
+ }
212
+ return target;
213
+ }
214
+
215
+ /** Deep-merge a `$ref` target with the keywords beside it (overlay wins). */
216
+ function mergeSchema(base: Schema, overlay: Schema): Schema {
217
+ const out: Schema = { ...base };
218
+ for (const [k, v] of Object.entries(overlay)) {
219
+ if (isPlainObject(out[k]) && isPlainObject(v)) out[k] = mergeSchema(out[k], v);
220
+ else out[k] = v;
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /**
226
+ * Resolve a JSON-Schema fragment to a logical {@link YNode}.
227
+ *
228
+ * @param schema the fragment to resolve
229
+ * @param container directory holding this node's file(s)
230
+ * @param defaultName file/subdir name when `x-yamlover.os.path` is absent
231
+ * @param backed true only when this node *is* `container` (surfaces stray files)
232
+ * @param root the schema document, against which `$ref` pointers resolve
233
+ */
234
+ function resolve(
235
+ schema: Schema | null,
236
+ container: string,
237
+ defaultName: string | null,
238
+ backed = false,
239
+ root: Schema | null = null,
240
+ ): YNode {
241
+ if (schema == null) return new YNode(null, null);
242
+ if (root == null) root = schema;
243
+
244
+ // $ref lives in schema coordinates: pull in the referenced fragment and merge
245
+ // any sibling keywords over it (JSON Schema 2020-12 allows $ref + siblings).
246
+ if (isPlainObject(schema) && "$ref" in schema) {
247
+ const target = resolveRef(schema["$ref"], root);
248
+ const siblings = { ...schema };
249
+ delete siblings["$ref"];
250
+ schema = mergeSchema(target, siblings);
251
+ }
252
+ if ("const" in schema!) return wrap(schema!["const"], SCHEMA_INSTANTIATE);
253
+
254
+ const xy = schema!["x-yamlover"] || {};
255
+ const concrete: string | null = xy.concrete ?? null;
256
+ const name = xyPath(xy) ?? defaultName;
257
+ const isFileConcrete = !!concrete && concrete.startsWith("file/");
258
+ const rel = xy.rel ?? null;
259
+
260
+ const stype = schema!["type"];
261
+ const isObject = stype === "object" || "properties" in schema!;
262
+ const isArray = stype === "array" || "prefixItems" in schema!;
263
+
264
+ // A structured node collapsed into a single file (e.g. 02-object-in-yaml).
265
+ if ((isObject || isArray) && isFileConcrete && name) {
266
+ const node = fromFile(path.join(container, name), concrete!, schema!, isObject ? "object" : "array");
267
+ annotate(node, schema!); // title/description/type/format (post-$ref-merge)
268
+ if (rel) node.rel = rel;
269
+ return node;
270
+ }
271
+
272
+ // A child expanded as its own subdirectory (e.g. the spec's address/).
273
+ if (name && isDir(path.join(container, name))) {
274
+ const node = loadEntity(path.join(container, name));
275
+ if (rel) node.rel = rel;
276
+ return node;
277
+ }
278
+
279
+ if (isObject) {
280
+ const children: Record<string, YNode> = {};
281
+ const consumed = new Set<string>([YAMLOVER_DIR]);
282
+ for (const [key, child] of Object.entries(schema!["properties"] || {})) {
283
+ const cnode = resolve(child as Schema, container, key, false, root);
284
+ annotate(cnode, child as Schema);
285
+ children[key] = cnode;
286
+ const cxy = (isPlainObject(child) ? (child as Schema)["x-yamlover"] : null) || {};
287
+ consumed.add(xyPath(cxy) ?? key);
288
+ }
289
+ if (backed) {
290
+ for (const child of Object.values(children))
291
+ claimPaths(child, container, consumed);
292
+ Object.assign(children, extraEntries(container, consumed, children));
293
+ }
294
+ const node = new YNode(children, concrete ?? SCHEMA_INSTANTIATE);
295
+ annotate(node, schema!); // title/description/type/format (post-$ref-merge)
296
+ if (rel) node.rel = rel;
297
+ return node;
298
+ }
299
+
300
+ if (isArray) {
301
+ // When the array's `items` is an element schema (not `false`), it is the base
302
+ // each `prefixItems` entry overlays — so a uniform element type/format (e.g. a
303
+ // chapter's chunks all `string`/`text/markdown`) is declared once, not repeated.
304
+ const itemBase = isPlainObject(schema!["items"]) ? (schema!["items"] as Schema) : null;
305
+ const items = (schema!["prefixItems"] || []).map((child: Schema, idx: number) => {
306
+ const eff = itemBase ? mergeSchema(itemBase, child) : child;
307
+ const cnode = resolve(eff, container, String(idx), false, root);
308
+ annotate(cnode, eff);
309
+ return cnode;
310
+ });
311
+ const node = new YNode(items, concrete ?? SCHEMA_INSTANTIATE);
312
+ annotate(node, schema!); // title/description/type/format (post-$ref-merge)
313
+ if (rel) node.rel = rel;
314
+ return node;
315
+ }
316
+
317
+ // A value stored in its own file. Its kind is known from the schema `type`
318
+ // (e.g. 04-object-in-dir's typed scalars) or, for an untyped `file/yaml`
319
+ // (e.g. 11-switch's `contact`), determined by reading the file on demand.
320
+ if (isFileConcrete && name) {
321
+ const kind = concrete === "file/binary" ? "binary" : kindFromType(stype);
322
+ const node = fromFile(path.join(container, name), concrete!, schema!, kind);
323
+ annotate(node, schema!); // title/description/type/format (post-$ref-merge)
324
+ if (rel) node.rel = rel;
325
+ return node;
326
+ }
327
+
328
+ // No value, but still defined inline in the schema → instantiated from it.
329
+ const node = new YNode(null, concrete ?? SCHEMA_INSTANTIATE);
330
+ annotate(node, schema!); // title/description/type/format (post-$ref-merge)
331
+ if (rel) node.rel = rel;
332
+ return node;
333
+ }
334
+
335
+ /** A lazy node *is* a file: its interior (yaml/json/binary) is decoded only when
336
+ * the value is first accessed. `kind` (from the schema) lets callers list or
337
+ * elide it without reading the bytes. */
338
+ function fromFile(filePath: string, concrete: string, schema: Schema | null, kind?: Kind): YNode {
339
+ const node = YNode.lazy(
340
+ () => wrap(decodeFile(filePath, concrete, schema), interior(concrete)).value,
341
+ concrete,
342
+ filePath, // the node is this file; its interior children stay path-less
343
+ kind,
344
+ );
345
+ // Fall back to the extension-implied format when the schema pins none, so a
346
+ // file routes to its renderer with no `format:` declaration. A schema `format`
347
+ // (applied by `annotate` after this) still wins.
348
+ const schemaFmt = isPlainObject(schema) ? schema["format"] : null;
349
+ const fmt = (typeof schemaFmt === "string" ? schemaFmt : null) ?? formatFromExt(filePath);
350
+ if (fmt) node.format = fmt;
351
+ return node;
352
+ }
353
+
354
+ /** Wrap a plain JS value into YNodes, tagging every level with `concrete`. */
355
+ function wrap(value: unknown, concrete: string): YNode {
356
+ if (isPlainObject(value)) {
357
+ const out: Record<string, YNode> = {};
358
+ for (const [k, v] of Object.entries(value)) out[k] = wrap(v, concrete);
359
+ return new YNode(out, concrete);
360
+ }
361
+ if (Array.isArray(value))
362
+ return new YNode(value.map((v) => wrap(v, concrete)), concrete);
363
+ return new YNode(value as NodeValue, concrete);
364
+ }
365
+
366
+ /** Add to `consumed` every filename in `container` that `node`'s subtree binds.
367
+ * A file-backed node's interior lives *inside* that one file, so we never recurse
368
+ * into it — which also keeps lazy file nodes from being read here. */
369
+ function claimPaths(node: YNode, container: string, consumed: Set<string>): void {
370
+ if (node.path && path.dirname(node.path) === container)
371
+ consumed.add(path.basename(node.path));
372
+ if (node.concrete && (node.concrete === "file" || node.concrete.startsWith("file/"))) return;
373
+ if (isPlainObject(node.value))
374
+ for (const child of Object.values(node.value)) claimPaths(child, container, consumed);
375
+ else if (Array.isArray(node.value))
376
+ for (const child of node.value) claimPaths(child, container, consumed);
377
+ }
378
+
379
+ // Predicate deciding whether an undescribed entry is hidden by .gitignore.
380
+ // Configured per server (see setIgnoreFilter); the default lets everything through.
381
+ let isIgnored: (absPath: string) => boolean = () => false;
382
+
383
+ /** Install the .gitignore predicate used to hide undescribed (stray) entries. */
384
+ export function setIgnoreFilter(fn: (absPath: string) => boolean): void {
385
+ isIgnored = fn;
386
+ }
387
+
388
+ /** Undescribed, non-hidden files/dirs physically present in `container`.
389
+ * Entries matched by .gitignore are skipped (schema-described children are
390
+ * always kept — only these surfaced strays are filtered). */
391
+ function extraEntries(
392
+ container: string,
393
+ consumed: Set<string>,
394
+ existing: Record<string, YNode>,
395
+ ): Record<string, YNode> {
396
+ const out: Record<string, YNode> = {};
397
+ if (isDir(container)) {
398
+ // `withFileTypes` returns each entry's kind in the one `readdir`, so the
399
+ // common dir/file case needs no extra `stat` per entry (a symlink still
400
+ // falls back to one, via `knownDir: undefined`).
401
+ const ents = fs.readdirSync(container, { withFileTypes: true });
402
+ ents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
403
+ for (const ent of ents) {
404
+ const name = ent.name;
405
+ if (name.startsWith(".") || consumed.has(name) || name in existing) continue;
406
+ const full = path.join(container, name);
407
+ if (isIgnored(full)) continue;
408
+ const knownDir = ent.isDirectory() ? true : ent.isFile() ? false : undefined;
409
+ out[name] = loadEntity(full, knownDir);
410
+ }
411
+ }
412
+ return out;
413
+ }
414
+
415
+ /** Read `filePath` and decode it according to its `concrete` encoding. A text
416
+ * file that does not parse as YAML/JSON (a README, a source file, …) falls back
417
+ * to its raw text — a yamlover string — rather than erroring. */
418
+ function decodeFile(filePath: string, concrete: string, schema: Schema | null): unknown {
419
+ if (!fs.existsSync(filePath)) return `<missing: ${path.basename(filePath)}>`;
420
+
421
+ if (concrete === "file/binary") {
422
+ try {
423
+ const size = fs.statSync(filePath).size; // cheap; avoids reading the blob
424
+ const fmt = (isPlainObject(schema) ? schema["format"] : null) ?? formatFromExt(filePath);
425
+ let decoded: unknown = null;
426
+ let data: Buffer | null = null;
427
+ if (fmt === "int32/le" && size === 4) {
428
+ data = fs.readFileSync(filePath);
429
+ decoded = data.readInt32LE(0);
430
+ }
431
+ return new Binary(size, fmt, decoded, data);
432
+ } catch (exc) {
433
+ return `<unreadable ${path.basename(filePath)}: ${(exc as Error).name}>`;
434
+ }
435
+ }
436
+
437
+ let text: string;
438
+ try {
439
+ text = fs.readFileSync(filePath, "utf-8");
440
+ } catch (exc) {
441
+ return `<unreadable ${path.basename(filePath)}: ${(exc as Error).name}>`;
442
+ }
443
+ try {
444
+ return concrete === "file/json" ? JSON.parse(text) : yaml.load(text);
445
+ } catch {
446
+ return text; // not YAML/JSON — show the file's raw content as a string
447
+ }
448
+ }
449
+
450
+ // --------------------------------------------------------------------------- //
451
+ // Small helpers
452
+ // --------------------------------------------------------------------------- //
453
+
454
+ function xyPath(xy: any): string | null {
455
+ return xy?.os?.path ?? null;
456
+ }
457
+
458
+ /** The interior representation of a collapsed document file. */
459
+ function interior(concrete: string | null): string {
460
+ return concrete === "file/json" ? "json" : "yaml";
461
+ }
462
+
463
+ // File extension → format (MIME-ish), the second half of the (type, format)
464
+ // renderer key. A file-backed node that carries no explicit schema `format`
465
+ // falls back to this, so a stray `.pdf`/`.png`/`.md` renders without being
466
+ // declared. The formats here are exactly the ones a client renderer claims.
467
+ const EXT_FORMAT: Record<string, string> = {
468
+ ".png": "image/png",
469
+ ".jpg": "image/jpeg",
470
+ ".jpeg": "image/jpeg",
471
+ ".gif": "image/gif",
472
+ ".webp": "image/webp",
473
+ ".avif": "image/avif",
474
+ ".bmp": "image/bmp",
475
+ ".ico": "image/x-icon",
476
+ ".svg": "image/svg+xml",
477
+ ".pdf": "application/pdf",
478
+ ".djvu": "image/vnd.djvu",
479
+ ".djv": "image/vnd.djvu",
480
+ ".psd": "image/vnd.adobe.photoshop",
481
+ ".psb": "image/vnd.adobe.photoshop",
482
+ ".tif": "image/tiff",
483
+ ".tiff": "image/tiff",
484
+ ".heic": "image/heic",
485
+ ".heif": "image/heic",
486
+ ".fb2": "application/x-fictionbook+xml",
487
+ ".epub": "application/epub+zip",
488
+ ".html": "text/html",
489
+ ".htm": "text/html",
490
+ ".md": "text/markdown",
491
+ ".markdown": "text/markdown",
492
+ ".adoc": "text/asciidoc",
493
+ ".asciidoc": "text/asciidoc",
494
+ ".asc": "text/asciidoc",
495
+ ".csv": "text/csv",
496
+ ".tsv": "text/tab-separated-values",
497
+ ".rtf": "application/rtf",
498
+ ".doc": "application/msword",
499
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
500
+ ".xls": "application/vnd.ms-excel",
501
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
502
+ ".kml": "application/vnd.google-earth.kml+xml",
503
+ ".kmz": "application/vnd.google-earth.kmz",
504
+ ".puml": "text/x-plantuml",
505
+ ".plantuml": "text/x-plantuml",
506
+ ".iuml": "text/x-plantuml",
507
+ ".pu": "text/x-plantuml",
508
+ };
509
+
510
+ /** The renderable format implied by a file's extension, or null when unknown. */
511
+ export function formatFromExt(filePath: string | null): string | null {
512
+ if (!filePath) return null;
513
+ return EXT_FORMAT[path.extname(filePath).toLowerCase()] ?? null;
514
+ }
515
+
516
+ // Formats whose nodes carry their content as a *string* value (rendered from the
517
+ // text). Everything else inferable (images, pdf, djvu, html) is served as bytes.
518
+ const TEXT_FORMATS = new Set([
519
+ "text/markdown",
520
+ "text/asciidoc",
521
+ "text/x-plantuml",
522
+ "text/csv",
523
+ "text/tab-separated-values",
524
+ ]);
525
+
526
+ /** Capture a fragment's annotations onto a node: `title`/`description` (tree
527
+ * labels) and `type`/`format` (the (type, format) renderer key — read without
528
+ * ever touching the file). */
529
+ function annotate(node: YNode, schema: Schema | null): void {
530
+ if (!isPlainObject(schema)) return;
531
+ if (typeof schema["title"] === "string") node.title = schema["title"];
532
+ if (typeof schema["description"] === "string") node.description = schema["description"];
533
+ if (typeof schema["type"] === "string") node.schemaType = schema["type"];
534
+ if (typeof schema["format"] === "string") node.format = schema["format"];
535
+ }
536
+
537
+ function isPlainObject(v: unknown): v is Record<string, any> {
538
+ return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Binary);
539
+ }
540
+
541
+ function isBinary(v: unknown): v is Binary {
542
+ return v instanceof Binary;
543
+ }
544
+
545
+ function isDir(p: string): boolean {
546
+ try {
547
+ return fs.statSync(p).isDirectory();
548
+ } catch {
549
+ return false;
550
+ }
551
+ }
552
+
553
+ function isFile(p: string): boolean {
554
+ try {
555
+ return fs.statSync(p).isFile();
556
+ } catch {
557
+ return false;
558
+ }
559
+ }
560
+
561
+ function valueKind(v: NodeValue): Kind {
562
+ if (isPlainObject(v)) return "object";
563
+ if (Array.isArray(v)) return "array";
564
+ if (isBinary(v)) return "binary";
565
+ return "scalar";
566
+ }
567
+
568
+ /** The node kind implied by a schema `type`, or undefined when `type` is absent
569
+ * (then the kind is only knowable by reading the file). */
570
+ function kindFromType(stype: unknown): Kind | undefined {
571
+ if (stype === "object") return "object";
572
+ if (stype === "array") return "array";
573
+ if (stype === "string" || stype === "integer" || stype === "number" || stype === "boolean" || stype === "null")
574
+ return "scalar";
575
+ return undefined;
576
+ }
577
+
578
+ /** A node's coarse kind *without forcing a lazy load* when it is already known
579
+ * (set from the schema). Only eager nodes fall through to inspecting the value. */
580
+ export function nodeKind(node: YNode): Kind {
581
+ return node.kind ?? valueKind(node.value);
582
+ }
583
+
584
+ export function isContainer(node: YNode): boolean {
585
+ const k = nodeKind(node);
586
+ return k === "object" || k === "array";
587
+ }
588
+
589
+ /** Whether a node *displays* as a container: a real object/array, or a `null`
590
+ * leaf *overlaid* with virtual children (dot-prefixed `rel` down-edges) — those
591
+ * keys make the null read as an object (e.g. a childless person who is recorded
592
+ * as a parent elsewhere). Plain up-edge relations (`father`/`mother`) do *not*
593
+ * promote a null: it stays a scalar. The virtual-children check (which reads only
594
+ * `rel`) comes before touching `value`, so leaves are judged without a file read. */
595
+ export function isDisplayContainer(node: YNode): boolean {
596
+ const k = nodeKind(node);
597
+ if (k === "object" || k === "array") return true;
598
+ if (k === "binary") return false;
599
+ if (Object.keys(virtualChildren(node)).length === 0) return false;
600
+ return node.value == null;
601
+ }
602
+
603
+ /** A node's display kind: an entity node (see {@link isDisplayContainer}) shows as
604
+ * `object`; otherwise its ordinary {@link nodeKind}. */
605
+ export function displayKind(node: YNode): Kind {
606
+ const k = nodeKind(node);
607
+ return k === "scalar" && isDisplayContainer(node) ? "object" : k;
608
+ }
609
+
610
+ /** Direct child count for display: real children plus non-colliding virtual ones
611
+ * (dot-prefixed `rel` down-edges read like real children). */
612
+ function displayChildCount(node: YNode): number {
613
+ const k = nodeKind(node);
614
+ const real =
615
+ k === "object" ? Object.keys(node.value as Record<string, YNode>)
616
+ : k === "array" ? (node.value as YNode[]).map((_, i) => String(i))
617
+ : [];
618
+ let n = real.length;
619
+ for (const name of Object.keys(virtualChildren(node))) if (!real.includes(name)) n++;
620
+ return n;
621
+ }
622
+
623
+ export function typeLabel(node: YNode): string {
624
+ const v = node.value;
625
+ if (isPlainObject(v)) return "object";
626
+ if (Array.isArray(v)) return "array";
627
+ if (isBinary(v)) return "binary";
628
+ if (typeof v === "boolean") return "boolean";
629
+ if (typeof v === "number") return Number.isInteger(v) ? "integer" : "number";
630
+ if (typeof v === "string") return "string";
631
+ if (v === null) return "null";
632
+ return typeof v;
633
+ }
634
+
635
+ /** The type shown in the TOC and the content header. Same as {@link typeLabel},
636
+ * except a `null` leaf overlaid with virtual children reads as `object` (see
637
+ * {@link isDisplayContainer}); a node with only up-edge relations stays `null`. */
638
+ export function displayTypeLabel(node: YNode): string {
639
+ return nodeKind(node) === "scalar" && isDisplayContainer(node) ? "object" : typeLabel(node);
640
+ }
641
+
642
+ // --------------------------------------------------------------------------- //
643
+ // Path handling (JSON space — no "properties")
644
+ // --------------------------------------------------------------------------- //
645
+
646
+ export type Seg = string | number;
647
+
648
+ /** Render path segments JSON-path style: `:key[0]:other` (root → `:`, colon-form —
649
+ * SEPARATOR.md M4). Each key is percent-encoded so a `:`, `[`, or `]` *inside* a
650
+ * key (e.g. `@vitejs/plugin-react`) does not read as a separator. */
651
+ export function segsToStr(segs: Seg[]): string {
652
+ return (
653
+ segs
654
+ .map((s) => (typeof s === "number" ? `[${s}]` : `:${encodeURIComponent(s)}`))
655
+ .join("") || ":"
656
+ );
657
+ }
658
+
659
+ const PATH_TOKEN = /\[\d+\]|[^:\[\]]+/g;
660
+
661
+ /** Parse a JSON-path string into segments (`[n]` → number, else decoded key). */
662
+ export function strToSegs(str: string): Seg[] {
663
+ const out: Seg[] = [];
664
+ for (const tok of str.match(PATH_TOKEN) || []) {
665
+ out.push(/^\[\d+\]$/.test(tok) ? Number(tok.slice(1, -1)) : safeDecode(tok));
666
+ }
667
+ return out;
668
+ }
669
+
670
+ function safeDecode(s: string): string {
671
+ try {
672
+ return decodeURIComponent(s);
673
+ } catch {
674
+ return s;
675
+ }
676
+ }
677
+
678
+ /** Walk to the node addressed by `segs`, throwing on a bad segment. */
679
+ export function getNode(root: YNode, segs: Seg[]): YNode {
680
+ let node = root;
681
+ for (const seg of segs) {
682
+ const v = node.value;
683
+ if (typeof seg === "number") {
684
+ if (!Array.isArray(v) || seg < 0 || seg >= v.length)
685
+ throw new Error(`index out of range: [${seg}]`);
686
+ node = v[seg];
687
+ } else {
688
+ if (!isPlainObject(v) || !(seg in v)) throw new Error(`no such child: ${seg}`);
689
+ node = (v as Record<string, YNode>)[seg];
690
+ }
691
+ }
692
+ return node;
693
+ }
694
+
695
+ // --------------------------------------------------------------------------- //
696
+ // rel pointer resolution (a port of walker.py's follow_pointer / walk_segments)
697
+ // --------------------------------------------------------------------------- //
698
+
699
+ // A rel-pointer token: a bracketed array index (`[0]`), a `^name` ascent, or a
700
+ // key name. `^` is a boundary (`a^b` → `a` then `^b`); keys may not contain `^`.
701
+ const REL_TOKEN = /\[\d+\]|\^[^/\[\]^]+|[^/\[\]^]+/g;
702
+
703
+ /** Translate a single path token into a real key/index for `node`'s value. */
704
+ function childKey(node: YNode, part: string): Seg {
705
+ const v = node.value;
706
+ if (isPlainObject(v)) {
707
+ if (part in v) return part;
708
+ throw new Error(`no such child: ${part}`);
709
+ }
710
+ if (Array.isArray(v)) {
711
+ const tok = part.startsWith("[") && part.endsWith("]") ? part.slice(1, -1) : part;
712
+ const idx = Number(tok);
713
+ if (Number.isInteger(idx) && idx >= 0 && idx < v.length) return idx;
714
+ throw new Error(`bad index: ${part}`);
715
+ }
716
+ throw new Error(`${typeLabel(node)} has no children`);
717
+ }
718
+
719
+ function hasChild(node: YNode, token: string): boolean {
720
+ try {
721
+ childKey(node, token);
722
+ return true;
723
+ } catch {
724
+ return false;
725
+ }
726
+ }
727
+
728
+ /** A node's virtual children — `rel` keys prefixed with `.` (down-edges), the
729
+ * `.` stripped — mapping name → pointer (string pointers only). */
730
+ function virtualChildren(node: YNode): Record<string, string> {
731
+ const out: Record<string, string> = {};
732
+ for (const [k, v] of Object.entries(node.rel || {}))
733
+ if (k.startsWith(".") && typeof v === "string") out[k.slice(1)] = v;
734
+ return out;
735
+ }
736
+
737
+ /** Segments of the nearest ancestor-or-self that is a yamlover entity — the
738
+ * anchor an absolute (`/…`) pointer is written relative to. Falls back to root. */
739
+ function entityRootSegs(root: YNode, segs: Seg[]): Seg[] {
740
+ for (let i = segs.length; i >= 0; i--)
741
+ if (getNode(root, segs.slice(0, i)).concrete === "yamlover") return segs.slice(0, i);
742
+ return [];
743
+ }
744
+
745
+ /** The JSON-space path of the *document* the node at `segs` belongs to: the nearest
746
+ * yamlover entity (or the served root). This is the anchor a document-relative
747
+ * (`/…`) link or `rel` pointer resolves against — see {@link entityRootSegs}. */
748
+ export function documentPath(root: YNode, segs: Seg[]): string {
749
+ return segsToStr(entityRootSegs(root, segs));
750
+ }
751
+
752
+ /** Walk *up* a named parent edge (`^name`) from the node at `segs`. */
753
+ function ascend(root: YNode, segs: Seg[], name: string): Seg[] {
754
+ const rel = getNode(root, segs).rel || {};
755
+ if (name in rel && typeof rel[name] === "string") return followPointer(root, segs, rel[name] as string);
756
+ if (segs.length && String(segs[segs.length - 1]) === name) return segs.slice(0, -1); // ^<own-key> undoes the descent
757
+ throw new Error(`no parent relation: ^${name}`);
758
+ }
759
+
760
+ /** Apply path `tokens` from `segs`: `..` ascends, `^name` ascends a named parent,
761
+ * `[n]`/names descend (falling back to a virtual child). Throws on a bad step. */
762
+ function walkSegments(root: YNode, segs: Seg[], tokens: string[]): Seg[] {
763
+ let cur = [...segs];
764
+ for (const token of tokens) {
765
+ if (token === ".") continue;
766
+ if (token === "..") {
767
+ if (cur.length) cur.pop();
768
+ } else if (token.startsWith("^")) {
769
+ cur = ascend(root, cur, token.slice(1));
770
+ } else {
771
+ const node = getNode(root, cur);
772
+ const vkids = virtualChildren(node);
773
+ // a real containment child wins; otherwise follow a virtual down-edge
774
+ if (token in vkids && !hasChild(node, token)) cur = followPointer(root, cur, vkids[token]);
775
+ else cur.push(childKey(node, token));
776
+ }
777
+ }
778
+ getNode(root, cur); // validate
779
+ return cur;
780
+ }
781
+
782
+ /** Resolve a `rel` pointer to target segments: `..`-relative walks from `segs`,
783
+ * an absolute `/…` from the enclosing yamlover entity (cf. walker.py). */
784
+ function followPointer(root: YNode, segs: Seg[], ptr: string): Seg[] {
785
+ if (ptr.startsWith("*")) throw new Error(`anchor refs not yet supported: ${ptr}`);
786
+ const base = ptr.startsWith("/") ? entityRootSegs(root, segs) : segs;
787
+ return walkSegments(root, base, ptr.match(REL_TOKEN) || []);
788
+ }
789
+
790
+ /** The target segments a `rel` pointer resolves to, or null when it is not a
791
+ * string pointer or does not resolve to a real node. */
792
+ export function resolveRel(root: YNode, segs: Seg[], ptr: unknown): Seg[] | null {
793
+ if (typeof ptr !== "string") return null;
794
+ try {
795
+ return followPointer(root, segs, ptr);
796
+ } catch {
797
+ return null;
798
+ }
799
+ }
800
+
801
+ /** The JSON-space path a `rel` pointer resolves to (for hyperlinking), or null. */
802
+ export function relTargetPath(root: YNode, segs: Seg[], ptr: unknown): string | null {
803
+ const target = resolveRel(root, segs, ptr);
804
+ return target ? segsToStr(target) : null;
805
+ }
806
+
807
+ // --------------------------------------------------------------------------- //
808
+ // Serialization: JSON value and instance JSON Schema
809
+ // --------------------------------------------------------------------------- //
810
+
811
+ function descend(depth: number | null): number | null {
812
+ return depth == null ? null : depth - 1;
813
+ }
814
+
815
+ // A node shown only as a *link* (a container past the depth budget, or any binary
816
+ // leaf) becomes a link marker rather than being inlined: the client renders it as
817
+ // `{ object with N properties }` / `[ array with M items ]` / `< binary of N
818
+ // bytes >` and descends to `path` on click. The same marker is used in the value
819
+ // and the schema views, so every representation behaves identically.
820
+ export const LINK_KEY = "$yamloverLink";
821
+
822
+ interface LinkMarker {
823
+ [LINK_KEY]: { kind: Kind; type: string; path: string; title?: string; count?: number; size?: number; format?: string | null; value?: unknown };
824
+ }
825
+
826
+ function linkMarker(node: YNode, segs: Seg[]): LinkMarker {
827
+ const kind = displayKind(node); // entity nodes link as `object`, like their siblings
828
+ // the (type, format) tuple rides along — the same key the TOC and RHS use — so a
829
+ // renderer can route a child to its own renderer (e.g. a chapter to its chunks)
830
+ const info: LinkMarker[typeof LINK_KEY] = { kind, type: tocType(node), path: segsToStr(segs) };
831
+ if (node.format) info.format = node.format; // half of the routing key (see above)
832
+ // a node's schema title rides along so a renderer can label the link with the
833
+ // target's heading (e.g. a chapter linking its subchapters by their titles)
834
+ if (node.title) info.title = node.title;
835
+ if (kind === "binary") {
836
+ const b = node.value as Binary; // stat-cheap; gives size + format
837
+ info.size = b.size;
838
+ if (info.format == null) info.format = b.fmt; // fall back to the file's encoded format
839
+ } else if (kind === "scalar") {
840
+ info.value = node.value; // a link to a genuine scalar shows its value as the label
841
+ } else {
842
+ info.count = displayChildCount(node);
843
+ }
844
+ return { [LINK_KEY]: info };
845
+ }
846
+
847
+ // An `x-yamlover.rel` pointer, emitted in the schema view as `{ [REF_KEY]: {text,
848
+ // path} }`: the client renders `text` (the original pointer) as a hyperlink that
849
+ // navigates to `path` (the resolved JSON-space location), or as plain text when
850
+ // `path` is null (the pointer does not resolve to a real node).
851
+ export const REF_KEY = "$yamloverRef";
852
+
853
+ interface RefMarker {
854
+ [REF_KEY]: { text: string; path: string | null };
855
+ }
856
+
857
+ /** Turn a node's `rel` table into ref markers, resolving each pointer (relative
858
+ * to `segs`, the node's own path) so the client can hyperlink it. */
859
+ function relMarkers(rel: Record<string, unknown>, segs: Seg[], root: YNode): Record<string, RefMarker> {
860
+ const out: Record<string, RefMarker> = {};
861
+ for (const [name, ptr] of Object.entries(rel))
862
+ out[name] = { [REF_KEY]: { text: String(ptr), path: relTargetPath(root, segs, ptr) } };
863
+ return out;
864
+ }
865
+
866
+ function refTo(path: string | null, fallbackText: string): RefMarker {
867
+ return { [REF_KEY]: { text: path ?? fallbackText, path } };
868
+ }
869
+
870
+ /** A hyperlink to where a `rel` pointer resolves, shown with the *target's*
871
+ * standard title (`{ object … }` / `[ array … ]` / scalar value) via a
872
+ * {@link linkMarker}; falls back to a plain-text {@link refTo} when the pointer
873
+ * does not resolve to a real node. */
874
+ function relLink(root: YNode, segs: Seg[], ptr: unknown): LinkMarker | RefMarker {
875
+ const target = resolveRel(root, segs, ptr);
876
+ return target ? linkMarker(getNode(root, target), target) : refTo(null, String(ptr));
877
+ }
878
+
879
+ /**
880
+ * The relations panel shown above the value in the data (yaml/json) views: the
881
+ * node's *named up-edges* (its non-dot `rel` keys, e.g. `father`/`mother`), each
882
+ * a hyperlink to where it resolves — shown with the target's standard title, like
883
+ * any other container link — led by the structural parent `..`. The `..` is
884
+ * omitted when a named edge already points to the parent (e.g. `father: ".."`),
885
+ * so the parent is not listed twice, and at the root (which has no parent); a node
886
+ * with no named up-edges shows only `..`. (Dot-prefixed `rel` keys are *virtual
887
+ * children* — see {@link toPlain} — surfaced in the value, not here.)
888
+ */
889
+ export function buildRelations(node: YNode, segs: Seg[], root: YNode): Record<string, unknown> {
890
+ const parentSegs = segs.slice(0, -1); // structural parent (root → itself)
891
+ const parentPath = segsToStr(parentSegs);
892
+ const named: Record<string, unknown> = {};
893
+ let parentCovered = false;
894
+ for (const [name, ptr] of Object.entries(node.rel || {})) {
895
+ if (name.startsWith(".")) continue;
896
+ named[name] = relLink(root, segs, ptr);
897
+ if (relTargetPath(root, segs, ptr) === parentPath) parentCovered = true;
898
+ }
899
+ const out: Record<string, unknown> = {};
900
+ if (segs.length > 0 && !parentCovered) out[".."] = linkMarker(getNode(root, parentSegs), parentSegs);
901
+ return Object.assign(out, named);
902
+ }
903
+
904
+ // The bytes of a binary leaf, shown only when the leaf itself is the selection.
905
+ // The client renders this as `!!binary` (YAML) or the metadata object (JSON).
906
+ export const BINARY_KEY = "$yamloverBinary";
907
+
908
+ export function binaryContent(node: YNode): Record<string, unknown> {
909
+ return { [BINARY_KEY]: binaryBase64(node) };
910
+ }
911
+
912
+ /**
913
+ * Materialize a node's subtree as plain JSON-able values. `depth` limits
914
+ * container nesting (null = unlimited); a container past the budget becomes a
915
+ * {@link linkMarker} (so the client links to it rather than inlining it).
916
+ * `segs` is the node's own JSON path, threaded so markers know where to point.
917
+ *
918
+ * Entity nodes — including childless ones that materialize as null-valued leaves
919
+ * carrying only a `rel` table — display as objects (see {@link isDisplayContainer})
920
+ * so siblings render uniformly. A node's *virtual children* (its dot-prefixed
921
+ * `rel` down-edges, e.g. a mother's `.cain`) are surfaced alongside any real
922
+ * children as links to where they resolve — with the target's standard title, like
923
+ * any container link — so they read like ordinary children. A real child of the
924
+ * same name always wins. `root` (default `node`) anchors the pointer resolution;
925
+ * the API passes the real entity root.
926
+ */
927
+ export function toPlain(
928
+ node: YNode,
929
+ depth: number | null = null,
930
+ segs: Seg[] = [],
931
+ top = true,
932
+ root: YNode = node,
933
+ ): unknown {
934
+ const k = nodeKind(node);
935
+ // Every child (non-top) is a hyperlink to its own page: at the one-level depth
936
+ // boundary a container links by its `{ … }`/`[ … ]` summary and a scalar by its
937
+ // rendered value, so all children are navigable alike. (The top node is shown in
938
+ // full, and a binary child always links — never inlined — regardless of depth.)
939
+ if (!top && depth != null && depth <= 0) return linkMarker(node, segs);
940
+ if (k === "binary" && !top) return linkMarker(node, segs);
941
+
942
+ if (k === "array") {
943
+ return (node.value as YNode[]).map((c, i) => toPlain(c, descend(depth), [...segs, i], false, root));
944
+ }
945
+ if (isDisplayContainer(node)) {
946
+ // a real object, or a null leaf overlaid with virtual children, rendered as an
947
+ // object: real children recursed, virtual ones (dot-rel) linked to where they
948
+ // resolve (a real child of the same name wins)
949
+ const out: Record<string, unknown> = {};
950
+ if (k === "object")
951
+ for (const [key, c] of Object.entries(node.value as Record<string, YNode>))
952
+ out[key] = toPlain(c, descend(depth), [...segs, key], false, root);
953
+ for (const [name, ptr] of Object.entries(virtualChildren(node)))
954
+ if (!(name in out)) out[name] = relLink(root, segs, ptr);
955
+ return out;
956
+ }
957
+ if (k === "binary") return (node.value as Binary).repr(); // top-level binary (header only)
958
+ return node.value; // scalar — read on demand
959
+ }
960
+
961
+ /**
962
+ * Build the JSON Schema whose sole instance is the node's subtree — the
963
+ * instance → schema direction of the Schema ↔ instance correspondence (every
964
+ * value `v` becomes `{const: v}`). Every node also carries its full
965
+ * `x-yamlover` block (see {@link xyProvenance}) — uniformly, whatever its
966
+ * concrete representation. A container past the `depth` budget becomes a
967
+ * {@link linkMarker}, exactly as in {@link toPlain}.
968
+ *
969
+ * `root` is the full materialized tree (defaulting to `node` itself), needed to
970
+ * resolve each node's `rel` pointers into hyperlinks; the API passes the real
971
+ * entity root so absolute (`/…`) pointers anchor correctly.
972
+ */
973
+ export function toSchema(
974
+ node: YNode,
975
+ depth: number | null = null,
976
+ segs: Seg[] = [],
977
+ top = true,
978
+ root: YNode = node,
979
+ ): unknown {
980
+ const k = nodeKind(node);
981
+ if ((k === "object" || k === "array") && depth != null && depth <= 0) return linkMarker(node, segs);
982
+ if (k === "binary" && !top) return linkMarker(node, segs); // a binary child links to its page
983
+
984
+ // Lead with the (type, format) tuple a node routes on — the same key the TOC,
985
+ // icons, and renderers use — in the JSON Schema order (`type`, then `format`), so
986
+ // the schema view mirrors the source. `type` is the declared schema `type` when
987
+ // pinned, else the inferred kind; `format` is whatever the node carries (a
988
+ // source-pinned `text/markdown` or an extension-inferred one). Without these the
989
+ // representation silently dropped a leaf's `type:`/`format:`.
990
+ const schema: Schema = { type: node.schemaType ?? typeLabel(node) };
991
+ if (node.format) schema.format = node.format;
992
+ if (k === "object") {
993
+ const properties: Schema = {};
994
+ for (const [key, c] of Object.entries(node.value as Record<string, YNode>))
995
+ properties[key] = toSchema(c, descend(depth), [...segs, key], false, root);
996
+ schema.properties = properties;
997
+ } else if (k === "array") {
998
+ schema.prefixItems = (node.value as YNode[]).map((c, i) => toSchema(c, descend(depth), [...segs, i], false, root));
999
+ schema.items = false;
1000
+ } else if (k === "binary") {
1001
+ schema.const = (node.value as Binary).repr();
1002
+ } else {
1003
+ schema.const = node.value;
1004
+ }
1005
+ if (node.title) schema.title = node.title;
1006
+ if (node.description) schema.description = node.description;
1007
+ const xy = xyProvenance(node, segs, root);
1008
+ if (xy) schema["x-yamlover"] = xy;
1009
+ return schema;
1010
+ }
1011
+
1012
+ /**
1013
+ * A node's full `x-yamlover` block for the schema view, built the same way for
1014
+ * every node regardless of how it is concretely stored: its `concrete` tag, any
1015
+ * `rel` links, and — only when the node is physically on disk — its `os` stat
1016
+ * provenance. There is no per-concrete special-casing: a schema-instantiated
1017
+ * node (no file, no directory) still surfaces its `concrete` and `rel`, and a
1018
+ * filesystem-backed one adds `os` on top. Returns null when nothing applies.
1019
+ * Each `rel` pointer is emitted as a {@link relMarkers} ref so the client can
1020
+ * hyperlink it to the location it resolves to.
1021
+ */
1022
+ function xyProvenance(node: YNode, segs: Seg[], root: YNode): Schema | null {
1023
+ const xy: Schema = {};
1024
+ if (node.concrete != null) xy.concrete = node.concrete;
1025
+ if (node.rel != null) xy.rel = relMarkers(node.rel, segs, root);
1026
+ if (node.path != null) xy.os = osInfo(node.path);
1027
+ return Object.keys(xy).length > 0 ? xy : null;
1028
+ }
1029
+
1030
+ function osInfo(p: string): Schema {
1031
+ const st = fs.statSync(p);
1032
+ const info: Schema = { path: path.basename(p) };
1033
+ if (!st.isDirectory()) info.size = st.size;
1034
+ info.mtime = new Date(st.mtimeMs).toISOString().replace(/\.\d+Z$/, "Z");
1035
+ return info;
1036
+ }
1037
+
1038
+ // --------------------------------------------------------------------------- //
1039
+ // Table of contents (LHS tree)
1040
+ // --------------------------------------------------------------------------- //
1041
+
1042
+ export interface TreeNode {
1043
+ path: string; // JSON-space path
1044
+ label: string;
1045
+ type: string; // JSON-Schema type (object | array | string | integer | …)
1046
+ format: string | null; // schema `format`; with `type` it keys the renderer/icon
1047
+ concrete: string | null; // how it is stored (e.g. `dir` → a plain folder icon)
1048
+ hasChildren: boolean; // container with children (whether or not loaded here)
1049
+ children: TreeNode[]; // loaded up to the requested depth ([] past the boundary)
1050
+ }
1051
+
1052
+ /**
1053
+ * A node's tree label: its schema `title`, else an instance `title` child, else
1054
+ * the key (objects) or `[index]` (arrays).
1055
+ */
1056
+ export function labelForSeg(node: YNode, keyOrIdx: Seg): string {
1057
+ if (node.title) return node.title;
1058
+ const v = node.value;
1059
+ if (isPlainObject(v)) {
1060
+ const t = (v as Record<string, YNode>)["title"];
1061
+ if (t && !isContainer(t) && !isBinary(t.value)) return String(t.value);
1062
+ }
1063
+ return typeof keyOrIdx === "number" ? `[${keyOrIdx}]` : keyOrIdx;
1064
+ }
1065
+
1066
+ /** The TOC type for a node, derived without forcing a file read: the schema
1067
+ * `type` if known, else the coarse kind (and the precise scalar type only when
1068
+ * the value already happens to be loaded, e.g. a `const`). A `null` leaf overlaid
1069
+ * with virtual children reads as `object`, matching the content header. */
1070
+ function tocType(node: YNode): string {
1071
+ if (node.schemaType) return node.schemaType;
1072
+ const k = nodeKind(node);
1073
+ if (k === "scalar") {
1074
+ if (isDisplayContainer(node)) return "object"; // virtual-children overlay
1075
+ return node.loaded ? typeLabel(node) : "string";
1076
+ }
1077
+ return k;
1078
+ }
1079
+
1080
+ /** Number of direct children, or 0 for a scalar (asks the kind, not the bytes). */
1081
+ function childCount(node: YNode): number {
1082
+ const k = nodeKind(node);
1083
+ if (k === "object") return Object.keys(node.value as Record<string, YNode>).length;
1084
+ if (k === "array") return (node.value as YNode[]).length;
1085
+ return 0;
1086
+ }
1087
+
1088
+ /**
1089
+ * Build the TOC subtree rooted at `node` (addressed by `segs`, shown as `label`)
1090
+ * down to `depth` levels of descendants. *Every* node is listed — scalar fields
1091
+ * and array elements included — so a leaf like `05-scalar-as-file` is clickable;
1092
+ * `hasChildren` says whether a node can be expanded further. Past `depth`,
1093
+ * `children` is left empty for the client to fetch lazily.
1094
+ */
1095
+ export function buildTree(node: YNode, segs: Seg[], label: string, depth: number): TreeNode {
1096
+ const container = isContainer(node);
1097
+ const out: TreeNode = {
1098
+ path: segsToStr(segs),
1099
+ label,
1100
+ type: tocType(node),
1101
+ format: node.format ?? null,
1102
+ concrete: node.concrete ?? null,
1103
+ // An unloaded lazy container (a directory not yet descended into) is assumed
1104
+ // expandable rather than read just to count — its children load on expand.
1105
+ hasChildren: container && (node.loaded ? childCount(node) > 0 : true),
1106
+ children: [],
1107
+ };
1108
+ if (container && depth > 0) {
1109
+ const k = nodeKind(node);
1110
+ if (k === "object") {
1111
+ for (const [key, c] of Object.entries(node.value as Record<string, YNode>))
1112
+ out.children.push(buildTree(c, [...segs, key], labelForSeg(c, key), depth - 1));
1113
+ } else {
1114
+ (node.value as YNode[]).forEach((c, i) => {
1115
+ out.children.push(buildTree(c, [...segs, i], labelForSeg(c, i), depth - 1));
1116
+ });
1117
+ }
1118
+ }
1119
+ return out;
1120
+ }
1121
+
1122
+ // --------------------------------------------------------------------------- //
1123
+ // Extra serializations used by the API
1124
+ // --------------------------------------------------------------------------- //
1125
+
1126
+ /** Read a binary leaf's bytes *on demand* and return a base64 payload. Used when
1127
+ * the binary node itself is the selection (we never read bytes just to list it). */
1128
+ export function binaryBase64(node: YNode): { format: string | null; size: number; base64: string } {
1129
+ const v = node.value;
1130
+ if (!isBinary(v)) throw new Error("not a binary node");
1131
+ const bytes = v.data ?? (node.path ? fs.readFileSync(node.path) : Buffer.alloc(0));
1132
+ return { format: v.fmt, size: v.size, base64: bytes.toString("base64") };
1133
+ }