omkit 0.8.9 → 0.8.11

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.
@@ -3,7 +3,7 @@ import type { ActionRef } from "../foundation/ActionRef.ts";
3
3
  import type { LogStore } from "../output/log/LogStore.ts";
4
4
  import type { SnapshotStore } from "../output/snapshot/SnapshotStore.ts";
5
5
  import type { ArtifactStore } from "../output/artifact/ArtifactStore.ts";
6
- import type { Activity, Exec, InstanceEvents, NodeStatus } from "./types.ts";
6
+ import type { ActionMeta, Activity, Exec, InstanceEvents, NodeStatus } from "./types.ts";
7
7
  /** Everything a node needs to exist, minus its behavior. */
8
8
  export interface NodeInit {
9
9
  readonly store: LogStore;
@@ -45,6 +45,8 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
45
45
  readonly path: string;
46
46
  readonly parentId: string | null;
47
47
  readonly tags: string[];
48
+ /** What the definition said about itself (`.describe()`, `.mcp()`, `.args()`); for the record. */
49
+ meta: ActionMeta | undefined;
48
50
  /** The captured args this node ran with (for the log header); set at run start. */
49
51
  args: readonly unknown[];
50
52
  readonly children: ActionRun[];
@@ -18,6 +18,8 @@ export class ActionRun {
18
18
  path;
19
19
  parentId;
20
20
  tags = [];
21
+ /** What the definition said about itself (`.describe()`, `.mcp()`, `.args()`); for the record. */
22
+ meta;
21
23
  /** The captured args this node ran with (for the log header); set at run start. */
22
24
  args = [];
23
25
  children = [];
@@ -22,6 +22,8 @@ export declare class ExecutionTree {
22
22
  readonly store: LogStore;
23
23
  readonly folder: RunFolder;
24
24
  readonly root: ActionRun;
25
+ /** The om's name, as given to `om(name)`. */
26
+ readonly name: string;
25
27
  /**
26
28
  * The om's `.describe(…)` summary, carried here from the builder so it survives the
27
29
  * terminal `.run(...)` call. Stored so it is retrievable; nothing reads it yet.
@@ -39,6 +41,7 @@ export declare class ExecutionTree {
39
41
  private readonly snapshotStore;
40
42
  private readonly artifactStore;
41
43
  private readonly registry;
44
+ private readonly schemaCache;
42
45
  private readonly unsettled;
43
46
  private readonly originalLog;
44
47
  private streaming;
@@ -99,6 +102,12 @@ export declare class ExecutionTree {
99
102
  private printSummary;
100
103
  private verdictOk;
101
104
  private nodeView;
105
+ /**
106
+ * The node's self-description, projected for the record. The root's comes from the om
107
+ * builder; a child's from its action definition. The args schema is converted here, once per
108
+ * definition, so a step launched a thousand times converts its schema once.
109
+ */
110
+ private metaView;
102
111
  /** Test seam: the projected run view (same object written to result.json). */
103
112
  runViewForTest(): RunView;
104
113
  /** Test seam: the run's curated artifacts. */
@@ -122,6 +131,8 @@ export declare class ExecutionTree {
122
131
  * artifact name is free-form, so any printable separator can be forged inside a key.
123
132
  */
124
133
  private curatedArtifacts;
134
+ /** {@link curatedArtifacts}, each still carrying the node that registered it (for the report). */
135
+ private curatedArtifactsWithNode;
125
136
  /**
126
137
  * Absolute `.log` path for a node — root is `main.log`, children drop the `main/`
127
138
  * prefix. One directory level per ancestor, mirroring the tree.
@@ -13,6 +13,7 @@ import { LiveRenderer } from "../output/LiveRenderer.js";
13
13
  import { writeNodeLogs } from "../output/writers/NodeLogWriter.js";
14
14
  import { writeRollup } from "../output/writers/RollupWriter.js";
15
15
  import { writeResult } from "../output/writers/ResultWriter.js";
16
+ import { writeReport } from "../output/writers/ReportWriter.js";
16
17
  import { ActionRun } from "./ActionRun.js";
17
18
  import { procRegistry } from "../system/proc.js";
18
19
  import { currentNode } from "./context.js";
@@ -41,6 +42,8 @@ export class ExecutionTree {
41
42
  store = new LogStore();
42
43
  folder;
43
44
  root;
45
+ /** The om's name, as given to `om(name)`. */
46
+ name;
44
47
  /**
45
48
  * The om's `.describe(…)` summary, carried here from the builder so it survives the
46
49
  * terminal `.run(...)` call. Stored so it is retrievable; nothing reads it yet.
@@ -58,6 +61,7 @@ export class ExecutionTree {
58
61
  snapshotStore;
59
62
  artifactStore = new ArtifactStore();
60
63
  registry = [];
64
+ schemaCache = new Map();
61
65
  unsettled = new Set();
62
66
  originalLog = console.log.bind(console);
63
67
  streaming;
@@ -84,6 +88,7 @@ export class ExecutionTree {
84
88
  onUncaught = (e) => this.onFatal("uncaughtException", e);
85
89
  onUnhandled = (e) => this.onFatal("unhandledRejection", e);
86
90
  constructor(name, definedAt, description, mcp) {
91
+ this.name = name;
87
92
  this.description = description;
88
93
  this.mcp = mcp;
89
94
  // The run's identity: its name + where om() is written (not how it was launched),
@@ -133,6 +138,7 @@ export class ExecutionTree {
133
138
  // into the parent's log, attributed to this child's id.
134
139
  bubble: (message) => this.append(parent, "child", id, message),
135
140
  });
141
+ node.meta = spec.meta;
136
142
  parent.children.push(node);
137
143
  this.registry.push(node);
138
144
  // Launch reference — the parent's pointer to the child's own (absolute) log, plus where
@@ -307,7 +313,22 @@ export class ExecutionTree {
307
313
  await writeRollup(at("asserts.log"), flat, entries, (e) => e.level === "assert", assertSummary);
308
314
  await writeRollup(at("snapshots.log"), flat, entries, (e) => e.level === "snapshot");
309
315
  await writeRollup(at("artifacts.log"), flat, entries, (e) => e.level === "artifact");
310
- await writeResult(at("result.json"), this.runView());
316
+ const run = this.runView();
317
+ await writeResult(at("result.json"), run);
318
+ // The page is a convenience over the record above — a failure to build it must not
319
+ // cost the run its verdict, its exit code, or its other files.
320
+ try {
321
+ await writeReport(at("report.html"), {
322
+ name: this.name,
323
+ folder: this.folder.path(),
324
+ run,
325
+ entries,
326
+ artifacts: this.curatedArtifactsWithNode(),
327
+ });
328
+ }
329
+ catch (e) {
330
+ this.originalLog(`omkit: report.html not written — ${e instanceof Error ? e.message : String(e)}`);
331
+ }
311
332
  // Only now let go of the process hooks — a Ctrl+C during the writes must still be
312
333
  // caught (as a no-op) so it can't fall through to Node's kill-without-log default.
313
334
  process.off("SIGINT", this.onSigint);
@@ -338,6 +359,7 @@ export class ExecutionTree {
338
359
  ` asserts → ${at("asserts.log")} ${assertSummary}`,
339
360
  ` snapshots → ${at("snapshots.log")}`,
340
361
  ` artifacts → ${at("artifacts.log")}`,
362
+ ` report → ${at("report.html")}`,
341
363
  ];
342
364
  }
343
365
  /** The recap to the terminal (files are already written). */
@@ -366,9 +388,34 @@ export class ExecutionTree {
366
388
  endedAt: node.endedAt,
367
389
  duration: node.duration,
368
390
  logFile: this.logFile(node.path),
391
+ ...this.metaView(node),
369
392
  children: node.children.map((child) => this.nodeView(child)),
370
393
  };
371
394
  }
395
+ /**
396
+ * The node's self-description, projected for the record. The root's comes from the om
397
+ * builder; a child's from its action definition. The args schema is converted here, once per
398
+ * definition, so a step launched a thousand times converts its schema once.
399
+ */
400
+ metaView(node) {
401
+ const meta = node.parentId === null
402
+ ? { description: this.description, mcp: this.mcp, describeArgs: node.meta?.describeArgs }
403
+ : node.meta;
404
+ if (!meta)
405
+ return {};
406
+ let inputSchema;
407
+ if (meta.describeArgs) {
408
+ if (!this.schemaCache.has(meta.describeArgs)) {
409
+ this.schemaCache.set(meta.describeArgs, meta.describeArgs().inputSchema);
410
+ }
411
+ inputSchema = this.schemaCache.get(meta.describeArgs);
412
+ }
413
+ return {
414
+ ...(meta.description === undefined ? {} : { description: { ...meta.description } }),
415
+ ...(meta.mcp === undefined ? {} : { mcp: { ...meta.mcp } }),
416
+ ...(inputSchema === undefined ? {} : { inputSchema }),
417
+ };
418
+ }
372
419
  /** Test seam: the projected run view (same object written to result.json). */
373
420
  runViewForTest() {
374
421
  return this.runView();
@@ -412,6 +459,10 @@ export class ExecutionTree {
412
459
  * artifact name is free-form, so any printable separator can be forged inside a key.
413
460
  */
414
461
  curatedArtifacts() {
462
+ return this.curatedArtifactsWithNode().map(({ nodeId: _nodeId, ...view }) => view);
463
+ }
464
+ /** {@link curatedArtifacts}, each still carrying the node that registered it (for the report). */
465
+ curatedArtifactsWithNode() {
415
466
  const byKey = new Map();
416
467
  for (const { name, file, description, mime, nodeId } of this.artifactStore.all()) {
417
468
  byKey.set(`${nodeId}\0${name}`, {
@@ -419,6 +470,7 @@ export class ExecutionTree {
419
470
  file,
420
471
  mime,
421
472
  ...(description === undefined ? {} : { description }),
473
+ nodeId,
422
474
  });
423
475
  }
424
476
  return [...byKey.values()];
@@ -49,9 +49,13 @@ class Builder {
49
49
  // No `.args()` on this path, so there is nothing to describe — or to resolve against.
50
50
  const describeArgs = () => describeSchema(undefined);
51
51
  const parseArgs = (supplied) => supplied;
52
+ const meta = {
53
+ ...(description === undefined ? {} : { description }),
54
+ ...(mcp === undefined ? {} : { mcp }),
55
+ };
52
56
  const exec = body;
53
57
  const create = (...args) => {
54
- const spec = { name, args, tags: [], body: exec, definedAt };
58
+ const spec = { name, args, tags: [], body: exec, definedAt, meta };
55
59
  return ExecutionTree.require().launch(spec);
56
60
  };
57
61
  return Object.assign(create, {
@@ -102,6 +106,11 @@ class ArgsBuilder {
102
106
  // that built it — rather than in whichever copy the discovery child is running.
103
107
  const schema = this.schema;
104
108
  const describeArgs = () => describeSchema(schema);
109
+ const meta = {
110
+ ...(description === undefined ? {} : { description }),
111
+ ...(mcp === undefined ? {} : { mcp }),
112
+ describeArgs,
113
+ };
105
114
  // `safeParse` is what applies `.default()`s, so this is where an advertised default
106
115
  // becomes the effective one. A refusal names the action and every failing field.
107
116
  const parseArgs = (supplied) => {
@@ -112,7 +121,7 @@ class ArgsBuilder {
112
121
  };
113
122
  const exec = body;
114
123
  const create = (...args) => {
115
- const spec = { name, args, tags: [], body: exec, definedAt };
124
+ const spec = { name, args, tags: [], body: exec, definedAt, meta };
116
125
  return ExecutionTree.require().launch(spec);
117
126
  };
118
127
  return Object.assign(create, {
package/dist/core/om.js CHANGED
@@ -37,6 +37,9 @@ function launch(name, site, description, exposure, body, schema) {
37
37
  }
38
38
  const tree = new ExecutionTree(name, site, description, exposure);
39
39
  ExecutionTree.current = tree;
40
+ // The root's description and exposure live on the tree; its schema rides on the node.
41
+ if (schema !== undefined)
42
+ tree.root.meta = { describeArgs: () => describeSchema(schema) };
40
43
  const rootBody = (ctx) => body({
41
44
  signal: ctx.signal,
42
45
  logs: ctx.logs,
@@ -239,4 +239,17 @@ export interface LaunchSpec {
239
239
  readonly body: Exec<object, unknown, unknown>;
240
240
  /** `file:line` where the action was defined; carried onto the node for its log header. */
241
241
  readonly definedAt: string | undefined;
242
+ /** What the definition declared about itself — carried onto the node for the run record. */
243
+ readonly meta?: ActionMeta;
244
+ }
245
+ /**
246
+ * A definition's self-description, as it travels onto each node it launches: what
247
+ * `.describe()` and `.mcp()` said, and the `.args()` schema as JSON Schema. Read only by the
248
+ * run record (`result.json`, `report.html`) — the runtime never branches on it.
249
+ */
250
+ export interface ActionMeta {
251
+ readonly description?: OmDescription;
252
+ readonly mcp?: ResolvedMcpExposure;
253
+ /** Converts the declared args schema; called once, at finalize. */
254
+ readonly describeArgs?: () => DescribedArgs;
242
255
  }
@@ -0,0 +1,16 @@
1
+ import type { LogEntry } from "../../foundation/LogEntry.ts";
2
+ import type { ArtifactView, RunView } from "./views.ts";
3
+ export interface ReportInput {
4
+ /** The om's name, as given to `om(name)`. */
5
+ readonly name: string;
6
+ /** The run folder (absolute). */
7
+ readonly folder: string;
8
+ readonly run: RunView;
9
+ readonly entries: readonly LogEntry[];
10
+ /** The curated artifacts, as in `run.artifacts`, with the node that registered each. */
11
+ readonly artifacts: ReadonlyArray<ArtifactView & {
12
+ readonly nodeId: string;
13
+ }>;
14
+ }
15
+ /** Writes `report.html` for a finished run. */
16
+ export declare function writeReport(file: string, input: ReportInput): Promise<void>;
@@ -0,0 +1,275 @@
1
+ import { readFile, readdir, stat, writeFile } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ /**
6
+ * `report.html` — the run as one self-contained page: the run view, every log entry, the
7
+ * snapshot payloads, the run's images (as base64), d3, and the viewer itself, all inlined so
8
+ * the file opens straight from disk, survives being zipped or attached, and needs no server.
9
+ *
10
+ * Heavy by design. Images are kept out of the main JSON — each sits in its own inert
11
+ * `<script type="text/plain">` block the viewer decodes only when it shows that image — so the
12
+ * page's one `JSON.parse` stays proportional to the log, not to the screenshots. The viewer
13
+ * paints a loader first and swaps it out once the data is parsed.
14
+ */
15
+ /** Snapshot payloads larger than this are embedded as truncated text, not parsed JSON. */
16
+ const SNAPSHOT_CAP = 2 * 1024 * 1024;
17
+ /** A single image larger than this is listed but not embedded. */
18
+ const IMAGE_CAP = 25 * 1024 * 1024;
19
+ /** Images stop being embedded once this many bytes are in the page. */
20
+ const IMAGES_TOTAL_CAP = 200 * 1024 * 1024;
21
+ /** Text artifacts (logs, json, md, …) are embedded up to this size. */
22
+ const TEXT_CAP = 1024 * 1024;
23
+ const IMAGE_MIME = {
24
+ ".png": "image/png",
25
+ ".jpg": "image/jpeg",
26
+ ".jpeg": "image/jpeg",
27
+ ".gif": "image/gif",
28
+ ".webp": "image/webp",
29
+ ".svg": "image/svg+xml",
30
+ };
31
+ /** Writes `report.html` for a finished run. */
32
+ export async function writeReport(file, input) {
33
+ const assets = [];
34
+ const files = await collectFiles(input, assets);
35
+ const snapshots = await collectSnapshots(input);
36
+ const root = await projectRoot(input.folder);
37
+ const payload = {
38
+ version: 1,
39
+ name: input.name,
40
+ folder: input.folder,
41
+ projectRoot: root,
42
+ sources: definitionSources(input.run.root, root),
43
+ generatedAt: Date.now(),
44
+ run: input.run,
45
+ entries: input.entries,
46
+ snapshots,
47
+ files,
48
+ };
49
+ const [d3, css, client] = await Promise.all([
50
+ readFile(d3Path(), "utf8"),
51
+ readFile(assetPath("report.css"), "utf8"),
52
+ readFile(assetPath("report.js"), "utf8"),
53
+ ]);
54
+ await writeFile(file, renderPage(input, payload, assets, { d3, css, client }), "utf8");
55
+ }
56
+ function renderPage(input, payload, assets, code) {
57
+ const title = `Run: ${input.name}`;
58
+ const blobs = assets
59
+ .map((a) => `<script type="text/plain" id="${a.id}" data-mime="${a.mime}">${a.data}</script>`)
60
+ .join("\n");
61
+ return `<!doctype html>
62
+ <html lang="en">
63
+ <head>
64
+ <meta charset="utf-8">
65
+ <meta name="viewport" content="width=device-width, initial-scale=1">
66
+ <title>${escapeHtml(title)}</title>
67
+ <style>${code.css}</style>
68
+ </head>
69
+ <body>
70
+ <div id="loader" role="status" aria-live="polite">
71
+ <div class="loader-card">
72
+ <div class="loader-spin"></div>
73
+ <div class="loader-title">${escapeHtml(title)}</div>
74
+ <div class="loader-step" id="loader-step">Loading the run record…</div>
75
+ </div>
76
+ </div>
77
+ <div id="app" hidden></div>
78
+ <script id="omkit-data" type="application/json">${inertJson(payload)}</script>
79
+ ${blobs}
80
+ <script>${inertScript(code.d3)}</script>
81
+ <script>${inertScript(code.client)}</script>
82
+ </body>
83
+ </html>
84
+ `;
85
+ }
86
+ async function collectFiles(input, assets) {
87
+ const out = [];
88
+ const seen = new Set();
89
+ let budget = IMAGES_TOTAL_CAP;
90
+ const artifactTs = artifactTimes(input.entries);
91
+ const add = async (abs, meta) => {
92
+ const key = path.resolve(abs).toLowerCase();
93
+ if (seen.has(key))
94
+ return;
95
+ seen.add(key);
96
+ const base = { ...meta, file: abs, rel: relTo(input.folder, abs) };
97
+ let size;
98
+ try {
99
+ size = (await stat(abs)).size;
100
+ }
101
+ catch {
102
+ out.push({ ...base, size: 0, omitted: "file not found" });
103
+ return;
104
+ }
105
+ if (meta.mime.startsWith("image/")) {
106
+ if (size > IMAGE_CAP) {
107
+ out.push({ ...base, size, omitted: `larger than ${mb(IMAGE_CAP)}` });
108
+ }
109
+ else if (size > budget) {
110
+ out.push({ ...base, size, omitted: `page image budget (${mb(IMAGES_TOTAL_CAP)}) spent` });
111
+ }
112
+ else {
113
+ budget -= size;
114
+ const id = `asset-${assets.length + 1}`;
115
+ assets.push({ id, mime: meta.mime, data: (await readFile(abs)).toString("base64") });
116
+ out.push({ ...base, size, asset: id });
117
+ }
118
+ }
119
+ else if (isText(meta.mime) && size <= TEXT_CAP) {
120
+ out.push({ ...base, size, text: await readFile(abs, "utf8") });
121
+ }
122
+ else {
123
+ out.push({ ...base, size, omitted: isText(meta.mime) ? `larger than ${mb(TEXT_CAP)}` : "binary" });
124
+ }
125
+ };
126
+ for (const a of input.artifacts) {
127
+ const ts = artifactTs.get(`${a.nodeId}\0${path.resolve(a.file)}`);
128
+ await add(a.file, {
129
+ name: a.name,
130
+ mime: a.mime,
131
+ curated: true,
132
+ nodeId: a.nodeId,
133
+ ...(ts === undefined ? {} : { ts }),
134
+ ...(a.description === undefined ? {} : { description: a.description }),
135
+ });
136
+ }
137
+ // Images the run left in its folder without labelling them still belong in the picture.
138
+ for (const abs of await walk(input.folder)) {
139
+ const mime = IMAGE_MIME[path.extname(abs).toLowerCase()];
140
+ if (mime)
141
+ await add(abs, { name: path.basename(abs), mime, curated: false });
142
+ }
143
+ return out;
144
+ }
145
+ async function collectSnapshots(input) {
146
+ const out = [];
147
+ let seq = 0;
148
+ for (const e of input.entries) {
149
+ if (e.level !== "snapshot")
150
+ continue;
151
+ const split = e.message.lastIndexOf(" → ");
152
+ if (split < 0)
153
+ continue;
154
+ const name = e.message.slice(0, split);
155
+ const abs = e.message.slice(split + 3);
156
+ const base = { seq: ++seq, name, nodeId: e.nodeId, ts: e.ts, file: abs, rel: relTo(input.folder, abs) };
157
+ try {
158
+ const raw = await readFile(abs, "utf8");
159
+ if (raw.length > SNAPSHOT_CAP) {
160
+ out.push({ ...base, text: raw.slice(0, SNAPSHOT_CAP), truncated: true });
161
+ }
162
+ else {
163
+ try {
164
+ out.push({ ...base, value: JSON.parse(raw) });
165
+ }
166
+ catch {
167
+ out.push({ ...base, text: raw });
168
+ }
169
+ }
170
+ }
171
+ catch {
172
+ out.push({ ...base, text: "(snapshot file not found)" });
173
+ }
174
+ }
175
+ return out;
176
+ }
177
+ /** `ts` of each `artifact` timeline line, keyed by `(nodeId, file)` — the latest wins. */
178
+ function artifactTimes(entries) {
179
+ const out = new Map();
180
+ for (const e of entries) {
181
+ if (e.level !== "artifact")
182
+ continue;
183
+ const split = e.message.lastIndexOf(" → ");
184
+ if (split >= 0)
185
+ out.set(`${e.nodeId}\0${path.resolve(e.message.slice(split + 3))}`, e.ts);
186
+ }
187
+ return out;
188
+ }
189
+ /**
190
+ * The project the run belongs to, for project-relative paths: the nearest ancestor of the run
191
+ * folder holding `.git`, else the folder `logs/` sits in (the root omkit was given), else cwd.
192
+ */
193
+ async function projectRoot(folder) {
194
+ let dir = path.resolve(folder);
195
+ for (;;) {
196
+ try {
197
+ await stat(path.join(dir, ".git"));
198
+ return dir;
199
+ }
200
+ catch {
201
+ /* keep climbing */
202
+ }
203
+ const up = path.dirname(dir);
204
+ if (up === dir)
205
+ break;
206
+ dir = up;
207
+ }
208
+ return process.env.OMKIT_ROOT ? path.resolve(process.env.OMKIT_ROOT) : process.cwd();
209
+ }
210
+ /** Each distinct `definedAt` site → its file, relative to the project root, forward-slashed, no line. */
211
+ function definitionSources(root, projectDir) {
212
+ const out = {};
213
+ const visit = (n) => {
214
+ if (n.definedAt && !(n.definedAt in out)) {
215
+ const file = n.definedAt.replace(/:\d+(:\d+)?$/, "");
216
+ out[n.definedAt] = path.relative(projectDir, file).split(path.sep).join("/") || file;
217
+ }
218
+ n.children.forEach(visit);
219
+ };
220
+ visit(root);
221
+ return out;
222
+ }
223
+ async function walk(dir, depth = 0) {
224
+ if (depth > 4)
225
+ return [];
226
+ let names;
227
+ try {
228
+ names = await readdir(dir, { withFileTypes: true });
229
+ }
230
+ catch {
231
+ return [];
232
+ }
233
+ const out = [];
234
+ for (const d of names) {
235
+ const abs = path.join(dir, d.name);
236
+ if (d.isDirectory())
237
+ out.push(...(await walk(abs, depth + 1)));
238
+ else if (d.isFile())
239
+ out.push(abs);
240
+ }
241
+ return out.sort();
242
+ }
243
+ function relTo(folder, abs) {
244
+ const rel = path.relative(folder, abs);
245
+ return rel.startsWith("..") || path.isAbsolute(rel) ? abs : rel.split(path.sep).join("/");
246
+ }
247
+ function isText(mime) {
248
+ return (mime.startsWith("text/") ||
249
+ mime === "application/json" ||
250
+ mime === "application/x-ndjson" ||
251
+ mime === "image/svg+xml");
252
+ }
253
+ function mb(bytes) {
254
+ return `${Math.round(bytes / 1024 / 1024)} MB`;
255
+ }
256
+ /** JSON safe to sit inside a `<script>` block: no `</script>`, no `<!--`. */
257
+ function inertJson(value) {
258
+ return JSON.stringify(value).replace(/</g, "\\u003c");
259
+ }
260
+ /** JS safe to sit inside a `<script>` block. */
261
+ function inertScript(code) {
262
+ return code.replace(/<\/script/gi, "<\\/script");
263
+ }
264
+ function escapeHtml(s) {
265
+ return s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
266
+ }
267
+ /** The viewer's own files, next to this module (`report/`) in both `src` and `dist`. */
268
+ function assetPath(name) {
269
+ return fileURLToPath(new URL(`./report/${name}`, import.meta.url));
270
+ }
271
+ /** d3's UMD bundle — the package's `exports` hide `dist/`, so go via its resolved entry. */
272
+ function d3Path() {
273
+ const entry = createRequire(import.meta.url).resolve("d3");
274
+ return path.join(path.dirname(entry), "..", "dist", "d3.min.js");
275
+ }