@fluent/bundle
Advanced tools
Comparing version
108
CHANGELOG.md
# Changelog | ||
## @fluent/bundle 0.14.0 (July 30, 2019) | ||
### `FluentBundle` API | ||
- Remove `FluentBundle.addMessages`. | ||
Use `FluentBundle.addResource` instead, combined with a `FluentResource` | ||
instance. | ||
- Remove `FluentBundle.messages`. | ||
The list of messages in the bundle should not be inspected. If you need | ||
to do it, please use the tooling parser from `@fluent/syntax`. | ||
- Change the shape returned by `FluentBundle.getMessage`. | ||
The method now returns the following shape: | ||
{value: Pattern | null, attributes: Record<string, Pattern>} | ||
- The internal representtion of `Pattern` is private. | ||
Raw messages returned from `getMessage` have their values and attributes | ||
stored in a `Pattern` type. The internal representation of this type is | ||
private and implementation-specific. It should not be inspected nor used | ||
for any purposes. The implementation may change without a warning in | ||
future releases. `Patterns` are black boxes and are meant to be used as | ||
arguments to `formatPattern`. | ||
- Rename `FluentBundle.format` to `formatPattern`. | ||
`formatPattern` only accepts valid `Patterns` as the first argument. In | ||
practice, you'll want to first retrieve a raw message from the bundle via | ||
`getMessage`, and then format the value (if present) and the attributes | ||
separately. | ||
```js | ||
let message = bundle.getMessage("hello"); | ||
if (message.value) { | ||
bundle.formatPattern(message.value, {userName: "Alex"}); | ||
} | ||
``` | ||
The list of all attributes defined in the message can be obtained with | ||
`Object.keys(message.attributes)`. | ||
- Throw from `formatPattern` when `errors` are not passed as an argument. | ||
The old `format()` method would silence all errors if the thrid argument, | ||
the `errors` array was not given. `formatPattern` changes this behavior | ||
to throwing on the first encountered error and interrupting the | ||
formatting. | ||
```js | ||
try { | ||
bundle.formatPattern(message.value, args); | ||
} catch (err) { | ||
// Handle the error yourself. | ||
} | ||
``` | ||
It's still possible to pass the `errors` array as the third argument to | ||
`formatPattern`. All errors encountered during the formatting will be | ||
then appended to the array. In this scenario, `formatPattern` is | ||
guaranteed to never throw because of errors in the translation. | ||
```js | ||
let errorrs = []; | ||
bundle.formatPattern(message.value, args, errors); | ||
for (let error of errors) { | ||
// Report errors. | ||
} | ||
``` | ||
### `FluentResource` API | ||
- Remove the static `FluentResource.fromString` method. | ||
Parse resources by using the constructor: `new FluentResource(text)`. | ||
- Do not extend `Map`. | ||
`FluentResources` are now instances of their own class only. | ||
- Add `FluentResource.body`. | ||
The `body` field is an array storing the resource's parsed messages and | ||
terms. | ||
### `FluentType` API | ||
- `FluentNumber` must be instantiated with a number. | ||
The constructor doesn't call `parseFloat` on the passed value anymore. | ||
- `FluentDateTime` must be instantiated with a number. | ||
The constructor doesn't call `new Date()` on the passed value anymore. | ||
The date is stored as the numerical timestamp, in milliseconds since the | ||
epoch. | ||
### Formatting Changes | ||
- Report errors from instantiating Intl objects used for formatting. | ||
- Report errors from functions, including built-in functions. | ||
- Format numbers and dates to safe defaults in case or errors. (#410) | ||
- When a transform function is given, transform only `TextElements`. | ||
## @fluent/bundle 0.13.0 (July 25, 2019) | ||
@@ -4,0 +112,0 @@ |
1005
compat.js
@@ -1,2 +0,2 @@ | ||
/* @fluent/bundle@0.13.0 */ | ||
/* @fluent/bundle@0.14.0 */ | ||
(function (global, factory) { | ||
@@ -8,2 +8,149 @@ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : | ||
/* 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 a `FluentType` instance. | ||
* | ||
* @param {Any} value - JavaScript value to wrap. | ||
* @returns {FluentType} | ||
*/ | ||
constructor(value) { | ||
/** The wrapped native value. */ | ||
this.value = value; | ||
} | ||
/** | ||
* 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 available through the `scope` | ||
* argument. | ||
* | ||
* @abstract | ||
* @param {Scope} scope | ||
* @returns {string} | ||
*/ | ||
toString(scope) { | ||
// eslint-disable-line no-unused-vars | ||
throw new Error("Subclasses of FluentType must implement toString."); | ||
} | ||
} | ||
/** | ||
* A `FluentType` representing no correct value. | ||
*/ | ||
class FluentNone extends FluentType { | ||
/** | ||
* Create an instance of `FluentNone` with an optional fallback value. | ||
* @param {string} value - The fallback value of this `FluentNone`. | ||
* @returns {FluentType} | ||
*/ | ||
constructor() { | ||
let value = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : "???"; | ||
super(value); | ||
} | ||
/** | ||
* Format this `FluentNone` to the fallback string. | ||
* @returns {string} | ||
*/ | ||
toString() { | ||
return "{".concat(this.value, "}"); | ||
} | ||
} | ||
/** | ||
* A `FluentType` representing a number. | ||
*/ | ||
class FluentNumber extends FluentType { | ||
/** | ||
* Create an instance of `FluentNumber` with options to the | ||
* `Intl.NumberFormat` constructor. | ||
* @param {number} value | ||
* @param {Intl.NumberFormatOptions} opts | ||
* @returns {FluentType} | ||
*/ | ||
constructor(value, opts) { | ||
super(value); | ||
/** Options passed to Intl.NumberFormat. */ | ||
this.opts = opts; | ||
} | ||
/** | ||
* Format this `FluentNumber` to a string. | ||
* @param {Scope} scope | ||
* @returns {string} | ||
*/ | ||
toString(scope) { | ||
try { | ||
const nf = scope.memoizeIntlObject(Intl.NumberFormat, this.opts); | ||
return nf.format(this.value); | ||
} catch (err) { | ||
scope.reportError(err); | ||
return this.value.toString(10); | ||
} | ||
} | ||
} | ||
/** | ||
* A `FluentType` representing a date and time. | ||
*/ | ||
class FluentDateTime extends FluentType { | ||
/** | ||
* Create an instance of `FluentDateTime` with options to the | ||
* `Intl.DateTimeFormat` constructor. | ||
* @param {number} value - timestamp in milliseconds | ||
* @param {Intl.DateTimeFormatOptions} opts | ||
* @returns {FluentType} | ||
*/ | ||
constructor(value, opts) { | ||
super(value); | ||
/** Options passed to Intl.DateTimeFormat. */ | ||
this.opts = opts; | ||
} | ||
/** | ||
* Format this `FluentDateTime` to a string. | ||
* @param {Scope} scope | ||
* @returns {string} | ||
*/ | ||
toString(scope) { | ||
try { | ||
const dtf = scope.memoizeIntlObject(Intl.DateTimeFormat, this.opts); | ||
return dtf.format(this.value); | ||
} catch (err) { | ||
scope.reportError(err); | ||
return new Date(this.value).toISOString(); | ||
} | ||
} | ||
} | ||
function _defineProperty(obj, key, value) { | ||
@@ -81,95 +228,2 @@ if (key in obj) { | ||
/* 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 { | ||
valueOf() { | ||
return null; | ||
} | ||
toString() { | ||
return "{".concat(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; | ||
} | ||
} | ||
} | ||
function merge(argopts, opts) { | ||
@@ -198,10 +252,12 @@ return Object.assign({}, argopts, values(opts)); | ||
if (arg instanceof FluentNone) { | ||
return arg; | ||
return new FluentNone("NUMBER(".concat(arg.valueOf(), ")")); | ||
} | ||
if (arg instanceof FluentNumber) { | ||
return new FluentNumber(arg.valueOf(), merge(arg.opts, opts)); | ||
let value = Number(arg.valueOf()); | ||
if (Number.isNaN(value)) { | ||
throw new TypeError("Invalid argument to NUMBER"); | ||
} | ||
return new FluentNone("NUMBER()"); | ||
return new FluentNumber(value, merge(arg.opts, opts)); | ||
} | ||
@@ -213,10 +269,12 @@ function DATETIME(_ref3, opts) { | ||
if (arg instanceof FluentNone) { | ||
return arg; | ||
return new FluentNone("DATETIME(".concat(arg.valueOf(), ")")); | ||
} | ||
if (arg instanceof FluentDateTime) { | ||
return new FluentDateTime(arg.valueOf(), merge(arg.opts, opts)); | ||
let value = Number(arg.valueOf()); | ||
if (Number.isNaN(value)) { | ||
throw new TypeError("Invalid argument to DATETIME"); | ||
} | ||
return new FluentNone("DATETIME()"); | ||
return new FluentDateTime(value, merge(arg.opts, opts)); | ||
} | ||
@@ -234,3 +292,3 @@ | ||
function match(bundle, selector, key) { | ||
function match(scope, selector, key) { | ||
if (key === selector) { | ||
@@ -247,3 +305,3 @@ // Both are strings. | ||
if (selector instanceof FluentNumber && typeof key === "string") { | ||
let category = bundle._memoizeIntlObject(Intl.PluralRules, selector.opts).select(selector.value); | ||
let category = scope.memoizeIntlObject(Intl.PluralRules, selector.opts).select(selector.value); | ||
@@ -261,6 +319,6 @@ if (key === category) { | ||
if (variants[star]) { | ||
return Type(scope, variants[star]); | ||
return resolvePattern(scope, variants[star].value); | ||
} | ||
scope.errors.push(new RangeError("No default")); | ||
scope.reportError(new RangeError("No default")); | ||
return new FluentNone(); | ||
@@ -282,5 +340,5 @@ } // Helper: resolve arguments to a call expression. | ||
if (arg.type === "narg") { | ||
named[arg.name] = Type(scope, arg.value); | ||
named[arg.name] = resolveExpression(scope, arg.value); | ||
} else { | ||
positional.push(Type(scope, arg)); | ||
positional.push(resolveExpression(scope, arg)); | ||
} | ||
@@ -307,21 +365,3 @@ } | ||
function Type(scope, expr) { | ||
// A fast-path for strings which are the most common case. Since they | ||
// natively have the `toString` method they can be used as if they were | ||
// a FluentType instance without incurring the cost of creating one. | ||
if (typeof expr === "string") { | ||
return scope.bundle._transform(expr); | ||
} // A fast-path for `FluentNone` which doesn't require any additional logic. | ||
if (expr instanceof FluentNone) { | ||
return expr; | ||
} // The Runtime AST (Entries) encodes patterns (complex strings with | ||
// placeables) as Arrays. | ||
if (Array.isArray(expr)) { | ||
return Pattern(scope, expr); | ||
} | ||
function resolveExpression(scope, expr) { | ||
switch (expr.type) { | ||
@@ -351,13 +391,2 @@ case "str": | ||
case undefined: | ||
{ | ||
// If it's a node with a value, resolve the value. | ||
if (expr.value !== null && expr.value !== undefined) { | ||
return Type(scope, expr.value); | ||
} | ||
scope.errors.push(new RangeError("No value")); | ||
return new FluentNone(); | ||
} | ||
default: | ||
@@ -374,3 +403,3 @@ return new FluentNone(); | ||
if (scope.insideTermReference === false) { | ||
scope.errors.push(new ReferenceError("Unknown variable: ".concat(name))); | ||
scope.reportError(new ReferenceError("Unknown variable: $".concat(name))); | ||
} | ||
@@ -397,7 +426,7 @@ | ||
if (arg instanceof Date) { | ||
return new FluentDateTime(arg); | ||
return new FluentDateTime(arg.getTime()); | ||
} | ||
default: | ||
scope.errors.push(new TypeError("Unsupported variable type: ".concat(name, ", ").concat(typeof arg))); | ||
scope.reportError(new TypeError("Variable type not supported: $".concat(name, ", ").concat(typeof arg))); | ||
return new FluentNone("$".concat(name)); | ||
@@ -415,4 +444,3 @@ } | ||
if (!message) { | ||
const err = new ReferenceError("Unknown message: ".concat(name)); | ||
scope.errors.push(err); | ||
scope.reportError(new ReferenceError("Unknown message: ".concat(name))); | ||
return new FluentNone(name); | ||
@@ -422,13 +450,18 @@ } | ||
if (attr) { | ||
const attribute = message.attrs && message.attrs[attr]; | ||
const attribute = message.attributes[attr]; | ||
if (attribute) { | ||
return Type(scope, attribute); | ||
return resolvePattern(scope, attribute); | ||
} | ||
scope.errors.push(new ReferenceError("Unknown attribute: ".concat(attr))); | ||
scope.reportError(new ReferenceError("Unknown attribute: ".concat(attr))); | ||
return new FluentNone("".concat(name, ".").concat(attr)); | ||
} | ||
return Type(scope, message); | ||
if (message.value) { | ||
return resolvePattern(scope, message.value); | ||
} | ||
scope.reportError(new ReferenceError("No value: ".concat(name))); | ||
return new FluentNone(name); | ||
} // Resolve a call to a Term with key-value arguments. | ||
@@ -446,6 +479,5 @@ | ||
if (!term) { | ||
const err = new ReferenceError("Unknown term: ".concat(id)); | ||
scope.errors.push(err); | ||
scope.reportError(new ReferenceError("Unknown term: ".concat(id))); | ||
return new FluentNone(id); | ||
} // Every TermReference has its own args. | ||
} // Every TermReference has its own variables. | ||
@@ -455,21 +487,18 @@ | ||
_getArguments2 = _slicedToArray(_getArguments, 2), | ||
keyargs = _getArguments2[1]; | ||
params = _getArguments2[1]; | ||
const local = _objectSpread({}, scope, { | ||
args: keyargs, | ||
insideTermReference: true | ||
}); | ||
const local = scope.cloneForTermReference(params); | ||
if (attr) { | ||
const attribute = term.attrs && term.attrs[attr]; | ||
const attribute = term.attributes[attr]; | ||
if (attribute) { | ||
return Type(local, attribute); | ||
return resolvePattern(local, attribute); | ||
} | ||
scope.errors.push(new ReferenceError("Unknown attribute: ".concat(attr))); | ||
scope.reportError(new ReferenceError("Unknown attribute: ".concat(attr))); | ||
return new FluentNone("".concat(id, ".").concat(attr)); | ||
} | ||
return Type(local, term); | ||
return resolvePattern(local, term.value); | ||
} // Resolve a call to a Function with positional and key-value arguments. | ||
@@ -486,3 +515,3 @@ | ||
if (!func) { | ||
scope.errors.push(new ReferenceError("Unknown function: ".concat(name, "()"))); | ||
scope.reportError(new ReferenceError("Unknown function: ".concat(name, "()"))); | ||
return new FluentNone("".concat(name, "()")); | ||
@@ -492,3 +521,3 @@ } | ||
if (typeof func !== "function") { | ||
scope.errors.push(new TypeError("Function ".concat(name, "() is not callable"))); | ||
scope.reportError(new TypeError("Function ".concat(name, "() is not callable"))); | ||
return new FluentNone("".concat(name, "()")); | ||
@@ -499,4 +528,4 @@ } | ||
return func(...getArguments(scope, args)); | ||
} catch (e) { | ||
// XXX Report errors. | ||
} catch (err) { | ||
scope.reportError(err); | ||
return new FluentNone("".concat(name, "()")); | ||
@@ -511,7 +540,6 @@ } | ||
star = _ref5.star; | ||
let sel = Type(scope, selector); | ||
let sel = resolveExpression(scope, selector); | ||
if (sel instanceof FluentNone) { | ||
const variant = getDefault(scope, variants, star); | ||
return Type(scope, variant); | ||
return getDefault(scope, variants, star); | ||
} // Match the selector against keys of each variant, in order. | ||
@@ -527,6 +555,6 @@ | ||
const variant = _step2.value; | ||
const key = Type(scope, variant.key); | ||
const key = resolveExpression(scope, variant.key); | ||
if (match(scope.bundle, sel, key)) { | ||
return Type(scope, variant); | ||
if (match(scope, sel, key)) { | ||
return resolvePattern(scope, variant.value); | ||
} | ||
@@ -549,10 +577,9 @@ } | ||
const variant = getDefault(scope, variants, star); | ||
return Type(scope, variant); | ||
return getDefault(scope, variants, star); | ||
} // Resolve a pattern (a complex string with placeables). | ||
function Pattern(scope, ptn) { | ||
function resolveComplexPattern(scope, ptn) { | ||
if (scope.dirty.has(ptn)) { | ||
scope.errors.push(new RangeError("Cyclic reference")); | ||
scope.reportError(new RangeError("Cyclic reference")); | ||
return new FluentNone(); | ||
@@ -580,3 +607,3 @@ } // Tag the pattern as dirty for the purpose of the current resolution. | ||
const part = Type(scope, elem).toString(scope.bundle); | ||
const part = resolveExpression(scope, elem).toString(scope); | ||
@@ -588,8 +615,12 @@ if (useIsolating) { | ||
if (part.length > MAX_PLACEABLE_LENGTH) { | ||
scope.errors.push(new RangeError("Too many characters in placeable " + "(".concat(part.length, ", max allowed is ").concat(MAX_PLACEABLE_LENGTH, ")"))); | ||
result.push(part.slice(MAX_PLACEABLE_LENGTH)); | ||
} else { | ||
result.push(part); | ||
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 characters in placeable " + "(".concat(part.length, ", max allowed is ").concat(MAX_PLACEABLE_LENGTH, ")")); | ||
} | ||
result.push(part); | ||
if (useIsolating) { | ||
@@ -616,31 +647,264 @@ result.push(PDI); | ||
return result.join(""); | ||
} // Resolve a simple or a complex Pattern to a FluentString (which is really the | ||
// string primitive). | ||
function resolvePattern(scope, node) { | ||
// Resolve a simple pattern. | ||
if (typeof node === "string") { | ||
return scope.bundle._transform(node); | ||
} | ||
return resolveComplexPattern(scope, node); | ||
} | ||
class Scope { | ||
constructor(bundle, errors, args) { | ||
let insideTermReference = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; | ||
let dirty = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : new WeakSet(); | ||
/** The bundle for which the given resolution is happening. */ | ||
this.bundle = bundle; | ||
/** The list of errors collected while resolving. */ | ||
this.errors = errors; | ||
/** A dict of developer-provided variables. */ | ||
this.args = args; | ||
/** Term references require different variable lookup logic. */ | ||
this.insideTermReference = insideTermReference; | ||
/** The Set of patterns already encountered during this resolution. | ||
* Used to detect and prevent cyclic resolutions. */ | ||
this.dirty = dirty; | ||
} | ||
cloneForTermReference(args) { | ||
return new Scope(this.bundle, this.errors, args, true, this.dirty); | ||
} | ||
reportError(error) { | ||
if (!this.errors) { | ||
throw error; | ||
} | ||
this.errors.push(error); | ||
} | ||
memoizeIntlObject(ctor, opts) { | ||
let cache = this.bundle._intls.get(ctor); | ||
if (!cache) { | ||
cache = {}; | ||
this.bundle._intls.set(ctor, cache); | ||
} | ||
let id = JSON.stringify(opts); | ||
if (!cache[id]) { | ||
cache[id] = new ctor(this.bundle.locales, opts); | ||
} | ||
return cache[id]; | ||
} | ||
} | ||
/** | ||
* 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} | ||
* Message bundles are single-language stores of translation resources. They are | ||
* responsible for formatting message values and attributes to strings. | ||
*/ | ||
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: | ||
* | ||
* let bundle = new FluentBundle(["en-US", "en"]); | ||
* | ||
* let bundle = new FluentBundle(locales, {useIsolating: false}); | ||
* | ||
* let 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. Default: `true`. | ||
* | ||
* - `transform` - a function used to transform string parts of patterns. | ||
* | ||
* @param {(string|Array.<string>)} locales - The locales of the bundle | ||
* @param {Object} [options] | ||
* @returns {FluentBundle} | ||
*/ | ||
constructor(locales) { | ||
let _ref = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, | ||
_ref$functions = _ref.functions, | ||
functions = _ref$functions === void 0 ? {} : _ref$functions, | ||
_ref$useIsolating = _ref.useIsolating, | ||
useIsolating = _ref$useIsolating === void 0 ? true : _ref$useIsolating, | ||
_ref$transform = _ref.transform, | ||
transform = _ref$transform === void 0 ? v => v : _ref$transform; | ||
function resolve(bundle, args, message) { | ||
let errors = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : []; | ||
const scope = { | ||
bundle, | ||
args, | ||
errors, | ||
dirty: new WeakSet(), | ||
// TermReferences are resolved in a new scope. | ||
insideTermReference: false | ||
}; | ||
return Type(scope, message).toString(bundle); | ||
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(); | ||
} | ||
/** | ||
* 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 a raw unformatted message object from the bundle. | ||
* | ||
* Raw messages are `{value, attributes}` shapes containing translation units | ||
* called `Patterns`. `Patterns` are implementation-specific; they should be | ||
* treated as black boxes and formatted with `FluentBundle.formatPattern`. | ||
* | ||
* interface RawMessage { | ||
* value: Pattern | null; | ||
* attributes: Record<string, Pattern>; | ||
* } | ||
* | ||
* @param {string} id - The identifier of the message to check. | ||
* @returns {{value: ?Pattern, attributes: Object.<string, Pattern>}} | ||
*/ | ||
getMessage(id) { | ||
return this._messages.get(id); | ||
} | ||
/** | ||
* Add a translation resource to the bundle. | ||
* | ||
* The translation resource must be an instance of `FluentResource`. | ||
* | ||
* let res = new FluentResource("foo = Foo"); | ||
* bundle.addResource(res); | ||
* bundle.getMessage("foo"); | ||
* // → {value: .., attributes: {..}} | ||
* | ||
* Available options: | ||
* | ||
* - `allowOverrides` - boolean specifying whether it's allowed to override | ||
* an existing message or term with a new value. Default: `false`. | ||
* | ||
* @param {FluentResource} res - FluentResource object. | ||
* @param {Object} [options] | ||
* @returns {Array.<FluentError>} | ||
*/ | ||
addResource(res) { | ||
let _ref2 = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, | ||
_ref2$allowOverrides = _ref2.allowOverrides, | ||
allowOverrides = _ref2$allowOverrides === void 0 ? false : _ref2$allowOverrides; | ||
const errors = []; | ||
for (let i = 0; i < res.body.length; i++) { | ||
let entry = res.body[i]; | ||
if (entry.id.startsWith("-")) { | ||
// Identifiers starting with a dash (-) define terms. Terms are private | ||
// and cannot be retrieved from FluentBundle. | ||
if (allowOverrides === false && this._terms.has(entry.id)) { | ||
errors.push("Attempt to override an existing term: \"".concat(entry.id, "\"")); | ||
continue; | ||
} | ||
this._terms.set(entry.id, entry); | ||
} else { | ||
if (allowOverrides === false && this._messages.has(entry.id)) { | ||
errors.push("Attempt to override an existing message: \"".concat(entry.id, "\"")); | ||
continue; | ||
} | ||
this._messages.set(entry.id, entry); | ||
} | ||
} | ||
return errors; | ||
} | ||
/** | ||
* Format a `Pattern` to a string. | ||
* | ||
* Format a raw `Pattern` into a string. `args` will be used to resolve | ||
* references to variables passed as arguments to the translation. | ||
* | ||
* In case of errors `formatPattern` 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. | ||
* | ||
* let errors = []; | ||
* bundle.addResource( | ||
* new FluentResource("hello = Hello, {$name}!")); | ||
* | ||
* let hello = bundle.getMessage("hello"); | ||
* if (hello.value) { | ||
* bundle.formatPattern(hello.value, {name: "Jane"}, errors); | ||
* // Returns "Hello, Jane!" and `errors` is empty. | ||
* | ||
* bundle.formatPattern(hello.value, undefined, errors); | ||
* // Returns "Hello, {$name}!" and `errors` is now: | ||
* // [<ReferenceError: Unknown variable: name>] | ||
* } | ||
* | ||
* If `errors` is omitted, the first encountered error will be thrown. | ||
* | ||
* @param {Pattern} pattern | ||
* @param {?Object} args | ||
* @param {?Array.<Error>} errors | ||
* @returns {string} | ||
*/ | ||
formatPattern(pattern, args, errors) { | ||
// Resolve a simple pattern without creating a scope. No error handling is | ||
// required; by definition simple patterns don't have placeables. | ||
if (typeof pattern === "string") { | ||
return this._transform(pattern); | ||
} // Resolve a complex pattern. | ||
let scope = new Scope(this, errors, args); | ||
try { | ||
let value = resolveComplexPattern(scope, pattern); | ||
return value.toString(scope); | ||
} catch (err) { | ||
if (scope.errors) { | ||
scope.errors.push(err); | ||
return new FluentNone().toString(scope); | ||
} | ||
throw err; | ||
} | ||
} | ||
} | ||
@@ -695,12 +959,13 @@ | ||
/** | ||
* Fluent Resource is a structure storing a map of parsed localization entries. | ||
* Fluent Resource is a structure storing parsed localization entries. | ||
*/ | ||
class FluentResource extends Map { | ||
/** | ||
* Create a new FluentResource from Fluent code. | ||
*/ | ||
static fromString(source) { | ||
class FluentResource { | ||
constructor(source) { | ||
this.body = this._parse(source); | ||
} | ||
_parse(source) { | ||
RE_MESSAGE_START.lastIndex = 0; | ||
let resource = new this(); | ||
let resource = []; | ||
let cursor = 0; // Iterate over the beginnings of messages and terms to efficiently skip | ||
@@ -719,3 +984,3 @@ // comments and recover from errors. | ||
try { | ||
resource.set(next[1], parseMessage()); | ||
resource.push(parseMessage(next[1])); | ||
} catch (err) { | ||
@@ -732,3 +997,4 @@ if (err instanceof FluentError) { | ||
return resource; // The parser implementation is inlined below for performance reasons. | ||
return resource; // The parser implementation is inlined below for performance reasons, | ||
// as well as for convenience of accessing `source` and `cursor`. | ||
// The parser focuses on minimizing the number of false negatives at the | ||
@@ -799,17 +1065,14 @@ // expense of increasing the risk of false positives. In other words, it | ||
function parseMessage() { | ||
function parseMessage(id) { | ||
let value = parsePattern(); | ||
let attrs = parseAttributes(); | ||
let attributes = parseAttributes(); | ||
if (attrs === null) { | ||
if (value === null) { | ||
throw new FluentError("Expected message value or attributes"); | ||
} | ||
return value; | ||
if (value === null && Object.keys(attributes).length === 0) { | ||
throw new FluentError("Expected message value or attributes"); | ||
} | ||
return { | ||
id, | ||
value, | ||
attrs | ||
attributes | ||
}; | ||
@@ -819,3 +1082,3 @@ } | ||
function parseAttributes() { | ||
let attrs = {}; | ||
let attrs = Object.create(null); | ||
@@ -833,3 +1096,3 @@ while (test(RE_ATTRIBUTE_START)) { | ||
return Object.keys(attrs).length > 0 ? attrs : null; | ||
return attrs; | ||
} | ||
@@ -929,5 +1192,2 @@ | ||
element = element.value.slice(0, element.value.length - commonIndent); | ||
} else if (element.type === "str") { | ||
// Optimize StringLiterals into their value. | ||
element = element.value; | ||
} | ||
@@ -1122,3 +1382,6 @@ | ||
consumeToken(TOKEN_BRACKET_OPEN, FluentError); | ||
let key = test(RE_NUMBER_LITERAL) ? parseNumberLiteral() : match1(RE_IDENTIFIER); | ||
let key = test(RE_NUMBER_LITERAL) ? parseNumberLiteral() : { | ||
type: "str", | ||
value: match1(RE_IDENTIFIER) | ||
}; | ||
consumeToken(TOKEN_BRACKET_CLOSE, FluentError); | ||
@@ -1257,292 +1520,2 @@ return key; | ||
/** | ||
* 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. | ||
* Default: true | ||
* | ||
* - `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) { | ||
let _ref = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, | ||
_ref$functions = _ref.functions, | ||
functions = _ref$functions === void 0 ? {} : _ref$functions, | ||
_ref$useIsolating = _ref.useIsolating, | ||
useIsolating = _ref$useIsolating === void 0 ? true : _ref$useIsolating, | ||
_ref$transform = _ref.transform, | ||
transform = _ref$transform === void 0 ? v => v : _ref$transform; | ||
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. | ||
* | ||
* bundle.addMessages('bar = Bar'); | ||
* bundle.addMessages('bar = Newbar', { allowOverrides: true }); | ||
* bundle.getMessage('bar'); | ||
* | ||
* // Returns a raw representation of the 'bar' message: Newbar. | ||
* | ||
* Parsed entities should be formatted with the `format` method in case they | ||
* contain logic (references, select expressions etc.). | ||
* | ||
* Available options: | ||
* | ||
* - `allowOverrides` - boolean specifying whether it's allowed to override | ||
* an existing message or term with a new value. | ||
* Default: false | ||
* | ||
* @param {string} source - Text resource with translations. | ||
* @param {Object} [options] | ||
* @returns {Array<Error>} | ||
*/ | ||
addMessages(source, options) { | ||
const res = FluentResource.fromString(source); | ||
return this.addResource(res, options); | ||
} | ||
/** | ||
* 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. | ||
* | ||
* let res = FluentResource.fromString("bar = Bar"); | ||
* bundle.addResource(res); | ||
* res = FluentResource.fromString("bar = Newbar"); | ||
* bundle.addResource(res, { allowOverrides: true }); | ||
* bundle.getMessage('bar'); | ||
* | ||
* // Returns a raw representation of the 'bar' message: Newbar. | ||
* | ||
* Parsed entities should be formatted with the `format` method in case they | ||
* contain logic (references, select expressions etc.). | ||
* | ||
* Available options: | ||
* | ||
* - `allowOverrides` - boolean specifying whether it's allowed to override | ||
* an existing message or term with a new value. | ||
* Default: false | ||
* | ||
* @param {FluentResource} res - FluentResource object. | ||
* @param {Object} [options] | ||
* @returns {Array<Error>} | ||
*/ | ||
addResource(res) { | ||
let _ref2 = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, | ||
_ref2$allowOverrides = _ref2.allowOverrides, | ||
allowOverrides = _ref2$allowOverrides === void 0 ? false : _ref2$allowOverrides; | ||
const errors = []; | ||
var _iteratorNormalCompletion = true; | ||
var _didIteratorError = false; | ||
var _iteratorError = undefined; | ||
try { | ||
for (var _iterator = res[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { | ||
const _step$value = _slicedToArray(_step.value, 2), | ||
id = _step$value[0], | ||
value = _step$value[1]; | ||
if (id.startsWith("-")) { | ||
// Identifiers starting with a dash (-) define terms. Terms are private | ||
// and cannot be retrieved from FluentBundle. | ||
if (allowOverrides === false && this._terms.has(id)) { | ||
errors.push("Attempt to override an existing term: \"".concat(id, "\"")); | ||
continue; | ||
} | ||
this._terms.set(id, value); | ||
} else { | ||
if (allowOverrides === false && this._messages.has(id)) { | ||
errors.push("Attempt to override an existing message: \"".concat(id, "\"")); | ||
continue; | ||
} | ||
this._messages.set(id, value); | ||
} | ||
} | ||
} catch (err) { | ||
_didIteratorError = true; | ||
_iteratorError = err; | ||
} finally { | ||
try { | ||
if (!_iteratorNormalCompletion && _iterator.return != null) { | ||
_iterator.return(); | ||
} | ||
} finally { | ||
if (_didIteratorError) { | ||
throw _iteratorError; | ||
} | ||
} | ||
} | ||
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]; | ||
} | ||
} | ||
/* | ||
* @module fluent | ||
@@ -1549,0 +1522,0 @@ * @overview |
802
index.js
@@ -1,2 +0,2 @@ | ||
/* @fluent/bundle@0.13.0 */ | ||
/* @fluent/bundle@0.14.0 */ | ||
(function (global, factory) { | ||
@@ -19,11 +19,10 @@ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : | ||
/** | ||
* Create an `FluentType` instance. | ||
* Create a `FluentType` instance. | ||
* | ||
* @param {Any} value - JavaScript value to wrap. | ||
* @param {Object} opts - Configuration. | ||
* @param {Any} value - JavaScript value to wrap. | ||
* @returns {FluentType} | ||
*/ | ||
constructor(value, opts) { | ||
constructor(value) { | ||
/** The wrapped native value. */ | ||
this.value = value; | ||
this.opts = opts; | ||
} | ||
@@ -44,9 +43,10 @@ | ||
* 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. | ||
* This method can use `Intl` formatters available through the `scope` | ||
* argument. | ||
* | ||
* @param {FluentBundle} [bundle] | ||
* @abstract | ||
* @param {Scope} scope | ||
* @returns {string} | ||
*/ | ||
toString() { | ||
toString(scope) { // eslint-disable-line no-unused-vars | ||
throw new Error("Subclasses of FluentType must implement toString."); | ||
@@ -56,26 +56,53 @@ } | ||
/** | ||
* A `FluentType` representing no correct value. | ||
*/ | ||
class FluentNone extends FluentType { | ||
valueOf() { | ||
return null; | ||
/** | ||
* Create an instance of `FluentNone` with an optional fallback value. | ||
* @param {string} value - The fallback value of this `FluentNone`. | ||
* @returns {FluentType} | ||
*/ | ||
constructor(value = "???") { | ||
super(value); | ||
} | ||
/** | ||
* Format this `FluentNone` to the fallback string. | ||
* @returns {string} | ||
*/ | ||
toString() { | ||
return `{${this.value || "???"}}`; | ||
return `{${this.value}}`; | ||
} | ||
} | ||
/** | ||
* A `FluentType` representing a number. | ||
*/ | ||
class FluentNumber extends FluentType { | ||
/** | ||
* Create an instance of `FluentNumber` with options to the | ||
* `Intl.NumberFormat` constructor. | ||
* @param {number} value | ||
* @param {Intl.NumberFormatOptions} opts | ||
* @returns {FluentType} | ||
*/ | ||
constructor(value, opts) { | ||
super(parseFloat(value), opts); | ||
super(value); | ||
/** Options passed to Intl.NumberFormat. */ | ||
this.opts = opts; | ||
} | ||
toString(bundle) { | ||
/** | ||
* Format this `FluentNumber` to a string. | ||
* @param {Scope} scope | ||
* @returns {string} | ||
*/ | ||
toString(scope) { | ||
try { | ||
const nf = bundle._memoizeIntlObject( | ||
Intl.NumberFormat, this.opts | ||
); | ||
const nf = scope.memoizeIntlObject(Intl.NumberFormat, this.opts); | ||
return nf.format(this.value); | ||
} catch (e) { | ||
// XXX Report the error. | ||
return this.value; | ||
} catch (err) { | ||
scope.reportError(err); | ||
return this.value.toString(10); | ||
} | ||
@@ -85,16 +112,31 @@ } | ||
/** | ||
* A `FluentType` representing a date and time. | ||
*/ | ||
class FluentDateTime extends FluentType { | ||
/** | ||
* Create an instance of `FluentDateTime` with options to the | ||
* `Intl.DateTimeFormat` constructor. | ||
* @param {number} value - timestamp in milliseconds | ||
* @param {Intl.DateTimeFormatOptions} opts | ||
* @returns {FluentType} | ||
*/ | ||
constructor(value, opts) { | ||
super(new Date(value), opts); | ||
super(value); | ||
/** Options passed to Intl.DateTimeFormat. */ | ||
this.opts = opts; | ||
} | ||
toString(bundle) { | ||
/** | ||
* Format this `FluentDateTime` to a string. | ||
* @param {Scope} scope | ||
* @returns {string} | ||
*/ | ||
toString(scope) { | ||
try { | ||
const dtf = bundle._memoizeIntlObject( | ||
Intl.DateTimeFormat, this.opts | ||
); | ||
const dtf = scope.memoizeIntlObject(Intl.DateTimeFormat, this.opts); | ||
return dtf.format(this.value); | ||
} catch (e) { | ||
// XXX Report the error. | ||
return this.value; | ||
} catch (err) { | ||
scope.reportError(err); | ||
return (new Date(this.value)).toISOString(); | ||
} | ||
@@ -131,8 +173,11 @@ } | ||
if (arg instanceof FluentNone) { | ||
return arg; | ||
return new FluentNone(`NUMBER(${arg.valueOf()})`); | ||
} | ||
if (arg instanceof FluentNumber) { | ||
return new FluentNumber(arg.valueOf(), merge(arg.opts, opts)); | ||
let value = Number(arg.valueOf()); | ||
if (Number.isNaN(value)) { | ||
throw new TypeError("Invalid argument to NUMBER"); | ||
} | ||
return new FluentNone("NUMBER()"); | ||
return new FluentNumber(value, merge(arg.opts, opts)); | ||
} | ||
@@ -142,8 +187,11 @@ | ||
if (arg instanceof FluentNone) { | ||
return arg; | ||
return new FluentNone(`DATETIME(${arg.valueOf()})`); | ||
} | ||
if (arg instanceof FluentDateTime) { | ||
return new FluentDateTime(arg.valueOf(), merge(arg.opts, opts)); | ||
let value = Number(arg.valueOf()); | ||
if (Number.isNaN(value)) { | ||
throw new TypeError("Invalid argument to DATETIME"); | ||
} | ||
return new FluentNone("DATETIME()"); | ||
return new FluentDateTime(value, merge(arg.opts, opts)); | ||
} | ||
@@ -167,3 +215,3 @@ | ||
// Helper: match a variant key to the given selector. | ||
function match(bundle, selector, key) { | ||
function match(scope, selector, key) { | ||
if (key === selector) { | ||
@@ -182,4 +230,4 @@ // Both are strings. | ||
if (selector instanceof FluentNumber && typeof key === "string") { | ||
let category = bundle | ||
._memoizeIntlObject(Intl.PluralRules, selector.opts) | ||
let category = scope | ||
.memoizeIntlObject(Intl.PluralRules, selector.opts) | ||
.select(selector.value); | ||
@@ -197,6 +245,6 @@ if (key === category) { | ||
if (variants[star]) { | ||
return Type(scope, variants[star]); | ||
return resolvePattern(scope, variants[star].value); | ||
} | ||
scope.errors.push(new RangeError("No default")); | ||
scope.reportError(new RangeError("No default")); | ||
return new FluentNone(); | ||
@@ -212,5 +260,5 @@ } | ||
if (arg.type === "narg") { | ||
named[arg.name] = Type(scope, arg.value); | ||
named[arg.name] = resolveExpression(scope, arg.value); | ||
} else { | ||
positional.push(Type(scope, arg)); | ||
positional.push(resolveExpression(scope, arg)); | ||
} | ||
@@ -223,21 +271,3 @@ } | ||
// Resolve an expression to a Fluent type. | ||
function Type(scope, expr) { | ||
// A fast-path for strings which are the most common case. Since they | ||
// natively have the `toString` method they can be used as if they were | ||
// a FluentType instance without incurring the cost of creating one. | ||
if (typeof expr === "string") { | ||
return scope.bundle._transform(expr); | ||
} | ||
// A fast-path for `FluentNone` which doesn't require any additional logic. | ||
if (expr instanceof FluentNone) { | ||
return expr; | ||
} | ||
// The Runtime AST (Entries) encodes patterns (complex strings with | ||
// placeables) as Arrays. | ||
if (Array.isArray(expr)) { | ||
return Pattern(scope, expr); | ||
} | ||
function resolveExpression(scope, expr) { | ||
switch (expr.type) { | ||
@@ -260,11 +290,2 @@ case "str": | ||
return SelectExpression(scope, expr); | ||
case undefined: { | ||
// If it's a node with a value, resolve the value. | ||
if (expr.value !== null && expr.value !== undefined) { | ||
return Type(scope, expr.value); | ||
} | ||
scope.errors.push(new RangeError("No value")); | ||
return new FluentNone(); | ||
} | ||
default: | ||
@@ -279,3 +300,3 @@ return new FluentNone(); | ||
if (scope.insideTermReference === false) { | ||
scope.errors.push(new ReferenceError(`Unknown variable: ${name}`)); | ||
scope.reportError(new ReferenceError(`Unknown variable: $${name}`)); | ||
} | ||
@@ -300,7 +321,7 @@ return new FluentNone(`$${name}`); | ||
if (arg instanceof Date) { | ||
return new FluentDateTime(arg); | ||
return new FluentDateTime(arg.getTime()); | ||
} | ||
default: | ||
scope.errors.push( | ||
new TypeError(`Unsupported variable type: ${name}, ${typeof arg}`) | ||
scope.reportError( | ||
new TypeError(`Variable type not supported: $${name}, ${typeof arg}`) | ||
); | ||
@@ -315,4 +336,3 @@ return new FluentNone(`$${name}`); | ||
if (!message) { | ||
const err = new ReferenceError(`Unknown message: ${name}`); | ||
scope.errors.push(err); | ||
scope.reportError(new ReferenceError(`Unknown message: ${name}`)); | ||
return new FluentNone(name); | ||
@@ -322,11 +342,16 @@ } | ||
if (attr) { | ||
const attribute = message.attrs && message.attrs[attr]; | ||
const attribute = message.attributes[attr]; | ||
if (attribute) { | ||
return Type(scope, attribute); | ||
return resolvePattern(scope, attribute); | ||
} | ||
scope.errors.push(new ReferenceError(`Unknown attribute: ${attr}`)); | ||
scope.reportError(new ReferenceError(`Unknown attribute: ${attr}`)); | ||
return new FluentNone(`${name}.${attr}`); | ||
} | ||
return Type(scope, message); | ||
if (message.value) { | ||
return resolvePattern(scope, message.value); | ||
} | ||
scope.reportError(new ReferenceError(`No value: ${name}`)); | ||
return new FluentNone(name); | ||
} | ||
@@ -339,21 +364,20 @@ | ||
if (!term) { | ||
const err = new ReferenceError(`Unknown term: ${id}`); | ||
scope.errors.push(err); | ||
scope.reportError(new ReferenceError(`Unknown term: ${id}`)); | ||
return new FluentNone(id); | ||
} | ||
// Every TermReference has its own args. | ||
const [, keyargs] = getArguments(scope, args); | ||
const local = {...scope, args: keyargs, insideTermReference: true}; | ||
// Every TermReference has its own variables. | ||
const [, params] = getArguments(scope, args); | ||
const local = scope.cloneForTermReference(params); | ||
if (attr) { | ||
const attribute = term.attrs && term.attrs[attr]; | ||
const attribute = term.attributes[attr]; | ||
if (attribute) { | ||
return Type(local, attribute); | ||
return resolvePattern(local, attribute); | ||
} | ||
scope.errors.push(new ReferenceError(`Unknown attribute: ${attr}`)); | ||
scope.reportError(new ReferenceError(`Unknown attribute: ${attr}`)); | ||
return new FluentNone(`${id}.${attr}`); | ||
} | ||
return Type(local, term); | ||
return resolvePattern(local, term.value); | ||
} | ||
@@ -367,3 +391,3 @@ | ||
if (!func) { | ||
scope.errors.push(new ReferenceError(`Unknown function: ${name}()`)); | ||
scope.reportError(new ReferenceError(`Unknown function: ${name}()`)); | ||
return new FluentNone(`${name}()`); | ||
@@ -373,3 +397,3 @@ } | ||
if (typeof func !== "function") { | ||
scope.errors.push(new TypeError(`Function ${name}() is not callable`)); | ||
scope.reportError(new TypeError(`Function ${name}() is not callable`)); | ||
return new FluentNone(`${name}()`); | ||
@@ -380,4 +404,4 @@ } | ||
return func(...getArguments(scope, args)); | ||
} catch (e) { | ||
// XXX Report errors. | ||
} catch (err) { | ||
scope.reportError(err); | ||
return new FluentNone(`${name}()`); | ||
@@ -389,6 +413,5 @@ } | ||
function SelectExpression(scope, {selector, variants, star}) { | ||
let sel = Type(scope, selector); | ||
let sel = resolveExpression(scope, selector); | ||
if (sel instanceof FluentNone) { | ||
const variant = getDefault(scope, variants, star); | ||
return Type(scope, variant); | ||
return getDefault(scope, variants, star); | ||
} | ||
@@ -398,16 +421,15 @@ | ||
for (const variant of variants) { | ||
const key = Type(scope, variant.key); | ||
if (match(scope.bundle, sel, key)) { | ||
return Type(scope, variant); | ||
const key = resolveExpression(scope, variant.key); | ||
if (match(scope, sel, key)) { | ||
return resolvePattern(scope, variant.value); | ||
} | ||
} | ||
const variant = getDefault(scope, variants, star); | ||
return Type(scope, variant); | ||
return getDefault(scope, variants, star); | ||
} | ||
// Resolve a pattern (a complex string with placeables). | ||
function Pattern(scope, ptn) { | ||
function resolveComplexPattern(scope, ptn) { | ||
if (scope.dirty.has(ptn)) { | ||
scope.errors.push(new RangeError("Cyclic reference")); | ||
scope.reportError(new RangeError("Cyclic reference")); | ||
return new FluentNone(); | ||
@@ -430,3 +452,3 @@ } | ||
const part = Type(scope, elem).toString(scope.bundle); | ||
const part = resolveExpression(scope, elem).toString(scope); | ||
@@ -438,13 +460,15 @@ if (useIsolating) { | ||
if (part.length > MAX_PLACEABLE_LENGTH) { | ||
scope.errors.push( | ||
new RangeError( | ||
"Too many characters in placeable " + | ||
`(${part.length}, max allowed is ${MAX_PLACEABLE_LENGTH})` | ||
) | ||
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 characters in placeable " + | ||
`(${part.length}, max allowed is ${MAX_PLACEABLE_LENGTH})` | ||
); | ||
result.push(part.slice(MAX_PLACEABLE_LENGTH)); | ||
} else { | ||
result.push(part); | ||
} | ||
result.push(part); | ||
if (useIsolating) { | ||
@@ -459,24 +483,240 @@ result.push(PDI); | ||
// Resolve a simple or a complex Pattern to a FluentString (which is really the | ||
// string primitive). | ||
function resolvePattern(scope, node) { | ||
// Resolve a simple pattern. | ||
if (typeof node === "string") { | ||
return scope.bundle._transform(node); | ||
} | ||
return resolveComplexPattern(scope, node); | ||
} | ||
class Scope { | ||
constructor( | ||
bundle, | ||
errors, | ||
args, | ||
insideTermReference = false, | ||
dirty = new WeakSet() | ||
) { | ||
/** The bundle for which the given resolution is happening. */ | ||
this.bundle = bundle; | ||
/** The list of errors collected while resolving. */ | ||
this.errors = errors; | ||
/** A dict of developer-provided variables. */ | ||
this.args = args; | ||
/** Term references require different variable lookup logic. */ | ||
this.insideTermReference = insideTermReference; | ||
/** The Set of patterns already encountered during this resolution. | ||
* Used to detect and prevent cyclic resolutions. */ | ||
this.dirty = dirty; | ||
} | ||
cloneForTermReference(args) { | ||
return new Scope(this.bundle, this.errors, args, true, this.dirty); | ||
} | ||
reportError(error) { | ||
if (!this.errors) { | ||
throw error; | ||
} | ||
this.errors.push(error); | ||
} | ||
memoizeIntlObject(ctor, opts) { | ||
let cache = this.bundle._intls.get(ctor); | ||
if (!cache) { | ||
cache = {}; | ||
this.bundle._intls.set(ctor, cache); | ||
} | ||
let id = JSON.stringify(opts); | ||
if (!cache[id]) { | ||
cache[id] = new ctor(this.bundle.locales, opts); | ||
} | ||
return cache[id]; | ||
} | ||
} | ||
/** | ||
* 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} | ||
* Message bundles are single-language stores of translation resources. They are | ||
* responsible for formatting message values and attributes to strings. | ||
*/ | ||
function resolve(bundle, args, message, errors = []) { | ||
const scope = { | ||
bundle, args, errors, dirty: new WeakSet(), | ||
// TermReferences are resolved in a new scope. | ||
insideTermReference: false, | ||
}; | ||
return Type(scope, message).toString(bundle); | ||
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: | ||
* | ||
* let bundle = new FluentBundle(["en-US", "en"]); | ||
* | ||
* let bundle = new FluentBundle(locales, {useIsolating: false}); | ||
* | ||
* let 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. Default: `true`. | ||
* | ||
* - `transform` - a function used to transform string parts of patterns. | ||
* | ||
* @param {(string|Array.<string>)} locales - The 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(); | ||
} | ||
/** | ||
* 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 a raw unformatted message object from the bundle. | ||
* | ||
* Raw messages are `{value, attributes}` shapes containing translation units | ||
* called `Patterns`. `Patterns` are implementation-specific; they should be | ||
* treated as black boxes and formatted with `FluentBundle.formatPattern`. | ||
* | ||
* interface RawMessage { | ||
* value: Pattern | null; | ||
* attributes: Record<string, Pattern>; | ||
* } | ||
* | ||
* @param {string} id - The identifier of the message to check. | ||
* @returns {{value: ?Pattern, attributes: Object.<string, Pattern>}} | ||
*/ | ||
getMessage(id) { | ||
return this._messages.get(id); | ||
} | ||
/** | ||
* Add a translation resource to the bundle. | ||
* | ||
* The translation resource must be an instance of `FluentResource`. | ||
* | ||
* let res = new FluentResource("foo = Foo"); | ||
* bundle.addResource(res); | ||
* bundle.getMessage("foo"); | ||
* // → {value: .., attributes: {..}} | ||
* | ||
* Available options: | ||
* | ||
* - `allowOverrides` - boolean specifying whether it's allowed to override | ||
* an existing message or term with a new value. Default: `false`. | ||
* | ||
* @param {FluentResource} res - FluentResource object. | ||
* @param {Object} [options] | ||
* @returns {Array.<FluentError>} | ||
*/ | ||
addResource(res, { | ||
allowOverrides = false, | ||
} = {}) { | ||
const errors = []; | ||
for (let i = 0; i < res.body.length; i++) { | ||
let entry = res.body[i]; | ||
if (entry.id.startsWith("-")) { | ||
// Identifiers starting with a dash (-) define terms. Terms are private | ||
// and cannot be retrieved from FluentBundle. | ||
if (allowOverrides === false && this._terms.has(entry.id)) { | ||
errors.push(`Attempt to override an existing term: "${entry.id}"`); | ||
continue; | ||
} | ||
this._terms.set(entry.id, entry); | ||
} else { | ||
if (allowOverrides === false && this._messages.has(entry.id)) { | ||
errors.push(`Attempt to override an existing message: "${entry.id}"`); | ||
continue; | ||
} | ||
this._messages.set(entry.id, entry); | ||
} | ||
} | ||
return errors; | ||
} | ||
/** | ||
* Format a `Pattern` to a string. | ||
* | ||
* Format a raw `Pattern` into a string. `args` will be used to resolve | ||
* references to variables passed as arguments to the translation. | ||
* | ||
* In case of errors `formatPattern` 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. | ||
* | ||
* let errors = []; | ||
* bundle.addResource( | ||
* new FluentResource("hello = Hello, {$name}!")); | ||
* | ||
* let hello = bundle.getMessage("hello"); | ||
* if (hello.value) { | ||
* bundle.formatPattern(hello.value, {name: "Jane"}, errors); | ||
* // Returns "Hello, Jane!" and `errors` is empty. | ||
* | ||
* bundle.formatPattern(hello.value, undefined, errors); | ||
* // Returns "Hello, {$name}!" and `errors` is now: | ||
* // [<ReferenceError: Unknown variable: name>] | ||
* } | ||
* | ||
* If `errors` is omitted, the first encountered error will be thrown. | ||
* | ||
* @param {Pattern} pattern | ||
* @param {?Object} args | ||
* @param {?Array.<Error>} errors | ||
* @returns {string} | ||
*/ | ||
formatPattern(pattern, args, errors) { | ||
// Resolve a simple pattern without creating a scope. No error handling is | ||
// required; by definition simple patterns don't have placeables. | ||
if (typeof pattern === "string") { | ||
return this._transform(pattern); | ||
} | ||
// Resolve a complex pattern. | ||
let scope = new Scope(this, errors, args); | ||
try { | ||
let value = resolveComplexPattern(scope, pattern); | ||
return value.toString(scope); | ||
} catch (err) { | ||
if (scope.errors) { | ||
scope.errors.push(err); | ||
return new FluentNone().toString(scope); | ||
} | ||
throw err; | ||
} | ||
} | ||
} | ||
@@ -539,12 +779,13 @@ | ||
/** | ||
* Fluent Resource is a structure storing a map of parsed localization entries. | ||
* Fluent Resource is a structure storing parsed localization entries. | ||
*/ | ||
class FluentResource extends Map { | ||
/** | ||
* Create a new FluentResource from Fluent code. | ||
*/ | ||
static fromString(source) { | ||
class FluentResource { | ||
constructor(source) { | ||
this.body = this._parse(source); | ||
} | ||
_parse(source) { | ||
RE_MESSAGE_START.lastIndex = 0; | ||
let resource = new this(); | ||
let resource = []; | ||
let cursor = 0; | ||
@@ -562,3 +803,3 @@ | ||
try { | ||
resource.set(next[1], parseMessage()); | ||
resource.push(parseMessage(next[1])); | ||
} catch (err) { | ||
@@ -576,3 +817,4 @@ if (err instanceof FluentError) { | ||
// The parser implementation is inlined below for performance reasons. | ||
// The parser implementation is inlined below for performance reasons, | ||
// as well as for convenience of accessing `source` and `cursor`. | ||
@@ -639,18 +881,15 @@ // The parser focuses on minimizing the number of false negatives at the | ||
function parseMessage() { | ||
function parseMessage(id) { | ||
let value = parsePattern(); | ||
let attrs = parseAttributes(); | ||
let attributes = parseAttributes(); | ||
if (attrs === null) { | ||
if (value === null) { | ||
throw new FluentError("Expected message value or attributes"); | ||
} | ||
return value; | ||
if (value === null && Object.keys(attributes).length === 0) { | ||
throw new FluentError("Expected message value or attributes"); | ||
} | ||
return {value, attrs}; | ||
return {id, value, attributes}; | ||
} | ||
function parseAttributes() { | ||
let attrs = {}; | ||
let attrs = Object.create(null); | ||
@@ -666,3 +905,3 @@ while (test(RE_ATTRIBUTE_START)) { | ||
return Object.keys(attrs).length > 0 ? attrs : null; | ||
return attrs; | ||
} | ||
@@ -749,5 +988,2 @@ | ||
element = element.value.slice(0, element.value.length - commonIndent); | ||
} else if (element.type === "str") { | ||
// Optimize StringLiterals into their value. | ||
element = element.value; | ||
} | ||
@@ -882,3 +1118,3 @@ if (element) { | ||
? parseNumberLiteral() | ||
: match1(RE_IDENTIFIER); | ||
: {type: "str", value: match1(RE_IDENTIFIER)}; | ||
consumeToken(TOKEN_BRACKET_CLOSE, FluentError); | ||
@@ -996,254 +1232,2 @@ return key; | ||
/** | ||
* 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. | ||
* Default: true | ||
* | ||
* - `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. | ||
* | ||
* bundle.addMessages('bar = Bar'); | ||
* bundle.addMessages('bar = Newbar', { allowOverrides: true }); | ||
* bundle.getMessage('bar'); | ||
* | ||
* // Returns a raw representation of the 'bar' message: Newbar. | ||
* | ||
* Parsed entities should be formatted with the `format` method in case they | ||
* contain logic (references, select expressions etc.). | ||
* | ||
* Available options: | ||
* | ||
* - `allowOverrides` - boolean specifying whether it's allowed to override | ||
* an existing message or term with a new value. | ||
* Default: false | ||
* | ||
* @param {string} source - Text resource with translations. | ||
* @param {Object} [options] | ||
* @returns {Array<Error>} | ||
*/ | ||
addMessages(source, options) { | ||
const res = FluentResource.fromString(source); | ||
return this.addResource(res, options); | ||
} | ||
/** | ||
* 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. | ||
* | ||
* let res = FluentResource.fromString("bar = Bar"); | ||
* bundle.addResource(res); | ||
* res = FluentResource.fromString("bar = Newbar"); | ||
* bundle.addResource(res, { allowOverrides: true }); | ||
* bundle.getMessage('bar'); | ||
* | ||
* // Returns a raw representation of the 'bar' message: Newbar. | ||
* | ||
* Parsed entities should be formatted with the `format` method in case they | ||
* contain logic (references, select expressions etc.). | ||
* | ||
* Available options: | ||
* | ||
* - `allowOverrides` - boolean specifying whether it's allowed to override | ||
* an existing message or term with a new value. | ||
* Default: false | ||
* | ||
* @param {FluentResource} res - FluentResource object. | ||
* @param {Object} [options] | ||
* @returns {Array<Error>} | ||
*/ | ||
addResource(res, { | ||
allowOverrides = false, | ||
} = {}) { | ||
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 (allowOverrides === false && this._terms.has(id)) { | ||
errors.push(`Attempt to override an existing term: "${id}"`); | ||
continue; | ||
} | ||
this._terms.set(id, value); | ||
} else { | ||
if (allowOverrides === false && 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]; | ||
} | ||
} | ||
/* | ||
* @module fluent | ||
@@ -1250,0 +1234,0 @@ * @overview |
{ | ||
"name": "@fluent/bundle", | ||
"description": "Localization library for expressive translations.", | ||
"version": "0.13.0", | ||
"version": "0.14.0", | ||
"homepage": "http://projectfluent.org", | ||
@@ -6,0 +6,0 @@ "author": "Mozilla <l10n-drivers@mozilla.org>", |
@@ -23,19 +23,20 @@ # @fluent/bundle | ||
```javascript | ||
import { FluentBundle, ftl } from '@fluent/bundle'; | ||
import {FluentBundle, FluentResource} from "@fluent/bundle"; | ||
const bundle = new FluentBundle('en-US'); | ||
const errors = bundle.addMessages(ftl` | ||
-brand-name = Foo 3000 | ||
welcome = Welcome, { $name }, to { -brand-name }! | ||
let resource = new FluentResource(` | ||
-brand-name = Foo 3000 | ||
welcome = Welcome, {$name}, to {-brand-name}! | ||
`); | ||
let bundle = new FluentBundle("en-US"); | ||
let errors = bundle.addResource(resource); | ||
if (errors.length) { | ||
// syntax errors are per-message and don't break the whole resource | ||
// Syntax errors are per-message and don't break the whole resource | ||
} | ||
const welcome = bundle.getMessage('welcome'); | ||
bundle.format(welcome, { name: 'Anna' }); | ||
// → 'Welcome, Anna, to Foo 3000!' | ||
let welcome = bundle.getMessage("welcome"); | ||
if (welcome.value) { | ||
bundle.formatPattern(welcome.value, {name: "Anna"}); | ||
// → "Welcome, Anna, to Foo 3000!" | ||
} | ||
``` | ||
@@ -59,3 +60,3 @@ | ||
import 'intl-pluralrules'; | ||
import { FluentBundle } from '@fluent/bundle'; | ||
import {FluentBundle} from '@fluent/bundle'; | ||
``` | ||
@@ -67,3 +68,3 @@ | ||
```javascript | ||
import { FluentBundle } from '@fluent/bundle/compat'; | ||
import {FluentBundle} from '@fluent/bundle/compat'; | ||
``` | ||
@@ -70,0 +71,0 @@ |
@@ -31,8 +31,11 @@ /** | ||
if (arg instanceof FluentNone) { | ||
return arg; | ||
return new FluentNone(`NUMBER(${arg.valueOf()})`); | ||
} | ||
if (arg instanceof FluentNumber) { | ||
return new FluentNumber(arg.valueOf(), merge(arg.opts, opts)); | ||
let value = Number(arg.valueOf()); | ||
if (Number.isNaN(value)) { | ||
throw new TypeError("Invalid argument to NUMBER"); | ||
} | ||
return new FluentNone("NUMBER()"); | ||
return new FluentNumber(value, merge(arg.opts, opts)); | ||
} | ||
@@ -43,8 +46,11 @@ | ||
if (arg instanceof FluentNone) { | ||
return arg; | ||
return new FluentNone(`DATETIME(${arg.valueOf()})`); | ||
} | ||
if (arg instanceof FluentDateTime) { | ||
return new FluentDateTime(arg.valueOf(), merge(arg.opts, opts)); | ||
let value = Number(arg.valueOf()); | ||
if (Number.isNaN(value)) { | ||
throw new TypeError("Invalid argument to DATETIME"); | ||
} | ||
return new FluentNone("DATETIME()"); | ||
return new FluentDateTime(value, merge(arg.opts, opts)); | ||
} |
@@ -1,16 +0,8 @@ | ||
import resolve from "./resolver.js"; | ||
import FluentResource from "./resource.js"; | ||
import {FluentNone} from "./types.js"; | ||
import {resolveComplexPattern} from "./resolver.js"; | ||
import Scope from "./scope.js"; | ||
/** | ||
* 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. | ||
* Message bundles are single-language stores of translation resources. They are | ||
* responsible for formatting message values and attributes to strings. | ||
*/ | ||
@@ -22,11 +14,11 @@ export default class FluentBundle { | ||
* The `locales` argument is used to instantiate `Intl` formatters used by | ||
* translations. The `options` object can be used to configure the bundle. | ||
* translations. The `options` object can be used to configure the bundle. | ||
* | ||
* Examples: | ||
* | ||
* const bundle = new FluentBundle(locales); | ||
* let bundle = new FluentBundle(["en-US", "en"]); | ||
* | ||
* const bundle = new FluentBundle(locales, { useIsolating: false }); | ||
* let bundle = new FluentBundle(locales, {useIsolating: false}); | ||
* | ||
* const bundle = new FluentBundle(locales, { | ||
* let bundle = new FluentBundle(locales, { | ||
* useIsolating: true, | ||
@@ -41,11 +33,10 @@ * functions: { | ||
* - `functions` - an object of additional functions available to | ||
* translations as builtins. | ||
* translations as builtins. | ||
* | ||
* - `useIsolating` - boolean specifying whether to use Unicode isolation | ||
* marks (FSI, PDI) for bidi interpolations. | ||
* Default: true | ||
* marks (FSI, PDI) for bidi interpolations. Default: `true`. | ||
* | ||
* - `transform` - a function used to transform string parts of patterns. | ||
* | ||
* @param {string|Array<string>} locales - Locale or locales of the bundle | ||
* @param {(string|Array.<string>)} locales - The locales of the bundle | ||
* @param {Object} [options] | ||
@@ -69,12 +60,3 @@ * @returns {FluentBundle} | ||
/* | ||
* 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. | ||
@@ -89,10 +71,16 @@ * | ||
/* | ||
* Return the internal representation of a message. | ||
/** | ||
* Return a raw unformatted message object from the bundle. | ||
* | ||
* The internal representation should only be used as an argument to | ||
* `FluentBundle.format`. | ||
* Raw messages are `{value, attributes}` shapes containing translation units | ||
* called `Patterns`. `Patterns` are implementation-specific; they should be | ||
* treated as black boxes and formatted with `FluentBundle.formatPattern`. | ||
* | ||
* interface RawMessage { | ||
* value: Pattern | null; | ||
* attributes: Record<string, Pattern>; | ||
* } | ||
* | ||
* @param {string} id - The identifier of the message to check. | ||
* @returns {Any} | ||
* @returns {{value: ?Pattern, attributes: Object.<string, Pattern>}} | ||
*/ | ||
@@ -106,67 +94,17 @@ getMessage(id) { | ||
* | ||
* 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. | ||
* The translation resource must be an instance of `FluentResource`. | ||
* | ||
* bundle.addMessages('foo = Foo'); | ||
* bundle.getMessage('foo'); | ||
* | ||
* // Returns a raw representation of the 'foo' message. | ||
* | ||
* bundle.addMessages('bar = Bar'); | ||
* bundle.addMessages('bar = Newbar', { allowOverrides: true }); | ||
* bundle.getMessage('bar'); | ||
* | ||
* // Returns a raw representation of the 'bar' message: Newbar. | ||
* | ||
* Parsed entities should be formatted with the `format` method in case they | ||
* contain logic (references, select expressions etc.). | ||
* | ||
* Available options: | ||
* | ||
* - `allowOverrides` - boolean specifying whether it's allowed to override | ||
* an existing message or term with a new value. | ||
* Default: false | ||
* | ||
* @param {string} source - Text resource with translations. | ||
* @param {Object} [options] | ||
* @returns {Array<Error>} | ||
*/ | ||
addMessages(source, options) { | ||
const res = FluentResource.fromString(source); | ||
return this.addResource(res, options); | ||
} | ||
/** | ||
* 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"); | ||
* let res = new FluentResource("foo = Foo"); | ||
* bundle.addResource(res); | ||
* bundle.getMessage('foo'); | ||
* bundle.getMessage("foo"); | ||
* // → {value: .., attributes: {..}} | ||
* | ||
* // Returns a raw representation of the 'foo' message. | ||
* | ||
* let res = FluentResource.fromString("bar = Bar"); | ||
* bundle.addResource(res); | ||
* res = FluentResource.fromString("bar = Newbar"); | ||
* bundle.addResource(res, { allowOverrides: true }); | ||
* bundle.getMessage('bar'); | ||
* | ||
* // Returns a raw representation of the 'bar' message: Newbar. | ||
* | ||
* Parsed entities should be formatted with the `format` method in case they | ||
* contain logic (references, select expressions etc.). | ||
* | ||
* Available options: | ||
* | ||
* - `allowOverrides` - boolean specifying whether it's allowed to override | ||
* an existing message or term with a new value. | ||
* Default: false | ||
* an existing message or term with a new value. Default: `false`. | ||
* | ||
* @param {FluentResource} res - FluentResource object. | ||
* @param {Object} [options] | ||
* @returns {Array<Error>} | ||
* @returns {Array.<FluentError>} | ||
*/ | ||
@@ -178,17 +116,18 @@ addResource(res, { | ||
for (const [id, value] of res) { | ||
if (id.startsWith("-")) { | ||
for (let i = 0; i < res.body.length; i++) { | ||
let entry = res.body[i]; | ||
if (entry.id.startsWith("-")) { | ||
// Identifiers starting with a dash (-) define terms. Terms are private | ||
// and cannot be retrieved from FluentBundle. | ||
if (allowOverrides === false && this._terms.has(id)) { | ||
errors.push(`Attempt to override an existing term: "${id}"`); | ||
if (allowOverrides === false && this._terms.has(entry.id)) { | ||
errors.push(`Attempt to override an existing term: "${entry.id}"`); | ||
continue; | ||
} | ||
this._terms.set(id, value); | ||
this._terms.set(entry.id, entry); | ||
} else { | ||
if (allowOverrides === false && this._messages.has(id)) { | ||
errors.push(`Attempt to override an existing message: "${id}"`); | ||
if (allowOverrides === false && this._messages.has(entry.id)) { | ||
errors.push(`Attempt to override an existing message: "${entry.id}"`); | ||
continue; | ||
} | ||
this._messages.set(id, value); | ||
this._messages.set(entry.id, entry); | ||
} | ||
@@ -201,61 +140,53 @@ } | ||
/** | ||
* Format a message to a string or null. | ||
* Format a `Pattern` to a string. | ||
* | ||
* 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. | ||
* Format a raw `Pattern` into a string. `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. | ||
* In case of errors `formatPattern` 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); | ||
* let errors = []; | ||
* bundle.addResource( | ||
* new FluentResource("hello = Hello, {$name}!")); | ||
* | ||
* // Returns 'Hello, Jane!' and `errors` is empty. | ||
* let hello = bundle.getMessage("hello"); | ||
* if (hello.value) { | ||
* bundle.formatPattern(hello.value, {name: "Jane"}, errors); | ||
* // Returns "Hello, Jane!" and `errors` is empty. | ||
* | ||
* bundle.format(hello, undefined, errors); | ||
* bundle.formatPattern(hello.value, undefined, errors); | ||
* // Returns "Hello, {$name}!" and `errors` is now: | ||
* // [<ReferenceError: Unknown variable: name>] | ||
* } | ||
* | ||
* // Returns 'Hello, name!' and `errors` is now: | ||
* If `errors` is omitted, the first encountered error will be thrown. | ||
* | ||
* [<ReferenceError: Unknown variable: name>] | ||
* | ||
* @param {Object | string} message | ||
* @param {Object | undefined} args | ||
* @param {Array} errors | ||
* @returns {?string} | ||
* @param {Pattern} pattern | ||
* @param {?Object} args | ||
* @param {?Array.<Error>} errors | ||
* @returns {string} | ||
*/ | ||
format(message, args, errors) { | ||
// optimize entities which are simple strings with no attributes | ||
if (typeof message === "string") { | ||
return this._transform(message); | ||
formatPattern(pattern, args, errors) { | ||
// Resolve a simple pattern without creating a scope. No error handling is | ||
// required; by definition simple patterns don't have placeables. | ||
if (typeof pattern === "string") { | ||
return this._transform(pattern); | ||
} | ||
// optimize entities with null values | ||
if (message === null || message.value === null) { | ||
return null; | ||
// Resolve a complex pattern. | ||
let scope = new Scope(this, errors, args); | ||
try { | ||
let value = resolveComplexPattern(scope, pattern); | ||
return value.toString(scope); | ||
} catch (err) { | ||
if (scope.errors) { | ||
scope.errors.push(err); | ||
return new FluentNone().toString(scope); | ||
} | ||
throw err; | ||
} | ||
// 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]; | ||
} | ||
} |
@@ -1,2 +0,2 @@ | ||
/* | ||
/** | ||
* @module fluent | ||
@@ -3,0 +3,0 @@ * @overview |
@@ -6,4 +6,5 @@ /* global Intl */ | ||
* | ||
* The role of the Fluent resolver is to format a translation object to an | ||
* instance of `FluentType` or an array of instances. | ||
* The role of the Fluent resolver is to format a `Pattern` to an instance of | ||
* `FluentType`. For performance reasons, primitive strings are considered such | ||
* instances, too. | ||
* | ||
@@ -13,8 +14,7 @@ * Translations can contain references to other messages or variables, | ||
* 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. | ||
* `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 | ||
* 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`. | ||
@@ -25,14 +25,4 @@ * | ||
* | ||
* All functions in this file pass around a special object called `scope`. | ||
* 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. | ||
* Functions in this file pass around an instance of the `Scope` class, which | ||
* stores the data required for successful resolution and error recovery. | ||
*/ | ||
@@ -54,3 +44,3 @@ | ||
// Helper: match a variant key to the given selector. | ||
function match(bundle, selector, key) { | ||
function match(scope, selector, key) { | ||
if (key === selector) { | ||
@@ -69,4 +59,4 @@ // Both are strings. | ||
if (selector instanceof FluentNumber && typeof key === "string") { | ||
let category = bundle | ||
._memoizeIntlObject(Intl.PluralRules, selector.opts) | ||
let category = scope | ||
.memoizeIntlObject(Intl.PluralRules, selector.opts) | ||
.select(selector.value); | ||
@@ -84,6 +74,6 @@ if (key === category) { | ||
if (variants[star]) { | ||
return Type(scope, variants[star]); | ||
return resolvePattern(scope, variants[star].value); | ||
} | ||
scope.errors.push(new RangeError("No default")); | ||
scope.reportError(new RangeError("No default")); | ||
return new FluentNone(); | ||
@@ -99,5 +89,5 @@ } | ||
if (arg.type === "narg") { | ||
named[arg.name] = Type(scope, arg.value); | ||
named[arg.name] = resolveExpression(scope, arg.value); | ||
} else { | ||
positional.push(Type(scope, arg)); | ||
positional.push(resolveExpression(scope, arg)); | ||
} | ||
@@ -110,21 +100,3 @@ } | ||
// Resolve an expression to a Fluent type. | ||
function Type(scope, expr) { | ||
// A fast-path for strings which are the most common case. Since they | ||
// natively have the `toString` method they can be used as if they were | ||
// a FluentType instance without incurring the cost of creating one. | ||
if (typeof expr === "string") { | ||
return scope.bundle._transform(expr); | ||
} | ||
// A fast-path for `FluentNone` which doesn't require any additional logic. | ||
if (expr instanceof FluentNone) { | ||
return expr; | ||
} | ||
// The Runtime AST (Entries) encodes patterns (complex strings with | ||
// placeables) as Arrays. | ||
if (Array.isArray(expr)) { | ||
return Pattern(scope, expr); | ||
} | ||
function resolveExpression(scope, expr) { | ||
switch (expr.type) { | ||
@@ -147,11 +119,2 @@ case "str": | ||
return SelectExpression(scope, expr); | ||
case undefined: { | ||
// If it's a node with a value, resolve the value. | ||
if (expr.value !== null && expr.value !== undefined) { | ||
return Type(scope, expr.value); | ||
} | ||
scope.errors.push(new RangeError("No value")); | ||
return new FluentNone(); | ||
} | ||
default: | ||
@@ -166,3 +129,3 @@ return new FluentNone(); | ||
if (scope.insideTermReference === false) { | ||
scope.errors.push(new ReferenceError(`Unknown variable: ${name}`)); | ||
scope.reportError(new ReferenceError(`Unknown variable: $${name}`)); | ||
} | ||
@@ -187,7 +150,7 @@ return new FluentNone(`$${name}`); | ||
if (arg instanceof Date) { | ||
return new FluentDateTime(arg); | ||
return new FluentDateTime(arg.getTime()); | ||
} | ||
default: | ||
scope.errors.push( | ||
new TypeError(`Unsupported variable type: ${name}, ${typeof arg}`) | ||
scope.reportError( | ||
new TypeError(`Variable type not supported: $${name}, ${typeof arg}`) | ||
); | ||
@@ -202,4 +165,3 @@ return new FluentNone(`$${name}`); | ||
if (!message) { | ||
const err = new ReferenceError(`Unknown message: ${name}`); | ||
scope.errors.push(err); | ||
scope.reportError(new ReferenceError(`Unknown message: ${name}`)); | ||
return new FluentNone(name); | ||
@@ -209,11 +171,16 @@ } | ||
if (attr) { | ||
const attribute = message.attrs && message.attrs[attr]; | ||
const attribute = message.attributes[attr]; | ||
if (attribute) { | ||
return Type(scope, attribute); | ||
return resolvePattern(scope, attribute); | ||
} | ||
scope.errors.push(new ReferenceError(`Unknown attribute: ${attr}`)); | ||
scope.reportError(new ReferenceError(`Unknown attribute: ${attr}`)); | ||
return new FluentNone(`${name}.${attr}`); | ||
} | ||
return Type(scope, message); | ||
if (message.value) { | ||
return resolvePattern(scope, message.value); | ||
} | ||
scope.reportError(new ReferenceError(`No value: ${name}`)); | ||
return new FluentNone(name); | ||
} | ||
@@ -226,21 +193,20 @@ | ||
if (!term) { | ||
const err = new ReferenceError(`Unknown term: ${id}`); | ||
scope.errors.push(err); | ||
scope.reportError(new ReferenceError(`Unknown term: ${id}`)); | ||
return new FluentNone(id); | ||
} | ||
// Every TermReference has its own args. | ||
const [, keyargs] = getArguments(scope, args); | ||
const local = {...scope, args: keyargs, insideTermReference: true}; | ||
// Every TermReference has its own variables. | ||
const [, params] = getArguments(scope, args); | ||
const local = scope.cloneForTermReference(params); | ||
if (attr) { | ||
const attribute = term.attrs && term.attrs[attr]; | ||
const attribute = term.attributes[attr]; | ||
if (attribute) { | ||
return Type(local, attribute); | ||
return resolvePattern(local, attribute); | ||
} | ||
scope.errors.push(new ReferenceError(`Unknown attribute: ${attr}`)); | ||
scope.reportError(new ReferenceError(`Unknown attribute: ${attr}`)); | ||
return new FluentNone(`${id}.${attr}`); | ||
} | ||
return Type(local, term); | ||
return resolvePattern(local, term.value); | ||
} | ||
@@ -254,3 +220,3 @@ | ||
if (!func) { | ||
scope.errors.push(new ReferenceError(`Unknown function: ${name}()`)); | ||
scope.reportError(new ReferenceError(`Unknown function: ${name}()`)); | ||
return new FluentNone(`${name}()`); | ||
@@ -260,3 +226,3 @@ } | ||
if (typeof func !== "function") { | ||
scope.errors.push(new TypeError(`Function ${name}() is not callable`)); | ||
scope.reportError(new TypeError(`Function ${name}() is not callable`)); | ||
return new FluentNone(`${name}()`); | ||
@@ -267,4 +233,4 @@ } | ||
return func(...getArguments(scope, args)); | ||
} catch (e) { | ||
// XXX Report errors. | ||
} catch (err) { | ||
scope.reportError(err); | ||
return new FluentNone(`${name}()`); | ||
@@ -276,6 +242,5 @@ } | ||
function SelectExpression(scope, {selector, variants, star}) { | ||
let sel = Type(scope, selector); | ||
let sel = resolveExpression(scope, selector); | ||
if (sel instanceof FluentNone) { | ||
const variant = getDefault(scope, variants, star); | ||
return Type(scope, variant); | ||
return getDefault(scope, variants, star); | ||
} | ||
@@ -285,16 +250,15 @@ | ||
for (const variant of variants) { | ||
const key = Type(scope, variant.key); | ||
if (match(scope.bundle, sel, key)) { | ||
return Type(scope, variant); | ||
const key = resolveExpression(scope, variant.key); | ||
if (match(scope, sel, key)) { | ||
return resolvePattern(scope, variant.value); | ||
} | ||
} | ||
const variant = getDefault(scope, variants, star); | ||
return Type(scope, variant); | ||
return getDefault(scope, variants, star); | ||
} | ||
// Resolve a pattern (a complex string with placeables). | ||
function Pattern(scope, ptn) { | ||
export function resolveComplexPattern(scope, ptn) { | ||
if (scope.dirty.has(ptn)) { | ||
scope.errors.push(new RangeError("Cyclic reference")); | ||
scope.reportError(new RangeError("Cyclic reference")); | ||
return new FluentNone(); | ||
@@ -317,3 +281,3 @@ } | ||
const part = Type(scope, elem).toString(scope.bundle); | ||
const part = resolveExpression(scope, elem).toString(scope); | ||
@@ -325,13 +289,15 @@ if (useIsolating) { | ||
if (part.length > MAX_PLACEABLE_LENGTH) { | ||
scope.errors.push( | ||
new RangeError( | ||
"Too many characters in placeable " + | ||
`(${part.length}, max allowed is ${MAX_PLACEABLE_LENGTH})` | ||
) | ||
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 characters in placeable " + | ||
`(${part.length}, max allowed is ${MAX_PLACEABLE_LENGTH})` | ||
); | ||
result.push(part.slice(MAX_PLACEABLE_LENGTH)); | ||
} else { | ||
result.push(part); | ||
} | ||
result.push(part); | ||
if (useIsolating) { | ||
@@ -346,24 +312,11 @@ result.push(PDI); | ||
/** | ||
* 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} | ||
*/ | ||
export default function resolve(bundle, args, message, errors = []) { | ||
const scope = { | ||
bundle, args, errors, dirty: new WeakSet(), | ||
// TermReferences are resolved in a new scope. | ||
insideTermReference: false, | ||
}; | ||
return Type(scope, message).toString(bundle); | ||
// Resolve a simple or a complex Pattern to a FluentString (which is really the | ||
// string primitive). | ||
function resolvePattern(scope, node) { | ||
// Resolve a simple pattern. | ||
if (typeof node === "string") { | ||
return scope.bundle._transform(node); | ||
} | ||
return resolveComplexPattern(scope, node); | ||
} |
@@ -56,12 +56,13 @@ import FluentError from "./error.js"; | ||
/** | ||
* Fluent Resource is a structure storing a map of parsed localization entries. | ||
* Fluent Resource is a structure storing parsed localization entries. | ||
*/ | ||
export default class FluentResource extends Map { | ||
/** | ||
* Create a new FluentResource from Fluent code. | ||
*/ | ||
static fromString(source) { | ||
export default class FluentResource { | ||
constructor(source) { | ||
this.body = this._parse(source); | ||
} | ||
_parse(source) { | ||
RE_MESSAGE_START.lastIndex = 0; | ||
let resource = new this(); | ||
let resource = []; | ||
let cursor = 0; | ||
@@ -79,3 +80,3 @@ | ||
try { | ||
resource.set(next[1], parseMessage()); | ||
resource.push(parseMessage(next[1])); | ||
} catch (err) { | ||
@@ -93,3 +94,4 @@ if (err instanceof FluentError) { | ||
// The parser implementation is inlined below for performance reasons. | ||
// The parser implementation is inlined below for performance reasons, | ||
// as well as for convenience of accessing `source` and `cursor`. | ||
@@ -156,18 +158,15 @@ // The parser focuses on minimizing the number of false negatives at the | ||
function parseMessage() { | ||
function parseMessage(id) { | ||
let value = parsePattern(); | ||
let attrs = parseAttributes(); | ||
let attributes = parseAttributes(); | ||
if (attrs === null) { | ||
if (value === null) { | ||
throw new FluentError("Expected message value or attributes"); | ||
} | ||
return value; | ||
if (value === null && Object.keys(attributes).length === 0) { | ||
throw new FluentError("Expected message value or attributes"); | ||
} | ||
return {value, attrs}; | ||
return {id, value, attributes}; | ||
} | ||
function parseAttributes() { | ||
let attrs = {}; | ||
let attrs = Object.create(null); | ||
@@ -183,3 +182,3 @@ while (test(RE_ATTRIBUTE_START)) { | ||
return Object.keys(attrs).length > 0 ? attrs : null; | ||
return attrs; | ||
} | ||
@@ -266,5 +265,2 @@ | ||
element = element.value.slice(0, element.value.length - commonIndent); | ||
} else if (element.type === "str") { | ||
// Optimize StringLiterals into their value. | ||
element = element.value; | ||
} | ||
@@ -399,3 +395,3 @@ if (element) { | ||
? parseNumberLiteral() | ||
: match1(RE_IDENTIFIER); | ||
: {type: "str", value: match1(RE_IDENTIFIER)}; | ||
consumeToken(TOKEN_BRACKET_CLOSE, FluentError); | ||
@@ -402,0 +398,0 @@ return key; |
@@ -12,11 +12,10 @@ /* global Intl */ | ||
/** | ||
* Create an `FluentType` instance. | ||
* Create a `FluentType` instance. | ||
* | ||
* @param {Any} value - JavaScript value to wrap. | ||
* @param {Object} opts - Configuration. | ||
* @param {Any} value - JavaScript value to wrap. | ||
* @returns {FluentType} | ||
*/ | ||
constructor(value, opts) { | ||
constructor(value) { | ||
/** The wrapped native value. */ | ||
this.value = value; | ||
this.opts = opts; | ||
} | ||
@@ -37,9 +36,10 @@ | ||
* 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. | ||
* This method can use `Intl` formatters available through the `scope` | ||
* argument. | ||
* | ||
* @param {FluentBundle} [bundle] | ||
* @abstract | ||
* @param {Scope} scope | ||
* @returns {string} | ||
*/ | ||
toString() { | ||
toString(scope) { // eslint-disable-line no-unused-vars | ||
throw new Error("Subclasses of FluentType must implement toString."); | ||
@@ -49,26 +49,53 @@ } | ||
/** | ||
* A `FluentType` representing no correct value. | ||
*/ | ||
export class FluentNone extends FluentType { | ||
valueOf() { | ||
return null; | ||
/** | ||
* Create an instance of `FluentNone` with an optional fallback value. | ||
* @param {string} value - The fallback value of this `FluentNone`. | ||
* @returns {FluentType} | ||
*/ | ||
constructor(value = "???") { | ||
super(value); | ||
} | ||
/** | ||
* Format this `FluentNone` to the fallback string. | ||
* @returns {string} | ||
*/ | ||
toString() { | ||
return `{${this.value || "???"}}`; | ||
return `{${this.value}}`; | ||
} | ||
} | ||
/** | ||
* A `FluentType` representing a number. | ||
*/ | ||
export class FluentNumber extends FluentType { | ||
/** | ||
* Create an instance of `FluentNumber` with options to the | ||
* `Intl.NumberFormat` constructor. | ||
* @param {number} value | ||
* @param {Intl.NumberFormatOptions} opts | ||
* @returns {FluentType} | ||
*/ | ||
constructor(value, opts) { | ||
super(parseFloat(value), opts); | ||
super(value); | ||
/** Options passed to Intl.NumberFormat. */ | ||
this.opts = opts; | ||
} | ||
toString(bundle) { | ||
/** | ||
* Format this `FluentNumber` to a string. | ||
* @param {Scope} scope | ||
* @returns {string} | ||
*/ | ||
toString(scope) { | ||
try { | ||
const nf = bundle._memoizeIntlObject( | ||
Intl.NumberFormat, this.opts | ||
); | ||
const nf = scope.memoizeIntlObject(Intl.NumberFormat, this.opts); | ||
return nf.format(this.value); | ||
} catch (e) { | ||
// XXX Report the error. | ||
return this.value; | ||
} catch (err) { | ||
scope.reportError(err); | ||
return this.value.toString(10); | ||
} | ||
@@ -78,18 +105,33 @@ } | ||
/** | ||
* A `FluentType` representing a date and time. | ||
*/ | ||
export class FluentDateTime extends FluentType { | ||
/** | ||
* Create an instance of `FluentDateTime` with options to the | ||
* `Intl.DateTimeFormat` constructor. | ||
* @param {number} value - timestamp in milliseconds | ||
* @param {Intl.DateTimeFormatOptions} opts | ||
* @returns {FluentType} | ||
*/ | ||
constructor(value, opts) { | ||
super(new Date(value), opts); | ||
super(value); | ||
/** Options passed to Intl.DateTimeFormat. */ | ||
this.opts = opts; | ||
} | ||
toString(bundle) { | ||
/** | ||
* Format this `FluentDateTime` to a string. | ||
* @param {Scope} scope | ||
* @returns {string} | ||
*/ | ||
toString(scope) { | ||
try { | ||
const dtf = bundle._memoizeIntlObject( | ||
Intl.DateTimeFormat, this.opts | ||
); | ||
const dtf = scope.memoizeIntlObject(Intl.DateTimeFormat, this.opts); | ||
return dtf.format(this.value); | ||
} catch (e) { | ||
// XXX Report the error. | ||
return this.value; | ||
} catch (err) { | ||
scope.reportError(err); | ||
return (new Date(this.value)).toISOString(); | ||
} | ||
} | ||
} |
142459
0.18%14
7.69%83
1.22%3332
-2.43%