@rolandjlevy/css-ghost 1.1.3 → 1.3.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 +64 -0
- package/README.md +54 -2
- package/index.d.ts +108 -0
- package/package.json +22 -4
- package/src/deleter.js +66 -16
- package/src/index.js +16 -0
- package/src/parser.js +63 -1
- 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,64 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@rolandjlevy/css-ghost` are documented here.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## [1.3.0] — 2026-10-17
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Programmatic API** (`src/index.js`) — `scan()` and `generateReport()` are now exposed as the package's public entry point via the `exports` field. Other tools and build plugins can now depend on css-ghost directly.
|
|
12
|
+
- **TypeScript types** (`index.d.ts`) — hand-authored declarations for `ScanResult`, `OrphanedItem`, `GhostTrail`, and `ReportTotals`; no `@types/` package needed.
|
|
13
|
+
- **GitHub Actions CI** (`.github/workflows/ci.yml`) — runs the smoke test suite on Node 18, 20 and 22 on every push and pull request.
|
|
14
|
+
- **Smoke test runner** (`test/run.js`) — `npm test` now runs `scan()` against all 13 fixture directories and asserts result shapes; 13/13 pass.
|
|
15
|
+
- **README badges** — npm version, monthly downloads, CI status, Node version and licence badge at the top of the README.
|
|
16
|
+
- **Programmatic API docs** — new README section with install instructions, usage example, and TypeScript note.
|
|
17
|
+
- **Keywords expanded** — added `vite`, `webpack`, `postcss`, `css-variables`, `vanilla-extract`, `orphaned-selectors` for better npm discovery.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## [1.2.0] — 2026-10-17
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **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.
|
|
26
|
+
|
|
27
|
+
- **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`.
|
|
28
|
+
|
|
29
|
+
- **`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.
|
|
30
|
+
|
|
31
|
+
- **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.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- 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.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## [1.1.3] — 2026-10-16
|
|
40
|
+
|
|
41
|
+
- Internal release; no public changelog entry.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## [1.1.0] — 2026-10-01
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- `--ghost-trail` flag: shows the last git commit that touched each orphaned selector
|
|
50
|
+
- `--ai` flag: appends an AI-generated summary via OpenRouter (run `--setup` once to save your key)
|
|
51
|
+
- CSS custom property orphan detection (`--my-token` defined but never consumed via `var(--my-token)`)
|
|
52
|
+
- `--install-hook` flag: writes a pre-commit hook that blocks commits when orphans are present
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## [1.0.0] — 2026-09-01
|
|
57
|
+
|
|
58
|
+
### Initial release
|
|
59
|
+
|
|
60
|
+
- CSS selector orphan detection across `.css`, `.module.css`, `.scss`, `.sass`, `.less`, and JS style files (`StyleSheet.create`, `makeStyles`, vanilla-extract)
|
|
61
|
+
- `--delete` / `--delete --yes` to remove orphaned selectors from source files
|
|
62
|
+
- `--strict` flag for CI (exit code 1 when any orphans found)
|
|
63
|
+
- `--output <file>` for custom Markdown report paths
|
|
64
|
+
- Interactive folder picker when run with no arguments
|
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# 👻 css-ghost
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@rolandjlevy/css-ghost)
|
|
4
|
+
[](https://www.npmjs.com/package/@rolandjlevy/css-ghost)
|
|
5
|
+
[](https://github.com/rolandjlevy/css-ghost/actions/workflows/ci.yml)
|
|
6
|
+
[](https://nodejs.org/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
3
9
|
Find and remove unused CSS selectors and orphaned CSS custom properties — selectors defined in your style files but never referenced in your components, across React, Vue, Svelte, SCSS, and CSS Modules
|
|
4
10
|
|
|
5
11
|
## What it does
|
|
@@ -8,9 +14,27 @@ Scans one or more directories for style files, extracts every class selector def
|
|
|
8
14
|
|
|
9
15
|
**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
16
|
|
|
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
|
|
17
|
+
**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
|
|
18
|
+
|
|
19
|
+
**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.
|
|
20
|
+
|
|
21
|
+
## Example output
|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
25
|
+
When a component uses dynamic class access (`styles[variant]`), the file is flagged rather than reporting false positives:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
✅ src/components/alert.module.css
|
|
29
|
+
|
|
30
|
+
⚠️ Dynamic class references detected — results may be incomplete.
|
|
31
|
+
Dynamic references in: src/components/Alert.jsx
|
|
32
|
+
|
|
33
|
+
👻 .hidden (line 30)
|
|
34
|
+
.hidden { display: none; }
|
|
35
|
+
```
|
|
12
36
|
|
|
13
|
-
|
|
37
|
+
---
|
|
14
38
|
|
|
15
39
|
## How is this different from PurgeCSS?
|
|
16
40
|
|
|
@@ -140,6 +164,34 @@ css-ghost ./src --ai # with AI summary
|
|
|
140
164
|
css-ghost ./src --output ./reports/audit.md
|
|
141
165
|
```
|
|
142
166
|
|
|
167
|
+
## Programmatic API
|
|
168
|
+
|
|
169
|
+
Use css-ghost as a library inside your own build tools, Vite plugins, or scripts:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
npm install @rolandjlevy/css-ghost
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
import { scan, generateReport } from '@rolandjlevy/css-ghost';
|
|
177
|
+
import { resolve } from 'path';
|
|
178
|
+
|
|
179
|
+
// Scan one or more directories
|
|
180
|
+
const results = scan([resolve(process.cwd(), 'src')]);
|
|
181
|
+
|
|
182
|
+
// Optional: write a Markdown report and get the orphan totals
|
|
183
|
+
const { totalAllOrphans } = generateReport(results, 'orphan-report.md');
|
|
184
|
+
|
|
185
|
+
// Use in CI: fail the build if any orphans are found
|
|
186
|
+
if (totalAllOrphans > 0) process.exit(1);
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
TypeScript types are included — no `@types/` package needed.
|
|
190
|
+
|
|
191
|
+
**`scan(dirs: string[])`** — returns a `ScanResult[]` array, one entry per style file. Each entry includes `filePath`, `orphaned` selectors, `orphanedVars` (CSS custom properties), `hasDynamic` flag, and more.
|
|
192
|
+
|
|
193
|
+
**`generateReport(results, outputPath?, aiSummary?)`** — writes a Markdown report and prints a colour summary to the terminal. Returns `{ totalOrphaned, totalVarsOrphaned, totalAllOrphans }`.
|
|
194
|
+
|
|
143
195
|
## Using with npm scripts
|
|
144
196
|
|
|
145
197
|
```json
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript declarations for @rolandjlevy/css-ghost
|
|
3
|
+
* Import from the package root to get `scan` and `generateReport`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** A single orphaned class selector or CSS variable. */
|
|
7
|
+
export interface OrphanedItem {
|
|
8
|
+
/** The selector name (without leading `.`) or CSS variable name (with `--` prefix). */
|
|
9
|
+
name: string;
|
|
10
|
+
/** Line number in the source style file. */
|
|
11
|
+
line: number;
|
|
12
|
+
/** The raw CSS rule text, if available. */
|
|
13
|
+
rule?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Git blame info for a single orphaned item (--ghost-trail mode). */
|
|
17
|
+
export interface GhostTrail {
|
|
18
|
+
/** Short commit hash. */
|
|
19
|
+
hash: string;
|
|
20
|
+
/** Commit author name. */
|
|
21
|
+
author: string;
|
|
22
|
+
/** Human-readable date/time of the commit. */
|
|
23
|
+
when: string;
|
|
24
|
+
/** Commit subject line. */
|
|
25
|
+
subject: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Per-file result returned by `scan()`.
|
|
30
|
+
* One entry per style file processed.
|
|
31
|
+
*/
|
|
32
|
+
export interface ScanResult {
|
|
33
|
+
/** Absolute path to the style file. */
|
|
34
|
+
filePath: string;
|
|
35
|
+
/** Total number of class selectors defined in the file. */
|
|
36
|
+
total: number;
|
|
37
|
+
/** Class selectors that are defined but never referenced in component files. */
|
|
38
|
+
orphaned: OrphanedItem[];
|
|
39
|
+
/** CSS custom properties (`--foo`) defined but never used via `var()`. */
|
|
40
|
+
orphanedVars: OrphanedItem[];
|
|
41
|
+
/** Total number of CSS custom properties defined in the file. */
|
|
42
|
+
totalVars: number;
|
|
43
|
+
/**
|
|
44
|
+
* `true` when the co-located component files use dynamic class access
|
|
45
|
+
* (`styles[variable]`, template literals) — results may be incomplete.
|
|
46
|
+
*/
|
|
47
|
+
hasDynamic: boolean;
|
|
48
|
+
/** Absolute paths of component files that contain dynamic class references. */
|
|
49
|
+
dynamicFiles: string[];
|
|
50
|
+
/**
|
|
51
|
+
* Ghost-trail git blame data for orphaned selectors, keyed by selector name.
|
|
52
|
+
* Only present when `--ghost-trail` is active or you call the git module directly.
|
|
53
|
+
*/
|
|
54
|
+
trails?: Record<string, GhostTrail>;
|
|
55
|
+
/**
|
|
56
|
+
* Ghost-trail git blame data for orphaned CSS variables, keyed by variable name.
|
|
57
|
+
* Only present when `--ghost-trail` is active or you call the git module directly.
|
|
58
|
+
*/
|
|
59
|
+
varTrails?: Record<string, GhostTrail>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Totals returned by `generateReport()`. Useful for CI / strict-mode checks. */
|
|
63
|
+
export interface ReportTotals {
|
|
64
|
+
/** Number of orphaned class selectors across all scanned files. */
|
|
65
|
+
totalOrphaned: number;
|
|
66
|
+
/** Number of orphaned CSS variables across all scanned files. */
|
|
67
|
+
totalVarsOrphaned: number;
|
|
68
|
+
/** Sum of `totalOrphaned` and `totalVarsOrphaned`. */
|
|
69
|
+
totalAllOrphans: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Scan one or more directories for orphaned CSS selectors and CSS custom properties.
|
|
74
|
+
*
|
|
75
|
+
* @param dirs - Absolute paths to the directories you want to scan.
|
|
76
|
+
* @returns An array of per-file results — one entry per style file found.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* import { scan } from '@rolandjlevy/css-ghost';
|
|
80
|
+
* import { resolve } from 'path';
|
|
81
|
+
*
|
|
82
|
+
* const results = scan([resolve(process.cwd(), 'src')]);
|
|
83
|
+
* const totalOrphans = results.reduce((n, r) => n + r.orphaned.length + r.orphanedVars.length, 0);
|
|
84
|
+
* console.log(`Found ${totalOrphans} orphan(s).`);
|
|
85
|
+
*/
|
|
86
|
+
export function scan(dirs: string[]): ScanResult[];
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Generate a Markdown report file and print a summary to stdout.
|
|
90
|
+
*
|
|
91
|
+
* @param results - Output from `scan()`.
|
|
92
|
+
* @param outputPath - Where to write the `.md` report. Defaults to `'orphan-report.md'`.
|
|
93
|
+
* @param aiSummary - Optional AI-generated summary string to embed in the report.
|
|
94
|
+
* @returns Totals you can use for CI gates or `--strict` mode.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* import { scan, generateReport } from '@rolandjlevy/css-ghost';
|
|
98
|
+
* import { resolve } from 'path';
|
|
99
|
+
*
|
|
100
|
+
* const results = scan([resolve(process.cwd(), 'src')]);
|
|
101
|
+
* const { totalAllOrphans } = generateReport(results, 'orphan-report.md');
|
|
102
|
+
* if (totalAllOrphans > 0) process.exit(1);
|
|
103
|
+
*/
|
|
104
|
+
export function generateReport(
|
|
105
|
+
results: ScanResult[],
|
|
106
|
+
outputPath?: string,
|
|
107
|
+
aiSummary?: string | null
|
|
108
|
+
): ReportTotals;
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rolandjlevy/css-ghost",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.js",
|
|
10
|
+
"types": "./index.d.ts",
|
|
6
11
|
"bin": {
|
|
7
12
|
"css-ghost": "bin/css-ghost.js"
|
|
8
13
|
},
|
|
@@ -12,18 +17,22 @@
|
|
|
12
17
|
"scan:trail": "node bin/css-ghost.js ./test --ghost-trail",
|
|
13
18
|
"delete": "node bin/css-ghost.js ./test --delete",
|
|
14
19
|
"delete:yes": "node bin/css-ghost.js ./test --delete --yes",
|
|
15
|
-
"setup": "node bin/css-ghost.js --setup"
|
|
20
|
+
"setup": "node bin/css-ghost.js --setup",
|
|
21
|
+
"test": "node test/run.js"
|
|
16
22
|
},
|
|
17
23
|
"files": [
|
|
18
24
|
"bin",
|
|
19
25
|
"src",
|
|
26
|
+
"index.d.ts",
|
|
20
27
|
"README.md",
|
|
28
|
+
"CHANGELOG.md",
|
|
21
29
|
"LICENSE"
|
|
22
30
|
],
|
|
23
31
|
"keywords": [
|
|
24
32
|
"css",
|
|
25
33
|
"unused",
|
|
26
34
|
"orphan",
|
|
35
|
+
"orphaned-selectors",
|
|
27
36
|
"selectors",
|
|
28
37
|
"dead-code",
|
|
29
38
|
"cleanup",
|
|
@@ -32,20 +41,29 @@
|
|
|
32
41
|
"audit",
|
|
33
42
|
"tree-shaking",
|
|
34
43
|
"custom-properties",
|
|
44
|
+
"css-variables",
|
|
35
45
|
"react",
|
|
36
46
|
"vue",
|
|
37
47
|
"svelte",
|
|
38
48
|
"scss",
|
|
39
49
|
"css-modules",
|
|
40
|
-
"purgecss"
|
|
50
|
+
"purgecss",
|
|
51
|
+
"vite",
|
|
52
|
+
"webpack",
|
|
53
|
+
"postcss",
|
|
54
|
+
"vanilla-extract",
|
|
55
|
+
"style-audit"
|
|
41
56
|
],
|
|
42
57
|
"author": "Roland Levy",
|
|
43
58
|
"license": "MIT",
|
|
44
59
|
"repository": {
|
|
45
60
|
"type": "git",
|
|
46
|
-
"url": "https://github.com/rolandjlevy/css-ghost.git"
|
|
61
|
+
"url": "git+https://github.com/rolandjlevy/css-ghost.git"
|
|
47
62
|
},
|
|
48
63
|
"homepage": "https://github.com/rolandjlevy/css-ghost#readme",
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://github.com/rolandjlevy/css-ghost/issues"
|
|
66
|
+
},
|
|
49
67
|
"engines": {
|
|
50
68
|
"node": ">=18"
|
|
51
69
|
},
|
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/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @rolandjlevy/css-ghost — Programmatic API
|
|
3
|
+
*
|
|
4
|
+
* Use this when integrating css-ghost into build tools, scripts, or plugins
|
|
5
|
+
* rather than running it as a CLI.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import { scan, generateReport } from '@rolandjlevy/css-ghost';
|
|
9
|
+
*
|
|
10
|
+
* const results = scan(['/absolute/path/to/src']);
|
|
11
|
+
* const { totalAllOrphans } = generateReport(results, 'orphan-report.md');
|
|
12
|
+
* if (totalAllOrphans > 0) process.exit(1);
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export { scan } from './scanner.js';
|
|
16
|
+
export { generateReport } from './reporter.js';
|
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');
|
|
@@ -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) {
|