htmlineage 0.0.0 → 0.1.0-alpha.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 (42) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +64 -2
  3. package/index.js +4 -0
  4. package/package.json +52 -3
  5. package/src/check/rules.js +379 -0
  6. package/src/check/summary.js +154 -0
  7. package/src/cli.js +99 -0
  8. package/src/commands/check.js +71 -0
  9. package/src/commands/index.js +7 -0
  10. package/src/commands/sql.js +65 -0
  11. package/src/commands/view.js +80 -0
  12. package/src/errors.js +39 -0
  13. package/src/graph/duplicates.js +78 -0
  14. package/src/graph/expr.js +54 -0
  15. package/src/graph/ids.js +24 -0
  16. package/src/graph/numbers.js +61 -0
  17. package/src/graph/parse.js +196 -0
  18. package/src/graph/schema.js +465 -0
  19. package/src/graph/trace.js +45 -0
  20. package/src/help.js +25 -0
  21. package/src/html/embed.js +142 -0
  22. package/src/html/page.js +101 -0
  23. package/src/meta.js +13 -0
  24. package/src/sql/compose.js +215 -0
  25. package/src/sql/style.js +193 -0
  26. package/src/viewer/client/bridge.js +141 -0
  27. package/src/viewer/client/fonts/JetBrainsMono-Bold.woff2 +0 -0
  28. package/src/viewer/client/fonts/JetBrainsMono-Medium.woff2 +0 -0
  29. package/src/viewer/client/fonts/JetBrainsMono-Regular.woff2 +0 -0
  30. package/src/viewer/client/fonts/JetBrainsMono-SemiBold.woff2 +0 -0
  31. package/src/viewer/client/fonts/OFL-JetBrainsMono.txt +93 -0
  32. package/src/viewer/client/fonts/OFL-SourceSerif4.txt +93 -0
  33. package/src/viewer/client/fonts/SourceSerif4-Medium.woff2 +0 -0
  34. package/src/viewer/client/fonts/SourceSerif4-Regular.woff2 +0 -0
  35. package/src/viewer/client/fonts/SourceSerif4-SemiBold.woff2 +0 -0
  36. package/src/viewer/client/index.html +95 -0
  37. package/src/viewer/client/lineage.js +650 -0
  38. package/src/viewer/client/themes.css +76 -0
  39. package/src/viewer/client/viewer.css +1418 -0
  40. package/src/viewer/client/viewer.js +761 -0
  41. package/src/viewer/payload.js +45 -0
  42. package/src/viewer/server.js +188 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Read the parts of a report that `check` looks at.
3
+ *
4
+ * A report is read into two flat lists, in document order:
5
+ *
6
+ * bindings every element carrying `data-lineage`, however deep or
7
+ * hidden, for C1 (unknown/invalid ids) and C3 (missing bindings).
8
+ * prose every visible text run, tagged with the id of its nearest
9
+ * bound ancestor (or `null` when unbound), for C2 (bare
10
+ * numbers).
11
+ *
12
+ * The HTML is only ever read here, never written back: `check` edits a
13
+ * report with string surgery over the raw bytes, using the offsets this
14
+ * module returns, so parsing it into a tree and reserializing would risk
15
+ * changing bytes the report's author never touched.
16
+ */
17
+
18
+ import { parse } from "node-html-parser";
19
+
20
+ const HIDDEN_TAGS = new Set([
21
+ "head",
22
+ "script",
23
+ "style",
24
+ "noscript",
25
+ "template",
26
+ ]);
27
+
28
+ const clean = (s) => s.replace(/\s+/g, " ").trim();
29
+
30
+ /**
31
+ * Read a report's bindings and prose.
32
+ *
33
+ * @param {string} html the report, as read from disk
34
+ * @param {{ skip?: (id: string) => boolean }} [options]
35
+ * skip(id) true: leave that bound element's text, and everything inside
36
+ * it, out of `prose`. Default: skip nothing.
37
+ * @returns {{
38
+ * bindings: { id: string, text: string, offset: number }[],
39
+ * prose: { text: string, node: string | null, offset: number }[],
40
+ * }}
41
+ */
42
+ export function readPage(html, { skip = () => false } = {}) {
43
+ const root = parse(html, {
44
+ comment: false,
45
+ blockTextElements: { script: true, style: true, noscript: true },
46
+ });
47
+
48
+ const bindings = [];
49
+ const prose = [];
50
+
51
+ const walk = (el, node, hidden) => {
52
+ for (const child of el.childNodes) {
53
+ if (child.nodeType === 1) {
54
+ let childNode = node;
55
+ let childHidden = hidden;
56
+ if (child.hasAttribute("data-lineage")) {
57
+ const id = child.getAttribute("data-lineage") ?? "";
58
+ bindings.push({
59
+ id,
60
+ text: clean(child.textContent),
61
+ offset: child.range[0],
62
+ });
63
+ childNode = id;
64
+ if (skip(id)) childHidden = true;
65
+ }
66
+ if (HIDDEN_TAGS.has(child.rawTagName.toLowerCase())) {
67
+ childHidden = true;
68
+ }
69
+ walk(child, childNode, childHidden);
70
+ } else if (child.nodeType === 3) {
71
+ if (hidden) continue;
72
+ if (child.rawText.startsWith("<!")) continue;
73
+ const text = clean(child.text);
74
+ if (text === "") continue;
75
+ prose.push({ text, node, offset: child.range[0] });
76
+ }
77
+ }
78
+ };
79
+
80
+ walk(root, null, false);
81
+
82
+ return { bindings, prose };
83
+ }
84
+
85
+ /**
86
+ * The skip predicate C2 uses: text counts only when it is unbound or its
87
+ * nearest binding is a grounded insight.
88
+ *
89
+ * @param {unknown} graph as returned by readGraph; may be malformed
90
+ * @returns {(id: string) => boolean}
91
+ */
92
+ export function proseSkip(graph) {
93
+ const nodes = graph?.nodes;
94
+ if (nodes === null || typeof nodes !== "object" || Array.isArray(nodes)) {
95
+ return () => true;
96
+ }
97
+ return (id) => {
98
+ const n = Object.hasOwn(nodes, id) ? nodes[id] : null;
99
+ return !(n?.step === "insight" && n.ungrounded !== true);
100
+ };
101
+ }
package/src/meta.js ADDED
@@ -0,0 +1,13 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ /**
5
+ * Read name and version straight from package.json rather than importing it,
6
+ * so this works identically on every supported Node version without relying
7
+ * on import attributes.
8
+ */
9
+ export function packageMeta() {
10
+ const path = fileURLToPath(new URL("../package.json", import.meta.url));
11
+ const { name, version } = JSON.parse(readFileSync(path, "utf8"));
12
+ return { name, version };
13
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Compose a lineage graph into one SQL script. Pure: no file I/O.
3
+ */
4
+
5
+ import { exprProblem, exprRefs } from "../graph/expr.js";
6
+ import { traceGraph } from "../graph/trace.js";
7
+
8
+ /** Text collapsed to one line, so it cannot end a `--` comment early. */
9
+ const oneLine = (text) => text.replace(/\s+/g, " ").trim();
10
+
11
+ const quote = (text) => `'${text.replaceAll("'", "''")}'`;
12
+
13
+ /** A recorded value as a SQL literal: number as written, string quoted, else null. */
14
+ const literal = (value) => {
15
+ if (typeof value === "number") return String(value);
16
+ if (typeof value === "string") return quote(value);
17
+ return "null";
18
+ };
19
+
20
+ /** The node's SQL as written, minus outer whitespace and one final `;`, indented. */
21
+ const indent = (sql) =>
22
+ sql
23
+ .trim()
24
+ .replace(/\s*;$/, "")
25
+ .replace(/^(?=.)/gm, " ");
26
+
27
+ /**
28
+ * An expression compiler that reads figures from `nodes`. The text is copied
29
+ * as written; only each word that names a node changes (not quoted text, and
30
+ * not a function name before `(`).
31
+ */
32
+ function compiler(nodes) {
33
+ const ids = new Set(Object.keys(nodes));
34
+ const compile = (expr) =>
35
+ expr.replace(
36
+ /('[^']*'|"[^"]*")|([A-Za-z_][A-Za-z0-9_]*\b(?!\s*\())/g,
37
+ (match, quoted, word) => {
38
+ if (quoted || !ids.has(word)) return match;
39
+ const { expr: inner } = nodes[word];
40
+ return inner ? `(${compile(inner)})` : `(select value from ${word})`;
41
+ },
42
+ );
43
+ return compile;
44
+ }
45
+
46
+ /** Why a report that fails `check` gets no script. */
47
+ export const refusal = (report) =>
48
+ `${report} does not pass \`htmlineage check\`, so no script was composed`;
49
+
50
+ const NOTHING = ", so there is nothing to compose";
51
+
52
+ /** Whether `script`, from `composeSql`, says there was nothing to compose. */
53
+ export const isEmptyScript = (script) =>
54
+ script.split("\n")[1].endsWith(NOTHING);
55
+
56
+ /**
57
+ * @param {object} graph a graph with no error-severity issues
58
+ * @param {{ report: string, id?: string }} options `report` is the file name
59
+ * for the header; `id` limits the script to that node and what is below it
60
+ * @returns {string} the script, with no trailing newline
61
+ */
62
+ export function composeSql(graph, options) {
63
+ const report = oneLine(options.report);
64
+ const header =
65
+ options.id === undefined ? report : `${report} (${options.id})`;
66
+ // The node and everything below it through `from`; the whole graph without an id.
67
+ const scope = new Set();
68
+ const collect = (id) => {
69
+ if (scope.has(id)) return;
70
+ scope.add(id);
71
+ (graph.nodes[id].from ?? []).forEach(collect);
72
+ };
73
+ if (options.id === undefined) Object.keys(graph.nodes).forEach(collect);
74
+ else collect(options.id);
75
+ const entries = Object.entries(graph.nodes).filter(([id]) => scope.has(id));
76
+ const figures = entries.filter(([, node]) => node.step === "figure");
77
+ // A sql source whose query was never recorded cannot run.
78
+ const noSql = (node) =>
79
+ node.step === "source" && node.kind === "sql" && node.sql === undefined;
80
+ const unreadable = (node) =>
81
+ node.expr !== undefined && exprProblem(node.expr) !== null;
82
+ const notCovered = entries.flatMap(([id, node]) => {
83
+ if (noSql(node)) return [`-- ${id} has no sql`];
84
+ if (unreadable(node)) return [`-- ${id} expr cannot be read`];
85
+ if (node.kind === "external") {
86
+ const ref = oneLine(node.ref ?? "");
87
+ return [ref ? `-- ${id} external: ${ref}` : `-- ${id} external`];
88
+ }
89
+ if (node.ungrounded) {
90
+ const text = oneLine(node.text ?? node.label ?? "");
91
+ return [text ? `-- ${id} ungrounded: ${text}` : `-- ${id} ungrounded`];
92
+ }
93
+ return [];
94
+ });
95
+ const tail =
96
+ notCovered.length > 0
97
+ ? `\n\n-- not covered by this script:\n${notCovered.join("\n")}`
98
+ : "";
99
+ // Sources anywhere below a node that pass `test`, in graph order.
100
+ const sourcesBelow = (id, test) => {
101
+ const below = new Set();
102
+ const walk = (parent) => {
103
+ if (test(graph.nodes[parent])) below.add(parent);
104
+ (graph.nodes[parent].from ?? []).forEach(walk);
105
+ };
106
+ walk(id);
107
+ return entries.map(([other]) => other).filter((other) => below.has(other));
108
+ };
109
+ const externalsBelow = (id) =>
110
+ sourcesBelow(id, (node) => node.kind === "external");
111
+ const states = traceGraph(graph);
112
+ const runs = (id) =>
113
+ states.get(id) === "full" && sourcesBelow(id, noSql).length === 0;
114
+ const target = graph.nodes[options.id];
115
+ // The first view below an insight, looking through insights only.
116
+ const viewBelow = (id) => {
117
+ const node = graph.nodes[id];
118
+ if (node.step === "view") return id;
119
+ if (node.step !== "insight") return null;
120
+ for (const parent of node.from) {
121
+ const found = viewBelow(parent);
122
+ if (found !== null) return found;
123
+ }
124
+ return null;
125
+ };
126
+ // A source or view with SQL to run ends in a select of itself; so does an
127
+ // insight that rests on no figures, through the first view below it.
128
+ const selected =
129
+ target?.step === "source" || target?.step === "view"
130
+ ? options.id
131
+ : target?.step === "insight" && figures.length === 0
132
+ ? viewBelow(options.id)
133
+ : null;
134
+ const selectsTarget = selected !== null && runs(selected);
135
+ if (figures.length === 0 && !selectsTarget) {
136
+ const { id } = options;
137
+ const externals = id === undefined ? [] : externalsBelow(id);
138
+ const ungrounded = entries
139
+ .filter(([, node]) => node.ungrounded)
140
+ .map(([other]) => other);
141
+ const missing = id === undefined ? [] : sourcesBelow(id, noSql);
142
+ const ref = oneLine(target?.ref ?? "");
143
+ let why = `${id} rests on no figures`;
144
+ if (id === undefined) why = "no figures";
145
+ else if (target.kind === "external") {
146
+ why = ref ? `${id} is external (${ref})` : `${id} is external`;
147
+ } else if (target.ungrounded) why = `${id} is ungrounded`;
148
+ else if (noSql(target)) why = `${id} has no sql`;
149
+ else if (externals.length > 0) {
150
+ why = `${id} rests on external ${externals.join(", ")}`;
151
+ } else if (missing.length > 0) {
152
+ why = `${id} rests on ${missing.join(", ")}, which ${missing.length > 1 ? "have" : "has"} no sql`;
153
+ } else if (ungrounded.length > 0) {
154
+ why = `${id} rests on ungrounded ${ungrounded.join(", ")}`;
155
+ }
156
+ return `-- ${header}\n-- ${why}${NOTHING}${tail}`;
157
+ }
158
+ const compile = compiler(graph.nodes);
159
+ const ofStep = (step) => entries.filter(([, node]) => node.step === step);
160
+ const views = [];
161
+ const visit = ([id, node]) => {
162
+ if (views.some(([seen]) => seen === id)) return;
163
+ for (const parent of node.from) {
164
+ if (graph.nodes[parent].step === "view") {
165
+ visit([parent, graph.nodes[parent]]);
166
+ }
167
+ }
168
+ views.push([id, node]);
169
+ };
170
+ ofStep("view")
171
+ .filter(([id]) => runs(id))
172
+ .forEach(visit);
173
+ const sqlSources = ofStep("source").filter(([id]) => runs(id));
174
+ const ctes = [...sqlSources, ...views].map(
175
+ ([id, node]) => `${id} as (\n${indent(node.sql)}\n)`,
176
+ );
177
+ // A sql figure with no CTE cannot be read: its value is null.
178
+ const unread = new Set();
179
+ for (const [id, node] of figures.filter(([, node]) => node.sql)) {
180
+ const externals = externalsBelow(id);
181
+ if (runs(id)) {
182
+ ctes.push(`${id} (value) as (\n${indent(node.sql)}\n)`);
183
+ } else if (externals.length > 0 && !node.ungrounded) {
184
+ ctes.push(
185
+ `${id} (value) as (\n -- from the report: rests on external ${externals.join(", ")}\n select ${literal(node.value)}\n)`,
186
+ );
187
+ } else {
188
+ unread.add(id);
189
+ }
190
+ }
191
+ const isNull = (id) =>
192
+ graph.nodes[id].ungrounded === true ||
193
+ unread.has(id) ||
194
+ unreadable(graph.nodes[id]) ||
195
+ (graph.nodes[id].expr !== undefined &&
196
+ exprRefs(graph.nodes[id].expr, Object.keys(graph.nodes)).some(isNull));
197
+ const rows = figures.map(([id, node], i) => {
198
+ const reading = node.expr
199
+ ? compile(node.expr)
200
+ : `(select value from ${id})`;
201
+ const [figure, label, value] = [
202
+ `'${id}'`,
203
+ node.label === undefined ? "null" : quote(node.label),
204
+ isNull(id) ? "null" : `cast(${reading} as varchar)`,
205
+ ];
206
+ return i === 0
207
+ ? `select ${figure} as figure, ${label} as label, ${value} as value`
208
+ : `select ${figure}, ${label}, ${value}`;
209
+ });
210
+ const final = selectsTarget
211
+ ? `select * from ${selected}`
212
+ : rows.join("\nunion all\n");
213
+ const withClause = ctes.length > 0 ? `with\n${ctes.join(",\n")}\n` : "";
214
+ return `-- ${header}\n${withClause}${final}${tail}`;
215
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The mechanical SQL checks: no `select *`, no date functions, and parents
3
+ * named by node id so the composed script runs. They scan a query's words and
4
+ * parentheses, never its meaning. `check` runs them over sources, views and
5
+ * figure queries (rule C8) and over a source's own SQL for date functions
6
+ * (rule C7). A SQL parser is deliberately out of scope; that is rule C10,
7
+ * deferred.
8
+ */
9
+
10
+ const CLAUSE_PATTERN =
11
+ /\b(select|from|where|group\s+by|having|order\s+by|limit|qualify|union|join)\b/gi;
12
+
13
+ const DATE_FUNCTION_PATTERN =
14
+ /\b(current_date|current_timestamp|current_time|localtimestamp|localtime|sysdate|systimestamp|getdate|now)\b/gi;
15
+
16
+ /** A select item that is a star, bare or qualified, with Snowflake's modifiers. */
17
+ const STAR_PATTERN = /(^|\.)\*(\s+(exclude|replace|rename|ilike)\b[\s\S]*)?$/i;
18
+
19
+ /**
20
+ * Blank out comments, and the contents of quoted strings and identifiers, so
21
+ * rules can scan the code with regexes. Same length as `sql`; line breaks
22
+ * kept; quote characters kept.
23
+ * @param {string} sql
24
+ * @param {{ keepStrings?: boolean }} [options] keep single-quoted string
25
+ * contents (for finding date literals). Default false.
26
+ * @returns {string}
27
+ */
28
+ export function maskSql(sql, { keepStrings = false } = {}) {
29
+ const out = [];
30
+ let i = 0;
31
+ const n = sql.length;
32
+
33
+ while (i < n) {
34
+ const c = sql[i];
35
+
36
+ if (c === "-" && sql[i + 1] === "-") {
37
+ while (i < n && sql[i] !== "\n") {
38
+ out.push(" ");
39
+ i++;
40
+ }
41
+ continue;
42
+ }
43
+
44
+ if (c === "/" && sql[i + 1] === "*") {
45
+ out.push(" ", " ");
46
+ i += 2;
47
+ while (i < n && !(sql[i] === "*" && sql[i + 1] === "/")) {
48
+ out.push(sql[i] === "\n" ? "\n" : " ");
49
+ i++;
50
+ }
51
+ if (i < n) {
52
+ out.push(" ", " ");
53
+ i += 2;
54
+ }
55
+ continue;
56
+ }
57
+
58
+ if (c === "'" || c === '"') {
59
+ const quote = c;
60
+ const keep = keepStrings && quote === "'";
61
+ out.push(quote);
62
+ i++;
63
+ while (i < n) {
64
+ if (sql[i] === quote) {
65
+ if (sql[i + 1] === quote) {
66
+ out.push(keep ? quote : " ", keep ? quote : " ");
67
+ i += 2;
68
+ continue;
69
+ }
70
+ out.push(quote);
71
+ i++;
72
+ break;
73
+ }
74
+ out.push(keep ? sql[i] : sql[i] === "\n" ? "\n" : " ");
75
+ i++;
76
+ }
77
+ continue;
78
+ }
79
+
80
+ out.push(c);
81
+ i++;
82
+ }
83
+
84
+ return out.join("");
85
+ }
86
+
87
+ /**
88
+ * Functions that read the clock instead of a literal date: current_date,
89
+ * current_timestamp, current_time, localtimestamp, localtime, sysdate,
90
+ * systimestamp, getdate, now. Date arithmetic over literals (`dateadd`) is
91
+ * pinned and allowed. Whole words, any case, outside strings and comments.
92
+ * @param {string} sql
93
+ * @returns {string[]} each function once, as first written, in order
94
+ */
95
+ export function dateFunctions(sql) {
96
+ const seen = new Set();
97
+ const found = [];
98
+ for (const match of maskSql(sql).matchAll(DATE_FUNCTION_PATTERN)) {
99
+ const name = match[0].toLowerCase();
100
+ if (seen.has(name)) continue;
101
+ seen.add(name);
102
+ found.push(match[0]);
103
+ }
104
+ return found;
105
+ }
106
+
107
+ /** Index of each char in `code`: paren depth before that index. */
108
+ function parenDepths(code) {
109
+ const depths = new Array(code.length);
110
+ let depth = 0;
111
+ for (let i = 0; i < code.length; i++) {
112
+ if (code[i] === ")") depth = Math.max(0, depth - 1);
113
+ depths[i] = depth;
114
+ if (code[i] === "(") depth++;
115
+ }
116
+ return depths;
117
+ }
118
+
119
+ /** Escape a string for use inside a regex, so an arbitrary id cannot break it. */
120
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
121
+
122
+ /**
123
+ * The mechanical SQL rules for one query.
124
+ * @param {string} sql
125
+ * @param {{ parents?: string[] }} [options] node ids the query must name
126
+ * @returns {{ rule: string, message: string }[]} at most one finding per
127
+ * rule, in this order: select-star, date-function, parent-ref
128
+ */
129
+ export function checkSqlStyle(sql, { parents = [] } = {}) {
130
+ const code = maskSql(sql);
131
+ const depths = parenDepths(code);
132
+ const findings = [];
133
+
134
+ const clauses = [...code.matchAll(CLAUSE_PATTERN)];
135
+
136
+ // select-star: split each select list, at any nesting depth, into items.
137
+ // A list runs from its "select" (after any "distinct") to the next clause
138
+ // at the same depth, the parenthesis that closes it, or the end.
139
+ let star = false;
140
+ for (const match of clauses) {
141
+ if (match[0].toLowerCase() !== "select") continue;
142
+ const depth = depths[match.index];
143
+ let start = match.index + match[0].length;
144
+ const distinct = /^\s*\bdistinct\b/i.exec(code.slice(start));
145
+ if (distinct) start += distinct[0].length;
146
+ const next = clauses.find(
147
+ (m) => m.index > match.index && depths[m.index] === depth,
148
+ );
149
+ let end = next ? next.index : code.length;
150
+ for (let i = start; i < end; i++) {
151
+ if (depths[i] < depth) {
152
+ end = i;
153
+ break;
154
+ }
155
+ }
156
+
157
+ let itemStart = start;
158
+ for (let i = start; i <= end; i++) {
159
+ if (i === end || (code[i] === "," && depths[i] === depth)) {
160
+ if (STAR_PATTERN.test(code.slice(itemStart, i).trim())) star = true;
161
+ itemStart = i + 1;
162
+ }
163
+ }
164
+ }
165
+ if (star) {
166
+ findings.push({
167
+ rule: "select-star",
168
+ message: "uses select *; list the columns",
169
+ });
170
+ }
171
+
172
+ // date-function
173
+ const dates = dateFunctions(sql);
174
+ if (dates.length > 0) {
175
+ findings.push({
176
+ rule: "date-function",
177
+ message: `use literal ISO dates, not date functions: ${dates.join(", ")}`,
178
+ });
179
+ }
180
+
181
+ // parent-ref
182
+ const missingParents = parents.filter(
183
+ (id) => !new RegExp(`\\b${escapeRegExp(id)}\\b`, "i").test(code),
184
+ );
185
+ if (missingParents.length > 0) {
186
+ findings.push({
187
+ rule: "parent-ref",
188
+ message: `refer to parents by node id: ${missingParents.join(", ")}`,
189
+ });
190
+ }
191
+
192
+ return findings;
193
+ }
@@ -0,0 +1,141 @@
1
+ // Runs inside the report, added by the viewer's server to the copy it serves.
2
+ // The report sits in a sandboxed frame, so the panel cannot touch it; the two
3
+ // talk only through postMessage. A classic script: it is inlined in a tag, and
4
+ // the frame's origin is "null", so it cannot import or fetch anything.
5
+ (function () {
6
+ var BOUND = "[data-lineage]";
7
+ var CSS_TEXT =
8
+ BOUND +
9
+ " { cursor: pointer; transition: outline-color .15s, background-color .15s; outline: 1.5px solid transparent; outline-offset: 2px; border-radius: 2px; }" +
10
+ " .tl-hover { outline-color: rgba(35, 70, 176, .45) !important; }" +
11
+ " .tl-peek { outline: 1.5px dashed #111 !important; }" +
12
+ " .tl-sel { outline: 2px solid #2346b0 !important; background-color: rgba(35, 70, 176, .07) !important; }" +
13
+ " @keyframes tl-flash { 0% { background-color: rgba(35,70,176,.28); } 100% { background-color: rgba(35,70,176,.07); } }" +
14
+ " .tl-flash { animation: tl-flash .9s ease-out; }" +
15
+ " " +
16
+ BOUND +
17
+ ":focus-visible { outline: 2px solid #2346b0; }";
18
+
19
+ var marked = null; // the id the panel has open
20
+ var current = null; // the element of it that is outlined
21
+
22
+ function post(message) {
23
+ parent.postMessage(message, "*");
24
+ }
25
+
26
+ function bound() {
27
+ return Array.prototype.slice.call(document.querySelectorAll(BOUND));
28
+ }
29
+
30
+ function places(id) {
31
+ return bound().filter(function (el) {
32
+ return el.getAttribute("data-lineage") === id;
33
+ });
34
+ }
35
+
36
+ function clear(name) {
37
+ Array.prototype.forEach.call(
38
+ document.querySelectorAll("." + name),
39
+ function (el) {
40
+ el.classList.remove(name);
41
+ },
42
+ );
43
+ }
44
+
45
+ function paint() {
46
+ clear("tl-sel");
47
+ if (marked === null) return;
48
+ var id = marked;
49
+ if (
50
+ !current ||
51
+ !current.isConnected ||
52
+ current.getAttribute("data-lineage") !== id
53
+ ) {
54
+ current = places(id)[0] || null;
55
+ }
56
+ if (current) current.classList.add("tl-sel");
57
+ }
58
+
59
+ function open(el) {
60
+ current = el;
61
+ post({ lineage: "select", id: el.getAttribute("data-lineage") });
62
+ }
63
+
64
+ function start() {
65
+ var style = document.createElement("style");
66
+ style.textContent = CSS_TEXT;
67
+ document.head.appendChild(style);
68
+ }
69
+
70
+ function ready() {
71
+ var counts = {};
72
+ bound().forEach(function (el) {
73
+ if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "0");
74
+ var id = el.getAttribute("data-lineage");
75
+ counts[id] = (counts[id] || 0) + 1;
76
+ });
77
+ post({ lineage: "ready", counts: counts });
78
+ }
79
+
80
+ document.addEventListener("click", function (e) {
81
+ var el = e.target.closest && e.target.closest(BOUND);
82
+ if (!el) return;
83
+ e.preventDefault();
84
+ open(el);
85
+ });
86
+
87
+ document.addEventListener("keydown", function (e) {
88
+ if (e.key === "Escape") return post({ lineage: "back" });
89
+ if (e.key !== "Enter" && e.key !== " ") return;
90
+ var el = e.target.closest && e.target.closest(BOUND);
91
+ if (!el || el !== e.target) return;
92
+ e.preventDefault();
93
+ open(el);
94
+ });
95
+
96
+ document.addEventListener("mouseover", function (e) {
97
+ clear("tl-hover");
98
+ var el = e.target.closest && e.target.closest(BOUND);
99
+ if (el) el.classList.add("tl-hover");
100
+ });
101
+
102
+ document.addEventListener("mouseleave", function () {
103
+ clear("tl-hover");
104
+ });
105
+
106
+ // What the panel asks for: outline the open node, outline one on hover,
107
+ // scroll to one of a node's places on the page.
108
+ window.addEventListener("message", function (e) {
109
+ if (e.source !== parent) return;
110
+ var m = e.data;
111
+ if (!m || typeof m !== "object") return;
112
+ if (m.lineage === "mark") {
113
+ marked = typeof m.id === "string" ? m.id : null;
114
+ paint();
115
+ } else if (m.lineage === "peek") {
116
+ clear("tl-peek");
117
+ if (typeof m.id === "string") {
118
+ places(m.id).forEach(function (el) {
119
+ el.classList.add("tl-peek");
120
+ });
121
+ }
122
+ } else if (m.lineage === "scroll") {
123
+ var el = typeof m.id === "string" && places(m.id)[m.index];
124
+ if (!el) return;
125
+ current = el;
126
+ paint();
127
+ var calm = matchMedia("(prefers-reduced-motion: reduce)").matches;
128
+ el.scrollIntoView({
129
+ behavior: calm ? "auto" : "smooth",
130
+ block: "center",
131
+ });
132
+ el.classList.remove("tl-flash");
133
+ void el.offsetWidth;
134
+ el.classList.add("tl-flash");
135
+ }
136
+ });
137
+
138
+ start();
139
+ if (document.readyState === "complete") ready();
140
+ else window.addEventListener("load", ready);
141
+ })();