UNPKG

@messageformat/fluent

Version:

Conversion & compatibility tools for using Fluent with MessageFormat 2

241 lines (240 loc) 9.45 kB
import * as Fluent from '@fluent/syntax'; import { isCatchallKey, isLiteral, isPatternMessage, isSelectMessage } from 'messageformat'; const reIdentifier = /^[a-zA-Z][\w-]*$/; const reNumberLiteral = /^-?[0-9]+(\.[0-9]+)?$/; /** * Convert a {@link MF.Message | Model.Message} data object into a * {@link https://projectfluent.org/fluent.js/syntax/classes/pattern.html | Fluent.Pattern} * (i.e. the value of a Fluent message or an attribute). * * @param options.defaultKey - The Fluent identifier or numeric literal to use for the * default/fallback variant, which is labelled as `*` in MessageFormat 2, * when not explicitly defined in the data. * Defaults to `other`. * @param options.functionMap - A mapping of custom MessageFormat 2 → Fluent function names. */ export function messageToFluent(msg, options) { const defaultKey = options?.defaultKey ?? 'other'; const functionMap = options?.functionMap ?? {}; const ctx = { declarations: msg.declarations, functionMap }; if (isPatternMessage(msg)) { return patternToFluent(ctx, msg.pattern); } if (isSelectMessage(msg)) { const defKey = findDefaultKey(msg.variants, defaultKey); const variants = msg.variants.map(({ keys, value }) => ({ keys: keys.slice(), // will be modified pattern: patternToFluent(ctx, value) })); const k0 = variants[0].keys; while (k0.length > 0) { let sel = variableRefToFluent(ctx, msg.selectors[k0.length - 1]); if (sel instanceof Fluent.FunctionReference && sel.id.name === 'NUMBER' && sel.arguments.positional.length === 1 && sel.arguments.positional[0] instanceof Fluent.VariableReference && sel.arguments.named.length === 0) { sel = sel.arguments.positional[0]; } let baseKeys = []; let exp; for (let i = 0; i < variants.length; ++i) { const { keys, pattern } = variants[i]; const key = keys.pop(); if (!key) throw new Error('Mismatch in selector key counts'); const variant = new Fluent.Variant(keyToIdentifier(key, defKey), pattern, isCatchallKey(key)); if (exp && keysMatch(baseKeys, keys)) { exp.variants.push(variant); variants.splice(i, 1); i -= 1; } else { baseKeys = keys; exp = new Fluent.SelectExpression(sel.clone(), [variant]); variants[i].pattern = new Fluent.Pattern([new Fluent.Placeable(exp)]); } } } if (variants.length !== 1) { throw new Error(`Error resolving select message variants (n=${variants.length})`); } return variants[0].pattern; } throw new Error('Unsupported message type'); } function findDefaultKey(variants, root) { let i = 0; let defKey = root; while (variants.some(v => v.keys.some(key => key.type !== '*' && key.value === defKey))) { i += 1; defKey = `${root}${i}`; } return defKey; } function keyToIdentifier(key, defKey) { const kv = isCatchallKey(key) ? key.value || defKey : key.value; if (reNumberLiteral.test(kv)) return new Fluent.NumberLiteral(kv); if (reIdentifier.test(kv)) return new Fluent.Identifier(kv); throw new Error(`Invalid variant key for Fluent: ${kv}`); } function keysMatch(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; ++i) { const aa = a[i]; const bb = b[i]; if (aa.type === '*') { if (bb.type !== '*') return false; } else { if (bb.type === '*' || aa.value !== bb.value) return false; } } return true; } function patternToFluent(ctx, pattern) { const elements = pattern.map(el => { if (typeof el === 'string') return new Fluent.TextElement(el); if (el.type === 'expression') { return new Fluent.Placeable(expressionToFluent(ctx, el)); } throw new Error(`Conversion of ${el.type} to Fluent is not supported`); }); return new Fluent.Pattern(elements); } function functionRefToFluent(ctx, arg, { name, options }) { const args = new Fluent.CallArguments(); if (arg) args.positional[0] = arg; if (options?.size) { args.named = []; for (const [name, value] of options) { if (name === 'u:dir' || name === 'u:locale') { throw new Error(`The option "${name}" is not supported in Fluent`); } const va = valueToFluent(ctx, value); if (va instanceof Fluent.BaseLiteral) { const id = new Fluent.Identifier(name); args.named.push(new Fluent.NamedArgument(id, va)); } else { throw new Error(`Fluent options must have literal values (got ${va.type} for ${name})`); } } } let id; switch (name) { case 'string': return args.positional[0]; case 'integer': args.named.unshift(new Fluent.NamedArgument(new Fluent.Identifier('maximumFractionDigits'), new Fluent.NumberLiteral('0'))); id = 'NUMBER'; break; case 'number': if (args.positional[0] instanceof Fluent.NumberLiteral && args.named.length === 0) { return args.positional[0]; } id = 'NUMBER'; break; case 'datetime': id = 'DATETIME'; break; case 'date': { let hasStyle = false; for (const arg of args.named) { if (arg.name.id === 'style') { arg.name.id = 'dateStyle'; hasStyle = true; break; } } if (!hasStyle) { args.named.unshift(new Fluent.NamedArgument(new Fluent.Identifier('dateStyle'), new Fluent.StringLiteral('medium'))); } id = 'DATETIME'; break; } case 'time': { let hasStyle = false; for (const arg of args.named) { if (arg.name.id === 'style') { arg.name.id = 'timeStyle'; hasStyle = true; break; } } if (!hasStyle) { args.named.unshift(new Fluent.NamedArgument(new Fluent.Identifier('timeStyle'), new Fluent.StringLiteral('short'))); } id = 'DATETIME'; break; } case 'currency': case 'unit': args.named.unshift(new Fluent.NamedArgument(new Fluent.Identifier('style'), new Fluent.StringLiteral(name))); id = 'NUMBER'; break; case 'fluent:message': { const lit = args.positional[0]; if (!(lit instanceof Fluent.BaseLiteral)) { throw new Error(`Fluent message and term references must have a literal message identifier`); } const { msgId, msgAttr } = valueToMessageRef(lit.value); const attr = msgAttr ? new Fluent.Identifier(msgAttr) : null; if (msgId[0] === '-') { args.positional = []; return new Fluent.TermReference(new Fluent.Identifier(msgId.substring(1)), attr, args.named.length > 0 ? args : null); } if (args.named.length > 0) { throw new Error(`Options are not allowed for Fluent message references`); } return new Fluent.MessageReference(new Fluent.Identifier(msgId), attr); } default: id = ctx.functionMap[name]; if (typeof id !== 'string') { throw new Error(`No Fluent equivalent found for "${name}" function`); } } return new Fluent.FunctionReference(new Fluent.Identifier(id), args); } function literalToFluent({ value }) { return reNumberLiteral.test(value) ? new Fluent.NumberLiteral(value) : new Fluent.StringLiteral(value); } function expressionToFluent(ctx, { arg, functionRef }) { const fluentArg = arg ? valueToFluent(ctx, arg) : null; if (functionRef) return functionRefToFluent(ctx, fluentArg, functionRef); if (fluentArg) return fluentArg; throw new Error('Invalid empty expression'); } function valueToFluent(ctx, val) { return isLiteral(val) ? literalToFluent(val) : variableRefToFluent(ctx, val); } export function valueToMessageRef(value) { const match = value.match(/^(-?[a-zA-Z][\w-]*)(?:\.([a-zA-Z][\w-]*))?$/); if (!match) throw new Error(`Invalid message identifier: ${value}`); return { msgId: match[1], msgAttr: match[2] ?? null }; } function variableRefToFluent(ctx, { name }) { const local = ctx.declarations.find(decl => decl.name === name); if (local?.value) { const idx = ctx.declarations.indexOf(local); return expressionToFluent({ ...ctx, declarations: ctx.declarations.slice(0, idx) }, local.value); } return new Fluent.VariableReference(new Fluent.Identifier(name)); }