6.1 KB186 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8// @lint-ignore-every SPELL
9
10import type {ReactNode} from 'react';
11
12import React, {createContext, useContext, useEffect, useState} from 'react';
13import {TypedEventEmitter} from 'shared/TypedEventEmitter';
14import * as en from './en/common.json';
15
16/**
17 * ISO 639-3 language code used to control which translation we use
18 */
19type LanguageId = string;
20
21// TODO: these language files should be lazilly loaded rather than bundled directly
22const langs: {[key: string]: {[key: string]: string}} = {
23 en,
24 // Add other languages here!
25};
26
27declare global {
28 interface Window {
29 // language may be pre-defined ahead of time by the HTML in the window
30 saplingLanguage?: string;
31 }
32}
33
34let currentLanguage: LanguageId =
35 (typeof window !== 'undefined' ? window.saplingLanguage : null) ?? 'en';
36
37const I18nContext = createContext(currentLanguage);
38export const onChangeLanguage = new TypedEventEmitter<'change', string>();
39
40/**
41 * We need to re-render translated components when the language is changed.
42 * React context lets us easily re-render any component using the language.
43 */
44export function I18nSupport({children}: {children: React.ReactNode}) {
45 const [lang, setLang] = useState(currentLanguage);
46 useEffect(() => {
47 onChangeLanguage.on('change', setLang);
48 return () => void onChangeLanguage.off('change', setLang);
49 }, []);
50 return <I18nContext.Provider value={lang}>{children}</I18nContext.Provider>;
51}
52
53export function getCurrentLanguage(): LanguageId {
54 return currentLanguage;
55}
56export function setCurrentLanguage(lang: LanguageId) {
57 currentLanguage = lang;
58 onChangeLanguage.emit('change', currentLanguage);
59}
60
61/**
62 * what key suffixes to use for count-based translations
63 * e.g., in en, myStringKey_one = 'open $count file', myStringKey_other = 'open $count files'
64 * so when you call t('myStringKey', {count: 4}) it uses the right plural.
65 * Different languages have different rules for plurals.
66 */
67const pluralizers: {[key in keyof typeof langs]: (n: number) => string} = {
68 en: (n: number) => (n == 1 ? 'one' : 'other'),
69};
70
71/**
72 * Translate provided en-language string. All user-visible strings should use t() or <T></T>, including
73 * title texts, tooltips, and error messages.
74 * Generally, the parameter is taken to be the english translation directly.
75 * You can also use a generic key and define an en translation.
76 * ```
77 * t('Cancel') -> 'Cancel' if current language is 'en',
78 * t('Cancel') -> 'Abbrechen' if current language is 'de', etc
79 * ```
80 * To pluralize, pass a `count` option. Then define translations with keys according to the pluralization rules
81 * in {@link pluralizers}.
82 * ```
83 * t('confirmFilesSave', {count: 1}) -> lookup en 'confirmFilesSave_one' -> 'Save 1 file' if current language is 'en'
84 * t('confirmFilesSave', {count: 4}) -> lookup en 'confirmFilesSave_other' -> 'Save 4 files' if current language is 'en'
85 * t('confirmFilesSave', {count: 4}) -> lookup de 'confirmFilesSave_other' -> 'Speichern Sie 4 Dateien' if current language is 'de'
86 * ```
87 * To include arbitrary opaque contents that are not translated, you can provide a replacer:
88 * ```
89 * t('Hello, my name is $name.', {replace: {$name: getName()}})
90 * ```
91 * {@link T See also &lt;T&gt; React component}
92 */
93export function t(
94 i18nKeyOrEnText: string,
95 options?: {count?: number; replace?: {[key: string]: string}},
96) {
97 return translate(i18nKeyOrEnText, options).join('');
98}
99
100/**
101 * Translates contents. Re-renders when language is updated.
102 * {@link t See t() function documentation}
103 *
104 * Unlike `t()`, `options.replace` can include arbitrary `ReactNode` contents
105 * ```
106 * <T replace={{$name: <b>{getName()}</b>}}>Hello, my name is $name.</T>
107 * ```
108 */
109export function T({
110 children,
111 count,
112 replace,
113}: {
114 children: string;
115 count?: number;
116 opaque?: ReactNode;
117 replace?: {[key: string]: string | ReactNode};
118}): JSX.Element {
119 // trigger re-render if the language is changed
120 useContext(I18nContext);
121
122 return <>{translate(children, {count, replace})}</>;
123}
124
125function translate(
126 i18nKeyOrEnText: string,
127 options?: {count?: number; replace?: {[key: string]: string | ReactNode}},
128): Array<string | ReactNode> {
129 let result;
130 if (options?.count != null) {
131 const pluralized =
132 getPlural(i18nKeyOrEnText, options.count, currentLanguage) ??
133 // fallback to pluralized en if the currentLanguage doesn't have this key
134 getPlural(i18nKeyOrEnText, options.count, 'en') ??
135 // last resort is to use the key directly
136 i18nKeyOrEnText;
137 // replace number into the appropriate location
138 result = pluralized.replace(/\$count/g, String(options.count));
139 }
140 if (!result) {
141 result =
142 langs[currentLanguage]?.[i18nKeyOrEnText] ?? langs.en[i18nKeyOrEnText] ?? i18nKeyOrEnText;
143 }
144 if (options?.replace) {
145 // if we split with a regexp match group, the value will stay in the array,
146 // so it can be replaced later
147 const regex = new RegExp(
148 // this requires escaping so special characters like $ can be used
149 '(' + Object.keys(options.replace).map(escapeForRegExp).join('|') + ')',
150 'g',
151 );
152 const parts = result.split(regex);
153 return (
154 parts
155 .map(part => options.replace?.[part] ?? part)
156 // if we replace with a component, we need to set a key or react will complain
157 .map((part, i) =>
158 typeof part === 'object' ? ({...part, key: String(i)} as ReactNode) : part,
159 )
160 );
161 }
162 return [result];
163}
164
165function escapeForRegExp(s: string) {
166 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
167}
168
169/**
170 * Returns current language. Also triggers re-renders if the language is changed.
171 */
172export function useCurrentLang(): LanguageId {
173 useContext(I18nContext);
174 return currentLanguage;
175}
176
177function getPlural(i18nKeyOrEnText: string, count: number, lang: LanguageId): string | undefined {
178 const pluralizer = pluralizers[lang];
179 if (pluralizer == null) {
180 return undefined;
181 }
182
183 const key = i18nKeyOrEnText + '_' + pluralizer(count);
184 return langs[lang]?.[key];
185}
186