@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 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
+ }
@@ -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
+ }
@@ -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
+ }