Як я імплементував i18n в next.js застосунку з FSD архітектурою
Мене звати Владислав, я 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, не містить у собі всього, що йому необхідно для роботи.
Як бачите, ідеального рішення тут немає, тому було обрано найменше із зол —
Структура папок вийшла такою:
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) із запитуваною локалізацією.
Спочатку я створив об’єкт, у якому вручну потрібно було реєструвати словник, і це б працювало, але виникли дві проблеми:
- ручна реєстрація кожного нового файлу — не те щоб проблема, але ми ж люди ліниві!
- неможливо було вивести тип об’єднаного словника для того, щоб при виклику функції локалізації 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 повертає цей текст. Щоб зробити текст помилки локалізованим, є кілька підходів. Я коротко їх опишу, а потім скажу, що обрав я і чому:
- Функція, яка приймає функцію локалізації t як параметр та повертає схему з уже локалізованими помилками. Тобто замість статичного тексту помилки ми робимо виклик функції t("errorKey«).
- Замість тексту помилки повертати сам ключ помилки і вже в компоненті викликати t(errorKey).
Я обрав саме
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. Пишіть свої коментарі, конструктивну критику. Дякую за увагу, всім гарного настрою!
Немає коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів