Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
});
/**
* @typedef L10nCachedMessage
* A cached L10n message object is similar to `L10nMessage` (defined in
* Localization.webidl) but its attributes are stored differently for
* convenience.
*
* For example, if we cache these strings from an ftl file:
*
* foo = Foo's value
* bar =
* .label = Bar's label value
*
* Then:
*
* cache.get("foo")
* // => { value: "Foo's value", attributes: null }
* cache.get("bar")
* // => { value: null, attributes: { label: "Bar's label value" }}
* @property {string} [value]
* The bare value of the string. If the string does not have a bare value
* (i.e., it has only attributes), this will be null.
* @property {{[key: string]: string}|null} [attributes]
* A mapping from attribute names to their values. If the string doesn't have
* any attributes, this will be null.
*/
/**
* This class implements a cache for l10n strings. Cached strings can be
* accessed synchronously, avoiding the asynchronicity of `data-l10n-id` and
* `document.l10n.setAttributes`, which can lead to text pop-in and flickering
* as strings are fetched from Fluent. (`document.l10n.formatValueSync` is also
* sync but should not be used since it may perform sync I/O.)
*
* Values stored and returned by the cache are JS objects similar to
* `L10nMessage` objects, not bare strings. This allows the cache to store not
* only l10n strings with bare values but also strings that define attributes
* (e.g., ".label = My label value"). See `get` for details.
*
* The cache stores up to `MAX_ENTRIES_PER_ID` entries per l10n ID, and entries
* are sorted from least recently cached to most recently cached. This only
* matters for strings that have arguments. For strings that don't have
* arguments, there can be only one cached value, so there can be only one cache
* entry. But for strings that do have arguments, their cached values depend on
* the arguments they were cached with. The cache will store up to
* `MAX_ENTRIES_PER_ID` of the most recently cached values for a given l10n ID.
*
* For example, given the following string from an ftl file:
*
* foo = My arg value is { $bar }
*
* And the following cache calls:
*
* cache.add({ id: "foo", args: { bar: "aaa" }});
* cache.add({ id: "foo", args: { bar: "bbb" }});
* cache.add({ id: "foo", args: { bar: "ccc" }});
*
* Then three different versions of the "foo" string will be cached, from least
* recently cached to most recently cached:
*
* "My arg value is aaa"
* "My arg value is bbb"
* "My arg value is ccc"
*
* If `MAX_ENTRIES_PER_ID` is 3 and we cache a fourth version like this:
*
* cache.add({ id: "foo", args: { bar: "zzz" }});
*
* Then the least recently cached version -- the "aaa" one -- will be evicted
* from the cache, and the remaining cached versions will be:
*
* "My arg value is bbb"
* "My arg value is ccc"
* "My arg value is zzz"
*/
export class L10nCache {
static MAX_ENTRIES_PER_ID = 5;
/**
* @param {Localization} [l10n]
* [test-only] A `Localization` to use instead of `document.l10n`.
*/
constructor(l10n) {
this.l10n = l10n ?? document.l10n;
this.QueryInterface = ChromeUtils.generateQI([
"nsIObserver",
"nsISupportsWeakReference",
]);
Services.obs.addObserver(this, "intl:app-locales-changed", true);
}
/**
* Gets a cached l10n message.
*
* @param {object} options
* Options
* @param {string} options.id
* The string's Fluent ID.
* @param {object} [options.args]
* The Fluent arguments as passed to `l10n.setAttributes`. Required if the
* l10n string has arguments.
* @returns {L10nCachedMessage|null}
* The cached message or null if it's not cached.
*/
get({ id, args = undefined }) {
return this.#messagesByArgsById.get(id)?.get(this.#argsKey(args)) ?? null;
}
/**
* Fetches a string from Fluent and caches it.
*
* @param {object} options
* Options
* @param {string} options.id
* The string's Fluent ID.
* @param {object} [options.args]
* The Fluent arguments as passed to `l10n.setAttributes`. Required if the
* l10n string has arguments.
*/
async add({ id, args = undefined }) {
let messages = await this.l10n.formatMessages([{ id, args }]);
if (!messages?.length) {
console.error(
"l10n.formatMessages returned an unexpected value for ID: ",
id
);
return;
}
/** @type {L10nCachedMessage} */
let message = { value: messages[0].value, attributes: null };
if (messages[0].attributes) {
// Convert `attributes` from an array of `{ name, value }` objects to one
// object mapping names to values.
message.attributes = messages[0].attributes.reduce((valuesByName, a) => {
valuesByName[a.name] = a.value;
return valuesByName;
}, {});
}
this.#update({ id, args, message });
}
/**
* Ensures that a string is the most recently cached for its ID. If the string
* is not already cached, then it's fetched from Fluent. This is just a slight
* optimization over `add` that avoids calling into Fluent unnecessarily.
*
* @param {object} options
* Options
* @param {string} options.id
* The string's Fluent ID.
* @param {object} [options.args]
* The Fluent arguments as passed to `l10n.setAttributes`. Required if the
* l10n string has arguments.
*/
async ensure({ id, args = undefined }) {
let message = this.get({ id, args });
if (message) {
this.#update({ id, args, message });
} else {
await this.add({ id, args });
}
}
/**
* A version of `ensure` that ensures multiple strings are cached at once.
*
* @param {object[]} objects
* An array of objects as passed to `ensure()`.
*/
async ensureAll(objects) {
let promises = [];
for (let obj of objects) {
promises.push(this.ensure(obj));
}
await Promise.all(promises);
}
/**
* Removes a cached string.
*
* @param {object} options
* Options
* @param {string} options.id
* The string's Fluent ID.
* @param {object} [options.args]
* The Fluent arguments as passed to `l10n.setAttributes`. Required if the
* l10n string has arguments.
*/
delete({ id, args = undefined }) {
let messagesByArgs = this.#messagesByArgsById.get(id);
if (messagesByArgs) {
messagesByArgs.delete(this.#argsKey(args));
if (!messagesByArgs.size) {
this.#messagesByArgsById.delete(id);
}
}
}
/**
* Removes all cached strings.
*/
clear() {
this.#messagesByArgsById.clear();
}
/**
* Returns the number of cached messages.
*/
size() {
return this.#messagesByArgsById
.values()
.reduce((total, messagesByArg) => total + messagesByArg.size, 0);
}
/**
* Sets an element's content or attribute to a cached l10n string. If the
* string isn't cached, then this falls back to the usual
* `document.l10n.setAttributes()` using the given l10n ID and args, which
* means the string will pop in on a later animation frame.
*
* This also caches the string so that it will be ready the next time. It
* returns a promise that will be resolved when the string has been cached.
* Typically there's no need to await it unless you want to be sure the string
* is cached before continuing.
*
* @param {Element} element
* The l10n string will be applied to this element.
* @param {object} options
* Options object.
* @param {string} options.id
* The l10n string ID.
* @param {object} [options.args]
* The l10n string arguments.
* @param {object} [options.argsHighlights]
* If this is set, apply substring highlighting to the corresponding l10n
* arguments in `args`. Each value in this object should be an array of
* highlights as returned by `UrlbarUtils.getTokenMatches()` or
* `UrlbarResult.getDisplayableValueAndHighlights()`.
* @param {string} [options.attribute]
* If the string applies to an attribute on the element, pass the name of
* the attribute. The string in the Fluent file should define a value for
* the attribute, like ".foo = My value". If the string applies to the
* element's content, leave this undefined.
* @param {boolean} [options.parseMarkup]
* This controls whether the cached string is applied to the element's
* `textContent` or its `innerHTML`. It's not relevant if the string is
* applied to an attribute. Typically it should be set to true when the
* string is expected to contain markup. When true, the cached string is
* essentially assigned to the element's `innerHTML` via `setHTML`. Only
* a few elements and attributes are allowed. When false, it's assigned
* to the element's `textContent`.
* @returns {Promise}
* A promise that's resolved when the string has been cached. You can ignore
* it and do not need to await it unless you want to make sure the string is
* cached before continuing.
*/
setElementL10n(
element,
{
id,
args = undefined,
argsHighlights = undefined,
attribute = undefined,
parseMarkup = false,
}
) {
// If the message is cached, apply it to the element.
let message = this.get({ id, args });
if (message) {
if (message.attributes) {
for (let [key, value] of Object.entries(message.attributes)) {
element.setAttribute(key, value);
}
}
if (typeof message.value == "string") {
if (!parseMarkup) {
element.textContent = message.value;
} else {
element.setHTML(message.value, {
sanitizer: {
elements: ["a", "br", "em", "span", "strong"],
attributes: ["data-l10n-name", "href"],
},
});
}
}
}
// If the message isn't cached and args highlights were specified, apply
// them now.
if (!message && !attribute && argsHighlights) {
// To avoid contaminated args because we cache it, create a new instance.
args = { ...args };
let span = element.ownerDocument.createElement("span");
for (let key in argsHighlights) {
lazy.UrlbarUtils.addTextContentWithHighlights(
span,
args[key],
argsHighlights[key]
);
args[key] = span.innerHTML;
}
}
// If an attribute was passed in, make sure it's allowed to be localized by
// setting `data-l10n-attrs`. This isn't required for attrbutes already in
// the Fluent allowlist but it doesn't hurt.
if (attribute) {
element.setAttribute("data-l10n-attrs", attribute);
} else {
element.removeAttribute("data-l10n-attrs");
}
// Set the l10n attributes. If the message wasn't cached, `DOMLocalization`
// will do its asynchronous translation and the text content will pop in. If
// the message was cached, then we already set the cached attributes and
// text content above, but we set the l10n attributes anyway because some
// tests rely on them being set. It shouldn't hurt anyway.
element.ownerDocument.l10n.setAttributes(element, id, args);
// Cache the string. We specifically do not do this first and await it
// because the whole point of the l10n cache is to synchronously update the
// element's content when possible. Here, we return a promise rather than
// making this function async and awaiting so it's clearer to callers that
// they probably don't need to wait for caching to finish.
return this.ensure({ id, args });
}
/**
* Removes content and attributes set by `setElementL10n()`.
*
* @param {Element} element
* The content and attributes will be removed from this element.
* @param {object} [options]
* Options object.
* @param {string} [options.attribute]
* If you passed an attribute to `setElementL10n()`, pass it here too.
*/
removeElementL10n(element, { attribute = undefined } = {}) {
if (attribute) {
element.removeAttribute(attribute);
element.removeAttribute("data-l10n-attrs");
} else {
element.textContent = "";
}
element.removeAttribute("data-l10n-id");
element.removeAttribute("data-l10n-args");
}
/**
* Observer method from Services.obs.addObserver.
*
* @param {nsISupports} subject
* The subject of the notification.
* @param {string} topic
* The topic of the notification.
*/
async observe(subject, topic) {
switch (topic) {
case "intl:app-locales-changed": {
this.clear();
break;
}
}
}
/**
* L10n ID => l10n args cache key => cached message object
*
* We rely on the fact that `Map` remembers insertion order to keep track of
* which cache entries are least recent, per l10n ID. The inner `Map`s will
* iterate their entries in order from least recently inserted to most
* recently inserted, i.e., least recently cached to most recently cached.
*
* @type {Map<string, Map<string, L10nCachedMessage>>}
*/
#messagesByArgsById = new Map();
/**
* Max entries per l10n ID for this cache.
*
* @type {number}
*/
#maxEntriesPerId = L10nCache.MAX_ENTRIES_PER_ID;
/**
* Inserts a message into the cache and makes it most recently cached.
*
* @param {object} options
* Options
* @param {string} options.id
* The string's Fluent ID.
* @param {object} options.args
* The Fluent arguments as passed to `l10n.setAttributes`.
* @param {L10nCachedMessage} options.message
* The message to cache.
*/
#update({ id, args, message }) {
let messagesByArgs = this.#messagesByArgsById.get(id);
if (!messagesByArgs) {
messagesByArgs = new Map();
this.#messagesByArgsById.set(id, messagesByArgs);
}
// We rely on the fact that `Map` remembers insertion order to keep track of
// which cache entries are least recent. To make `message` the most recent
// for its ID, delete it from `messagesByArgs` (step 1) and then reinsert it
// (step 2). That way it will move to the end of iteration.
let argsKey = this.#argsKey(args);
// step 1
messagesByArgs.delete(argsKey);
if (messagesByArgs.size == this.#maxEntriesPerId) {
// The cache entries are full for this ID. Remove the least recently
// cached entry, which will be the first entry returned by the map's
// iterator.
messagesByArgs.delete(messagesByArgs.keys().next().value);
}
// step 2
messagesByArgs.set(argsKey, message);
}
/**
* Returns a cache key for the inner `Maps` inside `#messagesByArgsById`.
* These `Map`s are keyed on l10n args.
*
* @param {object} args
* The Fluent arguments as passed to `l10n.setAttributes`.
* @returns {string}
* The args cache key.
*/
#argsKey(args) {
// `JSON.stringify` doesn't guarantee a particular ordering of object
// properties, so instead of stringifying `args` as is, sort its entries by
// key and then pull out the values. The final key is a JSON'ed array of
// sorted-by-key `args` values.
let argValues = Object.entries(args ?? [])
.sort(([key1], [key2]) => key1.localeCompare(key2))
.map(([_, value]) => value);
return JSON.stringify(argValues);
}
}