locale.tsx 11 KB

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