new file mode 100644
--- /dev/null
+++ b/intl/l10n/ContentLocalization.jsm
@@ -0,0 +1,222 @@
+/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */
+
+/**
+ * This module implements localization contexts which can be exposed to
+ * content-privilebed documents, including WebExtensions and semi-privileged
+ * resource: and about: pages.
+ */
+
+/* globals MozDocumentMatcher, MozDocumentObserver */
+
+var EXPORTED_SYMBOLS = ["ContentLocalization"];
+
+ChromeUtils.import("resource://gre/modules/Localization.jsm");
+
+/**
+ * Contains a map of privileged, opaque message objects to separate opaque
+ * objects in the content compartment.
+ */
+let messageMap = new WeakMap();
+
+/**
+ * Wraps a privileged MessageContext so that it can be used by a content
+ * document.
+ *
+ * @param {MessageContext} messageContext
+ * The privileged message context to wrap.
+ * @param {Document} doc
+ * The document which will own the wrapped message context.
+ *
+ * @returns {MessageContext}
+ * A MessageContext wrapper for the given content document.
+ */
+function wrapContext(messageContext, doc) {
+ let methods = {
+ hasMessage(id) {
+ return messageContext.hasMessage(id);
+ },
+
+ getMessage(id) {
+ let msg = messageContext.getMessage(id);
+ if (msg && typeof msg === "object") {
+ let obj = new doc.ownerGlobal.Object();
+ messageMap.set(obj, msg);
+ return obj;
+ }
+ return msg;
+ },
+
+ format(msg, args, errors) {
+ if (msg && typeof msg === "object") {
+ msg = messageMap.get(msg);
+ }
+ return messageContext.format(msg, args, errors);
+ }
+ };
+
+ let wrapper = Cu.cloneInto(methods, doc.ownerGlobal, {cloneFunctions: true});
+
+ Object.defineProperty(ChromeUtils.waiveXrays(wrapper), "messages", {
+ enumerable: true,
+ configurable: true,
+
+ get: Cu.exportFunction(function* () {
+ for (let msg of messageContext.messages) {
+ yield Cu.cloneInto(msg, doc.ownerGlobal);
+ }
+ }, doc.ownerGlobal),
+ });
+
+ return wrapper;
+}
+
+/**
+ * Wraps a privileged async iterator so that it can be used by the given content
+ * document. Each value returned by the iterator must already be accessible by
+ * the content document.
+ *
+ * @param {AsyncIterator} iter
+ * The iterator to wrap.
+ * @param {Document} doc
+ * The document which will own the wrapped iterator.
+ *
+ * @returns {AsyncIterator}
+ * An AsyncIterator wrapper for the given content document.
+ */
+function wrapAsyncIter(iter, doc) {
+ let wrapper;
+ let methods = {
+ next() {
+ return new doc.ownerGlobal.Promise((resolve, reject) => {
+ iter.next().then(result => {
+ resolve(ChromeUtils.shallowClone(result, doc.ownerGlobal));
+ }, error => {
+ reject(Cu.cloneInto(error, doc.ownerGlobal));
+ });
+ });
+ },
+
+ [Symbol.asyncIterator]() {
+ return wrapper;
+ },
+ };
+
+ wrapper = Cu.cloneInto(methods, doc.ownerGlobal, {cloneFunctions: true});
+
+ // Symbol properties are never cloned, so we need to clone the asyncIterator
+ // method manually.
+ Cu.exportFunction(methods[Symbol.asyncIterator], wrapper,
+ {defineAs: Symbol.asyncIterator});
+
+ return wrapper;
+}
+
+/**
+ * An object defining settings for a content localization API.
+ *
+ * @typedef {object} LocalizationOptions
+ * @property {Array<string>} resourceIds
+ * A list or resource IDs, as accepted by the `Localization` constructor.
+ * @property {Array<string>} matches
+ * A set of match patterns, as accepted by the MatchPattern API, of URLs
+ * into which this localization API should be injected.
+ * @property {boolean} [allFrames = false]
+ * If true, this localization API should be injected into all frames,
+ * rather than only top-level frames.
+ * @property {boolean} [matchAboutBlank = false]
+ * If true, this API should be injected into about:blank pages with
+ * unique principals.
+ * @property {Array<string>} [excludeMatches]
+ * A set of URL patterns for documents into which this API should never
+ * be injected, even if they match `matches`.
+ * @property {Array<string>} [includeGlobs]
+ * A set of URL globs for documents into which this API should be
+ * injected. If provided, the document URLs must match both the globs in
+ * this list and one of the patterns in `matches1.
+ * @property {Array<string>} [excludeGlobs]
+ * A set of URL globs for documents into which this API should never
+ * be injected, even if they match `matches`.
+ */
+
+/**
+ * A localization API context to be exposed to a content page.
+ *
+ * @param {LocalizationOptions} options
+ * Options for the localization object.
+ */
+class LocalizationAPI extends Localization {
+ constructor(options) {
+ super(options.resourceIds);
+
+ let matcherOptions = {
+ matches: new MatchPatternSet(options.matches, {restrictSchemes: false}),
+ allFrames: options.allFrames,
+ matchAboutBlank: options.matchAboutBlank,
+ };
+ if (options.excludeMatches) {
+ matcherOptions.excludeMatches = new MatchPatternSet(
+ matcherOptions.excludeMatches, {restrictSchemes: false});
+ }
+ if (options.includeGlobs) {
+ matcherOptions.includeGlobs = options.includeGlobs.map(
+ glob => new MatchGlob(glob));
+ }
+ if (options.excludeGlobs) {
+ matcherOptions.excludeGlobs = options.excludeGlobs.map(
+ glob => new MatchGlob(glob));
+ }
+
+ this.matcher = new MozDocumentMatcher(matcherOptions);
+ this.matcher.localizationAPI = this;
+
+ this.observer = new MozDocumentObserver(ContentLocalization);
+ this.observer.observe([this.matcher]);
+ }
+
+ injectAPI(window) {
+ let browser = ChromeUtils.waiveXrays(new window.Object());
+ let fluent = new window.Object();
+ browser.fluent = fluent;
+ ChromeUtils.waiveXrays(window).browser = browser;
+
+ let doc = window.document;
+
+ Cu.exportFunction(
+ this.generateMessagesWrappers.bind(this, doc),
+ fluent,
+ {defineAs: "generateMessages"});
+ }
+
+ async* _generateMessagesWrappers(doc, resourceIds) {
+ for await (let ctxt of this.generateMessages(resourceIds)) {
+ yield wrapContext(ctxt, doc);
+ }
+ }
+
+ generateMessagesWrappers(doc, resourceIds) {
+ return wrapAsyncIter(this._generateMessagesWrappers(doc, resourceIds),
+ doc);
+ }
+}
+
+var ContentLocalization = {
+ // A MozDocumentMatcher callback which injects the API into a matching window.
+ onNewDocument(matcher, window) {
+ matcher.localizationAPI.injectAPI(window);
+ },
+
+ // A MozDocumentMatcher callback for pre-loading a matched document. Ignored.
+ onPreloadDocument(matcher, loadInfo) {
+ },
+
+ /**
+ * Registers a set of localization resources which should be exposed to a
+ * matching set of content documents.
+ *
+ * @param {LocalizationOptions} options
+ * Options for the localization API.
+ */
+ registerContentPages(options) {
+ return new LocalizationAPI(options);
+ },
+};
--- a/intl/l10n/MessageContext.jsm
+++ b/intl/l10n/MessageContext.jsm
@@ -1233,18 +1233,18 @@ const PDI = "\u2069";
* @returns {FluentType}
* @private
*/
function DefaultMember(env, members, def) {
if (members[def]) {
return members[def];
}
- const { errors } = env;
- errors.push(new RangeError("No default"));
+ const { errors, global } = env;
+ errors.push(new global.RangeError("No default"));
return new FluentNone();
}
/**
* Resolve a reference to another message.
*
* @param {Object} env
@@ -1252,25 +1252,25 @@ function DefaultMember(env, members, def
* @param {Object} id
* 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 { ctx, errors, global } = env;
const message = name.startsWith("-")
? ctx._terms.get(name)
: ctx._messages.get(name);
if (!message) {
const err = name.startsWith("-")
- ? new ReferenceError(`Unknown term: ${name}`)
- : new ReferenceError(`Unknown message: ${name}`);
+ ? new global.ReferenceError(`Unknown term: ${name}`)
+ : new global.ReferenceError(`Unknown message: ${name}`);
errors.push(err);
return new FluentNone(name);
}
return message;
}
/**
@@ -1290,17 +1290,17 @@ function MessageReference(env, {name}) {
* @private
*/
function VariantExpression(env, {id, key}) {
const message = MessageReference(env, id);
if (message instanceof FluentNone) {
return message;
}
- const { ctx, errors } = env;
+ const { ctx, errors, global } = env;
const keyword = Type(env, key);
function isVariantList(node) {
return Array.isArray(node) &&
node[0].type === "sel" &&
node[0].exp === null;
}
@@ -1309,17 +1309,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.toString(ctx)}`));
+ errors.push(new global.ReferenceError(`Unknown variant: ${keyword.toString(ctx)}`));
return Type(env, message);
}
/**
* Resolve an attribute expression to the attribute object.
*
* @param {Object} env
@@ -1343,18 +1343,18 @@ function AttributeExpression(env, {id, n
// Match the specified name against keys of each attribute.
for (const attrName in message.attrs) {
if (name === attrName) {
return message.attrs[name];
}
}
}
- const { errors } = env;
- errors.push(new ReferenceError(`Unknown attribute: ${name}`));
+ const { errors, global } = env;
+ errors.push(new global.ReferenceError(`Unknown attribute: ${name}`));
return Type(env, message);
}
/**
* Resolve a select expression to the member object.
*
* @param {Object} env
* Resolver environment object.
@@ -1459,18 +1459,18 @@ function Type(env, expr) {
return Type(env, member);
}
case undefined: {
// If it's a node with a value, resolve the value.
if (expr.val !== null && expr.val !== undefined) {
return Type(env, expr.val);
}
- const { errors } = env;
- errors.push(new RangeError("No value"));
+ const { errors, global } = env;
+ errors.push(new global.RangeError("No value"));
return new FluentNone();
}
default:
return new FluentNone();
}
}
/**
@@ -1481,20 +1481,20 @@ function Type(env, expr) {
* @param {Object} expr
* An expression to be resolved.
* @param {String} expr.name
* Name of an argument to be returned.
* @returns {FluentType}
* @private
*/
function ExternalArgument(env, {name}) {
- const { args, errors } = env;
+ const { args, errors, global } = env;
if (!args || !args.hasOwnProperty(name)) {
- errors.push(new ReferenceError(`Unknown external: ${name}`));
+ errors.push(new global.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;
@@ -1507,17 +1507,17 @@ function ExternalArgument(env, {name}) {
case "number":
return new FluentNumber(arg);
case "object":
if (arg instanceof Date) {
return new FluentDateTime(arg);
}
default:
errors.push(
- new TypeError(`Unsupported external type: ${name}, ${typeof arg}`)
+ new global.TypeError(`Unsupported external type: ${name}, ${typeof arg}`)
);
return new FluentNone(name);
}
}
/**
* Resolve a reference to a function.
*
@@ -1528,26 +1528,26 @@ function ExternalArgument(env, {name}) {
* @param {String} expr.name
* Name of the function to be returned.
* @returns {Function}
* @private
*/
function FunctionReference(env, {name}) {
// Some functions are built-in. Others may be provided by the runtime via
// the `MessageContext` constructor.
- const { ctx: { _functions }, errors } = env;
+ const { ctx: { _functions }, errors, global } = env;
const func = _functions[name] || builtins[name];
if (!func) {
- errors.push(new ReferenceError(`Unknown function: ${name}()`));
+ errors.push(new global.ReferenceError(`Unknown function: ${name}()`));
return new FluentNone(`${name}()`);
}
if (typeof func !== "function") {
- errors.push(new TypeError(`Function ${name}() is not callable`));
+ errors.push(new global.TypeError(`Function ${name}() is not callable`));
return new FluentNone(`${name}()`);
}
return func;
}
/**
* Resolve a call to a Function with positional and key-value arguments.
@@ -1595,20 +1595,20 @@ function CallExpression(env, {fun, args}
* @param {Object} env
* Resolver environment object.
* @param {Array} ptn
* Array of pattern elements.
* @returns {Array}
* @private
*/
function Pattern(env, ptn) {
- const { ctx, dirty, errors } = env;
+ const { ctx, dirty, errors, global } = env;
if (dirty.has(ptn)) {
- errors.push(new RangeError("Cyclic reference"));
+ errors.push(new global.RangeError("Cyclic reference"));
return new FluentNone();
}
// Tag the pattern as dirty for the purpose of the current resolution.
dirty.add(ptn);
const result = [];
// Wrap interpolations with Directional Isolate Formatting characters
@@ -1624,17 +1624,17 @@ function Pattern(env, ptn) {
const part = Type(env, elem).toString(ctx);
if (useIsolating) {
result.push(FSI);
}
if (part.length > MAX_PLACEABLE_LENGTH) {
errors.push(
- new RangeError(
+ new global.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);
}
@@ -1656,21 +1656,23 @@ function Pattern(env, ptn) {
* 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
* An object with the Message to be resolved.
* @param {Array} errors
* An error array that any encountered errors will be appended to.
+ * @param {Object} [global]
+ * The global object in which to create error objects.
* @returns {FluentType}
*/
-function resolve(ctx, args, message, errors = []) {
+function resolve(ctx, args, message, errors = [], global = Cu.getGlobalForObject(errors)) {
const env = {
- ctx, args, errors, dirty: new WeakSet()
+ ctx, args, errors, global, dirty: new WeakSet()
};
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.
--- a/intl/l10n/moz.build
+++ b/intl/l10n/moz.build
@@ -1,15 +1,16 @@
# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
# vim: set filetype=python:
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
EXTRA_JS_MODULES += [
+ 'ContentLocalization.jsm',
'DOMLocalization.jsm',
'L10nRegistry.jsm',
'Localization.jsm',
'MessageContext.jsm',
]
XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell.ini']
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/data/localized.js
@@ -0,0 +1,19 @@
+"use strict";
+
+/* globals browser */
+
+(async () => {
+ let results = [];
+ for await (let ctxt of browser.fluent.generateMessages(["/test.ftl"])) {
+ let msg = ctxt.getMessage("key");
+ results.push({
+ keyHas: ctxt.hasMessage("key"),
+ keyTypeof: typeof msg,
+ keyFmt: ctxt.format(msg, [], []),
+ fooHas: ctxt.hasMessage("foo"),
+ fooMsg: ctxt.getMessage("foo"),
+ });
+ }
+
+ window.postMessage(results, "*");
+})();
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/test_contentlocalization.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
+ChromeUtils.import("resource://gre/modules/ContentLocalization.jsm");
+ChromeUtils.import("resource://gre/modules/Localization.jsm");
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
+
+const known_platforms = {
+ "linux": "linux",
+ "win": "windows",
+ "macosx": "macos",
+ "android": "android",
+};
+
+function mockSomeThings() {
+ const { L10nRegistry, FileSource } =
+ ChromeUtils.import("resource://gre/modules/L10nRegistry.jsm", {});
+
+ const fs = {
+ "/localization/en-US/test.ftl": `
+key = { PLATFORM() ->
+ ${ Object.values(known_platforms).map(
+ name => ` [${ name }] ${ name.toUpperCase() } Value\n`).join("") }
+ *[other] OTHER Value
+ }`,
+ };
+
+ L10nRegistry.load = async function(url) {
+ return fs[url];
+ };
+
+ const source = new FileSource("test", ["en-US"], "/localization/{locale}");
+ L10nRegistry.registerSource(source);
+}
+
+add_task(async function() {
+ let resProto = Services.io.getProtocolHandler("resource")
+ .QueryInterface(Ci.nsISubstitutingProtocolHandler);
+ let rootURI = Services.io.newFileURI(do_get_file("data"));
+ resProto.setSubstitution("foo", rootURI);
+
+ mockSomeThings();
+
+ ContentLocalization.registerContentPages({
+ matches: ["resource://foo/*"],
+ resourceIds: ["test.ftl"],
+ });
+
+ let browser = Services.appShell.createWindowlessBrowser(false);
+ let webNav = browser.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDocShell)
+ .QueryInterface(Ci.nsIWebNavigation);
+
+ webNav.loadURI("resource://foo/localized.html", 0, null, null, null);
+ await new Promise(executeSoon);
+
+ let {data: results} = await ExtensionUtils.promiseEvent(webNav.document.ownerGlobal, "message");
+
+ equal(results.length, 1, "Got one context");
+
+ let platform = known_platforms[AppConstants.platform];
+
+ let [result] = results;
+ equal(result.keyHas, true, "hasMessage('key')");
+ equal(result.keyTypeof, "object", "typeof getMessage('key')");
+ equal(result.keyFmt, `${platform.toUpperCase()} Value`,
+ "format(getMessage('key'))");
+ equal(result.fooHas, false, "hasMessage('foo')");
+ equal(result.fooMsg, undefined, "getMessage('foo')");
+});
--- a/intl/l10n/test/xpcshell.ini
+++ b/intl/l10n/test/xpcshell.ini
@@ -1,8 +1,11 @@
[DEFAULT]
+support-files =
+ data/**
head =
+[test_contentlocalization.js]
[test_domlocalization.js]
[test_l10nregistry.js]
[test_localization.js]
[test_messagecontext.js]
[test_pseudo.js]