Comparing version 0.2.0 to 0.3.0
@@ -6,2 +6,8 @@ # Changelog | ||
## fluent-dom 0.3.0 | ||
- Refactor the overlay sanitization methods into separate functions. (#189) | ||
- Separate out CachedIterable and CachedAsyncIterable, and add a param to touchNext. (#191) | ||
- Localization.formatValues should accept an array of objects. (#198) | ||
- Allow adding and removing resource Ids from a Localization. (#197) | ||
## fluent-dom 0.2.0 | ||
@@ -8,0 +14,0 @@ |
{ | ||
"name": "fluent-dom", | ||
"version": "0.2.0", | ||
"version": "0.3.0", | ||
"description": "Fluent bindings for DOM", | ||
@@ -5,0 +5,0 @@ "repository": { |
@@ -48,4 +48,4 @@ import translateElement from "./overlay"; | ||
onLanguageChange() { | ||
super.onLanguageChange(); | ||
onChange() { | ||
super.onChange(); | ||
this.translateRoots(); | ||
@@ -318,11 +318,11 @@ } | ||
* @param {Element} element | ||
* @returns {Array<string, Object>} | ||
* @returns {Object} | ||
* @private | ||
*/ | ||
getKeysForElement(element) { | ||
return [ | ||
element.getAttribute(L10NID_ATTR_NAME), | ||
JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null) | ||
]; | ||
return { | ||
id: element.getAttribute(L10NID_ATTR_NAME), | ||
args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null) | ||
}; | ||
} | ||
} |
/* eslint no-console: ["error", { allow: ["warn", "error"] }] */ | ||
/* global console */ | ||
import { CachedIterable } from "../../fluent/src/index"; | ||
import { CachedAsyncIterable } from "../../fluent/src/index"; | ||
@@ -23,5 +23,16 @@ /** | ||
this.generateMessages = generateMessages; | ||
this.ctxs = new CachedIterable(this.generateMessages(this.resourceIds)); | ||
this.ctxs = | ||
new CachedAsyncIterable(this.generateMessages(this.resourceIds)); | ||
} | ||
addResourceIds(resourceIds) { | ||
this.resourceIds.push(...resourceIds); | ||
this.onChange(); | ||
} | ||
removeResourceIds(resourceIds) { | ||
this.resourceIds = this.resourceIds.filter(r => !resourceIds.includes(r)); | ||
this.onChange(); | ||
} | ||
/** | ||
@@ -34,3 +45,3 @@ * Format translations and handle fallback if needed. | ||
* | ||
* @param {Array<Array>} keys - Translation keys to format. | ||
* @param {Array<Object>} keys - Translation keys to format. | ||
* @param {Function} method - Formatting function. | ||
@@ -43,8 +54,3 @@ * @returns {Promise<Array<string|Object>>} | ||
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; | ||
} | ||
for await (const ctx of this.ctxs) { | ||
const missingIds = keysFromContext(method, ctx, keys, translations); | ||
@@ -67,16 +73,16 @@ | ||
/** | ||
* Format translations into {value, attrs} objects. | ||
* Format translations into {value, attributes} objects. | ||
* | ||
* The fallback logic is the same as in `formatValues` but the argument type | ||
* is stricter (an array of arrays) and it returns {value, attrs} objects | ||
* which are suitable for the translation of DOM elements. | ||
* is stricter (an array of arrays) and it returns {value, attributes} | ||
* objects which are suitable for the translation of DOM elements. | ||
* | ||
* docL10n.formatMessages([ | ||
* ['hello', { who: 'Mary' }], | ||
* ['welcome', undefined] | ||
* {id: 'hello', args: { who: 'Mary' }}, | ||
* {id: 'welcome'} | ||
* ]).then(console.log); | ||
* | ||
* // [ | ||
* // { value: 'Hello, Mary!', attrs: null }, | ||
* // { value: 'Welcome!', attrs: { title: 'Hello' } } | ||
* // { value: 'Hello, Mary!', attributes: null }, | ||
* // { value: 'Welcome!', attributes: { title: 'Hello' } } | ||
* // ] | ||
@@ -86,4 +92,4 @@ * | ||
* | ||
* @param {Array<Array>} keys | ||
* @returns {Promise<Array<{value: string, attrs: Object}>>} | ||
* @param {Array<Object>} keys | ||
* @returns {Promise<Array<{value: string, attributes: Object}>>} | ||
* @private | ||
@@ -102,5 +108,5 @@ */ | ||
* docL10n.formatValues([ | ||
* ['hello', { who: 'Mary' }], | ||
* ['hello', { who: 'John' }], | ||
* ['welcome'] | ||
* {id: 'hello', args: { who: 'Mary' }}, | ||
* {id: 'hello', args: { who: 'John' }}, | ||
* {id: 'welcome'} | ||
* ]).then(console.log); | ||
@@ -112,3 +118,3 @@ * | ||
* | ||
* @param {Array<Array>} keys | ||
* @param {Array<Object>} keys | ||
* @returns {Promise<Array<string>>} | ||
@@ -143,3 +149,3 @@ */ | ||
async formatValue(id, args) { | ||
const [val] = await this.formatValues([[id, args]]); | ||
const [val] = await this.formatValues([{id, args}]); | ||
return val; | ||
@@ -149,3 +155,3 @@ } | ||
handleEvent() { | ||
this.onLanguageChange(); | ||
this.onChange(); | ||
} | ||
@@ -157,4 +163,5 @@ | ||
*/ | ||
onLanguageChange() { | ||
this.ctxs = new CachedIterable(this.generateMessages(this.resourceIds)); | ||
onChange() { | ||
this.ctxs = | ||
new CachedAsyncIterable(this.generateMessages(this.resourceIds)); | ||
} | ||
@@ -187,3 +194,3 @@ } | ||
/** | ||
* Format all public values of a message into a { value, attrs } object. | ||
* Format all public values of a message into a {value, attributes} object. | ||
* | ||
@@ -197,3 +204,3 @@ * This function is passed as a method to `keysFromContext` and resolve | ||
* If the function fails to retrieve the entity, the value is set to the ID of | ||
* an entity, and attrs to `null`. If formatting fails, it will return | ||
* an entity, and attributes to `null`. If formatting fails, it will return | ||
* a partially resolved value and attributes. | ||
@@ -258,3 +265,3 @@ * | ||
* @param {Array<string>} keys | ||
* @param {{Array<{value: string, attrs: Object}>}} translations | ||
* @param {{Array<{value: string, attributes: Object}>}} translations | ||
* | ||
@@ -268,3 +275,3 @@ * @returns {Set<string>} | ||
keys.forEach((key, i) => { | ||
keys.forEach(({id, args}, i) => { | ||
if (translations[i] !== undefined) { | ||
@@ -274,8 +281,8 @@ return; | ||
if (ctx.hasMessage(key[0])) { | ||
if (ctx.hasMessage(id)) { | ||
messageErrors.length = 0; | ||
translations[i] = method(ctx, messageErrors, key[0], key[1]); | ||
translations[i] = method(ctx, messageErrors, id, args); | ||
// XXX: Report resolver errors | ||
} else { | ||
missingIds.add(key[0]); | ||
missingIds.add(id); | ||
} | ||
@@ -282,0 +289,0 @@ }); |
@@ -65,3 +65,3 @@ /* eslint no-console: ["error", {allow: ["warn"]}] */ | ||
export default function translateElement(element, translation) { | ||
const value = translation.value; | ||
const {value} = translation; | ||
@@ -95,15 +95,37 @@ if (typeof value === "string") { | ||
* | ||
* @param {DocumentFragment} fromElement - The source of children to overlay. | ||
* @param {DocumentFragment} fromFragment - The source of children to overlay. | ||
* @param {Element} toElement - The target of the overlay. | ||
* @private | ||
*/ | ||
function overlayChildNodes(fromElement, toElement) { | ||
const content = toElement.ownerDocument.createDocumentFragment(); | ||
function overlayChildNodes(fromFragment, toElement) { | ||
for (const childNode of fromFragment.childNodes) { | ||
if (childNode.nodeType === childNode.TEXT_NODE) { | ||
// Keep the translated text node. | ||
continue; | ||
} | ||
for (const childNode of fromElement.childNodes) { | ||
content.appendChild(sanitizeUsing(toElement, childNode)); | ||
if (childNode.hasAttribute("data-l10n-name")) { | ||
const sanitized = namedChildFrom(toElement, childNode); | ||
fromFragment.replaceChild(sanitized, childNode); | ||
continue; | ||
} | ||
if (isElementAllowed(childNode)) { | ||
const sanitized = allowedChild(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(textNode(childNode), childNode); | ||
} | ||
toElement.textContent = ""; | ||
toElement.appendChild(content); | ||
toElement.appendChild(fromFragment); | ||
} | ||
@@ -150,74 +172,78 @@ | ||
/** | ||
* Sanitize a child node created by the translation. | ||
* Sanitize a child element created by the translation. | ||
* | ||
* If childNode has the data-l10n-name attribute, 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. | ||
* 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. | ||
* | ||
* This function must return new nodes or clones in all code paths. The | ||
* returned nodes are immediately appended to the intermediate DocumentFragment | ||
* which also _removes_ them from the constructed <template> containing the | ||
* translation, which in turn breaks the for…of iteration over its child nodes. | ||
* | ||
* @param {Element} sourceElement - The source for data-l10n-name lookups. | ||
* @param {Element} childNode - The child node to be sanitized. | ||
* @param {Element} translatedChild - The translated child to be sanitized. | ||
* @returns {Element} | ||
* @private | ||
*/ | ||
function sanitizeUsing(sourceElement, childNode) { | ||
if (childNode.nodeType === childNode.TEXT_NODE) { | ||
return childNode.cloneNode(false); | ||
} | ||
function namedChildFrom(sourceElement, translatedChild) { | ||
const childName = translatedChild.getAttribute("data-l10n-name"); | ||
const sourceChild = sourceElement.querySelector( | ||
`[data-l10n-name="${childName}"]` | ||
); | ||
if (childNode.hasAttribute("data-l10n-name")) { | ||
const childName = childNode.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.` | ||
); | ||
if (!sourceChild) { | ||
console.warn( | ||
`An element named "${childName}" wasn't found in the source.` | ||
); | ||
} else if (sourceChild.localName !== childNode.localName) { | ||
console.warn( | ||
`An element named "${childName}" was found in the translation ` + | ||
`but its type ${childNode.localName} didn't match the element ` + | ||
`found in the source (${sourceChild.localName}).` | ||
); | ||
} else { | ||
// 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(childNode, clone); | ||
} | ||
return textNode(translatedChild); | ||
} | ||
if (isElementAllowed(childNode)) { | ||
// Start with an empty element of the same type to remove nested children | ||
// and non-localizable attributes defined by the translation. | ||
const clone = childNode.ownerDocument.createElement(childNode.localName); | ||
return shallowPopulateUsing(childNode, clone); | ||
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 textNode(translatedChild); | ||
} | ||
console.warn( | ||
`An element of forbidden type "${childNode.localName}" was found in ` + | ||
"the translation. Only elements with data-l10n-name can be overlaid " + | ||
"onto source elements of the same data-l10n-name." | ||
); | ||
// 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); | ||
} | ||
// If all else fails, convert the element to its text content. | ||
return childNode.ownerDocument.createTextNode(childNode.textContent); | ||
/** | ||
* 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 allowedChild(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 textNode(element) { | ||
return element.ownerDocument.createTextNode(element.textContent); | ||
} | ||
/** | ||
* Check if element is allowed in the translation. | ||
@@ -224,0 +250,0 @@ * |
@@ -5,3 +5,3 @@ import assert from "assert"; | ||
function* mockGenerateMessages(resourceIds) { | ||
async function* mockGenerateMessages(resourceIds) { | ||
const mc = new MessageContext(["en-US"]); | ||
@@ -8,0 +8,0 @@ mc.addMessages("key1 = Key 1"); |
@@ -10,3 +10,3 @@ import assert from 'assert'; | ||
value: 'FOO <em>BAR</em> BAZ', | ||
attrs: null | ||
attributes: null | ||
}; | ||
@@ -22,3 +22,3 @@ | ||
value: 'FOO <img src="img.png" />', | ||
attrs: null | ||
attributes: null | ||
}; | ||
@@ -34,3 +34,3 @@ | ||
value: 'FOO <button>BUTTON</button>', | ||
attrs: null | ||
attributes: null | ||
}; | ||
@@ -46,3 +46,3 @@ | ||
value: 'FOO <em><strong>BAR</strong></em> BAZ', | ||
attrs: null | ||
attributes: null | ||
}; | ||
@@ -60,3 +60,3 @@ | ||
value: 'FOO <em title="BAR">BAR</em>', | ||
attrs: null, | ||
attributes: null, | ||
}; | ||
@@ -73,3 +73,3 @@ | ||
value: 'FOO <em class="BAR" title="BAR">BAR</em>', | ||
attrs: null, | ||
attributes: null, | ||
}; | ||
@@ -87,3 +87,3 @@ | ||
value: '<em>FOO</em>', | ||
attrs: null | ||
attributes: null | ||
}; | ||
@@ -90,0 +90,0 @@ |
@@ -5,3 +5,3 @@ import assert from "assert"; | ||
function* mockGenerateMessages(resourceIds) { | ||
async function* mockGenerateMessages(resourceIds) { | ||
const mc = new MessageContext(["en-US"]); | ||
@@ -15,3 +15,3 @@ mc.addMessages("key1 = Key 1"); | ||
const loc = new Localization(["test.ftl"], mockGenerateMessages); | ||
const translations = await loc.formatMessages([["key1"]]); | ||
const translations = await loc.formatMessages([{id: "key1"}]); | ||
@@ -23,3 +23,3 @@ assert.equal(translations[0].value, "Key 1"); | ||
const loc = new Localization(["test.ftl"], mockGenerateMessages); | ||
const translations = await loc.formatMessages([["missing_key"]]); | ||
const translations = await loc.formatMessages([{id: "missing_key"}]); | ||
@@ -26,0 +26,0 @@ // Make sure that the returned value here is `undefined`. |
@@ -32,3 +32,3 @@ import assert from 'assert'; | ||
element.innerHTML, | ||
'<em title="FOO">FOO</em>' | ||
'FOO' | ||
); | ||
@@ -35,0 +35,0 @@ }); |
52688
1427