1/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */
2
3/* Copyright 2017 Mozilla Foundation and others
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 *     http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18
19/* fluent-dom@fa25466f (October 12, 2018) */
20
21/* eslint no-console: ["error", { allow: ["warn", "error"] }] */
22/* global console */
23
24const { L10nRegistry } = ChromeUtils.import("resource://gre/modules/L10nRegistry.jsm");
25const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
26const { AppConstants } = ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
27
28/*
29 * Base CachedIterable class.
30 */
31class CachedIterable extends Array {
32  /**
33   * Create a `CachedIterable` instance from an iterable or, if another
34   * instance of `CachedIterable` is passed, return it without any
35   * modifications.
36   *
37   * @param {Iterable} iterable
38   * @returns {CachedIterable}
39   */
40  static from(iterable) {
41    if (iterable instanceof this) {
42      return iterable;
43    }
44
45    return new this(iterable);
46  }
47}
48
49/*
50 * CachedAsyncIterable caches the elements yielded by an async iterable.
51 *
52 * It can be used to iterate over an iterable many times without depleting the
53 * iterable.
54 */
55class CachedAsyncIterable extends CachedIterable {
56  /**
57   * Create an `CachedAsyncIterable` instance.
58   *
59   * @param {Iterable} iterable
60   * @returns {CachedAsyncIterable}
61   */
62  constructor(iterable) {
63    super();
64
65    if (Symbol.asyncIterator in Object(iterable)) {
66      this.iterator = iterable[Symbol.asyncIterator]();
67    } else if (Symbol.iterator in Object(iterable)) {
68      this.iterator = iterable[Symbol.iterator]();
69    } else {
70      throw new TypeError("Argument must implement the iteration protocol.");
71    }
72  }
73
74  /**
75   * Asynchronous iterator caching the yielded elements.
76   *
77   * Elements yielded by the original iterable will be cached and available
78   * synchronously. Returns an async generator object implementing the
79   * iterator protocol over the elements of the original (async or sync)
80   * iterable.
81   */
82  [Symbol.asyncIterator]() {
83    const cached = this;
84    let cur = 0;
85
86    return {
87      async next() {
88        if (cached.length <= cur) {
89          cached.push(cached.iterator.next());
90        }
91        return cached[cur++];
92      },
93    };
94  }
95
96  /**
97   * This method allows user to consume the next element from the iterator
98   * into the cache.
99   *
100   * @param {number} count - number of elements to consume
101   */
102  async touchNext(count = 1) {
103    let idx = 0;
104    while (idx++ < count) {
105      const last = this[this.length - 1];
106      if (last && (await last).done) {
107        break;
108      }
109      this.push(this.iterator.next());
110    }
111    // Return the last cached {value, done} object to allow the calling
112    // code to decide if it needs to call touchNext again.
113    return this[this.length - 1];
114  }
115}
116
117/*
118 * CachedSyncIterable caches the elements yielded by an iterable.
119 *
120 * It can be used to iterate over an iterable many times without depleting the
121 * iterable.
122 */
123class CachedSyncIterable extends CachedIterable {
124    /**
125     * Create an `CachedSyncIterable` instance.
126     *
127     * @param {Iterable} iterable
128     * @returns {CachedSyncIterable}
129     */
130    constructor(iterable) {
131        super();
132
133        if (Symbol.iterator in Object(iterable)) {
134            this.iterator = iterable[Symbol.iterator]();
135        } else {
136            throw new TypeError("Argument must implement the iteration protocol.");
137        }
138    }
139
140    [Symbol.iterator]() {
141        const cached = this;
142        let cur = 0;
143
144        return {
145            next() {
146                if (cached.length <= cur) {
147                    cached.push(cached.iterator.next());
148                }
149                return cached[cur++];
150            },
151        };
152    }
153
154    /**
155     * This method allows user to consume the next element from the iterator
156     * into the cache.
157     *
158     * @param {number} count - number of elements to consume
159     */
160    touchNext(count = 1) {
161        let idx = 0;
162        while (idx++ < count) {
163            const last = this[this.length - 1];
164            if (last && last.done) {
165                break;
166            }
167            this.push(this.iterator.next());
168        }
169        // Return the last cached {value, done} object to allow the calling
170        // code to decide if it needs to call touchNext again.
171        return this[this.length - 1];
172    }
173}
174
175/**
176 * The default localization strategy for Gecko. It comabines locales
177 * available in L10nRegistry, with locales requested by the user to
178 * generate the iterator over FluentBundles.
179 *
180 * In the future, we may want to allow certain modules to override this
181 * with a different negotitation strategy to allow for the module to
182 * be localized into a different language - for example DevTools.
183 */
184function defaultGenerateBundles(resourceIds) {
185  const appLocales = Services.locale.appLocalesAsBCP47;
186  return L10nRegistry.generateBundles(appLocales, resourceIds);
187}
188
189function defaultGenerateBundlesSync(resourceIds) {
190  const appLocales = Services.locale.appLocalesAsBCP47;
191  return L10nRegistry.generateBundlesSync(appLocales, resourceIds);
192}
193
194function maybeReportErrorToGecko(error) {
195  if (AppConstants.NIGHTLY_BUILD || Cu.isInAutomation) {
196    if (Cu.isInAutomation) {
197      // We throw a string, rather than Error
198      // to allow the C++ Promise handler
199      // to clone it
200      throw error;
201    }
202    console.warn(error);
203  }
204}
205
206/**
207 * The `Localization` class is a central high-level API for vanilla
208 * JavaScript use of Fluent.
209 * It combines language negotiation, FluentBundle and I/O to
210 * provide a scriptable API to format translations.
211 */
212const Localization = {
213  cached(iterable, isSync) {
214    if (isSync) {
215      return CachedSyncIterable.from(iterable);
216    } else {
217      return CachedAsyncIterable.from(iterable);
218    }
219  },
220
221  /**
222   * Format translations and handle fallback if needed.
223   *
224   * Format translations for `keys` from `FluentBundle` instances on this
225   * Localization. In case of errors, fetch the next context in the
226   * fallback chain.
227   *
228   * @param   {Array<String>}     resourceIds - List of resource ids used by this
229   *                                            localization.
230   * @param   {Iter<FluentBundle>}    bundles - Iterator over bundles.
231   * @param   {Array<string|Object>}  keys    - Translation keys to format.
232   * @param   {Function}              method  - Formatting function.
233   * @returns {Promise<Array<string?|Object?>>}
234   * @private
235   */
236  async formatWithFallback(resourceIds, bundles, keys, method) {
237    if (!bundles) {
238      throw new Error("Attempt to format on an uninitialized instance.");
239    }
240
241    const translations = new Array(keys.length).fill(null);
242    let hasAtLeastOneBundle = false;
243
244    for await (const bundle of bundles) {
245      hasAtLeastOneBundle = true;
246      const missingIds = keysFromBundle(method, bundle, keys, translations);
247
248      if (missingIds.size === 0) {
249        break;
250      }
251
252      const locale = bundle.locales[0];
253      const ids = Array.from(missingIds).join(", ");
254      maybeReportErrorToGecko(`[fluent] Missing translations in ${locale}: ${ids}.`);
255    }
256
257    if (!hasAtLeastOneBundle) {
258      maybeReportErrorToGecko(`[fluent] Request for keys failed because no resource bundles got generated.\n keys: ${JSON.stringify(keys)}.\n resourceIds: ${JSON.stringify(resourceIds)}.`);
259    }
260
261    return translations;
262  },
263
264  /**
265   * Format translations and handle fallback if needed.
266   *
267   * Format translations for `keys` from `FluentBundle` instances on this
268   * Localization. In case of errors, fetch the next context in the
269   * fallback chain.
270   *
271   * @param   {Array<String>}     resourceIds - List of resource ids used by this
272   *                                            localization.
273   * @param   {Iter<FluentBundle>}    bundles - Iterator over bundles.
274   * @param   {Array<string|Object>}  keys    - Translation keys to format.
275   * @param   {Function}              method  - Formatting function.
276   * @returns {Array<string|Object>}
277   * @private
278   */
279  formatWithFallbackSync(resourceIds, bundles, keys, method) {
280    if (!bundles) {
281      throw new Error("Attempt to format on an uninitialized instance.");
282    }
283
284    const translations = new Array(keys.length).fill(null);
285    let hasAtLeastOneBundle = false;
286
287    for (const bundle of bundles) {
288      hasAtLeastOneBundle = true;
289      const missingIds = keysFromBundle(method, bundle, keys, translations);
290
291      if (missingIds.size === 0) {
292        break;
293      }
294
295      const locale = bundle.locales[0];
296      const ids = Array.from(missingIds).join(", ");
297      maybeReportErrorToGecko(`[fluent] Missing translations in ${locale}: ${ids}.`);
298    }
299
300    if (!hasAtLeastOneBundle) {
301      maybeReportErrorToGecko(`[fluent] Request for keys failed because no resource bundles got generated.\n keys: ${JSON.stringify(keys)}.\n resourceIds: ${JSON.stringify(resourceIds)}.`);
302    }
303
304    return translations;
305  },
306
307
308  /**
309   * Format translations into {value, attributes} objects.
310   *
311   * The fallback logic is the same as in `formatValues` but it returns {value,
312   * attributes} objects which are suitable for the translation of DOM
313   * elements.
314   *
315   *     docL10n.formatMessages([
316   *       {id: 'hello', args: { who: 'Mary' }},
317   *       {id: 'welcome'}
318   *     ]).then(console.log);
319   *
320   *     // [
321   *     //   { value: 'Hello, Mary!', attributes: null },
322   *     //   {
323   *     //     value: 'Welcome!',
324   *     //     attributes: [ { name: "title", value: 'Hello' } ]
325   *     //   }
326   *     // ]
327   *
328   * Returns a Promise resolving to an array of the translation messages.
329   *
330   * @param   {Array<String>}     resourceIds - List of resource ids used by this
331   *                                            localization.
332   * @param   {Iter<FluentBundle>}    bundles - Iterator over bundles.
333   * @param   {Array<string|Object>}  keys    - Translation keys to format.
334   * @returns {Promise<Array<{value: string, attributes: Object}?>>}
335   * @private
336   */
337  formatMessages(resourceIds, bundles, keys) {
338    return this.formatWithFallback(resourceIds, bundles, keys, messageFromBundle);
339  },
340
341  /**
342   * Sync version of `formatMessages`.
343   *
344   * Returns an array of the translation messages.
345   *
346   * @param   {Array<String>}     resourceIds - List of resource ids used by this
347   *                                            localization.
348   * @param   {Iter<FluentBundle>}    bundles - Iterator over bundles.
349   * @param   {Array<string|Object>}  keys    - Translation keys to format.
350   * @returns {Array<{value: string, attributes: Object}?>}
351   * @private
352   */
353  formatMessagesSync(resourceIds, bundles, keys) {
354    return this.formatWithFallbackSync(resourceIds, bundles, keys, messageFromBundle);
355  },
356
357  /**
358   * Retrieve translations corresponding to the passed keys.
359   *
360   * A generalized version of `Localization.formatValue`. Keys must
361   * be `{id, args}` objects.
362   *
363   *     docL10n.formatValues([
364   *       {id: 'hello', args: { who: 'Mary' }},
365   *       {id: 'hello', args: { who: 'John' }},
366   *       {id: 'welcome'}
367   *     ]).then(console.log);
368   *
369   *     // ['Hello, Mary!', 'Hello, John!', 'Welcome!']
370   *
371   * Returns a Promise resolving to an array of the translation strings.
372   *
373   * @param   {Array<String>}     resourceIds - List of resource ids used by this
374   *                                            localization.
375   * @param   {Iter<FluentBundle>}    bundles - Iterator over bundles.
376   * @param   {Array<string|Object>}  keys    - Translation keys to format.
377   * @returns {Promise<Array<string?>>}
378   */
379  formatValues(resourceIds, bundles, keys) {
380    return this.formatWithFallback(resourceIds, bundles, keys, valueFromBundle);
381  },
382
383  /**
384   * Sync version of `formatValues`.
385   *
386   * Returns an array of the translation strings.
387   *
388   * @param   {Array<String>}     resourceIds - List of resource ids used by this
389   *                                            localization.
390   * @param   {Iter<FluentBundle>}    bundles - Iterator over bundles.
391   * @param   {Array<string|Object>}  keys    - Translation keys to format.
392   * @returns {Array<string?>}
393   * @private
394   */
395  formatValuesSync(resourceIds, bundles, keys) {
396    return this.formatWithFallbackSync(resourceIds, bundles, keys, valueFromBundle);
397  },
398
399  /**
400   * Retrieve the translation corresponding to the `id` identifier.
401   *
402   * If passed, `args` is a simple hash object with a list of variables that
403   * will be interpolated in the value of the translation.
404   *
405   *     docL10n.formatValue(
406   *       'hello', { who: 'world' }
407   *     ).then(console.log);
408   *
409   *     // 'Hello, world!'
410   *
411   * Returns a Promise resolving to a translation string.
412   *
413   * Use this sparingly for one-off messages which don't need to be
414   * retranslated when the user changes their language preferences, e.g. in
415   * notifications.
416   *
417   * @param   {Array<String>}     resourceIds - List of resource ids used by this
418   *                                            localization.
419   * @param   {Iter<FluentBundle>}    bundles - Iterator over bundles.
420   * @param   {string}                 id     - Identifier of the translation to format
421   * @param   {Object}                 [args] - Optional external arguments
422   * @returns {Promise<string?>}
423   */
424  async formatValue(resourceIds, bundles, id, args) {
425    const [val] = await this.formatValues(resourceIds, bundles, [{id, args}]);
426    return val;
427  },
428
429  /**
430   * Sync version of `formatValue`.
431   *
432   * Returns a translation string.
433   *
434   * @param   {Array<String>}     resourceIds - List of resource ids used by this
435   *                                            localization.
436   * @param   {Iter<FluentBundle>}    bundles - Iterator over bundles.
437   * @param   {string}                 id     - Identifier of the translation to format
438   * @param   {Object}                 [args] - Optional external arguments
439   * @returns {string?}
440   * @private
441   */
442  formatValueSync(resourceIds, bundles, id, args) {
443    const [val] = this.formatValuesSync(resourceIds, bundles, [{id, args}]);
444    return val;
445  },
446
447  /**
448   * This method should be called when there's a reason to believe
449   * that language negotiation or available resources changed.
450   *
451   * @param {Array<String>}    resourceIds - List of resource ids used by this
452   *                                         localization.
453   * @param {bool}                  isSync - Whether the instance should be
454   *                                         synchronous.
455   * @param {bool}                   eager - whether the I/O for new context should begin eagerly
456   * @param {Function}     generateBundles - Custom FluentBundle asynchronous generator.
457   * @param {Function} generateBundlesSync - Custom FluentBundle generator.
458   * @returns {Iter<FluentBundle>}
459   */
460  generateBundles(resourceIds, isSync, eager = false, generateBundles = defaultGenerateBundles, generateBundlesSync = defaultGenerateBundlesSync) {
461    // Store for error reporting from `formatWithFallback`.
462    let generateMessages = isSync ? generateBundlesSync : generateBundles;
463    let bundles = this.cached(generateMessages(resourceIds), isSync);
464    if (eager) {
465      // If the first app locale is the same as last fallback
466      // it means that we have all resources in this locale, and
467      // we want to eagerly fetch just that one.
468      // Otherwise, we're in a scenario where the first locale may
469      // be partial and we want to eagerly fetch a fallback as well.
470      const appLocale = Services.locale.appLocaleAsBCP47;
471      const lastFallback = Services.locale.lastFallbackLocale;
472      const prefetchCount = appLocale === lastFallback ? 1 : 2;
473      bundles.touchNext(prefetchCount);
474    }
475    return bundles;
476  },
477}
478
479/**
480 * Format the value of a message into a string or `null`.
481 *
482 * This function is passed as a method to `keysFromBundle` and resolve
483 * a value of a single L10n Entity using provided `FluentBundle`.
484
485 * If the message doesn't have a value, return `null`.
486 *
487 * @param   {FluentBundle} bundle
488 * @param   {Array<Error>} errors
489 * @param   {Object} message
490 * @param   {Object} args
491 * @returns {string?}
492 * @private
493 */
494function valueFromBundle(bundle, errors, message, args) {
495  if (message.value) {
496    return bundle.formatPattern(message.value, args, errors);
497  }
498
499  return null;
500}
501
502/**
503 * Format all public values of a message into a {value, attributes} object.
504 *
505 * This function is passed as a method to `keysFromBundle` and resolve
506 * a single L10n Entity using provided `FluentBundle`.
507 *
508 * The function will return an object with a value and attributes of the
509 * entity.
510 *
511 * @param   {FluentBundle} bundle
512 * @param   {Array<Error>}   errors
513 * @param   {Object} message
514 * @param   {Object} args
515 * @returns {Object}
516 * @private
517 */
518function messageFromBundle(bundle, errors, message, args) {
519  const formatted = {
520    value: null,
521    attributes: null,
522  };
523
524  if (message.value) {
525    formatted.value = bundle.formatPattern(message.value, args, errors);
526  }
527
528  let attrNames = Object.keys(message.attributes);
529  if (attrNames.length > 0) {
530    formatted.attributes = new Array(attrNames.length);
531    for (let [i, name] of attrNames.entries()) {
532      let value = bundle.formatPattern(message.attributes[name], args, errors);
533      formatted.attributes[i] = {name, value};
534    }
535  }
536
537  return formatted;
538}
539
540/**
541 * This function is an inner function for `Localization.formatWithFallback`.
542 *
543 * It takes a `FluentBundle`, list of l10n-ids and a method to be used for
544 * key resolution (either `valueFromBundle` or `messageFromBundle`) and
545 * optionally a value returned from `keysFromBundle` executed against
546 * another `FluentBundle`.
547 *
548 * The idea here is that if the previous `FluentBundle` did not resolve
549 * all keys, we're calling this function with the next context to resolve
550 * the remaining ones.
551 *
552 * In the function, we loop over `keys` and check if we have the `prev`
553 * passed and if it has an error entry for the position we're in.
554 *
555 * If it doesn't, it means that we have a good translation for this key and
556 * we return it. If it does, we'll try to resolve the key using the passed
557 * `FluentBundle`.
558 *
559 * In the end, we fill the translations array, and return the Set with
560 * missing ids.
561 *
562 * See `Localization.formatWithFallback` for more info on how this is used.
563 *
564 * @param {Function}       method
565 * @param {FluentBundle}   bundle
566 * @param {Array<string|Object>} keys
567 * @param {{Array<{value: string, attributes: Object}>}} translations
568 *
569 * @returns {Set<string>}
570 * @private
571 */
572function keysFromBundle(method, bundle, keys, translations) {
573  const messageErrors = [];
574  const missingIds = new Set();
575
576  keys.forEach((key, i) => {
577    let id;
578    let args = undefined;
579    if (typeof key == "object" && "id" in key) {
580      id = String(key.id);
581      args = key.args;
582    } else {
583      id = String(key);
584    }
585
586    if (translations[i] !== null) {
587      return;
588    }
589
590    let message = bundle.getMessage(id);
591    if (message) {
592      messageErrors.length = 0;
593      translations[i] = method(bundle, messageErrors, message, args);
594      if (messageErrors.length > 0) {
595        const locale = bundle.locales[0];
596        const errors = messageErrors.join(", ");
597        maybeReportErrorToGecko(`[fluent][resolver] errors in ${locale}/${id}: ${errors}.`);
598      }
599    } else {
600      missingIds.add(id);
601    }
602  });
603
604  return missingIds;
605}
606
607this.Localization = Localization;
608var EXPORTED_SYMBOLS = ["Localization"];
609