この記事の概要
Tailwind CSS v4 を前提として、「未定義のクラスを指定した際に Lint エラーになるようにする」を eslint のプラグインで実現する。
私が知っている中で最も著名なプラグインは eslint-plugin-tailwindcss なのだが、Tailwind CSS v4 対応がまだベータ版である。
そのため今回は主に eslint-plugin-better-tailwindcss を使っていく。
環境
最低限の実装と結果
eslint.config.mjs
最低限の設定は下記のようになる。
eslint.config.mjs
import { dirname } from "path";
import { fileURLToPath } from "url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
import ts from "typescript-eslint";
import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
js.configs.recommended,
...ts.configs.recommended,
...compat.extends("next/core-web-vitals", "next/typescript"),
{
files: ["**/*.{jsx,tsx}"],
languageOptions: {
parserOptions: {
ecmaFeatures: {
jsx: true
}
}
},
plugins: {
"better-tailwindcss": eslintPluginBetterTailwindcss
},
rules: {
...eslintPluginBetterTailwindcss.configs["recommended-error"].rules
},
settings: {
"better-tailwindcss": {
"entryPoint": "src/app/globals.css",
}
}
}
];
export default eslintConfig;
実装と Lint 実行結果
src/app/page.tsx
import { tv } from "tailwind-variants";
export default function Home() {
return (
div>
Test1 />
Test2 />
Test3 />
Test4 className="text-Home4" />
Test5 />
Test6 additionalClassName="text-Home6" />
Test7 customClass="text-Home7" />
div>
);
}
function Test1() {
return p className="font-Test1">testp>;
}
function Test2({ isActive }: { isActive?: boolean }) {
return p className={isActive ? 'font-Test2' : 'font-normal'}>testp>;
}
function Test3() {
const textClass = tv({
base: 'font-Test3',
});
return p className={textClass()}>testp>;
}
function Test4({ isActive, className }: { isActive?: boolean; className?: string }) {
const textClass = tv({
base: 'font-Test4',
variants: {
isActive: {
true: 'text-red-500',
false: 'text-blue-500',
},
},
});
const c = textClass({ isActive, class: className });
return p className={c}>testp>;
}
function Test5() {
const className = "font-Test5";
return p className={className}>testp>;
}
function Test6({ additionalClassName }: { additionalClassName?: string }) {
const customClassName = `
font-Test6
${additionalClassName || ""}
`;
return p className={customClassName}>testp>;
}
function Test7({ isActive, customClass }: { isActive?: boolean; customClass?: string }) {
const additionalClass = `
font-Test7
${isActive ? 'text-red-500' : 'text-blue-500'}
${customClass || ""}
`;
return p className={additionalClass}>testp>;
}
これで eslint を動かすと、下記の結果を得られる。
> [email protected] lint
> next lint
./src/app/page.tsx
9:25 Error: Unregistered class detected: text-Home4 better-tailwindcss/no-unregistered-classes
18:24 Error: Unregistered class detected: font-Test1 better-tailwindcss/no-unregistered-classes
22:36 Error: Unregistered class detected: font-Test2 better-tailwindcss/no-unregistered-classes
27:12 Error: Unregistered class detected: font-Test3 better-tailwindcss/no-unregistered-classes
34:12 Error: Unregistered class detected: font-Test4 better-tailwindcss/no-unregistered-classes
47:22 Error: Unregistered class detected: font-Test5 better-tailwindcss/no-unregistered-classes
次のことがわかる。
-
className
という props に未定義のクラスを指定すると検出できるが、違う名前の props では検出できない - tailwind-variants に指定した未定義のクラスは検出できる
-
className
という変数に未定義のクラスを指定すると検出できるが、違う名前の変数では検出できない
未定義のクラスを検出できないケースへの対応
attributes 設定の調整
attributes
The name of the attribute that contains the tailwind classes.
Type: Array of Matchers
Default: Name for “class” and strings Matcher for “class”, “className”
デフォルトでは “class”, “className” に対応している。
これを “xxClass” や “xxClassName” にも対応するように設定を変更する。
eslint.config.mjs
settings: {
"better-tailwindcss": {
"entryPoint": "src/app/globals.css",
+ "attributes": [
+ ["^class(?:Name)?$", [{ match: "strings" }]],
+ ["^.*Class(?:Name)?$", [{ match: "strings" }]]
+ ]
}
}
これで eslint を動かすと、下記の結果を得られる。
> [email protected] lint
> next lint
./src/app/page.tsx
9:25 Error: Unregistered class detected: text-Home4 better-tailwindcss/no-unregistered-classes
11:35 Error: Unregistered class detected: text-Home6 better-tailwindcss/no-unregistered-classes
12:27 Error: Unregistered class detected: text-Home7 better-tailwindcss/no-unregistered-classes
18:24 Error: Unregistered class detected: font-Test1 better-tailwindcss/no-unregistered-classes
22:36 Error: Unregistered class detected: font-Test2 better-tailwindcss/no-unregistered-classes
27:12 Error: Unregistered class detected: font-Test3 better-tailwindcss/no-unregistered-classes
34:12 Error: Unregistered class detected: font-Test4 better-tailwindcss/no-unregistered-classes
47:22 Error: Unregistered class detected: font-Test5 better-tailwindcss/no-unregistered-classes
variables 設定の調整
variables
List of variable names whose initializer should also get linted.
Type: Array of Matchers
Default: strings Matcher for “className”, “classNames”, “classes”, “style”, “styles”
デフォルトでは “className”, “classNames” などに対応している。
これを “xxClass” や “xxClassName” にも対応するように設定を変更する。
eslint.config.mjs
settings: {
"better-tailwindcss": {
"entryPoint": "src/app/globals.css",
"attributes": [
["^class(?:Name)?$", [{ match: "strings" }]],
["^.*Class(?:Name)?$", [{ match: "strings" }]]
],
+ "variables": [
+ ["^classNames?$", [{ match: "strings" }]],
+ ["^classes$", [{ match: "strings" }]],
+ ["^styles?$", [{ match: "strings" }]],
+ ["^.*Class(?:Name)?$", [{ match: "strings" }]]
+ ]
}
}
これで eslint を動かすと、下記の結果を得られる。
> [email protected] lint
> next lint
./src/app/page.tsx
9:25 Error: Unregistered class detected: text-Home4 better-tailwindcss/no-unregistered-classes
11:35 Error: Unregistered class detected: text-Home6 better-tailwindcss/no-unregistered-classes
12:27 Error: Unregistered class detected: text-Home7 better-tailwindcss/no-unregistered-classes
18:24 Error: Unregistered class detected: font-Test1 better-tailwindcss/no-unregistered-classes
22:36 Error: Unregistered class detected: font-Test2 better-tailwindcss/no-unregistered-classes
27:12 Error: Unregistered class detected: font-Test3 better-tailwindcss/no-unregistered-classes
34:12 Error: Unregistered class detected: font-Test4 better-tailwindcss/no-unregistered-classes
47:22 Error: Unregistered class detected: font-Test5 better-tailwindcss/no-unregistered-classes
53:5 Error: Unregistered class detected: font-Test6 better-tailwindcss/no-unregistered-classes
61:5 Error: Unregistered class detected: font-Test7 better-tailwindcss/no-unregistered-classes
番外:eslint-plugin-tailwindcss の場合
eslint-plugin-tailwindcss でも前述のように props や変数の名称により未定義のクラスを検出できないケースが発生する。
props については classRegex の設定値を調整することで対応できる。
しかし変数に関しては、見たところ設定値が存在しない。
この対応として callees
を利用する案が考えられる。callees
は
といったユーティリティ関数に渡される文字列をクラス名として認識するための設定である。
「クラス名を変数に入れる際はユーティリティ関数を通す」という運用ルールを敷けば、ユーティリティ関数を通った時点で未定義のクラスを検出することができる。
そこで、「クラス名を変数に入れる際はユーティリティ関数を通す」という運用ルールを強制するカスタムルールを Claude Code に考えてもらった。
まだ実運用していないので正常に動く保証は無いが参考として紹介する。
まずテストパターンは下記である。
src/app/page.tsx
import { tv } from "tailwind-variants";
export default function Home() {
return (
div>
PassTest1 />
PassTest2 />
PassTest3 />
PassTest4 />
ErrorTest1 />
ErrorTest2 />
ErrorTest3 />
div>
);
}
function PassTest1() {
return p className="font-bold">testp>;
}
function PassTest2({ isActive }: { isActive?: boolean }) {
return p className={isActive ? 'text-red-500' : 'text-blue-500'}>testp>;
}
function PassTest3() {
const textClass = tv({
base: 'font-bold',
});
return p className={textClass()}>testp>;
}
function PassTest4({ isActive, className }: { isActive?: boolean; className?: string }) {
const textClass = tv({
base: 'font-bold',
variants: {
isActive: {
true: 'text-red-500',
false: 'text-blue-500',
},
},
});
const c = textClass({ isActive, class: className });
return p className={c}>testp>;
}
function ErrorTest1() {
const className = "font-bold";
return p className={className}>testp>;
}
function ErrorTest2({ additionalClassName }: { additionalClassName?: string }) {
const customClass = `font-bold ${additionalClassName || ""}`;
return p className={customClass}>testp>;
}
function ErrorTest3({ isActive }: { isActive?: boolean }) {
const customClass = `font-bold ${isActive ? 'text-red-500' : 'text-blue-500'}`;
return p className={customClass}>testp>;
}
- 単純な文字列を変数に入れて
className
に指定する - テンプレートリテラルを変数に入れて
className
に指定する
の2パターンをエラーにしたい。
この条件で Claude Code が考えたカスタムルールが下記である。
eslint-rules/no-simple-classname-variables.js
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'Disallow assigning simple string literals to variables that are only used for className props',
category: 'Best Practices',
recommended: false,
},
schema: [],
messages: {
noSimpleClassNameVariable: 'Avoid assigning simple string literals to variables only used for className.',
},
},
create(context) {
const variables = new Map();
return {
VariableDeclarator(node) {
if (node.id?.type === 'Identifier') {
let isSimpleValue = false;
if (node.init?.type === 'Literal' && typeof node.init.value === 'string') {
isSimpleValue = true;
}
else if (node.init?.type === 'TemplateLiteral') {
const templateLiteral = node.init;
if (templateLiteral.quasis.length > 0) {
isSimpleValue = true;
}
}
if (isSimpleValue) {
const scope = context.sourceCode.getScope(node);
const variable = scope.set.get(node.id.name);
if (variable) {
variables.set(variable, {
declaratorNode: node,
usages: [],
classNameUsages: [],
});
}
}
}
},
JSXAttribute(node) {
if (
node.name?.name === 'className' &&
node.value?.type === 'JSXExpressionContainer' &&
node.value.expression?.type === 'Identifier'
) {
const scope = context.sourceCode.getScope(node);
const varName = node.value.expression.name;
let currentScope = scope;
let variable = null;
while (currentScope && !variable) {
variable = currentScope.set.get(varName);
if (!variable) {
currentScope = currentScope.upper;
}
}
if (variable && variables.has(variable)) {
const varInfo = variables.get(variable);
varInfo.classNameUsages.push(node);
varInfo.usages.push({ node, isClassName: true });
}
}
},
Identifier(node) {
const scope = context.sourceCode.getScope(node);
const varName = node.name;
let currentScope = scope;
let variable = null;
while (currentScope && !variable) {
variable = currentScope.set.get(varName);
if (!variable) {
currentScope = currentScope.upper;
}
}
if (variable && variables.has(variable)) {
const varInfo = variables.get(variable);
const parent = node.parent;
if (parent?.type === 'VariableDeclarator' && parent.id === node) {
return;
}
if (parent?.type === 'JSXExpressionContainer' &&
parent.parent?.type === 'JSXAttribute' &&
parent.parent.name?.name === 'className') {
return;
}
varInfo.usages.push({ node, isClassName: false });
}
},
'Program:exit'() {
variables.forEach((varInfo) => {
const nonClassNameUsages = varInfo.usages.filter(usage => !usage.isClassName);
const classNameUsages = varInfo.classNameUsages;
if (nonClassNameUsages.length === 0 && classNameUsages.length === 1) {
context.report({
node: varInfo.declaratorNode,
messageId: 'noSimpleClassNameVariable',
});
}
});
},
};
},
};
このルールを設定ファイルに読み込む。
eslint.config.mjs
+import noSimpleClassNameVariables from "./eslint-rules/no-simple-classname-variables.js";
const eslintConfig = [
js.configs.recommended,
...ts.configs.recommended,
...compat.extends("next/core-web-vitals", "next/typescript"),
...tailwind.configs["flat/recommended"],
{
settings: {
tailwindcss: {
config: `${__dirname}/src/app/globals.css`,
},
},
},
+ {
+ plugins: {
+ 'custom': {
+ rules: {
+ 'no-simple-classname-variables': noSimpleClassNameVariables,
+ },
+ },
+ },
+ rules: {
+ 'custom/no-simple-classname-variables': 'error',
+ },
+ },
];
export default eslintConfig;
Lint を実行すると、期待通りエラーケースのみエラーにすることができた。
./src/app/page.tsx
47:9 Error: Avoid assigning simple string literals to variables only used for className. custom/no-simple-classname-variables
52:9 Error: Avoid assigning simple string literals to variables only used for className. custom/no-simple-classname-variables
57:9 Error: Avoid assigning simple string literals to variables only used for className. custom/no-simple-classname-variables
感想
プラグインについて
eslint-plugin-tailwindcss より eslint-plugin-better-tailwindcss のほうが細やかな設定が可能で良いと思った。
しかし設定の Matcher 指定を間違えるとデフォルトでは検出できていたパターンが検出できなくなったりするので、設定を調整する際はテストケースをきちんと用意しておくことが必要だと感じた。
まだ不足しているルールについて
eslint-plugin-better-tailwindcss で未定義のクラスを検出できなかったケースに関しては attributes や variables の設定で対応したものの、props や variables の名称を制限するようなルールを eslint で強制できていない。
人間 or AI が txtCls
みたいな変数名でクラスを宣言した場合は未定義のクラスを検出できないということである。
ここを eslint で制御することも考えてみたが、かなり複雑なことになりそうなので断念した。
特に props に関しては「ある props が最終的にクラスとして指定された場合」という条件を検出しなければならず、難しいのではないかと思う。
カスタムルール作成について
私がテストケースを作成し、そのテストに適合するようなカスタムルールを Claude Code に実装させた。
私は eslint のカスタムルール作成について勉強したことがないので、私がやるよりは十分早く実装できたと思う。
しかし私が意図した汎用的な実装にさせるために時間を要した。具体的に言うと
-
customClass
という変数名だけエラーにする - シンプルな文字列はエラーにするが、テンプレートリテラルはエラーにしない
-
font-bold ${additionalClassName || ""}
というテンプレートリテラルはエラーにするけどfont-bold ${isActive ? 'text-red-500' : 'text-blue-500'}
というテンプレートリテラルはエラーにしない
みたいな、テストケースに示した例だけに適合する無意味な実装を作ってくるので、「そうではない」と軌道修正を何度か行う必要があった。
そもそも私の意図を正確に表現できるようにテストケースを作った上で、最終的な実装結果も見て意図通りになっていることを確認すべきだと思った。
以上
Views: 0