UNPKG

@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
'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