@rolandjlevy/css-ghost 1.1.2 → 1.2.0
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/CHANGELOG.md +50 -0
- package/README.md +48 -271
- package/package.json +2 -1
- package/src/deleter.js +66 -16
- package/src/parser.js +66 -4
- package/src/reporter.js +12 -1
- package/src/scanner.js +16 -3
- package/src/walker.js +1 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@rolandjlevy/css-ghost` are documented here.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## [1.2.0] — 2026-10-17
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **SASS indented syntax** (`src/parser.js`) — `.sass` files previously returned 0 selectors because the parser required `{` to open a rule block. A new `extractSassIndentedSelectors()` branch parses the indentation-based syntax correctly, detecting top-level class selectors and collecting their rule bodies from the indented lines that follow.
|
|
12
|
+
|
|
13
|
+
- **Vue and Svelte components not scanned** (`src/walker.js`) — `.vue` and `.svelte` files were excluded from the component walk, causing all selectors used only in Vue SFCs or Svelte components to appear as false positives. Both extensions are now included in `COMPONENT_EXTENSIONS`.
|
|
14
|
+
|
|
15
|
+
- **`hasDynamic` flag contaminating unrelated files** (`src/scanner.js`) — when any component anywhere in the scan used `styles[variable]` or a template literal, every style file in the entire scan was tagged `hasDynamic: true`. The flag and the `dynamicFiles` list are now scoped to style files that share a directory with the dynamic component.
|
|
16
|
+
|
|
17
|
+
- **Terminal output missing dynamic warning** (`src/reporter.js`) — the `⚠️ Dynamic class references detected` message appeared in the Markdown report but not in the terminal output. The warning now appears inline per file in the terminal, matching the Markdown behaviour.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Comprehensive CLI test fixtures under `test/` covering all documented use-cases: SCSS, LESS, SASS indented syntax, React Native `StyleSheet.create()`, dynamic class access, hyphenated selectors, `clsx` (strings/objects/arrays), Vue SFC `:class` bindings, and Svelte `class:name` directives.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## [1.1.3] — 2026-10-16
|
|
26
|
+
|
|
27
|
+
- Internal release; no public changelog entry.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## [1.1.0] — 2026-10-01
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- `--ghost-trail` flag: shows the last git commit that touched each orphaned selector
|
|
36
|
+
- `--ai` flag: appends an AI-generated summary via OpenRouter (run `--setup` once to save your key)
|
|
37
|
+
- CSS custom property orphan detection (`--my-token` defined but never consumed via `var(--my-token)`)
|
|
38
|
+
- `--install-hook` flag: writes a pre-commit hook that blocks commits when orphans are present
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## [1.0.0] — 2026-09-01
|
|
43
|
+
|
|
44
|
+
### Initial release
|
|
45
|
+
|
|
46
|
+
- CSS selector orphan detection across `.css`, `.module.css`, `.scss`, `.sass`, `.less`, and JS style files (`StyleSheet.create`, `makeStyles`, vanilla-extract)
|
|
47
|
+
- `--delete` / `--delete --yes` to remove orphaned selectors from source files
|
|
48
|
+
- `--strict` flag for CI (exit code 1 when any orphans found)
|
|
49
|
+
- `--output <file>` for custom Markdown report paths
|
|
50
|
+
- Interactive folder picker when run with no arguments
|
package/README.md
CHANGED
|
@@ -6,26 +6,29 @@ Find and remove unused CSS selectors and orphaned CSS custom properties — sele
|
|
|
6
6
|
|
|
7
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
|
-
**Supported style files:**
|
|
9
|
+
**Supported style files:** `.css`, `.module.css`, `.scss`, `.sass`, `.less`, `.js`/`.ts` using `StyleSheet.create({})` / `makeStyles({})` (MUI), `.css.ts`/`.css.js` using `style()` or `styleVariants()` (vanilla-extract)
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
- `.js` / `.ts` files using `StyleSheet.create({})`, `makeStyles({})` (MUI), or named style objects
|
|
13
|
-
- `.css.ts` / `.css.js` files using `style()` or `styleVariants()` (vanilla-extract)
|
|
11
|
+
**Detected usage patterns:** `className="foo"`, `className={styles.foo}`, `styles['foo']`, ternary patterns, `cx()` / `clsx()` / `classnames()` (strings, objects and arrays), `classes.foo` (MUI), vanilla-extract named imports, Vue `:class` bindings, Svelte `class:name` directives
|
|
14
12
|
|
|
15
|
-
**
|
|
13
|
+
**Dynamic class detection:** Patterns where the class name can't be resolved statically — `styles[variable]` or template literals with `${}` — are flagged with a ⚠️ warning per file in both the terminal output and the Markdown report.
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
- `className={styles.foo}`
|
|
19
|
-
- `styles['foo']`
|
|
20
|
-
- `styles[condition ? 'foo' : 'bar']` — both branches extracted
|
|
21
|
-
- `cx('foo')`, `clsx('foo')`, `classnames('foo')`
|
|
22
|
-
- `cx({ active: isOn, disabled: true })` — object keys extracted
|
|
23
|
-
- `cx(['foo', condition && 'bar'])` — array string literals extracted
|
|
24
|
-
- `classes.foo` — MUI `makeStyles` / `useStyles` convention
|
|
25
|
-
- `import { foo } from './styles.css'` — vanilla-extract named imports
|
|
15
|
+
## Example output
|
|
26
16
|
|
|
27
|
-
|
|
28
|
-
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
When a component uses dynamic class access (`styles[variant]`), the file is flagged rather than reporting false positives:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
✅ src/components/alert.module.css
|
|
23
|
+
|
|
24
|
+
⚠️ Dynamic class references detected — results may be incomplete.
|
|
25
|
+
Dynamic references in: src/components/Alert.jsx
|
|
26
|
+
|
|
27
|
+
👻 .hidden (line 30)
|
|
28
|
+
.hidden { display: none; }
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
29
32
|
|
|
30
33
|
## How is this different from PurgeCSS?
|
|
31
34
|
|
|
@@ -37,16 +40,16 @@ css-ghost is a **source code auditor** — it finds dead selectors in your actua
|
|
|
37
40
|
npx @rolandjlevy/css-ghost ./src
|
|
38
41
|
```
|
|
39
42
|
|
|
40
|
-
|
|
|
41
|
-
|
|
42
|
-
| Works on source files
|
|
43
|
-
| Zero config, no build step
|
|
44
|
-
| CSS custom property orphans | ❌
|
|
45
|
-
| Git history per orphan
|
|
46
|
-
| Pre-commit hook
|
|
47
|
-
| Deletes dead code for you
|
|
48
|
-
| MUI / vanilla-extract aware | ❌
|
|
49
|
-
| Safe with dynamic classes
|
|
43
|
+
| | PurgeCSS | css-ghost |
|
|
44
|
+
| --------------------------- | ----------------------------- | ---------------- |
|
|
45
|
+
| Works on source files | ❌ Build output only | ✅ |
|
|
46
|
+
| Zero config, no build step | ❌ Requires webpack / PostCSS | ✅ |
|
|
47
|
+
| CSS custom property orphans | ❌ | ✅ |
|
|
48
|
+
| Git history per orphan | ❌ | ✅ |
|
|
49
|
+
| Pre-commit hook | ❌ | ✅ |
|
|
50
|
+
| Deletes dead code for you | ❌ | ✅ |
|
|
51
|
+
| MUI / vanilla-extract aware | ❌ | ✅ |
|
|
52
|
+
| Safe with dynamic classes | ❌ Silently removes them | ✅ Flags with ⚠️ |
|
|
50
53
|
|
|
51
54
|
The two tools are complementary: PurgeCSS for trimming framework output at build time; css-ghost for auditing your own hand-written CSS throughout development.
|
|
52
55
|
|
|
@@ -54,19 +57,14 @@ The two tools are complementary: PurgeCSS for trimming framework output at build
|
|
|
54
57
|
|
|
55
58
|
## To enable AI features
|
|
56
59
|
|
|
57
|
-
|
|
60
|
+
Run the setup wizard once to save your free [OpenRouter](https://openrouter.ai) API key ([openrouter.ai/keys](https://openrouter.ai/keys)), then pass `--ai` to any scan:
|
|
58
61
|
|
|
59
62
|
```bash
|
|
60
63
|
css-ghost --setup
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
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:
|
|
64
|
-
|
|
65
|
-
```bash
|
|
66
64
|
css-ghost ./src --ai
|
|
67
65
|
```
|
|
68
66
|
|
|
69
|
-
The AI summary appears in both the terminal output and the markdown report. The core scan always runs without a key
|
|
67
|
+
The AI summary appears in both the terminal output and the markdown report. The core scan always runs without a key.
|
|
70
68
|
|
|
71
69
|
## Install
|
|
72
70
|
|
|
@@ -102,44 +100,18 @@ css-ghost [directory] [directory2] ... [options]
|
|
|
102
100
|
|
|
103
101
|
## CSS variable orphan detection
|
|
104
102
|
|
|
105
|
-
css-ghost also catches **orphaned CSS custom properties** — variables defined with `--my-token: value` but never consumed
|
|
106
|
-
|
|
107
|
-
Usages are detected across all file types: CSS, SCSS, JS, TS, JSX, TSX, Vue, Svelte, HTML. A variable defined in `variables.css` and used in a React component is correctly seen as _used_.
|
|
108
|
-
|
|
109
|
-
```
|
|
110
|
-
🔮 --colour-danger-hover (line 8)
|
|
111
|
-
--colour-danger-hover: #dc2626;
|
|
112
|
-
|
|
113
|
-
🔮 --spacing-xl (line 26)
|
|
114
|
-
--spacing-xl: 64px;
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
Orphaned variables appear under a separate **🔮 Orphaned CSS variables** section in both the terminal output and the markdown report.
|
|
118
|
-
|
|
119
|
-
---
|
|
103
|
+
css-ghost also catches **orphaned CSS custom properties** — variables defined with `--my-token: value` but never consumed via `var(--my-token)` anywhere in the codebase. Usages are detected across all file types: CSS, SCSS, JS, TS, JSX, TSX, Vue, Svelte, HTML. Orphaned variables appear under a **🔮 Orphaned CSS variables** section in both the terminal output and the markdown report.
|
|
120
104
|
|
|
121
105
|
## Ghost trail — git history for orphans
|
|
122
106
|
|
|
123
|
-
Pass `--ghost-trail` to fetch the last git commit that changed each orphan's occurrence count
|
|
107
|
+
Pass `--ghost-trail` to fetch the last git commit that changed each orphan's occurrence count:
|
|
124
108
|
|
|
125
109
|
```bash
|
|
126
110
|
css-ghost ./src --ghost-trail
|
|
127
111
|
```
|
|
128
112
|
|
|
129
|
-
```
|
|
130
|
-
👻 .iconRight (line 28)
|
|
131
|
-
🕵️ b5c6efb · Roland Levy · 3 weeks ago — "Refactor Button component"
|
|
132
|
-
.iconRight {
|
|
133
|
-
margin-left: 6px;
|
|
134
|
-
}
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
For CSS variables the search looks for `var(--name` across all file types.
|
|
138
|
-
|
|
139
113
|
> **Note:** ghost trail runs one `git log -S` per orphan in parallel. On large histories with many orphans it may take a few seconds.
|
|
140
114
|
|
|
141
|
-
---
|
|
142
|
-
|
|
143
115
|
## Pre-commit hook
|
|
144
116
|
|
|
145
117
|
Install a hook that blocks commits when orphaned selectors are present:
|
|
@@ -148,255 +120,60 @@ Install a hook that blocks commits when orphaned selectors are present:
|
|
|
148
120
|
css-ghost --install-hook ./src
|
|
149
121
|
```
|
|
150
122
|
|
|
151
|
-
This writes `.git/hooks/pre-commit` (executable, `chmod 755`)
|
|
152
|
-
|
|
153
|
-
```sh
|
|
154
|
-
#!/bin/sh
|
|
155
|
-
echo "👻 css-ghost — checking for orphaned CSS selectors..."
|
|
156
|
-
npx --yes @rolandjlevy/css-ghost ./src --strict --output /tmp/css-ghost-hook-report.md
|
|
157
|
-
|
|
158
|
-
if [ $? -ne 0 ]; then
|
|
159
|
-
echo "❌ Commit blocked: orphaned CSS selectors found"
|
|
160
|
-
echo " Run 'css-ghost ./src --delete' to remove them"
|
|
161
|
-
echo " Or skip: git commit --no-verify"
|
|
162
|
-
exit 1
|
|
163
|
-
fi
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
Pass multiple directories to the hook installer the same way you would to a normal scan:
|
|
167
|
-
|
|
168
|
-
```bash
|
|
169
|
-
css-ghost --install-hook ./src ./components ./pages
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
To uninstall: `rm .git/hooks/pre-commit`
|
|
123
|
+
This writes `.git/hooks/pre-commit` (executable, `chmod 755`). Pass multiple directories the same way you would to a normal scan. To uninstall: `rm .git/hooks/pre-commit`
|
|
173
124
|
|
|
174
125
|
### `--strict` flag
|
|
175
126
|
|
|
176
|
-
|
|
127
|
+
Exit with code 1 when any orphans are found — the mechanism the hook depends on, also useful in CI:
|
|
177
128
|
|
|
178
129
|
```bash
|
|
179
|
-
# In CI: fail the build if orphans exist
|
|
180
130
|
css-ghost ./src --strict
|
|
181
131
|
```
|
|
182
132
|
|
|
183
|
-
---
|
|
184
|
-
|
|
185
133
|
## Interactive folder picker
|
|
186
134
|
|
|
187
|
-
Run `css-ghost` with no arguments
|
|
188
|
-
|
|
189
|
-
```
|
|
190
|
-
📂 Directory picker — navigate into a folder, then press [a] to add it
|
|
191
|
-
|
|
192
|
-
📍 /home/user/my-project
|
|
193
|
-
|
|
194
|
-
📁 components
|
|
195
|
-
▶ 📁 src
|
|
196
|
-
📁 styles
|
|
197
|
-
|
|
198
|
-
✅ Added to scan:
|
|
199
|
-
/home/user/my-project/src
|
|
200
|
-
|
|
201
|
-
[↑↓/jk] navigate [↵/→] enter [←] parent [a] add current dir [d] done [^C] quit
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
| Key | Action |
|
|
205
|
-
| ---------------------- | --------------------------------------- |
|
|
206
|
-
| `↑` / `↓` or `k` / `j` | Move up and down the folder list |
|
|
207
|
-
| `↵` or `→` | Enter the highlighted folder |
|
|
208
|
-
| `←` or Backspace | Go up to the parent folder |
|
|
209
|
-
| `a` | Add the current folder to the scan list |
|
|
210
|
-
| `d` | Done — start the scan |
|
|
211
|
-
| `Ctrl+C` | Quit without scanning |
|
|
212
|
-
|
|
213
|
-
You can add **multiple folders** before pressing `d` — just navigate to each one and press `a`. If you press `d` without adding anything, the folder you're currently browsing is used.
|
|
135
|
+
Run `css-ghost` with no arguments to open an arrow-key picker. Navigate with `↑↓`, enter with `↵`, press `a` to add the current folder to the scan list, `d` to start the scan. Add multiple folders before pressing `d`.
|
|
214
136
|
|
|
215
137
|
## Deleting orphans
|
|
216
138
|
|
|
217
|
-
Pass `--delete` after a scan to remove every flagged selector directly from its source file:
|
|
218
|
-
|
|
219
139
|
```bash
|
|
220
|
-
css-ghost ./src --delete
|
|
140
|
+
css-ghost ./src --delete # prints report, then asks for confirmation
|
|
141
|
+
css-ghost ./src --delete --yes # skip the prompt (CI / scripts)
|
|
221
142
|
```
|
|
222
143
|
|
|
223
|
-
css-ghost prints the full report first, then lists what will be deleted and asks for confirmation before touching anything. Pass `--yes` to skip the prompt (useful in CI or scripts):
|
|
224
|
-
|
|
225
|
-
```bash
|
|
226
|
-
css-ghost ./src --delete --yes
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
Works across all supported file types — `.css`, `.scss`, `.less`, MUI `makeStyles`, and vanilla-extract exports.
|
|
230
|
-
|
|
231
144
|
> **Tip:** commit or stash your work before running `--delete`. There is no undo.
|
|
232
145
|
|
|
233
146
|
## Examples
|
|
234
147
|
|
|
235
148
|
```bash
|
|
236
|
-
#
|
|
237
|
-
css-ghost
|
|
238
|
-
|
|
239
|
-
#
|
|
240
|
-
css-ghost ./src
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
css-ghost ./src
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
css-ghost ./src --strict
|
|
247
|
-
|
|
248
|
-
# Install a pre-commit hook that scans ./src
|
|
249
|
-
css-ghost --install-hook ./src
|
|
250
|
-
|
|
251
|
-
# Scan and delete all orphans (with confirmation prompt)
|
|
252
|
-
css-ghost ./src --delete
|
|
253
|
-
|
|
254
|
-
# Scan and delete without prompting
|
|
255
|
-
css-ghost ./src --delete --yes
|
|
256
|
-
|
|
257
|
-
# Scan multiple directories
|
|
258
|
-
css-ghost ./src ./components ./pages
|
|
259
|
-
|
|
260
|
-
# With AI summary
|
|
261
|
-
css-ghost ./src --ai
|
|
262
|
-
|
|
263
|
-
# Write report to a custom path
|
|
264
|
-
css-ghost ./src --output ./reports/css-audit.md
|
|
149
|
+
css-ghost # interactive folder picker
|
|
150
|
+
css-ghost ./src # scan a folder
|
|
151
|
+
css-ghost ./src --ghost-trail # show git history per orphan
|
|
152
|
+
css-ghost ./src --strict # CI: exit 1 on any orphan
|
|
153
|
+
css-ghost --install-hook ./src # install pre-commit hook
|
|
154
|
+
css-ghost ./src --delete # remove orphans (with prompt)
|
|
155
|
+
css-ghost ./src --delete --yes # remove without prompting
|
|
156
|
+
css-ghost ./src ./components ./pages # scan multiple directories
|
|
157
|
+
css-ghost ./src --ai # with AI summary
|
|
158
|
+
css-ghost ./src --output ./reports/audit.md
|
|
265
159
|
```
|
|
266
160
|
|
|
267
|
-
## CLI output
|
|
268
|
-
|
|
269
|
-
Running `css-ghost ./src` prints a live summary to your terminal:
|
|
270
|
-
|
|
271
|
-
```
|
|
272
|
-
✅ Report written to orphan-report.md
|
|
273
|
-
📊 3 orphaned selectors across 1 file
|
|
274
|
-
|
|
275
|
-
📄 src/components/Button/Button.module.css
|
|
276
|
-
👻 .iconLeft (line 4)
|
|
277
|
-
.iconLeft {
|
|
278
|
-
margin-right: 6px;
|
|
279
|
-
flex-shrink: 0;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
👻 .loadingSpinner (line 12)
|
|
283
|
-
.loadingSpinner {
|
|
284
|
-
animation: spin 0.8s linear infinite;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
👻 .disabled (line 18)
|
|
288
|
-
.disabled {
|
|
289
|
-
opacity: 0.4;
|
|
290
|
-
pointer-events: none;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
📁 1 style file scanned
|
|
294
|
-
💾 Potential savings: 3 selectors removed
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
Each orphan shows:
|
|
298
|
-
|
|
299
|
-
- the selector name and the **exact line number** where it's defined
|
|
300
|
-
- the **full CSS rule** so you can see what it does before deciding to delete it
|
|
301
|
-
|
|
302
|
-
## Markdown report
|
|
303
|
-
|
|
304
|
-
A full report is also written to `orphan-report.md` (or your `--output` path):
|
|
305
|
-
|
|
306
|
-
---
|
|
307
|
-
|
|
308
|
-
### 👻 Orphaned selectors
|
|
309
|
-
|
|
310
|
-
#### `.iconLeft` — line 4
|
|
311
|
-
|
|
312
|
-
```css
|
|
313
|
-
.iconLeft {
|
|
314
|
-
margin-right: 6px;
|
|
315
|
-
flex-shrink: 0;
|
|
316
|
-
}
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
#### `.loadingSpinner` — line 12
|
|
320
|
-
|
|
321
|
-
```css
|
|
322
|
-
.loadingSpinner {
|
|
323
|
-
animation: spin 0.8s linear infinite;
|
|
324
|
-
}
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
---
|
|
328
|
-
|
|
329
161
|
## Using with npm scripts
|
|
330
162
|
|
|
331
|
-
If you're working inside the project itself, add a `scan` script to `package.json`:
|
|
332
|
-
|
|
333
163
|
```json
|
|
334
164
|
"scripts": {
|
|
335
165
|
"scan": "css-ghost ./src"
|
|
336
166
|
}
|
|
337
167
|
```
|
|
338
168
|
|
|
339
|
-
Then run:
|
|
340
|
-
|
|
341
169
|
```bash
|
|
342
|
-
npm run scan
|
|
343
|
-
|
|
344
|
-
# Pass extra args with --
|
|
345
170
|
npm run scan -- --output ./reports/audit.md
|
|
346
171
|
```
|
|
347
172
|
|
|
348
|
-
## Troubleshooting
|
|
349
|
-
|
|
350
|
-
### `css-ghost: command not found` after global install
|
|
351
|
-
|
|
352
|
-
Your npm global bin directory may not be on your PATH. Run:
|
|
353
|
-
|
|
354
|
-
```bash
|
|
355
|
-
npm bin -g
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
Then add that path to your shell config (`~/.zshrc`, `~/.bashrc`, etc.):
|
|
359
|
-
|
|
360
|
-
```bash
|
|
361
|
-
export PATH="$(npm bin -g):$PATH"
|
|
362
|
-
```
|
|
363
|
-
|
|
364
|
-
Reload with `source ~/.zshrc`. This is a one-time fix — it won't be needed again after that.
|
|
365
|
-
|
|
366
|
-
> This is common when Node was installed via the official installer rather than nvm or Homebrew.
|
|
367
|
-
|
|
368
173
|
## Dependencies
|
|
369
174
|
|
|
370
175
|
One runtime dependency: [`picocolors`](https://github.com/alexeyraspopov/picocolors) for terminal colours. Everything else uses Node.js built-ins (`fs`, `path`, `readline`).
|
|
371
176
|
|
|
372
|
-
## Origin — built with OpenRouter free models via Claude Code
|
|
373
|
-
|
|
374
|
-
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.
|
|
375
|
-
|
|
376
|
-
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.
|
|
377
|
-
|
|
378
|
-
If you've already run `css-ghost --setup`, you have an OpenRouter key. You can also export it as an environment variable — css-ghost picks it up automatically, so you don't even need `--setup`:
|
|
379
|
-
|
|
380
|
-
```bash
|
|
381
|
-
export OPENROUTER_API_KEY="sk-or-v1-YOUR_KEY"
|
|
382
|
-
```
|
|
383
|
-
|
|
384
|
-
And you can reuse the same key with Claude Code by adding these aliases to your `~/.zshrc`:
|
|
385
|
-
|
|
386
|
-
```bash
|
|
387
|
-
alias claude-free='ANTHROPIC_BASE_URL="https://openrouter.ai/api/v1" \
|
|
388
|
-
ANTHROPIC_API_KEY="$OPENROUTER_API_KEY" \
|
|
389
|
-
claude --model "nvidia/nemotron-3.5-lightning:free"'
|
|
390
|
-
|
|
391
|
-
alias claude-r1='ANTHROPIC_BASE_URL="https://openrouter.ai/api/v1" \
|
|
392
|
-
ANTHROPIC_API_KEY="$OPENROUTER_API_KEY" \
|
|
393
|
-
claude --model "qwen/qwen3.8-27b:free"'
|
|
394
|
-
```
|
|
395
|
-
|
|
396
|
-
Then `source ~/.zshrc`. Use `claude-free` or `claude-r1` in place of `claude` to start a free session.
|
|
397
|
-
|
|
398
|
-
> **Note:** Free model availability changes frequently. Browse current options at [openrouter.ai/models](https://openrouter.ai/models?q=free) — filter by `:free`.
|
|
399
|
-
|
|
400
177
|
## License
|
|
401
178
|
|
|
402
179
|
[MIT](./LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rolandjlevy/css-ghost",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Find and remove unused CSS selectors and orphaned CSS custom properties — a CLI linter and auditor for React, Vue, Svelte, SCSS, and CSS Modules",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"bin",
|
|
19
19
|
"src",
|
|
20
20
|
"README.md",
|
|
21
|
+
"CHANGELOG.md",
|
|
21
22
|
"LICENSE"
|
|
22
23
|
],
|
|
23
24
|
"keywords": [
|
package/src/deleter.js
CHANGED
|
@@ -36,10 +36,42 @@ function findBlockEnd(lines, startIdx) {
|
|
|
36
36
|
|
|
37
37
|
// ─── CSS / SCSS / LESS ───────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
// Return the start index of a comment block (/* ... */) that sits immediately
|
|
40
|
+
// before `idx` in `content` (separated only by whitespace/newlines), or -1.
|
|
41
|
+
function findPrecedingComment(content, idx) {
|
|
42
|
+
// Walk backwards over whitespace
|
|
43
|
+
let i = idx - 1;
|
|
44
|
+
while (i >= 0 && /[\s]/.test(content[i])) i--;
|
|
45
|
+
|
|
46
|
+
if (i < 1 || content[i] !== '/' || content[i - 1] !== '*') return -1;
|
|
47
|
+
|
|
48
|
+
// Found the end of a block comment — now find its start
|
|
49
|
+
const closePos = i - 1; // position of '*/' second char
|
|
50
|
+
const openPos = content.lastIndexOf('/*', closePos);
|
|
51
|
+
return openPos === -1 ? -1 : openPos;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Delete individual CSS declarations (e.g. ` --token: value;\n`) from content.
|
|
55
|
+
function deleteCssDeclarations(content, varNames) {
|
|
56
|
+
let removed = 0;
|
|
57
|
+
for (const name of varNames) {
|
|
58
|
+
// Match ` --name: anything up to semicolon;\n?` including leading whitespace
|
|
59
|
+
const re = new RegExp(`[ \\t]*${escapeRegex(name)}\\s*:[^;]*;[ \\t]*\\n?`, 'g');
|
|
60
|
+
const next = content.replace(re, () => { removed++; return ''; });
|
|
61
|
+
if (next !== content) content = next;
|
|
62
|
+
}
|
|
63
|
+
return { content, removed };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function escapeRegex(str) {
|
|
67
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function deleteFromCssFile(filePath, orphans, orphanedVarNames = []) {
|
|
40
71
|
let content = readFileSync(filePath, 'utf-8');
|
|
41
72
|
let removed = 0;
|
|
42
73
|
|
|
74
|
+
// ── 1. Delete orphaned class/selector rules ────────────────────────────────
|
|
43
75
|
// Sort longest rules first so that nested/contained rules don't mis-match.
|
|
44
76
|
const sorted = [...orphans].sort((a, b) => (b.rule?.length ?? 0) - (a.rule?.length ?? 0));
|
|
45
77
|
|
|
@@ -48,13 +80,24 @@ function deleteFromCssFile(filePath, orphans) {
|
|
|
48
80
|
const idx = content.indexOf(rule);
|
|
49
81
|
if (idx === -1) continue;
|
|
50
82
|
|
|
51
|
-
|
|
83
|
+
// Bug fix: also remove a comment block immediately preceding the rule.
|
|
84
|
+
const commentStart = findPrecedingComment(content, idx);
|
|
85
|
+
const deleteFrom = commentStart !== -1 ? commentStart : idx;
|
|
86
|
+
|
|
87
|
+
const before = content.slice(0, deleteFrom);
|
|
52
88
|
const after = content.slice(idx + rule.length);
|
|
53
89
|
// Eat one trailing newline so we don't leave double-blank-lines everywhere.
|
|
54
90
|
content = before + after.replace(/^\n/, '');
|
|
55
91
|
removed++;
|
|
56
92
|
}
|
|
57
93
|
|
|
94
|
+
// ── 2. Delete orphaned CSS variable declarations ───────────────────────────
|
|
95
|
+
if (orphanedVarNames.length > 0) {
|
|
96
|
+
const { content: next, removed: varRemoved } = deleteCssDeclarations(content, orphanedVarNames);
|
|
97
|
+
content = next;
|
|
98
|
+
removed += varRemoved;
|
|
99
|
+
}
|
|
100
|
+
|
|
58
101
|
writeFileSync(filePath, normalise(content), 'utf-8');
|
|
59
102
|
return removed;
|
|
60
103
|
}
|
|
@@ -110,23 +153,27 @@ function deleteFromVeFile(filePath, orphans) {
|
|
|
110
153
|
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
111
154
|
|
|
112
155
|
export async function deleteOrphans(results, { yes = false } = {}) {
|
|
113
|
-
|
|
156
|
+
// A file is eligible for deletion if it has orphaned selectors OR orphaned vars.
|
|
157
|
+
const toDelete = results.filter(r => r.orphaned.length > 0 || (r.orphanedVars?.length ?? 0) > 0);
|
|
114
158
|
|
|
115
159
|
if (toDelete.length === 0) {
|
|
116
160
|
console.log('Nothing to delete.\n');
|
|
117
161
|
return 0;
|
|
118
162
|
}
|
|
119
163
|
|
|
120
|
-
const totalCount = toDelete.reduce((n, r) => n + r.orphaned.length, 0);
|
|
121
|
-
|
|
122
164
|
console.log(
|
|
123
|
-
`\n${pc.bold('🗑️ The following orphaned selectors will be permanently deleted:')}\n`
|
|
165
|
+
`\n${pc.bold('🗑️ The following orphaned selectors and CSS variables will be permanently deleted:')}\n`
|
|
124
166
|
);
|
|
125
167
|
|
|
126
|
-
for (const { filePath, orphaned } of toDelete) {
|
|
168
|
+
for (const { filePath, orphaned, orphanedVars } of toDelete) {
|
|
127
169
|
const rel = relative(process.cwd(), filePath);
|
|
128
170
|
console.log(` ${pc.bold(rel)}`);
|
|
129
|
-
orphaned.forEach(o =>
|
|
171
|
+
orphaned.forEach(o =>
|
|
172
|
+
console.log(` ${pc.yellow('•')} .${o.name} ${pc.dim(`(line ${o.line})`)}`)
|
|
173
|
+
);
|
|
174
|
+
(orphanedVars ?? []).forEach(o =>
|
|
175
|
+
console.log(` ${pc.magenta('•')} ${o.name} ${pc.dim(`(line ${o.line})`)}`)
|
|
176
|
+
);
|
|
130
177
|
}
|
|
131
178
|
|
|
132
179
|
if (!yes) {
|
|
@@ -142,25 +189,27 @@ export async function deleteOrphans(results, { yes = false } = {}) {
|
|
|
142
189
|
console.log('');
|
|
143
190
|
let totalRemoved = 0;
|
|
144
191
|
|
|
145
|
-
for (const { filePath, orphaned } of toDelete) {
|
|
146
|
-
const ext
|
|
147
|
-
const rel
|
|
192
|
+
for (const { filePath, orphaned, orphanedVars } of toDelete) {
|
|
193
|
+
const ext = extname(filePath);
|
|
194
|
+
const rel = relative(process.cwd(), filePath);
|
|
195
|
+
const varNames = (orphanedVars ?? []).map(v => v.name);
|
|
196
|
+
const totalExpected = orphaned.length + varNames.length;
|
|
148
197
|
let removed;
|
|
149
198
|
|
|
150
199
|
try {
|
|
151
200
|
if (['.css', '.scss', '.sass', '.less'].includes(ext)) {
|
|
152
|
-
removed = deleteFromCssFile(filePath, orphaned);
|
|
201
|
+
removed = deleteFromCssFile(filePath, orphaned, varNames);
|
|
153
202
|
} else if (/\.css\.(ts|js)x?$/.test(filePath)) {
|
|
154
203
|
removed = deleteFromVeFile(filePath, orphaned);
|
|
155
204
|
} else {
|
|
156
205
|
removed = deleteFromJsFile(filePath, orphaned);
|
|
157
206
|
}
|
|
158
207
|
|
|
159
|
-
const label = removed ===
|
|
208
|
+
const label = removed === totalExpected
|
|
160
209
|
? pc.green(`✂️ ${rel}`)
|
|
161
210
|
: pc.yellow(`✂️ ${rel}`);
|
|
162
|
-
const note = removed <
|
|
163
|
-
? pc.dim(` (${
|
|
211
|
+
const note = removed < totalExpected
|
|
212
|
+
? pc.dim(` (${totalExpected - removed} could not be located — check manually)`)
|
|
164
213
|
: '';
|
|
165
214
|
console.log(`${label} — removed ${removed}${note}`);
|
|
166
215
|
totalRemoved += removed;
|
|
@@ -169,8 +218,9 @@ export async function deleteOrphans(results, { yes = false } = {}) {
|
|
|
169
218
|
}
|
|
170
219
|
}
|
|
171
220
|
|
|
221
|
+
const noun = totalRemoved === 1 ? 'item' : 'items';
|
|
172
222
|
console.log(
|
|
173
|
-
`\n${pc.green(pc.bold(`✅ Done.`))} Removed ${totalRemoved} orphaned
|
|
223
|
+
`\n${pc.green(pc.bold(`✅ Done.`))} Removed ${totalRemoved} orphaned ${noun}.\n`
|
|
174
224
|
);
|
|
175
225
|
|
|
176
226
|
return totalRemoved;
|
package/src/parser.js
CHANGED
|
@@ -53,6 +53,58 @@ export function extractVarUsages(content) {
|
|
|
53
53
|
|
|
54
54
|
// ─── CSS / SCSS selector extraction ────────────────────────────────────────
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Parse SASS indented syntax (.sass) — no braces, indentation defines blocks.
|
|
58
|
+
* Class selectors appear at the start of a line (after optional whitespace),
|
|
59
|
+
* followed by indented property lines.
|
|
60
|
+
*/
|
|
61
|
+
function extractSassIndentedSelectors(content) {
|
|
62
|
+
const selectors = new Map();
|
|
63
|
+
const lines = content.split('\n');
|
|
64
|
+
|
|
65
|
+
// Strip // single-line comments (SASS uses // as well as /* */)
|
|
66
|
+
const stripped = content
|
|
67
|
+
.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' '))
|
|
68
|
+
.split('\n')
|
|
69
|
+
.map(l => l.replace(/\/\/.*$/, ''));
|
|
70
|
+
|
|
71
|
+
let i = 0;
|
|
72
|
+
while (i < stripped.length) {
|
|
73
|
+
const raw = stripped[i];
|
|
74
|
+
const trimmed = raw.trimStart();
|
|
75
|
+
const indent = raw.length - trimmed.length;
|
|
76
|
+
|
|
77
|
+
// A class selector: zero or shallow indent, starts with "."
|
|
78
|
+
if (indent === 0 && trimmed.startsWith('.')) {
|
|
79
|
+
const m = trimmed.match(/^\.([a-zA-Z_-][a-zA-Z0-9_-]*)/);
|
|
80
|
+
if (m) {
|
|
81
|
+
const name = m[1];
|
|
82
|
+
const lineNum = i + 1;
|
|
83
|
+
// Collect the rule block: this line + indented lines that follow
|
|
84
|
+
const ruleLines = [lines[i]];
|
|
85
|
+
let j = i + 1;
|
|
86
|
+
while (j < lines.length) {
|
|
87
|
+
const nextRaw = stripped[j];
|
|
88
|
+
if (nextRaw.trim() === '') { j++; continue; }
|
|
89
|
+
const nextIndent = nextRaw.length - nextRaw.trimStart().length;
|
|
90
|
+
if (nextIndent > indent) {
|
|
91
|
+
ruleLines.push(lines[j]);
|
|
92
|
+
j++;
|
|
93
|
+
} else {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!selectors.has(name)) {
|
|
98
|
+
selectors.set(name, { line: lineNum, rule: ruleLines.join('\n').trim() });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
i++;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return selectors;
|
|
106
|
+
}
|
|
107
|
+
|
|
56
108
|
function extractCssSelectors(content) {
|
|
57
109
|
const selectors = new Map(); // name → { line, rule }
|
|
58
110
|
const lines = content.split('\n');
|
|
@@ -259,14 +311,14 @@ export function extractUsages(content) {
|
|
|
259
311
|
used.add(m[1]);
|
|
260
312
|
}
|
|
261
313
|
|
|
262
|
-
// styles['foo'] or styles["foo"]
|
|
263
|
-
const bracketStringRegex = /styles?\[['"]([a-zA-Z_$][a-zA-Z0-9_
|
|
314
|
+
// styles['foo'] or styles["foo"] — includes hyphenated names (e.g. styles['btn-primary'])
|
|
315
|
+
const bracketStringRegex = /styles?\[['"]([a-zA-Z_$][a-zA-Z0-9_$-]*)['"]]/g;
|
|
264
316
|
while ((m = bracketStringRegex.exec(content)) !== null) {
|
|
265
317
|
used.add(m[1]);
|
|
266
318
|
}
|
|
267
319
|
|
|
268
320
|
// styles[condition ? 'foo' : 'bar'] — ternary with string literals: extract both branches
|
|
269
|
-
const ternaryBracketRegex = /styles?\[\s*[^?]+\?\s*['"]([a-zA-Z_$][a-zA-Z0-9_
|
|
321
|
+
const ternaryBracketRegex = /styles?\[\s*[^?]+\?\s*['"]([a-zA-Z_$][a-zA-Z0-9_$-]*)["']\s*:\s*['"]([a-zA-Z_$][a-zA-Z0-9_$-]*)["']\s*\]/g;
|
|
270
322
|
while ((m = ternaryBracketRegex.exec(content)) !== null) {
|
|
271
323
|
used.add(m[1]);
|
|
272
324
|
used.add(m[2]);
|
|
@@ -324,7 +376,7 @@ export function parseStyleFile(filePath) {
|
|
|
324
376
|
const content = readFileSync(filePath, 'utf-8');
|
|
325
377
|
const ext = extname(filePath);
|
|
326
378
|
|
|
327
|
-
if (['.css', '.scss', '.
|
|
379
|
+
if (['.css', '.scss', '.less'].includes(ext)) {
|
|
328
380
|
return {
|
|
329
381
|
selectors: extractCssSelectors(content),
|
|
330
382
|
cssVars: extractCssVarDefinitions(content),
|
|
@@ -333,6 +385,16 @@ export function parseStyleFile(filePath) {
|
|
|
333
385
|
};
|
|
334
386
|
}
|
|
335
387
|
|
|
388
|
+
// SASS indented syntax — no braces; needs its own parser branch
|
|
389
|
+
if (ext === '.sass') {
|
|
390
|
+
return {
|
|
391
|
+
selectors: extractSassIndentedSelectors(content),
|
|
392
|
+
cssVars: extractCssVarDefinitions(content),
|
|
393
|
+
varUsages: extractVarUsages(content),
|
|
394
|
+
hasDynamic: false,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
336
398
|
// JS/TS style file — CSS custom properties are not typically defined here
|
|
337
399
|
const selectors = extractJsStyleKeys(content);
|
|
338
400
|
return { selectors, cssVars: new Map(), varUsages: new Set(), hasDynamic: false };
|
package/src/reporter.js
CHANGED
|
@@ -197,7 +197,7 @@ export function generateReport(results, outputPath = 'orphan-report.md', aiSumma
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
for (const result of results) {
|
|
200
|
-
const { orphaned, orphanedVars = [], trails, varTrails } = result;
|
|
200
|
+
const { orphaned, orphanedVars = [], trails, varTrails, hasDynamic, dynamicFiles } = result;
|
|
201
201
|
if (orphaned.length === 0 && orphanedVars.length === 0) continue;
|
|
202
202
|
|
|
203
203
|
const relPath = relative(process.cwd(), result.filePath);
|
|
@@ -207,6 +207,17 @@ export function generateReport(results, outputPath = 'orphan-report.md', aiSumma
|
|
|
207
207
|
: '';
|
|
208
208
|
console.log(`✅ ${pc.bold(relPath)}${savingsSuffix}\n`);
|
|
209
209
|
|
|
210
|
+
if (hasDynamic) {
|
|
211
|
+
console.log(` ${pc.yellow('⚠️ Dynamic class references detected — results may be incomplete.')}`);
|
|
212
|
+
if (dynamicFiles?.length > 0) {
|
|
213
|
+
const relDynamic = dynamicFiles
|
|
214
|
+
.map(f => `${relative(process.cwd(), f)}`)
|
|
215
|
+
.join(', ');
|
|
216
|
+
console.log(pc.dim(` Dynamic references in: ${relDynamic}`));
|
|
217
|
+
}
|
|
218
|
+
console.log('');
|
|
219
|
+
}
|
|
220
|
+
|
|
210
221
|
orphaned.forEach(({ name, line, rule }) => {
|
|
211
222
|
console.log(` 👻 ${pc.yellow('.' + name)} ${pc.dim(`(line ${line})`)}`);
|
|
212
223
|
const trail = trails?.[name];
|
package/src/scanner.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'fs';
|
|
2
|
-
import { extname } from 'path';
|
|
2
|
+
import { extname, dirname } from 'path';
|
|
3
3
|
import { walkDir } from './walker.js';
|
|
4
4
|
import { parseStyleFile, parseComponentFile, isJsStyleFile } from './parser.js';
|
|
5
5
|
|
|
@@ -75,6 +75,12 @@ export function scan(dirs) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
// ── PASS 3: Cross-reference and build results ─────────────────────────────
|
|
78
|
+
// Build a Set of directories that contain dynamic components, so we can
|
|
79
|
+
// scope the hasDynamic flag to style files that share a directory — rather
|
|
80
|
+
// than tagging every style file in the entire scan when just one component
|
|
81
|
+
// anywhere uses dynamic class access.
|
|
82
|
+
const dynamicDirs = new Set(dynamicComponentFiles.map(f => dirname(f)));
|
|
83
|
+
|
|
78
84
|
const results = [];
|
|
79
85
|
|
|
80
86
|
for (const styleFile of styleFiles) {
|
|
@@ -82,6 +88,13 @@ export function scan(dirs) {
|
|
|
82
88
|
if (!parsed) continue;
|
|
83
89
|
|
|
84
90
|
const { selectors, cssVars, hasDynamic } = parsed;
|
|
91
|
+
const styleDir = dirname(styleFile);
|
|
92
|
+
|
|
93
|
+
// Only the dynamic components co-located with this style file are relevant
|
|
94
|
+
const localDynamicFiles = dynamicComponentFiles.filter(
|
|
95
|
+
f => dirname(f) === styleDir
|
|
96
|
+
);
|
|
97
|
+
const localHasDynamic = hasDynamic || dynamicDirs.has(styleDir);
|
|
85
98
|
|
|
86
99
|
// Orphaned class selectors
|
|
87
100
|
const orphaned = [...selectors.entries()]
|
|
@@ -101,8 +114,8 @@ export function scan(dirs) {
|
|
|
101
114
|
orphaned,
|
|
102
115
|
orphanedVars,
|
|
103
116
|
totalVars: cssVars?.size ?? 0,
|
|
104
|
-
hasDynamic:
|
|
105
|
-
dynamicFiles:
|
|
117
|
+
hasDynamic: localHasDynamic,
|
|
118
|
+
dynamicFiles: localDynamicFiles,
|
|
106
119
|
});
|
|
107
120
|
}
|
|
108
121
|
|
package/src/walker.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readdirSync } from 'fs';
|
|
|
2
2
|
import { join, extname, basename } from 'path';
|
|
3
3
|
|
|
4
4
|
const STYLE_EXTENSIONS = ['.css', '.scss', '.sass', '.less'];
|
|
5
|
-
const COMPONENT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
|
|
5
|
+
const COMPONENT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte']);
|
|
6
6
|
const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'coverage', '.cache']);
|
|
7
7
|
|
|
8
8
|
export function walkDir(dir) {
|