locale.tsx 10 KB

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