中安拓也のブログ

プログラミングについて書くブログ

【ESLint】Angularのテンプレート(HTML)をチェックするESLintのカスタムルールを作成する

はじめに

JavaScriptやTypeScriptではなく、HTMLをチェックするESLintのカスタムルールを作成します。

具体的には、Ionic(Angular)のHTMLファイルで、(click)を使用するときに、Ionicのtappable属性が設定されているかチェックするESLintのカスタムルールeslint-plugin-ionic-tappableを作成して、npmパッケージとして公開します。

  • 作成したESLintプラグインのリポジトリ

github.com

  • 公開したnpmパッケージ

www.npmjs.com

tappable属性とは?

Ionicでは、クリッカブルな要素 (<button><a>)以外の要素でクリックイベント((click))を使用する場合は、要素をクリックしてイベントが発火するまでに、300msの遅延が発生する可能性があります。 このような遅延を防ぐためには、 tappable属性を設定してあげる必要があります。

悪いコードの例:

<!-- tappableがないから遅延してしまう -->
 <div (click)="doClick()">I am clickable!</div>

良いコードの例:

<!-- tappableがあるから遅延しない -->
 <div tappable (click)="doClick()">I am clickable!</div>

<!-- クリッカブルな要素だから、tappableがなくても遅延しない -->
 <button (click)="doClick()">I am clickable!</button>

環境

以下のバージョンのライブラリを使用して、ESLintのカスタムルールのnpmパッケージを作成しました。

  • eslint v7.28.0
  • typescript v4.3.2
  • jest v27.0.4

実装

HTMLをチェックするESLintのカスタムルールをTypeScriptで作成して、npmパッケージとして公開していきます。

package.json作成

まず、package.jsonから作成していきます。

  • package.json
{
  "name": "eslint-plugin-ionic-tappable",
  "version": "1.0.1",
  "description": "ESLint plugin for Ionic tappable attribute",
  "author": {
    "name": "Takuya Nakayasu",
    "email": "l08084.1989@gmail.com",
    "url": "https://github.com/l08084"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/l08084/eslint-plugin-ionic-tappable.git"
  },
  "homepage": "https://github.com/l08084/eslint-plugin-ionic-tappable",
  "license": "MIT",
  "keywords": [
    "eslint",
    "ionic",
    "eslintplugin",
    "eslint-plugin"
  ],
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "clean": "rimraf dist",
    "lint": "npx eslint . --ext .ts",
    "test": "jest",
    "test-sample": "eslint src/samples/sample.ts",
    "prepublishOnly": "npm run clean && npm run build"
  },
  "devDependencies": {
    "@angular-eslint/template-parser": "12.2.0",
    "@types/jest": "26.0.23",
    "@typescript-eslint/eslint-plugin": "4.26.0",
    "@typescript-eslint/experimental-utils": "4.25.0",
    "@typescript-eslint/parser": "4.26.0",
    "eslint": "7.28.0",
    "jest": "27.0.4",
    "prettier": "2.3.0",
    "rimraf": "3.0.2",
    "ts-jest": "27.0.2",
    "typescript": "4.3.2"
  }
}

インストールしているnpmパッケージについて説明します。

  • @typescript-eslint/experimental-utils
    • ESLintのカスタムルールをTypeScriptで書くときに型などをサポートしてくれるライブラリ
  • @types/jest, jest, ts-jest
    • テスティングフレームワークのJest関連のライブラリ。作成したカスタムルールのテストに使用する

tsconfig.json

JavaScriptではなくTypeScriptでESLintプラグインを作成したいのでtsconfig.jsonを作成します。

  • tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "strict": true,
    "outDir": "./dist",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["src/samples/*"]
}

テストコード

設定ファイルを作成したので、続いてテストコードを書いていきます。

テスティングフレームワークには、使用している@typescript-eslint/experimental-utilsライブラリが採用している関係でJestを使用します。

まずJestの設定ファイルから作成します。

jest.config.js

module.exports = {
  transform: {
    '^.+\\.ts$': 'ts-jest',
  },
};

続いて、Jestでテストコードを書いていきます。clickEventsHaveTappableはこれから作成するESLintプラグインのカスタムルールになります。なお、clickEventsHaveTappableのメッセージIDもclickEventsHaveTappableです。

tests/rules/click-events-have-tappable.test.ts

import { TSESLint } from '@typescript-eslint/experimental-utils';
import clickEventsHaveTappable from '../../src/rules/click-events-have-tappable';

const tester = new TSESLint.RuleTester({
  parser: require.resolve('@angular-eslint/template-parser'),
});

tester.run('clickEventsHaveTappable', clickEventsHaveTappable, {
  valid: [
    { code: `<h1>Heading Content!</h1>` },
    {
      code: `<div tappable (click)="doClick()">I am clickable!</div>`,
    },
    {
      code: `<div appHighlight tappable (click)="doClick()">I am clickable!</div>`,
    },
    { code: `<button (click)="doClick()">I am clickable!</button>` },
    { code: `<ion-button (click)="doClick()">I am clickable!</ion-button>` },
    { code: `<a (click)="doClick()">I am clickable!</a>` },
    {
      code: `
    <a
    aria-label="Angular on YouTube"
    target="_blank"
    rel="noopener"
    href="https://youtube.com/angular"
    title="YouTube"
  >
    <svg
      id="youtube-logo"
      height="24"
      width="24"
      fill="#fff"
    >
      <path d="M0 0h24v24H0V0z" fill="none" />
    </svg>
  </a>
  `,
    },
    {
      code: `
    <a
    (click)="doClick()"
    target="_blank"
    rel="noopener"
    href="https://youtube.com/angular"
    title="YouTube"
  >
    <svg
      id="youtube-logo"
      height="24"
      width="24"
      fill="#fff"
    >
      <path d="M0 0h24v24H0V0z" fill="none" />
    </svg>
  </a>
  `,
    },
  ],
  invalid: [
    {
      code: `<div (click)="doClick()">I am clickable!</div>`,
      errors: [{ messageId: 'clickEventsHaveTappable' }],
    },
    {
      code: `<span appHighlight (click)="doClick()">I am clickable!</span>`,
      errors: [{ messageId: 'clickEventsHaveTappable' }],
    },
  ],
});

リントエラーを出したくないコード(Angularのテンプレート)はvalid: []に、リントエラーがでて欲しいコードは、invalid: []に設定します。

今回のリントの対象はAngularのテンプレート(HTML)なので@angular-eslint/template-parserをパーサーとして使用しています。

ESLintカスタムルール作成

テストコードを作成したので本丸のESLintのカスタムルール作成していきます。

ESLintは、文字列データであるソースコードを抽象構文木(AST) にしてから解析するライブラリであるため、まずLint対象のソースコードがどのようなASTに変換されるのかを確認する必要があります。

以下のようなコードが@angular-eslint/template-parserパーサーだとどのようなASTに変換されるか確認します。

<div tappable (click)="doClick()">I am clickable!</div>

ATSに変換されると下記のようになります(一部略)。該当のコードはASTに変換されるとElementタイプになることがわかります。ちなみにESLintのカスタムルールにconsole.logを仕込む方法でAST変換をしています。

Element {
    name: 'div',
    attributes: [
      TextAttribute {
        name: 'tappable',
        value: '',
        sourceSpan: [ParseSourceSpan],
        keySpan: [ParseSourceSpan],
        valueSpan: undefined,
        i18n: undefined,
        type: 'TextAttribute',
        parent: [Circular *1]
      }
    ],
    inputs: [],
    outputs: [
      BoundEvent {
        name: 'click',
        type: 'BoundEvent',
        handler: [ASTWithSource],
        target: null,
        phase: null,
        sourceSpan: [ParseSourceSpan],
        handlerSpan: [ParseSourceSpan],
        keySpan: [ParseSourceSpan],
        __originalType: 0,
        parent: [Circular *1]
      }
    ],
    children: [
      Text {
        value: 'I am clickable!',
        sourceSpan: [ParseSourceSpan],
        type: 'Text',
        parent: [Circular *1]
      }
    ],
      sourceSpan: ParseSourceSpan {
        start: ParseLocation {
          file: [ParseSourceFile],
          offset: 0,
          line: 0,
          col: 0
        },
        end: ParseLocation {
          file: [ParseSourceFile],
          offset: 55,
          line: 0,
          col: 55
        },
        fullStart: ParseLocation {
          file: [ParseSourceFile],
          offset: 0,
          line: 0,
          col: 0
        },
        details: null
      },
    type: 'Element',
    parent: {
      type: 'Program',
      comments: [],
      tokens: [],
      range: [ 0, 55 ],
      loc: { start: [Object], end: [Object] },
      templateNodes: [ [Circular *1] ],
      value: '<div tappable (click)="doClick()">I am clickable!</div>',
      parent: null
    }
  }

Angularテンプレート(HTML)のASTのイメージが大体わかったので、カスタムルールのコードを書きます。

src/rules/click-events-have-tappable.ts

import type { TmplAstElement, ParseSourceSpan } from '@angular/compiler';

import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';

function convertNodeSourceSpanToLoc(
  sourceSpan: ParseSourceSpan
): TSESTree.SourceLocation {
  return {
    start: {
      line: sourceSpan.start.line + 1,
      column: sourceSpan.start.col,
    },
    end: {
      line: sourceSpan.end.line + 1,
      column: sourceSpan.end.col,
    },
  };
}

export const clickEventsHaveTappable: TSESLint.RuleModule<
  'clickEventsHaveTappable',
  []
> = {
  meta: {
    type: 'suggestion',
    docs: {
      category: 'Best Practices',
      description:
        'Ensures that the click event is accompanied by `tappable`, except for `<button>`, `<ion-button>` and `<a>`.',
      recommended: 'warn',
      url: 'https://github.com/l08084/eslint-plugin-ionic-tappable/blob/main/docs/rules/click-events-have-tappable.md',
    },
    messages: {
      clickEventsHaveTappable:
        'click must be accompanied by `tappable`, except for `<button>`, `<ion-button>` and `<a>`.',
    },
    schema: [],
  },
  create: (context) => {
    return {
      Element(node: TmplAstElement) {
        const name = node.name;
        // <ion-button> or <button> or <a> の要素の場合は処理を中断する
        if (name === 'ion-button' || name === 'button' || name === 'a') {
          return;
        }

        const haveClickEvent = node.outputs.find(
          (output) => output.name === 'click'
        );
        // (click)を持っていない要素の場合は中断する
        if (!haveClickEvent) {
          return;
        }

        const haveTappable = node.attributes.find(
          (attribute) => attribute.name === 'tappable'
        );
        if (!haveTappable) {
          // Angularテンプレートのパーサーを使用している場合は、locを作成してあげる必要がある
          const loc = convertNodeSourceSpanToLoc(node.sourceSpan);
          context.report({ loc, messageId: 'clickEventsHaveTappable' });
        }
      },
    };
  },
};

module.exports = clickEventsHaveTappable;
export default clickEventsHaveTappable;

index.ts作成

最後に、ESLintプラグインとして外部から参照できるように、index.tsを作成します。

src/index.ts

import clickEventsHaveTappable from './rules/click-events-have-tappable';

export = {
  rules: {
    'click-events-have-tappable': clickEventsHaveTappable,
  },
  configs: {
    all: {
      parser: '@angular-eslint/template-parser',
      plugins: ['ionic-tappable'],
      rules: {
        'ionic-tappable/click-events-have-tappable': 'warn',
      },
    },
    recommended: {
      parser: '@angular-eslint/template-parser',
      plugins: ['ionic-tappable'],
      rules: {
        'ionic-tappable/click-events-have-tappable': 'warn',
      },
    },
  },
};

作成したルールclick-events-have-tappableを公開するだけでなく、ESLintの設定、eslint:alleslint:recommendedも公開しています。

npmパッケージ公開

ESLintプラグインの作成が完了したので、npmパッケージとして公開します。

npm version major
npm publish
  • 公開したnpmパッケージ

www.npmjs.com

動作確認

作成したESLintプラグインを実際にAngular v12のプロジェクトで動かしてみます。

作成したESLintプラグインをAngularプロジェクトにインストールして、

$ npm install --save-dev eslint-plugin-ionic-tappable

AngularプロジェクトのESLintの設定ファイルを以下のように修正します。

.eslintrc.json

{
  "root": true,
  ...
  "overrides": [
    ...
    {
      "files": ["*.html"],
      "parser": "@angular-eslint/template-parser",
      "plugins": ["ionic-tappable"],
      "rules": { "ionic-tappable/click-events-have-tappable": "warn" }
    }
  ]
}

この状態でESLintを実行すると、以下画像の通りリントチェックのメッセージが表示されます。

$ npx eslint ./src/app/app.component.html

f:id:l08084:20210704215838p:plain
作成したESLintプラグインのリントチェックが正常に動作していることがわかる

参考サイト

ランタイムの問題 - Ionic Framework 日本語ドキュメンテーション

angular-eslint/click-events-have-key-events.ts at master · angular-eslint/angular-eslint · GitHub

angular-eslint/convert-source-span-to-loc.ts at a4beae803e48197b9897eb9a9742186dc18aa4b5 · angular-eslint/angular-eslint · GitHub