LoginSignup
9
4

More than 3 years have passed since last update.

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

Posted at

NuxtJSアプリのi18n(国際化)対応には、Vue用ライブラリvue-i18nとNuxt用にカスタムされたnuxt-i18nが使える。

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

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

セットアップ

公式ページの通りに進める。

$ yarn add nuxt-i18n

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

  • 日本語と英語の2種類(https://example.com/ja/https://example.com/en/
  • デフォルトは英語(https://example.com/は英語ページ)
  • フォールバック先は英語
  • 初回アクセス時はブラウザ設定から言語を推定
  • メッセージはlangディレクトリ内のja-JP.jsen-US.jsファイルに記述
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'
+    }
+  }
 }
lang/ja-JP.js
export default {
  welcome: 'ようこそ'
}
lang/en-US.js
export default {
  welcome: 'Welcome'
}

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

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型になるはず。

(例)i18n.d.ts
export interface I18n {
  system: {
    apptitle: string;
    header: string;
  };
  user: {
    name: string;
    age: string;
  };
}

2. 各言語のファイル

先ほどのinterfaceを実装する。

(例)ja-JP.ts
import { I18n } from './i18n';

const lang: I18n = {
  system: {
    apptitle: 'サンプルアプリ',
    header: 'サンプルのアプリです。'
  },
  user: {
    name: '名前',
    age: '年齢'
  }
};
export default lang;
(例)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というディレクトリを作って、そこにまとめて入れることにした。

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

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

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.tsen-US.ts.jsにトランスパイルされる。package.jsonscriptsに書いて、npm run langでビルドするように設定した。

package.json(抜粋)
{
  "scripts": {
    "lang": "tsc -p tools/lang"
  }
}

出力された.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で「補完用オブジェクトを使う」という方法を知った。

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

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ディレクトリを作った。間に合わせで雑に書いたコードで申し訳ない。

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で組み立てなかったのは怠慢)

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.jsonscriptsに追記した。

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

出力されたplugins/i18nHints.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.tspluginsディレクトリに出力しているのでもうお分かりかもしれないが、これをNuxt.jsのプラグインにして、各コンポーネントで毎回importしなくても済むようにする。

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

const i18nPlugin: Plugin = (_, inject) => {
  inject('i18nHints', i18nHints);
};
export default i18nPlugin;
nuxt.config.ts(抜粋)
 const nuxtConfig: Configuration = {
   plugins: [
     '@/plugins/vxm.ts',
+    '@/plugins/i18n.ts'
   ],
 };
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でビルドするのは多少面倒だが、そんなに多くないので今回は妥協。)

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

3-4. (VSCode用)Veturの設定

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

参考:Vue テンプレート内の式の型チェックと解析ができるまで | Web 猫

参考サイト(再掲)

9
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4