@rolandjlevy/css-ghost 0.0.0-stage → 1.0.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/README.md +122 -2
- package/bin/css-ghost.js +68 -0
- package/package.json +37 -4
- package/src/parser.js +180 -0
- package/src/reporter.js +110 -0
- package/src/scanner.js +76 -0
- package/src/walker.js +42 -0
package/README.md
CHANGED
|
@@ -1,3 +1,123 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 👻 css-ghost
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Find orphaned CSS class selectors in your codebase — defined in style files but never referenced in your components.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
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.
|
|
8
|
+
|
|
9
|
+
**Supported style files:**
|
|
10
|
+
- `.css`, `.module.css`, `.scss`, `.sass`, `.less`
|
|
11
|
+
- `.js` / `.ts` files using `StyleSheet.create({})` or named style objects
|
|
12
|
+
|
|
13
|
+
**Detected usage patterns:**
|
|
14
|
+
- `className="foo"`
|
|
15
|
+
- `className={styles.foo}`
|
|
16
|
+
- `styles['foo']`
|
|
17
|
+
- `cx('foo')`, `clsx('foo')`, `classnames('foo')`
|
|
18
|
+
|
|
19
|
+
**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.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g css-ghost
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or run directly without installing:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx css-ghost ./src
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
css-ghost <directory> [directory2] ... [options]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Options:**
|
|
41
|
+
|
|
42
|
+
| Flag | Description | Default |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| `--output <file>` | Output report file path | `orphan-report.md` |
|
|
45
|
+
| `-h, --help` | Show help | — |
|
|
46
|
+
|
|
47
|
+
## Examples
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Scan a single directory
|
|
51
|
+
css-ghost ./src
|
|
52
|
+
|
|
53
|
+
# Scan multiple directories
|
|
54
|
+
css-ghost ./src ./components ./pages
|
|
55
|
+
|
|
56
|
+
# Custom output path
|
|
57
|
+
css-ghost ./src --output ./reports/css-audit.md
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Output
|
|
61
|
+
|
|
62
|
+
Generates a markdown report (`orphan-report.md` by default):
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
# 👻 css-ghost — Orphaned Selector Report
|
|
67
|
+
|
|
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% |
|
|
76
|
+
|
|
77
|
+
## `src/components/Button/Button.module.css`
|
|
78
|
+
| Defined | Orphaned | Unused % |
|
|
79
|
+
| --- | --- | --- |
|
|
80
|
+
| 6 | 2 | 33% |
|
|
81
|
+
|
|
82
|
+
**Orphaned selectors:**
|
|
83
|
+
- `.iconLeft`
|
|
84
|
+
- `.loadingSpinner`
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## No dependencies
|
|
89
|
+
|
|
90
|
+
Built with Node.js built-ins only (`fs`, `path`). No npm packages required.
|
|
91
|
+
|
|
92
|
+
## Origin — built with OpenRouter free models via Claude Code
|
|
93
|
+
|
|
94
|
+
This tool was created as a test project to explore [using OpenRouter's free model tier with Claude Code](https://www.mindstudio.ai/blog/open-router-free-models-claude-code-cost-reduction) as a way to cut AI development costs.
|
|
95
|
+
|
|
96
|
+
Claude Code normally calls Anthropic's API directly. By overriding two environment variables you can route it through OpenRouter's free tier instead — meaning you can run agentic coding sessions at no cost using models like Llama 3.3 70B or Deepseek R1.
|
|
97
|
+
|
|
98
|
+
### Setup
|
|
99
|
+
|
|
100
|
+
Get a free API key at [openrouter.ai/keys](https://openrouter.ai/keys), then add these aliases to your `~/.zshrc`:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# Store your key once
|
|
104
|
+
export OPENROUTER_API_KEY="sk-or-v1-YOUR_KEY"
|
|
105
|
+
|
|
106
|
+
# Llama 3.3 70B — fast, capable, free
|
|
107
|
+
alias claude-free='ANTHROPIC_BASE_URL="https://openrouter.ai/api/v1" \
|
|
108
|
+
ANTHROPIC_API_KEY="$OPENROUTER_API_KEY" \
|
|
109
|
+
claude --model "meta-llama/llama-3.3-70b-instruct:free"'
|
|
110
|
+
|
|
111
|
+
# Deepseek R1 — strongest free option for reasoning and debugging
|
|
112
|
+
alias claude-r1='ANTHROPIC_BASE_URL="https://openrouter.ai/api/v1" \
|
|
113
|
+
ANTHROPIC_API_KEY="$OPENROUTER_API_KEY" \
|
|
114
|
+
claude --model "deepseek/deepseek-r1:free"'
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Then `source ~/.zshrc`. Use `claude-free` or `claude-r1` in place of `claude` to start a free session.
|
|
118
|
+
|
|
119
|
+
> **Note:** Free tier models handle Claude Code's tool call format well in practice, but run a quick agentic task first to confirm file read/edit/bash loops work end to end in your environment before committing to it for serious work.
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
package/bin/css-ghost.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { existsSync as fsExists } from 'fs';
|
|
5
|
+
import { scan } from '../src/scanner.js';
|
|
6
|
+
import { generateReport } from '../src/reporter.js';
|
|
7
|
+
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
|
|
10
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
11
|
+
console.log(`
|
|
12
|
+
👻 css-ghost — Find orphaned CSS class selectors
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
css-ghost <directory> [directory2] ... [options]
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--output <file> Output report file path (default: orphan-report.md)
|
|
19
|
+
-h, --help Show this help message
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
css-ghost ./src
|
|
23
|
+
css-ghost ./src ./components
|
|
24
|
+
css-ghost ./src --output ./reports/css-audit.md
|
|
25
|
+
`);
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Parse --output flag
|
|
30
|
+
const outputIdx = args.indexOf('--output');
|
|
31
|
+
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : 'orphan-report.md';
|
|
32
|
+
|
|
33
|
+
if (outputIdx !== -1 && !outputPath) {
|
|
34
|
+
console.error('❌ --output requires a file path');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Remaining args are directories
|
|
39
|
+
const rawDirs = args.filter((a, i) => !a.startsWith('--') && (outputIdx === -1 || i !== outputIdx + 1));
|
|
40
|
+
|
|
41
|
+
if (rawDirs.length === 0) {
|
|
42
|
+
console.error('❌ At least one directory is required');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Validate directories exist
|
|
47
|
+
const dirs = [];
|
|
48
|
+
for (const d of rawDirs) {
|
|
49
|
+
const resolved = resolve(d);
|
|
50
|
+
if (!fsExists(resolved)) {
|
|
51
|
+
console.error(`❌ Directory not found: ${d}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
dirs.push(resolved);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.log(`\n👻 css-ghost scanning ${dirs.length} director${dirs.length === 1 ? 'y' : 'ies'}...`);
|
|
58
|
+
dirs.forEach(d => console.log(` 📁 ${d}`));
|
|
59
|
+
console.log('');
|
|
60
|
+
|
|
61
|
+
const results = scan(dirs);
|
|
62
|
+
|
|
63
|
+
if (results.length === 0) {
|
|
64
|
+
console.log('🔍 No style files found in the specified directories.\n');
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
generateReport(results, outputPath);
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rolandjlevy/css-ghost",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Find orphaned CSS class selectors in your codebase",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"css-ghost": "bin/css-ghost.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/css-ghost.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"css",
|
|
19
|
+
"orphan",
|
|
20
|
+
"unused",
|
|
21
|
+
"selectors",
|
|
22
|
+
"cli",
|
|
23
|
+
"linter",
|
|
24
|
+
"react",
|
|
25
|
+
"scss",
|
|
26
|
+
"stylesheet",
|
|
27
|
+
"audit"
|
|
28
|
+
],
|
|
29
|
+
"author": "Roland Levy",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/rolandjlevy/css-ghost.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/rolandjlevy/css-ghost#readme",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/parser.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { extname } from 'path';
|
|
3
|
+
|
|
4
|
+
// ─── CSS / SCSS selector extraction ────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
function extractCssSelectors(content) {
|
|
7
|
+
const selectors = new Map(); // name → { line, rule }
|
|
8
|
+
const lines = content.split('\n');
|
|
9
|
+
|
|
10
|
+
// Strip block comments but preserve line numbers by replacing with spaces
|
|
11
|
+
const stripped = content.replace(/\/\*[\s\S]*?\*\//g, m =>
|
|
12
|
+
m.replace(/[^\n]/g, ' ')
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
// Walk line by line to track line numbers
|
|
16
|
+
const strippedLines = stripped.split('\n');
|
|
17
|
+
let insideRule = false;
|
|
18
|
+
let currentRule = [];
|
|
19
|
+
let currentSelectors = [];
|
|
20
|
+
let ruleStartLine = 0;
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < strippedLines.length; i++) {
|
|
23
|
+
const line = strippedLines[i];
|
|
24
|
+
const lineNum = i + 1;
|
|
25
|
+
|
|
26
|
+
if (!insideRule) {
|
|
27
|
+
// Look for .className optionally followed by { on same or next line
|
|
28
|
+
const selectorRegex = /\.([a-zA-Z_-][a-zA-Z0-9_-]*)/g;
|
|
29
|
+
let m;
|
|
30
|
+
while ((m = selectorRegex.exec(line)) !== null) {
|
|
31
|
+
currentSelectors.push({ name: m[1], line: lineNum });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (line.includes('{')) {
|
|
35
|
+
insideRule = true;
|
|
36
|
+
ruleStartLine = lineNum;
|
|
37
|
+
currentRule = [lines[i]];
|
|
38
|
+
|
|
39
|
+
if (line.includes('}')) {
|
|
40
|
+
// Single-line rule
|
|
41
|
+
const ruleText = currentRule.join('\n').trim();
|
|
42
|
+
currentSelectors.forEach(({ name, line: l }) => {
|
|
43
|
+
if (!selectors.has(name)) {
|
|
44
|
+
selectors.set(name, { line: l, rule: ruleText });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
currentSelectors = [];
|
|
48
|
+
currentRule = [];
|
|
49
|
+
insideRule = false;
|
|
50
|
+
}
|
|
51
|
+
} else if (currentSelectors.length > 0 && line.trim() === '') {
|
|
52
|
+
// Empty line with no { means these weren't selectors for a rule
|
|
53
|
+
currentSelectors = [];
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
currentRule.push(lines[i]);
|
|
57
|
+
if (line.includes('}')) {
|
|
58
|
+
const ruleText = currentRule.join('\n').trim();
|
|
59
|
+
currentSelectors.forEach(({ name, line: l }) => {
|
|
60
|
+
if (!selectors.has(name)) {
|
|
61
|
+
selectors.set(name, { line: l, rule: ruleText });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
currentSelectors = [];
|
|
65
|
+
currentRule = [];
|
|
66
|
+
insideRule = false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return selectors;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── JS style file detection & key extraction ───────────────────────────────
|
|
75
|
+
|
|
76
|
+
export function isJsStyleFile(content, filePath) {
|
|
77
|
+
return (
|
|
78
|
+
/StyleSheet\.create\s*\(\s*\{/.test(content) ||
|
|
79
|
+
(/styles?\.(js|ts)$/.test(filePath) && /(const|let|var)\s+styles?\s*=\s*\{/.test(content))
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function extractJsStyleKeys(content) {
|
|
84
|
+
const keys = new Map(); // name → { line, rule }
|
|
85
|
+
const lines = content.split('\n');
|
|
86
|
+
|
|
87
|
+
const ssMatch = content.match(/StyleSheet\.create\s*\(\s*\{([\s\S]*?)\}\s*\)/);
|
|
88
|
+
const plainMatch = content.match(/(?:const|let|var)\s+styles?\s*=\s*\{([\s\S]*)\}/);
|
|
89
|
+
const objectContent = ssMatch?.[1] ?? plainMatch?.[1];
|
|
90
|
+
|
|
91
|
+
if (objectContent) {
|
|
92
|
+
// Find start offset in original content
|
|
93
|
+
const startOffset = content.indexOf(objectContent);
|
|
94
|
+
const preContent = content.slice(0, startOffset);
|
|
95
|
+
const startLine = preContent.split('\n').length;
|
|
96
|
+
|
|
97
|
+
const keyRegex = /^\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/gm;
|
|
98
|
+
let m;
|
|
99
|
+
while ((m = keyRegex.exec(objectContent)) !== null) {
|
|
100
|
+
const keyLine = startLine + objectContent.slice(0, m.index).split('\n').length - 1;
|
|
101
|
+
const name = m[1];
|
|
102
|
+
if (!keys.has(name)) {
|
|
103
|
+
// Grab the value snippet for context
|
|
104
|
+
const lineText = lines[keyLine - 1]?.trim() ?? '';
|
|
105
|
+
keys.set(name, { line: keyLine, rule: lineText });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return keys;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ─── Component file usage extraction ────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
export function extractUsages(content) {
|
|
116
|
+
const used = new Set();
|
|
117
|
+
let hasDynamic = false;
|
|
118
|
+
|
|
119
|
+
// className="foo bar" or className='foo'
|
|
120
|
+
const directRegex = /className\s*=\s*["']([^"']+)["']/g;
|
|
121
|
+
let m;
|
|
122
|
+
while ((m = directRegex.exec(content)) !== null) {
|
|
123
|
+
m[1].trim().split(/\s+/).forEach(c => c && used.add(c));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// styles.foo
|
|
127
|
+
const dotRegex = /styles?\.([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
|
|
128
|
+
while ((m = dotRegex.exec(content)) !== null) {
|
|
129
|
+
used.add(m[1]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// styles['foo'] or styles["foo"]
|
|
133
|
+
const bracketStringRegex = /styles?\[['"]([a-zA-Z_$][a-zA-Z0-9_$]*)['"]]/g;
|
|
134
|
+
while ((m = bracketStringRegex.exec(content)) !== null) {
|
|
135
|
+
used.add(m[1]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// styles[variable] — dynamic, flag it
|
|
139
|
+
if (/styles?\[(?!['"])/.test(content)) {
|
|
140
|
+
hasDynamic = true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// cx('foo', 'bar') / clsx('foo') / classnames('foo')
|
|
144
|
+
const cxRegex = /(?:cx|clsx|classnames)\s*\(([^)]+)\)/g;
|
|
145
|
+
while ((m = cxRegex.exec(content)) !== null) {
|
|
146
|
+
const inner = m[1];
|
|
147
|
+
const strRegex = /['"]([a-zA-Z_-][a-zA-Z0-9_-]*)["']/g;
|
|
148
|
+
let sm;
|
|
149
|
+
while ((sm = strRegex.exec(inner)) !== null) {
|
|
150
|
+
used.add(sm[1]);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Template literal dynamic class names
|
|
155
|
+
if (/`[^`]*\$\{[^}]+\}[^`]*`/.test(content)) {
|
|
156
|
+
hasDynamic = true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { used, hasDynamic };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
export function parseStyleFile(filePath) {
|
|
165
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
166
|
+
const ext = extname(filePath);
|
|
167
|
+
|
|
168
|
+
if (['.css', '.scss', '.sass', '.less'].includes(ext)) {
|
|
169
|
+
return { selectors: extractCssSelectors(content), hasDynamic: false };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// JS/TS style file
|
|
173
|
+
const selectors = extractJsStyleKeys(content);
|
|
174
|
+
return { selectors, hasDynamic: false };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function parseComponentFile(filePath) {
|
|
178
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
179
|
+
return extractUsages(content);
|
|
180
|
+
}
|
package/src/reporter.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { writeFileSync } from 'fs';
|
|
2
|
+
import { relative } from 'path';
|
|
3
|
+
|
|
4
|
+
export function generateReport(results, outputPath = 'orphan-report.md') {
|
|
5
|
+
let totalDefined = 0;
|
|
6
|
+
let totalOrphaned = 0;
|
|
7
|
+
let filesWithOrphans = 0;
|
|
8
|
+
|
|
9
|
+
const sections = [];
|
|
10
|
+
|
|
11
|
+
for (const result of results) {
|
|
12
|
+
const { filePath, orphaned, total, hasDynamic, dynamicFiles } = result;
|
|
13
|
+
totalDefined += total;
|
|
14
|
+
totalOrphaned += orphaned.length;
|
|
15
|
+
|
|
16
|
+
const hasOrphans = orphaned.length > 0;
|
|
17
|
+
if (hasOrphans) filesWithOrphans++;
|
|
18
|
+
|
|
19
|
+
// Skip files with nothing to report
|
|
20
|
+
if (!hasOrphans && !hasDynamic) continue;
|
|
21
|
+
|
|
22
|
+
const relPath = relative(process.cwd(), filePath);
|
|
23
|
+
const pct = total > 0 ? Math.round((orphaned.length / total) * 100) : 0;
|
|
24
|
+
|
|
25
|
+
const lines = [
|
|
26
|
+
`## \`${relPath}\``,
|
|
27
|
+
'',
|
|
28
|
+
`| Defined | Orphaned | Unused % |`,
|
|
29
|
+
`| --- | --- | --- |`,
|
|
30
|
+
`| ${total} | ${orphaned.length} | ${pct}% |`,
|
|
31
|
+
'',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
if (hasDynamic) {
|
|
35
|
+
lines.push(`> ⚠️ **Dynamic class references detected** — results may be incomplete.`);
|
|
36
|
+
if (dynamicFiles?.length > 0) {
|
|
37
|
+
const relDynamic = dynamicFiles
|
|
38
|
+
.map(f => `\`${relative(process.cwd(), f)}\``)
|
|
39
|
+
.join(', ');
|
|
40
|
+
lines.push(`> Dynamic references found in: ${relDynamic}`);
|
|
41
|
+
}
|
|
42
|
+
lines.push('');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (hasOrphans) {
|
|
46
|
+
lines.push('### 👻 Orphaned selectors\n');
|
|
47
|
+
|
|
48
|
+
orphaned.forEach(({ name, line, rule }) => {
|
|
49
|
+
lines.push(`#### \`.${name}\` — line ${line}`);
|
|
50
|
+
lines.push('');
|
|
51
|
+
if (rule) {
|
|
52
|
+
lines.push('```css');
|
|
53
|
+
lines.push(rule);
|
|
54
|
+
lines.push('```');
|
|
55
|
+
}
|
|
56
|
+
lines.push('');
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
lines.push('---');
|
|
61
|
+
lines.push('');
|
|
62
|
+
sections.push(lines.join('\n'));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const unusedPct = totalDefined > 0
|
|
66
|
+
? Math.round((totalOrphaned / totalDefined) * 100)
|
|
67
|
+
: 0;
|
|
68
|
+
|
|
69
|
+
const summary = [
|
|
70
|
+
'# 👻 css-ghost — Orphaned Selector Report',
|
|
71
|
+
'',
|
|
72
|
+
`_Generated: ${new Date().toUTCString()}_`,
|
|
73
|
+
'',
|
|
74
|
+
'## Summary',
|
|
75
|
+
'',
|
|
76
|
+
`| Metric | Value |`,
|
|
77
|
+
`| --- | --- |`,
|
|
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}% |`,
|
|
83
|
+
'',
|
|
84
|
+
'---',
|
|
85
|
+
'',
|
|
86
|
+
].join('\n');
|
|
87
|
+
|
|
88
|
+
const content = summary + sections.join('');
|
|
89
|
+
writeFileSync(outputPath, content, 'utf-8');
|
|
90
|
+
|
|
91
|
+
// 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`);
|
|
94
|
+
|
|
95
|
+
for (const result of results) {
|
|
96
|
+
if (result.orphaned.length === 0) continue;
|
|
97
|
+
const relPath = relative(process.cwd(), result.filePath);
|
|
98
|
+
console.log(`📄 ${relPath}`);
|
|
99
|
+
result.orphaned.forEach(({ name, line, rule }) => {
|
|
100
|
+
console.log(` 👻 .${name} (line ${line})`);
|
|
101
|
+
if (rule) {
|
|
102
|
+
const preview = rule.split('\n').slice(0, 4).join('\n');
|
|
103
|
+
console.log(preview.split('\n').map(l => ` ${l}`).join('\n'));
|
|
104
|
+
}
|
|
105
|
+
console.log('');
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(`📁 ${results.length} style file${results.length !== 1 ? 's' : ''} scanned\n`);
|
|
110
|
+
}
|
package/src/scanner.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { extname } from 'path';
|
|
3
|
+
import { walkDir } from './walker.js';
|
|
4
|
+
import { parseStyleFile, parseComponentFile, isJsStyleFile } from './parser.js';
|
|
5
|
+
|
|
6
|
+
export function scan(dirs) {
|
|
7
|
+
const allStyleFiles = [];
|
|
8
|
+
const allJsCandidates = [];
|
|
9
|
+
|
|
10
|
+
for (const dir of dirs) {
|
|
11
|
+
const { styleFiles, componentFiles } = walkDir(dir);
|
|
12
|
+
allStyleFiles.push(...styleFiles);
|
|
13
|
+
allJsCandidates.push(...componentFiles);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Split JS/TS files into style files vs component files
|
|
17
|
+
const jsStyleFiles = [];
|
|
18
|
+
const componentFiles = [];
|
|
19
|
+
|
|
20
|
+
for (const file of allJsCandidates) {
|
|
21
|
+
const ext = extname(file);
|
|
22
|
+
if (['.js', '.ts'].includes(ext)) {
|
|
23
|
+
try {
|
|
24
|
+
const content = readFileSync(file, 'utf-8');
|
|
25
|
+
if (isJsStyleFile(content, file)) {
|
|
26
|
+
jsStyleFiles.push(file);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// unreadable — treat as component
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
componentFiles.push(file);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const styleFiles = [...allStyleFiles, ...jsStyleFiles];
|
|
37
|
+
|
|
38
|
+
// Build global usage map from all component files
|
|
39
|
+
const globalUsed = new Set();
|
|
40
|
+
const dynamicComponentFiles = [];
|
|
41
|
+
|
|
42
|
+
for (const file of componentFiles) {
|
|
43
|
+
try {
|
|
44
|
+
const { used, hasDynamic } = parseComponentFile(file);
|
|
45
|
+
for (const cls of used) globalUsed.add(cls);
|
|
46
|
+
if (hasDynamic) dynamicComponentFiles.push(file);
|
|
47
|
+
} catch {
|
|
48
|
+
// skip unreadable files
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Analyse each style file
|
|
53
|
+
const results = [];
|
|
54
|
+
|
|
55
|
+
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
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return results;
|
|
76
|
+
}
|
package/src/walker.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readdirSync } from 'fs';
|
|
2
|
+
import { join, extname, basename } from 'path';
|
|
3
|
+
|
|
4
|
+
const STYLE_EXTENSIONS = ['.css', '.scss', '.sass', '.less'];
|
|
5
|
+
const COMPONENT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
|
|
6
|
+
const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'coverage', '.cache']);
|
|
7
|
+
|
|
8
|
+
export function walkDir(dir) {
|
|
9
|
+
const styleFiles = [];
|
|
10
|
+
const componentFiles = [];
|
|
11
|
+
|
|
12
|
+
function walk(currentDir) {
|
|
13
|
+
let entries;
|
|
14
|
+
try {
|
|
15
|
+
entries = readdirSync(currentDir, { withFileTypes: true });
|
|
16
|
+
} catch {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
if (entry.name.startsWith('.') || IGNORE_DIRS.has(entry.name)) continue;
|
|
22
|
+
|
|
23
|
+
const fullPath = join(currentDir, entry.name);
|
|
24
|
+
|
|
25
|
+
if (entry.isDirectory()) {
|
|
26
|
+
walk(fullPath);
|
|
27
|
+
} else {
|
|
28
|
+
const name = basename(entry.name);
|
|
29
|
+
const ext = extname(entry.name);
|
|
30
|
+
|
|
31
|
+
if (STYLE_EXTENSIONS.some(se => name.endsWith(se))) {
|
|
32
|
+
styleFiles.push(fullPath);
|
|
33
|
+
} else if (COMPONENT_EXTENSIONS.has(ext)) {
|
|
34
|
+
componentFiles.push(fullPath);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
walk(dir);
|
|
41
|
+
return { styleFiles, componentFiles };
|
|
42
|
+
}
|