src/core.js
/*
Copyright (c) 2014, Yahoo! Inc. All rights reserved.
Copyrights licensed under the New BSD License.
See the accompanying LICENSE file for terms.
*/
/* jslint esnext: true */
import TagMessageFormat from 'tag-messageformat';
import diff from './diff';
import {
defineProperty,
objCreate,
arrIndexOf,
isArray,
dateNow
} from './es5';
export default RelativeFormat;
// -----------------------------------------------------------------------------
var STANDARD_FIELDS = [
'second',
'minute',
'hour',
'day',
'month',
'year'
];
var SHORT_FIELDS = [
'second-short',
'minute-short',
'hour-short',
'day-short',
'month-short',
'year-short'
];
var FIELDS = STANDARD_FIELDS.concat(SHORT_FIELDS);
var STYLES = ['best fit', 'numeric'];
// -- RelativeFormat -----------------------------------------------------------
function RelativeFormat(locales, options) {
options = options || {};
// Make a copy of `locales` if it's an array, so that it doesn't change
// since it's used lazily.
if (isArray(locales)) {
locales = locales.concat();
}
defineProperty(this, '_locale', {value: this._resolveLocale(locales)});
defineProperty(this, '_options', {value: {
style: this._resolveStyle(options.style),
units: this._isValidUnits(options.units) && options.units
}});
defineProperty(this, '_locales', {value: locales});
defineProperty(this, '_fields', {value: this._findFields(this._locale)});
defineProperty(this, '_messages', {value: objCreate(null)});
// "Bind" `format()` method to `this` so it can be passed by reference like
// the other `Intl` APIs.
var relativeFormat = this;
this.format = function format(date, options) {
return relativeFormat._format(date, options);
};
}
// Define internal private properties for dealing with locale data.
defineProperty(RelativeFormat, '__localeData__', {value: objCreate(null)});
defineProperty(RelativeFormat, '__addLocaleData', {value: function (data) {
if (!(data && data.locale)) {
throw new Error(
'Locale data provided to TagRelativeFormat is missing a ' +
'`locale` property value'
);
}
RelativeFormat.__localeData__[data.locale.toLowerCase()] = data;
// Add data to TagMessageFormat.
TagMessageFormat.__addLocaleData(data);
}});
// Define public `defaultLocale` property which can be set by the developer, or
// it will be set when the first RelativeFormat instance is created by
// leveraging the resolved locale from `Intl`.
defineProperty(RelativeFormat, 'defaultLocale', {
enumerable: true,
writable : true,
value : undefined
});
// Define public `thresholds` property which can be set by the developer, and
// defaults to relative time thresholds from moment.js.
defineProperty(RelativeFormat, 'thresholds', {
enumerable: true,
value: {
second: 45, 'second-short': 45, // seconds to minute
minute: 45, 'minute-short': 45, // minutes to hour
hour : 22, 'hour-short': 22, // hours to day
day : 26, 'day-short': 26, // days to month
month : 11, 'month-short': 11 // months to year
}
});
RelativeFormat.prototype.resolvedOptions = function () {
return {
locale: this._locale,
style : this._options.style,
units : this._options.units
};
};
RelativeFormat.prototype._compileMessage = function (units) {
// `this._locales` is the original set of locales the user specified to the
// constructor, while `this._locale` is the resolved root locale.
var locales = this._locales;
var resolvedLocale = this._locale;
var field = this._fields[units];
var relativeTime = field.relativeTime;
var future = '';
var past = '';
var i;
for (i in relativeTime.future) {
if (relativeTime.future.hasOwnProperty(i)) {
future += ' ' + i + ' {' +
relativeTime.future[i].replace('{0}', '#') + '}';
}
}
for (i in relativeTime.past) {
if (relativeTime.past.hasOwnProperty(i)) {
past += ' ' + i + ' {' +
relativeTime.past[i].replace('{0}', '#') + '}';
}
}
var message = '{when, select, future {{0, plural, ' + future + '}}' +
'past {{0, plural, ' + past + '}}' +
'other {}}';
// Create the synthetic TagMessageFormat instance using the original
// locales value specified by the user when constructing the the parent
// TagRelativeFormat instance.
return new TagMessageFormat(message, locales);
};
RelativeFormat.prototype._getMessage = function (units) {
var messages = this._messages;
// Create a new synthetic message based on the locale data from CLDR.
if (!messages[units]) {
messages[units] = this._compileMessage(units);
}
return messages[units];
};
RelativeFormat.prototype._getRelativeUnits = function (diff, units) {
var field = this._fields[units];
if (field.relative) {
return field.relative[diff];
}
};
RelativeFormat.prototype._findFields = function (locale) {
var localeData = RelativeFormat.__localeData__;
var data = localeData[locale.toLowerCase()];
// The locale data is de-duplicated, so we have to traverse the locale's
// hierarchy until we find `fields` to return.
while (data) {
if (data.fields) {
return data.fields;
}
data = data.parentLocale && localeData[data.parentLocale.toLowerCase()];
}
throw new Error(
'Locale data added to TagRelativeFormat is missing `fields` for :' +
locale
);
};
RelativeFormat.prototype._format = function (date, options) {
var now = options && options.now !== undefined ? options.now : dateNow();
if (date === undefined) {
date = now;
}
// Determine if the `date` and optional `now` values are valid, and throw a
// similar error to what `Intl.DateTimeFormat#format()` would throw.
if (!isFinite(now)) {
throw new RangeError(
'The `now` option provided to TagRelativeFormat#format() is not ' +
'in valid range.'
);
}
if (!isFinite(date)) {
throw new RangeError(
'The date value provided to TagRelativeFormat#format() is not ' +
'in valid range.'
);
}
var diffReport = diff(now, date);
var units = this._options.units || this._selectUnits(
diffReport,
(options && options.thresholds) || RelativeFormat.thresholds);
var diffInUnits = diffReport[units];
if (this._options.style !== 'numeric') {
var relativeUnits = this._getRelativeUnits(diffInUnits, units);
if (relativeUnits) {
return relativeUnits;
}
}
return this._getMessage(units).format({
'0' : Math.abs(diffInUnits),
when: diffInUnits < 0 ? 'past' : 'future'
});
};
RelativeFormat.prototype._isValidUnits = function (units) {
if (!units || arrIndexOf.call(FIELDS, units) >= 0) {
return true;
}
if (typeof units === 'string') {
var suggestion = /s$/.test(units) && units.substr(0, units.length - 1);
if (suggestion && arrIndexOf.call(FIELDS, suggestion) >= 0) {
throw new Error(
'"' + units + '" is not a valid TagRelativeFormat `units` ' +
'value, did you mean: ' + suggestion
);
}
}
throw new Error(
'"' + units + '" is not a valid TagRelativeFormat `units` value, it ' +
'must be one of: "' + FIELDS.join('", "') + '"'
);
};
RelativeFormat.prototype._resolveLocale = function (locales) {
if (typeof locales === 'string') {
locales = [locales];
}
// Create a copy of the array so we can push on the default locale.
locales = (locales || []).concat(RelativeFormat.defaultLocale);
var localeData = RelativeFormat.__localeData__;
var i, len, localeParts, data;
// Using the set of locales + the default locale, we look for the first one
// which that has been registered. When data does not exist for a locale, we
// traverse its ancestors to find something that's been registered within
// its hierarchy of locales. Since we lack the proper `parentLocale` data
// here, we must take a naive approach to traversal.
for (i = 0, len = locales.length; i < len; i += 1) {
localeParts = locales[i].toLowerCase().split('-');
while (localeParts.length) {
data = localeData[localeParts.join('-')];
if (data) {
// Return the normalized locale string; e.g., we return "en-US",
// instead of "en-us".
return data.locale;
}
localeParts.pop();
}
}
var defaultLocale = locales.pop();
throw new Error(
'No locale data has been added to TagRelativeFormat for: ' +
locales.join(', ') + ', or the default locale: ' + defaultLocale
);
};
RelativeFormat.prototype._resolveStyle = function (style) {
// Default to "best fit" style.
if (!style) {
return STYLES[0];
}
if (arrIndexOf.call(STYLES, style) >= 0) {
return style;
}
throw new Error(
'"' + style + '" is not a valid TagRelativeFormat `style` value, it ' +
'must be one of: "' + STYLES.join('", "') + '"'
);
};
RelativeFormat.prototype._selectUnits = function (diffReport, thresholds) {
var i, l, units;
for (i = 0, l = STANDARD_FIELDS.length; i < l; i += 1) {
units = STANDARD_FIELDS[i];
if (Math.abs(diffReport[units]) < thresholds[units]) {
break;
}
}
return units;
};