@rolandjlevy/css-ghost 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/src/deleter.js ADDED
@@ -0,0 +1,177 @@
1
+ import { readFileSync, writeFileSync } from 'fs';
2
+ import { extname, relative } from 'path';
3
+ import { createInterface } from 'readline';
4
+ import pc from 'picocolors';
5
+
6
+ // ─── Helpers ────────────────────────────────────────────────────────────────
7
+
8
+ function prompt(question) {
9
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
10
+ return new Promise(resolve => {
11
+ rl.question(question, answer => { rl.close(); resolve(answer.trim()); });
12
+ });
13
+ }
14
+
15
+ // Collapse runs of 3+ blank lines down to 2, and ensure a trailing newline.
16
+ function normalise(content) {
17
+ return content.replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
18
+ }
19
+
20
+ // Return the 0-based index of the last line belonging to the block that
21
+ // starts at `startIdx`. Counts {/(/[ as openers and }/)/] as closers.
22
+ function findBlockEnd(lines, startIdx) {
23
+ let depth = 0;
24
+ let hasOpener = false;
25
+
26
+ for (let i = startIdx; i < lines.length; i++) {
27
+ for (const ch of lines[i]) {
28
+ if (ch === '{' || ch === '(' || ch === '[') { depth++; hasOpener = true; }
29
+ else if (ch === '}' || ch === ')' || ch === ']') depth--;
30
+ }
31
+ if (hasOpener && depth <= 0) return i;
32
+ }
33
+
34
+ return startIdx; // single-line with no opener
35
+ }
36
+
37
+ // ─── CSS / SCSS / LESS ───────────────────────────────────────────────────────
38
+
39
+ function deleteFromCssFile(filePath, orphans) {
40
+ let content = readFileSync(filePath, 'utf-8');
41
+ let removed = 0;
42
+
43
+ // Sort longest rules first so that nested/contained rules don't mis-match.
44
+ const sorted = [...orphans].sort((a, b) => (b.rule?.length ?? 0) - (a.rule?.length ?? 0));
45
+
46
+ for (const { name, rule } of sorted) {
47
+ if (!rule) continue;
48
+ const idx = content.indexOf(rule);
49
+ if (idx === -1) continue;
50
+
51
+ const before = content.slice(0, idx);
52
+ const after = content.slice(idx + rule.length);
53
+ // Eat one trailing newline so we don't leave double-blank-lines everywhere.
54
+ content = before + after.replace(/^\n/, '');
55
+ removed++;
56
+ }
57
+
58
+ writeFileSync(filePath, normalise(content), 'utf-8');
59
+ return removed;
60
+ }
61
+
62
+ // ─── JS style objects (StyleSheet.create / makeStyles / plain objects) ───────
63
+
64
+ function deleteFromJsFile(filePath, orphans) {
65
+ const lines = readFileSync(filePath, 'utf-8').split('\n');
66
+ let removed = 0;
67
+
68
+ // Reverse order so later splices don't shift earlier line indices.
69
+ const sorted = [...orphans].sort((a, b) => b.line - a.line);
70
+
71
+ for (const { name, line } of sorted) {
72
+ const lineIdx = line - 1; // convert to 0-based
73
+ const lineContent = lines[lineIdx] ?? '';
74
+
75
+ // Guard: confirm the key is really on this line.
76
+ if (!new RegExp(`\\b${name}\\s*:`).test(lineContent)) continue;
77
+
78
+ const endIdx = findBlockEnd(lines, lineIdx);
79
+ lines.splice(lineIdx, endIdx - lineIdx + 1);
80
+ removed++;
81
+ }
82
+
83
+ writeFileSync(filePath, normalise(lines.join('\n')), 'utf-8');
84
+ return removed;
85
+ }
86
+
87
+ // ─── vanilla-extract (.css.ts / .css.js) ─────────────────────────────────────
88
+
89
+ function deleteFromVeFile(filePath, orphans) {
90
+ const lines = readFileSync(filePath, 'utf-8').split('\n');
91
+ let removed = 0;
92
+
93
+ const sorted = [...orphans].sort((a, b) => b.line - a.line);
94
+
95
+ for (const { name, line } of sorted) {
96
+ const lineIdx = line - 1;
97
+ const lineContent = lines[lineIdx] ?? '';
98
+
99
+ if (!lineContent.includes(`export const ${name}`)) continue;
100
+
101
+ const endIdx = findBlockEnd(lines, lineIdx);
102
+ lines.splice(lineIdx, endIdx - lineIdx + 1);
103
+ removed++;
104
+ }
105
+
106
+ writeFileSync(filePath, normalise(lines.join('\n')), 'utf-8');
107
+ return removed;
108
+ }
109
+
110
+ // ─── Public API ──────────────────────────────────────────────────────────────
111
+
112
+ export async function deleteOrphans(results, { yes = false } = {}) {
113
+ const toDelete = results.filter(r => r.orphaned.length > 0);
114
+
115
+ if (toDelete.length === 0) {
116
+ console.log('Nothing to delete.\n');
117
+ return 0;
118
+ }
119
+
120
+ const totalCount = toDelete.reduce((n, r) => n + r.orphaned.length, 0);
121
+
122
+ console.log(
123
+ `\n${pc.bold('🗑️ The following orphaned selectors will be permanently deleted:')}\n`
124
+ );
125
+
126
+ for (const { filePath, orphaned } of toDelete) {
127
+ const rel = relative(process.cwd(), filePath);
128
+ console.log(` ${pc.bold(rel)}`);
129
+ orphaned.forEach(o => console.log(` ${pc.yellow('•')} .${o.name} ${pc.dim(`(line ${o.line})`)}`));
130
+ }
131
+
132
+ if (!yes) {
133
+ const answer = await prompt(
134
+ `\n${pc.yellow('⚠️ This will modify your source files and cannot be undone.')} Continue? ${pc.dim('[y/N]')} `
135
+ );
136
+ if (answer.toLowerCase() !== 'y') {
137
+ console.log('\nCancelled — no files were changed.\n');
138
+ return 0;
139
+ }
140
+ }
141
+
142
+ console.log('');
143
+ let totalRemoved = 0;
144
+
145
+ for (const { filePath, orphaned } of toDelete) {
146
+ const ext = extname(filePath);
147
+ const rel = relative(process.cwd(), filePath);
148
+ let removed;
149
+
150
+ try {
151
+ if (['.css', '.scss', '.sass', '.less'].includes(ext)) {
152
+ removed = deleteFromCssFile(filePath, orphaned);
153
+ } else if (/\.css\.(ts|js)x?$/.test(filePath)) {
154
+ removed = deleteFromVeFile(filePath, orphaned);
155
+ } else {
156
+ removed = deleteFromJsFile(filePath, orphaned);
157
+ }
158
+
159
+ const label = removed === orphaned.length
160
+ ? pc.green(`✂️ ${rel}`)
161
+ : pc.yellow(`✂️ ${rel}`);
162
+ const note = removed < orphaned.length
163
+ ? pc.dim(` (${orphaned.length - removed} could not be located — check manually)`)
164
+ : '';
165
+ console.log(`${label} — removed ${removed}${note}`);
166
+ totalRemoved += removed;
167
+ } catch (err) {
168
+ console.log(`${pc.red('❌')} ${rel} — ${err.message}`);
169
+ }
170
+ }
171
+
172
+ console.log(
173
+ `\n${pc.green(pc.bold(`✅ Done.`))} Removed ${totalRemoved} orphaned selector${totalRemoved !== 1 ? 's' : ''}.\n`
174
+ );
175
+
176
+ return totalRemoved;
177
+ }
package/src/git.js ADDED
@@ -0,0 +1,73 @@
1
+ import { execFileSync, execFile } from 'child_process';
2
+ import { promisify } from 'util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ // File globs to restrict class-name searches to component files only
7
+ const COMPONENT_PATTERNS = [
8
+ '*.tsx', '*.jsx', '*.ts', '*.js',
9
+ '*.html', '*.vue', '*.svelte',
10
+ ];
11
+
12
+ export function isGitRepo(cwd) {
13
+ try {
14
+ execFileSync('git', ['rev-parse', '--git-dir'], { cwd, stdio: 'pipe' });
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ async function fetchTrail(searchStr, cwd, filePatterns) {
22
+ try {
23
+ const args = [
24
+ 'log',
25
+ '-S', searchStr,
26
+ '--pretty=format:%h|%an|%ar|%s',
27
+ '-1',
28
+ ];
29
+ if (filePatterns.length > 0) {
30
+ args.push('--');
31
+ args.push(...filePatterns);
32
+ }
33
+ const { stdout } = await execFileAsync('git', args, { cwd, timeout: 8000 });
34
+ const line = stdout.trim();
35
+ if (!line) return null;
36
+
37
+ const firstPipe = line.indexOf('|');
38
+ const secondPipe = line.indexOf('|', firstPipe + 1);
39
+ const thirdPipe = line.indexOf('|', secondPipe + 1);
40
+
41
+ return {
42
+ hash: line.slice(0, firstPipe),
43
+ author: line.slice(firstPipe + 1, secondPipe),
44
+ when: line.slice(secondPipe + 1, thirdPipe),
45
+ subject: line.slice(thirdPipe + 1),
46
+ };
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Fetch the most recent git commit that changed the occurrence count of each
54
+ * orphan's identifier in the repository.
55
+ *
56
+ * For class-name orphans, the search is limited to component file extensions
57
+ * so the results reflect usage history rather than the style-file definition.
58
+ *
59
+ * For CSS variable orphans (`isVar: true`), the search term is `var(--name`
60
+ * and is not restricted to file types.
61
+ *
62
+ * Returns an object mapping orphan name → trail (or null when not found).
63
+ */
64
+ export async function getGhostTrails(orphans, cwd, { isVar = false } = {}) {
65
+ const searches = orphans.map(o => {
66
+ const searchStr = isVar ? `var(${o.name}` : o.name;
67
+ const patterns = isVar ? [] : COMPONENT_PATTERNS;
68
+ return fetchTrail(searchStr, cwd, patterns).then(trail => [o.name, trail]);
69
+ });
70
+
71
+ const entries = await Promise.all(searches);
72
+ return Object.fromEntries(entries);
73
+ }
package/src/hook.js ADDED
@@ -0,0 +1,64 @@
1
+ import { writeFileSync, mkdirSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import pc from 'picocolors';
4
+
5
+ /**
6
+ * Write a pre-commit hook that runs css-ghost with --strict on every commit.
7
+ * The hook exits 1 (blocking the commit) if any orphaned selectors are found.
8
+ *
9
+ * `dirs` — the directories the hook should scan; defaults to '.' when empty.
10
+ * `cwd` — repo root to write the hook into.
11
+ */
12
+ export function installHook(dirs, cwd = process.cwd()) {
13
+ const gitDir = join(cwd, '.git');
14
+ if (!existsSync(gitDir)) {
15
+ console.error(`${pc.red('❌')} No ${pc.bold('.git')} directory found — is this a git repository?`);
16
+ process.exit(1);
17
+ }
18
+
19
+ const hooksDir = join(gitDir, 'hooks');
20
+ mkdirSync(hooksDir, { recursive: true });
21
+
22
+ const hookPath = join(hooksDir, 'pre-commit');
23
+ const dirArgs = dirs.length > 0 ? dirs.join(' ') : '.';
24
+ const alreadyExists = existsSync(hookPath);
25
+
26
+ // Single-quote escaping for shell: end the string, insert \', reopen it.
27
+ const sq = (s) => s.replace(/'/g, "'\\''");
28
+
29
+ const script = [
30
+ '#!/bin/sh',
31
+ '# css-ghost pre-commit hook',
32
+ '# Blocks commits when orphaned CSS selectors are present.',
33
+ `# Installed via: css-ghost --install-hook ${dirArgs}`,
34
+ `# Scans: ${dirArgs}`,
35
+ '# Skip once: git commit --no-verify',
36
+ '# Uninstall: rm .git/hooks/pre-commit',
37
+ '',
38
+ `echo "👻 css-ghost — checking for orphaned CSS selectors..."`,
39
+ `npx --yes @rolandjlevy/css-ghost ${dirArgs} --strict --output /tmp/css-ghost-hook-report.md`,
40
+ '',
41
+ 'if [ $? -ne 0 ]; then',
42
+ ' echo ""',
43
+ ` echo "❌ Commit blocked: orphaned CSS selectors found in: ${sq(dirArgs)}"`,
44
+ ` echo " Run 'css-ghost ${sq(dirArgs)}' to see the full report"`,
45
+ ` echo " Run 'css-ghost ${sq(dirArgs)} --delete' to remove them"`,
46
+ ' echo " Or skip this check: git commit --no-verify"',
47
+ ' exit 1',
48
+ 'fi',
49
+ '',
50
+ 'exit 0',
51
+ ].join('\n') + '\n';
52
+
53
+ writeFileSync(hookPath, script, { mode: 0o755 });
54
+
55
+ if (alreadyExists) {
56
+ console.log(`${pc.yellow('⚠️ ')} Overwrote existing pre-commit hook`);
57
+ } else {
58
+ console.log(`${pc.green('✅')} Pre-commit hook installed at ${pc.bold('.git/hooks/pre-commit')}`);
59
+ }
60
+ console.log(` Scans: ${pc.cyan(dirArgs)}`);
61
+ console.log(` Blocks: commits when orphaned selectors are found`);
62
+ console.log(` Skip once: ${pc.dim('git commit --no-verify')}`);
63
+ console.log(` Uninstall: ${pc.dim('rm .git/hooks/pre-commit')}`);
64
+ }
package/src/parser.js CHANGED
@@ -1,6 +1,56 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import { extname } from 'path';
3
3
 
4
+ // ─── CSS custom property extraction ─────────────────────────────────────────
5
+
6
+ /**
7
+ * Find every `--name: value;` declaration in a CSS/SCSS file.
8
+ * Returns a Map of '--name' → { line, rule }.
9
+ *
10
+ * Deliberately ignores `var(--name)` usages because those don't have `:` directly
11
+ * after the variable name — so the regex `--name\s*:` is safe.
12
+ */
13
+ function extractCssVarDefinitions(content) {
14
+ const vars = new Map();
15
+ // Strip block comments preserving line numbers
16
+ const stripped = content.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' '));
17
+ const strippedLines = stripped.split('\n');
18
+ const rawLines = content.split('\n');
19
+
20
+ for (let i = 0; i < strippedLines.length; i++) {
21
+ const line = strippedLines[i];
22
+ const lineNum = i + 1;
23
+ const varDefRe = /(--[a-zA-Z][a-zA-Z0-9-]*)\s*:/g;
24
+ let m;
25
+ while ((m = varDefRe.exec(line)) !== null) {
26
+ const name = m[1];
27
+ if (!vars.has(name)) {
28
+ // Read value from the raw (non-stripped) line
29
+ const rawAfterColon = rawLines[i].slice(rawLines[i].indexOf(m[0]) + m[0].length).trim();
30
+ const value = rawAfterColon.replace(/;.*$/, '').replace(/\/\*.*\*\//g, '').trim();
31
+ vars.set(name, { line: lineNum, rule: `${name}: ${value};` });
32
+ }
33
+ }
34
+ }
35
+
36
+ return vars;
37
+ }
38
+
39
+ /**
40
+ * Find every `var(--name)` usage in any file content.
41
+ * Returns a Set of '--name' strings.
42
+ * Works across CSS, SCSS, JS, TS, JSX, TSX, HTML, Vue, Svelte, etc.
43
+ */
44
+ export function extractVarUsages(content) {
45
+ const used = new Set();
46
+ const re = /var\s*\(\s*(--[a-zA-Z][a-zA-Z0-9-]*)/g;
47
+ let m;
48
+ while ((m = re.exec(content)) !== null) {
49
+ used.add(m[1]);
50
+ }
51
+ return used;
52
+ }
53
+
4
54
  // ─── CSS / SCSS selector extraction ────────────────────────────────────────
5
55
 
6
56
  function extractCssSelectors(content) {
@@ -76,34 +126,114 @@ function extractCssSelectors(content) {
76
126
  export function isJsStyleFile(content, filePath) {
77
127
  return (
78
128
  /StyleSheet\.create\s*\(\s*\{/.test(content) ||
129
+ /makeStyles\s*\(/.test(content) ||
130
+ /\.css\.(ts|js)x?$/.test(filePath) || // vanilla-extract
79
131
  (/styles?\.(js|ts)$/.test(filePath) && /(const|let|var)\s+styles?\s*=\s*\{/.test(content))
80
132
  );
81
133
  }
82
134
 
135
+ // Extract top-level keys from a JS style object, depth-aware so CSS property
136
+ // keys nested inside each class's value object are not returned.
137
+ function extractTopLevelKeys(objectContent, startLine) {
138
+ const keys = new Map();
139
+ const objLines = objectContent.split('\n');
140
+ let depth = 0;
141
+
142
+ for (let i = 0; i < objLines.length; i++) {
143
+ const line = objLines[i];
144
+ const lineNum = startLine + i;
145
+
146
+ if (depth === 0) {
147
+ const keyMatch = line.match(/^\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/);
148
+ if (keyMatch) {
149
+ const name = keyMatch[1];
150
+ if (!keys.has(name)) {
151
+ keys.set(name, { line: lineNum, rule: line.trim() });
152
+ }
153
+ }
154
+ }
155
+
156
+ for (const char of line) {
157
+ if (char === '{') depth++;
158
+ else if (char === '}') depth--;
159
+ }
160
+ }
161
+
162
+ return keys;
163
+ }
164
+
165
+ // Find the content between the first matching { } pair after `startIdx`.
166
+ function extractBraceContent(content, startIdx) {
167
+ let braceStart = -1;
168
+ for (let i = startIdx; i < content.length; i++) {
169
+ if (content[i] === '{') { braceStart = i; break; }
170
+ }
171
+ if (braceStart === -1) return null;
172
+
173
+ let depth = 0;
174
+ let end = braceStart;
175
+ for (let i = braceStart; i < content.length; i++) {
176
+ if (content[i] === '{') depth++;
177
+ else if (content[i] === '}') {
178
+ depth--;
179
+ if (depth === 0) { end = i; break; }
180
+ }
181
+ }
182
+
183
+ const inner = content.slice(braceStart + 1, end);
184
+ const startLine = content.slice(0, braceStart + 1).split('\n').length;
185
+ return { inner, startLine };
186
+ }
187
+
83
188
  function extractJsStyleKeys(content) {
84
- const keys = new Map(); // name → { line, rule }
85
- const lines = content.split('\n');
189
+ const keys = new Map();
86
190
 
191
+ // StyleSheet.create({ ... }) — React Native
87
192
  const ssMatch = content.match(/StyleSheet\.create\s*\(\s*\{([\s\S]*?)\}\s*\)/);
193
+ // const styles = { ... } — plain named object
88
194
  const plainMatch = content.match(/(?:const|let|var)\s+styles?\s*=\s*\{([\s\S]*)\}/);
89
- const objectContent = ssMatch?.[1] ?? plainMatch?.[1];
195
+ const flatContent = ssMatch?.[1] ?? plainMatch?.[1];
196
+
197
+ if (flatContent) {
198
+ const startOffset = content.indexOf(flatContent);
199
+ const startLine = content.slice(0, startOffset).split('\n').length;
200
+ for (const [k, v] of extractTopLevelKeys(flatContent, startLine)) {
201
+ if (!keys.has(k)) keys.set(k, v);
202
+ }
203
+ }
90
204
 
91
- if (objectContent) {
92
- // Find start offset in original content
93
- const startOffset = content.indexOf(objectContent);
94
- const preContent = content.slice(0, startOffset);
95
- const startLine = preContent.split('\n').length;
205
+ // makeStyles({ ... }) or makeStyles(theme => ({ ... })) — MUI
206
+ // Handles plain object, arrow returning object, and createStyles wrapper.
207
+ const msRegex = /makeStyles\s*\(/g;
208
+ let msMatch;
209
+ while ((msMatch = msRegex.exec(content)) !== null) {
210
+ const result = extractBraceContent(content, msMatch.index);
211
+ if (!result) continue;
212
+ for (const [k, v] of extractTopLevelKeys(result.inner, result.startLine)) {
213
+ if (!keys.has(k)) keys.set(k, v);
214
+ }
215
+ }
96
216
 
97
- const keyRegex = /^\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/gm;
98
- let m;
99
- while ((m = keyRegex.exec(objectContent)) !== null) {
100
- const keyLine = startLine + objectContent.slice(0, m.index).split('\n').length - 1;
101
- const name = m[1];
102
- if (!keys.has(name)) {
103
- // Grab the value snippet for context
104
- const lineText = lines[keyLine - 1]?.trim() ?? '';
105
- keys.set(name, { line: keyLine, rule: lineText });
106
- }
217
+ // vanilla-extract: export const foo = style({...}) or style([...])
218
+ // The export name itself is the class — tracked as a selector.
219
+ const veStyleRegex = /export\s+const\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*style\s*[([{]/g;
220
+ let veMatch;
221
+ while ((veMatch = veStyleRegex.exec(content)) !== null) {
222
+ const name = veMatch[1];
223
+ const lineNum = content.slice(0, veMatch.index).split('\n').length;
224
+ if (!keys.has(name)) {
225
+ keys.set(name, { line: lineNum, rule: `export const ${name} = style(...)` });
226
+ }
227
+ }
228
+
229
+ // vanilla-extract: export const variants = styleVariants({ small: {}, large: {} })
230
+ // The export name is tracked as a selector (usage: variants.small or variants[size]).
231
+ const veVariantsRegex = /export\s+const\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*styleVariants\s*\(/g;
232
+ while ((veMatch = veVariantsRegex.exec(content)) !== null) {
233
+ const name = veMatch[1];
234
+ const lineNum = content.slice(0, veMatch.index).split('\n').length;
235
+ if (!keys.has(name)) {
236
+ keys.set(name, { line: lineNum, rule: `export const ${name} = styleVariants(...)` });
107
237
  }
108
238
  }
109
239
 
@@ -123,8 +253,8 @@ export function extractUsages(content) {
123
253
  m[1].trim().split(/\s+/).forEach(c => c && used.add(c));
124
254
  }
125
255
 
126
- // styles.foo
127
- const dotRegex = /styles?\.([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
256
+ // styles.foo or classes.foo (MUI makeStyles convention)
257
+ const dotRegex = /(?:styles?|classes)\.([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
128
258
  while ((m = dotRegex.exec(content)) !== null) {
129
259
  used.add(m[1]);
130
260
  }
@@ -169,6 +299,16 @@ export function extractUsages(content) {
169
299
  }
170
300
  }
171
301
 
302
+ // vanilla-extract: import { foo, bar } from './something.css' (resolves to .css.ts)
303
+ // Named imports from any .css file are treated as used selectors.
304
+ // Handles aliases: `import { root as buttonRoot }` → tracks 'root' (the defined name).
305
+ const veImportRegex = /import\s*\{([^}]+)\}\s*from\s*['"][^'"]+\.css['"]/g;
306
+ while ((m = veImportRegex.exec(content)) !== null) {
307
+ m[1].split(',')
308
+ .map(n => n.trim().split(/\s+as\s+/)[0].trim())
309
+ .forEach(n => n && used.add(n));
310
+ }
311
+
172
312
  // Template literal dynamic class names — flag only if truly dynamic (has ${})
173
313
  // But first extract any static styles.foo refs inside them (already caught above by dotRegex)
174
314
  if (/`[^`]*\$\{[^}]+\}[^`]*`/.test(content)) {
@@ -185,15 +325,22 @@ export function parseStyleFile(filePath) {
185
325
  const ext = extname(filePath);
186
326
 
187
327
  if (['.css', '.scss', '.sass', '.less'].includes(ext)) {
188
- return { selectors: extractCssSelectors(content), hasDynamic: false };
328
+ return {
329
+ selectors: extractCssSelectors(content),
330
+ cssVars: extractCssVarDefinitions(content),
331
+ varUsages: extractVarUsages(content), // vars used within this style file itself
332
+ hasDynamic: false,
333
+ };
189
334
  }
190
335
 
191
- // JS/TS style file
336
+ // JS/TS style file — CSS custom properties are not typically defined here
192
337
  const selectors = extractJsStyleKeys(content);
193
- return { selectors, hasDynamic: false };
338
+ return { selectors, cssVars: new Map(), varUsages: new Set(), hasDynamic: false };
194
339
  }
195
340
 
196
341
  export function parseComponentFile(filePath) {
197
342
  const content = readFileSync(filePath, 'utf-8');
198
- return extractUsages(content);
343
+ const { used, hasDynamic } = extractUsages(content);
344
+ const usedVars = extractVarUsages(content);
345
+ return { used, usedVars, hasDynamic };
199
346
  }
package/src/picker.js ADDED
@@ -0,0 +1,123 @@
1
+ import { readdirSync, statSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'coverage', '.cache']);
5
+
6
+ function getDirs(dir) {
7
+ try {
8
+ return readdirSync(dir)
9
+ .filter(f => {
10
+ if (f.startsWith('.') || SKIP.has(f)) return false;
11
+ try { return statSync(join(dir, f)).isDirectory(); }
12
+ catch { return false; }
13
+ })
14
+ .sort();
15
+ } catch { return []; }
16
+ }
17
+
18
+ export async function pickDirectories() {
19
+ return new Promise((done) => {
20
+ let cwd = process.cwd();
21
+ let dirs = getDirs(cwd);
22
+ let cursor = 0;
23
+ let selected = [];
24
+
25
+ // ANSI helpers (raw — no extra dep)
26
+ const C = {
27
+ reset: '\x1b[0m',
28
+ bold: '\x1b[1m',
29
+ dim: '\x1b[2m',
30
+ invert: '\x1b[7m',
31
+ green: '\x1b[32m',
32
+ cyan: '\x1b[36m',
33
+ };
34
+
35
+ function render() {
36
+ const lines = [
37
+ '',
38
+ `${C.bold}📂 Directory picker${C.reset} — navigate into a folder, then press ${C.cyan}[a]${C.reset} to add it`,
39
+ '',
40
+ `${C.dim}📍 ${cwd}${C.reset}`,
41
+ '',
42
+ ];
43
+
44
+ if (dirs.length === 0) {
45
+ lines.push(` ${C.dim}(no subdirectories)${C.reset}`);
46
+ } else {
47
+ dirs.forEach((d, i) => {
48
+ const active = i === cursor;
49
+ lines.push(active
50
+ ? ` ${C.invert} 📁 ${d} ${C.reset}`
51
+ : ` 📁 ${d}`);
52
+ });
53
+ }
54
+
55
+ lines.push('');
56
+
57
+ if (selected.length > 0) {
58
+ lines.push(`${C.green}✅ Added to scan:${C.reset}`);
59
+ selected.forEach(s => lines.push(` ${C.green}${s}${C.reset}`));
60
+ lines.push('');
61
+ }
62
+
63
+ lines.push(
64
+ `${C.dim}[↑↓/jk] navigate [↵/→] enter [←] parent [a] add current dir [d] done [^C] quit${C.reset}`
65
+ );
66
+
67
+ process.stdout.write('\x1b[H\x1b[J' + lines.join('\n') + '\n');
68
+ }
69
+
70
+ function cleanup() {
71
+ process.stdin.setRawMode(false);
72
+ process.stdin.pause();
73
+ process.stdout.write('\x1b[2J\x1b[H');
74
+ }
75
+
76
+ function finish() {
77
+ cleanup();
78
+ done(selected.length > 0 ? selected : [cwd]);
79
+ }
80
+
81
+ function goUp() {
82
+ const parent = join(cwd, '..');
83
+ if (parent !== cwd) {
84
+ cwd = parent;
85
+ dirs = getDirs(cwd);
86
+ cursor = 0;
87
+ }
88
+ }
89
+
90
+ function goInto() {
91
+ if (dirs.length > 0) {
92
+ cwd = join(cwd, dirs[cursor]);
93
+ dirs = getDirs(cwd);
94
+ cursor = 0;
95
+ }
96
+ }
97
+
98
+ function toggleAdd() {
99
+ const idx = selected.indexOf(cwd);
100
+ if (idx === -1) selected.push(cwd);
101
+ else selected.splice(idx, 1);
102
+ }
103
+
104
+ process.stdin.setRawMode(true);
105
+ process.stdin.resume();
106
+ process.stdin.setEncoding('utf8');
107
+ process.stdout.write('\x1b[2J\x1b[H');
108
+ render();
109
+
110
+ process.stdin.on('data', (key) => {
111
+ if (key === '\x03') { cleanup(); process.exit(0); } // Ctrl+C
112
+ else if (key === '\x1b[A' || key === 'k') cursor = Math.max(0, cursor - 1); // up
113
+ else if (key === '\x1b[B' || key === 'j') cursor = Math.min(dirs.length - 1, cursor + 1); // down
114
+ else if (key === '\x1b[C' || key === '\r') goInto(); // right / Enter
115
+ else if (key === '\x1b[D' || key === '\x7f') goUp(); // left / backspace
116
+ else if (key === 'a') toggleAdd();
117
+ else if (key === 'd') { finish(); return; }
118
+
119
+ if (dirs.length === 0) cursor = 0;
120
+ render();
121
+ });
122
+ });
123
+ }