@rolandjlevy/css-ghost 1.0.2 ā 2.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/LICENSE +24 -0
- package/README.md +228 -19
- package/bin/css-ghost.js +103 -23
- package/package.json +11 -3
- package/src/ai.js +94 -0
- package/src/deleter.js +177 -0
- package/src/git.js +73 -0
- package/src/hook.js +64 -0
- package/src/parser.js +194 -28
- package/src/picker.js +123 -0
- package/src/reporter.js +158 -28
- package/src/scanner.js +55 -21
- package/src/setup.js +60 -0
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
|
+
}
|
package/src/reporter.js
CHANGED
|
@@ -1,36 +1,94 @@
|
|
|
1
1
|
import { writeFileSync } from 'fs';
|
|
2
2
|
import { relative } from 'path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
|
|
5
|
+
function minifyCss(css) {
|
|
6
|
+
return css
|
|
7
|
+
.replace(/\/\*[\s\S]*?\*\//g, '') // strip comments
|
|
8
|
+
.replace(/\s+/g, ' ') // collapse whitespace
|
|
9
|
+
.replace(/\s*([{}:;,])\s*/g, '$1') // remove spaces around punctuation
|
|
10
|
+
.trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function calcSavings(rules) {
|
|
14
|
+
const raw = rules.reduce((n, r) => n + Buffer.byteLength(r || '', 'utf-8'), 0);
|
|
15
|
+
const min = rules.reduce((n, r) => n + Buffer.byteLength(minifyCss(r || ''), 'utf-8'), 0);
|
|
16
|
+
return { raw, min };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatBytes(n) {
|
|
20
|
+
if (n === 0) return '0 B';
|
|
21
|
+
if (n < 1024) return `${n} B`;
|
|
22
|
+
return `${(n / 1024).toFixed(1)} kB`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Format a ghost-trail entry for markdown. */
|
|
26
|
+
function trailMd(trail) {
|
|
27
|
+
if (!trail) return '';
|
|
28
|
+
return `> šµļø Last git activity: \`${trail.hash}\` by ${trail.author} Ā· ${trail.when} ā *"${trail.subject}"*\n`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Format a ghost-trail entry for console. */
|
|
32
|
+
function trailConsole(trail) {
|
|
33
|
+
if (!trail) return '';
|
|
34
|
+
return ` ${pc.dim(`šµļø ${trail.hash} Ā· ${trail.author} Ā· ${trail.when}`)} ${pc.dim(`"${trail.subject}"`)}\n`;
|
|
35
|
+
}
|
|
3
36
|
|
|
4
|
-
export function generateReport(results, outputPath = 'orphan-report.md') {
|
|
5
|
-
let totalDefined
|
|
6
|
-
let totalOrphaned
|
|
7
|
-
let
|
|
37
|
+
export function generateReport(results, outputPath = 'orphan-report.md', aiSummary = null) {
|
|
38
|
+
let totalDefined = 0;
|
|
39
|
+
let totalOrphaned = 0;
|
|
40
|
+
let totalVarsDefined = 0;
|
|
41
|
+
let totalVarsOrphaned = 0;
|
|
42
|
+
let filesWithOrphans = 0;
|
|
43
|
+
let totalRaw = 0;
|
|
44
|
+
let totalMin = 0;
|
|
8
45
|
|
|
9
46
|
const sections = [];
|
|
10
47
|
|
|
11
48
|
for (const result of results) {
|
|
12
|
-
const { filePath, orphaned,
|
|
13
|
-
|
|
14
|
-
totalOrphaned += orphaned.length;
|
|
49
|
+
const { filePath, orphaned, orphanedVars = [], total, totalVars = 0,
|
|
50
|
+
hasDynamic, dynamicFiles, trails, varTrails } = result;
|
|
15
51
|
|
|
16
|
-
|
|
17
|
-
|
|
52
|
+
totalDefined += total;
|
|
53
|
+
totalOrphaned += orphaned.length;
|
|
54
|
+
totalVarsDefined += totalVars;
|
|
55
|
+
totalVarsOrphaned += orphanedVars.length;
|
|
18
56
|
|
|
19
|
-
|
|
20
|
-
|
|
57
|
+
const hasOrphans = orphaned.length > 0;
|
|
58
|
+
const hasVarOrphans = orphanedVars.length > 0;
|
|
59
|
+
if (hasOrphans || hasVarOrphans) filesWithOrphans++;
|
|
60
|
+
|
|
61
|
+
if (!hasOrphans && !hasVarOrphans && !hasDynamic) continue;
|
|
21
62
|
|
|
22
63
|
const relPath = relative(process.cwd(), filePath);
|
|
23
64
|
const pct = total > 0 ? Math.round((orphaned.length / total) * 100) : 0;
|
|
24
65
|
|
|
66
|
+
const savings = hasOrphans
|
|
67
|
+
? calcSavings(orphaned.map(o => o.rule).filter(Boolean))
|
|
68
|
+
: { raw: 0, min: 0 };
|
|
69
|
+
totalRaw += savings.raw;
|
|
70
|
+
totalMin += savings.min;
|
|
71
|
+
|
|
72
|
+
const savingsStr = hasOrphans
|
|
73
|
+
? `${formatBytes(savings.raw)} raw Ā· ${formatBytes(savings.min)} minified`
|
|
74
|
+
: 'ā';
|
|
75
|
+
|
|
25
76
|
const lines = [
|
|
26
77
|
`## \`${relPath}\``,
|
|
27
78
|
'',
|
|
28
|
-
`| Defined | Orphaned | Unused % |`,
|
|
29
|
-
`| --- | --- | --- |`,
|
|
30
|
-
`| ${total} | ${orphaned.length} | ${pct}% |`,
|
|
79
|
+
`| Defined | Orphaned | Unused % | Potential savings |`,
|
|
80
|
+
`| --- | --- | --- | --- |`,
|
|
81
|
+
`| ${total} | ${orphaned.length} | ${pct}% | ${savingsStr} |`,
|
|
31
82
|
'',
|
|
32
83
|
];
|
|
33
84
|
|
|
85
|
+
if (totalVars > 0) {
|
|
86
|
+
lines.push(`| CSS variables defined | CSS variables orphaned |`);
|
|
87
|
+
lines.push(`| --- | --- |`);
|
|
88
|
+
lines.push(`| ${totalVars} | ${orphanedVars.length} |`);
|
|
89
|
+
lines.push('');
|
|
90
|
+
}
|
|
91
|
+
|
|
34
92
|
if (hasDynamic) {
|
|
35
93
|
lines.push(`> ā ļø **Dynamic class references detected** ā results may be incomplete.`);
|
|
36
94
|
if (dynamicFiles?.length > 0) {
|
|
@@ -48,6 +106,26 @@ export function generateReport(results, outputPath = 'orphan-report.md') {
|
|
|
48
106
|
orphaned.forEach(({ name, line, rule }) => {
|
|
49
107
|
lines.push(`#### \`.${name}\` ā line ${line}`);
|
|
50
108
|
lines.push('');
|
|
109
|
+
const trail = trails?.[name];
|
|
110
|
+
if (trail) lines.push(trailMd(trail));
|
|
111
|
+
if (rule) {
|
|
112
|
+
lines.push('```css');
|
|
113
|
+
lines.push(rule);
|
|
114
|
+
lines.push('```');
|
|
115
|
+
}
|
|
116
|
+
lines.push('');
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (hasVarOrphans) {
|
|
121
|
+
lines.push('### š® Orphaned CSS variables\n');
|
|
122
|
+
lines.push('> These custom properties are defined but never referenced via `var()`.\n');
|
|
123
|
+
|
|
124
|
+
orphanedVars.forEach(({ name, line, rule }) => {
|
|
125
|
+
lines.push(`#### \`${name}\` ā line ${line}`);
|
|
126
|
+
lines.push('');
|
|
127
|
+
const trail = varTrails?.[name];
|
|
128
|
+
if (trail) lines.push(trailMd(trail));
|
|
51
129
|
if (rule) {
|
|
52
130
|
lines.push('```css');
|
|
53
131
|
lines.push(rule);
|
|
@@ -66,6 +144,19 @@ export function generateReport(results, outputPath = 'orphan-report.md') {
|
|
|
66
144
|
? Math.round((totalOrphaned / totalDefined) * 100)
|
|
67
145
|
: 0;
|
|
68
146
|
|
|
147
|
+
const summaryRows = [
|
|
148
|
+
`| Style files scanned | ${results.length} |`,
|
|
149
|
+
`| Total selectors defined | ${totalDefined} |`,
|
|
150
|
+
`| Total orphaned selectors | ${totalOrphaned} |`,
|
|
151
|
+
];
|
|
152
|
+
if (totalVarsDefined > 0) {
|
|
153
|
+
summaryRows.push(`| CSS variables defined | ${totalVarsDefined} |`);
|
|
154
|
+
summaryRows.push(`| CSS variables orphaned | ${totalVarsOrphaned} |`);
|
|
155
|
+
}
|
|
156
|
+
summaryRows.push(`| Files with orphans | ${filesWithOrphans} |`);
|
|
157
|
+
summaryRows.push(`| Overall unused rate | ${unusedPct}% |`);
|
|
158
|
+
summaryRows.push(`| Potential savings | ${formatBytes(totalRaw)} raw Ā· ${formatBytes(totalMin)} minified |`);
|
|
159
|
+
|
|
69
160
|
const summary = [
|
|
70
161
|
'# š» css-ghost ā Orphaned Selector Report',
|
|
71
162
|
'',
|
|
@@ -75,33 +166,72 @@ export function generateReport(results, outputPath = 'orphan-report.md') {
|
|
|
75
166
|
'',
|
|
76
167
|
`| Metric | Value |`,
|
|
77
168
|
`| --- | --- |`,
|
|
78
|
-
|
|
79
|
-
`| Total selectors defined | ${totalDefined} |`,
|
|
80
|
-
`| Total orphaned selectors | ${totalOrphaned} |`,
|
|
81
|
-
`| Files with orphans | ${filesWithOrphans} |`,
|
|
82
|
-
`| Overall unused rate | ${unusedPct}% |`,
|
|
169
|
+
...summaryRows,
|
|
83
170
|
'',
|
|
84
171
|
'---',
|
|
85
172
|
'',
|
|
86
173
|
].join('\n');
|
|
87
174
|
|
|
88
|
-
const
|
|
89
|
-
|
|
175
|
+
const aiBlock = aiSummary
|
|
176
|
+
? `## š¤ AI Summary\n\n${aiSummary}\n\n---\n\n`
|
|
177
|
+
: '';
|
|
90
178
|
|
|
91
|
-
|
|
92
|
-
|
|
179
|
+
const mdContent = summary + aiBlock + sections.join('');
|
|
180
|
+
writeFileSync(outputPath, mdContent, 'utf-8');
|
|
181
|
+
|
|
182
|
+
// āā Console output āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
183
|
+
const totalAllOrphans = totalOrphaned + totalVarsOrphaned;
|
|
184
|
+
const orphanCount = `${pc.bold(pc.yellow(totalOrphaned))} orphaned selector${totalOrphaned !== 1 ? 's' : ''}`;
|
|
185
|
+
const varOrphanCount = totalVarsOrphaned > 0
|
|
186
|
+
? ` Ā· ${pc.bold(pc.magenta(totalVarsOrphaned))} orphaned CSS variable${totalVarsOrphaned !== 1 ? 's' : ''}`
|
|
187
|
+
: '';
|
|
188
|
+
const fileCount = `${pc.bold(filesWithOrphans)} file${filesWithOrphans !== 1 ? 's' : ''}`;
|
|
189
|
+
|
|
190
|
+
console.log(`š ${orphanCount}${varOrphanCount} in ${fileCount}. See: ${pc.cyan(outputPath)}`);
|
|
191
|
+
if (totalMin > 0) console.log(`š¾ Potential savings: ${pc.green(formatBytes(totalMin))} minified\n`);
|
|
192
|
+
else console.log('');
|
|
193
|
+
|
|
194
|
+
if (aiSummary) {
|
|
195
|
+
console.log(`š¤ ${pc.bold('AI summary')}\n`);
|
|
196
|
+
console.log(` ${aiSummary.split('\n').join('\n ')}\n`);
|
|
197
|
+
}
|
|
93
198
|
|
|
94
199
|
for (const result of results) {
|
|
95
|
-
|
|
200
|
+
const { orphaned, orphanedVars = [], trails, varTrails } = result;
|
|
201
|
+
if (orphaned.length === 0 && orphanedVars.length === 0) continue;
|
|
202
|
+
|
|
96
203
|
const relPath = relative(process.cwd(), result.filePath);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
204
|
+
const s = calcSavings(orphaned.map(o => o.rule).filter(Boolean));
|
|
205
|
+
const savingsSuffix = s.raw > 0
|
|
206
|
+
? pc.dim(` ā saves ~${formatBytes(s.raw)} (${formatBytes(s.min)} minified)`)
|
|
207
|
+
: '';
|
|
208
|
+
console.log(`ā
${pc.bold(relPath)}${savingsSuffix}\n`);
|
|
209
|
+
|
|
210
|
+
orphaned.forEach(({ name, line, rule }) => {
|
|
211
|
+
console.log(` š» ${pc.yellow('.' + name)} ${pc.dim(`(line ${line})`)}`);
|
|
212
|
+
const trail = trails?.[name];
|
|
213
|
+
if (trail) process.stdout.write(trailConsole(trail));
|
|
100
214
|
if (rule) {
|
|
101
215
|
const preview = rule.split('\n').slice(0, 4).join('\n');
|
|
102
|
-
console.log(preview.split('\n').map(l => ` ${l}`).join('\n'));
|
|
216
|
+
console.log(preview.split('\n').map(l => ` ${pc.dim(l)}`).join('\n'));
|
|
103
217
|
}
|
|
104
218
|
console.log('');
|
|
105
219
|
});
|
|
220
|
+
|
|
221
|
+
if (orphanedVars.length > 0) {
|
|
222
|
+
console.log(` ${pc.magenta('š® Orphaned CSS variables:')}\n`);
|
|
223
|
+
orphanedVars.forEach(({ name, line, rule }) => {
|
|
224
|
+
console.log(` š® ${pc.magenta(name)} ${pc.dim(`(line ${line})`)}`);
|
|
225
|
+
const trail = varTrails?.[name];
|
|
226
|
+
if (trail) process.stdout.write(trailConsole(trail));
|
|
227
|
+
if (rule) {
|
|
228
|
+
console.log(` ${pc.dim(rule)}`);
|
|
229
|
+
}
|
|
230
|
+
console.log('');
|
|
231
|
+
});
|
|
232
|
+
}
|
|
106
233
|
}
|
|
234
|
+
|
|
235
|
+
// Return totals so callers can check orphan counts (e.g. for --strict)
|
|
236
|
+
return { totalOrphaned, totalVarsOrphaned, totalAllOrphans };
|
|
107
237
|
}
|
package/src/scanner.js
CHANGED
|
@@ -13,7 +13,9 @@ export function scan(dirs) {
|
|
|
13
13
|
allJsCandidates.push(...componentFiles);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
// Split JS/TS files into style files vs component files
|
|
16
|
+
// Split JS/TS files into style files vs component files.
|
|
17
|
+
// makeStyles files are dual-role: they define style keys AND may reference
|
|
18
|
+
// those classes in the same file, so they go into both lists.
|
|
17
19
|
const jsStyleFiles = [];
|
|
18
20
|
const componentFiles = [];
|
|
19
21
|
|
|
@@ -24,6 +26,8 @@ export function scan(dirs) {
|
|
|
24
26
|
const content = readFileSync(file, 'utf-8');
|
|
25
27
|
if (isJsStyleFile(content, file)) {
|
|
26
28
|
jsStyleFiles.push(file);
|
|
29
|
+
// makeStyles files may also consume their own classes in the same file
|
|
30
|
+
if (/makeStyles\s*\(/.test(content)) componentFiles.push(file);
|
|
27
31
|
continue;
|
|
28
32
|
}
|
|
29
33
|
} catch {
|
|
@@ -35,41 +39,71 @@ export function scan(dirs) {
|
|
|
35
39
|
|
|
36
40
|
const styleFiles = [...allStyleFiles, ...jsStyleFiles];
|
|
37
41
|
|
|
38
|
-
//
|
|
42
|
+
// āā PASS 1: Parse all style files once and cache results āāāāāāāāāāāāāāāāā
|
|
43
|
+
// This avoids double-reading and gives us cssVars / varUsages in one shot.
|
|
44
|
+
const parsedStyleFiles = new Map(); // filePath ā parseStyleFile result
|
|
45
|
+
|
|
46
|
+
const globalUsedVars = new Set(); // CSS var usages collected from ALL files
|
|
47
|
+
|
|
48
|
+
for (const styleFile of styleFiles) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = parseStyleFile(styleFile);
|
|
51
|
+
parsedStyleFiles.set(styleFile, parsed);
|
|
52
|
+
// Count var usages defined within style files themselves
|
|
53
|
+
// (e.g. one CSS file using a var defined in another)
|
|
54
|
+
if (parsed.varUsages) {
|
|
55
|
+
for (const v of parsed.varUsages) globalUsedVars.add(v);
|
|
56
|
+
}
|
|
57
|
+
} catch (e) {
|
|
58
|
+
console.warn(`ā ļø Could not parse ${styleFile}: ${e.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// āā PASS 2: Parse component files ā collect class usages + var usages āāāāā
|
|
39
63
|
const globalUsed = new Set();
|
|
40
64
|
const dynamicComponentFiles = [];
|
|
41
65
|
|
|
42
66
|
for (const file of componentFiles) {
|
|
43
67
|
try {
|
|
44
|
-
const { used, hasDynamic } = parseComponentFile(file);
|
|
68
|
+
const { used, usedVars, hasDynamic } = parseComponentFile(file);
|
|
45
69
|
for (const cls of used) globalUsed.add(cls);
|
|
70
|
+
if (usedVars) for (const v of usedVars) globalUsedVars.add(v);
|
|
46
71
|
if (hasDynamic) dynamicComponentFiles.push(file);
|
|
47
72
|
} catch {
|
|
48
73
|
// skip unreadable files
|
|
49
74
|
}
|
|
50
75
|
}
|
|
51
76
|
|
|
52
|
-
//
|
|
77
|
+
// āā PASS 3: Cross-reference and build results āāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
53
78
|
const results = [];
|
|
54
79
|
|
|
55
80
|
for (const styleFile of styleFiles) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
81
|
+
const parsed = parsedStyleFiles.get(styleFile);
|
|
82
|
+
if (!parsed) continue;
|
|
83
|
+
|
|
84
|
+
const { selectors, cssVars, hasDynamic } = parsed;
|
|
85
|
+
|
|
86
|
+
// Orphaned class selectors
|
|
87
|
+
const orphaned = [...selectors.entries()]
|
|
88
|
+
.filter(([name]) => !globalUsed.has(name))
|
|
89
|
+
.map(([name, meta]) => ({ name, ...meta }));
|
|
90
|
+
|
|
91
|
+
// Orphaned CSS custom properties
|
|
92
|
+
const orphanedVars = cssVars
|
|
93
|
+
? [...cssVars.entries()]
|
|
94
|
+
.filter(([name]) => !globalUsedVars.has(name))
|
|
95
|
+
.map(([name, meta]) => ({ name, ...meta }))
|
|
96
|
+
: [];
|
|
97
|
+
|
|
98
|
+
results.push({
|
|
99
|
+
filePath: styleFile,
|
|
100
|
+
total: selectors.size,
|
|
101
|
+
orphaned,
|
|
102
|
+
orphanedVars,
|
|
103
|
+
totalVars: cssVars?.size ?? 0,
|
|
104
|
+
hasDynamic: hasDynamic || dynamicComponentFiles.length > 0,
|
|
105
|
+
dynamicFiles: dynamicComponentFiles,
|
|
106
|
+
});
|
|
73
107
|
}
|
|
74
108
|
|
|
75
109
|
return results;
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createInterface } from 'readline';
|
|
2
|
+
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
export const CONFIG_PATH = join(homedir(), '.css-ghost');
|
|
7
|
+
|
|
8
|
+
export function loadConfig() {
|
|
9
|
+
if (!existsSync(CONFIG_PATH)) return {};
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
|
12
|
+
} catch {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function saveConfig(config) {
|
|
18
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runSetup() {
|
|
22
|
+
console.log(`
|
|
23
|
+
š» css-ghost ā AI setup
|
|
24
|
+
|
|
25
|
+
To enable AI summaries, you need a free OpenRouter API key.
|
|
26
|
+
|
|
27
|
+
1. Go to https://openrouter.ai/keys
|
|
28
|
+
2. Sign up (free) and create a key
|
|
29
|
+
3. Paste it below ā it will be saved to ${CONFIG_PATH}
|
|
30
|
+
|
|
31
|
+
`);
|
|
32
|
+
|
|
33
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
34
|
+
|
|
35
|
+
const key = await new Promise(resolve => {
|
|
36
|
+
rl.question('Paste your OpenRouter API key: ', answer => {
|
|
37
|
+
rl.close();
|
|
38
|
+
resolve(answer.trim());
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!key) {
|
|
43
|
+
console.log('\nā No key entered. Setup cancelled.\n');
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!key.startsWith('sk-or-')) {
|
|
48
|
+
console.log('\nā ļø That doesn\'t look like an OpenRouter key (should start with sk-or-).');
|
|
49
|
+
console.log(' Saving anyway ā double-check at https://openrouter.ai/keys if --ai fails.\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const existing = loadConfig();
|
|
53
|
+
saveConfig({ ...existing, openrouterApiKey: key });
|
|
54
|
+
|
|
55
|
+
console.log(`
|
|
56
|
+
ā
Key saved to ${CONFIG_PATH}
|
|
57
|
+
|
|
58
|
+
Run css-ghost ./src --ai to get an AI summary alongside your report.
|
|
59
|
+
`);
|
|
60
|
+
}
|