Skip to content

danikaze/react-markup-formatter

Repository files navigation

react-markup-formatter

Transform HTML-like strings into fully rendered React components.

npm install react-markup-formatter

For the eagers developer wanting to use this library, just check the Usage section.

Motivation

When building a React-based application usually every content is rendered inside React... but there are cases where external HTML or text-based content needs to be supported:

  • Content coming from external teams (i.e. business managed)
  • Translated strings supporting format
  • etc.

For those cases you might be considering to use dangerouslySetInnerHTML, but it's not a very pretty nor secure practice and probably you deserve better.

Usage

Example

This library doesn't provide a React component per-se, but a component factory. This means it is intented to be used statically (not inside any rendering function) to create a component, and then use it in your application.

  1. Create your component using the factory function:
// your-app/components/text-format.tsx
import { createTextFormat } from 'react-markup-formatter';

export const TextFormat = createTextFormat({
  tagHandlers: {
    // your configuration here
    // See `Configuration and Options` section
    // or `Example of Formatting Handlers`
  },
});
  1. Use your TextFormat component from anywhere in your app:
// your-app/components/fancy-modal.tsx
import { FC } from 'react';
import { TextFormat } from './text-format';

export const FancyModal: FC<{ title: string; text: string }> = ({
  title,
  text,
}) => {
  return (
    <div className="modal-root">
      <h3>{title}</h3>
      <div className="modal-body">
        <TextFormat>{text}</TextFormat>
      </div>
      <button>Close</button>
    </div>
  );
};

This basically just stress the fact that you should not call createTextFormat while rendering your component (or at least it should be memoized), as it creates a new function every time and can be done statically.

Types

FormatTextHandler

Function called when there's plain text to be formatted. Expected to return the element to render (or null to remove it).

Note that even if it just render plain texts, it stills need to specify key={index} as it might be called as part of another tag content together with other children, so React doesn't gives a Warning.

type FormatTextHandler = (index: number, text?: string) => JSX.Element | null;

FormatTagHandler

Function called when there's a tag to be formatted. Expected to return the element to render (or null to remove it), with key={index} to avoid the warning: each child in a list should have a unique "key" prop. by React.

type FormatTagHandler = (index: number, tag: TagData) => JSX.Element | null;

TagData

Data provided to render tags by handlers defined by tagHandlers or defaultTagHandler.

interface TagData {
  name: string;
  children: (JSX.Element | null)[];
  attrs: Record<string, string>;
}

Configuration and Options

When calling createTextFormat you need to specify how the formatting should be done:

Option Type Default Description
textHandler FormatTextHandler undefined Defines how plain text should be formatted. This includes top-level text as well as plain text inside each tag. When not defined, text is just rendered as text by (index, text) => <Fragment key={index}>{text}</Fragment>
tagHandlers Record<string, FormatTagHandler> undefined Mapping between the tags and the handlers to format them. Tag names are case-insensitive.
defaultTagHandler FormatTagHandler undefined When provided it allows for custom handling of the tags not defined in tagHandlers.
keepUnknownTags Boolean false When true it renders the received text for a tag not defined in tagHandlers. Only applies if defaultTagHandler is undefined. When this is false or undefined and defaultTagHandler is undefined, unregistered tags are removed from the formatted text.
hooks () => any[] undefined If the handlers require calling hooks, their calls need to be defined here to avoid altering the hooks invariant rules of React. See more about the usage on the examples below.

Example of Formatting Handlers

Every handler code shown in the examples is to be used in the tagHandlers option when calling createTextFormat, but not shown to keep examples code short.

const tagHandlers = {
  tag1: handler1,
  tag2: handler2,
  // ...
  tagN: handlerN,
};

const TextFormat = createTextFormat({ tagHandlers });

Support for bold text in different flavors

This defines a basic tag handler that allows using <b> in the provided text, and it renders the same <b> HTML element:

const tagHandlers = {
  b: (index, { children }) => <b key={index}>{children}</b>,
};

This defines a basic tag handler that allows using <strong> in the provided text, and it renders the equivalent HTML element:

const tagHandlers = {
  b: (index, { children }) => <b key={index}>{children}</b>,
  strong: (index, { children }) => <strong key={index}>{children}</strong>,
};

This would allow the same but standardize the usage to use <strong> in both cases, plus a custom <bold> tag that uses custom styles:

const tagHandlers = {
  b: (index, { children }) => <strong key={index}>{children}</strong>,
  strong: (index, { children }) => <strong key={index}>{children}</strong>,
  bold: (index, { children }) => (
    <span className={styles.boldText}>{children}</span>
  ),
};

This allows using the href tag attributes in a custom <link> tag (would work the same as <a>, but ignoring any attribute that is not href):

const tagHandlers = {
  link: (index, { children, attrs }) => (
    <a key={index} href={attrs.href}>
      {children}
    </a>
  ),
};

This allows self-closing tags to provide user interaction with a callback provided by a React.Context (i.e. it would be used in the text as <customButton /> or <customButton label="Text" />)

const tagHandlers = {
  customButton: (index, { attrs }) => {
    const context = useContext(YourContext);
    return (
      <button key={index} onClick={context.callback}>
        {attrs.label || 'Click me!'}
      </button>
    );
  },
};

Accessing further React hooks from the handlers

Calling a hook from a tag handler is forbidden as the rendering result is memoized which will result in a variable number of hooks call between different renders, triggering an invariant error.

If further calls to other hooks are needed in each render, it can be achieved by the hooks option when creating the text formater.

Let's suppose that a value returned by some hook is required to be displayed. In this example the value will be a simple counter, but it can be a text translation based on some variable tag attribute or any other complex case that could appear.

// the hooks function will be called in every render to maintain a constant
// number of hooks calls
const hooks = () => [useCounter()];
const tagHandlers = {
  // the 3rd parameter of every tag handler will be populated with the result
  // of the provided hooks function
  counter: (index, tagData, [counter]) => <span key={index}>{counter}</span>,
};

const TextFormat = createTextFormat({ tagHandlers, hooks });

Note that the list of results provided by hooks() will be used in the dependency array when memoizing the formatting results, so avoid returning new objects every time if the values are the same, as it would result on a re-calculation of the resulting JSX elements and therefore will slightly affect the performance of your application (even if it's a minimum impact).

Use Cases

Support a subset of tags

Let's consider that only a certain group of HTML basic tags (<b>, <i>, <a>) needs to be supported, and also control how they are rendered or even provide some custom one like <s> for strike or <u> for underline.

Those supported tags can be explicitly defined with:

const config: CreateTextFormatConfig = {
  /*
   * Note how every returned element requires to specify a key
   * This is because they might be have siblings so it's required to avoid
   * the warning triggered by React
   */
  tagHandlers: {
    b: (index, { children }) => <strong key={index}>{children}</strong>,
    i: (index, { children }) => <em key={index}>{children}</em>,
    // Note how other attributes apart from href are ignored
    a: (index, { children, attr: { href } }) => <a href={href}>{children}</a>,
    s: (index, { children }) => (
      <span style={{ fontDecoration: 'strike' }}>{children}</span>
    ),
    u: (index, { children }) => (
      <span style={{ fontDecoration: 'underline' }}>{children}</span>
    ),
  },
};

These are very basic definitions, but they allow controlling how they are rendered, their styles, etc.

While the raw formatted text only specifies the tags per se, this renderer can handle their class names, styles, and other properties if needed.

Provide extended custom tags

Consider a case where you want to render some especific component with custom behavior just by using a simple tag. Like a pre-defined link, or a button to close a modal, etc.

You can do it easily by providing your own tag handler:

const config: CreateTextFormatConfig = {
  tagHandlers: {
    ProfilePage: (index, children) => (
      <a key={index} href="/path/to/profile">
        {children ?? '[Profile Page]'}
      </a>
    ),
  },
};

const FormattedText = createTextFormat(config);

Now you can have a formatted text where you can inject your component with pre-defined behavior like this:

// the text probably comes from API or somewhere else
const text =
  'If you feel like changing your settings, just visit your <ProfilePage /> and play with the values.';

return <FormattedText>{text}</FormattedText>;

i18n formatted localizations

While most of the translations in a React application can be easily done with simple translation files like:

// en.json
{ "upload": "Upload" }

// ja.json
{ "upload": "アップロード" }

// es.json
{ "upload": "Subir" }

and then just render the text in your component using any of the available i18n libraries...

// my-component.tsx
export const MyComponent = () => {
  const { t } = useTranslation();
  return <button onClick={upload}>{t('upload')}</button>;
};

Some times it might need to provide translations mixed with formatting where following a basic approach might require splitting the text in multiple strings like displaying keywords within a text in different colors. And not even then it's a trivial solution, as they could appear anywhere in the text depending of the structure of each language.

This is a perfect use case for react-markup-formatter, as those special keywords can defined via tags, and still be provided with plain text in json files. Just provide a tag handler for the special keywords to be rendered with the desired style and then they can be placed anywhere inside the text and translated with any word without having to think on the details.

This example shows how to do exactly this with a custom <keyword> tag:

// en.json
{
  "myTextWithKeywords": "This is some text with special <keyword>keywords</keyword> that could appear <keyword>anywhere</keyword> in the document, but thanks to <keyword>react-markup-formatter</keyword> formatting them is now a trivial problem."
}
// ja.json
{
  "myTextWithKeywords": "これは特別な<keyword>キーワード</keyword>の入ってるテキストです。この<keyword>キーワードー</keyword>が文書のどこでも現れることができますが、<keyword>react-markup-formatter</keyword>のお陰様で、問題がありません。"
}
// my-component.tsx
const TextFormat = createTextFormat({
  tagHandlers: {
    keyword: (index, { children }) => <span className={styles.keyword}>{children}</span>;
  }
});


export const MyComponent = () => {
  const { t } = useTranslation();
  return <TextFormat>{t('myTextWithKeywords')}</TextFormat>;
}

Support Full HTML

As described in the createTextFormat function API, an optional defaultTagHandler setting is available to catch every received tag that was not defined in tagHandlers.

By taking advantage of this option and leaving tagHandlers undefined, a behavior similar to the browser parsing can be provided to transform HTML code into React components with something similar to this example:

const htmlHandler: CreateTextFormatConfig['defaultTagHandler'] = (
  index,
  { name, children, attrs }
) => {
  // React requires CSS properties to be an object with camelCased properties
  // instead of the string provided in HTML for the "style" tag attribute
  if (attrs.style) {
    // convert from a string like `"font-weight: bold; color: red"`
    // to an object like `{ fontWeight: 'bold', color: 'red' }`
    const styles = attrs.style.split(';');
    attrs.style = styles.reduce((acc, str) => {
      const parts = str.trim().split(':');
      if (!parts[0]) return acc;

      const key = parts[0].replace(/-(.)/g, (match, char) =>
        char.toUpperCase()
      );
      acc[key] = parts[1].trim();
      return acc;
    }, {});
  }
  // a key needs to be provided always
  attrs.key = index.toString();

  // React expects native HTML tags to be in lowercase while components
  // are specified in PascalCase (and the tag `name` is provide in UPPERCASE)
  const tagName = name.toLowerCase();

  return createElement(tagName, attrs, children);
};

// create our text parser component
const HtmlToReact = createTextFormat({ defaultTagHandler: htmlHandler });

// and now we can use it like...
const text =
  '<div>This converts <em>native</em> <b>HTML</b> into <b>React</b></div>';
<HtmlToReact>{text}</HtmlToReact>;

Note that this is just a simple example and even if it takes care of the styles transformations, probably are other cases that needs to be handled (maybe HtmlToReact can be provided in a future version as an exported component by this library, PRs are welcomed!)

Changelog

v1.1.0

  • Added the hooks option in CreateTextFormatConfig
    • TypeScript (via generics) will detect if the 3rd parameter with the returned values is available in the FormatTextHandler and FormatTagHandler callbacks based on the provided options when calling createTextFormat.

v1.0.0

  • First release.

About

Transform HTML-like strings into fully rendered React components

Resources

Stars

Watchers

Forks

Packages

No packages published