fluent-react.js (19068B)
1 /* Copyright 2019 Mozilla Foundation and others 2 * 3 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * you may not use this file except in compliance with the License. 5 * You may obtain a copy of the License at 6 * 7 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software 10 * distributed under the License is distributed on an "AS IS" BASIS, 11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 * See the License for the specific language governing permissions and 13 * limitations under the License. 14 */ 15 16 17 'use strict'; 18 19 /* fluent-react@0.10.0 */ 20 21 Object.defineProperty(exports, '__esModule', { value: true }); 22 23 function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } 24 25 const react = require("resource://devtools/client/shared/vendor/react.mjs"); 26 const PropTypes = _interopDefault(require("resource://devtools/client/shared/vendor/react-prop-types.mjs")); 27 28 /* 29 * Synchronously map an identifier or an array of identifiers to the best 30 * `FluentBundle` instance(s). 31 * 32 * @param {Iterable} iterable 33 * @param {string|Array<string>} ids 34 * @returns {FluentBundle|Array<FluentBundle>} 35 */ 36 function mapBundleSync(iterable, ids) { 37 if (!Array.isArray(ids)) { 38 return getBundleForId(iterable, ids); 39 } 40 41 return ids.map( 42 id => getBundleForId(iterable, id) 43 ); 44 } 45 46 /* 47 * Find the best `FluentBundle` with the translation for `id`. 48 */ 49 function getBundleForId(iterable, id) { 50 for (const bundle of iterable) { 51 if (bundle.hasMessage(id)) { 52 return bundle; 53 } 54 } 55 56 return null; 57 } 58 59 /* 60 * Asynchronously map an identifier or an array of identifiers to the best 61 * `FluentBundle` instance(s). 62 * 63 * @param {AsyncIterable} iterable 64 * @param {string|Array<string>} ids 65 * @returns {Promise<FluentBundle|Array<FluentBundle>>} 66 */ 67 68 /* 69 * @module fluent-sequence 70 * @overview Manage ordered sequences of FluentBundles. 71 */ 72 73 /* 74 * Base CachedIterable class. 75 */ 76 class CachedIterable extends Array { 77 /** 78 * Create a `CachedIterable` instance from an iterable or, if another 79 * instance of `CachedIterable` is passed, return it without any 80 * modifications. 81 * 82 * @param {Iterable} iterable 83 * @returns {CachedIterable} 84 */ 85 static from(iterable) { 86 if (iterable instanceof this) { 87 return iterable; 88 } 89 90 return new this(iterable); 91 } 92 } 93 94 /* 95 * CachedSyncIterable caches the elements yielded by an iterable. 96 * 97 * It can be used to iterate over an iterable many times without depleting the 98 * iterable. 99 */ 100 class CachedSyncIterable extends CachedIterable { 101 /** 102 * Create an `CachedSyncIterable` instance. 103 * 104 * @param {Iterable} iterable 105 * @returns {CachedSyncIterable} 106 */ 107 constructor(iterable) { 108 super(); 109 110 if (Symbol.iterator in Object(iterable)) { 111 this.iterator = iterable[Symbol.iterator](); 112 } else { 113 throw new TypeError("Argument must implement the iteration protocol."); 114 } 115 } 116 117 [Symbol.iterator]() { 118 const cached = this; 119 let cur = 0; 120 121 return { 122 next() { 123 if (cached.length <= cur) { 124 cached.push(cached.iterator.next()); 125 } 126 return cached[cur++]; 127 } 128 }; 129 } 130 131 /** 132 * This method allows user to consume the next element from the iterator 133 * into the cache. 134 * 135 * @param {number} count - number of elements to consume 136 */ 137 touchNext(count = 1) { 138 let idx = 0; 139 while (idx++ < count) { 140 const last = this[this.length - 1]; 141 if (last && last.done) { 142 break; 143 } 144 this.push(this.iterator.next()); 145 } 146 // Return the last cached {value, done} object to allow the calling 147 // code to decide if it needs to call touchNext again. 148 return this[this.length - 1]; 149 } 150 } 151 152 /* 153 * `ReactLocalization` handles translation formatting and fallback. 154 * 155 * The current negotiated fallback chain of languages is stored in the 156 * `ReactLocalization` instance in form of an iterable of `FluentBundle` 157 * instances. This iterable is used to find the best existing translation for 158 * a given identifier. 159 * 160 * `Localized` components must subscribe to the changes of the 161 * `ReactLocalization`'s fallback chain. When the fallback chain changes (the 162 * `bundles` iterable is set anew), all subscribed compontent must relocalize. 163 * 164 * The `ReactLocalization` class instances are exposed to `Localized` elements 165 * via the `LocalizationProvider` component. 166 */ 167 class ReactLocalization { 168 constructor(bundles) { 169 this.bundles = CachedSyncIterable.from(bundles); 170 this.subs = new Set(); 171 } 172 173 /* 174 * Subscribe a `Localized` component to changes of `bundles`. 175 */ 176 subscribe(comp) { 177 this.subs.add(comp); 178 } 179 180 /* 181 * Unsubscribe a `Localized` component from `bundles` changes. 182 */ 183 unsubscribe(comp) { 184 this.subs.delete(comp); 185 } 186 187 /* 188 * Set a new `bundles` iterable and trigger the retranslation. 189 */ 190 setBundles(bundles) { 191 this.bundles = CachedSyncIterable.from(bundles); 192 193 // Update all subscribed Localized components. 194 this.subs.forEach(comp => comp.relocalize()); 195 } 196 197 getBundle(id) { 198 return mapBundleSync(this.bundles, id); 199 } 200 201 /* 202 * Find a translation by `id` and format it to a string using `args`. 203 */ 204 getString(id, args, fallback) { 205 const bundle = this.getBundle(id); 206 if (bundle) { 207 const msg = bundle.getMessage(id); 208 if (msg && msg.value) { 209 let errors = []; 210 let value = bundle.formatPattern(msg.value, args, errors); 211 for (let error of errors) { 212 this.reportError(error); 213 } 214 return value; 215 } 216 } 217 218 return fallback || id; 219 } 220 221 // XXX Control this via a prop passed to the LocalizationProvider. 222 // See https://github.com/projectfluent/fluent.js/issues/411. 223 reportError(error) { 224 /* global console */ 225 // eslint-disable-next-line no-console 226 console.warn(`[@fluent/react] ${error.name}: ${error.message}`); 227 } 228 } 229 230 function isReactLocalization(props, propName) { 231 const prop = props[propName]; 232 233 if (prop instanceof ReactLocalization) { 234 return null; 235 } 236 237 return new Error( 238 `The ${propName} context field must be an instance of ReactLocalization.` 239 ); 240 } 241 242 /* eslint-env browser */ 243 244 let cachedParseMarkup; 245 246 // We use a function creator to make the reference to `document` lazy. At the 247 // same time, it's eager enough to throw in <LocalizationProvider> as soon as 248 // it's first mounted which reduces the risk of this error making it to the 249 // runtime without developers noticing it in development. 250 function createParseMarkup() { 251 if (typeof(document) === "undefined") { 252 // We can't use <template> to sanitize translations. 253 throw new Error( 254 "`document` is undefined. Without it, translations cannot " + 255 "be safely sanitized. Consult the documentation at " + 256 "https://github.com/projectfluent/fluent.js/wiki/React-Overlays." 257 ); 258 } 259 260 if (!cachedParseMarkup) { 261 const template = document.createElement("template"); 262 cachedParseMarkup = function parseMarkup(str) { 263 template.innerHTML = str; 264 return Array.from(template.content.childNodes); 265 }; 266 } 267 268 return cachedParseMarkup; 269 } 270 271 /* 272 * The Provider component for the `ReactLocalization` class. 273 * 274 * Exposes a `ReactLocalization` instance to all descendants via React's 275 * context feature. It makes translations available to all localizable 276 * elements in the descendant's render tree without the need to pass them 277 * explicitly. 278 * 279 * <LocalizationProvider bundles={…}> 280 * … 281 * </LocalizationProvider> 282 * 283 * The `LocalizationProvider` component takes one prop: `bundles`. It should 284 * be an iterable of `FluentBundle` instances in order of the user's 285 * preferred languages. The `FluentBundle` instances will be used by 286 * `ReactLocalization` to format translations. If a translation is missing in 287 * one instance, `ReactLocalization` will fall back to the next one. 288 */ 289 class LocalizationProvider extends react.Component { 290 constructor(props) { 291 super(props); 292 const {bundles, parseMarkup} = props; 293 294 if (bundles === undefined) { 295 throw new Error("LocalizationProvider must receive the bundles prop."); 296 } 297 298 if (!bundles[Symbol.iterator]) { 299 throw new Error("The bundles prop must be an iterable."); 300 } 301 302 this.l10n = new ReactLocalization(bundles); 303 this.parseMarkup = parseMarkup || createParseMarkup(); 304 } 305 306 getChildContext() { 307 return { 308 l10n: this.l10n, 309 parseMarkup: this.parseMarkup, 310 }; 311 } 312 313 componentWillReceiveProps(next) { 314 const { bundles } = next; 315 316 if (bundles !== this.props.bundles) { 317 this.l10n.setBundles(bundles); 318 } 319 } 320 321 render() { 322 return react.Children.only(this.props.children); 323 } 324 } 325 326 LocalizationProvider.childContextTypes = { 327 l10n: isReactLocalization, 328 parseMarkup: PropTypes.func, 329 }; 330 331 LocalizationProvider.propTypes = { 332 children: PropTypes.element.isRequired, 333 bundles: isIterable, 334 parseMarkup: PropTypes.func, 335 }; 336 337 function isIterable(props, propName, componentName) { 338 const prop = props[propName]; 339 340 if (Symbol.iterator in Object(prop)) { 341 return null; 342 } 343 344 return new Error( 345 `The ${propName} prop supplied to ${componentName} must be an iterable.` 346 ); 347 } 348 349 function withLocalization(Inner) { 350 class WithLocalization extends react.Component { 351 componentDidMount() { 352 const { l10n } = this.context; 353 354 if (l10n) { 355 l10n.subscribe(this); 356 } 357 } 358 359 componentWillUnmount() { 360 const { l10n } = this.context; 361 362 if (l10n) { 363 l10n.unsubscribe(this); 364 } 365 } 366 367 /* 368 * Rerender this component in a new language. 369 */ 370 relocalize() { 371 // When the `ReactLocalization`'s fallback chain changes, update the 372 // component. 373 this.forceUpdate(); 374 } 375 376 /* 377 * Find a translation by `id` and format it to a string using `args`. 378 */ 379 getString(id, args, fallback) { 380 const { l10n } = this.context; 381 382 if (!l10n) { 383 return fallback || id; 384 } 385 386 return l10n.getString(id, args, fallback); 387 } 388 389 render() { 390 return react.createElement( 391 Inner, 392 Object.assign( 393 // getString needs to be re-bound on updates to trigger a re-render 394 { getString: (...args) => this.getString(...args) }, 395 this.props 396 ) 397 ); 398 } 399 } 400 401 WithLocalization.displayName = `WithLocalization(${displayName(Inner)})`; 402 403 WithLocalization.contextTypes = { 404 l10n: isReactLocalization 405 }; 406 407 return WithLocalization; 408 } 409 410 function displayName(component) { 411 return component.displayName || component.name || "Component"; 412 } 413 414 /** 415 * Copyright (c) 2013-present, Facebook, Inc. 416 * 417 * This source code is licensed under the MIT license found in the 418 * LICENSE file in this directory. 419 */ 420 421 // For HTML, certain tags should omit their close tag. We keep a whitelist for 422 // those special-case tags. 423 424 var omittedCloseTags = { 425 area: true, 426 base: true, 427 br: true, 428 col: true, 429 embed: true, 430 hr: true, 431 img: true, 432 input: true, 433 keygen: true, 434 link: true, 435 meta: true, 436 param: true, 437 source: true, 438 track: true, 439 wbr: true, 440 // NOTE: menuitem's close tag should be omitted, but that causes problems. 441 }; 442 443 /** 444 * Copyright (c) 2013-present, Facebook, Inc. 445 * 446 * This source code is licensed under the MIT license found in the 447 * LICENSE file in this directory. 448 */ 449 450 // For HTML, certain tags cannot have children. This has the same purpose as 451 // `omittedCloseTags` except that `menuitem` should still have its closing tag. 452 453 var voidElementTags = { 454 menuitem: true, 455 ...omittedCloseTags, 456 }; 457 458 // Match the opening angle bracket (<) in HTML tags, and HTML entities like 459 // &, &, &. 460 const reMarkup = /<|&#?\w+;/; 461 462 /* 463 * Prepare props passed to `Localized` for formatting. 464 */ 465 function toArguments(props) { 466 const args = {}; 467 const elems = {}; 468 469 for (const [propname, propval] of Object.entries(props)) { 470 if (propname.startsWith("$")) { 471 const name = propname.substr(1); 472 args[name] = propval; 473 } else if (react.isValidElement(propval)) { 474 // We'll try to match localNames of elements found in the translation with 475 // names of elements passed as props. localNames are always lowercase. 476 const name = propname.toLowerCase(); 477 elems[name] = propval; 478 } 479 } 480 481 return [args, elems]; 482 } 483 484 /* 485 * The `Localized` class renders its child with translated props and children. 486 * 487 * <Localized id="hello-world"> 488 * <p>{'Hello, world!'}</p> 489 * </Localized> 490 * 491 * The `id` prop should be the unique identifier of the translation. Any 492 * attributes found in the translation will be applied to the wrapped element. 493 * 494 * Arguments to the translation can be passed as `$`-prefixed props on 495 * `Localized`. 496 * 497 * <Localized id="hello-world" $username={name}> 498 * <p>{'Hello, { $username }!'}</p> 499 * </Localized> 500 * 501 * It's recommended that the contents of the wrapped component be a string 502 * expression. The string will be used as the ultimate fallback if no 503 * translation is available. It also makes it easy to grep for strings in the 504 * source code. 505 */ 506 class Localized extends react.Component { 507 componentDidMount() { 508 const { l10n } = this.context; 509 510 if (l10n) { 511 l10n.subscribe(this); 512 } 513 } 514 515 componentWillUnmount() { 516 const { l10n } = this.context; 517 518 if (l10n) { 519 l10n.unsubscribe(this); 520 } 521 } 522 523 /* 524 * Rerender this component in a new language. 525 */ 526 relocalize() { 527 // When the `ReactLocalization`'s fallback chain changes, update the 528 // component. 529 this.forceUpdate(); 530 } 531 532 render() { 533 const { l10n, parseMarkup } = this.context; 534 const { id, attrs, children: child = null } = this.props; 535 536 // Validate that the child element isn't an array 537 if (Array.isArray(child)) { 538 throw new Error("<Localized/> expected to receive a single " + 539 "React node child"); 540 } 541 542 if (!l10n) { 543 // Use the wrapped component as fallback. 544 return child; 545 } 546 547 const bundle = l10n.getBundle(id); 548 549 if (bundle === null) { 550 // Use the wrapped component as fallback. 551 return child; 552 } 553 554 const msg = bundle.getMessage(id); 555 const [args, elems] = toArguments(this.props); 556 let errors = []; 557 558 // Check if the child inside <Localized> is a valid element -- if not, then 559 // it's either null or a simple fallback string. No need to localize the 560 // attributes. 561 if (!react.isValidElement(child)) { 562 if (msg.value) { 563 // Replace the fallback string with the message value; 564 let value = bundle.formatPattern(msg.value, args, errors); 565 for (let error of errors) { 566 l10n.reportError(error); 567 } 568 return value; 569 } 570 571 return child; 572 } 573 574 let localizedProps; 575 576 // The default is to forbid all message attributes. If the attrs prop exists 577 // on the Localized instance, only set message attributes which have been 578 // explicitly allowed by the developer. 579 if (attrs && msg.attributes) { 580 localizedProps = {}; 581 errors = []; 582 for (const [name, allowed] of Object.entries(attrs)) { 583 if (allowed && name in msg.attributes) { 584 localizedProps[name] = bundle.formatPattern( 585 msg.attributes[name], args, errors); 586 } 587 } 588 for (let error of errors) { 589 l10n.reportError(error); 590 } 591 } 592 593 // If the wrapped component is a known void element, explicitly dismiss the 594 // message value and do not pass it to cloneElement in order to avoid the 595 // "void element tags must neither have `children` nor use 596 // `dangerouslySetInnerHTML`" error. 597 if (child.type in voidElementTags) { 598 return react.cloneElement(child, localizedProps); 599 } 600 601 // If the message has a null value, we're only interested in its attributes. 602 // Do not pass the null value to cloneElement as it would nuke all children 603 // of the wrapped component. 604 if (msg.value === null) { 605 return react.cloneElement(child, localizedProps); 606 } 607 608 errors = []; 609 const messageValue = bundle.formatPattern(msg.value, args, errors); 610 for (let error of errors) { 611 l10n.reportError(error); 612 } 613 614 // If the message value doesn't contain any markup nor any HTML entities, 615 // insert it as the only child of the wrapped component. 616 if (!reMarkup.test(messageValue)) { 617 return react.cloneElement(child, localizedProps, messageValue); 618 } 619 620 // If the message contains markup, parse it and try to match the children 621 // found in the translation with the props passed to this Localized. 622 const translationNodes = parseMarkup(messageValue); 623 const translatedChildren = translationNodes.map(childNode => { 624 if (childNode.nodeType === childNode.TEXT_NODE) { 625 return childNode.textContent; 626 } 627 628 // If the child is not expected just take its textContent. 629 if (!elems.hasOwnProperty(childNode.localName)) { 630 return childNode.textContent; 631 } 632 633 const sourceChild = elems[childNode.localName]; 634 635 // If the element passed as a prop to <Localized> is a known void element, 636 // explicitly dismiss any textContent which might have accidentally been 637 // defined in the translation to prevent the "void element tags must not 638 // have children" error. 639 if (sourceChild.type in voidElementTags) { 640 return sourceChild; 641 } 642 643 // TODO Protect contents of elements wrapped in <Localized> 644 // https://github.com/projectfluent/fluent.js/issues/184 645 // TODO Control localizable attributes on elements passed as props 646 // https://github.com/projectfluent/fluent.js/issues/185 647 return react.cloneElement(sourceChild, null, childNode.textContent); 648 }); 649 650 return react.cloneElement(child, localizedProps, ...translatedChildren); 651 } 652 } 653 654 Localized.contextTypes = { 655 l10n: isReactLocalization, 656 parseMarkup: PropTypes.func, 657 }; 658 659 Localized.propTypes = { 660 children: PropTypes.node 661 }; 662 663 /* 664 * @module fluent-react 665 * @overview 666 * 667 668 * `fluent-react` provides React bindings for Fluent. It takes advantage of 669 * React's Components system and the virtual DOM. Translations are exposed to 670 * components via the provider pattern. 671 * 672 * <LocalizationProvider bundles={…}> 673 * <Localized id="hello-world"> 674 * <p>{'Hello, world!'}</p> 675 * </Localized> 676 * </LocalizationProvider> 677 * 678 * Consult the documentation of the `LocalizationProvider` and the `Localized` 679 * components for more information. 680 */ 681 682 exports.LocalizationProvider = LocalizationProvider; 683 exports.Localized = Localized; 684 exports.ReactLocalization = ReactLocalization; 685 exports.isReactLocalization = isReactLocalization; 686 exports.withLocalization = withLocalization;