gecko-dev/intl/l10n/MessageContext.jsm

1865 строки
45 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@0.4.1 */
/* eslint no-magic-numbers: [0] */
const MAX_PLACEABLES = 100;
const identifierRe = new RegExp('[a-zA-Z_][a-zA-Z0-9_-]*', 'y');
/**
* The `Parser` class is responsible for parsing FTL resources.
*
* It's only public method is `getResource(source)` which takes an FTL string
* and returns a two element Array with an Object of entries generated from the
* source as the first element and an array of SyntaxError objects as the
* second.
*
* This parser is optimized for runtime performance.
*
* There is an equivalent of this parser in syntax/parser which is
* generating full AST which is useful for FTL tools.
*/
class RuntimeParser {
/**
* Parse FTL code into entries formattable by the MessageContext.
*
* Given a string of FTL syntax, return a map of entries that can be passed
* to MessageContext.format and a list of errors encountered during parsing.
*
* @param {String} string
* @returns {Array<Object, Array>}
*/
getResource(string) {
this._source = string;
this._index = 0;
this._length = string.length;
this.entries = {};
const errors = [];
this.skipWS();
while (this._index < this._length) {
try {
this.getEntry();
} catch (e) {
if (e instanceof SyntaxError) {
errors.push(e);
this.skipToNextEntryStart();
} else {
throw e;
}
}
this.skipWS();
}
return [this.entries, errors];
}
/**
* Parse the source string from the current index as an FTL entry
* and add it to object's entries property.
*
* @private
*/
getEntry() {
// The index here should either be at the beginning of the file
// or right after new line.
if (this._index !== 0 &&
this._source[this._index - 1] !== '\n') {
throw this.error(`Expected an entry to start
at the beginning of the file or on a new line.`);
}
const ch = this._source[this._index];
// We don't care about comments or sections at runtime
if (ch === '/') {
this.skipComment();
return;
}
if (ch === '[') {
this.skipSection();
return;
}
this.getMessage();
}
/**
* Skip the section entry from the current index.
*
* @private
*/
skipSection() {
this._index += 1;
if (this._source[this._index] !== '[') {
throw this.error('Expected "[[" to open a section');
}
this._index += 1;
this.skipInlineWS();
this.getSymbol();
this.skipInlineWS();
if (this._source[this._index] !== ']' ||
this._source[this._index + 1] !== ']') {
throw this.error('Expected "]]" to close a section');
}
this._index += 2;
}
/**
* Parse the source string from the current index as an FTL message
* and add it to the entries property on the Parser.
*
* @private
*/
getMessage() {
const id = this.getIdentifier();
let attrs = null;
let tags = null;
this.skipInlineWS();
let ch = this._source[this._index];
let val;
if (ch === '=') {
this._index++;
this.skipInlineWS();
val = this.getPattern();
} else {
this.skipWS();
}
ch = this._source[this._index];
if (ch === '\n') {
this._index++;
this.skipInlineWS();
ch = this._source[this._index];
}
if (ch === '.') {
attrs = this.getAttributes();
}
if (ch === '#') {
if (attrs !== null) {
throw this.error('Tags cannot be added to a message with attributes.');
}
tags = this.getTags();
}
if (tags === null && attrs === null && typeof val === 'string') {
this.entries[id] = val;
} else {
if (val === undefined) {
if (tags === null && attrs === null) {
throw this.error(`Expected a value (like: " = value") or
an attribute (like: ".key = value")`);
}
}
this.entries[id] = { val };
if (attrs) {
this.entries[id].attrs = attrs;
}
if (tags) {
this.entries[id].tags = tags;
}
}
}
/**
* Skip whitespace.
*
* @private
*/
skipWS() {
let ch = this._source[this._index];
while (ch === ' ' || ch === '\n' || ch === '\t' || ch === '\r') {
ch = this._source[++this._index];
}
}
/**
* Skip inline whitespace (space and \t).
*
* @private
*/
skipInlineWS() {
let ch = this._source[this._index];
while (ch === ' ' || ch === '\t') {
ch = this._source[++this._index];
}
}
/**
* Get Message identifier.
*
* @returns {String}
* @private
*/
getIdentifier() {
identifierRe.lastIndex = this._index;
const result = identifierRe.exec(this._source);
if (result === null) {
this._index += 1;
throw this.error('Expected an identifier (starting with [a-zA-Z_])');
}
this._index = identifierRe.lastIndex;
return result[0];
}
/**
* Get Symbol.
*
* @returns {Object}
* @private
*/
getSymbol() {
let name = '';
const start = this._index;
let cc = this._source.charCodeAt(this._index);
if ((cc >= 97 && cc <= 122) || // a-z
(cc >= 65 && cc <= 90) || // A-Z
cc === 95 || cc === 32) { // _ <space>
cc = this._source.charCodeAt(++this._index);
} else {
throw this.error('Expected a keyword (starting with [a-zA-Z_])');
}
while ((cc >= 97 && cc <= 122) || // a-z
(cc >= 65 && cc <= 90) || // A-Z
(cc >= 48 && cc <= 57) || // 0-9
cc === 95 || cc === 45 || cc === 32) { // _- <space>
cc = this._source.charCodeAt(++this._index);
}
// If we encountered the end of name, we want to test if the last
// collected character is a space.
// If it is, we will backtrack to the last non-space character because
// the keyword cannot end with a space character.
while (this._source.charCodeAt(this._index - 1) === 32) {
this._index--;
}
name += this._source.slice(start, this._index);
return { type: 'sym', name };
}
/**
* Get simple string argument enclosed in `"`.
*
* @returns {String}
* @private
*/
getString() {
const start = this._index + 1;
while (++this._index < this._length) {
const ch = this._source[this._index];
if (ch === '"') {
break;
}
if (ch === '\n') {
break;
}
}
return this._source.substring(start, this._index++);
}
/**
* Parses a Message pattern.
* Message Pattern may be a simple string or an array of strings
* and placeable expressions.
*
* @returns {String|Array}
* @private
*/
getPattern() {
// We're going to first try to see if the pattern is simple.
// If it is we can just look for the end of the line and read the string.
//
// Then, if either the line contains a placeable opening `{` or the
// next line starts an indentation, we switch to complex pattern.
const start = this._index;
let eol = this._source.indexOf('\n', this._index);
if (eol === -1) {
eol = this._length;
}
const line = start !== eol ?
this._source.slice(start, eol) : undefined;
if (line !== undefined && line.includes('{')) {
return this.getComplexPattern();
}
this._index = eol + 1;
if (this._source[this._index] === ' ') {
this._index = start;
return this.getComplexPattern();
}
return line;
}
/**
* Parses a complex Message pattern.
* This function is called by getPattern when the message is multiline,
* or contains escape chars or placeables.
* It does full parsing of complex patterns.
*
* @returns {Array}
* @private
*/
/* eslint-disable complexity */
getComplexPattern() {
let buffer = '';
const content = [];
let placeables = 0;
let ch = this._source[this._index];
while (this._index < this._length) {
// This block handles multi-line strings combining strings separated
// by new line and `|` character at the beginning of the next one.
if (ch === '\n') {
this._index++;
if (this._source[this._index] !== ' ') {
break;
}
this.skipInlineWS();
if (this._source[this._index] === '}' ||
this._source[this._index] === '[' ||
this._source[this._index] === '*' ||
this._source[this._index] === '#' ||
this._source[this._index] === '.') {
break;
}
if (buffer.length || content.length) {
buffer += '\n';
}
ch = this._source[this._index];
continue;
} else if (ch === '\\') {
const ch2 = this._source[this._index + 1];
if (ch2 === '"' || ch2 === '{' || ch2 === '\\') {
ch = ch2;
this._index++;
}
} else if (ch === '{') {
// Push the buffer to content array right before placeable
if (buffer.length) {
content.push(buffer);
}
if (placeables > MAX_PLACEABLES - 1) {
throw this.error(
`Too many placeables, maximum allowed is ${MAX_PLACEABLES}`);
}
buffer = '';
content.push(this.getPlaceable());
this._index++;
ch = this._source[this._index];
placeables++;
continue;
}
if (ch) {
buffer += ch;
}
this._index++;
ch = this._source[this._index];
}
if (content.length === 0) {
return buffer.length ? buffer : undefined;
}
if (buffer.length) {
content.push(buffer);
}
return content;
}
/* eslint-enable complexity */
/**
* Parses a single placeable in a Message pattern and returns its
* expression.
*
* @returns {Object}
* @private
*/
getPlaceable() {
const start = ++this._index;
this.skipWS();
if (this._source[this._index] === '*' ||
(this._source[this._index] === '[' &&
this._source[this._index + 1] !== ']')) {
const variants = this.getVariants();
return {
type: 'sel',
exp: null,
vars: variants[0],
def: variants[1]
};
}
// Rewind the index and only support in-line white-space now.
this._index = start;
this.skipInlineWS();
const selector = this.getSelectorExpression();
this.skipWS();
const ch = this._source[this._index];
if (ch === '}') {
return selector;
}
if (ch !== '-' || this._source[this._index + 1] !== '>') {
throw this.error('Expected "}" or "->"');
}
this._index += 2; // ->
this.skipInlineWS();
if (this._source[this._index] !== '\n') {
throw this.error('Variants should be listed in a new line');
}
this.skipWS();
const variants = this.getVariants();
if (variants[0].length === 0) {
throw this.error('Expected members for the select expression');
}
return {
type: 'sel',
exp: selector,
vars: variants[0],
def: variants[1]
};
}
/**
* Parses a selector expression.
*
* @returns {Object}
* @private
*/
getSelectorExpression() {
const literal = this.getLiteral();
if (literal.type !== 'ref') {
return literal;
}
if (this._source[this._index] === '.') {
this._index++;
const name = this.getIdentifier();
this._index++;
return {
type: 'attr',
id: literal,
name
};
}
if (this._source[this._index] === '[') {
this._index++;
const key = this.getVariantKey();
this._index++;
return {
type: 'var',
id: literal,
key
};
}
if (this._source[this._index] === '(') {
this._index++;
const args = this.getCallArgs();
this._index++;
literal.type = 'fun';
return {
type: 'call',
fun: literal,
args
};
}
return literal;
}
/**
* Parses call arguments for a CallExpression.
*
* @returns {Array}
* @private
*/
getCallArgs() {
const args = [];
if (this._source[this._index] === ')') {
return args;
}
while (this._index < this._length) {
this.skipInlineWS();
const exp = this.getSelectorExpression();
// MessageReference in this place may be an entity reference, like:
// `call(foo)`, or, if it's followed by `:` it will be a key-value pair.
if (exp.type !== 'ref' ||
exp.namespace !== undefined) {
args.push(exp);
} else {
this.skipInlineWS();
if (this._source[this._index] === ':') {
this._index++;
this.skipInlineWS();
const val = this.getSelectorExpression();
// If the expression returned as a value of the argument
// is not a quote delimited string or number, throw.
//
// We don't have to check here if the pattern is quote delimited
// because that's the only type of string allowed in expressions.
if (typeof val === 'string' ||
Array.isArray(val) ||
val.type === 'num') {
args.push({
type: 'narg',
name: exp.name,
val
});
} else {
this._index = this._source.lastIndexOf(':', this._index) + 1;
throw this.error(
'Expected string in quotes, number.');
}
} else {
args.push(exp);
}
}
this.skipInlineWS();
if (this._source[this._index] === ')') {
break;
} else if (this._source[this._index] === ',') {
this._index++;
} else {
throw this.error('Expected "," or ")"');
}
}
return args;
}
/**
* Parses an FTL Number.
*
* @returns {Object}
* @private
*/
getNumber() {
let num = '';
let cc = this._source.charCodeAt(this._index);
// The number literal may start with negative sign `-`.
if (cc === 45) {
num += '-';
cc = this._source.charCodeAt(++this._index);
}
// next, we expect at least one digit
if (cc < 48 || cc > 57) {
throw this.error(`Unknown literal "${num}"`);
}
// followed by potentially more digits
while (cc >= 48 && cc <= 57) {
num += this._source[this._index++];
cc = this._source.charCodeAt(this._index);
}
// followed by an optional decimal separator `.`
if (cc === 46) {
num += this._source[this._index++];
cc = this._source.charCodeAt(this._index);
// followed by at least one digit
if (cc < 48 || cc > 57) {
throw this.error(`Unknown literal "${num}"`);
}
// and optionally more digits
while (cc >= 48 && cc <= 57) {
num += this._source[this._index++];
cc = this._source.charCodeAt(this._index);
}
}
return {
type: 'num',
val: num
};
}
/**
* Parses a list of Message attributes.
*
* @returns {Object}
* @private
*/
getAttributes() {
const attrs = {};
while (this._index < this._length) {
const ch = this._source[this._index];
if (ch !== '.') {
break;
}
this._index++;
const key = this.getIdentifier();
this.skipInlineWS();
this._index++;
this.skipInlineWS();
const val = this.getPattern();
if (typeof val === 'string') {
attrs[key] = val;
} else {
attrs[key] = {
val
};
}
this.skipWS();
}
return attrs;
}
/**
* Parses a list of Message tags.
*
* @returns {Array}
* @private
*/
getTags() {
const tags = [];
while (this._index < this._length) {
const ch = this._source[this._index];
if (ch !== '#') {
break;
}
this._index++;
const symbol = this.getSymbol();
tags.push(symbol.name);
this.skipWS();
}
return tags;
}
/**
* Parses a list of Selector variants.
*
* @returns {Array}
* @private
*/
getVariants() {
const variants = [];
let index = 0;
let defaultIndex;
while (this._index < this._length) {
const ch = this._source[this._index];
if ((ch !== '[' || this._source[this._index + 1] === '[') &&
ch !== '*') {
break;
}
if (ch === '*') {
this._index++;
defaultIndex = index;
}
if (this._source[this._index] !== '[') {
throw this.error('Expected "["');
}
this._index++;
const key = this.getVariantKey();
this.skipInlineWS();
const variant = {
key,
val: this.getPattern()
};
variants[index++] = variant;
this.skipWS();
}
return [variants, defaultIndex];
}
/**
* Parses a Variant key.
*
* @returns {String}
* @private
*/
getVariantKey() {
// VariantKey may be a Keyword or Number
const cc = this._source.charCodeAt(this._index);
let literal;
if ((cc >= 48 && cc <= 57) || cc === 45) {
literal = this.getNumber();
} else {
literal = this.getSymbol();
}
if (this._source[this._index] !== ']') {
throw this.error('Expected "]"');
}
this._index++;
return literal;
}
/**
* Parses an FTL literal.
*
* @returns {Object}
* @private
*/
getLiteral() {
const cc = this._source.charCodeAt(this._index);
if ((cc >= 48 && cc <= 57) || cc === 45) {
return this.getNumber();
} else if (cc === 34) { // "
return this.getString();
} else if (cc === 36) { // $
this._index++;
return {
type: 'ext',
name: this.getIdentifier()
};
}
return {
type: 'ref',
name: this.getIdentifier()
};
}
/**
* Skips an FTL comment.
*
* @private
*/
skipComment() {
// At runtime, we don't care about comments so we just have
// to parse them properly and skip their content.
let eol = this._source.indexOf('\n', this._index);
while (eol !== -1 &&
this._source[eol + 1] === '/' && this._source[eol + 2] === '/') {
this._index = eol + 3;
eol = this._source.indexOf('\n', this._index);
if (eol === -1) {
break;
}
}
if (eol === -1) {
this._index = this._length;
} else {
this._index = eol + 1;
}
}
/**
* Creates a new SyntaxError object with a given message.
*
* @param {String} message
* @returns {Object}
* @private
*/
error(message) {
return new SyntaxError(message);
}
/**
* Skips to the beginning of a next entry after the current position.
* This is used to mark the boundary of junk entry in case of error,
* and recover from the returned position.
*
* @private
*/
skipToNextEntryStart() {
let start = this._index;
while (true) {
if (start === 0 || this._source[start - 1] === '\n') {
const cc = this._source.charCodeAt(start);
if ((cc >= 97 && cc <= 122) || // a-z
(cc >= 65 && cc <= 90) || // A-Z
cc === 95 || cc === 47 || cc === 91) { // _/[
this._index = start;
return;
}
}
start = this._source.indexOf('\n', start);
if (start === -1) {
this._index = this._length;
return;
}
start++;
}
}
}
/**
* Parses an FTL string using RuntimeParser and returns the generated
* object with entries and a list of errors.
*
* @param {String} string
* @returns {Array<Object, Array>}
*/
function parse(string) {
const parser = new RuntimeParser();
return parser.getResource(string);
}
/* 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 `valueOf` 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 instance of `FluentType`.
*
* Unwrapped values are suitable for use outside of the `MessageContext`.
* This method can use `Intl` formatters memoized by the `MessageContext`
* instance passed as an argument.
*
* In most cases, valueOf returns a string, but it can be overriden
* and there are use cases, where the return type is not a string.
*
* An example is fluent-react which implements a custom `FluentType`
* to represent React elements passed as arguments to format().
*
* @param {MessageContext} [ctx]
* @returns {string}
*/
valueOf() {
throw new Error('Subclasses of FluentType must implement valueOf.');
}
}
class FluentNone extends FluentType {
valueOf() {
return this.value || '???';
}
}
class FluentNumber extends FluentType {
constructor(value, opts) {
super(parseFloat(value), opts);
}
valueOf(ctx) {
const nf = ctx._memoizeIntlObject(
Intl.NumberFormat, this.opts
);
return nf.format(this.value);
}
/**
* Compare the object with another instance of a FluentType.
*
* @param {MessageContext} ctx
* @param {FluentType} other
* @returns {bool}
*/
match(ctx, other) {
if (other instanceof FluentNumber) {
return this.value === other.value;
}
return false;
}
}
class FluentDateTime extends FluentType {
constructor(value, opts) {
super(new Date(value), opts);
}
valueOf(ctx) {
const dtf = ctx._memoizeIntlObject(
Intl.DateTimeFormat, this.opts
);
return dtf.format(this.value);
}
}
class FluentSymbol extends FluentType {
valueOf() {
return this.value;
}
/**
* Compare the object with another instance of a FluentType.
*
* @param {MessageContext} ctx
* @param {FluentType} other
* @returns {bool}
*/
match(ctx, other) {
if (other instanceof FluentSymbol) {
return this.value === other.value;
} else if (typeof other === 'string') {
return this.value === other;
} else if (other instanceof FluentNumber) {
const pr = ctx._memoizeIntlObject(
Intl.PluralRules, other.opts
);
return this.value === pr.select(other.value);
} else if (Array.isArray(other)) {
const values = other.map(symbol => symbol.value);
return values.includes(this.value);
}
return false;
}
}
/**
* @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.value, merge(arg.opts, opts)),
'DATETIME': ([arg], opts) =>
new FluentDateTime(arg.value, merge(arg.opts, opts)),
};
function merge(argopts, opts) {
return Object.assign({}, argopts, values(opts));
}
function values(opts) {
const unwrapped = {};
for (const name of Object.keys(opts)) {
unwrapped[name] = opts[name].value;
}
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 external arguments,
* 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
* context'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 `valueOf` 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:
*
* * {MessageContext} ctx
* context 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 computing the total character length of a placeable.
*
* Used in Pattern.
*
* @param {Object} env
* Resolver environment object.
* @param {Array} parts
* List of parts of a placeable.
* @returns {Number}
* @private
*/
function PlaceableLength(env, parts) {
const { ctx } = env;
return parts.reduce(
(sum, part) => sum + part.valueOf(ctx).length,
0
);
}
/**
* 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} def
* The index of the default variant.
* @returns {FluentType}
* @private
*/
function DefaultMember(env, members, def) {
if (members[def]) {
return members[def];
}
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 { ctx, errors } = env;
const message = ctx.getMessage(name);
if (!message) {
errors.push(new ReferenceError(`Unknown message: ${name}`));
return new FluentNone(name);
}
return message;
}
/**
* Resolve an array of tags.
*
* @param {Object} env
* Resolver environment object.
* @param {Object} id
* The identifier of the message with tags.
* @param {String} id.name
* The name of the identifier.
* @returns {Array}
* @private
*/
function Tags(env, {name}) {
const { ctx, errors } = env;
const message = ctx.getMessage(name);
if (!message) {
errors.push(new ReferenceError(`Unknown message: ${name}`));
return new FluentNone(name);
}
if (!message.tags) {
errors.push(new RangeError(`No tags in message "${name}"`));
return new FluentNone(name);
}
return message.tags.map(
tag => new FluentSymbol(tag)
);
}
/**
* 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.id
* 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, {id, key}) {
const message = MessageReference(env, id);
if (message instanceof FluentNone) {
return message;
}
const { ctx, errors } = env;
const keyword = Type(env, key);
function isVariantList(node) {
return Array.isArray(node) &&
node[0].type === 'sel' &&
node[0].exp === null;
}
if (isVariantList(message.val)) {
// Match the specified key against keys of each variant, in order.
for (const variant of message.val[0].vars) {
const variantKey = Type(env, variant.key);
if (keyword.match(ctx, variantKey)) {
return variant;
}
}
}
errors.push(new ReferenceError(`Unknown variant: ${keyword.valueOf(ctx)}`));
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.id
* 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, {id, name}) {
const message = MessageReference(env, id);
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.exp
* Selector expression
* @param {Array} expr.vars
* List of variants for the select expression.
* @param {Number} expr.def
* Index of the default variant.
* @returns {FluentType}
* @private
*/
function SelectExpression(env, {exp, vars, def}) {
if (exp === null) {
return DefaultMember(env, vars, def);
}
const selector = exp.type === 'ref'
? Tags(env, exp)
: Type(env, exp);
if (selector instanceof FluentNone) {
return DefaultMember(env, vars, def);
}
// Match the selector against keys of each variant, in order.
for (const variant of vars) {
const key = Type(env, variant.key);
const keyCanMatch =
key instanceof FluentNumber || key instanceof FluentSymbol;
if (!keyCanMatch) {
continue;
}
const { ctx } = env;
if (key.match(ctx, selector)) {
return variant;
}
}
return DefaultMember(env, vars, def);
}
/**
* Resolve expression to a Fluent type.
*
* JavaScript strings are a special case. Since they natively have the
* `valueOf` 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' || 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 'sym':
return new FluentSymbol(expr.name);
case 'num':
return new FluentNumber(expr.val);
case 'ext':
return ExternalArgument(env, expr);
case 'fun':
return FunctionReference(env, expr);
case 'call':
return CallExpression(env, expr);
case 'ref': {
const message = MessageReference(env, expr);
return Type(env, message);
}
case 'attr': {
const attr = AttributeExpression(env, expr);
return Type(env, attr);
}
case 'var': {
const variant = VariantExpression(env, expr);
return Type(env, variant);
}
case 'sel': {
const member = SelectExpression(env, expr);
return Type(env, member);
}
case undefined: {
// If it's a node with a value, resolve the value.
if (expr.val !== undefined) {
return Type(env, expr.val);
}
const { errors } = env;
errors.push(new RangeError('No value'));
return new FluentNone();
}
default:
return new FluentNone();
}
}
/**
* Resolve a reference to an external argument.
*
* @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 ExternalArgument(env, {name}) {
const { args, errors } = env;
if (!args || !args.hasOwnProperty(name)) {
errors.push(new ReferenceError(`Unknown external: ${name}`));
return new FluentNone(name);
}
const arg = args[name];
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 external 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 `MessageContext` constructor.
const { ctx: { _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.fun
* FTL Function object.
* @param {Array} expr.args
* FTL Function argument list.
* @returns {FluentType}
* @private
*/
function CallExpression(env, {fun, args}) {
const callee = FunctionReference(env, fun);
if (callee instanceof FluentNone) {
return callee;
}
const posargs = [];
const keyargs = [];
for (const arg of args) {
if (arg.type === 'narg') {
keyargs[arg.name] = Type(env, arg.val);
} else {
posargs.push(Type(env, arg));
}
}
// XXX functions should also report errors
return callee(posargs, keyargs);
}
/**
* 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 { ctx, 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 = [];
for (const elem of ptn) {
if (typeof elem === 'string') {
result.push(elem);
continue;
}
const part = Type(env, elem);
if (ctx._useIsolating) {
result.push(FSI);
}
if (Array.isArray(part)) {
const len = PlaceableLength(env, part);
if (len > MAX_PLACEABLE_LENGTH) {
errors.push(
new RangeError(
'Too many characters in placeable ' +
`(${len}, max allowed is ${MAX_PLACEABLE_LENGTH})`
)
);
result.push(new FluentNone());
} else {
result.push(...part);
}
} else {
result.push(part);
}
if (ctx._useIsolating) {
result.push(PDI);
}
}
dirty.delete(ptn);
return result;
}
/**
* Format a translation into an `FluentType`.
*
* The return value must be unwrapped via `valueOf` by the caller.
*
* @param {MessageContext} ctx
* A MessageContext 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(ctx, args, message, errors = []) {
const env = {
ctx, args, errors, dirty: new WeakSet()
};
return Type(env, message);
}
/**
* Message contexts 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 `MessageContext.format` to retrieve translation units from
* a context. Translations can contain references to other entities or
* external arguments, 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 context's language. See the documentation of the Fluent syntax for
* more information.
*/
class MessageContext {
/**
* Create an instance of `MessageContext`.
*
* The `locales` argument is used to instantiate `Intl` formatters used by
* translations. The `options` object can be used to configure the context.
*
* Examples:
*
* const ctx = new MessageContext(locales);
*
* const ctx = new MessageContext(locales, { useIsolating: false });
*
* const ctx = new MessageContext(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.
*
* @param {string|Array<string>} locales - Locale or locales of the context
* @param {Object} [options]
* @returns {MessageContext}
*/
constructor(locales, { functions = {}, useIsolating = true } = {}) {
this.locales = Array.isArray(locales) ? locales : [locales];
this._messages = new Map();
this._functions = functions;
this._useIsolating = useIsolating;
this._intls = new WeakMap();
}
/*
* Return an iterator over `[id, message]` pairs.
*
* @returns {Iterator}
*/
get messages() {
return this._messages[Symbol.iterator]();
}
/*
* Check if a message is present in the context.
*
* @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
* `MessageContext.format` and `MessageContext.formatToParts`.
*
* @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 context.
*
* The translation resource must use the Fluent syntax. It will be parsed by
* the context and each translation unit (message) will be available in the
* context by its identifier.
*
* ctx.addMessages('foo = Foo');
* ctx.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 [entries, errors] = parse(source);
for (const id in entries) {
this._messages.set(id, entries[id]);
}
return errors;
}
/**
* Format a message to an array of `FluentTypes` or null.
*
* Format a raw `message` from the context into an array of `FluentType`
* instances which may be used to build the final result. It may also return
* `null` if it has a null value. `args` will be used to resolve references
* to external arguments inside of the translation.
*
* See the documentation of {@link MessageContext#format} for more
* information about error handling.
*
* 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.
*
* ctx.addMessages('hello = Hello, { $name }!');
* const hello = ctx.getMessage('hello');
* ctx.formatToParts(hello, { name: 'Jane' }, []);
* // → ['Hello, ', '\u2068', 'Jane', '\u2069']
*
* The returned parts need to be formatted via `valueOf` before they can be
* used further. This will ensure all values are correctly formatted
* according to the `MessageContext`'s locale.
*
* const parts = ctx.formatToParts(hello, { name: 'Jane' }, []);
* const str = parts.map(part => part.valueOf(ctx)).join('');
*
* @see MessageContext#format
* @param {Object | string} message
* @param {Object | undefined} args
* @param {Array} errors
* @returns {?Array<FluentType>}
*/
formatToParts(message, args, errors) {
// optimize entities which are simple strings with no attributes
if (typeof message === 'string') {
return [message];
}
// optimize simple-string entities with attributes
if (typeof message.val === 'string') {
return [message.val];
}
// optimize entities with null values
if (message.val === undefined) {
return null;
}
const result = resolve(this, args, message, errors);
return result instanceof FluentNone ? null : result;
}
/**
* Format a message to a string or null.
*
* Format a raw `message` from the context into a string (or a null if it has
* a null value). `args` will be used to resolve references to external
* arguments inside of 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 = [];
* ctx.addMessages('hello = Hello, { $name }!');
* const hello = ctx.getMessage('hello');
* ctx.format(hello, { name: 'Jane' }, errors);
*
* // Returns 'Hello, Jane!' and `errors` is empty.
*
* ctx.format(hello, undefined, errors);
*
* // Returns 'Hello, name!' and `errors` is now:
*
* [<ReferenceError: Unknown external: 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 message;
}
// optimize simple-string entities with attributes
if (typeof message.val === 'string') {
return message.val;
}
// optimize entities with null values
if (message.val === undefined) {
return null;
}
const result = resolve(this, args, message, errors);
if (result instanceof FluentNone) {
return null;
}
return result.map(part => part.valueOf(this)).join('');
}
_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.MessageContext = MessageContext;
this.EXPORTED_SYMBOLS = ['MessageContext'];