Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
feat(rule): no-custom-classname
  • Loading branch information
francoismassart committed Aug 21, 2025
commit 13ccf4607ede7399eaa406b365617b6c974b9498
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
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,
},
],
});
172 changes: 172 additions & 0 deletions src/rules/no-custom-classname.ts
Original file line number Diff line number Diff line change
@@ -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<MessageIds, Options>;

// 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<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(

Check failure on line 77 in src/rules/no-custom-classname.ts

View workflow job for this annotation

GitHub Actions / run-tests

'suffix' is assigned a value but never used

Check failure on line 77 in src/rules/no-custom-classname.ts

View workflow job for this annotation

GitHub Actions / run-tests

'prefix' is assigned a value but never used
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++;

Check failure on line 92 in src/rules/no-custom-classname.ts

View workflow job for this annotation

GitHub Actions / run-tests

'start' is assigned a value but never used
end--;

Check failure on line 93 in src/rules/no-custom-classname.ts

View workflow job for this annotation

GitHub Actions / run-tests

'end' is assigned a value but never used
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<Options, MessageIds>({
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<GenericRuleContext>,
// Template visitor is only used within Vue SFC files (inside <template> section).
createTemplateVisitors(
context,
settings,
options,
detectCustomClassnames
),
// Script visitor is used within both JSX and Vue SFC files (inside <script> section).
createScriptVisitors(context, settings, options, detectCustomClassnames)
);
},
});
30 changes: 29 additions & 1 deletion src/utils/parser/test-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,45 @@
import * as AngularParser from "@angular-eslint/template-parser";
import * as Parser from "@typescript-eslint/parser";
import { TestLanguageOptions } from "@typescript-eslint/rule-tester";
import { TSESTree } from "@typescript-eslint/utils";
import * as VueParser from "vue-eslint-parser";
import { VStartTag } from "vue-eslint-parser/ast/index";

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

// This file exposes utils only used during the tests

const withJSX = {
export const withJSX = {
ecmaFeatures: { jsx: true },
};

export const withAngularParser: TestLanguageOptions = {
parser: AngularParser,
};

export const withVueParser: TestLanguageOptions = {
parser: VueParser,
};

export 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`,
};

export 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`,
};

export 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 getFirstHTMLOpeningElement = (code: string) => {
const program = AngularParser.parse(code, { filePath: "node.spec.ts" });
const node = program.templateNodes.at(0);
Expand Down
Loading