🌐

Storybookでnext-i18next対応

2023/04/23に公開

はじめに

多言語対応しているNext.jsのサービスにstorybookを導入しようとしたところ、next-i18nextから提供されているuseTranslationを使用しているコンポーネントで以下のようなエラーが発生しました。

ERROR in ./node_modules/next-i18next/dist/esm/config/createConfig.js 100:15-28
Module not found: Error: Can't resolve 'fs' in '/home/umyomyomyon/git/next-i18next-storybook/node_modules/next-i18next/dist/esm/config'

こちらの対応方法にについて書いていきます。

動いてるコードだけ見られればいいという方はこちらのリポジトリをご覧ください。
https://github.com/umyomyomyon/storybook-with-next-i18next

現象

storybookで表示したいコンポーネント内でnext-i18nextuseTranslate()を使用していると以下のエラーが発生します。

ERROR in ./node_modules/next-i18next/dist/esm/config/createConfig.js 100:15-28
Module not found: Error: Can't resolve 'fs' in '/home/umyomyomyon/git/next-i18next-storybook/node_modules/next-i18next/dist/esm/config'

実際に再現するために以下のコンポーネントを使用します。

コンポーネント

src/components/Button.tsx
import { FC } from "react";
import { useTranslation } from "next-i18next";

export const Button: FC = () => {
  const { t } = useTranslation();
  return <button>{t('click')}</button>;
};

story

src/components/Button.stories.tsx
import { Meta, StoryObj } from '@storybook/react'
import { Button } from './Button'

const meta: Meta<typeof Button> = {
  title: 'Button',
  component: Button,
  tags: ['autodocs'],
};
export default meta;

type Story = StoryObj<typeof Button>;

export const Default: Story = {};

翻訳ファイル

public/locales/en/common/json
{
  "click": "!CLICK!"
}
public/locales/ja/common/json
{
  "click": "!クリック!"
}

上記のコンポーネント、story、翻訳ファイル用意してstorybookを起動するとエラーが発生すると思います。
なお、翻訳ファイルはnext-i18nextのデフォルト設定である以下のcommon.jsonを使用するものとします。

.
└── public
    └── locales
        ├── en
        |   └── common.json
        └── ja
            └── common.json

対策

storybook上でnext-i18nextのかわりにreact-i18next使用することによってエラーを回避することができます。

以下のように.storybook/main.tsでaliasを設定してnext-i18nextのかわりにreact-i18nextを読み込むようにします
また、staticDirs: ['../public']を追加して翻訳ファイル(/public/locales/en/common.json, /public/locales/ja/common.json)を読み取れるようにします。

.storybook/main.ts
import type { StorybookConfig } from "@storybook/nextjs";
const config: StorybookConfig = {
  stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
  addons: [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@storybook/addon-interactions",
  ],
  framework: {
    name: "@storybook/nextjs",
    options: {},
  },
  docs: {
    autodocs: "tag",
  },
+  webpack: (config) => {
+    if (config.resolve && config.resolve.alias) {
+      config.resolve.alias = {
+        ...config.resolve.alias,
+        'next-i18next': 'react-i18next',
+      };
+    };
+    return config;
+  },
+  staticDirs: ['../public'],
};
export default config;

ここでstorybookを起動してみましょう。
すると、エラーが発生しないことが確認できると思います。
しかし、t('click')に対応する!クリック!または!CLICK!の表示が期待されているにも関わらずclickという表示のままです。
この原因はreact-i18nextの設定ができていないことにあるのでその設定を行います。

react-i18nextの設定

storybookで使用するreact-i18nextの設定を行っていきます。
まずi18next-http-backendを入れます。

yarn add -D i18next-http-backend

.storybook/i18n.tsを作成

.storybook/i18n.tsを新規作成して以下の内容を記述します。

.storybook/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';

i18n
  .use(initReactI18next)
  .use(Backend)
  .init({
    lng: 'ja',
    fallbackLng: 'ja',
    debug: true,
    defaultNS: 'common',
    ns: 'common',
    supportedLngs: ['ja', 'en'],
    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json'
    }
  });

export default i18n;

next-i18nextがデフォルトで使用する各言語翻訳ファイルは以下のようなっており

.
└── public
    └── locales
        ├── en
        |   └── common.json
        └── ja
            └── common.json

backend.loadPath'/locales/{{lng}}/{{ns}}.json'とすることでreact-i18nextでもnext-i18nextで使用する翻訳ファイルを読み取れるように設定しています。

    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json'
    }

decoratorを定義する

storybook上のすべてのコンポーネントに適用するdecoratorを定義します。
作成したi18nをdecoratorを通してコンポーネントに提供します。

まず、.storybook/preview.ts.storybook/preview.tsxにリネームします。
(拡張子を.ts -> .tsxに変更)

次に.storybook/preview.tsx内でdecoratorを定義します。

.storybook/preview.tsx
+import React from "react";
import type { Preview } from "@storybook/react";
+import { I18nextProvider } from "react-i18next";
+import i18n from "./i18n";

const preview: Preview = {
  parameters: {
    actions: { argTypesRegex: "^on[A-Z].*" },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/,
      },
    },
  },
+ decorators: [
+   (Story) => (
+     <I18nextProvider i18n={i18n}>
+       <Story />
+     </I18nextProvider>
+   )
+ ]
};

export default preview;

ここでstorybookを起動すると、ボタンには!クリック!と表示されているはずです。
しかしこのままではlocaleを切り替えることができないですね。

localeを切り替えられるようにする

こちらを参考に、storybookの画面上からlocaleを切り替えられるようにします。
https://storybook.js.org/recipes/react-i18next

.storybook/preview.tsxを以下のように編集します。

.storybook/preview.tsx
-import React from "react";
+import React, { useEffect } from "react";
import type { Preview } from "@storybook/react";
import { I18nextProvider } from "react-i18next";
import i18n from "./i18n";

+const withI18next = (Story, context) => {
+  const { locale } = context.globals;
+
+  useEffect(() => {
+    i18n.changeLanguage(locale);
+  }, [locale]);
+
+  return (
+    <I18nextProvider i18n={i18n}>
+      <Story />
+    </I18nextProvider>
+  );
+};
+
+export const globalTypes = {
+  locale: {
+    name: 'Locale',
+    description: 'Internationalization locale',
+    toolbar: {
+      icon: 'globe',
+      items: [
+        { value: 'ja', title: '日本語' },
+        { value: 'en', title: 'English' },
+      ],
+      showName: true,
+    },
+  },
+};
+
const preview: Preview = {
  parameters: {
    actions: { argTypesRegex: "^on[A-Z].*" },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/,
      },
    },
  },
-  decorators: [
-    (Story) => (
-      <I18nextProvider i18n={i18n}>
-        <Story />
-      </I18nextProvider>
-    )
-  ]
+  decorators: [withI18next]
};

export default preview;

以下の部分でlocaleというグローバル変数を宣言し、storybookにツールバーから変更できるようにしています。

export const globalTypes = {
  locale: {
    name: 'Locale',
    description: 'Internationalization locale',
    toolbar: {
      icon: 'globe',
      items: [
        { value: 'ja', title: '日本語' },
        { value: 'en', title: 'English' },
      ],
      showName: true,
    },
  },
};

その上で、グローバルなlocaleが変更されたらI18nextProviderに渡すi18nのlocaleを変更するようなdecoratorを定義しています。
上記で定義したグローバルなlocaleはcontext.globals.localeから取得することができます。

const withI18next = (Story, context) => {
  const { locale } = context.globals;

  useEffect(() => {
    i18n.changeLanguage(locale);
  }, [locale]);

  return (
    <I18nextProvider i18n={i18n}>
      <Story />
    </I18nextProvider>
  );
};

ここまでで、storybookを起動するとツールバーにlocaleの変更ボタンが追加されており、locale切り替えもできることが確認できるのではないでしょうか。

Discussion