htmlineage 0.0.0 → 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +109 -3
  3. package/index.js +4 -0
  4. package/package.json +53 -3
  5. package/skills/htmlineage/SKILL.md +173 -0
  6. package/skills/htmlineage/references/example/.lineage/august-retention.json +167 -0
  7. package/skills/htmlineage/references/example/README.md +51 -0
  8. package/skills/htmlineage/references/example/august-retention.html +111 -0
  9. package/skills/htmlineage/references/format.md +218 -0
  10. package/skills/htmlineage/references/review.md +79 -0
  11. package/src/check/rules.js +421 -0
  12. package/src/check/summary.js +154 -0
  13. package/src/cli.js +99 -0
  14. package/src/commands/check.js +74 -0
  15. package/src/commands/index.js +7 -0
  16. package/src/commands/sql.js +65 -0
  17. package/src/commands/view.js +82 -0
  18. package/src/errors.js +39 -0
  19. package/src/graph/duplicates.js +78 -0
  20. package/src/graph/expr.js +56 -0
  21. package/src/graph/ids.js +24 -0
  22. package/src/graph/numbers.js +61 -0
  23. package/src/graph/parse.js +196 -0
  24. package/src/graph/schema.js +491 -0
  25. package/src/graph/trace.js +45 -0
  26. package/src/help.js +25 -0
  27. package/src/html/embed.js +142 -0
  28. package/src/html/page.js +101 -0
  29. package/src/meta.js +13 -0
  30. package/src/sql/compose.js +215 -0
  31. package/src/sql/style.js +193 -0
  32. package/src/viewer/client/bridge.js +143 -0
  33. package/src/viewer/client/favicon.svg +6 -0
  34. package/src/viewer/client/fonts/JetBrainsMono-Bold.woff2 +0 -0
  35. package/src/viewer/client/fonts/JetBrainsMono-Medium.woff2 +0 -0
  36. package/src/viewer/client/fonts/JetBrainsMono-Regular.woff2 +0 -0
  37. package/src/viewer/client/fonts/JetBrainsMono-SemiBold.woff2 +0 -0
  38. package/src/viewer/client/fonts/OFL-JetBrainsMono.txt +93 -0
  39. package/src/viewer/client/fonts/OFL-SourceSerif4.txt +93 -0
  40. package/src/viewer/client/fonts/SourceSerif4-Medium.woff2 +0 -0
  41. package/src/viewer/client/fonts/SourceSerif4-Regular.woff2 +0 -0
  42. package/src/viewer/client/fonts/SourceSerif4-SemiBold.woff2 +0 -0
  43. package/src/viewer/client/index.html +95 -0
  44. package/src/viewer/client/lineage.js +674 -0
  45. package/src/viewer/client/themes.css +76 -0
  46. package/src/viewer/client/viewer.css +1420 -0
  47. package/src/viewer/client/viewer.js +765 -0
  48. package/src/viewer/payload.js +45 -0
  49. package/src/viewer/server.js +188 -0
@@ -0,0 +1,674 @@
1
+ /**
2
+ * The viewer's logic with no page in it: chain layout, trace state, titles,
3
+ * grounding, SQL highlighting, the status bar and what the copy buttons copy. An
4
+ * ES module with no imports, so the page and the tests load the same file.
5
+ */
6
+
7
+ /** True for a plain object: not null, not an array. */
8
+ const isObject = (x) =>
9
+ typeof x === "object" && x !== null && !Array.isArray(x);
10
+
11
+ /** A graph's nodes as an object, whatever the graph is. */
12
+ export function nodesOf(graph) {
13
+ if (!isObject(graph) || !isObject(graph.nodes)) return {};
14
+ return Object.fromEntries(
15
+ Object.entries(graph.nodes)
16
+ .filter(([, node]) => isObject(node))
17
+ .map(([id, node]) => [
18
+ id,
19
+ "from" in node && !Array.isArray(node.from)
20
+ ? { ...node, from: [] }
21
+ : node,
22
+ ]),
23
+ );
24
+ }
25
+
26
+ /** The trace state of a node, or "ungrounded" for an id not in the graph. */
27
+ export function nodeState(data, id) {
28
+ if (!data.graph.nodes[id]) return "ungrounded";
29
+ return data.trace[id] || "partial";
30
+ }
31
+
32
+ /**
33
+ * Every node under `id`, `id` included, and whether the walk met a cycle or a
34
+ * missing parent on the way.
35
+ */
36
+ function underneath(nodes, id) {
37
+ const seen = new Set();
38
+ const path = new Set();
39
+ let broken = false;
40
+ const walk = (x) => {
41
+ if (!nodes[x] || path.has(x)) return void (broken = true);
42
+ if (seen.has(x)) return;
43
+ seen.add(x);
44
+ path.add(x);
45
+ for (const parent of nodes[x].from ?? []) walk(parent);
46
+ path.delete(x);
47
+ };
48
+ walk(id);
49
+ return { ids: [...seen], broken };
50
+ }
51
+
52
+ /**
53
+ * How an external source is named in a label: by its type, as a tag.
54
+ * @param {unknown} type
55
+ * @returns {string}
56
+ */
57
+ export function externalTag(type) {
58
+ return typeof type === "string" && type.trim()
59
+ ? `${type} source`
60
+ : "outside source";
61
+ }
62
+
63
+ /**
64
+ * The label of the panel head's copy button. An external source has no SQL, so
65
+ * it gets none.
66
+ * @param {object} graph
67
+ * @param {string} id
68
+ * @returns {string | null}
69
+ */
70
+ export function copyChainLabel(graph, id) {
71
+ const n = graph.nodes[id];
72
+ if (n.kind === "external") return null;
73
+ return `Copy SQL for this ${n.step === "insight" ? "claim" : n.step}`;
74
+ }
75
+
76
+ /**
77
+ * What a node rests on: "sql" when its trace is full, "file" when an external
78
+ * source is under it, else "none". A cycle, a missing parent or an ungrounded
79
+ * node with no file under it rests on nothing.
80
+ * @returns {"sql" | "file" | "none"}
81
+ */
82
+ export function groundOf(data, id) {
83
+ const nodes = nodesOf(data.graph);
84
+ if (!nodes[id]) return "none";
85
+ if (data.trace[id] === "full") return "sql";
86
+ if (data.trace[id] === "ungrounded") return "none";
87
+ const { ids, broken } = underneath(nodes, id);
88
+ if (broken) return "none";
89
+ const file = ids.some(
90
+ (x) => nodes[x].step === "source" && nodes[x].kind === "external",
91
+ );
92
+ return file ? "file" : "none";
93
+ }
94
+
95
+ /**
96
+ * How much of the report rests on what. Numbers are the figures plus each
97
+ * bare number check found; claims are the insights.
98
+ * @returns {{ numbers: { sql: number, file: number, none: number }, claims: { supported: number, unsupported: number } }}
99
+ */
100
+ export function grounding(data) {
101
+ const numbers = { sql: 0, file: 0, none: 0 };
102
+ const claims = { supported: 0, unsupported: 0 };
103
+ for (const [id, node] of Object.entries(nodesOf(data.graph))) {
104
+ const ground = groundOf(data, id);
105
+ if (node.step === "figure") numbers[ground]++;
106
+ if (node.step === "insight") {
107
+ claims[ground === "none" ? "unsupported" : "supported"]++;
108
+ }
109
+ }
110
+ numbers.none += data.summary.issues.filter((i) => i.code === "C2").length;
111
+ return { numbers, claims };
112
+ }
113
+
114
+ /**
115
+ * The grounding counts as two plain-text lines, or null for a part with
116
+ * nothing in it.
117
+ * @returns {{ numbers: string | null, claims: string | null }}
118
+ */
119
+ export function headline({ numbers, claims }) {
120
+ const plural = (n, one, many) => (n === 1 ? one : many);
121
+ const nTotal = numbers.sql + numbers.file + numbers.none;
122
+ const cTotal = claims.supported + claims.unsupported;
123
+ const out = { numbers: null, claims: null };
124
+ if (nTotal) {
125
+ out.numbers =
126
+ numbers.sql === nTotal
127
+ ? nTotal === 1
128
+ ? "1 number traces to the warehouse."
129
+ : `All ${nTotal} numbers trace to the warehouse.`
130
+ : [
131
+ `${numbers.sql} of ${nTotal} numbers ${plural(numbers.sql, "traces", "trace")} to the warehouse.`,
132
+ numbers.file &&
133
+ `${numbers.file} ${plural(numbers.file, "rests on an outside source", "rest on outside sources")}.`,
134
+ numbers.none &&
135
+ `${numbers.none} ${plural(numbers.none, "has nothing behind it", "have nothing behind them")}.`,
136
+ ]
137
+ .filter(Boolean)
138
+ .join(" ");
139
+ }
140
+ if (cTotal) {
141
+ out.claims =
142
+ claims.supported === cTotal
143
+ ? cTotal === 1
144
+ ? "1 claim is supported."
145
+ : `All ${cTotal} claims are supported.`
146
+ : `${claims.supported} of ${cTotal} claims ${plural(claims.supported, "is", "are")} supported. ${claims.unsupported} ${plural(claims.unsupported, "is", "are")} stated without support.`;
147
+ }
148
+ return out;
149
+ }
150
+
151
+ /**
152
+ * Whether check left a sql source's period pinned. Any C7 on it is a problem
153
+ * with the period except one that is only about `mart` or `columns`; C7's
154
+ * message parts are fixed strings, joined with "; ".
155
+ */
156
+ function isPinned(data, id) {
157
+ const aboutPeriod = (part) => {
158
+ const missing = /^sql source is missing (.*)$/.exec(part);
159
+ if (!missing) return true;
160
+ return /`(period|sql)`/.test(missing[1]);
161
+ };
162
+ return !data.summary.issues
163
+ .filter((i) => i.code === "C7" && i.node === id)
164
+ .some((i) => i.message.split("; ").some(aboutPeriod));
165
+ }
166
+
167
+ /**
168
+ * One row per source: what it is, its period, whether check found its period
169
+ * unpinned (a C7), and how many figures rest on it at any depth.
170
+ * @returns {({ node: string, kind: "sql", name: string | null, period: object | null, pinned: boolean, numbers: number } | { node: string, kind: "external", name: string | null, numbers: number })[]}
171
+ */
172
+ export function builtOn(data) {
173
+ const nodes = nodesOf(data.graph);
174
+ const figures = Object.keys(nodes).filter(
175
+ (id) => nodes[id].step === "figure",
176
+ );
177
+ return Object.entries(nodes)
178
+ .filter(([, node]) => node.step === "source")
179
+ .map(([id, node]) => {
180
+ const numbers = figures.filter((f) =>
181
+ underneath(nodes, f).ids.includes(id),
182
+ ).length;
183
+ if (node.kind !== "sql") {
184
+ return { node: id, kind: "external", name: node.ref ?? null, numbers };
185
+ }
186
+ return {
187
+ node: id,
188
+ kind: "sql",
189
+ name: node.mart ?? null,
190
+ period: node.period ?? null,
191
+ pinned: isPinned(data, id),
192
+ numbers,
193
+ };
194
+ });
195
+ }
196
+
197
+ /** The rows of `builtOn` for the sources under `id`, `id` included. */
198
+ export function builtFrom(data, id) {
199
+ const under = underneath(nodesOf(data.graph), id).ids;
200
+ return builtOn(data).filter((row) => under.includes(row.node));
201
+ }
202
+
203
+ /** Where the page and the graph differ: one row per C3, in check's order. */
204
+ export function mismatches(data) {
205
+ return data.summary.issues
206
+ .filter((i) => i.code === "C3")
207
+ .map(({ node, message }) => {
208
+ const read =
209
+ /^page shows ((?:"[^"]*"(?:, )?)+) but (?:display is "(.*)"|value is (.*))$/.exec(
210
+ message,
211
+ );
212
+ if (!read) return { node, page: [], graph: null };
213
+ return {
214
+ node,
215
+ page: [...read[1].matchAll(/"([^"]*)"/g)].map((m) => m[1]),
216
+ graph: read[2] ?? read[3],
217
+ };
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Numbers and claims with nothing behind them: figures, then bare numbers,
223
+ * then claims.
224
+ * @returns {{ node: string | null, kind: "figure" | "bare" | "claim", text: string | null }[]}
225
+ */
226
+ export function untraced(data) {
227
+ const nodes = Object.entries(nodesOf(data.graph));
228
+ const none = (step) =>
229
+ nodes.filter(
230
+ ([id, node]) => node.step === step && groundOf(data, id) === "none",
231
+ );
232
+ const bare = data.summary.issues
233
+ .filter((i) => i.code === "C2")
234
+ .map(({ node, message }) => ({
235
+ node,
236
+ kind: "bare",
237
+ text: /^bare number "(.*)" is not/.exec(message)?.[1] ?? message,
238
+ }));
239
+ return [
240
+ ...none("figure").map(([node, n]) => ({
241
+ node,
242
+ kind: "figure",
243
+ text: n.display ?? null,
244
+ })),
245
+ ...bare,
246
+ ...none("insight").map(([node, n]) => ({
247
+ node,
248
+ kind: "claim",
249
+ text: n.text ?? null,
250
+ })),
251
+ ];
252
+ }
253
+
254
+ /**
255
+ * The status bar's text, every issue for the drawer (errors first), and why
256
+ * copying is off.
257
+ * @returns {{ line: string, issues: object[], reason: string | null }}
258
+ */
259
+ export function checkStatus(data) {
260
+ const all = data.summary.issues;
261
+ const errors = all.filter((i) => i.severity === "error");
262
+ const notes = all.filter((i) => i.severity !== "error");
263
+ const count = (list, one, many) =>
264
+ list.length && `${list.length} ${list.length === 1 ? one : many}`;
265
+ const parts = [
266
+ count(errors, "error", "errors"),
267
+ count(notes, "note", "notes"),
268
+ ];
269
+ return {
270
+ line: ["check", ...(all.length ? parts.filter(Boolean) : ["clean"])].join(
271
+ " · ",
272
+ ),
273
+ issues: [...errors, ...notes],
274
+ reason: data.refused,
275
+ };
276
+ }
277
+
278
+ /** A short plain-text name for a node. */
279
+ export function title(graph, id) {
280
+ const node = graph.nodes[id];
281
+ if (!node) return "Not in the graph";
282
+ if (node.step === "figure") return node.label;
283
+ if (node.step === "view") {
284
+ return leadingComment(node.sql) || node.encoding?.visual || "view";
285
+ }
286
+ if (node.step === "insight") return node.text;
287
+ return node.kind === "sql" ? node.mart : node.ref;
288
+ }
289
+
290
+ /** The text of a SQL script's first line when it is a `--` comment. */
291
+ function leadingComment(sql) {
292
+ const match = /^\s*--\s*(.+)/.exec(sql || "");
293
+ return match ? match[1].trim() : "";
294
+ }
295
+
296
+ /**
297
+ * A node's parents, each once; a `from` that is not a list (check's S4) has
298
+ * none.
299
+ */
300
+ const parentsOf = (graph, x) => {
301
+ const from = graph.nodes[x]?.from;
302
+ return Array.isArray(from) ? [...new Set(from)] : [];
303
+ };
304
+
305
+ // Each graph's users of every node, built the first time it is asked for.
306
+ const USERS = new WeakMap();
307
+
308
+ /** The nodes whose `from` names `x`, in graph order. */
309
+ function usersOf(graph, x) {
310
+ if (!USERS.has(graph)) {
311
+ const users = new Map();
312
+ for (const id of Object.keys(graph.nodes))
313
+ for (const p of parentsOf(graph, id))
314
+ users.set(p, [...(users.get(p) ?? []), id]);
315
+ USERS.set(graph, users);
316
+ }
317
+ return USERS.get(graph).get(x) ?? [];
318
+ }
319
+
320
+ /**
321
+ * The rows under one step of the outline. `branch` is the clicked node, then
322
+ * each opened row down to the step whose rows are wanted.
323
+ * @returns {{ label: string | null, rows: { id: string, key: string, loop: boolean }[] }}
324
+ */
325
+ export function outline(graph, branch, { up = false } = {}) {
326
+ const last = branch.at(-1);
327
+ const next = up ? usersOf(graph, last) : parentsOf(graph, last);
328
+ const rows = next.map((id) => ({
329
+ id,
330
+ key: JSON.stringify([...branch.slice(1), id]),
331
+ loop: branch.includes(id),
332
+ }));
333
+ const word = up ? "Used by" : "Rests on";
334
+ return {
335
+ label: rows.length
336
+ ? `${word}${rows.length > 1 ? ` ${rows.length}` : ""}`
337
+ : null,
338
+ rows,
339
+ };
340
+ }
341
+
342
+ // Each graph's "Open all" keys by clicked step: one key per path below it, so
343
+ // they are walked once, not on every render.
344
+ const KEYS = new WeakMap();
345
+
346
+ /** The keys "Open all" opens: every row under `id`, going down. */
347
+ export function outlineKeys(graph, id) {
348
+ if (!KEYS.has(graph)) KEYS.set(graph, new Map());
349
+ const keys = KEYS.get(graph);
350
+ const walk = (branch) =>
351
+ outline(graph, branch)
352
+ .rows.filter((r) => !r.loop)
353
+ .flatMap((r) => [r.key, ...walk([...branch, r.id])]);
354
+ if (!keys.has(id)) keys.set(id, walk([id]));
355
+ return keys.get(id);
356
+ }
357
+
358
+ /**
359
+ * Whether the clicked step shows its own card: not a claim that rests on
360
+ * something, whose card would only list the rows below it.
361
+ */
362
+ export function ownCard(graph, id) {
363
+ return !(
364
+ graph.nodes[id]?.step === "insight" && parentsOf(graph, id).length > 0
365
+ );
366
+ }
367
+
368
+ /**
369
+ * What a row says about one step: its kind, title and value, and the names of
370
+ * the sources it ends at.
371
+ * @returns {{ id: string, kind: string, title: string | null, value: string | null, onPage: boolean, ends: string[], note: string | null }}
372
+ */
373
+ export function inputRow(data, id, { onPage = [] } = {}) {
374
+ const { kind, title, value } = stepHead(data.graph, id);
375
+ const nodes = nodesOf(data.graph);
376
+ const under = new Set(underneath(nodes, id).ids);
377
+ const ends = Object.keys(nodes)
378
+ .filter((x) => under.has(x) && nodes[x].step === "source")
379
+ .map((x) => (nodes[x].kind === "sql" ? nodes[x].mart : nodes[x].ref) ?? x);
380
+ const note = !nodes[id]
381
+ ? "not in the graph"
382
+ : !ends.length && !parentsOf(data.graph, id).length
383
+ ? "nothing behind it"
384
+ : null;
385
+ return { id, kind, title, value, onPage: onPage.includes(id), ends, note };
386
+ }
387
+
388
+ /** A string with something in it, else null. */
389
+ const words = (x) => (typeof x === "string" && x.trim() ? x : null);
390
+
391
+ /**
392
+ * What a step says about itself in words, and its SQL toggle: `show` is null
393
+ * when the step has no SQL to show. `label` is the word the panel prints.
394
+ * @returns {{ desc: string | null, note: string | null, show: { label: string, open: boolean } | null }}
395
+ */
396
+ export function stepText(graph, id) {
397
+ const node = nodesOf(graph)[id];
398
+ if (!node) return { desc: null, note: null, show: null };
399
+ // An external source says what it is in `given`, an insight in `text`.
400
+ const described = node.step !== "insight" && node.kind !== "external";
401
+ const desc = described ? words(node.desc) : null;
402
+ const note = words(node.transform);
403
+ // A formula is its own calculation, so it has no SQL to toggle.
404
+ if (words(node.sql) === null) return { desc, note, show: null };
405
+ const label = node.step === "source" ? "query" : "SQL";
406
+ return { desc, note, show: { label, open: desc === null } };
407
+ }
408
+
409
+ /** A figure's display, else its value as a string, else null. */
410
+ const valueOf = (node) =>
411
+ node.display ?? (node.value == null ? null : String(node.value));
412
+
413
+ /** The word for each kind of step; "Missing" for an id not in the graph. */
414
+ const kindOf = (node) =>
415
+ ({
416
+ source: "Source",
417
+ view: "View",
418
+ figure: "Figure",
419
+ insight: "Claim",
420
+ })[node?.step] ?? "Missing";
421
+
422
+ /**
423
+ * A step's title line: its kind word, its title and the line under it, and
424
+ * the value at its right. The clicked step (`top`) drops the kind and value,
425
+ * which the panel's head shows.
426
+ * @returns {{ kind: string | null, title: string | null, sub: string | null, value: string | null }}
427
+ */
428
+ export function stepHead(graph, id, { top = false } = {}) {
429
+ const node = nodesOf(graph)[id];
430
+ const { desc } = stepText(graph, id);
431
+ const external = node?.kind === "external";
432
+ if (top) {
433
+ // With no words of its own, point at the SQL, which is open.
434
+ const sql = node?.step === "source" ? "query" : "SQL";
435
+ const pointer = stepText(graph, id).show
436
+ ? `${node.step === "source" ? "Fetched" : "Worked out"} by the ${sql} below.`
437
+ : null;
438
+ const title = (external ? words(node.given) : desc) ?? pointer;
439
+ return { kind: null, title, sub: null, value: null };
440
+ }
441
+ const kind = kindOf(node);
442
+ const value = node?.step === "figure" ? valueOf(node) : null;
443
+ if (node?.step === "source") {
444
+ const sub = external ? words(node.given) : desc;
445
+ return { kind, title: words(title(graph, id)), sub, value };
446
+ }
447
+ return {
448
+ kind,
449
+ title: desc ?? (node ? words(title(graph, id)) : null),
450
+ sub: null,
451
+ value,
452
+ };
453
+ }
454
+
455
+ /**
456
+ * What is selected, for the status bar; null for an id not in the graph.
457
+ * @returns {{ id: string, kind: string, value: string | null, name: string | null } | null}
458
+ */
459
+ export function selection(graph, id) {
460
+ const node = nodesOf(graph)[id];
461
+ if (!node) return null;
462
+ const figure = node.step === "figure";
463
+ return {
464
+ id,
465
+ kind: kindOf(node),
466
+ value: figure ? valueOf(node) : null,
467
+ name: figure ? (node.label ?? null) : title(graph, id),
468
+ };
469
+ }
470
+
471
+ /**
472
+ * What the report holds, as the status bar counts it. Numbers are the figures
473
+ * plus each bare number check found.
474
+ * @returns {{ numbers: number, claims: number, views: number, sources: number }}
475
+ */
476
+ export function reportCounts(data) {
477
+ const steps = Object.values(nodesOf(data.graph)).map((n) => n.step);
478
+ const count = (step) => steps.filter((s) => s === step).length;
479
+ const bare = data.summary.issues.filter((i) => i.code === "C2").length;
480
+ return {
481
+ numbers: count("figure") + bare,
482
+ claims: count("insight"),
483
+ views: count("view"),
484
+ sources: count("source"),
485
+ };
486
+ }
487
+
488
+ /**
489
+ * The structured facts of a step, one shape per kind of step, read from its
490
+ * fields and never from its `desc`; null for an id not in the graph.
491
+ */
492
+ export function stepFacts(data, id) {
493
+ const node = nodesOf(data.graph)[id];
494
+ if (!node) return null;
495
+ const list = (x) => (Array.isArray(x) ? x : []);
496
+ if (node.step === "view") {
497
+ return {
498
+ kind: "view",
499
+ from: list(node.from),
500
+ columns: list(node.columns),
501
+ visual: node.encoding?.visual ?? null,
502
+ };
503
+ }
504
+ if (node.step === "insight") {
505
+ return { kind: "claim", text: node.text ?? null, from: list(node.from) };
506
+ }
507
+ if (node.step === "figure") {
508
+ const shown = {
509
+ value: valueOf(node),
510
+ label: node.label ?? null,
511
+ };
512
+ if (words(node.sql) === null && words(node.expr) !== null) {
513
+ return { kind: "formula", ...shown, expr: node.expr };
514
+ }
515
+ return { kind: "pick", ...shown, from: list(node.from) };
516
+ }
517
+ if (node.kind === "external") {
518
+ return {
519
+ kind: "external",
520
+ ref: node.ref ?? null,
521
+ given: node.given ?? null,
522
+ columns: list(node.columns),
523
+ };
524
+ }
525
+ return {
526
+ kind: "sql",
527
+ mart: node.mart ?? null,
528
+ period: node.period ?? null,
529
+ pinned: isPinned(data, id),
530
+ how: node.how ?? null,
531
+ columns: list(node.columns),
532
+ };
533
+ }
534
+
535
+ const escapeHtml = (text) =>
536
+ String(text).replace(
537
+ /[&<>"]/g,
538
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
539
+ );
540
+
541
+ const KEYWORDS =
542
+ /^(select|from|where|and|or|not|group|by|order|join|left|inner|on|as|sum|count|avg|min|max|rank|over|partition|desc|asc|limit|between|in|with|union|all|case|when|then|else|end|null|is|distinct|having|dateadd|current_date)$/i;
543
+
544
+ /** SQL as HTML, with keywords, strings, comments and numbers in spans. */
545
+ export function highlightSql(sql) {
546
+ return sql
547
+ .split(/('(?:[^']|'')*'|--[^\n]*|\b\w+\b)/)
548
+ .map((token, k) => {
549
+ if (k % 2 === 0) return escapeHtml(token);
550
+ if (token.startsWith("'")) {
551
+ return `<span class="str">${escapeHtml(token)}</span>`;
552
+ }
553
+ if (token.startsWith("--")) {
554
+ return `<span class="cm">${escapeHtml(token)}</span>`;
555
+ }
556
+ if (/^\d+$/.test(token)) return `<span class="num">${token}</span>`;
557
+ return KEYWORDS.test(token) ? `<span class="kw">${token}</span>` : token;
558
+ })
559
+ .join("");
560
+ }
561
+
562
+ /**
563
+ * What a copy button copies. Exactly one of `sql` and `reason` is null: a
564
+ * report that fails check copies nothing, and says why.
565
+ * @returns {{ sql: string | null, reason: string | null }}
566
+ */
567
+ function copyable(data, sql) {
568
+ if (data.refused !== null) return { sql: null, reason: data.refused };
569
+ if (!sql) return { sql: null, reason: "Nothing here to run" };
570
+ return { sql, reason: null };
571
+ }
572
+
573
+ /** The whole report composed into one script. */
574
+ export function reportSql(data) {
575
+ return copyable(data, data.composedReport);
576
+ }
577
+
578
+ /** The script behind one node and everything it rests on. */
579
+ export function chainSql(data, id) {
580
+ return copyable(data, data.composed[id]);
581
+ }
582
+
583
+ /** One step's own SQL. */
584
+ export function stepSql(data, id) {
585
+ return copyable(data, data.graph.nodes[id].sql);
586
+ }
587
+
588
+ /**
589
+ * Copy `text`: with the Clipboard API when there is one and it accepts, else
590
+ * with `execCopy`. Resolves whether the text was copied; never rejects.
591
+ * @param {string} text
592
+ * @param {{ clipboard?: { writeText?: (text: string) => Promise<void> }, execCopy: (text: string) => boolean }} how
593
+ * @returns {Promise<boolean>}
594
+ */
595
+ export async function copyText(text, { clipboard, execCopy }) {
596
+ try {
597
+ await clipboard.writeText(text);
598
+ return true;
599
+ } catch {
600
+ // Also reached when there is no clipboard to call.
601
+ try {
602
+ return execCopy(text);
603
+ } catch {
604
+ return false;
605
+ }
606
+ }
607
+ }
608
+
609
+ /**
610
+ * What a message from the report frame asks the panel to do. A report's own
611
+ * scripts can post anything, so only what a click or a key could do gets
612
+ * through.
613
+ * @returns {{ type: "select", id: string } | { type: "back" } | { type: "ready", counts: object } | null}
614
+ */
615
+ export function fromReport(data, message) {
616
+ if (!isObject(message)) return null;
617
+ if (message.lineage === "back") return { type: "back" };
618
+ if (message.lineage === "select") {
619
+ const { id } = message;
620
+ const known =
621
+ typeof id === "string" && Object.hasOwn(nodesOf(data.graph), id);
622
+ return known ? { type: "select", id } : null;
623
+ }
624
+ if (message.lineage === "ready") {
625
+ const nodes = nodesOf(data.graph);
626
+ const counts = isObject(message.counts) ? message.counts : {};
627
+ return {
628
+ type: "ready",
629
+ counts: Object.fromEntries(
630
+ Object.entries(counts).filter(
631
+ ([id, n]) => Object.hasOwn(nodes, id) && Number.isInteger(n) && n > 0,
632
+ ),
633
+ ),
634
+ };
635
+ }
636
+ return null;
637
+ }
638
+
639
+ /** Whether an expression is only numbers, ids, brackets and + - * /. */
640
+ export const isArithmetic = (expr) =>
641
+ /^[\w\s.+\-*/()]*$/.test(expr) && !/\w\s*\(/.test(expr);
642
+
643
+ /**
644
+ * An expression for the formula line: plain arithmetic is spaced and written
645
+ * with − and ×; any other SQL is shown as written. The sign in 1e-3 stays put.
646
+ */
647
+ export function tidyExpr(expr) {
648
+ if (!/^[\w\s.+\-*/(),]*$/.test(expr)) return expr;
649
+ return expr
650
+ .replace(
651
+ /\s*([*/]|(?<!\b\d+(?:\.\d*)?[eE])[-+])\s*/g,
652
+ (_, op) => ` ${{ "-": "−", "*": "×" }[op] ?? op} `,
653
+ )
654
+ .trim();
655
+ }
656
+
657
+ /**
658
+ * The tidied expression as { id } and { text } parts, for the formula line.
659
+ * Words inside quotes and function names stay text, even when `isId` says yes.
660
+ */
661
+ export function exprParts(expr, isId) {
662
+ const parts = [];
663
+ const pieces = tidyExpr(expr).split(
664
+ /('[^']*'|"[^"]*"|\b[A-Za-z_]\w*\b(?!\s*\())/,
665
+ );
666
+ for (const [k, t] of pieces.entries()) {
667
+ const last = parts.at(-1);
668
+ if (k % 2 && isId(t)) parts.push({ id: t });
669
+ else if (!t) continue;
670
+ else if (last?.text !== undefined) last.text += t;
671
+ else parts.push({ text: t });
672
+ }
673
+ return parts;
674
+ }