diff --git a/README.md b/README.md index 6a1d8c5..bfc6d72 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ 🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\ 💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). -| Name             | Description | 🔧 | 💡 | -| :------------------------------------------------- | :---------------------------------------------------------------------------------- | :- | :- | -| [classnames-order](docs/rules/classnames-order.md) | Enforces a consistent order for the Tailwind CSS classnames, based on the compiler. | 🔧 | 💡 | +| Name                | Description | 🔧 | 💡 | +| :------------------------------------------------------- | :---------------------------------------------------------------------------------- | :- | :- | +| [classnames-order](docs/rules/classnames-order.md) | Enforces a consistent order for the Tailwind CSS classnames, based on the compiler. | 🔧 | 💡 | +| [no-custom-classname](docs/rules/no-custom-classname.md) | Detects classnames which do not belong to Tailwind CSS. | | 💡 | diff --git a/docs/rules/no-custom-classname.md b/docs/rules/no-custom-classname.md new file mode 100644 index 0000000..0609fae --- /dev/null +++ b/docs/rules/no-custom-classname.md @@ -0,0 +1,15 @@ +# Detects classnames which do not belong to Tailwind CSS + +💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). + + + +## Options + + + +| Name | Type | +| :---------- | :------- | +| `whitelist` | String[] | + + diff --git a/eslint.config.mjs b/eslint.config.mjs index a4d94be..2419be9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,3 +1,6 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + import pluginJs from "@eslint/js"; import importPlugin from "eslint-plugin-import"; import simpleImportSort from "eslint-plugin-simple-import-sort"; @@ -9,7 +12,14 @@ import tseslint from "typescript-eslint"; export default [ { files: ["**/*.{js,mjs,cjs,ts}"] }, { ignores: ["lib/**"] }, - { languageOptions: { globals: globals.browser } }, + { + languageOptions: { + globals: globals.browser, + parserOptions: { + tsconfigRootDir: path.dirname(fileURLToPath(import.meta.url)), + }, + }, + }, pluginJs.configs.recommended, ...tseslint.configs.recommended, { diff --git a/package.json b/package.json index dc599ab..847a149 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "homepage": "https://github.com/francoismassart/eslint-plugin-tailwindcss", "bugs": "https://github.com/francoismassart/eslint-plugin-tailwindcss/issues", "main": "lib/index.js", + "types": "lib/index.d.ts", "files": [ "docs/", "lib/" diff --git a/src/_sandbox/typeguard-temporary.ts b/src/_sandbox/typeguard-temporary.ts new file mode 100644 index 0000000..8bed1a8 --- /dev/null +++ b/src/_sandbox/typeguard-temporary.ts @@ -0,0 +1,20 @@ +interface Cat { + meow(): void; +} +interface Dog { + bark(): void; +} +function isCat(pet: Dog | Cat): pet is Cat { + return (pet as Cat).meow !== undefined; +} +const pet: Cat | Dog = + Math.random() > 0.5 + ? { meow: () => console.log("Meow") } + : { bark: () => console.log("Bark") }; + +// Using the 'is' keyword +if (isCat(pet)) { + pet.meow(); +} else { + pet.bark(); +} diff --git a/src/index.ts b/src/index.ts index 8461b79..d24638b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ import * as parserBase from "@typescript-eslint/parser"; import { TSESLint } from "@typescript-eslint/utils"; -import { Linter } from "@typescript-eslint/utils/ts-eslint"; +import { FlatConfig, Linter } from "@typescript-eslint/utils/ts-eslint"; -import { rules } from "./rules"; +import { recommendedRulesConfig, rules } from "./rules"; export const parser: TSESLint.FlatConfig.Parser = { meta: parserBase.meta, @@ -27,13 +27,46 @@ const { name, version } = // Plugin not fully initialized yet. // See https://eslint.org/docs/latest/extend/plugins#configs-in-plugins const plugin = { - // `configs`, assigned later - configs: {}, - rules, meta: { name, version, }, + // `configs`, assigned later + configs: {}, + rules, } satisfies Linter.Plugin; +// Config base for all configurations +const configBase: FlatConfig.Config = { + name: "tailwindcss/base", + plugins: { + tailwindcss: plugin, + }, + settings: { + tailwindcss: {}, + }, + files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], + languageOptions: { + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + }, +}; + +// Prepare configs here so we can reference `plugin` +const sharedConfigs: FlatConfig.SharedConfigs = { + recommended: { + ...configBase, + name: "tailwindcss/recommended", + rules: recommendedRulesConfig, + }, +}; + +// Inject shared configs into the plugin +Object.assign(plugin.configs, sharedConfigs); + export default plugin; 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/classnames-order.ts b/src/rules/classnames-order.ts index de08ca1..f4e73d2 100644 --- a/src/rules/classnames-order.ts +++ b/src/rules/classnames-order.ts @@ -13,8 +13,8 @@ import { PluginSettings, } from "../utils/parse-plugin-settings"; import { + dissectAtomicNode, getClassnamesFromValue, - getTemplateElementAffixes, } from "../utils/parser/node"; import { defineVisitors, GenericRuleContext } from "../utils/parser/visitors"; import { @@ -48,91 +48,38 @@ const sortClassnames = ( 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; - } - } + const { originalClassNamesValue, start, end, prefix, suffix } = + dissectAtomicNode(node, context as unknown as GenericRuleContext); // Process the extracted classnames and report - { - const { classNames, whitespaces, headSpace, tailSpace } = - getClassnamesFromValue(originalClassNamesValue); - // Skip empty/Single className - if (classNames.length <= 1) continue; - const orderedClassNames = getSortedClassNamesWorker( - settings.cssConfigPath, - classNames - ); + const { classNames, whitespaces, headSpace, tailSpace } = + getClassnamesFromValue(originalClassNamesValue); + // Skip empty/Single className + if (classNames.length <= 1) continue; + const orderedClassNames = getSortedClassNamesWorker( + settings.cssConfigPath, + classNames + ); - // Generates the validated/sorted attribute value - let validatedClassNamesValue = ""; - for (let index = 0; index < orderedClassNames.length; index++) { - const w = whitespaces[index] ?? ""; - const cls = orderedClassNames[index]; - validatedClassNamesValue += headSpace ? `${w}${cls}` : `${cls}${w}`; - if (headSpace && tailSpace && index === orderedClassNames.length - 1) { - validatedClassNamesValue += whitespaces.at(-1) ?? ""; - } + // Generates the validated/sorted attribute value + let validatedClassNamesValue = ""; + for (let index = 0; index < orderedClassNames.length; index++) { + const w = whitespaces[index] ?? ""; + const cls = orderedClassNames[index]; + validatedClassNamesValue += headSpace ? `${w}${cls}` : `${cls}${w}`; + if (headSpace && tailSpace && index === orderedClassNames.length - 1) { + validatedClassNamesValue += whitespaces.at(-1) ?? ""; } + } - if (originalClassNamesValue !== validatedClassNamesValue) { - validatedClassNamesValue = prefix + validatedClassNamesValue + suffix; - context.report({ - node: node as TSESTree.Node, - messageId: "fix:sort", - fix: function (fixer) { - return fixer.replaceTextRange( - [start, end], - validatedClassNamesValue - ); - }, - }); - } + if (originalClassNamesValue !== validatedClassNamesValue) { + validatedClassNamesValue = prefix + validatedClassNamesValue + suffix; + context.report({ + node: node as TSESTree.Node, + messageId: "fix:sort", + fix: function (fixer) { + return fixer.replaceTextRange([start, end], validatedClassNamesValue); + }, + }); } } }; diff --git a/src/rules/index.ts b/src/rules/index.ts index d52f3db..71cd151 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -1,8 +1,20 @@ +import { Linter } from "@typescript-eslint/utils/ts-eslint"; + import { classnamesOrder, RULE_NAME as CLASSNAMES_ORDER, } from "./classnames-order"; +import { + noCustomClassname, + RULE_NAME as NO_CUSTOM_CLASSNAME, +} from "./no-custom-classname"; -export const rules = { +export const rules: Linter.PluginRules = { [CLASSNAMES_ORDER]: classnamesOrder, + [NO_CUSTOM_CLASSNAME]: noCustomClassname, +}; + +export const recommendedRulesConfig: Linter.RulesRecord = { + [CLASSNAMES_ORDER]: "error", + [NO_CUSTOM_CLASSNAME]: "warn", }; 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..ecf5b4c --- /dev/null +++ b/src/rules/no-custom-classname.ts @@ -0,0 +1,128 @@ +/** + * @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 { + dissectAtomicNode, + getClassnamesFromValue, +} 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) { + const { originalClassNamesValue } = dissectAtomicNode( + node, + context as unknown as GenericRuleContext + ); + // 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, + // TODO see if useful + // context.sourceCode.getLocFromIndex(0) + // loc: { column: 1, line: 1 }, + // loc: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } }, + 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