@messageformat/icu-messageformat-1
Version:
Compile ICU MessageFormat 1 sources into MessageFormat 2 formatters
432 lines (431 loc) • 15.3 kB
JavaScript
import { getDateTimeFormatOptions, parseDateTokens } from '@messageformat/date-skeleton';
import { getNumberFormatOptions, parseNumberPattern, parseNumberSkeleton } from '@messageformat/number-skeleton';
const isAstSelect = (token) => token.type === 'plural' ||
token.type === 'select' ||
token.type === 'selectordinal';
const asPluralKey = (key) => /^=\d+$/.test(key) ? Number(key.substring(1)) : key;
function asSelectArg(sel) {
const isPlural = sel.type !== 'select';
const keys = sel.cases.map(c => (isPlural ? asPluralKey(c.key) : c.key));
const arg = { type: sel.type, arg: sel.arg, keys };
if ('pluralOffset' in sel)
arg.pluralOffset = sel.pluralOffset;
return arg;
}
const equalSelectArgs = (a) => (b) => a.arg === b.arg && a.pluralOffset === b.pluralOffset && a.type === b.type;
function findSelectArgs(tokens) {
const args = [];
const add = (arg) => {
const prev = args.find(equalSelectArgs(arg));
if (prev)
for (const key of arg.keys)
prev.keys.push(key);
else
args.push(arg);
};
for (const token of tokens) {
if (isAstSelect(token)) {
add(asSelectArg(token));
for (const c of token.cases) {
for (const arg of findSelectArgs(c.tokens))
add(arg);
}
}
}
return args;
}
function parseDateTimeArgStyle(argStyle) {
const options = new Map();
const onError = () => options.set('mf1:argStyle', { type: 'literal', value: argStyle });
const tokens = parseDateTokens(argStyle.substring(2));
const dtfOpt = getDateTimeFormatOptions(tokens, onError);
loop: for (let [key, value] of Object.entries(dtfOpt)) {
switch (key) {
case 'dayPeriod':
onError();
continue loop;
case 'hourCycle':
key = 'hour12';
value = String(value === 'h11' || value === 'h12');
break;
}
options.set(key, { type: 'literal', value });
}
return options;
}
function parseNumberArgStyle(argStyle) {
let name = 'number';
const options = new Map();
const onError = () => {
if (name === 'number')
name = 'mf1:number';
options.set('mf1:argStyle', { type: 'literal', value: argStyle });
};
const skeleton = argStyle.startsWith('::')
? parseNumberSkeleton(argStyle.substring(2), onError)
: parseNumberPattern(argStyle, 'XXX', onError);
const nfOpt = getNumberFormatOptions(skeleton, (stem, option) => {
if (stem === 'scale') {
options.set('mf1:scale', { type: 'literal', value: String(option) });
if (skeleton.unit?.style === 'percent') {
name = 'mf1:unit';
options.set('unit', { type: 'literal', value: 'percent' });
}
else if (name === 'number') {
name = 'mf1:number';
}
}
else {
onError();
}
});
const optEntries = Object.entries(nfOpt);
loop: for (let [key, value] of optEntries) {
switch (key) {
case 'currency':
if (value === 'XXX' && !argStyle.includes('XXX')) {
continue loop;
}
break;
case 'notation':
onError();
break;
case 'style':
switch (value) {
case 'currency':
name = 'mf1:currency';
continue loop;
case 'percent':
name = 'mf1:unit';
key = 'mf1:scale';
value = '100';
if (options.has(key))
continue loop;
break;
case 'unit':
name = 'mf1:unit';
continue loop;
default:
continue loop;
}
break;
case 'useGrouping':
if (value === false)
value = 'never';
break;
}
options.set(key, { type: 'literal', value });
}
return { type: 'function', name, options };
}
function tokenToFunctionRef(token) {
const attributes = new Map([
['mf1:argType', { type: 'literal', value: token.key }]
]);
let argStyle = '';
if (token.param) {
for (const pt of token.param) {
if (pt.type === 'content')
argStyle += pt.value;
else
throw new Error(`Unsupported param type: ${pt.type}`);
}
argStyle = argStyle.trim();
attributes.set('mf1:argStyle', { type: 'literal', value: argStyle });
}
switch (token.key) {
case 'date': {
let options;
if (argStyle.startsWith('::')) {
options = parseDateTimeArgStyle(argStyle);
}
else {
const month = { type: 'literal', value: 'short' };
options = new Map([
['year', { type: 'literal', value: 'numeric' }],
['month', month],
['day', { type: 'literal', value: 'numeric' }]
]);
switch (argStyle) {
case 'full':
month.value = 'long';
options.set('weekday', { type: 'literal', value: 'long' });
break;
case 'long':
month.value = 'long';
break;
case 'short':
month.value = 'numeric';
break;
case '':
case 'medium':
break;
default:
options.set('mf1:argStyle', { type: 'literal', value: argStyle });
}
}
return {
functionRef: { type: 'function', name: 'mf1:datetime', options },
attributes
};
}
case 'number':
switch (argStyle) {
case 'currency':
return {
functionRef: { type: 'function', name: 'mf1:currency' },
attributes
};
case 'integer':
return {
functionRef: { type: 'function', name: 'integer' },
attributes
};
case 'percent':
return {
functionRef: {
type: 'function',
name: 'mf1:unit',
options: new Map([
['unit', { type: 'literal', value: 'percent' }],
['mf1:scale', { type: 'literal', value: '100' }]
])
},
attributes
};
case '':
return {
functionRef: { type: 'function', name: 'number' },
attributes
};
default:
return { functionRef: parseNumberArgStyle(argStyle), attributes };
}
case 'time': {
let options;
if (argStyle.startsWith('::')) {
options = parseDateTimeArgStyle(argStyle);
}
else {
options = new Map([
['hour', { type: 'literal', value: 'numeric' }],
['minute', { type: 'literal', value: 'numeric' }]
]);
switch (argStyle) {
case 'full':
case 'long':
options.set('second', { type: 'literal', value: 'numeric' });
options.set('timeZoneName', { type: 'literal', value: 'short' });
break;
case 'short':
break;
case '':
case 'medium':
options.set('second', { type: 'literal', value: 'numeric' });
break;
default:
options.set('mf1:argStyle', { type: 'literal', value: argStyle });
}
}
return {
functionRef: { type: 'function', name: 'mf1:datetime', options },
attributes
};
}
default: {
const functionRef = {
type: 'function',
name: `mf1:${token.key}`
};
if (argStyle) {
functionRef.options = new Map([
['mf1:argStyle', { type: 'literal', value: argStyle }]
]);
}
return { functionRef, attributes };
}
}
}
function tokenToPart(token, pluralArg) {
switch (token.type) {
case 'content':
return token.value;
case 'argument':
return {
type: 'expression',
arg: { type: 'variable', name: token.arg }
};
case 'function':
return {
type: 'expression',
arg: { type: 'variable', name: token.arg },
...tokenToFunctionRef(token)
};
case 'octothorpe':
return pluralArg
? { type: 'expression', arg: { type: 'variable', name: pluralArg } }
: '#';
/* istanbul ignore next - never happens */
default:
throw new Error(`Unsupported token type: ${token.type}`);
}
}
function argToInputDeclaration({ arg: name, pluralOffset, type }) {
let functionRef;
if (type === 'select') {
functionRef = { type: 'function', name: 'string' };
}
else {
const options = new Map();
if (type === 'selectordinal') {
options.set('select', { type: 'literal', value: 'ordinal' });
}
if (pluralOffset) {
options.set('offset', { type: 'literal', value: String(pluralOffset) });
functionRef = { type: 'function', name: 'mf1:plural', options };
}
else {
functionRef = { type: 'function', name: 'number' };
if (options.size)
functionRef.options = options;
}
}
return {
type: 'input',
name,
value: {
type: 'expression',
arg: { type: 'variable', name },
functionRef
}
};
}
/**
* Convert an ICU MessageFormat 1 message into a {@link MF.Message | Model.Message} data object.
*
* If the source message contains any inner selectors, they will be
* lifted into a single top-level selector.
*
* Only literal values are supported in formatter parameters.
* Any unsupported `argStyle` value will be included as a {@link MF.Options | Model.Options} value.
*
* ```js
* import { mf1ToMessageData, mf1Validate } from '@messageformat/icu-messageformat-1';
* import { parse } from '@messageformat/parser';
*
* const mf1Msg = parse('The total is {V, number, ::currency/EUR}.');
* const mf2Msg = mf1ToMessageData(mf1Msg);
* mf1Validate(mf2Msg);
* mf2msg;
* ```
*
* ```js
* {
* type: 'message',
* declarations: [],
* pattern: [
* 'The total is ',
* {
* type: 'expression',
* arg: { type: 'variable', name: 'V' },
* functionRef: {
* type: 'function',
* name: 'mf1:currency',
* options: Map(1) { 'currency' => { type: 'literal', value: 'EUR' } }
* },
* attributes: Map(2) {
* 'mf1:argType' => { type: 'literal', value: 'number' },
* 'mf1:argStyle' => { type: 'literal', value: '::currency/EUR' }
* }
* },
* '.'
* ]
* }
* ```
*
* @param ast - An ICU MessageFormat message as an array of `@messageformat/parser`
* {@link https://messageformat.github.io/messageformat/api/parser.parse/ | AST tokens}.
*/
export function mf1ToMessageData(ast) {
const args = findSelectArgs(ast);
if (args.length === 0) {
return {
type: 'message',
declarations: [],
pattern: ast.map(token => tokenToPart(token, null))
};
}
// First determine the keys for all cases, with empty values
let keys = [];
for (let i = 0; i < args.length; ++i) {
const kk = Array.from(new Set(args[i].keys));
kk.sort((a, b) => {
if (typeof a === 'number' || b === 'other')
return -1;
if (typeof b === 'number' || a === 'other')
return 1;
return 0;
});
if (i === 0) {
keys = kk.map(key => [key]);
}
else {
for (let i = keys.length - 1; i >= 0; --i) {
keys.splice(i, 1, ...kk.map(key => [...keys[i], key]));
}
}
}
const variants = keys.map(key => ({
keys: key.map(k => k === 'other'
? { type: '*' }
: { type: 'literal', quoted: false, value: String(k) }),
value: []
}));
/**
* This reads `args` and modifies `cases`
*
* @param pluralArg - Required by # octothorpes
* @param filter - Selects which cases we're adding to
*/
function addParts(tokens, pluralArg, pluralOffset, filter) {
for (const token of tokens) {
if (isAstSelect(token)) {
const isPlural = token.type !== 'select';
const pa = isPlural ? token.arg : pluralArg;
const po = isPlural ? token.pluralOffset || null : pluralOffset;
const idx = args.findIndex(equalSelectArgs(token));
for (const c of token.cases) {
const value = isPlural ? asPluralKey(c.key) : c.key;
addParts(c.tokens, pa, po, [...filter, { idx, value }]);
}
}
else {
for (const v of variants) {
const vp = v.value;
if (filter.every(({ idx, value }) => {
const vi = v.keys[idx];
return vi.type === '*'
? value === 'other'
: String(value) === vi.value;
})) {
const i = vp.length - 1;
const part = tokenToPart(token, pluralArg);
if (typeof vp[i] === 'string' && typeof part === 'string') {
vp[i] += part;
}
else {
vp.push(part);
}
}
}
}
}
}
addParts(ast, null, null, []);
return {
type: 'select',
declarations: args.map(argToInputDeclaration),
selectors: args.map(arg => ({ type: 'variable', name: arg.arg })),
variants
};
}