@mauroandre/velojs 0.0.38 → 0.0.40
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/agent/skills/velojs-project-graph/SKILL.md +122 -0
- package/dist/cli.js +49 -33
- package/dist/graph-viewer.d.ts +13 -0
- package/dist/graph-viewer.js +378 -0
- package/dist/graph.d.ts +37 -0
- package/dist/graph.js +202 -0
- package/dist/init.js +16 -7
- package/dist/vite.js +279 -248
- package/package.json +1 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: velojs-project-graph
|
|
3
|
+
description: "Use `.velojs/graph.json` to understand the project's route tree and module dependency graph before reading any source files. Always consult the graph first when exploring a VeloJS codebase. Generate it with `velojs graph` if missing."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Project Graph
|
|
7
|
+
|
|
8
|
+
VeloJS can generate a structural snapshot of your project — the route tree hierarchy, module dependency graph, and convention exports — as a single JSON file at `.velojs/graph.json`. This file gives an AI agent (or any tool) a complete map of the codebase in a few KB, replacing dozens of exploratory file reads.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
Always. Before reading any source file in a VeloJS project, query `.velojs/graph.json` for the specific information you need. The full file can be large; use `jq` for targeted queries.
|
|
13
|
+
|
|
14
|
+
## Generating the graph
|
|
15
|
+
|
|
16
|
+
If `.velojs/graph.json` does not exist, generate it:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
velojs graph
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This runs a fast Babel-based crawl of `app/routes.tsx` and its transitive imports. No build. No Vite. Sub-second. Works even if the project does not compile.
|
|
23
|
+
|
|
24
|
+
The graph is also regenerated automatically on every `velojs build` (via the `velo:graph` plugin) and on every file change during `velojs dev`.
|
|
25
|
+
|
|
26
|
+
## Querying the graph
|
|
27
|
+
|
|
28
|
+
The graph is a compact JSON file (no whitespace). Do not read it in full — use `jq` for targeted queries:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# A single module — what it exports, what it imports, who imports it
|
|
32
|
+
jq '.modules["auth/Login"]' .velojs/graph.json
|
|
33
|
+
|
|
34
|
+
# The route tree (hierarchy of layouts + pages with paths and middlewares)
|
|
35
|
+
jq '.routes' .velojs/graph.json
|
|
36
|
+
|
|
37
|
+
# All modules that export a loader
|
|
38
|
+
jq '.modules | to_entries[] | select(.value.exports.hasLoader) | .key' .velojs/graph.json
|
|
39
|
+
|
|
40
|
+
# Impact analysis: what breaks if I change this service?
|
|
41
|
+
jq '.modules["modules/respondent/respondent.service"] | .importedBy' .velojs/graph.json
|
|
42
|
+
|
|
43
|
+
# All actions in the project (module + action name)
|
|
44
|
+
jq '.modules | to_entries[] | .key as $k | .value.exports.actions[] | "\($k): action_\(.)"' .velojs/graph.json
|
|
45
|
+
|
|
46
|
+
# Modules imported by a specific page
|
|
47
|
+
jq '.modules["admin/DepartmentDetail"].imports' .velojs/graph.json
|
|
48
|
+
|
|
49
|
+
# Which pages use a shared component?
|
|
50
|
+
jq -r '.modules["components/Inputs"].importedBy[]' .velojs/graph.json \
|
|
51
|
+
| while read id; do jq -r ".modules[\"$id\"].kind" .velojs/graph.json; done \
|
|
52
|
+
| sort | uniq -c
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Graph structure
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
interface GraphJson {
|
|
59
|
+
routes: RouteTreeNode[];
|
|
60
|
+
modules: Record<string, ModuleInfo>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface RouteTreeNode {
|
|
64
|
+
moduleId: string | null; // e.g. "pages/Home", null if no module
|
|
65
|
+
fullPath: string | null; // e.g. "/admin/users/:id"
|
|
66
|
+
path: string | null; // e.g. "/users/:id"
|
|
67
|
+
isRoot: boolean; // root layout (renders <html>)
|
|
68
|
+
middlewares: string[]; // middleware identifiers
|
|
69
|
+
children: RouteTreeNode[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ModuleInfo {
|
|
73
|
+
file: string; // relative path from project root
|
|
74
|
+
kind: "root" | "layout" | "page" | "unknown";
|
|
75
|
+
exports: {
|
|
76
|
+
hasComponent: boolean;
|
|
77
|
+
hasLoader: boolean;
|
|
78
|
+
actions: string[]; // e.g. ["save", "delete"]
|
|
79
|
+
streams: string[]; // e.g. ["progress", "logs"]
|
|
80
|
+
sockets: string[]; // e.g. ["terminal"]
|
|
81
|
+
};
|
|
82
|
+
imports: string[]; // moduleIds this module depends on
|
|
83
|
+
importedBy: string[]; // moduleIds that depend on this module
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Common queries
|
|
88
|
+
|
|
89
|
+
### "Which routes use this loader?"
|
|
90
|
+
|
|
91
|
+
1. Find the loader's module in `modules` → note its `moduleId`
|
|
92
|
+
2. Search `routes` recursively for nodes where `moduleId` matches
|
|
93
|
+
3. The matching node's ancestors are the layouts wrapping it
|
|
94
|
+
|
|
95
|
+
### "What breaks if I change this file?"
|
|
96
|
+
|
|
97
|
+
1. Find the module in `modules[moduleId]`
|
|
98
|
+
2. Follow `importedBy` — these modules import it
|
|
99
|
+
3. Transitively follow `importedBy` on those modules
|
|
100
|
+
4. Filter to modules where `kind` is `"page"` to see which pages are affected
|
|
101
|
+
|
|
102
|
+
### "What actions does this page expose?"
|
|
103
|
+
|
|
104
|
+
1. Find the page in `modules[moduleId]`
|
|
105
|
+
2. Read `exports.actions` — e.g. `["save", "delete"]`
|
|
106
|
+
3. These are the `action_*` exports callable from the client
|
|
107
|
+
|
|
108
|
+
### "Where is the auth middleware applied?"
|
|
109
|
+
|
|
110
|
+
1. Search `routes` recursively for nodes where `middlewares` includes the auth middleware name
|
|
111
|
+
2. The matching nodes and their children are protected
|
|
112
|
+
|
|
113
|
+
### "Which modules have no tests?"
|
|
114
|
+
|
|
115
|
+
Compare `modules` keys against the `tests/` directory. Modules with no corresponding test file are candidates for new tests.
|
|
116
|
+
|
|
117
|
+
## Notes
|
|
118
|
+
|
|
119
|
+
- External imports (`@mauroandre/velojs`, `preact`, `wouter-preact`) are recorded in `imports` but not crawled
|
|
120
|
+
- `export function action_*` is NOT detected — only `export const action_*` (matches framework behavior)
|
|
121
|
+
- Path-less layouts (no `path` property) have `fullPath: ""` and `path: ""`
|
|
122
|
+
- The graph is regenerated on every build and on file changes during dev — never stale
|
package/dist/cli.js
CHANGED
|
@@ -1,43 +1,44 @@
|
|
|
1
|
-
import { spawnSync as
|
|
2
|
-
import { resolve as
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { spawnSync as c } from "node:child_process";
|
|
2
|
+
import { join as n, resolve as u } from "node:path";
|
|
3
|
+
import p from "node:fs";
|
|
4
|
+
const o = process.argv.slice(2), a = o[0], v = (e, s = []) => {
|
|
5
|
+
const t = c(e, s, {
|
|
5
6
|
stdio: "inherit",
|
|
6
7
|
cwd: process.cwd()
|
|
7
8
|
});
|
|
8
|
-
process.exit(
|
|
9
|
-
},
|
|
10
|
-
|
|
11
|
-
},
|
|
12
|
-
const
|
|
9
|
+
process.exit(t.status ?? 0);
|
|
10
|
+
}, m = () => {
|
|
11
|
+
v("npx", ["vite", ...o.slice(1)]);
|
|
12
|
+
}, j = (e) => {
|
|
13
|
+
const s = Date.now().toString(36), t = {
|
|
13
14
|
...process.env,
|
|
14
|
-
VELO_BUILD_HASH:
|
|
15
|
+
VELO_BUILD_HASH: s,
|
|
15
16
|
...e ? { VELO_STATIC: "1" } : {}
|
|
16
17
|
};
|
|
17
18
|
console.log("Building client...");
|
|
18
|
-
const
|
|
19
|
+
const r = c("npx", ["vite", "build"], {
|
|
19
20
|
stdio: "inherit",
|
|
20
21
|
cwd: process.cwd(),
|
|
21
|
-
env:
|
|
22
|
+
env: t
|
|
22
23
|
});
|
|
23
|
-
|
|
24
|
-
const
|
|
24
|
+
r.status !== 0 && (console.error("Client build failed. Aborting."), process.exit(r.status ?? 1)), console.log("Building server...");
|
|
25
|
+
const i = c("npx", ["vite", "build", "--mode", "server"], {
|
|
25
26
|
stdio: "inherit",
|
|
26
27
|
cwd: process.cwd(),
|
|
27
|
-
env:
|
|
28
|
+
env: t
|
|
28
29
|
});
|
|
29
|
-
|
|
30
|
-
},
|
|
30
|
+
i.status !== 0 && (console.error("Server build failed. Aborting."), process.exit(i.status ?? 1)), console.log("Build complete!"), e && b();
|
|
31
|
+
}, b = async () => {
|
|
31
32
|
process.env.NODE_ENV = "production", process.env.VELO_STATIC = "1";
|
|
32
|
-
const
|
|
33
|
-
(!
|
|
34
|
-
const { generateStatic:
|
|
35
|
-
await
|
|
36
|
-
},
|
|
33
|
+
const s = await import(u(process.cwd(), "dist/server.js")), t = s.default, r = s.routes;
|
|
34
|
+
(!t || !r) && (console.error("Failed to load app or routes from dist/server.js"), process.exit(1));
|
|
35
|
+
const { generateStatic: i } = await import("./static.js");
|
|
36
|
+
await i(t, r);
|
|
37
|
+
}, f = () => {
|
|
37
38
|
process.env.NODE_ENV = "production";
|
|
38
|
-
const e =
|
|
39
|
-
|
|
40
|
-
},
|
|
39
|
+
const e = u(process.cwd(), "dist/server.js");
|
|
40
|
+
v("node", [e]);
|
|
41
|
+
}, d = () => {
|
|
41
42
|
console.log(`
|
|
42
43
|
VeloJS CLI
|
|
43
44
|
|
|
@@ -50,6 +51,8 @@ Commands:
|
|
|
50
51
|
build Build for production (client + server)
|
|
51
52
|
build --static Build as static site (HTML + JSON)
|
|
52
53
|
start Start production server
|
|
54
|
+
graph [app-dir] Generate .velojs/graph.json (route tree + dependency graph)
|
|
55
|
+
graph --view Generate the graph and open an interactive viewer
|
|
53
56
|
|
|
54
57
|
Examples:
|
|
55
58
|
velojs init my-app
|
|
@@ -57,30 +60,43 @@ Examples:
|
|
|
57
60
|
velojs build
|
|
58
61
|
velojs build --static
|
|
59
62
|
velojs start
|
|
63
|
+
velojs graph
|
|
64
|
+
velojs graph src/app
|
|
65
|
+
velojs graph --view
|
|
60
66
|
`);
|
|
61
67
|
};
|
|
62
|
-
switch (
|
|
68
|
+
switch (a) {
|
|
63
69
|
case "init": {
|
|
64
70
|
const { runInit: e } = await import("./init.js");
|
|
65
|
-
await e(
|
|
71
|
+
await e(o[1]);
|
|
66
72
|
break;
|
|
67
73
|
}
|
|
68
74
|
case "dev":
|
|
69
|
-
|
|
75
|
+
m();
|
|
70
76
|
break;
|
|
71
77
|
case "build": {
|
|
72
|
-
const e =
|
|
73
|
-
|
|
78
|
+
const e = o.includes("--static");
|
|
79
|
+
j(e);
|
|
74
80
|
break;
|
|
75
81
|
}
|
|
76
82
|
case "start":
|
|
77
|
-
|
|
83
|
+
f();
|
|
84
|
+
break;
|
|
85
|
+
case "graph": {
|
|
86
|
+
const { buildGraph: e } = await import("./graph.js"), s = process.cwd(), t = o[1] && !o[1].startsWith("-") ? o[1] : null, r = n(s, t ?? "app"), i = e(r), l = n(s, ".velojs");
|
|
87
|
+
p.mkdirSync(l, { recursive: !0 });
|
|
88
|
+
const w = n(l, "graph.json");
|
|
89
|
+
if (p.writeFileSync(w, JSON.stringify(i)), console.log("Graph written to .velojs/graph.json"), o.includes("--view")) {
|
|
90
|
+
const { serveGraphViewer: g } = await import("./graph-viewer.js"), h = await g(s);
|
|
91
|
+
console.log(`Graph viewer running at ${h} (Ctrl+C to stop)`);
|
|
92
|
+
}
|
|
78
93
|
break;
|
|
94
|
+
}
|
|
79
95
|
case "help":
|
|
80
96
|
case "--help":
|
|
81
97
|
case "-h":
|
|
82
|
-
|
|
98
|
+
d();
|
|
83
99
|
break;
|
|
84
100
|
default:
|
|
85
|
-
|
|
101
|
+
a && console.error(`Unknown command: ${a}`), d(), process.exit(1);
|
|
86
102
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VeloJS Graph Viewer
|
|
3
|
+
*
|
|
4
|
+
* Serves an interactive, self-contained HTML visualization of
|
|
5
|
+
* .velojs/graph.json — route tree + force-directed dependency graph.
|
|
6
|
+
* Plain node:http, no dependencies. The graph JSON is read from disk
|
|
7
|
+
* on every request so the view is always fresh.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Starts the graph viewer server. Reads `.velojs/graph.json` from
|
|
11
|
+
* projectRoot on every request — always fresh. Returns the URL.
|
|
12
|
+
*/
|
|
13
|
+
export declare function serveGraphViewer(projectRoot: string): Promise<string>;
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import r from "node:http";
|
|
2
|
+
import d from "node:fs";
|
|
3
|
+
import l from "node:path";
|
|
4
|
+
const s = `<!DOCTYPE html>
|
|
5
|
+
<html lang="en">
|
|
6
|
+
<head>
|
|
7
|
+
<meta charset="utf-8">
|
|
8
|
+
<title>VeloJS Graph</title>
|
|
9
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
10
|
+
<style>
|
|
11
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
12
|
+
body { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; background: #0b0e14; color: #d7dde8; overflow: hidden; }
|
|
13
|
+
#topbar { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: #11151f; border-bottom: 1px solid #232a3a; }
|
|
14
|
+
#topbar h1 { font-size: 14px; font-weight: 600; color: #e8edf5; }
|
|
15
|
+
#topbar h1 span { color: #5b8def; }
|
|
16
|
+
.tab { padding: 5px 14px; border-radius: 6px; border: 1px solid #2a3247; background: transparent; color: #9aa5b8; font: inherit; font-size: 12px; cursor: pointer; }
|
|
17
|
+
.tab.active { background: #1d2740; color: #e8edf5; border-color: #3d5a99; }
|
|
18
|
+
#search { margin-left: auto; padding: 5px 10px; border-radius: 6px; border: 1px solid #2a3247; background: #0b0e14; color: #d7dde8; font: inherit; font-size: 12px; width: 220px; }
|
|
19
|
+
#search:focus { outline: none; border-color: #3d5a99; }
|
|
20
|
+
#canvas { display: block; cursor: grab; }
|
|
21
|
+
#panel { position: fixed; top: 46px; right: 0; width: 330px; bottom: 0; background: #11151f; border-left: 1px solid #232a3a; padding: 16px; overflow-y: auto; transform: translateX(100%); transition: transform .18s ease; font-size: 12px; }
|
|
22
|
+
#panel.open { transform: translateX(0); }
|
|
23
|
+
#panel h2 { font-size: 14px; color: #e8edf5; word-break: break-all; margin-bottom: 4px; }
|
|
24
|
+
#panel .file { color: #6b768c; font-size: 11px; margin-bottom: 12px; word-break: break-all; }
|
|
25
|
+
#panel h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: #6b768c; margin: 14px 0 6px; }
|
|
26
|
+
#panel li { list-style: none; padding: 3px 0; color: #aebad0; word-break: break-all; cursor: pointer; }
|
|
27
|
+
#panel li:hover { color: #5b8def; }
|
|
28
|
+
#panel li.ext { color: #6b768c; cursor: default; }
|
|
29
|
+
#panel li.ext:hover { color: #6b768c; }
|
|
30
|
+
.badge { display: inline-block; padding: 1px 7px; border-radius: 4px; font-size: 10px; margin-right: 5px; background: #1d2740; color: #9db8ef; border: 1px solid #2f3d5f; }
|
|
31
|
+
#legend { position: fixed; left: 14px; bottom: 14px; background: #11151fdd; border: 1px solid #232a3a; border-radius: 8px; padding: 10px 14px; font-size: 11px; color: #9aa5b8; }
|
|
32
|
+
#legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; }
|
|
33
|
+
#hint { position: fixed; left: 50%; transform: translateX(-50%); bottom: 14px; font-size: 11px; color: #55607a; }
|
|
34
|
+
</style>
|
|
35
|
+
</head>
|
|
36
|
+
<body>
|
|
37
|
+
<div id="topbar">
|
|
38
|
+
<h1>velo<span>js</span> graph</h1>
|
|
39
|
+
<button class="tab active" id="tab-routes">Route tree</button>
|
|
40
|
+
<button class="tab" id="tab-modules">Dependencies</button>
|
|
41
|
+
<input id="search" placeholder="filter modules... (/)">
|
|
42
|
+
</div>
|
|
43
|
+
<canvas id="canvas"></canvas>
|
|
44
|
+
<div id="panel"></div>
|
|
45
|
+
<div id="legend">
|
|
46
|
+
<div><i style="background:#f59e0b"></i>root</div>
|
|
47
|
+
<div><i style="background:#a78bfa"></i>layout</div>
|
|
48
|
+
<div><i style="background:#5b8def"></i>page</div>
|
|
49
|
+
<div><i style="background:#4b5565"></i>module</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div id="hint">drag to pan · scroll to zoom · drag nodes · hover = impact highlight · / = search</div>
|
|
52
|
+
<script>
|
|
53
|
+
var KIND_COLORS = { root: "#f59e0b", layout: "#a78bfa", page: "#5b8def", unknown: "#4b5565" };
|
|
54
|
+
|
|
55
|
+
var graph = null;
|
|
56
|
+
var view = "routes";
|
|
57
|
+
var cam = { x: 0, y: 0, k: 1 };
|
|
58
|
+
var canvas = document.getElementById("canvas");
|
|
59
|
+
var ctx = canvas.getContext("2d");
|
|
60
|
+
var W = 0, H = 0;
|
|
61
|
+
|
|
62
|
+
function resize() {
|
|
63
|
+
W = window.innerWidth; H = window.innerHeight;
|
|
64
|
+
canvas.width = W * devicePixelRatio; canvas.height = H * devicePixelRatio;
|
|
65
|
+
canvas.style.width = W + "px"; canvas.style.height = H + "px";
|
|
66
|
+
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
|
|
67
|
+
}
|
|
68
|
+
window.addEventListener("resize", resize); resize();
|
|
69
|
+
|
|
70
|
+
// ---------- data ----------
|
|
71
|
+
var modNodes = [], modEdges = [], modById = {};
|
|
72
|
+
var routeNodes = [], routeEdges = [];
|
|
73
|
+
|
|
74
|
+
function buildModuleGraph() {
|
|
75
|
+
var ids = Object.keys(graph.modules);
|
|
76
|
+
ids.forEach(function (id, i) {
|
|
77
|
+
var m = graph.modules[id];
|
|
78
|
+
modNodes.push({ id: id, kind: m.kind, exports: m.exports, imports: m.imports, importedBy: m.importedBy, file: m.file,
|
|
79
|
+
x: (Math.random() - 0.5) * 800, y: (Math.random() - 0.5) * 800, vx: 0, vy: 0 });
|
|
80
|
+
});
|
|
81
|
+
modNodes.forEach(function (n) { modById[n.id] = n; });
|
|
82
|
+
modNodes.forEach(function (n) {
|
|
83
|
+
n.imports.forEach(function (imp) {
|
|
84
|
+
if (modById[imp]) modEdges.push({ from: n, to: modById[imp] });
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildRouteLayout() {
|
|
90
|
+
var leafCount = 0;
|
|
91
|
+
// tidy-ish: leaves get sequential x; parents sit at the midpoint of children
|
|
92
|
+
function layout(node, depth, parent) {
|
|
93
|
+
var rn = { data: node, depth: depth, x: 0, y: depth, parent: parent };
|
|
94
|
+
routeNodes.push(rn);
|
|
95
|
+
if (parent) routeEdges.push({ from: parent, to: rn });
|
|
96
|
+
if (!node.children.length) { rn.x = leafCount++; return rn; }
|
|
97
|
+
var xs = node.children.map(function (c) { return layout(c, depth + 1, rn).x; });
|
|
98
|
+
rn.x = (xs[0] + xs[xs.length - 1]) / 2;
|
|
99
|
+
return rn;
|
|
100
|
+
}
|
|
101
|
+
graph.routes.forEach(function (r) { layout(r, 0, null); });
|
|
102
|
+
var gapX = 210, gapY = 110;
|
|
103
|
+
var minX = Math.min.apply(null, routeNodes.map(function (n) { return n.x; }));
|
|
104
|
+
routeNodes.forEach(function (n) { n.px = (n.x - minX) * gapX + 80; n.py = n.y * gapY + 80; });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------- camera helpers ----------
|
|
108
|
+
function fitView(xs, ys) {
|
|
109
|
+
var minX = Math.min.apply(null, xs), maxX = Math.max.apply(null, xs);
|
|
110
|
+
var minY = Math.min.apply(null, ys), maxY = Math.max.apply(null, ys);
|
|
111
|
+
var w = Math.max(maxX - minX, 1), h = Math.max(maxY - minY, 1);
|
|
112
|
+
cam.k = Math.min((W - 120) / w, (H - 120) / h, 1.4);
|
|
113
|
+
cam.x = W / 2 - (minX + w / 2) * cam.k;
|
|
114
|
+
cam.y = H / 2 - (minY + h / 2) * cam.k;
|
|
115
|
+
}
|
|
116
|
+
function toScreen(x, y) { return [x * cam.k + cam.x, y * cam.k + cam.y]; }
|
|
117
|
+
function toWorld(sx, sy) { return [(sx - cam.x) / cam.k, (sy - cam.y) / cam.k]; }
|
|
118
|
+
|
|
119
|
+
// ---------- force simulation (modules) ----------
|
|
120
|
+
var alpha = 1;
|
|
121
|
+
function tick() {
|
|
122
|
+
if (view !== "modules" || alpha < 0.005) return;
|
|
123
|
+
alpha *= 0.995;
|
|
124
|
+
var i, j, n, m, dx, dy, d2, f;
|
|
125
|
+
for (i = 0; i < modNodes.length; i++) {
|
|
126
|
+
n = modNodes[i];
|
|
127
|
+
for (j = i + 1; j < modNodes.length; j++) {
|
|
128
|
+
m = modNodes[j];
|
|
129
|
+
dx = n.x - m.x; dy = n.y - m.y;
|
|
130
|
+
d2 = dx * dx + dy * dy + 0.01;
|
|
131
|
+
f = 2600 / d2 * alpha;
|
|
132
|
+
var d = Math.sqrt(d2);
|
|
133
|
+
n.vx += dx / d * f; n.vy += dy / d * f;
|
|
134
|
+
m.vx -= dx / d * f; m.vy -= dy / d * f;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
modEdges.forEach(function (e) {
|
|
138
|
+
dx = e.to.x - e.from.x; dy = e.to.y - e.from.y;
|
|
139
|
+
var d = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
140
|
+
f = (d - 110) * 0.02 * alpha;
|
|
141
|
+
e.from.vx += dx / d * f * d * 0.02; e.from.vy += dy / d * f * d * 0.02;
|
|
142
|
+
e.to.vx -= dx / d * f * d * 0.02; e.to.vy -= dy / d * f * d * 0.02;
|
|
143
|
+
});
|
|
144
|
+
modNodes.forEach(function (n) {
|
|
145
|
+
if (n === dragNode) return;
|
|
146
|
+
n.vx += -n.x * 0.002 * alpha; n.vy += -n.y * 0.002 * alpha;
|
|
147
|
+
n.vx *= 0.85; n.vy *= 0.85;
|
|
148
|
+
n.x += n.vx; n.y += n.vy;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------- impact highlight ----------
|
|
153
|
+
var hoverNode = null;
|
|
154
|
+
var impactSet = null;
|
|
155
|
+
function computeImpact(n) {
|
|
156
|
+
var set = {}, queue = [n.id];
|
|
157
|
+
set[n.id] = 2;
|
|
158
|
+
while (queue.length) {
|
|
159
|
+
var id = queue.shift();
|
|
160
|
+
var mod = graph.modules[id];
|
|
161
|
+
if (!mod) continue;
|
|
162
|
+
mod.importedBy.forEach(function (up) {
|
|
163
|
+
if (!(up in set)) { set[up] = 1; queue.push(up); }
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return set;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------- selection / panel ----------
|
|
170
|
+
var selected = null;
|
|
171
|
+
var panel = document.getElementById("panel");
|
|
172
|
+
function showPanel(id) {
|
|
173
|
+
selected = id;
|
|
174
|
+
var m = graph.modules[id];
|
|
175
|
+
if (!m) { panel.className = ""; panel.innerHTML = ""; return; }
|
|
176
|
+
var b = [];
|
|
177
|
+
if (m.exports.hasComponent) b.push('<span class="badge">Component</span>');
|
|
178
|
+
if (m.exports.hasLoader) b.push('<span class="badge">loader</span>');
|
|
179
|
+
m.exports.actions.forEach(function (a) { b.push('<span class="badge">action_' + a + "</span>"); });
|
|
180
|
+
m.exports.streams.forEach(function (s) { b.push('<span class="badge">stream_' + s + "</span>"); });
|
|
181
|
+
m.exports.sockets.forEach(function (s) { b.push('<span class="badge">socket_' + s + "</span>"); });
|
|
182
|
+
var html = "<h2>" + id + '</h2><div class="file">' + m.file + " · " + m.kind + "</div><div>" + b.join("") + "</div>";
|
|
183
|
+
html += "<h3>imports (" + m.imports.length + ")</h3><ul>";
|
|
184
|
+
m.imports.forEach(function (i) {
|
|
185
|
+
html += modById[i] ? "<li data-id='" + i + "'>→ " + i + "</li>" : "<li class='ext'>→ " + i + "</li>";
|
|
186
|
+
});
|
|
187
|
+
html += "</ul><h3>imported by (" + m.importedBy.length + ")</h3><ul>";
|
|
188
|
+
m.importedBy.forEach(function (i) { html += "<li data-id='" + i + "'>← " + i + "</li>"; });
|
|
189
|
+
html += "</ul>";
|
|
190
|
+
panel.innerHTML = html;
|
|
191
|
+
panel.className = "open";
|
|
192
|
+
panel.querySelectorAll("li[data-id]").forEach(function (li) {
|
|
193
|
+
li.addEventListener("click", function () { showPanel(li.getAttribute("data-id")); focusModule(li.getAttribute("data-id")); });
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function focusModule(id) {
|
|
197
|
+
var n = modById[id];
|
|
198
|
+
if (!n) return;
|
|
199
|
+
if (view !== "modules") setView("modules");
|
|
200
|
+
cam.x = W / 2 - n.x * cam.k; cam.y = H / 2 - n.y * cam.k;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------- search ----------
|
|
204
|
+
var query = "";
|
|
205
|
+
document.getElementById("search").addEventListener("input", function (e) { query = e.target.value.toLowerCase(); });
|
|
206
|
+
document.addEventListener("keydown", function (e) {
|
|
207
|
+
if (e.key === "/" && document.activeElement !== document.getElementById("search")) { e.preventDefault(); document.getElementById("search").focus(); }
|
|
208
|
+
if (e.key === "Escape") { panel.className = ""; selected = null; query = ""; document.getElementById("search").value = ""; }
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ---------- interaction ----------
|
|
212
|
+
var dragNode = null, panning = false, lastX = 0, lastY = 0, moved = false;
|
|
213
|
+
function nodeAt(sx, sy) {
|
|
214
|
+
var w = toWorld(sx, sy);
|
|
215
|
+
var list = view === "modules" ? modNodes : routeNodes;
|
|
216
|
+
for (var i = list.length - 1; i >= 0; i--) {
|
|
217
|
+
var n = list[i];
|
|
218
|
+
var nx = view === "modules" ? n.x : n.px, ny = view === "modules" ? n.y : n.py;
|
|
219
|
+
var dx = w[0] - nx, dy = w[1] - ny;
|
|
220
|
+
if (dx * dx + dy * dy < 14 * 14 / (cam.k * cam.k) + 36) return n;
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
canvas.addEventListener("mousedown", function (e) {
|
|
225
|
+
var n = nodeAt(e.clientX, e.clientY);
|
|
226
|
+
if (n && view === "modules") { dragNode = n; alpha = Math.max(alpha, 0.3); }
|
|
227
|
+
else panning = true;
|
|
228
|
+
lastX = e.clientX; lastY = e.clientY; moved = false;
|
|
229
|
+
});
|
|
230
|
+
canvas.addEventListener("mousemove", function (e) {
|
|
231
|
+
var dx = e.clientX - lastX, dy = e.clientY - lastY;
|
|
232
|
+
if (Math.abs(dx) + Math.abs(dy) > 3) moved = true;
|
|
233
|
+
if (dragNode) { var w = toWorld(e.clientX, e.clientY); dragNode.x = w[0]; dragNode.y = w[1]; dragNode.vx = dragNode.vy = 0; }
|
|
234
|
+
else if (panning) { cam.x += dx; cam.y += dy; }
|
|
235
|
+
else {
|
|
236
|
+
var n = nodeAt(e.clientX, e.clientY);
|
|
237
|
+
hoverNode = view === "modules" ? n : null;
|
|
238
|
+
impactSet = hoverNode ? computeImpact(hoverNode) : null;
|
|
239
|
+
canvas.style.cursor = n ? "pointer" : "grab";
|
|
240
|
+
}
|
|
241
|
+
lastX = e.clientX; lastY = e.clientY;
|
|
242
|
+
});
|
|
243
|
+
canvas.addEventListener("mouseup", function (e) {
|
|
244
|
+
if (!moved) {
|
|
245
|
+
var n = nodeAt(e.clientX, e.clientY);
|
|
246
|
+
if (view === "modules") showPanel(n ? n.id : null);
|
|
247
|
+
else if (n && n.data.moduleId) showPanel(n.data.moduleId);
|
|
248
|
+
else showPanel(null);
|
|
249
|
+
}
|
|
250
|
+
dragNode = null; panning = false;
|
|
251
|
+
});
|
|
252
|
+
canvas.addEventListener("wheel", function (e) {
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
var k = Math.exp(-e.deltaY * 0.0012);
|
|
255
|
+
var w = toWorld(e.clientX, e.clientY);
|
|
256
|
+
cam.k *= k;
|
|
257
|
+
cam.x = e.clientX - w[0] * cam.k; cam.y = e.clientY - w[1] * cam.k;
|
|
258
|
+
}, { passive: false });
|
|
259
|
+
|
|
260
|
+
// ---------- rendering ----------
|
|
261
|
+
function matches(id) { return !query || id.toLowerCase().indexOf(query) >= 0; }
|
|
262
|
+
|
|
263
|
+
function drawRoutes() {
|
|
264
|
+
ctx.clearRect(0, 0, W, H);
|
|
265
|
+
ctx.strokeStyle = "#2a3247"; ctx.lineWidth = 1.2;
|
|
266
|
+
routeEdges.forEach(function (e) {
|
|
267
|
+
var a = toScreen(e.from.px, e.from.py), b = toScreen(e.to.px, e.to.py);
|
|
268
|
+
ctx.beginPath();
|
|
269
|
+
ctx.moveTo(a[0], a[1]);
|
|
270
|
+
ctx.bezierCurveTo(a[0], (a[1] + b[1]) / 2, b[0], (a[1] + b[1]) / 2, b[0], b[1]);
|
|
271
|
+
ctx.stroke();
|
|
272
|
+
});
|
|
273
|
+
routeNodes.forEach(function (n) {
|
|
274
|
+
var d = n.data;
|
|
275
|
+
var id = d.moduleId || "(wrapper)";
|
|
276
|
+
if (!matches(id)) return;
|
|
277
|
+
var p = toScreen(n.px, n.py);
|
|
278
|
+
var color = d.isRoot ? KIND_COLORS.root : (d.children.length ? KIND_COLORS.layout : KIND_COLORS.page);
|
|
279
|
+
var sel = selected === d.moduleId;
|
|
280
|
+
ctx.beginPath(); ctx.arc(p[0], p[1], sel ? 9 : 6, 0, 7);
|
|
281
|
+
ctx.fillStyle = color; ctx.fill();
|
|
282
|
+
if (sel) { ctx.strokeStyle = "#e8edf5"; ctx.lineWidth = 2; ctx.stroke(); }
|
|
283
|
+
ctx.fillStyle = "#d7dde8"; ctx.font = "11px ui-monospace, monospace"; ctx.textAlign = "center";
|
|
284
|
+
ctx.fillText(id.split("/").pop(), p[0], p[1] - 13);
|
|
285
|
+
ctx.fillStyle = "#55607a"; ctx.font = "10px ui-monospace, monospace";
|
|
286
|
+
var sub = d.fullPath || "";
|
|
287
|
+
if (d.middlewares.length) sub += " · " + d.middlewares.join(", ");
|
|
288
|
+
ctx.fillText(sub, p[0], p[1] + 20);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function drawModules() {
|
|
293
|
+
ctx.clearRect(0, 0, W, H);
|
|
294
|
+
ctx.strokeStyle = "#232a3a"; ctx.lineWidth = 1;
|
|
295
|
+
modEdges.forEach(function (e) {
|
|
296
|
+
if (!matches(e.from.id) && !matches(e.to.id)) return;
|
|
297
|
+
var hot = impactSet && impactSet[e.from.id] && impactSet[e.to.id];
|
|
298
|
+
var a = toScreen(e.from.x, e.from.y), b = toScreen(e.to.x, e.to.y);
|
|
299
|
+
ctx.strokeStyle = hot ? "#f59e0b" : "#232a3a";
|
|
300
|
+
ctx.lineWidth = hot ? 1.8 : 1;
|
|
301
|
+
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
|
|
302
|
+
});
|
|
303
|
+
modNodes.forEach(function (n) {
|
|
304
|
+
if (!matches(n.id)) return;
|
|
305
|
+
var p = toScreen(n.x, n.y);
|
|
306
|
+
var dim = impactSet && !impactSet[n.id];
|
|
307
|
+
var hot = impactSet && impactSet[n.id];
|
|
308
|
+
var r = n.kind === "unknown" ? 4 : 7;
|
|
309
|
+
ctx.globalAlpha = dim ? 0.15 : 1;
|
|
310
|
+
ctx.beginPath(); ctx.arc(p[0], p[1], selected === n.id ? r + 3 : r, 0, 7);
|
|
311
|
+
ctx.fillStyle = hot === 2 ? "#f59e0b" : KIND_COLORS[n.kind] || KIND_COLORS.unknown;
|
|
312
|
+
ctx.fill();
|
|
313
|
+
if (selected === n.id) { ctx.strokeStyle = "#e8edf5"; ctx.lineWidth = 2; ctx.stroke(); }
|
|
314
|
+
if (!dim) {
|
|
315
|
+
ctx.fillStyle = hot ? "#e8edf5" : "#9aa5b8";
|
|
316
|
+
ctx.font = "10px ui-monospace, monospace"; ctx.textAlign = "center";
|
|
317
|
+
ctx.fillText(n.id.split("/").pop(), p[0], p[1] - r - 5);
|
|
318
|
+
}
|
|
319
|
+
ctx.globalAlpha = 1;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function frame() {
|
|
324
|
+
tick();
|
|
325
|
+
if (view === "routes") drawRoutes(); else drawModules();
|
|
326
|
+
requestAnimationFrame(frame);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------- tabs ----------
|
|
330
|
+
function setView(v) {
|
|
331
|
+
view = v;
|
|
332
|
+
document.getElementById("tab-routes").className = "tab" + (v === "routes" ? " active" : "");
|
|
333
|
+
document.getElementById("tab-modules").className = "tab" + (v === "modules" ? " active" : "");
|
|
334
|
+
if (v === "routes") fitView(routeNodes.map(function (n) { return n.px; }), routeNodes.map(function (n) { return n.py; }));
|
|
335
|
+
else { alpha = Math.max(alpha, 0.4); fitView(modNodes.map(function (n) { return n.x; }), modNodes.map(function (n) { return n.y; })); }
|
|
336
|
+
}
|
|
337
|
+
document.getElementById("tab-routes").addEventListener("click", function () { setView("routes"); });
|
|
338
|
+
document.getElementById("tab-modules").addEventListener("click", function () { setView("modules"); });
|
|
339
|
+
|
|
340
|
+
// ---------- boot ----------
|
|
341
|
+
fetch("/graph.json").then(function (r) { return r.json(); }).then(function (g) {
|
|
342
|
+
graph = g;
|
|
343
|
+
buildModuleGraph();
|
|
344
|
+
buildRouteLayout();
|
|
345
|
+
alpha = 1;
|
|
346
|
+
// pre-simulate so the first paint is already organized
|
|
347
|
+
for (var i = 0; i < 300 && alpha > 0.05; i++) tick();
|
|
348
|
+
setView("routes");
|
|
349
|
+
frame();
|
|
350
|
+
});
|
|
351
|
+
<\/script>
|
|
352
|
+
</body>
|
|
353
|
+
</html>`;
|
|
354
|
+
async function f(o) {
|
|
355
|
+
const i = l.join(o, ".velojs", "graph.json"), a = r.createServer((n, e) => {
|
|
356
|
+
if (n.url === "/graph.json") {
|
|
357
|
+
let t;
|
|
358
|
+
try {
|
|
359
|
+
t = d.readFileSync(i, "utf-8");
|
|
360
|
+
} catch {
|
|
361
|
+
e.writeHead(404, { "Content-Type": "application/json" }), e.end(JSON.stringify({ error: "graph.json not found — run `velojs graph` first" }));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
e.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" }), e.end(t);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
e.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }), e.end(s);
|
|
368
|
+
});
|
|
369
|
+
return await new Promise((n) => {
|
|
370
|
+
a.listen(0, "127.0.0.1", () => {
|
|
371
|
+
const e = a.address(), t = typeof e == "object" && e ? e.port : 0;
|
|
372
|
+
n(`http://127.0.0.1:${t}`);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
export {
|
|
377
|
+
f as serveGraphViewer
|
|
378
|
+
};
|
package/dist/graph.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VeloJS Project Graph
|
|
3
|
+
*
|
|
4
|
+
* Builds a structural JSON snapshot of the project — route tree hierarchy,
|
|
5
|
+
* module dependency graph, and convention exports — from source code alone.
|
|
6
|
+
* No Vite, no dev server, no build. Used by `velojs graph` and by the
|
|
7
|
+
* `velo:graph` Vite plugin.
|
|
8
|
+
*/
|
|
9
|
+
export interface RouteTreeNode {
|
|
10
|
+
moduleId: string | null;
|
|
11
|
+
fullPath: string | null;
|
|
12
|
+
path: string | null;
|
|
13
|
+
isRoot: boolean;
|
|
14
|
+
middlewares: string[];
|
|
15
|
+
children: RouteTreeNode[];
|
|
16
|
+
}
|
|
17
|
+
export interface ModuleExports {
|
|
18
|
+
hasComponent: boolean;
|
|
19
|
+
hasLoader: boolean;
|
|
20
|
+
actions: string[];
|
|
21
|
+
streams: string[];
|
|
22
|
+
sockets: string[];
|
|
23
|
+
}
|
|
24
|
+
export interface ModuleInfo {
|
|
25
|
+
file: string;
|
|
26
|
+
kind: "root" | "layout" | "page" | "unknown";
|
|
27
|
+
exports: ModuleExports;
|
|
28
|
+
imports: string[];
|
|
29
|
+
importedBy: string[];
|
|
30
|
+
}
|
|
31
|
+
export interface GraphJson {
|
|
32
|
+
routes: RouteTreeNode[];
|
|
33
|
+
modules: Record<string, ModuleInfo>;
|
|
34
|
+
}
|
|
35
|
+
export declare function detectExports(filePath: string): ModuleExports;
|
|
36
|
+
export declare function buildRouteTree(code: string): RouteTreeNode[];
|
|
37
|
+
export declare function buildGraph(appDir: string): GraphJson;
|