UNPKG

@messageformat/fluent

Version:

Conversion & compatibility tools for using Fluent with MessageFormat 2

290 lines (289 loc) 10.5 kB
import deepEqual from 'fast-deep-equal'; const CATCHALL = Symbol('catchall'); function findSelectArgs(pattern) { const args = []; const add = (arg) => { const prev = args.find(a => deepEqual(a.selector, arg.selector)); if (prev) for (const key of arg.keys) prev.keys.push(key); else args.push(arg); }; for (const el of pattern.elements) { if (el.type === 'Placeable' && el.expression.type === 'SelectExpression') { const { selector, variants } = el.expression; let defaultName = ''; const keys = variants.map(v => { const name = v.key.type === 'Identifier' ? v.key.name : v.key.parse().value; if (v.default) { defaultName = String(name); return CATCHALL; } else { return name; } }); add({ selector, defaultName, keys }); for (const v of variants) { for (const arg of findSelectArgs(v.value)) add(arg); } } } return args; } function asSelectorDeclaration({ selector, defaultName, keys }, index, detectNumberSelection = true) { switch (selector.type) { case 'StringLiteral': return { type: 'local', name: `_${index}`, value: { type: 'expression', arg: asValue(selector), functionRef: { type: 'function', name: 'string' } } }; case 'VariableReference': { let name = detectNumberSelection ? 'number' : 'string'; if (name === 'number') { for (const key of [...keys, defaultName]) { if (typeof key === 'string' && !['zero', 'one', 'two', 'few', 'many', 'other'].includes(key)) { name = 'string'; break; } } } return { type: 'input', name: selector.id.name, value: { type: 'expression', arg: asValue(selector), functionRef: { type: 'function', name } } }; } } const exp = asExpression(selector); return exp.arg?.type === 'variable' ? { type: 'input', name: exp.arg.name, value: exp } : { type: 'local', name: `_${index}`, value: exp }; } function asValue(exp) { switch (exp.type) { case 'NumberLiteral': return { type: 'literal', value: exp.value }; case 'StringLiteral': return { type: 'literal', value: exp.parse().value }; case 'VariableReference': return { type: 'variable', name: exp.id.name }; default: throw new Error(`A Fluent ${exp.type} is not supported here.`); } } function asExpression(exp) { switch (exp.type) { case 'NumberLiteral': return { type: 'expression', arg: asValue(exp), functionRef: { type: 'function', name: 'number' } }; case 'StringLiteral': case 'VariableReference': { return { type: 'expression', arg: asValue(exp) }; } case 'FunctionReference': { const annotation = { type: 'function', name: exp.id.name.toLowerCase() }; const { positional, named } = exp.arguments; const args = positional.map(asValue); if (args.length > 1) { throw new Error(`More than one positional argument is not supported.`); } if (named.length > 0) { annotation.options = new Map(); for (const { name, value } of named) { const quoted = value.type !== 'NumberLiteral'; const litValue = quoted ? value.parse().value : value.value; annotation.options.set(name.name, { type: 'literal', value: litValue }); } } if (annotation.name === 'number') { const style = annotation.options?.get('style'); if (style?.type === 'literal') { switch (style.value) { case 'decimal': break; case 'currency': case 'unit': annotation.name = style.value; break; default: throw new Error(`Unsupported NUMBER(..., style=${JSON.stringify(style.value)})`); } annotation.options.delete('style'); } } return args.length > 0 ? { type: 'expression', arg: args[0], functionRef: annotation } : { type: 'expression', functionRef: annotation }; } case 'MessageReference': { const msgId = exp.attribute ? `${exp.id.name}.${exp.attribute.name}` : exp.id.name; return { type: 'expression', arg: { type: 'literal', value: msgId }, functionRef: { type: 'function', name: 'fluent:message' } }; } case 'TermReference': { const annotation = { type: 'function', name: 'fluent:message' }; const msgId = exp.attribute ? `-${exp.id.name}.${exp.attribute.name}` : `-${exp.id.name}`; if (exp.arguments?.named.length) { annotation.options = new Map(); for (const { name, value } of exp.arguments.named) { const quoted = value.type !== 'NumberLiteral'; const litValue = quoted ? value.parse().value : value.value; annotation.options.set(name.name, { type: 'literal', value: litValue }); } } return { type: 'expression', arg: { type: 'literal', value: msgId }, functionRef: annotation }; } /* istanbul ignore next - never happens */ case 'Placeable': return asExpression(exp.expression); /* istanbul ignore next - never happens */ default: throw new Error(`${exp.type} not supported here`); } } const elementToPart = (el) => el.type === 'TextElement' ? el.value : asExpression(el.expression); function asFluentSelect(el) { if (el.type === 'TextElement') return null; switch (el.expression.type) { case 'SelectExpression': return el.expression; /* istanbul ignore next - never happens */ case 'Placeable': return asFluentSelect(el.expression); default: return null; } } /** * Compile a {@link https://projectfluent.org/fluent.js/syntax/classes/pattern.html | Fluent.Pattern} * (i.e. the value of a Fluent message or an attribute) into a * {@link MF.Message | Model.Message} data object. * * @param options.detectNumberSelection - Set `false` to disable number selector detection based on keys. */ export function fluentToMessage(ast, options) { const args = findSelectArgs(ast); if (args.length === 0) { return { type: 'message', declarations: [], pattern: ast.elements.map(elementToPart) }; } // First determine the keys for all cases, with empty values let keys = []; for (let i = 0; i < args.length; ++i) { const arg = args[i]; const sk = new Set(arg.keys); if (i === 0) { keys = Array.from(sk, key => [key]); } else { for (let i = keys.length - 1; i >= 0; --i) { keys.splice(i, 1, ...Array.from(sk, key => [...keys[i], key])); } } } const variants = keys.map(key => ({ keys: key.map((k, i) => k === CATCHALL ? { type: '*', value: args[i].defaultName } : { type: 'literal', value: String(k) }), value: [] })); /** * This reads `args` and modifies `variants` * * @param filter - Selects which cases we're adding to */ function addParts(pattern, filter) { for (const el of pattern.elements) { const sel = asFluentSelect(el); if (sel) { const idx = args.findIndex(a => deepEqual(a.selector, sel.selector)); for (const v of sel.variants) { const value = v.default ? CATCHALL : v.key.type === 'Identifier' ? v.key.name : v.key.parse().value; addParts(v.value, [...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 === CATCHALL : String(value) === vi.value; })) { const i = vp.length - 1; const part = elementToPart(el); if (typeof vp[i] === 'string' && typeof part === 'string') { vp[i] += part; } else { vp.push(part); } } } } } } addParts(ast, []); const declarations = args.map((arg, index) => asSelectorDeclaration(arg, index, options?.detectNumberSelection)); return { type: 'select', declarations, selectors: declarations.map(decl => ({ type: 'variable', name: decl.name })), variants }; }