Bug 1425104: Part 2 - Add stub fluent API for built-in content pages. r?gandalf draft
authorKris Maglione <maglione.k@gmail.com>
Thu, 19 Apr 2018 22:20:36 -0700
changeset 805621 fe8af2cc822f1e645a88cbc81bcfae009bd110ac
parent 805620 52562e8d57a8d2973cfaca9c350b6727996df39f
push id112718
push usermaglione.k@gmail.com
push dateFri, 08 Jun 2018 05:36:45 +0000
reviewersgandalf
bugs1425104
milestone62.0a1
Bug 1425104: Part 2 - Add stub fluent API for built-in content pages. r?gandalf MozReview-Commit-ID: 7zcCXJztYF1
intl/l10n/ContentLocalization.jsm
intl/l10n/MessageContext.jsm
intl/l10n/moz.build
intl/l10n/test/data/localized.js
intl/l10n/test/test_contentlocalization.js
intl/l10n/test/xpcshell.ini
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]