@messageformat/core
Version:
PluralFormat and SelectFormat Message and i18n Tool - A JavaScript Implemenation of the ICU standards.
1,558 lines (1,538 loc) • 74.7 kB
JavaScript
'use strict';
var parser = require('@messageformat/parser');
var Runtime = require('@messageformat/runtime');
var Formatters = require('@messageformat/runtime/lib/formatters');
var safeIdentifier = require('safe-identifier');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var Runtime__namespace = /*#__PURE__*/_interopNamespaceDefault(Runtime);
var Formatters__namespace = /*#__PURE__*/_interopNamespaceDefault(Formatters);
/**
* Parent class for errors.
*
* @remarks
* Errors with `type: "warning"` do not necessarily indicate that the parser
* encountered an error. In addition to a human-friendly `message`, may also
* includes the `token` at which the error was encountered.
*
* @public
*/
class DateFormatError extends Error {
/** @internal */
constructor(msg, token, type) {
super(msg);
this.token = token;
this.type = type || 'error';
}
}
const alpha = (width) => width < 4 ? 'short' : width === 4 ? 'long' : 'narrow';
const numeric = (width) => (width % 2 === 0 ? '2-digit' : 'numeric');
function yearOptions(token, onError) {
switch (token.char) {
case 'y':
return { year: numeric(token.width) };
case 'r':
return { calendar: 'gregory', year: 'numeric' };
case 'u':
case 'U':
case 'Y':
default:
onError(`${token.desc} is not supported; falling back to year:numeric`, DateFormatError.WARNING);
return { year: 'numeric' };
}
}
function monthStyle(token, onError) {
switch (token.width) {
case 1:
return 'numeric';
case 2:
return '2-digit';
case 3:
return 'short';
case 4:
return 'long';
case 5:
return 'narrow';
default:
onError(`${token.desc} is not supported with width ${token.width}`);
return undefined;
}
}
function dayStyle(token, onError) {
const { char, desc, width } = token;
if (char === 'd') {
return numeric(width);
}
else {
onError(`${desc} is not supported`);
return undefined;
}
}
function weekdayStyle(token, onError) {
const { char, desc, width } = token;
if ((char === 'c' || char === 'e') && width < 3) {
// ignoring stand-alone-ness
const msg = `Numeric value is not supported for ${desc}; falling back to weekday:short`;
onError(msg, DateFormatError.WARNING);
}
// merging narrow styles
return alpha(width);
}
function hourOptions(token) {
const hour = numeric(token.width);
let hourCycle;
switch (token.char) {
case 'h':
hourCycle = 'h12';
break;
case 'H':
hourCycle = 'h23';
break;
case 'k':
hourCycle = 'h24';
break;
case 'K':
hourCycle = 'h11';
break;
}
return hourCycle ? { hour, hourCycle } : { hour };
}
function timeZoneNameStyle(token, onError) {
// so much fallback behaviour here
const { char, desc, width } = token;
switch (char) {
case 'v':
case 'z':
return width === 4 ? 'long' : 'short';
case 'V':
if (width === 4)
return 'long';
onError(`${desc} is not supported with width ${width}`);
return undefined;
case 'X':
onError(`${desc} is not supported`);
return undefined;
}
return 'short';
}
function compileOptions(token, onError) {
switch (token.field) {
case 'era':
return { era: alpha(token.width) };
case 'year':
return yearOptions(token, onError);
case 'month':
return { month: monthStyle(token, onError) };
case 'day':
return { day: dayStyle(token, onError) };
case 'weekday':
return { weekday: weekdayStyle(token, onError) };
case 'period':
return undefined;
case 'hour':
return hourOptions(token);
case 'min':
return { minute: numeric(token.width) };
case 'sec':
return { second: numeric(token.width) };
case 'tz':
return { timeZoneName: timeZoneNameStyle(token, onError) };
case 'quarter':
case 'week':
case 'sec-frac':
case 'ms':
onError(`${token.desc} is not supported`);
}
return undefined;
}
function getDateFormatOptions(tokens, timeZone, onError = error => {
throw error;
}) {
const options = {
timeZone
};
const fields = [];
for (const token of tokens) {
const { error, field, str } = token;
if (error) {
const dte = new DateFormatError(error.message, token);
dte.stack = error.stack;
onError(dte);
}
if (str) {
const msg = `Ignoring string part: ${str}`;
onError(new DateFormatError(msg, token, DateFormatError.WARNING));
}
if (field) {
if (fields.indexOf(field) === -1)
fields.push(field);
else
onError(new DateFormatError(`Duplicate ${field} token`, token));
}
const opt = compileOptions(token, (msg, isWarning) => onError(new DateFormatError(msg, token, isWarning)));
if (opt)
Object.assign(options, opt);
}
return options;
}
const fields = {
G: { field: 'era', desc: 'Era' },
y: { field: 'year', desc: 'Year' },
Y: { field: 'year', desc: 'Year of "Week of Year"' },
u: { field: 'year', desc: 'Extended year' },
U: { field: 'year', desc: 'Cyclic year name' },
r: { field: 'year', desc: 'Related Gregorian year' },
Q: { field: 'quarter', desc: 'Quarter' },
q: { field: 'quarter', desc: 'Stand-alone quarter' },
M: { field: 'month', desc: 'Month in year' },
L: { field: 'month', desc: 'Stand-alone month in year' },
w: { field: 'week', desc: 'Week of year' },
W: { field: 'week', desc: 'Week of month' },
d: { field: 'day', desc: 'Day in month' },
D: { field: 'day', desc: 'Day of year' },
F: { field: 'day', desc: 'Day of week in month' },
g: { field: 'day', desc: 'Modified julian day' },
E: { field: 'weekday', desc: 'Day of week' },
e: { field: 'weekday', desc: 'Local day of week' },
c: { field: 'weekday', desc: 'Stand-alone local day of week' },
a: { field: 'period', desc: 'AM/PM marker' },
b: { field: 'period', desc: 'AM/PM/noon/midnight marker' },
B: { field: 'period', desc: 'Flexible day period' },
h: { field: 'hour', desc: 'Hour in AM/PM (1~12)' },
H: { field: 'hour', desc: 'Hour in day (0~23)' },
k: { field: 'hour', desc: 'Hour in day (1~24)' },
K: { field: 'hour', desc: 'Hour in AM/PM (0~11)' },
j: { field: 'hour', desc: 'Hour in preferred cycle' },
J: { field: 'hour', desc: 'Hour in preferred cycle without marker' },
C: { field: 'hour', desc: 'Hour in preferred cycle with flexible marker' },
m: { field: 'min', desc: 'Minute in hour' },
s: { field: 'sec', desc: 'Second in minute' },
S: { field: 'sec-frac', desc: 'Fractional second' },
A: { field: 'ms', desc: 'Milliseconds in day' },
z: { field: 'tz', desc: 'Time Zone: specific non-location' },
Z: { field: 'tz', desc: 'Time Zone' },
O: { field: 'tz', desc: 'Time Zone: localized' },
v: { field: 'tz', desc: 'Time Zone: generic non-location' },
V: { field: 'tz', desc: 'Time Zone: ID' },
X: { field: 'tz', desc: 'Time Zone: ISO8601 with Z' },
x: { field: 'tz', desc: 'Time Zone: ISO8601' }
};
const isLetter = (char) => (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z');
function readFieldToken(src, pos) {
const char = src[pos];
let width = 1;
while (src[++pos] === char)
++width;
const field = fields[char];
if (!field) {
const msg = `The letter ${char} is not a valid field identifier`;
return { char, error: new Error(msg), width };
}
return { char, field: field.field, desc: field.desc, width };
}
function readQuotedToken(src, pos) {
let str = src[++pos];
let width = 2;
if (str === "'")
return { char: "'", str, width };
while (true) {
const next = src[++pos];
++width;
if (next === undefined) {
const msg = `Unterminated quoted literal in pattern: ${str || src}`;
return { char: "'", error: new Error(msg), str, width };
}
else if (next === "'") {
if (src[++pos] !== "'")
return { char: "'", str, width };
else
++width;
}
str += next;
}
}
function readToken(src, pos) {
const char = src[pos];
if (!char)
return null;
if (isLetter(char))
return readFieldToken(src, pos);
if (char === "'")
return readQuotedToken(src, pos);
let str = char;
let width = 1;
while (true) {
const next = src[++pos];
if (!next || isLetter(next) || next === "'")
return { char, str, width };
str += next;
width += 1;
}
}
/**
* Parse an {@link http://userguide.icu-project.org/formatparse/datetime | ICU
* DateFormat skeleton} string into a {@link DateToken} array.
*
* @remarks
* Errors will not be thrown, but if encountered are included as the relevant
* token's `error` value.
*
* @public
* @param src - The skeleton string
*
* @example
* ```js
* import { parseDateTokens } from '@messageformat/date-skeleton'
*
* parseDateTokens('GrMMMdd', console.error)
* // [
* // { char: 'G', field: 'era', desc: 'Era', width: 1 },
* // { char: 'r', field: 'year', desc: 'Related Gregorian year', width: 1 },
* // { char: 'M', field: 'month', desc: 'Month in year', width: 3 },
* // { char: 'd', field: 'day', desc: 'Day in month', width: 2 }
* // ]
* ```
*/
function parseDateTokens(src) {
const tokens = [];
let pos = 0;
while (true) {
const token = readToken(src, pos);
if (!token)
return tokens;
tokens.push(token);
pos += token.width;
}
}
/**
* Returns a date formatter function for the given locales and date skeleton
*
* @remarks
* Uses `Intl.DateTimeFormat` internally.
*
* @public
* @param locales - One or more valid BCP 47 language tags, e.g. `fr` or `en-CA`
* @param tokens - An ICU DateFormat skeleton string, or an array or parsed
* `DateToken` tokens
* @param onError - If defined, will be called separately for each encountered
* parsing error and unsupported feature.
* @example
* ```js
* import { getDateFormatter } from '@messageformat/date-skeleton'
*
* // 2006 Jan 2, 15:04:05.789 in local time
* const date = new Date(2006, 0, 2, 15, 4, 5, 789)
*
* let fmt = getDateFormatter('en-CA', 'GrMMMdd', console.error)
* fmt(date) // 'Jan. 02, 2006 AD'
*
* fmt = getDateFormatter('en-CA', 'hamszzzz', console.error)
* fmt(date) // '3:04:05 p.m. Newfoundland Daylight Time'
* ```
*/
function getDateFormatter(locales, tokens, timeZone, onError) {
if (typeof tokens === 'string')
tokens = parseDateTokens(tokens);
if (typeof timeZone === 'function') {
onError = timeZone;
timeZone = undefined;
}
const opt = getDateFormatOptions(tokens, timeZone, onError);
const dtf = new Intl.DateTimeFormat(locales, opt);
return (date) => dtf.format(date);
}
/**
* Returns a string of JavaScript source that evaluates to a date formatter
* function with the same `(date: Date | number) => string` signature as the
* function returned by {@link getDateFormatter}.
*
* @remarks
* The returned function will memoize an `Intl.DateTimeFormat` instance.
*
* @public
* @param locales - One or more valid BCP 47 language tags, e.g. `fr` or `en-CA`
* @param tokens - An ICU DateFormat skeleton string, or an array or parsed
* `DateToken` tokens
* @param onError - If defined, will be called separately for each encountered
* parsing error and unsupported feature.
* @example
* ```js
* import { getDateFormatterSource } from '@messageformat/date-skeleton'
*
* getDateFormatterSource('en-CA', 'GrMMMdd', console.error)
* // '(function() {\n' +
* // ' var opt = {"era":"short","calendar":"gregory","year":"numeric",' +
* // '"month":"short","day":"2-digit"};\n' +
* // ' var dtf = new Intl.DateTimeFormat("en-CA", opt);\n' +
* // ' return function(value) { return dtf.format(value); }\n' +
* // '})()'
*
* const src = getDateFormatterSource('en-CA', 'hamszzzz', console.error)
* // '(function() {\n' +
* // ' var opt = {"hour":"numeric","hourCycle":"h12","minute":"numeric",' +
* // '"second":"numeric","timeZoneName":"long"};\n' +
* // ' var dtf = new Intl.DateTimeFormat("en-CA", opt);\n' +
* // ' return function(value) { return dtf.format(value); }\n' +
* // '})()'
*
* const fmt = new Function(`return ${src}`)()
* const date = new Date(2006, 0, 2, 15, 4, 5, 789)
* fmt(date) // '3:04:05 p.m. Newfoundland Daylight Time'
* ```
*/
function getDateFormatterSource(locales, tokens, timeZone, onError) {
if (typeof tokens === 'string')
tokens = parseDateTokens(tokens);
if (typeof timeZone === 'function') {
onError = timeZone;
timeZone = undefined;
}
const opt = getDateFormatOptions(tokens, timeZone, onError);
const lines = [
`(function() {`,
`var opt = ${JSON.stringify(opt)};`,
`var dtf = new Intl.DateTimeFormat(${JSON.stringify(locales)}, opt);`,
`return function(value) { return dtf.format(value); }`
];
return lines.join('\n ') + '\n})()';
}
/**
* Base class for errors. In addition to a `code` and a human-friendly
* `message`, may also includes the token `stem` as well as other fields.
*
* @public
*/
class NumberFormatError extends Error {
/** @internal */
constructor(code, msg) {
super(msg);
this.code = code;
}
}
/** @internal */
class BadOptionError extends NumberFormatError {
constructor(stem, opt) {
super('BAD_OPTION', `Unknown ${stem} option: ${opt}`);
this.stem = stem;
this.option = opt;
}
}
/** @internal */
class BadStemError extends NumberFormatError {
constructor(stem) {
super('BAD_STEM', `Unknown stem: ${stem}`);
this.stem = stem;
}
}
/** @internal */
class MaskedValueError extends NumberFormatError {
constructor(type, prev) {
super('MASKED_VALUE', `Value for ${type} is set multiple times`);
this.type = type;
this.prev = prev;
}
}
/** @internal */
class MissingOptionError extends NumberFormatError {
constructor(stem) {
super('MISSING_OPTION', `Required option missing for ${stem}`);
this.stem = stem;
}
}
/** @internal */
class PatternError extends NumberFormatError {
constructor(char, msg) {
super('BAD_PATTERN', msg);
this.char = char;
}
}
/** @internal */
class TooManyOptionsError extends NumberFormatError {
constructor(stem, options, maxOpt) {
const maxOptStr = maxOpt > 1 ? `${maxOpt} options` : 'one option';
super('TOO_MANY_OPTIONS', `Token ${stem} only supports ${maxOptStr} (got ${options.length})`);
this.stem = stem;
this.options = options;
}
}
/** @internal */
class UnsupportedError extends NumberFormatError {
constructor(stem, source) {
super('UNSUPPORTED', `The stem ${stem} is not supported`);
this.stem = stem;
if (source) {
this.message += ` with value ${source}`;
this.source = source;
}
}
}
/**
* Add
* {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#Locale_identification_and_negotiation | numbering-system tags}
* to locale identifiers
*
* @internal
*/
function getNumberFormatLocales(locales, { numberingSystem }) {
if (!Array.isArray(locales))
locales = [locales];
return numberingSystem
? locales
.map(lc => {
const ext = lc.indexOf('-u-') === -1 ? 'u-nu' : 'nu';
return `${lc}-${ext}-${numberingSystem}`;
})
.concat(locales)
: locales;
}
// from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round
function round(x, precision) {
const y = +x + precision / 2;
return y - (y % +precision);
}
function getNumberFormatMultiplier({ scale, unit }) {
let mult = typeof scale === 'number' && scale >= 0 ? scale : 1;
if (unit && unit.style === 'percent')
mult *= 0.01;
return mult;
}
/**
* Determine a modifier for the input value to account for any `scale`,
* `percent`, and `precision-increment` tokens in the skeleton.
*
* @internal
* @remarks
* With ICU NumberFormatter, the `percent` skeleton would style `25` as "25%".
* To achieve the same with `Intl.NumberFormat`, the input value must be `0.25`.
*/
function getNumberFormatModifier(skeleton) {
const mult = getNumberFormatMultiplier(skeleton);
const { precision } = skeleton;
if (precision && precision.style === 'precision-increment') {
return (n) => round(n, precision.increment) * mult;
}
else {
return (n) => n * mult;
}
}
/**
* Returns a string of JavaScript source that evaluates to a modifier for the
* input value to account for any `scale`, `percent`, and `precision-increment`
* tokens in the skeleton.
*
* @internal
* @remarks
* With ICU NumberFormatter, the `percent` skeleton would style `25` as "25%".
* To achieve the same with `Intl.NumberFormat`, the input value must be `0.25`.
*/
function getNumberFormatModifierSource(skeleton) {
const mult = getNumberFormatMultiplier(skeleton);
const { precision } = skeleton;
if (precision && precision.style === 'precision-increment') {
// see round() above for source
const setX = `+n + ${precision.increment / 2}`;
let res = `x - (x % +${precision.increment})`;
if (mult !== 1)
res = `(${res}) * ${mult}`;
return `function(n) { var x = ${setX}; return ${res}; }`;
}
return mult !== 1 ? `function(n) { return n * ${mult}; }` : null;
}
/**
* Given an input ICU NumberFormatter skeleton, does its best to construct a
* corresponding `Intl.NumberFormat` options structure.
*
* @remarks
* Some features depend on `Intl.NumberFormat` features defined in ES2020.
*
* @internal
* @param onUnsupported - If defined, called when encountering unsupported (but
* valid) tokens, such as `decimal-always` or `permille`. The error `source`
* may specify the source of an unsupported option.
*
* @example
* ```js
* import {
* getNumberFormatOptions,
* parseNumberSkeleton
* } from '@messageformat/number-skeleton'
*
* const src = 'currency/CAD unit-width-narrow'
* const skeleton = parseNumberSkeleton(src, console.error)
* // {
* // unit: { style: 'currency', currency: 'CAD' },
* // unitWidth: 'unit-width-narrow'
* // }
*
* getNumberFormatOptions(skeleton, console.error)
* // {
* // style: 'currency',
* // currency: 'CAD',
* // currencyDisplay: 'narrowSymbol',
* // unitDisplay: 'narrow'
* // }
*
* const sk2 = parseNumberSkeleton('group-min2')
* // { group: 'group-min2' }
*
* getNumberFormatOptions(sk2, console.error)
* // Error: The stem group-min2 is not supported
* // at UnsupportedError.NumberFormatError ... {
* // code: 'UNSUPPORTED',
* // stem: 'group-min2'
* // }
* // {}
* ```
*/
function getNumberFormatOptions(skeleton, onUnsupported) {
const { decimal, group, integerWidth, notation, precision, roundingMode, sign, unit, unitPer, unitWidth } = skeleton;
const fail = (stem, source) => {
if (onUnsupported)
onUnsupported(new UnsupportedError(stem, source));
};
const opt = {};
if (unit) {
switch (unit.style) {
case 'base-unit':
opt.style = 'decimal';
break;
case 'currency':
opt.style = 'currency';
opt.currency = unit.currency;
break;
case 'measure-unit':
opt.style = 'unit';
opt.unit = unit.unit.replace(/.*-/, '');
if (unitPer)
opt.unit += '-per-' + unitPer.replace(/.*-/, '');
break;
case 'percent':
opt.style = 'percent';
break;
case 'permille':
fail('permille');
break;
}
}
switch (unitWidth) {
case 'unit-width-full-name':
opt.currencyDisplay = 'name';
opt.unitDisplay = 'long';
break;
case 'unit-width-hidden':
fail(unitWidth);
break;
case 'unit-width-iso-code':
opt.currencyDisplay = 'code';
break;
case 'unit-width-narrow':
opt.currencyDisplay = 'narrowSymbol';
opt.unitDisplay = 'narrow';
break;
case 'unit-width-short':
opt.currencyDisplay = 'symbol';
opt.unitDisplay = 'short';
break;
}
switch (group) {
case 'group-off':
opt.useGrouping = false;
break;
case 'group-auto':
opt.useGrouping = true;
break;
case 'group-min2':
case 'group-on-aligned':
case 'group-thousands':
fail(group);
opt.useGrouping = true;
break;
}
if (precision) {
switch (precision.style) {
case 'precision-fraction': {
const { minFraction: minF, maxFraction: maxF, minSignificant: minS, maxSignificant: maxS, source } = precision;
if (typeof minF === 'number') {
opt.minimumFractionDigits = minF;
if (typeof minS === 'number')
fail('precision-fraction', source);
}
if (typeof maxF === 'number')
opt.maximumFractionDigits = maxF;
if (typeof minS === 'number')
opt.minimumSignificantDigits = minS;
if (typeof maxS === 'number')
opt.maximumSignificantDigits = maxS;
break;
}
case 'precision-integer':
opt.maximumFractionDigits = 0;
break;
case 'precision-unlimited':
opt.maximumFractionDigits = 20;
break;
case 'precision-increment':
break;
case 'precision-currency-standard':
opt.trailingZeroDisplay = precision.trailingZero;
break;
case 'precision-currency-cash':
fail(precision.style);
break;
}
}
if (notation) {
switch (notation.style) {
case 'compact-short':
opt.notation = 'compact';
opt.compactDisplay = 'short';
break;
case 'compact-long':
opt.notation = 'compact';
opt.compactDisplay = 'long';
break;
case 'notation-simple':
opt.notation = 'standard';
break;
case 'scientific':
case 'engineering': {
const { expDigits, expSign, source, style } = notation;
opt.notation = style;
if ((expDigits && expDigits > 1) ||
(expSign && expSign !== 'sign-auto')) {
fail(style, source);
}
break;
}
}
}
if (integerWidth) {
const { min, max, source } = integerWidth;
if (min > 0)
opt.minimumIntegerDigits = min;
if (Number(max) > 0) {
const hasExp = opt.notation === 'engineering' || opt.notation === 'scientific';
if (max === 3 && hasExp)
opt.notation = 'engineering';
else
fail('integer-width', source);
}
}
switch (sign) {
case 'sign-auto':
opt.signDisplay = 'auto';
break;
case 'sign-always':
opt.signDisplay = 'always';
break;
case 'sign-except-zero':
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore https://github.com/microsoft/TypeScript/issues/46712
opt.signDisplay = 'exceptZero';
break;
case 'sign-never':
opt.signDisplay = 'never';
break;
case 'sign-accounting':
opt.currencySign = 'accounting';
break;
case 'sign-accounting-always':
opt.currencySign = 'accounting';
opt.signDisplay = 'always';
break;
case 'sign-accounting-except-zero':
opt.currencySign = 'accounting';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore https://github.com/microsoft/TypeScript/issues/46712
opt.signDisplay = 'exceptZero';
break;
}
if (decimal === 'decimal-always')
fail(decimal);
if (roundingMode)
fail(roundingMode);
return opt;
}
function parseAffixToken(src, pos, onError) {
const char = src[pos];
switch (char) {
case '%':
return { char: '%', style: 'percent', width: 1 };
case '‰':
return { char: '%', style: 'permille', width: 1 };
case '¤': {
let width = 1;
while (src[++pos] === '¤')
++width;
switch (width) {
case 1:
return { char, currency: 'default', width };
case 2:
return { char, currency: 'iso-code', width };
case 3:
return { char, currency: 'full-name', width };
case 5:
return { char, currency: 'narrow', width };
default: {
const msg = `Invalid number (${width}) of ¤ chars in pattern`;
onError(new PatternError('¤', msg));
return null;
}
}
}
case '*': {
const pad = src[pos + 1];
if (pad)
return { char, pad, width: 2 };
break;
}
case '+':
case '-':
return { char, width: 1 };
case "'": {
let str = src[++pos];
let width = 2;
if (str === "'")
return { char, str, width };
while (true) {
const next = src[++pos];
++width;
if (next === undefined) {
const msg = `Unterminated quoted literal in pattern: ${str}`;
onError(new PatternError("'", msg));
return { char, str, width };
}
else if (next === "'") {
if (src[++pos] !== "'")
return { char, str, width };
else
++width;
}
str += next;
}
}
}
return null;
}
const isDigit = (char) => char >= '0' && char <= '9';
function parseNumberToken(src, pos) {
const char = src[pos];
if (isDigit(char)) {
let digits = char;
while (true) {
const next = src[++pos];
if (isDigit(next))
digits += next;
else
return { char: '0', digits, width: digits.length };
}
}
switch (char) {
case '#': {
let width = 1;
while (src[++pos] === '#')
++width;
return { char, width };
}
case '@': {
let min = 1;
while (src[++pos] === '@')
++min;
let width = min;
pos -= 1;
while (src[++pos] === '#')
++width;
return { char, min, width };
}
case 'E': {
const plus = src[pos + 1] === '+';
if (plus)
++pos;
let expDigits = 0;
while (src[++pos] === '0')
++expDigits;
const width = (plus ? 2 : 1) + expDigits;
if (expDigits)
return { char, expDigits, plus, width };
else
break;
}
case '.':
case ',':
return { char, width: 1 };
}
return null;
}
function parseSubpattern(src, pos, onError) {
let State;
(function (State) {
State[State["Prefix"] = 0] = "Prefix";
State[State["Number"] = 1] = "Number";
State[State["Suffix"] = 2] = "Suffix";
})(State || (State = {}));
const prefix = [];
const number = [];
const suffix = [];
let state = State.Prefix;
let str = '';
while (pos < src.length) {
const char = src[pos];
if (char === ';') {
pos += 1;
break;
}
switch (state) {
case State.Prefix: {
const token = parseAffixToken(src, pos, onError);
if (token) {
if (str) {
prefix.push({ char: "'", str, width: str.length });
str = '';
}
prefix.push(token);
pos += token.width;
}
else {
const token = parseNumberToken(src, pos);
if (token) {
if (str) {
prefix.push({ char: "'", str, width: str.length });
str = '';
}
state = State.Number;
number.push(token);
pos += token.width;
}
else {
str += char;
pos += 1;
}
}
break;
}
case State.Number: {
const token = parseNumberToken(src, pos);
if (token) {
number.push(token);
pos += token.width;
}
else {
state = State.Suffix;
}
break;
}
case State.Suffix: {
const token = parseAffixToken(src, pos, onError);
if (token) {
if (str) {
suffix.push({ char: "'", str, width: str.length });
str = '';
}
suffix.push(token);
pos += token.width;
}
else {
str += char;
pos += 1;
}
break;
}
}
}
if (str)
suffix.push({ char: "'", str, width: str.length });
return { pattern: { prefix, number, suffix }, pos };
}
function parseTokens(src, onError) {
const { pattern, pos } = parseSubpattern(src, 0, onError);
if (pos < src.length) {
const { pattern: negative } = parseSubpattern(src, pos, onError);
return { tokens: pattern, negative };
}
return { tokens: pattern };
}
function parseNumberAsSkeleton(tokens, onError) {
const res = {};
let hasGroups = false;
let hasExponent = false;
let intOptional = 0;
let intDigits = '';
let decimalPos = -1;
let fracDigits = '';
let fracOptional = 0;
for (let pos = 0; pos < tokens.length; ++pos) {
const token = tokens[pos];
switch (token.char) {
case '#': {
if (decimalPos === -1) {
if (intDigits) {
const msg = 'Pattern has # after integer digits';
onError(new PatternError('#', msg));
}
intOptional += token.width;
}
else {
fracOptional += token.width;
}
break;
}
case '0': {
if (decimalPos === -1) {
intDigits += token.digits;
}
else {
if (fracOptional) {
const msg = 'Pattern has digits after # in fraction';
onError(new PatternError('0', msg));
}
fracDigits += token.digits;
}
break;
}
case '@': {
if (res.precision) {
onError(new MaskedValueError('precision', res.precision));
}
res.precision = {
style: 'precision-fraction',
minSignificant: token.min,
maxSignificant: token.width
};
break;
}
case ',':
hasGroups = true;
break;
case '.':
if (decimalPos === 1) {
const msg = 'Pattern has more than one decimal separator';
onError(new PatternError('.', msg));
}
decimalPos = pos;
break;
case 'E': {
if (hasExponent) {
onError(new MaskedValueError('exponent', res.notation));
}
if (hasGroups) {
const msg = 'Exponential patterns may not contain grouping separators';
onError(new PatternError('E', msg));
}
res.notation = { style: 'scientific' };
if (token.expDigits > 1)
res.notation.expDigits = token.expDigits;
if (token.plus)
res.notation.expSign = 'sign-always';
hasExponent = true;
}
}
}
// imprecise mapping due to paradigm differences
if (hasGroups)
res.group = 'group-auto';
else if (intOptional + intDigits.length > 3)
res.group = 'group-off';
const increment = Number(`${intDigits || '0'}.${fracDigits}`);
if (increment)
res.precision = { style: 'precision-increment', increment };
if (!hasExponent) {
if (intDigits.length > 1)
res.integerWidth = { min: intDigits.length };
if (!res.precision && (fracDigits.length || fracOptional)) {
res.precision = {
style: 'precision-fraction',
minFraction: fracDigits.length,
maxFraction: fracDigits.length + fracOptional
};
}
}
else {
if (!res.precision || increment) {
res.integerWidth = intOptional
? { min: 1, max: intOptional + intDigits.length }
: { min: Math.max(1, intDigits.length) };
}
if (res.precision) {
if (!increment)
res.integerWidth = { min: 1, max: 1 };
}
else {
const dc = intDigits.length + fracDigits.length;
if (decimalPos === -1) {
if (dc > 0) {
res.precision = { style: 'precision-fraction', maxSignificant: dc };
}
}
else {
res.precision = {
style: 'precision-fraction',
maxSignificant: Math.max(1, dc) + fracOptional
};
if (dc > 1)
res.precision.minSignificant = dc;
}
}
}
return res;
}
function handleAffix(affixTokens, res, currency, onError, isPrefix) {
let inFmt = false;
let str = '';
for (const token of affixTokens) {
switch (token.char) {
case '%':
res.unit = { style: token.style };
if (isPrefix)
inFmt = true;
else
str = '';
break;
case '¤':
if (!currency) {
const msg = `The ¤ pattern requires a currency`;
onError(new PatternError('¤', msg));
break;
}
res.unit = { style: 'currency', currency };
switch (token.currency) {
case 'iso-code':
res.unitWidth = 'unit-width-iso-code';
break;
case 'full-name':
res.unitWidth = 'unit-width-full-name';
break;
case 'narrow':
res.unitWidth = 'unit-width-narrow';
break;
}
if (isPrefix)
inFmt = true;
else
str = '';
break;
case '*':
// TODO
break;
case '+':
if (!inFmt)
str += '+';
break;
case "'":
if (!inFmt)
str += token.str;
break;
}
}
return str;
}
function getNegativeAffix(affixTokens, isPrefix) {
let inFmt = false;
let str = '';
for (const token of affixTokens) {
switch (token.char) {
case '%':
case '¤':
if (isPrefix)
inFmt = true;
else
str = '';
break;
case '-':
if (!inFmt)
str += '-';
break;
case "'":
if (!inFmt)
str += token.str;
break;
}
}
return str;
}
/**
* Parse an {@link
* http://unicode.org/reports/tr35/tr35-numbers.html#Number_Format_Patterns |
* ICU NumberFormatter pattern} string into a {@link Skeleton} structure.
*
* @public
* @param src - The pattern string
* @param currency - If the pattern includes ¤ tokens, their skeleton
* representation requires a three-letter currency code.
* @param onError - Called when the parser encounters a syntax error. The
* function will still return a {@link Skeleton}, but it will be incomplete
* and/or inaccurate. If not defined, the error will be thrown instead.
*
* @remarks
* Unlike the skeleton parser, the pattern parser is not able to return partial
* results on error, and will instead throw. Output padding is not supported.
*
* @example
* ```js
* import { parseNumberPattern } from '@messageformat/number-skeleton'
*
* parseNumberPattern('#,##0.00 ¤', 'EUR', console.error)
* // {
* // group: 'group-auto',
* // precision: {
* // style: 'precision-fraction',
* // minFraction: 2,
* // maxFraction: 2
* // },
* // unit: { style: 'currency', currency: 'EUR' }
* // }
* ```
*/
function parseNumberPattern(src, currency, onError = error => {
throw error;
}) {
const { tokens, negative } = parseTokens(src, onError);
const res = parseNumberAsSkeleton(tokens.number, onError);
const prefix = handleAffix(tokens.prefix, res, currency, onError, true);
const suffix = handleAffix(tokens.suffix, res, currency, onError, false);
if (negative) {
const negPrefix = getNegativeAffix(negative.prefix, true);
const negSuffix = getNegativeAffix(negative.suffix, false);
res.affix = { pos: [prefix, suffix], neg: [negPrefix, negSuffix] };
res.sign = 'sign-never';
}
else if (prefix || suffix) {
res.affix = { pos: [prefix, suffix] };
}
return res;
}
/** @internal */
function isNumberingSystem(ns) {
const systems = [
'arab',
'arabext',
'bali',
'beng',
'deva',
'fullwide',
'gujr',
'guru',
'hanidec',
'khmr',
'knda',
'laoo',
'latn',
'limb',
'mlym',
'mong',
'mymr',
'orya',
'tamldec',
'telu',
'thai',
'tibt'
];
return systems.indexOf(ns) !== -1;
}
// FIXME: subtype is not checked
/** @internal */
function isUnit(unit) {
const types = [
'acceleration',
'angle',
'area',
'concentr',
'consumption',
'digital',
'duration',
'electric',
'energy',
'force',
'frequency',
'graphics',
'length',
'light',
'mass',
'power',
'pressure',
'speed',
'temperature',
'torque',
'volume'
];
const [type] = unit.split('-', 1);
return types.indexOf(type) !== -1;
}
const maxOptions = {
'compact-short': 0,
'compact-long': 0,
'notation-simple': 0,
scientific: 2,
engineering: 2,
percent: 0,
permille: 0,
'base-unit': 0,
currency: 1,
'measure-unit': 1,
'per-measure-unit': 1,
'unit-width-narrow': 0,
'unit-width-short': 0,
'unit-width-full-name': 0,
'unit-width-iso-code': 0,
'unit-width-hidden': 0,
'precision-integer': 0,
'precision-unlimited': 0,
'precision-currency-standard': 1,
'precision-currency-cash': 0,
'precision-increment': 1,
'rounding-mode-ceiling': 0,
'rounding-mode-floor': 0,
'rounding-mode-down': 0,
'rounding-mode-up': 0,
'rounding-mode-half-even': 0,
'rounding-mode-half-down': 0,
'rounding-mode-half-up': 0,
'rounding-mode-unnecessary': 0,
'integer-width': 1,
scale: 1,
'group-off': 0,
'group-min2': 0,
'group-auto': 0,
'group-on-aligned': 0,
'group-thousands': 0,
latin: 0,
'numbering-system': 1,
'sign-auto': 0,
'sign-always': 0,
'sign-never': 0,
'sign-accounting': 0,
'sign-accounting-always': 0,
'sign-except-zero': 0,
'sign-accounting-except-zero': 0,
'decimal-auto': 0,
'decimal-always': 0
};
const minOptions = {
currency: 1,
'integer-width': 1,
'measure-unit': 1,
'numbering-system': 1,
'per-measure-unit': 1,
'precision-increment': 1,
scale: 1
};
function hasMaxOption(stem) {
return stem in maxOptions;
}
function hasMinOption(stem) {
return stem in minOptions;
}
/** @internal */
function validOptions(stem, options, onError) {
if (hasMaxOption(stem)) {
const maxOpt = maxOptions[stem];
if (options.length > maxOpt) {
if (maxOpt === 0) {
for (const opt of options)
onError(new BadOptionError(stem, opt));
}
else {
onError(new TooManyOptionsError(stem, options, maxOpt));
}
return false;
}
else if (hasMinOption(stem) && options.length < minOptions[stem]) {
onError(new MissingOptionError(stem));
return false;
}
}
return true;
}
function parseBlueprintDigits(src, style) {
const re = style === 'fraction' ? /^\.(0*)(\+|#*)$/ : /^(@+)(\+|#*)$/;
const match = src && src.match(re);
if (match) {
const min = match[1].length;
switch (match[2].charAt(0)) {
case '':
return { min, max: min };
case '+':
return { min, max: null };
case '#': {
return { min, max: min + match[2].length };
}
}
}
return null;
}
function parsePrecisionBlueprint(stem, options, onError) {
const fd = parseBlueprintDigits(stem, 'fraction');
if (fd) {
if (options.length > 1)
onError(new TooManyOptionsError(stem, options, 1));
const res = {
style: 'precision-fraction',
source: stem,
minFraction: fd.min
};
if (fd.max != null)
res.maxFraction = fd.max;
const option = options[0];
const sd = parseBlueprintDigits(option, 'significant');
if (sd) {
res.source = `${stem}/${option}`;
res.minSignificant = sd.min;
if (sd.max != null)
res.maxSignificant = sd.max;
}
else if (option) {
onError(new BadOptionError(stem, option));
}
return res;
}
const sd = parseBlueprintDigits(stem, 'significant');
if (sd) {
for (const opt of options)
onError(new BadOptionError(stem, opt));
const res = {
style: 'precision-fraction',
source: stem,
minSignificant: sd.min
};
if (sd.max != null)
res.maxSignificant = sd.max;
return res;
}
return null;
}
/** @internal */
class TokenParser {
constructor(onError) {
this.skeleton = {};
this.onError = onError;
}
badOption(stem, opt) {
this.onError(new BadOptionError(stem, opt));
}
assertEmpty(key) {
const prev = this.skeleton[key];
if (prev)
this.onError(new MaskedValueError(key, prev));
}
parseToken(stem, options) {
if (!validOptions(stem, options, this.onError))
return;
const option = options[0];
const res = this.skeleton;
switch (stem) {
// notation
case 'compact-short':
case 'compact-long':
case 'notation-simple':
this.assertEmpty('notation');
res.notation = { style: stem };
break;
case 'scientific':
case 'engineering': {
let expDigits = null;
let expSign = undefined;
for (const opt of options) {
switch (opt) {
case 'sign-auto':
case 'sign-always':
case 'sign-never':
case 'sign-accounting':
case 'sign-accounting-always':
case 'sign-except-zero':
case 'sign-accounting-except-zero':
expSign = opt;
break;
default:
if (/^\+e+$/.test(opt)) {
expDigits = opt.length - 1;
}
else {
this.badOption(stem, opt);
}
}
}
this.assertEmpty('notation');
const source = options.join('/');
res.notation =
expDigits && expSign
? { style: stem, source, expDigits, expSign }
: expDigits
? { style: stem, source, expDigits }
: expSign
? { style: stem, source, expSign }
: { style: stem, source };
break;
}
// unit
case 'percent':
case 'permille':
case 'base-unit':
this.assertEmpty('unit');
res.unit = { style: stem };
break;
case 'currency':
if (/^[A-Z]{3}$/.test(option)) {
this.assertEmpty('unit');
res.unit = { style: stem, currency: option };
}
else {
this.badOption(stem, option);
}
break;
case 'measure-unit': {
if (isUnit(option)) {
this.assertEmpty('unit');
res.unit = { style: stem, unit: option };
}
else {
this.badOption(stem, option);
}
break;
}
// unitPer
case 'per-measure-unit': {
if (isUnit(option)) {
this.assertEmpty('unitPer');
res.unitPer = option;
}
else {
this.badOption(stem, option);
}
break;
}
// unitWidth
case 'unit-width-narrow':
case 'unit-width-short':
case 'unit-width-full-name':
case 'unit-width-iso-code':
case 'unit-width-hidden':
this.assertEmpty('unitWidth');
res.unitWidth = stem;
break;
// precision
case 'precision-integer':
case 'precision-unlimited':
case 'precision-currency-cash':
this.assertEmpty('precision');
res.precision = { style: stem };
break;
case 'precision-currency-standard':
this.assertEmpty('precision');
if (option === 'w') {
res.precision = { style: stem, trailingZero: 'stripIfInteger' };
}
else {
res.precision = { style: st