@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/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 = 0;
6
- let totalOrphaned = 0;
7
- let filesWithOrphans = 0;
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, total, hasDynamic, dynamicFiles } = result;
13
- totalDefined += total;
14
- totalOrphaned += orphaned.length;
49
+ const { filePath, orphaned, orphanedVars = [], total, totalVars = 0,
50
+ hasDynamic, dynamicFiles, trails, varTrails } = result;
15
51
 
16
- const hasOrphans = orphaned.length > 0;
17
- if (hasOrphans) filesWithOrphans++;
52
+ totalDefined += total;
53
+ totalOrphaned += orphaned.length;
54
+ totalVarsDefined += totalVars;
55
+ totalVarsOrphaned += orphanedVars.length;
18
56
 
19
- // Skip files with nothing to report
20
- if (!hasOrphans && !hasDynamic) continue;
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
- `| Style files scanned | ${results.length} |`,
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 content = summary + aiBlock + sections.join('');
93
- writeFileSync(outputPath, content, 'utf-8');
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
- // Console output โ€” also print orphans inline
96
- console.log(`๐Ÿ“Š ${totalOrphaned} orphaned selector${totalOrphaned !== 1 ? 's' : ''} in ${filesWithOrphans} file${filesWithOrphans !== 1 ? 's' : ''}. See: ${outputPath}\n`);
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
- if (result.orphaned.length === 0) continue;
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
- console.log(`โœ… ${relPath}\n`);
107
- result.orphaned.forEach(({ name, line, rule }) => {
108
- console.log(` ๐Ÿ‘ป .${name} (line ${line})`);
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
- // Build global usage map from all component files
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
- // Analyse each style file
77
+ // โ”€โ”€ PASS 3: Cross-reference and build results โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
53
78
  const results = [];
54
79
 
55
80
  for (const styleFile of styleFiles) {
56
- try {
57
- const { selectors, hasDynamic } = parseStyleFile(styleFile);
58
- // selectors is now a Map: name โ†’ { line, rule }
59
- const orphaned = [...selectors.entries()]
60
- .filter(([name]) => !globalUsed.has(name))
61
- .map(([name, meta]) => ({ name, ...meta }));
62
-
63
- results.push({
64
- filePath: styleFile,
65
- total: selectors.size,
66
- orphaned,
67
- hasDynamic: hasDynamic || dynamicComponentFiles.length > 0,
68
- dynamicFiles: dynamicComponentFiles,
69
- });
70
- } catch (e) {
71
- console.warn(`โš ๏ธ Could not parse ${styleFile}: ${e.message}`);
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;