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.
- package/LICENSE +201 -0
- package/README.md +64 -2
- package/index.js +4 -0
- package/package.json +52 -3
- package/src/check/rules.js +379 -0
- package/src/check/summary.js +154 -0
- package/src/cli.js +99 -0
- package/src/commands/check.js +71 -0
- package/src/commands/index.js +7 -0
- package/src/commands/sql.js +65 -0
- package/src/commands/view.js +80 -0
- package/src/errors.js +39 -0
- package/src/graph/duplicates.js +78 -0
- package/src/graph/expr.js +54 -0
- package/src/graph/ids.js +24 -0
- package/src/graph/numbers.js +61 -0
- package/src/graph/parse.js +196 -0
- package/src/graph/schema.js +465 -0
- package/src/graph/trace.js +45 -0
- package/src/help.js +25 -0
- package/src/html/embed.js +142 -0
- package/src/html/page.js +101 -0
- package/src/meta.js +13 -0
- package/src/sql/compose.js +215 -0
- package/src/sql/style.js +193 -0
- package/src/viewer/client/bridge.js +141 -0
- package/src/viewer/client/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/src/viewer/client/fonts/JetBrainsMono-Medium.woff2 +0 -0
- package/src/viewer/client/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/src/viewer/client/fonts/JetBrainsMono-SemiBold.woff2 +0 -0
- package/src/viewer/client/fonts/OFL-JetBrainsMono.txt +93 -0
- package/src/viewer/client/fonts/OFL-SourceSerif4.txt +93 -0
- package/src/viewer/client/fonts/SourceSerif4-Medium.woff2 +0 -0
- package/src/viewer/client/fonts/SourceSerif4-Regular.woff2 +0 -0
- package/src/viewer/client/fonts/SourceSerif4-SemiBold.woff2 +0 -0
- package/src/viewer/client/index.html +95 -0
- package/src/viewer/client/lineage.js +650 -0
- package/src/viewer/client/themes.css +76 -0
- package/src/viewer/client/viewer.css +1418 -0
- package/src/viewer/client/viewer.js +761 -0
- package/src/viewer/payload.js +45 -0
- package/src/viewer/server.js +188 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { checkRules } from "../check/rules.js";
|
|
4
|
+
import { HtmlineageError, UsageError } from "../errors.js";
|
|
5
|
+
import { readGraph, readText } from "../graph/parse.js";
|
|
6
|
+
import { validateGraph } from "../graph/schema.js";
|
|
7
|
+
import { proseSkip, readPage } from "../html/page.js";
|
|
8
|
+
import { composeSql, refusal } from "../sql/compose.js";
|
|
9
|
+
|
|
10
|
+
export const name = "sql";
|
|
11
|
+
export const summary = "Compose the report into one runnable SQL script";
|
|
12
|
+
|
|
13
|
+
export const usage = `htmlineage sql <report.html> [--id <node>] [options]
|
|
14
|
+
|
|
15
|
+
Joins every source, view and figure in the report's lineage graph into a
|
|
16
|
+
single script a reviewer can paste into the warehouse. External and
|
|
17
|
+
ungrounded nodes are listed as trailing comments. A report that fails
|
|
18
|
+
\`htmlineage check\` is refused.
|
|
19
|
+
|
|
20
|
+
Options
|
|
21
|
+
--id <node> Compose only this node and what it rests on
|
|
22
|
+
--graph <path> Graph JSON to read (default: .lineage/<report>.json
|
|
23
|
+
beside the report, or else the block already embedded
|
|
24
|
+
in it)
|
|
25
|
+
-h, --help Show this help`;
|
|
26
|
+
|
|
27
|
+
export const options = {
|
|
28
|
+
graph: { type: "string" },
|
|
29
|
+
id: { type: "string", multiple: true },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function run(parsed, io) {
|
|
33
|
+
const { positionals } = parsed;
|
|
34
|
+
if (positionals.length === 0) {
|
|
35
|
+
throw new UsageError("missing the report to compose", { command: name });
|
|
36
|
+
}
|
|
37
|
+
if (positionals.length > 1) {
|
|
38
|
+
throw new UsageError(`expected one report, got ${positionals.length}`, {
|
|
39
|
+
command: name,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const ids = parsed.values.id ?? [];
|
|
43
|
+
if (ids.length > 1) {
|
|
44
|
+
throw new UsageError(`expected one --id, got ${ids.length}`, {
|
|
45
|
+
command: name,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const reportPath = positionals[0];
|
|
49
|
+
const { graph } = readGraph({ reportPath, graphPath: parsed.values.graph });
|
|
50
|
+
const [id] = ids;
|
|
51
|
+
if (id !== undefined && !Object.hasOwn(graph?.nodes ?? {}, id)) {
|
|
52
|
+
throw new UsageError(`no node \`${id}\` in ${basename(reportPath)}`, {
|
|
53
|
+
command: name,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const html = readText(reportPath, "report");
|
|
57
|
+
const page = readPage(html, { skip: proseSkip(graph) });
|
|
58
|
+
const issues = [...validateGraph(graph), ...checkRules(graph, page)];
|
|
59
|
+
const report = basename(reportPath);
|
|
60
|
+
if (issues.some((issue) => issue.severity === "error")) {
|
|
61
|
+
throw new HtmlineageError(refusal(report));
|
|
62
|
+
}
|
|
63
|
+
io.out(composeSql(graph, { report, id }));
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { UsageError } from "../errors.js";
|
|
4
|
+
import { buildPayload } from "../viewer/payload.js";
|
|
5
|
+
import { browserCommand, openBrowser, startServer } from "../viewer/server.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_PORT = 7171;
|
|
8
|
+
|
|
9
|
+
export const name = "view";
|
|
10
|
+
export const summary = "Serve the report beside its lineage";
|
|
11
|
+
|
|
12
|
+
export const usage = `htmlineage view <report.html> [options]
|
|
13
|
+
|
|
14
|
+
Serves a two-pane page on localhost: the report on the left, the lineage of
|
|
15
|
+
whatever you click on the right. Reads the report and its graph, nothing
|
|
16
|
+
else. No database, no credentials. The viewer itself makes no request off
|
|
17
|
+
this machine; the report loads what it would load in any browser.
|
|
18
|
+
|
|
19
|
+
Options
|
|
20
|
+
--port <number> Port to serve on (default: the first free port from 7171)
|
|
21
|
+
--no-open Print the URL instead of opening a browser
|
|
22
|
+
--graph <path> Graph JSON to read (default: .lineage/<report>.json
|
|
23
|
+
beside the report, or else the block already embedded
|
|
24
|
+
in it)
|
|
25
|
+
-h, --help Show this help`;
|
|
26
|
+
|
|
27
|
+
export const options = {
|
|
28
|
+
port: { type: "string" },
|
|
29
|
+
"no-open": { type: "boolean", default: false },
|
|
30
|
+
graph: { type: "string" },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export async function run(parsed, io) {
|
|
34
|
+
const { positionals } = parsed;
|
|
35
|
+
if (positionals.length === 0) {
|
|
36
|
+
throw new UsageError("missing the report to view", { command: name });
|
|
37
|
+
}
|
|
38
|
+
if (positionals.length > 1) {
|
|
39
|
+
throw new UsageError(`expected one report, got ${positionals.length}`, {
|
|
40
|
+
command: name,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const { port } = parsed.values;
|
|
44
|
+
if (port !== undefined && !(/^\d+$/.test(port) && Number(port) <= 65535)) {
|
|
45
|
+
throw new UsageError(
|
|
46
|
+
`--port must be a whole number from 0 to 65535, got \`${port}\``,
|
|
47
|
+
{ command: name },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const reportPath = positionals[0];
|
|
51
|
+
const graphPath = parsed.values.graph;
|
|
52
|
+
// Read everything once before listening: an unreadable graph exits 1
|
|
53
|
+
// without ever printing a URL.
|
|
54
|
+
buildPayload({ reportPath, graphPath });
|
|
55
|
+
const server = await startServer({
|
|
56
|
+
reportPath,
|
|
57
|
+
graphPath,
|
|
58
|
+
port: port === undefined ? DEFAULT_PORT : Number(port),
|
|
59
|
+
exact: port !== undefined,
|
|
60
|
+
});
|
|
61
|
+
io.out(`Serving ${basename(reportPath)} at ${server.url}`);
|
|
62
|
+
io.out("Press Ctrl-C to stop.");
|
|
63
|
+
if (!parsed.values["no-open"]) {
|
|
64
|
+
const open =
|
|
65
|
+
io.open ?? ((url) => openBrowser(browserCommand(process.platform, url)));
|
|
66
|
+
// A browser that will not open is not an error: the URL is printed.
|
|
67
|
+
open(server.url);
|
|
68
|
+
}
|
|
69
|
+
await stopped(io.signal);
|
|
70
|
+
await server.close();
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Resolves when `signal` aborts; without one, on the first Ctrl-C. */
|
|
75
|
+
function stopped(signal) {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
if (signal === undefined) return process.once("SIGINT", resolve);
|
|
78
|
+
signal.addEventListener("abort", resolve, { once: true });
|
|
79
|
+
});
|
|
80
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit codes. These are part of the CLI contract: the skill and the Claude
|
|
3
|
+
* Code hook both branch on them, so they do not change without a major bump.
|
|
4
|
+
*
|
|
5
|
+
* 0 the command did what it was asked to do (warnings are still 0)
|
|
6
|
+
* 1 the command ran but the report or graph did not pass
|
|
7
|
+
* 2 the command was invoked wrongly and never ran
|
|
8
|
+
*/
|
|
9
|
+
export const EXIT_OK = 0;
|
|
10
|
+
export const EXIT_FAILED = 1;
|
|
11
|
+
export const EXIT_USAGE = 2;
|
|
12
|
+
|
|
13
|
+
/** Bad invocation: unknown command, missing argument, unreadable flag. */
|
|
14
|
+
export class UsageError extends Error {
|
|
15
|
+
constructor(message, { command } = {}) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "UsageError";
|
|
18
|
+
this.command = command ?? null;
|
|
19
|
+
this.exitCode = EXIT_USAGE;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The command ran and reported a real failure. */
|
|
24
|
+
export class HtmlineageError extends Error {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "HtmlineageError";
|
|
28
|
+
this.exitCode = EXIT_FAILED;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A command whose surface is fixed but whose body is not built yet. */
|
|
33
|
+
export class NotImplementedError extends HtmlineageError {
|
|
34
|
+
constructor(command) {
|
|
35
|
+
super(`\`htmlineage ${command}\` is not implemented yet.`);
|
|
36
|
+
this.name = "NotImplementedError";
|
|
37
|
+
this.command = command;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find keys that appear twice in the same JSON object.
|
|
3
|
+
*
|
|
4
|
+
* JSON.parse keeps the last of two equal keys and drops the first without a
|
|
5
|
+
* word. In a graph the model writes by hand, a repeated node id or field is
|
|
6
|
+
* an easy slip, and the silent loss of a node is the worst way to find out.
|
|
7
|
+
* This scans text JSON.parse has already accepted, so it can assume the
|
|
8
|
+
* input is well formed and only needs to track where it is.
|
|
9
|
+
*
|
|
10
|
+
* Returns `[{ path, first, second }]`: `path` like "nodes.f3" or
|
|
11
|
+
* "nodes.f3.sql", and the character offsets of both occurrences.
|
|
12
|
+
*/
|
|
13
|
+
export function findDuplicateKeys(text) {
|
|
14
|
+
const duplicates = [];
|
|
15
|
+
const stack = [];
|
|
16
|
+
// The key or index a new container will be stored under in its parent.
|
|
17
|
+
const childPath = () => {
|
|
18
|
+
const top = stack[stack.length - 1];
|
|
19
|
+
if (!top) return [];
|
|
20
|
+
return [...top.path, top.object ? top.key : top.index];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let i = 0;
|
|
24
|
+
while (i < text.length) {
|
|
25
|
+
const char = text[i];
|
|
26
|
+
const top = stack[stack.length - 1];
|
|
27
|
+
|
|
28
|
+
if (char === "{" || char === "[") {
|
|
29
|
+
stack.push({
|
|
30
|
+
object: char === "{",
|
|
31
|
+
path: childPath(),
|
|
32
|
+
keys: new Map(),
|
|
33
|
+
expectKey: true,
|
|
34
|
+
key: null,
|
|
35
|
+
index: 0,
|
|
36
|
+
});
|
|
37
|
+
i++;
|
|
38
|
+
} else if (char === "}" || char === "]") {
|
|
39
|
+
stack.pop();
|
|
40
|
+
i++;
|
|
41
|
+
} else if (char === ":") {
|
|
42
|
+
top.expectKey = false;
|
|
43
|
+
i++;
|
|
44
|
+
} else if (char === ",") {
|
|
45
|
+
if (top.object) top.expectKey = true;
|
|
46
|
+
else top.index++;
|
|
47
|
+
i++;
|
|
48
|
+
} else if (char === '"') {
|
|
49
|
+
const end = stringEnd(text, i);
|
|
50
|
+
if (top?.object && top.expectKey) {
|
|
51
|
+
const key = JSON.parse(text.slice(i, end));
|
|
52
|
+
if (top.keys.has(key)) {
|
|
53
|
+
duplicates.push({
|
|
54
|
+
path: [...top.path, key].join("."),
|
|
55
|
+
first: top.keys.get(key),
|
|
56
|
+
second: i,
|
|
57
|
+
});
|
|
58
|
+
} else {
|
|
59
|
+
top.keys.set(key, i);
|
|
60
|
+
}
|
|
61
|
+
top.key = key;
|
|
62
|
+
}
|
|
63
|
+
i = end;
|
|
64
|
+
} else {
|
|
65
|
+
i++;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return duplicates;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Offset just past the closing quote of the string starting at `start`. */
|
|
72
|
+
function stringEnd(text, start) {
|
|
73
|
+
let i = start + 1;
|
|
74
|
+
while (text[i] !== '"') {
|
|
75
|
+
i += text[i] === "\\" ? 2 : 1;
|
|
76
|
+
}
|
|
77
|
+
return i + 1;
|
|
78
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A figure's `expr`: a SQL scalar expression over figure ids, passed through
|
|
3
|
+
* as written. htmlineage has no grammar for it. It only finds the ids the text
|
|
4
|
+
* names as bare words, and says whether the text can be read at all (brackets
|
|
5
|
+
* and quotes close, and no comment).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The ids from `ids` that the text names as bare words (not inside quotes,
|
|
10
|
+
* and not a function name before `(`), distinct, in first-seen order.
|
|
11
|
+
*/
|
|
12
|
+
export function exprRefs(source, ids) {
|
|
13
|
+
const known = new Set(ids);
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
const bare = source.replace(/'[^']*'|"[^"]*"/g, " ");
|
|
16
|
+
for (const [word] of bare.matchAll(/[A-Za-z_][A-Za-z0-9_]*\b(?!\s*\()/g)) {
|
|
17
|
+
if (known.has(word)) seen.add(word);
|
|
18
|
+
}
|
|
19
|
+
return [...seen];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether the text can be read at all: brackets and quotes close, and there
|
|
24
|
+
* is no comment. Returns null, or { message, position } (0-based).
|
|
25
|
+
*/
|
|
26
|
+
export function exprProblem(source) {
|
|
27
|
+
const open = [];
|
|
28
|
+
for (let i = 0; i < source.length; i++) {
|
|
29
|
+
const char = source[i];
|
|
30
|
+
if (char === "'" || char === '"') {
|
|
31
|
+
const end = source.indexOf(char, i + 1);
|
|
32
|
+
if (end === -1) {
|
|
33
|
+
return { message: `\`${char}\` is never closed`, position: i };
|
|
34
|
+
}
|
|
35
|
+
i = end;
|
|
36
|
+
} else if (source.startsWith("--", i) || source.startsWith("/*", i)) {
|
|
37
|
+
const mark = source.slice(i, i + 2);
|
|
38
|
+
return {
|
|
39
|
+
message: `\`${mark}\` starts a comment; put notes in the node's \`label\``,
|
|
40
|
+
position: i,
|
|
41
|
+
};
|
|
42
|
+
} else if (source[i] === "(") open.push(i);
|
|
43
|
+
else if (source[i] === ")") {
|
|
44
|
+
if (open.length === 0) {
|
|
45
|
+
return { message: "`)` has no matching `(`", position: i };
|
|
46
|
+
}
|
|
47
|
+
open.pop();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (open.length > 0) {
|
|
51
|
+
return { message: "`(` is never closed", position: open.pop() };
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
package/src/graph/ids.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node ids double as table names in the composed script (`from v1`), so they
|
|
3
|
+
* must be plain SQL identifiers: lowercase, starting with a letter.
|
|
4
|
+
*/
|
|
5
|
+
export const ID_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The conventional first letter for each kind of node. A mismatch is only a
|
|
9
|
+
* warning: the id still works, it just reads worse in the viewer and script.
|
|
10
|
+
*/
|
|
11
|
+
export function expectedPrefix(node) {
|
|
12
|
+
switch (node.step) {
|
|
13
|
+
case "source":
|
|
14
|
+
return node.kind === "external" ? "x" : "q";
|
|
15
|
+
case "view":
|
|
16
|
+
return "v";
|
|
17
|
+
case "figure":
|
|
18
|
+
return "f";
|
|
19
|
+
case "insight":
|
|
20
|
+
return "i";
|
|
21
|
+
default:
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What counts as a number in prose, for rule C2.
|
|
3
|
+
*
|
|
4
|
+
* C2 says every number a report states must sit inside a figure's span. That
|
|
5
|
+
* only works if "number" means "a claim someone could check", not "any
|
|
6
|
+
* digit". This module is that definition, on plain text; `check` decides
|
|
7
|
+
* which text to feed it (prose outside figure spans, outside bound charts
|
|
8
|
+
* and tables, outside ungrounded insights).
|
|
9
|
+
*
|
|
10
|
+
* Counts: 46.6% $61.20 4,120 +11.8% #1 3rd 2.5x $2bn 12 pp
|
|
11
|
+
* Exempt: 2026 (a bare year) 2026-08-01 (an ISO date)
|
|
12
|
+
* 30-day, 12-month (a number hyphenated to a time unit: a metric's
|
|
13
|
+
* definition, not a claim about it)
|
|
14
|
+
* Ignored: Q3, H1, FY26, B2B (digits inside a word are part of a name)
|
|
15
|
+
*
|
|
16
|
+
* Deliberately not exempt: "12-point gain" is a claim. "Over 30 days" is
|
|
17
|
+
* ambiguous and counts; the skill tells the model to write "30-day".
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const ISO_DATE = /(?<![\w-])\d{4}-\d{2}-\d{2}(?![\w-])/g;
|
|
21
|
+
|
|
22
|
+
const NUMBER =
|
|
23
|
+
/(?<![\w.,])[+\-−]?[$€£¥]?#?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?:\s?(?:%|pp|bps)|bn|st|nd|rd|th|[xkmb])?(?![\w%])/gi;
|
|
24
|
+
|
|
25
|
+
const YEAR = /^(?:19|20)\d{2}$/;
|
|
26
|
+
const TIME_QUALIFIER =
|
|
27
|
+
/^[-‑](?:second|minute|hour|day|week|month|quarter|year)s?\b/i;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Every number-like token in `text`, in order, as
|
|
31
|
+
* `{ text, index, exempt }` where `exempt` is null for a token C2 counts,
|
|
32
|
+
* or the reason it does not: "date", "year" or "qualifier".
|
|
33
|
+
*/
|
|
34
|
+
export function findNumbers(text) {
|
|
35
|
+
const tokens = [];
|
|
36
|
+
const dates = [];
|
|
37
|
+
for (const match of text.matchAll(ISO_DATE)) {
|
|
38
|
+
dates.push([match.index, match.index + match[0].length]);
|
|
39
|
+
tokens.push({ text: match[0], index: match.index, exempt: "date" });
|
|
40
|
+
}
|
|
41
|
+
const inDate = (index) =>
|
|
42
|
+
dates.some(([start, end]) => index >= start && index < end);
|
|
43
|
+
|
|
44
|
+
for (const match of text.matchAll(NUMBER)) {
|
|
45
|
+
if (inDate(match.index)) continue;
|
|
46
|
+
const token = match[0];
|
|
47
|
+
const after = text.slice(match.index + token.length);
|
|
48
|
+
let exempt = null;
|
|
49
|
+
if (YEAR.test(token)) exempt = "year";
|
|
50
|
+
else if (/^\d+$/.test(token) && TIME_QUALIFIER.test(after)) {
|
|
51
|
+
exempt = "qualifier";
|
|
52
|
+
}
|
|
53
|
+
tokens.push({ text: token, index: match.index, exempt });
|
|
54
|
+
}
|
|
55
|
+
return tokens.sort((a, b) => a.index - b.index);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Only the tokens C2 counts. */
|
|
59
|
+
export function bareNumbers(text) {
|
|
60
|
+
return findNumbers(text).filter((token) => token.exempt === null);
|
|
61
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding and reading a report's lineage graph.
|
|
3
|
+
*
|
|
4
|
+
* A graph lives in one of two places:
|
|
5
|
+
*
|
|
6
|
+
* sidecar .lineage/<report-stem>.json beside the report. The model
|
|
7
|
+
* writes this during the build; it is the working copy.
|
|
8
|
+
* embedded <script type="application/lineage+json"> inside the report.
|
|
9
|
+
* `check` writes this; it is what travels when the file is sent.
|
|
10
|
+
*
|
|
11
|
+
* Both hold the same object. When both exist the sidecar wins, because a
|
|
12
|
+
* build in progress has not been re-embedded yet.
|
|
13
|
+
*
|
|
14
|
+
* The HTML is scanned as text, not parsed: the graph block is the one element
|
|
15
|
+
* htmlineage owns, and `check` will replace it in place using the offsets
|
|
16
|
+
* returned here, without reserializing anything else in the page.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { basename, dirname, extname, join, relative } from "node:path";
|
|
21
|
+
|
|
22
|
+
import { HtmlineageError } from "../errors.js";
|
|
23
|
+
import { findDuplicateKeys } from "./duplicates.js";
|
|
24
|
+
|
|
25
|
+
export const GRAPH_MIME = "application/lineage+json";
|
|
26
|
+
export const GRAPH_ELEMENT_ID = "lineage-graph";
|
|
27
|
+
export const SIDECAR_DIR = ".lineage";
|
|
28
|
+
|
|
29
|
+
/** `reports/aug.html` → `reports/.lineage/aug.json`. The report need not exist. */
|
|
30
|
+
export function sidecarPath(reportPath) {
|
|
31
|
+
const stem = basename(reportPath, extname(reportPath));
|
|
32
|
+
return join(dirname(reportPath), SIDECAR_DIR, `${stem}.json`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const OPEN_SCRIPT = /<script\b[^>]*>/gi;
|
|
36
|
+
// HTML ends a script element only at `</script` followed by whitespace, `/`,
|
|
37
|
+
// `>` or the end of input, so `</scripture>` inside the JSON does not close it.
|
|
38
|
+
const CLOSE_SCRIPT = /<\/script(?=[\s/>]|$)/gi;
|
|
39
|
+
const TYPE_ATTR =
|
|
40
|
+
/\stype\s*=\s*(["']?)application\/lineage\+json\1(?=[\s/>]|$)/i;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Locate the embedded graph block in an HTML string. Returns null when there
|
|
44
|
+
* is none, or `{ start, end, contentStart, contentEnd, content }` where
|
|
45
|
+
* start..end spans the whole element and contentStart..contentEnd its JSON.
|
|
46
|
+
* Throws when the page holds more than one block, since then it is unclear
|
|
47
|
+
* which one is the report's lineage.
|
|
48
|
+
*/
|
|
49
|
+
export function findEmbeddedGraph(html) {
|
|
50
|
+
const blocks = [];
|
|
51
|
+
for (const match of html.matchAll(OPEN_SCRIPT)) {
|
|
52
|
+
if (!TYPE_ATTR.test(match[0])) continue;
|
|
53
|
+
const contentStart = match.index + match[0].length;
|
|
54
|
+
CLOSE_SCRIPT.lastIndex = contentStart;
|
|
55
|
+
const contentEnd = CLOSE_SCRIPT.exec(html)?.index ?? -1;
|
|
56
|
+
if (contentEnd === -1) {
|
|
57
|
+
throw new HtmlineageError(
|
|
58
|
+
`the embedded lineage graph at ${lineCol(html, match.index)} is never closed with </script>`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const closeEnd = html.indexOf(">", contentEnd);
|
|
62
|
+
blocks.push({
|
|
63
|
+
start: match.index,
|
|
64
|
+
end: closeEnd === -1 ? html.length : closeEnd + 1,
|
|
65
|
+
contentStart,
|
|
66
|
+
contentEnd,
|
|
67
|
+
content: html.slice(contentStart, contentEnd),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (blocks.length > 1) {
|
|
71
|
+
const where = blocks.map((b) => lineCol(html, b.start)).join(", ");
|
|
72
|
+
throw new HtmlineageError(
|
|
73
|
+
`the report holds ${blocks.length} embedded lineage graphs (at ${where}); expected one`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return blocks[0] ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Turn a graph into the text that goes inside the embedded <script>. Every
|
|
81
|
+
* `<` is escaped so that SQL containing `</script>` or `<!--` cannot end the
|
|
82
|
+
* block early; JSON.parse reads the escape back as the same character.
|
|
83
|
+
*/
|
|
84
|
+
export function serializeForEmbed(graph) {
|
|
85
|
+
return JSON.stringify(graph, null, 2).replace(/</g, "\\u003c");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse graph JSON, turning a syntax error into one that names the file and
|
|
90
|
+
* the line and column. `offset` is where `text` starts inside `source`, so
|
|
91
|
+
* positions inside an embedded block are reported as positions in the page.
|
|
92
|
+
* A key repeated within one object is also an error: JSON.parse would keep
|
|
93
|
+
* the second and silently drop the first, losing a node or a field.
|
|
94
|
+
*/
|
|
95
|
+
export function parseGraphText(text, { label, source = text, offset = 0 }) {
|
|
96
|
+
let graph;
|
|
97
|
+
try {
|
|
98
|
+
graph = JSON.parse(text);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
101
|
+
const position = /\bposition (\d+)/.exec(error.message);
|
|
102
|
+
const where = position
|
|
103
|
+
? ` at ${lineCol(source, offset + Number(position[1]))}`
|
|
104
|
+
: "";
|
|
105
|
+
const reason = error.message.replace(
|
|
106
|
+
/\s*(in JSON )?at position \d+.*$/,
|
|
107
|
+
"",
|
|
108
|
+
);
|
|
109
|
+
throw new HtmlineageError(`${label} is not valid JSON${where}: ${reason}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const duplicates = findDuplicateKeys(text);
|
|
113
|
+
if (duplicates.length > 0) {
|
|
114
|
+
const lines = duplicates.map(
|
|
115
|
+
({ path, first, second }) =>
|
|
116
|
+
` \`${path}\` at ${lineOf(source, offset + first)} and ${lineOf(source, offset + second)}`,
|
|
117
|
+
);
|
|
118
|
+
throw new HtmlineageError(
|
|
119
|
+
`${label} defines the same key twice, so the first would be silently dropped:\n${lines.join("\n")}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return graph;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Read a report's graph. With `graphPath`, read exactly that file. Otherwise
|
|
127
|
+
* try the sidecar, then the block embedded in the report. Returns
|
|
128
|
+
* `{ graph, origin: "file" | "sidecar" | "embedded", path }`.
|
|
129
|
+
*/
|
|
130
|
+
export function readGraph({ reportPath, graphPath } = {}) {
|
|
131
|
+
if (graphPath) {
|
|
132
|
+
return { graph: readJsonFile(graphPath), origin: "file", path: graphPath };
|
|
133
|
+
}
|
|
134
|
+
if (!reportPath) {
|
|
135
|
+
throw new HtmlineageError("no report or graph file given");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const sidecar = sidecarPath(reportPath);
|
|
139
|
+
if (existsSync(sidecar)) {
|
|
140
|
+
return { graph: readJsonFile(sidecar), origin: "sidecar", path: sidecar };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const html = readText(reportPath, "report");
|
|
144
|
+
const block = findEmbeddedGraph(html);
|
|
145
|
+
if (!block) {
|
|
146
|
+
throw new HtmlineageError(
|
|
147
|
+
`no lineage graph found for ${display(reportPath)}. Looked for ` +
|
|
148
|
+
`${display(sidecar)} and a <script type="${GRAPH_MIME}"> block in the report.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const graph = parseGraphText(block.content, {
|
|
152
|
+
label: `the lineage graph embedded in ${display(reportPath)}`,
|
|
153
|
+
source: html,
|
|
154
|
+
offset: block.contentStart,
|
|
155
|
+
});
|
|
156
|
+
return { graph, origin: "embedded", path: reportPath };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function readJsonFile(path) {
|
|
160
|
+
return parseGraphText(readText(path, "graph file"), { label: display(path) });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Read a file as text; failures become `HtmlineageError`s worded for the builder. */
|
|
164
|
+
export function readText(path, what) {
|
|
165
|
+
try {
|
|
166
|
+
return readFileSync(path, "utf8");
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error.code === "ENOENT") {
|
|
169
|
+
throw new HtmlineageError(`${what} not found: ${display(path)}`);
|
|
170
|
+
}
|
|
171
|
+
throw new HtmlineageError(
|
|
172
|
+
`could not read ${display(path)}: ${error.message}`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function display(path) {
|
|
178
|
+
const rel = relative(process.cwd(), path);
|
|
179
|
+
return rel && !rel.startsWith("..") ? rel : path;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function lineOf(text, index) {
|
|
183
|
+
return lineCol(text, index).replace(/, column \d+$/, "");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function lineCol(text, index) {
|
|
187
|
+
let line = 1;
|
|
188
|
+
let lineStart = 0;
|
|
189
|
+
for (let i = 0; i < index && i < text.length; i++) {
|
|
190
|
+
if (text[i] === "\n") {
|
|
191
|
+
line++;
|
|
192
|
+
lineStart = i + 1;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return `line ${line}, column ${index - lineStart + 1}`;
|
|
196
|
+
}
|