ente/web/packages/next/i18n.ts

261 lines
8.8 KiB
TypeScript
Raw Normal View History

import { isDevBuild } from "@/next/env";
2024-04-09 06:03:44 +00:00
import log from "@/next/log";
import { includes } from "@/utils/type-guards";
import { getUserLocales } from "get-user-locale";
import i18n from "i18next";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next";
import { object, string } from "yup";
2024-02-22 17:24:17 +00:00
2024-02-23 05:21:17 +00:00
/**
* List of all {@link SupportedLocale}s.
*
* Locales are combinations of a language code, and an optional region code.
*
* For example, "en", "en-US", "en-IN" (Indian English), "pt" (Portuguese),
* "pt-BR" (Brazilian Portuguese).
*
* In our Crowdin Project, we have work-in-progress translations into more
* languages than this. When a translation reaches a high enough coverage, say
* 90%, then we manually add it to this list of supported languages.
*/
2024-02-23 13:19:23 +00:00
export const supportedLocales = [
2024-02-23 13:31:20 +00:00
"en-US" /* English */,
"fr-FR" /* French */,
"zh-CN" /* Simplified Chinese */,
"nl-NL" /* Dutch */,
"es-ES" /* Spanish */,
2024-02-24 04:04:14 +00:00
"pt-BR" /* Portuguese, Brazilian */,
"ru-RU" /* Russian */,
2024-02-23 13:19:23 +00:00
] as const;
2024-02-23 16:00:38 +00:00
/** The type of {@link supportedLocales}. */
2024-02-23 05:21:17 +00:00
export type SupportedLocale = (typeof supportedLocales)[number];
2024-02-24 03:41:24 +00:00
const defaultLocale: SupportedLocale = "en-US";
2024-02-21 11:09:31 +00:00
/**
* Load translations.
*
* Localization and related concerns (aka "internationalization", or "i18n") for
* our apps is handled by i18n framework.
*
* In addition to the base i18next package, we use two of its plugins:
*
* - i18next-http-backend, for loading the JSON files containin the translations
* at runtime, and
*
* - react-i18next, which adds React specific APIs
*/
2024-02-23 17:27:33 +00:00
export const setupI18n = async () => {
const localeString = savedLocaleStringMigratingIfNeeded();
const locale = closestSupportedLocale(localeString);
2024-02-23 05:21:17 +00:00
2024-02-21 11:09:31 +00:00
// https://www.i18next.com/overview/api
2023-03-15 12:01:17 +00:00
await i18n
2024-04-06 05:49:14 +00:00
// i18next-resources-to-backend: Use webpack to bundle translation, but
// still fetch them lazily using a dynamic import.
//
// The benefit of this is that, unlike the http backend that uses files
// from the public folder, these JSON files are content hash named and
// eminently cacheable.
//
// https://github.com/i18next/i18next-resources-to-backend
.use(
resourcesToBackend(
(language: string, namespace: string) =>
2024-04-06 05:49:14 +00:00
import(`./locales/${language}/${namespace}.json`),
),
)
2024-02-21 11:09:31 +00:00
// react-i18next: React support
// Pass the i18n instance to react-i18next.
2023-03-15 12:01:17 +00:00
.use(initReactI18next)
2024-02-21 11:09:31 +00:00
// Initialize i18next
// Option docs: https://www.i18next.com/overview/configuration-options
2023-03-15 12:01:17 +00:00
.init({
2024-02-21 14:00:32 +00:00
debug: isDevBuild,
2024-02-23 05:21:17 +00:00
// i18next calls it language, but it really is the locale
lng: locale,
2024-02-23 13:19:23 +00:00
// Tell i18next about the locales we support
supportedLngs: supportedLocales,
// Ask it to fetch only exact matches
//
// By default, if the lng was set to, say, en-GB, i18n would make
// network requests for ["en-GB", "en", "dev"] (where dev is the
// default fallback). By setting `load` to "currentOnly", we ask
// i18next to only try and fetch "en-GB" (i.e. the exact match).
load: "currentOnly",
// Disallow empty strings as valid translations.
//
// This way, empty strings will fallback to `fallbackLng`
returnEmptyString: false,
// The language to use if translation for a particular key in the
// current `lng` is not available.
2024-02-24 03:41:24 +00:00
fallbackLng: defaultLocale,
2023-03-15 12:01:17 +00:00
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
react: {
2023-03-20 05:22:58 +00:00
useSuspense: false,
2023-03-15 12:01:17 +00:00
transKeepBasicHtmlNodesFor: [
2024-02-23 04:11:42 +00:00
"div",
"strong",
"h2",
"span",
"code",
"p",
"br",
2023-03-15 12:01:17 +00:00
],
},
});
2023-03-13 15:28:00 +00:00
2024-02-23 04:11:42 +00:00
i18n.services.formatter?.add("dateTime", (value, lng) => {
2023-03-15 12:01:17 +00:00
return new Date(value / 1000).toLocaleDateString(lng, {
2024-02-23 04:11:42 +00:00
year: "numeric",
month: "long",
day: "numeric",
2023-03-15 12:01:17 +00:00
});
2023-03-14 08:46:23 +00:00
});
2023-03-15 12:01:17 +00:00
};
2024-02-21 14:51:58 +00:00
2024-02-23 05:21:17 +00:00
/**
2024-02-23 17:27:33 +00:00
* Read and return the locale (if any) that we'd previously saved in local
* storage.
2024-02-23 05:21:17 +00:00
*
2024-02-23 17:27:33 +00:00
* If it finds a locale stored in the old format, it also updates the saved
* value and returns it in the new format.
2024-02-23 05:21:17 +00:00
*/
const savedLocaleStringMigratingIfNeeded = (): SupportedLocale | undefined => {
const ls = localStorage.getItem("locale");
2024-02-23 05:21:17 +00:00
2024-02-23 13:19:23 +00:00
// An older version of our code had stored only the language code, not the
2024-02-23 17:27:33 +00:00
// full locale. Migrate these to the new locale format. Luckily, all such
// languages can be unambiguously mapped to locales in our current set.
//
// This migration is dated Feb 2024. And it can be removed after a few
// months, because by then either customers would've opened the app and
// their setting migrated to the new format, or the browser would've cleared
// the older local storage entry anyway.
if (!ls) {
// Nothing found
return undefined;
2024-02-23 17:27:33 +00:00
}
if (includes(supportedLocales, ls)) {
// Already in the new format
return ls;
}
let value: string | undefined;
try {
const oldFormatData = object({ value: string() }).json().cast(ls);
value = oldFormatData.value;
} catch (e) {
// Not a valid JSON, or not in the format we expected it. This shouldn't
// have happened, we're the only one setting it.
2024-04-09 06:03:44 +00:00
log.error("Failed to parse locale obtained from local storage", e);
2024-02-23 17:27:33 +00:00
// Also remove the old key, it is not parseable by us anymore.
localStorage.removeItem("locale");
2024-02-23 17:27:33 +00:00
return undefined;
}
const newValue = mapOldValue(value);
if (newValue) localStorage.setItem("locale", newValue);
2024-02-24 03:41:24 +00:00
2024-02-23 17:27:33 +00:00
return newValue;
};
const mapOldValue = (value: string | undefined) => {
switch (value) {
2024-02-23 04:11:42 +00:00
case "en":
2024-02-23 13:19:23 +00:00
return "en-US";
2024-02-23 04:11:42 +00:00
case "fr":
2024-02-23 13:19:23 +00:00
return "fr-FR";
2024-02-23 04:11:42 +00:00
case "zh":
2024-02-23 13:31:20 +00:00
return "zh-CN";
2024-02-23 04:11:42 +00:00
case "nl":
2024-02-23 13:19:23 +00:00
return "nl-NL";
2024-02-23 04:11:42 +00:00
case "es":
2024-02-23 13:19:23 +00:00
return "es-ES";
2024-02-23 17:27:33 +00:00
default:
return undefined;
2024-02-22 17:24:17 +00:00
}
2024-02-23 17:27:33 +00:00
};
/**
* Return the closest / best matching {@link SupportedLocale}.
*
* It takes as input a {@link savedLocaleString}, which denotes the user's
* explicitly chosen preference (which we then persist in local storage).
* Subsequently, we use this to (usually literally) return the supported locale
* that it represents.
*
* If {@link savedLocaleString} is `undefined`, it tries to deduce the closest
* {@link SupportedLocale} that matches the browser's locale.
*/
2024-02-24 03:41:24 +00:00
const closestSupportedLocale = (
2024-02-23 17:27:33 +00:00
savedLocaleString?: string,
2024-02-24 03:41:24 +00:00
): SupportedLocale => {
2024-02-23 17:27:33 +00:00
const ss = savedLocaleString;
if (ss && includes(supportedLocales, ss)) return ss;
2024-02-22 17:30:55 +00:00
2024-02-24 04:04:14 +00:00
for (const ls of getUserLocales()) {
2024-02-23 05:21:17 +00:00
// Exact match
2024-02-24 04:04:14 +00:00
if (ls && includes(supportedLocales, ls)) return ls;
2024-02-23 05:21:17 +00:00
// Language match
2024-02-24 04:04:14 +00:00
if (ls.startsWith("en")) {
2024-02-23 13:19:23 +00:00
return "en-US";
2024-02-24 04:04:14 +00:00
} else if (ls.startsWith("fr")) {
2024-02-23 13:19:23 +00:00
return "fr-FR";
2024-02-24 04:04:14 +00:00
} else if (ls.startsWith("zh")) {
2024-02-23 13:31:20 +00:00
return "zh-CN";
2024-02-24 04:04:14 +00:00
} else if (ls.startsWith("nl")) {
2024-02-23 13:19:23 +00:00
return "nl-NL";
2024-02-24 04:04:14 +00:00
} else if (ls.startsWith("es")) {
2024-02-23 13:19:23 +00:00
return "es-ES";
2024-02-24 04:04:14 +00:00
} else if (ls.startsWith("pt-BR")) {
// We'll never get here (it'd already be an exact match), just kept
// to keep this list consistent.
return "pt-BR";
} else if (ls.startsWith("ru")) {
return "ru-RU";
2024-02-22 17:24:17 +00:00
}
}
2024-02-23 05:21:17 +00:00
// Fallback
2024-02-24 03:41:24 +00:00
return defaultLocale;
};
/**
* Return the locale that is currently being used to show the app's UI.
*
* Note that this may be different from the user's locale. For example, the
* browser might be set to en-GB, but since we don't support that specific
* variant of English, this value will be (say) en-US.
*/
export const getLocaleInUse = (): SupportedLocale => {
const locale = i18n.resolvedLanguage;
if (locale && includes(supportedLocales, locale)) {
return locale;
} else {
// This shouldn't have happened. Log an error to attract attention.
2024-04-09 06:03:44 +00:00
log.error(
2024-02-24 03:41:24 +00:00
`Expected the i18next locale to be one of the supported values, but instead found ${locale}`,
);
return defaultLocale;
}
};
/**
* Set the locale that should be used to show the app's UI.
*
* This updates both the i18next state, and also the corresponding user
* preference that is stored in local storage.
*/
export const setLocaleInUse = async (locale: SupportedLocale) => {
localStorage.setItem("locale", locale);
2024-02-24 03:41:24 +00:00
return i18n.changeLanguage(locale);
};