@rolandjlevy/css-ghost 1.0.0 → 1.0.2
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 +75 -24
- package/bin/css-ghost.js +0 -0
- package/package.json +22 -4
- package/src/parser.js +75 -14
- package/src/reporter.js +28 -7
- package/src/scanner.js +4 -1
- package/.devcontainer/devcontainer.json +0 -35
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`
|
|
@@ -22,13 +22,13 @@ Files containing `styles[variable]` or template literal class names are flagged
|
|
|
22
22
|
## Install
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
npm install -g css-ghost
|
|
25
|
+
npm install -g @rolandjlevy/css-ghost
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
Or run
|
|
28
|
+
Or run without installing:
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
|
-
npx css-ghost ./src
|
|
31
|
+
npx @rolandjlevy/css-ghost ./src
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
## Usage
|
|
@@ -47,44 +47,95 @@ css-ghost <directory> [directory2] ... [options]
|
|
|
47
47
|
## Examples
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
|
-
# Scan
|
|
50
|
+
# Scan your src folder
|
|
51
51
|
css-ghost ./src
|
|
52
52
|
|
|
53
53
|
# Scan multiple directories
|
|
54
54
|
css-ghost ./src ./components ./pages
|
|
55
55
|
|
|
56
|
-
#
|
|
56
|
+
# Write report to a custom path
|
|
57
57
|
css-ghost ./src --output ./reports/css-audit.md
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
##
|
|
60
|
+
## CLI output
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
Running `css-ghost ./src` prints a live summary to your terminal:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
✅ Report written to orphan-report.md
|
|
66
|
+
📊 3 orphaned selectors across 1 file
|
|
67
|
+
|
|
68
|
+
📄 src/components/Button/Button.module.css
|
|
69
|
+
👻 .iconLeft (line 4)
|
|
70
|
+
.iconLeft {
|
|
71
|
+
margin-right: 6px;
|
|
72
|
+
flex-shrink: 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
👻 .loadingSpinner (line 12)
|
|
76
|
+
.loadingSpinner {
|
|
77
|
+
animation: spin 0.8s linear infinite;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
👻 .disabled (line 18)
|
|
81
|
+
.disabled {
|
|
82
|
+
opacity: 0.4;
|
|
83
|
+
pointer-events: none;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
📁 1 style file scanned
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Each orphan shows:
|
|
90
|
+
- the selector name and the **exact line number** where it's defined
|
|
91
|
+
- the **full CSS rule** so you can see what it does before deciding to delete it
|
|
92
|
+
|
|
93
|
+
## Markdown report
|
|
94
|
+
|
|
95
|
+
A full report is also written to `orphan-report.md` (or your `--output` path):
|
|
63
96
|
|
|
64
97
|
---
|
|
65
98
|
|
|
66
|
-
|
|
99
|
+
### 👻 Orphaned selectors
|
|
67
100
|
|
|
68
|
-
|
|
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% |
|
|
101
|
+
#### `.iconLeft` — line 4
|
|
76
102
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
103
|
+
```css
|
|
104
|
+
.iconLeft {
|
|
105
|
+
margin-right: 6px;
|
|
106
|
+
flex-shrink: 0;
|
|
107
|
+
}
|
|
108
|
+
```
|
|
81
109
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
110
|
+
#### `.loadingSpinner` — line 12
|
|
111
|
+
|
|
112
|
+
```css
|
|
113
|
+
.loadingSpinner {
|
|
114
|
+
animation: spin 0.8s linear infinite;
|
|
115
|
+
}
|
|
116
|
+
```
|
|
85
117
|
|
|
86
118
|
---
|
|
87
119
|
|
|
120
|
+
## Using with npm scripts
|
|
121
|
+
|
|
122
|
+
If you're working inside the project itself, add a `scan` script to `package.json`:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
"scripts": {
|
|
126
|
+
"scan": "css-ghost ./src"
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Then run:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
npm run scan
|
|
134
|
+
|
|
135
|
+
# Pass extra args with --
|
|
136
|
+
npm run scan -- --output ./reports/audit.md
|
|
137
|
+
```
|
|
138
|
+
|
|
88
139
|
## No dependencies
|
|
89
140
|
|
|
90
141
|
Built with Node.js built-ins only (`fs`, `path`). No npm packages required.
|
package/bin/css-ghost.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,22 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rolandjlevy/css-ghost",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
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"
|
|
11
12
|
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"src",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
12
18
|
"keywords": [
|
|
13
19
|
"css",
|
|
14
20
|
"orphan",
|
|
15
21
|
"unused",
|
|
16
22
|
"selectors",
|
|
17
23
|
"cli",
|
|
18
|
-
"linter"
|
|
24
|
+
"linter",
|
|
25
|
+
"react",
|
|
26
|
+
"scss",
|
|
27
|
+
"stylesheet",
|
|
28
|
+
"audit"
|
|
19
29
|
],
|
|
20
30
|
"author": "Roland Levy",
|
|
21
|
-
"license": "MIT"
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://github.com/rolandjlevy/css-ghost.git"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/rolandjlevy/css-ghost#readme",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
}
|
|
22
40
|
}
|
package/src/parser.js
CHANGED
|
@@ -4,17 +4,68 @@ import { extname } from 'path';
|
|
|
4
4
|
// ─── CSS / SCSS selector extraction ────────────────────────────────────────
|
|
5
5
|
|
|
6
6
|
function extractCssSelectors(content) {
|
|
7
|
-
const selectors = new
|
|
7
|
+
const selectors = new Map(); // name → { line, rule }
|
|
8
|
+
const lines = content.split('\n');
|
|
8
9
|
|
|
9
|
-
// Strip comments
|
|
10
|
-
const stripped = content
|
|
11
|
-
.replace(
|
|
12
|
-
|
|
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
|
+
);
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
+
}
|
|
18
69
|
}
|
|
19
70
|
|
|
20
71
|
return selectors;
|
|
@@ -30,19 +81,29 @@ export function isJsStyleFile(content, filePath) {
|
|
|
30
81
|
}
|
|
31
82
|
|
|
32
83
|
function extractJsStyleKeys(content) {
|
|
33
|
-
const keys = new
|
|
84
|
+
const keys = new Map(); // name → { line, rule }
|
|
85
|
+
const lines = content.split('\n');
|
|
34
86
|
|
|
35
|
-
// StyleSheet.create({ key: ... })
|
|
36
87
|
const ssMatch = content.match(/StyleSheet\.create\s*\(\s*\{([\s\S]*?)\}\s*\)/);
|
|
37
|
-
// Plain object: const styles = { key: ... }
|
|
38
88
|
const plainMatch = content.match(/(?:const|let|var)\s+styles?\s*=\s*\{([\s\S]*)\}/);
|
|
39
|
-
|
|
40
89
|
const objectContent = ssMatch?.[1] ?? plainMatch?.[1];
|
|
90
|
+
|
|
41
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
|
+
|
|
42
97
|
const keyRegex = /^\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/gm;
|
|
43
98
|
let m;
|
|
44
99
|
while ((m = keyRegex.exec(objectContent)) !== null) {
|
|
45
|
-
|
|
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
|
+
}
|
|
46
107
|
}
|
|
47
108
|
}
|
|
48
109
|
|
package/src/reporter.js
CHANGED
|
@@ -43,10 +43,18 @@ export function generateReport(results, outputPath = 'orphan-report.md') {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
if (hasOrphans) {
|
|
46
|
-
lines.push('
|
|
47
|
-
|
|
48
|
-
orphaned.forEach(
|
|
49
|
-
|
|
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
|
+
});
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
lines.push('---');
|
|
@@ -80,7 +88,20 @@ export function generateReport(results, outputPath = 'orphan-report.md') {
|
|
|
80
88
|
const content = summary + sections.join('');
|
|
81
89
|
writeFileSync(outputPath, content, 'utf-8');
|
|
82
90
|
|
|
83
|
-
|
|
84
|
-
console.log(`📊 ${totalOrphaned} orphaned selector${totalOrphaned !== 1 ? 's' : ''}
|
|
85
|
-
|
|
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`);
|
|
93
|
+
|
|
94
|
+
for (const result of results) {
|
|
95
|
+
if (result.orphaned.length === 0) continue;
|
|
96
|
+
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})`);
|
|
100
|
+
if (rule) {
|
|
101
|
+
const preview = rule.split('\n').slice(0, 4).join('\n');
|
|
102
|
+
console.log(preview.split('\n').map(l => ` ${l}`).join('\n'));
|
|
103
|
+
}
|
|
104
|
+
console.log('');
|
|
105
|
+
});
|
|
106
|
+
}
|
|
86
107
|
}
|
package/src/scanner.js
CHANGED
|
@@ -55,7 +55,10 @@ export function scan(dirs) {
|
|
|
55
55
|
for (const styleFile of styleFiles) {
|
|
56
56
|
try {
|
|
57
57
|
const { selectors, hasDynamic } = parseStyleFile(styleFile);
|
|
58
|
-
|
|
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 }));
|
|
59
62
|
|
|
60
63
|
results.push({
|
|
61
64
|
filePath: styleFile,
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "css-ghost",
|
|
3
|
-
"image": "mcr.microsoft.com/devcontainers/javascript-node:20",
|
|
4
|
-
|
|
5
|
-
"customizations": {
|
|
6
|
-
"vscode": {
|
|
7
|
-
"extensions": [
|
|
8
|
-
"dbaeumer.vscode-eslint",
|
|
9
|
-
"esbenp.prettier-vscode",
|
|
10
|
-
"GitHub.github-vscode-theme",
|
|
11
|
-
"christian-kohler.path-intellisense",
|
|
12
|
-
"streetsidesoftware.code-spell-checker"
|
|
13
|
-
],
|
|
14
|
-
"settings": {
|
|
15
|
-
"workbench.colorTheme": "GitHub Dark Dimmed",
|
|
16
|
-
"workbench.iconTheme": "vs-seti",
|
|
17
|
-
"editor.fontSize": 14,
|
|
18
|
-
"editor.tabSize": 2,
|
|
19
|
-
"editor.formatOnSave": true,
|
|
20
|
-
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
21
|
-
"editor.minimap.enabled": false,
|
|
22
|
-
"editor.wordWrap": "on",
|
|
23
|
-
"terminal.integrated.defaultProfile.linux": "bash",
|
|
24
|
-
"files.exclude": {
|
|
25
|
-
"node_modules": true,
|
|
26
|
-
"orphan-report.md": true
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
},
|
|
31
|
-
|
|
32
|
-
"postCreateCommand": "echo '👻 css-ghost dev container ready. Run: node bin/css-ghost.js --help'",
|
|
33
|
-
|
|
34
|
-
"remoteUser": "node"
|
|
35
|
-
}
|