locale.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. import {cloneElement, Fragment, isValidElement} from 'react';
  2. // @ts-expect-error TS(7016): Could not find a declaration file for module 'jed'... Remove this comment to see the full error message
  3. import Jed from 'jed';
  4. // @ts-expect-error TS(7016): Could not find a declaration file for module 'spri... Remove this comment to see the full error message
  5. import {sprintf} from 'sprintf-js';
  6. import toArray from 'sentry/utils/array/toArray';
  7. import localStorage from 'sentry/utils/localStorage';
  8. const markerStyles = {
  9. background: '#ff801790',
  10. outline: '2px solid #ff801790',
  11. };
  12. const LOCALE_DEBUG = localStorage.getItem('localeDebug') === '1';
  13. export const DEFAULT_LOCALE_DATA = {
  14. '': {
  15. domain: 'sentry',
  16. lang: 'en',
  17. plural_forms: 'nplurals=2; plural=(n != 1);',
  18. },
  19. };
  20. export function setLocaleDebug(value: boolean) {
  21. localStorage.setItem('localeDebug', value ? '1' : '0');
  22. // eslint-disable-next-line no-console
  23. console.log(`Locale debug is: ${value ? 'on' : 'off'}. Reload page to apply changes!`);
  24. }
  25. /**
  26. * Toggles the locale debug flag in local storage, but does _not_ reload the
  27. * page. The caller should do this.
  28. */
  29. export function toggleLocaleDebug() {
  30. const currentValue = localStorage.getItem('localeDebug');
  31. setLocaleDebug(currentValue !== '1');
  32. }
  33. /**
  34. * Global Jed locale object loaded with translations via setLocale
  35. */
  36. let i18n: Jed | null = null;
  37. const staticTranslations = new Set<string>();
  38. /**
  39. * Set the current application locale.
  40. *
  41. * NOTE: This MUST be called early in the application before calls to any
  42. * translation functions, as this mutates a singleton translation object used
  43. * to lookup translations at runtime.
  44. */
  45. export function setLocale(translations: any): Jed {
  46. i18n = new Jed({
  47. domain: 'sentry',
  48. missing_key_callback: () => {},
  49. locale_data: {
  50. sentry: translations,
  51. },
  52. });
  53. return i18n;
  54. }
  55. type FormatArg = ComponentMap | React.ReactNode;
  56. /**
  57. * Helper to return the i18n client, and initialize for the default locale (English)
  58. * if it has otherwise not been initialized.
  59. */
  60. function getClient(): Jed | null {
  61. if (!i18n) {
  62. // If this happens, it could mean that an import was added/changed where
  63. // locale initialization does not happen soon enough.
  64. // eslint-disable-next-line no-console
  65. console.warn('Locale not set, defaulting to English');
  66. return setLocale(DEFAULT_LOCALE_DATA);
  67. }
  68. return i18n;
  69. }
  70. export function isStaticString(formatString: string): boolean {
  71. if (formatString.trim() === '') {
  72. return false;
  73. }
  74. return staticTranslations.has(formatString);
  75. }
  76. /**
  77. * printf style string formatting which render as react nodes.
  78. */
  79. function formatForReact(formatString: string, args: FormatArg[]): React.ReactNode[] {
  80. const nodes: React.ReactNode[] = [];
  81. let cursor = 0;
  82. // always re-parse, do not cache, because we change the match
  83. sprintf.parse(formatString).forEach((match: any, idx: number) => {
  84. if (typeof match === 'string') {
  85. nodes.push(match);
  86. return;
  87. }
  88. let arg: FormatArg = null;
  89. if (match[2]) {
  90. arg = (args[0] as ComponentMap)[match[2][0]];
  91. } else if (match[1]) {
  92. arg = args[parseInt(match[1], 10) - 1];
  93. } else {
  94. arg = args[cursor++];
  95. }
  96. // this points to a react element!
  97. if (isValidElement(arg)) {
  98. nodes.push(cloneElement(arg, {key: idx}));
  99. } else {
  100. // Not a react element, massage it so that sprintf.format can format it
  101. // for us. We make sure match[2] is null so that we do not go down the
  102. // object path, and we set match[1] to the first index and then pass an
  103. // array with two items in.
  104. match[2] = null;
  105. match[1] = 1;
  106. nodes.push(<Fragment key={idx++}>{sprintf.format([match], [null, arg])}</Fragment>);
  107. }
  108. });
  109. return nodes;
  110. }
  111. /**
  112. * Determine if any arguments include React elements.
  113. */
  114. function argsInvolveReact(args: FormatArg[]): boolean {
  115. if (args.some(isValidElement)) {
  116. return true;
  117. }
  118. if (args.length !== 1 || !args[0] || typeof args[0] !== 'object') {
  119. return false;
  120. }
  121. const componentMap = args[0] as ComponentMap;
  122. return Object.keys(componentMap).some(key => isValidElement(componentMap[key]));
  123. }
  124. /**
  125. * Parse template strings will be parsed into an array of TemplateSubvalue's,
  126. * this represents either a portion of the string, or a object with the group
  127. * key indicating the group to lookup the group value in.
  128. */
  129. type TemplateSubvalue = string | {group: string; id: string};
  130. /**
  131. * ParsedTemplate is a mapping of group names to Template Subvalue arrays.
  132. */
  133. type ParsedTemplate = {[group: string]: TemplateSubvalue[]};
  134. /**
  135. * ComponentMap maps template group keys to react node instances.
  136. *
  137. * NOTE: template group keys that include additional sub values (e.g.
  138. * [groupName:this string is the sub value]) will override the mapped react
  139. * nodes children prop.
  140. *
  141. * In the above example the component map of {groupName: <strong>text</strong>}
  142. * will be translated to `<strong>this string is the sub value</strong>`.
  143. */
  144. type ComponentMap = {[group: string]: React.ReactNode};
  145. /**
  146. * Parses a template string into groups.
  147. *
  148. * The top level group will be keyed as `root`. All other group names will have
  149. * been extracted from the template string.
  150. */
  151. export function parseComponentTemplate(template: string): ParsedTemplate {
  152. const parsed: ParsedTemplate = {};
  153. let groupId = 1;
  154. function process(startPos: number, group: string, inGroup: boolean) {
  155. const regex = /\[(.*?)(:|\])|\]/g;
  156. const buf: TemplateSubvalue[] = [];
  157. let satisfied = false;
  158. let match: ReturnType<typeof regex.exec>;
  159. let pos = (regex.lastIndex = startPos);
  160. // eslint-disable-next-line no-cond-assign
  161. while ((match = regex.exec(template)) !== null) {
  162. const substr = template.substring(pos, match.index);
  163. if (substr !== '') {
  164. buf.push(substr);
  165. }
  166. const [fullMatch, groupName, closeBraceOrValueSeparator] = match;
  167. if (fullMatch === ']') {
  168. if (inGroup) {
  169. satisfied = true;
  170. break;
  171. } else {
  172. pos = regex.lastIndex;
  173. continue;
  174. }
  175. }
  176. const currentGroupId = groupId.toString();
  177. groupId++;
  178. if (closeBraceOrValueSeparator === ']') {
  179. pos = regex.lastIndex;
  180. } else {
  181. pos = regex.lastIndex = process(regex.lastIndex, currentGroupId, true);
  182. }
  183. buf.push({group: groupName!, id: currentGroupId});
  184. }
  185. let endPos = regex.lastIndex;
  186. if (!satisfied) {
  187. const rest = template.substring(pos);
  188. if (rest) {
  189. buf.push(rest);
  190. }
  191. endPos = template.length;
  192. }
  193. parsed[group] = buf;
  194. return endPos;
  195. }
  196. process(0, 'root', false);
  197. return parsed;
  198. }
  199. /**
  200. * Renders a parsed template into a React tree given a ComponentMap to use for
  201. * the parsed groups.
  202. */
  203. export function renderTemplate(
  204. template: ParsedTemplate,
  205. components: ComponentMap
  206. ): React.ReactNode {
  207. let idx = 0;
  208. function renderGroup(name: string, id: string) {
  209. const children: React.ReactNode[] = [];
  210. const group = template[id] || [];
  211. for (const item of group) {
  212. if (typeof item === 'string') {
  213. children.push(<Fragment key={idx++}>{item}</Fragment>);
  214. } else {
  215. children.push(renderGroup(item.group, item.id));
  216. }
  217. }
  218. // In case we cannot find our component, we call back to an empty
  219. // span so that stuff shows up at least.
  220. let reference = components[name] ?? <Fragment key={idx++} />;
  221. if (!isValidElement(reference)) {
  222. reference = <Fragment key={idx++}>{reference}</Fragment>;
  223. }
  224. const element = reference as React.ReactElement;
  225. return children.length === 0
  226. ? cloneElement(element, {key: idx++})
  227. : cloneElement(element, {key: idx++}, children);
  228. }
  229. return <Fragment>{renderGroup('root', 'root')}</Fragment>;
  230. }
  231. /**
  232. * mark is used to debug translations by visually marking translated strings.
  233. *
  234. * NOTE: This is a no-op and will return the node if LOCALE_DEBUG is not
  235. * currently enabled. See setLocaleDebug and toggleLocaleDebug.
  236. */
  237. function mark<T extends React.ReactNode>(node: T): T {
  238. if (!LOCALE_DEBUG) {
  239. return node;
  240. }
  241. // TODO(epurkhiser): Explain why we manually create a react node and assign
  242. // the toString function. This could likely also use better typing, but will
  243. // require some understanding of reacts internal types.
  244. const proxy = {
  245. $$typeof: Symbol.for('react.element'),
  246. type: Symbol.for('react.fragment'),
  247. key: null,
  248. ref: null,
  249. props: {
  250. style: markerStyles,
  251. children: toArray(node),
  252. },
  253. _owner: null,
  254. _store: {},
  255. };
  256. proxy.toString = () => '✅' + node + '✅';
  257. // TODO(TS): Should proxy be created using `React.createElement`?
  258. return proxy as any as T;
  259. }
  260. /**
  261. * sprintf style string formatting. Does not handle translations.
  262. *
  263. * See the sprintf-js library [0] for specifics on the argument
  264. * parameterization format.
  265. *
  266. * [0]: https://github.com/alexei/sprintf.js
  267. */
  268. export function format(formatString: string, args: FormatArg[]): React.ReactNode {
  269. if (argsInvolveReact(args)) {
  270. return formatForReact(formatString, args);
  271. }
  272. return sprintf(formatString, ...args) as string;
  273. }
  274. /**
  275. * Translates a string to the current locale.
  276. *
  277. * See the sprintf-js library [0] for specifics on the argument
  278. * parameterization format.
  279. *
  280. * [0]: https://github.com/alexei/sprintf.js
  281. */
  282. export function gettext(string: string, ...args: FormatArg[]): string {
  283. const val: string = getClient().gettext(string);
  284. if (args.length === 0) {
  285. staticTranslations.add(val);
  286. return mark(val);
  287. }
  288. // XXX(ts): It IS possible to use gettext in such a way that it will return a
  289. // React.ReactNodeArray, however we currently rarely (if at all) use it in
  290. // this way, and usually just expect strings back.
  291. return mark(format(val, args) as string);
  292. }
  293. /**
  294. * Translates a singular and plural string to the current locale. Supports
  295. * argument parameterization, and will use the first argument as the counter to
  296. * determine which message to use.
  297. *
  298. * See the sprintf-js library [0] for specifics on the argument
  299. * parameterization format.
  300. *
  301. * [0]: https://github.com/alexei/sprintf.js
  302. */
  303. export function ngettext(singular: string, plural: string, ...args: FormatArg[]): string {
  304. let countArg = 0;
  305. if (args.length > 0) {
  306. countArg = Math.abs(args[0] as number) || 0;
  307. // `toLocaleString` will render `999` as `"999"` but `9999` as `"9,999"`.
  308. // This means that any call with `tn` or `ngettext` cannot use `%d` in the
  309. // codebase but has to use `%s`.
  310. //
  311. // This means a string is always being passed in as an argument, but
  312. // `sprintf-js` implicitly coerces strings that can be parsed as integers
  313. // into an integer.
  314. //
  315. // This would break under any locale that used different formatting and
  316. // other undesirable behaviors.
  317. if ((singular + plural).includes('%d')) {
  318. // eslint-disable-next-line no-console
  319. console.error(new Error('You should not use %d within tn(), use %s instead'));
  320. } else {
  321. args = [countArg.toLocaleString(), ...args.slice(1)];
  322. }
  323. }
  324. // XXX(ts): See XXX in gettext.
  325. return mark(format(getClient().ngettext(singular, plural, countArg), args) as string);
  326. }
  327. /**
  328. * special form of gettext where you can render nested react components in
  329. * template strings.
  330. *
  331. * ```jsx
  332. * gettextComponentTemplate('Welcome. Click [link:here]', {
  333. * root: <p/>,
  334. * link: <a href="#" />,
  335. * });
  336. * ```
  337. *
  338. * The root string is always called "root", the rest is prefixed with the name
  339. * in the brackets
  340. *
  341. * You may recursively nest additional groups within the grouped string values.
  342. */
  343. export function gettextComponentTemplate(
  344. template: string,
  345. components: ComponentMap
  346. ): JSX.Element {
  347. const parsedTemplate = parseComponentTemplate(getClient().gettext(template));
  348. return mark(renderTemplate(parsedTemplate, components) as JSX.Element);
  349. }
  350. /**
  351. * Helper over `gettextComponentTemplate` with a pre-populated `<code />` component that
  352. * is commonly used.
  353. */
  354. export function tctCode(template: string, components: ComponentMap = {}) {
  355. return gettextComponentTemplate(template, {code: <code />, ...components});
  356. }
  357. /**
  358. * Shorthand versions should primarily be used.
  359. */
  360. export {gettext as t, gettextComponentTemplate as tct, ngettext as tn};