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