🌐

i18n基盤の作り方:CSVによる辞書ファイル一元管理

2024/12/20に公開

この記事はCommune Advent Calendar 2024、シリーズ2の20日目の記事です。

Frontend Techleadの野口です。

はじめに

アプリケーションの成長に伴い、国際化(i18n)辞書ファイルの管理は複雑になっていきます。コンポーネントやページごとにファイルが分散していると、コンテンツの重複や保守性の課題が発生しがちです。本記事では、CSVファイルを使用してi18n辞書を一元管理し、各種i18nライブラリ形式に自動変換する、より効率的なアプローチを紹介します。

現状の課題

現在のi18n辞書ファイルには、以下のような問題があります:

  • コンポーネントやページごとに複数のファイルに分散
  • 個別のJSONファイルとして管理
  • 異なる言語間でのkey-value関係の追跡が困難
  • コンテンツの重複が発生しやすい
  • メンテナンスとレビューが困難
  • ライブラリ固有の形式に依存

解決策:CSVによる一元管理

なぜCSVなのか?

CSVファイルによる管理には、以下のような利点があります:

  • すべての翻訳を1つのファイルで管理
  • 言語間の関係を視覚的に確認しやすい
  • 非技術者でも扱いやすいフォーマット
  • プログラムによる処理と変換が容易
  • バージョン管理での差分確認が容易

CSVの構造と管理方法

CSVファイルは、最初の列に翻訳キー、続く列に各言語の翻訳を配置する構造を採用します:

key,ja-JP,en-US
common.hello,こんにちは,Hello
pages.home.welcome,ようこそ,Welcome

新しい翻訳の追加

新しい翻訳を追加する場合は、新しい行を追加します。

key,ja-JP,en-US
common.hello,こんにちは,Hello
common.goodbye,さようなら,Goodbye
pages.home.welcome,ようこそ,Welcome

新しい言語のサポート

新しい言語をサポートする場合は、新しい列を追加します。

key,ja-JP,en-US,fr-FR
common.hello,こんにちは,Hello,Bonjour
common.goodbye,さようなら,Goodbye,Au revoir
pages.home.welcome,ようこそ,Welcome,Bienvenue

様々なi18nライブラリへの対応

CSVによる一元管理の大きな利点は、i18nライブラリの変更に柔軟に対応できる点です。変換スクリプトを修正するだけで、同じCSVファイルから異なるライブラリが要求する形式の辞書ファイルを生成できます。

next-international形式への変換

next-international用の形式(各言語ごとの単一のオブジェクト)を生成する場合:

import fs from "node:fs";
import { parse } from "papaparse";
import csvFile from "./index.csv";

const convertCsvToNextInternational = (csvFilePath) => {
  const csvFile = fs.readFileSync(csvFilePath, "utf8");

  parse(csvFile, {
    header: true,
    skipEmptyLines: true,
    complete: (result) => {
      const jsonDataObject = {};

      // 各行に対してJSONを生成
      for (const row of result.data) {
        for (const key of Object.keys(row)) {
          if (key === "key") continue;
          if (!jsonDataObject[key]) {
            jsonDataObject[key] = {};
          }
          jsonDataObject[key][row["key"]] = row[key];
        }
      }

      // 言語ごとのTypeScriptファイルを書き出し
      for (const locale of Object.keys(jsonDataObject)) {
        const filePath = `./gen/i18n/${locale}.ts`;
        fs.writeFileSync(
          filePath,
          `export default ${JSON.stringify(jsonDataObject[locale], null, 2)} as const;`,
          "utf8"
        );
        console.log(`生成完了: ${filePath}`);
      }
    },
    error: (error) => {
      console.error("CSVのパースエラー:", error.message);
    },
  });
};

生成される形式:

// ja-JP.ts
export default {
  "common.hello": "こんにちは",
  "common.goodbye": "さようなら",
  "pages.home.welcome": "ようこそ"
} as const;

react-i18next形式への変換

react-i18next用の形式(namespacesごとに階層化された構造)を生成する場合:

import fs from "node:fs";
import { parse } from "papaparse";
import csvFile from "./index.csv";

const convertCsvToReactI18next = (csvFilePath) => {
  const csvFile = fs.readFileSync(csvFilePath, "utf8");

  parse(csvFile, {
    header: true,
    skipEmptyLines: true,
    complete: (result) => {
      const jsonDataObject = {};

      // 各行に対してJSONを生成
      for (const row of result.data) {
        for (const key of Object.keys(row)) {
          if (key === "key") continue;
          if (!jsonDataObject[key]) {
            jsonDataObject[key] = {
              translation: {} // react-i18next用のnamespace
            };
          }

          // キーに含まれる.(ドット)を階層構造に変換
          const keyParts = row["key"].split('.');
          let current = jsonDataObject[key].translation;

          for (let i = 0; i < keyParts.length - 1; i++) {
            const part = keyParts[i];
            if (!current[part]) {
              current[part] = {};
            }
            current = current[part];
          }

          current[keyParts[keyParts.length - 1]] = row[key];
        }
      }

      // 言語ごとのJSONファイルを書き出し
      for (const locale of Object.keys(jsonDataObject)) {
        const filePath = `./gen/i18n/${locale}.json`;
        fs.writeFileSync(
          filePath,
          JSON.stringify(jsonDataObject[locale], null, 2),
          "utf8"
        );
        console.log(`生成完了: ${filePath}`);
      }
    },
    error: (error) => {
      console.error("CSVのパースエラー:", error.message);
    },
  });
};

生成される形式:

// ja-JP.json
{
  "translation": {
    "common": {
      "hello": "こんにちは",
      "goodbye": "さようなら"
    },
    "pages": {
      "home": {
        "welcome": "ようこそ"
      }
    }
  }
}

.gitignoreの設定

生成されたファイルはgitでの管理を行わず、CI上で実行時に都度生成されるようにします。

手動で生成を行うことによるコミットし忘れを防ぎ、常に最新のcsvファイルの状態へ追従するようにします。

VRTの設定

文言の変更をトレースするために、VRTの機構を用意します。Chromatic, あるいはreg-suit+storycapの組み合わせなど何を用いても構いません。

csvの編集が行われた結果、どの画面にどのような影響が出るかを確認できれば良いです。

VRT(Visual Regression Testing)との組み合わせにより、

  1. CSVファイルで直接コンテンツを変更
  2. 各ライブラリ形式のファイルを自動生成
  3. VRTで意図したUI要素のみが変更されていることを確認
  4. アプリケーションの他の部分への予期せぬ影響を検出

このような開発フローを行うことができます。

i18nライブラリ移行のメリット

このアプローチにより、以下のような利点が得られます:

  1. 辞書データの独立性: CSVファイルは特定のライブラリに依存しない形式で管理
  2. 移行コストの削減: ライブラリ変更時は変換スクリプトの修正のみ
  3. 段階的な移行: 必要に応じて複数のライブラリ形式を同時に生成可能
  4. データの一貫性: 単一のソースから全ての形式を生成するため、整合性が保証される

まとめ

CSVファイルによるi18nコンテンツの一元管理は、国際化における、より保守性が高くアクセスしやすいアプローチを提供します。

自動変換ツールとビジュアルリグレッションテストを組み合わせることで、複数の言語ファイル管理の複雑さを軽減しながら、高品質な翻訳を維持することができます。

コミューン株式会社

Discussion