From 13ccf4607ede7399eaa406b365617b6c974b9498 Mon Sep 17 00:00:00 2001 From: francoismassart Date: Thu, 21 Aug 2025 16:24:50 +0200 Subject: [PATCH 01/11] feat(rule): no-custom-classname --- src/rules/classnames-order.spec.ts | 44 ++----- src/rules/no-custom-classname.spec.ts | 49 ++++++++ src/rules/no-custom-classname.ts | 172 ++++++++++++++++++++++++++ src/utils/parser/test-helpers.ts | 30 ++++- 4 files changed, 258 insertions(+), 37 deletions(-) create mode 100644 src/rules/no-custom-classname.spec.ts create mode 100644 src/rules/no-custom-classname.ts diff --git a/src/rules/classnames-order.spec.ts b/src/rules/classnames-order.spec.ts index bd3fbf5..e47391f 100644 --- a/src/rules/classnames-order.spec.ts +++ b/src/rules/classnames-order.spec.ts @@ -1,46 +1,18 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ - -import * as AngularParser from "@angular-eslint/template-parser"; import * as Parser from "@typescript-eslint/parser"; -import { - RuleTester, - TestCaseError, - TestLanguageOptions, -} from "@typescript-eslint/rule-tester"; -// @ts-ignore -import * as VueParser from "vue-eslint-parser"; +import { RuleTester, TestCaseError } from "@typescript-eslint/rule-tester"; -import { PluginSettings } from "../utils/parse-plugin-settings"; +import { + generalSettings, + prefixedSettings, + withAngularParser, + withTypographySettings, + withVueParser, +} from "../utils/parser/test-helpers"; import { classnamesOrder, RULE_NAME } from "./classnames-order"; const error: TestCaseError<"fix:sort"> = { messageId: "fix:sort" }; const errors = [error]; -const withAngularParser: TestLanguageOptions = { - parser: AngularParser, -}; -const withVueParser: TestLanguageOptions = { - parser: VueParser, -}; - -const generalSettings: PluginSettings = { - cssConfigPath: - // @ts-expect-error The 'import.meta' meta-property is not allowed in files which will build into CommonJS output.ts(1470) - `${import.meta.dirname}/../../tests/stubs/css/normal.css`, -}; - -const prefixedSettings: PluginSettings = { - cssConfigPath: - // @ts-expect-error The 'import.meta' meta-property is not allowed in files which will build into CommonJS output.ts(1470) - `${import.meta.dirname}/../../tests/stubs/css/tiny-prefixed.css`, -}; - -const withTypographySettings: PluginSettings = { - cssConfigPath: - // @ts-expect-error The 'import.meta' meta-property is not allowed in files which will build into CommonJS output.ts(1470) - `${import.meta.dirname}/../../tests/stubs/css/with-typography.css`, -}; - const ruleTester = new RuleTester({ languageOptions: { parser: Parser, diff --git a/src/rules/no-custom-classname.spec.ts b/src/rules/no-custom-classname.spec.ts new file mode 100644 index 0000000..3ca8b27 --- /dev/null +++ b/src/rules/no-custom-classname.spec.ts @@ -0,0 +1,49 @@ +import * as Parser from "@typescript-eslint/parser"; +import { RuleTester, TestCaseError } from "@typescript-eslint/rule-tester"; + +import { + generalSettings, + withAngularParser, +} from "../utils/parser/test-helpers"; +import { noCustomClassname, RULE_NAME } from "./no-custom-classname"; + +const error: TestCaseError<"issue:unknown-classname"> = { + messageId: "issue:unknown-classname", +}; +const errors = [error]; + +const ruleTester = new RuleTester({ + languageOptions: { + parser: Parser, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + settings: { + tailwindcss: { + ...generalSettings, + }, + }, +}); + +ruleTester.run(RULE_NAME, noCustomClassname, { + valid: + // Angular / Native HTML + static text + [ + `

attributeVisitor with TextAttribute (single class gets skipped)

`, + `

extra spaces

`, + `

Single + double quotes

`, + ].map((testedNgCode) => ({ + code: testedNgCode, + languageOptions: withAngularParser, + })), + invalid: [ + { + code: `

basic

`, + errors, + languageOptions: withAngularParser, + }, + ], +}); diff --git a/src/rules/no-custom-classname.ts b/src/rules/no-custom-classname.ts new file mode 100644 index 0000000..e2c7357 --- /dev/null +++ b/src/rules/no-custom-classname.ts @@ -0,0 +1,172 @@ +/** + * @fileoverview Detects classnames which do not belong to Tailwind CSS. + * @author François Massart + */ + +import { TSESTree } from "@typescript-eslint/utils"; +import { RuleCreator } from "@typescript-eslint/utils/eslint-utils"; +import { RuleContext as TSESLintRuleContext } from "@typescript-eslint/utils/ts-eslint"; + +import urlCreator from "../url-creator"; +import { + parsePluginSettings, + PluginSettings, +} from "../utils/parse-plugin-settings"; +import { + getClassnamesFromValue, + getTemplateElementAffixes, +} from "../utils/parser/node"; +import { defineVisitors, GenericRuleContext } from "../utils/parser/visitors"; +import { + AtomicNode, + createScriptVisitors, + createTemplateVisitors, +} from "../utils/rule"; +import { isValidClassNameWorker } from "../utils/tailwindcss-api"; + +export { ESLintUtils } from "@typescript-eslint/utils"; + +export const RULE_NAME = "no-custom-classname"; + +// Message IDs don't need to be prefixed, I just find it easier to keep track of them this way +type MessageIds = "issue:unknown-classname"; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type RuleOptions = {}; + +type Options = [RuleOptions]; + +type RuleContext = TSESLintRuleContext; + +// The Rule creator returns a function that is used to create a well-typed ESLint rule +// The parameter passed into RuleCreator is a URL generator function. +export const createRule = RuleCreator(urlCreator); + +const detectCustomClassnames = ( + context: RuleContext, + settings: PluginSettings, + literals: Array +) => { + for (const node of literals) { + let originalClassNamesValue = ""; + let start = 0; + let end = 0; + let prefix = ""; + let suffix = ""; + switch (node.type) { + case TSESTree.AST_NODE_TYPES.Literal: { + originalClassNamesValue = "" + node.value; + [start, end] = node.range; + start++; + end--; + break; + } + case TSESTree.AST_NODE_TYPES.TemplateElement: { + originalClassNamesValue = node.value.raw; + if (originalClassNamesValue === "") { + break; + } + [start, end] = node.range; + // https://github.com/eslint/eslint/issues/13360 + // The problem is that range computation includes the backticks (`test`) + // but `value.raw` does not include them, so there is a mismatch. + // start/end does not include the backticks, therefore it matches value.raw. + const rawCode = context.sourceCode.getText( + node as unknown as TSESTree.Node + ); + [prefix, suffix] = getTemplateElementAffixes( + rawCode, + originalClassNamesValue + ); + break; + } + case "TextAttribute": { + originalClassNamesValue = node.value; + start = node.valueSpan.fullStart.offset; + end = node.valueSpan.end.offset; + break; + } + case "VLiteral": { + originalClassNamesValue = "" + node.value; + [start, end] = node.range; + start++; + end--; + break; + } + default: { + // console.log(index, "Unhandled literal type", literal.type); + break; + } + } + // Process the extracted classnames and report + { + const { classNames } = getClassnamesFromValue(originalClassNamesValue); + for (const className of classNames) { + if (!isValidClassNameWorker(settings.cssConfigPath, className)) { + context.report({ + node: node as TSESTree.Node, + messageId: "issue:unknown-classname", + data: { + classname: className, + }, + }); + } + } + } + } +}; + +export const noCustomClassname = createRule({ + name: RULE_NAME, + meta: { + docs: { + description: "Detects classnames which do not belong to Tailwind CSS.", + }, + hasSuggestions: true, + messages: { + "issue:unknown-classname": `Classname '{{classname}}' is not a Tailwind CSS class!`, + }, + // Schema is also parsed by `eslint-doc-generator` + schema: [ + { + type: "object", + properties: { + whitelist: { + type: "array", + items: { type: "string", minLength: 0 }, + uniqueItems: true, + }, + }, + additionalProperties: false, + }, + ], + type: "suggestion", + }, + /** + * About `defaultOptions`: + * - `defaultOptions` is not parsed to generate the documentation + * - `defaultOptions` is used when options are NOT provided in the rules configuration + * - If some configuration is provided as the second argument, `defaultOptions` is ignored completely (not merged) + * - In other words, the `defaultOptions` is only used when the rule is used WITHOUT any configuration + */ + defaultOptions: [{ whitelist: [] }], + create: (context, options) => { + // Merged settings + const settings = parsePluginSettings(context.settings); + + console.log(options); + + return defineVisitors( + context as unknown as Readonly, + // Template visitor is only used within Vue SFC files (inside