自作Schematicsを開発する上でやっておくといいかもしれないこと

# はじめに
AngularはAngular CLIの`ng generate`を用いることで、サービスやコンポーネントのコードを生成することができますが、そのコード生成の仕組みであるschematicsが私たち開発者にも使える形で公開されています。
schematicsの開発は[公式ドキュメント](https://angular.io/guide/schematics-authoring#cli)にも書いてあるとおり、`@angular-devkit/schematics-cli`でリポジトリを生成してから開発できますが、開発環境をより自分好みのものに変更することもできます。
この記事では、私が実際にschematicsを開発する上でやった開発環境のカスタマイズや、ng generateから実行することを想定して公開する場合に考慮するといいと思うことを紹介します。

- ビルド生成物を出力するディレクトリを変える
- Yarn, Jest, ESLint, Prettierの導入
- TypeScriptのバージョンアップ
- 各オプションへの対応方法
    - --flat
    - --skip-tests
    - パス付きの--name
    - --lint-fix
    - --project

まだschematicsを作ったことがないという人は、@puku0x さんの[チュートリアル記事](https://qiita.com/puku0x/items/462a038133e7233dfaed)がわかりやすかったので参考にしてみてください。

# 環境を整える
下記のようにschematics-cliのコマンドを使ってリポジトリを生成するとschematicsの開発に必要なものは全て揃っていますが、開発に普段遣いのツールを使いたい場合もあると思います。

```bash:プロジェクト生成直後の状態
$ schematics blank my-schematics
$ tree -a  my-schematics -I node_modules
my-schematics
├── .gitignore
├── .npmignore
├── README.md
├── package-lock.json
├── package.json
├── src
│   ├── collection.json
│   └── my-schematics
│       ├── index.ts
│       └── index_spec.ts
└── tsconfig.json
```
```package.json
{
  "name": "my-schematics",
  "version": "0.0.0",
  "description": "A blank schematics",
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "test": "npm run build && jasmine src/**/*_spec.js"
  },
  "keywords": [
    "schematics"
  ],
  "author": "",
  "license": "MIT",
  "schematics": "./src/collection.json",
  "dependencies": {
    "@angular-devkit/core": "^8.3.21",
    "@angular-devkit/schematics": "^8.3.21",
    "@types/jasmine": "^3.3.9",
    "@types/node": "^8.0.31",
    "jasmine": "^3.3.1",
    "typescript": "~3.5.3"
  }
}
```
## ビルド結果を別のディレクトリに吐くようにする
初期生成のtsconfig.jsonだとそのまま.tsファイルのあるディレクトリにビルド結果のファイルが生成されます。index.tsに対してはindex.js, index.js.map, index.d.tsが生成されます。
このままではディレクトリ内のファイルが増えてくると目的のファイルを探し辛くなってくるので、別ディレクトリに吐き出すようにしたいです。
まずはtsconfig.jsonにoutDirを指定しましょう。注意点としては、rootDirがないとディレクトリ構造が保持されないのでrootDirは残しておいてください。

```diff:tsconfig.json
     "noImplicitThis": true, 
     "noUnusedParameters": true, 
     "noUnusedLocals": true, 
     "rootDir": "src", 
+    "outDir": "dist", 
     "skipDefaultLibCheck": true, 
     "skipLibCheck": true, 
     "sourceMap": true, 
```

publishするディレクトリを指定するためにpackage.jsonに追記します。

```diff:package.json
    "scripts": { 
-     "build": "tsc -p tsconfig.json",
+     "build": "tsc -p tsconfig.json && scripts/post-build.sh",
...
     "author": "", 
     "license": "MIT", 
-    "schematics": "./src/collection.json",
+    "schematics": "./dist/collection.json",
+    "files": [ 
+      "dist" 
+    ], 
     "dependencies": { 
```
src下のcollection.jsonはsrcからの相対パスでschematisのファイルの場所を指定しているので、distディレクトリに移さなければならないです。
ビルドしてもcollection.jsonやschema.jsonはdistディレクトリに移らないので、ここではビルド後に実行される簡単なシェルスクリプトを実行するようにします。

```scripts/post-build.sh
#!/bin/bash

if [[ -f src/collection.json ]]; then
  cp src/collection.json dist/
fi

find src -name files | egrep 'files$' | while read src; do
  dist=$(echo $src | sed 's/src/dist/' | sed 's|/files$||')
  cp -a $src $dist
done

find src -name schema.json | while read src; do
  dist=$(echo $src | sed 's/src/dist/' | sed 's|/schema.json||')
  cp $src $dist
done
```
json以外にtsconfig.jsonでビルドから除外しているテンプレートファイルも移すようにしています。
シンボリックリンクではなくファイルをコピーしているのはnpm publishするときにシンボリックリンクがパッケージされないためです。

distに吐き出すようにしたので.npmignoreはいらなくまりました。消しても大丈夫です。
.gitignoreも書き換えましょう。

```diff:.gitignore
  # Outputs
-  src/**/*.js
-  src/**/*.js.map
-  src/**/*.d.ts
+  dist/
```

## Jasmineの代わりにJestを使う
schematicsの開発はJasmineのままでも問題なく開発できると思います。
ただ、普段の開発でJasmineよりもJestの方が使い慣れているのであれば代えてしまってもいいと思います。

まずは必要なパッケージをインストールします。

```
npm i -D jest ts-jest @types/jest
```

```diff:package.json
    "scripts": {
       "build": "tsc -p tsconfig.json", 
-      "test": "npm run build && jasmine src/**/*_spec.js" 
+      "test": "jest", 
+      "test:watch": "jest --watch" 
     }, 
```

```diff:tsconfig.json
   "types": [
-    "jasmine",
+    "jest",
     "node"
   ]
```

`npx ts-jest config:init`すると設定ファイルjest.config.jsが生成されます。
そして、このままだとテンプレートファイルの.spec.tsやビルド結果もテスト対象なので除外するよう追記します。

```diff:jest.config.js
 module.exports = {
   preset: 'ts-jest',
   testEnvironment: 'node',
+  testPathIgnorePatterns: ['/node_modules/', 'files', '/dist/']
 };
```

## npmの代わりにyarnを使う
yarn派の場合速やかにyarnしましょう。package-lock.jsonを消してプロジェクトルートでyarnするだけです(この記事ではnpmで説明します)

```
rm package-lock.json
yarn
```

## TypeScriptのバージョンを上げる
この記事を書いている時点でのschematics-cliでは、生成されるプロジェクトのTypeScriptのバージョンは3.5.3でした。
現在の最新は3.7.4です。必要に応じてアップデートしてもいいと思います。

```
npm uninstall typescript
npm install -D typescript@latest
```

## ESLintとPrettierを設定する
普段Angular上の開発ではTSLintとPrettierを設定して使っているのですが、TSLintは非推奨となっていて([2020年末には完全に開発停止](https://github.com/palantir/tslint/issues/4534))せっかくなのでESLintを設定します。

最初に必要なパッケージをインストールしましょう。

```
npm i -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin prettier eslint-config-prettier eslint-plugin-prettier eslint-plugin-jest
```

[@typescrpt-eslintの公式ドキュメントを元に](https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/README.md).eslint.jsを作成します。
JSONやYAMLでも書けます。

```js:.eslint.js
module.exports = {
  root: true,
  parser: '@typescript-eslint/parser',
  plugins: [
    '@typescript-eslint',
    'jest'
  ],
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/eslint-recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:jest/recommended',
    'plugin:prettier/recommended',
    'prettier/@typescript-eslint'
  ],
};
```
このうち、`plugin:prettier/recommended`は[eslintのフォーマットに関するルールを無効にするもの](https://github.com/prettier/eslint-plugin-prettier#recommended-configuration)で、`prettier/@typescript-eslint`は[@typescript-eslintのフォーマットに関するルールを打ち消すもの](https://github.com/prettier/eslint-config-prettier/blob/master/%40typescript-eslint.js)です。
詳しくは下記のリポジトリを参照してみてください

- https://github.com/prettier/eslint-plugin-prettier
- https://github.com/prettier/eslint-config-prettier

また、必要に応じてprettierの設定を書いてください。こちらもJSONで書いてもYAMLで書いてもいいです。

```js:.prettierrc.js
module.exports = {
  singleQuote: true
}
```

コマンドからもlintしたい場合はpackage.jsonのscriptsに追記します。

```diff:package.json
   "scripts": {
     "build": "tsc -p tsconfig.json && scripts/post-build.sh",
+    "lint": "eslint 'src/**/*.ts'",
+    "lint:fix": "eslint 'src/**/*.ts' --fix",
     "test": "jest",
     "test:watch": "jest --watch"
  },
```

# ng generateのオプションをサポートする
もし、schematicsの実行をng generateコマンドを介して行うことを想定している場合、componentやserviceなどビルトインのschematicsで利用できるオプションは確認しておくといいです。

```bash
# ビルトインのschematicsの一覧を表示する
$ ng generate --help 
# serviceのオプションを確認する
$ ng generate service --help
...
Help for schematic service
Creates a new, generic service definition in the given or default project.
arguments:
  name
    The name of the service.

options:
  --flat
    When true (the default), creates files at the top level of the project.
  --lint-fix
    When true, applies lint fixes after generating the service.
  --project
    The name of the project.
  --skip-tests
    When true, does not create "spec.ts" test files for the new service.
  --spec
    When true (the default), generates a  "spec.ts" test file for the new service.
```
serviceを拡張するようなschematicを公開する場合、ビルトインのserviceのschematicの振る舞いはできる限りサポートしたほうがよいでしょう。
また、特筆すべきビルトインのschematicsの振る舞いとして、`path/to/name`のように名前を指定すると`path/to`ディレクトリ以下に作成することができます。

```
$ ng generate service path/to/foo
CREATE src/app/path/to/foo.service.spec.ts (318 bytes)
CREATE src/app/path/to/foo.service.ts (132 bytes)
```

今回はserviceを例に各オプションの実装ポイントを解説します。他のビルトインschematicsのオプションに関しては実際の実装(のindex.tsやschema.ts)を見てください。

- [angular-cli/packages/schematics/angular](https://github.com/angular/angular-cli/tree/master/packages/schematics/angular)

## --flat
serviceでは--flatオプションはデフォルトtrueで、falseを指定したときに`name`ディレクトリの中にファイルを生成します。

```
$ ng generate service user --flat false
CREATE src/app/user/user.service.spec.ts (323 bytes)
CREATE src/app/user/user.service.ts (133 bytes)
```
これを実装するには、[serviceの実装](https://github.com/angular/angular-cli/tree/master/packages/schematics/angular/service)のとおり、filesディレクトリの中に`__name@dasherize@if-flat__`ディレクトリを作り、その中にテンプレートファイルを入れるとよいです。
そして、下記のようにif-flat関数をtemplate関数に入れるとよいです。

```ts:index.ts(一部抜粋)
const templateSource = apply(url('./files'), [
  template({
    ...options,
    'if-flat': (s: string) => (options.flat ? '' : s),
    ...strings
  })
});
```

## --skip-tests
--skip-testsは.spec.tsファイルを生成しないオプションです。
Angular CLI上ではケバブケース(ハイフン区切り)で表記されていますが、schema.tsやschema.jsonではキャメルケースで定義します。

```json:schema.json(一部抜粋)
"skipTests": {
  "type": "boolean",
  "description": "When true, does not create \"spec.ts\" test files for the new service.",
  "default": false
}
```

```schema.ts
export interface ServiceOptions {
  // ...
  skipTests?: boolean;
}
```

.spec.tsを含めないためには、`@angular-devkit/core`のfilter関数を使います。

```ts:index.ts(一部抜粋)
import {
  filter,
  noop,
  // ...
} from '@angular-devkit/schematics';

export default function(options: ServiceOptions): Rule {
  return (tree: Tree, context: SchematicContext) => {
    // ...
    const templateSource = apply(url('./files'), [
      options.skipTests ? filter(path => !path.endsWith('.spec.ts.template')) : noop(),
      applyTemplates({
        ...strings,
        'if-flat': (s: string) => options.flat ? '' : s,
        ...options,
      })
    ]);
    //...
```
--specオプションは--skip-testsオプションとは逆にfalseを指定すると(デフォルトtrue).spec.tsを生成しないオプションです。
おそらく後方互換性のために残しているオプションなので自分のschematicsでは--skip-testsさえ考慮しておけば良さそうです。

## --name
--nameオプションは第一引数にオプションフラグ(--name)なしに指定することができ、path/to/nameのように作成するディレクトリを指定することができます。
第一引数を割り当てるためにはnameのschemaを以下のように`$default`キーワードを定義します。

```json:schema.json(一部抜粋)
"name": {
  "type": "string",
  "description": "The name of the service.",
  "$default": {
    "$source": "argv",
    "index": 0
  }
}
```
ディレクトリを指定するためには、nameに入った文字列をparseして名前の部分とパスの部分に分ける必要があります。
また、service schematicの実装ではparseされたパスはoptionsオブジェクトのpathプロパティにschematic実行時に入れるような実装になっています。

```json:schema.json(一部抜粋)
"path": {
  "type": "string",
  "format": "path",
  "description": "The path at which to create the service, relative to the workspace root.",
  "visible": false
}
```
pathオプションは`"visible": false`が指定されているので、Angular CLI上ではユーザーから見えないオプションになっています(helpに出ないだけで指定することは可能)。

```ts:schema.ts(一部抜粋)
export interface ServiceOptions {
  // ...
  path?: string
}
```

parse処理は`@schematics/angular`に[parseName関数](https://github.com/angular/angular-cli/blob/60746edc4cc6369c0fb3f6b69a5764b03e4379da/packages/schematics/angular/utility/parse-name.ts#L17)が定義されているので、`@schematics/angular`をインストールして利用しましょう。

```
npm i -D @schematics/angular
```

```ts:index.ts(一部抜粋)
import { parseName } from '@schematics/angular/utility/parse-name';

export default function(options: ServiceOptions): Rule {
  return (tree: Tree, context: SchematicContext) => {
    // ...
    const parsedPath = parseName(options.path || '', options.name);
    options.name = parsedPath.name;
    options.path = parsedPath.path;

    const templateSource = apply(url('./files'), [
      applyTemplates({/* ...*/}),
      move(options.path)
    ]);
    // ...
  };
}
```
nameに入っていた`'path/to/name'`から`'/path/to'`と`'name'`に分けられます。
その後テンプレートファイルが`@angular-devkit/core`の[move関数](https://github.com/angular/angular-cli/blob/60746edc4cc6369c0fb3f6b69a5764b03e4379da/packages/angular_devkit/schematics/src/rules/move.ts#L13)により`'/path/to'`ディレクトリに移されます。

## --lint-fix
--lint-fixはテンプレートを生成した後プロジェクトルートのlintルールを適用するオプションです。
[applylintFix関数](https://github.com/angular/angular-cli/blob/60746edc4cc6369c0fb3f6b69a5764b03e4379da/packages/schematics/angular/utility/lint-fix.ts#L17)が`@schematics/angular`に定義されているので利用しましょう。

```ts:index.ts(一部抜粋)
import { applyLintFix } from '@schematics/angular/utility/lint-fix';

export default function(options: ServiceOptions): Rule {
  return (tree: Tree, context: SchematicContext) => {
    //...
    return chain([
      mergeWith(templateSource),
      options.lintFix ? applyLintFix(options.path) : noop()
    ]);
  };
}
```

## --project
Angularは[１つのワークスペースの中に複数のプロジェクトをもたせることができます。](https://angular.jp/guide/file-structure#%E3%83%9E%E3%83%AB%E3%83%81%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88)
--projectは存在するプロジェクト名が指定されるとそのプロジェクトのangular.jsonに書かれているsourceRootで指定されているパス(通常`/projects/プロジェクト名/src`)に生成されます。
また、複数プロジェクト構成でない場合は、Angular CLIにより--projectにデフォルトのプロジェクト名が渡されます。

service schematicでは`@schematics/angular`の[createDefaultPath関数](https://github.com/angular/angular-cli/blob/60746edc4cc6369c0fb3f6b69a5764b03e4379da/packages/schematics/angular/utility/workspace.ts#L82)でangular.jsonに指定されているパスを持ってこられます。

```ts:index.ts(一部抜粋)
export default function (options: ServiceOptions): Rule {
  return async (host: Tree) => {
    if (options.path === undefined) {
      options.path = await createDefaultPath(host, options.project as string);
    }

    const parsedPath = parseName(options.path, options.name);
    // ...
  };
}
```
ただし、schematics-cliで動作確認する場合は注意してください。
createDefaultPath関数の仕様上、angularワークスペース上でなければプロジェクトが存在せず例外が送出されるためです。
Angularプロジェクト上で動かすか、もしくは--pathを明示的に指定する必要があります。

# おわりに
Schematicsの実装の仕方はやはり[ビルトインのSchematicsの実装](https://github.com/angular/angular-cli/tree/master/packages/schematics/angular)を参考にするのが一番です。
例えば--moduleオプションなど、今回解説していないオプションについても同じようにして実装できるはずです。
また、テストの書き方に関しては詳しく述べていないですが、[僕のschematicsリポジトリ](https://github.com/softoika/schematics)で細かくテストを書いているのでよければ参考にしてみてください。