next-arch-map 0.1.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.
@@ -0,0 +1,321 @@
1
+ import ts from "typescript";
2
+ import { buildActionNode, buildEdgeKey, buildEndpointNode, buildPageNode, collectStringConstants, ensureNode, getExistingDirectories, getPageRouteFromFile, getSourceFile, getStringLiteralValue, isIgnoredSourceFile, isPageFile, resolveProjectRoot, walkDirectory, } from "../utils.js";
3
+ const DEFAULT_APP_DIRS = ["app", "src/app"];
4
+ const DEFAULT_EXTRA_SCAN_DIRS = ["src/features", "src/services", "src/lib", "src/hooks"];
5
+ const DEFAULT_HTTP_CLIENT_IDENTIFIERS = ["fetch", "axios", "apiClient"];
6
+ const DEFAULT_HTTP_CLIENT_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"];
7
+ export async function analyzePagesToEndpoints(options) {
8
+ const projectRoot = resolveProjectRoot(options.projectRoot);
9
+ const appDirs = getExistingDirectories(projectRoot, options.appDirs ?? DEFAULT_APP_DIRS);
10
+ if (appDirs.length === 0) {
11
+ throw new Error("Could not find an app/ or src/app/ directory.");
12
+ }
13
+ const extraScanDirs = getExistingDirectories(projectRoot, options.extraScanDirs ?? DEFAULT_EXTRA_SCAN_DIRS);
14
+ const httpClientIdentifiers = new Set((options.httpClientIdentifiers ?? DEFAULT_HTTP_CLIENT_IDENTIFIERS).map((value) => value.trim()));
15
+ const httpClientMethods = new Set((options.httpClientMethods ?? DEFAULT_HTTP_CLIENT_METHODS).map((value) => value.toLowerCase()));
16
+ const nodes = [];
17
+ const edges = [];
18
+ const nodeIds = new Set();
19
+ const edgeKeys = new Set();
20
+ const sourceFileCache = new Map();
21
+ const callIndexByRoute = new Map();
22
+ const actionIdCountByRoute = new Map();
23
+ for (const appDir of appDirs) {
24
+ for (const filePath of walkDirectory(appDir)) {
25
+ if (isIgnoredSourceFile(filePath)) {
26
+ continue;
27
+ }
28
+ const sourceFile = getSourceFile(filePath, sourceFileCache);
29
+ if (!sourceFile) {
30
+ continue;
31
+ }
32
+ const httpCalls = collectHttpCalls(sourceFile, httpClientIdentifiers, httpClientMethods);
33
+ // Non-page files (route handlers, layouts, helpers) under app/ only
34
+ // contribute endpoint nodes without creating fake page flows.
35
+ if (!isPageFile(filePath)) {
36
+ for (const call of httpCalls) {
37
+ ensureNode(nodes, nodeIds, buildEndpointNode(call.endpoint, filePath));
38
+ }
39
+ continue;
40
+ }
41
+ const route = getPageRouteFromFile(appDir, filePath);
42
+ ensureNode(nodes, nodeIds, buildPageNode(route, filePath));
43
+ for (const call of httpCalls) {
44
+ const nextCallIndex = (callIndexByRoute.get(route) ?? 0) + 1;
45
+ callIndexByRoute.set(route, nextCallIndex);
46
+ const actionContext = inferActionContext(call.node, sourceFile, nextCallIndex);
47
+ const actionId = allocateActionId(route, actionContext.id, actionIdCountByRoute);
48
+ const pageNode = ensureNode(nodes, nodeIds, buildPageNode(route, filePath));
49
+ const actionNode = ensureNode(nodes, nodeIds, buildActionNode(route, actionId, filePath, actionContext.meta));
50
+ const endpointNode = ensureNode(nodes, nodeIds, buildEndpointNode(call.endpoint, filePath));
51
+ const pageActionKey = buildEdgeKey(pageNode.id, actionNode.id, "page-action");
52
+ if (!edgeKeys.has(pageActionKey)) {
53
+ edgeKeys.add(pageActionKey);
54
+ edges.push({
55
+ from: pageNode.id,
56
+ to: actionNode.id,
57
+ kind: "page-action",
58
+ });
59
+ }
60
+ const actionEndpointKey = buildEdgeKey(actionNode.id, endpointNode.id, "action-endpoint");
61
+ if (!edgeKeys.has(actionEndpointKey)) {
62
+ edgeKeys.add(actionEndpointKey);
63
+ edges.push({
64
+ from: actionNode.id,
65
+ to: endpointNode.id,
66
+ kind: "action-endpoint",
67
+ meta: call.method ? { method: call.method } : undefined,
68
+ });
69
+ }
70
+ const edgeKey = buildEdgeKey(pageNode.id, endpointNode.id, "page-endpoint");
71
+ if (edgeKeys.has(edgeKey)) {
72
+ continue;
73
+ }
74
+ edgeKeys.add(edgeKey);
75
+ edges.push({
76
+ from: pageNode.id,
77
+ to: endpointNode.id,
78
+ kind: "page-endpoint",
79
+ meta: call.method ? { method: call.method } : undefined,
80
+ });
81
+ }
82
+ }
83
+ }
84
+ for (const scanDir of extraScanDirs) {
85
+ for (const filePath of walkDirectory(scanDir)) {
86
+ if (isIgnoredSourceFile(filePath)) {
87
+ continue;
88
+ }
89
+ const sourceFile = getSourceFile(filePath, sourceFileCache);
90
+ if (!sourceFile) {
91
+ continue;
92
+ }
93
+ for (const call of collectHttpCalls(sourceFile, httpClientIdentifiers, httpClientMethods)) {
94
+ ensureNode(nodes, nodeIds, buildEndpointNode(call.endpoint, filePath));
95
+ }
96
+ }
97
+ }
98
+ return {
99
+ nodes: nodes.sort((left, right) => left.id.localeCompare(right.id)),
100
+ edges: edges.sort((left, right) => left.kind.localeCompare(right.kind) ||
101
+ left.from.localeCompare(right.from) ||
102
+ left.to.localeCompare(right.to)),
103
+ };
104
+ }
105
+ function collectHttpCalls(sourceFile, httpClientIdentifiers, httpClientMethods) {
106
+ const calls = [];
107
+ const constMap = collectStringConstants(sourceFile);
108
+ const visitNode = (node) => {
109
+ if (ts.isCallExpression(node)) {
110
+ const httpCall = parseHttpCall(node, constMap, httpClientIdentifiers, httpClientMethods);
111
+ if (httpCall) {
112
+ calls.push({
113
+ endpoint: httpCall.endpoint,
114
+ method: httpCall.method,
115
+ node,
116
+ });
117
+ }
118
+ }
119
+ ts.forEachChild(node, visitNode);
120
+ };
121
+ visitNode(sourceFile);
122
+ return calls;
123
+ }
124
+ function parseHttpCall(node, constMap, httpClientIdentifiers, httpClientMethods) {
125
+ const endpoint = getEndpointArgument(node.arguments[0], constMap);
126
+ if (!endpoint) {
127
+ return null;
128
+ }
129
+ if (ts.isIdentifier(node.expression) && httpClientIdentifiers.has(node.expression.text)) {
130
+ return {
131
+ endpoint,
132
+ method: "GET",
133
+ };
134
+ }
135
+ if (ts.isPropertyAccessExpression(node.expression) &&
136
+ ts.isIdentifier(node.expression.expression) &&
137
+ httpClientIdentifiers.has(node.expression.expression.text) &&
138
+ httpClientMethods.has(node.expression.name.text.toLowerCase())) {
139
+ return {
140
+ endpoint,
141
+ method: node.expression.name.text.toUpperCase(),
142
+ };
143
+ }
144
+ return null;
145
+ }
146
+ function getEndpointArgument(expression, constMap) {
147
+ if (!expression) {
148
+ return null;
149
+ }
150
+ if (ts.isIdentifier(expression)) {
151
+ return constMap.get(expression.text) ?? null;
152
+ }
153
+ return getStringLiteralValue(expression);
154
+ }
155
+ function inferActionContext(call, sourceFile, callIndex) {
156
+ const fallbackId = `call-${callIndex}`;
157
+ const meta = {};
158
+ let handlerName;
159
+ let componentName;
160
+ let inlineEventName;
161
+ let inlineElementName;
162
+ let current = call;
163
+ while (current && current !== sourceFile) {
164
+ if (!inlineEventName && ts.isJsxAttribute(current) && ts.isIdentifier(current.name)) {
165
+ inlineEventName = current.name.text;
166
+ inlineElementName = getJsxElementName(current);
167
+ }
168
+ if (!handlerName && isFunctionLikeNode(current)) {
169
+ handlerName = getFunctionLikeName(current);
170
+ }
171
+ if (isTopLevelNamedFunctionLike(current, sourceFile)) {
172
+ componentName = getFunctionLikeName(current) ?? componentName;
173
+ }
174
+ current = current.parent;
175
+ }
176
+ if (componentName) {
177
+ meta.componentName = componentName;
178
+ }
179
+ if (handlerName === componentName && !inlineEventName) {
180
+ handlerName = undefined;
181
+ }
182
+ if (handlerName) {
183
+ meta.handlerName = handlerName;
184
+ }
185
+ const eventBinding = inlineEventName || inlineElementName
186
+ ? {
187
+ eventName: inlineEventName,
188
+ elementName: inlineElementName,
189
+ }
190
+ : handlerName
191
+ ? findJsxBindingForHandler(sourceFile, handlerName)
192
+ : undefined;
193
+ if (eventBinding?.eventName) {
194
+ meta.eventName = eventBinding.eventName;
195
+ }
196
+ if (eventBinding?.elementName) {
197
+ meta.elementName = eventBinding.elementName;
198
+ }
199
+ const callTarget = getCallTargetName(call);
200
+ if (callTarget) {
201
+ meta.callTarget = callTarget;
202
+ }
203
+ const actionId = buildActionContextId({
204
+ componentName,
205
+ handlerName,
206
+ eventName: eventBinding?.eventName,
207
+ elementName: eventBinding?.elementName,
208
+ callTarget,
209
+ });
210
+ return {
211
+ id: actionId ?? fallbackId,
212
+ meta: Object.keys(meta).length > 0 ? meta : undefined,
213
+ };
214
+ }
215
+ function allocateActionId(route, baseActionId, countsByRoute) {
216
+ const normalizedActionId = normalizeActionId(baseActionId);
217
+ const routeCounts = countsByRoute.get(route) ?? new Map();
218
+ const nextCount = (routeCounts.get(normalizedActionId) ?? 0) + 1;
219
+ routeCounts.set(normalizedActionId, nextCount);
220
+ countsByRoute.set(route, routeCounts);
221
+ return nextCount === 1 ? normalizedActionId : `${normalizedActionId}.${nextCount}`;
222
+ }
223
+ function buildActionContextId(parts) {
224
+ if (parts.elementName && parts.eventName) {
225
+ return [parts.elementName, parts.eventName].map(normalizeActionId).join(".");
226
+ }
227
+ if (parts.componentName && parts.handlerName) {
228
+ return [parts.componentName, parts.handlerName].map(normalizeActionId).join(".");
229
+ }
230
+ if (parts.handlerName && parts.eventName) {
231
+ return [parts.handlerName, parts.eventName].map(normalizeActionId).join(".");
232
+ }
233
+ if (parts.handlerName) {
234
+ return normalizeActionId(parts.handlerName);
235
+ }
236
+ if (parts.componentName && parts.callTarget) {
237
+ return [parts.componentName, parts.callTarget].map(normalizeActionId).join(".");
238
+ }
239
+ if (parts.callTarget) {
240
+ return normalizeActionId(parts.callTarget);
241
+ }
242
+ return null;
243
+ }
244
+ function isFunctionLikeNode(node) {
245
+ return (ts.isArrowFunction(node) ||
246
+ ts.isFunctionDeclaration(node) ||
247
+ ts.isFunctionExpression(node) ||
248
+ ts.isMethodDeclaration(node));
249
+ }
250
+ function isTopLevelNamedFunctionLike(node, sourceFile) {
251
+ if (!isFunctionLikeNode(node)) {
252
+ return false;
253
+ }
254
+ if (!getFunctionLikeName(node)) {
255
+ return false;
256
+ }
257
+ if (ts.isFunctionDeclaration(node)) {
258
+ return node.parent === sourceFile;
259
+ }
260
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
261
+ return ts.isVariableDeclaration(node.parent) && node.parent.parent.parent.parent === sourceFile;
262
+ }
263
+ return false;
264
+ }
265
+ function getFunctionLikeName(node) {
266
+ if ((ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isMethodDeclaration(node)) &&
267
+ node.name &&
268
+ ts.isIdentifier(node.name)) {
269
+ return node.name.text;
270
+ }
271
+ if ((ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && ts.isVariableDeclaration(node.parent)) {
272
+ return ts.isIdentifier(node.parent.name) ? node.parent.name.text : undefined;
273
+ }
274
+ return undefined;
275
+ }
276
+ function findJsxBindingForHandler(sourceFile, handlerName) {
277
+ let match;
278
+ const visitNode = (node) => {
279
+ if (match) {
280
+ return;
281
+ }
282
+ if (ts.isJsxAttribute(node) &&
283
+ ts.isIdentifier(node.name) &&
284
+ node.initializer &&
285
+ ts.isJsxExpression(node.initializer) &&
286
+ node.initializer.expression &&
287
+ ts.isIdentifier(node.initializer.expression) &&
288
+ node.initializer.expression.text === handlerName) {
289
+ match = {
290
+ eventName: node.name.text,
291
+ elementName: getJsxElementName(node),
292
+ };
293
+ return;
294
+ }
295
+ ts.forEachChild(node, visitNode);
296
+ };
297
+ visitNode(sourceFile);
298
+ return match;
299
+ }
300
+ function getJsxElementName(attribute) {
301
+ const jsxOwner = attribute.parent.parent;
302
+ if (!jsxOwner) {
303
+ return undefined;
304
+ }
305
+ if (ts.isJsxOpeningElement(jsxOwner) || ts.isJsxSelfClosingElement(jsxOwner)) {
306
+ return jsxOwner.tagName.getText();
307
+ }
308
+ return undefined;
309
+ }
310
+ function getCallTargetName(call) {
311
+ if (ts.isIdentifier(call.expression)) {
312
+ return call.expression.text;
313
+ }
314
+ if (ts.isPropertyAccessExpression(call.expression)) {
315
+ return call.expression.getText();
316
+ }
317
+ return undefined;
318
+ }
319
+ function normalizeActionId(value) {
320
+ return value.replace(/[:#/]/g, ".").replace(/\s+/g, "");
321
+ }
@@ -0,0 +1,11 @@
1
+ import type { Edge, Node } from "../model.js";
2
+ type AnalyzePagesToUiOptions = {
3
+ projectRoot: string;
4
+ appDirs?: string[];
5
+ uiImportPathGlobs?: string[];
6
+ };
7
+ export declare function analyzePagesToUi(options: AnalyzePagesToUiOptions): Promise<{
8
+ nodes: Node[];
9
+ edges: Edge[];
10
+ }>;
11
+ export {};
@@ -0,0 +1,118 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+ import { buildEdgeKey, buildPageNode, buildUiNode, ensureNode, getExistingDirectories, getPageRouteFromFile, getSourceFile, isPageFile, resolveLocalModulePath, resolveProjectRoot, walkDirectory, } from "../utils.js";
4
+ const DEFAULT_APP_DIRS = ["app", "src/app"];
5
+ const DEFAULT_UI_IMPORT_PATH_GLOBS = [
6
+ "src/components/**",
7
+ "src/features/**/components/**",
8
+ "src/app/**/components/**",
9
+ "app/**/components/**",
10
+ ];
11
+ export async function analyzePagesToUi(options) {
12
+ const projectRoot = resolveProjectRoot(options.projectRoot);
13
+ const appDirs = getExistingDirectories(projectRoot, options.appDirs ?? DEFAULT_APP_DIRS);
14
+ if (appDirs.length === 0) {
15
+ throw new Error("Could not find an app/ or src/app/ directory.");
16
+ }
17
+ const uiPathMatchers = (options.uiImportPathGlobs ?? DEFAULT_UI_IMPORT_PATH_GLOBS).map(globToRegExp);
18
+ const nodes = [];
19
+ const edges = [];
20
+ const nodeIds = new Set();
21
+ const edgeKeys = new Set();
22
+ for (const appDir of appDirs) {
23
+ for (const filePath of walkDirectory(appDir)) {
24
+ if (!isPageFile(filePath)) {
25
+ continue;
26
+ }
27
+ const route = getPageRouteFromFile(appDir, filePath);
28
+ const pageNode = ensureNode(nodes, nodeIds, buildPageNode(route, filePath));
29
+ const components = collectUiComponentUsages(filePath, projectRoot, uiPathMatchers);
30
+ for (const component of components) {
31
+ const uiNode = ensureNode(nodes, nodeIds, buildUiNode(component.componentName, component.filePath));
32
+ const edgeKey = buildEdgeKey(pageNode.id, uiNode.id, "page-ui");
33
+ if (edgeKeys.has(edgeKey)) {
34
+ continue;
35
+ }
36
+ edgeKeys.add(edgeKey);
37
+ edges.push({
38
+ from: pageNode.id,
39
+ to: uiNode.id,
40
+ kind: "page-ui",
41
+ });
42
+ }
43
+ }
44
+ }
45
+ return {
46
+ nodes: nodes.sort((left, right) => left.id.localeCompare(right.id)),
47
+ edges: edges.sort((left, right) => left.kind.localeCompare(right.kind) ||
48
+ left.from.localeCompare(right.from) ||
49
+ left.to.localeCompare(right.to)),
50
+ };
51
+ }
52
+ function collectUiComponentUsages(pageFilePath, projectRoot, uiPathMatchers) {
53
+ const sourceFile = getSourceFile(pageFilePath);
54
+ if (!sourceFile) {
55
+ return [];
56
+ }
57
+ const componentFilePaths = new Map();
58
+ for (const statement of sourceFile.statements) {
59
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
60
+ continue;
61
+ }
62
+ const importSource = statement.moduleSpecifier.text;
63
+ const resolvedImportPath = resolveLocalModulePath(pageFilePath, importSource, projectRoot);
64
+ if (!isUiLikeImport(importSource, resolvedImportPath, projectRoot, uiPathMatchers)) {
65
+ continue;
66
+ }
67
+ const importClause = statement.importClause;
68
+ if (!importClause || importClause.isTypeOnly) {
69
+ continue;
70
+ }
71
+ const componentFilePath = resolvedImportPath ?? pageFilePath;
72
+ if (importClause.name && isUiComponentName(importClause.name.text)) {
73
+ componentFilePaths.set(importClause.name.text, componentFilePath);
74
+ }
75
+ if (importClause.namedBindings && ts.isNamedImports(importClause.namedBindings)) {
76
+ for (const element of importClause.namedBindings.elements) {
77
+ if (!element.isTypeOnly && isUiComponentName(element.name.text)) {
78
+ componentFilePaths.set(element.name.text, componentFilePath);
79
+ }
80
+ }
81
+ }
82
+ }
83
+ return [...componentFilePaths.entries()]
84
+ .sort(([left], [right]) => left.localeCompare(right))
85
+ .map(([componentName, filePath]) => ({ componentName, filePath }));
86
+ }
87
+ function isUiLikeImport(importSource, resolvedImportPath, projectRoot, uiPathMatchers) {
88
+ if (/^\.\.?\/components(\/|$)/.test(importSource) || importSource.startsWith("@/components/")) {
89
+ return true;
90
+ }
91
+ if (!resolvedImportPath) {
92
+ return false;
93
+ }
94
+ const relativePath = path.relative(projectRoot, resolvedImportPath).replace(/\\/g, "/");
95
+ return uiPathMatchers.some((matcher) => matcher.test(relativePath));
96
+ }
97
+ function isUiComponentName(identifierName) {
98
+ return /^[A-Z]/.test(identifierName);
99
+ }
100
+ function globToRegExp(glob) {
101
+ let pattern = "^";
102
+ for (let index = 0; index < glob.length; index += 1) {
103
+ const character = glob[index];
104
+ const nextCharacter = glob[index + 1];
105
+ if (character === "*" && nextCharacter === "*") {
106
+ pattern += ".*";
107
+ index += 1;
108
+ continue;
109
+ }
110
+ if (character === "*") {
111
+ pattern += "[^/]*";
112
+ continue;
113
+ }
114
+ pattern += /[.+^${}()|[\]\\]/.test(character) ? `\\${character}` : character;
115
+ }
116
+ pattern += "$";
117
+ return new RegExp(pattern);
118
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};