こんにちは。ShareWis プロダクトチームのファット(Phat)です。本記事は、私が英語で書いた記事を日本語化したものです。ShareWis のプロダクトチームには海外出身のエンジニアも在籍していて、普段からこんな感じで多言語が飛び交う環境で開発しています。

Rails から React への移行と i18n
さて、私たちは現在、Rails のモノリシックアプリケーション + Backbone.js で構築されてきた WisdomBase のフロントエンドを、よりモダンでパフォーマンスに優れた React.js へとリファクタリングしています。WisdomBase の UI は日本語・英語・ベトナム語・韓国語・中国語など多くの言語に対応しています。そのため Rails から React への移行にあたって、「多言語対応と翻訳データの管理をどうするか」が、プロダクトチームとして解決すべき課題のひとつになりました。
ソフトウェアや Web・モバイルアプリの開発では、多言語対応のために i18n(Internationalization:国際化)という考え方を採用するのが一般的です。i18n は特定のソフトウェアやフレームワーク、ライブラリの名前ではなく、W3C が定めた標準的なプロセスを指します。弊社の Rails アプリケーションでは、Ruby gem の i18n(Rails で新規プロジェクトを作成するとデフォルトで入っているアレです)を使ってきました。その結果、いまや翻訳キーは 8 言語で数千件。ここまで規模が大きくなると、「翻訳キーを効率よく管理すること」「社外の翻訳者とスムーズに協業すること」自体が課題になってきます。
そこで React.js への移行を、翻訳ファイルを整理し直す絶好のチャンスと捉え、いくつかのツールの力を借りて再構成することにしました。React 側では react-i18next を採用し、ロケールファイルは JSON 形式で管理します。つまり、Rails アプリの YAML ファイルから React アプリの JSON ファイルへ、翻訳キーを丸ごと移し替える必要があるわけです。
翻訳キーの管理方法として、シンプルながら効果的だったのが Google スプレッドシートを使う方法です。これなら、外部の翻訳者にスプレッドシート上で直接作業してもらえます。この記事では、Google Spreadsheet API といくつかの Node ライブラリを使って、i18n ロケールの変更を Google シートとの間で pull / push するスクリプトの小さなデモを紹介します。ちなみに Google シートには変更履歴(リビジョン履歴)機能があるので、翻訳者とのやりとりでは「いつ・誰が・どこを変えたか」を追えるのが地味に便利です。
準備
- 次の画像のようなスプレッドシートを用意します。1 列目が Key、2 列目以降が各言語の翻訳です。

- Google Cloud Platform で(アカウントがなければ作成のうえ)サービスアカウントを新規登録し、シークレットキーを JSON ファイルとして取得します。このファイルを使って、スクリプトから GCP の認証を行います。
- 手順 2 で取得したサービスアカウント名に対して、スプレッドシートを共有します。
すべての i18n キーを取得(pull)するスクリプト
const { GoogleSpreadsheet } = require('google-spreadsheet')
const secret = require('./xxxx-yyyyyyyyyyyy.json')
const fs = require('fs')
//# Initialize the sheet
const doc = new GoogleSpreadsheet(
'1AgaWjGYPDjXmaicyRqnh-m_wLXhcXbxtrrq30dBjak',
)
//# Initialize Auth
const init = async () => {
await doc.useServiceAccountAuth({
client_email: secret.client_email,
private_key: secret.private_key,
})
}
const read = async () => {
await doc.loadInfo() //# loads document properties and worksheets
const sheet = doc.sheetsByTitle.Sheet1 //# get the sheet by title, I left the default title name. If you changed it, then you should use the name of your sheet
await sheet.loadHeaderRow() //# loads the header row (first row) of the sheet
const colTitles = sheet.headerValues //# array of strings from cell values in the first row
const rows = await sheet.getRows({ limit: sheet.rowCount }) //# fetch rows from the sheet (limited to row count)
let result = {}
//# map rows values and create an object with keys as columns titles starting from the second column (languages names) and values as an object with key value pairs, where the key is a key of translation, and value is a translation in a respective language
// eslint-disable-next-line array-callback-return
rows.map(row => {
colTitles.slice(1).forEach(title => {
result[title] = result[title] || {}
const key = row[colTitles[0]]
result = {
...result,
[title]: {
...result[title],
[key]: row[title] !== '' ? row[title] : undefined,
},
}
})
})
return result
}
function parseDotNotation(str, val, obj) {
let currentObj = obj
const keys = str.split('.')
let i
const l = Math.max(1, keys.length - 1)
let key
for (i = 0; i < l; ++i) { key = keys[i] currentObj[key] = currentObj[key] || {} currentObj = currentObj[key] } currentObj[keys[i]] = val delete obj[str] } Object.expand = function (obj) { for (const key in obj) { if (key.indexOf('.') !== -1) { parseDotNotation(key, obj[key], obj) } } return obj } const write = data => {
Object.keys(data).forEach(key => {
const tempObject = Object.expand(data[key])
fs.writeFile(
`./src/locales/${key}/translation.json`,
JSON.stringify(tempObject, null, 2),
err => {
if (err) {
console.error(err)
}
},
)
})
}
init()
.then(() => read())
.then(data => write(data))
.catch(err => console.log('ERROR!!!!', err))
- 2 行目のシークレットキーのファイルパスを、手順 2 で取得した自分のファイルのパスに変更してください。
- 8 行目を自分のスプレッドシート ID に変更してください。
- 74 行目は翻訳ファイルの保存先パスです。環境に合わせて変更してください。
このスクリプトを実行すると、Google シート上の変更がすべて取り込まれ、React i18n の JSON ファイルが更新されます。
JSON ファイルの新しい i18n キーをスプレッドシートに push するスクリプト
const { GoogleSpreadsheet } = require('google-spreadsheet')
const secret = require('./xxxx-yyyyyyyyyyyy.json')
const fs = require('fs')
//# Initialize the sheet
const doc = new GoogleSpreadsheet(
'1AgaWjGYPDjXXmaicyRqnh-m_wLXhcXbxtrrq30dBjak',
)
//# Initialize Auth
const init = async () => {
await doc.useServiceAccountAuth({
client_email: secret.client_email,
private_key: secret.private_key,
})
}
const traverse = function (enObj, jaObj, viObj, arr) {
const enObjData = enObj.data
const jaObjData = jaObj.data
const viObjData = viObj.data
for (const i in enObjData) {
if (enObjData[i] !== null && typeof enObjData[i] === 'object') {
//# going one step down in the object tree!!
const label = enObj.label !== '' ? `${enObj.label}.${i}` : `${i}`
const childEn = { label: label, data: enObjData[i] }
const childJa = { label: label, data: jaObjData[i] }
const childVi = { label: label, data: viObjData[i] }
traverse(childEn, childJa, childVi, arr)
} else {
arr.push({
key: enObj.label !== '' ? `${enObj.label}.${i}` : `${i}`,
en: enObjData[i],
ja: jaObjData[i],
vi: viObjData[i],
})
}
}
return arr
}
const read = async () => {
await doc.loadInfo() //# loads document properties and worksheets
const sheet = doc.sheetsByTitle.Sheet1 //# get the sheet by title, I left the default title name. If you changed it, then you should use the name of your sheet
const rows = await sheet.getRows({ limit: sheet.rowCount }) //# fetch rows from the sheet (limited to row count)
//# read /public/locales/en/translation.json
const en = fs.readFileSync(`./src/locales/en/translation.json`, {
encoding: 'utf8',
flag: 'r',
})
const ja = fs.readFileSync(`./src/locales/ja/translation.json`, {
encoding: 'utf8',
flag: 'r',
})
const vi = fs.readFileSync(`./src/locales/vi/translation.json`, {
encoding: 'utf8',
flag: 'r',
})
const enObj = { label: '', data: JSON.parse(en) }
const jaObj = { label: '', data: JSON.parse(ja) }
const viObj = { label: '', data: JSON.parse(vi) }
//# loop over JSON object and create new array
// eslint-disable-next-line no-undef
const result = traverse(enObj, jaObj, viObj, (arr = []))
//# difference between google-spreadsheet rows and newly created array
const el = result.filter(
({ key: id1 }) => !rows.some(({ key: id2 }) => id2 === id1),
)
return el
}
const append = async data => {
await doc.loadInfo() //# loads document properties and worksheets
const sheet = doc.sheetsByTitle.Sheet1 //# get the sheet by title, I left the default title name. If you changed it, then you should use the name of your sheet
await await sheet.addRows(data) //# append rows
}
init()
.then(() => read())
.then(data => append(data))
.catch(err => console.log('ERROR!!!!', err))
基本的な構成は先ほどのスクリプトと同じです。異なるのは 50・55・60 行目で、それぞれ対応するロケールファイルへのパスになっているので、ここを自分の環境に合わせて変更してください。
まとめ
今回紹介したスクリプトはあくまで「動くデモ」であり、改善の余地はまだまだあります(複数シートからの取得や、より多くのファイルからの push 対応など)。この仕組みが気に入ったら、ぜひご自身のプロジェクトに合わせて改良して使ってみてください。
ShareWis では、こんなふうに多言語・多国籍なチームで、レガシーなフロントエンドのモダン化にも取り組んでいます。興味を持っていただけたら嬉しいです。
(ファット)
Simple way to manage React i18n translations with Google Sheet

こんにちは, I’m Phat from ShareWis Product team. Recently, we are refactoring the front-end of ShareWis, which built by Rails monolithic application and Backbone.js to more modern and better performance front-end framework: React.js. As you can see, ShareWis supports many languages on user interface (UI), such as Japanese, English, Vietnamese, Korean, Chinese, etc… So, the multi-languages and how to manage the translations are issues that our Product team needs to solve when migrating from Rails to React.
In computing (software, web or mobile app development), we often apply i18n (Internationalization) to develop multi-languages applications. It isn’t a software, a framework, or a library, but a standardized process that defined by W3C group. In ShareWis, for Rails application, we are using a Ruby gem i18n, it will be installed by default when you create new project with Rails. Until now, we are having thousands of translation keys in 7 languages. Manage them efficiently and able to collaborate easily with outside translators become issues.
To deal with these issues, while migrating to React.js, we consider take this chance to re-organize the translation files with the help of some tools. On React.js, we are using library react-i18next, locale files will be stored as JSON format. We have to copy the translation keys from Rails Application to the React application, from the YAML files to JSON.
One simple solution I found useful to manage the translation keys is using Google Spreadsheet, therefore, outside translators that we hire can directly do their jobs on Google Spreadsheet. In this blog article, I will share a small demo how to use Google Spreadsheet API and some Node libraries to write a script to pull and push changes of I18n locales to Google Sheet. BTW, Translators can make use of the revision history feature of Google Sheet, which I found will useful in many cases.
Preparation
- Prepare a spreadsheet looks like as attached – with first column as Key and next are the translation of each language.

- On Google Cloud Platform (create an account if you don’t have one), register new service account with secret key as JSON file. We will use this file to authorize with our script with GCP.
- Share the spreadsheet with the service account which you got the service account name from step 2.
Script that fetches all I18n Keys
const { GoogleSpreadsheet } = require('google-spreadsheet')
const secret = require('./xxxx-yyyyyyyyyyyy.json')
const fs = require('fs')
//# Initialize the sheet
const doc = new GoogleSpreadsheet(
'1AgaWjGYPDjXmaicyRqnh-m_wLXhcXbxtrrq30dBjak',
)
//# Initialize Auth
const init = async () => {
await doc.useServiceAccountAuth({
client_email: secret.client_email,
private_key: secret.private_key,
})
}
const read = async () => {
await doc.loadInfo() //# loads document properties and worksheets
const sheet = doc.sheetsByTitle.Sheet1 //# get the sheet by title, I left the default title name. If you changed it, then you should use the name of your sheet
await sheet.loadHeaderRow() //# loads the header row (first row) of the sheet
const colTitles = sheet.headerValues //# array of strings from cell values in the first row
const rows = await sheet.getRows({ limit: sheet.rowCount }) //# fetch rows from the sheet (limited to row count)
let result = {}
//# map rows values and create an object with keys as columns titles starting from the second column (languages names) and values as an object with key value pairs, where the key is a key of translation, and value is a translation in a respective language
// eslint-disable-next-line array-callback-return
rows.map(row => {
colTitles.slice(1).forEach(title => {
result[title] = result[title] || {}
const key = row[colTitles[0]]
result = {
...result,
[title]: {
...result[title],
[key]: row[title] !== '' ? row[title] : undefined,
},
}
})
})
return result
}
function parseDotNotation(str, val, obj) {
let currentObj = obj
const keys = str.split('.')
let i
const l = Math.max(1, keys.length - 1)
let key
for (i = 0; i < l; ++i) { key = keys[i] currentObj[key] = currentObj[key] || {} currentObj = currentObj[key] } currentObj[keys[i]] = val delete obj[str] } Object.expand = function (obj) { for (const key in obj) { if (key.indexOf('.') !== -1) { parseDotNotation(key, obj[key], obj) } } return obj } const write = data => {
Object.keys(data).forEach(key => {
const tempObject = Object.expand(data[key])
fs.writeFile(
`./src/locales/${key}/translation.json`,
JSON.stringify(tempObject, null, 2),
err => {
if (err) {
console.error(err)
}
},
)
})
}
init()
.then(() => read())
.then(data => write(data))
.catch(err => console.log('ERROR!!!!', err))
- Change the path to your secret key file (from Step 2) at line 2.
- Change line 8 to your Spreadsheet ID.
- Line 74 is your path to where translation files are stored.
Run this script and fetch all changes from Google Sheet and the JSON files of React I18n will be updated.
Script that pushes new i18n keys from the JSON file to Spreadsheet
const { GoogleSpreadsheet } = require('google-spreadsheet')
const secret = require('./xxxx-yyyyyyyyyyyy.json')
const fs = require('fs')
//# Initialize the sheet
const doc = new GoogleSpreadsheet(
'1AgaWjGYPDjXXmaicyRqnh-m_wLXhcXbxtrrq30dBjak',
)
//# Initialize Auth
const init = async () => {
await doc.useServiceAccountAuth({
client_email: secret.client_email,
private_key: secret.private_key,
})
}
const traverse = function (enObj, jaObj, viObj, arr) {
const enObjData = enObj.data
const jaObjData = jaObj.data
const viObjData = viObj.data
for (const i in enObjData) {
if (enObjData[i] !== null && typeof enObjData[i] === 'object') {
//# going one step down in the object tree!!
const label = enObj.label !== '' ? `${enObj.label}.${i}` : `${i}`
const childEn = { label: label, data: enObjData[i] }
const childJa = { label: label, data: jaObjData[i] }
const childVi = { label: label, data: viObjData[i] }
traverse(childEn, childJa, childVi, arr)
} else {
arr.push({
key: enObj.label !== '' ? `${enObj.label}.${i}` : `${i}`,
en: enObjData[i],
ja: jaObjData[i],
vi: viObjData[i],
})
}
}
return arr
}
const read = async () => {
await doc.loadInfo() //# loads document properties and worksheets
const sheet = doc.sheetsByTitle.Sheet1 //# get the sheet by title, I left the default title name. If you changed it, then you should use the name of your sheet
const rows = await sheet.getRows({ limit: sheet.rowCount }) //# fetch rows from the sheet (limited to row count)
//# read /public/locales/en/translation.json
const en = fs.readFileSync(`./src/locales/en/translation.json`, {
encoding: 'utf8',
flag: 'r',
})
const ja = fs.readFileSync(`./src/locales/ja/translation.json`, {
encoding: 'utf8',
flag: 'r',
})
const vi = fs.readFileSync(`./src/locales/vi/translation.json`, {
encoding: 'utf8',
flag: 'r',
})
const enObj = { label: '', data: JSON.parse(en) }
const jaObj = { label: '', data: JSON.parse(ja) }
const viObj = { label: '', data: JSON.parse(vi) }
//# loop over JSON object and create new array
// eslint-disable-next-line no-undef
const result = traverse(enObj, jaObj, viObj, (arr = []))
//# difference between google-spreadsheet rows and newly created array
const el = result.filter(
({ key: id1 }) => !rows.some(({ key: id2 }) => id2 === id1),
)
return el
}
const append = async data => {
await doc.loadInfo() //# loads document properties and worksheets
const sheet = doc.sheetsByTitle.Sheet1 //# get the sheet by title, I left the default title name. If you changed it, then you should use the name of your sheet
await await sheet.addRows(data) //# append rows
}
init()
.then(() => read())
.then(data => append(data))
.catch(err => console.log('ERROR!!!!', err))
Same as the previous script, the different things that you should notice is line 50, 55, 60 which are the path to the correspond locale files.
Conclusion
These scripts are just workable demo and have spaces for improvement (such as fetch from many sheets, or push the changes from more files). Try yourself to improve them and apply for your projects if you like this solution.
(Phat)
