@rolandjlevy/css-ghost 1.0.1 → 1.0.4

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/README.md CHANGED
@@ -4,7 +4,7 @@ Find orphaned CSS class selectors in your codebase — defined in style files bu
4
4
 
5
5
  ## What it does
6
6
 
7
- Scans one or more directories for style files, extracts every class selector defined, then cross-references them against usages in your component files. Anything defined but never used is reported as an orphan.
7
+ Scans one or more directories for style files, extracts every class selector defined, then cross-references them against usages in your component files. Anything defined but never used is reported as an orphan — with its line number and the actual CSS printed inline.
8
8
 
9
9
  **Supported style files:**
10
10
  - `.css`, `.module.css`, `.scss`, `.sass`, `.less`
@@ -14,21 +14,40 @@ Scans one or more directories for style files, extracts every class selector def
14
14
  - `className="foo"`
15
15
  - `className={styles.foo}`
16
16
  - `styles['foo']`
17
+ - `styles[condition ? 'foo' : 'bar']` — both branches extracted
17
18
  - `cx('foo')`, `clsx('foo')`, `classnames('foo')`
19
+ - `cx({ active: isOn, disabled: true })` — object keys extracted
20
+ - `cx(['foo', condition && 'bar'])` — array string literals extracted
18
21
 
19
22
  **Dynamic class detection:**
20
- Files containing `styles[variable]` or template literal class names are flagged with a ⚠️ warning — results for those files may be incomplete.
23
+ Patterns where the class name is truly unknowable at parse time — `styles[variable]` or template literals containing `${}` — are flagged with a ⚠️ warning. All statically resolvable patterns (string literals, ternaries, object keys) are analysed without warnings.
24
+
25
+ ## To enable AI features
26
+
27
+ css-ghost can generate a plain English summary of your orphaned selectors using a free [OpenRouter](https://openrouter.ai) API key. Run the setup wizard once:
28
+
29
+ ```bash
30
+ css-ghost --setup
31
+ ```
32
+
33
+ It will prompt you to paste your key (free at [openrouter.ai/keys](https://openrouter.ai/keys)) and save it to `~/.css-ghost`. Then pass `--ai` to any scan:
34
+
35
+ ```bash
36
+ css-ghost ./src --ai
37
+ ```
38
+
39
+ The AI summary appears in both the terminal output and the markdown report. The core scan always runs without a key — `--ai` is entirely optional.
21
40
 
22
41
  ## Install
23
42
 
24
43
  ```bash
25
- npm install -g css-ghost
44
+ npm install -g @rolandjlevy/css-ghost
26
45
  ```
27
46
 
28
- Or run directly without installing:
47
+ Or run without installing:
29
48
 
30
49
  ```bash
31
- npx css-ghost ./src
50
+ npx @rolandjlevy/css-ghost ./src
32
51
  ```
33
52
 
34
53
  ## Usage
@@ -42,49 +61,105 @@ css-ghost <directory> [directory2] ... [options]
42
61
  | Flag | Description | Default |
43
62
  | --- | --- | --- |
44
63
  | `--output <file>` | Output report file path | `orphan-report.md` |
64
+ | `--ai` | Append an AI-generated summary (requires setup) | off |
65
+ | `--setup` | Save your OpenRouter API key for `--ai` | — |
45
66
  | `-h, --help` | Show help | — |
46
67
 
47
68
  ## Examples
48
69
 
49
70
  ```bash
50
- # Scan a single directory
71
+ # Scan your src folder
51
72
  css-ghost ./src
52
73
 
53
74
  # Scan multiple directories
54
75
  css-ghost ./src ./components ./pages
55
76
 
56
- # Custom output path
77
+ # With AI summary
78
+ css-ghost ./src --ai
79
+
80
+ # Write report to a custom path
57
81
  css-ghost ./src --output ./reports/css-audit.md
58
82
  ```
59
83
 
60
- ## Output
84
+ ## CLI output
85
+
86
+ Running `css-ghost ./src` prints a live summary to your terminal:
87
+
88
+ ```
89
+ ✅ Report written to orphan-report.md
90
+ 📊 3 orphaned selectors across 1 file
91
+
92
+ 📄 src/components/Button/Button.module.css
93
+ 👻 .iconLeft (line 4)
94
+ .iconLeft {
95
+ margin-right: 6px;
96
+ flex-shrink: 0;
97
+ }
98
+
99
+ 👻 .loadingSpinner (line 12)
100
+ .loadingSpinner {
101
+ animation: spin 0.8s linear infinite;
102
+ }
103
+
104
+ 👻 .disabled (line 18)
105
+ .disabled {
106
+ opacity: 0.4;
107
+ pointer-events: none;
108
+ }
109
+
110
+ 📁 1 style file scanned
111
+ ```
112
+
113
+ Each orphan shows:
114
+ - the selector name and the **exact line number** where it's defined
115
+ - the **full CSS rule** so you can see what it does before deciding to delete it
116
+
117
+ ## Markdown report
61
118
 
62
- Generates a markdown report (`orphan-report.md` by default):
119
+ A full report is also written to `orphan-report.md` (or your `--output` path):
63
120
 
64
121
  ---
65
122
 
66
- # 👻 css-ghost — Orphaned Selector Report
123
+ ### 👻 Orphaned selectors
67
124
 
68
- ## Summary
69
- | Metric | Value |
70
- | --- | --- |
71
- | Style files scanned | 12 |
72
- | Total selectors defined | 84 |
73
- | Total orphaned selectors | 7 |
74
- | Files with orphans | 3 |
75
- | Overall unused rate | 8% |
125
+ #### `.iconLeft` — line 4
76
126
 
77
- ## `src/components/Button/Button.module.css`
78
- | Defined | Orphaned | Unused % |
79
- | --- | --- | --- |
80
- | 6 | 2 | 33% |
127
+ ```css
128
+ .iconLeft {
129
+ margin-right: 6px;
130
+ flex-shrink: 0;
131
+ }
132
+ ```
81
133
 
82
- **Orphaned selectors:**
83
- - `.iconLeft`
84
- - `.loadingSpinner`
134
+ #### `.loadingSpinner` — line 12
135
+
136
+ ```css
137
+ .loadingSpinner {
138
+ animation: spin 0.8s linear infinite;
139
+ }
140
+ ```
85
141
 
86
142
  ---
87
143
 
144
+ ## Using with npm scripts
145
+
146
+ If you're working inside the project itself, add a `scan` script to `package.json`:
147
+
148
+ ```json
149
+ "scripts": {
150
+ "scan": "css-ghost ./src"
151
+ }
152
+ ```
153
+
154
+ Then run:
155
+
156
+ ```bash
157
+ npm run scan
158
+
159
+ # Pass extra args with --
160
+ npm run scan -- --output ./reports/audit.md
161
+ ```
162
+
88
163
  ## No dependencies
89
164
 
90
165
  Built with Node.js built-ins only (`fs`, `path`). No npm packages required.
package/bin/css-ghost.js CHANGED
@@ -4,9 +4,17 @@ import { resolve } from 'path';
4
4
  import { existsSync as fsExists } from 'fs';
5
5
  import { scan } from '../src/scanner.js';
6
6
  import { generateReport } from '../src/reporter.js';
7
+ import { runSetup } from '../src/setup.js';
8
+ import { getAiSummary } from '../src/ai.js';
7
9
 
8
10
  const args = process.argv.slice(2);
9
11
 
12
+ // --setup
13
+ if (args.includes('--setup')) {
14
+ await runSetup();
15
+ process.exit(0);
16
+ }
17
+
10
18
  if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
11
19
  console.log(`
12
20
  👻 css-ghost — Find orphaned CSS class selectors
@@ -16,17 +24,21 @@ Usage:
16
24
 
17
25
  Options:
18
26
  --output <file> Output report file path (default: orphan-report.md)
27
+ --ai Append an AI-generated summary to the report
28
+ --setup Save your OpenRouter API key for use with --ai
19
29
  -h, --help Show this help message
20
30
 
21
31
  Examples:
22
32
  css-ghost ./src
23
33
  css-ghost ./src ./components
34
+ css-ghost ./src --ai
24
35
  css-ghost ./src --output ./reports/css-audit.md
25
36
  `);
26
37
  process.exit(0);
27
38
  }
28
39
 
29
- // Parse --output flag
40
+ // Parse flags
41
+ const useAi = args.includes('--ai');
30
42
  const outputIdx = args.indexOf('--output');
31
43
  const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : 'orphan-report.md';
32
44
 
@@ -35,8 +47,10 @@ if (outputIdx !== -1 && !outputPath) {
35
47
  process.exit(1);
36
48
  }
37
49
 
38
- // Remaining args are directories
39
- const rawDirs = args.filter((a, i) => !a.startsWith('--') && (outputIdx === -1 || i !== outputIdx + 1));
50
+ // Remaining args are directories (strip flags and their values)
51
+ const rawDirs = args.filter((a, i) =>
52
+ !a.startsWith('--') && (outputIdx === -1 || i !== outputIdx + 1)
53
+ );
40
54
 
41
55
  if (rawDirs.length === 0) {
42
56
  console.error('❌ At least one directory is required');
@@ -65,4 +79,7 @@ if (results.length === 0) {
65
79
  process.exit(0);
66
80
  }
67
81
 
68
- generateReport(results, outputPath);
82
+ // Get AI summary before writing report (so it can be included in the .md)
83
+ const aiSummary = useAi ? await getAiSummary(results) : null;
84
+
85
+ generateReport(results, outputPath, aiSummary);
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@rolandjlevy/css-ghost",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
4
4
  "description": "Find orphaned CSS class selectors in your codebase",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "css-ghost": "bin/css-ghost.js"
8
8
  },
9
9
  "scripts": {
10
- "start": "node bin/css-ghost.js"
10
+ "start": "node bin/css-ghost.js",
11
+ "scan": "node bin/css-ghost.js ./test",
12
+ "setup": "node bin/css-ghost.js --setup"
11
13
  },
12
14
  "files": [
13
15
  "bin",
package/src/ai.js ADDED
@@ -0,0 +1,94 @@
1
+ import { relative } from 'path';
2
+ import { loadConfig } from './setup.js';
3
+
4
+ const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
5
+ // Fallback chain — tried in order until one returns a response
6
+ const FREE_MODELS = [
7
+ 'nvidia/nemotron-3.5-lightning:free',
8
+ 'google/gemma-4-31b-it:free',
9
+ 'qwen/qwen3.8-27b:free',
10
+ 'thinkingmachines/inkling:free',
11
+ 'nvidia/nemotron-3-super-120b-a12b:free',
12
+ ];
13
+
14
+ function getApiKey() {
15
+ if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY;
16
+ const config = loadConfig();
17
+ return config.openrouterApiKey || null;
18
+ }
19
+
20
+ function getModelOverride() {
21
+ if (process.env.OPENROUTER_MODEL) return process.env.OPENROUTER_MODEL;
22
+ const config = loadConfig();
23
+ return config.openrouterModel || null;
24
+ }
25
+
26
+ async function tryModel(model, key, prompt) {
27
+ const response = await fetch(OPENROUTER_URL, {
28
+ method: 'POST',
29
+ headers: {
30
+ 'Authorization': `Bearer ${key}`,
31
+ 'Content-Type': 'application/json',
32
+ 'HTTP-Referer': 'https://github.com/rolandjlevy/css-ghost',
33
+ 'X-Title': 'css-ghost',
34
+ },
35
+ body: JSON.stringify({
36
+ model,
37
+ messages: [{ role: 'user', content: prompt }],
38
+ max_tokens: 300,
39
+ }),
40
+ });
41
+
42
+ if (!response.ok) return null;
43
+ const data = await response.json();
44
+ return data.choices?.[0]?.message?.content?.trim() || null;
45
+ }
46
+
47
+ function buildPrompt(results) {
48
+ const totalOrphaned = results.reduce((n, r) => n + r.orphaned.length, 0);
49
+ const filesWithOrphans = results.filter(r => r.orphaned.length > 0);
50
+
51
+ const details = filesWithOrphans.map(r => {
52
+ const relPath = relative(process.cwd(), r.filePath);
53
+ const names = r.orphaned.map(o => `.${o.name}`).join(', ');
54
+ return `- ${relPath}: ${names}`;
55
+ }).join('\n');
56
+
57
+ return `You are a CSS code quality assistant. A static analysis tool found ${totalOrphaned} orphaned CSS selectors (defined but never referenced in any component file) across ${filesWithOrphans.length} file(s):
58
+
59
+ ${details}
60
+
61
+ Write a short, practical summary (3–5 sentences) for a developer. Cover:
62
+ 1. Whether this looks like a significant problem or minor cleanup
63
+ 2. Which files or selectors are most worth investigating first
64
+ 3. One concrete suggestion for how to approach the cleanup
65
+
66
+ Be direct and specific. No fluff.`;
67
+ }
68
+
69
+ export async function getAiSummary(results) {
70
+ const key = getApiKey();
71
+
72
+ if (!key) {
73
+ console.log('💡 Tip: Run css-ghost --setup to enable AI summaries with --ai\n');
74
+ return null;
75
+ }
76
+
77
+ const override = getModelOverride();
78
+ const models = override ? [override] : FREE_MODELS;
79
+ const prompt = buildPrompt(results);
80
+
81
+ console.log('🤖 Generating AI summary...\n');
82
+
83
+ for (const model of models) {
84
+ try {
85
+ const content = await tryModel(model, key, prompt);
86
+ if (content) return content;
87
+ } catch {
88
+ // try next model
89
+ }
90
+ }
91
+
92
+ console.warn('⚠️ AI summary unavailable — all free models failed or returned empty responses.\n');
93
+ return null;
94
+ }
package/src/parser.js CHANGED
@@ -135,23 +135,42 @@ export function extractUsages(content) {
135
135
  used.add(m[1]);
136
136
  }
137
137
 
138
- // styles[variable] — dynamic, flag it
139
- if (/styles?\[(?!['"])/.test(content)) {
138
+ // styles[condition ? 'foo' : 'bar'] — ternary with string literals: extract both branches
139
+ const ternaryBracketRegex = /styles?\[\s*[^?]+\?\s*['"]([a-zA-Z_$][a-zA-Z0-9_$]*)["']\s*:\s*['"]([a-zA-Z_$][a-zA-Z0-9_$]*)["']\s*\]/g;
140
+ while ((m = ternaryBracketRegex.exec(content)) !== null) {
141
+ used.add(m[1]);
142
+ used.add(m[2]);
143
+ }
144
+
145
+ // styles[variable] — truly dynamic (not a string literal or ternary): flag it
146
+ const trulyDynamicBracket = /styles?\[(?!['"])(?![^?]+\?\s*['"])/;
147
+ if (trulyDynamicBracket.test(content)) {
140
148
  hasDynamic = true;
141
149
  }
142
150
 
143
151
  // cx('foo', 'bar') / clsx('foo') / classnames('foo')
144
- const cxRegex = /(?:cx|clsx|classnames)\s*\(([^)]+)\)/g;
152
+ // Also handles object syntax: cx({ active: isOn, disabled: true })
153
+ // and array syntax: cx(['foo', condition && 'bar'])
154
+ const cxRegex = /(?:cx|clsx|classnames)\s*\(([\s\S]*?)\)/g;
145
155
  while ((m = cxRegex.exec(content)) !== null) {
146
156
  const inner = m[1];
157
+
158
+ // String literals: 'foo' or "foo"
147
159
  const strRegex = /['"]([a-zA-Z_-][a-zA-Z0-9_-]*)["']/g;
148
160
  let sm;
149
161
  while ((sm = strRegex.exec(inner)) !== null) {
150
162
  used.add(sm[1]);
151
163
  }
164
+
165
+ // Object keys: { active: ..., disabled: ... }
166
+ const objKeyRegex = /^\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/gm;
167
+ while ((sm = objKeyRegex.exec(inner)) !== null) {
168
+ used.add(sm[1]);
169
+ }
152
170
  }
153
171
 
154
- // Template literal dynamic class names
172
+ // Template literal dynamic class names — flag only if truly dynamic (has ${})
173
+ // But first extract any static styles.foo refs inside them (already caught above by dotRegex)
155
174
  if (/`[^`]*\$\{[^}]+\}[^`]*`/.test(content)) {
156
175
  hasDynamic = true;
157
176
  }
package/src/reporter.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { writeFileSync } from 'fs';
2
2
  import { relative } from 'path';
3
3
 
4
- export function generateReport(results, outputPath = 'orphan-report.md') {
4
+ export function generateReport(results, outputPath = 'orphan-report.md', aiSummary = null) {
5
5
  let totalDefined = 0;
6
6
  let totalOrphaned = 0;
7
7
  let filesWithOrphans = 0;
@@ -85,17 +85,25 @@ export function generateReport(results, outputPath = 'orphan-report.md') {
85
85
  '',
86
86
  ].join('\n');
87
87
 
88
- const content = summary + sections.join('');
88
+ const aiBlock = aiSummary
89
+ ? `## 🤖 AI Summary\n\n${aiSummary}\n\n---\n\n`
90
+ : '';
91
+
92
+ const content = summary + aiBlock + sections.join('');
89
93
  writeFileSync(outputPath, content, 'utf-8');
90
94
 
91
95
  // Console output — also print orphans inline
92
- console.log(`✅ Report written to ${outputPath}`);
93
- console.log(`📊 ${totalOrphaned} orphaned selector${totalOrphaned !== 1 ? 's' : ''} across ${filesWithOrphans} file${filesWithOrphans !== 1 ? 's' : ''}\n`);
96
+ console.log(`📊 ${totalOrphaned} orphaned selector${totalOrphaned !== 1 ? 's' : ''} in ${filesWithOrphans} file${filesWithOrphans !== 1 ? 's' : ''}. See: ${outputPath}\n`);
97
+
98
+ if (aiSummary) {
99
+ console.log(`🤖 AI summary\n`);
100
+ console.log(` ${aiSummary.split('\n').join('\n ')}\n`);
101
+ }
94
102
 
95
103
  for (const result of results) {
96
104
  if (result.orphaned.length === 0) continue;
97
105
  const relPath = relative(process.cwd(), result.filePath);
98
- console.log(`📄 ${relPath}`);
106
+ console.log(`✅ ${relPath}\n`);
99
107
  result.orphaned.forEach(({ name, line, rule }) => {
100
108
  console.log(` 👻 .${name} (line ${line})`);
101
109
  if (rule) {
@@ -105,6 +113,4 @@ export function generateReport(results, outputPath = 'orphan-report.md') {
105
113
  console.log('');
106
114
  });
107
115
  }
108
-
109
- console.log(`📁 ${results.length} style file${results.length !== 1 ? 's' : ''} scanned\n`);
110
116
  }
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
+ }