UNPKG

@fluent/bundle

Version:

Localization library for expressive translations.

273 lines (272 loc) 10.2 kB
/** * The role of the Fluent resolver is to format a `Pattern` to an instance of * `FluentValue`. For performance reasons, primitive strings are considered * such instances, too. * * Translations can contain references to other messages or variables, * conditional logic in form of select expressions, traits which describe their * grammatical features, and can use Fluent builtins which make use of the * `Intl` formatters to format numbers and dates into the bundle's languages. * See the documentation of the Fluent syntax for more information. * * In case of errors the resolver will try to salvage as much of the * translation as possible. In rare situations where the resolver didn't know * how to recover from an error it will return an instance of `FluentNone`. * * All expressions resolve to an instance of `FluentValue`. The caller should * use the `toString` method to convert the instance to a native value. * * Functions in this file pass around an instance of the `Scope` class, which * stores the data required for successful resolution and error recovery. */ import { FluentType, FluentNone, FluentNumber, FluentDateTime, } from "./types.js"; /** * The maximum number of placeables which can be expanded in a single call to * `formatPattern`. The limit protects against the Billion Laughs and Quadratic * Blowup attacks. See https://msdn.microsoft.com/en-us/magazine/ee335713.aspx. */ const MAX_PLACEABLES = 100; /** Unicode bidi isolation characters. */ const FSI = "\u2068"; const PDI = "\u2069"; /** Helper: match a variant key to the given selector. */ function match(scope, selector, key) { if (key === selector) { // Both are strings. return true; } // XXX Consider comparing options too, e.g. minimumFractionDigits. if (key instanceof FluentNumber && selector instanceof FluentNumber && key.value === selector.value) { return true; } if (selector instanceof FluentNumber && typeof key === "string") { let category = scope .memoizeIntlObject(Intl.PluralRules, selector.opts) .select(selector.value); if (key === category) { return true; } } return false; } /** Helper: resolve the default variant from a list of variants. */ function getDefault(scope, variants, star) { if (variants[star]) { return resolvePattern(scope, variants[star].value); } scope.reportError(new RangeError("No default")); return new FluentNone(); } /** Helper: resolve arguments to a call expression. */ function getArguments(scope, args) { const positional = []; const named = Object.create(null); for (const arg of args) { if (arg.type === "narg") { named[arg.name] = resolveExpression(scope, arg.value); } else { positional.push(resolveExpression(scope, arg)); } } return { positional, named }; } /** Resolve an expression to a Fluent type. */ function resolveExpression(scope, expr) { switch (expr.type) { case "str": return expr.value; case "num": return new FluentNumber(expr.value, { minimumFractionDigits: expr.precision, }); case "var": return resolveVariableReference(scope, expr); case "mesg": return resolveMessageReference(scope, expr); case "term": return resolveTermReference(scope, expr); case "func": return resolveFunctionReference(scope, expr); case "select": return resolveSelectExpression(scope, expr); default: return new FluentNone(); } } /** Resolve a reference to a variable. */ function resolveVariableReference(scope, { name }) { let arg; if (scope.params) { // We're inside a TermReference. It's OK to reference undefined parameters. if (Object.prototype.hasOwnProperty.call(scope.params, name)) { arg = scope.params[name]; } else { return new FluentNone(`$${name}`); } } else if (scope.args && Object.prototype.hasOwnProperty.call(scope.args, name)) { // We're in the top-level Pattern or inside a MessageReference. Missing // variables references produce ReferenceErrors. arg = scope.args[name]; } else { scope.reportError(new ReferenceError(`Unknown variable: $${name}`)); return new FluentNone(`$${name}`); } // Return early if the argument already is an instance of FluentType. if (arg instanceof FluentType) { return arg; } // Convert the argument to a Fluent type. switch (typeof arg) { case "string": return arg; case "number": return new FluentNumber(arg); case "object": if (FluentDateTime.supportsValue(arg)) { return new FluentDateTime(arg); } // eslint-disable-next-line no-fallthrough default: scope.reportError(new TypeError(`Variable type not supported: $${name}, ${typeof arg}`)); return new FluentNone(`$${name}`); } } /** Resolve a reference to another message. */ function resolveMessageReference(scope, { name, attr }) { const message = scope.bundle._messages.get(name); if (!message) { scope.reportError(new ReferenceError(`Unknown message: ${name}`)); return new FluentNone(name); } if (attr) { const attribute = message.attributes[attr]; if (attribute) { return resolvePattern(scope, attribute); } scope.reportError(new ReferenceError(`Unknown attribute: ${attr}`)); return new FluentNone(`${name}.${attr}`); } if (message.value) { return resolvePattern(scope, message.value); } scope.reportError(new ReferenceError(`No value: ${name}`)); return new FluentNone(name); } /** Resolve a call to a Term with key-value arguments. */ function resolveTermReference(scope, { name, attr, args }) { const id = `-${name}`; const term = scope.bundle._terms.get(id); if (!term) { scope.reportError(new ReferenceError(`Unknown term: ${id}`)); return new FluentNone(id); } if (attr) { const attribute = term.attributes[attr]; if (attribute) { // Every TermReference has its own variables. scope.params = getArguments(scope, args).named; const resolved = resolvePattern(scope, attribute); scope.params = null; return resolved; } scope.reportError(new ReferenceError(`Unknown attribute: ${attr}`)); return new FluentNone(`${id}.${attr}`); } scope.params = getArguments(scope, args).named; const resolved = resolvePattern(scope, term.value); scope.params = null; return resolved; } /** Resolve a call to a Function with positional and key-value arguments. */ function resolveFunctionReference(scope, { name, args }) { // Some functions are built-in. Others may be provided by the runtime via // the `FluentBundle` constructor. let func = scope.bundle._functions[name]; if (!func) { scope.reportError(new ReferenceError(`Unknown function: ${name}()`)); return new FluentNone(`${name}()`); } if (typeof func !== "function") { scope.reportError(new TypeError(`Function ${name}() is not callable`)); return new FluentNone(`${name}()`); } try { let resolved = getArguments(scope, args); return func(resolved.positional, resolved.named); } catch (err) { scope.reportError(err); return new FluentNone(`${name}()`); } } /** Resolve a select expression to the member object. */ function resolveSelectExpression(scope, { selector, variants, star }) { let sel = resolveExpression(scope, selector); if (sel instanceof FluentNone) { return getDefault(scope, variants, star); } // Match the selector against keys of each variant, in order. for (const variant of variants) { const key = resolveExpression(scope, variant.key); if (match(scope, sel, key)) { return resolvePattern(scope, variant.value); } } return getDefault(scope, variants, star); } /** Resolve a pattern (a complex string with placeables). */ export function resolveComplexPattern(scope, ptn) { if (scope.dirty.has(ptn)) { scope.reportError(new RangeError("Cyclic reference")); return new FluentNone(); } // Tag the pattern as dirty for the purpose of the current resolution. scope.dirty.add(ptn); const result = []; // Wrap interpolations with Directional Isolate Formatting characters // only when the pattern has more than one element. const useIsolating = scope.bundle._useIsolating && ptn.length > 1; for (const elem of ptn) { if (typeof elem === "string") { result.push(scope.bundle._transform(elem)); continue; } scope.placeables++; if (scope.placeables > MAX_PLACEABLES) { scope.dirty.delete(ptn); // This is a fatal error which causes the resolver to instantly bail out // on this pattern. The length check protects against excessive memory // usage, and throwing protects against eating up the CPU when long // placeables are deeply nested. throw new RangeError(`Too many placeables expanded: ${scope.placeables}, ` + `max allowed is ${MAX_PLACEABLES}`); } if (useIsolating) { result.push(FSI); } result.push(resolveExpression(scope, elem).toString(scope)); if (useIsolating) { result.push(PDI); } } scope.dirty.delete(ptn); return result.join(""); } /** * Resolve a simple or a complex Pattern to a FluentString * (which is really the string primitive). */ function resolvePattern(scope, value) { // Resolve a simple pattern. if (typeof value === "string") { return scope.bundle._transform(value); } return resolveComplexPattern(scope, value); }