@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/LICENSE +24 -0
- package/README.md +200 -22
- package/bin/css-ghost.js +88 -25
- package/package.json +19 -6
- package/src/deleter.js +177 -0
- package/src/git.js +73 -0
- package/src/hook.js +64 -0
- package/src/parser.js +171 -24
- package/src/picker.js +123 -0
- package/src/reporter.js +149 -28
- package/src/scanner.js +55 -21
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();
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|