TypeScript Compiler APIを活用してi18nの不整合をチェックする

こんにちは。業務委託としてコミューンのグローバルチームでエンジニアをしているhiro08と申します。

グローバルチームでは主に海外展開に向けた開発や施策を行なっています。もし、グローバルチームに興味のある方は日本のSaaSスタートアップが世界で戦うためのプロダクトを開発するということも一読ください。

今回はグローバルチームでの開発の一部として、TypeScript Compiler APIを活用したi18nの不整合をチェックするツールを作成したお話をしようと思います。

背景

海外展開に向けた開発でまず思いつくのがi18n (多言語化) 対応ではないでしょうか。コミューンのプロダクトではi18nの対応が完了しており、すでに運用フェーズに入っています。

しかし、運用していく中で対訳データのキーやネームスペースの不整合に気づく仕組みがないことが課題点として挙がってきました。

i18nでは、ネームスペースという仕組みで、対訳データJSONファイルの木構造の一部を取り出すことで多言語化を実現しています。そのため、ネームスペースや対訳データのキーに不整合があると、プロダクトで意図しない言語が表示されてしまい不具合となります。

上記懸念を考慮して、UXライターの方がプロダクト内の文言を修正する際に、エンジニアが画面を見ながらチェックするという二重の工程を踏んでおり、レビュー業務がややボトルネックとなっていました。

人間の工数を少しでも減らすため、TypeScript Compiler APIを活用してi18nの不整合を機械的に検出する仕組み作りに取り組みました。

TypeScript Compiler APIについて

普段TypeScriptで開発している方でも、Compiler APIについて聞き慣れない方もいるかもしれません。TypeScriptの本来の目的として、TypeScriptで書かれたコードをJavaScriptのコードにトランスパイルすることです。

TypeScript Compiler APIを利用するとASTの解析やコードの生成など幅広く活用することができます。今回の例でいうと、ReactコンポーネントをASTで静的解析することでコードチェックを行うことができます。

また、TypeScriptのWikiにもドキュメントとして書かれています。こちらのコード例は大変参考になります。

コンポーネントファイルからASTを生成する

TypeScript Compiler APIの最初の一歩としてReactのコンポーネントを例としてコード解析してみます。

コミューンではreact-i18nextというライブラリを使用しています。useTranslationにネームスペースを指定して、t()関数にキーを渡して対訳を取り出します。

ネームスペースの命名ルールは対訳データのディレクトリに基づいています。例えば、translation/components/templates/admin_event.jsonという対訳データがある場合、components/templates/admin_eventというネームスペースになります。

import { useTranslation } from 'react-i18next'

export const Component: React.FC = () => {
  const { t } = useTranslation(['components/templates/admin_event']) 
  return (
    <div>
      <h1>{t('text1')}</h1>
      <p>{t('text2')}</p>
    </div>
  )
}

今回、i18nのLintツールを作るに当たって必要な情報はuseTranslationの値とt関数の値になります。例として、useTranslationとt関数の値をログに出してみましょう。

import { readFileSync } from 'fs'

import * as ts from 'typescript'

const printComponent = (filePath: string) => {
  const sourceFile = ts.createSourceFile(filePath, readFileSync(filePath).toString(), ts.ScriptTarget.ES2015, true)

  const lintNode = (ts: ts.Node) => {
    if (ts.isCallExpression(node)) {
      console.log(`CallExpression ${ts.expression.getText()}`)
      console.log(`value ${ts.arguments[0]?.getText()}`)
    }

    ts.forEachChild(child => {
      lintNode(child)
    })
  }

  lintNode(sourceFile)
}

ASTの情報を取得するためにts.createSourceFileを使います。createSourceの返り値はパースされたASTが格納されています。

また、ts.forEachChildというメソッドを利用すると、SourceFileを再帰的にアクセスして任意の子ノードを参照することができます。useTranslationやt関数はCallExpressionに該当します。なので、ts.isCallExpressで対象の子ノードを参照することができます。

filePathを指定してローカルで実行した結果になります。useTranslationに対してcomponents/templates/admin_eventの文字列が取れて、t関数に対してのtext1とtext2の文字列をコンソールに表示することができました。

CallExpression useTranslation text 'components/templates/admin_event'
CallExpression t value 'text1'
CallExpression t value 'text2'

i18nのキーの整合性をチェックする

上記ASTの解析結果を利用して、i18nのキーに対する整合性をチェックするロジックを追加していきます。

まずはネームスペースと対訳データを合わせたデータセットを作成します。これによって、ネームスペースでフィルターすることで各対訳データを取り出すことができます。下記はデータセットのイメージになります。

const translationData = [
  {
    "namespace": "namespace"
    "data": {
      "en": {
        "key": "value"
      },
      "ja": {
       "key": "value"  
      }
    }
  },
  // more
]

上記でも説明しましたが、コミューンのプロダクトではtranslationフォルダに対訳データのJSONファイルがまとまっていて、対訳データのディレクトリ構成に沿ってネームスペースが決まります。それを踏まえてデータセットを作成する処理は以下になります。

import * as fs from 'fs'
import path from 'path'

// translationフォルダのディレクトリパスからパスの一覧を再帰的に取り出す
const readDirRecursively = (directory: string) => {
  const entries = fs.readdirSync(directory, { withFileTypes: true })
  const filePaths: string[] = []

  for (const entry of entries) {
    const absolutePath = path.join(directory, entry.name)

    if (entry.isDirectory()) {
      filePaths.push(...readDirRecursively(absolutePath))
    } else if (entry.isFile()) {
      filePaths.push(absolutePath)
    }
  }

  return filePaths
}

// 上記で取得したパスの一覧からデータセットを作成する
const getTranslations = () => {
  const translations: { namespace: string; data: unknown }[] = []
  // translationフォルダ配下のJSONパスを全て取得
  const targetTranslationPath = [...readDirRecursively(path.resolve(__dirname, './translation'))]

  // データセットの作成
  translationPath.forEach(translationFile => {
    if (translationFile.match(/\.json$/)) {
      translations.push({
        namespace: translationFile.replace(`${translationPath}/`, '').replace(`.json`, ''),
        data: require(translationFile),
      })
    }
  })

  return translations
}

そして、コンポーネントのuseTranslationからネームスペースの情報を取得するロジックを追加します。こちらは上記ネームスペースの値を取得したロジックと類似しています。

const getNamespaces = (sourceFile: ts.SourceFile) => {
  const namespaces: string[] = []

  const lintNode = (node: ts.Node) => {
    if (ts.isCallExpression(node) && node.expression.getText() === 'useTranslation')) {
      if (node.arguments[0]?.kind === ts.SyntaxKind.StringLiteral) {
        // 文字列にシングルクォートが含まれるためremoveQuotesで削除する
        namespaces.push(removeQuotes(node.arguments[0]?.getText()))
      }
    }

    node.forEachChild(child => {
      print(child, sourceFile)
    })
  }

  lintNode(sourceFile)

  return namespaces
}

取得したデータセットからt関数の値を照合して不整合がないかを確認するロジックを追加します。こちらは最初に紹介したロジックの拡張になります。getTranslationDataというネームスペースの値によって、特定のデータを取得する便利関数を作成しました。

ここから取れるデータを元にt関数のデータに不整合がないかを確認します。具体的なロジックは以下になります。

const printComponent = (filePath: string) => {
  const sourceFile = ts.createSourceFile(filePath, readFileSync(filePath).toString(), ts.ScriptTarget.ES2015, true)

  const namespaces = getNamespaces(sourceFile)
  const translation = getTranslationData(namespaces)

  const lintNode = (node: ts.Node) => {
    if (ts.isCallExpression(node) && node.expression?.getText() === 't') {
      const key = removeQuotes(node.arguments[0]?.getText() || '')

      if (!translation.includes(key) && !isDynamicKeyIdentifier(node)) {
        const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart())

        console.log(`${key} is missing key.`)
      }
    }

    node.forEachChild(child => {
      lintNode(child)
    })
  }

  lintNode(sourceFile)
}

// 対訳データのデータセットからネームスペースでフィルタリング
const getTranslationData = (namespaces: string[]) => {
  const translationData = getTranslations().find(v => namespaces.includes(v.namespace))?.data
  if (!translationData) {
    throw new Error(`not match namespaces`)
  }

  const keys = [...Object.keys(translationData.en.translation), ...Object.keys(translationData.ja.translation)]

  return uniq(keys)
}

最後

今回はコミューンでのTypeScript Compiler APIを使ったi18nのチェックツールについて紹介しました。

私自身ツール作成に取り組む前は、TypeScript Compiler APIやAST等の前提知識はほとんどありませんでした。なので、チームの課題解決に対して、新しい技術で挑めたのは非常にいい経験でした。

今回の仕組みを利用して、今後は不整合だけではなくコーディングスタイルを機械的にチェックすることもできそうです。コミューンではi18nのコーディングスタイルがドキュメント化されており、機会があれば機能の拡充に挑戦したいです。

コミューン株式会社ではエンジニアやEMを絶賛募集中です!
カジュアル面談もできますので、記事を読んで弊社に興味を持ってくれた方は是非気軽に応募してみて下さい。

commmune-careers.studio.site

docs.google.com