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