@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 +25 -1
- package/bin/css-ghost.js +21 -4
- package/package.json +3 -2
- package/src/ai.js +94 -0
- package/src/parser.js +23 -4
- package/src/reporter.js +11 -2
- package/src/setup.js +60 -0
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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.
|
|
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[
|
|
139
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|