UNPKG

@fluent/bundle

Version:

Localization library for expressive translations.

1,239 lines (1,229 loc) 49.9 kB
/** @fluent/bundle@0.19.1 */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define('@fluent/bundle', ['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.FluentBundle = {})); })(this, (function (exports) { 'use strict'; /** * The `FluentType` class is the base of Fluent's type system. * * Fluent types wrap JavaScript values and store additional configuration for * them, which can then be used in the `toString` method together with a proper * `Intl` formatter. */ class FluentType { /** * Create a `FluentType` instance. * * @param value The JavaScript value to wrap. */ constructor(value) { this.value = value; } /** * Unwrap the raw value stored by this `FluentType`. */ valueOf() { return this.value; } } /** * A {@link FluentType} representing no correct value. */ class FluentNone extends FluentType { /** * Create an instance of `FluentNone` with an optional fallback value. * @param value The fallback value of this `FluentNone`. */ constructor(value = "???") { super(value); } /** * Format this `FluentNone` to the fallback string. */ toString(scope) { return `{${this.value}}`; } } /** * A {@link FluentType} representing a number. * * A `FluentNumber` instance stores the number value of the number it * represents. It may also store an option bag of options which will be passed * to `Intl.NumerFormat` when the `FluentNumber` is formatted to a string. */ class FluentNumber extends FluentType { /** * Create an instance of `FluentNumber` with options to the * `Intl.NumberFormat` constructor. * * @param value The number value of this `FluentNumber`. * @param opts Options which will be passed to `Intl.NumberFormat`. */ constructor(value, opts = {}) { super(value); this.opts = opts; } /** * Format this `FluentNumber` to a string. */ toString(scope) { if (scope) { try { const nf = scope.memoizeIntlObject(Intl.NumberFormat, this.opts); return nf.format(this.value); } catch (err) { scope.reportError(err); } } return this.value.toString(10); } } /** * A {@link FluentType} representing a date and time. * * A `FluentDateTime` instance stores a Date object, Temporal object, or a number * as a numerical timestamp in milliseconds. It may also store an * option bag of options which will be passed to `Intl.DateTimeFormat` when the * `FluentDateTime` is formatted to a string. */ class FluentDateTime extends FluentType { static supportsValue(value) { if (typeof value === "number") return true; if (value instanceof Date) return true; if (value instanceof FluentType) return FluentDateTime.supportsValue(value.valueOf()); // Temporary workaround to support environments without Temporal if ("Temporal" in globalThis) { // for TypeScript, which doesn't know about Temporal yet const _Temporal = globalThis.Temporal; if (value instanceof _Temporal.Instant || value instanceof _Temporal.PlainDateTime || value instanceof _Temporal.PlainDate || value instanceof _Temporal.PlainMonthDay || value instanceof _Temporal.PlainTime || value instanceof _Temporal.PlainYearMonth) { return true; } } return false; } /** * Create an instance of `FluentDateTime` with options to the * `Intl.DateTimeFormat` constructor. * * @param value The number value of this `FluentDateTime`, in milliseconds. * @param opts Options which will be passed to `Intl.DateTimeFormat`. */ constructor(value, opts = {}) { // unwrap any FluentType value, but only retain the opts from FluentDateTime if (value instanceof FluentDateTime) { opts = { ...value.opts, ...opts }; value = value.value; } else if (value instanceof FluentType) { value = value.valueOf(); } // Intl.DateTimeFormat defaults to gregorian calendar, but Temporal defaults to iso8601 if (typeof value === "object" && "calendarId" in value && opts.calendar === undefined) { opts = { ...opts, calendar: value.calendarId }; } super(value); this.opts = opts; } [Symbol.toPrimitive](hint) { return hint === "string" ? this.toString() : this.toNumber(); } /** * Convert this `FluentDateTime` to a number. * Note that this isn't always possible due to the nature of Temporal objects. * In such cases, a TypeError will be thrown. */ toNumber() { const value = this.value; if (typeof value === "number") return value; if (value instanceof Date) return value.getTime(); if ("epochMilliseconds" in value) { return value.epochMilliseconds; } if ("toZonedDateTime" in value) { return value.toZonedDateTime("UTC").epochMilliseconds; } throw new TypeError("Unwrapping a non-number value as a number"); } /** * Format this `FluentDateTime` to a string. */ toString(scope) { if (scope) { try { const dtf = scope.memoizeIntlObject(Intl.DateTimeFormat, this.opts); return dtf.format(this.value); } catch (err) { scope.reportError(err); } } if (typeof this.value === "number" || this.value instanceof Date) { return new Date(this.value).toISOString(); } return this.value.toString(); } } /** * The role of the Fluent resolver is to format a `Pattern` to an instance of * `FluentValue`. For performance reasons, primitive strings are considered * such instances, too. * * Translations can contain references to other messages or variables, * conditional logic in form of select expressions, traits which describe their * grammatical features, and can use Fluent builtins which make use of the * `Intl` formatters to format numbers and dates into the bundle's languages. * See the documentation of the Fluent syntax for more information. * * In case of errors the resolver will try to salvage as much of the * translation as possible. In rare situations where the resolver didn't know * how to recover from an error it will return an instance of `FluentNone`. * * All expressions resolve to an instance of `FluentValue`. The caller should * use the `toString` method to convert the instance to a native value. * * Functions in this file pass around an instance of the `Scope` class, which * stores the data required for successful resolution and error recovery. */ /** * The maximum number of placeables which can be expanded in a single call to * `formatPattern`. The limit protects against the Billion Laughs and Quadratic * Blowup attacks. See https://msdn.microsoft.com/en-us/magazine/ee335713.aspx. */ const MAX_PLACEABLES = 100; /** Unicode bidi isolation characters. */ const FSI = "\u2068"; const PDI = "\u2069"; /** Helper: match a variant key to the given selector. */ function match(scope, selector, key) { if (key === selector) { // Both are strings. return true; } // XXX Consider comparing options too, e.g. minimumFractionDigits. if (key instanceof FluentNumber && selector instanceof FluentNumber && key.value === selector.value) { return true; } if (selector instanceof FluentNumber && typeof key === "string") { let category = scope .memoizeIntlObject(Intl.PluralRules, selector.opts) .select(selector.value); if (key === category) { return true; } } return false; } /** Helper: resolve the default variant from a list of variants. */ function getDefault(scope, variants, star) { if (variants[star]) { return resolvePattern(scope, variants[star].value); } scope.reportError(new RangeError("No default")); return new FluentNone(); } /** Helper: resolve arguments to a call expression. */ function getArguments(scope, args) { const positional = []; const named = Object.create(null); for (const arg of args) { if (arg.type === "narg") { named[arg.name] = resolveExpression(scope, arg.value); } else { positional.push(resolveExpression(scope, arg)); } } return { positional, named }; } /** Resolve an expression to a Fluent type. */ function resolveExpression(scope, expr) { switch (expr.type) { case "str": return expr.value; case "num": return new FluentNumber(expr.value, { minimumFractionDigits: expr.precision, }); case "var": return resolveVariableReference(scope, expr); case "mesg": return resolveMessageReference(scope, expr); case "term": return resolveTermReference(scope, expr); case "func": return resolveFunctionReference(scope, expr); case "select": return resolveSelectExpression(scope, expr); default: return new FluentNone(); } } /** Resolve a reference to a variable. */ function resolveVariableReference(scope, { name }) { let arg; if (scope.params) { // We're inside a TermReference. It's OK to reference undefined parameters. if (Object.prototype.hasOwnProperty.call(scope.params, name)) { arg = scope.params[name]; } else { return new FluentNone(`$${name}`); } } else if (scope.args && Object.prototype.hasOwnProperty.call(scope.args, name)) { // We're in the top-level Pattern or inside a MessageReference. Missing // variables references produce ReferenceErrors. arg = scope.args[name]; } else { scope.reportError(new ReferenceError(`Unknown variable: $${name}`)); return new FluentNone(`$${name}`); } // Return early if the argument already is an instance of FluentType. if (arg instanceof FluentType) { return arg; } // Convert the argument to a Fluent type. switch (typeof arg) { case "string": return arg; case "number": return new FluentNumber(arg); case "object": if (FluentDateTime.supportsValue(arg)) { return new FluentDateTime(arg); } // eslint-disable-next-line no-fallthrough default: scope.reportError(new TypeError(`Variable type not supported: $${name}, ${typeof arg}`)); return new FluentNone(`$${name}`); } } /** Resolve a reference to another message. */ function resolveMessageReference(scope, { name, attr }) { const message = scope.bundle._messages.get(name); if (!message) { scope.reportError(new ReferenceError(`Unknown message: ${name}`)); return new FluentNone(name); } if (attr) { const attribute = message.attributes[attr]; if (attribute) { return resolvePattern(scope, attribute); } scope.reportError(new ReferenceError(`Unknown attribute: ${attr}`)); return new FluentNone(`${name}.${attr}`); } if (message.value) { return resolvePattern(scope, message.value); } scope.reportError(new ReferenceError(`No value: ${name}`)); return new FluentNone(name); } /** Resolve a call to a Term with key-value arguments. */ function resolveTermReference(scope, { name, attr, args }) { const id = `-${name}`; const term = scope.bundle._terms.get(id); if (!term) { scope.reportError(new ReferenceError(`Unknown term: ${id}`)); return new FluentNone(id); } if (attr) { const attribute = term.attributes[attr]; if (attribute) { // Every TermReference has its own variables. scope.params = getArguments(scope, args).named; const resolved = resolvePattern(scope, attribute); scope.params = null; return resolved; } scope.reportError(new ReferenceError(`Unknown attribute: ${attr}`)); return new FluentNone(`${id}.${attr}`); } scope.params = getArguments(scope, args).named; const resolved = resolvePattern(scope, term.value); scope.params = null; return resolved; } /** Resolve a call to a Function with positional and key-value arguments. */ function resolveFunctionReference(scope, { name, args }) { // Some functions are built-in. Others may be provided by the runtime via // the `FluentBundle` constructor. let func = scope.bundle._functions[name]; if (!func) { scope.reportError(new ReferenceError(`Unknown function: ${name}()`)); return new FluentNone(`${name}()`); } if (typeof func !== "function") { scope.reportError(new TypeError(`Function ${name}() is not callable`)); return new FluentNone(`${name}()`); } try { let resolved = getArguments(scope, args); return func(resolved.positional, resolved.named); } catch (err) { scope.reportError(err); return new FluentNone(`${name}()`); } } /** Resolve a select expression to the member object. */ function resolveSelectExpression(scope, { selector, variants, star }) { let sel = resolveExpression(scope, selector); if (sel instanceof FluentNone) { return getDefault(scope, variants, star); } // Match the selector against keys of each variant, in order. for (const variant of variants) { const key = resolveExpression(scope, variant.key); if (match(scope, sel, key)) { return resolvePattern(scope, variant.value); } } return getDefault(scope, variants, star); } /** Resolve a pattern (a complex string with placeables). */ function resolveComplexPattern(scope, ptn) { if (scope.dirty.has(ptn)) { scope.reportError(new RangeError("Cyclic reference")); return new FluentNone(); } // Tag the pattern as dirty for the purpose of the current resolution. scope.dirty.add(ptn); const result = []; // Wrap interpolations with Directional Isolate Formatting characters // only when the pattern has more than one element. const useIsolating = scope.bundle._useIsolating && ptn.length > 1; for (const elem of ptn) { if (typeof elem === "string") { result.push(scope.bundle._transform(elem)); continue; } scope.placeables++; if (scope.placeables > MAX_PLACEABLES) { scope.dirty.delete(ptn); // This is a fatal error which causes the resolver to instantly bail out // on this pattern. The length check protects against excessive memory // usage, and throwing protects against eating up the CPU when long // placeables are deeply nested. throw new RangeError(`Too many placeables expanded: ${scope.placeables}, ` + `max allowed is ${MAX_PLACEABLES}`); } if (useIsolating) { result.push(FSI); } result.push(resolveExpression(scope, elem).toString(scope)); if (useIsolating) { result.push(PDI); } } scope.dirty.delete(ptn); return result.join(""); } /** * Resolve a simple or a complex Pattern to a FluentString * (which is really the string primitive). */ function resolvePattern(scope, value) { // Resolve a simple pattern. if (typeof value === "string") { return scope.bundle._transform(value); } return resolveComplexPattern(scope, value); } class Scope { constructor(bundle, errors, args) { /** * The Set of patterns already encountered during this resolution. * Used to detect and prevent cyclic resolutions. * @ignore */ this.dirty = new WeakSet(); /** A dict of parameters passed to a TermReference. */ this.params = null; /** * The running count of placeables resolved so far. * Used to detect the Billion Laughs and Quadratic Blowup attacks. * @ignore */ this.placeables = 0; this.bundle = bundle; this.errors = errors; this.args = args; } reportError(error) { if (!this.errors || !(error instanceof Error)) { throw error; } this.errors.push(error); } memoizeIntlObject(ctor, opts) { let cache = this.bundle._intls.get(ctor); if (!cache) { cache = {}; this.bundle._intls.set(ctor, cache); } let id = JSON.stringify(opts); if (!cache[id]) { // @ts-expect-error This is fine. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment cache[id] = new ctor(this.bundle.locales, opts); } return cache[id]; } } /** * @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 * `FluentValue`. Functions must return `FluentValues` as well. */ function values(opts, allowed) { const unwrapped = Object.create(null); for (const [name, opt] of Object.entries(opts)) { if (allowed.includes(name)) { unwrapped[name] = opt.valueOf(); } } return unwrapped; } const NUMBER_ALLOWED = [ "unitDisplay", "currencyDisplay", "useGrouping", "minimumIntegerDigits", "minimumFractionDigits", "maximumFractionDigits", "minimumSignificantDigits", "maximumSignificantDigits", ]; /** * The implementation of the `NUMBER()` builtin available to translations. * * Translations may call the `NUMBER()` builtin in order to specify formatting * options of a number. For example: * * pi = The value of π is {NUMBER($pi, maximumFractionDigits: 2)}. * * The implementation expects an array of {@link FluentValue | FluentValues} representing the * positional arguments, and an object of named {@link FluentValue | FluentValues} representing the * named parameters. * * The following options are recognized: * * unitDisplay * currencyDisplay * useGrouping * minimumIntegerDigits * minimumFractionDigits * maximumFractionDigits * minimumSignificantDigits * maximumSignificantDigits * * Other options are ignored. * * @param args The positional arguments passed to this `NUMBER()`. * @param opts The named argments passed to this `NUMBER()`. */ function NUMBER(args, opts) { let arg = args[0]; if (arg instanceof FluentNone) { return new FluentNone(`NUMBER(${arg.valueOf()})`); } if (arg instanceof FluentNumber) { return new FluentNumber(arg.valueOf(), { ...arg.opts, ...values(opts, NUMBER_ALLOWED), }); } if (arg instanceof FluentDateTime) { return new FluentNumber(arg.toNumber(), { ...values(opts, NUMBER_ALLOWED), }); } throw new TypeError("Invalid argument to NUMBER"); } const DATETIME_ALLOWED = [ "dateStyle", "timeStyle", "fractionalSecondDigits", "dayPeriod", "hour12", "weekday", "era", "year", "month", "day", "hour", "minute", "second", "timeZoneName", ]; /** * The implementation of the `DATETIME()` builtin available to translations. * * Translations may call the `DATETIME()` builtin in order to specify * formatting options of a number. For example: * * now = It's {DATETIME($today, month: "long")}. * * The implementation expects an array of {@link FluentValue | FluentValues} representing the * positional arguments, and an object of named {@link FluentValue | FluentValues} representing the * named parameters. * * The following options are recognized: * * dateStyle * timeStyle * fractionalSecondDigits * dayPeriod * hour12 * weekday * era * year * month * day * hour * minute * second * timeZoneName * * Other options are ignored. * * @param args The positional arguments passed to this `DATETIME()`. * @param opts The named argments passed to this `DATETIME()`. */ function DATETIME(args, opts) { let arg = args[0]; if (arg instanceof FluentNone) { return new FluentNone(`DATETIME(${arg.valueOf()})`); } if (arg instanceof FluentDateTime || arg instanceof FluentNumber) { return new FluentDateTime(arg, values(opts, DATETIME_ALLOWED)); } throw new TypeError("Invalid argument to DATETIME"); } const cache = new Map(); function getMemoizerForLocale(locales) { const stringLocale = Array.isArray(locales) ? locales.join(" ") : locales; let memoizer = cache.get(stringLocale); if (memoizer === undefined) { memoizer = new Map(); cache.set(stringLocale, memoizer); } return memoizer; } /** * Message bundles are single-language stores of translation resources. They are * responsible for formatting message values and attributes to strings. */ class FluentBundle { /** * Create an instance of `FluentBundle`. * * @example * ```js * let bundle = new FluentBundle(["en-US", "en"]); * * let bundle = new FluentBundle(locales, {useIsolating: false}); * * let bundle = new FluentBundle(locales, { * useIsolating: true, * functions: { * NODE_ENV: () => process.env.NODE_ENV * } * }); * ``` * * @param locales - Used to instantiate `Intl` formatters used by translations. * @param options - Optional configuration for the bundle. */ constructor(locales, { functions, useIsolating = true, transform = (v) => v, } = {}) { /** @ignore */ this._terms = new Map(); /** @ignore */ this._messages = new Map(); this.locales = Array.isArray(locales) ? locales : [locales]; this._functions = { NUMBER, DATETIME, ...functions, }; this._useIsolating = useIsolating; this._transform = transform; this._intls = getMemoizerForLocale(locales); } /** * Check if a message is present in the bundle. * * @param id - The identifier of the message to check. */ hasMessage(id) { return this._messages.has(id); } /** * Return a raw unformatted message object from the bundle. * * Raw messages are `{value, attributes}` shapes containing translation units * called `Patterns`. `Patterns` are implementation-specific; they should be * treated as black boxes and formatted with `FluentBundle.formatPattern`. * * @param id - The identifier of the message to check. */ getMessage(id) { return this._messages.get(id); } /** * Add a translation resource to the bundle. * * @example * ```js * let res = new FluentResource("foo = Foo"); * bundle.addResource(res); * bundle.getMessage("foo"); * // → {value: .., attributes: {..}} * ``` * * @param res * @param options */ addResource(res, { allowOverrides = false, } = {}) { const errors = []; for (let i = 0; i < res.body.length; i++) { let entry = res.body[i]; if (entry.id.startsWith("-")) { // Identifiers starting with a dash (-) define terms. Terms are private // and cannot be retrieved from FluentBundle. if (allowOverrides === false && this._terms.has(entry.id)) { errors.push(new Error(`Attempt to override an existing term: "${entry.id}"`)); continue; } this._terms.set(entry.id, entry); } else { if (allowOverrides === false && this._messages.has(entry.id)) { errors.push(new Error(`Attempt to override an existing message: "${entry.id}"`)); continue; } this._messages.set(entry.id, entry); } } return errors; } /** * Format a `Pattern` to a string. * * Format a raw `Pattern` into a string. `args` will be used to resolve * references to variables passed as arguments to the translation. * * In case of errors `formatPattern` will try to salvage as much of the * translation as possible and will still return a string. For performance * reasons, the encountered errors are not returned but instead are appended * to the `errors` array passed as the third argument. * * If `errors` is omitted, the first encountered error will be thrown. * * @example * ```js * let errors = []; * bundle.addResource( * new FluentResource("hello = Hello, {$name}!")); * * let hello = bundle.getMessage("hello"); * if (hello.value) { * bundle.formatPattern(hello.value, {name: "Jane"}, errors); * // Returns "Hello, Jane!" and `errors` is empty. * * bundle.formatPattern(hello.value, undefined, errors); * // Returns "Hello, {$name}!" and `errors` is now: * // [<ReferenceError: Unknown variable: name>] * } * ``` */ formatPattern(pattern, args = null, errors = null) { // Resolve a simple pattern without creating a scope. No error handling is // required; by definition simple patterns don't have placeables. if (typeof pattern === "string") { return this._transform(pattern); } // Resolve a complex pattern. let scope = new Scope(this, errors, args); try { let value = resolveComplexPattern(scope, pattern); return value.toString(scope); } catch (err) { if (scope.errors && err instanceof Error) { scope.errors.push(err); return new FluentNone().toString(scope); } throw err; } } } // This regex is used to iterate through the beginnings of messages and terms. // With the /m flag, the ^ matches at the beginning of every line. const RE_MESSAGE_START = /^(-?[a-zA-Z][\w-]*) *= */gm; // Both Attributes and Variants are parsed in while loops. These regexes are // used to break out of them. const RE_ATTRIBUTE_START = /\.([a-zA-Z][\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. 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. const RE_BLANK_LINES = / *\r?\n/g; // Used in makeIndent to measure the indentation. 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. const TOKEN_COMMA = /\s*,?\s*/y; const TOKEN_BLANK = /\s+/y; /** * Fluent Resource is a structure storing parsed localization entries. */ class FluentResource { constructor(source) { this.body = []; RE_MESSAGE_START.lastIndex = 0; let cursor = 0; // Iterate over the beginnings of messages and terms to efficiently skip // comments and recover from errors. while (true) { let next = RE_MESSAGE_START.exec(source); if (next === null) { break; } cursor = RE_MESSAGE_START.lastIndex; try { this.body.push(parseMessage(next[1])); } catch (err) { if (err instanceof SyntaxError) { // Don't report any Fluent syntax errors. Skip directly to the // beginning of the next message or term. continue; } throw err; } } // The parser implementation is inlined below for performance reasons, // as well as for convenience of accessing `source` and `cursor`. // The parser focuses on minimizing the number of false negatives at the // expense of increasing the risk of false positives. In other words, it // aims at parsing valid Fluent messages with a success rate of 100%, but it // may also parse a few invalid messages which the reference parser would // reject. The parser doesn't perform any validation and may produce entries // which wouldn't make sense in the real world. For best results users are // advised to validate translations with the fluent-syntax parser // pre-runtime. // The parser makes an extensive use of sticky regexes which can be anchored // to any offset of the source string without slicing it. Errors are thrown // to bail out of parsing of ill-formed messages. function test(re) { re.lastIndex = cursor; return re.test(source); } // Advance the cursor by the char if it matches. May be used as a predicate // (was the match found?) or, if errorClass is passed, as an assertion. function consumeChar(char, errorClass) { if (source[cursor] === char) { cursor++; return true; } if (errorClass) { throw new errorClass(`Expected ${char}`); } return false; } // Advance the cursor by the token if it matches. May be used as a predicate // (was the match found?) or, if errorClass is passed, as an assertion. function consumeToken(re, errorClass) { if (test(re)) { cursor = re.lastIndex; return true; } if (errorClass) { throw new errorClass(`Expected ${re.toString()}`); } return false; } // Execute a regex, advance the cursor, and return all capture groups. function match(re) { re.lastIndex = cursor; let result = re.exec(source); if (result === null) { throw new SyntaxError(`Expected ${re.toString()}`); } cursor = re.lastIndex; return result; } // Execute a regex, advance the cursor, and return the capture group. function match1(re) { return match(re)[1]; } function parseMessage(id) { let value = parsePattern(); let attributes = parseAttributes(); if (value === null && Object.keys(attributes).length === 0) { throw new SyntaxError("Expected message value or attributes"); } return { id, value, attributes }; } function parseAttributes() { let attrs = Object.create(null); while (test(RE_ATTRIBUTE_START)) { let name = match1(RE_ATTRIBUTE_START); let value = parsePattern(); if (value === null) { throw new SyntaxError("Expected attribute value"); } attrs[name] = value; } return attrs; } function parsePattern() { let first; // First try to parse any simple text on the same line as the id. if (test(RE_TEXT_RUN)) { first = match1(RE_TEXT_RUN); } // If there's a placeable on the first line, parse a complex pattern. 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. let indent = parseIndent(); 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. indent.value = trim(indent.value, RE_LEADING_NEWLINES); return parsePatternElements([indent], indent.length); } if (first) { // It was just a simple inline text after all. return trim(first, RE_TRAILING_SPACES); } return null; } // Parse a complex pattern as an array of elements. function parsePatternElements(elements = [], commonIndent) { while (true) { if (test(RE_TEXT_RUN)) { elements.push(match1(RE_TEXT_RUN)); continue; } if (source[cursor] === "{") { elements.push(parsePlaceable()); continue; } if (source[cursor] === "}") { throw new SyntaxError("Unbalanced closing brace"); } let indent = parseIndent(); if (indent) { elements.push(indent); commonIndent = Math.min(commonIndent, indent.length); continue; } break; } let lastIndex = elements.length - 1; let lastElement = elements[lastIndex]; // Trim the trailing spaces in the last element if it's a TextElement. if (typeof lastElement === "string") { elements[lastIndex] = trim(lastElement, RE_TRAILING_SPACES); } let baked = []; for (let element of elements) { if (element instanceof Indent) { // Dedent indented lines by the maximum common indent. element = element.value.slice(0, element.value.length - commonIndent); } if (element) { baked.push(element); } } return baked; } function parsePlaceable() { consumeToken(TOKEN_BRACE_OPEN, SyntaxError); let selector = parseInlineExpression(); if (consumeToken(TOKEN_BRACE_CLOSE)) { return selector; } if (consumeToken(TOKEN_ARROW)) { let variants = parseVariants(); consumeToken(TOKEN_BRACE_CLOSE, SyntaxError); return { type: "select", selector, ...variants, }; } throw new SyntaxError("Unclosed placeable"); } function parseInlineExpression() { if (source[cursor] === "{") { // It's a nested placeable. return parsePlaceable(); } if (test(RE_REFERENCE)) { let [, sigil, name, attr = null] = match(RE_REFERENCE); if (sigil === "$") { return { type: "var", name }; } if (consumeToken(TOKEN_PAREN_OPEN)) { let args = parseArguments(); if (sigil === "-") { // A parameterized term: -term(...). return { type: "term", name, attr, args }; } if (RE_FUNCTION_NAME.test(name)) { return { type: "func", name, args }; } throw new SyntaxError("Function names must be all upper-case"); } if (sigil === "-") { // A non-parameterized term: -term. return { type: "term", name, attr, args: [], }; } return { type: "mesg", name, attr }; } return parseLiteral(); } function parseArguments() { let args = []; while (true) { switch (source[cursor]) { case ")": // End of the argument list. cursor++; return args; case undefined: // EOF throw new SyntaxError("Unclosed argument list"); } args.push(parseArgument()); // Commas between arguments are treated as whitespace. consumeToken(TOKEN_COMMA); } } function parseArgument() { let expr = parseInlineExpression(); if (expr.type !== "mesg") { return expr; } 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. return expr; } function parseVariants() { let variants = []; let count = 0; let star; while (test(RE_VARIANT_START)) { if (consumeChar("*")) { star = count; } let key = parseVariantKey(); let value = parsePattern(); if (value === null) { throw new SyntaxError("Expected variant value"); } variants[count++] = { key, value }; } if (count === 0) { return null; } if (star === undefined) { throw new SyntaxError("Expected default variant"); } return { variants, star }; } function parseVariantKey() { consumeToken(TOKEN_BRACKET_OPEN, SyntaxError); let key; if (test(RE_NUMBER_LITERAL)) { key = parseNumberLiteral(); } else { key = { type: "str", value: match1(RE_IDENTIFIER), }; } consumeToken(TOKEN_BRACKET_CLOSE, SyntaxError); return key; } function parseLiteral() { if (test(RE_NUMBER_LITERAL)) { return parseNumberLiteral(); } if (source[cursor] === '"') { return parseStringLiteral(); } throw new SyntaxError("Invalid expression"); } function parseNumberLiteral() { let [, value, fraction = ""] = match(RE_NUMBER_LITERAL); let precision = fraction.length; return { type: "num", value: parseFloat(value), precision, }; } function parseStringLiteral() { consumeChar('"', SyntaxError); let value = ""; while (true) { value += match1(RE_STRING_RUN); if (source[cursor] === "\\") { value += parseEscapeSequence(); continue; } if (consumeChar('"')) { return { type: "str", value }; } // We've reached an EOL of EOF. throw new SyntaxError("Unclosed string literal"); } } // Unescape known escape sequences. 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. "�"; } throw new SyntaxError("Unknown escape sequence"); } // Parse blank space. Return it if it looks like indent before a pattern // line. Skip it othwerwise. function parseIndent() { let start = cursor; consumeToken(TOKEN_BLANK); // Check the first non-blank character after the indent. switch (source[cursor]) { case ".": case "[": case "*": case "}": case undefined: // EOF // A special character. End the Pattern. return false; case "{": // Placeables don't require indentation (in EBNF: block-placeable). // Continue the Pattern. return 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 (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. return false; } // Trim blanks in text according to the given regex. function trim(text, re) { return text.replace(re, ""); } // Normalize a blank block and extract the indent details. function makeIndent(blank) { let value = blank.replace(RE_BLANK_LINES, "\n"); let length = RE_INDENT.exec(blank)[1].length; return new Indent(value, length); } } } class Indent { constructor(value, length) { this.value = value; this.length = length; } } exports.FluentBundle = FluentBundle; exports.FluentDateTime = FluentDateTime; exports.FluentNone = FluentNone; exports.FluentNumber = FluentNumber; exports.FluentResource = FluentResource; exports.FluentType = FluentType; }));