@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/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 = 0;
6
- let totalOrphaned = 0;
7
- let filesWithOrphans = 0;
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, 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') {
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
- `| 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
  '',
86
173
  ].join('\n');
87
174
 
88
- const content = summary + sections.join('');
89
- writeFileSync(outputPath, content, 'utf-8');
175
+ const aiBlock = aiSummary
176
+ ? `## šŸ¤– AI Summary\n\n${aiSummary}\n\n---\n\n`
177
+ : '';
90
178
 
91
- // Console output — also print orphans inline
92
- console.log(`šŸ“Š ${totalOrphaned} orphaned selector${totalOrphaned !== 1 ? 's' : ''} in ${filesWithOrphans} file${filesWithOrphans !== 1 ? 's' : ''}. See: ${outputPath}\n`);
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
- if (result.orphaned.length === 0) continue;
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
- console.log(`āœ… ${relPath}\n`);
98
- result.orphaned.forEach(({ name, line, rule }) => {
99
- 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));
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
- // 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;
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
+ }