Skip to content

Commit f4fe6a2

Browse files
committed
OK, maybe I do need to check in dist for now
1 parent f90a550 commit f4fe6a2

File tree

9 files changed

+318
-1
lines changed

9 files changed

+318
-1
lines changed

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
11
node_modules
2-
dist

dist/index.d.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* @fileoverview ESLint plugin to validate CSS class usage in JavaScript/TypeScript files
3+
* @author ESLint Plugin CSS Class Usage Contributors
4+
*/
5+
declare const _default: {
6+
/**
7+
* List of supported rules
8+
*/
9+
rules: {
10+
/**
11+
* Rule to validate that CSS classes used in code exist in stylesheets
12+
* @see ./rules/no-unknown-class.ts
13+
*/
14+
'no-unknown-classes': import("@typescript-eslint/utils/ts-eslint").RuleModule<"unknownClass", [import("./rules/no-unknown-class").PluginOptions], import("@typescript-eslint/utils/ts-eslint").RuleListener>;
15+
};
16+
/**
17+
* Recommended configuration
18+
*/
19+
configs: {
20+
recommended: {
21+
plugins: string[];
22+
rules: {
23+
'css-class-usage/no-unknown-class': (string | {
24+
cssFiles: string[];
25+
classAttributes: string[];
26+
classFunctions: string[];
27+
ignore: string[];
28+
})[];
29+
};
30+
};
31+
};
32+
};
33+
export = _default;

dist/index.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"use strict";
2+
var __importDefault = (this && this.__importDefault) || function (mod) {
3+
return (mod && mod.__esModule) ? mod : { "default": mod };
4+
};
5+
const no_unknown_class_1 = __importDefault(require("./rules/no-unknown-class"));
6+
module.exports = {
7+
/**
8+
* List of supported rules
9+
*/
10+
rules: {
11+
/**
12+
* Rule to validate that CSS classes used in code exist in stylesheets
13+
* @see ./rules/no-unknown-class.ts
14+
*/
15+
'no-unknown-classes': no_unknown_class_1.default,
16+
},
17+
/**
18+
* Recommended configuration
19+
*/
20+
configs: {
21+
recommended: {
22+
plugins: ['css-class-usage'],
23+
rules: {
24+
'css-class-usage/no-unknown-class': [
25+
'error',
26+
{
27+
cssFiles: ['src/**/*.css', 'src/**/*.scss'],
28+
classAttributes: ['className', 'class', 'classList'],
29+
classFunctions: ['clsx', 'classNames', 'cx'],
30+
ignore: ['**/node_modules/**', '**/dist/**', '**/out/**', '**/build/**'],
31+
},
32+
],
33+
},
34+
},
35+
},
36+
};

dist/rules/no-unknown-class.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { type RuleModule } from '@typescript-eslint/utils/ts-eslint';
2+
export interface PluginOptions {
3+
/** Attributes to check in JSX elements (e.g., 'className', 'class') */
4+
classAttributes?: string[];
5+
/** Function names that handle class composition (e.g., 'clsx', 'classNames') */
6+
classFunctions?: string[];
7+
/** File patterns to watch for CSS classes */
8+
cssFiles?: string[];
9+
/** Patterns to ignore when searching for CSS files */
10+
ignore?: string[];
11+
}
12+
declare const rule: RuleModule<'unknownClass', [PluginOptions]>;
13+
export default rule;

dist/rules/no-unknown-class.js

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"use strict";
2+
Object.defineProperty(exports, "__esModule", { value: true });
3+
const file_watcher_1 = require("../utils/file-watcher");
4+
let cssWatcher = null;
5+
const rule = {
6+
defaultOptions: [
7+
{
8+
classAttributes: ['className', 'class', 'classList'],
9+
classFunctions: ['clsx', 'classNames', 'cx'],
10+
cssFiles: ['**/*.css'],
11+
ignore: ['**/node_modules/**', '**/dist/**', '**/out/**', '**/build/**'],
12+
},
13+
],
14+
meta: {
15+
type: 'problem',
16+
docs: {
17+
description: 'Ensure that CSS classes used in JS/TS files exist',
18+
recommended: 'recommended',
19+
},
20+
messages: {
21+
unknownClass: "Unknown CSS class '{{className}}'",
22+
},
23+
schema: [
24+
{
25+
type: 'object',
26+
properties: {
27+
classAttributes: {
28+
type: 'array',
29+
items: { type: 'string' },
30+
},
31+
classFunctions: {
32+
type: 'array',
33+
items: { type: 'string' },
34+
},
35+
cssFiles: {
36+
type: 'array',
37+
items: { type: 'string' },
38+
},
39+
ignore: {
40+
type: 'array',
41+
items: { type: 'string' },
42+
},
43+
},
44+
additionalProperties: false,
45+
},
46+
],
47+
},
48+
create(context) {
49+
const options = context.options[0];
50+
// Initialize watcher if not already done
51+
if (!cssWatcher) {
52+
cssWatcher = new file_watcher_1.CssWatcher(options.cssFiles, options.ignore);
53+
}
54+
/** Helper to check if a class exists in our CSS files */
55+
const validate = (className, node) => {
56+
// Ignore Tailwind classes with arbitrary values
57+
if (className.includes('[')) {
58+
return;
59+
}
60+
// Remove any Tailwind modifiers
61+
const baseClass = className.split(':').pop();
62+
if (!(cssWatcher === null || cssWatcher === void 0 ? void 0 : cssWatcher.hasClass(baseClass))) {
63+
context.report({
64+
node,
65+
messageId: 'unknownClass',
66+
data: { className: baseClass },
67+
});
68+
}
69+
};
70+
/** Helper to validate class names in object expressions */
71+
const validateObjectExpression = (objExpr) => {
72+
objExpr.properties.forEach((prop) => {
73+
if (prop.type === 'Property') {
74+
if (prop.key.type === 'Literal' && typeof prop.key.value === 'string') {
75+
validate(prop.key.value, prop);
76+
}
77+
else if (prop.key.type === 'Identifier') {
78+
validate(prop.key.name, prop);
79+
}
80+
}
81+
// We ignore SpreadElement as it can't contain class names directly
82+
});
83+
};
84+
return {
85+
// Check JSX className attributes
86+
JSXAttribute(node) {
87+
var _a, _b, _c;
88+
if (node.name.type === 'JSXIdentifier' &&
89+
((_a = options.classAttributes) === null || _a === void 0 ? void 0 : _a.includes(node.name.name))) {
90+
if (((_b = node.value) === null || _b === void 0 ? void 0 : _b.type) === 'Literal' && typeof node.value.value === 'string') {
91+
const classNames = node.value.value.split(/\s+/);
92+
classNames.forEach((className) => {
93+
validate(className, node);
94+
});
95+
}
96+
else if (((_c = node.value) === null || _c === void 0 ? void 0 : _c.type) === 'JSXExpressionContainer') {
97+
const expr = node.value.expression;
98+
if (expr.type === 'ObjectExpression') {
99+
validateObjectExpression(expr);
100+
}
101+
}
102+
}
103+
},
104+
// Check class utility function calls
105+
CallExpression(node) {
106+
var _a;
107+
if (node.callee.type === 'Identifier' &&
108+
((_a = options.classFunctions) === null || _a === void 0 ? void 0 : _a.includes(node.callee.name))) {
109+
node.arguments.forEach((arg) => {
110+
if (arg.type === 'Literal' && typeof arg.value === 'string') {
111+
const classNames = arg.value.split(/\s+/);
112+
classNames.forEach((className) => {
113+
validate(className, arg);
114+
});
115+
}
116+
else if (arg.type === 'ObjectExpression') {
117+
validateObjectExpression(arg);
118+
}
119+
});
120+
}
121+
},
122+
};
123+
},
124+
};
125+
exports.default = rule;

dist/utils/css-extractor.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export declare function extractClassesFromCss(content: string): Set<string>;

dist/utils/css-extractor.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"use strict";
2+
Object.defineProperty(exports, "__esModule", { value: true });
3+
exports.extractClassesFromCss = extractClassesFromCss;
4+
function extractClassesFromCss(content) {
5+
const classes = new Set();
6+
// Regex for CSS classes
7+
const classRegex = /\.([a-zA-Z][a-zA-Z0-9_-]*)/g;
8+
let match;
9+
while ((match = classRegex.exec(content)) !== null) {
10+
if (match[1]) {
11+
// Regular CSS class
12+
classes.add(match[1]);
13+
}
14+
}
15+
return classes;
16+
}

dist/utils/file-watcher.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export declare class CssWatcher {
2+
private state;
3+
private watcher;
4+
private patterns;
5+
private ignorePatterns;
6+
constructor(patterns?: string[], ignore?: string[]);
7+
private setupWatcher;
8+
private updateClassesForFile;
9+
hasClass(className: string): boolean;
10+
close(): Promise<void>;
11+
}

dist/utils/file-watcher.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"use strict";
2+
var __importDefault = (this && this.__importDefault) || function (mod) {
3+
return (mod && mod.__esModule) ? mod : { "default": mod };
4+
};
5+
Object.defineProperty(exports, "__esModule", { value: true });
6+
exports.CssWatcher = void 0;
7+
const fs_1 = __importDefault(require("fs"));
8+
const chokidar_1 = __importDefault(require("chokidar"));
9+
const css_extractor_1 = require("./css-extractor");
10+
class CssWatcher {
11+
constructor(patterns = ['**/*.css'], ignore = ['**/node_modules/**', '**/dist/**', '**/out/**', '**/build/**']) {
12+
this.state = {
13+
fileClasses: new Map(),
14+
lastUpdate: 0,
15+
};
16+
this.watcher = null;
17+
this.patterns = patterns;
18+
this.ignorePatterns = ignore;
19+
this.setupWatcher();
20+
}
21+
setupWatcher() {
22+
// Initial scan and watch setup
23+
const watchPatterns = this.patterns.map((pattern) => {
24+
// Convert Windows-style paths if necessary
25+
return pattern.replace(/\\/g, '/');
26+
});
27+
// Set up chokidar with appropriate options
28+
this.watcher = chokidar_1.default.watch(watchPatterns, {
29+
persistent: true,
30+
ignoreInitial: false, // This ensures we get the initial scan
31+
ignored: [...this.ignorePatterns, /(^|[/\\])\../], // Ignore dotfiles and user-specified patterns
32+
cwd: '.', // Use current working directory as base
33+
followSymlinks: true,
34+
awaitWriteFinish: {
35+
stabilityThreshold: 200,
36+
pollInterval: 100,
37+
},
38+
});
39+
// Setup event handlers
40+
this.watcher
41+
.on('add', (filePath) => {
42+
this.updateClassesForFile(filePath);
43+
})
44+
.on('change', (filePath) => {
45+
this.updateClassesForFile(filePath);
46+
})
47+
.on('unlink', (filePath) => {
48+
this.state.fileClasses.delete(filePath);
49+
this.state.lastUpdate = Date.now();
50+
})
51+
.on('error', (error) => {
52+
console.error(`Watcher error: ${error}`);
53+
});
54+
}
55+
async updateClassesForFile(filePath) {
56+
try {
57+
const content = await fs_1.default.promises.readFile(filePath, 'utf8');
58+
const fileClasses = (0, css_extractor_1.extractClassesFromCss)(content);
59+
this.state.fileClasses.set(filePath, new Set(fileClasses));
60+
this.state.lastUpdate = Date.now();
61+
}
62+
catch (error) {
63+
console.error(`Error reading CSS file ${filePath}:`, error);
64+
this.state.fileClasses.delete(filePath);
65+
}
66+
}
67+
hasClass(className) {
68+
for (const classes of this.state.fileClasses.values()) {
69+
if (classes.has(className)) {
70+
return true;
71+
}
72+
}
73+
return false;
74+
}
75+
// Clean up method to close the watcher when done
76+
async close() {
77+
if (this.watcher) {
78+
await this.watcher.close();
79+
this.watcher = null;
80+
}
81+
}
82+
}
83+
exports.CssWatcher = CssWatcher;

0 commit comments

Comments
 (0)