ESLintのカスタムルールを実装した話

はじめに

プロダクトエンジニアのpurin(@puringts)です。最近はAstroの導入をしたりパフォーマンスチューニングしたりしていて充実しています。

さて、カラリアのフロントエンドでは最近ESLintの カスタムルールを実装する機会があったので、紹介させていただきます。

背景

カラリアではGAの拡張イベントを利用しています。aタグ(コード上ではNext.jsのLinkコンポーネント)にid属性を付与しているといざという時に特定のリンクのクリックイベントを集計することが簡単にできるので、カラリアで実装されているaタグ全てにid属性を付与したいという話になりました。ただ、全てのaタグを人力で網羅するのは大変です。

そこで、ESLintのカスタムルールを実装することにしました。カスタムルールでid属性が付与されていない箇所の検出をできるようにすれば、すでに実装されているリンクはもちろん、今後実装されるリンクもidが付与されていることを担保できます。

前提条件

前提としてカラリアでは以下のライブラリ、メジャーバージョンを利用しています。

  • Node.js v20
  • Next.js v15
  • ESLint v9 (Flat Config)

カスタムルールの概要

まずカスタムルールとは、既存のルールでは検出できない問題のあるコードを検出するために独自にルールを実装できる仕組みで、複数ファイルに跨って実装する必要があります。

以下が今回実装したカスタムルールの構成です。

frontend/
├── eslint.config.mjs
└── .eslint
    └──custom-rules            // カスタムルールの置き場所
       ├── enforce-link-id.mts // カスタムルールの実装
       └── index.mjs           // カスタムルールをまとめるためのモジュール

実装されたカスタムルールは以下のようにeslint.config.mjsで適用することで使えるようになります。

import eslintJs from '@eslint/js';
import next from '@next/eslint-plugin-next';
import prettier from 'eslint-config-prettier';
import tseslint from 'typescript-eslint';

import customRules from './.eslint/custom-rules/index.mjs';

export default tseslint.config(
  eslintJs.configs.recommended,
  prettier,
  {
    plugins: {
      '@next/next': next,
      'custom-rules': customRules,
    },
  },
  ...(以降他の設定)

カスタムルールの実装

ではカスタムルールの実装を説明していきます。以下がカスタムルールの実装です。

// .eslint/custom-rules/enforce-link-id.mts
export default {
  create(context) {
    let importName = null;

    return {
      ImportDeclaration(node) {
        if (node.source.value === 'next/link') {
          importName = node.specifiers[0].local.name;
        }
      },
      JSXElement(node) {
        if (importName == null) return;

        if (
          node.openingElement &&
          node.openingElement.name &&
          node.openingElement.name.name !== importName
        ) {
          return;
        }

        const attributeNames = new Set();

        node.openingElement.attributes.forEach((attribute) => {
          if (attribute.type === 'JSXAttribute') {
            attributeNames.add(attribute.name.name);
          } else if (attribute.type === 'JSXSpreadAttribute') {
            if (attribute.argument && attribute.argument.properties) {
              attribute.argument.properties.forEach((property) => {
                attributeNames.add(property.key.name);
              });
            }
          }
        });

        if (!attributeNames.has('id')) {
          context.report({
            node,
            message: `\`Link\` components must specify an \`id\` attribute.`,
          });
        }
      },
    };
  },
};

ImportDeclaration() でimport文に next/link があった場合そのimportした変数名を取得しています。(一般的には Link になります。)

JSXElement() でコードの中に当該の変数名の要素(<Link>)があれば渡されているpropsのキーを取得して attributeNames に追加しています。スプレッド演算子の場合も考慮されています。

最後に attributeNames にid属性がなければ context.report で警告を出すといった流れです。

ルールを実装したら、custom-rules/index.mjs でルール名が custom-rule/enforce-link-id になるように定義し、exportします。

// .eslint/custom-rules/index.mjs
import enforceLinkId from './enforce-link-id.mjs';

export default {
  rules: {
    'enforce-link-id': enforceLinkId,
  },
};

eslint.config.mjsでの実装は先で述べている通りです。

これでWarningが表示されるようになります。

めでたしめでたし。

参照: https://eslint.org/docs/latest/extend/custom-rules

おわりに

この記事ではESLintのカスタムルールの実装についてご紹介しました。カスタムルールを実装することで、ライブラリですでに実装されていないルールもESLintで自動でチェックすることができます。独自のルールが欲しくなった際はぜひカスタムルールを作ってみてください。

High Linkでは一緒に働くメンバーを募集しています。 High Linkに興味がある方は気軽にお話しさせてください!

herp.careers