@kenjura/ursa 0.85.0 → 0.87.1

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.
@@ -0,0 +1,542 @@
1
+ /**
2
+ * Incremental build graph for Ursa (Make/Shake/Salsa semantics).
3
+ *
4
+ * Every output is a derived node; a node recomputes if and only if one of its
5
+ * recorded input fingerprints changed. See docs/changes/serve-logic.md.
6
+ *
7
+ * Concepts:
8
+ * - Leaf nodes are created implicitly when a compute function calls
9
+ * `ctx.read(path)` (file leaf, content-hash fingerprint) or
10
+ * `ctx.exists(path)` (lookup leaf, "exists"/"absent" fingerprint).
11
+ * Lookup leaves make file *creation* an observable change: probing for a
12
+ * style.css that isn't there records an edge that dirties the subtree when
13
+ * the file appears.
14
+ * - Derived nodes are registered with `graph.node(id, async (ctx) => value)`.
15
+ * Edges are recorded fresh on every recompute (replace, not append), so
16
+ * dynamic dependencies — e.g. which template a doc's frontmatter selects —
17
+ * are always correct and can shrink.
18
+ * - Early cutoff: after a recompute, the node's own fingerprint is derived
19
+ * from its value. If it is unchanged, dependents' recorded fingerprints
20
+ * still match and propagation stops.
21
+ * - Verification is demand-driven and topological: verifying a node verifies
22
+ * its recorded dependencies first. `build(roots)` verifies roots in the
23
+ * given order, so callers can schedule client-viewed pages first.
24
+ * - Values live in memory only. Persistence stores {edges, fingerprints,
25
+ * leafStats}; after a restart a clean node is *verified* without recompute,
26
+ * and is only recomputed if a dependent actually demands its value.
27
+ *
28
+ * Compute functions should be deterministic. Non-determinism (timestamps,
29
+ * randomness) degrades to extra recomputes, never to staleness across passes.
30
+ * To force global invalidation on Ursa upgrades, wire a version string in as
31
+ * a leaf (e.g. ctx.read of package.json) rather than versioning nodes.
32
+ */
33
+
34
+ import { createHash } from "crypto";
35
+ import { existsSync } from "fs";
36
+ import { mkdir, readFile, stat, writeFile } from "fs/promises";
37
+ import { join } from "path";
38
+ import { getUrsaDir } from "../contentHash.js";
39
+
40
+ export const GRAPH_SCHEMA_VERSION = 1;
41
+ const GRAPH_FILE = "graph.json";
42
+
43
+ const FILE_PREFIX = "file:";
44
+ const LOOKUP_PREFIX = "lookup:";
45
+ const MISSING = "missing";
46
+
47
+ export function fileNodeId(path) {
48
+ return FILE_PREFIX + path;
49
+ }
50
+
51
+ export function lookupNodeId(path) {
52
+ return LOOKUP_PREFIX + path;
53
+ }
54
+
55
+ function isLeafId(id) {
56
+ return id.startsWith(FILE_PREFIX) || id.startsWith(LOOKUP_PREFIX);
57
+ }
58
+
59
+ function leafPath(id) {
60
+ return id.startsWith(FILE_PREFIX)
61
+ ? id.slice(FILE_PREFIX.length)
62
+ : id.slice(LOOKUP_PREFIX.length);
63
+ }
64
+
65
+ function hashBytes(data) {
66
+ return createHash("md5").update(data).digest("hex").substring(0, 16);
67
+ }
68
+
69
+ function defaultValueFingerprint(value) {
70
+ if (value === undefined) return "undefined";
71
+ if (typeof value === "string") return hashBytes(value);
72
+ return hashBytes(JSON.stringify(value) ?? "null");
73
+ }
74
+
75
+ /** Error thrown when a compute function fails; wraps the original error. */
76
+ export class GraphComputeError extends Error {
77
+ constructor(nodeId, cause) {
78
+ super(`Node "${nodeId}" failed: ${cause?.message ?? cause}`);
79
+ this.name = "GraphComputeError";
80
+ this.nodeId = nodeId;
81
+ this.cause = cause;
82
+ }
83
+ }
84
+
85
+ export class BuildGraph {
86
+ constructor() {
87
+ /** @type {Map<string, {fn: Function, fingerprint?: Function}>} derived node definitions */
88
+ this.fns = new Map();
89
+ /** @type {Map<string, any>} last computed values (in-memory only, not persisted) */
90
+ this.values = new Map();
91
+ /** @type {Map<string, string>} node id → current fingerprint (persisted) */
92
+ this.fingerprints = new Map();
93
+ /** @type {Map<string, Map<string, string>>} node id → (dep id → fingerprint recorded when read) (persisted) */
94
+ this.edges = new Map();
95
+ /** @type {Map<string, Set<string>>} dep id → dependent node ids (derived from edges) */
96
+ this.rdeps = new Map();
97
+ /** @type {Map<string, {size: number, mtimeMs: number}>} file leaf id → stat fast-path info (persisted) */
98
+ this.leafStats = new Map();
99
+ /** @type {Map<string, string>} node id → error message for nodes whose last compute threw */
100
+ this.failed = new Map();
101
+
102
+ /** @type {Set<string>} leaf ids (file: and lookup:) the watcher reported changed since last verification */
103
+ this._staleLeaves = new Set();
104
+ // Per-pass state
105
+ this._verified = null;
106
+ this._inProgress = null;
107
+ this._computedThisPass = new Set();
108
+ }
109
+
110
+ /**
111
+ * Define (or replace) a derived node.
112
+ * @param {string} id - Node id, by convention "kind:key" (e.g. "pageHtml:/abs/doc.md")
113
+ * @param {(ctx: {read: Function, exists: Function, get: Function}) => Promise<any>} fn
114
+ * @param {{fingerprint?: (value: any) => string}} [opts] - Custom value fingerprint
115
+ */
116
+ node(id, fn, opts = {}) {
117
+ if (isLeafId(id)) throw new Error(`Cannot define a derived node with a leaf id: ${id}`);
118
+ this.fns.set(id, { fn, fingerprint: opts.fingerprint });
119
+ }
120
+
121
+ hasNode(id) {
122
+ return this.fns.has(id);
123
+ }
124
+
125
+ /**
126
+ * Remove a derived node (e.g. its source document was deleted).
127
+ * Dependents holding a recorded edge to it will recompute on next verify.
128
+ */
129
+ removeNode(id) {
130
+ this.fns.delete(id);
131
+ this.values.delete(id);
132
+ this.fingerprints.delete(id);
133
+ this.failed.delete(id);
134
+ const deps = this.edges.get(id);
135
+ if (deps) {
136
+ for (const depId of deps.keys()) {
137
+ const set = this.rdeps.get(depId);
138
+ if (set) {
139
+ set.delete(id);
140
+ if (set.size === 0) this.rdeps.delete(depId);
141
+ }
142
+ }
143
+ this.edges.delete(id);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Drop persisted state for derived nodes not in keepIds (e.g. deleted
149
+ * documents), then drop leaves no longer referenced by any edge.
150
+ * @param {Set<string>} keepIds - Derived node ids that should survive
151
+ */
152
+ gc(keepIds) {
153
+ for (const id of [...this.edges.keys()]) {
154
+ if (!isLeafId(id) && !keepIds.has(id)) this.removeNode(id);
155
+ }
156
+ for (const id of [...this.fingerprints.keys()]) {
157
+ if (!isLeafId(id) && !keepIds.has(id)) this.removeNode(id);
158
+ }
159
+ // Orphaned leaves: no remaining dependents
160
+ for (const id of [...this.fingerprints.keys()]) {
161
+ if (isLeafId(id) && !this.rdeps.has(id)) {
162
+ this.fingerprints.delete(id);
163
+ this.leafStats.delete(id);
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Watcher hook: mark a path's leaves (file + lookup) as needing a re-stat
170
+ * before they are next trusted.
171
+ * @param {string} path - Absolute path reported by the file watcher
172
+ */
173
+ invalidatePath(path) {
174
+ this._staleLeaves.add(fileNodeId(path));
175
+ this._staleLeaves.add(lookupNodeId(path));
176
+ }
177
+
178
+ /**
179
+ * Re-stat every known leaf (warm start). Uses the size+mtime fast path and
180
+ * only re-hashes content when the stat changed. Returns leaf ids whose
181
+ * fingerprint actually changed.
182
+ * @returns {Promise<string[]>}
183
+ */
184
+ async scanLeaves() {
185
+ const changed = [];
186
+ for (const id of [...this.fingerprints.keys()]) {
187
+ if (!isLeafId(id)) continue;
188
+ const before = this.fingerprints.get(id);
189
+ await this._refreshLeaf(id, { force: true });
190
+ if (this.fingerprints.get(id) !== before) changed.push(id);
191
+ }
192
+ this._staleLeaves.clear();
193
+ return changed;
194
+ }
195
+
196
+ /**
197
+ * Bring the given nodes (and everything they depend on) up to date, in
198
+ * order — schedule client-viewed pages first for priority regeneration.
199
+ * A node whose compute function throws is marked failed (and retried next
200
+ * pass) without corrupting the rest of the graph.
201
+ * @param {string[]} rootIds
202
+ * @returns {Promise<{ok: boolean, results: Map<string, any>, errors: Map<string, Error>, computed: Set<string>}>}
203
+ */
204
+ async build(rootIds) {
205
+ this._verified = new Set();
206
+ this._inProgress = new Set();
207
+ this._computedThisPass = new Set();
208
+ const results = new Map();
209
+ const errors = new Map();
210
+ for (const id of rootIds) {
211
+ try {
212
+ await this._verify(id);
213
+ results.set(id, this.values.get(id));
214
+ } catch (e) {
215
+ errors.set(id, e);
216
+ }
217
+ }
218
+ return { ok: errors.size === 0, results, errors, computed: this._computedThisPass };
219
+ }
220
+
221
+ /**
222
+ * Verify a node and return its value, recomputing if the value is not in
223
+ * memory (e.g. after a restart). Usable standalone or during a build pass.
224
+ * @param {string} id
225
+ */
226
+ async demand(id) {
227
+ if (!this._verified) {
228
+ this._verified = new Set();
229
+ this._inProgress = new Set();
230
+ this._computedThisPass = new Set();
231
+ }
232
+ await this._verify(id);
233
+ if (!this.values.has(id) && this.fns.has(id)) {
234
+ await this._compute(id);
235
+ }
236
+ return this.values.get(id);
237
+ }
238
+
239
+ // -------------------------------------------------------------------------
240
+ // Internals
241
+ // -------------------------------------------------------------------------
242
+
243
+ /**
244
+ * Ensure a node is up to date: verify its recorded deps (topologically),
245
+ * recompute when any recorded input fingerprint differs from current.
246
+ */
247
+ async _verify(id) {
248
+ if (this._verified.has(id)) return;
249
+ if (this._inProgress.has(id)) {
250
+ throw new Error(`Dependency cycle detected at "${id}"`);
251
+ }
252
+
253
+ if (isLeafId(id)) {
254
+ await this._refreshLeaf(id);
255
+ this._verified.add(id);
256
+ return;
257
+ }
258
+
259
+ const def = this.fns.get(id);
260
+ if (!def) throw new Error(`Unknown node: "${id}"`);
261
+
262
+ this._inProgress.add(id);
263
+ try {
264
+ let needsCompute = false;
265
+ const deps = this.edges.get(id);
266
+ if (!this.fingerprints.has(id) || this.failed.has(id) || !deps) {
267
+ // Never computed, failed last time (retry), or no recorded inputs
268
+ needsCompute = true;
269
+ } else {
270
+ for (const [depId, recordedFp] of deps) {
271
+ if (!isLeafId(depId) && !this.fns.has(depId)) {
272
+ // Recorded dep no longer defined — recompute to rediscover deps
273
+ needsCompute = true;
274
+ break;
275
+ }
276
+ await this._verify(depId);
277
+ if (this.fingerprints.get(depId) !== recordedFp) {
278
+ needsCompute = true;
279
+ break;
280
+ }
281
+ }
282
+ }
283
+ if (needsCompute) {
284
+ await this._compute(id);
285
+ }
286
+ this._verified.add(id);
287
+ } finally {
288
+ this._inProgress.delete(id);
289
+ }
290
+ }
291
+
292
+ /** Run a node's compute function, recording fresh edges as inputs are consumed. */
293
+ async _compute(id) {
294
+ const def = this.fns.get(id);
295
+ if (!def) throw new Error(`Unknown node: "${id}"`);
296
+ const depMap = new Map();
297
+ const ctx = this._makeCtx(depMap);
298
+
299
+ let value;
300
+ try {
301
+ value = await def.fn(ctx);
302
+ } catch (e) {
303
+ // Mark failed; keep previous edges/fingerprint intact so the graph
304
+ // is not corrupted. The node is retried on the next pass.
305
+ this.failed.set(id, String(e?.message ?? e));
306
+ throw e instanceof GraphComputeError ? e : new GraphComputeError(id, e);
307
+ }
308
+
309
+ this.failed.delete(id);
310
+ this._setEdges(id, depMap);
311
+ this.values.set(id, value);
312
+ const oldFp = this.fingerprints.get(id);
313
+ const newFp = (def.fingerprint ?? defaultValueFingerprint)(value);
314
+ this.fingerprints.set(id, newFp);
315
+ this._computedThisPass.add(id);
316
+ if (oldFp !== undefined && oldFp !== newFp) {
317
+ // Fingerprint moved mid-pass (normally only when an input changed):
318
+ // anything already verified that depends on this must be re-checked.
319
+ this._unverifyDependents(id);
320
+ }
321
+ return value;
322
+ }
323
+
324
+ /** Compute context handed to node functions; records edges as they are consumed. */
325
+ _makeCtx(depMap) {
326
+ const graph = this;
327
+ return {
328
+ /** Read a file, recording a file-leaf dependency. Throws if missing (the miss is still recorded). */
329
+ async read(path) {
330
+ const id = fileNodeId(path);
331
+ let content;
332
+ try {
333
+ const [st, buf] = await Promise.all([stat(path), readFile(path)]);
334
+ graph.leafStats.set(id, { size: st.size, mtimeMs: st.mtimeMs });
335
+ graph.fingerprints.set(id, hashBytes(buf));
336
+ content = buf.toString("utf8");
337
+ } catch (e) {
338
+ graph.leafStats.delete(id);
339
+ graph.fingerprints.set(id, MISSING);
340
+ graph._markLeafFresh(id);
341
+ depMap.set(id, MISSING);
342
+ throw e;
343
+ }
344
+ graph._markLeafFresh(id);
345
+ depMap.set(id, graph.fingerprints.get(id));
346
+ return content;
347
+ },
348
+ /** Probe for a file's existence, recording a lookup-leaf dependency. */
349
+ exists(path) {
350
+ const id = lookupNodeId(path);
351
+ const fp = existsSync(path) ? "exists" : "absent";
352
+ graph.fingerprints.set(id, fp);
353
+ graph._markLeafFresh(id);
354
+ depMap.set(id, fp);
355
+ return fp === "exists";
356
+ },
357
+ /** Get another node's value, recording a derived dependency. */
358
+ async get(otherId) {
359
+ await graph._verify(otherId);
360
+ if (!graph.values.has(otherId) && graph.fns.has(otherId)) {
361
+ // Clean but value not in memory (restart) — recompute on demand
362
+ await graph._compute(otherId);
363
+ }
364
+ depMap.set(otherId, graph.fingerprints.get(otherId));
365
+ return graph.values.get(otherId);
366
+ },
367
+ };
368
+ }
369
+
370
+ _markLeafFresh(id) {
371
+ if (this._verified) this._verified.add(id);
372
+ this._staleLeaves.delete(id);
373
+ }
374
+
375
+ /** Replace a node's recorded edges (deps can shrink), keeping rdeps in sync. */
376
+ _setEdges(id, depMap) {
377
+ const old = this.edges.get(id);
378
+ if (old) {
379
+ for (const depId of old.keys()) {
380
+ if (!depMap.has(depId)) {
381
+ const set = this.rdeps.get(depId);
382
+ if (set) {
383
+ set.delete(id);
384
+ if (set.size === 0) this.rdeps.delete(depId);
385
+ }
386
+ }
387
+ }
388
+ }
389
+ for (const depId of depMap.keys()) {
390
+ if (!this.rdeps.has(depId)) this.rdeps.set(depId, new Set());
391
+ this.rdeps.get(depId).add(id);
392
+ }
393
+ this.edges.set(id, depMap);
394
+ }
395
+
396
+ /** Remove a node's transitive dependents from this pass's verified set. */
397
+ _unverifyDependents(id) {
398
+ const stack = [id];
399
+ const seen = new Set();
400
+ while (stack.length > 0) {
401
+ const cur = stack.pop();
402
+ const dependents = this.rdeps.get(cur);
403
+ if (!dependents) continue;
404
+ for (const dep of dependents) {
405
+ if (seen.has(dep)) continue;
406
+ seen.add(dep);
407
+ this._verified.delete(dep);
408
+ stack.push(dep);
409
+ }
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Refresh a leaf's fingerprint. File leaves use the size+mtime fast path
415
+ * and re-hash content only on stat mismatch; lookup leaves re-probe
416
+ * existence. Unless forced (scanLeaves) or flagged stale (invalidatePath),
417
+ * an already-known leaf is trusted.
418
+ */
419
+ async _refreshLeaf(id, { force = false } = {}) {
420
+ const path = leafPath(id);
421
+ const known = this.fingerprints.has(id);
422
+ if (known && !force && !this._staleLeaves.has(id)) return;
423
+ this._staleLeaves.delete(id);
424
+
425
+ if (id.startsWith(LOOKUP_PREFIX)) {
426
+ this.fingerprints.set(id, existsSync(path) ? "exists" : "absent");
427
+ return;
428
+ }
429
+
430
+ let st;
431
+ try {
432
+ st = await stat(path);
433
+ } catch {
434
+ st = null;
435
+ }
436
+ if (!st) {
437
+ this.leafStats.delete(id);
438
+ this.fingerprints.set(id, MISSING);
439
+ return;
440
+ }
441
+ const prev = this.leafStats.get(id);
442
+ if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs && known) {
443
+ return; // fast path: stat unchanged, trust existing content hash
444
+ }
445
+ const buf = await readFile(path);
446
+ this.leafStats.set(id, { size: st.size, mtimeMs: st.mtimeMs });
447
+ this.fingerprints.set(id, hashBytes(buf));
448
+ }
449
+
450
+ // -------------------------------------------------------------------------
451
+ // Persistence
452
+ // -------------------------------------------------------------------------
453
+
454
+ /** Serialize {edges, fingerprints, leafStats} for .ursa/graph.json. */
455
+ serialize() {
456
+ return {
457
+ version: GRAPH_SCHEMA_VERSION,
458
+ fingerprints: Object.fromEntries(this.fingerprints),
459
+ edges: Object.fromEntries(
460
+ [...this.edges].map(([id, deps]) => [id, Object.fromEntries(deps)])
461
+ ),
462
+ leafStats: Object.fromEntries(this.leafStats),
463
+ };
464
+ }
465
+
466
+ /**
467
+ * Load persisted state. Returns false (leaving the graph empty for a clean
468
+ * pass) when the schema version is stale or the data is malformed.
469
+ * All leaves are marked stale so the first pass re-stats them — call
470
+ * scanLeaves() to do this eagerly on warm start.
471
+ * @param {object} data - Previously serialized graph
472
+ * @returns {boolean} Whether the data was loaded
473
+ */
474
+ load(data) {
475
+ if (!data || data.version !== GRAPH_SCHEMA_VERSION) return false;
476
+ try {
477
+ this.fingerprints = new Map(Object.entries(data.fingerprints ?? {}));
478
+ this.leafStats = new Map(Object.entries(data.leafStats ?? {}));
479
+ this.edges = new Map();
480
+ this.rdeps = new Map();
481
+ for (const [id, deps] of Object.entries(data.edges ?? {})) {
482
+ this._setEdges(id, new Map(Object.entries(deps)));
483
+ }
484
+ for (const id of this.fingerprints.keys()) {
485
+ if (isLeafId(id)) this._staleLeaves.add(id);
486
+ }
487
+ return true;
488
+ } catch {
489
+ this.fingerprints = new Map();
490
+ this.leafStats = new Map();
491
+ this.edges = new Map();
492
+ this.rdeps = new Map();
493
+ return false;
494
+ }
495
+ }
496
+
497
+ /** Stats for logging. */
498
+ getStats() {
499
+ let leaves = 0;
500
+ for (const id of this.fingerprints.keys()) {
501
+ if (isLeafId(id)) leaves++;
502
+ }
503
+ return {
504
+ derivedNodes: this.edges.size,
505
+ leaves,
506
+ edges: [...this.edges.values()].reduce((sum, m) => sum + m.size, 0),
507
+ failed: this.failed.size,
508
+ };
509
+ }
510
+ }
511
+
512
+ /** Path to the persisted graph for a source directory. */
513
+ export function getGraphPath(sourceDir) {
514
+ return join(getUrsaDir(sourceDir), GRAPH_FILE);
515
+ }
516
+
517
+ /**
518
+ * Load a persisted graph from .ursa/graph.json into the given BuildGraph.
519
+ * @returns {Promise<boolean>} Whether a valid graph was loaded
520
+ */
521
+ export async function loadGraph(sourceDir, graph) {
522
+ try {
523
+ if (!existsSync(getGraphPath(sourceDir))) return false;
524
+ const data = JSON.parse(await readFile(getGraphPath(sourceDir), "utf8"));
525
+ return graph.load(data);
526
+ } catch (e) {
527
+ console.warn(`Could not load build graph: ${e.message}`);
528
+ return false;
529
+ }
530
+ }
531
+
532
+ /** Persist a graph to .ursa/graph.json. */
533
+ export async function saveGraph(sourceDir, graph) {
534
+ try {
535
+ await mkdir(getUrsaDir(sourceDir), { recursive: true });
536
+ await writeFile(getGraphPath(sourceDir), JSON.stringify(graph.serialize()));
537
+ return true;
538
+ } catch (e) {
539
+ console.warn(`Could not save build graph: ${e.message}`);
540
+ return false;
541
+ }
542
+ }
@@ -10,4 +10,5 @@ export * from './templates.js';
10
10
  export * from './menu.js';
11
11
  export * from './metadata.js';
12
12
  export * from './footer.js';
13
+ export * from './ursaMetadata.js';
13
14
  export * from './autoIndex.js';
@@ -0,0 +1,62 @@
1
+ // Helper for building the _ursa_metadata field embedded in generated JSON files
2
+ import { existsSync } from "fs";
3
+ import { readFile } from "fs/promises";
4
+ import { dirname, join, resolve } from "path";
5
+ import { URL } from "url";
6
+
7
+ /**
8
+ * Read the ursa version from ursa's own package.json
9
+ * @returns {Promise<string>} The ursa version, or 'unknown' if it can't be read
10
+ */
11
+ async function getUrsaVersion() {
12
+ try {
13
+ // From src/helper/build/ursaMetadata.js, go up to the package root
14
+ const currentDir = dirname(new URL(import.meta.url).pathname);
15
+ const ursaPackagePath = resolve(currentDir, "..", "..", "..", "package.json");
16
+ if (existsSync(ursaPackagePath)) {
17
+ const ursaPackage = JSON.parse(await readFile(ursaPackagePath, "utf8"));
18
+ if (ursaPackage.version) return ursaPackage.version;
19
+ }
20
+ } catch (e) {
21
+ console.error(`Error reading ursa package.json: ${e.message}`);
22
+ }
23
+ return "unknown";
24
+ }
25
+
26
+ /**
27
+ * Read the documentation repo version from its package.json.
28
+ * Checks the source dir itself, then one level up (if docs is a subfolder).
29
+ * @param {string} _source - original source path
30
+ * @returns {Promise<string>} The doc repo version, or 'unknown' if it can't be read
31
+ */
32
+ async function getDocVersion(_source) {
33
+ const sourceDir = resolve(_source);
34
+ const packagePaths = [
35
+ join(sourceDir, "package.json"),
36
+ join(sourceDir, "..", "package.json"),
37
+ ];
38
+ for (const packagePath of packagePaths) {
39
+ try {
40
+ if (existsSync(packagePath)) {
41
+ const docPackage = JSON.parse(await readFile(packagePath, "utf8"));
42
+ if (docPackage.version) return docPackage.version;
43
+ }
44
+ } catch (e) {
45
+ // Continue to next path
46
+ }
47
+ }
48
+ return "unknown";
49
+ }
50
+
51
+ /**
52
+ * Build the _ursa_metadata object that is embedded in every generated JSON file.
53
+ * @param {string} _source - original source path of the documentation repo
54
+ * @returns {Promise<{ursaVersion: string, docVersion: string}>}
55
+ */
56
+ export async function getUrsaMetadata(_source) {
57
+ const [ursaVersion, docVersion] = await Promise.all([
58
+ getUrsaVersion(),
59
+ getDocVersion(_source),
60
+ ]);
61
+ return { ursaVersion, docVersion };
62
+ }