diff --git a/Gruntfile.js b/Gruntfile.js index 090e044ce..f7616189c 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -132,7 +132,8 @@ module.exports = function( grunt ) { paths: { cldr: "../external/cldrjs/dist/cldr", "make-plural": "../external/make-plural/make-plural", - messageformat: "../external/messageformat/messageformat", + "messageformat-parser": "../node_modules/messageformat-parser/parser", + "reserved-words": "../node_modules/reserved-words/lib/reserved-words", "zoned-date-time": "../node_modules/zoned-date-time/src/zoned-date-time" }, shim: { @@ -151,8 +152,7 @@ module.exports = function( grunt ) { // Only for root id's (the ones in src, not in src's subpaths). Note there's no // conditional code checking for this type. onBuildWrite: function( id, path, contents ) { - var messageformat, - name = camelCase( id.replace( /util\/|common\//, "" ) ); + var name = camelCase( id.replace( /util\/|common\//, "" ) ); // MakePlural if ( ( /make-plural/ ).test( id ) ) { @@ -200,68 +200,39 @@ module.exports = function( grunt ) { "/* jshint ignore:end */" ].join( "\n" ) ); - // messageformat - } else if ( ( /messageformat/ ).test( id ) ) { - return contents + // messageformat-parser + } else if ( ( /messageformat-parser/.test( id ) ) ) { - // Remove browserify wrappers. - .replace( /^\(function\(f\)\{if\(typeof exports==="object"&&type.*/, "" ) - .replace( "},{}],2:[function(require,module,exports){", "" ) - .replace( /\},\{"\.\/messageformat-parser":1,"make-plural\/plural.*/, "" ) - .replace( /\},\{\}\]\},\{\},\[2\]\)\(2\)[\s\S]*?$/, "" ) - - // Set `MessageFormat.plurals` and remove `make-plural/plurals` - // completely. This is populated by Globalize on demand. - .replace( /var _cp = \[[\s\S]*?$/, "" ) - .replace( - "MessageFormat.plurals = require('make-plural/plurals')", - "MessageFormat.plurals = {}" - ) - - // Set `MessageFormat._parse` - .replace( - "MessageFormat._parse = require('./messageformat-parser').parse;", - "" - ) - .replace( /module\.exports = \(function\(\) \{([\s\S]*?)\n\}\)\(\);/, [ - "MessageFormat._parse = (function() {", - "$1", - "}()).parse;" + return contents + .replace( /^/, [ + "var Parser;", + "/* jshint ignore:start */\n", + "Parser = (function() {" ].join( "\n" ) ) + .replace( "module.exports = ", "return " ) + .replace( /$/, [ + "}());", + "/* jshint ignore:end */" + ].join( "\n" ) ); - // Remove unused code. - .replace( /if \(!pluralFunc\) \{\n[\s\S]*?\n \}/, "" ) - .replace( /if \(!locale\) \{\n[\s\S]*? \}\n/, "this.lc = [locale];" ) - .replace( /(MessageFormat\.formatters) = \{[\s\S]*?\n\};/, "$1 = {};" ) - .replace( /MessageFormat\.prototype\.setIntlSupport[\s\S]*?\n\};/, "" ) + // reserved-words + } else if ( ( /reserved-words/.test( id ) ) ) { - // Wrap everything into a var assignment. - .replace( "module.exports = MessageFormat;", "" ) + return contents .replace( /^/, [ - "var MessageFormat;", - "/* jshint ignore:start */", - "MessageFormat = (function() {" + "var reserved;", + "/* jshint ignore:start */\n", + "reserved = (function() {", + "var exports = {};" ].join( "\n" ) ) + .replace( "var assert = require\('assert'\);", "" ) + .replace( /^\s*assert\(.*;\s*$/gm, "" ) .replace( /$/, [ - "return MessageFormat;", + "return exports;", "}());", "/* jshint ignore:end */" ].join( "\n" ) ); - // message-runtime - } else if ( ( /message-runtime/ ).test( id ) ) { - messageformat = require( "./external/messageformat/messageformat" ); - delete messageformat.prototype.runtime.fmt; - delete messageformat.prototype.runtime.pluralFuncs; - contents = contents.replace( "Globalize._messageFormat = {};", [ - "/* jshint ignore:start */", - "Globalize._messageFormat = (function() {", - messageformat.prototype.runtime.toString(), - "return {number: number, plural: plural, select: select};", - "}());", - "/* jshint ignore:end */" - ].join( "\n" ) ); - // ZonedDateTime } else if ( ( /zoned-date-time/ ).test( id ) ) { contents = contents.replace( diff --git a/bower.json b/bower.json index 524c7b592..ffe347670 100644 --- a/bower.json +++ b/bower.json @@ -14,7 +14,6 @@ "cldr-data": ">=25", "es5-shim": "3.4.0", "make-plural": "eemeli/make-plural.js#3.0.0", - "messageformat": "SlexAxton/messageformat.js#v0.3.0-1", "qunit": "1.18.0", "requirejs": "2.1.20", "requirejs-plugins": "1.0.2", diff --git a/examples/amd-bower/main.js b/examples/amd-bower/main.js index 1d44f831b..9ab900e32 100644 --- a/examples/amd-bower/main.js +++ b/examples/amd-bower/main.js @@ -41,6 +41,7 @@ require([ "json!cldr-data/supplemental/likelySubtags.json", "json!cldr-data/supplemental/metaZones.json", "json!cldr-data/supplemental/plurals.json", + "json!cldr-data/supplemental/ordinals.json", "json!cldr-data/supplemental/timeData.json", "json!cldr-data/supplemental/weekData.json", "json!messages/en.json", @@ -54,9 +55,8 @@ require([ "globalize/plural", "globalize/relative-time", "globalize/unit" -], function( Globalize, enGregorian, enCurrencies, enDateFields, enNumbers, - enTimeZoneNames, enUnits, currencyData, likelySubtags, metaZones, - pluralsData, timeData, weekData, messages, ianaTzData ) { +], function( Globalize, enGregorian, enCurrencies, enDateFields, enNumbers, enUnits, currencyData, + likelySubtags, pluralsData, ordinalsData, timeData, weekData, messages ) { var en, like, number; @@ -72,6 +72,7 @@ require([ likelySubtags, metaZones, pluralsData, + ordinalsData, timeData, weekData ); diff --git a/examples/node-npm/main.js b/examples/node-npm/main.js index 1ce052430..b0faf80ee 100644 --- a/examples/node-npm/main.js +++ b/examples/node-npm/main.js @@ -13,6 +13,7 @@ Globalize.load( require( "cldr-data/supplemental/likelySubtags" ), require( "cldr-data/supplemental/metaZones" ), require( "cldr-data/supplemental/plurals" ), + require( "cldr-data/supplemental/ordinals" ), require( "cldr-data/supplemental/timeData" ), require( "cldr-data/supplemental/weekData" ) ); diff --git a/examples/plain-javascript/index.html b/examples/plain-javascript/index.html index 900303448..c4c04a4b5 100644 --- a/examples/plain-javascript/index.html +++ b/examples/plain-javascript/index.html @@ -267,6 +267,14 @@

Demo output

"pluralRule-count-one": "i = 1 and v = 0 @integer 1", "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" } + }, + "plurals-type-ordinal": { + "en": { + "pluralRule-count-one": "n % 10 = 1 and n % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …", + "pluralRule-count-two": "n % 10 = 2 and n % 100 != 12 @integer 2, 22, 32, 42, 52, 62, 72, 82, 102, 1002, …", + "pluralRule-count-few": "n % 10 = 3 and n % 100 != 13 @integer 3, 23, 33, 43, 53, 63, 73, 83, 103, 1003, …", + "pluralRule-count-other": " @integer 0, 4~18, 100, 1000, 10000, 100000, 1000000, …" + } } } }); diff --git a/package.json b/package.json index dc2a4afeb..9dfce4d39 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,9 @@ "matchdep": "0.3.0", "mocha": "^3.3.0", "semver": "^5.3.0", - "zoned-date-time": "1.0.0" + "zoned-date-time": "1.0.0", + "messageformat-parser": "^1.0.0", + "reserved-words": "^0.1.1" }, "commitplease": { "nohook": true diff --git a/src/common/validate/parameter-type/plural-type.js b/src/common/validate/parameter-type/plural-type.js index 8991f1907..b0d66e40b 100644 --- a/src/common/validate/parameter-type/plural-type.js +++ b/src/common/validate/parameter-type/plural-type.js @@ -6,8 +6,8 @@ return function( value, name ) { validateParameterType( value, name, - value === undefined || value === "cardinal" || value === "ordinal", - "String \"cardinal\" or \"ordinal\"" + value === undefined || value === "cardinal" || value === "ordinal" || value === "both", + "String \"cardinal\" or \"ordinal\" or \"both\"" ); }; diff --git a/src/core.js b/src/core.js index 28a7b34ba..255e40e56 100644 --- a/src/core.js +++ b/src/core.js @@ -17,12 +17,13 @@ define([ "./util/object/extend", "./util/regexp/escape", "./util/string/pad", + "./util/formatterfn/options", "cldr/event" ], function( Cldr, createError, formatMessage, runtimeBind, validate, validateCldr, validateDefaultLocale, validateParameterPresence, validateParameterRange, validateParameterType, validateParameterTypeLocale, validateParameterTypePlainObject, alwaysArray, alwaysCldr, - isPlainObject, objectExtend, regexpEscape, stringPad ) { + isPlainObject, objectExtend, regexpEscape, stringPad, formatterfnOptions ) { function validateLikelySubtags( cldr ) { cldr.once( "get", validateCldr ); @@ -86,6 +87,12 @@ Globalize.locale = function( locale ) { return this.cldr; }; +Globalize._messageFmts = {}; + +Globalize.addMessageFormatterFunction = function( name, fn, options ) { + Globalize._messageFmts[name] = formatterfnOptions( fn, options ); +}; + /** * Optimization to avoid duplicating some internal functions across modules. */ diff --git a/src/currency.js b/src/currency.js index 977758038..5abde745f 100644 --- a/src/currency.js +++ b/src/currency.js @@ -91,6 +91,14 @@ Globalize.prototype.currencyFormatter = function( currency, options ) { return returnFn; }; +Globalize.addMessageFormatterFunction( "currency", function( currency, style ) { + var options = {}; + if ( style ) { + options.style = style; + } + return this.currencyFormatter( currency, options ); +}); + /** * .currencyParser( currency [, options] ) * diff --git a/src/date.js b/src/date.js index 16ec37a9e..659d0cbbb 100644 --- a/src/date.js +++ b/src/date.js @@ -55,12 +55,14 @@ function validateOptionsPreset( options ) { validateOptionsPresetEach( "datetime", options ); } +var presets = [ "short", "medium", "long", "full" ]; + function validateOptionsPresetEach( type, options ) { var value = options[ type ]; validate( "E_INVALID_OPTIONS", "Invalid `{{type}: \"{value}\"}`.", - value === undefined || [ "short", "medium", "long", "full" ].indexOf( value ) !== -1, + value === undefined || presets.indexOf( value ) !== -1, { type: type, value: value } ); } @@ -210,6 +212,24 @@ Globalize.prototype.dateToPartsFormatter = function( options ) { return returnFn; }; +[ "date", "time", "datetime" ].map(function( type ) { + Globalize.addMessageFormatterFunction( type, function( p ) { + var options = {}; + if ( p ) { + var trimmed = p.trim(); + if ( presets.indexOf( trimmed ) !== -1 ) { + options[type] = trimmed; + } else if ( trimmed.indexOf( "skeleton" ) === 0 && trimmed.indexOf( "," ) !== -1 ) { + var splitArgs = p.split( ",", 2 ); + options.skeleton = splitArgs[1].trim(); + } else { + options.raw = p; + } + } + return this.dateFormatter( options ); + }, { split: false, trim: false }); +}); + /** * .dateParser( options ) * diff --git a/src/message-runtime.js b/src/message-runtime.js index 6880351de..eae8bc9de 100644 --- a/src/message-runtime.js +++ b/src/message-runtime.js @@ -2,11 +2,14 @@ define([ "./common/runtime-key", "./common/validate/parameter-type/message-variables", "./core-runtime", - "./message/formatter-fn" -], function( runtimeKey, validateParameterTypeMessageVariables, Globalize, messageFormatterFn ) { + "./message/formatter-fn", + "./message/formatter-runtime" +], function( runtimeKey, validateParameterTypeMessageVariables, Globalize, messageFormatterFn, + messageFormatterRuntime +) { Globalize._messageFormatterFn = messageFormatterFn; -Globalize._messageFormat = {}; +Globalize._messageFormat = new messageFormatterRuntime(); // TODO setStrictNumber Globalize._validateParameterTypeMessageVariables = validateParameterTypeMessageVariables; Globalize.messageFormatter = diff --git a/src/message.js b/src/message.js index b5c4e4675..0b0b86c04 100644 --- a/src/message.js +++ b/src/message.js @@ -1,6 +1,5 @@ define([ "cldr", - "messageformat", "./common/create-error", "./common/create-error/plural-module-presence", "./common/runtime-bind", @@ -12,15 +11,18 @@ define([ "./common/validate/parameter-type", "./common/validate/parameter-type/plain-object", "./core", + "./message/compiler", + "./message/formatter-runtime", "./message/formatter-fn", "./message/formatter-runtime-bind", "./util/always-array", "cldr/event" -], function( Cldr, MessageFormat, createError, createErrorPluralModulePresence, runtimeBind, +], function( Cldr, createError, createErrorPluralModulePresence, runtimeBind, validateDefaultLocale, validateMessageBundle, validateMessagePresence, validateMessageType, validateParameterPresence, validateParameterType, validateParameterTypePlainObject, Globalize, - messageFormatterFn, messageFormatterRuntimeBind, alwaysArray ) { + messageCompiler, messageFormatterRuntime, messageFormatterFn, messageFormatterRuntimeBind, + alwaysArray ) { var slice = [].slice; @@ -82,17 +84,56 @@ Globalize.prototype.messageFormatter = function( path ) { } validateMessageType( path, message ); - // Is plural module present? Yes, use its generator. Nope, use an error generator. - pluralGenerator = this.plural !== undefined ? - this.pluralGenerator() : - createErrorPluralModulePresence; + var compiler = new messageCompiler( this, Globalize._messageFmts ); + var formatterSrc = compiler + .compile( message, cldr.locale ); + + var pluralType = false; + if ( compiler.pluralTypes.cardinal && compiler.pluralTypes.ordinal ) { + pluralType = "both"; + } else if ( compiler.pluralTypes.cardinal ) { + pluralType = "cardinal"; + } else if ( compiler.pluralTypes.ordinal ) { + pluralType = "ordinal"; + } + + if ( pluralType !== false ) { - formatter = new MessageFormat( cldr.locale, pluralGenerator ).compile( message ); + // Is plural module present? Yes, use its generator. Nope, use an error generator. + pluralGenerator = this.plural !== undefined ? + this.pluralGenerator( { type: pluralType } ) : + createErrorPluralModulePresence; + } - returnFn = messageFormatterFn( formatter ); + var runtime = new messageFormatterRuntime( compiler.strictNumberSign ); + + /* jshint evil:true */ + formatter = new Function( + "number, plural, select", messageCompiler.funcname( cldr.locale ), + " var fmt = [].slice.call( arguments, 4 );\n" + + " return " + formatterSrc + "\n" + ); + + returnFn = messageFormatterFn.apply( this, [ + formatter, "call", + runtime.number, runtime.plural, runtime.select, pluralGenerator + ].concat( compiler.formatters ) ); + + var runtimeArgs = [ + messageFormatterRuntimeBind( + formatter, formatterSrc, compiler.runtime, pluralType, cldr.locale, compiler.formatters + ), + "call" + ]; + + if ( pluralGenerator ) { + runtimeArgs.push( pluralGenerator ); + } + runtimeArgs = runtimeArgs.concat( + compiler.formatters + ); - runtimeBind( args, cldr, returnFn, - [ messageFormatterRuntimeBind( cldr, formatter ), pluralGenerator ] ); + runtimeBind( args, cldr, returnFn, runtimeArgs ); return returnFn; }; diff --git a/src/message/compiler.js b/src/message/compiler.js new file mode 100644 index 000000000..d53527aa9 --- /dev/null +++ b/src/message/compiler.js @@ -0,0 +1,211 @@ +define([ + "../core", + "messageformat-parser", + "reserved-words" +], function( Globalize, Parser, reserved ) { + +/** Creates a new message compiler. + * + * @class + * @param {object} context - Context (this) for formatter functions + * @param {array} fmtFuncs - Formatter functions + * @property {object} runtime - Names of the core runtime functions used by the compiled functions + * @property {array} formatters - The formatter functions used by the compiled functions + * @property {object} pluralTypes - The plural types used by the compiled functions + */ +function Compiler( context, fmtFuncs ) { + this._globalize = Globalize; + this.fmt = fmtFuncs; + this.runtime = {}; + this.pluralTypes = { + ordinal: false, + cardinal: false + }; + this.formatters = []; + this.context = context; +} + +/** 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 Compiler + * @param {boolean} [enable=true] + * @returns {Compiler} The Compiler instance, to allow for chaining + */ +Compiler.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.js 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 Compiler + * @param {boolean} [enable=true] + * @returns {Compiler} The Compiler instance, to allow for chaining + */ +Compiler.prototype.setStrictNumberSign = function( enable ) { + this.strictNumberSign = !!enable || ( typeof enable === "undefined" ); + + // TODO + // this.runtime.setStrictNumber(this.strictNumberSign); + return this; +}; + +/** Utility function for quoting an Object's key value if required + * + * Quotes the key if it contains invalid characters or is an + * ECMAScript 3rd Edition reserved word (for IE8). + */ +Compiler.propname = function( key, obj ) { + if ( /^[A-Z_$][0-9A-Z_$]*$/i.test( key ) && + [ "break", "continue", "delete", "else", "for", "function", "if", "in", "new", + "return", "this", "typeof", "var", "void", "while", "with", "case", "catch", + "default", "do", "finally", "instanceof", "switch", "throw", "try" ].indexOf( key ) < 0 ) { + return obj ? obj + "." + key : key; + } else { + var jkey = JSON.stringify( key ); + return obj ? obj + "[" + jkey + "]" : jkey; + } +}; + +/** Utility function for escaping a function name if required + */ +Compiler.funcname = function( key ) { + var fn = key.trim().replace( /\W+/g, "_" ); + return reserved.check( fn, "es2015", true ) || /^\d/.test( fn ) ? "_" + fn : fn; +}; + +/** Utility formatter function for enforcing Bidi Structured Text by using UCC + * + * List inlined from data extracted from CLDR v27 & v28 + * To verify/recreate, use the following: + * + * git clone https://github.com/unicode-cldr/cldr-misc-full.git + * cd cldr-misc-full/main/ + * grep characterOrder -r . | tr '"/' '\t' | cut -f2,6 | grep -C4 right-to-left + */ +Compiler.bidiMarkText = function( text, locale ) { + function isLocaleRTL( locale ) { + var rtlLanguages = [ "ar", "ckb", "fa", "he", "ks($|[^bfh])", "lrc", "mzn", + "pa-Arab", "ps", "ug", "ur", "uz-Arab", "yi" ]; + return new RegExp( "^" + rtlLanguages.join( "|^" ) ).test( locale ); + } + var mark = JSON.stringify( isLocaleRTL( locale ) ? "\u200F" : "\u200E" ); + return mark + " + " + text + " + " + mark; +}; + +/** @private */ +Compiler.prototype.cases = function( token, plural ) { + var needOther = true; + var r = token.cases.map( function( c ) { + if ( c.key === "other" ) { + needOther = false; + } + var s = c.tokens.map( function( tok ) { return this.token( tok, plural ); }, this ); + return Compiler.propname( c.key ) + ": " + ( s.join( " + " ) || "\"\"" ); + }, this ); + if ( needOther ) { + throw new Error( "No 'other' form found in " + JSON.stringify( token ) ); + } + return "{ " + r.join( ", " ) + " }"; +}; + +/** @private */ +Compiler.prototype.token = function( token, plural ) { + if ( typeof token === "string" ) { + return JSON.stringify( token ); + } + + var fn, args = [ Compiler.propname( token.arg, "d" ) ]; + switch ( token.type ) { + case "argument": + return this.bidiSupport ? Compiler.bidiMarkText( args[0], this.lc ) : args[0]; + + case "select": + fn = "select"; + args.push( this.cases( token, this.strictNumberSign ? null : plural ) ); + this.runtime.select = true; + break; + + case "selectordinal": + fn = "plural"; + args.push( 0, Compiler.funcname( this.lc ), this.cases( token, token ), 1 ); + this.runtime.plural = true; + this.pluralTypes.ordinal = true; + break; + + case "plural": + fn = "plural"; + args.push( + token.offset || 0, + Compiler.funcname( this.lc ), this.cases( token, token ) + ); + this.runtime.plural = true; + this.pluralTypes.cardinal = true; + break; + + case "function": + if ( !this.fmt[token.key] ) { + throw new Error( "Formatting function " + + JSON.stringify( token.key ) + " not found!" ); + } + fn = "fmt[" + JSON.stringify( this.formatters.length ) + "]"; + this.formatters.push( this.fmt[token.key].apply( this.context, token.params || [] ) ); + break; + + case "octothorpe": + if ( !plural ) { + return "\"#\""; + } + fn = "number"; + args = [ Compiler.propname( plural.arg, "d" ), JSON.stringify( plural.arg ) ]; + if ( plural.offset ) { + args.push( plural.offset ); + } + + this.runtime.number = true; + break; + } + + if ( !fn ) { + throw new Error( "Parser error for token " + JSON.stringify( token ) ); + } + return fn + "(" + args.join( ", " ) + ")"; +}; + +/** Recursively compile a string or a tree of strings to JavaScript function sources + * + * If `src` is an object with a key that is also present in `plurals`, the key + * in question will be used as the locale identifier for its value. To disable + * the compile-time checks for plural & selectordinal keys while maintaining + * multi-locale support, use falsy values in `plurals`. + * + * @param {string|object} src - the source for which the JS code should be generated + * @param {string} lc - the default locale + * @param {object} plurals - a map of pluralization keys for all available locales + */ +Compiler.prototype.compile = function( src, lc ) { + this.lc = lc; + + // TODO: pc is only needed for validation, disable for now. + var pc = { cardinal: [], ordinal: [] }; + + // Enable icu-compatible function parameter parsing so that commas + // can be used inside custom date formats. + pc.strictFunctionParams = true; + var r = Parser.parse( src, pc ).map( function( token ) { return this.token( token ); }, this ); + return "function(d) { return " + ( r.join( " + " ) || "\"\"" ) + "; }"; +}; + +return Compiler; + +}); diff --git a/src/message/formatter-fn.js b/src/message/formatter-fn.js index 720f481cb..a0205a258 100644 --- a/src/message/formatter-fn.js +++ b/src/message/formatter-fn.js @@ -2,7 +2,10 @@ define([ "../common/validate/parameter-type/message-variables" ], function( validateParameterTypeMessageVariables ) { -return function( formatter ) { +return function( formatter, shouldCall ) { + if ( shouldCall === "call" ) { + formatter = formatter.apply( null, [].slice.call( arguments, 2 ) ); + } return function messageFormatter( variables ) { if ( typeof variables === "number" || typeof variables === "string" ) { variables = [].slice.call( arguments, 0 ); diff --git a/src/message/formatter-runtime-bind.js b/src/message/formatter-runtime-bind.js index c2b285d65..ff57f02d0 100644 --- a/src/message/formatter-runtime-bind.js +++ b/src/message/formatter-runtime-bind.js @@ -1,43 +1,35 @@ define(function() { -return function( cldr, messageformatter ) { - var locale = cldr.locale, - origToString = messageformatter.toString; +return function( messageformatter, formatterSrc, runtime, pluralType, locale, formatters ) { + var hasFormatters = formatters.length > 0; messageformatter.toString = function() { - var argNames, argValues, output, - args = {}; + var locals = []; + var argCount = 0, + args = []; - // Properly adjust SlexAxton/messageformat.js compiled variables with Globalize variables: - output = origToString.call( messageformatter ); + if ( runtime.number ) { + locals.push( "var number = messageFormat.number;" ); + } - if ( /number\(/.test( output ) ) { - args.number = "messageFormat.number"; + if ( runtime.plural ) { + locals.push( "var plural = messageFormat.plural;" ); } - if ( /plural\(/.test( output ) ) { - args.plural = "messageFormat.plural"; + if ( runtime.select ) { + locals.push( "var select = messageFormat.select;" ); } - if ( /select\(/.test( output ) ) { - args.select = "messageFormat.select"; + if ( pluralType !== false ) { + args.push( locale ); + argCount++; } - output.replace( /pluralFuncs(\[([^\]]+)\]|\.([a-zA-Z]+))/, function( match ) { - args.pluralFuncs = "{" + - "\"" + locale + "\": Globalize(\"" + locale + "\").pluralGenerator()" + - "}"; - return match; - }); - - argNames = Object.keys( args ).join( ", " ); - argValues = Object.keys( args ).map(function( key ) { - return args[ key ]; - }).join( ", " ); - - return "(function( " + argNames + " ) {\n" + - " return " + output + "\n" + - "})(" + argValues + ")"; + return "(function( " + args.join( ", " ) + " ) {\n" + + ( locals.length ? ( locals.join( "\n" ) + "\n" ) : "" ) + + ( hasFormatters ? " var fmt = [].slice.call( arguments, " + argCount + " );\n" : "" ) + + " return " + formatterSrc + "\n" + + "})"; }; return messageformatter; diff --git a/src/message/formatter-runtime.js b/src/message/formatter-runtime.js new file mode 100644 index 000000000..c2c53d239 --- /dev/null +++ b/src/message/formatter-runtime.js @@ -0,0 +1,92 @@ +define([], function() { + +function Runtime( strictNumberSign ) { + this.setStrictNumber( strictNumberSign ); +} + +/** 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; +}; + +return Runtime; + +}); diff --git a/src/number.js b/src/number.js index 4922e4b27..f4bef6870 100644 --- a/src/number.js +++ b/src/number.js @@ -100,6 +100,14 @@ Globalize.prototype.numberFormatter = function( options ) { return returnFn; }; +Globalize.addMessageFormatterFunction( "number", function( style ) { + var options = {}; + if ( style ) { + options.style = style; + } + return this.numberFormatter( options ); +}); + /** * .numberParser( [options] ) * diff --git a/src/plural.js b/src/plural.js index b9781546c..9895f0b2b 100644 --- a/src/plural.js +++ b/src/plural.js @@ -48,7 +48,7 @@ Globalize.prototype.plural = function( value, options ) { */ Globalize.pluralGenerator = Globalize.prototype.pluralGenerator = function( options ) { - var args, cldr, isOrdinal, plural, returnFn, type; + var args, cldr, isOrdinal, isCardinal, plural, returnFn, type; validateParameterTypePlainObject( options, "options" ); @@ -62,18 +62,29 @@ Globalize.prototype.pluralGenerator = function( options ) { validateDefaultLocale( cldr ); - isOrdinal = type === "ordinal"; + isOrdinal = type === "ordinal" || type === "both"; + isCardinal = type === "cardinal" || type === "both"; cldr.on( "get", validateCldr ); - cldr.supplemental([ "plurals-type-" + type, "{language}" ]); + if ( isOrdinal ) { + cldr.supplemental([ "plurals-type-ordinal", "{language}" ]); + } + if ( isCardinal ) { + cldr.supplemental([ "plurals-type-cardinal", "{language}" ]); + } cldr.off( "get", validateCldr ); MakePlural.rules = {}; - MakePlural.rules[ type ] = cldr.supplemental( "plurals-type-" + type ); + if ( isOrdinal ) { + MakePlural.rules.ordinal = cldr.supplemental( "plurals-type-ordinal" ); + } + if ( isCardinal ) { + MakePlural.rules.cardinal = cldr.supplemental( "plurals-type-cardinal" ); + } plural = new MakePlural( cldr.attributes.language, { "ordinals": isOrdinal, - "cardinals": !isOrdinal + "cardinals": isCardinal }); returnFn = pluralGeneratorFn( plural ); diff --git a/src/plural/generator-fn.js b/src/plural/generator-fn.js index 3c052396c..79cc58551 100644 --- a/src/plural/generator-fn.js +++ b/src/plural/generator-fn.js @@ -4,11 +4,11 @@ define([ ], function( validateParameterPresence, validateParameterTypeNumber ) { return function( plural ) { - return function pluralGenerator( value ) { + return function pluralGenerator( value, ord ) { validateParameterPresence( value, "value" ); validateParameterTypeNumber( value, "value" ); - return plural( value ); + return plural( value, ord ); }; }; diff --git a/src/relative-time.js b/src/relative-time.js index 4c36886af..557d2a2e9 100644 --- a/src/relative-time.js +++ b/src/relative-time.js @@ -74,6 +74,14 @@ Globalize.prototype.relativeTimeFormatter = function( unit, options ) { return returnFn; }; +Globalize.addMessageFormatterFunction( "relativetime", function( unit, form ) { + var options = {}; + if ( form ) { + options.form = form; + } + return this.relativeTimeFormatter( unit, options ); +}); + return Globalize; }); diff --git a/src/unit.js b/src/unit.js index 024b68e51..7349e22bd 100644 --- a/src/unit.js +++ b/src/unit.js @@ -69,6 +69,14 @@ Globalize.prototype.unitFormatter = function( unit, options ) { return returnFn; }; +Globalize.addMessageFormatterFunction( "unit", function( unit, form ) { + var options = {}; + if ( form ) { + options.form = form; + } + return this.unitFormatter( unit, options ); +}); + return Globalize; }); diff --git a/src/util/formatterfn/options.js b/src/util/formatterfn/options.js new file mode 100644 index 000000000..965648258 --- /dev/null +++ b/src/util/formatterfn/options.js @@ -0,0 +1,19 @@ +define(function() { + +return function( fn, options ) { + options = options || {}; + return function( ) { + var args = [].slice.call( arguments, 0 ); + if ( args.length === 1 && ( options.split === undefined || options.split === true ) ) { + args = args[0].split( "," ); + } + if ( options.trim === undefined || options.trim === true ) { + args = args.map( function( v ) { + return v.trim(); + }); + } + return fn.apply( this, args ); + }; +}; + +}); diff --git a/test/compiler/cases/message.js b/test/compiler/cases/message.js index 16e81e883..2fe2e8d93 100644 --- a/test/compiler/cases/message.js +++ b/test/compiler/cases/message.js @@ -4,13 +4,89 @@ module.exports = { Globalize.load( // core - require( "../../../external/cldr-data/supplemental/likelySubtags.json" ) + require( "../../../external/cldr-data/supplemental/likelySubtags.json" ), + // date + require( "../../../external/cldr-data/main/en/ca-gregorian.json" ), + require( "../../../external/cldr-data/main/en/timeZoneNames.json" ), + require( "../../../external/cldr-data/supplemental/metaZones.json" ), + require( "../../../external/cldr-data/supplemental/timeData.json" ), + require( "../../../external/cldr-data/supplemental/weekData.json" ), + // number + require( "../../../external/cldr-data/main/en/numbers.json" ), + require( "../../../external/cldr-data/supplemental/numberingSystems.json" ), + // currency + require( "../../../external/cldr-data/main/en/currencies.json" ), + require( "../../../external/cldr-data/supplemental/currencyData.json" ), + // plural + require( "../../../external/cldr-data/supplemental/plurals.json" ), + require( "../../../external/cldr-data/supplemental/ordinals.json" ), + // relative time + require( "../../../external/cldr-data/main/en/dateFields.json" ), + // unit + require( "../../../external/cldr-data/main/en/units.json" ) ); Globalize.loadMessages({ en: { greetings: { - hello: "Hello, {name}" + hello: "Hello", + helloArray: "Hello, {0}", + helloArray2: "Hello, {0} and {1}", + helloName: "Hello, {name}" + }, + like: [ + "{count, plural, offset:1", + " =0 {Be the first to like this}", + " =1 {You liked this}", + " one {You and {someone} liked this}", + " other {You and # others liked this}", + "}" + ], + party: [ + "{hostGender, select,", + " female {{host} invites {guest} to her party}", + " male {{host} invites {guest} to his party}", + " other {{host} invites {guest} to their party}", + "}" + ], + task: [ + "You have {0, plural,", + " one {one task}", + " other {# tasks}", + "} remaining" + ], + ordinal: [ + "{cat, selectordinal, one{#st} two{#nd} few{#rd} other{#th} }", + "category" + ], + date: { + date: "date: {x, date, long}", + time: "time: {x, time, long}", + datetime: "datetime: {x, datetime, long}", + raw: "date raw: {x, date, y-M-d HH:mm:ss zzzz }", + rawComma: "date raw comma: {x, date, y-M-d, HH:mm:ss zzzz }", + skeleton: "date skeleton: {x, date, skeleton, GyMMMEdhms}", + skeletonInvalid: "date skeleton: {x, date, skeleton}" + }, + relativetime: { + default: "relativetime: {x, relativetime, minute}", + short: "relativetime short: {x, relativetime, minute, short}", + narrow: "relativetime narrow: {x, relativetime, minute, narrow}" + }, + number: { + decimal: "number decimal: {x, number}", + percent: "number percent: {x, number, percent}" + }, + currency: { + symbol: "currency symbol: {x, currency, USD}", + accounting: "currency accounting: {x, currency, USD, accounting}", + code: "currency code: {x, currency, USD, code}", + name: "currency name: {x, currency, USD, name}" + }, + unit: { + long: "unit long: {x, unit, second, long}", + short: "unit short: {x, unit, second, short}", + narrow: "unit narrow: {x, unit, second, narrow}" } } }); @@ -18,11 +94,142 @@ module.exports = { return Globalize; }, cases: function( Globalize ) { + var date = new Date( 2010, 8, 15, 17, 35, 7, 369 ); + Globalize.locale( "en" ); return [ - { formatter: Globalize( "en" ).messageFormatter( "greetings/hello" ), args: [ { - name: "Beethoven" - } ] } + { + formatter: Globalize( "en" ).messageFormatter( "greetings/hello" ), + args: [] + }, + { + formatter: Globalize( "en" ).messageFormatter( "greetings/helloArray" ), + args: [ "Beethoven" ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "greetings/helloArray2" ), + args: [ [ "Beethoven", "Mozart" ] ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "greetings/helloName" ), + args: [ { name: "Beethoven" } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "task" ), + args: [ 123 ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "party" ), + args: [ { + guest: "Mozart", + host: "Beethoven", + hostGender: "male" + } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "like" ), + args: [ { count: 0 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "like" ), + args: [ { count: 1 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "like" ), + args: [ { count: 2, someone: "Beethoven" } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "like" ), + args: [ { count: 3 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "ordinal" ), + args: [ { cat: 1 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "ordinal" ), + args: [ { cat: 2 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "ordinal" ), + args: [ { cat: 3 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "ordinal" ), + args: [ { cat: 4 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "date/date" ), + args: [ { x: date } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "date/time" ), + args: [ { x: date } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "date/datetime" ), + args: [ { x: date } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "relativetime/default" ), + args: [ { x: 2 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "relativetime/default" ), + args: [ { x: -2 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "relativetime/short" ), + args: [ { x: 2 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "relativetime/short" ), + args: [ { x: -2 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "relativetime/narrow" ), + args: [ { x: 2 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "relativetime/narrow" ), + args: [ { x: -2 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "number/decimal" ), + args: [ { x: 0.5 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "number/percent" ), + args: [ { x: 0.5 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "currency/symbol" ), + args: [ { x: 100 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "currency/accounting" ), + args: [ { x: 100 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "currency/code" ), + args: [ { x: 100 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "currency/name" ), + args: [ { x: 100 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "unit/long" ), + args: [ { x: 42 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "unit/short" ), + args: [ { x: 42 } ] + }, + { + formatter: Globalize( "en" ).messageFormatter( "unit/narrow" ), + args: [ { x: 42 } ] + }, ]; } }; diff --git a/test/functional/message/format-message.js b/test/functional/message/format-message.js index 5cd71f2be..3c1f2dd16 100644 --- a/test/functional/message/format-message.js +++ b/test/functional/message/format-message.js @@ -2,16 +2,18 @@ define([ "globalize", "json!cldr-data/supplemental/likelySubtags.json", "json!cldr-data/supplemental/plurals.json", + "json!cldr-data/supplemental/ordinals.json", "../../util", "globalize/message", "globalize/plural" -], function( Globalize, likelySubtags, plurals, util ) { +], function( Globalize, likelySubtags, plurals, ordinals, util ) { QUnit.module( ".formatMessage( path [, variables] )", { setup: function() { Globalize.load( likelySubtags ); Globalize.load( plurals ); + Globalize.load( ordinals ); Globalize.loadMessages({ en: { greetings: { diff --git a/test/functional/message/message-formatter.js b/test/functional/message/message-formatter.js index 08224fd90..b2757a12e 100644 --- a/test/functional/message/message-formatter.js +++ b/test/functional/message/message-formatter.js @@ -1,13 +1,43 @@ define([ "globalize", "json!cldr-data/supplemental/likelySubtags.json", + "json!cldr-data/supplemental/numberingSystems.json", + "json!cldr-data/supplemental/currencyData.json", "json!cldr-data/supplemental/plurals.json", + "json!cldr-data/supplemental/ordinals.json", + "json!cldr-data/supplemental/timeData.json", + "json!cldr-data/supplemental/weekData.json", + "json!cldr-data/main/en/numbers.json", + "json!cldr-data/main/en/units.json", + "json!cldr-data/main/en/currencies.json", + "json!cldr-data/main/en/ca-gregorian.json", + "json!cldr-data/main/en/timeZoneNames.json", + "json!cldr-data/main/en/dateFields.json", + "json!iana-tz-data.json", "../../util", "cldr/unresolved", "globalize/message", "globalize/plural" -], function( Globalize, likelySubtags, plurals, util ) { +], function( Globalize, likelySubtags, numberingSystems, currencyData, plurals, + ordinals, timeData, weekData, enNumbers, enUnitFields, enCurrencies, enCaGregorian, + enTimeZoneNames, enDateFields, ianaTimezoneData, util ) { + +function extraSetup() { + Globalize.load( + numberingSystems, + currencyData, + enNumbers, + enUnitFields, + enCurrencies, + timeData, + weekData, + enCaGregorian, + enTimeZoneNames, + enDateFields + ); + Globalize.loadTimeZone( ianaTimezoneData ); +} QUnit.assert.messageFormatter = function( locale, path, variables, expected ) { if ( arguments.length === 3 ) { @@ -28,6 +58,7 @@ QUnit.module( ".messageFormatter( path )", { setup: function() { Globalize.load( likelySubtags ); Globalize.load( plurals ); + Globalize.load( ordinals ); Globalize.loadMessages({ root: { amen: "Amen" @@ -60,7 +91,40 @@ QUnit.module( ".messageFormatter( path )", { " one {one task}", " other {# tasks}", "} remaining" - ] + ], + ordinal: [ + "{cat, selectordinal, one{#st} two{#nd} few{#rd} other{#th} }", + "category" + ], + date: { + date: "date: {x, date, long}", + time: "time: {x, time, long}", + datetime: "datetime: {x, datetime, long}", + raw: "date raw: {x, date, y-M-d HH:mm:ss zzzz }", + rawComma: "date raw comma: {x, date, y-M-d, HH:mm:ss zzzz }", + skeleton: "date skeleton: {x, date, skeleton, GyMMMEdhms}", + skeletonInvalid: "date skeleton: {x, date, skeleton}" + }, + relativetime: { + default: "relativetime: {x, relativetime, minute}", + short: "relativetime short: {x, relativetime, minute, short}", + narrow: "relativetime narrow: {x, relativetime, minute, narrow}" + }, + number: { + decimal: "number decimal: {x, number}", + percent: "number percent: {x, number, percent}" + }, + currency: { + symbol: "currency symbol: {x, currency, USD}", + accounting: "currency accounting: {x, currency, USD, accounting}", + code: "currency code: {x, currency, USD, code}", + name: "currency name: {x, currency, USD, name}" + }, + unit: { + long: "unit long: {x, unit, second, long}", + short: "unit short: {x, unit, second, short}", + narrow: "unit narrow: {x, unit, second, narrow}" + } }, "en-GB": {}, fr: {}, @@ -173,6 +237,100 @@ QUnit.test( "should support ICU message format", function( assert ) { }), "You and Beethoven liked this" ); assert.equal( like({ count: 3 }), "You and 2 others liked this" ); + + // Selectordinal + assert.messageFormatter( "en", "ordinal", { + cat: 1, + }, "1st category" ); + + assert.messageFormatter( "en", "ordinal", { + cat: 2, + }, "2nd category" ); + + assert.messageFormatter( "en", "ordinal", { + cat: 3, + }, "3rd category" ); + + assert.messageFormatter( "en", "ordinal", { + cat: 4, + }, "4th category" ); +}); + +QUnit.test( "should support formatters in messages", function( assert ) { + extraSetup(); + + var date = new Date( 2010, 8, 15, 17, 35, 7, 369 ); + + assert.messageFormatter( "en", "date/date", { + x: date, + }, "date: September 15, 2010" ); + assert.messageFormatter( "en", "date/time", { + x: date, + }, "time: 5:35:07 PM GMT+2" ); + assert.messageFormatter( "en", "date/datetime", { + x: date, + }, "datetime: September 15, 2010 at 5:35:07 PM GMT+2" ); + assert.messageFormatter( "en", "date/raw", { + x: date, + }, "date raw: 2010-9-15 17:35:07 GMT+02:00 " ); + assert.messageFormatter( "en", "date/rawComma", { + x: date, + }, "date raw comma: 2010-9-15, 17:35:07 GMT+02:00 " ); + assert.messageFormatter( "en", "date/skeleton", { + x: date, + }, "date skeleton: Wed, Sep 15, 2010 AD, 5:35:07 PM" ); + assert.messageFormatter( "en", "date/skeletonInvalid", { + x: date, + }, "date skeleton: 7174l4ton" ); + + assert.messageFormatter( "en", "relativetime/default", { + x: 2, + }, "relativetime: in 2 minutes" ); + assert.messageFormatter( "en", "relativetime/default", { + x: -2, + }, "relativetime: 2 minutes ago" ); + assert.messageFormatter( "en", "relativetime/short", { + x: 2, + }, "relativetime short: in 2 min." ); + assert.messageFormatter( "en", "relativetime/short", { + x: -2, + }, "relativetime short: 2 min. ago" ); + assert.messageFormatter( "en", "relativetime/narrow", { + x: 2, + }, "relativetime narrow: in 2 min." ); + assert.messageFormatter( "en", "relativetime/narrow", { + x: -2, + }, "relativetime narrow: 2 min. ago" ); + + assert.messageFormatter( "en", "number/decimal", { + x: 0.5, + }, "number decimal: 0.5" ); + assert.messageFormatter( "en", "number/percent", { + x: 0.5, + }, "number percent: 50%" ); + + assert.messageFormatter( "en", "currency/symbol", { + x: 100, + }, "currency symbol: $100.00" ); + assert.messageFormatter( "en", "currency/accounting", { + x: 100, + }, "currency accounting: $100.00" ); + assert.messageFormatter( "en", "currency/code", { + x: 100, + }, "currency code: 100.00 USD" ); + assert.messageFormatter( "en", "currency/name", { + x: 100, + }, "currency name: 100.00 US dollars" ); + + assert.messageFormatter( "en", "unit/long", { + x: 42, + }, "unit long: 42 seconds" ); + assert.messageFormatter( "en", "unit/short", { + x: 42, + }, "unit short: 42 sec" ); + assert.messageFormatter( "en", "unit/narrow", { + x: 42, + }, "unit narrow: 42s" ); }); // Reference #473