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