Як я імплементував i18n в next.js застосунку з FSD архітектурою

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Мене звати Владислав, я full-stack розробник. У цій статті я поділюся своїм способом імплементації i18n + Next.js + FSD. Це не покрокова інструкція, а більше концепція та архітектура. У статті я розповім про:

  • архітектуру та організацію словників;
  • автоматичну реєстрацію словників;
  • локалізацію помилок при використанні zod + next-intl.

Вимоги:

  • усі налаштування i18n повинні знаходитись в 1 папці;
  • словники розділені на окремі файли для зручності читання;
  • зручне додавання нових файлів з перекладами;
  • TypeScript повинен підказувати ключі при виклику функції перекладу.

Для імплементації була обрана бібліотека next-intl (routing based). Архітектурно було прийнято рішення розмістити i18n у шарі (Layer) shared/i18n (нижче розповім, чому).

Організація словників

Першою проблемою стало питання організації словників. FSD-архітектура пропонує тримати всередині слайсу все, що йому необхідно. Тому напрошується висновок, що ми повинні тримати i18n-директорію в app/i18n/* та імпортувати туди всі словники з нижчих шарів. Однак я швидко відкинув цю ідею, тому що бібліотека надає ряд хуків, які потрібні іншим шарам. В результаті i18n був розміщений в нижньому шарі shared/i18n/*.

Виникає питання: а де тепер зберігати словники? Є два варіанти, які спадали на думку:

  • В слайсах, наприклад feature/auth/i18n/*.json, але тоді доведеться імпортувати їх у нижчий шар shared/i18n, що є грубим порушенням FSD-архітектури.
  • В самій директорії shared/i18n/dictionaries/. В такому разі немає порушення імпортів, проте тепер слайс, наприклад feature/auth, не містить у собі всього, що йому необхідно для роботи.

Як бачите, ідеального рішення тут немає, тому було обрано найменше із зол — 2-й варіант.

Структура папок вийшла такою:

shared/i18n/
├── index.ts
├── request.ts                
├── routing.ts                
├── types.d.ts                
└── dictionaries/
    |── en/
    │   ├── index.ts          
    │   ├── auth.json         
    │   └── common.json       
    ├── ru/
    │   ├── index.ts
    │   ├── auth.json
    │   └── common.json
    └── uk/
        ├── index.ts
        ├── auth.json
        └── common.json

Всередині dictionaries знаходиться папка, яка об'єднує словники за локалізацією.

Реєстрація та додавання словників

Наступним кроком потрібно було якось підключити всі ці словники. Бібліотека next-intl містить поле messages, яке динамічно завантажує словник (1 файл *.json) із запитуваною локалізацією.

Спочатку я створив об’єкт, у якому вручну потрібно було реєструвати словник, і це б працювало, але виникли дві проблеми:

  1. ручна реєстрація кожного нового файлу — не те щоб проблема, але ми ж люди ліниві!
  2. неможливо було вивести тип об’єднаного словника для того, щоб при виклику функції локалізації TypeScript підказував ключі.

Приклад ручної реєстрації, що працює, якщо ми не хочемо типізувати функцію перекладу «t»:

export const dictionary: Record<string, RegisterDictionary> = {
  common: (locale) => import(`./${locale}/common.json`),
  auth: (locale) => import(`./${locale}/auth.json`),
};

export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale;
  const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;

  const entries = await Promise.all(
    Object.entries(dictionary).map(async ([namespace, loadModule]) => {
      const module = await loadModule(locale);
      const data = module.default;
      return [namespace, data];
    }),
  );

  const messages = Object.fromEntries(entries);

  return {
    locale,
    messages,
  };
});

Рішенням став файл index.ts у кожній директорії локалізації. Файл index.ts імпортує всі словники своєї локалізації, об’єднує їх та експортує назовні messages. Підключення словників до next-intl:

import { getRequestConfig } from "next-intl/server";
import { hasLocale } from "next-intl";
import { routing } from "./routing";

export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale;
  const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;

  const { default: messages } = await import(`./dictionaries/${locale}/index`);

  return {
    locale,
    messages,
  };
});

Тепер давайте вирішимо проблему ручної реєстрації файлів. Уявімо, що у нас на сайті є 3 локалі, це означає, що при додаванні нового словника нам потрібно оновити 3 файли index.ts, а саме: додати імпорт нового словника та оновити об’єкт messages.

Щоб не робити все це вручну, був написаний скрипт на Node.js, який парсить усі словники всередині директорії з локалізацією та автоматично генерує файл index.ts і оновлює messages. У результаті після додавання нового словника все, що потрібно зробити, — це запустити команду для генерації нового index-файлу. У мене команда називається generate:i18n і виконується автоматично при pnpm dev, pnpm build. Скрипт для парсингу та генерації — в кінці статті.

Трохи про те, як саме оновлюється об’єкт messages. Оскільки це об’єкт, значить, у нього є ключ:значення. Значення — це сам словник із перекладами, а ось ключ — це ім’я *.json файлу. Наприклад, якщо файл називається auth.json, то ключем стане «auth».

Приклад index.ts файлу:

shared/i18n/dictionaries/uk/index.ts

// THIS FILE IS AUTO-GENERATED. DO NOT EDIT.

import auth from "./auth.json";
import common from "./common.json";

const messages = { auth, common };
export default messages;

Оскільки об’єкт messages став статичним, це означає, що ми можемо вивести з нього тип для того, щоб функція перекладу t(key) підказувала нам існуючі ключі, що дуже зручно. Приклад розширення типу Messages з «next-intl»:

shared/i18n/types.d.ts

import messages from "./dictionaries/en/index";

export type Messages = typeof messages;

declare module "next-intl" {
  interface AppConfig {
    Messages: Messages;
  }
}

Бібліотека для валідації Zod + переклад помилок

При описі схеми валідації Zod, ми можемо вказати текст помилки, і якщо валідація негативна, то Zod повертає цей текст. Щоб зробити текст помилки локалізованим, є кілька підходів. Я коротко їх опишу, а потім скажу, що обрав я і чому:

  1. Функція, яка приймає функцію локалізації t як параметр та повертає схему з уже локалізованими помилками. Тобто замість статичного тексту помилки ми робимо виклик функції t("errorKey«).
  2. Замість тексту помилки повертати сам ключ помилки і вже в компоненті викликати t(errorKey).

Я обрав саме 2-й спосіб, оскільки 1-й спосіб вимагає викликати функцію, яка повертає схему при кожному рендері компонента, мені такий варіант не дуже сподобався. Однак із 2-м способом також є нюанс: функція перекладу «t» у нас типізована й очікує на строковий літерал, тоді як zod повертає помилку типу string. Щоб вирішити це завдання, я створив функцію-обгортку, яка приймає t та message, виводить тип із параметра t та повертає message без помилки TypeScript.

export const getTranslatedError = <T extends (key: any, ...args: any[]) => string>(t: T, message: string) =>
  t(message as Parameters<typeof t>[0]);

Ось так виглядає схема zod:

export const loginSchema = z.object({
  email: z.email("validation.emailInvalid"),
  password: z.string().trim().min(6, "validation.passwordMin").max(64, "validation.passwordMax"),
});
export type LoginData = z.infer;

Скрипт для генерації index.ts

import { writeFile, readdir } from "fs/promises";
import path from "path";

const DICTIONARIES_DIR = path.resolve(import.meta.dirname, "../app/(frontend)/shared/i18n/dictionaries");

const HEADER = "// THIS FILE IS AUTO-GENERATED. DO NOT EDIT.\n\n";

async function generate() {
  const root = await readdir(DICTIONARIES_DIR, { withFileTypes: true });
  const generateResult: string[] = [];

  for (const dir of root) {
    if (!dir.isDirectory()) continue;

    const localeDir = path.join(DICTIONARIES_DIR, dir.name);
    const dictionaries = (await readdir(localeDir, { withFileTypes: true })).filter(
      (d) => d.isFile() && d.name.endsWith(".json"),
    );
    const sortedDictionaries = dictionaries.sort((a, b) => a.name.localeCompare(b.name));
    const keys: string[] = [];

    const imports = sortedDictionaries
      .map((file) => {
        const name = file.name.replace(/\.json$/, "").replace(/-([a-z])/g, (_, c) => c.toUpperCase());
        keys.push(name);
        return `import ${name} from "./${file.name}";`;
      })
      .join("\n");

    const content = `${HEADER}${imports}\n\nconst messages = { ${keys.join(", ")} };\n\nexport default messages;\n`;
    const outputPath = path.join(localeDir, "index.ts");
    await writeFile(outputPath, content);
    generateResult.push(`${dir.name}[${dictionaries.length}]`);
  }
  console.log(`Generated: ${generateResult.join(", ")}`);
}

generate().catch((err) => {
  console.error(err);
  process.exit(1);
});

Ось, у принципі, і все, що я хотів розповісти про імплементацію next-intl у Next.js-застосунку з архітектурою FSD. Пишіть свої коментарі, конструктивну критику. Дякую за увагу, всім гарного настрою!

👍ПодобаєтьсяСподобалось0
До обраногоВ обраному0
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підписатись на коментарі