Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. | | 💡 |

<!-- end auto-generated rules list -->

Expand Down
15 changes: 15 additions & 0 deletions docs/rules/no-custom-classname.md
Original file line number Diff line number Diff line change
@@ -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).

<!-- end auto-generated rule header -->

## Options

<!-- begin auto-generated rule options list -->

| Name | Type |
| :---------- | :------- |
| `whitelist` | String[] |

<!-- end auto-generated rule options list -->
12 changes: 11 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
{
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
20 changes: 20 additions & 0 deletions src/_sandbox/typeguard-temporary.ts
Original file line number Diff line number Diff line change
@@ -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();
}
43 changes: 38 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
44 changes: 8 additions & 36 deletions src/rules/classnames-order.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
111 changes: 29 additions & 82 deletions src/rules/classnames-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -48,91 +48,38 @@ const sortClassnames = (
literals: Array<AtomicNode>
) => {
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);
},
});
}
}
};
Expand Down
14 changes: 13 additions & 1 deletion src/rules/index.ts
Original file line number Diff line number Diff line change
@@ -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",
};
49 changes: 49 additions & 0 deletions src/rules/no-custom-classname.spec.ts
Original file line number Diff line number Diff line change
@@ -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
[
`<h1 class="flex">attributeVisitor with TextAttribute (single class gets skipped)</h1>`,
`<h1 class=" relative ">extra spaces</h1>`,
`<h1 class=" relative " className=' flex'>Single + double quotes</h1>`,
].map((testedNgCode) => ({
code: testedNgCode,
languageOptions: withAngularParser,
})),
invalid: [
{
code: `<h1 class="unknown relative">basic</h1>`,
errors,
languageOptions: withAngularParser,
},
],
});
Loading