@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/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
37
|
export function generateReport(results, outputPath = 'orphan-report.md', aiSummary = null) {
|
|
5
|
-
let totalDefined
|
|
6
|
-
let totalOrphaned
|
|
7
|
-
let
|
|
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', aiSumma
|
|
|
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', aiSumma
|
|
|
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,11 +166,7 @@ export function generateReport(results, outputPath = 'orphan-report.md', aiSumma
|
|
|
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
|
'',
|
|
@@ -89,28 +176,62 @@ export function generateReport(results, outputPath = 'orphan-report.md', aiSumma
|
|
|
89
176
|
? `## ๐ค AI Summary\n\n${aiSummary}\n\n---\n\n`
|
|
90
177
|
: '';
|
|
91
178
|
|
|
92
|
-
const
|
|
93
|
-
writeFileSync(outputPath,
|
|
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' : ''}`;
|
|
94
189
|
|
|
95
|
-
|
|
96
|
-
console.log(
|
|
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('');
|
|
97
193
|
|
|
98
194
|
if (aiSummary) {
|
|
99
|
-
console.log(`๐ค AI summary\n`);
|
|
195
|
+
console.log(`๐ค ${pc.bold('AI summary')}\n`);
|
|
100
196
|
console.log(` ${aiSummary.split('\n').join('\n ')}\n`);
|
|
101
197
|
}
|
|
102
198
|
|
|
103
199
|
for (const result of results) {
|
|
104
|
-
|
|
200
|
+
const { orphaned, orphanedVars = [], trails, varTrails } = result;
|
|
201
|
+
if (orphaned.length === 0 && orphanedVars.length === 0) continue;
|
|
202
|
+
|
|
105
203
|
const relPath = relative(process.cwd(), result.filePath);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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));
|
|
109
214
|
if (rule) {
|
|
110
215
|
const preview = rule.split('\n').slice(0, 4).join('\n');
|
|
111
|
-
console.log(preview.split('\n').map(l => ` ${l}`).join('\n'));
|
|
216
|
+
console.log(preview.split('\n').map(l => ` ${pc.dim(l)}`).join('\n'));
|
|
112
217
|
}
|
|
113
218
|
console.log('');
|
|
114
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
|
+
}
|
|
115
233
|
}
|
|
234
|
+
|
|
235
|
+
// Return totals so callers can check orphan counts (e.g. for --strict)
|
|
236
|
+
return { totalOrphaned, totalVarsOrphaned, totalAllOrphans };
|
|
116
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;
|