@rolandjlevy/css-ghost 1.0.2 → 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
@@ -14,10 +14,29 @@ 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
 
@@ -42,6 +61,8 @@ 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
@@ -53,6 +74,9 @@ css-ghost ./src
53
74
  # Scan multiple directories
54
75
  css-ghost ./src ./components ./pages
55
76
 
77
+ # With AI summary
78
+ css-ghost ./src --ai
79
+
56
80
  # Write report to a custom path
57
81
  css-ghost ./src --output ./reports/css-audit.md
58
82
  ```
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@rolandjlevy/css-ghost",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Find orphaned CSS class selectors in your codebase",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/css-ghost.js",
11
- "scan": "node bin/css-ghost.js ./test"
11
+ "scan": "node bin/css-ghost.js ./test",
12
+ "setup": "node bin/css-ghost.js --setup"
12
13
  },
13
14
  "files": [
14
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,12 +85,21 @@ 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
96
  console.log(`📊 ${totalOrphaned} orphaned selector${totalOrphaned !== 1 ? 's' : ''} in ${filesWithOrphans} file${filesWithOrphans !== 1 ? 's' : ''}. See: ${outputPath}\n`);
93
97
 
98
+ if (aiSummary) {
99
+ console.log(`🤖 AI summary\n`);
100
+ console.log(` ${aiSummary.split('\n').join('\n ')}\n`);
101
+ }
102
+
94
103
  for (const result of results) {
95
104
  if (result.orphaned.length === 0) continue;
96
105
  const relPath = relative(process.cwd(), result.filePath);
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
+ }