diff --git a/README.md b/README.md index 6f9fb92..f5e4b22 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![npm version](https://img.shields.io/npm/v/i18next-fluent.svg?style=flat-square)](https://www.npmjs.com/package/i18next-fluent) [![David](https://img.shields.io/david/i18next/i18next-fluent.svg?style=flat-square)](https://david-dm.org/i18next/i18next-fluent) -This changes i18n format from i18next json to [fluent](https://projectfluent.org) +This changes i18n format from i18next json to [fluent](https://projectfluent.org) Spec version 1.0.0 # Getting started diff --git a/i18nextFluent.js b/i18nextFluent.js index 2da695f..eb4ee4d 100644 --- a/i18nextFluent.js +++ b/i18nextFluent.js @@ -1,8 +1,8 @@ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : - (global.i18nextFluent = factory()); -}(this, (function () { 'use strict'; + (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.i18nextFluent = factory()); +})(this, (function () { 'use strict'; function getLastOfPath(object, path, Empty) { function cleanKey(key) { @@ -58,1746 +58,1055 @@ return obj; } - /* eslint no-magic-numbers: [0] */ - const MAX_PLACEABLES = 100; - const entryIdentifierRe = /-?[a-zA-Z][a-zA-Z0-9_-]*/y; - const identifierRe = /[a-zA-Z][a-zA-Z0-9_-]*/y; - const functionIdentifierRe = /^[A-Z][A-Z_?-]*$/; - const unicodeEscapeRe = /^[a-fA-F0-9]{4}$/; - const trailingWSRe = /[ \t\n\r]+$/; + /* global Intl */ + /** - * The `Parser` class is responsible for parsing FTL resources. - * - * It's only public method is `getResource(source)` which takes an FTL string - * and returns a two element Array with an Object of entries generated from the - * source as the first element and an array of SyntaxError objects as the - * second. - * - * This parser is optimized for runtime performance. + * The `FluentType` class is the base of Fluent's type system. * - * There is an equivalent of this parser in syntax/parser which is - * generating full AST which is useful for FTL tools. + * 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 RuntimeParser { + class FluentType { /** - * Parse FTL code into entries formattable by the FluentBundle. - * - * Given a string of FTL syntax, return a map of entries that can be passed - * to FluentBundle.format and a list of errors encountered during parsing. + * Create an `FluentType` instance. * - * @param {String} string - * @returns {Array} + * @param {Any} value - JavaScript value to wrap. + * @param {Object} opts - Configuration. + * @returns {FluentType} */ - getResource(string) { - this._source = string; - this._index = 0; - this._length = string.length; - this.entries = {}; - const errors = []; - this.skipWS(); - - while (this._index < this._length) { - try { - this.getEntry(); - } catch (e) { - if (e instanceof SyntaxError) { - errors.push(e); - this.skipToNextEntryStart(); - } else { - throw e; - } - } - - this.skipWS(); - } - - return [this.entries, errors]; + constructor(value, opts) { + this.value = value; + this.opts = opts; } /** - * Parse the source string from the current index as an FTL entry - * and add it to object's entries property. + * Unwrap the raw value stored by this `FluentType`. * - * @private + * @returns {Any} */ - getEntry() { - // The index here should either be at the beginning of the file - // or right after new line. - if (this._index !== 0 && this._source[this._index - 1] !== "\n") { - throw this.error(`Expected an entry to start - at the beginning of the file or on a new line.`); - } - - const ch = this._source[this._index]; // We don't care about comments or sections at runtime - - if (ch === "#" && [" ", "#", "\n"].includes(this._source[this._index + 1])) { - this.skipComment(); - return; - } - - this.getMessage(); + valueOf() { + return this.value; } /** - * Parse the source string from the current index as an FTL message - * and add it to the entries property on the Parser. + * 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. * - * @private + * @param {FluentBundle} [bundle] + * @returns {string} */ - getMessage() { - const id = this.getEntryIdentifier(); - this.skipInlineWS(); - - if (this._source[this._index] === "=") { - this._index++; - } else { - throw this.error("Expected \"=\" after the identifier"); - } - - this.skipInlineWS(); - const val = this.getPattern(); - - if (id.startsWith("-") && val === null) { - throw this.error("Expected term to have a value"); - } - - let attrs = null; - - if (this._source[this._index] === " ") { - const lineStart = this._index; - this.skipInlineWS(); + toString() { + throw new Error("Subclasses of FluentType must implement toString."); + } - if (this._source[this._index] === ".") { - this._index = lineStart; - attrs = this.getAttributes(); - } - } + } + class FluentNone extends FluentType { + valueOf() { + return null; + } - if (attrs === null && typeof val === "string") { - this.entries[id] = val; - } else { - if (val === null && attrs === null) { - throw this.error("Expected message to have a value or attributes"); - } + toString() { + return `{${this.value || "???"}}`; + } - this.entries[id] = {}; + } + class FluentNumber extends FluentType { + constructor(value, opts) { + super(parseFloat(value), opts); + } - if (val !== null) { - this.entries[id].val = val; - } + toString(bundle) { + try { + const nf = bundle._memoizeIntlObject(Intl.NumberFormat, this.opts); - if (attrs !== null) { - this.entries[id].attrs = attrs; - } + return nf.format(this.value); + } catch (e) { + // XXX Report the error. + return this.value; } } - /** - * Skip whitespace. - * - * @private - */ + } + class FluentDateTime extends FluentType { + constructor(value, opts) { + super(new Date(value), opts); + } - skipWS() { - let ch = this._source[this._index]; + toString(bundle) { + try { + const dtf = bundle._memoizeIntlObject(Intl.DateTimeFormat, this.opts); - while (ch === " " || ch === "\n" || ch === "\t" || ch === "\r") { - ch = this._source[++this._index]; + return dtf.format(this.value); + } catch (e) { + // XXX Report the error. + return this.value; } } - /** - * Skip inline whitespace (space and \t). - * - * @private - */ + } - skipInlineWS() { - let ch = this._source[this._index]; - - while (ch === " " || ch === "\t") { - ch = this._source[++this._index]; - } - } - /** - * Skip blank lines. - * - * @private - */ + /** + * @overview + * + * The FTL resolver ships with a number of functions built-in. + * + * Each function take two arguments: + * - args - an array of positional args + * - opts - an object of key-value args + * + * Arguments to functions are guaranteed to already be instances of + * `FluentType`. Functions must return `FluentType` objects as well. + */ + function merge(argopts, opts) { + return Object.assign({}, argopts, values(opts)); + } - skipBlankLines() { - while (true) { - const ptr = this._index; - this.skipInlineWS(); + function values(opts) { + const unwrapped = {}; - if (this._source[this._index] === "\n") { - this._index += 1; - } else { - this._index = ptr; - break; - } - } + for (const [name, opt] of Object.entries(opts)) { + unwrapped[name] = opt.valueOf(); } - /** - * Get identifier using the provided regex. - * - * By default this will get identifiers of public messages, attributes and - * variables (without the $). - * - * @returns {String} - * @private - */ - - getIdentifier(re = identifierRe) { - re.lastIndex = this._index; - const result = re.exec(this._source); + return unwrapped; + } - if (result === null) { - this._index += 1; - throw this.error(`Expected an identifier [${re.toString()}]`); - } + function NUMBER([arg], opts) { + if (arg instanceof FluentNone) { + return arg; + } - this._index = re.lastIndex; - return result[0]; + if (arg instanceof FluentNumber) { + return new FluentNumber(arg.valueOf(), merge(arg.opts, opts)); } - /** - * Get identifier of a Message or a Term (staring with a dash). - * - * @returns {String} - * @private - */ + return new FluentNone("NUMBER()"); + } + function DATETIME([arg], opts) { + if (arg instanceof FluentNone) { + return arg; + } - getEntryIdentifier() { - return this.getIdentifier(entryIdentifierRe); + if (arg instanceof FluentDateTime) { + return new FluentDateTime(arg.valueOf(), merge(arg.opts, opts)); } - /** - * Get Variant name. - * - * @returns {Object} - * @private - */ + return new FluentNone("DATETIME()"); + } - getVariantName() { - let name = ""; - const start = this._index; + var builtins = /*#__PURE__*/Object.freeze({ + __proto__: null, + NUMBER: NUMBER, + DATETIME: DATETIME + }); - let cc = this._source.charCodeAt(this._index); + /* global Intl */ - if (cc >= 97 && cc <= 122 || // a-z - cc >= 65 && cc <= 90 || // A-Z - cc === 95 || cc === 32) { - // _ - cc = this._source.charCodeAt(++this._index); - } else { - throw this.error("Expected a keyword (starting with [a-zA-Z_])"); - } + const MAX_PLACEABLE_LENGTH = 2500; // Unicode bidi isolation characters. - while (cc >= 97 && cc <= 122 || // a-z - cc >= 65 && cc <= 90 || // A-Z - cc >= 48 && cc <= 57 || // 0-9 - cc === 95 || cc === 45 || cc === 32) { - // _- - cc = this._source.charCodeAt(++this._index); - } // If we encountered the end of name, we want to test if the last - // collected character is a space. - // If it is, we will backtrack to the last non-space character because - // the keyword cannot end with a space character. + const FSI = "\u2068"; + const PDI = "\u2069"; // Helper: match a variant key to the given selector. + function match(bundle, selector, key) { + if (key === selector) { + // Both are strings. + return true; + } // XXX Consider comparing options too, e.g. minimumFractionDigits. - while (this._source.charCodeAt(this._index - 1) === 32) { - this._index--; - } - name += this._source.slice(start, this._index); - return { - type: "varname", - name - }; + if (key instanceof FluentNumber && selector instanceof FluentNumber && key.value === selector.value) { + return true; } - /** - * Get simple string argument enclosed in `"`. - * - * @returns {String} - * @private - */ - - - getString() { - let value = ""; - this._index++; - - while (this._index < this._length) { - const ch = this._source[this._index]; - - if (ch === '"') { - this._index++; - break; - } - if (ch === "\n") { - throw this.error("Unterminated string expression"); - } + if (selector instanceof FluentNumber && typeof key === "string") { + let category = bundle._memoizeIntlObject(Intl.PluralRules, selector.opts).select(selector.value); - if (ch === "\\") { - value += this.getEscapedCharacter(["{", "\\", "\""]); - } else { - this._index++; - value += ch; - } + if (key === category) { + return true; } - - return value; } - /** - * Parses a Message pattern. - * Message Pattern may be a simple string or an array of strings - * and placeable expressions. - * - * @returns {String|Array} - * @private - */ + return false; + } // Helper: resolve the default variant from a list of variants. - getPattern() { - // We're going to first try to see if the pattern is simple. - // If it is we can just look for the end of the line and read the string. - // - // Then, if either the line contains a placeable opening `{` or the - // next line starts an indentation, we switch to complex pattern. - const start = this._index; - let eol = this._source.indexOf("\n", this._index); + function getDefault(scope, variants, star) { + if (variants[star]) { + return Type(scope, variants[star]); + } - if (eol === -1) { - eol = this._length; - } // If there's any text between the = and the EOL, store it for now. The next - // non-empty line will decide what to do with it. + scope.errors.push(new RangeError("No default")); + return new FluentNone(); + } // Helper: resolve arguments to a call expression. - const firstLineContent = start !== eol // Trim the trailing whitespace in case this is a single-line pattern. - // Multiline patterns are parsed anew by getComplexPattern. - ? this._source.slice(start, eol).replace(trailingWSRe, "") : null; + function getArguments(scope, args) { + const positional = []; + const named = {}; - if (firstLineContent && (firstLineContent.includes("{") || firstLineContent.includes("\\"))) { - return this.getComplexPattern(); + for (const arg of args) { + if (arg.type === "narg") { + named[arg.name] = Type(scope, arg.value); + } else { + positional.push(Type(scope, arg)); } + } - this._index = eol + 1; - this.skipBlankLines(); - - if (this._source[this._index] !== " ") { - // No indentation means we're done with this message. Callers should check - // if the return value here is null. It may be OK for messages, but not OK - // for terms, attributes and variants. - return firstLineContent; - } + return [positional, named]; + } // Resolve an expression to a Fluent type. - const lineStart = this._index; - this.skipInlineWS(); - if (this._source[this._index] === ".") { - // The pattern is followed by an attribute. Rewind _index to the first - // column of the current line as expected by getAttributes. - this._index = lineStart; - return firstLineContent; - } + 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 (firstLineContent) { - // It's a multiline pattern which started on the same line as the - // identifier. Reparse the whole pattern to make sure we get all of it. - this._index = start; - } - return this.getComplexPattern(); - } - /** - * Parses a complex Message pattern. - * This function is called by getPattern when the message is multiline, - * or contains escape chars or placeables. - * It does full parsing of complex patterns. - * - * @returns {Array} - * @private - */ + if (expr instanceof FluentNone) { + return expr; + } // The Runtime AST (Entries) encodes patterns (complex strings with + // placeables) as Arrays. - /* eslint-disable complexity */ + if (Array.isArray(expr)) { + return Pattern(scope, expr); + } - getComplexPattern() { - let buffer = ""; - const content = []; - let placeables = 0; - let ch = this._source[this._index]; + switch (expr.type) { + case "str": + return expr.value; - while (this._index < this._length) { - // This block handles multi-line strings combining strings separated - // by new line. - if (ch === "\n") { - this._index++; // We want to capture the start and end pointers - // around blank lines and add them to the buffer - // but only if the blank lines are in the middle - // of the string. + case "num": + return new FluentNumber(expr.value, { + minimumFractionDigits: expr.precision + }); - const blankLinesStart = this._index; - this.skipBlankLines(); - const blankLinesEnd = this._index; + case "var": + return VariableReference(scope, expr); - if (this._source[this._index] !== " ") { - break; - } + case "mesg": + return MessageReference(scope, expr); - this.skipInlineWS(); + case "term": + return TermReference(scope, expr); - if (this._source[this._index] === "}" || this._source[this._index] === "[" || this._source[this._index] === "*" || this._source[this._index] === ".") { - this._index = blankLinesEnd; - break; - } + case "func": + return FunctionReference(scope, expr); - buffer += this._source.substring(blankLinesStart, blankLinesEnd); + case "select": + return SelectExpression(scope, expr); - if (buffer.length || content.length) { - buffer += "\n"; + 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); } - ch = this._source[this._index]; - continue; + scope.errors.push(new RangeError("No value")); + return new FluentNone(); } - if (ch === undefined) { - break; - } + default: + return new FluentNone(); + } + } // Resolve a reference to a variable. - if (ch === "\\") { - buffer += this.getEscapedCharacter(); - ch = this._source[this._index]; - continue; - } - if (ch === "{") { - // Push the buffer to content array right before placeable - if (buffer.length) { - content.push(buffer); - } + function VariableReference(scope, { + name + }) { + if (!scope.args || !scope.args.hasOwnProperty(name)) { + if (scope.insideTermReference === false) { + scope.errors.push(new ReferenceError(`Unknown variable: ${name}`)); + } - if (placeables > MAX_PLACEABLES - 1) { - throw this.error(`Too many placeables, maximum allowed is ${MAX_PLACEABLES}`); - } + return new FluentNone(`$${name}`); + } - buffer = ""; - content.push(this.getPlaceable()); - ch = this._source[++this._index]; - placeables++; - continue; - } + const arg = scope.args[name]; // Return early if the argument already is an instance of FluentType. - buffer += ch; - ch = this._source[++this._index]; - } + if (arg instanceof FluentType) { + return arg; + } // Convert the argument to a Fluent type. - if (content.length === 0) { - return buffer.length ? buffer : null; - } - if (buffer.length) { - // Trim trailing whitespace, too. - content.push(buffer.replace(trailingWSRe, "")); - } + switch (typeof arg) { + case "string": + return arg; - return content; - } - /* eslint-enable complexity */ + case "number": + return new FluentNumber(arg); - /** - * Parse an escape sequence and return the unescaped character. - * - * @returns {string} - * @private - */ + case "object": + if (arg instanceof Date) { + return new FluentDateTime(arg); + } + default: + scope.errors.push(new TypeError(`Unsupported variable type: ${name}, ${typeof arg}`)); + return new FluentNone(`$${name}`); + } + } // Resolve a reference to another message. - getEscapedCharacter(specials = ["{", "\\"]) { - this._index++; - const next = this._source[this._index]; - if (specials.includes(next)) { - this._index++; - return next; - } + function MessageReference(scope, { + name, + attr + }) { + const message = scope.bundle._messages.get(name); - if (next === "u") { - const sequence = this._source.slice(this._index + 1, this._index + 5); + if (!message) { + const err = new ReferenceError(`Unknown message: ${name}`); + scope.errors.push(err); + return new FluentNone(name); + } - if (unicodeEscapeRe.test(sequence)) { - this._index += 5; - return String.fromCodePoint(parseInt(sequence, 16)); - } + if (attr) { + const attribute = message.attrs && message.attrs[attr]; - throw this.error(`Invalid Unicode escape sequence: \\u${sequence}`); + if (attribute) { + return Type(scope, attribute); } - throw this.error(`Unknown escape sequence: \\${next}`); + scope.errors.push(new ReferenceError(`Unknown attribute: ${attr}`)); + return new FluentNone(`${name}.${attr}`); } - /** - * Parses a single placeable in a Message pattern and returns its - * expression. - * - * @returns {Object} - * @private - */ + return Type(scope, message); + } // Resolve a call to a Term with key-value arguments. - getPlaceable() { - const start = ++this._index; - this.skipWS(); - - if (this._source[this._index] === "*" || this._source[this._index] === "[" && this._source[this._index + 1] !== "]") { - const variants = this.getVariants(); - return { - type: "sel", - exp: null, - vars: variants[0], - def: variants[1] - }; - } // Rewind the index and only support in-line white-space now. + function TermReference(scope, { + name, + attr, + args + }) { + const id = `-${name}`; - this._index = start; - this.skipInlineWS(); - const selector = this.getSelectorExpression(); - this.skipWS(); - const ch = this._source[this._index]; + const term = scope.bundle._terms.get(id); - if (ch === "}") { - if (selector.type === "getattr" && selector.id.name.startsWith("-")) { - throw this.error("Attributes of private messages cannot be interpolated."); - } + if (!term) { + const err = new ReferenceError(`Unknown term: ${id}`); + scope.errors.push(err); + return new FluentNone(id); + } // Every TermReference has its own args. - return selector; - } - if (ch !== "-" || this._source[this._index + 1] !== ">") { - throw this.error('Expected "}" or "->"'); - } + const [, keyargs] = getArguments(scope, args); + const local = { ...scope, + args: keyargs, + insideTermReference: true + }; - if (selector.type === "ref") { - throw this.error("Message references cannot be used as selectors."); - } + if (attr) { + const attribute = term.attrs && term.attrs[attr]; - if (selector.type === "getvar") { - throw this.error("Variants cannot be used as selectors."); + if (attribute) { + return Type(local, attribute); } - if (selector.type === "getattr" && !selector.id.name.startsWith("-")) { - throw this.error("Attributes of public messages cannot be used as selectors."); - } + scope.errors.push(new ReferenceError(`Unknown attribute: ${attr}`)); + return new FluentNone(`${id}.${attr}`); + } - this._index += 2; // -> + return Type(local, term); + } // Resolve a call to a Function with positional and key-value arguments. - this.skipInlineWS(); - if (this._source[this._index] !== "\n") { - throw this.error("Variants should be listed in a new line"); - } + function FunctionReference(scope, { + name, + args + }) { + // Some functions are built-in. Others may be provided by the runtime via + // the `FluentBundle` constructor. + const func = scope.bundle._functions[name] || builtins[name]; - this.skipWS(); - const variants = this.getVariants(); + if (!func) { + scope.errors.push(new ReferenceError(`Unknown function: ${name}()`)); + return new FluentNone(`${name}()`); + } - if (variants[0].length === 0) { - throw this.error("Expected members for the select expression"); - } + if (typeof func !== "function") { + scope.errors.push(new TypeError(`Function ${name}() is not callable`)); + return new FluentNone(`${name}()`); + } - return { - type: "sel", - exp: selector, - vars: variants[0], - def: variants[1] - }; + try { + return func(...getArguments(scope, args)); + } catch (e) { + // XXX Report errors. + return new FluentNone(`${name}()`); } - /** - * Parses a selector expression. - * - * @returns {Object} - * @private - */ + } // Resolve a select expression to the member object. - getSelectorExpression() { - if (this._source[this._index] === "{") { - return this.getPlaceable(); - } + function SelectExpression(scope, { + selector, + variants, + star + }) { + let sel = Type(scope, selector); - const literal = this.getLiteral(); + if (sel instanceof FluentNone) { + const variant = getDefault(scope, variants, star); + return Type(scope, variant); + } // Match the selector against keys of each variant, in order. - if (literal.type !== "ref") { - return literal; - } - if (this._source[this._index] === ".") { - this._index++; - const name = this.getIdentifier(); - this._index++; - return { - type: "getattr", - id: literal, - name - }; - } + for (const variant of variants) { + const key = Type(scope, variant.key); - if (this._source[this._index] === "[") { - this._index++; - const key = this.getVariantKey(); - this._index++; - return { - type: "getvar", - id: literal, - key - }; + if (match(scope.bundle, sel, key)) { + return Type(scope, variant); } + } - if (this._source[this._index] === "(") { - this._index++; - const args = this.getCallArgs(); - - if (!functionIdentifierRe.test(literal.name)) { - throw this.error("Function names must be all upper-case"); - } + const variant = getDefault(scope, variants, star); + return Type(scope, variant); + } // Resolve a pattern (a complex string with placeables). - this._index++; - literal.type = "fun"; - return { - type: "call", - fun: literal, - args - }; - } - return literal; - } - /** - * Parses call arguments for a CallExpression. - * - * @returns {Array} - * @private - */ + function Pattern(scope, ptn) { + if (scope.dirty.has(ptn)) { + scope.errors.push(new RangeError("Cyclic reference")); + return new FluentNone(); + } // Tag the pattern as dirty for the purpose of the current resolution. - getCallArgs() { - const args = []; + scope.dirty.add(ptn); + const result = []; // Wrap interpolations with Directional Isolate Formatting characters + // only when the pattern has more than one element. - while (this._index < this._length) { - this.skipWS(); + const useIsolating = scope.bundle._useIsolating && ptn.length > 1; - if (this._source[this._index] === ")") { - return args; - } + for (const elem of ptn) { + if (typeof elem === "string") { + result.push(scope.bundle._transform(elem)); + continue; + } - const exp = this.getSelectorExpression(); // MessageReference in this place may be an entity reference, like: - // `call(foo)`, or, if it's followed by `:` it will be a key-value pair. + const part = Type(scope, elem).toString(scope.bundle); - if (exp.type !== "ref") { - args.push(exp); - } else { - this.skipInlineWS(); - - if (this._source[this._index] === ":") { - this._index++; - this.skipWS(); - const val = this.getSelectorExpression(); // If the expression returned as a value of the argument - // is not a quote delimited string or number, throw. - // - // We don't have to check here if the pattern is quote delimited - // because that's the only type of string allowed in expressions. - - if (typeof val === "string" || Array.isArray(val) || val.type === "num") { - args.push({ - type: "narg", - name: exp.name, - val - }); - } else { - this._index = this._source.lastIndexOf(":", this._index) + 1; - throw this.error("Expected string in quotes, number."); - } - } else { - args.push(exp); - } - } - - this.skipWS(); + if (useIsolating) { + result.push(FSI); + } - if (this._source[this._index] === ")") { - break; - } else if (this._source[this._index] === ",") { - this._index++; - } else { - throw this.error('Expected "," or ")"'); - } + if (part.length > MAX_PLACEABLE_LENGTH) { + scope.errors.push(new RangeError("Too many characters in placeable " + `(${part.length}, max allowed is ${MAX_PLACEABLE_LENGTH})`)); + result.push(part.slice(MAX_PLACEABLE_LENGTH)); + } else { + result.push(part); } - return args; + if (useIsolating) { + result.push(PDI); + } } - /** - * Parses an FTL Number. - * - * @returns {Object} - * @private - */ + scope.dirty.delete(ptn); + return result.join(""); + } + /** + * Format a translation into a string. + * + * @param {FluentBundle} bundle + * A FluentBundle instance which will be used to resolve the + * contextual information of the message. + * @param {Object} args + * List of arguments provided by the developer which can be accessed + * from the message. + * @param {Object} message + * An object with the Message to be resolved. + * @param {Array} errors + * An error array that any encountered errors will be appended to. + * @returns {FluentType} + */ - getNumber() { - let num = ""; - let cc = this._source.charCodeAt(this._index); // The number literal may start with negative sign `-`. + 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 FluentError extends Error {} - if (cc === 45) { - num += "-"; - cc = this._source.charCodeAt(++this._index); - } // next, we expect at least one digit + // With the /m flag, the ^ matches at the beginning of every line. + const RE_MESSAGE_START = /^(-?[a-zA-Z][\w-]*) *= */mg; // Both Attributes and Variants are parsed in while loops. These regexes are + // used to break out of them. - if (cc < 48 || cc > 57) { - throw this.error(`Unknown literal "${num}"`); - } // followed by potentially more digits + const RE_ATTRIBUTE_START = /\.([a-zA-Z][\w-]*) *= */y; + const RE_VARIANT_START = /\*?\[/y; + const RE_NUMBER_LITERAL = /(-?[0-9]+(?:\.([0-9]+))?)/y; + const RE_IDENTIFIER = /([a-zA-Z][\w-]*)/y; + const RE_REFERENCE = /([$-])?([a-zA-Z][\w-]*)(?:\.([a-zA-Z][\w-]*))?/y; + const RE_FUNCTION_NAME = /^[A-Z][A-Z0-9_-]*$/; // A "run" is a sequence of text or string literal characters which don't + // require any special handling. For TextElements such special characters are: { + // (starts a placeable), and line breaks which require additional logic to check + // if the next line is indented. For StringLiterals they are: \ (starts an + // escape sequence), " (ends the literal), and line breaks which are not allowed + // in StringLiterals. Note that string runs may be empty; text runs may not. + const RE_TEXT_RUN = /([^{}\n\r]+)/y; + const RE_STRING_RUN = /([^\\"\n\r]*)/y; // Escape sequences. - while (cc >= 48 && cc <= 57) { - num += this._source[this._index++]; - cc = this._source.charCodeAt(this._index); - } // followed by an optional decimal separator `.` + const RE_STRING_ESCAPE = /\\([\\"])/y; + const RE_UNICODE_ESCAPE = /\\u([a-fA-F0-9]{4})|\\U([a-fA-F0-9]{6})/y; // Used for trimming TextElements and indents. + const RE_LEADING_NEWLINES = /^\n+/; + const RE_TRAILING_SPACES = / +$/; // Used in makeIndent to strip spaces from blank lines and normalize CRLF to LF. - if (cc === 46) { - num += this._source[this._index++]; - cc = this._source.charCodeAt(this._index); // followed by at least one digit + const RE_BLANK_LINES = / *\r?\n/g; // Used in makeIndent to measure the indentation. - if (cc < 48 || cc > 57) { - throw this.error(`Unknown literal "${num}"`); - } // and optionally more digits + const RE_INDENT = /( *)$/; // Common tokens. + const TOKEN_BRACE_OPEN = /{\s*/y; + const TOKEN_BRACE_CLOSE = /\s*}/y; + const TOKEN_BRACKET_OPEN = /\[\s*/y; + const TOKEN_BRACKET_CLOSE = /\s*] */y; + const TOKEN_PAREN_OPEN = /\s*\(\s*/y; + const TOKEN_ARROW = /\s*->\s*/y; + const TOKEN_COLON = /\s*:\s*/y; // Note the optional comma. As a deviation from the Fluent EBNF, the parser + // doesn't enforce commas between call arguments. - while (cc >= 48 && cc <= 57) { - num += this._source[this._index++]; - cc = this._source.charCodeAt(this._index); - } - } + const TOKEN_COMMA = /\s*,?\s*/y; + const TOKEN_BLANK = /\s+/y; // Maximum number of placeables in a single Pattern to protect against Quadratic + // Blowup attacks. See https://msdn.microsoft.com/en-us/magazine/ee335713.aspx. - return { - type: "num", - val: num - }; - } + const MAX_PLACEABLES = 100; + /** + * Fluent Resource is a structure storing a map of parsed localization entries. + */ + + class FluentResource extends Map { /** - * Parses a list of Message attributes. - * - * @returns {Object} - * @private + * Create a new FluentResource from Fluent code. */ + static fromString(source) { + RE_MESSAGE_START.lastIndex = 0; + let resource = new this(); + let cursor = 0; // Iterate over the beginnings of messages and terms to efficiently skip + // comments and recover from errors. + while (true) { + let next = RE_MESSAGE_START.exec(source); - getAttributes() { - const attrs = {}; - - while (this._index < this._length) { - if (this._source[this._index] !== " ") { + if (next === null) { break; } - this.skipInlineWS(); - - if (this._source[this._index] !== ".") { - break; - } + cursor = RE_MESSAGE_START.lastIndex; - this._index++; - const key = this.getIdentifier(); - this.skipInlineWS(); + try { + resource.set(next[1], parseMessage()); + } catch (err) { + if (err instanceof FluentError) { + // Don't report any Fluent syntax errors. Skip directly to the + // beginning of the next message or term. + continue; + } - if (this._source[this._index] !== "=") { - throw this.error('Expected "="'); + throw err; } + } - this._index++; - this.skipInlineWS(); - const val = this.getPattern(); - - if (val === null) { - throw this.error("Expected attribute to have a value"); + return resource; // The parser implementation is inlined below for performance reasons. + // The parser focuses on minimizing the number of false negatives at the + // expense of increasing the risk of false positives. In other words, it + // aims at parsing valid Fluent messages with a success rate of 100%, but it + // may also parse a few invalid messages which the reference parser would + // reject. The parser doesn't perform any validation and may produce entries + // which wouldn't make sense in the real world. For best results users are + // advised to validate translations with the fluent-syntax parser + // pre-runtime. + // The parser makes an extensive use of sticky regexes which can be anchored + // to any offset of the source string without slicing it. Errors are thrown + // to bail out of parsing of ill-formed messages. + + function test(re) { + re.lastIndex = cursor; + return re.test(source); + } // Advance the cursor by the char if it matches. May be used as a predicate + // (was the match found?) or, if errorClass is passed, as an assertion. + + + function consumeChar(char, errorClass) { + if (source[cursor] === char) { + cursor++; + return true; } - if (typeof val === "string") { - attrs[key] = val; - } else { - attrs[key] = { - val - }; + if (errorClass) { + throw new errorClass(`Expected ${char}`); } - this.skipBlankLines(); - } - - return attrs; - } - /** - * Parses a list of Selector variants. - * - * @returns {Array} - * @private - */ - + return false; + } // Advance the cursor by the token if it matches. May be used as a predicate + // (was the match found?) or, if errorClass is passed, as an assertion. - getVariants() { - const variants = []; - let index = 0; - let defaultIndex; - while (this._index < this._length) { - const ch = this._source[this._index]; - - if ((ch !== "[" || this._source[this._index + 1] === "[") && ch !== "*") { - break; - } - - if (ch === "*") { - this._index++; - defaultIndex = index; + function consumeToken(re, errorClass) { + if (test(re)) { + cursor = re.lastIndex; + return true; } - if (this._source[this._index] !== "[") { - throw this.error('Expected "["'); + if (errorClass) { + throw new errorClass(`Expected ${re.toString()}`); } - this._index++; - const key = this.getVariantKey(); - this.skipInlineWS(); - const val = this.getPattern(); - - if (val === null) { - throw this.error("Expected variant to have a value"); - } + return false; + } // Execute a regex, advance the cursor, and return all capture groups. - variants[index++] = { - key, - val - }; - this.skipWS(); - } - return [variants, defaultIndex]; - } - /** - * Parses a Variant key. - * - * @returns {String} - * @private - */ + function match(re) { + re.lastIndex = cursor; + let result = re.exec(source); + if (result === null) { + throw new FluentError(`Expected ${re.toString()}`); + } - getVariantKey() { - // VariantKey may be a Keyword or Number - const cc = this._source.charCodeAt(this._index); + cursor = re.lastIndex; + return result; + } // Execute a regex, advance the cursor, and return the capture group. - let literal; - if (cc >= 48 && cc <= 57 || cc === 45) { - literal = this.getNumber(); - } else { - literal = this.getVariantName(); + function match1(re) { + return match(re)[1]; } - if (this._source[this._index] !== "]") { - throw this.error('Expected "]"'); - } + function parseMessage() { + let value = parsePattern(); + let attrs = parseAttributes(); - this._index++; - return literal; - } - /** - * Parses an FTL literal. - * - * @returns {Object} - * @private - */ - - - getLiteral() { - const cc0 = this._source.charCodeAt(this._index); - - if (cc0 === 36) { - // $ - this._index++; - return { - type: "var", - name: this.getIdentifier() - }; - } + if (attrs === null) { + if (value === null) { + throw new FluentError("Expected message value or attributes"); + } - const cc1 = cc0 === 45 // - - // Peek at the next character after the dash. - ? this._source.charCodeAt(this._index + 1) // Or keep using the character at the current index. - : cc0; + return value; + } - if (cc1 >= 97 && cc1 <= 122 || // a-z - cc1 >= 65 && cc1 <= 90) { - // A-Z return { - type: "ref", - name: this.getEntryIdentifier() + value, + attrs }; } - if (cc1 >= 48 && cc1 <= 57) { - // 0-9 - return this.getNumber(); - } - - if (cc0 === 34) { - // " - return this.getString(); - } - - throw this.error("Expected literal"); - } - /** - * Skips an FTL comment. - * - * @private - */ - - - skipComment() { - // At runtime, we don't care about comments so we just have - // to parse them properly and skip their content. - let eol = this._source.indexOf("\n", this._index); - - while (eol !== -1 && this._source[eol + 1] === "#" && [" ", "#"].includes(this._source[eol + 2])) { - this._index = eol + 3; - eol = this._source.indexOf("\n", this._index); + function parseAttributes() { + let attrs = {}; - if (eol === -1) { - break; - } - } + while (test(RE_ATTRIBUTE_START)) { + let name = match1(RE_ATTRIBUTE_START); + let value = parsePattern(); - if (eol === -1) { - this._index = this._length; - } else { - this._index = eol + 1; - } - } - /** - * Creates a new SyntaxError object with a given message. - * - * @param {String} message - * @returns {Object} - * @private - */ - - - error(message) { - return new SyntaxError(message); - } - /** - * Skips to the beginning of a next entry after the current position. - * This is used to mark the boundary of junk entry in case of error, - * and recover from the returned position. - * - * @private - */ - - - skipToNextEntryStart() { - let start = this._index; - - while (true) { - if (start === 0 || this._source[start - 1] === "\n") { - const cc = this._source.charCodeAt(start); - - if (cc >= 97 && cc <= 122 || // a-z - cc >= 65 && cc <= 90 || // A-Z - cc === 45) { - // - - this._index = start; - return; + if (value === null) { + throw new FluentError("Expected attribute value"); } - } - - start = this._source.indexOf("\n", start); - if (start === -1) { - this._index = this._length; - return; + attrs[name] = value; } - start++; + return Object.keys(attrs).length > 0 ? attrs : null; } - } - - } - /** - * Parses an FTL string using RuntimeParser and returns the generated - * object with entries and a list of errors. - * - * @param {String} string - * @returns {Array} - */ + function parsePattern() { + // First try to parse any simple text on the same line as the id. + if (test(RE_TEXT_RUN)) { + var first = match1(RE_TEXT_RUN); + } // If there's a placeable on the first line, parse a complex pattern. - function parse(string) { - const parser = new RuntimeParser(); - return parser.getResource(string); - } - - /* global Intl */ - - /** - * The `FluentType` class is the base of Fluent's type system. - * - * Fluent types wrap JavaScript values and store additional configuration for - * them, which can then be used in the `toString` method together with a proper - * `Intl` formatter. - */ - class FluentType { - /** - * Create an `FluentType` instance. - * - * @param {Any} value - JavaScript value to wrap. - * @param {Object} opts - Configuration. - * @returns {FluentType} - */ - constructor(value, opts) { - this.value = value; - this.opts = opts; - } - /** - * Unwrap the raw value stored by this `FluentType`. - * - * @returns {Any} - */ - - - valueOf() { - return this.value; - } - /** - * Format this instance of `FluentType` to a string. - * - * Formatted values are suitable for use outside of the `FluentBundle`. - * This method can use `Intl` formatters memoized by the `FluentBundle` - * instance passed as an argument. - * - * @param {FluentBundle} [bundle] - * @returns {string} - */ - - - toString() { - throw new Error("Subclasses of FluentType must implement toString."); - } - - } - class FluentNone extends FluentType { - toString() { - return this.value || "???"; - } - - } - class FluentNumber extends FluentType { - constructor(value, opts) { - super(parseFloat(value), opts); - } - - toString(bundle) { - try { - const nf = bundle._memoizeIntlObject(Intl.NumberFormat, this.opts); - - return nf.format(this.value); - } catch (e) { - // XXX Report the error. - return this.value; - } - } - /** - * Compare the object with another instance of a FluentType. - * - * @param {FluentBundle} bundle - * @param {FluentType} other - * @returns {bool} - */ - - - match(bundle, other) { - if (other instanceof FluentNumber) { - return this.value === other.value; - } - - return false; - } - - } - 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; - } - } - - } - class FluentSymbol extends FluentType { - toString() { - return this.value; - } - /** - * Compare the object with another instance of a FluentType. - * - * @param {FluentBundle} bundle - * @param {FluentType} other - * @returns {bool} - */ - - - match(bundle, other) { - if (other instanceof FluentSymbol) { - return this.value === other.value; - } else if (typeof other === "string") { - return this.value === other; - } else if (other instanceof FluentNumber) { - const pr = bundle._memoizeIntlObject(Intl.PluralRules, other.opts); - - return this.value === pr.select(other.value); - } - - return false; - } - - } - - /** - * @overview - * - * The FTL resolver ships with a number of functions built-in. - * - * Each function take two arguments: - * - args - an array of positional args - * - opts - an object of key-value args - * - * Arguments to functions are guaranteed to already be instances of - * `FluentType`. Functions must return `FluentType` objects as well. - */ - var builtins = { - "NUMBER": ([arg], opts) => new FluentNumber(arg.valueOf(), merge(arg.opts, opts)), - "DATETIME": ([arg], opts) => new FluentDateTime(arg.valueOf(), merge(arg.opts, opts)) - }; - - function merge(argopts, opts) { - return Object.assign({}, argopts, values(opts)); - } - - function values(opts) { - const unwrapped = {}; - - for (const [name, opt] of Object.entries(opts)) { - unwrapped[name] = opt.valueOf(); - } - - return unwrapped; - } - - /** - * @overview - * - * The role of the Fluent resolver is to format a translation object to an - * instance of `FluentType` or an array of instances. - * - * Translations can contain references to other messages or variables, - * conditional logic in form of select expressions, traits which describe their - * grammatical features, and can use Fluent builtins which make use of the - * `Intl` formatters to format numbers, dates, lists and more into the - * context's language. See the documentation of the Fluent syntax for more - * information. - * - * In case of errors the resolver will try to salvage as much of the - * translation as possible. In rare situations where the resolver didn't know - * how to recover from an error it will return an instance of `FluentNone`. - * - * `MessageReference`, `VariantExpression`, `AttributeExpression` and - * `SelectExpression` resolve to raw Runtime Entries objects and the result of - * the resolution needs to be passed into `Type` to get their real value. - * This is useful for composing expressions. Consider: - * - * brand-name[nominative] - * - * which is a `VariantExpression` with properties `id: MessageReference` and - * `key: Keyword`. If `MessageReference` was resolved eagerly, it would - * instantly resolve to the value of the `brand-name` message. Instead, we - * want to get the message object and look for its `nominative` variant. - * - * All other expressions (except for `FunctionReference` which is only used in - * `CallExpression`) resolve to an instance of `FluentType`. The caller should - * use the `toString` method to convert the instance to a native value. - * - * - * All functions in this file pass around a special object called `env`. - * This object stores a set of elements used by all resolve functions: - * - * * {FluentBundle} bundle - * context for which the given resolution is happening - * * {Object} args - * list of developer provided arguments that can be used - * * {Array} errors - * list of errors collected while resolving - * * {WeakSet} dirty - * Set of patterns already encountered during this resolution. - * This is used to prevent cyclic resolutions. - */ - - const MAX_PLACEABLE_LENGTH = 2500; // Unicode bidi isolation characters. - - const FSI = "\u2068"; - const PDI = "\u2069"; - /** - * Helper for choosing the default value from a set of members. - * - * Used in SelectExpressions and Type. - * - * @param {Object} env - * Resolver environment object. - * @param {Object} members - * Hash map of variants from which the default value is to be selected. - * @param {Number} def - * The index of the default variant. - * @returns {FluentType} - * @private - */ - - function DefaultMember(env, members, def) { - if (members[def]) { - return members[def]; - } - - const { - errors - } = env; - errors.push(new RangeError("No default")); - return new FluentNone(); - } - /** - * Resolve a reference to another message. - * - * @param {Object} env - * Resolver environment object. - * @param {Object} id - * The identifier of the message to be resolved. - * @param {String} id.name - * The name of the identifier. - * @returns {FluentType} - * @private - */ - - - function MessageReference(env, { - name - }) { - const { - bundle, - errors - } = env; - const message = name.startsWith("-") ? bundle._terms.get(name) : bundle._messages.get(name); - - if (!message) { - const err = name.startsWith("-") ? new ReferenceError(`Unknown term: ${name}`) : new ReferenceError(`Unknown message: ${name}`); - errors.push(err); - return new FluentNone(name); - } - - return message; - } - /** - * Resolve a variant expression to the variant object. - * - * @param {Object} env - * Resolver environment object. - * @param {Object} expr - * An expression to be resolved. - * @param {Object} expr.id - * An Identifier of a message for which the variant is resolved. - * @param {Object} expr.id.name - * Name a message for which the variant is resolved. - * @param {Object} expr.key - * Variant key to be resolved. - * @returns {FluentType} - * @private - */ + if (source[cursor] === "{" || source[cursor] === "}") { + // Re-use the text parsed above, if possible. + return parsePatternElements(first ? [first] : [], Infinity); + } // RE_TEXT_VALUE stops at newlines. Only continue parsing the pattern if + // what comes after the newline is indented. - function VariantExpression(env, { - id, - key - }) { - const message = MessageReference(env, id); - if (message instanceof FluentNone) { - return message; - } + let indent = parseIndent(); - const { - bundle, - errors - } = env; - const keyword = Type(env, key); + if (indent) { + if (first) { + // If there's text on the first line, the blank block is part of the + // translation content in its entirety. + return parsePatternElements([first, indent], indent.length); + } // Otherwise, we're dealing with a block pattern, i.e. a pattern which + // starts on a new line. Discrad the leading newlines but keep the + // inline indent; it will be used by the dedentation logic. - function isVariantList(node) { - return Array.isArray(node) && node[0].type === "sel" && node[0].exp === null; - } - if (isVariantList(message.val)) { - // Match the specified key against keys of each variant, in order. - for (const variant of message.val[0].vars) { - const variantKey = Type(env, variant.key); + indent.value = trim(indent.value, RE_LEADING_NEWLINES); + return parsePatternElements([indent], indent.length); + } - if (keyword.match(bundle, variantKey)) { - return variant; + if (first) { + // It was just a simple inline text after all. + return trim(first, RE_TRAILING_SPACES); } - } - } - errors.push(new ReferenceError(`Unknown variant: ${keyword.toString(bundle)}`)); - return Type(env, message); - } - /** - * Resolve an attribute expression to the attribute object. - * - * @param {Object} env - * Resolver environment object. - * @param {Object} expr - * An expression to be resolved. - * @param {String} expr.id - * An ID of a message for which the attribute is resolved. - * @param {String} expr.name - * Name of the attribute to be resolved. - * @returns {FluentType} - * @private - */ + return null; + } // Parse a complex pattern as an array of elements. - function AttributeExpression(env, { - id, - name - }) { - const message = MessageReference(env, id); + function parsePatternElements(elements = [], commonIndent) { + let placeableCount = 0; - if (message instanceof FluentNone) { - return message; - } + while (true) { + if (test(RE_TEXT_RUN)) { + elements.push(match1(RE_TEXT_RUN)); + continue; + } - if (message.attrs) { - // Match the specified name against keys of each attribute. - for (const attrName in message.attrs) { - if (name === attrName) { - return message.attrs[name]; - } - } - } + if (source[cursor] === "{") { + if (++placeableCount > MAX_PLACEABLES) { + throw new FluentError("Too many placeables"); + } - const { - errors - } = env; - errors.push(new ReferenceError(`Unknown attribute: ${name}`)); - return Type(env, message); - } - /** - * Resolve a select expression to the member object. - * - * @param {Object} env - * Resolver environment object. - * @param {Object} expr - * An expression to be resolved. - * @param {String} expr.exp - * Selector expression - * @param {Array} expr.vars - * List of variants for the select expression. - * @param {Number} expr.def - * Index of the default variant. - * @returns {FluentType} - * @private - */ + elements.push(parsePlaceable()); + continue; + } + + if (source[cursor] === "}") { + throw new FluentError("Unbalanced closing brace"); + } + let indent = parseIndent(); - function SelectExpression(env, { - exp, - vars, - def - }) { - if (exp === null) { - return DefaultMember(env, vars, def); - } + if (indent) { + elements.push(indent); + commonIndent = Math.min(commonIndent, indent.length); + continue; + } - const selector = Type(env, exp); + break; + } - if (selector instanceof FluentNone) { - return DefaultMember(env, vars, def); - } // Match the selector against keys of each variant, in order. + let lastIndex = elements.length - 1; // Trim the trailing spaces in the last element if it's a TextElement. + if (typeof elements[lastIndex] === "string") { + elements[lastIndex] = trim(elements[lastIndex], RE_TRAILING_SPACES); + } - for (const variant of vars) { - const key = Type(env, variant.key); - const keyCanMatch = key instanceof FluentNumber || key instanceof FluentSymbol; + let baked = []; - if (!keyCanMatch) { - continue; - } + for (let element of elements) { + if (element.type === "indent") { + // Dedent indented lines by the maximum common indent. + element = element.value.slice(0, element.value.length - commonIndent); + } else if (element.type === "str") { + // Optimize StringLiterals into their value. + element = element.value; + } - const { - bundle - } = env; + if (element) { + baked.push(element); + } + } - if (key.match(bundle, selector)) { - return variant; + return baked; } - } - return DefaultMember(env, vars, def); - } - /** - * Resolve expression to a Fluent type. - * - * JavaScript strings are a special case. Since they natively have the - * `toString` method they can be used as if they were a Fluent type without - * paying the cost of creating a instance of one. - * - * @param {Object} env - * Resolver environment object. - * @param {Object} expr - * An expression object to be resolved into a Fluent type. - * @returns {FluentType} - * @private - */ + function parsePlaceable() { + consumeToken(TOKEN_BRACE_OPEN, FluentError); + let selector = parseInlineExpression(); + if (consumeToken(TOKEN_BRACE_CLOSE)) { + return selector; + } - function Type(env, expr) { - // A fast-path for strings which are the most common case, and for - // `FluentNone` which doesn't require any additional logic. - if (typeof expr === "string") { - return env.bundle._transform(expr); - } + if (consumeToken(TOKEN_ARROW)) { + let variants = parseVariants(); + consumeToken(TOKEN_BRACE_CLOSE, FluentError); + return { + type: "select", + selector, + ...variants + }; + } - if (expr instanceof FluentNone) { - return expr; - } // The Runtime AST (Entries) encodes patterns (complex strings with - // placeables) as Arrays. + throw new FluentError("Unclosed placeable"); + } + function parseInlineExpression() { + if (source[cursor] === "{") { + // It's a nested placeable. + return parsePlaceable(); + } - if (Array.isArray(expr)) { - return Pattern(env, expr); - } + if (test(RE_REFERENCE)) { + let [, sigil, name, attr = null] = match(RE_REFERENCE); - switch (expr.type) { - case "varname": - return new FluentSymbol(expr.name); + if (sigil === "$") { + return { + type: "var", + name + }; + } - case "num": - return new FluentNumber(expr.val); + if (consumeToken(TOKEN_PAREN_OPEN)) { + let args = parseArguments(); + + if (sigil === "-") { + // A parameterized term: -term(...). + return { + type: "term", + name, + attr, + args + }; + } - case "var": - return VariableReference(env, expr); + if (RE_FUNCTION_NAME.test(name)) { + return { + type: "func", + name, + args + }; + } - case "fun": - return FunctionReference(env, expr); + throw new FluentError("Function names must be all upper-case"); + } - case "call": - return CallExpression(env, expr); + if (sigil === "-") { + // A non-parameterized term: -term. + return { + type: "term", + name, + attr, + args: [] + }; + } - case "ref": - { - const message = MessageReference(env, expr); - return Type(env, message); + return { + type: "mesg", + name, + attr + }; } - case "getattr": - { - const attr = AttributeExpression(env, expr); - return Type(env, attr); - } + return parseLiteral(); + } - case "getvar": - { - const variant = VariantExpression(env, expr); - return Type(env, variant); - } + function parseArguments() { + let args = []; - case "sel": - { - const member = SelectExpression(env, expr); - return Type(env, member); - } + while (true) { + switch (source[cursor]) { + case ")": + // End of the argument list. + cursor++; + return args; - case undefined: - { - // If it's a node with a value, resolve the value. - if (expr.val !== null && expr.val !== undefined) { - return Type(env, expr.val); + case undefined: + // EOF + throw new FluentError("Unclosed argument list"); } - const { - errors - } = env; - errors.push(new RangeError("No value")); - return new FluentNone(); + args.push(parseArgument()); // Commas between arguments are treated as whitespace. + + consumeToken(TOKEN_COMMA); } + } - default: - return new FluentNone(); - } - } - /** - * Resolve a reference to a variable. - * - * @param {Object} env - * Resolver environment object. - * @param {Object} expr - * An expression to be resolved. - * @param {String} expr.name - * Name of an argument to be returned. - * @returns {FluentType} - * @private - */ + function parseArgument() { + let expr = parseInlineExpression(); + if (expr.type !== "mesg") { + return expr; + } - function VariableReference(env, { - name - }) { - const { - args, - errors - } = env; + if (consumeToken(TOKEN_COLON)) { + // The reference is the beginning of a named argument. + return { + type: "narg", + name: expr.name, + value: parseLiteral() + }; + } // It's a regular message reference. - if (!args || !args.hasOwnProperty(name)) { - errors.push(new ReferenceError(`Unknown variable: ${name}`)); - return new FluentNone(name); - } - const arg = args[name]; // Return early if the argument already is an instance of FluentType. + return expr; + } - if (arg instanceof FluentType) { - return arg; - } // Convert the argument to a Fluent type. + function parseVariants() { + let variants = []; + let count = 0; + let star; + while (test(RE_VARIANT_START)) { + if (consumeChar("*")) { + star = count; + } - switch (typeof arg) { - case "string": - return arg; + let key = parseVariantKey(); + let value = parsePattern(); - case "number": - return new FluentNumber(arg); + if (value === null) { + throw new FluentError("Expected variant value"); + } - case "object": - if (arg instanceof Date) { - return new FluentDateTime(arg); + variants[count++] = { + key, + value + }; } - default: - errors.push(new TypeError(`Unsupported variable type: ${name}, ${typeof arg}`)); - return new FluentNone(name); - } - } - /** - * Resolve a reference to a function. - * - * @param {Object} env - * Resolver environment object. - * @param {Object} expr - * An expression to be resolved. - * @param {String} expr.name - * Name of the function to be returned. - * @returns {Function} - * @private - */ + if (count === 0) { + return null; + } + + if (star === undefined) { + throw new FluentError("Expected default variant"); + } + return { + variants, + star + }; + } - function FunctionReference(env, { - name - }) { - // Some functions are built-in. Others may be provided by the runtime via - // the `FluentBundle` constructor. - const { - bundle: { - _functions - }, - errors - } = env; - const func = _functions[name] || builtins[name]; + function parseVariantKey() { + consumeToken(TOKEN_BRACKET_OPEN, FluentError); + let key = test(RE_NUMBER_LITERAL) ? parseNumberLiteral() : match1(RE_IDENTIFIER); + consumeToken(TOKEN_BRACKET_CLOSE, FluentError); + return key; + } - if (!func) { - errors.push(new ReferenceError(`Unknown function: ${name}()`)); - return new FluentNone(`${name}()`); - } + function parseLiteral() { + if (test(RE_NUMBER_LITERAL)) { + return parseNumberLiteral(); + } - if (typeof func !== "function") { - errors.push(new TypeError(`Function ${name}() is not callable`)); - return new FluentNone(`${name}()`); - } + if (source[cursor] === "\"") { + return parseStringLiteral(); + } - return func; - } - /** - * Resolve a call to a Function with positional and key-value arguments. - * - * @param {Object} env - * Resolver environment object. - * @param {Object} expr - * An expression to be resolved. - * @param {Object} expr.fun - * FTL Function object. - * @param {Array} expr.args - * FTL Function argument list. - * @returns {FluentType} - * @private - */ + throw new FluentError("Invalid expression"); + } + function parseNumberLiteral() { + let [, value, fraction = ""] = match(RE_NUMBER_LITERAL); + let precision = fraction.length; + return { + type: "num", + value: parseFloat(value), + precision + }; + } - function CallExpression(env, { - fun, - args - }) { - const callee = FunctionReference(env, fun); + function parseStringLiteral() { + consumeChar("\"", FluentError); + let value = ""; - if (callee instanceof FluentNone) { - return callee; - } + while (true) { + value += match1(RE_STRING_RUN); - const posargs = []; - const keyargs = {}; + if (source[cursor] === "\\") { + value += parseEscapeSequence(); + continue; + } - for (const arg of args) { - if (arg.type === "narg") { - keyargs[arg.name] = Type(env, arg.val); - } else { - posargs.push(Type(env, arg)); - } - } + if (consumeChar("\"")) { + return { + type: "str", + value + }; + } // We've reached an EOL of EOF. - try { - return callee(posargs, keyargs); - } catch (e) { - // XXX Report errors. - return new FluentNone(); - } - } - /** - * Resolve a pattern (a complex string with placeables). - * - * @param {Object} env - * Resolver environment object. - * @param {Array} ptn - * Array of pattern elements. - * @returns {Array} - * @private - */ + throw new FluentError("Unclosed string literal"); + } + } // Unescape known escape sequences. - function Pattern(env, ptn) { - const { - bundle, - dirty, - errors - } = env; - if (dirty.has(ptn)) { - errors.push(new RangeError("Cyclic reference")); - return new FluentNone(); - } // Tag the pattern as dirty for the purpose of the current resolution. + function parseEscapeSequence() { + if (test(RE_STRING_ESCAPE)) { + return match1(RE_STRING_ESCAPE); + } + if (test(RE_UNICODE_ESCAPE)) { + let [, codepoint4, codepoint6] = match(RE_UNICODE_ESCAPE); + let codepoint = parseInt(codepoint4 || codepoint6, 16); + return codepoint <= 0xD7FF || 0xE000 <= codepoint // It's a Unicode scalar value. + ? String.fromCodePoint(codepoint) // Lonely surrogates can cause trouble when the parsing result is + // saved using UTF-8. Use U+FFFD REPLACEMENT CHARACTER instead. + : "�"; + } - dirty.add(ptn); - const result = []; // Wrap interpolations with Directional Isolate Formatting characters - // only when the pattern has more than one element. + throw new FluentError("Unknown escape sequence"); + } // Parse blank space. Return it if it looks like indent before a pattern + // line. Skip it othwerwise. - const useIsolating = bundle._useIsolating && ptn.length > 1; - for (const elem of ptn) { - if (typeof elem === "string") { - result.push(bundle._transform(elem)); - continue; - } + function parseIndent() { + let start = cursor; + consumeToken(TOKEN_BLANK); // Check the first non-blank character after the indent. - const part = Type(env, elem).toString(bundle); + switch (source[cursor]) { + case ".": + case "[": + case "*": + case "}": + case undefined: + // EOF + // A special character. End the Pattern. + return false; - if (useIsolating) { - result.push(FSI); - } + case "{": + // Placeables don't require indentation (in EBNF: block-placeable). + // Continue the Pattern. + return makeIndent(source.slice(start, cursor)); + } // If the first character on the line is not one of the special characters + // listed above, it's a regular text character. Check if there's at least + // one space of indent before it. - if (part.length > MAX_PLACEABLE_LENGTH) { - errors.push(new RangeError("Too many characters in placeable " + `(${part.length}, max allowed is ${MAX_PLACEABLE_LENGTH})`)); - result.push(part.slice(MAX_PLACEABLE_LENGTH)); - } else { - result.push(part); - } - if (useIsolating) { - result.push(PDI); - } - } + if (source[cursor - 1] === " ") { + // It's an indented text character (in EBNF: indented-char). Continue + // the Pattern. + return makeIndent(source.slice(start, cursor)); + } // A not-indented text character is likely the identifier of the next + // message. End the Pattern. - dirty.delete(ptn); - return result.join(""); - } - /** - * Format a translation into a string. - * - * @param {FluentBundle} bundle - * A FluentBundle instance which will be used to resolve the - * contextual information of the message. - * @param {Object} args - * List of arguments provided by the developer which can be accessed - * from the message. - * @param {Object} message - * An object with the Message to be resolved. - * @param {Array} errors - * An error array that any encountered errors will be appended to. - * @returns {FluentType} - */ + return false; + } // Trim blanks in text according to the given regex. - function resolve(bundle, args, message, errors = []) { - const env = { - bundle, - args, - errors, - dirty: new WeakSet() - }; - return Type(env, message).toString(bundle); - } - /** - * Fluent Resource is a structure storing a map - * of localization entries. - */ + function trim(text, re) { + return text.replace(re, ""); + } // Normalize a blank block and extract the indent details. - class FluentResource extends Map { - constructor(entries, errors = []) { - super(entries); - this.errors = errors; - } - static fromString(source) { - const [entries, errors] = parse(source); - return new FluentResource(Object.entries(entries), errors); + function makeIndent(blank) { + let value = blank.replace(RE_BLANK_LINES, "\n"); + let length = RE_INDENT.exec(blank)[1].length; + return { + type: "indent", + value, + length + }; + } } } @@ -1808,11 +1117,11 @@ * format translation units (entities) to strings. * * Always use `FluentBundle.format` to retrieve translation units from a - * context. Translations can contain references to other entities or variables, + * 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 - * context's language. See the documentation of the Fluent syntax for more + * bundle's language. See the documentation of the Fluent syntax for more * information. */ @@ -1821,7 +1130,7 @@ * 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 context. + * translations. The `options` object can be used to configure the bundle. * * Examples: * @@ -1843,10 +1152,11 @@ * * - `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} locales - Locale or locales of the context + * @param {string|Array} locales - Locale or locales of the bundle * @param {Object} [options] * @returns {FluentBundle} */ @@ -1874,7 +1184,7 @@ return this._messages[Symbol.iterator](); } /* - * Check if a message is present in the context. + * Check if a message is present in the bundle. * * @param {string} id - The identifier of the message to check. * @returns {bool} @@ -1899,64 +1209,94 @@ return this._messages.get(id); } /** - * Add a translation resource to the context. + * Add a translation resource to the bundle. * * The translation resource must use the Fluent syntax. It will be parsed by - * the context and each translation unit (message) will be available in the - * context by its identifier. + * 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} */ - addMessages(source) { + addMessages(source, options) { const res = FluentResource.fromString(source); - return this.addResource(res); + return this.addResource(res, options); } /** - * Add a translation resource to the context. + * Add a translation resource to the bundle. * - * The translation resource must be a proper FluentResource - * parsed by `FluentBundle.parseResource`. + * The translation resource must be an instance of FluentResource, + * e.g. parsed by `FluentResource.fromString`. * - * let res = FluentBundle.parseResource("foo = Foo"); + * 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} */ - addResource(res) { - const errors = res.errors.slice(); + 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 (this._terms.has(id)) { + if (allowOverrides === false && this._terms.has(id)) { errors.push(`Attempt to override an existing term: "${id}"`); continue; } this._terms.set(id, value); } else { - if (this._messages.has(id)) { + if (allowOverrides === false && this._messages.has(id)) { errors.push(`Attempt to override an existing message: "${id}"`); continue; } @@ -1970,7 +1310,7 @@ /** * Format a message to a string or null. * - * Format a raw `message` from the context into a string (or a null if it has + * 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. * @@ -2003,16 +1343,16 @@ // optimize entities which are simple strings with no attributes if (typeof message === "string") { return this._transform(message); - } // optimize simple-string entities with attributes - - - if (typeof message.val === "string") { - return this._transform(message.val); } // optimize entities with null values - if (message.val === undefined) { + 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); @@ -2033,17 +1373,8 @@ } - /* - * @module fluent - * @overview - * - * `fluent` is a JavaScript implementation of Project Fluent, a localization - * framework designed to unleash the expressive power of the natural language. - * - */ - function addValue(k, value) { - let ftl = ''; + var ftl = ''; ftl = ftl + k + ' ='; if (value && value.indexOf('\n') > -1) { @@ -2057,16 +1388,16 @@ } function addComment(comment) { - let ftl = ''; + var ftl = ''; ftl = ftl + '# ' + comment.split('\n').join('\n# '); ftl = ftl + '\n'; return ftl; } function js2ftl(resources, cb) { - let ftl = ''; - Object.keys(resources).forEach(k => { - const value = resources[k]; + var ftl = ''; + Object.keys(resources).forEach(function (k) { + var value = resources[k]; if (typeof value === 'string') { ftl = ftl + addValue(k, value); @@ -2074,9 +1405,9 @@ } else { if (value.comment) ftl = ftl + addComment(value.comment); ftl = ftl + addValue(k, value.val); - Object.keys(value).forEach(innerK => { + Object.keys(value).forEach(function (innerK) { if (innerK === 'comment' || innerK === 'val') return; - const innerValue = value[innerK]; + var innerValue = value[innerK]; ftl = ftl + addValue('\n .' + innerK, innerValue); }); ftl = ftl + '\n\n'; @@ -2086,29 +1417,27 @@ return ftl; } - var js2ftl_1 = js2ftl; - function getDefaults() { return { - bindI18nextStore: true, + bindI18nStore: true, fluentBundleOptions: { useIsolating: false } }; } - function nonBlank$1(line) { + function nonBlank(line) { return !/^\s*$/.test(line); } - function countIndent$1(line) { + function countIndent(line) { const [indent] = line.match(/^\s*/); return indent.length; } - function ftl$1(code) { - const lines = code.split("\n").filter(nonBlank$1); - const indents = lines.map(countIndent$1); + function ftl(code) { + const lines = code.split("\n").filter(nonBlank); + const indents = lines.map(countIndent); const common = Math.min(...indents); const indent = new RegExp(`^\\s{${common}}`); return lines.map(line => line.replace(indent, "")).join("\n"); @@ -2124,9 +1453,9 @@ } createBundle(lng, ns, json) { - const ftlStr = json ? js2ftl_1(json) : ""; + const ftlStr = json ? js2ftl(json) : ""; const bundle = new FluentBundle(lng, this.options.fluentBundleOptions); - const errors = bundle.addMessages(ftl$1(ftlStr)); + bundle.addMessages(ftl(ftlStr)); setPath(this.bundles, [lng, ns], bundle); } @@ -2169,7 +1498,7 @@ if (i18next) { this.store = new BundleStore(i18next, this.options); - if (this.options.bindI18nextStore) this.store.bind(); + if (this.options.bindI18nStore) this.store.bind(); i18next.fluent = this; } else { this.store = new BundleStore(null, this.options); @@ -2204,4 +1533,4 @@ return Fluent; -}))); +})); diff --git a/i18nextFluent.min.js b/i18nextFluent.min.js index a6140d2..cc0be50 100644 --- a/i18nextFluent.min.js +++ b/i18nextFluent.min.js @@ -1 +1 @@ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):t.i18nextFluent=e()}(this,function(){"use strict";function t(t,e,s){function i(t){return t&&t.indexOf("###")>-1?t.replace(/###/g,"."):t}function n(){return!t||"string"==typeof t}const r="string"!=typeof e?[].concat(e):e.split(".");for(;r.length>1;){if(n())return{};const e=i(r.shift());!t[e]&&s&&(t[e]=new s),t=t[e]}return n()?{}:{obj:t,k:i(r.shift())}}function e(e,s){const{obj:i,k:n}=t(e,s);if(i)return i[n]}let s=[],i=s.forEach,n=s.slice;const r=100,o=/-?[a-zA-Z][a-zA-Z0-9_-]*/y,h=/[a-zA-Z][a-zA-Z0-9_-]*/y,a=/^[A-Z][A-Z_?-]*$/,c=/^[a-fA-F0-9]{4}$/,u=/[ \t\n\r]+$/;class l{getResource(t){this._source=t,this._index=0,this._length=t.length,this.entries={};const e=[];for(this.skipWS();this._index=97&&s<=122||s>=65&&s<=90||95===s||32===s))throw this.error("Expected a keyword (starting with [a-zA-Z_])");for(s=this._source.charCodeAt(++this._index);s>=97&&s<=122||s>=65&&s<=90||s>=48&&s<=57||95===s||45===s||32===s;)s=this._source.charCodeAt(++this._index);for(;32===this._source.charCodeAt(this._index-1);)this._index--;return{type:"varname",name:t+=this._source.slice(e,this._index)}}getString(){let t="";for(this._index++;this._indexr-1)throw this.error(`Too many placeables, maximum allowed is ${r}`);t="",e.push(this.getPlaceable()),i=this._source[++this._index],s++}else t+=this.getEscapedCharacter(),i=this._source[this._index]}else{this._index++;const s=this._index;this.skipBlankLines();const n=this._index;if(" "!==this._source[this._index])break;if(this.skipInlineWS(),"}"===this._source[this._index]||"["===this._source[this._index]||"*"===this._source[this._index]||"."===this._source[this._index]){this._index=n;break}((t+=this._source.substring(s,n)).length||e.length)&&(t+="\n"),i=this._source[this._index]}return 0===e.length?t.length?t:null:(t.length&&e.push(t.replace(u,"")),e)}getEscapedCharacter(t=["{","\\"]){this._index++;const e=this._source[this._index];if(t.includes(e))return this._index++,e;if("u"===e){const t=this._source.slice(this._index+1,this._index+5);if(c.test(t))return this._index+=5,String.fromCodePoint(parseInt(t,16));throw this.error(`Invalid Unicode escape sequence: \\u${t}`)}throw this.error(`Unknown escape sequence: \\${e}`)}getPlaceable(){const t=++this._index;if(this.skipWS(),"*"===this._source[this._index]||"["===this._source[this._index]&&"]"!==this._source[this._index+1]){const t=this.getVariants();return{type:"sel",exp:null,vars:t[0],def:t[1]}}this._index=t,this.skipInlineWS();const e=this.getSelectorExpression();this.skipWS();const s=this._source[this._index];if("}"===s){if("getattr"===e.type&&e.id.name.startsWith("-"))throw this.error("Attributes of private messages cannot be interpolated.");return e}if("-"!==s||">"!==this._source[this._index+1])throw this.error('Expected "}" or "->"');if("ref"===e.type)throw this.error("Message references cannot be used as selectors.");if("getvar"===e.type)throw this.error("Variants cannot be used as selectors.");if("getattr"===e.type&&!e.id.name.startsWith("-"))throw this.error("Attributes of public messages cannot be used as selectors.");if(this._index+=2,this.skipInlineWS(),"\n"!==this._source[this._index])throw this.error("Variants should be listed in a new line");this.skipWS();const i=this.getVariants();if(0===i[0].length)throw this.error("Expected members for the select expression");return{type:"sel",exp:e,vars:i[0],def:i[1]}}getSelectorExpression(){if("{"===this._source[this._index])return this.getPlaceable();const t=this.getLiteral();if("ref"!==t.type)return t;if("."===this._source[this._index]){this._index++;const e=this.getIdentifier();return this._index++,{type:"getattr",id:t,name:e}}if("["===this._source[this._index]){this._index++;const e=this.getVariantKey();return this._index++,{type:"getvar",id:t,key:e}}if("("===this._source[this._index]){this._index++;const e=this.getCallArgs();if(!a.test(t.name))throw this.error("Function names must be all upper-case");return this._index++,t.type="fun",{type:"call",fun:t,args:e}}return t}getCallArgs(){const t=[];for(;this._index57)throw this.error(`Unknown literal "${t}"`);for(;e>=48&&e<=57;)t+=this._source[this._index++],e=this._source.charCodeAt(this._index);if(46===e){if(t+=this._source[this._index++],(e=this._source.charCodeAt(this._index))<48||e>57)throw this.error(`Unknown literal "${t}"`);for(;e>=48&&e<=57;)t+=this._source[this._index++],e=this._source.charCodeAt(this._index)}return{type:"num",val:t}}getAttributes(){const t={};for(;this._index=48&&t<=57||45===t?this.getNumber():this.getVariantName(),"]"!==this._source[this._index])throw this.error('Expected "]"');return this._index++,e}getLiteral(){const t=this._source.charCodeAt(this._index);if(36===t)return this._index++,{type:"var",name:this.getIdentifier()};const e=45===t?this._source.charCodeAt(this._index+1):t;if(e>=97&&e<=122||e>=65&&e<=90)return{type:"ref",name:this.getEntryIdentifier()};if(e>=48&&e<=57)return this.getNumber();if(34===t)return this.getString();throw this.error("Expected literal")}skipComment(){let t=this._source.indexOf("\n",this._index);for(;-1!==t&&"#"===this._source[t+1]&&[" ","#"].includes(this._source[t+2])&&(this._index=t+3,-1!==(t=this._source.indexOf("\n",this._index))););this._index=-1===t?this._length:t+1}error(t){return new SyntaxError(t)}skipToNextEntryStart(){let t=this._index;for(;;){if(0===t||"\n"===this._source[t-1]){const e=this._source.charCodeAt(t);if(e>=97&&e<=122||e>=65&&e<=90||45===e)return void(this._index=t)}if(-1===(t=this._source.indexOf("\n",t)))return void(this._index=this._length);t++}}}class d{constructor(t,e){this.value=t,this.opts=e}valueOf(){return this.value}toString(){throw new Error("Subclasses of FluentType must implement toString.")}}class f extends d{toString(){return this.value||"???"}}class _ extends d{constructor(t,e){super(parseFloat(t),e)}toString(t){try{return t._memoizeIntlObject(Intl.NumberFormat,this.opts).format(this.value)}catch(t){return this.value}}match(t,e){return e instanceof _&&this.value===e.value}}class x extends d{constructor(t,e){super(new Date(t),e)}toString(t){try{return t._memoizeIntlObject(Intl.DateTimeFormat,this.opts).format(this.value)}catch(t){return this.value}}}class p extends d{toString(){return this.value}match(t,e){if(e instanceof p)return this.value===e.value;if("string"==typeof e)return this.value===e;if(e instanceof _){const s=t._memoizeIntlObject(Intl.PluralRules,e.opts);return this.value===s.select(e.value)}return!1}}var g={NUMBER:([t],e)=>new _(t.valueOf(),m(t.opts,e)),DATETIME:([t],e)=>new x(t.valueOf(),m(t.opts,e))};function m(t,e){return Object.assign({},t,function(t){const e={};for(const[s,i]of Object.entries(t))e[s]=i.valueOf();return e}(e))}const w=2500,y="⁨",v="⁩";function b(t,e,s){if(e[s])return e[s];const{errors:i}=t;return i.push(new RangeError("No default")),new f}function k(t,{name:e}){const{bundle:s,errors:i}=t,n=e.startsWith("-")?s._terms.get(e):s._messages.get(e);if(!n){const t=e.startsWith("-")?new ReferenceError(`Unknown term: ${e}`):new ReferenceError(`Unknown message: ${e}`);return i.push(t),new f(e)}return n}function E(t,e){if("string"==typeof e)return t.bundle._transform(e);if(e instanceof f)return e;if(Array.isArray(e))return function(t,e){const{bundle:s,dirty:i,errors:n}=t;if(i.has(e))return n.push(new RangeError("Cyclic reference")),new f;i.add(e);const r=[],o=s._useIsolating&&e.length>1;for(const i of e){if("string"==typeof i){r.push(s._transform(i));continue}const e=E(t,i).toString(s);o&&r.push(y),e.length>w?(n.push(new RangeError("Too many characters in placeable "+`(${e.length}, max allowed is ${w})`)),r.push(e.slice(w))):r.push(e),o&&r.push(v)}return i.delete(e),r.join("")}(t,e);switch(e.type){case"varname":return new p(e.name);case"num":return new _(e.val);case"var":return function(t,{name:e}){const{args:s,errors:i}=t;if(!s||!s.hasOwnProperty(e))return i.push(new ReferenceError(`Unknown variable: ${e}`)),new f(e);const n=s[e];if(n instanceof d)return n;switch(typeof n){case"string":return n;case"number":return new _(n);case"object":if(n instanceof Date)return new x(n);default:return i.push(new TypeError(`Unsupported variable type: ${e}, ${typeof n}`)),new f(e)}}(t,e);case"fun":return S(t,e);case"call":return function(t,{fun:e,args:s}){const i=S(t,e);if(i instanceof f)return i;const n=[],r={};for(const e of s)"narg"===e.type?r[e.name]=E(t,e.val):n.push(E(t,e));try{return i(n,r)}catch(t){return new f}}(t,e);case"ref":return E(t,k(t,e));case"getattr":{const s=function(t,{id:e,name:s}){const i=k(t,e);if(i instanceof f)return i;if(i.attrs)for(const t in i.attrs)if(s===t)return i.attrs[s];const{errors:n}=t;return n.push(new ReferenceError(`Unknown attribute: ${s}`)),E(t,i)}(t,e);return E(t,s)}case"getvar":{const s=function(t,{id:e,key:s}){const i=k(t,e);if(i instanceof f)return i;const{bundle:n,errors:r}=t,o=E(t,s);if(h=i.val,Array.isArray(h)&&"sel"===h[0].type&&null===h[0].exp)for(const e of i.val[0].vars){const s=E(t,e.key);if(o.match(n,s))return e}var h;return r.push(new ReferenceError(`Unknown variant: ${o.toString(n)}`)),E(t,i)}(t,e);return E(t,s)}case"sel":{const s=function(t,{exp:e,vars:s,def:i}){if(null===e)return b(t,s,i);const n=E(t,e);if(n instanceof f)return b(t,s,i);for(const e of s){const s=E(t,e.key);if(!(s instanceof _||s instanceof p))continue;const{bundle:i}=t;if(s.match(i,n))return e}return b(t,s,i)}(t,e);return E(t,s)}case void 0:{if(null!==e.val&&void 0!==e.val)return E(t,e.val);const{errors:s}=t;return s.push(new RangeError("No value")),new f}default:return new f}}function S(t,{name:e}){const{bundle:{_functions:s},errors:i}=t,n=s[e]||g[e];return n?"function"!=typeof n?(i.push(new TypeError(`Function ${e}() is not callable`)),new f(`${e}()`)):n:(i.push(new ReferenceError(`Unknown function: ${e}()`)),new f(`${e}()`))}class I extends Map{constructor(t,e=[]){super(t),this.errors=e}static fromString(t){const[e,s]=(i=t,(new l).getResource(i));var i;return new I(Object.entries(e),s)}}class A{constructor(t,{functions:e={},useIsolating:s=!0,transform:i=(t=>t)}={}){this.locales=Array.isArray(t)?t:[t],this._terms=new Map,this._messages=new Map,this._functions=e,this._useIsolating=s,this._transform=i,this._intls=new WeakMap}get messages(){return this._messages[Symbol.iterator]()}hasMessage(t){return this._messages.has(t)}getMessage(t){return this._messages.get(t)}addMessages(t){const e=I.fromString(t);return this.addResource(e)}addResource(t){const e=t.errors.slice();for(const[s,i]of t)if(s.startsWith("-")){if(this._terms.has(s)){e.push(`Attempt to override an existing term: "${s}"`);continue}this._terms.set(s,i)}else{if(this._messages.has(s)){e.push(`Attempt to override an existing message: "${s}"`);continue}this._messages.set(s,i)}return e}format(t,e,s){return"string"==typeof t?this._transform(t):"string"==typeof t.val?this._transform(t.val):void 0===t.val?null:function(t,e,s,i=[]){return E({bundle:t,args:e,errors:i,dirty:new WeakSet},s).toString(t)}(this,e,t,s)}_memoizeIntlObject(t,e){const s=this._intls.get(t)||{},i=JSON.stringify(e);return s[i]||(s[i]=new t(this.locales,e),this._intls.set(t,s)),s[i]}}function W(t,e){let s="";return s=s+t+" =",e&&e.indexOf("\n")>-1?(s+="\n ",s+=e.split("\n").join("\n ")):s=s+" "+e,s}var O=function(t,e){let s="";return Object.keys(t).forEach(e=>{const i=t[e];"string"==typeof i?(s+=W(e,i),s+="\n\n"):(i.comment&&(s+=function(t){let e="";return e=e+"# "+t.split("\n").join("\n# "),e+="\n"}(i.comment)),s+=W(e,i.val),Object.keys(i).forEach(t=>{if("comment"===t||"val"===t)return;const e=i[t];s+=W("\n ."+t,e)}),s+="\n\n")}),e&&e(null,s),s};function $(t){return!/^\s*$/.test(t)}function C(t){const[e]=t.match(/^\s*/);return e.length}class j{constructor(t,e){this.i18next=t,this.options=e,this.bundles={}}createBundle(e,s,i){const n=i?O(i):"",r=new A(e,this.options.fluentBundleOptions);r.addMessages(function(t){const e=t.split("\n").filter($),s=e.map(C),i=Math.min(...s),n=new RegExp(`^\\s{${i}}`);return e.map(t=>t.replace(n,"")).join("\n")}(n));!function(e,s,i){const{obj:n,k:r}=t(e,s,Object);n[r]=i}(this.bundles,[e,s],r)}createBundleFromI18next(t,s){this.createBundle(t,s,e(this.i18next.store.data,[t,s]))}getBundle(t,s){return e(this.bundles,[t,s])}bind(){this.i18next.store.on("added",(t,e)=>{this.i18next.isInitialized&&this.createBundleFromI18next(t,e)}),this.i18next.on("initialized",()=>{var t=this.i18next.languages||[],e=this.i18next.options.preload||[];t.filter(t=>!e.includes(t)).concat(e).forEach(t=>{this.i18next.options.ns.forEach(e=>{this.createBundleFromI18next(t,e)})})})}}class R{constructor(t){this.type="i18nFormat",this.handleAsObject=!1,this.init(null,t)}init(t,e){const s=t&&t.options&&t.options.i18nFormat||{};this.options=function(t){return i.call(n.call(arguments,1),function(e){if(e)for(var s in e)void 0===t[s]&&(t[s]=e[s])}),t}(s,e,this.options||{},{bindI18nextStore:!0,fluentBundleOptions:{useIsolating:!1}}),t?(this.store=new j(t,this.options),this.options.bindI18nextStore&&this.store.bind(),t.fluent=this):this.store=new j(null,this.options)}parse(t,e,s,i,n,r){const o=this.store.getBundle(s,i),h=n.indexOf(".")>-1;if(!t)return n;const a=h?t.attrs[n.split(".")[1]]:t;return o?o.format(a,e):n}getResource(t,e,s,i){let n=this.store.getBundle(t,e);const r=s.indexOf(".")>-1?s.split(".")[0]:s;if(n)return n.getMessage(r)}addLookupKeys(t,e,s,i,n){return t}}return R.type="i18nFormat",R}); +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).i18nextFluent=t()}(this,(function(){"use strict";function e(e,t,n){function r(e){return e&&e.indexOf("###")>-1?e.replace(/###/g,"."):e}function s(){return!e||"string"==typeof e}const i="string"!=typeof t?[].concat(t):t.split(".");for(;i.length>1;){if(s())return{};const t=r(i.shift());!e[t]&&n&&(e[t]=new n),e=e[t]}return s()?{}:{obj:e,k:r(i.shift())}}function t(t,n){const{obj:r,k:s}=e(t,n);if(r)return r[s]}let n=[],r=n.forEach,s=n.slice;class i{constructor(e,t){this.value=e,this.opts=t}valueOf(){return this.value}toString(){throw new Error("Subclasses of FluentType must implement toString.")}}class o extends i{valueOf(){return null}toString(){return`{${this.value||"???"}}`}}class u extends i{constructor(e,t){super(parseFloat(e),t)}toString(e){try{return e._memoizeIntlObject(Intl.NumberFormat,this.opts).format(this.value)}catch(e){return this.value}}}class a extends i{constructor(e,t){super(new Date(e),t)}toString(e){try{return e._memoizeIntlObject(Intl.DateTimeFormat,this.opts).format(this.value)}catch(e){return this.value}}}function c(e,t){return Object.assign({},e,function(e){const t={};for(const[n,r]of Object.entries(e))t[n]=r.valueOf();return t}(t))}var l=Object.freeze({__proto__:null,NUMBER:function([e],t){return e instanceof o?e:e instanceof u?new u(e.valueOf(),c(e.opts,t)):new o("NUMBER()")},DATETIME:function([e],t){return e instanceof o?e:e instanceof a?new a(e.valueOf(),c(e.opts,t)):new o("DATETIME()")}});const f=2500;function h(e,t,n){if(n===t)return!0;if(n instanceof u&&t instanceof u&&n.value===t.value)return!0;if(t instanceof u&&"string"==typeof n){if(n===e._memoizeIntlObject(Intl.PluralRules,t.opts).select(t.value))return!0}return!1}function p(e,t,n){return t[n]?g(e,t[n]):(e.errors.push(new RangeError("No default")),new o)}function d(e,t){const n=[],r={};for(const s of t)"narg"===s.type?r[s.name]=g(e,s.value):n.push(g(e,s));return[n,r]}function g(e,t){if("string"==typeof t)return e.bundle._transform(t);if(t instanceof o)return t;if(Array.isArray(t))return function(e,t){if(e.dirty.has(t))return e.errors.push(new RangeError("Cyclic reference")),new o;e.dirty.add(t);const n=[],r=e.bundle._useIsolating&&t.length>1;for(const s of t){if("string"==typeof s){n.push(e.bundle._transform(s));continue}const t=g(e,s).toString(e.bundle);r&&n.push("⁨"),t.length>f?(e.errors.push(new RangeError(`Too many characters in placeable (${t.length}, max allowed is 2500)`)),n.push(t.slice(f))):n.push(t),r&&n.push("⁩")}return e.dirty.delete(t),n.join("")}(e,t);switch(t.type){case"str":return t.value;case"num":return new u(t.value,{minimumFractionDigits:t.precision});case"var":return function(e,{name:t}){if(!e.args||!e.args.hasOwnProperty(t))return!1===e.insideTermReference&&e.errors.push(new ReferenceError(`Unknown variable: ${t}`)),new o(`$${t}`);const n=e.args[t];if(n instanceof i)return n;switch(typeof n){case"string":return n;case"number":return new u(n);case"object":if(n instanceof Date)return new a(n);default:return e.errors.push(new TypeError(`Unsupported variable type: ${t}, ${typeof n}`)),new o(`$${t}`)}}(e,t);case"mesg":return function(e,{name:t,attr:n}){const r=e.bundle._messages.get(t);if(!r){const n=new ReferenceError(`Unknown message: ${t}`);return e.errors.push(n),new o(t)}if(n){const s=r.attrs&&r.attrs[n];return s?g(e,s):(e.errors.push(new ReferenceError(`Unknown attribute: ${n}`)),new o(`${t}.${n}`))}return g(e,r)}(e,t);case"term":return function(e,{name:t,attr:n,args:r}){const s=`-${t}`,i=e.bundle._terms.get(s);if(!i){const t=new ReferenceError(`Unknown term: ${s}`);return e.errors.push(t),new o(s)}const[,u]=d(e,r),a={...e,args:u,insideTermReference:!0};if(n){const t=i.attrs&&i.attrs[n];return t?g(a,t):(e.errors.push(new ReferenceError(`Unknown attribute: ${n}`)),new o(`${s}.${n}`))}return g(a,i)}(e,t);case"func":return function(e,{name:t,args:n}){const r=e.bundle._functions[t]||l[t];if(!r)return e.errors.push(new ReferenceError(`Unknown function: ${t}()`)),new o(`${t}()`);if("function"!=typeof r)return e.errors.push(new TypeError(`Function ${t}() is not callable`)),new o(`${t}()`);try{return r(...d(e,n))}catch(e){return new o(`${t}()`)}}(e,t);case"select":return function(e,{selector:t,variants:n,star:r}){let s=g(e,t);if(s instanceof o){return g(e,p(e,n,r))}for(const t of n){const n=g(e,t.key);if(h(e.bundle,s,n))return g(e,t)}const i=p(e,n,r);return g(e,i)}(e,t);case void 0:return null!==t.value&&void 0!==t.value?g(e,t.value):(e.errors.push(new RangeError("No value")),new o);default:return new o}}class m extends Error{}const w=/^(-?[a-zA-Z][\w-]*) *= */gm,y=/\.([a-zA-Z][\w-]*) *= */y,v=/\*?\[/y,b=/(-?[0-9]+(?:\.([0-9]+))?)/y,x=/([a-zA-Z][\w-]*)/y,$=/([$-])?([a-zA-Z][\w-]*)(?:\.([a-zA-Z][\w-]*))?/y,E=/^[A-Z][A-Z0-9_-]*$/,_=/([^{}\n\r]+)/y,I=/([^\\"\n\r]*)/y,O=/\\([\\"])/y,j=/\\u([a-fA-F0-9]{4})|\\U([a-fA-F0-9]{6})/y,k=/^\n+/,R=/ +$/,A=/ *\r?\n/g,S=/( *)$/,F=/{\s*/y,M=/\s*}/y,T=/\[\s*/y,U=/\s*] */y,z=/\s*\(\s*/y,B=/\s*->\s*/y,Z=/\s*:\s*/y,D=/\s*,?\s*/y,N=/\s+/y;class P extends Map{static fromString(e){w.lastIndex=0;let t=new this,n=0;for(;;){let r=w.exec(e);if(null===r)break;n=w.lastIndex;try{t.set(r[1],a())}catch(e){if(e instanceof m)continue;throw e}}return t;function r(t){return t.lastIndex=n,t.test(e)}function s(t,r){if(e[n]===t)return n++,!0;if(r)throw new r(`Expected ${t}`);return!1}function i(e,t){if(r(e))return n=e.lastIndex,!0;if(t)throw new t(`Expected ${e.toString()}`);return!1}function o(t){t.lastIndex=n;let r=t.exec(e);if(null===r)throw new m(`Expected ${t.toString()}`);return n=t.lastIndex,r}function u(e){return o(e)[1]}function a(){let e=c(),t=function(){let e={};for(;r(y);){let t=u(y),n=c();if(null===n)throw new m("Expected attribute value");e[t]=n}return Object.keys(e).length>0?e:null}();if(null===t){if(null===e)throw new m("Expected message value or attributes");return e}return{value:e,attrs:t}}function c(){if(r(_))var t=u(_);if("{"===e[n]||"}"===e[n])return l(t?[t]:[],1/0);let s=C();return s?t?l([t,s],s.length):(s.value=q(s.value,k),l([s],s.length)):t?q(t,R):null}function l(t=[],s){let i=0;for(;;){if(r(_)){t.push(u(_));continue}if("{"===e[n]){if(++i>100)throw new m("Too many placeables");t.push(f());continue}if("}"===e[n])throw new m("Unbalanced closing brace");let o=C();if(!o)break;t.push(o),s=Math.min(s,o.length)}let o=t.length-1;"string"==typeof t[o]&&(t[o]=q(t[o],R));let a=[];for(let e of t)"indent"===e.type?e=e.value.slice(0,e.value.length-s):"str"===e.type&&(e=e.value),e&&a.push(e);return a}function f(){i(F,m);let e=h();if(i(M))return e;if(i(B)){let t=function(){let e,t=[],n=0;for(;r(v);){s("*")&&(e=n);let r=d(),i=c();if(null===i)throw new m("Expected variant value");t[n++]={key:r,value:i}}if(0===n)return null;if(void 0===e)throw new m("Expected default variant");return{variants:t,star:e}}();return i(M,m),{type:"select",selector:e,...t}}throw new m("Unclosed placeable")}function h(){if("{"===e[n])return f();if(r($)){let[,t,r,s=null]=o($);if("$"===t)return{type:"var",name:r};if(i(z)){let o=function(){let t=[];for(;;){switch(e[n]){case")":return n++,t;case void 0:throw new m("Unclosed argument list")}t.push(p()),i(D)}}();if("-"===t)return{type:"term",name:r,attr:s,args:o};if(E.test(r))return{type:"func",name:r,args:o};throw new m("Function names must be all upper-case")}return"-"===t?{type:"term",name:r,attr:s,args:[]}:{type:"mesg",name:r,attr:s}}return g()}function p(){let e=h();return"mesg"!==e.type?e:i(Z)?{type:"narg",name:e.name,value:g()}:e}function d(){i(T,m);let e=r(b)?P():u(x);return i(U,m),e}function g(){if(r(b))return P();if('"'===e[n])return function(){s('"',m);let t="";for(;;){if(t+=u(I),"\\"!==e[n]){if(s('"'))return{type:"str",value:t};throw new m("Unclosed string literal")}t+=W()}}();throw new m("Invalid expression")}function P(){let[,e,t=""]=o(b),n=t.length;return{type:"num",value:parseFloat(e),precision:n}}function W(){if(r(O))return u(O);if(r(j)){let[,e,t]=o(j),n=parseInt(e||t,16);return n<=55295||57344<=n?String.fromCodePoint(n):"�"}throw new m("Unknown escape sequence")}function C(){let t=n;switch(i(N),e[n]){case".":case"[":case"*":case"}":case void 0:return!1;case"{":return J(e.slice(t,n))}return" "===e[n-1]&&J(e.slice(t,n))}function q(e,t){return e.replace(t,"")}function J(e){return{type:"indent",value:e.replace(A,"\n"),length:S.exec(e)[1].length}}}}class W{constructor(e,{functions:t={},useIsolating:n=!0,transform:r=(e=>e)}={}){this.locales=Array.isArray(e)?e:[e],this._terms=new Map,this._messages=new Map,this._functions=t,this._useIsolating=n,this._transform=r,this._intls=new WeakMap}get messages(){return this._messages[Symbol.iterator]()}hasMessage(e){return this._messages.has(e)}getMessage(e){return this._messages.get(e)}addMessages(e,t){const n=P.fromString(e);return this.addResource(n,t)}addResource(e,{allowOverrides:t=!1}={}){const n=[];for(const[r,s]of e)if(r.startsWith("-")){if(!1===t&&this._terms.has(r)){n.push(`Attempt to override an existing term: "${r}"`);continue}this._terms.set(r,s)}else{if(!1===t&&this._messages.has(r)){n.push(`Attempt to override an existing message: "${r}"`);continue}this._messages.set(r,s)}return n}format(e,t,n){return"string"==typeof e?this._transform(e):null===e||null===e.value?null:"string"==typeof e.value?this._transform(e.value):function(e,t,n,r=[]){return g({bundle:e,args:t,errors:r,dirty:new WeakSet,insideTermReference:!1},n).toString(e)}(this,t,e,n)}_memoizeIntlObject(e,t){const n=this._intls.get(e)||{},r=JSON.stringify(t);return n[r]||(n[r]=new e(this.locales,t),this._intls.set(e,n)),n[r]}}function C(e,t){var n="";return n=n+e+" =",t&&t.indexOf("\n")>-1?(n+="\n ",n+=t.split("\n").join("\n ")):n=n+" "+t,n}function q(e){return!/^\s*$/.test(e)}function J(e){const[t]=e.match(/^\s*/);return t.length}class K{constructor(e,t){this.i18next=e,this.options=t,this.bundles={}}createBundle(t,n,r){const s=r?function(e,t){var n="";return Object.keys(e).forEach((function(t){var r=e[t];"string"==typeof r?(n+=C(t,r),n+="\n\n"):(r.comment&&(n+=function(e){var t="";return(t=t+"# "+e.split("\n").join("\n# "))+"\n"}(r.comment)),n+=C(t,r.val),Object.keys(r).forEach((function(e){if("comment"!==e&&"val"!==e){var t=r[e];n+=C("\n ."+e,t)}})),n+="\n\n")})),t&&t(null,n),n}(r):"",i=new W(t,this.options.fluentBundleOptions);i.addMessages(function(e){const t=e.split("\n").filter(q),n=t.map(J),r=Math.min(...n),s=new RegExp(`^\\s{${r}}`);return t.map((e=>e.replace(s,""))).join("\n")}(s)),function(t,n,r){const{obj:s,k:i}=e(t,n,Object);s[i]=r}(this.bundles,[t,n],i)}createBundleFromI18next(e,n){this.createBundle(e,n,t(this.i18next.store.data,[e,n]))}getBundle(e,n){return t(this.bundles,[e,n])}bind(){this.i18next.store.on("added",((e,t)=>{this.i18next.isInitialized&&this.createBundleFromI18next(e,t)})),this.i18next.on("initialized",(()=>{var e=this.i18next.languages||[],t=this.i18next.options.preload||[];e.filter((e=>!t.includes(e))).concat(t).forEach((e=>{this.i18next.options.ns.forEach((t=>{this.createBundleFromI18next(e,t)}))}))}))}}class L{constructor(e){this.type="i18nFormat",this.handleAsObject=!1,this.init(null,e)}init(e,t){const n=e&&e.options&&e.options.i18nFormat||{};this.options=function(e){return r.call(s.call(arguments,1),(function(t){if(t)for(var n in t)void 0===e[n]&&(e[n]=t[n])})),e}(n,t,this.options||{},{bindI18nStore:!0,fluentBundleOptions:{useIsolating:!1}}),e?(this.store=new K(e,this.options),this.options.bindI18nStore&&this.store.bind(),e.fluent=this):this.store=new K(null,this.options)}parse(e,t,n,r,s,i){const o=this.store.getBundle(n,r),u=s.indexOf(".")>-1;if(!e)return s;const a=u?e.attrs[s.split(".")[1]]:e;return o?o.format(a,t):s}getResource(e,t,n,r){let s=this.store.getBundle(e,t);const i=n.indexOf(".")>-1?n.split(".")[0]:n;if(s)return s.getMessage(i)}addLookupKeys(e,t,n,r,s){return e}}return L.type="i18nFormat",L})); diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..84a9a0f --- /dev/null +++ b/index.d.ts @@ -0,0 +1,25 @@ +declare module "i18next-fluent" { + import { i18n, ThirdPartyModule } from "i18next"; + + + export interface FluentConfig { + bindI18nStore?: boolean, + fluentBundleOptions?: { + useIsolating?: boolean + } + } + + export interface FluentInstance extends ThirdPartyModule { + init(i18next: i18n, options?: TOptions): void; + } + + interface FluentConstructor { + new (config?: FluentConfig): FluentInstance; + type: "i18nFormat"; + } + + const Fluent: FluentConstructor; + + + export default Fluent; +} diff --git a/mocha_setup.js b/mocha_setup.js index 341fe97..8ae22cb 100644 --- a/mocha_setup.js +++ b/mocha_setup.js @@ -1,11 +1,10 @@ "use strict"; -require("@babel/polyfill"); require("@babel/register")({ ignore: [ // Ignore node_modules other than own Fluent dependencies. - path => /node_modules/.test(path) - && !/node_modules\/fluent/.test(path) + (path) => + /node_modules/.test(path) && !/node_modules\/@fluent\/bundle/.test(path), ], plugins: [ "@babel/plugin-proposal-async-generator-functions", @@ -15,4 +14,4 @@ require("@babel/register")({ }); var chai = require("chai"); -global.expect = chai.expect; \ No newline at end of file +global.expect = chai.expect; diff --git a/package.json b/package.json index 2974006..e98e04d 100644 --- a/package.json +++ b/package.json @@ -16,32 +16,31 @@ "url": "https://github.com/i18next/i18next-fluent" }, "dependencies": { - "fluent": "^0.8.0", - "fluent_conv": "^1.1.1" + "@fluent/bundle": "^0.13.0", + "fluent_conv": "^3.1.0" }, "devDependencies": { - "@babel/cli": "^7.0.0", - "@babel/core": "^7.0.0", - "@babel/plugin-proposal-async-generator-functions": "^7.0.0", - "@babel/plugin-proposal-object-rest-spread": "^7.0.0", - "@babel/plugin-transform-modules-commonjs": "^7.0.0-", - "@babel/polyfill": "^7.0.0", - "@babel/preset-env": "^7.0.0", - "@babel/register": "^7.0.0", - "babel-eslint": "^9.0.0", - "chai": "^4.1.2", - "eslint": "^5.5.0", - "eslint-plugin-mocha": "^5.2.0", - "i18next": "^11.8.0", - "mocha": "^5.2.0", - "rimraf": "2.6.2", - "rollup": "0.65.2", - "rollup-plugin-babel": "^4.0.3", - "rollup-plugin-commonjs": "^9.1.6", - "rollup-plugin-node-resolve": "3.4.0", - "rollup-plugin-terser": "^2.0.2", - "sinon": "6.2.0", - "yargs": "12.0.2" + "@babel/cli": "^7.15.7", + "@babel/core": "^7.15.5", + "@babel/plugin-proposal-async-generator-functions": "^7.15.0", + "@babel/plugin-proposal-object-rest-spread": "^7.15.6", + "@babel/plugin-transform-modules-commonjs": "^7.15.4-", + "@babel/preset-env": "^7.15.6", + "@babel/register": "^7.15.3", + "babel-eslint": "^10.1.0", + "chai": "^4.3.4", + "eslint": "^7.32.0", + "eslint-plugin-mocha": "^9.0.0", + "i18next": "^21.0.2", + "mocha": "^9.1.1", + "rimraf": "3.0.2", + "rollup": "2.57.0", + "@rollup/plugin-babel": "^5.3.0", + "@rollup/plugin-commonjs": "^20.0.0", + "@rollup/plugin-node-resolve": "13.0.5", + "rollup-plugin-terser": "^7.0.2", + "sinon": "11.1.2", + "yargs": "17.2.0" }, "scripts": { "test": "mocha -r ./mocha_setup.js", diff --git a/rollup.config.js b/rollup.config.js index 45ef421..2f0719f 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,6 +1,6 @@ -import babel from 'rollup-plugin-babel'; -import commonjs from 'rollup-plugin-commonjs'; -import nodeResolve from 'rollup-plugin-node-resolve'; +import babel from '@rollup/plugin-babel'; +import commonjs from '@rollup/plugin-commonjs'; +import nodeResolve from '@rollup/plugin-node-resolve'; import { terser } from "rollup-plugin-terser"; import { argv } from 'yargs'; @@ -29,7 +29,5 @@ export default { name: 'i18nextFluent', format, file - }, - - + } }; diff --git a/src/index.js b/src/index.js index 08d00ce..814fb3a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,11 +1,11 @@ import * as utils from './utils.js'; -import { FluentBundle } from 'fluent'; -import js2ftl from 'fluent_conv/js2ftl'; +import { FluentBundle } from '@fluent/bundle'; +import { js2ftl } from 'fluent_conv'; function getDefaults() { return { - bindI18nextStore: true, - fluentBundleOptions: { useIsolating: false } + bindI18nStore: true, + fluentBundleOptions: { useIsolating: false }, }; } @@ -53,7 +53,7 @@ class BundleStore { } getBundle(lng, ns) { - return utils.getPath(this.bundles, [lng, ns]) + return utils.getPath(this.bundles, [lng, ns]); } bind() { @@ -67,13 +67,13 @@ class BundleStore { var preload = this.i18next.options.preload || []; lngs - .filter(l => !preload.includes(l)) + .filter((l) => !preload.includes(l)) .concat(preload) - .forEach(lng => { - this.i18next.options.ns.forEach(ns => { - this.createBundleFromI18next(lng, ns); + .forEach((lng) => { + this.i18next.options.ns.forEach((ns) => { + this.createBundleFromI18next(lng, ns); + }); }); - }) }); } } @@ -87,12 +87,13 @@ class Fluent { } init(i18next, options) { - const i18nextOptions = (i18next && i18next.options && i18next.options.i18nFormat) || {}; + const i18nextOptions = + (i18next && i18next.options && i18next.options.i18nFormat) || {}; this.options = utils.defaults(i18nextOptions, options, this.options || {}, getDefaults()); if (i18next) { this.store = new BundleStore(i18next, this.options); - if (this.options.bindI18nextStore) this.store.bind(); + if (this.options.bindI18nStore) this.store.bind(); i18next.fluent = this; } else { @@ -128,5 +129,4 @@ class Fluent { Fluent.type = 'i18nFormat'; - export default Fluent; diff --git a/test/fuent.spec.js b/test/fuent.spec.js index ff08263..fc324da 100644 --- a/test/fuent.spec.js +++ b/test/fuent.spec.js @@ -1,15 +1,15 @@ import Fluent from "../src/"; import i18next from "i18next"; -import { FluentBundle, ftl } from "fluent"; +import { FluentBundle, ftl } from "@fluent/bundle"; const testJSON = { emails: "{ $unreadEmails ->\n [0] You have no unread emails.\n [one] You have one unread email.\n *[other] You have { $unreadEmails } unread emails.\n}", - "-brand-name": "{\n *[nominative] Firefox\n [accusative] Firefoxa\n}", + "-brand-name": "{\n $case -> *[nominative] Firefox\n [accusative] Firefoxa\n}", "-another-term": "another term", "app-title": "{ -brand-name }", - "restart-app": "Zrestartuj { -brand-name[accusative] }.", + "restart-app": 'Zrestartuj { -brand-name(case: "accusative") }.', login: { comment: "Note: { $title } is a placeholder for the title of the web page\ncaptured in the screenshot. The default, for pages without titles, is\ncreating-page-title-default.", @@ -28,7 +28,7 @@ describe("fluent format", () => { before(() => { fluent = new Fluent({ - bindI18nextStore: false + bindI18nStore: false }); fluent.store.createBundle("en", "translations", testJSON);