i18n.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import { isDevBuild } from "@/utils/env";
  2. import {
  3. getLSString,
  4. removeLSString,
  5. setLSString,
  6. } from "@/utils/local-storage";
  7. import { logError } from "@/utils/logging";
  8. import { includes } from "@/utils/type-guards";
  9. import { getUserLocales } from "get-user-locale";
  10. import i18n from "i18next";
  11. import Backend from "i18next-http-backend";
  12. import { initReactI18next } from "react-i18next";
  13. import { object, string } from "yup";
  14. /**
  15. * List of all {@link SupportedLocale}s.
  16. *
  17. * Locales are combinations of a language code, and an optional region code.
  18. *
  19. * For example, "en", "en-US", "en-IN" (Indian English), "pt" (Portuguese),
  20. * "pt-BR" (Brazilian Portuguese).
  21. *
  22. * In our Crowdin Project, we have work-in-progress translations into more
  23. * languages than this. When a translation reaches a high enough coverage, say
  24. * 90%, then we manually add it to this list of supported languages.
  25. */
  26. export const supportedLocales = [
  27. "en-US" /* English */,
  28. "fr-FR" /* French */,
  29. "zh-CN" /* Simplified Chinese */,
  30. "nl-NL" /* Dutch */,
  31. "es-ES" /* Spanish */,
  32. "pt-BR" /* Portuguese, Brazilian */,
  33. ] as const;
  34. /** The type of {@link supportedLocales}. */
  35. export type SupportedLocale = (typeof supportedLocales)[number];
  36. const defaultLocale: SupportedLocale = "en-US";
  37. /**
  38. * Load translations.
  39. *
  40. * Localization and related concerns (aka "internationalization", or "i18n") for
  41. * our apps is handled by i18n framework.
  42. *
  43. * In addition to the base i18next package, we use two of its plugins:
  44. *
  45. * - i18next-http-backend, for loading the JSON files containin the translations
  46. * at runtime, and
  47. *
  48. * - react-i18next, which adds React specific APIs
  49. */
  50. export const setupI18n = async () => {
  51. const localeString = savedLocaleStringMigratingIfNeeded();
  52. const locale = closestSupportedLocale(localeString);
  53. // https://www.i18next.com/overview/api
  54. await i18n
  55. // i18next-http-backend: Asynchronously loads translations over HTTP
  56. // https://github.com/i18next/i18next-http-backend
  57. .use(Backend)
  58. // react-i18next: React support
  59. // Pass the i18n instance to react-i18next.
  60. .use(initReactI18next)
  61. // Initialize i18next
  62. // Option docs: https://www.i18next.com/overview/configuration-options
  63. .init({
  64. debug: isDevBuild,
  65. // i18next calls it language, but it really is the locale
  66. lng: locale,
  67. // Tell i18next about the locales we support
  68. supportedLngs: supportedLocales,
  69. // Ask it to fetch only exact matches
  70. //
  71. // By default, if the lng was set to, say, en-GB, i18n would make
  72. // network requests for ["en-GB", "en", "dev"] (where dev is the
  73. // default fallback). By setting `load` to "currentOnly", we ask
  74. // i18next to only try and fetch "en-GB" (i.e. the exact match).
  75. load: "currentOnly",
  76. // Disallow empty strings as valid translations.
  77. //
  78. // This way, empty strings will fallback to `fallbackLng`
  79. returnEmptyString: false,
  80. // The language to use if translation for a particular key in the
  81. // current `lng` is not available.
  82. fallbackLng: defaultLocale,
  83. interpolation: {
  84. escapeValue: false, // not needed for react as it escapes by default
  85. },
  86. react: {
  87. useSuspense: false,
  88. transKeepBasicHtmlNodesFor: [
  89. "div",
  90. "strong",
  91. "h2",
  92. "span",
  93. "code",
  94. "p",
  95. "br",
  96. ],
  97. },
  98. });
  99. i18n.services.formatter?.add("dateTime", (value, lng) => {
  100. return new Date(value / 1000).toLocaleDateString(lng, {
  101. year: "numeric",
  102. month: "long",
  103. day: "numeric",
  104. });
  105. });
  106. };
  107. /**
  108. * Read and return the locale (if any) that we'd previously saved in local
  109. * storage.
  110. *
  111. * If it finds a locale stored in the old format, it also updates the saved
  112. * value and returns it in the new format.
  113. */
  114. const savedLocaleStringMigratingIfNeeded = () => {
  115. const ls = getLSString("locale");
  116. // An older version of our code had stored only the language code, not the
  117. // full locale. Migrate these to the new locale format. Luckily, all such
  118. // languages can be unambiguously mapped to locales in our current set.
  119. //
  120. // This migration is dated Feb 2024. And it can be removed after a few
  121. // months, because by then either customers would've opened the app and
  122. // their setting migrated to the new format, or the browser would've cleared
  123. // the older local storage entry anyway.
  124. if (!ls) {
  125. // Nothing found
  126. return ls;
  127. }
  128. if (includes(supportedLocales, ls)) {
  129. // Already in the new format
  130. return ls;
  131. }
  132. let value: string | undefined;
  133. try {
  134. const oldFormatData = object({ value: string() }).json().cast(ls);
  135. value = oldFormatData.value;
  136. } catch (e) {
  137. // Not a valid JSON, or not in the format we expected it. This shouldn't
  138. // have happened, we're the only one setting it.
  139. logError("Failed to parse locale obtained from local storage", e);
  140. // Also remove the old key, it is not parseable by us anymore.
  141. removeLSString("locale");
  142. return undefined;
  143. }
  144. const newValue = mapOldValue(value);
  145. if (newValue) setLSString("locale", newValue);
  146. return newValue;
  147. };
  148. const mapOldValue = (value: string | undefined) => {
  149. switch (value) {
  150. case "en":
  151. return "en-US";
  152. case "fr":
  153. return "fr-FR";
  154. case "zh":
  155. return "zh-CN";
  156. case "nl":
  157. return "nl-NL";
  158. case "es":
  159. return "es-ES";
  160. default:
  161. return undefined;
  162. }
  163. };
  164. /**
  165. * Return the closest / best matching {@link SupportedLocale}.
  166. *
  167. * It takes as input a {@link savedLocaleString}, which denotes the user's
  168. * explicitly chosen preference (which we then persist in local storage).
  169. * Subsequently, we use this to (usually literally) return the supported locale
  170. * that it represents.
  171. *
  172. * If {@link savedLocaleString} is `undefined`, it tries to deduce the closest
  173. * {@link SupportedLocale} that matches the browser's locale.
  174. */
  175. const closestSupportedLocale = (
  176. savedLocaleString?: string,
  177. ): SupportedLocale => {
  178. const ss = savedLocaleString;
  179. if (ss && includes(supportedLocales, ss)) return ss;
  180. for (const ls of getUserLocales()) {
  181. // Exact match
  182. if (ls && includes(supportedLocales, ls)) return ls;
  183. // Language match
  184. if (ls.startsWith("en")) {
  185. return "en-US";
  186. } else if (ls.startsWith("fr")) {
  187. return "fr-FR";
  188. } else if (ls.startsWith("zh")) {
  189. return "zh-CN";
  190. } else if (ls.startsWith("nl")) {
  191. return "nl-NL";
  192. } else if (ls.startsWith("es")) {
  193. return "es-ES";
  194. } else if (ls.startsWith("pt-BR")) {
  195. // We'll never get here (it'd already be an exact match), just kept
  196. // to keep this list consistent.
  197. return "pt-BR";
  198. }
  199. }
  200. // Fallback
  201. return defaultLocale;
  202. };
  203. /**
  204. * Return the locale that is currently being used to show the app's UI.
  205. *
  206. * Note that this may be different from the user's locale. For example, the
  207. * browser might be set to en-GB, but since we don't support that specific
  208. * variant of English, this value will be (say) en-US.
  209. */
  210. export const getLocaleInUse = (): SupportedLocale => {
  211. const locale = i18n.resolvedLanguage;
  212. if (locale && includes(supportedLocales, locale)) {
  213. return locale;
  214. } else {
  215. // This shouldn't have happened. Log an error to attract attention.
  216. logError(
  217. `Expected the i18next locale to be one of the supported values, but instead found ${locale}`,
  218. );
  219. return defaultLocale;
  220. }
  221. };
  222. /**
  223. * Set the locale that should be used to show the app's UI.
  224. *
  225. * This updates both the i18next state, and also the corresponding user
  226. * preference that is stored in local storage.
  227. */
  228. export const setLocaleInUse = async (locale: SupportedLocale) => {
  229. setLSString("locale", locale);
  230. return i18n.changeLanguage(locale);
  231. };