Bug 1461048 - Update FluentDOM to 0.3.0. r=mossop

MozReview-Commit-ID: FKHICfiqXVr

--HG--
extra : rebase_source : 93258d2425509bbdc2a4a9e77b98ed87da526af0
This commit is contained in:
Zibi Braniecki 2018-05-14 12:14:27 -07:00
Родитель 4fecaa7a1a
Коммит 1fc028c7d8
3 изменённых файлов: 191 добавлений и 122 удалений

Просмотреть файл

@ -82,7 +82,7 @@ const LOCALIZABLE_ATTRIBUTES = {
* @private
*/
function translateElement(element, translation) {
const value = translation.value;
const {value} = translation;
if (typeof value === "string") {
if (!reOverlay.test(value)) {
@ -112,19 +112,41 @@ function translateElement(element, translation) {
* The contents of the target element will be cleared and fully replaced with
* sanitized contents of the source element.
*
* @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);
}
/**
@ -166,75 +188,79 @@ function overlayAttributes(fromElement, toElement) {
}
/**
* 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.
*
* 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 forof iteration over its child nodes.
* 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} 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);
}
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.`
);
} 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);
}
}
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);
}
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."
function namedChildFrom(sourceElement, translatedChild) {
const childName = translatedChild.getAttribute("data-l10n-name");
const sourceChild = sourceElement.querySelector(
`[data-l10n-name="${childName}"]`
);
// If all else fails, convert the element to its text content.
return childNode.ownerDocument.createTextNode(childNode.textContent);
if (!sourceChild) {
console.warn(
`An element named "${childName}" wasn't found in the source.`
);
return textNode(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 textNode(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 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);
}
/**
@ -406,8 +432,8 @@ class DOMLocalization extends Localization {
};
}
onLanguageChange() {
super.onLanguageChange();
onChange() {
super.onChange();
this.translateRoots();
}
@ -728,14 +754,14 @@ class DOMLocalization extends Localization {
* array.
*
* @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)
};
}
}

Просмотреть файл

@ -50,20 +50,6 @@ class CachedAsyncIterable {
this.seen = [];
}
[Symbol.iterator]() {
const { seen, iterator } = this;
let cur = 0;
return {
next() {
if (seen.length <= cur) {
seen.push(iterator.next());
}
return seen[cur++];
}
};
}
[Symbol.asyncIterator]() {
const { seen, iterator } = this;
let cur = 0;
@ -126,7 +112,18 @@ class Localization {
constructor(resourceIds, generateMessages = defaultGenerateMessages) {
this.resourceIds = resourceIds;
this.generateMessages = generateMessages;
this.ctxs = new CachedAsyncIterable(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();
}
/**
@ -136,7 +133,7 @@ class Localization {
* DOMLocalization. In case of errors, fetch the next context in the
* fallback chain.
*
* @param {Array<Array>} keys - Translation keys to format.
* @param {Array<Object>} keys - Translation keys to format.
* @param {Function} method - Formatting function.
* @returns {Promise<Array<string|Object>>}
* @private
@ -144,12 +141,7 @@ class Localization {
async formatWithFallback(keys, method) {
const translations = [];
for await (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);
if (missingIds.size === 0) {
@ -167,26 +159,26 @@ class Localization {
}
/**
* 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' } }
* // ]
*
* Returns a Promise resolving to an array of the translation strings.
*
* @param {Array<Array>} keys
* @returns {Promise<Array<{value: string, attrs: Object}>>}
* @param {Array<Object>} keys
* @returns {Promise<Array<{value: string, attributes: Object}>>}
* @private
*/
formatMessages(keys) {
@ -200,16 +192,16 @@ class Localization {
* either be simple string identifiers or `[id, args]` arrays.
*
* docL10n.formatValues([
* ['hello', { who: 'Mary' }],
* ['hello', { who: 'John' }],
* ['welcome']
* {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<Array>} keys
* @param {Array<Object>} keys
* @returns {Promise<Array<string>>}
*/
formatValues(keys) {
@ -239,7 +231,7 @@ class Localization {
* @returns {Promise<string>}
*/
async formatValue(id, args) {
const [val] = await this.formatValues([[id, args]]);
const [val] = await this.formatValues([{id, args}]);
return val;
}
@ -260,7 +252,7 @@ class Localization {
observe(subject, topic, data) {
switch (topic) {
case "intl:app-locales-changed":
this.onLanguageChange();
this.onChange();
break;
default:
break;
@ -271,8 +263,9 @@ class Localization {
* This method should be called when there's a reason to believe
* that language negotiation or available resources changed.
*/
onLanguageChange() {
this.ctxs = new CachedAsyncIterable(this.generateMessages(this.resourceIds));
onChange() {
this.ctxs =
new CachedAsyncIterable(this.generateMessages(this.resourceIds));
}
}
@ -300,12 +293,11 @@ Localization.prototype.QueryInterface = ChromeUtils.generateQI([
*/
function valueFromContext(ctx, errors, id, args) {
const msg = ctx.getMessage(id);
return ctx.format(msg, args, errors);
}
/**
* Format all public values of a message into a { value, attrs } object.
* Format all public values of a message into a {value, attributes} object.
*
* This function is passed as a method to `keysFromContext` and resolve
* a single L10n Entity using provided `MessageContext`.
@ -314,7 +306,7 @@ function valueFromContext(ctx, errors, id, args) {
* entity.
*
* 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.
*
* In both cases, an error is being added to the errors array.
@ -374,7 +366,7 @@ function messageFromContext(ctx, errors, id, args) {
* @param {Function} method
* @param {MessageContext} ctx
* @param {Array<string>} keys
* @param {{Array<{value: string, attrs: Object}>}} translations
* @param {{Array<{value: string, attributes: Object}>}} translations
*
* @returns {Set<string>}
* @private
@ -383,17 +375,17 @@ function keysFromContext(method, ctx, keys, translations) {
const messageErrors = [];
const missingIds = new Set();
keys.forEach((key, i) => {
keys.forEach(({id, args}, i) => {
if (translations[i] !== undefined) {
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);
}
});

Просмотреть файл

@ -43,6 +43,7 @@ add_task(async function test_methods_calling() {
equal(values[1], "[en] Value3");
L10nRegistry.sources.clear();
L10nRegistry.ctxCache.clear();
L10nRegistry.load = originalLoad;
Services.locale.setRequestedLocales(originalRequested);
});
@ -89,5 +90,55 @@ key = { PLATFORM() ->
`${ known_platforms[AppConstants.platform].toUpperCase() } Value`));
L10nRegistry.sources.clear();
L10nRegistry.ctxCache.clear();
L10nRegistry.load = originalLoad;
});
add_task(async function test_add_remove_resourceIds() {
const { L10nRegistry, FileSource } =
ChromeUtils.import("resource://gre/modules/L10nRegistry.jsm", {});
const fs = {
"/localization/en-US/browser/menu.ftl": "key1 = Value1",
"/localization/en-US/toolkit/menu.ftl": "key2 = Value2",
};
const originalLoad = L10nRegistry.load;
const originalRequested = Services.locale.getRequestedLocales();
L10nRegistry.load = async function(url) {
return fs[url];
};
const source = new FileSource("test", ["en-US"], "/localization/{locale}");
L10nRegistry.registerSource(source);
async function* generateMessages(resIds) {
yield * await L10nRegistry.generateContexts(["en-US"], resIds);
}
const l10n = new Localization(["/browser/menu.ftl"], generateMessages);
let values = await l10n.formatValues([{id: "key1"}, {id: "key2"}]);
equal(values[0], "Value1");
equal(values[1], undefined);
l10n.addResourceIds(["/toolkit/menu.ftl"]);
values = await l10n.formatValues([{id: "key1"}, {id: "key2"}]);
equal(values[0], "Value1");
equal(values[1], "Value2");
l10n.removeResourceIds(["/browser/menu.ftl"]);
values = await l10n.formatValues([{id: "key1"}, {id: "key2"}]);
equal(values[0], undefined);
equal(values[1], "Value2");
L10nRegistry.sources.clear();
L10nRegistry.ctxCache.clear();
L10nRegistry.load = originalLoad;
Services.locale.setRequestedLocales(originalRequested);
});