はじめに
JavaScriptやTypeScriptではなく、HTMLをチェックするESLintのカスタムルールを作成します。
具体的には、Ionic(Angular)のHTMLファイルで、(click)
を使用するときに、Ionicのtappable属性が設定されているかチェックするESLintのカスタムルールeslint-plugin-ionic-tappableを作成して、npmパッケージとして公開します。
- 作成したESLintプラグインのリポジトリ
- 公開したnpmパッケージ
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:all
やeslint:recommended
も公開しています。
npmパッケージ公開
ESLintプラグインの作成が完了したので、npmパッケージとして公開します。
npm version major npm publish
- 公開したnpmパッケージ
動作確認
作成した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
参考サイト
ランタイムの問題 - Ionic Framework 日本語ドキュメンテーション
angular-eslint/click-events-have-key-events.ts at master · angular-eslint/angular-eslint · GitHub