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;
+
+}));