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.
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/bin/yamlover.js +202 -0
- package/dist/server.js +4681 -0
- package/index.html +12 -0
- package/package.json +72 -0
- package/src/client/App.tsx +372 -0
- package/src/client/NodeView.tsx +422 -0
- package/src/client/TaskStrip.tsx +34 -0
- package/src/client/Tree.tsx +97 -0
- package/src/client/api.ts +186 -0
- package/src/client/icons.ts +91 -0
- package/src/client/links.tsx +108 -0
- package/src/client/live.ts +42 -0
- package/src/client/main.tsx +10 -0
- package/src/client/paste-html.ts +228 -0
- package/src/client/paste-links.ts +42 -0
- package/src/client/paths.ts +109 -0
- package/src/client/render.tsx +326 -0
- package/src/client/renderers/annotate.tsx +507 -0
- package/src/client/renderers/asciidoc.tsx +35 -0
- package/src/client/renderers/chapter.tsx +138 -0
- package/src/client/renderers/csv.tsx +233 -0
- package/src/client/renderers/decoded.tsx +72 -0
- package/src/client/renderers/djvu.tsx +97 -0
- package/src/client/renderers/doc.tsx +40 -0
- package/src/client/renderers/docx.tsx +49 -0
- package/src/client/renderers/epub.tsx +147 -0
- package/src/client/renderers/explorer.tsx +209 -0
- package/src/client/renderers/fb2.tsx +149 -0
- package/src/client/renderers/headings.ts +69 -0
- package/src/client/renderers/heic.tsx +23 -0
- package/src/client/renderers/imagemap.tsx +157 -0
- package/src/client/renderers/kml.ts +46 -0
- package/src/client/renderers/latex.tsx +36 -0
- package/src/client/renderers/map.tsx +205 -0
- package/src/client/renderers/marklower.tsx +119 -0
- package/src/client/renderers/markup.tsx +64 -0
- package/src/client/renderers/media.tsx +19 -0
- package/src/client/renderers/panzoom.ts +101 -0
- package/src/client/renderers/pdf.tsx +176 -0
- package/src/client/renderers/plaintext.tsx +120 -0
- package/src/client/renderers/plantuml.tsx +82 -0
- package/src/client/renderers/psd.tsx +25 -0
- package/src/client/renderers/registry.tsx +389 -0
- package/src/client/renderers/rtf.tsx +210 -0
- package/src/client/renderers/spreadsheet.tsx +105 -0
- package/src/client/renderers/tag.tsx +113 -0
- package/src/client/renderers/text.tsx +41 -0
- package/src/client/renderers/tiff.tsx +33 -0
- package/src/client/styles.css +1115 -0
- package/src/client/vendor/README.md +30 -0
- package/src/client/vendor/djvu.js +15535 -0
- package/src/client/vite-env.d.ts +31 -0
- package/src/server/api.ts +147 -0
- package/src/server/engine-api.ts +1442 -0
- package/src/server/gitignore.ts +81 -0
- package/src/server/node-kind.ts +48 -0
- package/src/server/tasks.ts +83 -0
- 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
|
+
}
|