中安拓也のブログ

プログラミングについて書くブログ。 Twitterやってます @l08084

【ESLint】Angularのファイル名の命名規則をチェックするESLintのカスタムルールを作成した

はじめに

Angularプロジェクトで作成したファイル名Angularコーディングスタイルガイドに沿っているかチェックするESLintのカスタムルール『eslint-plugin-angular-file-naming』を作成してnpmパッケージとして公開しました。

  • GitHubリポジトリ

github.com

  • npmパッケージ

www.npmjs.com

eslint-plugin-angular-file-namingの概要

eslint-plugin-angular-file-namingでは、5件のESLintのカスタムルールを提供していて、それぞれのルールでAngularのファイルタイプ(Component, Directive, Module, Pipe, Service)に適したsuffix(.component.ts, .directive.ts, .module.ts, .pipe.ts, .service.ts...)がファイル名に設定されているかを確認します。

また、suffixについてはデフォルトで設定されている値(.component.ts, .directive.ts, .module.ts, .pipe.ts, .service.ts...)とは別にオプションでsuffixの値を設定することもできます。

使用例

例えば、以下のように.eslintrcを設定した場合は、

// .eslintrc.json
module.exports = {
  "plugins": [
    ...,
    "angular-file-naming"
  ],
  "rules": [
    ...,
    "angular-file-naming/component-filename-suffix": [
      "error",
      {
        "suffixes": ["component", "page", "view"]
      }
    ],
    "angular-file-naming/directive-filename-suffix": "error",
    "angular-file-naming/module-filename-suffix": "error",
    "angular-file-naming/pipe-filename-suffix": "error",
    "angular-file-naming/service-filename-suffix": [
      "error",
      {
        "suffixes": ["service", "guard", "store"]
      }
    ],
  ]
  ...,
}

以下のファイル名が誤ったファイル名になり、

app.comp.ts
sample.ts
test.filter.ts

以下のファイル名が正しいファイル名になります。

app.component.ts
app.page.ts
app.view.ts
test.directive.ts
app.module.ts
sample.pipe.ts
test.service.ts
test.guard.ts
test.store.ts

f:id:l08084:20210724171604p:plain
誤ったファイル名を設定している場合、Lintエラーメッセージが表示される

環境

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

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

Angular v12のプロジェクトで動作確認をしています。

実装

eslint-plugin-angular-file-namingのカスタムルールの一つ、component-filename-suffixの実装内容について説明していきます。

カスタムルールのコード

component-filename-suffixは、@Componentデコレーターを持つクラスのファイル名のsuffixが.component.ts(もしくはオプションで設定したsuffix)に設定されているかをチェックするルールになります。

以下2件のソースコードのうち、src/rules/component-filename-suffix.tscomponent-filename-suffixのソースコード、src/utils/utils.tsは、component-filename-suffixが参照しているユーティリティになります。

src/rules/component-filename-suffix.ts

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

type Options = { suffixes: string[] };

export const componentFilenameSuffix: TSESLint.RuleModule<
  'componentFilenameSuffix',
  [Options]
> = {
  meta: {
    type: 'suggestion',
    docs: {
      category: 'Best Practices',
      description: `The file name of Classes decorated with @Component must have suffix "component" (or custom) in their name. See more at ${STYLE_GUIDE_LINK}`,
      recommended: 'error',
      url: 'https://github.com/l08084/eslint-plugin-angular-file-naming/blob/main/docs/rules/component-filename-suffix.md',
    },
    messages: {
      componentFilenameSuffix: `The file name of component class should end with one of these suffixes: {{suffixes}} (${STYLE_GUIDE_LINK})`,
    },
    schema: [
      {
        type: 'object',
        properties: {
          suffixes: {
            type: 'array',
            items: {
              type: 'string',
            },
          },
        },
        additionalProperties: false,
      },
    ],
  },
  create: (context) => {
    return {
      [COMPONENT_CLASS_DECORATOR](node: TSESTree.Decorator) {
        const filename = context.getFilename();
        const suffixes = context.options[0]?.suffixes || ['component'];
        const filenameSuffix = getFilenameSuffix(filename);
        if (
          !filenameSuffix ||
          !(filenameSuffix.length > 1) ||
          !suffixes.find((suffix) => suffix === filenameSuffix[1])
        ) {
          context.report({
            node,
            messageId: 'componentFilenameSuffix',
            data: { suffixes },
          });
        }
      },
    };
  },
};

module.exports = componentFilenameSuffix;
export default componentFilenameSuffix;

src/utils/utils.ts

export const STYLE_GUIDE_LINK =
  'https://angular.io/guide/styleguide#style-02-03';

export const COMPONENT_CLASS_DECORATOR =
  'ClassDeclaration > Decorator[expression.callee.name="Component"]';

export const DIRECTIVE_CLASS_DECORATOR =
  'ClassDeclaration > Decorator[expression.callee.name="Directive"]';

export const PIPE_CLASS_DECORATOR =
  'ClassDeclaration > Decorator[expression.callee.name="Pipe"]';

export const MODULE_CLASS_DECORATOR =
  'ClassDeclaration > Decorator[expression.callee.name="NgModule"]';

export const INJECTABLE_CLASS_DECORATOR =
  'ClassDeclaration > Decorator[expression.callee.name="Injectable"]';

/**
 * get filename suffix
 *
 * @export
 * @param {string} filename
 * @returns {(RegExpMatchArray | null)}
 */
export function getFilenameSuffix(filename: string): RegExpMatchArray | null {
  return filename.match(/(?:\/|\\).+\.(.+)\.ts$/);
}

ソースコードに@Componentデコレーターが含まれているかどうかのチェックは、ESLintのSelector機能を使用しています。

export const COMPONENT_CLASS_DECORATOR =
  'ClassDeclaration > Decorator[expression.callee.name="Component"]';

ファイル名のsuffixの抽出は、正規表現のグループ機能を使用しています。

return filename.match(/(?:\/|\\).+\.(.+)\.ts$/);

テストコード

component-filename-suffixのテストコードです。

テスティングフレームワークとして、Jestを使用しています。

tests/rules/component-filename-suffix.test.ts

import { TSESLint } from '@typescript-eslint/experimental-utils';
import componentFilenameSuffix from '../../src/rules/component-filename-suffix';

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

tester.run('componentFilenameSuffix', componentFilenameSuffix, {
  valid: [
    {
      code: `
        @Component({
      selector: 'sg-foo-bar',
      templateUrl: './test.component.html',
    })
    class TestComponent {}
  `,
      filename: '/src/app/test.component.ts',
    },
    {
      code: `
        @Component({
      selector: 'sg-foo-bar',
      templateUrl: './test.component.html',
    })
    class TestComponent {}
  `,
      filename: '/src/app/test.component.ts',
      options: [{ suffixes: ['component', 'page'] }],
    },
    {
      code: `
        @Component({
      selector: 'sg-foo-bar',
      templateUrl: './test.page.html',
    })
    class TestComponent {}
  `,
      filename: '/src/app/test.page.ts',
      options: [{ suffixes: ['component', 'page'] }],
    },
    {
      code: `
        @Component({
      selector: 'sg-foo-bar',
      templateUrl: './test.page.html',
    })
    class TestPage {}
  `,
      filename: '/src/app/test.page.ts',
      options: [{ suffixes: ['page'] }],
    },
    {
      code: `
    @Directive({
      selector: '[myHighlight]'
    })
    class TestDirective {}
  `,
      filename: '/src/app/test.directive.ts',
      options: [{ suffixes: ['page'] }],
    },
  ],
  invalid: [
    {
      code: `
        @Component({
      selector: 'sg-foo-bar',
      templateUrl: './test.component.html',
    })
    class TestComponent {}
  `,
      filename: '/src/app/test.components.ts',
      errors: [{ messageId: 'componentFilenameSuffix' }],
    },
    {
      code: `
        @Component({
      selector: 'sg-foo-bar',
      templateUrl: './test.view.html',
    })
    class TestView {}
  `,
      filename: '/src/app/test.view.ts',
      options: [{ suffixes: ['component', 'page'] }],
      errors: [{ messageId: 'componentFilenameSuffix' }],
    },
    {
      code: `
        @Component({
      selector: 'sg-foo-bar',
      templateUrl: './test.component.html',
    })
    class TestComponent {}
  `,
      filename: '/src/app/test.component.ts',
      options: [{ suffixes: ['view', 'page'] }],
      errors: [{ messageId: 'componentFilenameSuffix' }],
    },
  ],
});

参考サイト

How can I enforce filename and folder name convention in typescript eslint? - Stack Overflow

eslint-plugin-unicorn/filename-case.md at main · sindresorhus/eslint-plugin-unicorn · GitHub

Angular: ESLintサポートの現状 2020 Autumn | lacolaco/tech

Selectors - ESLint - Pluggable JavaScript linter

【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

【TypeScript】npmパッケージでコンパイル後のJSファイルをgit管理の対象外にする

はじめに

f:id:l08084:20210619164051p:plain
コンパイル後のJSファイル(distディレクトリ)がgit管理されてしまっている

npmのライフサイクルスクリプトであるprepublishOnlyを使うことで、コンパイル後のJSファイルをgit管理下から除外することができます。

今回は、前回TypeScriptで作成したnpmパッケージeslint-plugin-lookbehind-assertionsのコンパイル後のJSファイル(distディレクトリ)をgit管理の対象外にしていきます。

環境

npmのバージョンは7.17.0です。

$ npm -v
7.17.0

prepublishOnlyの設定

まず、.gitignoreを使って、git管理したくないコンパイル後のJSファイル(distディレクトリ)をgit管理の対象外に設定します。

.gitignore

dist

続いて、npm publish前に呼び出されるライフサイクルスクリプトであるprepublishOnlypackage.jsonに設定します。

package.json

  "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"
  },

prepublishOnlyでは、コンパイル後のJSファイルの削除とTSファイルのコンパイル(distディレクトリの作成)を呼び出しています。

"prepublishOnly": "npm run clean && npm run build"

こうすることで、npm publishの直前でdistディレクトリが作成されるため、npmレジストリにはdistディレクトリも含まれた状態でパッケージが公開されます。

動作確認

試しにnpmパッケージeslint-plugin-lookbehind-assertionsを公開してみます。

$ npm version patch
v1.0.1
$ npm publish

> eslint-plugin-lookbehind-assertions@1.0.1 prepublishOnly
> npm run clean && npm run build


> eslint-plugin-lookbehind-assertions@1.0.1 clean
> rimraf dist

... 
> eslint-plugin-lookbehind-assertions@1.0.1 build
> tsc

...

+ eslint-plugin-lookbehind-assertions@1.0.1

npm publishが実行される前にprepublishOnlyが呼び出されてdistディレクトリが作成されていることがわかります。

参考サイト

npmにライブラリを公開してみよう | 株式会社LIG

npm の prepublish と prepare の変遷 - Qiita

npm prepublishOnlyでビルドを呼んではいけない - Qiita

【TypeScript】正規表現の後読み(lookbehind assertions)を禁止するESLintのカスタムルールを作成する

はじめに

RegExp - JavaScript | MDN

f:id:l08084:20210529152110p:plain

上記サイトに記載されている通り、正規表現の後読み(lookbehind assertions)は、一部のブラウザ(Safariなど)で非対応になっているため、開発環境によっては実行時に予期せぬエラーを引き起こす可能性があります。

今回は、そのような事態を防ぐために、正規表現の後読み(lookbehind assertions)を禁止するESLintのカスタムルールを作成していきます。

なお、ESLintのカスタムルールはTypeScriptで作成します。

環境

本記事で作成したESLintのカスタムルールは下記環境で動作確認をしています。

  • JavaScript(TypeScript)のフレームワークであるAngular v12.0.2
  • ESLint v7.27.0

ng versionの実行結果

$ ng version

Angular CLI: 12.0.2
Node: 16.2.0
Package Manager: npm 7.15.0
OS: darwin x64

Angular: 12.0.2
... animations, cli, common, compiler, compiler-cli, core, forms
... platform-browser, platform-browser-dynamic, router

Package                         Version
---------------------------------------------------------
@angular-devkit/architect       0.1200.2
@angular-devkit/build-angular   12.0.2
@angular-devkit/core            12.0.2
@angular-devkit/schematics      12.0.2
@schematics/angular             12.0.2
rxjs                            6.6.7
typescript                      4.2.4

ESLintを動かしてみる

カスタムESLintの作成に入る前に、ESLintを動かす環境を作っていきます。

まず、ESLintでチェックするプロジェクトを作成します。

$ ng new test-eslint

上記のng newコマンドでAngularプロジェクトを作成した後、下記のng add @angular-eslint/schematicsコマンドでAngularプロジェクトにESLintを導入します。

ng add @angular-eslint/schematicsコマンドを実行すると、ESLintがインストールされるだけでなく、Angular向けのESLintのルールの導入も実施されます。

$ cd test-eslint
$ ng add @angular-eslint/schematics

続いて、実際にESLintを動かしてみます。下記の通り、Angularの命名規則を無視した、suffixが不正なComponentクラスを用意します(suffixがCamponentになっている正しくはComponent)。

src/app/app.component.ts

// ...省略
export class AppCamponent {
  title = 'test-eslint';
}

npx eslintコマンドでESLintを実行すると、下記の通りLintエラーが出力されることがわかります。

$ npx eslint ./src/**/*

/Users/takuya/test-eslint/src/app/app.component.ts
  8:14  error  The name of the class AppCamponent should end with the suffix Component (https://angular.io/styleguide#style-02-03)  @angular-eslint/component-class-suffix

✖ 1 problem (1 error, 0 warnings)

ESLintのカスタムルール作成

上記でESLintを動かすのに成功したため、ESLintのカスタムルールを作成していきます。

作成したESLintのカスタムルールのリポジトリです。

github.com

作成したESLintのカスタムルールのフォルダ構成は以下の通りになります。

$ tree -I node_modules
.
├── LICENSE
├── dist
│   ├── index.js
│   └── rules
│       └── no-lookbehind-assertions-regexp.js
├── docs
│   └── rules
│       └── no-lookbehind-assertions-regexp.md
├── jest.config.js
├── package-lock.json
├── package.json
├── src
│   ├── index.ts
│   ├── rules
│   │   └── no-lookbehind-assertions-regexp.ts
│   └── samples
│       └── sample.ts
├── tests
│   └── rules
│       └── no-lookbehind-assertions-regexp.test.ts
└── tsconfig.json

package.json

作成したESLintカスタムルールは、npmパッケージとして公開したいので、まずpackage.jsonを作成します。

package.json

{
  "name": "eslint-plugin-lookbehind-assertions",
  "version": "0.0.1",
  "description": "ESLint plugin for regular expression lookbehind assertions",
  "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-lookbehind-assertions.git"
  },
  "homepage": "https://github.com/l08084/eslint-plugin-lookbehind-assertions",
  "license": "MIT",
  "keywords": [
    "eslint",
    "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"
  },
  "devDependencies": {
    "@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",
    "eslint-plugin-rulesdir": "0.2.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関連のライブラリ。作成したカスタムルールのテストに使用する
  • eslint-plugin-rulesdir
    • 作成したカスタムルールをプロジェクト内のローカルルールとして使用することができるライブラリ

tsconfig.json

今回、ESLintのカスタムルールはTypeScriptで書くので、tsconfig.jsonを作成します。

tsconfig.json

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

src/samples/配下には、作成したカスタムルールで試しにチェックしたいファイルを配置するだけで、TypeScriptからJavaScriptへの変換の必要がないので、excludeで変換の対象外にしています。

JavaScriptに変換したコードはdist/配下に配置します。

カスタムルール作成

さて、いよいよ本丸のカスタムルール作成に入ります。

カスタムルールを作成するには、まずどのようなコードをリントエラーにしたいか考える必要があります。今回の場合は、下記のようなコードになります。

// safariなどのブラウザーで非対応な正規表現の後読みが使用されているのでリントエラーにしたい
let re = /(?<=ripe )orange/;
re = new RegExp('(?<=ripe )orange');

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

AST Explorerというサイトを使用します。以下がソースコードをASTに変換した結果です。(一部省略)

{
  "type": "Literal",
  "start": 20,
  "end": 38,
  "loc": {
    "start": {
      "line": 1,
      "column": 20
    },
    "end": {
      "line": 1,
      "column": 38
    }
  },
  "value": "(?<=ripe )orange",
  "_babelType": "StringLiteral",
  "raw": "'(?<=ripe )orange'"
}

該当のコードはASTでは、typeLiteralのNodeになることがわかりました。それを踏まえて、カスタムルールのコードを作成します。

src/rules/no-lookbehind-assertions-regexp.ts

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

function getStringValue(node: any): string | undefined {
  if (node.regex) {
    return node.regex.pattern;
  }
  if (typeof node.value === 'string') {
    return node.value;
  }
  return undefined;
}

function isLookbehindAssertions(pattern: string): boolean {
  const positiveLookbehindAssertions = new RegExp('\\(\\?<=.+');
  const negativeLookbehindAssertions = new RegExp('\\(\\?<!.+');
  return (
    positiveLookbehindAssertions.test(pattern) ||
    negativeLookbehindAssertions.test(pattern)
  );
}

export const noLookbehindAssertionsRegexp: TSESLint.RuleModule<
  'noLookbehindAssertionsRegexp',
  []
> = {
  meta: {
    type: 'problem',
    docs: {
      category: 'Possible Errors',
      description:
        'disallow the use of lookbehind assertions((?<= ) and (?<! )) in regular expressions',
      recommended: 'error',
      url: '',
    },
    messages: {
      noLookbehindAssertionsRegexp:
        'Unexpected lookbehind assertions((?<= ) and (?<! )) in regular expression: {{stringValue}}.',
    },
    schema: [],
  },
  create: (context) => {
    return {
      Literal(node) {
        const stringValue = getStringValue(node);
        if (stringValue) {
          if (isLookbehindAssertions(stringValue)) {
            context.report({
              node,
              messageId: 'noLookbehindAssertionsRegexp',
              data: {
                stringValue,
              },
            });
          }
        }
      },
    };
  },
};

module.exports = noLookbehindAssertionsRegexp;
export default noLookbehindAssertionsRegexp;

NodeのTypeがLiteralだったので、Literal()のメソッドを呼び出しています。

下記のコードは、テストコード(Jest)とライブラリeslint-plugin-rulesdirのために書いています。

module.exports = noLookbehindAssertionsRegexp;
export default noLookbehindAssertionsRegexp;

テストコード作成

上記で作成したカスタムルールのテストコード(Jest)を書きます。

tests/rules/no-lookbehind-assertions-regexp.test.ts

import { TSESLint } from '@typescript-eslint/experimental-utils';
import noLookbehindAssertionsRegexp from '../../src/rules/no-lookbehind-assertions-regexp';

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

tester.run('noLookbehindAssertionsRegexp', noLookbehindAssertionsRegexp, {
  valid: [{ code: 'const RE_AS_BS = /aa(?=bb)/' }],
  invalid: [
    {
      code: 'const RE_DOLLAR_PREFIX = /(?<=$)foo/g',
      errors: [{ messageId: 'noLookbehindAssertionsRegexp' }],
    },
    {
      code: `const re = new RegExp('(?<=ripe )orange')`,
      errors: [{ messageId: 'noLookbehindAssertionsRegexp' }],
    },
    {
      code: `const str = '(?<!ripe )orange'`,
      errors: [{ messageId: 'noLookbehindAssertionsRegexp' }],
    },
    {
      code: 'const RE_DOLLAR_PREFIX = /(?<!$)foo/g',
      errors: [{ messageId: 'noLookbehindAssertionsRegexp' }],
    },
  ],
});

validでは、リントエラーが出ないコードを、invalidでは、リントエラーが出るコードを設定します。

続いて、テスティングフレームワークJestを動かすために設定ファイルを作成します。

jest.config.js

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

準備が整ったので、テストコードを実行すると、以下の様にテストに成功します。

f:id:l08084:20210613153504p:plain
テストに成功する

index.ts作成

作成したカスタムルールをindex.tsexportして外部から使用できる様にします。

import noLookbehindAssertionsRegexp from './rules/no-lookbehind-assertions-regexp';
export = {
  rules: {
    'no-lookbehind-assertions-regexp': noLookbehindAssertionsRegexp,
  },
  configs: {
    all: {
      plugins: ['lookbehind-assertions'],
      rules: {
        'lookbehind-assertions/no-lookbehind-assertions-regexp': 'error',
      },
    },
    recommended: {
      plugins: ['lookbehind-assertions'],
      rules: {
        'lookbehind-assertions/no-lookbehind-assertions-regexp': 'error',
      },
    },
  },
};

rules:で設定することで外部からルールを使用できる様にしています。また、configsallrecommendedの設定もしています。

動作確認

作成したESLintのカスタムルールのnpmパッケージを最初に作成したAngularプロジェクトに取り込んで試しに動かしてみます。

$ npm i https://github.com/l08084/eslint-plugin-lookbehind-assertions.git -D

npmパッケージeslint-plugin-lookbehind-assertionsをインストールした後、ESLintの設定ファイルを以下の様に更新します。

plugins: []には取り込みたいプラグインのnpmパッケージ名を書きます(今回はeslint-plugin-lookbehind-assertions)。eslint-pluginの部分は省略できるため、"plugins": ["lookbehind-assertions"]と書いています。

また、rules: []に設定したいルールであるlookbehind-assertions/no-lookbehind-assertions-regexpを追加します。([プラグイン名]/[パッケージ名])

.eslintrc.json

{
  "root": true,
  "ignorePatterns": ["projects/**/*"],
  "overrides": [
    {
      "files": ["*.ts"],
      "parserOptions": {
        "project": ["tsconfig.json", "e2e/tsconfig.json"],
        "createDefaultProgram": true
      },
      // ...
      "plugins": ["lookbehind-assertions"],
      "rules": {
         // ...
        "lookbehind-assertions/no-lookbehind-assertions-regexp": "error"
      }
    }
    // ...
  ]
}

index.tsで、allrecommendedの設定をしているため、下記の様に設定しても、該当のルールを動かすことができます。(allrecommendedを使用した場合、pluginsrulesの設定は省略できる)

"extends": ["plugin:lookbehind-assertions/recommended"],
"extends": ["plugin:lookbehind-assertions/all"],

準備ができたので、ESLintを実行します。

f:id:l08084:20210613155935p:plain
正規表現の後読みがリントエラーとして指摘されている

作成したカスタムルールがうまく動いていることがわかります。

npmパッケージ公開

カスタムルールの作成が完了したので、npmパッケージとして公開します。

$ npm publish ./

公開したnpmパッケージ

www.npmjs.com

参考サイト

Eslintの独自ルールをリポジトリに追加したい

プロジェクト内で完結するESLintのローカルルールを作りたい

angular-eslint/packages/eslint-plugin/src/rules at master · angular-eslint/angular-eslint · GitHub

TypeScript で eslint-plugin を作成する

Custom (local) ESLint rules for TypeScript - Qiita

GitHub - typescript-eslint/typescript-eslint: Monorepo for all the tooling which enables ESLint to support TypeScript

GitHub - angular-eslint/angular-eslint: Monorepo for all the tooling related to using ESLint with Angular

GitHub - tc39/proposal-regexp-lookbehind: RegExp lookbehind assertions

RegExp - JavaScript | MDN

ESLintの設定をパッケージ化してnpmで公開する

ESLint をグローバルにインストールせずに使う - Qiita

Create custom ESLint rules in 2 minutes | Webiny

GitHub - francoismassart/eslint-plugin-tailwindcss: ESLint plugin for Tailwind CSS usage

TypeScript + Node.js プロジェクトのはじめかた2020 - Qiita

Jest - TypeScript Deep Dive 日本語版

eslint/prefer-regex-literals.js at master · eslint/eslint · GitHub

eslint/no-control-regex.js at master · eslint/eslint · GitHub

自作CordovaプラグインのIonic Nativeを作る

はじめに

以前作ったCordovaプラグイン、cordova-plugin-cache-deleteIonic Nativeを作ります。

Ionic Nativeとは

Ionic NativeとはCordova プラグインのTypeScriptラッパーであり、コールバック関数ベースのCordova プラグインを、PromiseまたはObservableベースに変換する機能を提供します。

cordova-plugin-cache-delete自体は、Ionic Nativeを使わなくてもすでにPromiseでラップ済なので、Promise経由で機能を呼び出すことができるプラグインになっています。 しかし、アンビエント宣言の省略など、他のIonic Nativeを持つCordovaプラグインと平仄を合わせるためにcordova-plugin-cache-deleteについても今回の記事でIonic Nativeを作成して、TypeScriptでラップできるようにします。

環境

今回作成したIonic Nativeは、下記バージョンのIonic(Angular, Cordova)で作成したAndroidアプリ上で動作確認しています。

ionic infoコマンドの実行結果

$ ionic info

Ionic:

   Ionic CLI                     : 6.11.8 (/usr/local/lib/node_modules/@ionic/cli)
   Ionic Framework               : @ionic/angular 5.6.6
   @angular-devkit/build-angular : 0.1102.11
   @angular-devkit/schematics    : 11.2.11
   @angular/cli                  : 11.2.11
   @ionic/angular-toolkit        : 3.1.1

Cordova:

   Cordova CLI       : 10.0.0
   Cordova Platforms : android 9.1.0
   Cordova Plugins   : cordova-plugin-ionic-keyboard 2.2.0, cordova-plugin-ionic-webview 4.2.1, (and 5 other plugins)

Utility:

   cordova-res : not installed
   native-run  : not installed

System:

   ios-deploy : 1.9.2
   ios-sim    : 6.1.2
   NodeJS     : v12.13.1 (/usr/local/bin/node)
   npm        : 6.14.12
   OS         : macOS Catalina
   Xcode      : Xcode 12.4 Build version 12D4e

Ionic Native作成

それでは、ionic-team / ionic-nativeリポジトリのDEVELOPER.mdの手順に従って、カスタムIonic Nativeを作成していきます

Ionic Nativeのテンプレートを作成

Ionic Nativeを作成するにあたって、ベースになるテンプレートから作成していきます。

まず、Ionic NativeのリポジトリをForkします。

f:id:l08084:20210522174457p:plain
Ionic NativeをForkする

その後、Forkしたリポジトリをクローンして、クローンしたIonic Nativeのルートディレクトリに移動します。

$ git clone https://github.com/l08084/ionic-native.git
$ cd ionic-native

テンプレートの作成に使用するため、なければgulp.jsをインストールします。

$ gulp -v
bash: gulp: command not found
$ sudo npm install -g gulp
$ gulp -v
CLI version: 2.3.0
Local version: Unknown

gulpのインストールが完了したら、クローンしたIonic Nativeのルート上で、npm iして環境を整えます。

その後、テンプレートを作成するコマンドである、gulp plugin:create -n CacheDeleteを実行します。

CacheDeleteの部分は、今回Ionic Nativeを作成したいCordovaプラグインの名前です。

$ pwd
/Users/takuya/fork/ionic-native
$ npm i
$ gulp plugin:create -n CacheDelete

gulp plugin:create -n PluginNameコマンドを実行すると、Ionic Nativeの./gulpfile.jsのタスクが呼び出されて、ディレクトリ(src/@ionic-native/plugins/plugin-name/)とindex.tsが新規作成されます。

f:id:l08084:20210522173311p:plain
./gulpfile.js

新規作成されたindex.tsの内容は以下の通りになります。

src/@ionic-native/plugins/cache-delete/index.ts

/**
 * This is a template for new plugin wrappers
 *
 * TODO:
 * - Add/Change information below
 * - Document usage (importing, executing main functionality)
 * - Remove any imports that you are not using
 * - Remove all the comments included in this template, EXCEPT the @Plugin wrapper docs and any other docs you added
 * - Remove this note
 *
 */
import { Injectable } from '@angular/core';
import { Plugin, Cordova, CordovaProperty, CordovaInstance, InstanceProperty, IonicNativePlugin } from '@ionic-native/core';
import { Observable } from 'rxjs';

/**
 * @name Cache Delete
 * @description
 * This plugin does something
 *
 * @usage
 * ```typescript
 * import { CacheDelete } from '@ionic-native/cache-delete';
 *
 *
 * constructor(private cacheDelete: CacheDelete) { }
 *
 * ...
 *
 *
 * this.cacheDelete.functionName('Hello', 123)
 *   .then((res: any) => console.log(res))
 *   .catch((error: any) => console.error(error));
 *
 * ```
 */
@Plugin({
  pluginName: 'CacheDelete',
  plugin: '', // npm package name, example: cordova-plugin-camera
  pluginRef: '', // the variable reference to call the plugin, example: navigator.geolocation
  repo: '', // the github repository URL for the plugin
  install: '', // OPTIONAL install command, in case the plugin requires variables
  installVariables: [], // OPTIONAL the plugin requires variables
  platforms: [] // Array of platforms supported, example: ['Android', 'iOS']
})
@Injectable()
export class CacheDelete extends IonicNativePlugin {

  /**
   * This function does something
   * @param arg1 {string} Some param to configure something
   * @param arg2 {number} Another param to configure something
   * @return {Promise<any>} Returns a promise that resolves when something happens
   */
  @Cordova()
  functionName(arg1: string, arg2: number): Promise<any> {
    return; // We add return; here to avoid any IDE / Compiler errors
  }

}

これでテンプレートの作成は完了です。

プラグインラッパーの作成

作成されたindex.tsをソースコードに記載されているコメント通りに修正していくと下記のようになります。

import { Injectable } from '@angular/core';
import { Plugin, Cordova, IonicNativePlugin } from '@ionic-native/core';

/**
 * @name Cache Delete
 * @description
 * Cordova plugin to delete Webview cache
 *
 * @usage
 * ```typescript
 * import { CacheDelete } from '@ionic-native/cache-delete';
 *
 *
 * constructor(private cacheDelete: CacheDelete) { }
 *
 * ...
 *
 *
 * this.cacheDelete.deleteCache()
 *   .then((res: any) => console.log(res))
 *   .catch((error: any) => console.error(error));
 *
 * ```
 */
@Plugin({
  pluginName: 'CacheDelete',
  plugin: 'cordova-plugin-cache-delete',
  pluginRef: 'CacheDelete',
  repo: 'https://github.com/l08084/cordova-plugin-cache-delete',
  platforms: ['Android']
})
@Injectable()
export class CacheDelete extends IonicNativePlugin {

  /**
   * delete a cordova webview cache.
   *
   * @returns {Promise<any>} Returns a Promise
   */
  @Cordova({ sync: true })
  deleteCache(): Promise<any> {
    return;
  }

}

Cordovaデコレーターにsync: trueをセットしているのは(@Cordova({ sync: true }))、Promiseでラップせずに値をそのまま返す必要があるためです。(cordova-plugin-cache-deleteの方ですでに値をPromiseでラップ済のため、Ionic Nativeの方でPromseでラップする必要がない)

Cordovaデコレーターの引数の詳細な内容については、ionic-team / ionic-nativeリポジトリのDEVELOPER.mdに詳しく載っています。

これでIonic Nativeの作成は完了です。

動作確認

作成したIonic Native経由でCordovaプラグインcordova-plugin-cache-deleteを呼び出すことができるかIonicアプリで確認します。

まず、npm run lintコマンドでコーディング規約に反したコードがないか確認します。

$ npm run lint

Lintエラーが出力されないのを確認したら、npm run buildコマンドでビルドを実施します。

$ npm run build

ビルドが成功すると、このようにdistディレクトリが作成されます。

f:id:l08084:20210523163611p:plain
distディレクトリが作成される

作成されたdistディレクトリの構造は以下のようになっています。

dist/@ionic-native/plugins/cache-delete
├── index.d.ts
├── index.js
└── ngx
    ├── bundle.js
    ├── index.d.ts
    ├── index.js
    └── index.metadata.json

続いて、作成したdistディレクトリ配下のcache-deleteディレクトリを、テストしたいIonicアプリのnode_modules/@ionic-nativeのディレクトリ配下に移動します。

f:id:l08084:20210523171136p:plain

Ionicアプリから今回作成したIonic Nativeを呼び出します。

Ionic Nativeを呼び出すために、コードを下記のように修正します。

src/app/app.module.ts

import { CacheDelete } from '@ionic-native/cache-delete/ngx';

// ...省略

@NgModule({
  // ...省略
  providers: [
    // ...省略
    CacheDelete,
    // ...省略
  ],
  // ...省略
})
export class AppModule {}

src/app/tab1/tab1.page.ts

import { Component } from '@angular/core';
import { Platform } from '@ionic/angular';
import { CacheDelete } from '@ionic-native/cache-delete/ngx';

@Component({
  selector: 'app-tab1',
  templateUrl: 'tab1.page.html',
  styleUrls: ['tab1.page.scss'],
})
export class Tab1Page {
  constructor(private platform: Platform, private cacheDelete: CacheDelete) {}

  public ngOnInit(): void {
    this.platform.ready().then(() => {
      if (this.platform.is('android')) {
        // delete cache
        this.cacheDelete
          .deleteCache()
          .then(() => console.log('delete cache success!!'))
          .catch((error) => console.error(error));
      }
    });
  }

  public delete(): void {
    this.cacheDelete
      .deleteCache()
      .then(() => console.log('delete cache success!!'))
      .catch((error) => console.error(error));
  }
}

上記のIonicアプリをビルドしてシミュレーターで動かすと、今回作成したIonic Native経由でCordovaプラグインの呼び出せることを確認できます。

参考サイト

Ionic Native - Ionic Native

【ionic】Ionic native pluginの作成|ブログ|West Wind Corporation

Ionic Native Pluginを自作する - Qiita

ionic-native/DEVELOPER.md at master · ionic-team/ionic-native · GitHub

Create Ionic TypeScript wrapper from my Cordova Custom plugins - Stack Overflow

GitHub - ionic-team/ionic-native: Native features for mobile apps built with Cordova/PhoneGap and open web technologies. Complete with TypeScript support. The successor to ngCordova. Pairs exquisitely with a nice bottle of Ionic Framework.

Ionic 4 Cordova Custom Plugin using Ionic-Native - Ionic Native - Ionic Forum

自作Cordovaプラグインの戻り値をPromiseにする

はじめに

今回は、以前自作したCordovaプラグイン、cordova-plugin-cache-deleteについて、引数に成功・失敗時のコールバック関数を受け取る形式から、コールバック関数を使わないPromiseベースのプラグインに変更する改修を実施していきます。

関連記事

自作Cordovaプラグイン、cordova-plugin-cache-deleteについて書いた過去記事です。

Webviewのキャッシュを削除するCordovaプラグインを作成しました - 中安拓也のブログ

初めてのCordovaプラグイン公開 - 中安拓也のブログ

cordova.execについて

そもそも、cordova-plugin-cache-deleteで、引数に成功時・失敗時のコールバック関数を受け取る形式を採用していたのは、ネイティブプラットフォーム(今回のプラグインではAndroid)と通信するためのcordova.execメソッドが成功時・失敗時のコールバック引数を受け取る仕様になっているためです。

下記がcordova.execメソッドになります。第一引数が成功時に呼び出されるコールバック関数、第二引数が失敗時のコールバック関数になっています。以下サイトから引用してます。

Plugin Development Guide - Apache Cordova

cordova.exec(function(winParam) {},
             function(error) {},
             "service",
             "action",
             ["firstArgument", "secondArgument", 42, false]);

上記のcordova.execの呼び出し方を変えることで、コールバックを廃止した、Promiseベースのプラグインに変更することができます。そうすることで、コールバックの多重ネスト問題(コールバック地獄)が発生しなくなるなどのメリットがあります。

プラグインの改修

cordova-plugin-cache-deleteのディレクトリ構造です。

.
├── LICENSE.txt
├── README.md
├── package.json
├── plugin.xml
├── src
│   └── android
│       ├── CacheDelete.java
│       └── android.iml
└── www
    └── CacheDelete.js

CordovaプラグインのIFを、コールバック関数を使用する形式から、Promiseを返す形式に変更したいので、プラグインのIF(ネイティブプラットフォームの呼び出し方)を定義している下記のJavaScriptファイルを改修していきます。

改修前: www/CacheDelete.js

var exec = require("cordova/exec");

module.exports = {
  deleteCache: function (success, error) {
    exec(success, error, "CacheDelete", "deleteCache", []);
  },
};

上記は、改修前のJavaScriptファイルになります。success引数とerror引数でコールバック関数を受け取り、cordova.execメソッドに渡しています。

上記のJavaScriptファイル経由でプラグインを呼び出す場合、次のような形式で呼び出す必要があります。成功時・失敗時のハンドリングを引数のコールバック関数で行っており、戻り値はありません。

CacheDelete.deleteCache(successCallback, errorCallback)

なお、上記のJavaScriptファイルで呼び出されるネイティブプラットフォーム側のコードは以下のようになっています。

src/android/CacheDelete.java

package jp.l08084.plugin;

import android.util.Log;

import org.apache.cordova.CallbackContext;
import org.apache.cordova.CordovaPlugin;
import org.json.JSONArray;
import org.json.JSONException;

import java.io.File;

public class CacheDelete extends CordovaPlugin {
    private static final String TAG = "CacheDelete";
    private static final String DELETE_CACHE_MESSAGE = "Cordova CacheDelete.deleteCache() called.";
    private static final String ERROR_MESSAGE = "Failed to delete the cache, error";

    @Override
    public boolean execute(String action, JSONArray args, final CallbackContext callbackContext)
            throws JSONException {
        Log.v(TAG, DELETE_CACHE_MESSAGE);
        if("deleteCache".equals(action)) {
            deleteCache(callbackContext);
            return true;
        }
        return false;
    }

    private void deleteCache(final CallbackContext callbackContext) {
        File cacheDir = cordova.getActivity().getApplicationContext().getCacheDir();
        clearCacheFolder(cacheDir, callbackContext);
    }

    private void clearCacheFolder (File dir, final CallbackContext callbackContext) {
        try {
            if (dir != null && dir.isDirectory()) {
                for (File child : dir.listFiles()) {
                    if (child.isDirectory()) {
                        clearCacheFolder(child, callbackContext);
                    }
                    child.delete();
                }
            }
            callbackContext.success();
        } catch (Exception ex) {
            Log.e(TAG, ERROR_MESSAGE, ex);
            callbackContext.error(ERROR_MESSAGE);
        }
    }

}

処理の成功時にcallbackContext.success();、失敗時にcallbackContext.error(ERROR_MESSAGE);を呼び出すことで、JavaScriptファイルから渡された成功時・失敗時のコールバック関数を呼び出しています。

それでは、JavaScriptファイルを改修してPromiseベースのプラグインに切り替えていきます。

改修後: www/CacheDelete.js

var exec = require("cordova/exec");

module.exports = {
  deleteCache: function () {
    return new Promise(function(resolve, reject) {
      exec(resolve, reject, "CacheDelete", "deleteCache", []);
    });
  },
};

成功(success)、失敗(error)時に渡していたコールバック関数の代わりにPromiseのresolverejectを渡すように改修しています。

動作確認

改修後のプラグインは以下のように呼び出すことができます。

import { Component } from '@angular/core';
import { Platform } from '@ionic/angular';

// アンビエント宣言
declare var CacheDelete: any;

@Component({
  selector: 'app-tab1',
  templateUrl: 'tab1.page.html',
  styleUrls: ['tab1.page.scss'],
})
export class Tab1Page {
  constructor(private platform: Platform) {}

  public ngOnInit(): void {
    this.platform.ready().then(() => {
      if (this.platform.is('android')) {
        // delete cache
        CacheDelete.deleteCache()
          .then(() => console.log('delete cache success!!'))
          .catch((error) => console.error(error));
      }
    });
  }
}

参考サイト

Cordova プラグインのコールバック地獄から脱却!Non-Angular アプリケーションでも Ionic Native を利用するという選択 - Qiita

GitHub - chemerisuk/cordova-plugin-firebase-analytics: Cordova plugin for Firebase Analytics

angular - Cordova (Ionic2) custom plugin: manage Angular2 new Promise() with cordova.exec() successHandler - Stack Overflow

Cordovaをdisる人類全員に読んでほしい「Cordovaつらいを考える」|榊原昌彦|note

初めてのCordovaプラグイン公開

はじめに

前回の記事cordova-plugin-cache-deleteというCordovaプラグインを作成したので、他の開発者にも使いやすいようにこのプラグインを公開していきます。

Cordovaプラグイン公開

Cordovaプラグインの公開ですが、公式サイトの説明を読むとnpmレジストリの使用を推奨しています。

通常のnpmライブラリ同様、npm publishコマンドでCordovaプラグインについても公開できちゃうんですね....

CordovaプラグインをインストールするCordova CLIのコマンド(cordova plugin add [plugin name])では、npmレジストリからプラグインを引っ張ってくる仕組みになっているようです。

npmアカウント作成

まず、こちらのリンクからnpmアカウントを作成します。

npm adduserコマンド

続いて、Cordovaプラグインのルートディレクトリに移動して、npm adduserコマンドを実行します。ちなみにnpm loginコマンドはnpm adduserコマンドのエイリアスのため、同じ機能を持つコマンドになります。

$ npm adduser

npmアカウント登録時に設定したUsernamePasswordEmailが聞かれるので回答します。

npm publishコマンド

準備ができたのでnpm publishコマンドを実行して、Cordovaプラグインを公開します。

$ npm publish ./

こんな感じのエラー(403 Forbidden)が出て、Cordovaプラグインの公開に失敗しました。

npm ERR! code E403
npm ERR! 403 403 Forbidden - PUT https://registry.npmjs.org/cordova-plugin-cache-delete - Forbidden
npm ERR! 403 In most cases, you or one of your dependencies are requesting
npm ERR! 403 a package version that is forbidden by your security policy.

npm ERR! A complete log of this run can be found in:
npm ERR!     /Users/takuya/.npm/_logs/2021-05-08T15_16_22_628Z-debug.log

スタックオーバーフローを見ると、メールアドレスの確認を実施していないせいで、npmアカウント登録が完全に完了していないのが原因じゃないかとのこと。

受信トレイを確認すると、確かにnpmから登録したメールアドレスの確認メールが来ていました...全然気づかなかった。

f:id:l08084:20210509174829p:plain
npm空のメールアドレスの確認メール

メールの本文にメールアドレスの確認が完了すると、publishができるようになるよ〜って書いてありますね。

メールのVerify email addressボタンを押下すると下記の画面に遷移します。

f:id:l08084:20210509175324p:plain
この画面の時点ではメールアドレスの確認は終わっていません

npmアカウントでログインし直すと、下記の画面が表示されます。

f:id:l08084:20210509175511p:plain
Continueボタンを押下すると、メールアドレスの確認が完了

上記画面でContinueボタンを押下すると、メールアドレスの確認が完了します。

メールアドレスの確認が完了した後に、npm publishコマンドを実行すると、今度はCordovaプラグインの公開に成功します。

$ npm publish ./
+ cordova-plugin-cache-delete@1.0.1

動作確認

npm publishした後だと、以下のようにcordova plugin add [プラグインの名前]でCordovaプラグインをインストールすることができるようになります。

$ cordova plugin add cordova-plugin-cache-delete

初回のCordovaプラグイン公開だけでなく、Cordovaプラグインのバージョンアップも下記の通りnpm publishで実行できます

# パッチバージョンを上げる
$ npm version patch
# バージョンのアップグレードをnpmレジストリに反映する
$ npm publish ./

参考サイト

Plugin Development Guide - Apache Cordova

Contributing packages to the registry | npm Docs

Plugins Release and Moving plugins to npm: April 21, 2015 - Apache Cordova

3分でできるnpmモジュール - Qiita

npm publish や unpublish とかいろいろやってみたメモ - Memento

vue.js - NPM: 403 forbidden - PUT http://registry.npmjs.org/[package-name] - Forbidden - Stack Overflow

「npm audit」って何?って時に少し調べた時のノート - Programming Self-Study Notebook

NPM's suggestion for package lock and audit is misleading or doesn't work - 2nd attempt - 💁🏾 support - npm community portal