--- a/intl/l10n/DOMLocalization.jsm
+++ b/intl/l10n/DOMLocalization.jsm
@@ -11,44 +11,44 @@
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-/* fluent@0.4.1 */
+/* fluent@0.6.0 */
const { Localization } =
Components.utils.import("resource://gre/modules/Localization.jsm", {});
// Match the opening angle bracket (<) in HTML tags, and HTML entities like
// &, &, &.
const reOverlay = /<|&#?\w+;/;
/**
* 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 ALLOWED_ELEMENTS = {
+const LOCALIZABLE_ELEMENTS = {
'http://www.w3.org/1999/xhtml': [
'a', 'em', 'strong', 'small', 's', 'cite', 'q', 'dfn', 'abbr', 'data',
'time', 'code', 'var', 'samp', 'kbd', 'sub', 'sup', 'i', 'b', 'u',
'mark', 'ruby', 'rt', 'rp', 'bdi', 'bdo', 'span', 'br', 'wbr'
],
};
-const ALLOWED_ATTRIBUTES = {
+const LOCALIZABLE_ATTRIBUTES = {
'http://www.w3.org/1999/xhtml': {
global: ['title', 'aria-label', 'aria-valuetext', 'aria-moz-hint'],
a: ['download'],
area: ['download', 'alt'],
- // value is special-cased in isAttrNameAllowed
+ // value is special-cased in isAttrNameLocalizable
input: ['alt', 'placeholder'],
menuitem: ['label'],
menu: ['label'],
optgroup: ['label'],
option: ['label'],
track: ['label'],
img: ['alt'],
textarea: ['placeholder'],
@@ -87,28 +87,34 @@ function overlayElement(targetElement, t
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;
- for (const [name, val] of translation.attrs) {
- if (isAttrNameAllowed(name, targetElement, explicitlyAllowed)) {
- targetElement.setAttribute(name, val);
+ // 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);
+ }
+ }
+
+ if (translation.attrs) {
+ for (const [name, val] of translation.attrs) {
+ if (isAttrNameLocalizable(name, targetElement, explicitlyAllowed)) {
+ targetElement.setAttribute(name, val);
+ }
}
}
}
/**
* Sanitize `translationFragment` using `sourceElement` to add functional
* HTML attributes to children. `sourceElement` will have all its child nodes
* removed.
@@ -126,79 +132,105 @@ function overlayElement(targetElement, t
* 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.
for (const childNode of translationFragment.childNodes) {
if (childNode.nodeType === childNode.TEXT_NODE) {
continue;
}
// If the child is forbidden just take its textContent.
- if (!isElementAllowed(childNode)) {
- const text = translationFragment.ownerDocument.createTextNode(
- childNode.textContent
- );
+ if (!isElementLocalizable(childNode)) {
+ const text = ownerDocument.createTextNode(childNode.textContent);
translationFragment.replaceChild(text, childNode);
continue;
}
-
- // If a child of the same type exists in sourceElement, use it as the base
- // for the resultChild. This also removes the child from sourceElement.
- const sourceChild = shiftNamedElement(sourceElement, childNode.localName);
-
- const mergedChild = sourceChild
- // Shallow-clone the sourceChild to remove all childNodes.
- ? sourceChild.cloneNode(false)
- // Create a fresh element as a way to remove all forbidden attributes.
- : childNode.ownerDocument.createElement(childNode.localName);
+ // 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;
- for (const attr of Array.from(childNode.attributes)) {
- if (isAttrNameAllowed(attr.name, childNode)) {
- mergedChild.setAttribute(attr.name, attr.value);
- }
+ // 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);
}
translationFragment.replaceChild(mergedChild, childNode);
}
// 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 = '';
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);
+}
+
+/**
* 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 = ALLOWED_ELEMENTS[element.namespaceURI];
+function isElementLocalizable(element) {
+ const allowed = LOCALIZABLE_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
@@ -208,22 +240,22 @@ function isElementAllowed(element) {
* allowed on this element.
*
* @param {string} name
* @param {Element} element
* @param {Array} explicitlyAllowed
* @returns {boolean}
* @private
*/
-function isAttrNameAllowed(name, element, explicitlyAllowed = null) {
+function isAttrNameLocalizable(name, element, explicitlyAllowed = null) {
if (explicitlyAllowed && explicitlyAllowed.includes(name)) {
return true;
}
- const allowed = ALLOWED_ATTRIBUTES[element.namespaceURI];
+ const allowed = LOCALIZABLE_ATTRIBUTES[element.namespaceURI];
if (!allowed) {
return false;
}
const attrName = name.toLowerCase();
const elemName = element.localName;
// Is it a globally safe attribute?
@@ -470,41 +502,42 @@ class DOMLocalization extends Localizati
switch (mutation.type) {
case 'attributes':
this.pendingElements.add(mutation.target);
break;
case 'childList':
for (const addedNode of mutation.addedNodes) {
if (addedNode.nodeType === addedNode.ELEMENT_NODE) {
if (addedNode.childElementCount) {
- for (let element of this.getTranslatables(addedNode)) {
+ 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.
+ // 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.
*
--- a/intl/l10n/Localization.jsm
+++ b/intl/l10n/Localization.jsm
@@ -10,45 +10,69 @@
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-/* fluent@0.4.1 */
+
+/* fluent@0.6.0 */
/* eslint no-console: ["error", { allow: ["warn", "error"] }] */
/* global console */
const Cu = Components.utils;
const Cc = Components.classes;
const Ci = Components.interfaces;
const { L10nRegistry } = Cu.import("resource://gre/modules/L10nRegistry.jsm", {});
const LocaleService = Cc["@mozilla.org/intl/localeservice;1"].getService(Ci.mozILocaleService);
const ObserverService = Cc["@mozilla.org/observer-service;1"].getService(Ci.nsIObserverService);
-/**
+/*
* CachedIterable caches the elements yielded by an iterable.
*
* It can be used to iterate over an iterable many times without depleting the
* iterable.
*/
class CachedIterable {
+ /**
+ * Create an `CachedIterable` instance.
+ *
+ * @param {Iterable} iterable
+ * @returns {CachedIterable}
+ */
constructor(iterable) {
- if (!(Symbol.asyncIterator in Object(iterable))) {
- throw new TypeError('Argument must implement the async iteration protocol.');
+ if (Symbol.asyncIterator in Object(iterable)) {
+ this.iterator = iterable[Symbol.asyncIterator]();
+ } else if (Symbol.iterator in Object(iterable)) {
+ this.iterator = iterable[Symbol.iterator]();
+ } else {
+ throw new TypeError('Argument must implement the iteration protocol.');
}
- this.iterator = iterable[Symbol.asyncIterator]();
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;
return {
async next() {
if (seen.length <= cur) {
seen.push(await iterator.next());
@@ -83,17 +107,17 @@ class CachedIterable {
class L10nError extends Error {
constructor(message) {
super();
this.name = 'L10nError';
this.message = message;
}
}
-/**
+ /**
* The default localization strategy for Gecko. It comabines locales
* available in L10nRegistry, with locales requested by the user to
* generate the iterator over MessageContexts.
*
* In the future, we may want to allow certain modules to override this
* with a different negotitation strategy to allow for the module to
* be localized into a different language - for example DevTools.
*/
@@ -112,17 +136,17 @@ function defaultGenerateMessages(resourc
* 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.
*/
class Localization {
/**
* @param {Array<String>} resourceIds - List of resource IDs
- * @param {Function} generateMessages - Function that returns the
+ * @param {Function} generateMessages - Function that returns a
* generator over MessageContexts
*
* @returns {Localization}
*/
constructor(resourceIds, generateMessages = defaultGenerateMessages) {
this.resourceIds = resourceIds;
this.generateMessages = generateMessages;
this.ctxs = new CachedIterable(this.generateMessages(this.resourceIds));
@@ -181,16 +205,19 @@ class Localization {
*/
formatMessages(keys) {
return this.formatWithFallback(keys, messageFromContext);
}
/**
* Retrieve translations corresponding to the passed keys.
*
+ * A generalized version of `DOMLocalization.formatValue`. Keys can
+ * either be simple string identifiers or `[id, args]` arrays.
+ *
* docL10n.formatValues([
* ['hello', { who: 'Mary' }],
* ['hello', { who: 'John' }],
* ['welcome']
* ]).then(console.log);
*
* // ['Hello, Mary!', 'Hello, John!', 'Welcome!']
*
--- a/intl/l10n/MessageContext.jsm
+++ b/intl/l10n/MessageContext.jsm
@@ -11,23 +11,25 @@
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-/* fluent@0.4.1 */
+/* fluent@0.6.0 */
/* eslint no-magic-numbers: [0] */
const MAX_PLACEABLES = 100;
-const identifierRe = new RegExp('[a-zA-Z_][a-zA-Z0-9_-]*', 'y');
+const entryIdentifierRe = /-?[a-zA-Z][a-zA-Z0-9_-]*/y;
+const identifierRe = /[a-zA-Z][a-zA-Z0-9_-]*/y;
+const functionIdentifierRe = /^[A-Z][A-Z_?-]*$/;
/**
* The `Parser` class is responsible for parsing FTL resources.
*
* It's only public method is `getResource(source)` which takes an FTL string
* and returns a two element Array with an Object of entries generated from the
* source as the first element and an array of SyntaxError objects as the
* second.
@@ -87,17 +89,18 @@ class RuntimeParser {
this._source[this._index - 1] !== '\n') {
throw this.error(`Expected an entry to start
at the beginning of the file or on a new line.`);
}
const ch = this._source[this._index];
// We don't care about comments or sections at runtime
- if (ch === '/') {
+ if (ch === '/' ||
+ (ch === '#' && [' ', '#'].includes(this._source[this._index + 1]))) {
this.skipComment();
return;
}
if (ch === '[') {
this.skipSection();
return;
}
@@ -114,17 +117,17 @@ class RuntimeParser {
this._index += 1;
if (this._source[this._index] !== '[') {
throw this.error('Expected "[[" to open a section');
}
this._index += 1;
this.skipInlineWS();
- this.getSymbol();
+ this.getVariantName();
this.skipInlineWS();
if (this._source[this._index] !== ']' ||
this._source[this._index + 1] !== ']') {
throw this.error('Expected "]]" to close a section');
}
this._index += 2;
@@ -132,72 +135,60 @@ class RuntimeParser {
/**
* Parse the source string from the current index as an FTL message
* and add it to the entries property on the Parser.
*
* @private
*/
getMessage() {
- const id = this.getIdentifier();
- let attrs = null;
- let tags = null;
+ const id = this.getEntryIdentifier();
+
+ this.skipInlineWS();
+
+ if (this._source[this._index] === '=') {
+ this._index++;
+ }
this.skipInlineWS();
- let ch = this._source[this._index];
-
- let val;
-
- if (ch === '=') {
- this._index++;
-
- this.skipInlineWS();
+ const val = this.getPattern();
- val = this.getPattern();
- } else {
- this.skipWS();
- }
-
- ch = this._source[this._index];
-
- if (ch === '\n') {
- this._index++;
- this.skipInlineWS();
- ch = this._source[this._index];
+ if (id.startsWith('-') && val === null) {
+ throw this.error('Expected term to have a value');
}
- if (ch === '.') {
- attrs = this.getAttributes();
+ let attrs = null;
+
+ if (this._source[this._index] === ' ') {
+ const lineStart = this._index;
+ this.skipInlineWS();
+
+ if (this._source[this._index] === '.') {
+ this._index = lineStart;
+ attrs = this.getAttributes();
+ }
}
- if (ch === '#') {
- if (attrs !== null) {
- throw this.error('Tags cannot be added to a message with attributes.');
- }
- tags = this.getTags();
- }
-
- if (tags === null && attrs === null && typeof val === 'string') {
+ if (attrs === null && typeof val === 'string') {
this.entries[id] = val;
} else {
- if (val === undefined) {
- if (tags === null && attrs === null) {
- throw this.error(`Expected a value (like: " = value") or
- an attribute (like: ".key = value")`);
- }
+ if (val === null && attrs === null) {
+ throw this.error('Expected message to have a value or attributes');
}
- this.entries[id] = { val };
- if (attrs) {
+ this.entries[id] = {};
+
+ if (val !== null) {
+ this.entries[id].val = val;
+ }
+
+ if (attrs !== null) {
this.entries[id].attrs = attrs;
}
- if (tags) {
- this.entries[id].tags = tags;
- }
}
}
/**
* Skip whitespace.
*
* @private
*/
@@ -216,73 +207,105 @@ class RuntimeParser {
skipInlineWS() {
let ch = this._source[this._index];
while (ch === ' ' || ch === '\t') {
ch = this._source[++this._index];
}
}
/**
- * Get Message identifier.
+ * Skip blank lines.
+ *
+ * @private
+ */
+ skipBlankLines() {
+ while (true) {
+ const ptr = this._index;
+
+ this.skipInlineWS();
+
+ if (this._source[this._index] === '\n') {
+ this._index += 1;
+ } else {
+ this._index = ptr;
+ break;
+ }
+ }
+ }
+
+ /**
+ * Get identifier using the provided regex.
+ *
+ * By default this will get identifiers of public messages, attributes and
+ * external arguments (without the $).
*
* @returns {String}
* @private
*/
- getIdentifier() {
- identifierRe.lastIndex = this._index;
-
- const result = identifierRe.exec(this._source);
+ getIdentifier(re = identifierRe) {
+ re.lastIndex = this._index;
+ const result = re.exec(this._source);
if (result === null) {
this._index += 1;
- throw this.error('Expected an identifier (starting with [a-zA-Z_])');
+ throw this.error(`Expected an identifier [${re.toString()}]`);
}
- this._index = identifierRe.lastIndex;
+ this._index = re.lastIndex;
return result[0];
}
/**
- * Get Symbol.
+ * Get identifier of a Message or a Term (staring with a dash).
+ *
+ * @returns {String}
+ * @private
+ */
+ getEntryIdentifier() {
+ return this.getIdentifier(entryIdentifierRe);
+ }
+
+ /**
+ * Get Variant name.
*
* @returns {Object}
* @private
*/
- getSymbol() {
+ getVariantName() {
let name = '';
const start = this._index;
let cc = this._source.charCodeAt(this._index);
if ((cc >= 97 && cc <= 122) || // a-z
- (cc >= 65 && cc <= 90) || // A-Z
- cc === 95 || cc === 32) { // _ <space>
+ (cc >= 65 && cc <= 90) || // A-Z
+ cc === 95 || cc === 32) { // _ <space>
cc = this._source.charCodeAt(++this._index);
} else {
throw this.error('Expected a keyword (starting with [a-zA-Z_])');
}
while ((cc >= 97 && cc <= 122) || // a-z
- (cc >= 65 && cc <= 90) || // A-Z
- (cc >= 48 && cc <= 57) || // 0-9
- cc === 95 || cc === 45 || cc === 32) { // _- <space>
+ (cc >= 65 && cc <= 90) || // A-Z
+ (cc >= 48 && cc <= 57) || // 0-9
+ cc === 95 || cc === 45 || cc === 32) { // _- <space>
cc = this._source.charCodeAt(++this._index);
}
// If we encountered the end of name, we want to test if the last
// collected character is a space.
// If it is, we will backtrack to the last non-space character because
// the keyword cannot end with a space character.
while (this._source.charCodeAt(this._index - 1) === 32) {
this._index--;
}
name += this._source.slice(start, this._index);
- return { type: 'sym', name };
+ return { type: 'varname', name };
}
/**
* Get simple string argument enclosed in `"`.
*
* @returns {String}
* @private
*/
@@ -292,17 +315,17 @@ class RuntimeParser {
while (++this._index < this._length) {
const ch = this._source[this._index];
if (ch === '"') {
break;
}
if (ch === '\n') {
- break;
+ throw this.error('Unterminated string expression');
}
}
return this._source.substring(start, this._index++);
}
/**
* Parses a Message pattern.
@@ -320,31 +343,50 @@ class RuntimeParser {
// next line starts an indentation, we switch to complex pattern.
const start = this._index;
let eol = this._source.indexOf('\n', this._index);
if (eol === -1) {
eol = this._length;
}
- const line = start !== eol ?
- this._source.slice(start, eol) : undefined;
+ const firstLineContent = start !== eol ?
+ this._source.slice(start, eol) : null;
- if (line !== undefined && line.includes('{')) {
+ if (firstLineContent && firstLineContent.includes('{')) {
return this.getComplexPattern();
}
this._index = eol + 1;
- if (this._source[this._index] === ' ') {
- this._index = start;
- return this.getComplexPattern();
+ this.skipBlankLines();
+
+ if (this._source[this._index] !== ' ') {
+ // No indentation means we're done with this message.
+ return firstLineContent;
}
- return line;
+ const lineStart = this._index;
+
+ this.skipInlineWS();
+
+ if (this._source[this._index] === '.') {
+ // The pattern is followed by an attribute. Rewind _index to the first
+ // column of the current line as expected by getAttributes.
+ this._index = lineStart;
+ return firstLineContent;
+ }
+
+ if (firstLineContent) {
+ // It's a multiline pattern which started on the same line as the
+ // identifier. Reparse the whole pattern to make sure we get all of it.
+ this._index = start;
+ }
+
+ return this.getComplexPattern();
}
/**
* Parses a complex Message pattern.
* This function is called by getPattern when the message is multiline,
* or contains escape chars or placeables.
* It does full parsing of complex patterns.
*
@@ -356,32 +398,44 @@ class RuntimeParser {
let buffer = '';
const content = [];
let placeables = 0;
let ch = this._source[this._index];
while (this._index < this._length) {
// This block handles multi-line strings combining strings separated
- // by new line and `|` character at the beginning of the next one.
+ // by new line.
if (ch === '\n') {
this._index++;
+
+ // We want to capture the start and end pointers
+ // around blank lines and add them to the buffer
+ // but only if the blank lines are in the middle
+ // of the string.
+ const blankLinesStart = this._index;
+ this.skipBlankLines();
+ const blankLinesEnd = this._index;
+
+
if (this._source[this._index] !== ' ') {
break;
}
this.skipInlineWS();
if (this._source[this._index] === '}' ||
this._source[this._index] === '[' ||
this._source[this._index] === '*' ||
- this._source[this._index] === '#' ||
this._source[this._index] === '.') {
+ this._index = blankLinesEnd;
break;
}
+ buffer += this._source.substring(blankLinesStart, blankLinesEnd);
+
if (buffer.length || content.length) {
buffer += '\n';
}
ch = this._source[this._index];
continue;
} else if (ch === '\\') {
const ch2 = this._source[this._index + 1];
if (ch2 === '"' || ch2 === '{' || ch2 === '\\') {
@@ -410,17 +464,17 @@ class RuntimeParser {
if (ch) {
buffer += ch;
}
this._index++;
ch = this._source[this._index];
}
if (content.length === 0) {
- return buffer.length ? buffer : undefined;
+ return buffer.length ? buffer : null;
}
if (buffer.length) {
content.push(buffer);
}
return content;
}
@@ -457,23 +511,44 @@ class RuntimeParser {
const selector = this.getSelectorExpression();
this.skipWS();
const ch = this._source[this._index];
if (ch === '}') {
+ if (selector.type === 'attr' && selector.id.name.startsWith('-')) {
+ throw this.error(
+ 'Attributes of private messages cannot be interpolated.'
+ );
+ }
+
return selector;
}
if (ch !== '-' || this._source[this._index + 1] !== '>') {
throw this.error('Expected "}" or "->"');
}
+ if (selector.type === 'ref') {
+ throw this.error('Message references cannot be used as selectors.');
+ }
+
+ if (selector.type === 'var') {
+ throw this.error('Variants cannot be used as selectors.');
+ }
+
+ if (selector.type === 'attr' && !selector.id.name.startsWith('-')) {
+ throw this.error(
+ 'Attributes of public messages cannot be used as selectors.'
+ );
+ }
+
+
this._index += 2; // ->
this.skipInlineWS();
if (this._source[this._index] !== '\n') {
throw this.error('Variants should be listed in a new line');
}
@@ -529,16 +604,20 @@ class RuntimeParser {
key
};
}
if (this._source[this._index] === '(') {
this._index++;
const args = this.getCallArgs();
+ if (!functionIdentifierRe.test(literal.name)) {
+ throw this.error('Function names must be all upper-case');
+ }
+
this._index++;
literal.type = 'fun';
return {
type: 'call',
fun: literal,
args
@@ -552,29 +631,28 @@ class RuntimeParser {
* Parses call arguments for a CallExpression.
*
* @returns {Array}
* @private
*/
getCallArgs() {
const args = [];
- if (this._source[this._index] === ')') {
- return args;
- }
-
while (this._index < this._length) {
this.skipInlineWS();
+ if (this._source[this._index] === ')') {
+ return args;
+ }
+
const exp = this.getSelectorExpression();
// MessageReference in this place may be an entity reference, like:
// `call(foo)`, or, if it's followed by `:` it will be a key-value pair.
- if (exp.type !== 'ref' ||
- exp.namespace !== undefined) {
+ if (exp.type !== 'ref') {
args.push(exp);
} else {
this.skipInlineWS();
if (this._source[this._index] === ':') {
this._index++;
this.skipInlineWS();
@@ -673,75 +751,54 @@ class RuntimeParser {
*
* @returns {Object}
* @private
*/
getAttributes() {
const attrs = {};
while (this._index < this._length) {
- const ch = this._source[this._index];
+ if (this._source[this._index] !== ' ') {
+ break;
+ }
+ this.skipInlineWS();
- if (ch !== '.') {
+ if (this._source[this._index] !== '.') {
break;
}
this._index++;
const key = this.getIdentifier();
this.skipInlineWS();
+ if (this._source[this._index] !== '=') {
+ throw this.error('Expected "="');
+ }
this._index++;
this.skipInlineWS();
const val = this.getPattern();
if (typeof val === 'string') {
attrs[key] = val;
} else {
attrs[key] = {
val
};
}
- this.skipWS();
+ this.skipBlankLines();
}
return attrs;
}
/**
- * Parses a list of Message tags.
- *
- * @returns {Array}
- * @private
- */
- getTags() {
- const tags = [];
-
- while (this._index < this._length) {
- const ch = this._source[this._index];
-
- if (ch !== '#') {
- break;
- }
- this._index++;
-
- const symbol = this.getSymbol();
-
- tags.push(symbol.name);
-
- this.skipWS();
- }
-
- return tags;
- }
-
- /**
* Parses a list of Selector variants.
*
* @returns {Array}
* @private
*/
getVariants() {
const variants = [];
let index = 0;
@@ -791,17 +848,17 @@ class RuntimeParser {
// VariantKey may be a Keyword or Number
const cc = this._source.charCodeAt(this._index);
let literal;
if ((cc >= 48 && cc <= 57) || cc === 45) {
literal = this.getNumber();
} else {
- literal = this.getSymbol();
+ literal = this.getVariantName();
}
if (this._source[this._index] !== ']') {
throw this.error('Expected "]"');
}
this._index++;
return literal;
@@ -809,47 +866,65 @@ class RuntimeParser {
/**
* Parses an FTL literal.
*
* @returns {Object}
* @private
*/
getLiteral() {
- const cc = this._source.charCodeAt(this._index);
- if ((cc >= 48 && cc <= 57) || cc === 45) {
- return this.getNumber();
- } else if (cc === 34) { // "
- return this.getString();
- } else if (cc === 36) { // $
+ const cc0 = this._source.charCodeAt(this._index);
+
+ if (cc0 === 36) { // $
this._index++;
return {
type: 'ext',
name: this.getIdentifier()
};
}
- return {
- type: 'ref',
- name: this.getIdentifier()
- };
+ const cc1 = cc0 === 45 // -
+ // Peek at the next character after the dash.
+ ? this._source.charCodeAt(this._index + 1)
+ // Or keep using the character at the current index.
+ : cc0;
+
+ if ((cc1 >= 97 && cc1 <= 122) || // a-z
+ (cc1 >= 65 && cc1 <= 90)) { // A-Z
+ return {
+ type: 'ref',
+ name: this.getEntryIdentifier()
+ };
+ }
+
+ if ((cc1 >= 48 && cc1 <= 57)) { // 0-9
+ return this.getNumber();
+ }
+
+ if (cc0 === 34) { // "
+ return this.getString();
+ }
+
+ throw this.error('Expected literal');
}
/**
* Skips an FTL comment.
*
* @private
*/
skipComment() {
// At runtime, we don't care about comments so we just have
// to parse them properly and skip their content.
let eol = this._source.indexOf('\n', this._index);
while (eol !== -1 &&
- this._source[eol + 1] === '/' && this._source[eol + 2] === '/') {
+ ((this._source[eol + 1] === '/' && this._source[eol + 2] === '/') ||
+ (this._source[eol + 1] === '#' &&
+ [' ', '#'].includes(this._source[eol + 2])))) {
this._index = eol + 3;
eol = this._source.indexOf('\n', this._index);
if (eol === -1) {
break;
}
}
@@ -882,18 +957,18 @@ class RuntimeParser {
skipToNextEntryStart() {
let start = this._index;
while (true) {
if (start === 0 || this._source[start - 1] === '\n') {
const cc = this._source.charCodeAt(start);
if ((cc >= 97 && cc <= 122) || // a-z
- (cc >= 65 && cc <= 90) || // A-Z
- cc === 95 || cc === 47 || cc === 91) { // _/[
+ (cc >= 65 && cc <= 90) || // A-Z
+ cc === 47 || cc === 91) { // /[
this._index = start;
return;
}
}
start = this._source.indexOf('\n', start);
if (start === -1) {
@@ -918,17 +993,17 @@ function parse(string) {
}
/* global Intl */
/**
* The `FluentType` class is the base of Fluent's type system.
*
* Fluent types wrap JavaScript values and store additional configuration for
- * them, which can then be used in the `valueOf` method together with a proper
+ * them, which can then be used in the `toString` method together with a proper
* `Intl` formatter.
*/
class FluentType {
/**
* Create an `FluentType` instance.
*
* @param {Any} value - JavaScript value to wrap.
@@ -936,52 +1011,60 @@ class FluentType {
* @returns {FluentType}
*/
constructor(value, opts) {
this.value = value;
this.opts = opts;
}
/**
- * Unwrap the instance of `FluentType`.
+ * Unwrap the raw value stored by this `FluentType`.
*
- * Unwrapped values are suitable for use outside of the `MessageContext`.
+ * @returns {Any}
+ */
+ valueOf() {
+ return this.value;
+ }
+
+ /**
+ * Format this instance of `FluentType` to a string.
+ *
+ * Formatted values are suitable for use outside of the `MessageContext`.
* This method can use `Intl` formatters memoized by the `MessageContext`
* instance passed as an argument.
*
- * In most cases, valueOf returns a string, but it can be overriden
- * and there are use cases, where the return type is not a string.
- *
- * An example is fluent-react which implements a custom `FluentType`
- * to represent React elements passed as arguments to format().
- *
* @param {MessageContext} [ctx]
* @returns {string}
*/
- valueOf() {
- throw new Error('Subclasses of FluentType must implement valueOf.');
+ toString() {
+ throw new Error('Subclasses of FluentType must implement toString.');
}
}
class FluentNone extends FluentType {
- valueOf() {
+ toString() {
return this.value || '???';
}
}
class FluentNumber extends FluentType {
constructor(value, opts) {
super(parseFloat(value), opts);
}
- valueOf(ctx) {
- const nf = ctx._memoizeIntlObject(
- Intl.NumberFormat, this.opts
- );
- return nf.format(this.value);
+ toString(ctx) {
+ try {
+ const nf = ctx._memoizeIntlObject(
+ Intl.NumberFormat, this.opts
+ );
+ return nf.format(this.value);
+ } catch (e) {
+ // XXX Report the error.
+ return this.value;
+ }
}
/**
* Compare the object with another instance of a FluentType.
*
* @param {MessageContext} ctx
* @param {FluentType} other
* @returns {bool}
@@ -994,26 +1077,31 @@ class FluentNumber extends FluentType {
}
}
class FluentDateTime extends FluentType {
constructor(value, opts) {
super(new Date(value), opts);
}
- valueOf(ctx) {
- const dtf = ctx._memoizeIntlObject(
- Intl.DateTimeFormat, this.opts
- );
- return dtf.format(this.value);
+ toString(ctx) {
+ try {
+ const dtf = ctx._memoizeIntlObject(
+ Intl.DateTimeFormat, this.opts
+ );
+ return dtf.format(this.value);
+ } catch (e) {
+ // XXX Report the error.
+ return this.value;
+ }
}
}
class FluentSymbol extends FluentType {
- valueOf() {
+ toString() {
return this.value;
}
/**
* Compare the object with another instance of a FluentType.
*
* @param {MessageContext} ctx
* @param {FluentType} other
@@ -1024,19 +1112,16 @@ class FluentSymbol extends FluentType {
return this.value === other.value;
} else if (typeof other === 'string') {
return this.value === other;
} else if (other instanceof FluentNumber) {
const pr = ctx._memoizeIntlObject(
Intl.PluralRules, other.opts
);
return this.value === pr.select(other.value);
- } else if (Array.isArray(other)) {
- const values = other.map(symbol => symbol.value);
- return values.includes(this.value);
}
return false;
}
}
/**
* @overview
*
@@ -1047,29 +1132,29 @@ class FluentSymbol extends FluentType {
* - opts - an object of key-value args
*
* Arguments to functions are guaranteed to already be instances of
* `FluentType`. Functions must return `FluentType` objects as well.
*/
const builtins = {
'NUMBER': ([arg], opts) =>
- new FluentNumber(arg.value, merge(arg.opts, opts)),
+ new FluentNumber(arg.valueOf(), merge(arg.opts, opts)),
'DATETIME': ([arg], opts) =>
- new FluentDateTime(arg.value, merge(arg.opts, opts)),
+ new FluentDateTime(arg.valueOf(), merge(arg.opts, opts)),
};
function merge(argopts, opts) {
return Object.assign({}, argopts, values(opts));
}
function values(opts) {
const unwrapped = {};
- for (const name of Object.keys(opts)) {
- unwrapped[name] = opts[name].value;
+ for (const [name, opt] of Object.entries(opts)) {
+ unwrapped[name] = opt.valueOf();
}
return unwrapped;
}
/**
* @overview
*
* The role of the Fluent resolver is to format a translation object to an
@@ -1095,17 +1180,17 @@ function values(opts) {
*
* which is a `VariantExpression` with properties `id: MessageReference` and
* `key: Keyword`. If `MessageReference` was resolved eagerly, it would
* instantly resolve to the value of the `brand-name` message. Instead, we
* want to get the message object and look for its `nominative` variant.
*
* All other expressions (except for `FunctionReference` which is only used in
* `CallExpression`) resolve to an instance of `FluentType`. The caller should
- * use the `valueOf` method to convert the instance to a native value.
+ * use the `toString` method to convert the instance to a native value.
*
*
* All functions in this file pass around a special object called `env`.
* This object stores a set of elements used by all resolve functions:
*
* * {MessageContext} ctx
* context for which the given resolution is happening
* * {Object} args
@@ -1122,37 +1207,16 @@ function values(opts) {
const MAX_PLACEABLE_LENGTH = 2500;
// Unicode bidi isolation characters.
const FSI = '\u2068';
const PDI = '\u2069';
/**
- * Helper for computing the total character length of a placeable.
- *
- * Used in Pattern.
- *
- * @param {Object} env
- * Resolver environment object.
- * @param {Array} parts
- * List of parts of a placeable.
- * @returns {Number}
- * @private
- */
-function PlaceableLength(env, parts) {
- const { ctx } = env;
- return parts.reduce(
- (sum, part) => sum + part.valueOf(ctx).length,
- 0
- );
-}
-
-
-/**
* Helper for choosing the default value from a set of members.
*
* Used in SelectExpressions and Type.
*
* @param {Object} env
* Resolver environment object.
* @param {Object} members
* Hash map of variants from which the default value is to be selected.
@@ -1181,59 +1245,32 @@ function DefaultMember(env, members, def
* The identifier of the message to be resolved.
* @param {String} id.name
* The name of the identifier.
* @returns {FluentType}
* @private
*/
function MessageReference(env, {name}) {
const { ctx, errors } = env;
- const message = ctx.getMessage(name);
+ const message = name.startsWith('-')
+ ? ctx._terms.get(name)
+ : ctx._messages.get(name);
if (!message) {
- errors.push(new ReferenceError(`Unknown message: ${name}`));
+ const err = name.startsWith('-')
+ ? new ReferenceError(`Unknown term: ${name}`)
+ : new ReferenceError(`Unknown message: ${name}`);
+ errors.push(err);
return new FluentNone(name);
}
return message;
}
/**
- * Resolve an array of tags.
- *
- * @param {Object} env
- * Resolver environment object.
- * @param {Object} id
- * The identifier of the message with tags.
- * @param {String} id.name
- * The name of the identifier.
- * @returns {Array}
- * @private
- */
-function Tags(env, {name}) {
- const { ctx, errors } = env;
- const message = ctx.getMessage(name);
-
- if (!message) {
- errors.push(new ReferenceError(`Unknown message: ${name}`));
- return new FluentNone(name);
- }
-
- if (!message.tags) {
- errors.push(new RangeError(`No tags in message "${name}"`));
- return new FluentNone(name);
- }
-
- return message.tags.map(
- tag => new FluentSymbol(tag)
- );
-}
-
-
-/**
* Resolve a variant expression to the variant object.
*
* @param {Object} env
* Resolver environment object.
* @param {Object} expr
* An expression to be resolved.
* @param {Object} expr.id
* An Identifier of a message for which the variant is resolved.
@@ -1264,17 +1301,17 @@ function VariantExpression(env, {id, key
for (const variant of message.val[0].vars) {
const variantKey = Type(env, variant.key);
if (keyword.match(ctx, variantKey)) {
return variant;
}
}
}
- errors.push(new ReferenceError(`Unknown variant: ${keyword.valueOf(ctx)}`));
+ errors.push(new ReferenceError(`Unknown variant: ${keyword.toString(ctx)}`));
return Type(env, message);
}
/**
* Resolve an attribute expression to the attribute object.
*
* @param {Object} env
@@ -1324,19 +1361,17 @@ function AttributeExpression(env, {id, n
* @returns {FluentType}
* @private
*/
function SelectExpression(env, {exp, vars, def}) {
if (exp === null) {
return DefaultMember(env, vars, def);
}
- const selector = exp.type === 'ref'
- ? Tags(env, exp)
- : Type(env, exp);
+ const selector = Type(env, exp);
if (selector instanceof FluentNone) {
return DefaultMember(env, vars, def);
}
// Match the selector against keys of each variant, in order.
for (const variant of vars) {
const key = Type(env, variant.key);
const keyCanMatch =
@@ -1356,17 +1391,17 @@ function SelectExpression(env, {exp, var
return DefaultMember(env, vars, def);
}
/**
* Resolve expression to a Fluent type.
*
* JavaScript strings are a special case. Since they natively have the
- * `valueOf` method they can be used as if they were a Fluent type without
+ * `toString` method they can be used as if they were a Fluent type without
* paying the cost of creating a instance of one.
*
* @param {Object} env
* Resolver environment object.
* @param {Object} expr
* An expression object to be resolved into a Fluent type.
* @returns {FluentType}
* @private
@@ -1381,17 +1416,17 @@ function Type(env, expr) {
// The Runtime AST (Entries) encodes patterns (complex strings with
// placeables) as Arrays.
if (Array.isArray(expr)) {
return Pattern(env, expr);
}
switch (expr.type) {
- case 'sym':
+ case 'varname':
return new FluentSymbol(expr.name);
case 'num':
return new FluentNumber(expr.val);
case 'ext':
return ExternalArgument(env, expr);
case 'fun':
return FunctionReference(env, expr);
case 'call':
@@ -1409,17 +1444,17 @@ function Type(env, expr) {
return Type(env, variant);
}
case 'sel': {
const member = SelectExpression(env, expr);
return Type(env, member);
}
case undefined: {
// If it's a node with a value, resolve the value.
- if (expr.val !== undefined) {
+ if (expr.val !== null && expr.val !== undefined) {
return Type(env, expr.val);
}
const { errors } = env;
errors.push(new RangeError('No value'));
return new FluentNone();
}
default:
@@ -1444,16 +1479,17 @@ function ExternalArgument(env, {name}) {
if (!args || !args.hasOwnProperty(name)) {
errors.push(new ReferenceError(`Unknown external: ${name}`));
return new FluentNone(name);
}
const arg = args[name];
+ // Return early if the argument already is an instance of FluentType.
if (arg instanceof FluentType) {
return arg;
}
// Convert the argument to a Fluent type.
switch (typeof arg) {
case 'string':
return arg;
@@ -1519,28 +1555,32 @@ function FunctionReference(env, {name})
function CallExpression(env, {fun, args}) {
const callee = FunctionReference(env, fun);
if (callee instanceof FluentNone) {
return callee;
}
const posargs = [];
- const keyargs = [];
+ const keyargs = {};
for (const arg of args) {
if (arg.type === 'narg') {
keyargs[arg.name] = Type(env, arg.val);
} else {
posargs.push(Type(env, arg));
}
}
- // XXX functions should also report errors
- return callee(posargs, keyargs);
+ try {
+ return callee(posargs, keyargs);
+ } catch (e) {
+ // XXX Report errors.
+ return new FluentNone();
+ }
}
/**
* Resolve a pattern (a complex string with placeables).
*
* @param {Object} env
* Resolver environment object.
* @param {Array} ptn
@@ -1561,53 +1601,45 @@ function Pattern(env, ptn) {
const result = [];
for (const elem of ptn) {
if (typeof elem === 'string') {
result.push(elem);
continue;
}
- const part = Type(env, elem);
+ const part = Type(env, elem).toString(ctx);
if (ctx._useIsolating) {
result.push(FSI);
}
- if (Array.isArray(part)) {
- const len = PlaceableLength(env, part);
-
- if (len > MAX_PLACEABLE_LENGTH) {
- errors.push(
- new RangeError(
- 'Too many characters in placeable ' +
- `(${len}, max allowed is ${MAX_PLACEABLE_LENGTH})`
- )
- );
- result.push(new FluentNone());
- } else {
- result.push(...part);
- }
+ if (part.length > MAX_PLACEABLE_LENGTH) {
+ errors.push(
+ new RangeError(
+ 'Too many characters in placeable ' +
+ `(${part.length}, max allowed is ${MAX_PLACEABLE_LENGTH})`
+ )
+ );
+ result.push(part.slice(MAX_PLACEABLE_LENGTH));
} else {
result.push(part);
}
if (ctx._useIsolating) {
result.push(PDI);
}
}
dirty.delete(ptn);
- return result;
+ return result.join('');
}
/**
- * Format a translation into an `FluentType`.
- *
- * The return value must be unwrapped via `valueOf` by the caller.
+ * Format a translation into a string.
*
* @param {MessageContext} ctx
* A MessageContext instance which will be used to resolve the
* contextual information of the message.
* @param {Object} args
* List of arguments provided by the developer which can be accessed
* from the message.
* @param {Object} message
@@ -1615,17 +1647,17 @@ function Pattern(env, ptn) {
* @param {Array} errors
* An error array that any encountered errors will be appended to.
* @returns {FluentType}
*/
function resolve(ctx, args, message, errors = []) {
const env = {
ctx, args, errors, dirty: new WeakSet()
};
- return Type(env, message);
+ return Type(env, message).toString(ctx);
}
/**
* Message contexts are single-language stores of translations. They are
* responsible for parsing translation resources in the Fluent syntax and can
* format translation units (entities) to strings.
*
* Always use `MessageContext.format` to retrieve translation units from
@@ -1667,24 +1699,25 @@ class MessageContext {
*
* @param {string|Array<string>} locales - Locale or locales of the context
* @param {Object} [options]
* @returns {MessageContext}
*/
constructor(locales, { functions = {}, useIsolating = true } = {}) {
this.locales = Array.isArray(locales) ? locales : [locales];
+ this._terms = new Map();
this._messages = new Map();
this._functions = functions;
this._useIsolating = useIsolating;
this._intls = new WeakMap();
}
/*
- * Return an iterator over `[id, message]` pairs.
+ * Return an iterator over public `[id, message]` pairs.
*
* @returns {Iterator}
*/
get messages() {
return this._messages[Symbol.iterator]();
}
/*
@@ -1696,17 +1729,17 @@ class MessageContext {
hasMessage(id) {
return this._messages.has(id);
}
/*
* Return the internal representation of a message.
*
* The internal representation should only be used as an argument to
- * `MessageContext.format` and `MessageContext.formatToParts`.
+ * `MessageContext.format`.
*
* @param {string} id - The identifier of the message to check.
* @returns {Any}
*/
getMessage(id) {
return this._messages.get(id);
}
@@ -1726,78 +1759,29 @@ class MessageContext {
* contain logic (references, select expressions etc.).
*
* @param {string} source - Text resource with translations.
* @returns {Array<Error>}
*/
addMessages(source) {
const [entries, errors] = parse(source);
for (const id in entries) {
- this._messages.set(id, entries[id]);
+ if (id.startsWith('-')) {
+ // Identifiers starting with a dash (-) define terms. Terms are private
+ // and cannot be retrieved from MessageContext.
+ this._terms.set(id, entries[id]);
+ } else {
+ this._messages.set(id, entries[id]);
+ }
}
return errors;
}
/**
- * Format a message to an array of `FluentTypes` or null.
- *
- * Format a raw `message` from the context into an array of `FluentType`
- * instances which may be used to build the final result. It may also return
- * `null` if it has a null value. `args` will be used to resolve references
- * to external arguments inside of the translation.
- *
- * See the documentation of {@link MessageContext#format} for more
- * information about error handling.
- *
- * In case of errors `format` will try to salvage as much of the translation
- * as possible and will still return a string. For performance reasons, the
- * encountered errors are not returned but instead are appended to the
- * `errors` array passed as the third argument.
- *
- * ctx.addMessages('hello = Hello, { $name }!');
- * const hello = ctx.getMessage('hello');
- * ctx.formatToParts(hello, { name: 'Jane' }, []);
- * // → ['Hello, ', '\u2068', 'Jane', '\u2069']
- *
- * The returned parts need to be formatted via `valueOf` before they can be
- * used further. This will ensure all values are correctly formatted
- * according to the `MessageContext`'s locale.
- *
- * const parts = ctx.formatToParts(hello, { name: 'Jane' }, []);
- * const str = parts.map(part => part.valueOf(ctx)).join('');
- *
- * @see MessageContext#format
- * @param {Object | string} message
- * @param {Object | undefined} args
- * @param {Array} errors
- * @returns {?Array<FluentType>}
- */
- formatToParts(message, args, errors) {
- // optimize entities which are simple strings with no attributes
- if (typeof message === 'string') {
- return [message];
- }
-
- // optimize simple-string entities with attributes
- if (typeof message.val === 'string') {
- return [message.val];
- }
-
- // optimize entities with null values
- if (message.val === undefined) {
- return null;
- }
-
- const result = resolve(this, args, message, errors);
-
- return result instanceof FluentNone ? null : result;
- }
-
- /**
* Format a message to a string or null.
*
* Format a raw `message` from the context into a string (or a null if it has
* a null value). `args` will be used to resolve references to external
* arguments inside of the translation.
*
* In case of errors `format` will try to salvage as much of the translation
* as possible and will still return a string. For performance reasons, the
@@ -1833,23 +1817,17 @@ class MessageContext {
return message.val;
}
// optimize entities with null values
if (message.val === undefined) {
return null;
}
- const result = resolve(this, args, message, errors);
-
- if (result instanceof FluentNone) {
- return null;
- }
-
- return result.map(part => part.valueOf(this)).join('');
+ return resolve(this, args, message, errors);
}
_memoizeIntlObject(ctor, opts) {
const cache = this._intls.get(ctor) || {};
const id = JSON.stringify(opts);
if (!cache[id]) {
cache[id] = new ctor(this.locales, opts);
new file mode 100644
--- /dev/null
+++ b/intl/l10n/README
@@ -0,0 +1,17 @@
+The content of this directory is partially sourced from the fluent.js project.
+
+The following files are affected:
+ - MessageContext.jsm
+ - Localization.jsm
+ - DOMLocalization.jsm
+ - l10n.js
+
+At the moment, the tool used to produce those files in fluent.js repository, doesn't
+fully align with how the code is structured here, so we perform a manual adjustments
+mostly around header and footer.
+
+The result difference is stored in `./fluent.js.patch` file which can be used to
+approximate the changes needed to be applied on the output of the
+fluent.js/fluent-gecko's make.
+
+In b.m.o. bug 1434434 we will try to reduce this difference to zero.
new file mode 100644
--- /dev/null
+++ b/intl/l10n/fluent.js.patch
@@ -0,0 +1,492 @@
+diff -uNr ./dist/DOMLocalization.jsm /home/zbraniecki/projects/mozilla-unified/intl/l10n/DOMLocalization.jsm
+--- ./dist/DOMLocalization.jsm 2018-01-30 13:46:58.589811108 -0800
++++ /home/zbraniecki/projects/mozilla-unified/intl/l10n/DOMLocalization.jsm 2018-01-30 13:46:13.613146435 -0800
+@@ -18,7 +18,8 @@
+
+ /* fluent@0.6.0 */
+
+-import Localization from '../../fluent-dom/src/localization.js';
++const { Localization } =
++ Components.utils.import("resource://gre/modules/Localization.jsm", {});
+
+ // Match the opening angle bracket (<) in HTML tags, and HTML entities like
+ // &, &, &.
+@@ -623,36 +624,5 @@
+ }
+ }
+
+-/* global L10nRegistry, Services */
+-/**
+- * The default localization strategy for Gecko. It comabines locales
+- * available in L10nRegistry, with locales requested by the user to
+- * generate the iterator over MessageContexts.
+- *
+- * In the future, we may want to allow certain modules to override this
+- * with a different negotitation strategy to allow for the module to
+- * be localized into a different language - for example DevTools.
+- */
+-function defaultGenerateMessages(resourceIds) {
+- const requestedLocales = Services.locale.getRequestedLocales();
+- const availableLocales = L10nRegistry.getAvailableLocales();
+- const defaultLocale = Services.locale.defaultLocale;
+- const locales = Services.locale.negotiateLanguages(
+- requestedLocales, availableLocales, defaultLocale,
+- );
+- return L10nRegistry.generateContexts(locales, resourceIds);
+-}
+-
+-
+-class GeckoDOMLocalization extends DOMLocalization {
+- constructor(
+- windowElement,
+- resourceIds,
+- generateMessages = defaultGenerateMessages
+- ) {
+- super(windowElement, resourceIds, generateMessages);
+- }
+-}
+-
+-this.DOMLocalization = GeckoDOMLocalization;
++this.DOMLocalization = DOMLocalization;
+ this.EXPORTED_SYMBOLS = ['DOMLocalization'];
+diff -uNr ./dist/l10n.js /home/zbraniecki/projects/mozilla-unified/intl/l10n/l10n.js
+--- ./dist/l10n.js 2018-01-30 13:46:58.749811101 -0800
++++ /home/zbraniecki/projects/mozilla-unified/intl/l10n/l10n.js 2018-01-26 20:52:09.106650798 -0800
+@@ -1,7 +1,6 @@
+-/* global Components, document, window */
+ {
+ const { DOMLocalization } =
+- Components.utils.import('resource://gre/modules/DOMLocalization.jsm');
++ Components.utils.import("resource://gre/modules/DOMLocalization.jsm");
+
+ /**
+ * Polyfill for document.ready polyfill.
+diff -uNr ./dist/Localization.jsm /home/zbraniecki/projects/mozilla-unified/intl/l10n/Localization.jsm
+--- ./dist/Localization.jsm 2018-01-30 13:46:58.393144450 -0800
++++ /home/zbraniecki/projects/mozilla-unified/intl/l10n/Localization.jsm 2018-01-30 13:46:04.593146834 -0800
+@@ -18,92 +18,16 @@
+
+ /* fluent@0.6.0 */
+
+-/* eslint no-magic-numbers: [0] */
+-
+-/* global Intl */
+-
+-/**
+- * The `FluentType` class is the base of Fluent's type system.
+- *
+- * Fluent types wrap JavaScript values and store additional configuration for
+- * them, which can then be used in the `toString` method together with a proper
+- * `Intl` formatter.
+- */
+-
+-/**
+- * @overview
+- *
+- * The FTL resolver ships with a number of functions built-in.
+- *
+- * Each function take two arguments:
+- * - args - an array of positional args
+- * - opts - an object of key-value args
+- *
+- * Arguments to functions are guaranteed to already be instances of
+- * `FluentType`. Functions must return `FluentType` objects as well.
+- */
++/* eslint no-console: ["error", { allow: ["warn", "error"] }] */
++/* global console */
+
+-/**
+- * @overview
+- *
+- * The role of the Fluent resolver is to format a translation object to an
+- * instance of `FluentType` or an array of instances.
+- *
+- * Translations can contain references to other messages or external arguments,
+- * conditional logic in form of select expressions, traits which describe their
+- * grammatical features, and can use Fluent builtins which make use of the
+- * `Intl` formatters to format numbers, dates, lists and more into the
+- * context's language. See the documentation of the Fluent syntax for more
+- * information.
+- *
+- * In case of errors the resolver will try to salvage as much of the
+- * translation as possible. In rare situations where the resolver didn't know
+- * how to recover from an error it will return an instance of `FluentNone`.
+- *
+- * `MessageReference`, `VariantExpression`, `AttributeExpression` and
+- * `SelectExpression` resolve to raw Runtime Entries objects and the result of
+- * the resolution needs to be passed into `Type` to get their real value.
+- * This is useful for composing expressions. Consider:
+- *
+- * brand-name[nominative]
+- *
+- * which is a `VariantExpression` with properties `id: MessageReference` and
+- * `key: Keyword`. If `MessageReference` was resolved eagerly, it would
+- * instantly resolve to the value of the `brand-name` message. Instead, we
+- * want to get the message object and look for its `nominative` variant.
+- *
+- * All other expressions (except for `FunctionReference` which is only used in
+- * `CallExpression`) resolve to an instance of `FluentType`. The caller should
+- * use the `toString` method to convert the instance to a native value.
+- *
+- *
+- * All functions in this file pass around a special object called `env`.
+- * This object stores a set of elements used by all resolve functions:
+- *
+- * * {MessageContext} ctx
+- * context for which the given resolution is happening
+- * * {Object} args
+- * list of developer provided arguments that can be used
+- * * {Array} errors
+- * list of errors collected while resolving
+- * * {WeakSet} dirty
+- * Set of patterns already encountered during this resolution.
+- * This is used to prevent cyclic resolutions.
+- */
++const Cu = Components.utils;
++const Cc = Components.classes;
++const Ci = Components.interfaces;
+
+-/**
+- * Message contexts are single-language stores of translations. They are
+- * responsible for parsing translation resources in the Fluent syntax and can
+- * format translation units (entities) to strings.
+- *
+- * Always use `MessageContext.format` to retrieve translation units from
+- * a context. Translations can contain references to other entities or
+- * external arguments, conditional logic in form of select expressions, traits
+- * which describe their grammatical features, and can use Fluent builtins which
+- * make use of the `Intl` formatters to format numbers, dates, lists and more
+- * into the context's language. See the documentation of the Fluent syntax for
+- * more information.
+- */
++const { L10nRegistry } = Cu.import("resource://gre/modules/L10nRegistry.jsm", {});
++const LocaleService = Cc["@mozilla.org/intl/localeservice;1"].getService(Ci.mozILocaleService);
++const ObserverService = Cc["@mozilla.org/observer-service;1"].getService(Ci.nsIObserverService);
+
+ /*
+ * CachedIterable caches the elements yielded by an iterable.
+@@ -170,87 +94,6 @@
+ }
+ }
+
+-/*
+- * @overview
+- *
+- * Functions for managing ordered sequences of MessageContexts.
+- *
+- * An ordered iterable of MessageContext instances can represent the current
+- * negotiated fallback chain of languages. This iterable can be used to find
+- * the best existing translation for a given identifier.
+- *
+- * The mapContext* methods can be used to find the first MessageContext in the
+- * given iterable which contains the translation with the given identifier. If
+- * the iterable is ordered according to the result of a language negotiation
+- * the returned MessageContext contains the best available translation.
+- *
+- * A simple function which formats translations based on the identifier might
+- * be implemented as follows:
+- *
+- * formatString(id, args) {
+- * const ctx = mapContextSync(contexts, id);
+- *
+- * if (ctx === null) {
+- * return id;
+- * }
+- *
+- * const msg = ctx.getMessage(id);
+- * return ctx.format(msg, args);
+- * }
+- *
+- * In order to pass an iterator to mapContext*, wrap it in CachedIterable.
+- * This allows multiple calls to mapContext* without advancing and eventually
+- * depleting the iterator.
+- *
+- * function *generateMessages() {
+- * // Some lazy logic for yielding MessageContexts.
+- * yield *[ctx1, ctx2];
+- * }
+- *
+- * const contexts = new CachedIterable(generateMessages());
+- * const ctx = mapContextSync(contexts, id);
+- *
+- */
+-
+-/*
+- * Synchronously map an identifier or an array of identifiers to the best
+- * `MessageContext` instance(s).
+- *
+- * @param {Iterable} iterable
+- * @param {string|Array<string>} ids
+- * @returns {MessageContext|Array<MessageContext>}
+- */
+-
+-
+-/*
+- * Asynchronously map an identifier or an array of identifiers to the best
+- * `MessageContext` instance(s).
+- *
+- * @param {AsyncIterable} iterable
+- * @param {string|Array<string>} ids
+- * @returns {Promise<MessageContext|Array<MessageContext>>}
+- */
+-
+-/**
+- * Template literal tag for dedenting FTL code.
+- *
+- * Strip the common indent of non-blank lines. Remove blank lines.
+- *
+- * @param {Array<string>} strings
+- */
+-
+-/*
+- * @module fluent
+- * @overview
+- *
+- * `fluent` is a JavaScript implementation of Project Fluent, a localization
+- * framework designed to unleash the expressive power of the natural language.
+- *
+- */
+-
+-/* eslint no-console: ["error", { allow: ["warn", "error"] }] */
+-/* global console */
+-
+ /**
+ * Specialized version of an Error used to indicate errors that are result
+ * of a problem during the localization process.
+@@ -269,6 +112,26 @@
+ }
+ }
+
++ /**
++ * The default localization strategy for Gecko. It comabines locales
++ * available in L10nRegistry, with locales requested by the user to
++ * generate the iterator over MessageContexts.
++ *
++ * In the future, we may want to allow certain modules to override this
++ * with a different negotitation strategy to allow for the module to
++ * be localized into a different language - for example DevTools.
++ */
++function defaultGenerateMessages(resourceIds) {
++ const availableLocales = L10nRegistry.getAvailableLocales();
++
++ const requestedLocales = LocaleService.getRequestedLocales();
++ const defaultLocale = LocaleService.defaultLocale;
++ const locales = LocaleService.negotiateLanguages(
++ requestedLocales, availableLocales, defaultLocale,
++ );
++ return L10nRegistry.generateContexts(locales, resourceIds);
++}
++
+ /**
+ * The `Localization` class is a central high-level API for vanilla
+ * JavaScript use of Fluent.
+@@ -283,7 +146,7 @@
+ *
+ * @returns {Localization}
+ */
+- constructor(resourceIds, generateMessages) {
++ constructor(resourceIds, generateMessages = defaultGenerateMessages) {
+ this.resourceIds = resourceIds;
+ this.generateMessages = generateMessages;
+ this.ctxs = new CachedIterable(this.generateMessages(this.resourceIds));
+@@ -303,7 +166,7 @@
+ */
+ async formatWithFallback(keys, method) {
+ const translations = [];
+- for (let ctx of this.ctxs) {
++ for await (let ctx of this.ctxs) {
+ // This can operate on synchronous and asynchronous
+ // contexts coming from the iterator.
+ if (typeof ctx.then === 'function') {
+@@ -394,8 +257,38 @@
+ return val;
+ }
+
+- handleEvent() {
+- this.onLanguageChange();
++ /**
++ * Register observers on events that will trigger cache invalidation
++ */
++ registerObservers() {
++ ObserverService.addObserver(this, 'l10n:available-locales-changed', false);
++ ObserverService.addObserver(this, 'intl:requested-locales-changed', false);
++ }
++
++ /**
++ * Unregister observers on events that will trigger cache invalidation
++ */
++ unregisterObservers() {
++ ObserverService.removeObserver(this, 'l10n:available-locales-changed');
++ ObserverService.removeObserver(this, 'intl:requested-locales-changed');
++ }
++
++ /**
++ * Default observer handler method.
++ *
++ * @param {String} subject
++ * @param {String} topic
++ * @param {Object} data
++ */
++ observe(subject, topic, data) {
++ switch (topic) {
++ case 'l10n:available-locales-changed':
++ case 'intl:requested-locales-changed':
++ this.onLanguageChange();
++ break;
++ default:
++ break;
++ }
+ }
+
+ /**
+@@ -538,7 +431,8 @@
+ hasErrors = true;
+ }
+
+- if (messageErrors.length && typeof console !== 'undefined') {
++ if (messageErrors.length) {
++ const { console } = Cu.import("resource://gre/modules/Console.jsm", {});
+ messageErrors.forEach(error => console.warn(error));
+ }
+ });
+@@ -546,45 +440,5 @@
+ return hasErrors;
+ }
+
+-/* global Components */
+-/* eslint no-unused-vars: 0 */
+-
+-const Cu = Components.utils;
+-const Cc = Components.classes;
+-const Ci = Components.interfaces;
+-
+-const { L10nRegistry } =
+- Cu.import('resource://gre/modules/L10nRegistry.jsm', {});
+-const ObserverService =
+- Cc['@mozilla.org/observer-service;1'].getService(Ci.nsIObserverService);
+-const { Services } =
+- Cu.import('resource://gre/modules/Services.jsm', {});
+-
+-
+-/**
+- * The default localization strategy for Gecko. It comabines locales
+- * available in L10nRegistry, with locales requested by the user to
+- * generate the iterator over MessageContexts.
+- *
+- * In the future, we may want to allow certain modules to override this
+- * with a different negotitation strategy to allow for the module to
+- * be localized into a different language - for example DevTools.
+- */
+-function defaultGenerateMessages(resourceIds) {
+- const requestedLocales = Services.locale.getRequestedLocales();
+- const availableLocales = L10nRegistry.getAvailableLocales();
+- const defaultLocale = Services.locale.defaultLocale;
+- const locales = Services.locale.negotiateLanguages(
+- requestedLocales, availableLocales, defaultLocale,
+- );
+- return L10nRegistry.generateContexts(locales, resourceIds);
+-}
+-
+-class GeckoLocalization extends Localization {
+- constructor(resourceIds, generateMessages = defaultGenerateMessages) {
+- super(resourceIds, generateMessages);
+- }
+-}
+-
+-this.Localization = GeckoLocalization;
++this.Localization = Localization;
+ this.EXPORTED_SYMBOLS = ['Localization'];
+diff -uNr ./dist/MessageContext.jsm /home/zbraniecki/projects/mozilla-unified/intl/l10n/MessageContext.jsm
+--- ./dist/MessageContext.jsm 2018-01-30 13:46:58.119811129 -0800
++++ /home/zbraniecki/projects/mozilla-unified/intl/l10n/MessageContext.jsm 2018-01-30 13:53:23.036460739 -0800
+@@ -1838,90 +1838,5 @@
+ }
+ }
+
+-/*
+- * CachedIterable caches the elements yielded by an iterable.
+- *
+- * It can be used to iterate over an iterable many times without depleting the
+- * iterable.
+- */
+-
+-/*
+- * @overview
+- *
+- * Functions for managing ordered sequences of MessageContexts.
+- *
+- * An ordered iterable of MessageContext instances can represent the current
+- * negotiated fallback chain of languages. This iterable can be used to find
+- * the best existing translation for a given identifier.
+- *
+- * The mapContext* methods can be used to find the first MessageContext in the
+- * given iterable which contains the translation with the given identifier. If
+- * the iterable is ordered according to the result of a language negotiation
+- * the returned MessageContext contains the best available translation.
+- *
+- * A simple function which formats translations based on the identifier might
+- * be implemented as follows:
+- *
+- * formatString(id, args) {
+- * const ctx = mapContextSync(contexts, id);
+- *
+- * if (ctx === null) {
+- * return id;
+- * }
+- *
+- * const msg = ctx.getMessage(id);
+- * return ctx.format(msg, args);
+- * }
+- *
+- * In order to pass an iterator to mapContext*, wrap it in CachedIterable.
+- * This allows multiple calls to mapContext* without advancing and eventually
+- * depleting the iterator.
+- *
+- * function *generateMessages() {
+- * // Some lazy logic for yielding MessageContexts.
+- * yield *[ctx1, ctx2];
+- * }
+- *
+- * const contexts = new CachedIterable(generateMessages());
+- * const ctx = mapContextSync(contexts, id);
+- *
+- */
+-
+-/*
+- * Synchronously map an identifier or an array of identifiers to the best
+- * `MessageContext` instance(s).
+- *
+- * @param {Iterable} iterable
+- * @param {string|Array<string>} ids
+- * @returns {MessageContext|Array<MessageContext>}
+- */
+-
+-
+-/*
+- * Asynchronously map an identifier or an array of identifiers to the best
+- * `MessageContext` instance(s).
+- *
+- * @param {AsyncIterable} iterable
+- * @param {string|Array<string>} ids
+- * @returns {Promise<MessageContext|Array<MessageContext>>}
+- */
+-
+-/**
+- * Template literal tag for dedenting FTL code.
+- *
+- * Strip the common indent of non-blank lines. Remove blank lines.
+- *
+- * @param {Array<string>} strings
+- */
+-
+-/*
+- * @module fluent
+- * @overview
+- *
+- * `fluent` is a JavaScript implementation of Project Fluent, a localization
+- * framework designed to unleash the expressive power of the natural language.
+- *
+- */
+-
+ this.MessageContext = MessageContext;
+ this.EXPORTED_SYMBOLS = ['MessageContext'];
--- a/intl/l10n/test/test_messagecontext.js
+++ b/intl/l10n/test/test_messagecontext.js
@@ -9,31 +9,25 @@ function run_test() {
ok(true);
}
function test_methods_presence(MessageContext) {
const ctx = new MessageContext(["en-US", "pl"]);
equal(typeof ctx.addMessages, "function");
equal(typeof ctx.format, "function");
- equal(typeof ctx.formatToParts, "function");
}
function test_methods_calling(MessageContext) {
const ctx = new MessageContext(["en-US", "pl"], {
useIsolating: false
});
ctx.addMessages("key = Value");
const msg = ctx.getMessage("key");
equal(ctx.format(msg), "Value");
- deepEqual(ctx.formatToParts(msg), ["Value"]);
ctx.addMessages("key2 = Hello { $name }");
const msg2 = ctx.getMessage("key2");
equal(ctx.format(msg2, { name: "Amy" }), "Hello Amy");
- deepEqual(ctx.formatToParts(msg2), ["Hello ", {
- value: "name",
- opts: undefined
- }]);
ok(true);
}