@weerachai06/tw-scanner 1.0.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/README.md +88 -0
- package/dist/cli.js +57 -0
- package/dist/extractor.js +244 -0
- package/dist/reporter.js +81 -0
- package/dist/scanner.js +58 -0
- package/dist/types.js +1 -0
- package/dist/validator.js +82 -0
- package/package.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# tw-scanner
|
|
2
|
+
|
|
3
|
+
AST-based Tailwind v4 class validator for React projects.
|
|
4
|
+
Detects invalid, renamed, or missing classes after design token / plugin migrations.
|
|
5
|
+
|
|
6
|
+
## Usage
|
|
7
|
+
|
|
8
|
+
Run directly without installing:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx @weerachai06/tw-scanner --src ./src --css ./src/globals.css
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or install globally:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g @weerachai06/tw-scanner
|
|
18
|
+
tw-scanner --src ./src --css ./src/globals.css
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Options
|
|
22
|
+
|
|
23
|
+
| Flag | Description |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `--src` | Directory to scan (default: `./src`) |
|
|
26
|
+
| `--css` | Path to Tailwind v4 CSS entry file (with `@import tailwindcss` + `@theme`) |
|
|
27
|
+
| `--verbose` | Show source context snippet for each error |
|
|
28
|
+
| `--output report.json` | Save full JSON report to file |
|
|
29
|
+
| `--json` | Print JSON report to stdout (for CI piping) |
|
|
30
|
+
|
|
31
|
+
### Examples
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Basic scan
|
|
35
|
+
npx @weerachai06/tw-scanner --src ./src --css ./src/globals.css
|
|
36
|
+
|
|
37
|
+
# With verbose context + save report
|
|
38
|
+
npx @weerachai06/tw-scanner --src ./src --css ./src/globals.css --verbose --output report.json
|
|
39
|
+
|
|
40
|
+
# CI mode (exits with code 1 if invalid classes found)
|
|
41
|
+
npx @weerachai06/tw-scanner --src ./src --css ./src/globals.css --json | jq '.invalid | length'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## What it detects
|
|
45
|
+
|
|
46
|
+
| Type | Example | Action |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| ❌ Invalid class | `bg-old-token-500` | **Error** — invalid after migration |
|
|
49
|
+
| ❌ Invalid in cva variants | `danger: 'bg-red-danger'` | **Error** — detected via AST |
|
|
50
|
+
| ⚠️ Dynamic expression | `` `text-${color}` `` | **Warning** — cannot validate statically |
|
|
51
|
+
|
|
52
|
+
## How it works
|
|
53
|
+
|
|
54
|
+
### 1. AST Extraction
|
|
55
|
+
|
|
56
|
+
Uses `@typescript-eslint/typescript-estree` to parse `.ts/.tsx/.js/.jsx` and traverse:
|
|
57
|
+
- `className="..."` JSX props
|
|
58
|
+
- `clsx(...)`, `cn(...)`, `cva(...)` call arguments
|
|
59
|
+
- Nested `ObjectExpression` (cva variant maps)
|
|
60
|
+
- `ConditionalExpression` and `LogicalExpression` inside class utilities
|
|
61
|
+
- `TemplateLiteral` — static parts extracted, dynamic parts flagged as warnings
|
|
62
|
+
|
|
63
|
+
### 2. Tailwind v4 Validation
|
|
64
|
+
|
|
65
|
+
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
|
+
|
|
67
|
+
Validation is **100% accurate against your real config** — custom tokens, plugins, and all.
|
|
68
|
+
|
|
69
|
+
### 3. Batch validation
|
|
70
|
+
|
|
71
|
+
All unique class values are validated in a single `build()` call per batch for performance.
|
|
72
|
+
|
|
73
|
+
## Extending
|
|
74
|
+
|
|
75
|
+
To add support for more class utilities (e.g. `tv` from `tailwind-variants`), add the function name to `CLASS_UTIL_NAMES` in `src/extractor.ts`:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const CLASS_UTIL_NAMES = new Set(['clsx', 'cn', 'cx', 'classnames', 'cva', 'tv'])
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
bun install # install dependencies
|
|
85
|
+
bun run build # compile TypeScript → dist/
|
|
86
|
+
bun test # run tests
|
|
87
|
+
bun run scan -- --src ./src --css ./src/globals.css # run locally without building
|
|
88
|
+
```
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { scan } from './scanner.js';
|
|
4
|
+
import { printReport, writeJsonReport } from './reporter.js';
|
|
5
|
+
// ─── Minimal arg parser ───────────────────────────────────────────────────────
|
|
6
|
+
function parseArgs(argv) {
|
|
7
|
+
const args = {};
|
|
8
|
+
for (let i = 0; i < argv.length; i++) {
|
|
9
|
+
const arg = argv[i];
|
|
10
|
+
if (arg.startsWith('--')) {
|
|
11
|
+
const key = arg.slice(2);
|
|
12
|
+
const next = argv[i + 1];
|
|
13
|
+
if (next && !next.startsWith('--')) {
|
|
14
|
+
args[key] = next;
|
|
15
|
+
i++;
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
args[key] = true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return args;
|
|
23
|
+
}
|
|
24
|
+
async function main() {
|
|
25
|
+
const args = parseArgs(process.argv.slice(2));
|
|
26
|
+
const srcArg = args['src'] ?? './src';
|
|
27
|
+
const cssArg = args['css'];
|
|
28
|
+
const outputArg = args['output'];
|
|
29
|
+
const jsonMode = Boolean(args['json']);
|
|
30
|
+
const verbose = Boolean(args['verbose']);
|
|
31
|
+
if (!cssArg) {
|
|
32
|
+
console.error('Usage: tw-scanner --src <dir> --css <tailwind-entry.css> [--output report.json] [--verbose] [--json]');
|
|
33
|
+
console.error('');
|
|
34
|
+
console.error(' --src Directory to scan (default: ./src)');
|
|
35
|
+
console.error(' --css Path to Tailwind CSS entry file with @import and @theme');
|
|
36
|
+
console.error(' --output Write JSON report to file');
|
|
37
|
+
console.error(' --verbose Show context snippets for each error');
|
|
38
|
+
console.error(' --json Print full JSON report to stdout');
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
const src = path.resolve(srcArg);
|
|
42
|
+
const css = path.resolve(cssArg);
|
|
43
|
+
try {
|
|
44
|
+
const result = await scan({ src, css });
|
|
45
|
+
printReport(result, { json: jsonMode, verbose });
|
|
46
|
+
if (outputArg) {
|
|
47
|
+
writeJsonReport(result, path.resolve(outputArg));
|
|
48
|
+
}
|
|
49
|
+
// Exit with error code if invalid classes found
|
|
50
|
+
process.exit(result.invalid.length > 0 ? 1 : 0);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
console.error('Fatal error:', err.message);
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
main();
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { parse } from '@typescript-eslint/typescript-estree';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
// ─── Class utility function names to detect ──────────────────────────────────
|
|
4
|
+
const CLASS_UTIL_NAMES = new Set(['clsx', 'cn', 'cx', 'classnames', 'cva', 'tv']);
|
|
5
|
+
// JSX props that contain class strings
|
|
6
|
+
const CLASS_PROPS = new Set(['className', 'class']);
|
|
7
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
8
|
+
function makeExtracted(value, node, file, source, isDynamic = false) {
|
|
9
|
+
const classes = isDynamic
|
|
10
|
+
? [value] // keep raw template for dynamic
|
|
11
|
+
: value.split(/\s+/).filter(Boolean);
|
|
12
|
+
return classes.map((cls) => ({
|
|
13
|
+
value: cls,
|
|
14
|
+
file,
|
|
15
|
+
line: node.loc.start.line,
|
|
16
|
+
col: node.loc.start.column,
|
|
17
|
+
isDynamic,
|
|
18
|
+
context: source.slice(Math.max(0, (node.range?.[0] ?? 0) - 30), Math.min(source.length, (node.range?.[1] ?? 0) + 30)).replace(/\n/g, ' ').trim(),
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
// Escape regex special chars
|
|
22
|
+
function escapeRegex(s) {
|
|
23
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
24
|
+
}
|
|
25
|
+
// ─── Node visitors ────────────────────────────────────────────────────────────
|
|
26
|
+
/**
|
|
27
|
+
* Extract classes from a Literal (plain string)
|
|
28
|
+
*/
|
|
29
|
+
function visitLiteral(node, file, source) {
|
|
30
|
+
if (typeof node.value !== 'string' || !node.value.trim())
|
|
31
|
+
return [];
|
|
32
|
+
return makeExtracted(node.value, node, file, source, false);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Extract classes from a TemplateLiteral.
|
|
36
|
+
* Quasis (static parts) are extracted normally.
|
|
37
|
+
* If there are expressions, we flag the whole thing as dynamic.
|
|
38
|
+
*/
|
|
39
|
+
function visitTemplateLiteral(node, file, source) {
|
|
40
|
+
const results = [];
|
|
41
|
+
const hasDynamic = node.expressions.length > 0;
|
|
42
|
+
if (hasDynamic) {
|
|
43
|
+
// Extract static quasis individually — they may be valid standalone classes
|
|
44
|
+
for (const quasi of node.quasis) {
|
|
45
|
+
const raw = quasi.value.cooked ?? quasi.value.raw;
|
|
46
|
+
const staticParts = raw.split(/\s+/).filter(Boolean);
|
|
47
|
+
for (const part of staticParts) {
|
|
48
|
+
// Skip parts that look like incomplete prefixes (e.g. "text-", "bg-")
|
|
49
|
+
// These are the left/right edges of a dynamic expression: `text-${x}`
|
|
50
|
+
if (part && !part.endsWith('-') && !part.endsWith(':')) {
|
|
51
|
+
results.push({
|
|
52
|
+
value: part,
|
|
53
|
+
file,
|
|
54
|
+
line: quasi.loc.start.line,
|
|
55
|
+
col: quasi.loc.start.column,
|
|
56
|
+
isDynamic: false,
|
|
57
|
+
context: source.slice(Math.max(0, (node.range?.[0] ?? 0) - 20), Math.min(source.length, (node.range?.[1] ?? 0) + 20)).replace(/\n/g, ' ').trim(),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Also record the whole template as dynamic for the warning report
|
|
63
|
+
const rawTemplate = source.slice(node.range?.[0] ?? 0, node.range?.[1] ?? 0);
|
|
64
|
+
results.push({
|
|
65
|
+
value: rawTemplate,
|
|
66
|
+
file,
|
|
67
|
+
line: node.loc.start.line,
|
|
68
|
+
col: node.loc.start.column,
|
|
69
|
+
isDynamic: true,
|
|
70
|
+
context: rawTemplate.replace(/\n/g, ' ').trim(),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
// Fully static template — treat like a plain string
|
|
75
|
+
const cooked = node.quasis.map((q) => q.value.cooked ?? q.value.raw).join('');
|
|
76
|
+
results.push(...makeExtracted(cooked, node, file, source, false));
|
|
77
|
+
}
|
|
78
|
+
return results;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Extract classes from an ArrayExpression (clsx(['a', 'b', cond && 'c']))
|
|
82
|
+
*/
|
|
83
|
+
function visitExpression(node, file, source) {
|
|
84
|
+
const results = [];
|
|
85
|
+
switch (node.type) {
|
|
86
|
+
case 'Literal':
|
|
87
|
+
results.push(...visitLiteral(node, file, source));
|
|
88
|
+
break;
|
|
89
|
+
case 'TemplateLiteral':
|
|
90
|
+
results.push(...visitTemplateLiteral(node, file, source));
|
|
91
|
+
break;
|
|
92
|
+
case 'ArrayExpression':
|
|
93
|
+
for (const el of node.elements) {
|
|
94
|
+
if (el)
|
|
95
|
+
results.push(...visitExpression(el, file, source));
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
case 'ObjectExpression':
|
|
99
|
+
for (const prop of node.properties) {
|
|
100
|
+
if (prop.type === 'Property') {
|
|
101
|
+
const key = prop.key;
|
|
102
|
+
const val = prop.value;
|
|
103
|
+
// Pattern 1: { 'bg-red-500': condition } — key is the class name
|
|
104
|
+
if (key.type === 'Literal' && typeof key.value === 'string') {
|
|
105
|
+
results.push(...makeExtracted(key.value, key, file, source, false));
|
|
106
|
+
}
|
|
107
|
+
// Pattern 2: cva({ variants: { size: { sm: 'text-sm', lg: 'text-lg' } } })
|
|
108
|
+
// Key is an Identifier (e.g. "sm", "danger") and VALUE is the class string
|
|
109
|
+
if (key.type === 'Identifier') {
|
|
110
|
+
// Skip cva's `defaultVariants` key — values are variant names, not class strings
|
|
111
|
+
// e.g. defaultVariants: { variant: 'default', size: 'md' }
|
|
112
|
+
if (key.name === 'defaultVariants')
|
|
113
|
+
continue;
|
|
114
|
+
// Recurse into the value — it could be a string, nested object, etc.
|
|
115
|
+
results.push(...visitExpression(val, file, source));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else if (prop.type === 'SpreadElement') {
|
|
119
|
+
results.push(...visitExpression(prop.argument, file, source));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
case 'ConditionalExpression':
|
|
124
|
+
// cond ? 'a' : 'b'
|
|
125
|
+
results.push(...visitExpression(node.consequent, file, source));
|
|
126
|
+
results.push(...visitExpression(node.alternate, file, source));
|
|
127
|
+
break;
|
|
128
|
+
case 'LogicalExpression':
|
|
129
|
+
// cond && 'a' | cond || 'b' | cond ?? 'c'
|
|
130
|
+
results.push(...visitExpression(node.left, file, source));
|
|
131
|
+
results.push(...visitExpression(node.right, file, source));
|
|
132
|
+
break;
|
|
133
|
+
case 'CallExpression': {
|
|
134
|
+
// nested clsx / cn / cva calls
|
|
135
|
+
const callee = node.callee;
|
|
136
|
+
const name = callee.type === 'Identifier'
|
|
137
|
+
? callee.name
|
|
138
|
+
: callee.type === 'MemberExpression' && callee.property.type === 'Identifier'
|
|
139
|
+
? callee.property.name
|
|
140
|
+
: null;
|
|
141
|
+
if (name && CLASS_UTIL_NAMES.has(name)) {
|
|
142
|
+
for (const arg of node.arguments) {
|
|
143
|
+
results.push(...visitExpression(arg, file, source));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case 'SpreadElement':
|
|
149
|
+
results.push(...visitExpression(node.argument, file, source));
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
return results;
|
|
153
|
+
}
|
|
154
|
+
// ─── Walk entire AST ──────────────────────────────────────────────────────────
|
|
155
|
+
function walk(node, file, source, results, visited = new WeakSet()) {
|
|
156
|
+
if (!node || visited.has(node))
|
|
157
|
+
return;
|
|
158
|
+
visited.add(node);
|
|
159
|
+
// ── JSX className="..." ──
|
|
160
|
+
if (node.type === 'JSXAttribute') {
|
|
161
|
+
const nameNode = node.name;
|
|
162
|
+
const attrName = nameNode.type === 'JSXIdentifier'
|
|
163
|
+
? nameNode.name
|
|
164
|
+
: nameNode.type === 'JSXNamespacedName'
|
|
165
|
+
? nameNode.name.name
|
|
166
|
+
: '';
|
|
167
|
+
if (CLASS_PROPS.has(attrName) && node.value) {
|
|
168
|
+
const val = node.value;
|
|
169
|
+
if (val.type === 'Literal') {
|
|
170
|
+
results.push(...visitLiteral(val, file, source));
|
|
171
|
+
}
|
|
172
|
+
else if (val.type === 'JSXExpressionContainer') {
|
|
173
|
+
const expr = val.expression;
|
|
174
|
+
if (expr.type !== 'JSXEmptyExpression') {
|
|
175
|
+
results.push(...visitExpression(expr, file, source));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// ── clsx(...) / cn(...) / cva(...) calls ──
|
|
181
|
+
if (node.type === 'CallExpression') {
|
|
182
|
+
const callee = node.callee;
|
|
183
|
+
const calleeName = callee.type === 'Identifier'
|
|
184
|
+
? callee.name
|
|
185
|
+
: callee.type === 'MemberExpression' && callee.property.type === 'Identifier'
|
|
186
|
+
? callee.property.name
|
|
187
|
+
: null;
|
|
188
|
+
if (calleeName && CLASS_UTIL_NAMES.has(calleeName)) {
|
|
189
|
+
for (const arg of node.arguments) {
|
|
190
|
+
results.push(...visitExpression(arg, file, source));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// cva('base', { variants: { size: { sm: 'text-sm' } } })
|
|
194
|
+
// Already handled by nested visitExpression calls above
|
|
195
|
+
}
|
|
196
|
+
// ── Recurse into children ──
|
|
197
|
+
for (const key of Object.keys(node)) {
|
|
198
|
+
if (key === 'parent' || key === 'tokens' || key === 'comments')
|
|
199
|
+
continue;
|
|
200
|
+
const child = node[key];
|
|
201
|
+
if (Array.isArray(child)) {
|
|
202
|
+
for (const c of child) {
|
|
203
|
+
if (c && typeof c === 'object' && 'type' in c) {
|
|
204
|
+
walk(c, file, source, results, visited);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else if (child && typeof child === 'object' && 'type' in child) {
|
|
209
|
+
walk(child, file, source, results, visited);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
214
|
+
export function extractClassesFromFile(file) {
|
|
215
|
+
if (!fs.existsSync(file))
|
|
216
|
+
return [];
|
|
217
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
218
|
+
const isTS = /\.(ts|tsx)$/.test(file);
|
|
219
|
+
let ast;
|
|
220
|
+
try {
|
|
221
|
+
ast = parse(source, {
|
|
222
|
+
jsx: true,
|
|
223
|
+
loc: true,
|
|
224
|
+
range: true,
|
|
225
|
+
tolerant: true,
|
|
226
|
+
...(isTS ? {} : {}),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
console.warn(`⚠ Parse error in ${file}: ${err.message}`);
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
const results = [];
|
|
234
|
+
walk(ast, file, source, results);
|
|
235
|
+
// Deduplicate by value+line+col
|
|
236
|
+
const seen = new Set();
|
|
237
|
+
return results.filter((r) => {
|
|
238
|
+
const key = `${r.value}:${r.line}:${r.col}`;
|
|
239
|
+
if (seen.has(key))
|
|
240
|
+
return false;
|
|
241
|
+
seen.add(key);
|
|
242
|
+
return true;
|
|
243
|
+
});
|
|
244
|
+
}
|
package/dist/reporter.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
function relativePath(file, cwd = process.cwd()) {
|
|
5
|
+
return path.relative(cwd, file);
|
|
6
|
+
}
|
|
7
|
+
function pluralize(n, word) {
|
|
8
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
9
|
+
}
|
|
10
|
+
// Group by file
|
|
11
|
+
function groupByFile(items) {
|
|
12
|
+
const map = new Map();
|
|
13
|
+
for (const item of items) {
|
|
14
|
+
const list = map.get(item.file) ?? [];
|
|
15
|
+
list.push(item);
|
|
16
|
+
map.set(item.file, list);
|
|
17
|
+
}
|
|
18
|
+
return map;
|
|
19
|
+
}
|
|
20
|
+
export function printReport(result, opts = {}) {
|
|
21
|
+
if (opts.json) {
|
|
22
|
+
console.log(JSON.stringify(result, null, 2));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const { invalid, dynamic, totalClasses, totalFiles, durationMs } = result;
|
|
26
|
+
console.log('');
|
|
27
|
+
console.log(pc.bold('─── Tailwind v4 Class Scanner ───────────────────────────────'));
|
|
28
|
+
console.log(` Scanned ${pc.cyan(pluralize(totalFiles, 'file'))} · ` +
|
|
29
|
+
`${pc.cyan(String(totalClasses))} classes found · ` +
|
|
30
|
+
`${pc.dim(`${durationMs}ms`)}`);
|
|
31
|
+
console.log('');
|
|
32
|
+
// ── Invalid classes ──
|
|
33
|
+
if (invalid.length === 0) {
|
|
34
|
+
console.log(pc.green(' ✓ No invalid Tailwind classes found'));
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
console.log(pc.red(pc.bold(` ✗ ${pluralize(invalid.length, 'invalid class')} found`)));
|
|
38
|
+
console.log('');
|
|
39
|
+
const byFile = groupByFile(invalid.map((v) => ({ ...v.cls, _result: v })));
|
|
40
|
+
for (const [file, items] of byFile) {
|
|
41
|
+
console.log(` ${pc.underline(relativePath(file))}`);
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
const loc = pc.dim(`${item.line}:${item.col}`);
|
|
44
|
+
const cls = pc.red(pc.bold(item.value));
|
|
45
|
+
const ctx = pc.dim(`…${item.context}…`);
|
|
46
|
+
console.log(` ${loc} ${cls}`);
|
|
47
|
+
if (opts.verbose) {
|
|
48
|
+
console.log(` ${ctx}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
console.log('');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// ── Dynamic (warnings) ──
|
|
55
|
+
const dynamicOnly = dynamic.filter((d) => d.isDynamic);
|
|
56
|
+
if (dynamicOnly.length > 0) {
|
|
57
|
+
console.log(pc.yellow(pc.bold(` ⚠ ${pluralize(dynamicOnly.length, 'dynamic class expression')} (cannot validate)`)));
|
|
58
|
+
console.log('');
|
|
59
|
+
const byFile = groupByFile(dynamicOnly);
|
|
60
|
+
for (const [file, items] of byFile) {
|
|
61
|
+
console.log(` ${pc.underline(relativePath(file))}`);
|
|
62
|
+
for (const item of items) {
|
|
63
|
+
const loc = pc.dim(`${item.line}:${item.col}`);
|
|
64
|
+
const val = pc.yellow(item.value.length > 60 ? item.value.slice(0, 60) + '…' : item.value);
|
|
65
|
+
console.log(` ${loc} ${val}`);
|
|
66
|
+
}
|
|
67
|
+
console.log('');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// ── Summary ──
|
|
71
|
+
console.log('─────────────────────────────────────────────────────────────');
|
|
72
|
+
const status = invalid.length === 0 ? pc.green('PASS') : pc.red('FAIL');
|
|
73
|
+
console.log(` Status: ${status} · ` +
|
|
74
|
+
`${pc.red(String(invalid.length))} invalid · ` +
|
|
75
|
+
`${pc.yellow(String(dynamicOnly.length))} dynamic warnings`);
|
|
76
|
+
console.log('');
|
|
77
|
+
}
|
|
78
|
+
export function writeJsonReport(result, outputPath) {
|
|
79
|
+
fs.writeFileSync(outputPath, JSON.stringify(result, null, 2));
|
|
80
|
+
console.log(pc.dim(` Report saved → ${outputPath}`));
|
|
81
|
+
}
|
package/dist/scanner.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { globSync } from 'glob';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { extractClassesFromFile } from './extractor.js';
|
|
4
|
+
import { loadTailwindContext, validateBatch } from './validator.js';
|
|
5
|
+
const DEFAULT_INCLUDE = ['**/*.{ts,tsx,js,jsx}'];
|
|
6
|
+
const DEFAULT_EXCLUDE = [
|
|
7
|
+
'**/node_modules/**',
|
|
8
|
+
'**/dist/**',
|
|
9
|
+
'**/.next/**',
|
|
10
|
+
'**/build/**',
|
|
11
|
+
'**/coverage/**',
|
|
12
|
+
'**/*.min.js',
|
|
13
|
+
'**/*.d.ts',
|
|
14
|
+
];
|
|
15
|
+
export async function scan(opts) {
|
|
16
|
+
const start = Date.now();
|
|
17
|
+
// ── 1. Find files ──────────────────────────────────────────────────────────
|
|
18
|
+
const include = opts.include ?? DEFAULT_INCLUDE;
|
|
19
|
+
const exclude = opts.exclude ?? DEFAULT_EXCLUDE;
|
|
20
|
+
const files = include.flatMap((pattern) => globSync(pattern, {
|
|
21
|
+
cwd: path.resolve(opts.src),
|
|
22
|
+
absolute: true,
|
|
23
|
+
ignore: exclude,
|
|
24
|
+
nodir: true,
|
|
25
|
+
}));
|
|
26
|
+
if (files.length === 0) {
|
|
27
|
+
console.warn(`⚠ No files found in: ${opts.src}`);
|
|
28
|
+
}
|
|
29
|
+
// ── 2. Load Tailwind context ───────────────────────────────────────────────
|
|
30
|
+
const twContext = await loadTailwindContext(opts.css);
|
|
31
|
+
// ── 3. Extract classes from all files ─────────────────────────────────────
|
|
32
|
+
const allExtracted = [];
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
const extracted = extractClassesFromFile(file);
|
|
35
|
+
allExtracted.push(...extracted);
|
|
36
|
+
}
|
|
37
|
+
// ── 4. Separate static vs dynamic ─────────────────────────────────────────
|
|
38
|
+
const staticClasses = allExtracted.filter((c) => !c.isDynamic);
|
|
39
|
+
const dynamicClasses = allExtracted.filter((c) => c.isDynamic);
|
|
40
|
+
// ── 5. Batch validate static classes ──────────────────────────────────────
|
|
41
|
+
const uniqueValues = [...new Set(staticClasses.map((c) => c.value))];
|
|
42
|
+
const validityMap = validateBatch(uniqueValues, twContext);
|
|
43
|
+
// ── 6. Build results ───────────────────────────────────────────────────────
|
|
44
|
+
const invalid = [];
|
|
45
|
+
for (const cls of staticClasses) {
|
|
46
|
+
const valid = validityMap.get(cls.value) ?? false;
|
|
47
|
+
if (!valid) {
|
|
48
|
+
invalid.push({ cls, valid });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
invalid,
|
|
53
|
+
dynamic: dynamicClasses,
|
|
54
|
+
totalClasses: allExtracted.length,
|
|
55
|
+
totalFiles: files.length,
|
|
56
|
+
durationMs: Date.now() - start,
|
|
57
|
+
};
|
|
58
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { compile } from '@tailwindcss/node';
|
|
4
|
+
// ─── Cache: compile result per CSS file ──────────────────────────────────────
|
|
5
|
+
const compileCache = new Map();
|
|
6
|
+
export async function loadTailwindContext(cssFile) {
|
|
7
|
+
const abs = path.resolve(cssFile);
|
|
8
|
+
if (compileCache.has(abs))
|
|
9
|
+
return compileCache.get(abs);
|
|
10
|
+
if (!fs.existsSync(abs)) {
|
|
11
|
+
throw new Error(`Tailwind CSS file not found: ${abs}`);
|
|
12
|
+
}
|
|
13
|
+
const css = fs.readFileSync(abs, 'utf8');
|
|
14
|
+
const result = await compile(css, {
|
|
15
|
+
base: path.dirname(abs),
|
|
16
|
+
onDependency: () => { },
|
|
17
|
+
});
|
|
18
|
+
compileCache.set(abs, result);
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
// ─── Build escape for CSS selector matching ───────────────────────────────────
|
|
22
|
+
function cssEscape(cls) {
|
|
23
|
+
// Matches what Tailwind outputs in CSS selectors
|
|
24
|
+
return cls
|
|
25
|
+
.replace(/\//g, '\\/') // p-1/2 → p-1\/2
|
|
26
|
+
.replace(/\./g, '\\.') // . → \.
|
|
27
|
+
.replace(/:/g, '\\:') // md: → md\:
|
|
28
|
+
.replace(/\[/g, '\\[')
|
|
29
|
+
.replace(/\]/g, '\\]')
|
|
30
|
+
.replace(/\(/g, '\\(')
|
|
31
|
+
.replace(/\)/g, '\\)')
|
|
32
|
+
.replace(/#/g, '\\#')
|
|
33
|
+
.replace(/%/g, '\\%')
|
|
34
|
+
.replace(/!/g, '\\!');
|
|
35
|
+
}
|
|
36
|
+
// ─── Validation cache: per-context ───────────────────────────────────────────
|
|
37
|
+
const validityCache = new Map();
|
|
38
|
+
export function isValidClass(cls, context) {
|
|
39
|
+
if (!validityCache.has(context))
|
|
40
|
+
validityCache.set(context, new Map());
|
|
41
|
+
const cache = validityCache.get(context);
|
|
42
|
+
if (cache.has(cls))
|
|
43
|
+
return cache.get(cls);
|
|
44
|
+
// Build CSS with this single candidate
|
|
45
|
+
const output = context.build([cls]);
|
|
46
|
+
// A valid class generates a selector in the utilities/components layer
|
|
47
|
+
const escaped = cssEscape(cls);
|
|
48
|
+
// Look for .classname{ or .classname { or .classname:hover{
|
|
49
|
+
const pattern = new RegExp(`\\.${escaped.replace(/\\/g, '\\\\')}[\\s{\\[:]`);
|
|
50
|
+
const valid = pattern.test(output);
|
|
51
|
+
cache.set(cls, valid);
|
|
52
|
+
return valid;
|
|
53
|
+
}
|
|
54
|
+
// ─── Batch validate (more efficient: one build call per batch) ────────────────
|
|
55
|
+
export function validateBatch(classes, context) {
|
|
56
|
+
const result = new Map();
|
|
57
|
+
const toCheck = [];
|
|
58
|
+
const cache = validityCache.get(context) ?? new Map();
|
|
59
|
+
if (!validityCache.has(context))
|
|
60
|
+
validityCache.set(context, cache);
|
|
61
|
+
// Use cache first
|
|
62
|
+
for (const cls of classes) {
|
|
63
|
+
if (cache.has(cls)) {
|
|
64
|
+
result.set(cls, cache.get(cls));
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
toCheck.push(cls);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (toCheck.length === 0)
|
|
71
|
+
return result;
|
|
72
|
+
// Build all candidates at once
|
|
73
|
+
const output = context.build(toCheck);
|
|
74
|
+
for (const cls of toCheck) {
|
|
75
|
+
const escaped = cssEscape(cls);
|
|
76
|
+
const pattern = new RegExp(`\\.${escaped.replace(/\\/g, '\\\\')}[\\s{\\[:]`);
|
|
77
|
+
const valid = pattern.test(output);
|
|
78
|
+
result.set(cls, valid);
|
|
79
|
+
cache.set(cls, valid);
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@weerachai06/tw-scanner",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "AST-based Tailwind v4 class validator for React projects",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tw-scanner": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"scan": "bun src/cli.ts",
|
|
15
|
+
"test": "bun test"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@tailwindcss/node": "^4.3.1",
|
|
19
|
+
"@typescript-eslint/typescript-estree": "^8.61.1",
|
|
20
|
+
"glob": "^11.0.0",
|
|
21
|
+
"picocolors": "^1.1.1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^26.0.0",
|
|
25
|
+
"tailwindcss": "^4.3.1",
|
|
26
|
+
"typescript": "^5.8.3"
|
|
27
|
+
}
|
|
28
|
+
}
|