Source: lib/messageformat.js

var Compiler = require('./compiler');
var formatters = require('./formatters');
var Plurals = require('./plurals');
var Runtime = require('./runtime');


/**
 * Create a new MessageFormat compiler
 *
 * If set, the `locale` parameter limits the compiler to use a subset of the 204
 * languages' pluralisation rules made available by the Unicode CLDR.
 *
 * Leaving `locale` undefined or using an array of strings will create a
 * MessageFormat instance with multi-language support. To select which to use,
 * use the second parameter of `{@link MessageFormat#compile compile()}`, or use
 * message keys corresponding to your locales. The default locale will be the
 * first entry of the array, or `{@link MessageFormat.defaultLocale defaultLocale}`
 * if not set.
 *
 * A string `locale` will create a single-locale MessageFormat instance.
 *
 * Using an object `locale` with all properties of type `function` allows for
 * the use of custom or externally defined pluralisation rules; in this case
 *
 * @class
 * @param {string|string[]|Object} [locale] - The locale(s) to use
 *
 * @classdesc MessageFormat-to-JavaScript compiler
 *
 * ```
 * import MessageFormat from 'messageformat'
 * ```
 */
function MessageFormat(locale) {
  this.pluralFuncs = {};
  if (typeof locale === 'string') {
    this.pluralFuncs[locale] = Plurals.get(locale);
    this.defaultLocale = locale;
  } else if (Array.isArray(locale)) {
    locale.forEach(function(lc) {
      this.pluralFuncs[lc] = Plurals.get(lc);
    }, this);
    this.defaultLocale = locale[0];
  } else {
    for (var lc in locale) if (locale.hasOwnProperty(lc)) {
      if (typeof locale[lc] !== 'function') {
        throw new Error('Expected function value for locale ' + JSON.stringify(lc));
      }
      this.pluralFuncs[lc] = locale[lc];
      if (!this.defaultLocale) this.defaultLocale = lc;
    }
    if (this.defaultLocale) {
      this.hasCustomPluralFuncs = true;
    } else {
      this.defaultLocale = MessageFormat.defaultLocale;
    }
  }
  this.fmt = {};
  this.runtime = new Runtime(this);
}


/**
 * The default locale
 *
 * Used by the constructor when no `locale` has been set to initialise the value
 * of its instance counterpart, `MessageFormat#defaultLocale`.
 *
 * @memberof MessageFormat
 * @default 'en'
 */
MessageFormat.defaultLocale = 'en';


/** Escape special characaters
 *
 *  Surround the characters `{` and `}` in the input string with 'quotes'.
 *  This will allow those characters to not be considered as MessageFormat
 *  control characters.
 *
 * @param {string} str - The input string
 * @param {boolean} [octothorpe=false] - Include `#` in the escaped characters
 * @returns {string} The escaped string
 */
MessageFormat.escape = function(str, octothorpe) {
  var esc = octothorpe ? '[#{}]' : '[{}]';
  return String(str).replace(new RegExp(esc, 'g'), "'$&'");
}


MessageFormat.formatters = formatters;

/**
 * Add custom formatter functions to this MessageFormat instance. See the
 * {@page guide} for more details.
 *
 * The general syntax for calling a formatting function in MessageFormat is
 * `{var, fn[, arg]}`, where `var` is the variable that will be set by the
 * user code, `fn` determines the formatting function, and `arg` is an
 * optional string argument.
 *
 * In JavaScript, each formatting function is called with three parameters;
 * the variable value `v`, the current locale `lc`, and `arg` as a string, or
 * undefined if not set. `arg` will be trimmed of surrounding whitespace.
 * Formatting functions should not have side effects.
 *
 * @memberof MessageFormat
 * @param {Object.<string,function>} fmt - A map of formatting functions
 * @returns {MessageFormat} The MessageFormat instance, to allow for chaining
 *
 * @example
 * const mf = new MessageFormat('en-GB')
 * mf.addFormatters({
 *   upcase: function(v) { return v.toUpperCase() },
 *   locale: function(v, lc) { return lc },
 *   prop: function(v, lc, p) { return v[p] }
 * })
 * const messages = mf.compile({
 *   describe: 'This is {VAR, upcase}.',
 *   locale: 'The current locale is {_, locale}.',
 *   answer: 'Answer: {obj, prop, a}'
 * }
 *
 * messages.describe({ VAR: 'big' })        // 'This is BIG.'
 * messages.locale({})                      // 'The current locale is en-GB.'
 * messages.answer({ obj: {q: 3, a: 42} })  // 'Answer: 42'
 */
MessageFormat.prototype.addFormatters = function(fmt) {
  for (var name in fmt) if (fmt.hasOwnProperty(name)) {
    this.fmt[name] = fmt[name];
  }
  return this;
};


/** Disable the validation of plural & selectordinal keys
 *
 *  Previous versions of messageformat allowed the use of plural &
 *  selectordinal statements with any keys; now we throw an error when a
 *  statement uses a non-numerical key that will never be matched as a
 *  pluralization category for the current locale.
 *
 *  Use this method to disable the validation and allow usage as previously.
 *  To re-enable, you'll need to create a new MessageFormat instance.
 *
 * @returns {MessageFormat} The MessageFormat instance, to allow for chaining
 *
 * @example
 * const mf = new MessageFormat('en')
 * const msg = '{X, plural, zero{no answers} one{an answer} other{# answers}}'
 *
 * mf.compile(msg)
 * // Error: Invalid key `zero` for argument `X`. Valid plural keys for this
 * //        locale are `one`, `other`, and explicit keys like `=0`.
 *
 * mf.disablePluralKeyChecks()
 * mf.compile(msg)({ X: 0 })  // '0 answers'
 */
MessageFormat.prototype.disablePluralKeyChecks = function() {
  this.noPluralKeyChecks = true;
  for (var lc in this.pluralFuncs) if (this.pluralFuncs.hasOwnProperty(lc)) {
    this.pluralFuncs[lc].cardinal = [];
    this.pluralFuncs[lc].ordinal = [];
  }
  return this;
};


/** Enable or disable the addition of Unicode control characters to all input
 *  to preserve the integrity of the output when mixing LTR and RTL text.
 *
 * @see http://cldr.unicode.org/development/development-process/design-proposals/bidi-handling-of-structured-text
 *
 * @memberof MessageFormat
 * @param {boolean} [enable=true]
 * @returns {MessageFormat} The MessageFormat instance, to allow for chaining
 *
 * @example
 * // upper case stands for RTL characters, output is shown as rendered
 * const mf = new MessageFormat('en')
 *
 * mf.compile('{0} >> {1} >> {2}')(['first', 'SECOND', 'THIRD'])
 *   // 'first >> THIRD << SECOND'
 *
 * mf.setBiDiSupport(true)
 * mf.compile('{0} >> {1} >> {2}')(['first', 'SECOND', 'THIRD'])
 *   // 'first >> SECOND >> THIRD'
 */
MessageFormat.prototype.setBiDiSupport = function(enable) {
    this.bidiSupport = !!enable || (typeof enable == 'undefined');
    return this;
};


/** According to the ICU MessageFormat spec, a `#` character directly inside a
 *  `plural` or `selectordinal` statement should be replaced by the number
 *  matching the surrounding statement. By default, messageformat will replace
 *  `#` signs with the value of the nearest surrounding `plural` or
 *  `selectordinal` statement.
 *
 *  Set this to true to follow the stricter ICU MessageFormat spec, and to
 *  throw a runtime error if `#` is used with non-numeric input.
 *
 * @memberof MessageFormat
 * @param {boolean} [enable=true]
 * @returns {MessageFormat} The MessageFormat instance, to allow for chaining
 *
 * @example
 * const mf = new MessageFormat('en')
 * const src = {
 *   cookie: '#: {X, plural, =0{no cookies} one{a cookie} other{# cookies}}',
 *   pastry: `{X, plural,
 *     one {{P, select, cookie{a cookie} other{a pie}}}
 *     other {{P, select, cookie{# cookies} other{# pies}}}
 *   }`
 * }
 * let messages = mf.compile(src)
 *
 * messages.cookie({ X: 3 })            // '#: 3 cookies'
 * messages.pastry({ X: 3, P: 'pie' })  // '3 pies'
 *
 * mf.setStrictNumberSign(true)
 * messages = mf.compile(src)
 * messages.pastry({ X: 3, P: 'pie' })  // '# pies'
 */
MessageFormat.prototype.setStrictNumberSign = function(enable) {
    this.strictNumberSign = !!enable || (typeof enable == 'undefined');
    this.runtime.setStrictNumber(this.strictNumberSign);
    return this;
};


/** Compile messages into storable functions
 *
 *  If `messages` is a single string including ICU MessageFormat declarations,
 *  the result of `compile()` is a function taking a single Object parameter
 *  `d` representing each of the input's defined variables.
 *
 *  If `messages` is a hierarchical structure of such strings, the output of
 *  `compile()` will match that structure, with each string replaced by its
 *  corresponding JavaScript function.
 *
 *  If the input `messages` -- and therefore the output -- of `compile()` is an
 *  object, the output object will have a `toString(global)` method that may be
 *  used to store or cache the compiled functions to disk, for later inclusion
 *  in any JS environment, without a local MessageFormat instance required. If
 *  its `global` parameter is null or undefined, the result is an ES6 module
 *  with a default export. If `global` is a string containing `.`, the result
 *  will be a script setting its value. Otherwise, the output defaults to an UMD
 *  pattern that sets the value of `global` if used outside of AMD and CommonJS
 *  loaders.
 *
 *  If `locale` is not set, it will default to
 *  `{@link MessageFormat.defaultLocale defaultLocale}`; using a key at any
 *  depth of `messages` that is a declared locale will set its child elements to
 *  use that locale.
 *
 *  If `locale` is set, it is used for all messages, ignoring any otherwise
 *  matching locale keys. If the constructor declared any locales, `locale`
 *  needs to be one of them.
 *
 *  If `compile()` is called with a `messages` object on a MessageFormat
 *  instance that does not specify any locales, it will match keys to *all* 204
 *  available locales. This is really useful if you want your messages to be
 *  completely determined by your data, but may provide surprising results if
 *  your input includes any 2-3 letter strings that are not locale identifiers.
 *
 * @memberof MessageFormat
 * @param {string|Object} messages - The input message(s) to be compiled, in ICU MessageFormat
 * @param {string} [locale] - A locale to use for the messages
 * @returns {function|Object} The first match found for the given locale(s)
 *
 * @example
 * const mf = new MessageFormat('en')
 * const msg = mf.compile('A {TYPE} example.')
 *
 * msg({ TYPE: 'simple' })  // 'A simple example.'
 *
 * @example
 * const mf = new MessageFormat(['en', 'fi'])
 * const messages = mf.compile({
 *   en: { a: 'A {TYPE} example.',
 *         b: 'This is the {COUNT, selectordinal, one{#st} two{#nd} few{#rd} other{#th}} example.' },
 *   fi: { a: '{TYPE} esimerkki.',
 *         b: 'Tämä on {COUNT, selectordinal, other{#.}} esimerkki.' }
 * })
 *
 * messages.en.b({ COUNT: 2 })  // 'This is the 2nd example.'
 * messages.fi.b({ COUNT: 2 })  // 'Tämä on 2. esimerkki.'
 *
 * @example
 * const fs = require('fs')
 * const mf = new MessageFormat('en')
 * const msgSet = {
 *   a: 'A {TYPE} example.',
 *   b: 'This has {COUNT, plural, one{one member} other{# members}}.',
 *   c: 'We have {P, number, percent} code coverage.'
 * }
 * const msgStr = mf.compile(msgSet).toString('module.exports')
 * fs.writeFileSync('messages.js', msgStr)
 *
 * ...
 *
 * const messages = require('./messages')
 *
 * messages.a({ TYPE: 'more complex' })  // 'A more complex example.'
 * messages.b({ COUNT: 3 })              // 'This has 3 members.'
 */
MessageFormat.prototype.compile = function(messages, locale) {
  function _stringify(obj, level) {
    if (!level) level = 0;
    if (typeof obj != 'object') return obj;
    var o = [], indent = '';
    for (var i = 0; i < level; ++i) indent += '  ';
    for (var k in obj) o.push('\n' + indent + '  ' + Compiler.propname(k) + ': ' + _stringify(obj[k], level + 1));
    return '{' + o.join(',') + '\n' + indent + '}';
  }

  var pf;
  if (Object.keys(this.pluralFuncs).length == 0) {
    if (!locale) locale = this.defaultLocale;
    pf = Plurals.getAll(this.noPluralKeyChecks);
  } else if (locale) {
    pf = {};
    pf[locale] = this.pluralFuncs[locale];
    if (!pf[locale]) throw new Error('Locale ' + JSON.stringify(locale) + ' not found in ' + JSON.stringify(this.pluralFuncs) + '!');
  } else {
    pf = this.pluralFuncs;
    locale = this.defaultLocale;
  }

  var compiler = new Compiler(this);
  var obj = compiler.compile(messages, locale, pf);

  if (typeof messages != 'object') {
    var fn = new Function(
        'number, plural, select, fmt', Compiler.funcname(locale),
        'return ' + obj);
    var rt = this.runtime;
    return fn(rt.number, rt.plural, rt.select, this.fmt, pf[locale]);
  }

  var rtStr = this.runtime.toString(pf, compiler) + '\n';
  var objStr = _stringify(obj);
  var result = new Function(rtStr + 'return ' + objStr)();
  if (result.hasOwnProperty('toString')) throw new Error('The top-level message key `toString` is reserved');

  result.toString = function(global) {
    if (!global || global === 'export default') {
      return rtStr + 'export default ' + objStr;
    } else if (global.indexOf('.') > -1) {
      return rtStr + global + ' = ' + objStr;
    } else {
      return rtStr + [
        '(function (root, G) {',
        '  if (typeof define === "function" && define.amd) { define(G); }',
        '  else if (typeof exports === "object") { module.exports = G; }',
        '  else { ' + Compiler.propname(global, 'root') + ' = G; }',
        '})(this, ' + objStr + ');'
      ].join('\n');
    }
  }
  return result;
}

module.exports = MessageFormat;