@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 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 directly without installing:
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 a single directory
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
- # Custom output path
56
+ # Write report to a custom path
57
57
  css-ghost ./src --output ./reports/css-audit.md
58
58
  ```
59
59
 
60
- ## Output
60
+ ## CLI output
61
61
 
62
- Generates a markdown report (`orphan-report.md` by default):
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
- # 👻 css-ghost — Orphaned Selector Report
99
+ ### 👻 Orphaned selectors
67
100
 
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% |
101
+ #### `.iconLeft` — line 4
76
102
 
77
- ## `src/components/Button/Button.module.css`
78
- | Defined | Orphaned | Unused % |
79
- | --- | --- | --- |
80
- | 6 | 2 | 33% |
103
+ ```css
104
+ .iconLeft {
105
+ margin-right: 6px;
106
+ flex-shrink: 0;
107
+ }
108
+ ```
81
109
 
82
- **Orphaned selectors:**
83
- - `.iconLeft`
84
- - `.loadingSpinner`
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.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 Set();
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(/\/\*[\s\S]*?\*\//g, '')
12
- .replace(/\/\/.*$/gm, '');
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
- const regex = /\.([a-zA-Z_-][a-zA-Z0-9_-]*)/g;
15
- let match;
16
- while ((match = regex.exec(stripped)) !== null) {
17
- selectors.add(match[1]);
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 Set();
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
- keys.add(m[1]);
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('**Orphaned selectors:**');
47
- lines.push('');
48
- orphaned.forEach(s => lines.push(`- \`.${s}\``));
49
- lines.push('');
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
- console.log(`✅ Report written to ${outputPath}`);
84
- console.log(`📊 ${totalOrphaned} orphaned selector${totalOrphaned !== 1 ? 's' : ''} across ${filesWithOrphans} file${filesWithOrphans !== 1 ? 's' : ''}`);
85
- console.log(`📁 ${results.length} style file${results.length !== 1 ? 's' : ''} scanned\n`);
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
- const orphaned = [...selectors].filter(s => !globalUsed.has(s));
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
- }