gettext.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665
  1. /* ----------------------------------------------------------------------------
  2. | Copyright (c) Jupyter Development Team.
  3. | Distributed under the terms of the Modified BSD License.
  4. |
  5. | Base gettext.js implementation.
  6. | Copyright (c) Guillaume Potier.
  7. | Distributed under the terms of the Modified MIT License.
  8. | See: https://github.com/guillaumepotier/gettext.js
  9. |
  10. | Type definitions.
  11. | Copyright (c) Julien Crouzet and Florian Schwingenschlögl.
  12. | Distributed under the terms of the Modified MIT License.
  13. | See: https://github.com/DefinitelyTyped/DefinitelyTyped
  14. |----------------------------------------------------------------------------*/
  15. /**
  16. * A plural form function.
  17. */
  18. type PluralForm = (n: number) => number;
  19. /**
  20. * Metadata for a language pack.
  21. */
  22. interface IJsonDataHeader {
  23. /**
  24. * Language locale. Example: es_CO, es-CO.
  25. */
  26. language: string;
  27. /**
  28. * The domain of the translation, usually the normalized package name.
  29. * Example: "jupyterlab", "jupyterlab_git"
  30. */
  31. domain: string;
  32. /**
  33. * String describing the plural of the given language.
  34. * See: https://www.gnu.org/software/gettext/manual/html_node/Translating-plural-forms.html
  35. */
  36. pluralForms: string;
  37. }
  38. /**
  39. * Translatable string messages.
  40. */
  41. interface IJsonDataMessages {
  42. /**
  43. * Translation strings for a given msg_id.
  44. */
  45. [key: string]: string[] | IJsonDataHeader;
  46. }
  47. /**
  48. * Translatable string messages incluing metadata.
  49. */
  50. interface IJsonData extends IJsonDataMessages {
  51. /**
  52. * Metadata of the language bundle.
  53. */
  54. '': IJsonDataHeader;
  55. }
  56. /**
  57. * Configurable options for the Gettext constructor.
  58. */
  59. interface IOptions {
  60. /**
  61. * Language locale. Example: es_CO, es-CO.
  62. */
  63. locale?: string;
  64. /**
  65. * The domain of the translation, usually the normalized package name.
  66. * Example: "jupyterlab", "jupyterlab_git"
  67. */
  68. domain?: string;
  69. /**
  70. * The delimiter to use when adding contextualized strings.
  71. */
  72. contextDelimiter?: string;
  73. /**
  74. * Translation message strings.
  75. */
  76. messages?: Array<string>;
  77. /**
  78. * String describing the plural of the given language.
  79. * See: https://www.gnu.org/software/gettext/manual/html_node/Translating-plural-forms.html
  80. */
  81. pluralForms?: string;
  82. /**
  83. * The string prefix to add to localized strings.
  84. */
  85. stringsPrefix?: string;
  86. /**
  87. * Plural form function.
  88. */
  89. pluralFunc?: PluralForm;
  90. }
  91. /**
  92. * Options of the main translation `t` method.
  93. */
  94. interface ITOptions {
  95. /**
  96. * String describing the plural of the given language.
  97. * See: https://www.gnu.org/software/gettext/manual/html_node/Translating-plural-forms.html
  98. */
  99. pluralForm?: string;
  100. /**
  101. * Plural form function.
  102. */
  103. pluralFunc?: PluralForm;
  104. /**
  105. * Language locale. Example: es_CO, es-CO.
  106. */
  107. locale?: string;
  108. }
  109. /**
  110. * Gettext class providing localization methods.
  111. */
  112. class Gettext {
  113. constructor(options?: IOptions) {
  114. options = options || {};
  115. // default values that could be overriden in Gettext() constructor
  116. this._defaults = {
  117. domain: 'messages',
  118. locale: document.documentElement.getAttribute('lang') || 'en',
  119. pluralFunc: function (n: number) {
  120. return { nplurals: 2, plural: n != 1 ? 1 : 0 };
  121. },
  122. contextDelimiter: String.fromCharCode(4), // \u0004
  123. stringsPrefix: ''
  124. };
  125. // Ensure the correct separator is used
  126. this._locale = (options.locale || this._defaults.locale).replace('_', '-');
  127. this._domain = options.domain || this._defaults.domain;
  128. this._contextDelimiter =
  129. options.contextDelimiter || this._defaults.contextDelimiter;
  130. this._stringsPrefix = options.stringsPrefix || this._defaults.stringsPrefix;
  131. this._pluralFuncs = {};
  132. this._dictionary = {};
  133. this._pluralForms = {};
  134. if (options.messages) {
  135. this._dictionary[this._domain] = {};
  136. this._dictionary[this._domain][this._locale] = options.messages;
  137. }
  138. if (options.pluralForms) {
  139. this._pluralForms[this._locale] = options.pluralForms;
  140. }
  141. }
  142. /**
  143. * Set current context delimiter.
  144. *
  145. * @param delimiter - The delimiter to set.
  146. */
  147. setContextDelimiter(delimiter: string): void {
  148. this._contextDelimiter = delimiter;
  149. }
  150. /**
  151. * Get current context delimiter.
  152. *
  153. * @return The current delimiter.
  154. */
  155. getContextDelimiter(): string {
  156. return this._contextDelimiter;
  157. }
  158. /**
  159. * Set current locale.
  160. *
  161. * @param locale - The locale to set.
  162. */
  163. setLocale(locale: string): void {
  164. this._locale = locale.replace('_', '-');
  165. }
  166. /**
  167. * Get current locale.
  168. *
  169. * @return The current locale.
  170. */
  171. getLocale(): string {
  172. return this._locale;
  173. }
  174. /**
  175. * Set current domain.
  176. *
  177. * @param domain - The domain to set.
  178. */
  179. setDomain(domain: string): void {
  180. this._domain = domain;
  181. }
  182. /**
  183. * Get current domain.
  184. *
  185. * @return The current domain string.
  186. */
  187. getDomain(): string {
  188. return this._domain;
  189. }
  190. /**
  191. * Set current strings prefix.
  192. *
  193. * @param prefix - The string prefix to set.
  194. */
  195. setStringsPrefix(prefix: string): void {
  196. this._stringsPrefix = prefix;
  197. }
  198. /**
  199. * Get current strings prefix.
  200. *
  201. * @return The strings prefix.
  202. */
  203. getStringsPrefix(): string {
  204. return this._stringsPrefix;
  205. }
  206. /**
  207. * `sprintf` equivalent, takes a string and some arguments to make a
  208. * computed string.
  209. *
  210. * @param fmt - The string to interpolate.
  211. * @param args - The variables to use in interpolation.
  212. *
  213. * ### Examples
  214. * strfmt("%1 dogs are in %2", 7, "the kitchen"); => "7 dogs are in the kitchen"
  215. * strfmt("I like %1, bananas and %1", "apples"); => "I like apples, bananas and apples"
  216. */
  217. static strfmt(fmt: string, ...args: any[]): string {
  218. return (
  219. fmt
  220. // put space after double % to prevent placeholder replacement of such matches
  221. .replace(/%%/g, '%% ')
  222. // replace placeholders
  223. .replace(/%(\d+)/g, function (str, p1) {
  224. return args[p1 - 1];
  225. })
  226. // replace double % and space with single %
  227. .replace(/%% /g, '%')
  228. );
  229. }
  230. /**
  231. * Load json translations strings (In Jed 2.x format).
  232. *
  233. * @param jsonData - The translation strings plus metadata.
  234. * @param domain - The translation domain, e.g. "jupyterlab".
  235. */
  236. loadJSON(jsonData: IJsonData, domain: string): void {
  237. if (
  238. !jsonData[''] ||
  239. !jsonData['']['language'] ||
  240. !jsonData['']['pluralForms']
  241. ) {
  242. throw new Error(
  243. `Wrong jsonData, it must have an empty key ("") with "language" and "pluralForms" information: ${jsonData}`
  244. );
  245. }
  246. let headers = jsonData[''];
  247. let jsonDataCopy = JSON.parse(JSON.stringify(jsonData));
  248. delete jsonDataCopy[''];
  249. this.setMessages(
  250. domain || this._defaults.domain,
  251. headers['language'],
  252. jsonDataCopy,
  253. headers['pluralForms']
  254. );
  255. }
  256. /**
  257. * Shorthand for gettext.
  258. *
  259. * @param msgid - The singular string to translate.
  260. * @param args - Any additional values to use with interpolation.
  261. *
  262. * @return A translated string if found, or the original string.
  263. *
  264. * ### Notes
  265. * This is not a private method (starts with an underscore) it is just
  266. * a shorter and standard way to call these methods.
  267. */
  268. __(msgid: string, ...args: any[]): string {
  269. return this.gettext(msgid, ...args);
  270. }
  271. /**
  272. * Shorthand for ngettext.
  273. *
  274. * @param msgid - The singular string to translate.
  275. * @param msgid_plural - The plural string to translate.
  276. * @param n - The number for pluralization.
  277. * @param args - Any additional values to use with interpolation.
  278. *
  279. * @return A translated string if found, or the original string.
  280. *
  281. * ### Notes
  282. * This is not a private method (starts with an underscore) it is just
  283. * a shorter and standard way to call these methods.
  284. */
  285. _n(msgid: string, msgid_plural: string, n: number, ...args: any[]): string {
  286. return this.ngettext(msgid, msgid_plural, n, ...args);
  287. }
  288. /**
  289. * Shorthand for pgettext.
  290. *
  291. * @param msgctxt - The message context.
  292. * @param msgid - The singular string to translate.
  293. * @param args - Any additional values to use with interpolation.
  294. *
  295. * @return A translated string if found, or the original string.
  296. *
  297. * ### Notes
  298. * This is not a private method (starts with an underscore) it is just
  299. * a shorter and standard way to call these methods.
  300. */
  301. _p(msgctxt: string, msgid: string, ...args: any[]): string {
  302. return this.pgettext(msgctxt, msgid, ...args);
  303. }
  304. /**
  305. * Shorthand for npgettext.
  306. *
  307. * @param msgctxt - The message context.
  308. * @param msgid - The singular string to translate.
  309. * @param msgid_plural - The plural string to translate.
  310. * @param n - The number for pluralization.
  311. * @param args - Any additional values to use with interpolation.
  312. *
  313. * @return A translated string if found, or the original string.
  314. *
  315. * ### Notes
  316. * This is not a private method (starts with an underscore) it is just
  317. * a shorter and standard way to call these methods.
  318. */
  319. _np(
  320. msgctxt: string,
  321. msgid: string,
  322. msgid_plural: string,
  323. n: number,
  324. ...args: any[]
  325. ): string {
  326. return this.npgettext(msgctxt, msgid, msgid_plural, n, ...args);
  327. }
  328. /**
  329. * Translate a singular string with extra interpolation values.
  330. *
  331. * @param msgid - The singular string to translate.
  332. * @param args - Any additional values to use with interpolation.
  333. *
  334. * @return A translated string if found, or the original string.
  335. */
  336. gettext(msgid: string, ...args: any[]): string {
  337. return this.dcnpgettext('', '', msgid, '', 0, ...args);
  338. }
  339. /**
  340. * Translate a plural string with extra interpolation values.
  341. *
  342. * @param msgid - The singular string to translate.
  343. * @param args - Any additional values to use with interpolation.
  344. *
  345. * @return A translated string if found, or the original string.
  346. */
  347. ngettext(
  348. msgid: string,
  349. msgid_plural: string,
  350. n: number,
  351. ...args: any[]
  352. ): string {
  353. return this.dcnpgettext('', '', msgid, msgid_plural, n, ...args);
  354. }
  355. /**
  356. * Translate a contextualized singular string with extra interpolation values.
  357. *
  358. * @param msgctxt - The message context.
  359. * @param msgid - The singular string to translate.
  360. * @param args - Any additional values to use with interpolation.
  361. *
  362. * @return A translated string if found, or the original string.
  363. *
  364. * ### Notes
  365. * This is not a private method (starts with an underscore) it is just
  366. * a shorter and standard way to call these methods.
  367. */
  368. pgettext(msgctxt: string, msgid: string, ...args: any[]): string {
  369. return this.dcnpgettext('', msgctxt, msgid, '', 0, ...args);
  370. }
  371. /**
  372. * Translate a contextualized plural string with extra interpolation values.
  373. *
  374. * @param msgctxt - The message context.
  375. * @param msgid - The singular string to translate.
  376. * @param msgid_plural - The plural string to translate.
  377. * @param n - The number for pluralization.
  378. * @param args - Any additional values to use with interpolation
  379. *
  380. * @return A translated string if found, or the original string.
  381. */
  382. npgettext(
  383. msgctxt: string,
  384. msgid: string,
  385. msgid_plural: string,
  386. n: number,
  387. ...args: any[]
  388. ): string {
  389. return this.dcnpgettext('', msgctxt, msgid, msgid_plural, n, ...args);
  390. }
  391. /**
  392. * Translate a singular string with extra interpolation values.
  393. *
  394. * @param domain - The translations domain.
  395. * @param msgctxt - The message context.
  396. * @param msgid - The singular string to translate.
  397. * @param msgid_plural - The plural string to translate.
  398. * @param n - The number for pluralization.
  399. * @param args - Any additional values to use with interpolation
  400. *
  401. * @return A translated string if found, or the original string.
  402. */
  403. dcnpgettext(
  404. domain: string,
  405. msgctxt: string,
  406. msgid: string,
  407. msgid_plural: string,
  408. n: number,
  409. ...args: any[]
  410. ): string {
  411. domain = domain || this._domain;
  412. let translation: Array<string>;
  413. let key: string = msgctxt
  414. ? msgctxt + this._contextDelimiter + msgid
  415. : msgid;
  416. let options: any = { pluralForm: false };
  417. let exist: boolean = false;
  418. let locale: string = this._locale;
  419. let locales = this.expandLocale(this._locale);
  420. for (let i in locales) {
  421. locale = locales[i];
  422. exist =
  423. this._dictionary[domain] &&
  424. this._dictionary[domain][locale] &&
  425. this._dictionary[domain][locale][key];
  426. // check condition are valid (.length)
  427. // because it's not possible to define both a singular and a plural form of the same msgid,
  428. // we need to check that the stored form is the same as the expected one.
  429. // if not, we'll just ignore the translation and consider it as not translated.
  430. if (msgid_plural) {
  431. exist = exist && this._dictionary[domain][locale][key].length > 1;
  432. } else {
  433. exist = exist && this._dictionary[domain][locale][key].length == 1;
  434. }
  435. if (exist) {
  436. // This ensures that a variation is used.
  437. options.locale = locale;
  438. break;
  439. }
  440. }
  441. if (!exist) {
  442. translation = [msgid];
  443. options.pluralFunc = this._defaults.pluralFunc;
  444. } else {
  445. translation = this._dictionary[domain][locale][key];
  446. }
  447. // Singular form
  448. if (!msgid_plural) {
  449. return this.t(translation, n, options, ...args);
  450. }
  451. // Plural one
  452. options.pluralForm = true;
  453. let value: Array<string> = exist ? translation : [msgid, msgid_plural];
  454. return this.t(value, n, options, ...args);
  455. }
  456. /**
  457. * Split a locale into parent locales. "es-CO" -> ["es-CO", "es"]
  458. *
  459. * @param locale - The locale string.
  460. *
  461. * @return An array of locales.
  462. */
  463. private expandLocale(locale: string): Array<string> {
  464. let locales: Array<string> = [locale];
  465. let i: number = locale.lastIndexOf('-');
  466. while (i > 0) {
  467. locale = locale.slice(0, i);
  468. locales.push(locale);
  469. i = locale.lastIndexOf('-');
  470. }
  471. return locales;
  472. }
  473. /**
  474. * Split a locale into parent locales. "es-CO" -> ["es-CO", "es"]
  475. *
  476. * @param pluralForm - Plural form string..
  477. * @return An function to compute plural forms.
  478. */
  479. private getPluralFunc(pluralForm: string): Function {
  480. // Plural form string regexp
  481. // taken from https://github.com/Orange-OpenSource/gettext.js/blob/master/lib.gettext.js
  482. // plural forms list available here http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html
  483. let pf_re = new RegExp(
  484. '^\\s*nplurals\\s*=\\s*[0-9]+\\s*;\\s*plural\\s*=\\s*(?:\\s|[-\\?\\|&=!<>+*/%:;n0-9_()])+'
  485. );
  486. if (!pf_re.test(pluralForm))
  487. throw new Error(
  488. Gettext.strfmt('The plural form "%1" is not valid', pluralForm)
  489. );
  490. // Careful here, this is a hidden eval() equivalent..
  491. // Risk should be reasonable though since we test the pluralForm through regex before
  492. // taken from https://github.com/Orange-OpenSource/gettext.js/blob/master/lib.gettext.js
  493. // TODO: should test if https://github.com/soney/jsep present and use it if so
  494. return new Function(
  495. 'n',
  496. 'let plural, nplurals; ' +
  497. pluralForm +
  498. ' return { nplurals: nplurals, plural: (plural === true ? 1 : (plural ? plural : 0)) };'
  499. );
  500. }
  501. /**
  502. * Remove the context delimiter from string.
  503. *
  504. * @param str - Translation string.
  505. * @return A translation string without context.
  506. */
  507. private removeContext(str: string): string {
  508. // if there is context, remove it
  509. if (str.indexOf(this._contextDelimiter) !== -1) {
  510. let parts = str.split(this._contextDelimiter);
  511. return parts[1];
  512. }
  513. return str;
  514. }
  515. /**
  516. * Proper translation function that handle plurals and directives.
  517. *
  518. * @param messages - List of translation strings.
  519. * @param n - The number for pluralization.
  520. * @param options - Translation options.
  521. * @param args - Any variables to interpolate.
  522. *
  523. * @return A translation string without context.
  524. *
  525. * ### Notes
  526. * Contains juicy parts of https://github.com/Orange-OpenSource/gettext.js/blob/master/lib.gettext.js
  527. */
  528. private t(
  529. messages: Array<string>,
  530. n: number,
  531. options: ITOptions,
  532. ...args: any[]
  533. ): string {
  534. // Singular is very easy, just pass dictionary message through strfmt
  535. if (!options.pluralForm)
  536. return (
  537. this._stringsPrefix +
  538. Gettext.strfmt(this.removeContext(messages[0]), ...args)
  539. );
  540. let plural;
  541. // if a plural func is given, use that one
  542. if (options.pluralFunc) {
  543. plural = options.pluralFunc(n);
  544. // if plural form never interpreted before, do it now and store it
  545. } else if (!this._pluralFuncs[options.locale || '']) {
  546. this._pluralFuncs[options.locale || ''] = this.getPluralFunc(
  547. this._pluralForms[options.locale || '']
  548. );
  549. plural = this._pluralFuncs[options.locale || ''](n);
  550. // we have the plural function, compute the plural result
  551. } else {
  552. plural = this._pluralFuncs[options.locale || ''](n);
  553. }
  554. // If there is a problem with plurals, fallback to singular one
  555. if (
  556. 'undefined' === typeof !plural.plural ||
  557. plural.plural > plural.nplurals ||
  558. messages.length <= plural.plural
  559. )
  560. plural.plural = 0;
  561. return (
  562. this._stringsPrefix +
  563. Gettext.strfmt(
  564. this.removeContext(messages[plural.plural]),
  565. ...[n].concat(args)
  566. )
  567. );
  568. }
  569. /**
  570. * Set messages after loading them.
  571. *
  572. * @param domain - The translation domain.
  573. * @param locale - The translation locale.
  574. * @param messages - List of translation strings.
  575. * @param pluralForms - Plural form string.
  576. *
  577. * ### Notes
  578. * Contains juicy parts of https://github.com/Orange-OpenSource/gettext.js/blob/master/lib.gettext.js
  579. */
  580. private setMessages(
  581. domain: string,
  582. locale: string,
  583. messages: IJsonDataMessages,
  584. pluralForms: string
  585. ): void {
  586. if (pluralForms) this._pluralForms[locale] = pluralForms;
  587. if (!this._dictionary[domain]) this._dictionary[domain] = {};
  588. this._dictionary[domain][locale] = messages;
  589. }
  590. private _stringsPrefix: string;
  591. private _pluralForms: any;
  592. private _dictionary: any;
  593. private _locale: string;
  594. private _domain: string;
  595. private _contextDelimiter: string;
  596. private _pluralFuncs: any;
  597. private _defaults: any;
  598. }
  599. export { Gettext };