addons/isl/src/i18n/index.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318// @lint-ignore-every SPELL
b69ab319
b69ab3110import type {ReactNode} from 'react';
b69ab3111
b69ab3112import React, {createContext, useContext, useEffect, useState} from 'react';
b69ab3113import {TypedEventEmitter} from 'shared/TypedEventEmitter';
b69ab3114import * as en from './en/common.json';
b69ab3115
b69ab3116/**
b69ab3117 * ISO 639-3 language code used to control which translation we use
b69ab3118 */
b69ab3119type LanguageId = string;
b69ab3120
b69ab3121// TODO: these language files should be lazilly loaded rather than bundled directly
b69ab3122const langs: {[key: string]: {[key: string]: string}} = {
b69ab3123 en,
b69ab3124 // Add other languages here!
b69ab3125};
b69ab3126
b69ab3127declare global {
b69ab3128 interface Window {
b69ab3129 // language may be pre-defined ahead of time by the HTML in the window
b69ab3130 saplingLanguage?: string;
b69ab3131 }
b69ab3132}
b69ab3133
b69ab3134let currentLanguage: LanguageId =
b69ab3135 (typeof window !== 'undefined' ? window.saplingLanguage : null) ?? 'en';
b69ab3136
b69ab3137const I18nContext = createContext(currentLanguage);
b69ab3138export const onChangeLanguage = new TypedEventEmitter<'change', string>();
b69ab3139
b69ab3140/**
b69ab3141 * We need to re-render translated components when the language is changed.
b69ab3142 * React context lets us easily re-render any component using the language.
b69ab3143 */
b69ab3144export function I18nSupport({children}: {children: React.ReactNode}) {
b69ab3145 const [lang, setLang] = useState(currentLanguage);
b69ab3146 useEffect(() => {
b69ab3147 onChangeLanguage.on('change', setLang);
b69ab3148 return () => void onChangeLanguage.off('change', setLang);
b69ab3149 }, []);
b69ab3150 return <I18nContext.Provider value={lang}>{children}</I18nContext.Provider>;
b69ab3151}
b69ab3152
b69ab3153export function getCurrentLanguage(): LanguageId {
b69ab3154 return currentLanguage;
b69ab3155}
b69ab3156export function setCurrentLanguage(lang: LanguageId) {
b69ab3157 currentLanguage = lang;
b69ab3158 onChangeLanguage.emit('change', currentLanguage);
b69ab3159}
b69ab3160
b69ab3161/**
b69ab3162 * what key suffixes to use for count-based translations
b69ab3163 * e.g., in en, myStringKey_one = 'open $count file', myStringKey_other = 'open $count files'
b69ab3164 * so when you call t('myStringKey', {count: 4}) it uses the right plural.
b69ab3165 * Different languages have different rules for plurals.
b69ab3166 */
b69ab3167const pluralizers: {[key in keyof typeof langs]: (n: number) => string} = {
b69ab3168 en: (n: number) => (n == 1 ? 'one' : 'other'),
b69ab3169};
b69ab3170
b69ab3171/**
b69ab3172 * Translate provided en-language string. All user-visible strings should use t() or <T></T>, including
b69ab3173 * title texts, tooltips, and error messages.
b69ab3174 * Generally, the parameter is taken to be the english translation directly.
b69ab3175 * You can also use a generic key and define an en translation.
b69ab3176 * ```
b69ab3177 * t('Cancel') -> 'Cancel' if current language is 'en',
b69ab3178 * t('Cancel') -> 'Abbrechen' if current language is 'de', etc
b69ab3179 * ```
b69ab3180 * To pluralize, pass a `count` option. Then define translations with keys according to the pluralization rules
b69ab3181 * in {@link pluralizers}.
b69ab3182 * ```
b69ab3183 * t('confirmFilesSave', {count: 1}) -> lookup en 'confirmFilesSave_one' -> 'Save 1 file' if current language is 'en'
b69ab3184 * t('confirmFilesSave', {count: 4}) -> lookup en 'confirmFilesSave_other' -> 'Save 4 files' if current language is 'en'
b69ab3185 * t('confirmFilesSave', {count: 4}) -> lookup de 'confirmFilesSave_other' -> 'Speichern Sie 4 Dateien' if current language is 'de'
b69ab3186 * ```
b69ab3187 * To include arbitrary opaque contents that are not translated, you can provide a replacer:
b69ab3188 * ```
b69ab3189 * t('Hello, my name is $name.', {replace: {$name: getName()}})
b69ab3190 * ```
b69ab3191 * {@link T See also &lt;T&gt; React component}
b69ab3192 */
b69ab3193export function t(
b69ab3194 i18nKeyOrEnText: string,
b69ab3195 options?: {count?: number; replace?: {[key: string]: string}},
b69ab3196) {
b69ab3197 return translate(i18nKeyOrEnText, options).join('');
b69ab3198}
b69ab3199
b69ab31100/**
b69ab31101 * Translates contents. Re-renders when language is updated.
b69ab31102 * {@link t See t() function documentation}
b69ab31103 *
b69ab31104 * Unlike `t()`, `options.replace` can include arbitrary `ReactNode` contents
b69ab31105 * ```
b69ab31106 * <T replace={{$name: <b>{getName()}</b>}}>Hello, my name is $name.</T>
b69ab31107 * ```
b69ab31108 */
b69ab31109export function T({
b69ab31110 children,
b69ab31111 count,
b69ab31112 replace,
b69ab31113}: {
b69ab31114 children: string;
b69ab31115 count?: number;
b69ab31116 opaque?: ReactNode;
b69ab31117 replace?: {[key: string]: string | ReactNode};
b69ab31118}): JSX.Element {
b69ab31119 // trigger re-render if the language is changed
b69ab31120 useContext(I18nContext);
b69ab31121
b69ab31122 return <>{translate(children, {count, replace})}</>;
b69ab31123}
b69ab31124
b69ab31125function translate(
b69ab31126 i18nKeyOrEnText: string,
b69ab31127 options?: {count?: number; replace?: {[key: string]: string | ReactNode}},
b69ab31128): Array<string | ReactNode> {
b69ab31129 let result;
b69ab31130 if (options?.count != null) {
b69ab31131 const pluralized =
b69ab31132 getPlural(i18nKeyOrEnText, options.count, currentLanguage) ??
b69ab31133 // fallback to pluralized en if the currentLanguage doesn't have this key
b69ab31134 getPlural(i18nKeyOrEnText, options.count, 'en') ??
b69ab31135 // last resort is to use the key directly
b69ab31136 i18nKeyOrEnText;
b69ab31137 // replace number into the appropriate location
b69ab31138 result = pluralized.replace(/\$count/g, String(options.count));
b69ab31139 }
b69ab31140 if (!result) {
b69ab31141 result =
b69ab31142 langs[currentLanguage]?.[i18nKeyOrEnText] ?? langs.en[i18nKeyOrEnText] ?? i18nKeyOrEnText;
b69ab31143 }
b69ab31144 if (options?.replace) {
b69ab31145 // if we split with a regexp match group, the value will stay in the array,
b69ab31146 // so it can be replaced later
b69ab31147 const regex = new RegExp(
b69ab31148 // this requires escaping so special characters like $ can be used
b69ab31149 '(' + Object.keys(options.replace).map(escapeForRegExp).join('|') + ')',
b69ab31150 'g',
b69ab31151 );
b69ab31152 const parts = result.split(regex);
b69ab31153 return (
b69ab31154 parts
b69ab31155 .map(part => options.replace?.[part] ?? part)
b69ab31156 // if we replace with a component, we need to set a key or react will complain
b69ab31157 .map((part, i) =>
b69ab31158 typeof part === 'object' ? ({...part, key: String(i)} as ReactNode) : part,
b69ab31159 )
b69ab31160 );
b69ab31161 }
b69ab31162 return [result];
b69ab31163}
b69ab31164
b69ab31165function escapeForRegExp(s: string) {
b69ab31166 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
b69ab31167}
b69ab31168
b69ab31169/**
b69ab31170 * Returns current language. Also triggers re-renders if the language is changed.
b69ab31171 */
b69ab31172export function useCurrentLang(): LanguageId {
b69ab31173 useContext(I18nContext);
b69ab31174 return currentLanguage;
b69ab31175}
b69ab31176
b69ab31177function getPlural(i18nKeyOrEnText: string, count: number, lang: LanguageId): string | undefined {
b69ab31178 const pluralizer = pluralizers[lang];
b69ab31179 if (pluralizer == null) {
b69ab31180 return undefined;
b69ab31181 }
b69ab31182
b69ab31183 const key = i18nKeyOrEnText + '_' + pluralizer(count);
b69ab31184 return langs[lang]?.[key];
b69ab31185}