diff --git a/Makefile b/Makefile index 92db6c47..364e5aaa 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ CHK=${GREEN} ✓${STOP} ERR=${RED} ✖${STOP} BIN=./node_modules/.bin -SRC=lib/index.js lib/compiler.js +SRC=lib/index.js lib/compiler.js lib/runtime.js .PHONY: all test test-browser doc release clean @@ -35,7 +35,7 @@ test-browser: messageformat.js doc: doc/index.html -doc/index.html: lib/index.js lib/compiler.js | node_modules +doc/index.html: $(SRC) | node_modules @${BIN}/jsdoc -c doc/jsdoc-conf.json @git apply doc/jsdoc-fix-fonts.patch @rm -r doc/fonts diff --git a/doc/jsdoc-conf.json b/doc/jsdoc-conf.json index 89cc5047..d22224fc 100644 --- a/doc/jsdoc-conf.json +++ b/doc/jsdoc-conf.json @@ -1,5 +1,5 @@ { - "source": { "include": [ "lib/index.js", "lib/compiler.js" ] }, + "source": { "include": [ "lib/" ] }, "plugins": [ "plugins/markdown" ], "opts": { "destination": "./doc/" } } diff --git a/lib/index.js b/lib/index.js index d68513ee..28397f23 100644 --- a/lib/index.js +++ b/lib/index.js @@ -7,6 +7,7 @@ */ var Compiler = require('./compiler'); +var Runtime = require('./runtime'); /** Utility getter/wrapper for pluralization functions from @@ -65,6 +66,7 @@ function MessageFormat(locale) { } } this.fmt = {}; + this.runtime = new Runtime(this); } @@ -199,86 +201,6 @@ MessageFormat.formatters = { }; -/** A set of utility functions that are called by the compiled Javascript - * functions, these are included locally in the output of {@link - * MessageFormat#compile compile()}. - * - * @namespace - */ -MessageFormat.runtime = { - - - /** Utility function for `#` in plural rules - * - * @param {number} value - The value to operate on - * @param {string} argumentName - The name of the argument containing `value` - * @param {number} [offset=0] - An optional offset, set by the surrounding context - */ - number: function(value, argumentName, offset) { - if (isNaN(value)) throw new Error("'" + value + "' from argument '" + argumentName + "' isn't a number."); - return value - (offset || 0); - }, - - - /** Utility function for `{N, plural|selectordinal, ...}` - * - * @param {number} value - The key to use to find a pluralization rule - * @param {number} offset - An offset to apply to `value` - * @param {function} lcfunc - A locale function from `pluralFuncs` - * @param {Object.} data - The object from which results are looked up - * @param {?boolean} isOrdinal - If true, use ordinal rather than cardinal rules - * @returns {string} The result of the pluralization - */ - plural: function(value, offset, lcfunc, data, isOrdinal) { - if ({}.hasOwnProperty.call(data, value)) return data[value]; - if (offset) value -= offset; - var key = lcfunc(value, isOrdinal); - if (key in data) return data[key]; - return data.other; - }, - - - /** Utility function for `{N, select, ...}` - * - * @param {number} value - The key to use to find a selection - * @param {Object.} data - The object from which results are looked up - * @returns {string} The result of the select statement - */ - select: function(value, data) { - if ({}.hasOwnProperty.call(data, value)) return data[value]; - return data.other; - }, - - - /** @private */ - toString: function(pluralFuncs, fmt, compiler) { - function _stringify(o, level) { - if (typeof o != 'object') { - var funcStr = o.toString().replace(/^(function )\w*/, '$1'); - var indent = /([ \t]*)\S.*$/.exec(funcStr); - return indent ? funcStr.replace(new RegExp('^' + indent[1], 'mg'), '') : funcStr; - } - var s = []; - for (var i in o) { - if (level == 0) s.push('var ' + i + ' = ' + _stringify(o[i], level + 1) + ';\n'); - else s.push(Compiler.propname(i) + ': ' + _stringify(o[i], level + 1)); - } - if (level == 0) return s.join(''); - if (s.length == 0) return '{}'; - var indent = ' '; while (--level) indent += ' '; - return '{\n' + s.join(',\n').replace(/^/gm, indent) + '\n}'; - } - - var obj = {}; - Object.keys(compiler.locales).forEach(function(lc) { obj[Compiler.funcname(lc)] = pluralFuncs[lc]; }); - Object.keys(compiler.runtime).forEach(function(fn) { obj[fn] = MessageFormat.runtime[fn]; }); - var fmtKeys = Object.keys(compiler.formatters); - if (fmtKeys.length) obj.fmt = fmtKeys.reduce(function(o, key) { o[key] = fmt[key]; return o; }, {}); - return _stringify(obj, 0); - } -}; - - /** Add custom formatter functions to this MessageFormat instance * * The general syntax for calling a formatting function in MessageFormat is @@ -405,7 +327,8 @@ MessageFormat.prototype.setIntlSupport = function(enable) { * replace `#` signs with the value of the nearest surrounding `plural` or * `selectordinal` statement. * - * Set this to true to follow the stricter ICU MessageFormat spec. + * 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] @@ -428,6 +351,7 @@ MessageFormat.prototype.setIntlSupport = function(enable) { */ MessageFormat.prototype.setStrictNumberSign = function(enable) { this.strictNumberSign = !!enable || (typeof enable == 'undefined'); + this.runtime.setStrictNumber(this.strictNumberSign); return this; }; @@ -534,11 +458,11 @@ MessageFormat.prototype.compile = function(messages, locale) { var fn = new Function( 'number, plural, select, fmt', Compiler.funcname(locale), 'return ' + obj); - var rt = MessageFormat.runtime; + var rt = this.runtime; return fn(rt.number, rt.plural, rt.select, this.fmt, pf[locale]); } - var rtStr = MessageFormat.runtime.toString(pf, this.fmt, compiler) + '\n'; + 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'); diff --git a/lib/runtime.js b/lib/runtime.js new file mode 100644 index 00000000..eb5acece --- /dev/null +++ b/lib/runtime.js @@ -0,0 +1,118 @@ +var Compiler = require('./compiler'); + + +/** A set of utility functions that are called by the compiled Javascript + * functions, these are included locally in the output of {@link + * MessageFormat#compile compile()}. + * + * @class + * @param {MessageFormat} mf - A MessageFormat instance + */ +function Runtime(mf) { + this.mf = mf; + this.setStrictNumber(mf.strictNumberSign); +} + +module.exports = Runtime; + + +/** Utility function for `#` in plural rules + * + * Will throw an Error if `value` has a non-numeric value and `offset` is + * non-zero or {@link MessageFormat#setStrictNumberSign} is set. + * + * @function Runtime#number + * @param {number} value - The value to operate on + * @param {string} name - The name of the argument, used for error reporting + * @param {number} [offset=0] - An optional offset, set by the surrounding context + * @returns {number|string} The result of applying the offset to the input value + */ +function defaultNumber(value, name, offset) { + if (!offset) return value; + if (isNaN(value)) throw new Error('Can\'t apply offset:' + offset + ' to argument `' + name + + '` with non-numerical value ' + JSON.stringify(value) + '.'); + return value - offset; +} + + +/** @private */ +function strictNumber(value, name, offset) { + if (isNaN(value)) throw new Error('Argument `' + name + '` has non-numerical value ' + JSON.stringify(value) + '.'); + return value - (offset || 0); +} + + +/** Set how strictly the {@link number} method parses its input. + * + * According to the ICU MessageFormat spec, `#` can only be used to replace a + * number input of a `plural` statement. By default, messageformat.js does not + * throw a runtime error if you use non-numeric argument with a `plural` rule, + * unless rule also includes a non-zero `offset`. + * + * This is called by {@link MessageFormat#setStrictNumberSign} to follow the + * stricter ICU MessageFormat spec. + * + * @param {boolean} [enable=false] + */ +Runtime.prototype.setStrictNumber = function(enable) { + this.number = enable ? strictNumber : defaultNumber; +} + + +/** Utility function for `{N, plural|selectordinal, ...}` + * + * @param {number} value - The key to use to find a pluralization rule + * @param {number} offset - An offset to apply to `value` + * @param {function} lcfunc - A locale function from `pluralFuncs` + * @param {Object.} data - The object from which results are looked up + * @param {?boolean} isOrdinal - If true, use ordinal rather than cardinal rules + * @returns {string} The result of the pluralization + */ +Runtime.prototype.plural = function(value, offset, lcfunc, data, isOrdinal) { + if ({}.hasOwnProperty.call(data, value)) return data[value]; + if (offset) value -= offset; + var key = lcfunc(value, isOrdinal); + if (key in data) return data[key]; + return data.other; +} + + +/** Utility function for `{N, select, ...}` + * + * @param {number} value - The key to use to find a selection + * @param {Object.} data - The object from which results are looked up + * @returns {string} The result of the select statement + */ +Runtime.prototype.select = function(value, data) { + if ({}.hasOwnProperty.call(data, value)) return data[value]; + return data.other; +} + + +/** @private */ +Runtime.prototype.toString = function(pluralFuncs, compiler) { + function _stringify(o, level) { + if (typeof o != 'object') { + var funcStr = o.toString().replace(/^(function )\w*/, '$1'); + var indent = /([ \t]*)\S.*$/.exec(funcStr); + return indent ? funcStr.replace(new RegExp('^' + indent[1], 'mg'), '') : funcStr; + } + var s = []; + for (var i in o) { + if (level == 0) s.push('var ' + i + ' = ' + _stringify(o[i], level + 1) + ';\n'); + else s.push(Compiler.propname(i) + ': ' + _stringify(o[i], level + 1)); + } + if (level == 0) return s.join(''); + if (s.length == 0) return '{}'; + var indent = ' '; while (--level) indent += ' '; + return '{\n' + s.join(',\n').replace(/^/gm, indent) + '\n}'; + } + + var obj = {}; + Object.keys(compiler.locales).forEach(function(lc) { obj[Compiler.funcname(lc)] = pluralFuncs[lc]; }); + Object.keys(compiler.runtime).forEach(function(fn) { obj[fn] = this[fn]; }, this); + var fmtKeys = Object.keys(compiler.formatters); + var fmt = this.mf.fmt; + if (fmtKeys.length) obj.fmt = fmtKeys.reduce(function(o, key) { o[key] = fmt[key]; return o; }, {}); + return _stringify(obj, 0); +} diff --git a/test/messageformat.js b/test/messageformat.js index bdb81c0e..1e432f3e 100644 --- a/test/messageformat.js +++ b/test/messageformat.js @@ -123,10 +123,12 @@ describe("Basic Message Formatting", function() { it("should have configurable # parsing support", function() { var mf = new MessageFormat('en'); - var msg = '{X, plural, other{{Y, select, other{#}}}}'; + var msg = '{X, plural, one{#} other{{Y, select, other{#}}}}'; expect(mf.compile(msg)({ X: 3, Y: 5 })).to.eql('3'); + expect(mf.compile(msg)({ X: 'x' })).to.eql('x'); mf.setStrictNumberSign(true); expect(mf.compile(msg)({ X: 3, Y: 5 })).to.eql('#'); + expect(function() { mf.compile(msg)({ X: 'x' }); }).to.throwError(/\bX\b.*non-numerical value/); }); it("obeys plural functions", function() { @@ -286,12 +288,13 @@ describe("Basic Message Formatting", function() { it("should reject number injections of numbers that don't exist", function() { var mf = new MessageFormat('en'); var mfunc = mf.compile( - "I have {FRIENDS, plural, one{one friend} other{# friends but {ENEMIES, plural, one{one enemy} other{# enemies}}}}." + "I have {FRIENDS, plural, one{one friend} other{# friends but {ENEMIES, plural, offset:1 " + + "=0{no enemies} =1{one nemesis} one{two enemies} other{one nemesis and # enemies}}}}." ); - expect(mfunc({FRIENDS:0, ENEMIES: 0})).to.eql("I have 0 friends but 0 enemies."); - expect(function(){ var x = mfunc({FRIENDS:0,ENEMIES:'none'}); }).to.throwError(/\'ENEMIES\' isn't a number\.$/); - expect(function(){ var x = mfunc({}); }).to.throwError(/\'.+\' isn't a number\.$/); - expect(function(){ var x = mfunc({ENEMIES:0}); }).to.throwError(/\'FRIENDS\' isn't a number\.$/); + expect(mfunc({FRIENDS:0, ENEMIES: 0})).to.eql("I have 0 friends but no enemies."); + expect(function(){ var x = mfunc({}); }).to.throwError(/\bENEMIES\b.*non-numerical value/); + expect(function(){ var x = mfunc({FRIENDS:0}); }).to.throwError(/\bENEMIES\b.*non-numerical value/); + expect(mfunc({ENEMIES:1})).to.eql('I have undefined friends but one nemesis.'); }); it("should not expose prototype members - selects", function() {