@buoy-design/scanners 0.2.1 → 0.2.26
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/design-systems/index.d.ts +6 -0
- package/dist/design-systems/index.d.ts.map +1 -0
- package/dist/design-systems/index.js +6 -0
- package/dist/design-systems/index.js.map +1 -0
- package/dist/design-systems/library-detector.d.ts +53 -0
- package/dist/design-systems/library-detector.d.ts.map +1 -0
- package/dist/design-systems/library-detector.js +183 -0
- package/dist/design-systems/library-detector.js.map +1 -0
- package/dist/extractors/css-in-js.d.ts +20 -0
- package/dist/extractors/css-in-js.d.ts.map +1 -0
- package/dist/extractors/css-in-js.js +284 -0
- package/dist/extractors/css-in-js.js.map +1 -0
- package/dist/extractors/html-style.d.ts +1 -1
- package/dist/extractors/html-style.d.ts.map +1 -1
- package/dist/extractors/index.d.ts +1 -0
- package/dist/extractors/index.d.ts.map +1 -1
- package/dist/extractors/index.js +1 -0
- package/dist/extractors/index.js.map +1 -1
- package/dist/figma/client.d.ts +4 -0
- package/dist/figma/client.d.ts.map +1 -1
- package/dist/figma/client.js.map +1 -1
- package/dist/figma/component-scanner.d.ts +67 -1
- package/dist/figma/component-scanner.d.ts.map +1 -1
- package/dist/figma/component-scanner.js +162 -1
- package/dist/figma/component-scanner.js.map +1 -1
- package/dist/figma/index.d.ts +1 -1
- package/dist/figma/index.d.ts.map +1 -1
- package/dist/figma/index.js.map +1 -1
- package/dist/git/angular-scanner.d.ts +78 -1
- package/dist/git/angular-scanner.d.ts.map +1 -1
- package/dist/git/angular-scanner.js +249 -6
- package/dist/git/angular-scanner.js.map +1 -1
- package/dist/git/index.d.ts +4 -3
- package/dist/git/index.d.ts.map +1 -1
- package/dist/git/index.js +1 -0
- package/dist/git/index.js.map +1 -1
- package/dist/git/nextjs-scanner.d.ts +190 -0
- package/dist/git/nextjs-scanner.d.ts.map +1 -0
- package/dist/git/nextjs-scanner.js +979 -0
- package/dist/git/nextjs-scanner.js.map +1 -0
- package/dist/git/react-scanner.d.ts +74 -1
- package/dist/git/react-scanner.d.ts.map +1 -1
- package/dist/git/react-scanner.js +174 -5
- package/dist/git/react-scanner.js.map +1 -1
- package/dist/git/token-scanner.d.ts.map +1 -1
- package/dist/git/token-scanner.js +24 -2
- package/dist/git/token-scanner.js.map +1 -1
- package/dist/git/vue-scanner.d.ts +43 -1
- package/dist/git/vue-scanner.d.ts.map +1 -1
- package/dist/git/vue-scanner.js +130 -4
- package/dist/git/vue-scanner.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/signals/extractors/arbitrary-value.d.ts +7 -0
- package/dist/signals/extractors/arbitrary-value.d.ts.map +1 -0
- package/dist/signals/extractors/arbitrary-value.js +57 -0
- package/dist/signals/extractors/arbitrary-value.js.map +1 -0
- package/dist/signals/extractors/breakpoint.d.ts +6 -0
- package/dist/signals/extractors/breakpoint.d.ts.map +1 -0
- package/dist/signals/extractors/breakpoint.js +41 -0
- package/dist/signals/extractors/breakpoint.js.map +1 -0
- package/dist/signals/extractors/index.d.ts +7 -0
- package/dist/signals/extractors/index.d.ts.map +1 -1
- package/dist/signals/extractors/index.js +7 -0
- package/dist/signals/extractors/index.js.map +1 -1
- package/dist/signals/extractors/inline-style.d.ts +6 -0
- package/dist/signals/extractors/inline-style.d.ts.map +1 -0
- package/dist/signals/extractors/inline-style.js +60 -0
- package/dist/signals/extractors/inline-style.js.map +1 -0
- package/dist/signals/extractors/radius.d.ts +3 -0
- package/dist/signals/extractors/radius.d.ts.map +1 -0
- package/dist/signals/extractors/radius.js +32 -0
- package/dist/signals/extractors/radius.js.map +1 -0
- package/dist/signals/extractors/shadow.d.ts +3 -0
- package/dist/signals/extractors/shadow.d.ts.map +1 -0
- package/dist/signals/extractors/shadow.js +41 -0
- package/dist/signals/extractors/shadow.js.map +1 -0
- package/dist/signals/extractors/sizing.d.ts +6 -0
- package/dist/signals/extractors/sizing.d.ts.map +1 -0
- package/dist/signals/extractors/sizing.js +60 -0
- package/dist/signals/extractors/sizing.js.map +1 -0
- package/dist/signals/extractors/z-index.d.ts +3 -0
- package/dist/signals/extractors/z-index.d.ts.map +1 -0
- package/dist/signals/extractors/z-index.js +30 -0
- package/dist/signals/extractors/z-index.js.map +1 -0
- package/dist/signals/scanner-integration.d.ts.map +1 -1
- package/dist/signals/scanner-integration.js +35 -0
- package/dist/signals/scanner-integration.js.map +1 -1
- package/dist/signals/types.d.ts +4 -4
- package/dist/signals/types.d.ts.map +1 -1
- package/dist/signals/types.js +4 -0
- package/dist/signals/types.js.map +1 -1
- package/dist/storybook/extractor.d.ts +106 -1
- package/dist/storybook/extractor.d.ts.map +1 -1
- package/dist/storybook/extractor.js +325 -5
- package/dist/storybook/extractor.js.map +1 -1
- package/dist/storybook/index.d.ts +1 -1
- package/dist/storybook/index.d.ts.map +1 -1
- package/dist/storybook/index.js.map +1 -1
- package/dist/tailwind/index.d.ts +1 -1
- package/dist/tailwind/index.d.ts.map +1 -1
- package/dist/tailwind/index.js.map +1 -1
- package/dist/tailwind/scanner.d.ts +32 -0
- package/dist/tailwind/scanner.d.ts.map +1 -1
- package/dist/tailwind/scanner.js +105 -0
- package/dist/tailwind/scanner.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
import { SignalAwareScanner } from "../base/index.js";
|
|
2
|
+
import { createComponentId } from "@buoy-design/core";
|
|
3
|
+
import * as ts from "typescript";
|
|
4
|
+
import { readFile } from "fs/promises";
|
|
5
|
+
import { readFileSync, existsSync, statSync } from "fs";
|
|
6
|
+
import { relative, join, dirname, basename } from "path";
|
|
7
|
+
import { glob } from "glob";
|
|
8
|
+
import { createScannerSignalCollector, } from "../signals/scanner-integration.js";
|
|
9
|
+
import { getHardcodedValueType } from "../patterns/index.js";
|
|
10
|
+
// Color patterns for CSS scanning
|
|
11
|
+
const COLOR_PATTERNS = [
|
|
12
|
+
/^#[0-9a-fA-F]{3,8}$/,
|
|
13
|
+
/^rgb\s*\(/i,
|
|
14
|
+
/^rgba\s*\(/i,
|
|
15
|
+
/^hsl\s*\(/i,
|
|
16
|
+
/^hsla\s*\(/i,
|
|
17
|
+
/^oklch\s*\(/i,
|
|
18
|
+
];
|
|
19
|
+
// Spacing patterns for CSS scanning
|
|
20
|
+
const SPACING_PATTERNS = [
|
|
21
|
+
/^\d+(\.\d+)?(px|rem|em|vh|vw|%)$/,
|
|
22
|
+
];
|
|
23
|
+
// App Router special file patterns
|
|
24
|
+
const APP_ROUTER_FILES = {
|
|
25
|
+
page: /^page\.(tsx?|jsx?)$/,
|
|
26
|
+
layout: /^layout\.(tsx?|jsx?)$/,
|
|
27
|
+
loading: /^loading\.(tsx?|jsx?)$/,
|
|
28
|
+
error: /^error\.(tsx?|jsx?)$/,
|
|
29
|
+
"not-found": /^not-found\.(tsx?|jsx?)$/,
|
|
30
|
+
template: /^template\.(tsx?|jsx?)$/,
|
|
31
|
+
default: /^default\.(tsx?|jsx?)$/,
|
|
32
|
+
route: /^route\.(tsx?|jsx?)$/,
|
|
33
|
+
};
|
|
34
|
+
export class NextJSScanner extends SignalAwareScanner {
|
|
35
|
+
static DEFAULT_PATTERNS = ["**/*.tsx", "**/*.jsx", "**/*.ts", "**/*.js"];
|
|
36
|
+
static CSS_MODULE_PATTERNS = ["**/*.module.css", "**/*.module.scss"];
|
|
37
|
+
async scan() {
|
|
38
|
+
this.clearSignals();
|
|
39
|
+
const result = {
|
|
40
|
+
items: [],
|
|
41
|
+
serverComponents: [],
|
|
42
|
+
clientComponents: [],
|
|
43
|
+
cssModules: [],
|
|
44
|
+
imageUsage: [],
|
|
45
|
+
routes: [],
|
|
46
|
+
routeGroups: [],
|
|
47
|
+
errors: [],
|
|
48
|
+
stats: {
|
|
49
|
+
filesScanned: 0,
|
|
50
|
+
itemsFound: 0,
|
|
51
|
+
duration: 0,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
const startTime = Date.now();
|
|
55
|
+
// Detect App Router structure
|
|
56
|
+
const appDir = join(this.config.projectRoot, "app");
|
|
57
|
+
let hasAppRouter = false;
|
|
58
|
+
try {
|
|
59
|
+
hasAppRouter = existsSync(appDir) && statSync(appDir).isDirectory();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// App directory not accessible
|
|
63
|
+
}
|
|
64
|
+
if (hasAppRouter && this.config.appRouter !== false) {
|
|
65
|
+
// Scan App Router routes
|
|
66
|
+
const routeResult = await this.scanAppRouter(appDir);
|
|
67
|
+
result.routes = routeResult.routes;
|
|
68
|
+
result.routeGroups = routeResult.routeGroups;
|
|
69
|
+
}
|
|
70
|
+
// Scan components
|
|
71
|
+
let componentResult;
|
|
72
|
+
if (this.config.cache) {
|
|
73
|
+
componentResult = await this.runScanWithCache((file) => this.parseFile(file), NextJSScanner.DEFAULT_PATTERNS);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
componentResult = await this.runScan((file) => this.parseFile(file), NextJSScanner.DEFAULT_PATTERNS);
|
|
77
|
+
}
|
|
78
|
+
result.items = componentResult.items;
|
|
79
|
+
result.errors = componentResult.errors;
|
|
80
|
+
result.stats = {
|
|
81
|
+
...componentResult.stats,
|
|
82
|
+
duration: Date.now() - startTime,
|
|
83
|
+
};
|
|
84
|
+
// Categorize server vs client components based on tags
|
|
85
|
+
for (const comp of result.items) {
|
|
86
|
+
const tags = comp.metadata.tags || [];
|
|
87
|
+
if (tags.includes("client-component")) {
|
|
88
|
+
result.clientComponents.push(comp);
|
|
89
|
+
}
|
|
90
|
+
else if (tags.includes("server-component")) {
|
|
91
|
+
result.serverComponents.push(comp);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Scan CSS modules
|
|
95
|
+
if (this.config.cssModules !== false) {
|
|
96
|
+
result.cssModules = await this.scanCSSModules();
|
|
97
|
+
}
|
|
98
|
+
// Scan next/image usage
|
|
99
|
+
if (this.config.validateImage !== false) {
|
|
100
|
+
result.imageUsage = await this.scanImageUsage();
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
getSourceType() {
|
|
105
|
+
return "nextjs";
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Scan the App Router directory structure
|
|
109
|
+
*/
|
|
110
|
+
async scanAppRouter(appDir) {
|
|
111
|
+
const routes = [];
|
|
112
|
+
const routeGroups = new Set();
|
|
113
|
+
const scanDirectory = async (dir, routePath, currentGroup) => {
|
|
114
|
+
const entries = await this.readDirectory(dir);
|
|
115
|
+
// Check for route group (parentheses directory)
|
|
116
|
+
const dirName = basename(dir);
|
|
117
|
+
let groupName = currentGroup;
|
|
118
|
+
if (dirName.startsWith("(") && dirName.endsWith(")")) {
|
|
119
|
+
groupName = dirName.slice(1, -1);
|
|
120
|
+
routeGroups.add(groupName);
|
|
121
|
+
}
|
|
122
|
+
// Detect dynamic segments
|
|
123
|
+
const isDynamic = dirName.startsWith("[") && dirName.endsWith("]");
|
|
124
|
+
let segmentName;
|
|
125
|
+
if (isDynamic) {
|
|
126
|
+
segmentName = dirName.slice(1, -1);
|
|
127
|
+
// Handle catch-all routes [...slug] and optional catch-all [[...slug]]
|
|
128
|
+
if (segmentName.startsWith("...")) {
|
|
129
|
+
segmentName = segmentName.slice(3);
|
|
130
|
+
}
|
|
131
|
+
else if (segmentName.startsWith("[...")) {
|
|
132
|
+
segmentName = segmentName.slice(4, -1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Build route path (skip route group names in path)
|
|
136
|
+
let currentRoutePath = routePath;
|
|
137
|
+
if (!dirName.startsWith("(")) {
|
|
138
|
+
if (isDynamic && segmentName) {
|
|
139
|
+
currentRoutePath = routePath + `/[${segmentName}]`;
|
|
140
|
+
}
|
|
141
|
+
else if (dirName !== "app") {
|
|
142
|
+
currentRoutePath = routePath + "/" + dirName;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const route = {
|
|
146
|
+
path: currentRoutePath || "/",
|
|
147
|
+
isDynamic: isDynamic,
|
|
148
|
+
dynamicSegments: [],
|
|
149
|
+
routeGroup: groupName,
|
|
150
|
+
};
|
|
151
|
+
// Check for special App Router files
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
const entryPath = join(dir, entry);
|
|
154
|
+
// Gracefully handle files that don't exist or are inaccessible
|
|
155
|
+
let stat;
|
|
156
|
+
try {
|
|
157
|
+
stat = statSync(entryPath);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// Skip entries that can't be accessed (symlinks, permissions, etc.)
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (stat.isFile()) {
|
|
164
|
+
for (const [fileType, pattern] of Object.entries(APP_ROUTER_FILES)) {
|
|
165
|
+
if (pattern.test(entry)) {
|
|
166
|
+
const relativePath = relative(this.config.projectRoot, entryPath);
|
|
167
|
+
switch (fileType) {
|
|
168
|
+
case "page":
|
|
169
|
+
route.pageFile = relativePath;
|
|
170
|
+
break;
|
|
171
|
+
case "layout":
|
|
172
|
+
route.layoutFile = relativePath;
|
|
173
|
+
break;
|
|
174
|
+
case "loading":
|
|
175
|
+
route.loadingFile = relativePath;
|
|
176
|
+
break;
|
|
177
|
+
case "error":
|
|
178
|
+
route.errorFile = relativePath;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else if (stat.isDirectory()) {
|
|
185
|
+
await scanDirectory(entryPath, currentRoutePath, groupName);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Only add route if it has at least a page
|
|
189
|
+
if (route.pageFile) {
|
|
190
|
+
routes.push(route);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
await scanDirectory(appDir, "", undefined);
|
|
194
|
+
return { routes, routeGroups: Array.from(routeGroups) };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Read directory entries
|
|
198
|
+
*/
|
|
199
|
+
async readDirectory(dir) {
|
|
200
|
+
try {
|
|
201
|
+
const { readdir } = await import("fs/promises");
|
|
202
|
+
return await readdir(dir);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Parse a single file for components
|
|
210
|
+
*/
|
|
211
|
+
async parseFile(filePath) {
|
|
212
|
+
const content = await readFile(filePath, "utf-8");
|
|
213
|
+
const relativePath = relative(this.config.projectRoot, filePath);
|
|
214
|
+
// Determine if this is a client or server component
|
|
215
|
+
const isClientComponent = this.hasUseClientDirective(content);
|
|
216
|
+
const isInAppDir = relativePath.startsWith("app/") || relativePath.startsWith("app\\");
|
|
217
|
+
const isServerComponent = isInAppDir && !isClientComponent;
|
|
218
|
+
// Detect App Router file type
|
|
219
|
+
const fileName = basename(filePath);
|
|
220
|
+
let appRouterFileType;
|
|
221
|
+
for (const [fileType, pattern] of Object.entries(APP_ROUTER_FILES)) {
|
|
222
|
+
if (pattern.test(fileName)) {
|
|
223
|
+
appRouterFileType = fileType;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Detect route group from path
|
|
228
|
+
const routeGroup = this.extractRouteGroup(relativePath);
|
|
229
|
+
// Detect dynamic segments from path
|
|
230
|
+
const dynamicSegments = this.extractDynamicSegments(relativePath);
|
|
231
|
+
// Parse TypeScript/JavaScript
|
|
232
|
+
let scriptKind;
|
|
233
|
+
if (filePath.endsWith(".tsx")) {
|
|
234
|
+
scriptKind = ts.ScriptKind.TSX;
|
|
235
|
+
}
|
|
236
|
+
else if (filePath.endsWith(".jsx") || filePath.endsWith(".js")) {
|
|
237
|
+
scriptKind = ts.ScriptKind.JSX;
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
scriptKind = ts.ScriptKind.TS;
|
|
241
|
+
}
|
|
242
|
+
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, scriptKind);
|
|
243
|
+
const components = [];
|
|
244
|
+
// Use 'react' as the signal collector type since Next.js is React-based
|
|
245
|
+
const signalCollector = createScannerSignalCollector("react", relativePath);
|
|
246
|
+
const visit = (node) => {
|
|
247
|
+
// Function declarations
|
|
248
|
+
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
249
|
+
if (this.isAtModuleScope(node) && this.isReactComponent(node, sourceFile)) {
|
|
250
|
+
const comp = this.extractComponent(node, sourceFile, relativePath, isClientComponent, isServerComponent, appRouterFileType, routeGroup, dynamicSegments, signalCollector);
|
|
251
|
+
if (comp)
|
|
252
|
+
components.push(comp);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Variable declarations (arrow functions, etc.)
|
|
256
|
+
if (ts.isVariableStatement(node) && this.isAtModuleScope(node)) {
|
|
257
|
+
for (const decl of node.declarationList.declarations) {
|
|
258
|
+
if (ts.isIdentifier(decl.name) && decl.initializer) {
|
|
259
|
+
if (this.isReactComponentExpression(decl.initializer, sourceFile)) {
|
|
260
|
+
const comp = this.extractVariableComponent(decl, sourceFile, relativePath, isClientComponent, isServerComponent, appRouterFileType, routeGroup, dynamicSegments, signalCollector);
|
|
261
|
+
if (comp)
|
|
262
|
+
components.push(comp);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Default exports (common in Next.js pages/layouts)
|
|
268
|
+
if (ts.isExportAssignment(node) && !node.isExportEquals) {
|
|
269
|
+
const expr = node.expression;
|
|
270
|
+
if (ts.isIdentifier(expr)) {
|
|
271
|
+
// export default ComponentName - already handled by function/variable declarations
|
|
272
|
+
}
|
|
273
|
+
else if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) {
|
|
274
|
+
// export default () => {} or export default function() {}
|
|
275
|
+
const comp = this.extractDefaultExportComponent(node, sourceFile, relativePath, isClientComponent, isServerComponent, appRouterFileType, routeGroup, dynamicSegments, signalCollector);
|
|
276
|
+
if (comp)
|
|
277
|
+
components.push(comp);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
ts.forEachChild(node, visit);
|
|
281
|
+
};
|
|
282
|
+
ts.forEachChild(sourceFile, visit);
|
|
283
|
+
// Add signals
|
|
284
|
+
this.addSignals(relativePath, signalCollector.getEmitter());
|
|
285
|
+
return components;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Check if content has 'use client' directive
|
|
289
|
+
*/
|
|
290
|
+
hasUseClientDirective(content) {
|
|
291
|
+
// Check first few lines for 'use client'
|
|
292
|
+
const lines = content.split("\n").slice(0, 10);
|
|
293
|
+
for (const line of lines) {
|
|
294
|
+
const trimmed = line.trim();
|
|
295
|
+
if (trimmed === '"use client"' || trimmed === "'use client'" || trimmed === '"use client";' || trimmed === "'use client';") {
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
// Stop checking if we hit actual code (not comments or empty lines)
|
|
299
|
+
if (trimmed && !trimmed.startsWith("//") && !trimmed.startsWith("/*") && !trimmed.startsWith("*")) {
|
|
300
|
+
if (!trimmed.includes("use client")) {
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Extract route group from file path
|
|
309
|
+
*/
|
|
310
|
+
extractRouteGroup(filePath) {
|
|
311
|
+
const match = filePath.match(/\(([^)]+)\)/);
|
|
312
|
+
return match ? match[1] : undefined;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Extract dynamic segments from file path
|
|
316
|
+
*/
|
|
317
|
+
extractDynamicSegments(filePath) {
|
|
318
|
+
const segments = [];
|
|
319
|
+
const matches = filePath.matchAll(/\[([^\]]+)\]/g);
|
|
320
|
+
for (const match of matches) {
|
|
321
|
+
let segment = match[1];
|
|
322
|
+
if (segment) {
|
|
323
|
+
// Handle catch-all routes
|
|
324
|
+
if (segment.startsWith("...")) {
|
|
325
|
+
segment = segment.slice(3);
|
|
326
|
+
}
|
|
327
|
+
segments.push(segment);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return segments;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Check if node is at module scope
|
|
334
|
+
*/
|
|
335
|
+
isAtModuleScope(node) {
|
|
336
|
+
let current = node.parent;
|
|
337
|
+
while (current) {
|
|
338
|
+
if (ts.isFunctionDeclaration(current) ||
|
|
339
|
+
ts.isFunctionExpression(current) ||
|
|
340
|
+
ts.isArrowFunction(current) ||
|
|
341
|
+
ts.isMethodDeclaration(current)) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
if (ts.isSourceFile(current)) {
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
current = current.parent;
|
|
348
|
+
}
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Check if a function declaration is a React component
|
|
353
|
+
*/
|
|
354
|
+
isReactComponent(node, sourceFile) {
|
|
355
|
+
if (!node.name)
|
|
356
|
+
return false;
|
|
357
|
+
const name = node.name.getText(sourceFile);
|
|
358
|
+
if (!/^[A-Z]/.test(name))
|
|
359
|
+
return false;
|
|
360
|
+
// Check for JSX
|
|
361
|
+
if (this.returnsJsx(node))
|
|
362
|
+
return true;
|
|
363
|
+
// Check return type
|
|
364
|
+
if (node.type) {
|
|
365
|
+
const returnType = node.type.getText(sourceFile);
|
|
366
|
+
if (returnType.includes("ReactNode") ||
|
|
367
|
+
returnType.includes("ReactElement") ||
|
|
368
|
+
returnType.includes("JSX.Element")) {
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Check if an expression is a React component
|
|
376
|
+
*/
|
|
377
|
+
isReactComponentExpression(node, sourceFile) {
|
|
378
|
+
if (ts.isAsExpression(node)) {
|
|
379
|
+
return this.isReactComponentExpression(node.expression, sourceFile);
|
|
380
|
+
}
|
|
381
|
+
if (ts.isParenthesizedExpression(node)) {
|
|
382
|
+
return this.isReactComponentExpression(node.expression, sourceFile);
|
|
383
|
+
}
|
|
384
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
|
|
385
|
+
if (this.returnsJsx(node))
|
|
386
|
+
return true;
|
|
387
|
+
if (node.type) {
|
|
388
|
+
const returnType = node.type.getText(sourceFile);
|
|
389
|
+
if (returnType.includes("ReactNode") ||
|
|
390
|
+
returnType.includes("ReactElement") ||
|
|
391
|
+
returnType.includes("JSX.Element")) {
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
if (ts.isCallExpression(node)) {
|
|
398
|
+
const callText = node.expression.getText(sourceFile);
|
|
399
|
+
if (callText.includes("forwardRef") ||
|
|
400
|
+
callText.includes("memo") ||
|
|
401
|
+
callText === "lazy" ||
|
|
402
|
+
callText === "React.lazy") {
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Check if a function returns JSX
|
|
410
|
+
*/
|
|
411
|
+
returnsJsx(node) {
|
|
412
|
+
let hasJsx = false;
|
|
413
|
+
const checkNode = (n) => {
|
|
414
|
+
if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) {
|
|
415
|
+
hasJsx = true;
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
ts.forEachChild(n, checkNode);
|
|
419
|
+
};
|
|
420
|
+
if (node.body) {
|
|
421
|
+
checkNode(node.body);
|
|
422
|
+
}
|
|
423
|
+
return hasJsx;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Extract component from function declaration
|
|
427
|
+
*/
|
|
428
|
+
extractComponent(node, sourceFile, relativePath, isClientComponent, isServerComponent, appRouterFileType, routeGroup, _dynamicSegments, signalCollector) {
|
|
429
|
+
if (!node.name)
|
|
430
|
+
return null;
|
|
431
|
+
const name = node.name.getText(sourceFile);
|
|
432
|
+
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
433
|
+
const props = this.extractProps(node.parameters, sourceFile);
|
|
434
|
+
const hardcodedValues = this.extractHardcodedValues(node, sourceFile, signalCollector);
|
|
435
|
+
// Build tags
|
|
436
|
+
const tags = [];
|
|
437
|
+
if (isClientComponent)
|
|
438
|
+
tags.push("client-component");
|
|
439
|
+
if (isServerComponent)
|
|
440
|
+
tags.push("server-component");
|
|
441
|
+
if (appRouterFileType)
|
|
442
|
+
tags.push(`app-router-${appRouterFileType}`);
|
|
443
|
+
if (routeGroup)
|
|
444
|
+
tags.push(`route-group-${routeGroup}`);
|
|
445
|
+
signalCollector.collectComponentDef(name, line, {
|
|
446
|
+
isClientComponent,
|
|
447
|
+
isServerComponent,
|
|
448
|
+
appRouterFileType,
|
|
449
|
+
});
|
|
450
|
+
// Use react source type for compatibility
|
|
451
|
+
const source = {
|
|
452
|
+
type: "react",
|
|
453
|
+
path: relativePath,
|
|
454
|
+
exportName: name,
|
|
455
|
+
line,
|
|
456
|
+
};
|
|
457
|
+
return {
|
|
458
|
+
id: createComponentId(source, name),
|
|
459
|
+
name,
|
|
460
|
+
source,
|
|
461
|
+
props,
|
|
462
|
+
variants: [],
|
|
463
|
+
tokens: [],
|
|
464
|
+
dependencies: this.extractDependencies(node, sourceFile, signalCollector),
|
|
465
|
+
metadata: {
|
|
466
|
+
tags,
|
|
467
|
+
hardcodedValues: hardcodedValues.length > 0 ? hardcodedValues : undefined,
|
|
468
|
+
},
|
|
469
|
+
scannedAt: new Date(),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Extract component from variable declaration
|
|
474
|
+
*/
|
|
475
|
+
extractVariableComponent(node, sourceFile, relativePath, isClientComponent, isServerComponent, appRouterFileType, routeGroup, _dynamicSegments, signalCollector) {
|
|
476
|
+
if (!ts.isIdentifier(node.name))
|
|
477
|
+
return null;
|
|
478
|
+
const name = node.name.getText(sourceFile);
|
|
479
|
+
if (!/^[A-Z]/.test(name))
|
|
480
|
+
return null;
|
|
481
|
+
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
482
|
+
const hardcodedValues = this.extractHardcodedValues(node, sourceFile, signalCollector);
|
|
483
|
+
let props = [];
|
|
484
|
+
const init = node.initializer;
|
|
485
|
+
if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
|
|
486
|
+
props = this.extractProps(init.parameters, sourceFile);
|
|
487
|
+
}
|
|
488
|
+
const tags = [];
|
|
489
|
+
if (isClientComponent)
|
|
490
|
+
tags.push("client-component");
|
|
491
|
+
if (isServerComponent)
|
|
492
|
+
tags.push("server-component");
|
|
493
|
+
if (appRouterFileType)
|
|
494
|
+
tags.push(`app-router-${appRouterFileType}`);
|
|
495
|
+
if (routeGroup)
|
|
496
|
+
tags.push(`route-group-${routeGroup}`);
|
|
497
|
+
signalCollector.collectComponentDef(name, line, {
|
|
498
|
+
isClientComponent,
|
|
499
|
+
isServerComponent,
|
|
500
|
+
appRouterFileType,
|
|
501
|
+
});
|
|
502
|
+
const source = {
|
|
503
|
+
type: "react",
|
|
504
|
+
path: relativePath,
|
|
505
|
+
exportName: name,
|
|
506
|
+
line,
|
|
507
|
+
};
|
|
508
|
+
return {
|
|
509
|
+
id: createComponentId(source, name),
|
|
510
|
+
name,
|
|
511
|
+
source,
|
|
512
|
+
props,
|
|
513
|
+
variants: [],
|
|
514
|
+
tokens: [],
|
|
515
|
+
dependencies: [],
|
|
516
|
+
metadata: {
|
|
517
|
+
tags,
|
|
518
|
+
hardcodedValues: hardcodedValues.length > 0 ? hardcodedValues : undefined,
|
|
519
|
+
},
|
|
520
|
+
scannedAt: new Date(),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Extract component from default export
|
|
525
|
+
*/
|
|
526
|
+
extractDefaultExportComponent(node, sourceFile, relativePath, isClientComponent, isServerComponent, appRouterFileType, routeGroup, _dynamicSegments, signalCollector) {
|
|
527
|
+
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
528
|
+
// Generate name from file path for default exports
|
|
529
|
+
const fileName = basename(relativePath, ".tsx").replace(/\.jsx?$/, "");
|
|
530
|
+
let name;
|
|
531
|
+
if (appRouterFileType) {
|
|
532
|
+
// Use the route segment + file type for App Router files
|
|
533
|
+
const pathParts = dirname(relativePath).split("/").filter(p => p && p !== "app");
|
|
534
|
+
const routeSegment = pathParts[pathParts.length - 1] || "Root";
|
|
535
|
+
name = this.toPascalCase(routeSegment) + this.toPascalCase(appRouterFileType);
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
name = this.toPascalCase(fileName);
|
|
539
|
+
}
|
|
540
|
+
const expr = node.expression;
|
|
541
|
+
let props = [];
|
|
542
|
+
if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) {
|
|
543
|
+
props = this.extractProps(expr.parameters, sourceFile);
|
|
544
|
+
}
|
|
545
|
+
const hardcodedValues = this.extractHardcodedValues(node, sourceFile, signalCollector);
|
|
546
|
+
const tags = ["default-export"];
|
|
547
|
+
if (isClientComponent)
|
|
548
|
+
tags.push("client-component");
|
|
549
|
+
if (isServerComponent)
|
|
550
|
+
tags.push("server-component");
|
|
551
|
+
if (appRouterFileType)
|
|
552
|
+
tags.push(`app-router-${appRouterFileType}`);
|
|
553
|
+
if (routeGroup)
|
|
554
|
+
tags.push(`route-group-${routeGroup}`);
|
|
555
|
+
signalCollector.collectComponentDef(name, line, {
|
|
556
|
+
isClientComponent,
|
|
557
|
+
isServerComponent,
|
|
558
|
+
appRouterFileType,
|
|
559
|
+
isDefaultExport: true,
|
|
560
|
+
});
|
|
561
|
+
const source = {
|
|
562
|
+
type: "react",
|
|
563
|
+
path: relativePath,
|
|
564
|
+
exportName: "default",
|
|
565
|
+
line,
|
|
566
|
+
};
|
|
567
|
+
return {
|
|
568
|
+
id: createComponentId(source, name),
|
|
569
|
+
name,
|
|
570
|
+
source,
|
|
571
|
+
props,
|
|
572
|
+
variants: [],
|
|
573
|
+
tokens: [],
|
|
574
|
+
dependencies: [],
|
|
575
|
+
metadata: {
|
|
576
|
+
tags,
|
|
577
|
+
hardcodedValues: hardcodedValues.length > 0 ? hardcodedValues : undefined,
|
|
578
|
+
},
|
|
579
|
+
scannedAt: new Date(),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Convert string to PascalCase
|
|
584
|
+
*/
|
|
585
|
+
toPascalCase(str) {
|
|
586
|
+
return str
|
|
587
|
+
.replace(/[-_](.)/g, (_, c) => c.toUpperCase())
|
|
588
|
+
.replace(/^(.)/, (_, c) => c.toUpperCase());
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Extract props from parameters
|
|
592
|
+
*/
|
|
593
|
+
extractProps(parameters, sourceFile) {
|
|
594
|
+
const props = [];
|
|
595
|
+
const propsParam = parameters[0];
|
|
596
|
+
if (!propsParam)
|
|
597
|
+
return props;
|
|
598
|
+
const typeNode = propsParam.type;
|
|
599
|
+
if (typeNode && ts.isTypeLiteralNode(typeNode)) {
|
|
600
|
+
for (const member of typeNode.members) {
|
|
601
|
+
if (ts.isPropertySignature(member) && member.name) {
|
|
602
|
+
props.push({
|
|
603
|
+
name: member.name.getText(sourceFile),
|
|
604
|
+
type: member.type ? member.type.getText(sourceFile) : "unknown",
|
|
605
|
+
required: !member.questionToken,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
else if (typeNode && ts.isTypeReferenceNode(typeNode)) {
|
|
611
|
+
props.push({
|
|
612
|
+
name: "props",
|
|
613
|
+
type: typeNode.getText(sourceFile),
|
|
614
|
+
required: true,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
if (ts.isObjectBindingPattern(propsParam.name)) {
|
|
618
|
+
for (const element of propsParam.name.elements) {
|
|
619
|
+
if (ts.isBindingElement(element) && ts.isIdentifier(element.name)) {
|
|
620
|
+
props.push({
|
|
621
|
+
name: element.name.getText(sourceFile),
|
|
622
|
+
type: "unknown",
|
|
623
|
+
required: !element.initializer,
|
|
624
|
+
defaultValue: element.initializer ? element.initializer.getText(sourceFile) : undefined,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return props;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Extract dependencies (component usage)
|
|
633
|
+
*/
|
|
634
|
+
extractDependencies(node, sourceFile, signalCollector) {
|
|
635
|
+
const deps = new Set();
|
|
636
|
+
const visit = (n) => {
|
|
637
|
+
if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
|
|
638
|
+
const tagName = n.tagName.getText(sourceFile);
|
|
639
|
+
if (/^[A-Z]/.test(tagName)) {
|
|
640
|
+
deps.add(tagName);
|
|
641
|
+
if (signalCollector) {
|
|
642
|
+
const depLine = sourceFile.getLineAndCharacterOfPosition(n.getStart(sourceFile)).line + 1;
|
|
643
|
+
signalCollector.collectComponentUsage(tagName, depLine);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
ts.forEachChild(n, visit);
|
|
648
|
+
};
|
|
649
|
+
visit(node);
|
|
650
|
+
return Array.from(deps);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Extract hardcoded values from a node
|
|
654
|
+
*/
|
|
655
|
+
extractHardcodedValues(node, sourceFile, signalCollector) {
|
|
656
|
+
const hardcoded = [];
|
|
657
|
+
const visit = (n) => {
|
|
658
|
+
if (ts.isJsxAttribute(n)) {
|
|
659
|
+
const attrName = n.name.getText(sourceFile);
|
|
660
|
+
// Style prop
|
|
661
|
+
if (attrName === "style" && n.initializer) {
|
|
662
|
+
const styleValues = this.extractStyleObjectValues(n.initializer, sourceFile, signalCollector);
|
|
663
|
+
hardcoded.push(...styleValues);
|
|
664
|
+
}
|
|
665
|
+
// Direct color/spacing props
|
|
666
|
+
if (["color", "bg", "backgroundColor", "fill", "stroke"].includes(attrName)) {
|
|
667
|
+
const value = this.getJsxAttributeValue(n, sourceFile);
|
|
668
|
+
if (value && this.isHardcodedColor(value)) {
|
|
669
|
+
const attrLine = sourceFile.getLineAndCharacterOfPosition(n.getStart(sourceFile)).line + 1;
|
|
670
|
+
hardcoded.push({
|
|
671
|
+
type: "color",
|
|
672
|
+
value,
|
|
673
|
+
property: attrName,
|
|
674
|
+
location: `line ${attrLine}`,
|
|
675
|
+
});
|
|
676
|
+
signalCollector?.collectFromValue(value, attrName, attrLine);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
ts.forEachChild(n, visit);
|
|
681
|
+
};
|
|
682
|
+
visit(node);
|
|
683
|
+
// Deduplicate
|
|
684
|
+
const seen = new Set();
|
|
685
|
+
return hardcoded.filter((h) => {
|
|
686
|
+
const key = `${h.property}:${h.value}`;
|
|
687
|
+
if (seen.has(key))
|
|
688
|
+
return false;
|
|
689
|
+
seen.add(key);
|
|
690
|
+
return true;
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Extract hardcoded values from style object
|
|
695
|
+
*/
|
|
696
|
+
extractStyleObjectValues(initializer, sourceFile, signalCollector) {
|
|
697
|
+
const values = [];
|
|
698
|
+
const styleProperties = {
|
|
699
|
+
color: "color",
|
|
700
|
+
backgroundColor: "color",
|
|
701
|
+
background: "color",
|
|
702
|
+
borderColor: "color",
|
|
703
|
+
padding: "spacing",
|
|
704
|
+
paddingTop: "spacing",
|
|
705
|
+
paddingRight: "spacing",
|
|
706
|
+
paddingBottom: "spacing",
|
|
707
|
+
paddingLeft: "spacing",
|
|
708
|
+
margin: "spacing",
|
|
709
|
+
marginTop: "spacing",
|
|
710
|
+
marginRight: "spacing",
|
|
711
|
+
marginBottom: "spacing",
|
|
712
|
+
marginLeft: "spacing",
|
|
713
|
+
fontSize: "fontSize",
|
|
714
|
+
};
|
|
715
|
+
const processObject = (obj) => {
|
|
716
|
+
for (const prop of obj.properties) {
|
|
717
|
+
if (ts.isPropertyAssignment(prop) && prop.name) {
|
|
718
|
+
const propName = prop.name.getText(sourceFile);
|
|
719
|
+
const valueType = styleProperties[propName];
|
|
720
|
+
if (valueType && prop.initializer) {
|
|
721
|
+
const value = this.getLiteralValue(prop.initializer, sourceFile);
|
|
722
|
+
if (value && this.isHardcodedValue(value, valueType)) {
|
|
723
|
+
const propLine = sourceFile.getLineAndCharacterOfPosition(prop.getStart(sourceFile)).line + 1;
|
|
724
|
+
values.push({
|
|
725
|
+
type: valueType,
|
|
726
|
+
value,
|
|
727
|
+
property: propName,
|
|
728
|
+
location: `line ${propLine}`,
|
|
729
|
+
});
|
|
730
|
+
signalCollector?.collectFromValue(value, propName, propLine);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
if (ts.isJsxExpression(initializer) && initializer.expression) {
|
|
737
|
+
if (ts.isObjectLiteralExpression(initializer.expression)) {
|
|
738
|
+
processObject(initializer.expression);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return values;
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Get JSX attribute value
|
|
745
|
+
*/
|
|
746
|
+
getJsxAttributeValue(attr, sourceFile) {
|
|
747
|
+
if (!attr.initializer)
|
|
748
|
+
return null;
|
|
749
|
+
if (ts.isStringLiteral(attr.initializer)) {
|
|
750
|
+
return attr.initializer.text;
|
|
751
|
+
}
|
|
752
|
+
if (ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
|
|
753
|
+
return this.getLiteralValue(attr.initializer.expression, sourceFile);
|
|
754
|
+
}
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Get literal value from expression
|
|
759
|
+
*/
|
|
760
|
+
getLiteralValue(node, _sourceFile) {
|
|
761
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
762
|
+
return node.text;
|
|
763
|
+
}
|
|
764
|
+
if (ts.isNumericLiteral(node)) {
|
|
765
|
+
return node.text;
|
|
766
|
+
}
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Check if value is hardcoded
|
|
771
|
+
*/
|
|
772
|
+
isHardcodedValue(value, type) {
|
|
773
|
+
if (value.includes("var(--") || value.includes("theme.") || value.includes("tokens.")) {
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
switch (type) {
|
|
777
|
+
case "color":
|
|
778
|
+
return this.isHardcodedColor(value);
|
|
779
|
+
case "spacing":
|
|
780
|
+
case "fontSize":
|
|
781
|
+
return this.isHardcodedSpacing(value);
|
|
782
|
+
default:
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Check if value is a hardcoded color
|
|
788
|
+
*/
|
|
789
|
+
isHardcodedColor(value) {
|
|
790
|
+
if (value.includes("var(--") || value.includes("theme.") || value.includes("tokens.")) {
|
|
791
|
+
return false;
|
|
792
|
+
}
|
|
793
|
+
if (["inherit", "transparent", "currentColor", "initial", "unset"].includes(value)) {
|
|
794
|
+
return false;
|
|
795
|
+
}
|
|
796
|
+
return COLOR_PATTERNS.some((p) => p.test(value));
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Check if value is hardcoded spacing
|
|
800
|
+
*/
|
|
801
|
+
isHardcodedSpacing(value) {
|
|
802
|
+
if (value.includes("var(--") || value.includes("theme.") || value.includes("tokens.")) {
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
if (["auto", "inherit", "0", "100%", "50%"].includes(value)) {
|
|
806
|
+
return false;
|
|
807
|
+
}
|
|
808
|
+
return SPACING_PATTERNS.some((p) => p.test(value));
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Scan CSS modules for hardcoded values
|
|
812
|
+
*/
|
|
813
|
+
async scanCSSModules() {
|
|
814
|
+
const results = [];
|
|
815
|
+
for (const pattern of NextJSScanner.CSS_MODULE_PATTERNS) {
|
|
816
|
+
try {
|
|
817
|
+
const files = await glob(pattern, {
|
|
818
|
+
cwd: this.config.projectRoot,
|
|
819
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/.next/**"],
|
|
820
|
+
absolute: true,
|
|
821
|
+
});
|
|
822
|
+
for (const file of files) {
|
|
823
|
+
const analysis = this.analyzeCSSModule(file);
|
|
824
|
+
if (analysis) {
|
|
825
|
+
results.push(analysis);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
// Continue on error
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return results;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Analyze a single CSS module file
|
|
837
|
+
*/
|
|
838
|
+
analyzeCSSModule(filePath) {
|
|
839
|
+
try {
|
|
840
|
+
const content = readFileSync(filePath, "utf-8");
|
|
841
|
+
const relativePath = relative(this.config.projectRoot, filePath);
|
|
842
|
+
const analysis = {
|
|
843
|
+
path: relativePath,
|
|
844
|
+
classNames: [],
|
|
845
|
+
hardcodedValues: [],
|
|
846
|
+
cssVariables: [],
|
|
847
|
+
};
|
|
848
|
+
// Extract class names
|
|
849
|
+
const classNameRegex = /\.([a-zA-Z_][\w-]*)/g;
|
|
850
|
+
let match;
|
|
851
|
+
while ((match = classNameRegex.exec(content)) !== null) {
|
|
852
|
+
const className = match[1];
|
|
853
|
+
if (className && !analysis.classNames.includes(className)) {
|
|
854
|
+
analysis.classNames.push(className);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// Extract CSS variables used
|
|
858
|
+
const varRegex = /var\(--([^)]+)\)/g;
|
|
859
|
+
while ((match = varRegex.exec(content)) !== null) {
|
|
860
|
+
const varName = match[1];
|
|
861
|
+
if (varName && !analysis.cssVariables.includes(varName)) {
|
|
862
|
+
analysis.cssVariables.push(varName);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
// Find hardcoded values
|
|
866
|
+
let lineNum = 1;
|
|
867
|
+
const lines = content.split("\n");
|
|
868
|
+
for (const lineContent of lines) {
|
|
869
|
+
const localRegex = /([a-z-]+)\s*:\s*([^;{}]+)/g;
|
|
870
|
+
let propMatch;
|
|
871
|
+
while ((propMatch = localRegex.exec(lineContent)) !== null) {
|
|
872
|
+
const property = propMatch[1];
|
|
873
|
+
const value = propMatch[2]?.trim();
|
|
874
|
+
if (!property || !value)
|
|
875
|
+
continue;
|
|
876
|
+
// Skip if using CSS variable
|
|
877
|
+
if (value.includes("var(--"))
|
|
878
|
+
continue;
|
|
879
|
+
const hardcodedType = getHardcodedValueType(property, value);
|
|
880
|
+
if (hardcodedType) {
|
|
881
|
+
analysis.hardcodedValues.push({
|
|
882
|
+
type: hardcodedType,
|
|
883
|
+
value,
|
|
884
|
+
property,
|
|
885
|
+
location: `${relativePath}:${lineNum}`,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
lineNum++;
|
|
890
|
+
}
|
|
891
|
+
return analysis;
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Scan for next/image usage
|
|
899
|
+
*/
|
|
900
|
+
async scanImageUsage() {
|
|
901
|
+
const results = [];
|
|
902
|
+
try {
|
|
903
|
+
const files = await glob("**/*.{tsx,jsx}", {
|
|
904
|
+
cwd: this.config.projectRoot,
|
|
905
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/.next/**"],
|
|
906
|
+
absolute: true,
|
|
907
|
+
});
|
|
908
|
+
for (const file of files) {
|
|
909
|
+
const content = await readFile(file, "utf-8");
|
|
910
|
+
const relativePath = relative(this.config.projectRoot, file);
|
|
911
|
+
// Check if file imports next/image
|
|
912
|
+
if (!content.includes("next/image") && !content.includes("from 'next/image'") && !content.includes('from "next/image"')) {
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
// Parse and find Image usage
|
|
916
|
+
const sourceFile = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
917
|
+
const visit = (node) => {
|
|
918
|
+
if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) {
|
|
919
|
+
const tagName = node.tagName.getText(sourceFile);
|
|
920
|
+
if (tagName === "Image" || tagName === "NextImage") {
|
|
921
|
+
const imageLine = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
922
|
+
const usage = this.analyzeImageUsage(node, sourceFile, relativePath, imageLine);
|
|
923
|
+
results.push(usage);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
ts.forEachChild(node, visit);
|
|
927
|
+
};
|
|
928
|
+
ts.forEachChild(sourceFile, visit);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
catch {
|
|
932
|
+
// Continue on error
|
|
933
|
+
}
|
|
934
|
+
return results;
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Analyze a single Image component usage
|
|
938
|
+
*/
|
|
939
|
+
analyzeImageUsage(node, sourceFile, file, line) {
|
|
940
|
+
const usage = {
|
|
941
|
+
file,
|
|
942
|
+
line,
|
|
943
|
+
hasAlt: false,
|
|
944
|
+
hasWidth: false,
|
|
945
|
+
hasHeight: false,
|
|
946
|
+
hasFill: false,
|
|
947
|
+
styleIssues: [],
|
|
948
|
+
};
|
|
949
|
+
const attributes = node.attributes;
|
|
950
|
+
for (const attr of attributes.properties) {
|
|
951
|
+
if (ts.isJsxAttribute(attr) && attr.name) {
|
|
952
|
+
const attrName = attr.name.getText(sourceFile);
|
|
953
|
+
switch (attrName) {
|
|
954
|
+
case "alt":
|
|
955
|
+
usage.hasAlt = true;
|
|
956
|
+
break;
|
|
957
|
+
case "width":
|
|
958
|
+
usage.hasWidth = true;
|
|
959
|
+
break;
|
|
960
|
+
case "height":
|
|
961
|
+
usage.hasHeight = true;
|
|
962
|
+
break;
|
|
963
|
+
case "fill":
|
|
964
|
+
usage.hasFill = true;
|
|
965
|
+
break;
|
|
966
|
+
case "style":
|
|
967
|
+
// Check for hardcoded values in style
|
|
968
|
+
if (attr.initializer) {
|
|
969
|
+
const styleValues = this.extractStyleObjectValues(attr.initializer, sourceFile);
|
|
970
|
+
usage.styleIssues.push(...styleValues);
|
|
971
|
+
}
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return usage;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
//# sourceMappingURL=nextjs-scanner.js.map
|