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,45 @@
1
+ /**
2
+ * Everything the viewer panel shows for one report, in one object. Reads files
3
+ * and nothing else: no subprocess, no writes.
4
+ */
5
+
6
+ import { basename } from "node:path";
7
+
8
+ import { checkRules } from "../check/rules.js";
9
+ import { summarize } from "../check/summary.js";
10
+ import { readGraph, readText } from "../graph/parse.js";
11
+ import { validateGraph } from "../graph/schema.js";
12
+ import { traceGraph } from "../graph/trace.js";
13
+ import { proseSkip, readPage } from "../html/page.js";
14
+ import { composeSql, isEmptyScript, refusal } from "../sql/compose.js";
15
+
16
+ /**
17
+ * @param {{ reportPath: string, graphPath?: string }} options as `readGraph`
18
+ * @returns {object} the report name and HTML, its graph, trace states, check
19
+ * summary and composed SQL
20
+ */
21
+ export function buildPayload({ reportPath, graphPath }) {
22
+ const { graph } = readGraph({ reportPath, graphPath });
23
+ const html = readText(reportPath, "report");
24
+ const page = readPage(html, { skip: proseSkip(graph) });
25
+ const issues = [...validateGraph(graph), ...checkRules(graph, page)];
26
+ const report = basename(reportPath);
27
+ const refused = issues.some((issue) => issue.severity === "error")
28
+ ? refusal(report)
29
+ : null;
30
+ const ids = refused === null ? Object.keys(graph.nodes) : [];
31
+ return {
32
+ report,
33
+ html,
34
+ graph,
35
+ trace: Object.fromEntries(traceGraph(graph)),
36
+ summary: summarize(graph, page, issues, { report }),
37
+ composedReport: refused === null ? composeSql(graph, { report }) : null,
38
+ composed: Object.fromEntries(
39
+ ids
40
+ .map((id) => [id, composeSql(graph, { report, id })])
41
+ .filter(([, script]) => !isEmptyScript(script)),
42
+ ),
43
+ refused,
44
+ };
45
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The viewer's local web server: the panel page, its assets, the payload and
3
+ * the report with the files beside it, on 127.0.0.1 only.
4
+ */
5
+
6
+ import { spawn } from "node:child_process";
7
+ import { readFileSync, realpathSync } from "node:fs";
8
+ import http from "node:http";
9
+ import { dirname, extname, isAbsolute, join, relative, sep } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ import { HtmlineageError } from "../errors.js";
13
+ import { buildPayload } from "./payload.js";
14
+
15
+ const CLIENT = fileURLToPath(new URL("./client/", import.meta.url));
16
+ const BRIDGE = readFileSync(join(CLIENT, "bridge.js"), "utf8");
17
+
18
+ const TYPES = {
19
+ ".html": "text/html; charset=utf-8",
20
+ ".css": "text/css; charset=utf-8",
21
+ ".js": "text/javascript; charset=utf-8",
22
+ ".mjs": "text/javascript; charset=utf-8",
23
+ ".json": "application/json; charset=utf-8",
24
+ ".svg": "image/svg+xml",
25
+ ".jpg": "image/jpeg",
26
+ ".jpeg": "image/jpeg",
27
+ ".gif": "image/gif",
28
+ ".webp": "image/webp",
29
+ ".png": "image/png",
30
+ ".csv": "text/csv; charset=utf-8",
31
+ ".txt": "text/plain; charset=utf-8",
32
+ ".woff2": "font/woff2",
33
+ };
34
+
35
+ /** Everything is `no-store`: the report and payload are re-read on reload. */
36
+ function send(res, status, type, body) {
37
+ res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
38
+ res.end(body);
39
+ }
40
+
41
+ /**
42
+ * The bytes of the file a request names under `root`, or null when there is
43
+ * none: the name has a part starting with a dot (`..`, `.env`, `.git/`), or
44
+ * climbs out of `root` through a symlink, or the file is missing or is a
45
+ * folder. Backslashes count as separators so the answer is the same on
46
+ * Windows.
47
+ */
48
+ function readUnder(root, encoded) {
49
+ try {
50
+ const name = decodeURIComponent(encoded);
51
+ if (name.split(/[\\/]/).some((part) => part.startsWith("."))) return null;
52
+ const real = realpathSync(join(root, name));
53
+ const inside = relative(realpathSync(root), real);
54
+ if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside))
55
+ return null;
56
+ return { file: real, body: readFileSync(real) };
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The report's HTML with the bridge script inline: right before `</head>`, or
64
+ * at the end when there is none.
65
+ */
66
+ function withBridge(html) {
67
+ const tag = `<script>${BRIDGE}</script>`;
68
+ if (!/<\/head>/i.test(html)) return html + tag;
69
+ return html.replace(/<\/head>/i, (head) => `${tag}${head}`);
70
+ }
71
+
72
+ /** Send a file; with `reportPath`, that file gets the bridge. */
73
+ function sendFile(res, root, encoded, reportPath) {
74
+ const found = readUnder(root, encoded);
75
+ if (found === null) return sendText(res, 404, "not found");
76
+ const type = TYPES[extname(found.file)] ?? "application/octet-stream";
77
+ const body =
78
+ reportPath && found.file === realpathSync(reportPath)
79
+ ? withBridge(found.body.toString())
80
+ : found.body;
81
+ send(res, 200, type, body);
82
+ }
83
+
84
+ function sendText(res, status, text) {
85
+ send(res, status, "text/plain; charset=utf-8", text);
86
+ }
87
+
88
+ /**
89
+ * Listen on `port`, or with `exact` false on the first free port above it.
90
+ * Resolves the port it got.
91
+ */
92
+ async function listen(server, port, exact) {
93
+ for (let candidate = port; candidate <= 65535; candidate++) {
94
+ try {
95
+ await new Promise((resolve, reject) => {
96
+ server.once("error", reject);
97
+ server.listen(candidate, "127.0.0.1", () => {
98
+ server.off("error", reject);
99
+ resolve();
100
+ });
101
+ });
102
+ return server.address().port;
103
+ } catch (error) {
104
+ if (error.code !== "EADDRINUSE") throw error;
105
+ if (exact) throw new HtmlineageError(`port ${candidate} is in use`);
106
+ }
107
+ }
108
+ throw new HtmlineageError(`no free port from ${port}; pass --port`);
109
+ }
110
+
111
+ /**
112
+ * @param {{ reportPath: string, graphPath?: string, port: number, exact?: boolean }} options
113
+ * @returns {Promise<{ url: string, port: number, close: () => Promise<void> }>}
114
+ */
115
+ export async function startServer({
116
+ reportPath,
117
+ graphPath,
118
+ port,
119
+ exact = false,
120
+ }) {
121
+ let actual;
122
+ const server = http.createServer((req, res) => {
123
+ // Only our own address names us; a rebound DNS name does not.
124
+ const host = req.headers.host;
125
+ if (host !== `127.0.0.1:${actual}` && host !== `localhost:${actual}`) {
126
+ return sendText(res, 403, "forbidden");
127
+ }
128
+ const path = req.url.split("?")[0];
129
+ if (path === "/") {
130
+ sendFile(res, CLIENT, "index.html");
131
+ } else if (path === "/data.json") {
132
+ try {
133
+ const payload = buildPayload({ reportPath, graphPath });
134
+ send(res, 200, TYPES[".json"], JSON.stringify(payload));
135
+ } catch (error) {
136
+ if (!(error instanceof HtmlineageError)) throw error;
137
+ sendText(res, 500, error.message);
138
+ }
139
+ } else if (path.startsWith("/report/")) {
140
+ sendFile(
141
+ res,
142
+ dirname(reportPath),
143
+ path.slice("/report/".length),
144
+ reportPath,
145
+ );
146
+ } else if (path.startsWith("/assets/")) {
147
+ sendFile(res, CLIENT, path.slice("/assets/".length));
148
+ } else {
149
+ sendText(res, 404, "not found");
150
+ }
151
+ });
152
+ actual = await listen(server, port, exact);
153
+ return {
154
+ url: `http://127.0.0.1:${actual}/`,
155
+ port: actual,
156
+ close: () => {
157
+ const closed = new Promise((resolve) => server.close(resolve));
158
+ // Node before 19 keeps idle keep-alive connections open, and a browser
159
+ // always holds one.
160
+ server.closeAllConnections();
161
+ return closed;
162
+ },
163
+ };
164
+ }
165
+
166
+ /** The command that opens `url` in the user's browser on `platform`. */
167
+ export function browserCommand(platform, url) {
168
+ if (platform === "darwin") return { command: "open", args: [url] };
169
+ if (platform === "win32") {
170
+ return { command: "cmd", args: ["/c", "start", "", url] };
171
+ }
172
+ return { command: "xdg-open", args: [url] };
173
+ }
174
+
175
+ /**
176
+ * Start `command` without waiting for the browser to exit. Resolves false
177
+ * rather than throwing when it cannot start: the URL is already printed.
178
+ */
179
+ export function openBrowser({ command, args }) {
180
+ return new Promise((resolve) => {
181
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
182
+ child.once("error", () => resolve(false));
183
+ child.once("spawn", () => {
184
+ child.unref();
185
+ resolve(true);
186
+ });
187
+ });
188
+ }