Translator.js 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. const has = require('./hasProperty')
  2. /**
  3. * Translates strings with interpolation & pluralization support.
  4. * Extensible with custom dictionaries and pluralization functions.
  5. *
  6. * Borrows heavily from and inspired by Polyglot https://github.com/airbnb/polyglot.js,
  7. * basically a stripped-down version of it. Differences: pluralization functions are not hardcoded
  8. * and can be easily added among with dictionaries, nested objects are used for pluralization
  9. * as opposed to `||||` delimeter
  10. *
  11. * Usage example: `translator.translate('files_chosen', {smart_count: 3})`
  12. */
  13. module.exports = class Translator {
  14. /**
  15. * @param {object|Array<object>} locales - locale or list of locales.
  16. */
  17. constructor (locales) {
  18. this.locale = {
  19. strings: {},
  20. pluralize (n) {
  21. if (n === 1) {
  22. return 0
  23. }
  24. return 1
  25. },
  26. }
  27. if (Array.isArray(locales)) {
  28. locales.forEach((locale) => this._apply(locale))
  29. } else {
  30. this._apply(locales)
  31. }
  32. }
  33. _apply (locale) {
  34. if (!locale || !locale.strings) {
  35. return
  36. }
  37. const prevLocale = this.locale
  38. this.locale = { ...prevLocale, strings: { ...prevLocale.strings, ...locale.strings } }
  39. this.locale.pluralize = locale.pluralize || prevLocale.pluralize
  40. }
  41. /**
  42. * Takes a string with placeholder variables like `%{smart_count} file selected`
  43. * and replaces it with values from options `{smart_count: 5}`
  44. *
  45. * @license https://github.com/airbnb/polyglot.js/blob/master/LICENSE
  46. * taken from https://github.com/airbnb/polyglot.js/blob/master/lib/polyglot.js#L299
  47. *
  48. * @param {string} phrase that needs interpolation, with placeholders
  49. * @param {object} options with values that will be used to replace placeholders
  50. * @returns {any[]} interpolated
  51. */
  52. interpolate (phrase, options) {
  53. const { split, replace } = String.prototype
  54. const dollarRegex = /\$/g
  55. const dollarBillsYall = '$$$$'
  56. let interpolated = [phrase]
  57. for (const arg in options) {
  58. if (arg !== '_' && has(options, arg)) {
  59. // Ensure replacement value is escaped to prevent special $-prefixed
  60. // regex replace tokens. the "$$$$" is needed because each "$" needs to
  61. // be escaped with "$" itself, and we need two in the resulting output.
  62. var replacement = options[arg]
  63. if (typeof replacement === 'string') {
  64. replacement = replace.call(options[arg], dollarRegex, dollarBillsYall)
  65. }
  66. // We create a new `RegExp` each time instead of using a more-efficient
  67. // string replace so that the same argument can be replaced multiple times
  68. // in the same phrase.
  69. interpolated = insertReplacement(interpolated, new RegExp(`%\\{${arg}\\}`, 'g'), replacement)
  70. }
  71. }
  72. return interpolated
  73. function insertReplacement (source, rx, replacement) {
  74. const newParts = []
  75. source.forEach((chunk) => {
  76. // When the source contains multiple placeholders for interpolation,
  77. // we should ignore chunks that are not strings, because those
  78. // can be JSX objects and will be otherwise incorrectly turned into strings.
  79. // Without this condition we’d get this: [object Object] hello [object Object] my <button>
  80. if (typeof chunk !== 'string') {
  81. return newParts.push(chunk)
  82. }
  83. split.call(chunk, rx).forEach((raw, i, list) => {
  84. if (raw !== '') {
  85. newParts.push(raw)
  86. }
  87. // Interlace with the `replacement` value
  88. if (i < list.length - 1) {
  89. newParts.push(replacement)
  90. }
  91. })
  92. })
  93. return newParts
  94. }
  95. }
  96. /**
  97. * Public translate method
  98. *
  99. * @param {string} key
  100. * @param {object} options with values that will be used later to replace placeholders in string
  101. * @returns {string} translated (and interpolated)
  102. */
  103. translate (key, options) {
  104. return this.translateArray(key, options).join('')
  105. }
  106. /**
  107. * Get a translation and return the translated and interpolated parts as an array.
  108. *
  109. * @param {string} key
  110. * @param {object} options with values that will be used to replace placeholders
  111. * @returns {Array} The translated and interpolated parts, in order.
  112. */
  113. translateArray (key, options) {
  114. if (!has(this.locale.strings, key)) {
  115. throw new Error(`missing string: ${key}`)
  116. }
  117. const string = this.locale.strings[key]
  118. const hasPluralForms = typeof string === 'object'
  119. if (hasPluralForms) {
  120. if (options && typeof options.smart_count !== 'undefined') {
  121. const plural = this.locale.pluralize(options.smart_count)
  122. return this.interpolate(string[plural], options)
  123. }
  124. throw new Error('Attempted to use a string with plural forms, but no value was given for %{smart_count}')
  125. }
  126. return this.interpolate(string, options)
  127. }
  128. }