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 +74 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/screenshot.d.ts +22 -0
- package/dist/screenshot.js +120 -0
- package/package.json +9 -1
- package/viewer/src/GraphView.tsx +57 -5
- package/viewer/src/NodeDetails.tsx +25 -1
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.
|
|
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",
|
package/viewer/src/GraphView.tsx
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
-
...(
|
|
245
|
-
data: {
|
|
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(
|
|
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>
|