@weerachai06/tw-scanner 1.0.3 → 1.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.
- package/dist/cli.js +1 -2
- package/dist/extractor.js +115 -0
- package/dist/reporter.js +23 -2
- package/dist/scanner.js +30 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -46,8 +46,7 @@ async function main() {
|
|
|
46
46
|
if (outputArg) {
|
|
47
47
|
writeJsonReport(result, path.resolve(outputArg));
|
|
48
48
|
}
|
|
49
|
-
|
|
50
|
-
process.exit(result.invalid.length > 0 ? 1 : 0);
|
|
49
|
+
process.exit(result.invalid.length > 0 || result.cssModuleViolations.length > 0 ? 1 : 0);
|
|
51
50
|
}
|
|
52
51
|
catch (err) {
|
|
53
52
|
console.error('Fatal error:', err.message);
|
package/dist/extractor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parse } from '@typescript-eslint/typescript-estree';
|
|
2
2
|
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
3
4
|
// ─── Class utility function names to detect ──────────────────────────────────
|
|
4
5
|
const CLASS_UTIL_NAMES = new Set(['clsx', 'cn', 'cx', 'classnames', 'cva', 'tv']);
|
|
5
6
|
// JSX props that contain class strings
|
|
@@ -210,6 +211,120 @@ function walk(node, file, source, results, visited = new WeakSet()) {
|
|
|
210
211
|
}
|
|
211
212
|
}
|
|
212
213
|
}
|
|
214
|
+
// ─── CSS Modules extractor ───────────────────────────────────────────────────
|
|
215
|
+
export function extractCssModuleUsages(file) {
|
|
216
|
+
if (!fs.existsSync(file))
|
|
217
|
+
return [];
|
|
218
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
219
|
+
const isJSX = /\.(tsx|jsx)$/.test(file);
|
|
220
|
+
let ast;
|
|
221
|
+
try {
|
|
222
|
+
ast = parse(source, { jsx: isJSX, loc: true, range: true, tolerant: true });
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
// Pass 1: collect `import styles from './foo.module.css'` → binding → resolved path
|
|
228
|
+
const imports = new Map();
|
|
229
|
+
for (const node of ast.body) {
|
|
230
|
+
if (node.type === 'ImportDeclaration' &&
|
|
231
|
+
typeof node.source.value === 'string' &&
|
|
232
|
+
node.source.value.endsWith('.module.css')) {
|
|
233
|
+
const resolvedPath = path.resolve(path.dirname(file), node.source.value);
|
|
234
|
+
for (const specifier of node.specifiers) {
|
|
235
|
+
if (specifier.type === 'ImportDefaultSpecifier') {
|
|
236
|
+
imports.set(specifier.local.name, resolvedPath);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (imports.size === 0)
|
|
242
|
+
return [];
|
|
243
|
+
// Pass 2: collect `styles.btn` / `styles['btn']` / `styles[expr]` usages
|
|
244
|
+
const results = [];
|
|
245
|
+
function walkForUsages(node) {
|
|
246
|
+
if (!node)
|
|
247
|
+
return;
|
|
248
|
+
if (node.type === 'MemberExpression') {
|
|
249
|
+
const obj = node.object;
|
|
250
|
+
if (obj.type === 'Identifier' && imports.has(obj.name)) {
|
|
251
|
+
const modulePath = imports.get(obj.name);
|
|
252
|
+
const prop = node.property;
|
|
253
|
+
const snippet = source.slice(Math.max(0, (node.range?.[0] ?? 0) - 20), Math.min(source.length, (node.range?.[1] ?? 0) + 20)).replace(/\n/g, ' ').trim();
|
|
254
|
+
if (!node.computed && prop.type === 'Identifier') {
|
|
255
|
+
results.push({ file, line: node.loc.start.line, col: node.loc.start.column, className: prop.name, modulePath, context: snippet, isDynamic: false });
|
|
256
|
+
}
|
|
257
|
+
else if (node.computed && prop.type === 'Literal' && typeof prop.value === 'string') {
|
|
258
|
+
results.push({ file, line: node.loc.start.line, col: node.loc.start.column, className: prop.value, modulePath, context: snippet, isDynamic: false });
|
|
259
|
+
}
|
|
260
|
+
else if (node.computed) {
|
|
261
|
+
results.push({ file, line: node.loc.start.line, col: node.loc.start.column, className: source.slice(node.range?.[0] ?? 0, node.range?.[1] ?? 0), modulePath, context: snippet, isDynamic: true });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
for (const key of Object.keys(node)) {
|
|
266
|
+
if (key === 'parent' || key === 'tokens' || key === 'comments')
|
|
267
|
+
continue;
|
|
268
|
+
const child = node[key];
|
|
269
|
+
if (Array.isArray(child)) {
|
|
270
|
+
for (const c of child) {
|
|
271
|
+
if (c && typeof c === 'object' && 'type' in c)
|
|
272
|
+
walkForUsages(c);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
else if (child && typeof child === 'object' && 'type' in child) {
|
|
276
|
+
walkForUsages(child);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
walkForUsages(ast);
|
|
281
|
+
return results;
|
|
282
|
+
}
|
|
283
|
+
export function extractDefinedCssModuleClasses(cssFile) {
|
|
284
|
+
if (!fs.existsSync(cssFile))
|
|
285
|
+
return new Set();
|
|
286
|
+
const source = fs.readFileSync(cssFile, 'utf8');
|
|
287
|
+
const classes = new Set();
|
|
288
|
+
const lines = source.split('\n');
|
|
289
|
+
for (const line of lines) {
|
|
290
|
+
// Skip @apply lines — those dots are Tailwind classes, not module class names
|
|
291
|
+
if (line.trimStart().startsWith('@apply'))
|
|
292
|
+
continue;
|
|
293
|
+
const regex = /\.([a-zA-Z_][a-zA-Z0-9_-]*)\b/g;
|
|
294
|
+
let m;
|
|
295
|
+
while ((m = regex.exec(line)) !== null) {
|
|
296
|
+
classes.add(m[1]);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return classes;
|
|
300
|
+
}
|
|
301
|
+
// ─── CSS @apply extractor ─────────────────────────────────────────────────────
|
|
302
|
+
export function extractClassesFromCss(file) {
|
|
303
|
+
if (!fs.existsSync(file))
|
|
304
|
+
return [];
|
|
305
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
306
|
+
const results = [];
|
|
307
|
+
const lines = source.split('\n');
|
|
308
|
+
for (let i = 0; i < lines.length; i++) {
|
|
309
|
+
const line = lines[i];
|
|
310
|
+
const match = line.match(/^\s*@apply\s+(.+?)\s*;/);
|
|
311
|
+
if (!match)
|
|
312
|
+
continue;
|
|
313
|
+
const classStr = match[1];
|
|
314
|
+
const col = line.indexOf('@apply');
|
|
315
|
+
classStr.split(/\s+/).filter(Boolean).forEach((cls) => {
|
|
316
|
+
results.push({
|
|
317
|
+
value: cls,
|
|
318
|
+
file,
|
|
319
|
+
line: i + 1,
|
|
320
|
+
col,
|
|
321
|
+
isDynamic: false,
|
|
322
|
+
context: line.trim(),
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return results;
|
|
327
|
+
}
|
|
213
328
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
214
329
|
export function extractClassesFromFile(file) {
|
|
215
330
|
if (!fs.existsSync(file))
|
package/dist/reporter.js
CHANGED
|
@@ -22,7 +22,7 @@ export function printReport(result, opts = {}) {
|
|
|
22
22
|
console.log(JSON.stringify(result, null, 2));
|
|
23
23
|
return;
|
|
24
24
|
}
|
|
25
|
-
const { invalid, dynamic, totalClasses, totalFiles, durationMs } = result;
|
|
25
|
+
const { invalid, dynamic, cssModuleViolations, totalClasses, totalFiles, durationMs } = result;
|
|
26
26
|
console.log('');
|
|
27
27
|
console.log(pc.bold('─── Tailwind v4 Class Scanner ───────────────────────────────'));
|
|
28
28
|
console.log(` Scanned ${pc.cyan(pluralize(totalFiles, 'file'))} · ` +
|
|
@@ -67,11 +67,32 @@ export function printReport(result, opts = {}) {
|
|
|
67
67
|
console.log('');
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
// ── CSS Module violations ──
|
|
71
|
+
if (cssModuleViolations.length > 0) {
|
|
72
|
+
console.log(pc.red(pc.bold(` ✗ ${pluralize(cssModuleViolations.length, 'CSS Module class')} not found`)));
|
|
73
|
+
console.log('');
|
|
74
|
+
const byFile = groupByFile(cssModuleViolations);
|
|
75
|
+
for (const [file, items] of byFile) {
|
|
76
|
+
console.log(` ${pc.underline(relativePath(file))}`);
|
|
77
|
+
for (const item of items) {
|
|
78
|
+
const loc = pc.dim(`${item.line}:${item.col}`);
|
|
79
|
+
const cls = pc.red(pc.bold(item.className));
|
|
80
|
+
const mod = pc.dim(`(${relativePath(item.modulePath)})`);
|
|
81
|
+
console.log(` ${loc} ${cls} ${mod}`);
|
|
82
|
+
if (opts.verbose) {
|
|
83
|
+
console.log(` ${pc.dim(`…${item.context}…`)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
console.log('');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
70
89
|
// ── Summary ──
|
|
71
90
|
console.log('─────────────────────────────────────────────────────────────');
|
|
72
|
-
const
|
|
91
|
+
const hasErrors = invalid.length > 0 || cssModuleViolations.length > 0;
|
|
92
|
+
const status = hasErrors ? pc.red('FAIL') : pc.green('PASS');
|
|
73
93
|
console.log(` Status: ${status} · ` +
|
|
74
94
|
`${pc.red(String(invalid.length))} invalid · ` +
|
|
95
|
+
`${pc.red(String(cssModuleViolations.length))} CSS module · ` +
|
|
75
96
|
`${pc.yellow(String(dynamicOnly.length))} dynamic warnings`);
|
|
76
97
|
console.log('');
|
|
77
98
|
}
|
package/dist/scanner.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { globSync } from 'glob';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import { extractClassesFromFile } from './extractor.js';
|
|
3
|
+
import { extractClassesFromFile, extractClassesFromCss, extractCssModuleUsages, extractDefinedCssModuleClasses } from './extractor.js';
|
|
4
4
|
import { loadTailwindContext, validateBatch } from './validator.js';
|
|
5
|
-
const DEFAULT_INCLUDE = ['**/*.{ts,tsx,js,jsx}'];
|
|
5
|
+
const DEFAULT_INCLUDE = ['**/*.{ts,tsx,js,jsx,css}'];
|
|
6
6
|
const DEFAULT_EXCLUDE = [
|
|
7
7
|
'**/node_modules/**',
|
|
8
8
|
'**/dist/**',
|
|
@@ -31,7 +31,9 @@ export async function scan(opts) {
|
|
|
31
31
|
// ── 3. Extract classes from all files ─────────────────────────────────────
|
|
32
32
|
const allExtracted = [];
|
|
33
33
|
for (const file of files) {
|
|
34
|
-
const extracted =
|
|
34
|
+
const extracted = file.endsWith('.css')
|
|
35
|
+
? extractClassesFromCss(file)
|
|
36
|
+
: extractClassesFromFile(file);
|
|
35
37
|
allExtracted.push(...extracted);
|
|
36
38
|
}
|
|
37
39
|
// ── 4. Separate static vs dynamic ─────────────────────────────────────────
|
|
@@ -48,9 +50,34 @@ export async function scan(opts) {
|
|
|
48
50
|
invalid.push({ cls, valid });
|
|
49
51
|
}
|
|
50
52
|
}
|
|
53
|
+
// ── 7. CSS Module cross-reference ────────────────────────────────────────────
|
|
54
|
+
const cssModuleViolations = [];
|
|
55
|
+
const jsFiles = files.filter((f) => !f.endsWith('.css'));
|
|
56
|
+
// Collect all usages across JS/TS files
|
|
57
|
+
const allUsages = jsFiles.flatMap((f) => extractCssModuleUsages(f));
|
|
58
|
+
const staticUsages = allUsages.filter((u) => !u.isDynamic);
|
|
59
|
+
// Load defined classes per unique module path (cached)
|
|
60
|
+
const moduleClassesCache = new Map();
|
|
61
|
+
for (const usage of staticUsages) {
|
|
62
|
+
if (!moduleClassesCache.has(usage.modulePath)) {
|
|
63
|
+
moduleClassesCache.set(usage.modulePath, extractDefinedCssModuleClasses(usage.modulePath));
|
|
64
|
+
}
|
|
65
|
+
const defined = moduleClassesCache.get(usage.modulePath);
|
|
66
|
+
if (!defined.has(usage.className)) {
|
|
67
|
+
cssModuleViolations.push({
|
|
68
|
+
file: usage.file,
|
|
69
|
+
line: usage.line,
|
|
70
|
+
col: usage.col,
|
|
71
|
+
className: usage.className,
|
|
72
|
+
modulePath: usage.modulePath,
|
|
73
|
+
context: usage.context,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
51
77
|
return {
|
|
52
78
|
invalid,
|
|
53
79
|
dynamic: dynamicClasses,
|
|
80
|
+
cssModuleViolations,
|
|
54
81
|
totalClasses: allExtracted.length,
|
|
55
82
|
totalFiles: files.length,
|
|
56
83
|
durationMs: Date.now() - start,
|