@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 CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rolandjlevy/css-ghost",
3
- "version": "1.0.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 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,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
- 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
- }