NuxtJSアプリのi18n(国際化)対応には、Vue用ライブラリvue-i18nとNuxt用にカスタムされたnuxt-i18nが使える。
ざっくり言うと、Nuxt用にカスタムされた便利機能で不都合が出るならvue-i18nを使い、そうでなければnuxt-i18nを使った方が楽なようだ。
(例えばnuxt-i18n
はhttps://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.js
、en-US.js
ファイルに記述
{
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'
+ }
+ }
}
export default {
welcome: 'ようこそ'
}
export default {
welcome: 'Welcome'
}
これでwelcome
のメッセージが使えるようになる。
<template>
<p>{{ $t('welcome') }}</p>
</template>
翻訳ファイルの管理方法
ここで、tscに甘えっぱなしの私としては、翻訳ファイルが.js
であることに不安を覚えた。あと$t('welcome')
の'welcome'
という文字列リテラルも嫌だ。補完もチェックも効かない。つらい。
今回は、翻訳も開発も一人でやる小規模なプロジェクトなので、以下のようにTypeScriptに寄せた形で管理を行うことにした。
- 翻訳ファイルの型(どんなメッセージがあるか)をTypeScriptの
interface
で表現する - 各言語のファイルはその
interface
を実装するオブジェクトとして表現する -
$t
に渡すキーをそのinterface
を実装するオブジェクトとして自動生成する
1. 翻訳ファイルのinterface
どんなメッセージが存在するかをTypeScriptのinterface
で記述していく。インターフェースなのでネストも可能だが、値はすべてstring
型になるはず。
export interface I18n {
system: {
apptitle: string;
header: string;
};
user: {
name: string;
age: string;
};
}
2. 各言語のファイル
先ほどのinterface
を実装する。
import { I18n } from './i18n';
const lang: I18n = {
system: {
apptitle: 'サンプルアプリ',
header: 'サンプルのアプリです。'
},
user: {
name: '名前',
age: '年齢'
}
};
export default lang;
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.json
はsourceMap
をオフにしてルート直下のlang
ディレクトリ内に.js
ファイルを出力する設定。
{
"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
でビルドするように設定した。
{
"scripts": {
"lang": "tsc -p tools/lang"
}
}
出力された.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
ディレクトリを作った。間に合わせで雑に書いたコードで申し訳ない。
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で組み立てなかったのは怠慢)
{
"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
に追記した。
{
"scripts": {
"i18n:build": "tsc -p tools/i18n", // 補完用ファイルビルドプログラムのビルド(ややこしい)
"i18n:gen": "node tools/i18n/build/index.js > 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.ts
をplugins
ディレクトリに出力しているのでもうお分かりかもしれないが、これをNuxt.jsのプラグインにして、各コンポーネントで毎回import
しなくても済むようにする。
import { Plugin } from '@nuxt/types';
import { i18nHints } from './i18nHints'; // 自動生成される補完用ファイル
const i18nPlugin: Plugin = (_, inject) => {
inject('i18nHints', i18nHints);
};
export default i18nPlugin;
const nuxtConfig: Configuration = {
plugins: [
'@/plugins/vxm.ts',
+ '@/plugins/i18n.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
でビルドするのは多少面倒だが、そんなに多くないので今回は妥協。)
<template>
<p>{{ $t($i18nHints.system.apptitle) }}</p>
</template>
3-4. (VSCode用)Veturの設定
"vetur.experimental.templateInterpolationService": true
にすると、テンプレート部分での補完やチェックが効くようになる。
参考:Vue テンプレート内の式の型チェックと解析ができるまで | Web 猫