Як ми українізували наш стартап. Локалізація застосунку на React
Привіт. Я Леонід Шевцов — співзасновник сервісу Сінтра — це маленький український застосунок для ведення щоденного бюджету. Роблять його два хлопці з Дніпра — Олександр Зайцев — ідеолог та дизайнер, та я — програміст. Нашою задачею було зробити простий, але довершений сервіс, що гарно реалізує конкретний підхід до особистого бюджету. Більше про виникнення продукту можна почитати в статті Олександра, а про технічну реалізацію — в моїй. У 2022 році, після початку широкомасштабного вторгнення росії, нам стало очевидно — застосунок має бути перекладений українською мовою. Байдуже, що до того локалізація здавалась далеко за межами команди з двох людей, байдуже, що з війною вільного часу стало ще менше, ніж було раніше — переклад треба було робити. І нарешті у серпні Сінтра заговорила українською. На технічну реалізацію перекладу пішло 30 годин мого часу. До того ж, власне переклад рядків та тестування робив Олександр. У цій статті хочу поділитися технічними підходами та несподіваними додатковими задачами, які у нас виникали. Бо, як виявилося — переклад застосунку — це набагато більше, ніж просто переклад застосунку.
Локалізація вебзастосунку
Перше, що приходить на думку інженера — це локалізація коду застосунку. Бо з ним ми працюємо кожен день, та доволі очевидно, що всі вписані в код рядки мають бути перекладені. Застосунок у нас на React, тому для перекладу обрали бібліотеку i18next з додаванням react-i18next. Це, мабуть, найпоширеніша бібліотека для перекладів JavaScript. Доповнення react-i18next
, по-перше, надає контекст, що стежить за поточною локаллю, а по-друге, уможливлює включення у переклад HTML-тегів, що ну дуже корисно для перекладу всіляких текстових повідомлень. Найпростіша частина — то перекладання статичних рядків в застосунку. Тут достатньо побудувати словник та позаміняти рядки на виклик функції t
:
// словник const uk = { welcome: "Ласкаво просимо, {{name}}", app_prompt: "Встановіть наш <0>додаток</0>", }; // компонент додатка function Welcome({ name }) { const { t } = useTranslation(); return (
<div> {/* Простий переклад з параметром */} {t("welcome", { name })} {/* Використання react-i18next для включення тегу */} <p> , ]} /> </p> </div> <pre> ); }
Визначальними інструментами для якісного перекладу були:
- Плагін для ESLint eslint-plugin-i18next, який повідомляє про всі неперекладені рядки в застосунку. Так вдалося знайти майже кожний рядок, що потребував перекладу. Я кажу «майже», бо для здорового використання цього плагіну потрібно додати в список ігнорованих чимало виключень, та в них проскочило декілька рядків, які все ж таки треба було локалізувати (наприклад, URL).
- Декларації для TypeScript від i18next. З ними є гарантія, що ключі перекладу дійсно існують в словнику. Без таких декларацій довелося б потім вручну шукати ключі з помилками, або просто такі, що забули додати.
Інколи локалізований рядок будується не в компоненті React. Найбільш поширений приклад — повідомлення валідації. Тут є два підходи:
- Можна замінити рядки на ключі локалізації, а локалізувати вже при відображенні. Цей підхід мені здається найчистішим. Наприклад, результати валідації для форми дуже зручно генерувати у вигляді ключів:
required
,must_be_positive
. Але це гарно працює тільки доки рядки не містять параметрів, бо повертати ключ та набір параметрів — це вже суттєве ускладнення, яке до того ж погано покривається типами. - Або треба передавати в бізнес-логіку поточну локаль, щоб згенерувати правильно локалізований рядок. А потім ще й переконатись, що при зміні локалі результат не буде закешований. Перевага цього підходу в тому, що він точно дасть локалізувати все, що завгодно. Наприклад, у нас так будується підпис до діапазону дат. Ми доклали багато зусиль, щоб він був стислий та гуманізований:
«1-31 березня 2023», але «5 грудня 2023 — 5 січня 2024». Щоб досягти цього, ми складаємо рядок з декількох фрагментів за алгоритмом, тому одним ключем було не обійтись.
Але рядки — це не все, що потрібно локалізовувати. Дати та інші значення, наприклад, суми грошей, теж мають відповідати обраній локалі. Раджу робити це через i18next — можливо, написати власний хелпер:
// конфігурація i18next.services.formatter?.add("date", (value, lng, options) => dayjs(value).locale(lng).format(options.f) ); // використання const translations = { posted_on: "Опубліковано {{posted_on, date(f: 'D.MM')}}", };
Локалізація мобільного застосунку
Оскільки мобільний застосунок у нас написаний на React Native, то його переклад робився буквально так само, як і вебзастосунку. Навіть словник у них спільний. Окрім коду на React Native, довелося перекласти буквально пару системних рядків в застосунку iOS. Наприклад, запрошення дозволити повідомлення — воно вказується у файлі Info.plist
, і перекладається власними засобами Xcode. У Xcode перелік мов є властивістю проєкту, а для локалізації є дві опції. Або для файлу можна створити окремий варіант для кожної мови. Або ж додається так званий файл рядків, який схожий на словник i18next, але містить переклади з «головної мови», тобто мови вихідних файлів.
# uk.lproj/InfoPlist.strings addExpenseShortcutTitle = "Додати витрату";
Локалізація бекенда
Бекенд, який у нас на Firebase Functions та теж на TypeScript, не займається інтерфейсом, тому ті рядки, що приходять з бекенду, ми також замінили на ключі локалізації. Але якщо потрібно, то можна туди теж під’єднати i18next. Єдине, що локаль на бекенді змінюється від запиту до запиту, тому її потрібно отримувати з профілю користувача.
Локалізація листів
Перед локалізацією зміст наших листів зберігався у шаблонах Mailgun. На той момент було зручно не мати шаблонізатора зі свого боку, а просто передавати в Mailgun ключ шаблону та змінні. Але ніякої підтримки локалізації тут немає — хіба що створювати копії шаблонів на кожну мову. Таке рішення не сподобалось. Тому спочатку шаблони перенесли в застосунок; Mailgun використовує шаблонізатор Handlebars та його нескладно було інтегрувати в наш бекенд. Потім постало питання, як же ж перекладати шаблони Handlebars. Після роздумів та вагань вирішили й цього не робити, а буквально замінити Handlebars на i18next, бо ніякої складної логіки в наших шаблонах не було. Тож остаточне рішення було таке — для листів є окремий словник i18next, в якому ключ — це назва листа, а значення — це його зміст. А ще є словник для тем листів, які теж треба перекладати. Цікаво, що переклади тіл листів ми пишемо в окремі файли, а потім вже завантажуємо в структуру словника. На бекенді цілком можливо під час запуску застосунку сканувати директорію з перекладами функцією fs.readDirSync.
Локалізація сайту
Сайт у нас статично згенерований на платформі Hugo. Це значить, що всі переклади він готує заздалегідь, у вигляді окремих сторінок. Hugo має вбудовану підтримку мультилінгвальності, проте треба розуміти, що це значить. В найпростішій реалізації сторінки мають варіанти для кожної мови. Це зручно зі звичайними текстовими сторінками, як-от умови використання.
# pages/tos.uk.md --- url: /tos title: Умови використання --- # Умови використання .... # pages/tos.en.md --- url: /en/tos title: Terms of Service --- # Terms of Service ....
Для звичайних текстових сторінок це все, що потрібно. Зазначу, що завдяки атрибуту url
можна маршрутизувати сторінки так, як це зручно, а не спиратись на якусь жорстку структуру шляхів. Окрім того, треба було перекласти навігацію. Тут працює підхід зі словником; така можливість вбудована в Hugo та дуже схожа на i18next. Також є функції для того, щоб отримати адреси сторінок в поточній локалі, або навпаки — перелік локалей для поточної сторінки.
<!-- отримуємо посилання на сторінку в поточній локалі --> {{with .Site.GetPage "/tos"}}
{{end}}
Якщо деяка сторінка має більше розмітки, ніж тексту, ну як, наприклад, сторінка з цінами, то її теж варто перекладати за шаблоном, щоб не треба було вручну дублювати майбутні зміни розмітки. Використати шаблони в тексті сторінки Hugo заборонено. Тому для таких сторінок створюється виділений layout.
# pages/pricing.uk.md --- url: /pricing layout: pricing title: Ціни --- # layouts/pricing.html <!-- тут можна використати шаблони --> {{ i18n "pricing_title" }}
Локалізація сторінок App Store
Коли ви вже думали, що все перекладено, то виявляється, що всі елементи сторінки застосунку в App Store теж треба перекласти: опис, скриншоти, назви підписок та інше. Для цього в інтерфейсі App Store Connect є можливість створити локалізації для різних мов, а потім додавати переклади. При кожному оновленні застосунку також потрібно вказувати нотатки оновлення для кожної мови окремо.
Інше
А ще неперекладеною залишилась назва підписки у Paddle. Цей білінг-провайдер не дозволяє перекладати назви товарів. Технічно, мабуть, ми могли б створити окремі підписки для кожної мови. Але поки що просто вказали назву на інтернаціональній англійській. Цю назву клієнти бачать при оформленні підписки та в листах з рахунками. У інших місцях нашого сайту та застосунку назва локалізована, бо вона береться не з Paddle.
Визначення локалі
Як зрозуміти, яку мову показувати користувачу при першому відвідуванні? Є два фундаментальних підходи — можна дивитись на Accept-Language
браузера, а можна на географічне розташування. Нам більше сподобався останній, бо нерідко люди ставлять системною мовою англійську. Виявилось, що Google Cloud та Firebase автоматично визначають геолокацію всіх відвідувачів та передають у спеціальних заголовках. Тобто нам залишилось просто дивитись на країну відвідувача і виставляти мову. Для цього ми написали на бекенді спеціальну функцію. Для застосунку вона просто повертає мову, тут все просто. А от якщо викликати її зі статичного сайту, то функція, завдяки технології JSONP, перенаправить вас на відповідний переклад поточної сторінки. При авторизації поточна мова зберігається у вашому профілі, і далі геолокація не має значення. Та, звісно, мову профілю можна змінити у будь-який час.
Автоматична перевірка (лінтери)
Щоб переконатись, що переклад зроблений повністю, ми додали декілька лінтерів.
eslint-plugin-i18next
, про який я писав вище — допоможе в майбутньому помітити нові неперекладені рядки.- Самописний лінтер № 1 — перевіряє, що словники для кожної мови мають одну й ту саму структуру. Так можна уникнути десинхронізації словників при рефакторингу та поповненню словника.
- Самописний лінтер № 2 — спеціально для пошти — перевіряє, що словники для листів мають однаковий набір ключів для тем та для змісту. Їх важко порівняти вручну, тому лінтер додає спокою.
Лінтери здатні покращити якість коду малої команди без витрат часу, якого в принципі немає, тому раджу не нехтувати цією можливістю
Плани
Тепер хочеться перекласти продукт на інші мови, принаймні, на англійську. Але поки що через війну та інші обставини на це немає часу. Якщо застосунок вже локалізований і має словник, то переклад можна доручити іншим особам, таким чином, отримати переклади хоч на всі мови світу. (А ще можна перекласти автоматично — бачу, що деякі автори так роблять, судячи з кумедних результатів). Після локалізації ми ще не додавали суттєвих фіч (хоча це плануємо, та раніше, ніж наступні переклади.) Як я передбачаю, то доповнення вже локалізованого застосунку — теж цікава проблема. Сподіваюсь, що лінтери нам в цьому допоможуть. Цей досвід навчив мене, що локалізація — не така непідйомна справа, як я її вважав раніше. Та зараз вона потрібна як ніколи. Тож беріть надійні інструменти, заручайтеся уважністю та терпінням, та гайда робити більше українського софту!
35 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів