水曜日, 8月 6, 2025
水曜日, 8月 6, 2025
- Advertisment -
ホームニューステックニュースTailwind CSS で未定義のクラスを指定したら絶対エラーにしたい

Tailwind CSS で未定義のクラスを指定したら絶対エラーにしたい



この記事の概要

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 設定の調整

https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/settings/settings.md#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 設定の調整

https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/settings/settings.md#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 の設定値を調整することで対応できる。

https://github.com/francoismassart/eslint-plugin-tailwindcss?tab=readme-ov-file#optional-shared-settings

しかし変数に関しては、見たところ設定値が存在しない。
この対応として 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>;
}
  1. 単純な文字列を変数に入れて className に指定する
  2. テンプレートリテラルを変数に入れて 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'} というテンプレートリテラルはエラーにしない

みたいな、テストケースに示した例だけに適合する無意味な実装を作ってくるので、「そうではない」と軌道修正を何度か行う必要があった。
そもそも私の意図を正確に表現できるようにテストケースを作った上で、最終的な実装結果も見て意図通りになっていることを確認すべきだと思った。

以上



Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -