How to render JSX to whatever you want with a custom JSX Renderer

afl_ext

Adrian

Posted on December 4, 2023

How to render JSX to whatever you want with a custom JSX Renderer

Most of the time, you don't need to write your own JSX renderer. But what if you needed to? Well, this guide will show you how to do it.

It's actually surprisingly simple! So let's dive in.

TLDR: If you want to see the code straight away, here is the repository: https://github.com/adrian-afl/render-jsx-reupload

What will be needed

You will need TypeScript to follow this guide, and that's it, no external libraries, just your code.

Preparation

Create a project - I mean, with package.json - and install TypeScript.

Then create a tsconfig file, configure it however you want, but in compilerOptions include those two values:

"jsx": "react-jsx",
"jsxImportSource": "./src/app",
Enter fullscreen mode Exit fullscreen mode

jsx set to react-jsx tells the compiler that we want JSX, but we will be using a different implementation than React, in fact, we will write our own. jsxImportSource set to ./src/app tells the compiler that this is the place where to find the necessary exported stuff that makes handling JSX possible. Beware, this is a path, and TypeScript will append to it a postfix /jsx-runtime and will import that file.

Once this is done, let's go to the implementation.

The JSX namespace

Create a file ./src/app/jsx-runtime.ts. This is the file that TypeScript will actually load to do the JSX stuff.

TypeScript requires you to export very specific things from this file. Those are:

  • JSX namespace
    • IntrinsicElements type - an object type, with keys being the allowed tags, and values being the type of attributes for tags
    • Element type - can be absolutely anything, defines the type that is the result of the JSX node rendering
  • jsx function - a function which needs a specific arguments and return value type, this is the function that is called by the runtime when rendering JSX nodes.
  • jsxs and jsxDEV which for now can be aliased to point to the same function as jsx

This is also the place where you can limit what tags are allowed and what attributes are allowed. To limit that:

  • To limit what tags are allowed, narrow the type of IntrinsicElements key type.
  • To limit what attributes are allowed, narrow the type of IntrinsicElements value type

This limiting is usually done by making IntrinsicElements an interface looking like that:

export interface IntrinsicElements {
  a: AnhorAttributes;
  div: DivAttributes;
  span: SpanAttibutes;
  ... and so on
}
Enter fullscreen mode Exit fullscreen mode

And the attribute types are also interfaces, for example

interface AnhorAttributes {
  href: string;
  target?: string;
}
Enter fullscreen mode Exit fullscreen mode

I just made it in a way that all tags are allowed - by setting the IntrinsicElements key type to a string, and all attributes are allowed, by setting the IntrinsicElements value type to Record<string, JSXNode | undefined>.

Example in this article renders the JSX nodes to objects with only one field, a string containing the rendered HTML.

Here's how it looks like in this file:

// Set the attributes to allow any keys and very permissive values
export type HTMLAttributes = Record<string, JSXNode | undefined> &
        JSXChildren;

namespace JSX {
  // Allow any HTML tag
  export type IntrinsicElements = Record<string, HTMLAttributes>;

  // Declare the shape of JSX rendering result
  // This is required so the return types of components can be inferred
  export type Element = RenderedNode;
}

// Export the main namespace
export { JSX };

// Export factories
export const jsx = renderJSX;
export const jsxs = renderJSX;
export const jsxDEV = renderJSX;
Enter fullscreen mode Exit fullscreen mode

You can see there are names that are not yet defined, RenderedNode, JSXNode and JSXChildren. Let's explain what they are.

Types

RenderedNode is the result shape of the rendering, jsx function returns values that are of this type. I decided to make it an object, actually a class, because if I went with just a string, I wouldn't be able to do proper escaping of the HTML. This is because I would not be able to differentiate between rendered HTML and a string literal. Anyway, here's how it looks like:

export class RenderedNode {
  public constructor(public readonly string: string) {}
}
Enter fullscreen mode Exit fullscreen mode

Now, onto JSXChildren. It defines how a children property looks like. This is the thing that defines what is inside the JSX tags, for example in this JSX:

<div>
    Hello
</div>
Enter fullscreen mode Exit fullscreen mode

the string Hello is the children prop.

export interface JSXChildren {
    children?: JSXNode | JSXNode[] | undefined;
}
Enter fullscreen mode Exit fullscreen mode

And now, the big one. JSXNode defines the values inside JSX attributes and JSX children. I made it pretty permissive about what can be rendered. You can define separate types for values available for attributes and separate for children, I used the same type for both, which allowed me to use the same serializer function.

export type JSXNode =
    | RenderedNode
    | RawContentNode
    | (() => JSXNode)
    | boolean
    | number
    | bigint
    | string
    | null
    | undefined;
Enter fullscreen mode Exit fullscreen mode

While we are on types, let's define 2 additional types that will come in handy soon:

interface RawContentNode {
    htmlContent: string;
}

export type FunctionComponent = (
    props: Record<string, unknown>
) => RenderedNode;
Enter fullscreen mode Exit fullscreen mode

RawContentNode is a special object that if passed as a value will be rendered to a string but without any escaping. Handy for CSS and style tags, for example. Obviously we need to handle it, and this is not something standard, I just thought it's necessary, for example to handle <script> and <style> tags, and decided this is a cool way to specify it.

FunctionComponent is a type for a function component, will be used during the rendering.

Rendering

The jsx function and its friends are mapped to the renderJSX function. Here's how it looks like:

export function renderJSX(
  tag: string | FunctionComponent | undefined,
  props: JSX.HTMLAttributes,
  _key?: string
): JSX.Element {
  if (typeof tag === "function") {
    // handling Function Components
    return tag(props);
  } else if (tag === undefined) {
    // handling <></>
    return new RenderedNode(renderChildren(props));
  } else {
    // handling plain HTML codes
    return new RenderedNode(
      renderTag(tag, renderAttributes(props), renderChildren(props))
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Not that complicated, in renderJSX 3 execution paths are possible:

  1. If tag is a function, we got a function component to handle. It's already a function, and we got the props that should be passed to it, so just call it with the props and return whatever it returns.
  2. If tag is undefined, we have a <></> fragment. It cannot take props, and can only have one or more children. Rendering it boils down to rendering every child inside, we will see soon how it's done.
  3. If tag is a string, it's a plain HTML element. Render it using the renderTag method that I will describe below.

We see that we need 3 helper functions so far, those will again have helper functions, but bear with me.

function renderTag(tag: string, attributes: string, children: string): string {
  const tagWithAttributes = [tag, attributes].join(" ").trim();
  if (children.length !== 0) {
    // render open and close tags
    return `<${tagWithAttributes}>${children}</${tag}>`;
  } else {
    // render only one self-closing tag
    return `<${tagWithAttributes}/>`;
  }
}
Enter fullscreen mode Exit fullscreen mode

This renderTag function is straightforward, renders a plain HTML element to a string, using the parameters passed.

function renderChildren(attributes: JSX.HTMLAttributes): string {
  const children = attributes.children;
  if (!children) {
    return "";
  }
  const childrenArray = !Array.isArray(children) ? [children] : children;
  return childrenArray.map((c) => serialize(c, escapeHTML)).join("");
}
Enter fullscreen mode Exit fullscreen mode

renderChildren functions gets a special attribute named children from the props object that jsx got. This attribute, if present, holds whatever is between the JSX tags, like in the example before, <div>Hello<div> would contain Hello as children. This might be just one element, then it's not an array, or an array, then it comes in as an array. To handle it easier, transform the value to always be an array, then serialize all elements to strings and join them together.

function renderAttributes(attributes: JSX.HTMLAttributes): string {
  return Object.entries(attributes)
    .filter((prop) => prop[0] !== "children")
    .map((prop) => {
      const value = serialize(prop[1], escapeProp);
      return `${prop[0]}="${value}"`;
    })
    .join(" ");
}
Enter fullscreen mode Exit fullscreen mode

This renderAttributes is responsible for rendering the HTML attributes string that will ultimately make its way into an HTML tag. First, filter out the special children property, it was handled with renderChildren. Then with the remaining elements, serialize the value to a string and render the attribute string. Finally, join the resulting strings, but this time with a space, because we need a space between the attributes in final HTML string.

As you can see, there are some other helper functions. First of all we need the serialize function, its helper type and helper error, here it is:

export class SerializationError extends Error {
  public constructor(public readonly invalidValue: unknown) {
    super("Invalid value");
  }
}

interface RawContentNodeTest {
  htmlContent?: string | undefined;
}

export function serialize(
  value: JSXNode,
  escaper: (value: string) => string
): string {
  // Null and undefined handling
  if (value === null || value === undefined) {
    return "";
  }
  // String node handling
  if (typeof value === "string") {
    return escaper(value);
  }
  // Number node handling
  if (typeof value === "number") {
    return value.toString();
  }
  if (typeof value === "bigint") {
    return value.toString();
  }
  // Boolean node handling
  if (typeof value === "boolean") {
    return value ? "true" : "false";
  }
  // Function node handling
  if (typeof value === "function") {
    return serialize(value(), escaper);
  }
  // RenderedNode node handling
  if (value instanceof RenderedNode) {
    return value.string;
  }
  // Dangerous string handling
  if (
    typeof value === "object" &&
    typeof (value as RawContentNodeTest).htmlContent === "string"
  ) {
    return value.htmlContent;
  }

  throw new SerializationError(value);
}
Enter fullscreen mode Exit fullscreen mode

The error is only for semantics, I like to make custom errors for various occasions. The RawContentNodeTest is a helper type to test if the value coming in is the special one that should be rendered without escaping special characters.

You can also notice that the function takes a second parameter that will do the escaping. It's a simple function signature that takes a string and returns a string. I decided to make the serializer be the same for HTML content and attributes, but both require slightly different escaping, that's why the second parameter is needed.

So the following happen in this function:

  1. If the value is null or undefined, return an empty string
  2. If the value is a string, escape special characters and return it
  3. If value is a number, bigint or boolean, convert it to string and return it
  4. If value is a function, call it, and pass the result via serialize again, then return it
  5. If value is already a RenderedNode, extract the content of it and return it
  6. If value is of that special RawContentNode type, which is checked by checking that value is an object and then the existence and type of value under the htmlContent, then return it as is, without escaping
  7. Otherwise, throw and error

What is left to make this implementation complete are the functions that escape special characters inside HTML content and HTML attributes. Those are very simple, look like that:

export function escapeProp(value: string): string {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll('"', "&quot;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll("\n", "&#10;")
    .trim();
}

export function escapeHTML(value: string): string {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll("\n", "<br/>")
    .trim();
}
Enter fullscreen mode Exit fullscreen mode

Those just replace some stuff inside the strings and return them.

Usage

And here we have it! It will work from now automatically without any special imports or libraries. Just this TypeScript code.

To use it, for example, create a Test.tsx file and put inside:

const ComponentA = (): RenderedNode => <div>Hello</div>;

const ComponentB = (): RenderedNode => (
  <span>
    <ComponentA />
  </span>
);

const rendered = <ComponentB />;

console.log(rendered);
Enter fullscreen mode Exit fullscreen mode

Running this code you will see this printed out:

<div><span>Hello</span></div>
Enter fullscreen mode Exit fullscreen mode

Why would anyone want to do that?

I know preact to string exists, but it is not a fit for some usages because it still assumes you are targeting real HTML. For example, only HTML tags are allowed, and the values and names of attributes are type-checked to be correct HTML. It's not desired sometimes. With this code you don't need to worry about it, you can render the JSX to whatever you want.

I personally need this for an upcoming terminal based UI library that will use JSX to define its content, but that's a story
for another time :)

Thanks for reading, and happy rendering!

Here you can find the code from this article: https://github.com/adrian-afl/render-jsx-reupload

💖 💪 🙅 🚩
afl_ext
Adrian

Posted on December 4, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related