Next.js + next-i18nextの各ページに依存する名前空間を抽出する

はじめに

こんにちは、sukeです。
next-i18nextでserverSideTranslationsを使用する際は、ページで必要なすべての名前空間をserverSideTranslationsの引数に渡す必要があります。

import { serverSideTranslations } from 'next-i18next/serverSideTranslations'

export async function getStaticProps({ locale }) {
  return {
    props: {
      ...(await serverSideTranslations(locale, [
        'common',
        'footer',
      ])),
    },
  }
}

しかし、読み込み速度の高速化のためにより細かい名前空間に分割すると、ページに依存する名前空間の管理コストが上昇します。 ページ間で再利用するコンポーネントなどに専用の名前空間を指定しただけで、そのコンポーネントに依存するすべてのページコンポーネントを修正する必要があります。

今回はnode-dependency-treeとTypeScript Compiler APIを用いて、ページに依存するコンポーネントから名前空間を抽出する方法を紹介します。 また、今回はNext.jsのPages Routerを使用します。

この記事では以下のライブラリバージョンを使用します。
※ 最低限必要なライブラリのみ記載しています。

{
  "dependencies": {
    "i18next": "^23.2.11",
    "next": "13.4.10",
    "next-i18next": "^14.0.0",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-i18next": "^13.0.2",
    "typescript": "5.1.6"
  }
}

サンプルリポジトリ

実装

名前空間の抽出には大きく分けて2つのステップが存在します。

  1. node-dependency-treeでページ毎のuseTranslationに依存するコンポーネントのファイル名を取得する。
  2. 1で抽出したファイルからTypeScript Compiler APIを用いてuseTranslationの引数を取得する。

ページ毎のuseTranslationに依存するコンポーネントを取得する

node-dependency-treeでページコンポーネントのファイルをエントリーポイントに依存ツリーを作成します。 オプションのfilter関数で、next-i18next以外のnode_modulesは依存関係のツリーから削除されるようにします。

import path from "path";
import dependencyTree from "dependency-tree";

const currentDir = process.cwd();
const nextI18nNextPath = path.join("node_modules", "next-i18next");
const isNextI18NextPath = (filePath: string) => filePath.indexOf(nextI18nNextPath) !== -1;

const tree = dependencyTree({
  filename: 'index.tsx' // 実際にはページコンポーネントのファイル名が使用されます,
  directory,
  tsConfig: path.join(currentDir, "tsconfig.json"),
  nodeModulesConfig: {
    entry: "module",
  },
  filter: (modulePath) => isNextI18NextPath(modulePath) || modulePath.indexOf("node_modules") === -1,
  nonExistent: [],
  noTypeDefinitions: false,
});

dependency-treeにより以下のような依存ツリーが作成されます。 次のステップではこの依存ツリーをトラバースして、TypeScript Compiler APIを用いてuseTranslationの引数を取得します。

{
  '/Users/suke/extract-required-namespaces/src/pages/index.tsx': {
    '/Users/suke/extract-required-namespaces/src/components/Header.tsx': {
      '/Users/suke/extract-required-namespaces/src/components/Button.tsx': {
        '/Users/suke/extract-required-namespaces/node_modules/next-i18next/dist/types/types.d.ts': {
          '/Users/suke/extract-required-namespaces/node_modules/next-i18next/dist/types/index.d.ts': {
            '/Users/suke/extract-required-namespaces/node_modules/next-i18next/dist/types/appWithTranslation.d.ts': [Object]
          }
        }
      }
    },
  }
}

TypeScript Compiler APIを用いてコンポーネントのソースからuseTranslationの引数を取得する

以下のステップでuseTranslationに渡している名前空間を抽出します。

  1. TypeScript Compiler APIでソースファイルからASTを作成する。
  2. ASTをトラバースしてuseTranslationを呼び出している変数宣言を特定する。
  3. 2で特定したノードからuseTranslationの第一引数のテキストを取得する。
import ts from "typescript";

const visitAll = (node: ts.Node, callback: (node: ts.Node) => void) => {
  callback(node);
  node.forEachChild((child) => {
    visitAll(child, callback);
  });
};

export const extractNameSpaceFromSourceFile = (sourceFile: ts.SourceFile) => {
  const result = new Set<string>();

  // ルートからすべてのASTをトラバースする
  visitAll(sourceFile, (node) => {
    if (!ts.isVariableDeclaration(node)) {
      return;
    }

    if (
      node.initializer &&
      ts.isCallExpression(node.initializer) &&
      node.initializer.expression.getText() === "useTranslation"
    ) {
      const firstArgument = node.initializer.arguments.at(0);
      if (!firstArgument) {
        return;
      }

      const namespace = firstArgument.getText();
      if (namespace) {
        // 取得したnamespaceにシングルクォーテーションやダブルクォーテーションが含まれるので削除する
        result.add(namespace.replace(/['‘’"“”]/g, ""));
      }
    }
  });

  return Array.from(result);
};
const source = `
  import React from 'react'
  import { useTranslation } from 'next-i18next'

  export const Component: React.FC = () => {
    const { t } = useTranslation('common')
    return <div>{t('test')}</div>
  }
`;

extractNameSpaceFromSourceFile(
  ts.createSourceFile(
    "test.tsx",
    source,
    ts.ScriptTarget.ES2015,
    true
  );
)
// => ['common']

上記サンプルコードでは簡略化のために、useTranslationに文字列を直接すケースしか考慮していません。 名前空間を定数で管理するパターンはサンプルリポジトリを参照してください。

これまでの実装を組み合わせてページ単位で依存する名前空間を抽出します。

const isDependOnNextI18Next = (tree: dependencyTree.Tree) => {
  return Object.keys(tree).some((value) => {
    if (typeof value === "string") {
      return isNextI18NextPath(value);
    }
    return false;
  });
};

export const extractRequiredNamespacesFromTree = (
  tree: dependencyTree.Tree,
  visited = new Set<string>(),
  result = new Set<string>()
) => {
  for (const [key, value] of Object.entries(tree)) {
    if (visited.has(key)) {
      continue;
    }
    visited.add(key);

    if (key.indexOf("node_modules") !== -1) {
      continue;
    }

    if (isDependOnNextI18Next(value)) {
      const requiredNamespaces = extractNameSpaceFromSourceFile(
        createSourceFile(key)
      );
      requiredNamespaces.forEach((value) => result.add(value));
    }

    if (typeof value !== "string") {
      extractRequiredNamespacesFromTree(value, visited, result);
    }
  }

  return Array.from(result);
};

export const extractRequiredNamespaces = async () => {
  // /pages配下のファイル一覧を取得する
  const allPageFiles = await getPageFiles();
  const result: Record<string, string[]> = {};
  const directory = path.join(currentDir, "src");

  for (const file of allPageFiles) {
    const tree = dependencyTree({
      filename: file,
      directory,
      tsConfig: path.join(currentDir, "tsconfig.json"),
      nodeModulesConfig: {
        entry: "module",
      },
      filter: (modulePath) =>
        isNextI18NextPath(modulePath) ||
        modulePath.indexOf("node_modules") === -1,
      nonExistent: [],
      noTypeDefinitions: false,
    });

    const outputKey = file.replace(directory, "").split(".")[0];
    result[outputKey] = extractRequiredNamespacesFromTree(tree);
  }

  return result;
};

extractRequiredNamespacesは以下のようなオブジェクトを返します。

{
  "/pages/index": ["common"]
}

このオブジェクトをjsonとして書き出して、serverSideTranslationsの引数で使用します。

import type { GetServerSidePropsContext } from "next";
import requiredNamespaces from '../required-namespaces.json';

export async function getServerSideProps({
  locale = "ja",
}: GetServerSidePropsContext<{ locale: string }>) {
  return {
    props: {
      ...(await serverSideTranslations(locale, requiredNamespaces['/pages/index'])),
    },
  };
}

https://www.i18next.com/overview/typescript のようにリソースの型定義をしている場合、serverSideTranslationsの引数にjsonを値を渡すとタイプエラーになることがあります。 その場合、type assertionを使用した定数を作成することで回避可能です。 名前空間の抽出時に実際に存在する名前空間の一覧に存在するかのバリデーションを挟むと、より安全になるかと思います。

import common from '../public/locales/ja/common.json'
import * as requiredNamespaceJson from '../required-namespaces.json'

export type TranslationResources = {
  'common': typeof common
}

// NOTE: 値の型がstring[]になるので、型を上書きする
export const requiredNamespace = requiredNamespaceJson as unknown as {
  [P in keyof typeof requiredNamespaceJson]: (keyof TranslationResources)[]
}

おわりに

node-dependency-tree + TypeScript Compiler APIでページコンポーネントに依存する名前空間を抽出する方法を紹介しました。 名前空間を抽出する関数はjestでスナップショットテストをすることで、CI上で名前空間の修正漏れを検知することができます。

時間があれば型定義の自動生成までやってみたいですね。

サンプルリポジトリ

参考