@rolandjlevy/css-ghost 1.0.0 → 1.0.1
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/bin/css-ghost.js +0 -0
- package/package.json +20 -3
- package/src/parser.js +75 -14
- package/src/reporter.js +29 -5
- package/src/scanner.js +4 -1
- package/.devcontainer/devcontainer.json +0 -35
package/bin/css-ghost.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rolandjlevy/css-ghost",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Find orphaned CSS class selectors in your codebase",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,14 +9,31 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/css-ghost.js"
|
|
11
11
|
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
12
17
|
"keywords": [
|
|
13
18
|
"css",
|
|
14
19
|
"orphan",
|
|
15
20
|
"unused",
|
|
16
21
|
"selectors",
|
|
17
22
|
"cli",
|
|
18
|
-
"linter"
|
|
23
|
+
"linter",
|
|
24
|
+
"react",
|
|
25
|
+
"scss",
|
|
26
|
+
"stylesheet",
|
|
27
|
+
"audit"
|
|
19
28
|
],
|
|
20
29
|
"author": "Roland Levy",
|
|
21
|
-
"license": "MIT"
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/rolandjlevy/css-ghost.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/rolandjlevy/css-ghost#readme",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
}
|
|
22
39
|
}
|
package/src/parser.js
CHANGED
|
@@ -4,17 +4,68 @@ import { extname } from 'path';
|
|
|
4
4
|
// ─── CSS / SCSS selector extraction ────────────────────────────────────────
|
|
5
5
|
|
|
6
6
|
function extractCssSelectors(content) {
|
|
7
|
-
const selectors = new
|
|
7
|
+
const selectors = new Map(); // name → { line, rule }
|
|
8
|
+
const lines = content.split('\n');
|
|
8
9
|
|
|
9
|
-
// Strip comments
|
|
10
|
-
const stripped = content
|
|
11
|
-
.replace(
|
|
12
|
-
|
|
10
|
+
// Strip block comments but preserve line numbers by replacing with spaces
|
|
11
|
+
const stripped = content.replace(/\/\*[\s\S]*?\*\//g, m =>
|
|
12
|
+
m.replace(/[^\n]/g, ' ')
|
|
13
|
+
);
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
// Walk line by line to track line numbers
|
|
16
|
+
const strippedLines = stripped.split('\n');
|
|
17
|
+
let insideRule = false;
|
|
18
|
+
let currentRule = [];
|
|
19
|
+
let currentSelectors = [];
|
|
20
|
+
let ruleStartLine = 0;
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < strippedLines.length; i++) {
|
|
23
|
+
const line = strippedLines[i];
|
|
24
|
+
const lineNum = i + 1;
|
|
25
|
+
|
|
26
|
+
if (!insideRule) {
|
|
27
|
+
// Look for .className optionally followed by { on same or next line
|
|
28
|
+
const selectorRegex = /\.([a-zA-Z_-][a-zA-Z0-9_-]*)/g;
|
|
29
|
+
let m;
|
|
30
|
+
while ((m = selectorRegex.exec(line)) !== null) {
|
|
31
|
+
currentSelectors.push({ name: m[1], line: lineNum });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (line.includes('{')) {
|
|
35
|
+
insideRule = true;
|
|
36
|
+
ruleStartLine = lineNum;
|
|
37
|
+
currentRule = [lines[i]];
|
|
38
|
+
|
|
39
|
+
if (line.includes('}')) {
|
|
40
|
+
// Single-line rule
|
|
41
|
+
const ruleText = currentRule.join('\n').trim();
|
|
42
|
+
currentSelectors.forEach(({ name, line: l }) => {
|
|
43
|
+
if (!selectors.has(name)) {
|
|
44
|
+
selectors.set(name, { line: l, rule: ruleText });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
currentSelectors = [];
|
|
48
|
+
currentRule = [];
|
|
49
|
+
insideRule = false;
|
|
50
|
+
}
|
|
51
|
+
} else if (currentSelectors.length > 0 && line.trim() === '') {
|
|
52
|
+
// Empty line with no { means these weren't selectors for a rule
|
|
53
|
+
currentSelectors = [];
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
currentRule.push(lines[i]);
|
|
57
|
+
if (line.includes('}')) {
|
|
58
|
+
const ruleText = currentRule.join('\n').trim();
|
|
59
|
+
currentSelectors.forEach(({ name, line: l }) => {
|
|
60
|
+
if (!selectors.has(name)) {
|
|
61
|
+
selectors.set(name, { line: l, rule: ruleText });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
currentSelectors = [];
|
|
65
|
+
currentRule = [];
|
|
66
|
+
insideRule = false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
18
69
|
}
|
|
19
70
|
|
|
20
71
|
return selectors;
|
|
@@ -30,19 +81,29 @@ export function isJsStyleFile(content, filePath) {
|
|
|
30
81
|
}
|
|
31
82
|
|
|
32
83
|
function extractJsStyleKeys(content) {
|
|
33
|
-
const keys = new
|
|
84
|
+
const keys = new Map(); // name → { line, rule }
|
|
85
|
+
const lines = content.split('\n');
|
|
34
86
|
|
|
35
|
-
// StyleSheet.create({ key: ... })
|
|
36
87
|
const ssMatch = content.match(/StyleSheet\.create\s*\(\s*\{([\s\S]*?)\}\s*\)/);
|
|
37
|
-
// Plain object: const styles = { key: ... }
|
|
38
88
|
const plainMatch = content.match(/(?:const|let|var)\s+styles?\s*=\s*\{([\s\S]*)\}/);
|
|
39
|
-
|
|
40
89
|
const objectContent = ssMatch?.[1] ?? plainMatch?.[1];
|
|
90
|
+
|
|
41
91
|
if (objectContent) {
|
|
92
|
+
// Find start offset in original content
|
|
93
|
+
const startOffset = content.indexOf(objectContent);
|
|
94
|
+
const preContent = content.slice(0, startOffset);
|
|
95
|
+
const startLine = preContent.split('\n').length;
|
|
96
|
+
|
|
42
97
|
const keyRegex = /^\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/gm;
|
|
43
98
|
let m;
|
|
44
99
|
while ((m = keyRegex.exec(objectContent)) !== null) {
|
|
45
|
-
|
|
100
|
+
const keyLine = startLine + objectContent.slice(0, m.index).split('\n').length - 1;
|
|
101
|
+
const name = m[1];
|
|
102
|
+
if (!keys.has(name)) {
|
|
103
|
+
// Grab the value snippet for context
|
|
104
|
+
const lineText = lines[keyLine - 1]?.trim() ?? '';
|
|
105
|
+
keys.set(name, { line: keyLine, rule: lineText });
|
|
106
|
+
}
|
|
46
107
|
}
|
|
47
108
|
}
|
|
48
109
|
|
package/src/reporter.js
CHANGED
|
@@ -43,10 +43,18 @@ export function generateReport(results, outputPath = 'orphan-report.md') {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
if (hasOrphans) {
|
|
46
|
-
lines.push('
|
|
47
|
-
|
|
48
|
-
orphaned.forEach(
|
|
49
|
-
|
|
46
|
+
lines.push('### 👻 Orphaned selectors\n');
|
|
47
|
+
|
|
48
|
+
orphaned.forEach(({ name, line, rule }) => {
|
|
49
|
+
lines.push(`#### \`.${name}\` — line ${line}`);
|
|
50
|
+
lines.push('');
|
|
51
|
+
if (rule) {
|
|
52
|
+
lines.push('```css');
|
|
53
|
+
lines.push(rule);
|
|
54
|
+
lines.push('```');
|
|
55
|
+
}
|
|
56
|
+
lines.push('');
|
|
57
|
+
});
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
lines.push('---');
|
|
@@ -80,7 +88,23 @@ export function generateReport(results, outputPath = 'orphan-report.md') {
|
|
|
80
88
|
const content = summary + sections.join('');
|
|
81
89
|
writeFileSync(outputPath, content, 'utf-8');
|
|
82
90
|
|
|
91
|
+
// Console output — also print orphans inline
|
|
83
92
|
console.log(`✅ Report written to ${outputPath}`);
|
|
84
|
-
console.log(`📊 ${totalOrphaned} orphaned selector${totalOrphaned !== 1 ? 's' : ''} across ${filesWithOrphans} file${filesWithOrphans !== 1 ? 's' : ''}`);
|
|
93
|
+
console.log(`📊 ${totalOrphaned} orphaned selector${totalOrphaned !== 1 ? 's' : ''} across ${filesWithOrphans} file${filesWithOrphans !== 1 ? 's' : ''}\n`);
|
|
94
|
+
|
|
95
|
+
for (const result of results) {
|
|
96
|
+
if (result.orphaned.length === 0) continue;
|
|
97
|
+
const relPath = relative(process.cwd(), result.filePath);
|
|
98
|
+
console.log(`📄 ${relPath}`);
|
|
99
|
+
result.orphaned.forEach(({ name, line, rule }) => {
|
|
100
|
+
console.log(` 👻 .${name} (line ${line})`);
|
|
101
|
+
if (rule) {
|
|
102
|
+
const preview = rule.split('\n').slice(0, 4).join('\n');
|
|
103
|
+
console.log(preview.split('\n').map(l => ` ${l}`).join('\n'));
|
|
104
|
+
}
|
|
105
|
+
console.log('');
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
85
109
|
console.log(`📁 ${results.length} style file${results.length !== 1 ? 's' : ''} scanned\n`);
|
|
86
110
|
}
|
package/src/scanner.js
CHANGED
|
@@ -55,7 +55,10 @@ export function scan(dirs) {
|
|
|
55
55
|
for (const styleFile of styleFiles) {
|
|
56
56
|
try {
|
|
57
57
|
const { selectors, hasDynamic } = parseStyleFile(styleFile);
|
|
58
|
-
|
|
58
|
+
// selectors is now a Map: name → { line, rule }
|
|
59
|
+
const orphaned = [...selectors.entries()]
|
|
60
|
+
.filter(([name]) => !globalUsed.has(name))
|
|
61
|
+
.map(([name, meta]) => ({ name, ...meta }));
|
|
59
62
|
|
|
60
63
|
results.push({
|
|
61
64
|
filePath: styleFile,
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "css-ghost",
|
|
3
|
-
"image": "mcr.microsoft.com/devcontainers/javascript-node:20",
|
|
4
|
-
|
|
5
|
-
"customizations": {
|
|
6
|
-
"vscode": {
|
|
7
|
-
"extensions": [
|
|
8
|
-
"dbaeumer.vscode-eslint",
|
|
9
|
-
"esbenp.prettier-vscode",
|
|
10
|
-
"GitHub.github-vscode-theme",
|
|
11
|
-
"christian-kohler.path-intellisense",
|
|
12
|
-
"streetsidesoftware.code-spell-checker"
|
|
13
|
-
],
|
|
14
|
-
"settings": {
|
|
15
|
-
"workbench.colorTheme": "GitHub Dark Dimmed",
|
|
16
|
-
"workbench.iconTheme": "vs-seti",
|
|
17
|
-
"editor.fontSize": 14,
|
|
18
|
-
"editor.tabSize": 2,
|
|
19
|
-
"editor.formatOnSave": true,
|
|
20
|
-
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
21
|
-
"editor.minimap.enabled": false,
|
|
22
|
-
"editor.wordWrap": "on",
|
|
23
|
-
"terminal.integrated.defaultProfile.linux": "bash",
|
|
24
|
-
"files.exclude": {
|
|
25
|
-
"node_modules": true,
|
|
26
|
-
"orphan-report.md": true
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
},
|
|
31
|
-
|
|
32
|
-
"postCreateCommand": "echo '👻 css-ghost dev container ready. Run: node bin/css-ghost.js --help'",
|
|
33
|
-
|
|
34
|
-
"remoteUser": "node"
|
|
35
|
-
}
|