next-arch-map 0.1.22 → 0.1.23

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.23",
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",
@@ -141,14 +141,34 @@ function reorderColumn(
141
141
  nodes.forEach((node, i) => nodeRowIndex.set(node.id, i));
142
142
  }
143
143
 
144
+ function DescriptionLine({ text, dark }: { text: string; dark?: boolean }) {
145
+ return (
146
+ <div
147
+ style={{
148
+ marginTop: 3,
149
+ fontSize: 10,
150
+ fontWeight: 400,
151
+ opacity: 0.85,
152
+ lineHeight: 1.3,
153
+ color: dark ? "#1e293b" : "#ffffff",
154
+ }}
155
+ >
156
+ {text}
157
+ </div>
158
+ );
159
+ }
160
+
144
161
  function PageNode({ data }: NodeProps) {
145
- const screenshot = (data as Record<string, unknown>).screenshot as string | undefined;
162
+ const d = data as Record<string, unknown>;
163
+ const screenshot = d.screenshot as string | undefined;
164
+ const description = d.description as string | undefined;
146
165
  return (
147
166
  <div>
148
167
  <Handle type="target" position={Position.Left} style={{ visibility: "hidden" }} />
149
168
  <div style={{ fontSize: 12, fontWeight: 600 }}>
150
- {String((data as Record<string, unknown>).label ?? "")}
169
+ {String(d.label ?? "")}
151
170
  </div>
171
+ {description && <DescriptionLine text={description} />}
152
172
  {screenshot && (
153
173
  <img
154
174
  src={screenshot}
@@ -166,7 +186,26 @@ function PageNode({ data }: NodeProps) {
166
186
  );
167
187
  }
168
188
 
169
- const nodeTypes = { pageNode: PageNode };
189
+ function DescribedNode({ data }: NodeProps) {
190
+ const d = data as Record<string, unknown>;
191
+ const description = d.description as string | undefined;
192
+ const dark = d.dark as boolean | undefined;
193
+ return (
194
+ <div>
195
+ <Handle type="target" position={Position.Left} style={{ visibility: "hidden" }} />
196
+ <div style={{ fontSize: 12, fontWeight: 600 }}>
197
+ {String(d.label ?? "")}
198
+ </div>
199
+ {description && <DescriptionLine text={description} dark={dark} />}
200
+ <Handle type="source" position={Position.Right} style={{ visibility: "hidden" }} />
201
+ </div>
202
+ );
203
+ }
204
+
205
+ const nodeTypes = {
206
+ pageNode: PageNode,
207
+ describedNode: DescribedNode,
208
+ };
170
209
 
171
210
  export function GraphView(props: GraphViewProps) {
172
211
  const {
@@ -238,11 +277,24 @@ export function GraphView(props: GraphViewProps) {
238
277
  const borderStyle = status === "removed" ? "dashed" : "solid";
239
278
  const isPage = node.type === "page";
240
279
  const screenshot = isPage ? (node.meta?.screenshot as string | undefined) : undefined;
280
+ const description = node.meta?.description as string | undefined;
281
+ const isDarkText = node.type === "action";
282
+
283
+ const nodeType = isPage
284
+ ? "pageNode"
285
+ : description
286
+ ? "describedNode"
287
+ : undefined;
241
288
 
242
289
  flowNodes.push({
243
290
  id: node.id,
244
- ...(isPage ? { type: "pageNode" } : {}),
245
- data: { label: node.label, ...(screenshot ? { screenshot } : {}) },
291
+ ...(nodeType ? { type: nodeType } : {}),
292
+ data: {
293
+ label: node.label,
294
+ ...(screenshot ? { screenshot } : {}),
295
+ ...(description ? { description } : {}),
296
+ ...(isDarkText ? { dark: true } : {}),
297
+ },
246
298
  position: {
247
299
  x: 80 + columnIndex * columnWidth,
248
300
  y: 80 + rowIndex * rowHeight,
@@ -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>