diff options
Diffstat (limited to 'devtools/client/shared/vendor/fluent-react.js')
-rw-r--r-- | devtools/client/shared/vendor/fluent-react.js | 686 |
1 files changed, 686 insertions, 0 deletions
diff --git a/devtools/client/shared/vendor/fluent-react.js b/devtools/client/shared/vendor/fluent-react.js new file mode 100644 index 0000000000..472c1247a4 --- /dev/null +++ b/devtools/client/shared/vendor/fluent-react.js @@ -0,0 +1,686 @@ +/* Copyright 2019 Mozilla Foundation and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +'use strict'; + +/* fluent-react@0.10.0 */ + +Object.defineProperty(exports, '__esModule', { value: true }); + +function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } + +const react = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = _interopDefault(require("resource://devtools/client/shared/vendor/react-prop-types.js")); + +/* + * Synchronously map an identifier or an array of identifiers to the best + * `FluentBundle` instance(s). + * + * @param {Iterable} iterable + * @param {string|Array<string>} ids + * @returns {FluentBundle|Array<FluentBundle>} + */ +function mapBundleSync(iterable, ids) { + if (!Array.isArray(ids)) { + return getBundleForId(iterable, ids); + } + + return ids.map( + id => getBundleForId(iterable, id) + ); +} + +/* + * Find the best `FluentBundle` with the translation for `id`. + */ +function getBundleForId(iterable, id) { + for (const bundle of iterable) { + if (bundle.hasMessage(id)) { + return bundle; + } + } + + return null; +} + +/* + * Asynchronously map an identifier or an array of identifiers to the best + * `FluentBundle` instance(s). + * + * @param {AsyncIterable} iterable + * @param {string|Array<string>} ids + * @returns {Promise<FluentBundle|Array<FluentBundle>>} + */ + +/* + * @module fluent-sequence + * @overview Manage ordered sequences of FluentBundles. + */ + +/* + * Base CachedIterable class. + */ +class CachedIterable extends Array { + /** + * Create a `CachedIterable` instance from an iterable or, if another + * instance of `CachedIterable` is passed, return it without any + * modifications. + * + * @param {Iterable} iterable + * @returns {CachedIterable} + */ + static from(iterable) { + if (iterable instanceof this) { + return iterable; + } + + return new this(iterable); + } +} + +/* + * CachedSyncIterable caches the elements yielded by an iterable. + * + * It can be used to iterate over an iterable many times without depleting the + * iterable. + */ +class CachedSyncIterable extends CachedIterable { + /** + * Create an `CachedSyncIterable` instance. + * + * @param {Iterable} iterable + * @returns {CachedSyncIterable} + */ + constructor(iterable) { + super(); + + if (Symbol.iterator in Object(iterable)) { + this.iterator = iterable[Symbol.iterator](); + } else { + throw new TypeError("Argument must implement the iteration protocol."); + } + } + + [Symbol.iterator]() { + const cached = this; + let cur = 0; + + return { + next() { + if (cached.length <= cur) { + cached.push(cached.iterator.next()); + } + return cached[cur++]; + } + }; + } + + /** + * This method allows user to consume the next element from the iterator + * into the cache. + * + * @param {number} count - number of elements to consume + */ + touchNext(count = 1) { + let idx = 0; + while (idx++ < count) { + const last = this[this.length - 1]; + if (last && last.done) { + break; + } + this.push(this.iterator.next()); + } + // Return the last cached {value, done} object to allow the calling + // code to decide if it needs to call touchNext again. + return this[this.length - 1]; + } +} + +/* + * `ReactLocalization` handles translation formatting and fallback. + * + * The current negotiated fallback chain of languages is stored in the + * `ReactLocalization` instance in form of an iterable of `FluentBundle` + * instances. This iterable is used to find the best existing translation for + * a given identifier. + * + * `Localized` components must subscribe to the changes of the + * `ReactLocalization`'s fallback chain. When the fallback chain changes (the + * `bundles` iterable is set anew), all subscribed compontent must relocalize. + * + * The `ReactLocalization` class instances are exposed to `Localized` elements + * via the `LocalizationProvider` component. + */ +class ReactLocalization { + constructor(bundles) { + this.bundles = CachedSyncIterable.from(bundles); + this.subs = new Set(); + } + + /* + * Subscribe a `Localized` component to changes of `bundles`. + */ + subscribe(comp) { + this.subs.add(comp); + } + + /* + * Unsubscribe a `Localized` component from `bundles` changes. + */ + unsubscribe(comp) { + this.subs.delete(comp); + } + + /* + * Set a new `bundles` iterable and trigger the retranslation. + */ + setBundles(bundles) { + this.bundles = CachedSyncIterable.from(bundles); + + // Update all subscribed Localized components. + this.subs.forEach(comp => comp.relocalize()); + } + + getBundle(id) { + return mapBundleSync(this.bundles, id); + } + + /* + * Find a translation by `id` and format it to a string using `args`. + */ + getString(id, args, fallback) { + const bundle = this.getBundle(id); + if (bundle) { + const msg = bundle.getMessage(id); + if (msg && msg.value) { + let errors = []; + let value = bundle.formatPattern(msg.value, args, errors); + for (let error of errors) { + this.reportError(error); + } + return value; + } + } + + return fallback || id; + } + + // XXX Control this via a prop passed to the LocalizationProvider. + // See https://github.com/projectfluent/fluent.js/issues/411. + reportError(error) { + /* global console */ + // eslint-disable-next-line no-console + console.warn(`[@fluent/react] ${error.name}: ${error.message}`); + } +} + +function isReactLocalization(props, propName) { + const prop = props[propName]; + + if (prop instanceof ReactLocalization) { + return null; + } + + return new Error( + `The ${propName} context field must be an instance of ReactLocalization.` + ); +} + +/* eslint-env browser */ + +let cachedParseMarkup; + +// We use a function creator to make the reference to `document` lazy. At the +// same time, it's eager enough to throw in <LocalizationProvider> as soon as +// it's first mounted which reduces the risk of this error making it to the +// runtime without developers noticing it in development. +function createParseMarkup() { + if (typeof(document) === "undefined") { + // We can't use <template> to sanitize translations. + throw new Error( + "`document` is undefined. Without it, translations cannot " + + "be safely sanitized. Consult the documentation at " + + "https://github.com/projectfluent/fluent.js/wiki/React-Overlays." + ); + } + + if (!cachedParseMarkup) { + const template = document.createElement("template"); + cachedParseMarkup = function parseMarkup(str) { + template.innerHTML = str; + return Array.from(template.content.childNodes); + }; + } + + return cachedParseMarkup; +} + +/* + * The Provider component for the `ReactLocalization` class. + * + * Exposes a `ReactLocalization` instance to all descendants via React's + * context feature. It makes translations available to all localizable + * elements in the descendant's render tree without the need to pass them + * explicitly. + * + * <LocalizationProvider bundles={…}> + * … + * </LocalizationProvider> + * + * The `LocalizationProvider` component takes one prop: `bundles`. It should + * be an iterable of `FluentBundle` instances in order of the user's + * preferred languages. The `FluentBundle` instances will be used by + * `ReactLocalization` to format translations. If a translation is missing in + * one instance, `ReactLocalization` will fall back to the next one. + */ +class LocalizationProvider extends react.Component { + constructor(props) { + super(props); + const {bundles, parseMarkup} = props; + + if (bundles === undefined) { + throw new Error("LocalizationProvider must receive the bundles prop."); + } + + if (!bundles[Symbol.iterator]) { + throw new Error("The bundles prop must be an iterable."); + } + + this.l10n = new ReactLocalization(bundles); + this.parseMarkup = parseMarkup || createParseMarkup(); + } + + getChildContext() { + return { + l10n: this.l10n, + parseMarkup: this.parseMarkup, + }; + } + + componentWillReceiveProps(next) { + const { bundles } = next; + + if (bundles !== this.props.bundles) { + this.l10n.setBundles(bundles); + } + } + + render() { + return react.Children.only(this.props.children); + } +} + +LocalizationProvider.childContextTypes = { + l10n: isReactLocalization, + parseMarkup: PropTypes.func, +}; + +LocalizationProvider.propTypes = { + children: PropTypes.element.isRequired, + bundles: isIterable, + parseMarkup: PropTypes.func, +}; + +function isIterable(props, propName, componentName) { + const prop = props[propName]; + + if (Symbol.iterator in Object(prop)) { + return null; + } + + return new Error( + `The ${propName} prop supplied to ${componentName} must be an iterable.` + ); +} + +function withLocalization(Inner) { + class WithLocalization extends react.Component { + componentDidMount() { + const { l10n } = this.context; + + if (l10n) { + l10n.subscribe(this); + } + } + + componentWillUnmount() { + const { l10n } = this.context; + + if (l10n) { + l10n.unsubscribe(this); + } + } + + /* + * Rerender this component in a new language. + */ + relocalize() { + // When the `ReactLocalization`'s fallback chain changes, update the + // component. + this.forceUpdate(); + } + + /* + * Find a translation by `id` and format it to a string using `args`. + */ + getString(id, args, fallback) { + const { l10n } = this.context; + + if (!l10n) { + return fallback || id; + } + + return l10n.getString(id, args, fallback); + } + + render() { + return react.createElement( + Inner, + Object.assign( + // getString needs to be re-bound on updates to trigger a re-render + { getString: (...args) => this.getString(...args) }, + this.props + ) + ); + } + } + + WithLocalization.displayName = `WithLocalization(${displayName(Inner)})`; + + WithLocalization.contextTypes = { + l10n: isReactLocalization + }; + + return WithLocalization; +} + +function displayName(component) { + return component.displayName || component.name || "Component"; +} + +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in this directory. + */ + +// For HTML, certain tags should omit their close tag. We keep a whitelist for +// those special-case tags. + +var omittedCloseTags = { + area: true, + base: true, + br: true, + col: true, + embed: true, + hr: true, + img: true, + input: true, + keygen: true, + link: true, + meta: true, + param: true, + source: true, + track: true, + wbr: true, + // NOTE: menuitem's close tag should be omitted, but that causes problems. +}; + +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in this directory. + */ + +// For HTML, certain tags cannot have children. This has the same purpose as +// `omittedCloseTags` except that `menuitem` should still have its closing tag. + +var voidElementTags = { + menuitem: true, + ...omittedCloseTags, +}; + +// Match the opening angle bracket (<) in HTML tags, and HTML entities like +// &, &, &. +const reMarkup = /<|&#?\w+;/; + +/* + * Prepare props passed to `Localized` for formatting. + */ +function toArguments(props) { + const args = {}; + const elems = {}; + + for (const [propname, propval] of Object.entries(props)) { + if (propname.startsWith("$")) { + const name = propname.substr(1); + args[name] = propval; + } else if (react.isValidElement(propval)) { + // We'll try to match localNames of elements found in the translation with + // names of elements passed as props. localNames are always lowercase. + const name = propname.toLowerCase(); + elems[name] = propval; + } + } + + return [args, elems]; +} + +/* + * The `Localized` class renders its child with translated props and children. + * + * <Localized id="hello-world"> + * <p>{'Hello, world!'}</p> + * </Localized> + * + * The `id` prop should be the unique identifier of the translation. Any + * attributes found in the translation will be applied to the wrapped element. + * + * Arguments to the translation can be passed as `$`-prefixed props on + * `Localized`. + * + * <Localized id="hello-world" $username={name}> + * <p>{'Hello, { $username }!'}</p> + * </Localized> + * + * It's recommended that the contents of the wrapped component be a string + * expression. The string will be used as the ultimate fallback if no + * translation is available. It also makes it easy to grep for strings in the + * source code. + */ +class Localized extends react.Component { + componentDidMount() { + const { l10n } = this.context; + + if (l10n) { + l10n.subscribe(this); + } + } + + componentWillUnmount() { + const { l10n } = this.context; + + if (l10n) { + l10n.unsubscribe(this); + } + } + + /* + * Rerender this component in a new language. + */ + relocalize() { + // When the `ReactLocalization`'s fallback chain changes, update the + // component. + this.forceUpdate(); + } + + render() { + const { l10n, parseMarkup } = this.context; + const { id, attrs, children: child = null } = this.props; + + // Validate that the child element isn't an array + if (Array.isArray(child)) { + throw new Error("<Localized/> expected to receive a single " + + "React node child"); + } + + if (!l10n) { + // Use the wrapped component as fallback. + return child; + } + + const bundle = l10n.getBundle(id); + + if (bundle === null) { + // Use the wrapped component as fallback. + return child; + } + + const msg = bundle.getMessage(id); + const [args, elems] = toArguments(this.props); + let errors = []; + + // Check if the child inside <Localized> is a valid element -- if not, then + // it's either null or a simple fallback string. No need to localize the + // attributes. + if (!react.isValidElement(child)) { + if (msg.value) { + // Replace the fallback string with the message value; + let value = bundle.formatPattern(msg.value, args, errors); + for (let error of errors) { + l10n.reportError(error); + } + return value; + } + + return child; + } + + let localizedProps; + + // The default is to forbid all message attributes. If the attrs prop exists + // on the Localized instance, only set message attributes which have been + // explicitly allowed by the developer. + if (attrs && msg.attributes) { + localizedProps = {}; + errors = []; + for (const [name, allowed] of Object.entries(attrs)) { + if (allowed && name in msg.attributes) { + localizedProps[name] = bundle.formatPattern( + msg.attributes[name], args, errors); + } + } + for (let error of errors) { + l10n.reportError(error); + } + } + + // If the wrapped component is a known void element, explicitly dismiss the + // message value and do not pass it to cloneElement in order to avoid the + // "void element tags must neither have `children` nor use + // `dangerouslySetInnerHTML`" error. + if (child.type in voidElementTags) { + return react.cloneElement(child, localizedProps); + } + + // If the message has a null value, we're only interested in its attributes. + // Do not pass the null value to cloneElement as it would nuke all children + // of the wrapped component. + if (msg.value === null) { + return react.cloneElement(child, localizedProps); + } + + errors = []; + const messageValue = bundle.formatPattern(msg.value, args, errors); + for (let error of errors) { + l10n.reportError(error); + } + + // If the message value doesn't contain any markup nor any HTML entities, + // insert it as the only child of the wrapped component. + if (!reMarkup.test(messageValue)) { + return react.cloneElement(child, localizedProps, messageValue); + } + + // If the message contains markup, parse it and try to match the children + // found in the translation with the props passed to this Localized. + const translationNodes = parseMarkup(messageValue); + const translatedChildren = translationNodes.map(childNode => { + if (childNode.nodeType === childNode.TEXT_NODE) { + return childNode.textContent; + } + + // If the child is not expected just take its textContent. + if (!elems.hasOwnProperty(childNode.localName)) { + return childNode.textContent; + } + + const sourceChild = elems[childNode.localName]; + + // If the element passed as a prop to <Localized> is a known void element, + // explicitly dismiss any textContent which might have accidentally been + // defined in the translation to prevent the "void element tags must not + // have children" error. + if (sourceChild.type in voidElementTags) { + return sourceChild; + } + + // TODO Protect contents of elements wrapped in <Localized> + // https://github.com/projectfluent/fluent.js/issues/184 + // TODO Control localizable attributes on elements passed as props + // https://github.com/projectfluent/fluent.js/issues/185 + return react.cloneElement(sourceChild, null, childNode.textContent); + }); + + return react.cloneElement(child, localizedProps, ...translatedChildren); + } +} + +Localized.contextTypes = { + l10n: isReactLocalization, + parseMarkup: PropTypes.func, +}; + +Localized.propTypes = { + children: PropTypes.node +}; + +/* + * @module fluent-react + * @overview + * + + * `fluent-react` provides React bindings for Fluent. It takes advantage of + * React's Components system and the virtual DOM. Translations are exposed to + * components via the provider pattern. + * + * <LocalizationProvider bundles={…}> + * <Localized id="hello-world"> + * <p>{'Hello, world!'}</p> + * </Localized> + * </LocalizationProvider> + * + * Consult the documentation of the `LocalizationProvider` and the `Localized` + * components for more information. + */ + +exports.LocalizationProvider = LocalizationProvider; +exports.Localized = Localized; +exports.ReactLocalization = ReactLocalization; +exports.isReactLocalization = isReactLocalization; +exports.withLocalization = withLocalization; |