diff --git a/README.md b/README.md index c9ffc61..7d97423 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,8 @@

-ESLint plugin to automatically break up long tailwind class strings into multiple lines based on a specified print width or class count. This improves readability and eliminates horizontal scrolling. -In addition it sorts the classes logically, removes unnecessary whitespaces and duplicate classes and groups the classes by their variants. It works in React, Solid.js, Qwik, Svelte, Vue, Angular, HTML, JavaScript and TypeScript projects. +ESLint plugin with a strong focus on improving readability of lengthy tailwindcss class strings. The core feature is to automatically break up long tailwind class strings into multiple lines based on a specified print width or class count. This makes your code cleaner and easier to read while eliminating the need for horizontal scrolling. +Beyond formatting, it also sorts classes in a logical order, removes duplicates and unnecessary whitespace, and groups classes by their variants. It works in React, Solid.js, Qwik, Svelte, Vue, Angular, HTML, JavaScript and TypeScript projects.

@@ -161,6 +161,7 @@ The following table shows the available rules and if they are enabled by default | [no-unnecessary-whitespace](docs/rules/no-unnecessary-whitespace.md) | Disallow unnecessary whitespace in tailwind classes. | ✔ | ✔ | ✔ | | [sort-classes](docs/rules/sort-classes.md) | Enforce a consistent order for tailwind classes. | ✔ | ✔ | ✔ | | [no-duplicate-classes](docs/rules/no-duplicate-classes.md) | Remove duplicate classes. | ✔ | ✔ | ✔ | +| [no-unregistered-classes](docs/rules/no-unregistered-classes.md) | Report classes not registered with tailwindcss. | | | |

@@ -195,7 +196,7 @@ Read the [API documentation](./docs/api/defaults.md) to learn how to override or ##### Auto-fix on save -All rules are intended to automatically fix the tailwind classes. If you have installed the [VSCode ESLint plugin](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint), you can configure it to automatically fix the classes on save by adding the following options to your `.vscode/settings.json`: +Most rules are intended to automatically fix the tailwind classes. If you have installed the [VSCode ESLint plugin](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint), you can configure it to automatically fix the classes on save by adding the following options to your `.vscode/settings.json`: ```jsonc { diff --git a/docs/rules/no-unregistered-classes.md b/docs/rules/no-unregistered-classes.md new file mode 100644 index 0000000..faee806 --- /dev/null +++ b/docs/rules/no-unregistered-classes.md @@ -0,0 +1,70 @@ +# readable-tailwind/no-unregistered-classes + +Disallow unregistered classes in tailwindcss class strings. Unregistered classes are classes that are not defined in your tailwind config file and therefore not recognized by tailwindcss. + +
+ +## Options + +### `ignore` + + List of classes that should not report an error. The entries in this list are treated as regular expressions. + + The rule works, by checking the output that a given class will produce. By default, the utilities `group` and `peer` are ignored, because they don't produce any css output. + + If you want to customize the ignore list, it is recommended to add the default options to the ignore override. You can use the function `getDefaultIgnoredUnregisteredClasses()` exported from `/api/defaults` to get the original ignore list. + + **Type**: `string[]` + **Default**: `["^group(?:\\/(\\S*))?$", "^peer(?:\\/(\\S*))?$"]` + +
+ +### `attributes` + + The name of the attribute that contains the tailwind classes. This can also be set globally via the [`settings` object](../settings/settings.md#attributes). + + **Type**: Array of [Name](../concepts/concepts.md#name), [Regex](../concepts/concepts.md#regular-expressions) or [Matchers](../concepts/concepts.md#matchers) + **Default**: [Name](../concepts/concepts.md#name) for `"class"` and [strings Matcher](../concepts/concepts.md#types-of-matchers) for `"class", "className"` + +
+ +### `callees` + + List of function names which arguments should also get linted. This can also be set globally via the [`settings` object](../settings/settings.md#callees). + + **Type**: Array of [Name](../concepts/concepts.md#name), [Regex](../concepts/concepts.md#regular-expressions) or [Matchers](../concepts/concepts.md#matchers) + **Default**: [Matchers](../concepts/concepts.md#types-of-matchers) for `"cc", "clb", "clsx", "cn", "cnb", "ctl", "cva", "cx", "dcnb", "objstr", "tv", "twJoin", "twMerge"` + +
+ +### `variables` + + List of variable names whose initializer should also get linted. This can also be set globally via the [`settings` object](../settings/settings.md#variables). + + **Type**: Array of [Name](../concepts/concepts.md#name), [Regex](../concepts/concepts.md#regular-expressions) or [Matchers](../concepts/concepts.md#matchers) + **Default**: [strings Matcher](../concepts/concepts.md#types-of-matchers) for `"className", "classNames", "classes", "style", "styles"` + +
+ +### `tags` + + List of template literal tag names whose content should get linted. This can also be set globally via the [`settings` object](../settings/settings.md#tags). + + **Type**: Array of [Name](../concepts/concepts.md#name), [Regex](../concepts/concepts.md#regular-expressions) or [Matchers](../concepts/concepts.md#matchers) + **Default**: None + + Note: When using the `tags` option, it is recommended to use the [strings Matcher](../concepts/concepts.md#types-of-matchers) for your tag names. This will ensure that nested expressions get linted correctly. + +
+ +## Examples + +```tsx +// ❌ BAD: unregistered class +
; +``` + +```tsx +// ✅ GOOD: only valid tailwindcss classes +
; +``` diff --git a/package-lock.json b/package-lock.json index e722b4b..28358d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "eslint-plugin-readable-tailwind", - "version": "2.1.1", + "version": "2.2.0-no-unregistered-classes.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "eslint-plugin-readable-tailwind", - "version": "2.1.1", + "version": "2.2.0-no-unregistered-classes.0", "license": "MIT", "dependencies": { "enhanced-resolve": "^5.18.1", @@ -2092,6 +2092,28 @@ } } }, + "node_modules/@schoero/configs/node_modules/eslint-plugin-readable-tailwind": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-readable-tailwind/-/eslint-plugin-readable-tailwind-2.1.0.tgz", + "integrity": "sha512-H52qCofokg5rgjOSs+e/oOBIh1BkT+kaA55IuULufVrnkEY5o0U3p5ZTK/NnhYNNqeFzgEwLCmpRWfYgeG5mGA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "postcss": "^8.5.3", + "postcss-import": "^16.1.0", + "synckit": "0.9.2" + }, + "engines": { + "node": ">=v20.11.0" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0", + "tailwindcss": "^3.3.0 || ^4.0.0" + } + }, "node_modules/@sindresorhus/merge-streams": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", diff --git a/package.json b/package.json index e9099ed..0d677f7 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "2.1.1", + "version": "2.2.0-no-unregistered-classes.0", "type": "module", "name": "eslint-plugin-readable-tailwind", "description": "auto-wraps tailwind classes after a certain print width or class count into multiple lines to improve readability.", diff --git a/src/api/defaults.ts b/src/api/defaults.ts index 34e2d0f..e03fac3 100644 --- a/src/api/defaults.ts +++ b/src/api/defaults.ts @@ -4,6 +4,7 @@ import { DEFAULT_TAG_NAMES, DEFAULT_VARIABLE_NAMES } from "readable-tailwind:options:default-options.js"; +import { DEFAULT_IGNORED_UNREGISTERED_CLASSES } from "readable-tailwind:rules:tailwind-no-unregistered-classes.js"; export function getDefaultCallees() { @@ -21,3 +22,7 @@ export function getDefaultVariables() { export function getDefaultTags() { return DEFAULT_TAG_NAMES; } + +export function getDefaultIgnoredUnregisteredClasses() { + return DEFAULT_IGNORED_UNREGISTERED_CLASSES; +} diff --git a/src/configs/config.ts b/src/configs/config.ts index 2d848f0..71f1fe4 100644 --- a/src/configs/config.ts +++ b/src/configs/config.ts @@ -1,6 +1,7 @@ import { tailwindMultiline } from "readable-tailwind:rules:tailwind-multiline.js"; import { tailwindNoDuplicateClasses } from "readable-tailwind:rules:tailwind-no-duplicate-classes.js"; import { tailwindNoUnnecessaryWhitespace } from "readable-tailwind:rules:tailwind-no-unnecessary-whitespace.js"; +import { tailwindNoUnregisteredClasses } from "readable-tailwind:rules:tailwind-no-unregistered-classes.js"; import { tailwindSortClasses } from "readable-tailwind:rules:tailwind-sort-classes.js"; import type { ESLint } from "eslint"; @@ -31,6 +32,7 @@ export const config = { [tailwindMultiline.name]: tailwindMultiline.rule, [tailwindNoDuplicateClasses.name]: tailwindNoDuplicateClasses.rule, [tailwindNoUnnecessaryWhitespace.name]: tailwindNoUnnecessaryWhitespace.rule, + [tailwindNoUnregisteredClasses.name]: tailwindNoUnregisteredClasses.rule, [tailwindSortClasses.name]: tailwindSortClasses.rule } } satisfies ESLint.Plugin; diff --git a/src/options/descriptions.ts b/src/options/descriptions.ts index f7b7b0b..4bdc763 100644 --- a/src/options/descriptions.ts +++ b/src/options/descriptions.ts @@ -272,3 +272,17 @@ export const TAG_SCHEMA = { type: "array" } } satisfies Rule.RuleMetaData["schema"]; + +export const ENTRYPOINT_SCHEMA = { + entryPoint: { + description: "The path to the css entry point of the project. If not specified, the plugin will fall back to the default tailwind classes.", + type: "string" + } +}; + +export const TAILWIND_CONFIG_SCHEMA = { + tailwindConfig: { + description: "The path to the tailwind config file. If not specified, the plugin will try to find it automatically or falls back to the default configuration.", + type: "string" + } +}; diff --git a/src/rules/tailwind-no-unregistered-classes.test.ts b/src/rules/tailwind-no-unregistered-classes.test.ts new file mode 100644 index 0000000..758ab7b --- /dev/null +++ b/src/rules/tailwind-no-unregistered-classes.test.ts @@ -0,0 +1,211 @@ +import { getTailwindcssVersion } from "src/tailwind/utils/version.js"; +import { describe, it } from "vitest"; + +import { tailwindNoUnregisteredClasses } from "readable-tailwind:rules:tailwind-no-unregistered-classes.js"; +import { lint, TEST_SYNTAXES } from "readable-tailwind:tests:utils.js"; + + +describe(tailwindNoUnregisteredClasses.name, () => { + + it("should not report standard tailwind classes", () => { + lint( + tailwindNoUnregisteredClasses, + TEST_SYNTAXES, + { + valid: [ + { + angular: ``, + html: ``, + jsx: `() => `, + svelte: ``, + vue: `` + } + ] + } + ); + }); + + it("should not report standard tailwind classes with variants", () => { + lint( + tailwindNoUnregisteredClasses, + TEST_SYNTAXES, + { + valid: [ + { + angular: ``, + html: ``, + jsx: `() => `, + svelte: ``, + vue: `` + } + ] + } + ); + }); + + it("should not report standard tailwind classes with many variants", () => { + lint( + tailwindNoUnregisteredClasses, + TEST_SYNTAXES, + { + valid: [ + { + angular: ``, + html: ``, + jsx: `() => `, + svelte: ``, + vue: `` + } + ] + } + ); + }); + + it("should report standard tailwind classes with an unregistered variant in many variants", () => { + lint( + tailwindNoUnregisteredClasses, + TEST_SYNTAXES, + { + invalid: [ + { + angular: ``, + errors: 1, + html: ``, + jsx: `() => `, + svelte: ``, + vue: `` + } + ] + } + ); + }); + + it.skipIf(getTailwindcssVersion().major < 4)("should not report on dynamic utility values in tailwind >= 4", () => { + lint( + tailwindNoUnregisteredClasses, + TEST_SYNTAXES, + { + valid: [ + { + angular: ``, + html: ``, + jsx: `() => `, + svelte: ``, + vue: `` + } + ] + } + ); + }); + + it.skipIf(getTailwindcssVersion().major > 3)("should report on dynamic utility values in tailwind <= 3", () => { + lint( + tailwindNoUnregisteredClasses, + TEST_SYNTAXES, + { + invalid: [ + { + angular: ``, + errors: 1, + html: ``, + jsx: `() => `, + svelte: ``, + vue: `` + } + ] + } + ); + }); + + it("should report unregistered classes", () => { + lint( + tailwindNoUnregisteredClasses, + TEST_SYNTAXES, + { + invalid: [ + { + angular: ``, + errors: 1, + html: ``, + jsx: `() => `, + svelte: ``, + vue: `` + } + ] + } + ); + }); + + it("should be possible to whitelist classes in options", () => { + lint( + tailwindNoUnregisteredClasses, + TEST_SYNTAXES, + { + valid: [ + { + angular: ``, + html: ``, + jsx: `() => `, + options: [{ ignore: ["unregistered"] }], + svelte: ``, + vue: `` + } + ] + } + ); + }); + + it("should be possible to whitelist classes in options via regex", () => { + lint( + tailwindNoUnregisteredClasses, + TEST_SYNTAXES, + { + valid: [ + { + angular: ``, + html: ``, + jsx: `() => `, + options: [{ ignore: ["^ignored-.*$"] }], + svelte: ``, + vue: `` + } + ] + } + ); + }); + + it("should not report on tailwind utility classes that don't produce a css output", () => { + lint( + tailwindNoUnregisteredClasses, + TEST_SYNTAXES, + { + valid: [ + { + angular: ``, + html: ``, + jsx: `() => `, + svelte: ``, + vue: `` + } + ] + } + ); + + lint( + tailwindNoUnregisteredClasses, + TEST_SYNTAXES, + { + valid: [ + { + angular: ``, + html: ``, + jsx: `() => `, + svelte: ``, + vue: `` + } + ] + } + ); + }); + +}); diff --git a/src/rules/tailwind-no-unregistered-classes.ts b/src/rules/tailwind-no-unregistered-classes.ts new file mode 100644 index 0000000..8516173 --- /dev/null +++ b/src/rules/tailwind-no-unregistered-classes.ts @@ -0,0 +1,185 @@ +import { getUnregisteredClasses } from "readable-tailwind:async:unregistered-classes.sync.js"; +import { + DEFAULT_ATTRIBUTE_NAMES, + DEFAULT_CALLEE_NAMES, + DEFAULT_TAG_NAMES, + DEFAULT_VARIABLE_NAMES +} from "readable-tailwind:options:default-options.js"; +import { + ATTRIBUTE_SCHEMA, + CALLEE_SCHEMA, + ENTRYPOINT_SCHEMA, + TAG_SCHEMA, + TAILWIND_CONFIG_SCHEMA, + VARIABLE_SCHEMA +} from "readable-tailwind:options:descriptions.js"; +import { createRuleListener } from "readable-tailwind:utils:rule.js"; +import { + augmentMessageWithWarnings, + display, + escapeForRegex, + getCommonOptions, + splitClasses +} from "readable-tailwind:utils:utils.js"; + +import type { Rule } from "eslint"; + +import type { Literal, Loc } from "readable-tailwind:types:ast.js"; +import type { + AttributeOption, + CalleeOption, + ESLintRule, + TagOption, + VariableOption +} from "readable-tailwind:types:rule.js"; + + +export type Options = [ + Partial< + AttributeOption & + CalleeOption & + TagOption & + VariableOption & + { + entryPoint?: string; + ignore?: string[]; + tailwindConfig?: string; + } + > +]; + +export const DEFAULT_IGNORED_UNREGISTERED_CLASSES = [ + "^group(?:\\/(\\S*))?$", + "^peer(?:\\/(\\S*))?$" +]; + +const defaultOptions = { + attributes: DEFAULT_ATTRIBUTE_NAMES, + callees: DEFAULT_CALLEE_NAMES, + ignore: DEFAULT_IGNORED_UNREGISTERED_CLASSES, + tags: DEFAULT_TAG_NAMES, + variables: DEFAULT_VARIABLE_NAMES +} as const satisfies Options[0]; + +const DOCUMENTATION_URL = "https://github.com/schoero/eslint-plugin-readable-tailwind/blob/main/docs/rules/no-unregistered-classes.md"; + +export const tailwindNoUnregisteredClasses: ESLintRule = { + name: "no-unregistered-classes" as const, + rule: { + create: ctx => createRuleListener(ctx, getOptions(ctx), lintLiterals), + meta: { + docs: { + description: "Disallow any css classes that are not registered in tailwindcss.", + recommended: false, + url: DOCUMENTATION_URL + }, + fixable: "code", + schema: [ + { + additionalProperties: false, + properties: { + ...CALLEE_SCHEMA, + ...ATTRIBUTE_SCHEMA, + ...VARIABLE_SCHEMA, + ...TAG_SCHEMA, + ...ENTRYPOINT_SCHEMA, + ...TAILWIND_CONFIG_SCHEMA, + ignore: { + description: "A list of classes that should be ignored by the rule.", + items: { + type: "string" + }, + type: "array" + } + }, + type: "object" + } + ], + type: "problem" + } + } +}; + +function lintLiterals(ctx: Rule.RuleContext, literals: Literal[]) { + + for(const literal of literals){ + + const { ignore, tailwindConfig } = getOptions(ctx); + + const classes = splitClasses(literal.content); + + const [unregisteredClasses, warnings] = getUnregisteredClasses({ classes, configPath: tailwindConfig, cwd: ctx.cwd }); + + const unregisteredClassesWarnings = warnings.map(warning => ({ ...warning, url: DOCUMENTATION_URL })); + + if(unregisteredClasses.length === 0){ + continue; + } + + for(const unregisteredClass of unregisteredClasses){ + if(ignore.some(ignoredClass => unregisteredClass.match(ignoredClass))){ + continue; + } + + ctx.report({ + data: { + unregistered: display(unregisteredClass) + }, + loc: getExactLocation(literal.loc, literal, unregisteredClass), + message: augmentMessageWithWarnings("Unregistered class detected: {{ unregistered }}", unregisteredClassesWarnings) + }); + } + + } +} + +function getExactLocation(loc: Loc["loc"], literal: Literal, className: string) { + const escapedClass = escapeForRegex(className); + const regex = new RegExp(`(?:^|\\s+)(${escapedClass})(?=\\s+|$)`); + const match = literal.content.match(regex); + + if(match?.index === undefined){ + return loc; + } + + const fullMatchIndex = match.index; + const word = match?.[1]; + const indexOfClass = fullMatchIndex + match[0].indexOf(word); + + const linesUpToStartIndex = literal.content.slice(0, indexOfClass).split("\n"); + const isOnFirstLine = linesUpToStartIndex.length === 1; + const containingLine = linesUpToStartIndex.at(-1); + + const line = loc.start.line + linesUpToStartIndex.length - 1; + const column = ( + isOnFirstLine + ? loc.start.column + (literal.openingQuote?.length ?? 0) + : 0 + ) + (containingLine?.length ?? 0); + + return { + end: { + column: column + className.length, + line + }, + start: { + column, + line + } + }; +} + +export function getOptions(ctx: Rule.RuleContext) { + + const options: Options[0] = ctx.options[0] ?? {}; + + const common = getCommonOptions(ctx); + + const ignore = options.ignore ?? defaultOptions.ignore; + + return { + ...common, + ignore + }; + +} diff --git a/src/rules/tailwind-sort-classes.ts b/src/rules/tailwind-sort-classes.ts index bd40af1..57e7162 100644 --- a/src/rules/tailwind-sort-classes.ts +++ b/src/rules/tailwind-sort-classes.ts @@ -8,7 +8,9 @@ import { import { ATTRIBUTE_SCHEMA, CALLEE_SCHEMA, + ENTRYPOINT_SCHEMA, TAG_SCHEMA, + TAILWIND_CONFIG_SCHEMA, VARIABLE_SCHEMA } from "readable-tailwind:options:descriptions.js"; import { escapeNestedQuotes } from "readable-tailwind:utils:quotes.js"; @@ -78,10 +80,8 @@ export const tailwindSortClasses: ESLintRule = { ...ATTRIBUTE_SCHEMA, ...VARIABLE_SCHEMA, ...TAG_SCHEMA, - entryPoint: { - description: "The path to the css entry point of the project. If not specified, the plugin will fall back to the default tailwind classes.", - type: "string" - }, + ...ENTRYPOINT_SCHEMA, + ...TAILWIND_CONFIG_SCHEMA, order: { default: defaultOptions.order, description: "The algorithm to use when sorting classes.", @@ -92,10 +92,6 @@ export const tailwindSortClasses: ESLintRule = { "improved" ], type: "string" - }, - tailwindConfig: { - description: "The path to the tailwind config file. If not specified, the plugin will try to find it automatically or falls back to the default configuration.", - type: "string" } }, type: "object" diff --git a/src/tailwind/api/interface.ts b/src/tailwind/api/interface.ts index 4a1f92a..56f33a6 100644 --- a/src/tailwind/api/interface.ts +++ b/src/tailwind/api/interface.ts @@ -7,6 +7,13 @@ export interface GetClassOrderRequest { configPath?: string; } +export interface GetUnregisteredClassesRequest { + classes: string[]; + cwd: string; + configPath?: string; +} + export type ConfigWarning = Omit & Partial>; export type GetClassOrderResponse = [classOrder: [className: string, order: bigint | null][], warnings: ConfigWarning[]]; +export type GetUnregisteredClassesResponse = [unregisteredClasses: string[], warnings: ConfigWarning[]]; diff --git a/src/tailwind/async/unregistered-classes.async.ts b/src/tailwind/async/unregistered-classes.async.ts new file mode 100644 index 0000000..df06f24 --- /dev/null +++ b/src/tailwind/async/unregistered-classes.async.ts @@ -0,0 +1,12 @@ +import { runAsWorker } from "synckit"; + +import type { GetUnregisteredClassesRequest } from "../api/interface.js"; +import type { SupportedTailwindVersion } from "../utils/version.js"; + + +let getUnregisteredClassesModule: typeof import("../v3/unregistered-classes.js") | typeof import("../v4/unregistered-classes.js"); + +runAsWorker(async (version: SupportedTailwindVersion, request: GetUnregisteredClassesRequest) => { + getUnregisteredClassesModule ??= await import(`../v${version}/unregistered-classes.js`); + return getUnregisteredClassesModule.getUnregisteredClasses(request); +}); diff --git a/src/tailwind/async/unregistered-classes.sync.ts b/src/tailwind/async/unregistered-classes.sync.ts new file mode 100644 index 0000000..d0f19c8 --- /dev/null +++ b/src/tailwind/async/unregistered-classes.sync.ts @@ -0,0 +1,43 @@ +// runner.js +import { resolve } from "node:path"; +import { env } from "node:process"; + +import { createSyncFn, TsRunner } from "synckit"; + +import { getTailwindcssVersion, isSupportedVersion } from "../utils/version.js"; + +import type { GetUnregisteredClassesRequest, GetUnregisteredClassesResponse } from "../api/interface.js"; +import type { SupportedTailwindVersion } from "../utils/version.js"; + + +const workerPath = getWorkerPath(); +const version = getTailwindcssVersion(); +const workerOptions = getWorkerOptions(); + +const getUnregisteredClassesSync = createSyncFn<(version: SupportedTailwindVersion, request: GetUnregisteredClassesRequest) => any>(workerPath, workerOptions); + + +export function getUnregisteredClasses(request: GetUnregisteredClassesRequest): GetUnregisteredClassesResponse { + if(!isSupportedVersion(version.major)){ + throw new Error(`Unsupported Tailwind CSS version: ${version.major}`); + } + + return getUnregisteredClassesSync(version.major, request) as GetUnregisteredClassesResponse; +} + + +function getWorkerPath() { + return resolve(getCurrentDirectory(), "./unregistered-classes.async.js"); +} + +function getWorkerOptions() { + if(env.NODE_ENV === "test"){ + return { execArgv: ["--import", TsRunner.TSX] }; + } +} + +function getCurrentDirectory() { + // eslint-disable-next-line eslint-plugin-typescript/prefer-ts-expect-error + // @ts-ignore - `import.meta` doesn't exist in CommonJS -> will be transformed in build step + return import.meta.dirname; +} diff --git a/src/tailwind/v3/unregistered-classes.ts b/src/tailwind/v3/unregistered-classes.ts new file mode 100644 index 0000000..cb89fa9 --- /dev/null +++ b/src/tailwind/v3/unregistered-classes.ts @@ -0,0 +1,33 @@ +import * as rules from "tailwindcss3/lib/lib/generateRules.js"; + +import { findTailwindConfig } from "./config.js"; +import { createTailwindContextFromConfigFile } from "./context.js"; + +import type { + ConfigWarning, + GetUnregisteredClassesRequest, + GetUnregisteredClassesResponse +} from "../api/interface.js"; + + +export async function getUnregisteredClasses({ classes, configPath, cwd }: GetUnregisteredClassesRequest): Promise { + const warnings: ConfigWarning[] = []; + + const config = findTailwindConfig(cwd, configPath); + + if(!config){ + warnings.push({ + option: "entryPoint", + title: `No tailwind css config found at \`${configPath}\`` + }); + } + + const context = createTailwindContextFromConfigFile(config?.path, config?.invalidate); + + const invalidClasses = classes + .filter(className => { + return (rules.generateRules?.([className], context) ?? rules.default?.generateRules?.([className], context)).length === 0; + }); + + return [invalidClasses, warnings]; +} diff --git a/src/tailwind/v4/context.ts b/src/tailwind/v4/context.ts index 58b40be..5625e96 100644 --- a/src/tailwind/v4/context.ts +++ b/src/tailwind/v4/context.ts @@ -141,14 +141,9 @@ export async function createTailwindContextFromEntryPoint(entryPoint: string, in } }); - const context = { - getClassOrder: (classList: string[]) => design.getClassOrder(classList), - getVariants: (className: string) => design.getVariants(className) - }; - - CACHE.set(entryPoint, context); + CACHE.set(entryPoint, design); - return context; + return design; } function getCurrentFilename() { diff --git a/src/tailwind/v4/unregistered-classes.ts b/src/tailwind/v4/unregistered-classes.ts new file mode 100644 index 0000000..fd91f52 --- /dev/null +++ b/src/tailwind/v4/unregistered-classes.ts @@ -0,0 +1,39 @@ +import { findDefaultConfig, findTailwindConfig } from "./config.js"; +import { createTailwindContextFromEntryPoint } from "./context.js"; + +import type { + ConfigWarning, + GetUnregisteredClassesRequest, + GetUnregisteredClassesResponse +} from "../api/interface.js"; + + +export async function getUnregisteredClasses({ classes, configPath, cwd }: GetUnregisteredClassesRequest): Promise { + const warnings: ConfigWarning[] = []; + + const config = findTailwindConfig(cwd, configPath); + const defaultConfig = findDefaultConfig(cwd); + + if(!config){ + warnings.push({ + option: "entryPoint", + title: configPath + ? `No tailwind css config found at \`${configPath}\`` + : "No tailwind css entry point configured" + }); + } + + const path = config?.path ?? defaultConfig.path; + const invalidate = config?.invalidate ?? defaultConfig.invalidate; + + if(!path){ + throw new Error("Could not find a valid Tailwind CSS configuration"); + } + + const context = await createTailwindContextFromEntryPoint(path, invalidate); + + const css = context.candidatesToCss(classes); + const invalidClasses = classes.filter((_, index) => css.at(index) === null); + + return [invalidClasses, warnings]; +} diff --git a/src/utils/utils.test.ts b/src/utils/utils.test.ts index c805d17..15d1199 100644 --- a/src/utils/utils.test.ts +++ b/src/utils/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { matchesName } from "readable-tailwind:utils:utils.js"; +import { escapeForRegex, matchesName } from "readable-tailwind:utils:utils.js"; describe("matchesName", () => { @@ -20,3 +20,13 @@ describe("matchesName", () => { expect(matchesName("class\\$", "class$")).toBe(true); }); }); + +describe("escapeForRegex", () => { + it("should escape an user provided string to be used in a regular expression", () => { + expect(escapeForRegex(".*")).toBe("\\.\\*"); + expect(escapeForRegex("hello?")).toBe("hello\\?"); + expect(escapeForRegex("[abc]")).toBe("\\[abc\\]"); + expect(escapeForRegex("a+b*c")).toBe("a\\+b\\*c"); + expect(escapeForRegex("class$")).toBe("class\\$"); + }); +}); diff --git a/src/utils/utils.ts b/src/utils/utils.ts index f6a8707..fc2f9bc 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -102,6 +102,10 @@ export function getIndentation(line: string): number { return line.match(/^[\t ]*/)?.[0].length ?? 0; } +export function escapeForRegex(word: string) { + return word.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&"); +} + export function matchesName(pattern: string, name: string | undefined): boolean { if(!name){ return false; } diff --git a/tests/e2e/no-unregistered-classes/v3/eslint.config.js b/tests/e2e/no-unregistered-classes/v3/eslint.config.js new file mode 100644 index 0000000..f4f2b74 --- /dev/null +++ b/tests/e2e/no-unregistered-classes/v3/eslint.config.js @@ -0,0 +1,21 @@ +import eslintParserHTML from "@html-eslint/parser"; +import eslintPluginReadableTailwind from "eslint-plugin-readable-tailwind"; + + +export default { + files: ["**/*.html"], + languageOptions: { + parser: eslintParserHTML + }, + plugins: { + "readable-tailwind": eslintPluginReadableTailwind + }, + rules: { + "readable-tailwind/no-unregistered-classes": [ + "warn", + { + tailwindConfig: "./tailwind.config.ts" + } + ] + } +}; diff --git a/tests/e2e/no-unregistered-classes/v3/package.json b/tests/e2e/no-unregistered-classes/v3/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/tests/e2e/no-unregistered-classes/v3/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/e2e/no-unregistered-classes/v3/plugin.ts b/tests/e2e/no-unregistered-classes/v3/plugin.ts new file mode 100644 index 0000000..3df60dd --- /dev/null +++ b/tests/e2e/no-unregistered-classes/v3/plugin.ts @@ -0,0 +1,11 @@ +/* eslint-disable eslint-plugin-typescript/naming-convention */ + +export function plugin() { + return function({ addUtilities }) { + addUtilities({ + ".from-plugin": { + color: "red" + } + }); + }; +} diff --git a/tests/e2e/no-unregistered-classes/v3/tailwind.config.ts b/tests/e2e/no-unregistered-classes/v3/tailwind.config.ts new file mode 100644 index 0000000..8e6eecb --- /dev/null +++ b/tests/e2e/no-unregistered-classes/v3/tailwind.config.ts @@ -0,0 +1,15 @@ +import { plugin } from "./plugin.js"; + + +export default { + plugins: [ + plugin() + ], + theme: { + extend: { + colors: { + config: "red" + } + } + } +}; diff --git a/tests/e2e/no-unregistered-classes/v3/test.html b/tests/e2e/no-unregistered-classes/v3/test.html new file mode 100644 index 0000000..9288032 --- /dev/null +++ b/tests/e2e/no-unregistered-classes/v3/test.html @@ -0,0 +1,6 @@ + + + +
+ + \ No newline at end of file diff --git a/tests/e2e/no-unregistered-classes/v3/test.test.ts b/tests/e2e/no-unregistered-classes/v3/test.test.ts new file mode 100644 index 0000000..f95ecc2 --- /dev/null +++ b/tests/e2e/no-unregistered-classes/v3/test.test.ts @@ -0,0 +1,28 @@ +import { loadESLint } from "eslint"; +import { getTailwindcssVersion } from "src/tailwind/utils/version.js"; +import { describe, expect, it } from "vitest"; + + +describe.skipIf(getTailwindcssVersion().major > 3)("e2e/no-unregistered-classes/v3", async () => { + it("should not report on registered utility classes", async () => { + const ESLint = await loadESLint({ useFlatConfig: true }); + + const eslint = new ESLint({ + cwd: import.meta.dirname, + overrideConfigFile: "./eslint.config.js" + }); + + const [json] = await eslint.lintFiles("./test.html"); + + expect(json.errorCount).toBe(0); + expect(json.fatalErrorCount).toBe(0); + expect(json.fixableErrorCount).toBe(0); + expect(json.fixableWarningCount).toBe(0); + expect(json.warningCount).toBe(1); + + expect(json.messages.map(({ ruleId }) => ruleId)).toEqual([ + "readable-tailwind/no-unregistered-classes" + ]); + + }); +}); diff --git a/tests/e2e/no-unregistered-classes/v4/eslint.config.js b/tests/e2e/no-unregistered-classes/v4/eslint.config.js new file mode 100644 index 0000000..dfe3e36 --- /dev/null +++ b/tests/e2e/no-unregistered-classes/v4/eslint.config.js @@ -0,0 +1,21 @@ +import eslintParserHTML from "@html-eslint/parser"; +import eslintPluginReadableTailwind from "eslint-plugin-readable-tailwind"; + + +export default { + files: ["**/*.html"], + languageOptions: { + parser: eslintParserHTML + }, + plugins: { + "readable-tailwind": eslintPluginReadableTailwind + }, + rules: { + "readable-tailwind/no-unregistered-classes": [ + "warn", + { + entryPoint: "./tailwind.css" + } + ] + } +}; diff --git a/tests/e2e/no-unregistered-classes/v4/package.json b/tests/e2e/no-unregistered-classes/v4/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/tests/e2e/no-unregistered-classes/v4/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/e2e/no-unregistered-classes/v4/plugin.ts b/tests/e2e/no-unregistered-classes/v4/plugin.ts new file mode 100644 index 0000000..38912db --- /dev/null +++ b/tests/e2e/no-unregistered-classes/v4/plugin.ts @@ -0,0 +1,12 @@ +/* eslint-disable eslint-plugin-typescript/naming-convention */ + +import createPlugin from "tailwindcss/plugin"; + + +export default createPlugin(({ addUtilities }) => { + addUtilities({ + ".from-plugin": { + color: "red" + } + }); +}); diff --git a/tests/e2e/no-unregistered-classes/v4/tailwind.config.ts b/tests/e2e/no-unregistered-classes/v4/tailwind.config.ts new file mode 100644 index 0000000..4b3b7d4 --- /dev/null +++ b/tests/e2e/no-unregistered-classes/v4/tailwind.config.ts @@ -0,0 +1,9 @@ +export default { + theme: { + extend: { + colors: { + config: "red" + } + } + } +}; diff --git a/tests/e2e/no-unregistered-classes/v4/tailwind.css b/tests/e2e/no-unregistered-classes/v4/tailwind.css new file mode 100644 index 0000000..6848336 --- /dev/null +++ b/tests/e2e/no-unregistered-classes/v4/tailwind.css @@ -0,0 +1,7 @@ +@import "tailwindcss"; +@config "./tailwind.config.ts"; +@plugin "./plugin.ts"; + +@utility from-utility { + @apply text-red-500; +} \ No newline at end of file diff --git a/tests/e2e/no-unregistered-classes/v4/test.html b/tests/e2e/no-unregistered-classes/v4/test.html new file mode 100644 index 0000000..a247820 --- /dev/null +++ b/tests/e2e/no-unregistered-classes/v4/test.html @@ -0,0 +1,6 @@ + + + +
+ + \ No newline at end of file diff --git a/tests/e2e/no-unregistered-classes/v4/test.test.ts b/tests/e2e/no-unregistered-classes/v4/test.test.ts new file mode 100644 index 0000000..731b359 --- /dev/null +++ b/tests/e2e/no-unregistered-classes/v4/test.test.ts @@ -0,0 +1,28 @@ +import { loadESLint } from "eslint"; +import { getTailwindcssVersion } from "src/tailwind/utils/version.js"; +import { describe, expect, it } from "vitest"; + + +describe.skipIf(getTailwindcssVersion().major < 4)("e2e/no-unregistered-classes/v4", async () => { + it("should not report on registered utility classes", async () => { + const ESLint = await loadESLint({ useFlatConfig: true }); + + const eslint = new ESLint({ + cwd: import.meta.dirname, + overrideConfigFile: "./eslint.config.js" + }); + + const [json] = await eslint.lintFiles("./test.html"); + + expect(json.errorCount).toBe(0); + expect(json.fatalErrorCount).toBe(0); + expect(json.fixableErrorCount).toBe(0); + expect(json.fixableWarningCount).toBe(0); + expect(json.warningCount).toBe(1); + + expect(json.messages.map(({ ruleId }) => ruleId)).toEqual([ + "readable-tailwind/no-unregistered-classes" + ]); + + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index 8da0b7c..b7de44a 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -64,7 +64,7 @@ export function lint