UNPKG

18.9 kBJavaScriptView Raw
1/** @file messageformat.js - ICU PluralFormat + SelectFormat for JavaScript
2 *
3 * @author Alex Sexton - @SlexAxton, Eemeli Aro
4 * @version 1.0.1
5 * @copyright 2012-2016 Alex Sexton, Eemeli Aro, and Contributors
6 * @license To use or fork, MIT. To contribute back, Dojo CLA
7 */
8
9var Compiler = require('./compiler');
10var Runtime = require('./runtime');
11
12
13/** Utility getter/wrapper for pluralization functions from
14 * {@link http://github.com/eemeli/make-plural.js make-plural}
15 *
16 * @private
17 */
18function getPluralFunc(locale, noPluralKeyChecks) {
19 var plurals = require('make-plural/plurals');
20 var pluralCategories = require('make-plural/pluralCategories');
21 for (var l = locale; l; l = l.replace(/[-_]?[^-_]*$/, '')) {
22 var pf = plurals[l];
23 if (pf) {
24 var pc = noPluralKeyChecks ? { cardinal: [], ordinal: [] } : (pluralCategories[l] || {});
25 var fn = function() { return pf.apply(this, arguments); };
26 fn.toString = function() { return pf.toString(); };
27 fn.cardinal = pc.cardinal;
28 fn.ordinal = pc.ordinal;
29 return fn;
30 }
31 }
32 throw new Error('Localisation function not found for locale ' + JSON.stringify(locale));
33}
34
35
36/** Create a new message formatter
37 *
38 * If `locale` is not set, calls to `compile()` will fetch the default locale
39 * each time. A string `locale` will create a single-locale MessageFormat
40 * instance, with pluralisation rules fetched from the Unicode CLDR using
41 * {@link http://github.com/eemeli/make-plural.js make-plural}.
42 *
43 * Using an array of strings as `locale` will create a MessageFormat object
44 * with multi-language support, with pluralisation rules fetched as above. To
45 * select which to use, use the second parameter of `compile()`, or use message
46 * keys corresponding to your locales.
47 *
48 * Using an object `locale` with all properties of type `function` allows for
49 * the use of custom/externally defined pluralisation rules.
50 *
51 * @class
52 * @param {string|string[]|Object.<string,function>} [locale] - The locale(s) to use
53 */
54function MessageFormat(locale) {
55 this.pluralFuncs = {};
56 if (locale) {
57 if (typeof locale == 'string') {
58 this.pluralFuncs[locale] = getPluralFunc(locale);
59 } else if (Array.isArray(locale)) {
60 locale.forEach(function(lc) { this.pluralFuncs[lc] = getPluralFunc(lc); }, this);
61 } else if (typeof locale == 'object') {
62 for (var lc in locale) if (locale.hasOwnProperty(lc)) {
63 if (typeof locale[lc] != 'function') throw new Error('Expected function value for locale ' + JSON.stringify(lc));
64 this.pluralFuncs[lc] = locale[lc];
65 }
66 }
67 }
68 this.fmt = {};
69 this.runtime = new Runtime(this);
70}
71
72
73/** The default locale
74 *
75 * Read by `compile()` when no locale has been previously set
76 *
77 * @memberof MessageFormat
78 * @default 'en'
79 */
80MessageFormat.defaultLocale = 'en';
81
82
83/** Escape special characaters
84 *
85 * Prefix the characters `#`, `{`, `}` and `\` in the input string with a `\`.
86 * This will allow those characters to not be considered as MessageFormat
87 * control characters.
88 *
89 * @param {string} str - The input string
90 * @returns {string} The escaped string
91 */
92MessageFormat.escape = function(str) {
93 return str.replace(/[#{}\\]/g, '\\$&');
94}
95
96
97/** Default number formatting functions in the style of ICU's
98 * {@link http://icu-project.org/apiref/icu4j/com/ibm/icu/text/MessageFormat.html simpleArg syntax}
99 * implemented using the
100 * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl Intl}
101 * object defined by ECMA-402.
102 *
103 * **Note**: Intl is not defined in default Node until 0.11.15 / 0.12.0, so
104 * earlier versions require a {@link https://www.npmjs.com/package/intl polyfill}.
105 * Therefore {@link MessageFormat.intlSupport} needs to be true for these default
106 * functions to be available for inclusion in the output.
107 *
108 * @see MessageFormat#setIntlSupport
109 *
110 * @namespace
111 */
112MessageFormat.formatters = {
113
114
115 /** Represent a number as an integer, percent or currency value
116 *
117 * Available in MessageFormat strings as `{VAR, number, integer|percent|currency}`.
118 * Internally, calls Intl.NumberFormat with appropriate parameters. `currency` will
119 * default to USD; to change, set `MessageFormat#currency` to the appropriate
120 * three-letter currency code.
121 *
122 * @param {number} value - The value to operate on
123 * @param {string} type - One of `'integer'`, `'percent'` , or `currency`
124 *
125 * @example
126 * var mf = new MessageFormat('en').setIntlSupport(true);
127 * mf.currency = 'EUR'; // needs to be set before first compile() call
128 *
129 * mf.compile('{N} is almost {N, number, integer}')({ N: 3.14 })
130 * // '3.14 is almost 3'
131 *
132 * mf.compile('{P, number, percent} complete')({ P: 0.99 })
133 * // '99% complete'
134 *
135 * mf.compile('The total is {V, number, currency}.')({ V: 5.5 })
136 * // 'The total is €5.50.'
137 */
138 number: function(self) {
139 return new Function("v,lc,p",
140 "return new Intl.NumberFormat(lc,\n" +
141 " p=='integer' ? {maximumFractionDigits:0}\n" +
142 " : p=='percent' ? {style:'percent'}\n" +
143 " : p=='currency' ? {style:'currency', currency:'" + (self.currency || 'USD') + "', minimumFractionDigits:2, maximumFractionDigits:2}\n" +
144 " : {}).format(v)"
145 );
146 },
147
148
149 /** Represent a date as a short/default/long/full string
150 *
151 * The input value needs to be in a form that the
152 * {@link https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Date Date object}
153 * can process using its single-argument form, `new Date(value)`.
154 *
155 * @param {number|string} value - Either a Unix epoch time in milliseconds, or a string value representing a date
156 * @param {string} [type='default'] - One of `'short'`, `'default'`, `'long'` , or `full`
157 *
158 * @example
159 * var mf = new MessageFormat(['en', 'fi']).setIntlSupport(true);
160 *
161 * mf.compile('Today is {T, date}')({ T: Date.now() })
162 * // 'Today is Feb 21, 2016'
163 *
164 * mf.compile('Tänään on {T, date}', 'fi')({ T: Date.now() })
165 * // 'Tänään on 21. helmikuuta 2016'
166 *
167 * mf.compile('Unix time started on {T, date, full}')({ T: 0 })
168 * // 'Unix time started on Thursday, January 1, 1970'
169 *
170 * var cf = mf.compile('{sys} became operational on {d0, date, short}');
171 * cf({ sys: 'HAL 9000', d0: '12 January 1999' })
172 * // 'HAL 9000 became operational on 1/12/1999'
173 */
174 date: function(v,lc,p) {
175 var o = {day:'numeric', month:'short', year:'numeric'};
176 switch (p) {
177 case 'full': o.weekday = 'long';
178 case 'long': o.month = 'long'; break;
179 case 'short': o.month = 'numeric';
180 }
181 return (new Date(v)).toLocaleDateString(lc, o)
182 },
183
184
185 /** Represent a time as a short/default/long string
186 *
187 * The input value needs to be in a form that the
188 * {@link https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Date Date object}
189 * can process using its single-argument form, `new Date(value)`.
190 *
191 * @param {number|string} value - Either a Unix epoch time in milliseconds, or a string value representing a date
192 * @param {string} [type='default'] - One of `'short'`, `'default'`, `'long'` , or `full`
193 *
194 * @example
195 * var mf = new MessageFormat(['en', 'fi']).setIntlSupport(true);
196 *
197 * mf.compile('The time is now {T, time}')({ T: Date.now() })
198 * // 'The time is now 11:26:35 PM'
199 *
200 * mf.compile('Kello on nyt {T, time}', 'fi')({ T: Date.now() })
201 * // 'Kello on nyt 23.26.35'
202 *
203 * var cf = mf.compile('The Eagle landed at {T, time, full} on {T, date, full}');
204 * cf({ T: '1969-07-20 20:17:40 UTC' })
205 * // 'The Eagle landed at 10:17:40 PM GMT+2 on Sunday, July 20, 1969'
206 */
207 time: function(v,lc,p) {
208 var o = {second:'numeric', minute:'numeric', hour:'numeric'};
209 switch (p) {
210 case 'full': case 'long': o.timeZoneName = 'short'; break;
211 case 'short': delete o.second;
212 }
213 return (new Date(v)).toLocaleTimeString(lc, o)
214 }
215};
216
217
218/** Add custom formatter functions to this MessageFormat instance
219 *
220 * The general syntax for calling a formatting function in MessageFormat is
221 * `{var, fn[, args]*}`, where `var` is the variable that will be set by the
222 * user code, `fn` determines the formatting function, and `args` is an
223 * optional comma-separated list of additional arguments.
224 *
225 * In JavaScript, each formatting function is called with three parameters;
226 * the variable value `v`, the current locale `lc`, and (if set) `args` as a
227 * single string, or an array of strings. Formatting functions should not have
228 * side effects.
229 *
230 * @see MessageFormat.formatters
231 *
232 * @memberof MessageFormat
233 * @param {Object.<string,function>} fmt - A map of formatting functions
234 * @returns {MessageFormat} The MessageFormat instance, to allow for chaining
235 *
236 * @example
237 * var mf = new MessageFormat('en-GB');
238 * mf.addFormatters({
239 * upcase: function(v) { return v.toUpperCase(); },
240 * locale: function(v, lc) { return lc; },
241 * prop: function(v, lc, p) { return v[p] }
242 * });
243 *
244 * mf.compile('This is {VAR, upcase}.')({ VAR: 'big' })
245 * // 'This is BIG.'
246 *
247 * mf.compile('The current locale is {_, locale}.')({ _: '' })
248 * // 'The current locale is en-GB.'
249 *
250 * mf.compile('Answer: {obj, prop, a}')({ obj: {q: 3, a: 42} })
251 * // 'Answer: 42'
252 */
253MessageFormat.prototype.addFormatters = function(fmt) {
254 for (var name in fmt) if (fmt.hasOwnProperty(name)) {
255 this.fmt[name] = fmt[name];
256 }
257 return this;
258};
259
260
261/** Disable the validation of plural & selectordinal keys
262 *
263 * Previous versions of messageformat.js allowed the use of plural &
264 * selectordinal statements with any keys; now we throw an error when a
265 * statement uses a non-numerical key that will never be matched as a
266 * pluralization category for the current locale.
267 *
268 * Use this method to disable the validation and allow usage as previously.
269 * To re-enable, you'll need to create a new MessageFormat instance.
270 *
271 * @returns {MessageFormat} The MessageFormat instance, to allow for chaining
272 *
273 * @example
274 * var mf = new MessageFormat('en');
275 * var msg = '{X, plural, zero{no answers} one{an answer} other{# answers}}';
276 *
277 * mf.compile(msg);
278 * // Error: Invalid key `zero` for argument `X`. Valid plural keys for this
279 * // locale are `one`, `other`, and explicit keys like `=0`.
280 *
281 * mf.disablePluralKeyChecks();
282 * mf.compile(msg)({ X: 0 });
283 * // '0 answers'
284 */
285MessageFormat.prototype.disablePluralKeyChecks = function() {
286 this.noPluralKeyChecks = true;
287 for (var lc in this.pluralFuncs) if (this.pluralFuncs.hasOwnProperty(lc)) {
288 this.pluralFuncs[lc].cardinal = [];
289 this.pluralFuncs[lc].ordinal = [];
290 }
291 return this;
292};
293
294
295/** Enable or disable the addition of Unicode control characters to all input
296 * to preserve the integrity of the output when mixing LTR and RTL text.
297 *
298 * @see http://cldr.unicode.org/development/development-process/design-proposals/bidi-handling-of-structured-text
299 *
300 * @memberof MessageFormat
301 * @param {boolean} [enable=true]
302 * @returns {MessageFormat} The MessageFormat instance, to allow for chaining
303 *
304 * @example
305 * // upper case stands for RTL characters, output is shown as rendered
306 * var mf = new MessageFormat('en');
307 *
308 * mf.compile('{0} >> {1} >> {2}')(['first', 'SECOND', 'THIRD']);
309 * // 'first >> THIRD << SECOND'
310 *
311 * mf.setBiDiSupport(true);
312 * mf.compile('{0} >> {1} >> {2}')(['first', 'SECOND', 'THIRD']);
313 * // 'first >> SECOND >> THIRD'
314 */
315MessageFormat.prototype.setBiDiSupport = function(enable) {
316 this.bidiSupport = !!enable || (typeof enable == 'undefined');
317 return this;
318};
319
320
321/** Enable or disable support for the default formatters, which require the
322 * `Intl` object. Note that this can't be autodetected, as the environment
323 * in which the formatted text is compiled into Javascript functions is not
324 * necessarily the same environment in which they will get executed.
325 *
326 * @see MessageFormat.formatters
327 *
328 * @memberof MessageFormat
329 * @param {boolean} [enable=true]
330 * @returns {MessageFormat} The MessageFormat instance, to allow for chaining
331 */
332MessageFormat.prototype.setIntlSupport = function(enable) {
333 this.intlSupport = !!enable || (typeof enable == 'undefined');
334 return this;
335};
336
337
338/** According to the ICU MessageFormat spec, a `#` character directly inside a
339 * `plural` or `selectordinal` statement should be replaced by the number
340 * matching the surrounding statement. By default, messageformat.js will
341 * replace `#` signs with the value of the nearest surrounding `plural` or
342 * `selectordinal` statement.
343 *
344 * Set this to true to follow the stricter ICU MessageFormat spec, and to
345 * throw a runtime error if `#` is used with non-numeric input.
346 *
347 * @memberof MessageFormat
348 * @param {boolean} [enable=true]
349 * @returns {MessageFormat} The MessageFormat instance, to allow for chaining
350 *
351 * @example
352 * var mf = new MessageFormat('en');
353 *
354 * var cookieMsg = '#: {X, plural, =0{no cookies} one{a cookie} other{# cookies}}';
355 * mf.compile(cookieMsg)({ X: 3 });
356 * // '#: 3 cookies'
357 *
358 * var pastryMsg = '{X, plural, one{{P, select, cookie{a cookie} other{a pie}}} other{{P, select, cookie{# cookies} other{# pies}}}}';
359 * mf.compile(pastryMsg)({ X: 3, P: 'pie' });
360 * // '3 pies'
361 *
362 * mf.setStrictNumberSign(true);
363 * mf.compile(pastryMsg)({ X: 3, P: 'pie' });
364 * // '# pies'
365 */
366MessageFormat.prototype.setStrictNumberSign = function(enable) {
367 this.strictNumberSign = !!enable || (typeof enable == 'undefined');
368 this.runtime.setStrictNumber(this.strictNumberSign);
369 return this;
370};
371
372
373/** Compile messages into storable functions
374 *
375 * If `messages` is a single string including ICU MessageFormat declarations,
376 * the result of `compile()` is a function taking a single Object parameter
377 * `d` representing each of the input's defined variables.
378 *
379 * If `messages` is a hierarchical structure of such strings, the output of
380 * `compile()` will match that structure, with each string replaced by its
381 * corresponding JavaScript function.
382 *
383 * If the input `messages` -- and therefore the output -- of `compile()` is an
384 * object, the output object will have a `toString(global)` method that may be
385 * used to store or cache the compiled functions to disk, for later inclusion
386 * in any JS environment, without a local MessageFormat instance required. Its
387 * `global` parameters sets the name (if any) of the resulting global variable,
388 * with special handling for `exports`, `module.exports`, and `export default`.
389 * If `global` does not contain a `.`, the output defaults to an UMD pattern.
390 *
391 * If `locale` is not set, the first locale set in the object's constructor
392 * will be used by default; using a key at any depth of `messages` that is a
393 * declared locale will set its child elements to use that locale.
394 *
395 * If `locale` is set, it is used for all messages. If the constructor
396 * declared any locales, `locale` needs to be one of them.
397 *
398 * @memberof MessageFormat
399 * @param {string|Object} messages - The input message(s) to be compiled, in ICU MessageFormat
400 * @param {string} [locale] - A locale to use for the messages
401 * @returns {function|Object} The first match found for the given locale(s)
402 *
403 * @example
404 * var mf = new MessageFormat('en');
405 * var cf = mf.compile('A {TYPE} example.');
406 *
407 * cf({ TYPE: 'simple' })
408 * // 'A simple example.'
409 *
410 * @example
411 * var mf = new MessageFormat(['en', 'fi']);
412 * var cf = mf.compile({
413 * en: { a: 'A {TYPE} example.',
414 * b: 'This is the {COUNT, selectordinal, one{#st} two{#nd} few{#rd} other{#th}} example.' },
415 * fi: { a: '{TYPE} esimerkki.',
416 * b: 'Tämä on {COUNT, selectordinal, other{#.}} esimerkki.' }
417 * });
418 *
419 * cf.en.b({ COUNT: 2 })
420 * // 'This is the 2nd example.'
421 *
422 * cf.fi.b({ COUNT: 2 })
423 * // 'Tämä on 2. esimerkki.'
424 *
425 * @example
426 * var fs = require('fs');
427 * var mf = new MessageFormat('en').setIntlSupport();
428 * var msgSet = {
429 * a: 'A {TYPE} example.',
430 * b: 'This has {COUNT, plural, one{one member} other{# members}}.',
431 * c: 'We have {P, number, percent} code coverage.'
432 * };
433 * var cfStr = mf.compile(msgSet).toString('module.exports');
434 * fs.writeFileSync('messages.js', cfStr);
435 * ...
436 * var messages = require('./messages');
437 *
438 * messages.a({ TYPE: 'more complex' })
439 * // 'A more complex example.'
440 *
441 * messages.b({ COUNT: 3 })
442 * // 'This has 3 members.'
443 */
444MessageFormat.prototype.compile = function(messages, locale) {
445 function _stringify(obj, level) {
446 if (!level) level = 0;
447 if (typeof obj != 'object') return obj;
448 var o = [], indent = '';
449 for (var i = 0; i < level; ++i) indent += ' ';
450 for (var k in obj) o.push('\n' + indent + ' ' + Compiler.propname(k) + ': ' + _stringify(obj[k], level + 1));
451 return '{' + o.join(',') + '\n' + indent + '}';
452 }
453
454 var pf;
455 if (Object.keys(this.pluralFuncs).length == 0) {
456 if (!locale) locale = MessageFormat.defaultLocale;
457 pf = {};
458 pf[locale] = getPluralFunc(locale, this.noPluralKeyChecks);
459 } else if (locale) {
460 pf = {};
461 pf[locale] = this.pluralFuncs[locale];
462 if (!pf[locale]) throw new Error('Locale ' + JSON.stringify(locale) + 'not found in ' + JSON.stringify(this.pluralFuncs) + '!');
463 } else {
464 pf = this.pluralFuncs;
465 locale = Object.keys(pf)[0];
466 }
467
468 var compiler = new Compiler(this);
469 var obj = compiler.compile(messages, locale, pf);
470
471 if (typeof messages != 'object') {
472 var fn = new Function(
473 'number, plural, select, fmt', Compiler.funcname(locale),
474 'return ' + obj);
475 var rt = this.runtime;
476 return fn(rt.number, rt.plural, rt.select, this.fmt, pf[locale]);
477 }
478
479 var rtStr = this.runtime.toString(pf, compiler) + '\n';
480 var objStr = _stringify(obj);
481 var result = new Function(rtStr + 'return ' + objStr)();
482 if (result.hasOwnProperty('toString')) throw new Error('The top-level message key `toString` is reserved');
483
484 result.toString = function(global) {
485 switch (global || '') {
486 case 'exports':
487 var o = [];
488 for (var k in obj) o.push(Compiler.propname(k, 'exports') + ' = ' + _stringify(obj[k]));
489 return rtStr + o.join(';\n');
490 case 'module.exports':
491 return rtStr + 'module.exports = ' + objStr;
492 case 'export default':
493 return rtStr + 'export default ' + objStr;
494 case '':
495 return rtStr + 'return ' + objStr;
496 default:
497 if (global.indexOf('.') > -1) return rtStr + global + ' = ' + objStr;
498 return rtStr + [
499 '(function (root, G) {',
500 ' if (typeof define === "function" && define.amd) { define(G); }',
501 ' else if (typeof exports === "object") { module.exports = G; }',
502 ' else { ' + Compiler.propname(global, 'root') + ' = G; }',
503 '})(this, ' + objStr + ');'
504 ].join('\n');
505 }
506 }
507 return result;
508}
509
510
511module.exports = MessageFormat;