NuxtJS+TypeScriptの開発記録 (4) nuxt-i18nの設定、管理方法模索

NuxtJSアプリのi18n（国際化）対応には、Vue用ライブラリ[vue-i18n](https://kazupon.github.io/vue-i18n/)とNuxt用にカスタムされた[nuxt-i18n](https://github.com/nuxt-community/nuxt-i18n)が使える。

ざっくり言うと、Nuxt用にカスタムされた便利機能で不都合が出るなら[vue-i18n](https://kazupon.github.io/vue-i18n/)を使い、そうでなければ[nuxt-i18n](https://github.com/nuxt-community/nuxt-i18n)を使った方が楽なようだ。
（例えば`nuxt-i18n`は`https://example.com/ja/`や`https://example.com/en/`といったルーティングを自動で行ってくれるが、サイトの要件によってはこれが不都合になることもあるだろう。）

今回はこのURLで特に不都合がないので`nuxt-i18n`を採用した。

## セットアップ

[公式ページ](https://nuxt-community.github.io/nuxt-i18n/setup.html)の通りに進める。

```
$ yarn add nuxt-i18n
```

設定は`nuxt.config.ts`に追記する。今回は以下のような設定にした。

* 日本語と英語の2種類（`https://example.com/ja/`と`https://example.com/en/`）
* デフォルトは英語（`https://example.com/`は英語ページ）
* フォールバック先は英語
* 初回アクセス時はブラウザ設定から言語を推定
* メッセージは`lang`ディレクトリ内の`ja-JP.js`、`en-US.js`ファイルに記述

```diff:nuxt.config.ts（抜粋）
 {
   modules: [
+    'nuxt-i18n'
   ],
+  /*
+   ** i18n module configuration
+   ** See https://nuxt-community.github.io/nuxt-i18n/
+   */
+  i18n: {
+    locales: [
+      {
+        code: 'ja',
+        iso: 'ja-JP',
+        name: '日本語',
+        file: 'ja-JP.js'
+      },
+      {
+        code: 'en',
+        iso: 'en-US',
+        name: 'English',
+        file: 'en-US.js'
+      }
+    ],
+    defaultLocale: 'en',
+    strategy: 'prefix_and_default',
+    detectBrowserLanguage: {
+      useCookie: true
+    },
+    lazy: true,
+    langDir: 'lang/',
+    vueI18n: {
+      fallbackLocale: 'en'
+    }
+  }
 }
```

```js:lang/ja-JP.js
export default {
  welcome: 'ようこそ'
}
```

```js:lang/en-US.js
export default {
  welcome: 'Welcome'
}
```

これで`welcome`のメッセージが使えるようになる。

```vue:Welcome.vue（抜粋）
<template>
  <p>{{ $t('welcome') }}</p>
</template>
```

## 翻訳ファイルの管理方法

ここで、tscに甘えっぱなしの私としては、翻訳ファイルが`.js`であることに不安を覚えた。あと`$t('welcome')`の`'welcome'`という文字列リテラルも嫌だ。補完もチェックも効かない。つらい。

今回は、翻訳も開発も一人でやる小規模なプロジェクトなので、以下のようにTypeScriptに寄せた形で管理を行うことにした。

1. 翻訳ファイルの型（どんなメッセージがあるか）を**TypeScriptの`interface`で表現**する
2. 各言語のファイルはその`interface`を実装するオブジェクトとして表現する
3. `$t`に渡すキーをその`interface`を実装するオブジェクトとして自動生成する

### 1. 翻訳ファイルの`interface`

どんなメッセージが存在するかをTypeScriptの`interface`で記述していく。インターフェースなのでネストも可能だが、値はすべて`string`型になるはず。

```ts:（例）i18n.d.ts
export interface I18n {
  system: {
    apptitle: string;
    header: string;
  };
  user: {
    name: string;
    age: string;
  };
}
```

### 2. 各言語のファイル

先ほどの`interface`を実装する。

```ts:（例）ja-JP.ts
import { I18n } from './i18n';

const lang: I18n = {
  system: {
    apptitle: 'サンプルアプリ',
    header: 'サンプルのアプリです。'
  },
  user: {
    name: '名前',
    age: '年齢'
  }
};
export default lang;
```

```ts:（例）en-US.ts
import { I18n } from './i18n';

const lang: I18n = {
  system: {
    apptitle: 'Sample App',
    header: 'My sample application.'
  },
  user: {
    name: 'Name',
    age: 'Age'
  }
};
export default lang;
```

`vue-i18n`は`.ts`のファイルを読めないので、`tsc`でビルドしてやる必要がある。今回は分量が少ないので手動でビルドしたが、ビルドプロセスに組み込むといいかもしれない。

これらの`.ts`ファイルはNuxtアプリのビルドに直接は不要な補助ファイルなので、ルートに`tools`というディレクトリを作って、そこにまとめて入れることにした。

```text
(Nuxtのルート)
└─tools
    └─lang
        en-US.ts
        i18n.d.ts
        ja-JP.ts
        tsconfig.json
```

`tsconfig.json`は`sourceMap`をオフにしてルート直下の`lang`ディレクトリ内に`.js`ファイルを出力する設定。

```json:tools/lang/tsconfig.json（抜粋）
{
  "compilerOptions": {
    "target": "es2018",
    "module": "esnext",
    "moduleResolution": "node",
    "sourceMap": false,
    "rootDir": ".",
    "outDir": "../../lang",
    "skipLibCheck": true
  },
  "exclude": ["node_modules"]
}
```

`tsc -p tools/lang`すれば`ja-JP.ts`と`en-US.ts`が`.js`にトランスパイルされる。`package.json`の`scripts`に書いて、`npm run lang`でビルドするように設定した。

```json:package.json（抜粋）
{
  "scripts": {
    "lang": "tsc -p tools/lang"
  }
}
```

出力された`.js`ファイルは以下のようになる。（型情報が消えただけで、ほとんど変わらない）

```js:lang/ja-JP.js
const lang = {
  system: {
    apptitle: 'サンプルアプリ',
    header: 'サンプルのアプリです。'
  },
  user: {
    name: '名前',
    age: '年齢'
  }
};
export default lang;
```

### 3. 補完用ファイルの自動生成

これで翻訳ファイルは`tsc`の恩恵を受けられるようになったが、コンポーネント内の`$t('system.apptitle')`は依然としてただの文字列リテラルである。つらい。

#### 3-1. 補完用ファイル

管理方法を検索していたところ、[vue-i18n の簡易入力補完を実装してみた雑感 | For X Developers](https://blog.tanaka.world/vue-i18n-autocompletion/)で「補完用オブジェクトを使う」という方法を知った。

もし以下のようなオブジェクトがあれば、補完とチェックが効く形で翻訳ファイルのキーを指定できる。

```ts
const hints = {
  system: {
    apptitle: 'system.apptitle', // 自身のキー（パス）が値として入っている
    header: 'system.header'
  },
  user: {
    name: 'user.name',
    age: 'user.age'
  }
};
```

参考にした記事はJSを前提に「補完」のみを目的にしていたので、このオブジェクトを動的に生成している。しかし私としては、型チェック（典型的には、typoで存在しないメンバーを指定していたら警告を出す）してほしいので……TypeScriptでやれたらいいなぁ。

オブジェクトを手動で作ってもいいのだが、管理が面倒だ。TypeScriptにはASTをいじる機能があるから、先ほどの`interface`から自動生成してみた。

#### 3-2. 補完用ファイル自動生成プログラム

補完用ファイル自動生成プログラム用に`tools/i18n`ディレクトリを作った。間に合わせで雑に書いたコードで申し訳ない。

```ts:tools/i18n/src/index.ts
import * as ts from 'typescript';

const file = './tools/lang/i18n.d.ts'; // 元にするinterfaceのファイル

function getIdentifer(ps: ts.PropertySignature): string {
  if (ps.name.kind === ts.SyntaxKind.Identifier) {
    const psname = ps.name as ts.Identifier;
    const name = psname.escapedText.toString();
    if (name.length > 0) {
      return name;
    }
  }
  return '';
}

function getType(
  ps: ts.PropertySignature
): 'string' | ts.TypeLiteralNode | 'invalid' {
  if (!ps.type) return 'invalid';
  if (ps.type.kind === ts.SyntaxKind.StringKeyword) return 'string';
  if (ps.type.kind === ts.SyntaxKind.TypeLiteral) {
    return ps.type as ts.TypeLiteralNode;
  }
  return 'invalid';
}

type KV = { [key: string]: string | KV };

function parseMembers(members: ts.NodeArray<ts.TypeElement>, path = ''): KV {
  const result: KV = {};
  members.forEach(member => {
    if (member.kind !== ts.SyntaxKind.PropertySignature) return;
    const name = getIdentifer(member as ts.PropertySignature);
    if (name.length === 0) return;
    const type = getType(member as ts.PropertySignature);
    if (type === 'invalid') return;
    const val = path.length === 0 ? name : path + '.' + name;
    if (type === 'string') {
      result[name] = val;
      return;
    }
    result[name] = parseMembers(type.members, val);
  });
  return result;
}

function parseInterface(itrfc: ts.InterfaceDeclaration): KV {
  return parseMembers(itrfc.members);
}

function createIndent(nest: number): string {
  let result = '';
  for (let i = 0; i < nest; i++) {
    result += '  ';
  }
  return result;
}

function toString(data: KV, nest = 0): string {
  const indent = createIndent(nest);
  let result = indent + '{\n';
  const indent2 = createIndent(nest + 1);
  Object.keys(data).forEach(key => {
    const v = data[key];
    if (typeof v === 'string') {
      result += indent2 + key + ": '" + v + "',\n";
    } else {
      result += indent2 + key + ': ' + toString(v, nest + 1) + ',\n';
    }
  });
  return result + indent + '}';
}

function toTS(data: KV): string {
  return (
    "import { I18n } from '@/tools/lang/i18n';\n\n" +
    'export const i18nHints: I18n = ' +
    toString(data) +
    ';\n'
  );
}

// Create program
const program = ts.createProgram([file], {});
// Get source of the specified file
const source = program.getSourceFile(file);
// Parse AST
const itrfc = source?.statements[0];
if (itrfc) {
  const data = parseInterface(itrfc as ts.InterfaceDeclaration);
  const src = toTS(data);
  process.stdout.write(src);
}
```

ざっくり説明すると、`interface`のファイルを開いてASTをパースし、プロパティの名前と型（ネストしているか`string`か）を取得、「自身のキー（パス）が値として入っているオブジェクト」として文字列組み立てして出力している。（ASTで組み立てなかったのは怠慢）

```json:tools/i18n/tsconfig.json（抜粋）
{
  "compilerOptions": {
    "target": "es2018",
    "module": "CommonJS",
    "moduleResolution": "node",
    "rootDir": "./src",
    "outDir": "./build",
    "skipLibCheck": true
  },
  "exclude": ["node_modules"]
}
```

このプログラムをトランスパイルして`tools/i18n/build/index.js`とかにして、`node`で実行し、結果を補完用ファイルにリダイレクトしてやればよい。これも`package.json`の`scripts`に追記した。

```js:package.json（抜粋）
{
  "scripts": {
    "i18n:build": "tsc -p tools/i18n", // 補完用ファイルビルドプログラムのビルド（ややこしい）
    "i18n:gen": "node tools/i18n/build/index.js > plugins/i18nHints.ts", // 補完用ファイルのビルド
  }
}
```

出力された`plugins/i18nHints.ts`は以下のようになる。

```ts:plugins/i18nHints.ts
import { I18n } from '@/tools/lang/i18n';

export const i18nHints: I18n = {
  system: {
    apptitle: 'system.apptitle',
    header: 'system.header'
  },
  user: {
    name: 'user.name',
    age: 'user.age'
  }
};
```

TypeScriptファイルであることと、1で定義した`interface`を実装するようにしているため、自動生成プログラムに問題があってもすぐに気付けると思う。

#### 3-3. 補完用ファイルのプラグイン化

`i18nHints.ts`を`plugins`ディレクトリに出力しているのでもうお分かりかもしれないが、これをNuxt.jsのプラグインにして、各コンポーネントで毎回`import`しなくても済むようにする。

```ts:plugins/i18n.ts
import { Plugin } from '@nuxt/types';
import { i18nHints } from './i18nHints'; // 自動生成される補完用ファイル

const i18nPlugin: Plugin = (_, inject) => {
  inject('i18nHints', i18nHints);
};
export default i18nPlugin;
```

```diff:nuxt.config.ts（抜粋）
 const nuxtConfig: Configuration = {
   plugins: [
     '@/plugins/vxm.ts',
+    '@/plugins/i18n.ts'
   ],
 };
```

```diff:vue-shim.d.ts
 import { VXM } from '@/store';
+import { I18n } from '@/tools/lang/i18n';

 declare module '*.vue' {
   export default Vue;
 }

 declare module 'vue/types/vue' {
   interface Vue {
     $vxm: VXM;
+    $i18nHints: I18n;
   }
 }
```

これで、SFCのテンプレート部分でもスクリプト部分でも補完とチェックが効く`i18nHints`が手に入った。
（メッセージを変更するたびに`npm run`でビルドするのは多少面倒だが、そんなに多くないので今回は妥協。）

```vue:Welcome.vue（抜粋）
<template>
  <p>{{ $t($i18nHints.system.apptitle) }}</p>
</template>
```

#### 3-4. （VSCode用）Veturの設定

`"vetur.experimental.templateInterpolationService": true`にすると、テンプレート部分での補完やチェックが効くようになる。

参考：[Vue テンプレート内の式の型チェックと解析ができるまで | Web 猫](https://katashin.info/2019/04/28/261)

## 参考サイト（再掲）

* [nuxt-i18n](https://nuxt-community.github.io/nuxt-i18n/)
* [vue-i18n の簡易入力補完を実装してみた雑感 | For X Developers](https://blog.tanaka.world/vue-i18n-autocompletion/)
* [Vue テンプレート内の式の型チェックと解析ができるまで | Web 猫](https://katashin.info/2019/04/28/261)