mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-25 05:41:12 +00:00
56bbe2cfb3
Differential Revision: https://phabricator.services.mozilla.com/D8689 --HG-- rename : intl/l10n/MessageContext.jsm => intl/l10n/Fluent.jsm extra : moz-landing-system : lando
1371 lines
38 KiB
JavaScript
1371 lines
38 KiB
JavaScript
/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */
|
|
|
|
/* Copyright 2017 Mozilla Foundation and others
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
|
|
/* fluent@fa25466f (October 12, 2018) */
|
|
|
|
/* global Intl */
|
|
|
|
/**
|
|
* The `FluentType` class is the base of Fluent's type system.
|
|
*
|
|
* Fluent types wrap JavaScript values and store additional configuration for
|
|
* them, which can then be used in the `toString` method together with a proper
|
|
* `Intl` formatter.
|
|
*/
|
|
class FluentType {
|
|
|
|
/**
|
|
* Create an `FluentType` instance.
|
|
*
|
|
* @param {Any} value - JavaScript value to wrap.
|
|
* @param {Object} opts - Configuration.
|
|
* @returns {FluentType}
|
|
*/
|
|
constructor(value, opts) {
|
|
this.value = value;
|
|
this.opts = opts;
|
|
}
|
|
|
|
/**
|
|
* Unwrap the raw value stored by this `FluentType`.
|
|
*
|
|
* @returns {Any}
|
|
*/
|
|
valueOf() {
|
|
return this.value;
|
|
}
|
|
|
|
/**
|
|
* Format this instance of `FluentType` to a string.
|
|
*
|
|
* Formatted values are suitable for use outside of the `FluentBundle`.
|
|
* This method can use `Intl` formatters memoized by the `FluentBundle`
|
|
* instance passed as an argument.
|
|
*
|
|
* @param {FluentBundle} [bundle]
|
|
* @returns {string}
|
|
*/
|
|
toString() {
|
|
throw new Error("Subclasses of FluentType must implement toString.");
|
|
}
|
|
}
|
|
|
|
class FluentNone extends FluentType {
|
|
toString() {
|
|
return this.value || "???";
|
|
}
|
|
}
|
|
|
|
class FluentNumber extends FluentType {
|
|
constructor(value, opts) {
|
|
super(parseFloat(value), opts);
|
|
}
|
|
|
|
toString(bundle) {
|
|
try {
|
|
const nf = bundle._memoizeIntlObject(
|
|
Intl.NumberFormat, this.opts
|
|
);
|
|
return nf.format(this.value);
|
|
} catch (e) {
|
|
// XXX Report the error.
|
|
return this.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
class FluentDateTime extends FluentType {
|
|
constructor(value, opts) {
|
|
super(new Date(value), opts);
|
|
}
|
|
|
|
toString(bundle) {
|
|
try {
|
|
const dtf = bundle._memoizeIntlObject(
|
|
Intl.DateTimeFormat, this.opts
|
|
);
|
|
return dtf.format(this.value);
|
|
} catch (e) {
|
|
// XXX Report the error.
|
|
return this.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @overview
|
|
*
|
|
* The FTL resolver ships with a number of functions built-in.
|
|
*
|
|
* Each function take two arguments:
|
|
* - args - an array of positional args
|
|
* - opts - an object of key-value args
|
|
*
|
|
* Arguments to functions are guaranteed to already be instances of
|
|
* `FluentType`. Functions must return `FluentType` objects as well.
|
|
*/
|
|
|
|
const builtins = {
|
|
"NUMBER": ([arg], opts) =>
|
|
new FluentNumber(arg.valueOf(), merge(arg.opts, opts)),
|
|
"DATETIME": ([arg], opts) =>
|
|
new FluentDateTime(arg.valueOf(), merge(arg.opts, opts)),
|
|
};
|
|
|
|
function merge(argopts, opts) {
|
|
return Object.assign({}, argopts, values(opts));
|
|
}
|
|
|
|
function values(opts) {
|
|
const unwrapped = {};
|
|
for (const [name, opt] of Object.entries(opts)) {
|
|
unwrapped[name] = opt.valueOf();
|
|
}
|
|
return unwrapped;
|
|
}
|
|
|
|
/**
|
|
* @overview
|
|
*
|
|
* The role of the Fluent resolver is to format a translation object to an
|
|
* instance of `FluentType` or an array of instances.
|
|
*
|
|
* 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, dates, lists and more into the
|
|
* bundle's language. 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`.
|
|
*
|
|
* `MessageReference`, `VariantExpression`, `AttributeExpression` and
|
|
* `SelectExpression` resolve to raw Runtime Entries objects and the result of
|
|
* the resolution needs to be passed into `Type` to get their real value.
|
|
* This is useful for composing expressions. Consider:
|
|
*
|
|
* brand-name[nominative]
|
|
*
|
|
* which is a `VariantExpression` with properties `id: MessageReference` and
|
|
* `key: Keyword`. If `MessageReference` was resolved eagerly, it would
|
|
* instantly resolve to the value of the `brand-name` message. Instead, we
|
|
* want to get the message object and look for its `nominative` variant.
|
|
*
|
|
* All other expressions (except for `FunctionReference` which is only used in
|
|
* `CallExpression`) resolve to an instance of `FluentType`. The caller should
|
|
* use the `toString` method to convert the instance to a native value.
|
|
*
|
|
*
|
|
* All functions in this file pass around a special object called `env`.
|
|
* This object stores a set of elements used by all resolve functions:
|
|
*
|
|
* * {FluentBundle} bundle
|
|
* bundle for which the given resolution is happening
|
|
* * {Object} args
|
|
* list of developer provided arguments that can be used
|
|
* * {Array} errors
|
|
* list of errors collected while resolving
|
|
* * {WeakSet} dirty
|
|
* Set of patterns already encountered during this resolution.
|
|
* This is used to prevent cyclic resolutions.
|
|
*/
|
|
|
|
// Prevent expansion of too long placeables.
|
|
const MAX_PLACEABLE_LENGTH = 2500;
|
|
|
|
// Unicode bidi isolation characters.
|
|
const FSI = "\u2068";
|
|
const PDI = "\u2069";
|
|
|
|
|
|
/**
|
|
* Helper for matching a variant key to the given selector.
|
|
*
|
|
* Used in SelectExpressions and VariantExpressions.
|
|
*
|
|
* @param {FluentBundle} bundle
|
|
* Resolver environment object.
|
|
* @param {FluentType} key
|
|
* The key of the currently considered variant.
|
|
* @param {FluentType} selector
|
|
* The selector based om which the correct variant should be chosen.
|
|
* @returns {FluentType}
|
|
* @private
|
|
*/
|
|
function match(bundle, selector, key) {
|
|
if (key === selector) {
|
|
// Both are strings.
|
|
return true;
|
|
}
|
|
|
|
if (key instanceof FluentNumber
|
|
&& selector instanceof FluentNumber
|
|
&& key.value === selector.value) {
|
|
return true;
|
|
}
|
|
|
|
if (selector instanceof FluentNumber && typeof key === "string") {
|
|
let category = bundle
|
|
._memoizeIntlObject(Intl.PluralRules, selector.opts)
|
|
.select(selector.value);
|
|
if (key === category) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Helper for choosing the default value from a set of members.
|
|
*
|
|
* Used in SelectExpressions and Type.
|
|
*
|
|
* @param {Object} env
|
|
* Resolver environment object.
|
|
* @param {Object} members
|
|
* Hash map of variants from which the default value is to be selected.
|
|
* @param {Number} star
|
|
* The index of the default variant.
|
|
* @returns {FluentType}
|
|
* @private
|
|
*/
|
|
function DefaultMember(env, members, star) {
|
|
if (members[star]) {
|
|
return members[star];
|
|
}
|
|
|
|
const { errors } = env;
|
|
errors.push(new RangeError("No default"));
|
|
return new FluentNone();
|
|
}
|
|
|
|
|
|
/**
|
|
* Resolve a reference to another message.
|
|
*
|
|
* @param {Object} env
|
|
* Resolver environment object.
|
|
* @param {Object} id
|
|
* The identifier of the message to be resolved.
|
|
* @param {String} id.name
|
|
* The name of the identifier.
|
|
* @returns {FluentType}
|
|
* @private
|
|
*/
|
|
function MessageReference(env, {name}) {
|
|
const { bundle, errors } = env;
|
|
const message = name.startsWith("-")
|
|
? bundle._terms.get(name)
|
|
: bundle._messages.get(name);
|
|
|
|
if (!message) {
|
|
const err = name.startsWith("-")
|
|
? new ReferenceError(`Unknown term: ${name}`)
|
|
: new ReferenceError(`Unknown message: ${name}`);
|
|
errors.push(err);
|
|
return new FluentNone(name);
|
|
}
|
|
|
|
return message;
|
|
}
|
|
|
|
/**
|
|
* Resolve a variant expression to the variant object.
|
|
*
|
|
* @param {Object} env
|
|
* Resolver environment object.
|
|
* @param {Object} expr
|
|
* An expression to be resolved.
|
|
* @param {Object} expr.ref
|
|
* An Identifier of a message for which the variant is resolved.
|
|
* @param {Object} expr.id.name
|
|
* Name a message for which the variant is resolved.
|
|
* @param {Object} expr.key
|
|
* Variant key to be resolved.
|
|
* @returns {FluentType}
|
|
* @private
|
|
*/
|
|
function VariantExpression(env, {ref, selector}) {
|
|
const message = MessageReference(env, ref);
|
|
if (message instanceof FluentNone) {
|
|
return message;
|
|
}
|
|
|
|
const { bundle, errors } = env;
|
|
const sel = Type(env, selector);
|
|
const value = message.value || message;
|
|
|
|
function isVariantList(node) {
|
|
return Array.isArray(node) &&
|
|
node[0].type === "select" &&
|
|
node[0].selector === null;
|
|
}
|
|
|
|
if (isVariantList(value)) {
|
|
// Match the specified key against keys of each variant, in order.
|
|
for (const variant of value[0].variants) {
|
|
const key = Type(env, variant.key);
|
|
if (match(env.bundle, sel, key)) {
|
|
return variant;
|
|
}
|
|
}
|
|
}
|
|
|
|
errors.push(
|
|
new ReferenceError(`Unknown variant: ${sel.toString(bundle)}`));
|
|
return Type(env, message);
|
|
}
|
|
|
|
|
|
/**
|
|
* Resolve an attribute expression to the attribute object.
|
|
*
|
|
* @param {Object} env
|
|
* Resolver environment object.
|
|
* @param {Object} expr
|
|
* An expression to be resolved.
|
|
* @param {String} expr.ref
|
|
* An ID of a message for which the attribute is resolved.
|
|
* @param {String} expr.name
|
|
* Name of the attribute to be resolved.
|
|
* @returns {FluentType}
|
|
* @private
|
|
*/
|
|
function AttributeExpression(env, {ref, name}) {
|
|
const message = MessageReference(env, ref);
|
|
if (message instanceof FluentNone) {
|
|
return message;
|
|
}
|
|
|
|
if (message.attrs) {
|
|
// Match the specified name against keys of each attribute.
|
|
for (const attrName in message.attrs) {
|
|
if (name === attrName) {
|
|
return message.attrs[name];
|
|
}
|
|
}
|
|
}
|
|
|
|
const { errors } = env;
|
|
errors.push(new ReferenceError(`Unknown attribute: ${name}`));
|
|
return Type(env, message);
|
|
}
|
|
|
|
/**
|
|
* Resolve a select expression to the member object.
|
|
*
|
|
* @param {Object} env
|
|
* Resolver environment object.
|
|
* @param {Object} expr
|
|
* An expression to be resolved.
|
|
* @param {String} expr.selector
|
|
* Selector expression
|
|
* @param {Array} expr.variants
|
|
* List of variants for the select expression.
|
|
* @param {Number} expr.star
|
|
* Index of the default variant.
|
|
* @returns {FluentType}
|
|
* @private
|
|
*/
|
|
function SelectExpression(env, {selector, variants, star}) {
|
|
if (selector === null) {
|
|
return DefaultMember(env, variants, star);
|
|
}
|
|
|
|
let sel = Type(env, selector);
|
|
if (sel instanceof FluentNone) {
|
|
return DefaultMember(env, variants, star);
|
|
}
|
|
|
|
// Match the selector against keys of each variant, in order.
|
|
for (const variant of variants) {
|
|
const key = Type(env, variant.key);
|
|
if (match(env.bundle, sel, key)) {
|
|
return variant;
|
|
}
|
|
}
|
|
|
|
return DefaultMember(env, variants, star);
|
|
}
|
|
|
|
|
|
/**
|
|
* Resolve expression to a Fluent type.
|
|
*
|
|
* JavaScript strings are a special case. Since they natively have the
|
|
* `toString` method they can be used as if they were a Fluent type without
|
|
* paying the cost of creating a instance of one.
|
|
*
|
|
* @param {Object} env
|
|
* Resolver environment object.
|
|
* @param {Object} expr
|
|
* An expression object to be resolved into a Fluent type.
|
|
* @returns {FluentType}
|
|
* @private
|
|
*/
|
|
function Type(env, expr) {
|
|
// A fast-path for strings which are the most common case, and for
|
|
// `FluentNone` which doesn't require any additional logic.
|
|
if (typeof expr === "string") {
|
|
return env.bundle._transform(expr);
|
|
}
|
|
if (expr instanceof FluentNone) {
|
|
return expr;
|
|
}
|
|
|
|
// The Runtime AST (Entries) encodes patterns (complex strings with
|
|
// placeables) as Arrays.
|
|
if (Array.isArray(expr)) {
|
|
return Pattern(env, expr);
|
|
}
|
|
|
|
|
|
switch (expr.type) {
|
|
case "num":
|
|
return new FluentNumber(expr.value);
|
|
case "var":
|
|
return VariableReference(env, expr);
|
|
case "func":
|
|
return FunctionReference(env, expr);
|
|
case "call":
|
|
return CallExpression(env, expr);
|
|
case "ref": {
|
|
const message = MessageReference(env, expr);
|
|
return Type(env, message);
|
|
}
|
|
case "getattr": {
|
|
const attr = AttributeExpression(env, expr);
|
|
return Type(env, attr);
|
|
}
|
|
case "getvar": {
|
|
const variant = VariantExpression(env, expr);
|
|
return Type(env, variant);
|
|
}
|
|
case "select": {
|
|
const member = SelectExpression(env, expr);
|
|
return Type(env, member);
|
|
}
|
|
case undefined: {
|
|
// If it's a node with a value, resolve the value.
|
|
if (expr.value !== null && expr.value !== undefined) {
|
|
return Type(env, expr.value);
|
|
}
|
|
|
|
const { errors } = env;
|
|
errors.push(new RangeError("No value"));
|
|
return new FluentNone();
|
|
}
|
|
default:
|
|
return new FluentNone();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a reference to a variable.
|
|
*
|
|
* @param {Object} env
|
|
* Resolver environment object.
|
|
* @param {Object} expr
|
|
* An expression to be resolved.
|
|
* @param {String} expr.name
|
|
* Name of an argument to be returned.
|
|
* @returns {FluentType}
|
|
* @private
|
|
*/
|
|
function VariableReference(env, {name}) {
|
|
const { args, errors } = env;
|
|
|
|
if (!args || !args.hasOwnProperty(name)) {
|
|
errors.push(new ReferenceError(`Unknown variable: ${name}`));
|
|
return new FluentNone(name);
|
|
}
|
|
|
|
const arg = args[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 (arg instanceof Date) {
|
|
return new FluentDateTime(arg);
|
|
}
|
|
default:
|
|
errors.push(
|
|
new TypeError(`Unsupported variable type: ${name}, ${typeof arg}`)
|
|
);
|
|
return new FluentNone(name);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a reference to a function.
|
|
*
|
|
* @param {Object} env
|
|
* Resolver environment object.
|
|
* @param {Object} expr
|
|
* An expression to be resolved.
|
|
* @param {String} expr.name
|
|
* Name of the function to be returned.
|
|
* @returns {Function}
|
|
* @private
|
|
*/
|
|
function FunctionReference(env, {name}) {
|
|
// Some functions are built-in. Others may be provided by the runtime via
|
|
// the `FluentBundle` constructor.
|
|
const { bundle: { _functions }, errors } = env;
|
|
const func = _functions[name] || builtins[name];
|
|
|
|
if (!func) {
|
|
errors.push(new ReferenceError(`Unknown function: ${name}()`));
|
|
return new FluentNone(`${name}()`);
|
|
}
|
|
|
|
if (typeof func !== "function") {
|
|
errors.push(new TypeError(`Function ${name}() is not callable`));
|
|
return new FluentNone(`${name}()`);
|
|
}
|
|
|
|
return func;
|
|
}
|
|
|
|
/**
|
|
* Resolve a call to a Function with positional and key-value arguments.
|
|
*
|
|
* @param {Object} env
|
|
* Resolver environment object.
|
|
* @param {Object} expr
|
|
* An expression to be resolved.
|
|
* @param {Object} expr.callee
|
|
* FTL Function object.
|
|
* @param {Array} expr.args
|
|
* FTL Function argument list.
|
|
* @returns {FluentType}
|
|
* @private
|
|
*/
|
|
function CallExpression(env, {callee, args}) {
|
|
const func = FunctionReference(env, callee);
|
|
|
|
if (func instanceof FluentNone) {
|
|
return func;
|
|
}
|
|
|
|
const posargs = [];
|
|
const keyargs = {};
|
|
|
|
for (const arg of args) {
|
|
if (arg.type === "narg") {
|
|
keyargs[arg.name] = Type(env, arg.value);
|
|
} else {
|
|
posargs.push(Type(env, arg));
|
|
}
|
|
}
|
|
|
|
try {
|
|
return func(posargs, keyargs);
|
|
} catch (e) {
|
|
// XXX Report errors.
|
|
return new FluentNone();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a pattern (a complex string with placeables).
|
|
*
|
|
* @param {Object} env
|
|
* Resolver environment object.
|
|
* @param {Array} ptn
|
|
* Array of pattern elements.
|
|
* @returns {Array}
|
|
* @private
|
|
*/
|
|
function Pattern(env, ptn) {
|
|
const { bundle, dirty, errors } = env;
|
|
|
|
if (dirty.has(ptn)) {
|
|
errors.push(new RangeError("Cyclic reference"));
|
|
return new FluentNone();
|
|
}
|
|
|
|
// Tag the pattern as dirty for the purpose of the current resolution.
|
|
dirty.add(ptn);
|
|
const result = [];
|
|
|
|
// Wrap interpolations with Directional Isolate Formatting characters
|
|
// only when the pattern has more than one element.
|
|
const useIsolating = bundle._useIsolating && ptn.length > 1;
|
|
|
|
for (const elem of ptn) {
|
|
if (typeof elem === "string") {
|
|
result.push(bundle._transform(elem));
|
|
continue;
|
|
}
|
|
|
|
const part = Type(env, elem).toString(bundle);
|
|
|
|
if (useIsolating) {
|
|
result.push(FSI);
|
|
}
|
|
|
|
if (part.length > MAX_PLACEABLE_LENGTH) {
|
|
errors.push(
|
|
new RangeError(
|
|
"Too many characters in placeable " +
|
|
`(${part.length}, max allowed is ${MAX_PLACEABLE_LENGTH})`
|
|
)
|
|
);
|
|
result.push(part.slice(MAX_PLACEABLE_LENGTH));
|
|
} else {
|
|
result.push(part);
|
|
}
|
|
|
|
if (useIsolating) {
|
|
result.push(PDI);
|
|
}
|
|
}
|
|
|
|
dirty.delete(ptn);
|
|
return result.join("");
|
|
}
|
|
|
|
/**
|
|
* Format a translation into a string.
|
|
*
|
|
* @param {FluentBundle} bundle
|
|
* A FluentBundle instance which will be used to resolve the
|
|
* contextual information of the message.
|
|
* @param {Object} args
|
|
* List of arguments provided by the developer which can be accessed
|
|
* from the message.
|
|
* @param {Object} message
|
|
* An object with the Message to be resolved.
|
|
* @param {Array} errors
|
|
* An error array that any encountered errors will be appended to.
|
|
* @returns {FluentType}
|
|
*/
|
|
function resolve(bundle, args, message, errors = []) {
|
|
const env = {
|
|
bundle, args, errors, dirty: new WeakSet(),
|
|
};
|
|
return Type(env, message).toString(bundle);
|
|
}
|
|
|
|
class FluentError extends Error {}
|
|
|
|
// This regex is used to iterate through the beginnings of messages and terms.
|
|
// With the /m flag, the ^ matches at the beginning of every line.
|
|
const RE_MESSAGE_START = /^(-?[a-zA-Z][a-zA-Z0-9_-]*) *= */mg;
|
|
|
|
// Both Attributes and Variants are parsed in while loops. These regexes are
|
|
// used to break out of them.
|
|
const RE_ATTRIBUTE_START = /\.([a-zA-Z][a-zA-Z0-9_-]*) *= */y;
|
|
// [^] matches all characters, including newlines.
|
|
// XXX Use /s (dotall) when it's widely supported.
|
|
const RE_VARIANT_START = /\*?\[[^]*?] */y;
|
|
|
|
const RE_IDENTIFIER = /(-?[a-zA-Z][a-zA-Z0-9_-]*)/y;
|
|
const RE_NUMBER_LITERAL = /(-?[0-9]+(\.[0-9]+)?)/y;
|
|
|
|
// A "run" is a sequence of text or string literal characters which don't
|
|
// require any special handling. For TextElements such special characters are:
|
|
// { (starts a placeable), \ (starts an escape sequence), and line breaks which
|
|
// require additional logic to check if the next line is indented. For
|
|
// StringLiterals they are: \ (starts an escape sequence), " (ends the
|
|
// literal), and line breaks which are not allowed in StringLiterals. Also note
|
|
// that string runs may be empty, but text runs may not.
|
|
const RE_TEXT_RUN = /([^\\{\n\r]+)/y;
|
|
const RE_STRING_RUN = /([^\\"\n\r]*)/y;
|
|
|
|
// Escape sequences.
|
|
const RE_UNICODE_ESCAPE = /\\u([a-fA-F0-9]{4})/y;
|
|
const RE_STRING_ESCAPE = /\\([\\"])/y;
|
|
const RE_TEXT_ESCAPE = /\\([\\{])/y;
|
|
|
|
// Used for trimming TextElements and indents. With the /m flag, the $ matches
|
|
// the end of every line.
|
|
const RE_TRAILING_SPACES = / +$/mg;
|
|
// CRLFs are normalized to LF.
|
|
const RE_CRLF = /\r\n/g;
|
|
|
|
// Common tokens.
|
|
const TOKEN_BRACE_OPEN = /{\s*/y;
|
|
const TOKEN_BRACE_CLOSE = /\s*}/y;
|
|
const TOKEN_BRACKET_OPEN = /\[\s*/y;
|
|
const TOKEN_BRACKET_CLOSE = /\s*]/y;
|
|
const TOKEN_PAREN_OPEN = /\(\s*/y;
|
|
const TOKEN_ARROW = /\s*->\s*/y;
|
|
const TOKEN_COLON = /\s*:\s*/y;
|
|
// Note the optional comma. As a deviation from the Fluent EBNF, the parser
|
|
// doesn't enforce commas between call arguments.
|
|
const TOKEN_COMMA = /\s*,?\s*/y;
|
|
const TOKEN_BLANK = /\s+/y;
|
|
|
|
// Maximum number of placeables in a single Pattern to protect against Quadratic
|
|
// Blowup attacks. See https://msdn.microsoft.com/en-us/magazine/ee335713.aspx.
|
|
const MAX_PLACEABLES = 100;
|
|
|
|
/**
|
|
* Fluent Resource is a structure storing a map of parsed localization entries.
|
|
*/
|
|
class FluentResource extends Map {
|
|
/**
|
|
* Create a new FluentResource from Fluent code.
|
|
*/
|
|
static fromString(source) {
|
|
RE_MESSAGE_START.lastIndex = 0;
|
|
|
|
let resource = new this();
|
|
let cursor = 0;
|
|
|
|
// Iterate over the beginnings of messages and terms to efficiently skip
|
|
// comments and recover from errors.
|
|
while (true) {
|
|
let next = RE_MESSAGE_START.exec(source);
|
|
if (next === null) {
|
|
break;
|
|
}
|
|
|
|
cursor = RE_MESSAGE_START.lastIndex;
|
|
try {
|
|
resource.set(next[1], parseMessage());
|
|
} catch (err) {
|
|
if (err instanceof FluentError) {
|
|
// Don't report any Fluent syntax errors. Skip directly to the
|
|
// beginning of the next message or term.
|
|
continue;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
return resource;
|
|
|
|
// The parser implementation is inlined below for performance reasons.
|
|
|
|
// The parser focuses on minimizing the number of false negatives at the
|
|
// expense of increasing the risk of false positives. In other words, it
|
|
// aims at parsing valid Fluent messages with a success rate of 100%, but it
|
|
// may also parse a few invalid messages which the reference parser would
|
|
// reject. The parser doesn't perform any validation and may produce entries
|
|
// which wouldn't make sense in the real world. For best results users are
|
|
// advised to validate translations with the fluent-syntax parser
|
|
// pre-runtime.
|
|
|
|
// The parser makes an extensive use of sticky regexes which can be anchored
|
|
// to any offset of the source string without slicing it. Errors are thrown
|
|
// to bail out of parsing of ill-formed messages.
|
|
|
|
function test(re) {
|
|
re.lastIndex = cursor;
|
|
return re.test(source);
|
|
}
|
|
|
|
// Advance the cursor by the char if it matches. May be used as a predicate
|
|
// (was the match found?) or, if errorClass is passed, as an assertion.
|
|
function consumeChar(char, errorClass) {
|
|
if (source[cursor] === char) {
|
|
cursor++;
|
|
return true;
|
|
}
|
|
if (errorClass) {
|
|
throw new errorClass(`Expected ${char}`);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Advance the cursor by the token if it matches. May be used as a predicate
|
|
// (was the match found?) or, if errorClass is passed, as an assertion.
|
|
function consumeToken(re, errorClass) {
|
|
if (test(re)) {
|
|
cursor = re.lastIndex;
|
|
return true;
|
|
}
|
|
if (errorClass) {
|
|
throw new errorClass(`Expected ${re.toString()}`);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Execute a regex, advance the cursor, and return the capture group.
|
|
function match(re) {
|
|
re.lastIndex = cursor;
|
|
let result = re.exec(source);
|
|
if (result === null) {
|
|
throw new FluentError(`Expected ${re.toString()}`);
|
|
}
|
|
cursor = re.lastIndex;
|
|
return result[1];
|
|
}
|
|
|
|
function parseMessage() {
|
|
let value = parsePattern();
|
|
let attrs = parseAttributes();
|
|
|
|
if (attrs === null) {
|
|
return value;
|
|
}
|
|
|
|
return {value, attrs};
|
|
}
|
|
|
|
function parseAttributes() {
|
|
let attrs = {};
|
|
let hasAttributes = false;
|
|
|
|
while (test(RE_ATTRIBUTE_START)) {
|
|
if (!hasAttributes) {
|
|
hasAttributes = true;
|
|
}
|
|
|
|
let name = match(RE_ATTRIBUTE_START);
|
|
attrs[name] = parsePattern();
|
|
}
|
|
|
|
return hasAttributes ? attrs : null;
|
|
}
|
|
|
|
function parsePattern() {
|
|
// First try to parse any simple text on the same line as the id.
|
|
if (test(RE_TEXT_RUN)) {
|
|
var first = match(RE_TEXT_RUN);
|
|
}
|
|
|
|
// If there's a backslash escape or a placeable on the first line, fall
|
|
// back to parsing a complex pattern.
|
|
switch (source[cursor]) {
|
|
case "{":
|
|
case "\\":
|
|
return first
|
|
// Re-use the text parsed above, if possible.
|
|
? parsePatternElements(first)
|
|
: parsePatternElements();
|
|
}
|
|
|
|
// RE_TEXT_VALUE stops at newlines. Only continue parsing the pattern if
|
|
// what comes after the newline is indented.
|
|
let indent = parseIndent();
|
|
if (indent) {
|
|
return first
|
|
// If there's text on the first line, the blank block is part of the
|
|
// translation content.
|
|
? parsePatternElements(first, trim(indent))
|
|
// Otherwise, we're dealing with a block pattern. The blank block is
|
|
// the leading whitespace; discard it.
|
|
: parsePatternElements();
|
|
}
|
|
|
|
if (first) {
|
|
// It was just a simple inline text after all.
|
|
return trim(first);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Parse a complex pattern as an array of elements.
|
|
function parsePatternElements(...elements) {
|
|
let placeableCount = 0;
|
|
let needsTrimming = false;
|
|
|
|
while (true) {
|
|
if (test(RE_TEXT_RUN)) {
|
|
elements.push(match(RE_TEXT_RUN));
|
|
needsTrimming = true;
|
|
continue;
|
|
}
|
|
|
|
if (source[cursor] === "{") {
|
|
if (++placeableCount > MAX_PLACEABLES) {
|
|
throw new FluentError("Too many placeables");
|
|
}
|
|
elements.push(parsePlaceable());
|
|
needsTrimming = false;
|
|
continue;
|
|
}
|
|
|
|
let indent = parseIndent();
|
|
if (indent) {
|
|
elements.push(trim(indent));
|
|
needsTrimming = false;
|
|
continue;
|
|
}
|
|
|
|
if (source[cursor] === "\\") {
|
|
elements.push(parseEscapeSequence(RE_TEXT_ESCAPE));
|
|
needsTrimming = false;
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
if (needsTrimming) {
|
|
// Trim the trailing whitespace of the last element if it's a
|
|
// TextElement. Use a flag rather than a typeof check to tell
|
|
// TextElements and StringLiterals apart (both are strings).
|
|
let lastIndex = elements.length - 1;
|
|
elements[lastIndex] = trim(elements[lastIndex]);
|
|
}
|
|
|
|
return elements;
|
|
}
|
|
|
|
function parsePlaceable() {
|
|
consumeToken(TOKEN_BRACE_OPEN, FluentError);
|
|
|
|
// VariantLists are parsed as selector-less SelectExpressions.
|
|
let onlyVariants = parseVariants();
|
|
if (onlyVariants) {
|
|
consumeToken(TOKEN_BRACE_CLOSE, FluentError);
|
|
return {type: "select", selector: null, ...onlyVariants};
|
|
}
|
|
|
|
let selector = parseInlineExpression();
|
|
if (consumeToken(TOKEN_BRACE_CLOSE)) {
|
|
return selector;
|
|
}
|
|
|
|
if (consumeToken(TOKEN_ARROW)) {
|
|
let variants = parseVariants();
|
|
consumeToken(TOKEN_BRACE_CLOSE, FluentError);
|
|
return {type: "select", selector, ...variants};
|
|
}
|
|
|
|
throw new FluentError("Unclosed placeable");
|
|
}
|
|
|
|
function parseInlineExpression() {
|
|
if (source[cursor] === "{") {
|
|
// It's a nested placeable.
|
|
return parsePlaceable();
|
|
}
|
|
|
|
if (consumeChar("$")) {
|
|
return {type: "var", name: match(RE_IDENTIFIER)};
|
|
}
|
|
|
|
if (test(RE_IDENTIFIER)) {
|
|
let ref = {type: "ref", name: match(RE_IDENTIFIER)};
|
|
|
|
if (consumeChar(".")) {
|
|
let name = match(RE_IDENTIFIER);
|
|
return {type: "getattr", ref, name};
|
|
}
|
|
|
|
if (source[cursor] === "[") {
|
|
return {type: "getvar", ref, selector: parseVariantKey()};
|
|
}
|
|
|
|
if (consumeToken(TOKEN_PAREN_OPEN)) {
|
|
let callee = {...ref, type: "func"};
|
|
return {type: "call", callee, args: parseArguments()};
|
|
}
|
|
|
|
return ref;
|
|
}
|
|
|
|
return parseLiteral();
|
|
}
|
|
|
|
function parseArguments() {
|
|
let args = [];
|
|
while (true) {
|
|
switch (source[cursor]) {
|
|
case ")": // End of the argument list.
|
|
cursor++;
|
|
return args;
|
|
case undefined: // EOF
|
|
throw new FluentError("Unclosed argument list");
|
|
}
|
|
|
|
args.push(parseArgument());
|
|
// Commas between arguments are treated as whitespace.
|
|
consumeToken(TOKEN_COMMA);
|
|
}
|
|
}
|
|
|
|
function parseArgument() {
|
|
let ref = parseInlineExpression();
|
|
if (ref.type !== "ref") {
|
|
return ref;
|
|
}
|
|
|
|
if (consumeToken(TOKEN_COLON)) {
|
|
// The reference is the beginning of a named argument.
|
|
return {type: "narg", name: ref.name, value: parseLiteral()};
|
|
}
|
|
|
|
// It's a regular message reference.
|
|
return ref;
|
|
}
|
|
|
|
function parseVariants() {
|
|
let variants = [];
|
|
let count = 0;
|
|
let star;
|
|
|
|
while (test(RE_VARIANT_START)) {
|
|
if (consumeChar("*")) {
|
|
star = count;
|
|
}
|
|
|
|
let key = parseVariantKey();
|
|
cursor = RE_VARIANT_START.lastIndex;
|
|
variants[count++] = {key, value: parsePattern()};
|
|
}
|
|
|
|
return count > 0 ? {variants, star} : null;
|
|
}
|
|
|
|
function parseVariantKey() {
|
|
consumeToken(TOKEN_BRACKET_OPEN, FluentError);
|
|
let key = test(RE_NUMBER_LITERAL)
|
|
? parseNumberLiteral()
|
|
: match(RE_IDENTIFIER);
|
|
consumeToken(TOKEN_BRACKET_CLOSE, FluentError);
|
|
return key;
|
|
}
|
|
|
|
function parseLiteral() {
|
|
if (test(RE_NUMBER_LITERAL)) {
|
|
return parseNumberLiteral();
|
|
}
|
|
|
|
if (source[cursor] === "\"") {
|
|
return parseStringLiteral();
|
|
}
|
|
|
|
throw new FluentError("Invalid expression");
|
|
}
|
|
|
|
function parseNumberLiteral() {
|
|
return {type: "num", value: match(RE_NUMBER_LITERAL)};
|
|
}
|
|
|
|
function parseStringLiteral() {
|
|
consumeChar("\"", FluentError);
|
|
let value = "";
|
|
while (true) {
|
|
value += match(RE_STRING_RUN);
|
|
|
|
if (source[cursor] === "\\") {
|
|
value += parseEscapeSequence(RE_STRING_ESCAPE);
|
|
continue;
|
|
}
|
|
|
|
if (consumeChar("\"")) {
|
|
return value;
|
|
}
|
|
|
|
// We've reached an EOL of EOF.
|
|
throw new FluentError("Unclosed string literal");
|
|
}
|
|
}
|
|
|
|
// Unescape known escape sequences.
|
|
function parseEscapeSequence(reSpecialized) {
|
|
if (test(RE_UNICODE_ESCAPE)) {
|
|
let sequence = match(RE_UNICODE_ESCAPE);
|
|
return String.fromCodePoint(parseInt(sequence, 16));
|
|
}
|
|
|
|
if (test(reSpecialized)) {
|
|
return match(reSpecialized);
|
|
}
|
|
|
|
throw new FluentError("Unknown escape sequence");
|
|
}
|
|
|
|
// Parse blank space. Return it if it looks like indent before a pattern
|
|
// line. Skip it othwerwise.
|
|
function parseIndent() {
|
|
let start = cursor;
|
|
consumeToken(TOKEN_BLANK);
|
|
|
|
// Check the first non-blank character after the indent.
|
|
switch (source[cursor]) {
|
|
case ".":
|
|
case "[":
|
|
case "*":
|
|
case "}":
|
|
case undefined: // EOF
|
|
// A special character. End the Pattern.
|
|
return false;
|
|
case "{":
|
|
// Placeables don't require indentation (in EBNF: block-placeable).
|
|
// Continue the Pattern.
|
|
return source.slice(start, cursor).replace(RE_CRLF, "\n");
|
|
}
|
|
|
|
// If the first character on the line is not one of the special characters
|
|
// listed above, it's a regular text character. Check if there's at least
|
|
// one space of indent before it.
|
|
if (source[cursor - 1] === " ") {
|
|
// It's an indented text character (in EBNF: indented-char). Continue
|
|
// the Pattern.
|
|
return source.slice(start, cursor).replace(RE_CRLF, "\n");
|
|
}
|
|
|
|
// A not-indented text character is likely the identifier of the next
|
|
// message. End the Pattern.
|
|
return false;
|
|
}
|
|
|
|
// Trim spaces trailing on every line of text.
|
|
function trim(text) {
|
|
return text.replace(RE_TRAILING_SPACES, "");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Message bundles are single-language stores of translations. They are
|
|
* responsible for parsing translation resources in the Fluent syntax and can
|
|
* format translation units (entities) to strings.
|
|
*
|
|
* Always use `FluentBundle.format` to retrieve translation units from a
|
|
* bundle. Translations can contain references to other entities 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, dates, lists and more into the
|
|
* bundle's language. See the documentation of the Fluent syntax for more
|
|
* information.
|
|
*/
|
|
class FluentBundle {
|
|
|
|
/**
|
|
* Create an instance of `FluentBundle`.
|
|
*
|
|
* The `locales` argument is used to instantiate `Intl` formatters used by
|
|
* translations. The `options` object can be used to configure the bundle.
|
|
*
|
|
* Examples:
|
|
*
|
|
* const bundle = new FluentBundle(locales);
|
|
*
|
|
* const bundle = new FluentBundle(locales, { useIsolating: false });
|
|
*
|
|
* const bundle = new FluentBundle(locales, {
|
|
* useIsolating: true,
|
|
* functions: {
|
|
* NODE_ENV: () => process.env.NODE_ENV
|
|
* }
|
|
* });
|
|
*
|
|
* Available options:
|
|
*
|
|
* - `functions` - an object of additional functions available to
|
|
* translations as builtins.
|
|
*
|
|
* - `useIsolating` - boolean specifying whether to use Unicode isolation
|
|
* marks (FSI, PDI) for bidi interpolations.
|
|
*
|
|
* - `transform` - a function used to transform string parts of patterns.
|
|
*
|
|
* @param {string|Array<string>} locales - Locale or locales of the bundle
|
|
* @param {Object} [options]
|
|
* @returns {FluentBundle}
|
|
*/
|
|
constructor(locales, {
|
|
functions = {},
|
|
useIsolating = true,
|
|
transform = v => v,
|
|
} = {}) {
|
|
this.locales = Array.isArray(locales) ? locales : [locales];
|
|
|
|
this._terms = new Map();
|
|
this._messages = new Map();
|
|
this._functions = functions;
|
|
this._useIsolating = useIsolating;
|
|
this._transform = transform;
|
|
this._intls = new WeakMap();
|
|
}
|
|
|
|
/*
|
|
* Return an iterator over public `[id, message]` pairs.
|
|
*
|
|
* @returns {Iterator}
|
|
*/
|
|
get messages() {
|
|
return this._messages[Symbol.iterator]();
|
|
}
|
|
|
|
/*
|
|
* Check if a message is present in the bundle.
|
|
*
|
|
* @param {string} id - The identifier of the message to check.
|
|
* @returns {bool}
|
|
*/
|
|
hasMessage(id) {
|
|
return this._messages.has(id);
|
|
}
|
|
|
|
/*
|
|
* Return the internal representation of a message.
|
|
*
|
|
* The internal representation should only be used as an argument to
|
|
* `FluentBundle.format`.
|
|
*
|
|
* @param {string} id - The identifier of the message to check.
|
|
* @returns {Any}
|
|
*/
|
|
getMessage(id) {
|
|
return this._messages.get(id);
|
|
}
|
|
|
|
/**
|
|
* Add a translation resource to the bundle.
|
|
*
|
|
* The translation resource must use the Fluent syntax. It will be parsed by
|
|
* the bundle and each translation unit (message) will be available in the
|
|
* bundle by its identifier.
|
|
*
|
|
* bundle.addMessages('foo = Foo');
|
|
* bundle.getMessage('foo');
|
|
*
|
|
* // Returns a raw representation of the 'foo' message.
|
|
*
|
|
* Parsed entities should be formatted with the `format` method in case they
|
|
* contain logic (references, select expressions etc.).
|
|
*
|
|
* @param {string} source - Text resource with translations.
|
|
* @returns {Array<Error>}
|
|
*/
|
|
addMessages(source) {
|
|
const res = FluentResource.fromString(source);
|
|
return this.addResource(res);
|
|
}
|
|
|
|
/**
|
|
* Add a translation resource to the bundle.
|
|
*
|
|
* The translation resource must be an instance of FluentResource,
|
|
* e.g. parsed by `FluentResource.fromString`.
|
|
*
|
|
* let res = FluentResource.fromString("foo = Foo");
|
|
* bundle.addResource(res);
|
|
* bundle.getMessage('foo');
|
|
*
|
|
* // Returns a raw representation of the 'foo' message.
|
|
*
|
|
* Parsed entities should be formatted with the `format` method in case they
|
|
* contain logic (references, select expressions etc.).
|
|
*
|
|
* @param {FluentResource} res - FluentResource object.
|
|
* @returns {Array<Error>}
|
|
*/
|
|
addResource(res) {
|
|
const errors = [];
|
|
|
|
for (const [id, value] of res) {
|
|
if (id.startsWith("-")) {
|
|
// Identifiers starting with a dash (-) define terms. Terms are private
|
|
// and cannot be retrieved from FluentBundle.
|
|
if (this._terms.has(id)) {
|
|
errors.push(`Attempt to override an existing term: "${id}"`);
|
|
continue;
|
|
}
|
|
this._terms.set(id, value);
|
|
} else {
|
|
if (this._messages.has(id)) {
|
|
errors.push(`Attempt to override an existing message: "${id}"`);
|
|
continue;
|
|
}
|
|
this._messages.set(id, value);
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Format a message to a string or null.
|
|
*
|
|
* Format a raw `message` from the bundle into a string (or a null if it has
|
|
* a null value). `args` will be used to resolve references to variables
|
|
* passed as arguments to the translation.
|
|
*
|
|
* In case of errors `format` will try to salvage as much of the translation
|
|
* as possible and will still return a string. For performance reasons, the
|
|
* encountered errors are not returned but instead are appended to the
|
|
* `errors` array passed as the third argument.
|
|
*
|
|
* const errors = [];
|
|
* bundle.addMessages('hello = Hello, { $name }!');
|
|
* const hello = bundle.getMessage('hello');
|
|
* bundle.format(hello, { name: 'Jane' }, errors);
|
|
*
|
|
* // Returns 'Hello, Jane!' and `errors` is empty.
|
|
*
|
|
* bundle.format(hello, undefined, errors);
|
|
*
|
|
* // Returns 'Hello, name!' and `errors` is now:
|
|
*
|
|
* [<ReferenceError: Unknown variable: name>]
|
|
*
|
|
* @param {Object | string} message
|
|
* @param {Object | undefined} args
|
|
* @param {Array} errors
|
|
* @returns {?string}
|
|
*/
|
|
format(message, args, errors) {
|
|
// optimize entities which are simple strings with no attributes
|
|
if (typeof message === "string") {
|
|
return this._transform(message);
|
|
}
|
|
|
|
// optimize entities with null values
|
|
if (message === null || message.value === null) {
|
|
return null;
|
|
}
|
|
|
|
// optimize simple-string entities with attributes
|
|
if (typeof message.value === "string") {
|
|
return this._transform(message.value);
|
|
}
|
|
|
|
return resolve(this, args, message, errors);
|
|
}
|
|
|
|
_memoizeIntlObject(ctor, opts) {
|
|
const cache = this._intls.get(ctor) || {};
|
|
const id = JSON.stringify(opts);
|
|
|
|
if (!cache[id]) {
|
|
cache[id] = new ctor(this.locales, opts);
|
|
this._intls.set(ctor, cache);
|
|
}
|
|
|
|
return cache[id];
|
|
}
|
|
}
|
|
|
|
this.FluentBundle = FluentBundle;
|
|
this.FluentResource = FluentResource;
|
|
var EXPORTED_SYMBOLS = ["FluentBundle", "FluentResource"];
|