Use Google Sheets as a Translation Database for Your Web App (Apps Script + Next.js)のカバー画像

Hayrullah Kar

これまで見てきたあらゆるi18nセットアップには、同じ三つ巴の対立がある。開発者は型安全なJSONをリポジトリに置きたい。翻訳者はプルリクエストではなく使い慣れたツールを望む。プロダクトチームはデプロイなしで誤字を修正したい。そこで、月額50〜500ドルのローカライゼーションSaaSに課金するか、翻訳者のスプレッドシートとJSONファイル間で文字列をコピー&ペーストし続け、どこかで静かに壊れるまでやり続けることになる。

キーが約1,000件未満のプロジェクトでは、より良い中間策がある。スプレッドシート自体をデータベースにするのだ。翻訳者はGoogleスプレッドシートを編集し、Apps ScriptのエンドポイントがそれをきれいなロケールJSONとして提供し、アプリはビルド時にそれを読み込む。以下にコード付きで全体の手法を示す。

小規模プロジェクトで翻訳サービスよりスプレッドシートが優れている理由

ローカライゼーションSaaSは規模が大きくなるとその価値を発揮する——数十人の翻訳者、数千のキー、スクリーンショットやレビュー用のワークフローなど。300キー程度のマーケティングサイトにはそのような問題はなく、調整の問題がある。スプレッドシートは調整を無料で解決する。翻訳者はすでに使い方を知っており、改訂履歴や提案編集機能が最初から備わっており、プロダクトチームは10秒で文字列を変更できる。生のスプレッドシートに欠けている2つの要素——クリーンなJSON APIと欠落翻訳のフォールバック——を追加するだけでよい。

スキーマ:1タブにキーごとに1行

stringsタブを用意し、A列にキーを、以降の列に各ロケールを配置する。

key en tr es fr
hero.title Welcome Hoş geldiniz Bienvenido Bienvenue
hero.cta Get started Başla Empezar Commencer

ドット記法のキー(hero.title)を使うことで、i18nライブラリ内で自然にネストされたJSONが得られる。小さなmetaタブも用意する。B1セルにデフォルトロケール(en)、B3セルにバージョン(1.0.0)を入れる。

Apps Scriptエンドポイント

これをWebアプリとしてデプロイする(通常のApps Script webhookと同じ仕組み)。doGetは1つのロケールまたは全ロケールをJSONで提供し、クエリ内にフォールバックロジックを入れる。空のセルはデフォルトロケールに解決されるため、翻訳が途中のキーでも空白のまま配信されることはない。

// Code.gs
const SHEET_ID = 'your-sheet-id';

function doGet(e) {
  const locale = (e.parameter.locale || 'all').toLowerCase();
  const result = buildLocaleData(locale);
  return ContentService
    .createTextOutput(JSON.stringify(result))
    .setMimeType(ContentService.MimeType.JSON);
}

function buildLocaleData(locale) {
  const ss = SpreadsheetApp.openById(SHEET_ID);
  const data = ss.getSheetByName('strings').getDataRange().getValues();
  const headers = data[0];            // ['key','en','tr','es','fr']
  const localeIdx = {};
  headers.forEach((h, i) => {
    if (i > 0) localeIdx[String(h).toLowerCase()] = i;
  });

  const meta = ss.getSheetByName('meta');
  const defaultLocale = meta.getRange('B1').getValue() || 'en';
  const version = meta.getRange('B3').getValue() || '1.0.0';

  if (locale !== 'all' && !localeIdx[locale]) {
    return { error: 'Unknown locale', available: Object.keys(localeIdx) };
  }

  const targets = locale === 'all' ? Object.keys(localeIdx) : [locale];
  const out = { locale, defaultLocale, version, strings: {} };
  if (locale === 'all') targets.forEach(l => out.strings[l] = {});

  for (let row = 1; row < data.length; row++) {
    const key = data[row][0];
    if (!key) continue;
    if (locale === 'all') {
      targets.forEach(l => {
        out.strings[l][key] =
          data[row][localeIdx[l]] || data[row][localeIdx[defaultLocale]];
      });
    } else {
      out.strings[key] =
        data[row][localeIdx[locale]] || data[row][localeIdx[defaultLocale]];
    }
  }
  return out;
}

Enter fullscreen mode Exit fullscreen mode

buildLocaleDataはリリース前にユニットテスト済みだ。重要なケースは、空のセルがデフォルトロケールにフォールバックすること、空のキー行がスキップされること、不明なロケールが500ではなく利用可能なロケール一覧付きのエラーを返すことである。

フロントエンド統合:ビルド時に同期

ランタイムにエンドポイントを叩くのではなく、ビルド時に一度JSONを取得してpublic/localesにコミットする。アプリは静的ファイルを配信するため、スプレッドシートの障害がサイトをダウンさせることはない。

// scripts/sync-locales.ts
import fs from 'fs';
import path from 'path';

const URL = process.env.LOCALE_SOURCE_URL!;
const LOCALES = ['en', 'tr', 'es', 'fr'];

async function sync() {
  for (const locale of LOCALES) {
    const res = await fetch(`${URL}?locale=${locale}`);
    const data = await res.json();
    const out = path.join(process.cwd(), 'public', 'locales', `${locale}.json`);
    fs.writeFileSync(out, JSON.stringify(data.strings, null, 2));
    console.log(`✓ ${locale}: ${Object.keys(data.strings).length} strings`);
  }
}

sync();

Enter fullscreen mode Exit fullscreen mode

これをprebuildに組み込み、出力をnext-intli18nextに通常のJSONと同様に渡す。デプロイの合間に、ライブでプレビュー環境を用意したい場合は、エンドポイントの前に5分程度のエッジキャッシュを置けば十分だ。

キャッシュ、バージョン管理、欠落キーの検知

metaタブのversionフィールドにより、推測ではなく意図的にキャッシュを破棄できる。また、日次トリガーを設定すれば、「翻訳者が3つの文字列を忘れていた」という本番でのサプライズをメール通知に変えられる。

// Missing-keys alert — run on a daily time trigger
function alertMissingKeys() {
  const ss = SpreadsheetApp.openById(SHEET_ID);
  const data = ss.getSheetByName('strings').getDataRange().getValues();
  const headers = data[0];
  const missing = [];
  for (let r = 1; r < data.length; r++) {
    headers.forEach((h, c) => {
      if (c > 0 && !data[r][c]) missing.push(`${data[r][0]}${h}`);
    });
  }
  if (missing.length) {
    MailApp.sendEmail('[email protected]',
      `${missing.length} missing translations`,
      missing.join('\n'));
  }
}

Enter fullscreen mode Exit fullscreen mode

スプレッドシートの使用をやめてLokaliseに課金すべきタイミング

上限を正直に認識しよう。スプレッドシートは約1,000キーまでは快適に使える。Apps Scriptは秒間約50〜100リクエストを処理できるが、ビルド時の同期ではその水準に達しない。真の制限は人数だ。5人程度のアクティブな翻訳者を超えると、適切なロール、スクリーンショット、レビューキューが必要になる——その時点でシートをCSVにエクスポートし、LokaliseやCrowdinに移行すべきだ。それより小さい規模では、ツール自体が調整コストであり、価値ではない。

落とし穴

  • ランタイムで取得しない。 ビルド時の同期により、スプレッドシートの障害が本番サイトを破壊しない。JSONをコミットする。
  • フォールバックはクエリ内に置き、クライアントには置かない。 上記のように空のセルがデフォルトロケールに解決される方が、コンポーネントに散在するフォールバックロジックよりシンプルで安全だ。
  • 空のキー行をスキップする。 翻訳者が空行を残すことがある。if (!key) continue;でガードしないと、JSONに""キーが含まれてしまう。
  • 意図的にバージョンを管理する。 タイムスタンプではなくversionフィールドでキャッシュすることで、誤字修正が完全再ビルドを強制したり、必要なときに再ビルドがスキップされたりするのを防ぐ。
  • 複数形:フォームごとに1行。 単純な数値以外は、ICU MessageFormat文字列を保存しクライアント側で解析する——独自の複数形カラムを考案しない。

まとめ

1,000キー未満のプロジェクトでは、i18nにサブスクリプションは必要ない。共有して編集可能なソースと、クリーンなJSON APIがあればよい。Googleスプレッドシートが前者、約40行のApps Scriptが後者を提供する。翻訳者は自分のツールを手に入れ、開発者はJSONを、プロダクトチームは即時編集を手に入れ、何も空白のまま配信されない。

本番版——エッジキャッシュレイヤー、バージョンピニング、完全なNext.js配線——はMageSheetブログに詳しく書かれている。

MageSheetチームにより作成。