Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

fluent-dom

Package Overview
Dependencies
Maintainers
3
Versions
8
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

fluent-dom - npm Package Compare versions

Comparing version 0.0.1 to 0.1.0

test/__setup.js

20

CHANGELOG.md
# Changelog
## Unreleased
- …
## fluent-dom 0.1.0
- Extend formatWithFallback to accept async iterator (#46)
- Documented all methods
- Removed `DOMLocalization.prototype.translateRoot` method
- Simplified initial version of DOM Overlays (to be extended in 0.2) (#71)
- Only children of the white-listed types are allowed now. It's not possible
anymore to put elements of other types in the source HTML to make exceptions.
- The identity of the source element's children is explicitly not kept
anymore. This allows us to treat the translation DocumentFragment as the
reference for iteration over child nodes.
- The overlay function is also no longer recursive. Any nested HTML
will be lost and only its textContent will be preserved.
- Added `data-l10n-attrs` to allow for whitelisting localizable attributes (#70)
- Added a guard to prevent registering nested roots (#72)
- Added a guard to prevent leaking attributes between translations (#73)
- Added a performance optimization coalescing all translations from mutations
per animation frame (#113)
## fluent-dom 0.0.1

@@ -8,0 +26,0 @@

19

package.json
{
"name": "fluent-dom",
"version": "0.0.1",
"version": "0.1.0",
"description": "Fluent bindings for DOM",

@@ -28,7 +28,18 @@ "repository": {

"localization",
"l10n"
"l10n",
"internationalization",
"i18n",
"locale",
"language",
"formatting",
"translate",
"translation",
"format"
],
"engine": {
"node": ">=6"
"engines": {
"node": ">=8.9.0"
},
"devDependencies": {
"jsdom": "^11.6.2"
}
}

@@ -68,3 +68,3 @@ # fluent-dom

Find out more about Project Fluent at [projectfluent.io][], including
Find out more about Project Fluent at [projectfluent.org][], including
documentation of the Fluent file format ([FTL][]), links to other packages and

@@ -74,3 +74,3 @@ implementations, and information about how to get involved.

[projectfluent.io]: http://projectfluent.io
[FTL]: http://projectfluent.io/fluent/guide/
[projectfluent.org]: http://projectfluent.org
[FTL]: http://projectfluent.org/fluent/guide/
import overlayElement from './overlay';
import Localization from './localization';
const L10NID_ATTR_NAME = 'data-l10n-id';
const L10NARGS_ATTR_NAME = 'data-l10n-args';
const L10N_ELEMENT_QUERY = `[${L10NID_ATTR_NAME}]`;
/**

@@ -14,11 +19,19 @@ * The `DOMLocalization` class is responsible for fetching resources and

/**
* @param {Window} windowElement
* @param {Array<String>} resourceIds - List of resource IDs
* @param {Function} generateMessages - Function that returns a
* generator over MessageContexts
* @returns {DOMLocalization}
*/
constructor(MutationObserver, resIds, generateMessages) {
super(resIds, generateMessages);
this.query = '[data-l10n-id]';
constructor(windowElement, resourceIds, generateMessages) {
super(resourceIds, generateMessages);
// A Set of DOM trees observed by the `MutationObserver`.
this.roots = new Set();
this.mutationObserver = new MutationObserver(
// requestAnimationFrame handler.
this.pendingrAF = null;
// list of elements pending for translation.
this.pendingElements = new Set();
this.windowElement = windowElement;
this.mutationObserver = new windowElement.MutationObserver(
mutations => this.translateMutations(mutations)

@@ -32,10 +45,6 @@ );

subtree: true,
attributeFilter: ['data-l10n-id', 'data-l10n-args']
attributeFilter: [L10NID_ATTR_NAME, L10NARGS_ATTR_NAME]
};
}
handleEvent() {
this.onLanguageChange();
}
onLanguageChange() {

@@ -74,12 +83,15 @@ super.onLanguageChange();

* </p>
* ```
*
* @param {Element} element - Element to set attributes on
* @param {string} id - l10n-id string
* @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('data-l10n-id', id);
element.setAttribute(L10NID_ATTR_NAME, id);
if (args) {
element.setAttribute('data-l10n-args', JSON.stringify(args));
element.setAttribute(L10NARGS_ATTR_NAME, JSON.stringify(args));
} else {
element.removeAttribute(L10NARGS_ATTR_NAME);
}

@@ -104,4 +116,4 @@ return element;

return {
id: element.getAttribute('data-l10n-id'),
args: JSON.parse(element.getAttribute('data-l10n-args'))
id: element.getAttribute(L10NID_ATTR_NAME),
args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null)
};

@@ -111,12 +123,20 @@ }

/**
* Add `root` to the list of roots managed by this `DOMLocalization`.
* Add `newRoot` to the list of roots managed by this `DOMLocalization`.
*
* Additionally, if this `DOMLocalization` has an observer, start observing
* `root` in order to translate mutations in it.
* `newRoot` in order to translate mutations in it.
*
* @param {Element} root - Root to observe.
* @param {Element} newRoot - Root to observe.
*/
connectRoot(root) {
this.roots.add(root);
this.mutationObserver.observe(root, this.observerConfig);
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.');
}
}
this.roots.add(newRoot);
this.mutationObserver.observe(newRoot, this.observerConfig);
}

@@ -153,3 +173,3 @@

return Promise.all(
roots.map(root => this.translateRoot(root))
roots.map(root => this.translateElements(this.getTranslatables(root)))
);

@@ -159,11 +179,2 @@ }

/**
* Translate `root`.
*
* @returns {Promise}
*/
translateRoot(root) {
return this.translateFragment(root);
}
/**
* Pauses the `MutationObserver`.

@@ -174,2 +185,3 @@ *

pauseObserving() {
this.translateMutations(this.mutationObserver.takeRecords());
this.mutationObserver.disconnect();

@@ -198,3 +210,3 @@ }

case 'attributes':
this.translateElement(mutation.target);
this.pendingElements.add(mutation.target);
break;

@@ -205,5 +217,7 @@ case 'childList':

if (addedNode.childElementCount) {
this.translateFragment(addedNode);
} else if (addedNode.hasAttribute('data-l10n-id')) {
this.translateElement(addedNode);
for (const element of this.getTranslatables(addedNode)) {
this.pendingElements.add(element);
}
} else if (addedNode.hasAttribute(L10NID_ATTR_NAME)) {
this.pendingElements.add(addedNode);
}

@@ -215,4 +229,17 @@ }

}
// 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;
});
}
}
}
/**

@@ -232,27 +259,35 @@ * Translate a DOM element or fragment asynchronously using this

translateFragment(frag) {
const elements = this.getTranslatables(frag);
if (!elements.length) {
return Promise.resolve([]);
}
const keys = elements.map(this.getKeysForElement);
return this.formatMessages(keys).then(
translations => this.applyTranslations(elements, translations)
);
return this.translateElements(this.getTranslatables(frag));
}
/**
* Translate a single DOM element asynchronously.
* 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 {Element} element - HTML element to be translated
* @param {Array<Element>} elements - List of elements to be translated
* @returns {Promise}
*/
translateElement(element) {
return this.formatMessages([this.getKeysForElement(element)]).then(
translations => this.applyTranslations([element], translations)
);
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) {

@@ -268,7 +303,14 @@ this.pauseObserving();

/**
* Collects all translatable child elements of the element.
*
* @param {Element} element
* @returns {Array<Element>}
* @private
*/
getTranslatables(element) {
const nodes = Array.from(element.querySelectorAll(this.query));
const nodes = Array.from(element.querySelectorAll(L10N_ELEMENT_QUERY));
if (typeof element.hasAttribute === 'function' &&
element.hasAttribute('data-l10n-id')) {
element.hasAttribute(L10NID_ATTR_NAME)) {
nodes.push(element);

@@ -280,8 +322,16 @@ }

/**
* Get the `data-l10n-*` attributes from DOM elements as a two-element
* array.
*
* @param {Element} element
* @returns {Array<string, Object>}
* @private
*/
getKeysForElement(element) {
return [
element.getAttribute('data-l10n-id'),
JSON.parse(element.getAttribute('data-l10n-args') || null)
element.getAttribute(L10NID_ATTR_NAME),
JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null)
];
}
}
/* eslint no-console: ["error", { allow: ["warn", "error"] }] */
/* global console */
import { CachedIterable } from '../../fluent/src/index';
/**
* Specialized version of an Error used to indicate errors that are result
* of a problem during the localization process.
*
* We use them to identify the class of errors the require a fallback
* mechanism to be triggered vs errors that should be reported, but
* do not prevent the message from being used.
*
* An example of an L10nError is a missing entry.
*/
class L10nError extends Error {
constructor(message, id, lang) {
constructor(message) {
super();
this.name = 'L10nError';
this.message = message;
this.id = id;
this.lang = lang;
}

@@ -15,13 +25,19 @@ }

/**
* The `Localization` class is responsible for fetching resources and
* formatting translations.
* The `Localization` class is a central high-level API for vanilla
* JavaScript use of Fluent.
* It combines language negotiation, MessageContext and I/O to
* provide a scriptable API to format translations.
*/
export default class Localization {
/**
* @param {Array<String>} resourceIds - List of resource IDs
* @param {Function} generateMessages - Function that returns a
* generator over MessageContexts
*
* @returns {Localization}
*/
constructor(resIds, generateMessages) {
this.resIds = resIds;
constructor(resourceIds, generateMessages) {
this.resourceIds = resourceIds;
this.generateMessages = generateMessages;
this.ctxs = this.generateMessages(this.id, this.resIds);
this.ctxs = new CachedIterable(this.generateMessages(this.resourceIds));
}

@@ -43,3 +59,8 @@

const translations = [];
for (const ctx of this.ctxs) {
for (let ctx of this.ctxs) {
// This can operate on synchronous and asynchronous
// contexts coming from the iterator.
if (typeof ctx.then === 'function') {
ctx = await ctx;
}
const errors = keysFromContext(method, ctx, keys, translations);

@@ -88,3 +109,4 @@ if (!errors) {

* ['hello', { who: 'Mary' }],
* ['hello', { who: 'John' }]
* ['hello', { who: 'John' }],
* ['welcome']
* ]).then(console.log);

@@ -134,4 +156,8 @@ *

/**
* This method should be called when there's a reason to believe
* that language negotiation or available resources changed.
*/
onLanguageChange() {
this.ctxs = this.generateMessages(this.id, this.resIds);
this.ctxs = new CachedIterable(this.generateMessages(this.resourceIds));
}

@@ -159,3 +185,3 @@ }

function valueFromContext(ctx, errors, id, args) {
const msg = ctx.getMessages(id);
const msg = ctx.getMessage(id);

@@ -222,4 +248,2 @@ if (msg === undefined) {

/**
* @private
*
* This function is an inner function for `Localization.formatWithFallback`.

@@ -236,3 +260,3 @@ *

*
* In the function, we loop oer `keys` and check if we have the `prev`
* 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.

@@ -255,2 +279,3 @@ *

* @returns {Boolean}
* @private
*/

@@ -257,0 +282,0 @@ function keysFromContext(method, ctx, keys, translations) {

@@ -5,4 +5,8 @@ // Match the opening angle bracket (<) in HTML tags, and HTML entities like

// XXX The allowed list should be amendable; https://bugzil.la/922573.
const ALLOWED_ELEMENTS = {
/**
* The list of elements that are allowed to be inserted into a localization.
*
* Source: https://www.w3.org/TR/html5/text-level-semantics.html
*/
const LOCALIZABLE_ELEMENTS = {
'http://www.w3.org/1999/xhtml': [

@@ -15,3 +19,3 @@ 'a', 'em', 'strong', 'small', 's', 'cite', 'q', 'dfn', 'abbr', 'data',

const ALLOWED_ATTRIBUTES = {
const LOCALIZABLE_ATTRIBUTES = {
'http://www.w3.org/1999/xhtml': {

@@ -21,3 +25,3 @@ global: ['title', 'aria-label', 'aria-valuetext', 'aria-moz-hint'],

area: ['download', 'alt'],
// value is special-cased in isAttrAllowed
// value is special-cased in isAttrNameLocalizable
input: ['alt', 'placeholder'],

@@ -47,7 +51,7 @@ menuitem: ['label'],

*
* @param {Element} element
* @param {string} translation
* @param {Element} targetElement
* @param {string|Object} translation
* @private
*/
export default function overlayElement(element, translation) {
export default function overlayElement(targetElement, translation) {
const value = translation.value;

@@ -58,21 +62,34 @@

// If the translation doesn't contain any markup skip the overlay logic.
element.textContent = value;
targetElement.textContent = value;
} else {
// Else start with an inert template element and move its children into
// `element` but such that `element`'s own children are not replaced.
const tmpl = element.ownerDocument.createElementNS(
// Else parse the translation's HTML using an inert template element,
// sanitize it and replace the targetElement's content.
const templateElement = targetElement.ownerDocument.createElementNS(
'http://www.w3.org/1999/xhtml', 'template');
tmpl.innerHTML = value;
// Overlay the node with the DocumentFragment.
overlay(element, tmpl.content);
templateElement.innerHTML = value;
targetElement.appendChild(
// The targetElement will be cleared at the end of sanitization.
sanitizeUsing(templateElement.content, targetElement)
);
}
}
if (translation.attrs === null) {
return;
const explicitlyAllowed = targetElement.hasAttribute('data-l10n-attrs')
? targetElement.getAttribute('data-l10n-attrs')
.split(',').map(i => i.trim())
: null;
// Remove localizable attributes which may have been set by a previous
// translation.
for (const attr of Array.from(targetElement.attributes)) {
if (isAttrNameLocalizable(attr.name, targetElement, explicitlyAllowed)) {
targetElement.removeAttribute(attr.name);
}
}
for (const [name, val] of translation.attrs) {
if (isAttrAllowed({ name }, element)) {
element.setAttribute(name, val);
if (translation.attrs) {
for (const [name, val] of translation.attrs) {
if (isAttrNameLocalizable(name, targetElement, explicitlyAllowed)) {
targetElement.setAttribute(name, val);
}
}

@@ -82,67 +99,100 @@ }

// The goal of overlay is to move the children of `translationElement`
// into `sourceElement` such that `sourceElement`'s own children are not
// replaced, but only have their text nodes and their attributes modified.
//
// We want to make it possible for localizers to apply text-level semantics to
// the translations and make use of HTML entities. At the same time, we
// don't trust translations so we need to filter unsafe elements and
// attributes out and we don't want to break the Web by replacing elements to
// which third-party code might have created references (e.g. two-way
// bindings in MVC frameworks).
function overlay(sourceElement, translationElement) {
const result = translationElement.ownerDocument.createDocumentFragment();
let k, attr;
// Take one node from translationElement at a time and check it against
/**
* Sanitize `translationFragment` using `sourceElement` to add functional
* HTML attributes to children. `sourceElement` will have all its child nodes
* removed.
*
* The sanitization is conducted according to the following rules:
*
* - Allow text nodes.
* - Replace forbidden children with their textContent.
* - Remove forbidden attributes from allowed children.
*
* Additionally when a child of the same type is present in `sourceElement` its
* attributes will be merged into the translated child. Whitelisted attributes
* of the translated child will then overwrite the ones present in the source.
*
* The overlay logic is subject to the following limitations:
*
* - Children are always cloned. Event handlers attached to them are lost.
* - Nested HTML in source and in translations is not supported.
* - Multiple children of the same type will be matched in order.
*
* @param {DocumentFragment} translationFragment
* @param {Element} sourceElement
* @returns {DocumentFragment}
* @private
*/
function sanitizeUsing(translationFragment, sourceElement) {
const ownerDocument = translationFragment.ownerDocument;
// Take one node from translationFragment at a time and check it against
// the allowed list or try to match it with a corresponding element
// in the source.
let childElement;
while ((childElement = translationElement.childNodes[0])) {
translationElement.removeChild(childElement);
for (const childNode of translationFragment.childNodes) {
if (childElement.nodeType === childElement.TEXT_NODE) {
result.appendChild(childElement);
if (childNode.nodeType === childNode.TEXT_NODE) {
continue;
}
const index = getIndexOfType(childElement);
const sourceChild = getNthElementOfType(sourceElement, childElement, index);
if (sourceChild) {
// There is a corresponding element in the source, let's use it.
overlay(sourceChild, childElement);
result.appendChild(sourceChild);
// If the child is forbidden just take its textContent.
if (!isElementLocalizable(childNode)) {
const text = ownerDocument.createTextNode(childNode.textContent);
translationFragment.replaceChild(text, childNode);
continue;
}
if (isElementAllowed(childElement)) {
const sanitizedChild = childElement.ownerDocument.createElement(
childElement.nodeName);
overlay(sanitizedChild, childElement);
result.appendChild(sanitizedChild);
continue;
// Start the sanitization with an empty element.
const mergedChild = ownerDocument.createElement(childNode.localName);
// Explicitly discard nested HTML by serializing childNode to a TextNode.
mergedChild.textContent = childNode.textContent;
// If a child of the same type exists in sourceElement, take its functional
// (i.e. non-localizable) attributes. This also removes the child from
// sourceElement.
const sourceChild = shiftNamedElement(sourceElement, childNode.localName);
// Find the union of all safe attributes: localizable attributes from
// childNode and functional attributes from sourceChild.
const safeAttributes = sanitizeAttrsUsing(childNode, sourceChild);
for (const attr of safeAttributes) {
mergedChild.setAttribute(attr.name, attr.value);
}
// Otherwise just take this child's textContent.
result.appendChild(
translationElement.ownerDocument.createTextNode(
childElement.textContent));
translationFragment.replaceChild(mergedChild, childNode);
}
// Clear `sourceElement` and append `result` which by this time contains
// `sourceElement`'s original children, overlayed with translation.
// SourceElement might have been already modified by shiftNamedElement.
// Let's clear it to make sure other code doesn't rely on random leftovers.
sourceElement.textContent = '';
sourceElement.appendChild(result);
// If we're overlaying a nested element, translate the allowed
// attributes; top-level attributes are handled in `overlayElement`.
// XXX Attributes previously set here for another language should be
// cleared if a new language doesn't use them; https://bugzil.la/922577
if (translationElement.attributes) {
for (k = 0, attr; (attr = translationElement.attributes[k]); k++) {
if (isAttrAllowed(attr, sourceElement)) {
sourceElement.setAttribute(attr.name, attr.value);
}
}
return translationFragment;
}
/**
* Sanitize and merge attributes.
*
* Only localizable attributes from the translated child element and only
* functional attributes from the source child element are considered safe.
*
* @param {Element} translatedElement
* @param {Element} sourceElement
* @returns {Array<Attr>}
* @private
*/
function sanitizeAttrsUsing(translatedElement, sourceElement) {
const localizedAttrs = Array.from(translatedElement.attributes).filter(
attr => isAttrNameLocalizable(attr.name, translatedElement)
);
if (!sourceElement) {
return localizedAttrs;
}
const functionalAttrs = Array.from(sourceElement.attributes).filter(
attr => !isAttrNameLocalizable(attr.name, sourceElement)
);
return localizedAttrs.concat(functionalAttrs);
}

@@ -160,9 +210,5 @@

*/
function isElementAllowed(element) {
const allowed = ALLOWED_ELEMENTS[element.namespaceURI];
if (!allowed) {
return false;
}
return allowed.indexOf(element.tagName.toLowerCase()) !== -1;
function isElementLocalizable(element) {
const allowed = LOCALIZABLE_ELEMENTS[element.namespaceURI];
return allowed && allowed.includes(element.localName);
}

@@ -177,9 +223,17 @@

*
* @param {{name: string}} attr
* `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 isAttrAllowed(attr, element) {
const allowed = ALLOWED_ATTRIBUTES[element.namespaceURI];
function isAttrNameLocalizable(name, element, explicitlyAllowed = null) {
if (explicitlyAllowed && explicitlyAllowed.includes(name)) {
return true;
}
const allowed = LOCALIZABLE_ATTRIBUTES[element.namespaceURI];
if (!allowed) {

@@ -189,7 +243,7 @@ return false;

const attrName = attr.name.toLowerCase();
const elemName = element.tagName.toLowerCase();
const attrName = name.toLowerCase();
const elemName = element.localName;
// Is it a globally safe attribute?
if (allowed.global.indexOf(attrName) !== -1) {
if (allowed.global.includes(attrName)) {
return true;

@@ -204,3 +258,3 @@ }

// Is it allowed on this element?
if (allowed[elemName].indexOf(attrName) !== -1) {
if (allowed[elemName].includes(attrName)) {
return true;

@@ -221,15 +275,15 @@ }

// Get n-th immediate child of context that is of the same type as element.
// XXX Use querySelector(':scope > ELEMENT:nth-of-type(index)'), when:
// 1) :scope is widely supported in more browsers and 2) it works with
// DocumentFragments.
function getNthElementOfType(context, element, index) {
let nthOfType = 0;
for (let i = 0, child; (child = context.children[i]); i++) {
if (child.nodeType === child.ELEMENT_NODE &&
child.tagName.toLowerCase() === element.tagName.toLowerCase()) {
if (nthOfType === index) {
return child;
}
nthOfType++;
/**
* Remove and return the first child of the given type.
*
* @param {DOMFragment} element
* @param {string} localName
* @returns {Element | null}
* @private
*/
function shiftNamedElement(element, localName) {
for (const child of element.children) {
if (child.localName === localName) {
element.removeChild(child);
return child;
}

@@ -239,13 +293,1 @@ }

}
// Get the index of the element among siblings of the same type.
function getIndexOfType(element) {
let index = 0;
let child;
while ((child = element.previousElementSibling)) {
if (child.tagName === element.tagName) {
index++;
}
}
return index;
}

Sorry, the diff of this file is not supported yet

SocketSocket SOC 2 Logo

Product

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

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc