@weerachai06/tw-scanner 1.0.4 → 1.1.1
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/README.md +28 -2
- package/dist/cli.js +1 -2
- package/dist/extractor.js +88 -0
- package/dist/reporter.js +23 -2
- package/dist/scanner.js +26 -1
- package/dist/validator.js +17 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,7 +47,10 @@ npx @weerachai06/tw-scanner --src ./src --css ./src/globals.css --json | jq '.in
|
|
|
47
47
|
|---|---|---|
|
|
48
48
|
| ❌ Invalid class | `bg-old-token-500` | **Error** — invalid after migration |
|
|
49
49
|
| ❌ Invalid in cva variants | `danger: 'bg-red-danger'` | **Error** — detected via AST |
|
|
50
|
+
| ❌ Invalid `@apply` class | `@apply bg-fake-999;` in `.css` | **Error** — validated against your config |
|
|
51
|
+
| ❌ Missing CSS Module class | `styles.nonExistent` | **Error** — class not defined in `.module.css` |
|
|
50
52
|
| ⚠️ Dynamic expression | `` `text-${color}` `` | **Warning** — cannot validate statically |
|
|
53
|
+
| ⚠️ Dynamic module access | `styles[variable]` | **Warning** — skipped, cannot resolve statically |
|
|
51
54
|
|
|
52
55
|
## How it works
|
|
53
56
|
|
|
@@ -60,13 +63,36 @@ Uses `@typescript-eslint/typescript-estree` to parse `.ts/.tsx/.js/.jsx` and tra
|
|
|
60
63
|
- `ConditionalExpression` and `LogicalExpression` inside class utilities
|
|
61
64
|
- `TemplateLiteral` — static parts extracted, dynamic parts flagged as warnings
|
|
62
65
|
|
|
63
|
-
### 2.
|
|
66
|
+
### 2. CSS file scanning
|
|
67
|
+
|
|
68
|
+
`.css` and `.module.css` files are also scanned. Classes inside `@apply` directives are extracted and validated against your Tailwind config.
|
|
69
|
+
|
|
70
|
+
```css
|
|
71
|
+
.btn {
|
|
72
|
+
@apply bg-blue-500 text-white px-4; /* ✓ valid */
|
|
73
|
+
@apply bg-fake-999; /* ✗ error */
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### 3. CSS Modules validation
|
|
78
|
+
|
|
79
|
+
When a JS/TS file imports a `.module.css` file, all `styles.xxx` and `styles['xxx']` usages are checked against the class names actually defined in that file.
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
import styles from './button.module.css'
|
|
83
|
+
|
|
84
|
+
<button className={styles.btn}>...</button> // ✓ defined
|
|
85
|
+
<button className={styles.nonExistent}>...</button> // ✗ error
|
|
86
|
+
<button className={styles[variant]}>...</button> // ⚠ skipped (dynamic)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 4. Tailwind v4 Validation
|
|
64
90
|
|
|
65
91
|
Uses `@tailwindcss/node`'s `compile()` API to load your actual CSS entry file (including all `@theme` tokens and plugins), then calls `build([candidate])` for each class. If the class generates no CSS rule in the output, it's invalid.
|
|
66
92
|
|
|
67
93
|
Validation is **100% accurate against your real config** — custom tokens, plugins, and all.
|
|
68
94
|
|
|
69
|
-
###
|
|
95
|
+
### 5. Batch validation
|
|
70
96
|
|
|
71
97
|
All unique class values are validated in a single `build()` call per batch for performance.
|
|
72
98
|
|
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,93 @@ 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
|
+
}
|
|
213
301
|
// ─── CSS @apply extractor ─────────────────────────────────────────────────────
|
|
214
302
|
export function extractClassesFromCss(file) {
|
|
215
303
|
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,6 +1,6 @@
|
|
|
1
1
|
import { globSync } from 'glob';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import { extractClassesFromFile, extractClassesFromCss } from './extractor.js';
|
|
3
|
+
import { extractClassesFromFile, extractClassesFromCss, extractCssModuleUsages, extractDefinedCssModuleClasses } from './extractor.js';
|
|
4
4
|
import { loadTailwindContext, validateBatch } from './validator.js';
|
|
5
5
|
const DEFAULT_INCLUDE = ['**/*.{ts,tsx,js,jsx,css}'];
|
|
6
6
|
const DEFAULT_EXCLUDE = [
|
|
@@ -50,9 +50,34 @@ export async function scan(opts) {
|
|
|
50
50
|
invalid.push({ cls, valid });
|
|
51
51
|
}
|
|
52
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
|
+
}
|
|
53
77
|
return {
|
|
54
78
|
invalid,
|
|
55
79
|
dynamic: dynamicClasses,
|
|
80
|
+
cssModuleViolations,
|
|
56
81
|
totalClasses: allExtracted.length,
|
|
57
82
|
totalFiles: files.length,
|
|
58
83
|
durationMs: Date.now() - start,
|
package/dist/validator.js
CHANGED
|
@@ -11,10 +11,23 @@ export async function loadTailwindContext(cssFile) {
|
|
|
11
11
|
throw new Error(`Tailwind CSS file not found: ${abs}`);
|
|
12
12
|
}
|
|
13
13
|
const css = fs.readFileSync(abs, 'utf8');
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
let result;
|
|
15
|
+
try {
|
|
16
|
+
result = await compile(css, {
|
|
17
|
+
base: path.dirname(abs),
|
|
18
|
+
onDependency: () => { },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
const msg = err.message ?? '';
|
|
23
|
+
const match = msg.match(/Cannot apply unknown utility class [`']?([^\s`']+)[`']?/);
|
|
24
|
+
if (match) {
|
|
25
|
+
throw new Error(`Tailwind could not compile "${path.relative(process.cwd(), abs)}" — ` +
|
|
26
|
+
`unknown utility used in @apply: "${match[1]}".\n` +
|
|
27
|
+
` Fix: add "@utility ${match[1]} {}" to your CSS entry file to register it as a custom utility.`);
|
|
28
|
+
}
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
18
31
|
compileCache.set(abs, result);
|
|
19
32
|
return result;
|
|
20
33
|
}
|