diff --git a/src/assets/index.html b/src/assets/index.html index b066d79..360d471 100644 --- a/src/assets/index.html +++ b/src/assets/index.html @@ -453,6 +453,7 @@ + diff --git a/src/assets/javascripts/fluent.js b/src/assets/javascripts/fluent.js new file mode 100644 index 0000000..b5a868f --- /dev/null +++ b/src/assets/javascripts/fluent.js @@ -0,0 +1,1238 @@ +/** @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: + * // [] + * } + * ``` + */ + 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; + +}));