Skip to content

Commit

Permalink
Add getElement method to ReactLocalization (#595)
Browse files Browse the repository at this point in the history
  • Loading branch information
Vinnl committed Oct 27, 2022
1 parent 8c2eae4 commit e99614e
Show file tree
Hide file tree
Showing 5 changed files with 232 additions and 182 deletions.
159 changes: 157 additions & 2 deletions fluent-react/src/localization.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { FluentBundle, FluentVariable } from "@fluent/bundle";
import { mapBundleSync } from "@fluent/sequence";
import {
Fragment,
ReactElement,
createElement,
isValidElement,
cloneElement,
} from "react";
import { CachedSyncIterable } from "cached-iterable";
import { createParseMarkup, MarkupParser } from "./markup.js";
import voidElementTags from "../vendor/voidElementTags.js";

// Match the opening angle bracket (<) in HTML tags, and HTML entities like
// &amp;, &#0038;, &#x0026;.
const reMarkup = /<|&#?\w+;/;

/*
* `ReactLocalization` handles translation formatting and fallback.
Expand Down Expand Up @@ -38,15 +50,15 @@ export class ReactLocalization {

getString(
id: string,
args?: Record<string, FluentVariable> | null,
vars?: Record<string, FluentVariable> | null,
fallback?: string
): string {
const bundle = this.getBundle(id);
if (bundle) {
const msg = bundle.getMessage(id);
if (msg && msg.value) {
let errors: Array<Error> = [];
let value = bundle.formatPattern(msg.value, args, errors);
let value = bundle.formatPattern(msg.value, vars, errors);
for (let error of errors) {
this.reportError(error);
}
Expand All @@ -73,6 +85,149 @@ export class ReactLocalization {
return fallback || id;
}

getElement(
sourceElement: ReactElement,
id: string,
args: {
vars?: Record<string, FluentVariable>;
elems?: Record<string, ReactElement>;
attrs?: Record<string, boolean>;
} = {}
): ReactElement {
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 createElement(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: Array<Error> = [];

let localizedProps: Record<string, string> | undefined;
// 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 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 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 cloneElement(sourceElement, localizedProps, messageValue);
}

let elemsLower: Map<string, ReactElement>;
if (args.elems) {
elemsLower = new Map();
for (let [name, elem] of Object.entries(args.elems)) {
// Ignore elems which are not valid React elements.
if (!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?.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 cloneElement(sourceChild, undefined, textContent);
}
);

return cloneElement(sourceElement, localizedProps, ...translatedChildren);
}

// XXX Control this via a prop passed to the LocalizationProvider.
// See https://github.com/projectfluent/fluent.js/issues/411.
reportError(error: Error): void {
Expand Down

0 comments on commit e99614e

Please sign in to comment.