Socket
Socket
Sign inDemoInstall

@fluent/dom

Package Overview
Dependencies
1
Maintainers
4
Versions
6
Alerts
File Explorer

Advanced tools

Install Socket

Detect and block malicious and high-risk dependencies

Install

Comparing version 0.6.0 to 0.7.0

esm/dom_localization.d.ts

15

CHANGELOG.md
# Changelog
## @fluent/dom 0.7.0 (July 2, 2020)
- Remove the `compat.js` build and compile everything to ES2018. (#472, #474)
The source code is now compiled by the TypeScript's compiler, `tsc`, to
ES2018 files in the `esm/` directory. These files are then bundled into a
single `index.js` UMD file without any further transpilation.
The `compat.js` build (available as `@fluent/dom/compat`) was removed.
Please use your own transpilation pipeline if ES2018 is too recent for
your project.
Refer to https://github.com/projectfluent/fluent.js/wiki/Compatibility
for more information
## @fluent/dom 0.6.0 (August 21, 2019)

@@ -4,0 +19,0 @@

1731

index.js

@@ -1,1005 +0,910 @@

/* @fluent/dom@0.6.0 */
/* @fluent/dom@0.7.0 */
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('cached-iterable')) :
typeof define === 'function' && define.amd ? define('@fluent/dom', ['exports', 'cached-iterable'], factory) :
(global = global || self, factory(global.FluentDOM = {}, global.CachedIterable));
}(this, function (exports, cachedIterable) { 'use strict';
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('cached-iterable')) :
typeof define === 'function' && define.amd ? define('@fluent/dom', ['exports', 'cached-iterable'], factory) :
(global = global || self, factory(global.FluentDOM = {}, global.CachedIterable));
}(this, (function (exports, cachedIterable) { 'use strict';
/* eslint no-console: ["error", {allow: ["warn"]}] */
/* global console */
// Match the opening angle bracket (<) in HTML tags, and HTML entities like
// &amp;, &#0038;, &#x0026;.
const reOverlay = /<|&#?\w+;/;
/**
* Elements allowed in translations even if they are not present in the source
* HTML. They are text-level elements as defined by the HTML5 spec:
* https://www.w3.org/TR/html5/text-level-semantics.html with the exception of:
*
* - a - because we don't allow href on it anyways,
* - ruby, rt, rp - because we don't allow nested elements to be inserted.
*/
const TEXT_LEVEL_ELEMENTS = {
"http://www.w3.org/1999/xhtml": [
"em", "strong", "small", "s", "cite", "q", "dfn", "abbr", "data",
"time", "code", "var", "samp", "kbd", "sub", "sup", "i", "b", "u",
"mark", "bdi", "bdo", "span", "br", "wbr"
],
};
const LOCALIZABLE_ATTRIBUTES = {
"http://www.w3.org/1999/xhtml": {
global: ["title", "aria-label", "aria-valuetext"],
a: ["download"],
area: ["download", "alt"],
// value is special-cased in isAttrNameLocalizable
input: ["alt", "placeholder"],
menuitem: ["label"],
menu: ["label"],
optgroup: ["label"],
option: ["label"],
track: ["label"],
img: ["alt"],
textarea: ["placeholder"],
th: ["abbr"]
},
"http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul": {
global: [
"accesskey", "aria-label", "aria-valuetext", "label",
"title", "tooltiptext"
],
description: ["value"],
key: ["key", "keycode"],
label: ["value"],
textbox: ["placeholder", "value"],
}
};
/**
* Translate an element.
*
* Translate the element's text content and attributes. Some HTML markup is
* allowed in the translation. The element's children with the data-l10n-name
* attribute will be treated as arguments to the translation. If the
* translation defines the same children, their attributes and text contents
* will be used for translating the matching source child.
*
* @param {Element} element
* @param {Object} translation
* @private
*/
function translateElement(element, translation) {
const {value} = translation;
if (typeof value === "string") {
if (!reOverlay.test(value)) {
// If the translation doesn't contain any markup skip the overlay logic.
element.textContent = value;
} else {
// Else parse the translation's HTML using an inert template element,
// sanitize it and replace the element's content.
const templateElement = element.ownerDocument.createElementNS(
"http://www.w3.org/1999/xhtml", "template"
);
templateElement.innerHTML = value;
overlayChildNodes(templateElement.content, element);
}
}
// Even if the translation doesn't define any localizable attributes, run
// overlayAttributes to remove any localizable attributes set by previous
// translations.
overlayAttributes(translation, element);
}
/**
* Replace child nodes of an element with child nodes of another element.
*
* The contents of the target element will be cleared and fully replaced with
* sanitized contents of the source element.
*
* @param {DocumentFragment} fromFragment - The source of children to overlay.
* @param {Element} toElement - The target of the overlay.
* @private
*/
function overlayChildNodes(fromFragment, toElement) {
for (const childNode of fromFragment.childNodes) {
if (childNode.nodeType === childNode.TEXT_NODE) {
// Keep the translated text node.
continue;
}
if (childNode.hasAttribute("data-l10n-name")) {
const sanitized = getNodeForNamedElement(toElement, childNode);
fromFragment.replaceChild(sanitized, childNode);
continue;
}
if (isElementAllowed(childNode)) {
const sanitized = createSanitizedElement(childNode);
fromFragment.replaceChild(sanitized, childNode);
continue;
}
console.warn(
`An element of forbidden type "${childNode.localName}" was found in ` +
"the translation. Only safe text-level elements and elements with " +
"data-l10n-name are allowed."
);
// If all else fails, replace the element with its text content.
fromFragment.replaceChild(
createTextNodeFromTextContent(childNode), childNode);
}
toElement.textContent = "";
toElement.appendChild(fromFragment);
}
function hasAttribute(attributes, name) {
if (!attributes) {
return false;
}
for (let attr of attributes) {
if (attr.name === name) {
return true;
}
}
return false;
}
/**
* Transplant localizable attributes of an element to another element.
*
* Any localizable attributes already set on the target element will be
* cleared.
*
* @param {Element|Object} fromElement - The source of child nodes to overlay.
* @param {Element} toElement - The target of the overlay.
* @private
*/
function overlayAttributes(fromElement, toElement) {
const explicitlyAllowed = toElement.hasAttribute("data-l10n-attrs")
? toElement.getAttribute("data-l10n-attrs")
.split(",").map(i => i.trim())
: null;
// Remove existing localizable attributes if they
// will not be used in the new translation.
for (const attr of Array.from(toElement.attributes)) {
if (isAttrNameLocalizable(attr.name, toElement, explicitlyAllowed)
&& !hasAttribute(fromElement.attributes, attr.name)) {
toElement.removeAttribute(attr.name);
}
}
// fromElement might be a {value, attributes} object as returned by
// Localization.messageFromBundle. In which case attributes may be null to
// save GC cycles.
if (!fromElement.attributes) {
return;
}
// Set localizable attributes.
for (const attr of Array.from(fromElement.attributes)) {
if (isAttrNameLocalizable(attr.name, toElement, explicitlyAllowed)
&& toElement.getAttribute(attr.name) !== attr.value) {
toElement.setAttribute(attr.name, attr.value);
}
}
}
/**
* Sanitize a child element created by the translation.
*
* Try to find a corresponding child in sourceElement and use it as the base
* for the sanitization. This will preserve functional attribtues defined on
* the child element in the source HTML.
*
* @param {Element} sourceElement - The source for data-l10n-name lookups.
* @param {Element} translatedChild - The translated child to be sanitized.
* @returns {Element}
* @private
*/
function getNodeForNamedElement(sourceElement, translatedChild) {
const childName = translatedChild.getAttribute("data-l10n-name");
const sourceChild = sourceElement.querySelector(
`[data-l10n-name="${childName}"]`
);
if (!sourceChild) {
console.warn(
`An element named "${childName}" wasn't found in the source.`
);
return createTextNodeFromTextContent(translatedChild);
}
if (sourceChild.localName !== translatedChild.localName) {
console.warn(
`An element named "${childName}" was found in the translation ` +
`but its type ${translatedChild.localName} didn't match the ` +
`element found in the source (${sourceChild.localName}).`
);
return createTextNodeFromTextContent(translatedChild);
}
// Remove it from sourceElement so that the translation cannot use
// the same reference name again.
sourceElement.removeChild(sourceChild);
// We can't currently guarantee that a translation won't remove
// sourceChild from the element completely, which could break the app if
// it relies on an event handler attached to the sourceChild. Let's make
// this limitation explicit for now by breaking the identitiy of the
// sourceChild by cloning it. This will destroy all event handlers
// attached to sourceChild via addEventListener and via on<name>
// properties.
const clone = sourceChild.cloneNode(false);
return shallowPopulateUsing(translatedChild, clone);
}
/**
* Sanitize an allowed element.
*
* Text-level elements allowed in translations may only use safe attributes
* and will have any nested markup stripped to text content.
*
* @param {Element} element - The element to be sanitized.
* @returns {Element}
* @private
*/
function createSanitizedElement(element) {
// Start with an empty element of the same type to remove nested children
// and non-localizable attributes defined by the translation.
const clone = element.ownerDocument.createElement(element.localName);
return shallowPopulateUsing(element, clone);
}
/**
* Convert an element to a text node.
*
* @param {Element} element - The element to be sanitized.
* @returns {Node}
* @private
*/
function createTextNodeFromTextContent(element) {
return element.ownerDocument.createTextNode(element.textContent);
}
/**
* Check if element is allowed in the translation.
*
* This method is used by the sanitizer when the translation markup contains
* an element which is not present in the source code.
*
* @param {Element} element
* @returns {boolean}
* @private
*/
function isElementAllowed(element) {
const allowed = TEXT_LEVEL_ELEMENTS[element.namespaceURI];
return allowed && allowed.includes(element.localName);
}
/**
* Check if attribute is allowed for the given element.
*
* This method is used by the sanitizer when the translation markup contains
* DOM attributes, or when the translation has traits which map to DOM
* attributes.
*
* `explicitlyAllowed` can be passed as a list of attributes explicitly
* allowed on this element.
*
* @param {string} name
* @param {Element} element
* @param {Array} explicitlyAllowed
* @returns {boolean}
* @private
*/
function isAttrNameLocalizable(name, element, explicitlyAllowed = null) {
if (explicitlyAllowed && explicitlyAllowed.includes(name)) {
return true;
}
const allowed = LOCALIZABLE_ATTRIBUTES[element.namespaceURI];
if (!allowed) {
return false;
}
const attrName = name.toLowerCase();
const elemName = element.localName;
// Is it a globally safe attribute?
if (allowed.global.includes(attrName)) {
return true;
}
// Are there no allowed attributes for this element?
if (!allowed[elemName]) {
return false;
}
// Is it allowed on this element?
if (allowed[elemName].includes(attrName)) {
return true;
}
// Special case for value on HTML inputs with type button, reset, submit
if (element.namespaceURI === "http://www.w3.org/1999/xhtml" &&
elemName === "input" && attrName === "value") {
const type = element.type.toLowerCase();
if (type === "submit" || type === "button" || type === "reset") {
return true;
}
}
return false;
}
/**
* Helper to set textContent and localizable attributes on an element.
*
* @param {Element} fromElement
* @param {Element} toElement
* @returns {Element}
* @private
*/
function shallowPopulateUsing(fromElement, toElement) {
toElement.textContent = fromElement.textContent;
overlayAttributes(fromElement, toElement);
return toElement;
}
/* eslint no-console: ["error", { allow: ["warn", "error"] }] */
/**
* The `Localization` class is a central high-level API for vanilla
* JavaScript use of Fluent.
* It combines language negotiation, FluentBundle and I/O to
* provide a scriptable API to format translations.
*/
class Localization {
/* eslint no-console: ["error", {allow: ["warn"]}] */
/* global console */
// Match the opening angle bracket (<) in HTML tags, and HTML entities like
// &amp;, &#0038;, &#x0026;.
const reOverlay = /<|&#?\w+;/;
/**
* @param {Array<String>} resourceIds - List of resource IDs
* @param {Function} generateBundles - Function that returns a
* generator over FluentBundles
* Elements allowed in translations even if they are not present in the source
* HTML. They are text-level elements as defined by the HTML5 spec:
* https://www.w3.org/TR/html5/text-level-semantics.html with the exception of:
*
* @returns {Localization}
* - a - because we don't allow href on it anyways,
* - ruby, rt, rp - because we don't allow nested elements to be inserted.
*/
constructor(resourceIds = [], generateBundles) {
this.resourceIds = resourceIds;
this.generateBundles = generateBundles;
this.onChange(true);
}
addResourceIds(resourceIds, eager = false) {
this.resourceIds.push(...resourceIds);
this.onChange(eager);
return this.resourceIds.length;
}
removeResourceIds(resourceIds) {
this.resourceIds = this.resourceIds.filter(r => !resourceIds.includes(r));
this.onChange();
return this.resourceIds.length;
}
const TEXT_LEVEL_ELEMENTS = {
"http://www.w3.org/1999/xhtml": [
"em", "strong", "small", "s", "cite", "q", "dfn", "abbr", "data",
"time", "code", "var", "samp", "kbd", "sub", "sup", "i", "b", "u",
"mark", "bdi", "bdo", "span", "br", "wbr"
],
};
const LOCALIZABLE_ATTRIBUTES = {
"http://www.w3.org/1999/xhtml": {
global: ["title", "aria-label", "aria-valuetext"],
a: ["download"],
area: ["download", "alt"],
// value is special-cased in isAttrNameLocalizable
input: ["alt", "placeholder"],
menuitem: ["label"],
menu: ["label"],
optgroup: ["label"],
option: ["label"],
track: ["label"],
img: ["alt"],
textarea: ["placeholder"],
th: ["abbr"]
},
"http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul": {
global: [
"accesskey", "aria-label", "aria-valuetext", "label",
"title", "tooltiptext"
],
description: ["value"],
key: ["key", "keycode"],
label: ["value"],
textbox: ["placeholder", "value"],
}
};
/**
* Format translations and handle fallback if needed.
* Translate an element.
*
* Format translations for `keys` from `FluentBundle` instances on this
* DOMLocalization. In case of errors, fetch the next context in the
* fallback chain.
* Translate the element's text content and attributes. Some HTML markup is
* allowed in the translation. The element's children with the data-l10n-name
* attribute will be treated as arguments to the translation. If the
* translation defines the same children, their attributes and text contents
* will be used for translating the matching source child.
*
* @param {Array<Object>} keys - Translation keys to format.
* @param {Function} method - Formatting function.
* @returns {Promise<Array<string|Object>>}
* @param {Element} element
* @param {Object} translation
* @private
*/
async formatWithFallback(keys, method) {
const translations = [];
let hasAtLeastOneBundle = false;
for await (const bundle of this.bundles) {
hasAtLeastOneBundle = true;
const missingIds = keysFromBundle(method, bundle, keys, translations);
if (missingIds.size === 0) {
break;
function translateElement(element, translation) {
const { value } = translation;
if (typeof value === "string") {
if (element.localName === "title" &&
element.namespaceURI === "http://www.w3.org/1999/xhtml") {
// A special case for the HTML title element whose content must be text.
element.textContent = value;
}
else if (!reOverlay.test(value)) {
// If the translation doesn't contain any markup skip the overlay logic.
element.textContent = value;
}
else {
// Else parse the translation's HTML using an inert template element,
// sanitize it and replace the element's content.
const templateElement = element.ownerDocument.createElementNS("http://www.w3.org/1999/xhtml", "template");
templateElement.innerHTML = value;
overlayChildNodes(templateElement.content, element);
}
}
if (typeof console !== "undefined") {
const locale = bundle.locales[0];
const ids = Array.from(missingIds).join(", ");
console.warn(`[fluent] Missing translations in ${locale}: ${ids}`);
}
}
if (!hasAtLeastOneBundle && typeof console !== "undefined") {
// eslint-disable-next-line max-len
console.warn(`[fluent] Request for keys failed because no resource bundles got generated.
keys: ${JSON.stringify(keys)}.
resourceIds: ${JSON.stringify(this.resourceIds)}.`);
}
return translations;
// Even if the translation doesn't define any localizable attributes, run
// overlayAttributes to remove any localizable attributes set by previous
// translations.
overlayAttributes(translation, element);
}
/**
* Format translations into {value, attributes} objects.
* Replace child nodes of an element with child nodes of another element.
*
* The fallback logic is the same as in `formatValues` but it returns {value,
* attributes} objects which are suitable for the translation of DOM
* elements.
* The contents of the target element will be cleared and fully replaced with
* sanitized contents of the source element.
*
* docL10n.formatMessages([
* {id: 'hello', args: { who: 'Mary' }},
* {id: 'welcome'}
* ]).then(console.log);
*
* // [
* // { value: 'Hello, Mary!', attributes: null },
* // {
* // value: 'Welcome!',
* // attributes: [ { name: "title", value: 'Hello' } ]
* // }
* // ]
*
* Returns a Promise resolving to an array of the translation strings.
*
* @param {Array<Object>} keys
* @returns {Promise<Array<{value: string, attributes: Object}>>}
* @param {DocumentFragment} fromFragment - The source of children to overlay.
* @param {Element} toElement - The target of the overlay.
* @private
*/
formatMessages(keys) {
return this.formatWithFallback(keys, messageFromBundle);
function overlayChildNodes(fromFragment, toElement) {
for (const childNode of fromFragment.childNodes) {
if (childNode.nodeType === childNode.TEXT_NODE) {
// Keep the translated text node.
continue;
}
if (childNode.hasAttribute("data-l10n-name")) {
const sanitized = getNodeForNamedElement(toElement, childNode);
fromFragment.replaceChild(sanitized, childNode);
continue;
}
if (isElementAllowed(childNode)) {
const sanitized = createSanitizedElement(childNode);
fromFragment.replaceChild(sanitized, childNode);
continue;
}
console.warn(`An element of forbidden type "${childNode.localName}" was found in ` +
"the translation. Only safe text-level elements and elements with " +
"data-l10n-name are allowed.");
// If all else fails, replace the element with its text content.
fromFragment.replaceChild(createTextNodeFromTextContent(childNode), childNode);
}
toElement.textContent = "";
toElement.appendChild(fromFragment);
}
function hasAttribute(attributes, name) {
if (!attributes) {
return false;
}
for (let attr of attributes) {
if (attr.name === name) {
return true;
}
}
return false;
}
/**
* Retrieve translations corresponding to the passed keys.
* Transplant localizable attributes of an element to another element.
*
* A generalized version of `DOMLocalization.formatValue`. Keys must
* be `{id, args}` objects.
* Any localizable attributes already set on the target element will be
* cleared.
*
* docL10n.formatValues([
* {id: 'hello', args: { who: 'Mary' }},
* {id: 'hello', args: { who: 'John' }},
* {id: 'welcome'}
* ]).then(console.log);
*
* // ['Hello, Mary!', 'Hello, John!', 'Welcome!']
*
* Returns a Promise resolving to an array of the translation strings.
*
* @param {Array<Object>} keys
* @returns {Promise<Array<string>>}
* @param {Element|Object} fromElement - The source of child nodes to overlay.
* @param {Element} toElement - The target of the overlay.
* @private
*/
formatValues(keys) {
return this.formatWithFallback(keys, valueFromBundle);
function overlayAttributes(fromElement, toElement) {
const explicitlyAllowed = toElement.hasAttribute("data-l10n-attrs")
? toElement.getAttribute("data-l10n-attrs")
.split(",").map(i => i.trim())
: null;
// Remove existing localizable attributes if they
// will not be used in the new translation.
for (const attr of Array.from(toElement.attributes)) {
if (isAttrNameLocalizable(attr.name, toElement, explicitlyAllowed)
&& !hasAttribute(fromElement.attributes, attr.name)) {
toElement.removeAttribute(attr.name);
}
}
// fromElement might be a {value, attributes} object as returned by
// Localization.messageFromBundle. In which case attributes may be null to
// save GC cycles.
if (!fromElement.attributes) {
return;
}
// Set localizable attributes.
for (const attr of Array.from(fromElement.attributes)) {
if (isAttrNameLocalizable(attr.name, toElement, explicitlyAllowed)
&& toElement.getAttribute(attr.name) !== attr.value) {
toElement.setAttribute(attr.name, attr.value);
}
}
}
/**
* Retrieve the translation corresponding to the `id` identifier.
* Sanitize a child element created by the translation.
*
* If passed, `args` is a simple hash object with a list of variables that
* will be interpolated in the value of the translation.
* Try to find a corresponding child in sourceElement and use it as the base
* for the sanitization. This will preserve functional attribtues defined on
* the child element in the source HTML.
*
* docL10n.formatValue(
* 'hello', { who: 'world' }
* ).then(console.log);
*
* // 'Hello, world!'
*
* Returns a Promise resolving to the translation string.
*
* Use this sparingly for one-off messages which don't need to be
* retranslated when the user changes their language preferences, e.g. in
* notifications.
*
* @param {string} id - Identifier of the translation to format
* @param {Object} [args] - Optional external arguments
* @returns {Promise<string>}
* @param {Element} sourceElement - The source for data-l10n-name lookups.
* @param {Element} translatedChild - The translated child to be sanitized.
* @returns {Element}
* @private
*/
async formatValue(id, args) {
const [val] = await this.formatValues([{id, args}]);
return val;
}
handleEvent() {
this.onChange();
}
/**
* This method should be called when there's a reason to believe
* that language negotiation or available resources changed.
*/
onChange(eager = false) {
this.bundles = cachedIterable.CachedAsyncIterable.from(
this.generateBundles(this.resourceIds));
if (eager) {
this.bundles.touchNext(2);
}
}
}
/**
* Format the value of a message into a string or `null`.
*
* This function is passed as a method to `keysFromBundle` and resolve
* a value of a single L10n Entity using provided `FluentBundle`.
*
* If the message doesn't have a value, return `null`.
*
* @param {FluentBundle} bundle
* @param {Array<Error>} errors
* @param {Object} message
* @param {Object} args
* @returns {string|null}
* @private
*/
function valueFromBundle(bundle, errors, message, args) {
if (message.value) {
return bundle.formatPattern(message.value, args, errors);
}
return null;
}
/**
* Format all public values of a message into a {value, attributes} object.
*
* This function is passed as a method to `keysFromBundle` and resolve
* a single L10n Entity using provided `FluentBundle`.
*
* The function will return an object with a value and attributes of the
* entity.
*
* @param {FluentBundle} bundle
* @param {Array<Error>} errors
* @param {Object} message
* @param {Object} args
* @returns {Object}
* @private
*/
function messageFromBundle(bundle, errors, message, args) {
const formatted = {
value: null,
attributes: null,
};
if (message.value) {
formatted.value = bundle.formatPattern(message.value, args, errors);
}
let attrNames = Object.keys(message.attributes);
if (attrNames.length > 0) {
formatted.attributes = new Array(attrNames.length);
for (let [i, name] of attrNames.entries()) {
let value = bundle.formatPattern(message.attributes[name], args, errors);
formatted.attributes[i] = {name, value};
}
}
return formatted;
}
/**
* This function is an inner function for `Localization.formatWithFallback`.
*
* It takes a `FluentBundle`, list of l10n-ids and a method to be used for
* key resolution (either `valueFromBundle` or `messageFromBundle`) and
* optionally a value returned from `keysFromBundle` executed against
* another `FluentBundle`.
*
* The idea here is that if the previous `FluentBundle` did not resolve
* all keys, we're calling this function with the next context to resolve
* the remaining ones.
*
* In the function, we loop over `keys` and check if we have the `prev`
* passed and if it has an error entry for the position we're in.
*
* If it doesn't, it means that we have a good translation for this key and
* we return it. If it does, we'll try to resolve the key using the passed
* `FluentBundle`.
*
* In the end, we fill the translations array, and return the Set with
* missing ids.
*
* See `Localization.formatWithFallback` for more info on how this is used.
*
* @param {Function} method
* @param {FluentBundle} bundle
* @param {Array<string>} keys
* @param {{Array<{value: string, attributes: Object}>}} translations
*
* @returns {Set<string>}
* @private
*/
function keysFromBundle(method, bundle, keys, translations) {
const messageErrors = [];
const missingIds = new Set();
keys.forEach(({id, args}, i) => {
if (translations[i] !== undefined) {
return;
}
let message = bundle.getMessage(id);
if (message) {
messageErrors.length = 0;
translations[i] = method(bundle, messageErrors, message, args);
if (messageErrors.length > 0 && typeof console !== "undefined") {
const locale = bundle.locales[0];
const errors = messageErrors.join(", ");
// eslint-disable-next-line max-len
console.warn(`[fluent][resolver] errors in ${locale}/${id}: ${errors}.`);
function getNodeForNamedElement(sourceElement, translatedChild) {
const childName = translatedChild.getAttribute("data-l10n-name");
const sourceChild = sourceElement.querySelector(`[data-l10n-name="${childName}"]`);
if (!sourceChild) {
console.warn(`An element named "${childName}" wasn't found in the source.`);
return createTextNodeFromTextContent(translatedChild);
}
} else {
missingIds.add(id);
}
});
return missingIds;
}
const L10NID_ATTR_NAME = "data-l10n-id";
const L10NARGS_ATTR_NAME = "data-l10n-args";
const L10N_ELEMENT_QUERY = `[${L10NID_ATTR_NAME}]`;
/**
* The `DOMLocalization` class is responsible for fetching resources and
* formatting translations.
*
* It implements the fallback strategy in case of errors encountered during the
* formatting of translations and methods for observing DOM
* trees with a `MutationObserver`.
*/
class DOMLocalization extends Localization {
/**
* @param {Array<String>} resourceIds - List of resource IDs
* @param {Function} generateBundles - Function that returns a
* generator over FluentBundles
* @returns {DOMLocalization}
*/
constructor(resourceIds, generateBundles) {
super(resourceIds, generateBundles);
// A Set of DOM trees observed by the `MutationObserver`.
this.roots = new Set();
// requestAnimationFrame handler.
this.pendingrAF = null;
// list of elements pending for translation.
this.pendingElements = new Set();
this.windowElement = null;
this.mutationObserver = null;
this.observerConfig = {
attributes: true,
characterData: false,
childList: true,
subtree: true,
attributeFilter: [L10NID_ATTR_NAME, L10NARGS_ATTR_NAME]
};
if (sourceChild.localName !== translatedChild.localName) {
console.warn(`An element named "${childName}" was found in the translation ` +
`but its type ${translatedChild.localName} didn't match the ` +
`element found in the source (${sourceChild.localName}).`);
return createTextNodeFromTextContent(translatedChild);
}
// Remove it from sourceElement so that the translation cannot use
// the same reference name again.
sourceElement.removeChild(sourceChild);
// We can't currently guarantee that a translation won't remove
// sourceChild from the element completely, which could break the app if
// it relies on an event handler attached to the sourceChild. Let's make
// this limitation explicit for now by breaking the identitiy of the
// sourceChild by cloning it. This will destroy all event handlers
// attached to sourceChild via addEventListener and via on<name>
// properties.
const clone = sourceChild.cloneNode(false);
return shallowPopulateUsing(translatedChild, clone);
}
onChange(eager = false) {
super.onChange(eager);
if (this.roots) {
this.translateRoots();
}
}
/**
* Set the `data-l10n-id` and `data-l10n-args` attributes on DOM elements.
* FluentDOM makes use of mutation observers to detect changes
* to `data-l10n-*` attributes and translate elements asynchronously.
* `setAttributes` is a convenience method which allows to translate
* DOM elements declaratively.
* Sanitize an allowed element.
*
* You should always prefer to use `data-l10n-id` on elements (statically in
* HTML or dynamically via `setAttributes`) over manually retrieving
* translations with `format`. The use of attributes ensures that the
* elements can be retranslated when the user changes their language
* preferences.
* Text-level elements allowed in translations may only use safe attributes
* and will have any nested markup stripped to text content.
*
* ```javascript
* localization.setAttributes(
* document.querySelector('#welcome'), 'hello', { who: 'world' }
* );
* ```
*
* This will set the following attributes on the `#welcome` element.
* The MutationObserver will pick up this change and will localize the element
* asynchronously.
*
* ```html
* <p id='welcome'
* data-l10n-id='hello'
* data-l10n-args='{"who": "world"}'>
* </p>
* ```
*
* @param {Element} element - Element to set attributes on
* @param {string} id - l10n-id string
* @param {Object<string, string>} args - KVP list of l10n arguments
* @param {Element} element - The element to be sanitized.
* @returns {Element}
* @private
*/
setAttributes(element, id, args) {
element.setAttribute(L10NID_ATTR_NAME, id);
if (args) {
element.setAttribute(L10NARGS_ATTR_NAME, JSON.stringify(args));
} else {
element.removeAttribute(L10NARGS_ATTR_NAME);
}
return element;
function createSanitizedElement(element) {
// Start with an empty element of the same type to remove nested children
// and non-localizable attributes defined by the translation.
const clone = element.ownerDocument.createElement(element.localName);
return shallowPopulateUsing(element, clone);
}
/**
* Get the `data-l10n-*` attributes from DOM elements.
* Convert an element to a text node.
*
* ```javascript
* localization.getAttributes(
* document.querySelector('#welcome')
* );
* // -> { id: 'hello', args: { who: 'world' } }
* ```
*
* @param {Element} element - HTML element
* @returns {{id: string, args: Object}}
* @param {Element} element - The element to be sanitized.
* @returns {Node}
* @private
*/
getAttributes(element) {
return {
id: element.getAttribute(L10NID_ATTR_NAME),
args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null)
};
function createTextNodeFromTextContent(element) {
return element.ownerDocument.createTextNode(element.textContent);
}
/**
* Add `newRoot` to the list of roots managed by this `DOMLocalization`.
* Check if element is allowed in the translation.
*
* Additionally, if this `DOMLocalization` has an observer, start observing
* `newRoot` in order to translate mutations in it.
* This method is used by the sanitizer when the translation markup contains
* an element which is not present in the source code.
*
* @param {Element} newRoot - Root to observe.
* @param {Element} element
* @returns {boolean}
* @private
*/
connectRoot(newRoot) {
for (const root of this.roots) {
if (root === newRoot ||
root.contains(newRoot) ||
newRoot.contains(root)) {
throw new Error("Cannot add a root that overlaps with existing root.");
}
}
if (this.windowElement) {
if (this.windowElement !== newRoot.ownerDocument.defaultView) {
throw new Error(`Cannot connect a root:
DOMLocalization already has a root from a different window.`);
}
} else {
this.windowElement = newRoot.ownerDocument.defaultView;
this.mutationObserver = new this.windowElement.MutationObserver(
mutations => this.translateMutations(mutations)
);
}
this.roots.add(newRoot);
this.mutationObserver.observe(newRoot, this.observerConfig);
function isElementAllowed(element) {
const allowed = TEXT_LEVEL_ELEMENTS[element.namespaceURI];
return allowed && allowed.includes(element.localName);
}
/**
* Remove `root` from the list of roots managed by this `DOMLocalization`.
* Check if attribute is allowed for the given element.
*
* Additionally, if this `DOMLocalization` has an observer, stop observing
* `root`.
* This method is used by the sanitizer when the translation markup contains
* DOM attributes, or when the translation has traits which map to DOM
* attributes.
*
* Returns `true` if the root was the last one managed by this
* `DOMLocalization`.
* `explicitlyAllowed` can be passed as a list of attributes explicitly
* allowed on this element.
*
* @param {Element} root - Root to disconnect.
* @param {string} name
* @param {Element} element
* @param {Array} explicitlyAllowed
* @returns {boolean}
*/
disconnectRoot(root) {
this.roots.delete(root);
// Pause the mutation observer to stop observing `root`.
this.pauseObserving();
if (this.roots.size === 0) {
this.mutationObserver = null;
this.windowElement = null;
this.pendingrAF = null;
this.pendingElements.clear();
return true;
}
// Resume observing all other roots.
this.resumeObserving();
return false;
}
/**
* Translate all roots associated with this `DOMLocalization`.
*
* @returns {Promise}
*/
translateRoots() {
const roots = Array.from(this.roots);
return Promise.all(
roots.map(root => this.translateFragment(root))
);
}
/**
* Pauses the `MutationObserver`.
*
* @private
*/
pauseObserving() {
if (!this.mutationObserver) {
return;
}
this.translateMutations(this.mutationObserver.takeRecords());
this.mutationObserver.disconnect();
function isAttrNameLocalizable(name, element, explicitlyAllowed = null) {
if (explicitlyAllowed && explicitlyAllowed.includes(name)) {
return true;
}
const allowed = LOCALIZABLE_ATTRIBUTES[element.namespaceURI];
if (!allowed) {
return false;
}
const attrName = name.toLowerCase();
const elemName = element.localName;
// Is it a globally safe attribute?
if (allowed.global.includes(attrName)) {
return true;
}
// Are there no allowed attributes for this element?
if (!allowed[elemName]) {
return false;
}
// Is it allowed on this element?
if (allowed[elemName].includes(attrName)) {
return true;
}
// Special case for value on HTML inputs with type button, reset, submit
if (element.namespaceURI === "http://www.w3.org/1999/xhtml" &&
elemName === "input" && attrName === "value") {
const type = element.type.toLowerCase();
if (type === "submit" || type === "button" || type === "reset") {
return true;
}
}
return false;
}
/**
* Resumes the `MutationObserver`.
* Helper to set textContent and localizable attributes on an element.
*
* @param {Element} fromElement
* @param {Element} toElement
* @returns {Element}
* @private
*/
resumeObserving() {
if (!this.mutationObserver) {
return;
}
for (const root of this.roots) {
this.mutationObserver.observe(root, this.observerConfig);
}
function shallowPopulateUsing(fromElement, toElement) {
toElement.textContent = fromElement.textContent;
overlayAttributes(fromElement, toElement);
return toElement;
}
/* eslint no-console: ["error", { allow: ["warn", "error"] }] */
/**
* Translate mutations detected by the `MutationObserver`.
*
* @private
* The `Localization` class is a central high-level API for vanilla
* JavaScript use of Fluent.
* It combines language negotiation, FluentBundle and I/O to
* provide a scriptable API to format translations.
*/
translateMutations(mutations) {
for (const mutation of mutations) {
switch (mutation.type) {
case "attributes":
if (mutation.target.hasAttribute("data-l10n-id")) {
this.pendingElements.add(mutation.target);
}
break;
case "childList":
for (const addedNode of mutation.addedNodes) {
if (addedNode.nodeType === addedNode.ELEMENT_NODE) {
if (addedNode.childElementCount) {
for (const element of this.getTranslatables(addedNode)) {
this.pendingElements.add(element);
}
} else if (addedNode.hasAttribute(L10NID_ATTR_NAME)) {
this.pendingElements.add(addedNode);
class Localization {
/**
* @param {Array<String>} resourceIds - List of resource IDs
* @param {Function} generateBundles - Function that returns a
* generator over FluentBundles
*
* @returns {Localization}
*/
constructor(resourceIds = [], generateBundles) {
this.resourceIds = resourceIds;
this.generateBundles = generateBundles;
this.onChange(true);
}
addResourceIds(resourceIds, eager = false) {
this.resourceIds.push(...resourceIds);
this.onChange(eager);
return this.resourceIds.length;
}
removeResourceIds(resourceIds) {
this.resourceIds = this.resourceIds.filter(r => !resourceIds.includes(r));
this.onChange();
return this.resourceIds.length;
}
/**
* Format translations and handle fallback if needed.
*
* Format translations for `keys` from `FluentBundle` instances on this
* DOMLocalization. In case of errors, fetch the next context in the
* fallback chain.
*
* @param {Array<Object>} keys - Translation keys to format.
* @param {Function} method - Formatting function.
* @returns {Promise<Array<string|Object>>}
* @private
*/
async formatWithFallback(keys, method) {
const translations = [];
let hasAtLeastOneBundle = false;
for await (const bundle of this.bundles) {
hasAtLeastOneBundle = true;
const missingIds = keysFromBundle(method, bundle, keys, translations);
if (missingIds.size === 0) {
break;
}
}
if (typeof console !== "undefined") {
const locale = bundle.locales[0];
const ids = Array.from(missingIds).join(", ");
console.warn(`[fluent] Missing translations in ${locale}: ${ids}`);
}
}
break;
if (!hasAtLeastOneBundle && typeof console !== "undefined") {
// eslint-disable-next-line max-len
console.warn(`[fluent] Request for keys failed because no resource bundles got generated.
keys: ${JSON.stringify(keys)}.
resourceIds: ${JSON.stringify(this.resourceIds)}.`);
}
return translations;
}
}
// This fragment allows us to coalesce all pending translations
// into a single requestAnimationFrame.
if (this.pendingElements.size > 0) {
if (this.pendingrAF === null) {
this.pendingrAF = this.windowElement.requestAnimationFrame(() => {
this.translateElements(Array.from(this.pendingElements));
this.pendingElements.clear();
this.pendingrAF = null;
});
/**
* Format translations into {value, attributes} objects.
*
* The fallback logic is the same as in `formatValues` but it returns {value,
* attributes} objects which are suitable for the translation of DOM
* elements.
*
* docL10n.formatMessages([
* {id: 'hello', args: { who: 'Mary' }},
* {id: 'welcome'}
* ]).then(console.log);
*
* // [
* // { value: 'Hello, Mary!', attributes: null },
* // {
* // value: 'Welcome!',
* // attributes: [ { name: "title", value: 'Hello' } ]
* // }
* // ]
*
* Returns a Promise resolving to an array of the translation strings.
*
* @param {Array<Object>} keys
* @returns {Promise<Array<{value: string, attributes: Object}>>}
* @private
*/
formatMessages(keys) {
return this.formatWithFallback(keys, messageFromBundle);
}
}
/**
* Retrieve translations corresponding to the passed keys.
*
* A generalized version of `DOMLocalization.formatValue`. Keys must
* be `{id, args}` objects.
*
* docL10n.formatValues([
* {id: 'hello', args: { who: 'Mary' }},
* {id: 'hello', args: { who: 'John' }},
* {id: 'welcome'}
* ]).then(console.log);
*
* // ['Hello, Mary!', 'Hello, John!', 'Welcome!']
*
* Returns a Promise resolving to an array of the translation strings.
*
* @param {Array<Object>} keys
* @returns {Promise<Array<string>>}
*/
formatValues(keys) {
return this.formatWithFallback(keys, valueFromBundle);
}
/**
* Retrieve the translation corresponding to the `id` identifier.
*
* If passed, `args` is a simple hash object with a list of variables that
* will be interpolated in the value of the translation.
*
* docL10n.formatValue(
* 'hello', { who: 'world' }
* ).then(console.log);
*
* // 'Hello, world!'
*
* Returns a Promise resolving to the translation string.
*
* Use this sparingly for one-off messages which don't need to be
* retranslated when the user changes their language preferences, e.g. in
* notifications.
*
* @param {string} id - Identifier of the translation to format
* @param {Object} [args] - Optional external arguments
* @returns {Promise<string>}
*/
async formatValue(id, args) {
const [val] = await this.formatValues([{ id, args }]);
return val;
}
handleEvent() {
this.onChange();
}
/**
* This method should be called when there's a reason to believe
* that language negotiation or available resources changed.
*/
onChange(eager = false) {
this.bundles = cachedIterable.CachedAsyncIterable.from(this.generateBundles(this.resourceIds));
if (eager) {
this.bundles.touchNext(2);
}
}
}
/**
* Translate a DOM element or fragment asynchronously using this
* `DOMLocalization` object.
* Format the value of a message into a string or `null`.
*
* Manually trigger the translation (or re-translation) of a DOM fragment.
* Use the `data-l10n-id` and `data-l10n-args` attributes to mark up the DOM
* with information about which translations to use.
* This function is passed as a method to `keysFromBundle` and resolve
* a value of a single L10n Entity using provided `FluentBundle`.
*
* Returns a `Promise` that gets resolved once the translation is complete.
* If the message doesn't have a value, return `null`.
*
* @param {DOMFragment} frag - Element or DocumentFragment to be translated
* @returns {Promise}
* @param {FluentBundle} bundle
* @param {Array<Error>} errors
* @param {Object} message
* @param {Object} args
* @returns {string|null}
* @private
*/
translateFragment(frag) {
return this.translateElements(this.getTranslatables(frag));
function valueFromBundle(bundle, errors, message, args) {
if (message.value) {
return bundle.formatPattern(message.value, args, errors);
}
return null;
}
/**
* Translate a list of DOM elements asynchronously using this
* `DOMLocalization` object.
* Format all public values of a message into a {value, attributes} object.
*
* Manually trigger the translation (or re-translation) of a list of elements.
* Use the `data-l10n-id` and `data-l10n-args` attributes to mark up the DOM
* with information about which translations to use.
* This function is passed as a method to `keysFromBundle` and resolve
* a single L10n Entity using provided `FluentBundle`.
*
* Returns a `Promise` that gets resolved once the translation is complete.
* The function will return an object with a value and attributes of the
* entity.
*
* @param {Array<Element>} elements - List of elements to be translated
* @returns {Promise}
*/
async translateElements(elements) {
if (!elements.length) {
return undefined;
}
const keys = elements.map(this.getKeysForElement);
const translations = await this.formatMessages(keys);
return this.applyTranslations(elements, translations);
}
/**
* Applies translations onto elements.
*
* @param {Array<Element>} elements
* @param {Array<Object>} translations
* @param {FluentBundle} bundle
* @param {Array<Error>} errors
* @param {Object} message
* @param {Object} args
* @returns {Object}
* @private
*/
applyTranslations(elements, translations) {
this.pauseObserving();
for (let i = 0; i < elements.length; i++) {
if (translations[i] !== undefined) {
translateElement(elements[i], translations[i]);
function messageFromBundle(bundle, errors, message, args) {
const formatted = {
value: null,
attributes: null,
};
if (message.value) {
formatted.value = bundle.formatPattern(message.value, args, errors);
}
}
this.resumeObserving();
let attrNames = Object.keys(message.attributes);
if (attrNames.length > 0) {
formatted.attributes = new Array(attrNames.length);
for (let [i, name] of attrNames.entries()) {
let value = bundle.formatPattern(message.attributes[name], args, errors);
formatted.attributes[i] = { name, value };
}
}
return formatted;
}
/**
* Collects all translatable child elements of the element.
* This function is an inner function for `Localization.formatWithFallback`.
*
* @param {Element} element
* @returns {Array<Element>}
* It takes a `FluentBundle`, list of l10n-ids and a method to be used for
* key resolution (either `valueFromBundle` or `messageFromBundle`) and
* optionally a value returned from `keysFromBundle` executed against
* another `FluentBundle`.
*
* The idea here is that if the previous `FluentBundle` did not resolve
* all keys, we're calling this function with the next context to resolve
* the remaining ones.
*
* In the function, we loop over `keys` and check if we have the `prev`
* passed and if it has an error entry for the position we're in.
*
* If it doesn't, it means that we have a good translation for this key and
* we return it. If it does, we'll try to resolve the key using the passed
* `FluentBundle`.
*
* In the end, we fill the translations array, and return the Set with
* missing ids.
*
* See `Localization.formatWithFallback` for more info on how this is used.
*
* @param {Function} method
* @param {FluentBundle} bundle
* @param {Array<string>} keys
* @param {{Array<{value: string, attributes: Object}>}} translations
*
* @returns {Set<string>}
* @private
*/
getTranslatables(element) {
const nodes = Array.from(element.querySelectorAll(L10N_ELEMENT_QUERY));
if (typeof element.hasAttribute === "function" &&
element.hasAttribute(L10NID_ATTR_NAME)) {
nodes.push(element);
}
return nodes;
function keysFromBundle(method, bundle, keys, translations) {
const messageErrors = [];
const missingIds = new Set();
keys.forEach(({ id, args }, i) => {
if (translations[i] !== undefined) {
return;
}
let message = bundle.getMessage(id);
if (message) {
messageErrors.length = 0;
translations[i] = method(bundle, messageErrors, message, args);
if (messageErrors.length > 0 && typeof console !== "undefined") {
const locale = bundle.locales[0];
const errors = messageErrors.join(", ");
// eslint-disable-next-line max-len
console.warn(`[fluent][resolver] errors in ${locale}/${id}: ${errors}.`);
}
}
else {
missingIds.add(id);
}
});
return missingIds;
}
const L10NID_ATTR_NAME = "data-l10n-id";
const L10NARGS_ATTR_NAME = "data-l10n-args";
const L10N_ELEMENT_QUERY = `[${L10NID_ATTR_NAME}]`;
/**
* Get the `data-l10n-*` attributes from DOM elements as a two-element
* array.
* The `DOMLocalization` class is responsible for fetching resources and
* formatting translations.
*
* @param {Element} element
* @returns {Object}
* @private
* It implements the fallback strategy in case of errors encountered during the
* formatting of translations and methods for observing DOM
* trees with a `MutationObserver`.
*/
getKeysForElement(element) {
return {
id: element.getAttribute(L10NID_ATTR_NAME),
args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null)
};
class DOMLocalization extends Localization {
/**
* @param {Array<String>} resourceIds - List of resource IDs
* @param {Function} generateBundles - Function that returns a
* generator over FluentBundles
* @returns {DOMLocalization}
*/
constructor(resourceIds, generateBundles) {
super(resourceIds, generateBundles);
// A Set of DOM trees observed by the `MutationObserver`.
this.roots = new Set();
// requestAnimationFrame handler.
this.pendingrAF = null;
// list of elements pending for translation.
this.pendingElements = new Set();
this.windowElement = null;
this.mutationObserver = null;
this.observerConfig = {
attributes: true,
characterData: false,
childList: true,
subtree: true,
attributeFilter: [L10NID_ATTR_NAME, L10NARGS_ATTR_NAME]
};
}
onChange(eager = false) {
super.onChange(eager);
if (this.roots) {
this.translateRoots();
}
}
/**
* Set the `data-l10n-id` and `data-l10n-args` attributes on DOM elements.
* FluentDOM makes use of mutation observers to detect changes
* to `data-l10n-*` attributes and translate elements asynchronously.
* `setAttributes` is a convenience method which allows to translate
* DOM elements declaratively.
*
* You should always prefer to use `data-l10n-id` on elements (statically in
* HTML or dynamically via `setAttributes`) over manually retrieving
* translations with `format`. The use of attributes ensures that the
* elements can be retranslated when the user changes their language
* preferences.
*
* ```javascript
* localization.setAttributes(
* document.querySelector('#welcome'), 'hello', { who: 'world' }
* );
* ```
*
* This will set the following attributes on the `#welcome` element.
* The MutationObserver will pick up this change and will localize the element
* asynchronously.
*
* ```html
* <p id='welcome'
* data-l10n-id='hello'
* data-l10n-args='{"who": "world"}'>
* </p>
* ```
*
* @param {Element} element - Element to set attributes on
* @param {string} id - l10n-id string
* @param {Object<string, string>} args - KVP list of l10n arguments
* @returns {Element}
*/
setAttributes(element, id, args) {
element.setAttribute(L10NID_ATTR_NAME, id);
if (args) {
element.setAttribute(L10NARGS_ATTR_NAME, JSON.stringify(args));
}
else {
element.removeAttribute(L10NARGS_ATTR_NAME);
}
return element;
}
/**
* Get the `data-l10n-*` attributes from DOM elements.
*
* ```javascript
* localization.getAttributes(
* document.querySelector('#welcome')
* );
* // -> { id: 'hello', args: { who: 'world' } }
* ```
*
* @param {Element} element - HTML element
* @returns {{id: string, args: Object}}
*/
getAttributes(element) {
return {
id: element.getAttribute(L10NID_ATTR_NAME),
args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null)
};
}
/**
* Add `newRoot` to the list of roots managed by this `DOMLocalization`.
*
* Additionally, if this `DOMLocalization` has an observer, start observing
* `newRoot` in order to translate mutations in it.
*
* @param {Element} newRoot - Root to observe.
*/
connectRoot(newRoot) {
for (const root of this.roots) {
if (root === newRoot ||
root.contains(newRoot) ||
newRoot.contains(root)) {
throw new Error("Cannot add a root that overlaps with existing root.");
}
}
if (this.windowElement) {
if (this.windowElement !== newRoot.ownerDocument.defaultView) {
throw new Error(`Cannot connect a root:
DOMLocalization already has a root from a different window.`);
}
}
else {
this.windowElement = newRoot.ownerDocument.defaultView;
this.mutationObserver = new this.windowElement.MutationObserver(mutations => this.translateMutations(mutations));
}
this.roots.add(newRoot);
this.mutationObserver.observe(newRoot, this.observerConfig);
}
/**
* Remove `root` from the list of roots managed by this `DOMLocalization`.
*
* Additionally, if this `DOMLocalization` has an observer, stop observing
* `root`.
*
* Returns `true` if the root was the last one managed by this
* `DOMLocalization`.
*
* @param {Element} root - Root to disconnect.
* @returns {boolean}
*/
disconnectRoot(root) {
this.roots.delete(root);
// Pause the mutation observer to stop observing `root`.
this.pauseObserving();
if (this.roots.size === 0) {
this.mutationObserver = null;
this.windowElement = null;
this.pendingrAF = null;
this.pendingElements.clear();
return true;
}
// Resume observing all other roots.
this.resumeObserving();
return false;
}
/**
* Translate all roots associated with this `DOMLocalization`.
*
* @returns {Promise}
*/
translateRoots() {
const roots = Array.from(this.roots);
return Promise.all(roots.map(root => this.translateFragment(root)));
}
/**
* Pauses the `MutationObserver`.
*
* @private
*/
pauseObserving() {
if (!this.mutationObserver) {
return;
}
this.translateMutations(this.mutationObserver.takeRecords());
this.mutationObserver.disconnect();
}
/**
* Resumes the `MutationObserver`.
*
* @private
*/
resumeObserving() {
if (!this.mutationObserver) {
return;
}
for (const root of this.roots) {
this.mutationObserver.observe(root, this.observerConfig);
}
}
/**
* Translate mutations detected by the `MutationObserver`.
*
* @private
*/
translateMutations(mutations) {
for (const mutation of mutations) {
switch (mutation.type) {
case "attributes":
if (mutation.target.hasAttribute("data-l10n-id")) {
this.pendingElements.add(mutation.target);
}
break;
case "childList":
for (const addedNode of mutation.addedNodes) {
if (addedNode.nodeType === addedNode.ELEMENT_NODE) {
if (addedNode.childElementCount) {
for (const element of this.getTranslatables(addedNode)) {
this.pendingElements.add(element);
}
}
else if (addedNode.hasAttribute(L10NID_ATTR_NAME)) {
this.pendingElements.add(addedNode);
}
}
}
break;
}
}
// This fragment allows us to coalesce all pending translations
// into a single requestAnimationFrame.
if (this.pendingElements.size > 0) {
if (this.pendingrAF === null) {
this.pendingrAF = this.windowElement.requestAnimationFrame(() => {
this.translateElements(Array.from(this.pendingElements));
this.pendingElements.clear();
this.pendingrAF = null;
});
}
}
}
/**
* Translate a DOM element or fragment asynchronously using this
* `DOMLocalization` object.
*
* Manually trigger the translation (or re-translation) of a DOM fragment.
* Use the `data-l10n-id` and `data-l10n-args` attributes to mark up the DOM
* with information about which translations to use.
*
* Returns a `Promise` that gets resolved once the translation is complete.
*
* @param {DOMFragment} frag - Element or DocumentFragment to be translated
* @returns {Promise}
*/
translateFragment(frag) {
return this.translateElements(this.getTranslatables(frag));
}
/**
* Translate a list of DOM elements asynchronously using this
* `DOMLocalization` object.
*
* Manually trigger the translation (or re-translation) of a list of elements.
* Use the `data-l10n-id` and `data-l10n-args` attributes to mark up the DOM
* with information about which translations to use.
*
* Returns a `Promise` that gets resolved once the translation is complete.
*
* @param {Array<Element>} elements - List of elements to be translated
* @returns {Promise}
*/
async translateElements(elements) {
if (!elements.length) {
return undefined;
}
const keys = elements.map(this.getKeysForElement);
const translations = await this.formatMessages(keys);
return this.applyTranslations(elements, translations);
}
/**
* Applies translations onto elements.
*
* @param {Array<Element>} elements
* @param {Array<Object>} translations
* @private
*/
applyTranslations(elements, translations) {
this.pauseObserving();
for (let i = 0; i < elements.length; i++) {
if (translations[i] !== undefined) {
translateElement(elements[i], translations[i]);
}
}
this.resumeObserving();
}
/**
* Collects all translatable child elements of the element.
*
* @param {Element} element
* @returns {Array<Element>}
* @private
*/
getTranslatables(element) {
const nodes = Array.from(element.querySelectorAll(L10N_ELEMENT_QUERY));
if (typeof element.hasAttribute === "function" &&
element.hasAttribute(L10NID_ATTR_NAME)) {
nodes.push(element);
}
return nodes;
}
/**
* Get the `data-l10n-*` attributes from DOM elements as a two-element
* array.
*
* @param {Element} element
* @returns {Object}
* @private
*/
getKeysForElement(element) {
return {
id: element.getAttribute(L10NID_ATTR_NAME),
args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null)
};
}
}
}
exports.DOMLocalization = DOMLocalization;
exports.Localization = Localization;
exports.DOMLocalization = DOMLocalization;
exports.Localization = Localization;
Object.defineProperty(exports, '__esModule', { value: true });
Object.defineProperty(exports, '__esModule', { value: true });
}));
})));
{
"name": "@fluent/dom",
"version": "0.6.0",
"version": "0.7.0",
"description": "Fluent bindings for DOM",

@@ -21,7 +21,6 @@ "repository": {

],
"directories": {
"lib": "./src"
},
"type": "commonjs",
"main": "./index.js",
"module": "./src/index.js",
"module": "./esm/index.js",
"types": "./esm/index.d.ts",
"keywords": [

@@ -40,5 +39,6 @@ "localization",

"engines": {
"node": ">=8.9.0"
"node": ">=10.0.0"
},
"devDependencies": {
"@fluent/bundle": "^0.14.0",
"jsdom": "^15.1.0"

@@ -45,0 +45,0 @@ },

@@ -1,6 +0,8 @@

# @fluent/dom
# @fluent/dom ![](https://github.com/projectfluent/fluent.js/workflows/@fluent/dom/badge.svg)
`@fluent/dom` provides DOM bindings for Project Fluent, a localization
framework designed to unleash the expressive power of the natural language.
`@fluent/dom` provides the DOM bindings for [Project Fluent][].
[Project Fluent]: https://projectfluent.org
## Installation

@@ -65,11 +67,1 @@

```
## Learn more
Find out more about Project Fluent at [projectfluent.org][], including
documentation of the Fluent file format ([FTL][]), links to other packages and
implementations, and information about how to get involved.
[projectfluent.org]: https://projectfluent.org
[FTL]: https://projectfluent.org/fluent/guide/
SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc