UNPKG

@fluent/react

Version:
351 lines (334 loc) 15.2 kB
/** @fluent/react@0.15.2 */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('@fluent/sequence'), require('react'), require('cached-iterable')) : typeof define === 'function' && define.amd ? define('@fluent/react', ['exports', '@fluent/sequence', 'react', 'cached-iterable'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.FluentReact = {}, global.FluentSequence, global.React, global.CachedIterable)); })(this, (function (exports, sequence, React, cachedIterable) { 'use strict'; 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; } /** * 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 // &amp;, &#0038;, &#x0026;. const reMarkup = /<|&#?\w+;/; const defaultReportError = (error) => { /* global console */ // eslint-disable-next-line no-console console.warn(`[@fluent/react] ${error.name}: ${error.message}`); }; /** * `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. * * The `ReactLocalization` class instances are exposed to `Localized` elements * via the `LocalizationProvider` component. */ class ReactLocalization { constructor(bundles, parseMarkup = createParseMarkup(), reportError) { this.bundles = cachedIterable.CachedSyncIterable.from(bundles); this.parseMarkup = parseMarkup; this.reportError = reportError || defaultReportError; } getBundle(id) { return sequence.mapBundleSync(this.bundles, id); } areBundlesEmpty() { // Create an iterator and only peek at the first value to see if it contains // anything. return Boolean(this.bundles[Symbol.iterator]().next().done); } getString(id, vars, 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, vars, errors); for (let error of errors) { this.reportError(error); } return value; } } else { if (this.areBundlesEmpty()) { this.reportError(new Error("Attempting to get a string when no localization bundles are " + "present.")); } else { this.reportError(new Error(`The id "${id}" did not match any messages in the localization ` + "bundles.")); } } return fallback || id; } getElement(sourceElement, id, args = {}) { const bundle = this.getBundle(id); if (bundle === null) { if (!id) { this.reportError(new Error("No string id was provided when localizing a component.")); } else if (this.areBundlesEmpty()) { this.reportError(new Error("Attempting to get a localized element when no localization bundles are " + "present.")); } else { this.reportError(new Error(`The id "${id}" did not match any messages in the localization ` + "bundles.")); } return React.createElement(React.Fragment, null, sourceElement); } // this.getBundle makes the bundle.hasMessage check which ensures that // bundle.getMessage returns an existing message. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const msg = bundle.getMessage(id); let errors = []; 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 (args.attrs && msg.attributes) { localizedProps = {}; errors = []; for (const [name, allowed] of Object.entries(args.attrs)) { if (allowed && name in msg.attributes) { localizedProps[name] = bundle.formatPattern(msg.attributes[name], args.vars, errors); } } for (let error of errors) { this.reportError(error); } } // If the component to render 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 (typeof sourceElement.type === "string" && sourceElement.type in voidElementTags) { return React.cloneElement(sourceElement, 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(sourceElement, localizedProps); } errors = []; const messageValue = bundle.formatPattern(msg.value, args.vars, errors); for (let error of errors) { this.reportError(error); } // If the message value doesn't contain any markup nor any HTML entities, // insert it as the only child of the component to render. if (!reMarkup.test(messageValue) || this.parseMarkup === null) { return React.cloneElement(sourceElement, localizedProps, messageValue); } let elemsLower; if (args.elems) { elemsLower = new Map(); for (let [name, elem] of Object.entries(args.elems)) { // Ignore elems which are not valid React elements. if (!React.isValidElement(elem)) { continue; } elemsLower.set(name.toLowerCase(), elem); } } // If the message contains markup, parse it and try to match the children // found in the translation with the args passed to this function. const translationNodes = this.parseMarkup(messageValue); const translatedChildren = translationNodes.map(({ nodeName, textContent }) => { if (nodeName === "#text") { return textContent; } const childName = nodeName.toLowerCase(); const sourceChild = elemsLower === null || elemsLower === void 0 ? void 0 : elemsLower.get(childName); // If the child is not expected just take its textContent. if (!sourceChild) { return textContent; } // If the element passed in the elems prop 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 (typeof sourceChild.type === "string" && 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, undefined, textContent); }); return React.cloneElement(sourceElement, localizedProps, ...translatedChildren); } } let FluentContext = React.createContext(null); /** * 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` takes an instance of `ReactLocalization` in the * `l10n` prop. This instance will be made available to `Localized` components * under the provider. * * @example * ```jsx * <LocalizationProvider l10n={…}> * … * </LocalizationProvider> * ``` */ function LocalizationProvider(props) { return React.createElement(FluentContext.Provider, { value: props.l10n }, props.children); } function withLocalization(Inner) { function WithLocalization(props) { const l10n = React.useContext(FluentContext); if (!l10n) { throw new Error("withLocalization was used without wrapping it in a " + "<LocalizationProvider />."); } // Re-bind getString to trigger a re-render of Inner. const getString = l10n.getString.bind(l10n); return React.createElement(Inner, { getString, ...props }); } WithLocalization.displayName = `WithLocalization(${displayName(Inner)})`; return WithLocalization; } function displayName(component) { return component.displayName || component.name || "Component"; } /** * The `Localized` class renders its child with translated props and children. * * 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`. * * 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. * * @example * ```jsx * <Localized id="hello-world"> * <p>{'Hello, world!'}</p> * </Localized> * * <Localized id="hello-world" $username={name}> * <p>{'Hello, { $username }!'}</p> * </Localized> * ``` */ function Localized(props) { const { id, attrs, vars, elems, children } = props; const l10n = React.useContext(FluentContext); if (!l10n) { throw new Error("The <Localized /> component was not properly wrapped in a <LocalizationProvider />."); } let source; if (Array.isArray(children)) { if (children.length > 1) { throw new Error("Expected to receive a single React element to localize."); } // If it's an array with zero or one element, we can directly get the first one. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment source = children[0]; } else { source = children !== null && children !== void 0 ? children : null; } // Check if the component to render is a valid element -- if not, then // it's either null or a simple fallback string. No need to localize the // attributes or replace. if (!React.isValidElement(source)) { const fallback = typeof source === "string" ? source : undefined; const string = l10n.getString(id, vars, fallback); return React.createElement(React.Fragment, null, string); } return l10n.getElement(source, id, { attrs, vars, elems }); } const useLocalization = () => { const l10n = React.useContext(FluentContext); if (!l10n) { throw new Error("useLocalization was used without wrapping it in a " + "<LocalizationProvider />."); } return { l10n }; }; exports.LocalizationProvider = LocalizationProvider; exports.Localized = Localized; exports.ReactLocalization = ReactLocalization; exports.useLocalization = useLocalization; exports.withLocalization = withLocalization; }));