@messageformat/icu-messageformat-1
Version:
Compile ICU MessageFormat 1 sources into MessageFormat 2 formatters
397 lines (396 loc) • 13.9 kB
JavaScript
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 parseNumberArgStyle(argStyle) {
let name = 'number';
const options = {};
const onError = () => {
if (name === 'number')
name = 'mf1:number';
options['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['mf1:scale'] = { type: 'literal', value: String(option) };
if (skeleton.unit?.style === 'percent') {
name = 'mf1:unit';
options['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 (Object.hasOwn(options, key))
continue loop;
break;
case 'unit':
name = 'mf1:unit';
continue loop;
default:
continue loop;
}
break;
case 'useGrouping':
if (value === false)
value = 'never';
break;
}
options[key] = { type: 'literal', value };
}
return { type: 'function', name, options };
}
function tokenToFunctionRef(token) {
const attributes = {
'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['mf1:argStyle'] = { type: 'literal', value: argStyle };
}
switch (token.key) {
case 'date': {
const options = {};
switch (argStyle) {
case '':
break;
case 'full':
options['fields'] = {
type: 'literal',
value: 'year-month-day-weekday'
};
options['length'] = { type: 'literal', value: 'long' };
break;
case 'long':
case 'medium':
case 'short':
options['length'] = { type: 'literal', value: argStyle };
break;
default:
options['mf1:argStyle'] = { type: 'literal', value: argStyle };
}
return {
functionRef: { type: 'function', name: 'mf1:date', 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: {
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': {
const options = {};
switch (argStyle) {
case 'full':
options['precision'] = { type: 'literal', value: 'second' };
options['timeZoneName'] = { type: 'literal', value: 'long' };
break;
case 'long':
options['precision'] = { type: 'literal', value: 'second' };
options['timeZoneName'] = { type: 'literal', value: 'short' };
break;
case '':
case 'medium':
options['precision'] = { type: 'literal', value: 'second' };
break;
case 'short':
options['precision'] = { type: 'literal', value: 'minute' };
break;
default:
options['mf1:argStyle'] = { type: 'literal', value: argStyle };
}
return {
functionRef: { type: 'function', name: 'mf1:time', options },
attributes
};
}
default: {
const functionRef = {
type: 'function',
name: `mf1:${token.key}`
};
if (argStyle) {
functionRef.options = {
'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 = {};
if (type === 'selectordinal') {
options['select'] = { type: 'literal', value: 'ordinal' };
}
if (pluralOffset) {
options['offset'] = { type: 'literal', value: String(pluralOffset) };
functionRef = { type: 'function', name: 'mf1:plural', options };
}
else {
functionRef = { type: 'function', name: 'number' };
if (Object.keys(options).length)
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
};
}