next-arch-map 0.1.22 → 0.1.24

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/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { analyzeProject, diffGraphs } from "./index.js";
6
+ import { captureScreenshots, generateParamsTemplate, } from "./screenshot.js";
6
7
  import { serve } from "./serve.js";
7
8
  import { readJsonFile, writeJsonFile } from "./utils.js";
8
9
  async function main() {
@@ -17,6 +18,28 @@ async function main() {
17
18
  await serve(options);
18
19
  return;
19
20
  }
21
+ if (commandOrArg === "screenshot") {
22
+ const options = parseScreenshotArgs(rest);
23
+ if (options.generateParams) {
24
+ generateParamsTemplate({
25
+ graphPath: path.resolve(options.graphPath),
26
+ outPath: path.resolve(options.paramsPath),
27
+ });
28
+ console.log(`params template written to ${options.paramsPath}`);
29
+ return;
30
+ }
31
+ if (!options.baseUrl) {
32
+ throw new Error("The screenshot command requires --base-url <url>.");
33
+ }
34
+ const result = await captureScreenshots({
35
+ baseUrl: options.baseUrl,
36
+ graphPath: path.resolve(options.graphPath),
37
+ outDir: path.resolve(options.outDir),
38
+ paramsPath: path.resolve(options.paramsPath),
39
+ });
40
+ logScreenshotSummary(result);
41
+ return;
42
+ }
20
43
  if (commandOrArg === "diff") {
21
44
  const options = parseDiffArgs(rest);
22
45
  const beforePath = path.resolve(options.beforePath);
@@ -198,6 +221,46 @@ function parseDevArgs(args) {
198
221
  appDirs: appDirs.length > 0 ? appDirs : undefined,
199
222
  };
200
223
  }
224
+ function parseScreenshotArgs(args) {
225
+ let baseUrl = "";
226
+ let graphPath = "arch/graph.full.json";
227
+ let outDir = "arch/screenshots";
228
+ let paramsPath = "arch/screenshot-params.json";
229
+ let generateParams = false;
230
+ for (let index = 0; index < args.length; index += 1) {
231
+ const argument = args[index];
232
+ if (argument === "--base-url" && args[index + 1]) {
233
+ baseUrl = args[index + 1];
234
+ index += 1;
235
+ continue;
236
+ }
237
+ if (argument === "--graph" && args[index + 1]) {
238
+ graphPath = args[index + 1];
239
+ index += 1;
240
+ continue;
241
+ }
242
+ if (argument === "--out-dir" && args[index + 1]) {
243
+ outDir = args[index + 1];
244
+ index += 1;
245
+ continue;
246
+ }
247
+ if (argument === "--params" && args[index + 1]) {
248
+ paramsPath = args[index + 1];
249
+ index += 1;
250
+ continue;
251
+ }
252
+ if (argument === "--generate-params") {
253
+ generateParams = true;
254
+ continue;
255
+ }
256
+ if (argument === "--help" || argument === "-h") {
257
+ printHelp();
258
+ process.exit(0);
259
+ }
260
+ throw new Error(`Unknown argument: ${argument}`);
261
+ }
262
+ return { baseUrl, graphPath, outDir, paramsPath, generateParams };
263
+ }
201
264
  async function runDev(options) {
202
265
  const children = [];
203
266
  const cleanup = () => {
@@ -292,9 +355,13 @@ function logDiffSummary(diff, beforePath, afterPath, outputFile) {
292
355
  `removedEdges=${removedEdges}`,
293
356
  ].join(" "));
294
357
  }
358
+ function logScreenshotSummary(result) {
359
+ console.log([`mode=screenshot`, `captured=${result.captured}`, `skipped=${result.skipped}`].join(" "));
360
+ }
295
361
  function printHelp() {
296
362
  console.log(`next-arch-map analyze [options]
297
363
  next-arch-map diff --before <path> --after <path> [--out <path>]
364
+ next-arch-map screenshot [options]
298
365
  next-arch-map serve [options]
299
366
  next-arch-map dev [options]
300
367
 
@@ -308,6 +375,13 @@ Diff options:
308
375
  --after <path> Path to the updated graph JSON.
309
376
  --out <path> Output diff JSON path. Defaults to arch/graph.diff.json.
310
377
 
378
+ Screenshot options:
379
+ --base-url <url> Base URL of the running app (e.g. http://localhost:3000).
380
+ --graph <path> Path to graph JSON. Defaults to arch/graph.full.json.
381
+ --out-dir <path> Directory for screenshot PNGs. Defaults to arch/screenshots.
382
+ --params <path> Path to params JSON. Defaults to arch/screenshot-params.json.
383
+ --generate-params Generate a params template for dynamic routes and exit.
384
+
311
385
  Serve options:
312
386
  --project-root <path> Project root to analyze. Defaults to the current working directory.
313
387
  --port <number> Port to listen on. Defaults to 4321.
package/dist/index.d.ts CHANGED
@@ -16,3 +16,4 @@ export { analyzePagesToEndpoints } from "./analyzers/pagesToEndpoints.js";
16
16
  export { analyzeEndpointsToDb } from "./analyzers/endpointsToDb.js";
17
17
  export { mergeGraphs, mergePartial } from "./merge.js";
18
18
  export { getDbModelsForPage, getEndpointsForPage, getPagesForDbModel } from "./query.js";
19
+ export { captureScreenshots, generateParamsTemplate } from "./screenshot.js";
package/dist/index.js CHANGED
@@ -21,3 +21,4 @@ export { analyzePagesToEndpoints } from "./analyzers/pagesToEndpoints.js";
21
21
  export { analyzeEndpointsToDb } from "./analyzers/endpointsToDb.js";
22
22
  export { mergeGraphs, mergePartial } from "./merge.js";
23
23
  export { getDbModelsForPage, getEndpointsForPage, getPagesForDbModel } from "./query.js";
24
+ export { captureScreenshots, generateParamsTemplate } from "./screenshot.js";
@@ -0,0 +1,22 @@
1
+ import type { Graph } from "./model.js";
2
+ export declare function isDynamicRoute(route: string): boolean;
3
+ export declare function extractRouteParamNames(route: string): string[];
4
+ export declare function resolveRoute(route: string, params: Record<string, string>): string;
5
+ export declare function sanitizeFilename(route: string): string;
6
+ export type GenerateParamsOptions = {
7
+ graphPath: string;
8
+ outPath: string;
9
+ };
10
+ export declare function generateParamsTemplate(options: GenerateParamsOptions): void;
11
+ export type CaptureScreenshotsOptions = {
12
+ baseUrl: string;
13
+ graphPath: string;
14
+ outDir: string;
15
+ paramsPath?: string;
16
+ };
17
+ export type CaptureResult = {
18
+ graph: Graph;
19
+ captured: number;
20
+ skipped: number;
21
+ };
22
+ export declare function captureScreenshots(options: CaptureScreenshotsOptions): Promise<CaptureResult>;
@@ -0,0 +1,120 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { readJsonFile, writeJsonFile } from "./utils.js";
4
+ const DYNAMIC_SEGMENT_PATTERN = /\[[\w.]+\]/;
5
+ const PARAM_SEGMENT_PATTERN = /\[{1,2}(?:\.{3})?(\w+)\]{1,2}/g;
6
+ export function isDynamicRoute(route) {
7
+ return DYNAMIC_SEGMENT_PATTERN.test(route);
8
+ }
9
+ export function extractRouteParamNames(route) {
10
+ const names = [];
11
+ for (const match of route.matchAll(PARAM_SEGMENT_PATTERN)) {
12
+ names.push(match[1]);
13
+ }
14
+ return names;
15
+ }
16
+ export function resolveRoute(route, params) {
17
+ return route.replace(PARAM_SEGMENT_PATTERN, (_match, name) => {
18
+ return params[name] ?? _match;
19
+ });
20
+ }
21
+ export function sanitizeFilename(route) {
22
+ if (route === "/")
23
+ return "index.png";
24
+ const name = route
25
+ .replace(/^\//, "")
26
+ .replace(/\//g, "-");
27
+ return `${name}.png`;
28
+ }
29
+ export function generateParamsTemplate(options) {
30
+ const graph = readJsonFile(options.graphPath);
31
+ const template = {};
32
+ for (const node of graph.nodes) {
33
+ if (node.type !== "page")
34
+ continue;
35
+ const route = node.meta?.route ?? node.label;
36
+ if (!isDynamicRoute(String(route)))
37
+ continue;
38
+ const paramNames = extractRouteParamNames(String(route));
39
+ const params = {};
40
+ for (const name of paramNames) {
41
+ params[name] = "";
42
+ }
43
+ template[String(route)] = params;
44
+ }
45
+ writeJsonFile(options.outPath, template);
46
+ }
47
+ export async function captureScreenshots(options) {
48
+ let playwright;
49
+ try {
50
+ const moduleName = "playwright";
51
+ playwright = await import(/* webpackIgnore: true */ moduleName);
52
+ }
53
+ catch {
54
+ throw new Error("Playwright is required for screenshots. Install it with: npm install playwright");
55
+ }
56
+ const graph = readJsonFile(options.graphPath);
57
+ const params = options.paramsPath &&
58
+ fs.existsSync(options.paramsPath)
59
+ ? readJsonFile(options.paramsPath)
60
+ : {};
61
+ const pageNodes = graph.nodes
62
+ .filter((node) => node.type === "page")
63
+ .sort((a, b) => a.label.localeCompare(b.label));
64
+ let captured = 0;
65
+ let skipped = 0;
66
+ const browser = await playwright.chromium.launch();
67
+ const context = await browser.newContext();
68
+ const page = await context.newPage();
69
+ try {
70
+ for (const node of pageNodes) {
71
+ const route = String(node.meta?.route ?? node.label);
72
+ if (isDynamicRoute(route)) {
73
+ const routeParams = params[route];
74
+ if (!routeParams || Object.values(routeParams).some((v) => !v)) {
75
+ console.log(`skipped ${route} (missing params)`);
76
+ skipped += 1;
77
+ continue;
78
+ }
79
+ const resolved = resolveRoute(route, routeParams);
80
+ try {
81
+ await capturePageScreenshot(page, node, resolved, options);
82
+ captured += 1;
83
+ }
84
+ catch (error) {
85
+ const message = error instanceof Error ? error.message : String(error);
86
+ console.log(`failed ${route}: ${message}`);
87
+ skipped += 1;
88
+ }
89
+ }
90
+ else {
91
+ try {
92
+ await capturePageScreenshot(page, node, route, options);
93
+ captured += 1;
94
+ }
95
+ catch (error) {
96
+ const message = error instanceof Error ? error.message : String(error);
97
+ console.log(`failed ${route}: ${message}`);
98
+ skipped += 1;
99
+ }
100
+ }
101
+ }
102
+ }
103
+ finally {
104
+ await browser.close();
105
+ }
106
+ writeJsonFile(options.graphPath, graph);
107
+ return { graph, captured, skipped };
108
+ }
109
+ async function capturePageScreenshot(page, node, resolvedRoute, options) {
110
+ const url = options.baseUrl.replace(/\/$/, "") + resolvedRoute;
111
+ await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
112
+ const buffer = await page.screenshot({ fullPage: true });
113
+ fs.mkdirSync(options.outDir, { recursive: true });
114
+ const filename = sanitizeFilename(resolvedRoute);
115
+ fs.writeFileSync(path.join(options.outDir, filename), buffer);
116
+ if (!node.meta)
117
+ node.meta = {};
118
+ node.meta.screenshot = `data:image/png;base64,${buffer.toString("base64")}`;
119
+ console.log(`captured ${resolvedRoute} → ${filename}`);
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-arch-map",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Static analyzer that builds a multi-layer architecture graph for Next.js-style apps.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,6 +47,14 @@
47
47
  "chokidar": "^3.6.0",
48
48
  "typescript": "^5.8.2"
49
49
  },
50
+ "peerDependencies": {
51
+ "playwright": ">=1.40.0"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "playwright": {
55
+ "optional": true
56
+ }
57
+ },
50
58
  "devDependencies": {
51
59
  "@eslint/js": "^10.0.1",
52
60
  "@types/node": "^20.19.0",
@@ -9,15 +9,12 @@ const ALL_NODE_TYPES: NodeType[] = [
9
9
  "page",
10
10
  "endpoint",
11
11
  "handler",
12
- "action",
13
12
  "db",
14
13
  ];
15
14
  const ALL_EDGE_KINDS: EdgeKind[] = [
16
15
  "page-endpoint",
17
16
  "endpoint-db",
18
17
  "endpoint-handler",
19
- "page-action",
20
- "action-endpoint",
21
18
  ];
22
19
 
23
20
 
@@ -15,7 +15,6 @@ const NODE_TYPE_COLORS: Record<NodeType, string> = {
15
15
  page: "bg-blue-500",
16
16
  endpoint: "bg-emerald-600",
17
17
  handler: "bg-teal-500",
18
- action: "bg-amber-400",
19
18
  db: "bg-red-600",
20
19
  };
21
20
 
@@ -31,7 +31,6 @@ const NODE_COLOR: Record<NodeType, string> = {
31
31
  endpoint: "#059669",
32
32
  db: "#dc2626",
33
33
  handler: "#14b8a6",
34
- action: "#fbbf24",
35
34
  };
36
35
 
37
36
  const NODE_BORDER: Record<NodeType, string> = {
@@ -39,15 +38,12 @@ const NODE_BORDER: Record<NodeType, string> = {
39
38
  endpoint: "#047857",
40
39
  db: "#b91c1c",
41
40
  handler: "#0d9488",
42
- action: "#f59e0b",
43
41
  };
44
42
 
45
43
  const EDGE_COLOR: Record<EdgeKind, string> = {
46
44
  "page-endpoint": "#06b6d4",
47
45
  "endpoint-db": "#f97316",
48
46
  "endpoint-handler": "#22c55e",
49
- "page-action": "#eab308",
50
- "action-endpoint": "#a855f7",
51
47
  };
52
48
 
53
49
  const DIFF_BORDER_COLOR: Record<DiffStatus, string> = {
@@ -141,14 +137,34 @@ function reorderColumn(
141
137
  nodes.forEach((node, i) => nodeRowIndex.set(node.id, i));
142
138
  }
143
139
 
140
+ function DescriptionLine({ text, dark }: { text: string; dark?: boolean }) {
141
+ return (
142
+ <div
143
+ style={{
144
+ marginTop: 3,
145
+ fontSize: 10,
146
+ fontWeight: 400,
147
+ opacity: 0.85,
148
+ lineHeight: 1.3,
149
+ color: dark ? "#1e293b" : "#ffffff",
150
+ }}
151
+ >
152
+ {text}
153
+ </div>
154
+ );
155
+ }
156
+
144
157
  function PageNode({ data }: NodeProps) {
145
- const screenshot = (data as Record<string, unknown>).screenshot as string | undefined;
158
+ const d = data as Record<string, unknown>;
159
+ const screenshot = d.screenshot as string | undefined;
160
+ const description = d.description as string | undefined;
146
161
  return (
147
162
  <div>
148
163
  <Handle type="target" position={Position.Left} style={{ visibility: "hidden" }} />
149
164
  <div style={{ fontSize: 12, fontWeight: 600 }}>
150
- {String((data as Record<string, unknown>).label ?? "")}
165
+ {String(d.label ?? "")}
151
166
  </div>
167
+ {description && <DescriptionLine text={description} />}
152
168
  {screenshot && (
153
169
  <img
154
170
  src={screenshot}
@@ -166,7 +182,26 @@ function PageNode({ data }: NodeProps) {
166
182
  );
167
183
  }
168
184
 
169
- const nodeTypes = { pageNode: PageNode };
185
+ function DescribedNode({ data }: NodeProps) {
186
+ const d = data as Record<string, unknown>;
187
+ const description = d.description as string | undefined;
188
+ const dark = d.dark as boolean | undefined;
189
+ return (
190
+ <div>
191
+ <Handle type="target" position={Position.Left} style={{ visibility: "hidden" }} />
192
+ <div style={{ fontSize: 12, fontWeight: 600 }}>
193
+ {String(d.label ?? "")}
194
+ </div>
195
+ {description && <DescriptionLine text={description} dark={dark} />}
196
+ <Handle type="source" position={Position.Right} style={{ visibility: "hidden" }} />
197
+ </div>
198
+ );
199
+ }
200
+
201
+ const nodeTypes = {
202
+ pageNode: PageNode,
203
+ describedNode: DescribedNode,
204
+ };
170
205
 
171
206
  export function GraphView(props: GraphViewProps) {
172
207
  const {
@@ -202,7 +237,7 @@ export function GraphView(props: GraphViewProps) {
202
237
 
203
238
  // Compute layout without hover state — this is the expensive part
204
239
  const { flowNodes: baseFlowNodes, flowEdges: baseFlowEdges } = useMemo(() => {
205
- const typeOrder: NodeType[] = ["page", "action", "endpoint", "handler", "db"];
240
+ const typeOrder: NodeType[] = ["page", "endpoint", "handler", "db"];
206
241
  const visibleNodes = graph.nodes.filter((node) => visibleNodeTypes.has(node.type));
207
242
  const visibleNodeIds = new Set(visibleNodes.map((node) => node.id));
208
243
  const nodesByType = new Map<NodeType, typeof visibleNodes>(
@@ -238,11 +273,24 @@ export function GraphView(props: GraphViewProps) {
238
273
  const borderStyle = status === "removed" ? "dashed" : "solid";
239
274
  const isPage = node.type === "page";
240
275
  const screenshot = isPage ? (node.meta?.screenshot as string | undefined) : undefined;
276
+ const description = node.meta?.description as string | undefined;
277
+ const isDarkText = false;
278
+
279
+ const nodeType = isPage
280
+ ? "pageNode"
281
+ : description
282
+ ? "describedNode"
283
+ : undefined;
241
284
 
242
285
  flowNodes.push({
243
286
  id: node.id,
244
- ...(isPage ? { type: "pageNode" } : {}),
245
- data: { label: node.label, ...(screenshot ? { screenshot } : {}) },
287
+ ...(nodeType ? { type: nodeType } : {}),
288
+ data: {
289
+ label: node.label,
290
+ ...(screenshot ? { screenshot } : {}),
291
+ ...(description ? { description } : {}),
292
+ ...(isDarkText ? { dark: true } : {}),
293
+ },
246
294
  position: {
247
295
  x: 80 + columnIndex * columnWidth,
248
296
  y: 80 + rowIndex * rowHeight,
@@ -256,7 +304,7 @@ export function GraphView(props: GraphViewProps) {
256
304
  border: `2px ${borderStyle} ${borderColor}`,
257
305
  padding: "10px 14px",
258
306
  background: NODE_COLOR[node.type],
259
- color: node.type === "action" ? "#1e293b" : "#ffffff",
307
+ color: "#ffffff",
260
308
  fontSize: 12,
261
309
  fontWeight: 600,
262
310
  fontFamily: "'Inter', -apple-system, sans-serif",
@@ -46,6 +46,12 @@ export function NodeDetails({ node }: NodeDetailsProps) {
46
46
  {node.id}
47
47
  </div>
48
48
 
49
+ {(node.meta?.descriptionLong || node.meta?.description) && (
50
+ <p className="text-xs text-slate-600 leading-relaxed">
51
+ {String(node.meta.descriptionLong ?? node.meta.description)}
52
+ </p>
53
+ )}
54
+
49
55
  {filePath !== undefined && filePath !== null && (
50
56
  <div className="text-[11px] text-slate-500">
51
57
  <span className="text-slate-400">file: </span>
@@ -53,9 +59,27 @@ export function NodeDetails({ node }: NodeDetailsProps) {
53
59
  </div>
54
60
  )}
55
61
 
62
+ {node.meta?.screenshot && (
63
+ <div className="mt-2">
64
+ <img
65
+ src={String(node.meta.screenshot)}
66
+ alt={`Screenshot of ${node.label}`}
67
+ className="w-full rounded-md border border-slate-200"
68
+ />
69
+ </div>
70
+ )}
71
+
56
72
  {node.meta && (
57
73
  <pre className="mt-2 p-2.5 rounded-md bg-slate-50 border border-slate-100 text-[11px] font-mono text-slate-600 max-h-40 overflow-auto whitespace-pre-wrap break-all">
58
- {JSON.stringify(node.meta, null, 2)}
74
+ {JSON.stringify(
75
+ Object.fromEntries(
76
+ Object.entries(node.meta).filter(
77
+ ([key]) => key !== "screenshot" && key !== "description" && key !== "descriptionLong",
78
+ ),
79
+ ),
80
+ null,
81
+ 2,
82
+ )}
59
83
  </pre>
60
84
  )}
61
85
  </div>
@@ -2,8 +2,7 @@ export type NodeType =
2
2
  | "page"
3
3
  | "endpoint"
4
4
  | "db"
5
- | "handler"
6
- | "action";
5
+ | "handler";
7
6
 
8
7
  export type Node = {
9
8
  id: string;