Skip to content

Commit 13ccf46

Browse files
feat(rule): no-custom-classname
1 parent 1eea1d3 commit 13ccf46

File tree

4 files changed

+258
-37
lines changed

4 files changed

+258
-37
lines changed

src/rules/classnames-order.spec.ts

Lines changed: 8 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,18 @@
1-
/* eslint-disable @typescript-eslint/ban-ts-comment */
2-
3-
import * as AngularParser from "@angular-eslint/template-parser";
41
import * as Parser from "@typescript-eslint/parser";
5-
import {
6-
RuleTester,
7-
TestCaseError,
8-
TestLanguageOptions,
9-
} from "@typescript-eslint/rule-tester";
10-
// @ts-ignore
11-
import * as VueParser from "vue-eslint-parser";
2+
import { RuleTester, TestCaseError } from "@typescript-eslint/rule-tester";
123

13-
import { PluginSettings } from "../utils/parse-plugin-settings";
4+
import {
5+
generalSettings,
6+
prefixedSettings,
7+
withAngularParser,
8+
withTypographySettings,
9+
withVueParser,
10+
} from "../utils/parser/test-helpers";
1411
import { classnamesOrder, RULE_NAME } from "./classnames-order";
1512

1613
const error: TestCaseError<"fix:sort"> = { messageId: "fix:sort" };
1714
const errors = [error];
1815

19-
const withAngularParser: TestLanguageOptions = {
20-
parser: AngularParser,
21-
};
22-
const withVueParser: TestLanguageOptions = {
23-
parser: VueParser,
24-
};
25-
26-
const generalSettings: PluginSettings = {
27-
cssConfigPath:
28-
// @ts-expect-error The 'import.meta' meta-property is not allowed in files which will build into CommonJS output.ts(1470)
29-
`${import.meta.dirname}/../../tests/stubs/css/normal.css`,
30-
};
31-
32-
const prefixedSettings: PluginSettings = {
33-
cssConfigPath:
34-
// @ts-expect-error The 'import.meta' meta-property is not allowed in files which will build into CommonJS output.ts(1470)
35-
`${import.meta.dirname}/../../tests/stubs/css/tiny-prefixed.css`,
36-
};
37-
38-
const withTypographySettings: PluginSettings = {
39-
cssConfigPath:
40-
// @ts-expect-error The 'import.meta' meta-property is not allowed in files which will build into CommonJS output.ts(1470)
41-
`${import.meta.dirname}/../../tests/stubs/css/with-typography.css`,
42-
};
43-
4416
const ruleTester = new RuleTester({
4517
languageOptions: {
4618
parser: Parser,
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import * as Parser from "@typescript-eslint/parser";
2+
import { RuleTester, TestCaseError } from "@typescript-eslint/rule-tester";
3+
4+
import {
5+
generalSettings,
6+
withAngularParser,
7+
} from "../utils/parser/test-helpers";
8+
import { noCustomClassname, RULE_NAME } from "./no-custom-classname";
9+
10+
const error: TestCaseError<"issue:unknown-classname"> = {
11+
messageId: "issue:unknown-classname",
12+
};
13+
const errors = [error];
14+
15+
const ruleTester = new RuleTester({
16+
languageOptions: {
17+
parser: Parser,
18+
parserOptions: {
19+
ecmaFeatures: {
20+
jsx: true,
21+
},
22+
},
23+
},
24+
settings: {
25+
tailwindcss: {
26+
...generalSettings,
27+
},
28+
},
29+
});
30+
31+
ruleTester.run(RULE_NAME, noCustomClassname, {
32+
valid:
33+
// Angular / Native HTML + static text
34+
[
35+
`<h1 class="flex">attributeVisitor with TextAttribute (single class gets skipped)</h1>`,
36+
`<h1 class=" relative ">extra spaces</h1>`,
37+
`<h1 class=" relative " className=' flex'>Single + double quotes</h1>`,
38+
].map((testedNgCode) => ({
39+
code: testedNgCode,
40+
languageOptions: withAngularParser,
41+
})),
42+
invalid: [
43+
{
44+
code: `<h1 class="unknown relative">basic</h1>`,
45+
errors,
46+
languageOptions: withAngularParser,
47+
},
48+
],
49+
});

src/rules/no-custom-classname.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* @fileoverview Detects classnames which do not belong to Tailwind CSS.
3+
* @author François Massart
4+
*/
5+
6+
import { TSESTree } from "@typescript-eslint/utils";
7+
import { RuleCreator } from "@typescript-eslint/utils/eslint-utils";
8+
import { RuleContext as TSESLintRuleContext } from "@typescript-eslint/utils/ts-eslint";
9+
10+
import urlCreator from "../url-creator";
11+
import {
12+
parsePluginSettings,
13+
PluginSettings,
14+
} from "../utils/parse-plugin-settings";
15+
import {
16+
getClassnamesFromValue,
17+
getTemplateElementAffixes,
18+
} from "../utils/parser/node";
19+
import { defineVisitors, GenericRuleContext } from "../utils/parser/visitors";
20+
import {
21+
AtomicNode,
22+
createScriptVisitors,
23+
createTemplateVisitors,
24+
} from "../utils/rule";
25+
import { isValidClassNameWorker } from "../utils/tailwindcss-api";
26+
27+
export { ESLintUtils } from "@typescript-eslint/utils";
28+
29+
export const RULE_NAME = "no-custom-classname";
30+
31+
// Message IDs don't need to be prefixed, I just find it easier to keep track of them this way
32+
type MessageIds = "issue:unknown-classname";
33+
34+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
35+
export type RuleOptions = {};
36+
37+
type Options = [RuleOptions];
38+
39+
type RuleContext = TSESLintRuleContext<MessageIds, Options>;
40+
41+
// The Rule creator returns a function that is used to create a well-typed ESLint rule
42+
// The parameter passed into RuleCreator is a URL generator function.
43+
export const createRule = RuleCreator(urlCreator);
44+
45+
const detectCustomClassnames = (
46+
context: RuleContext,
47+
settings: PluginSettings,
48+
literals: Array<AtomicNode>
49+
) => {
50+
for (const node of literals) {
51+
let originalClassNamesValue = "";
52+
let start = 0;
53+
let end = 0;
54+
let prefix = "";
55+
let suffix = "";
56+
switch (node.type) {
57+
case TSESTree.AST_NODE_TYPES.Literal: {
58+
originalClassNamesValue = "" + node.value;
59+
[start, end] = node.range;
60+
start++;
61+
end--;
62+
break;
63+
}
64+
case TSESTree.AST_NODE_TYPES.TemplateElement: {
65+
originalClassNamesValue = node.value.raw;
66+
if (originalClassNamesValue === "") {
67+
break;
68+
}
69+
[start, end] = node.range;
70+
// https://github.com/eslint/eslint/issues/13360
71+
// The problem is that range computation includes the backticks (`test`)
72+
// but `value.raw` does not include them, so there is a mismatch.
73+
// start/end does not include the backticks, therefore it matches value.raw.
74+
const rawCode = context.sourceCode.getText(
75+
node as unknown as TSESTree.Node
76+
);
77+
[prefix, suffix] = getTemplateElementAffixes(
78+
rawCode,
79+
originalClassNamesValue
80+
);
81+
break;
82+
}
83+
case "TextAttribute": {
84+
originalClassNamesValue = node.value;
85+
start = node.valueSpan.fullStart.offset;
86+
end = node.valueSpan.end.offset;
87+
break;
88+
}
89+
case "VLiteral": {
90+
originalClassNamesValue = "" + node.value;
91+
[start, end] = node.range;
92+
start++;
93+
end--;
94+
break;
95+
}
96+
default: {
97+
// console.log(index, "Unhandled literal type", literal.type);
98+
break;
99+
}
100+
}
101+
// Process the extracted classnames and report
102+
{
103+
const { classNames } = getClassnamesFromValue(originalClassNamesValue);
104+
for (const className of classNames) {
105+
if (!isValidClassNameWorker(settings.cssConfigPath, className)) {
106+
context.report({
107+
node: node as TSESTree.Node,
108+
messageId: "issue:unknown-classname",
109+
data: {
110+
classname: className,
111+
},
112+
});
113+
}
114+
}
115+
}
116+
}
117+
};
118+
119+
export const noCustomClassname = createRule<Options, MessageIds>({
120+
name: RULE_NAME,
121+
meta: {
122+
docs: {
123+
description: "Detects classnames which do not belong to Tailwind CSS.",
124+
},
125+
hasSuggestions: true,
126+
messages: {
127+
"issue:unknown-classname": `Classname '{{classname}}' is not a Tailwind CSS class!`,
128+
},
129+
// Schema is also parsed by `eslint-doc-generator`
130+
schema: [
131+
{
132+
type: "object",
133+
properties: {
134+
whitelist: {
135+
type: "array",
136+
items: { type: "string", minLength: 0 },
137+
uniqueItems: true,
138+
},
139+
},
140+
additionalProperties: false,
141+
},
142+
],
143+
type: "suggestion",
144+
},
145+
/**
146+
* About `defaultOptions`:
147+
* - `defaultOptions` is not parsed to generate the documentation
148+
* - `defaultOptions` is used when options are NOT provided in the rules configuration
149+
* - If some configuration is provided as the second argument, `defaultOptions` is ignored completely (not merged)
150+
* - In other words, the `defaultOptions` is only used when the rule is used WITHOUT any configuration
151+
*/
152+
defaultOptions: [{ whitelist: [] }],
153+
create: (context, options) => {
154+
// Merged settings
155+
const settings = parsePluginSettings(context.settings);
156+
157+
console.log(options);
158+
159+
return defineVisitors(
160+
context as unknown as Readonly<GenericRuleContext>,
161+
// Template visitor is only used within Vue SFC files (inside <template> section).
162+
createTemplateVisitors(
163+
context,
164+
settings,
165+
options,
166+
detectCustomClassnames
167+
),
168+
// Script visitor is used within both JSX and Vue SFC files (inside <script> section).
169+
createScriptVisitors(context, settings, options, detectCustomClassnames)
170+
);
171+
},
172+
});

src/utils/parser/test-helpers.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,45 @@
11
import * as AngularParser from "@angular-eslint/template-parser";
22
import * as Parser from "@typescript-eslint/parser";
3+
import { TestLanguageOptions } from "@typescript-eslint/rule-tester";
34
import { TSESTree } from "@typescript-eslint/utils";
45
import * as VueParser from "vue-eslint-parser";
56
import { VStartTag } from "vue-eslint-parser/ast/index";
67

78
import { GenericElement, TextAttribute } from "../../types";
9+
import { PluginSettings } from "../parse-plugin-settings";
810

911
// This file exposes utils only used during the tests
1012

11-
const withJSX = {
13+
export const withJSX = {
1214
ecmaFeatures: { jsx: true },
1315
};
1416

17+
export const withAngularParser: TestLanguageOptions = {
18+
parser: AngularParser,
19+
};
20+
21+
export const withVueParser: TestLanguageOptions = {
22+
parser: VueParser,
23+
};
24+
25+
export const generalSettings: PluginSettings = {
26+
cssConfigPath:
27+
// @ts-expect-error The 'import.meta' meta-property is not allowed in files which will build into CommonJS output.ts(1470)
28+
`${import.meta.dirname}/../../../tests/stubs/css/normal.css`,
29+
};
30+
31+
export const prefixedSettings: PluginSettings = {
32+
cssConfigPath:
33+
// @ts-expect-error The 'import.meta' meta-property is not allowed in files which will build into CommonJS output.ts(1470)
34+
`${import.meta.dirname}/../../../tests/stubs/css/tiny-prefixed.css`,
35+
};
36+
37+
export const withTypographySettings: PluginSettings = {
38+
cssConfigPath:
39+
// @ts-expect-error The 'import.meta' meta-property is not allowed in files which will build into CommonJS output.ts(1470)
40+
`${import.meta.dirname}/../../../tests/stubs/css/with-typography.css`,
41+
};
42+
1543
const getFirstHTMLOpeningElement = (code: string) => {
1644
const program = AngularParser.parse(code, { filePath: "node.spec.ts" });
1745
const node = program.templateNodes.at(0);

0 commit comments

Comments
 (0)