@fluent/react
Version:
Fluent bindings for React
351 lines (334 loc) • 15.2 kB
JavaScript
/** @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
// &, &, &.
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;
}));