How to render JSX to whatever you want with a custom JSX Renderer
Tags: jsx, render, typescript, tsx
Published: 2023-12-04 18:21:02

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:
```json
"jsx": "react-jsx",
"jsxImportSource": "./src/app",
```

`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:
```ts
export interface IntrinsicElements {
  a: AnhorAttributes;
  div: DivAttributes;
  span: SpanAttibutes;
  ... and so on
}
```

And the attribute types are also interfaces, for example
```ts
interface AnhorAttributes {
  href: string;
  target?: string;
}
```

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:

```ts
// 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;
```

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:

```ts
export class RenderedNode {
  public constructor(public readonly string: string) {}
}
```

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: 
```jsx
<div>
    Hello
</div>
```
the string `Hello` is the children prop.

```ts
export interface JSXChildren {
    children?: JSXNode | JSXNode[] | undefined;
}
```

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.

```ts
export type JSXNode =
    | RenderedNode
    | RawContentNode
    | (() => JSXNode)
    | boolean
    | number
    | bigint
    | string
    | null
    | undefined;
```

While we are on types, let's define 2 additional types that will come in handy soon:
```ts
interface RawContentNode {
    htmlContent: string;
}

export type FunctionComponent = (
    props: Record<string, unknown>
) => RenderedNode;
```

`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:

```ts
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))
    );
  }
}
```

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.

```ts
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}/>`;
  }
}
```

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

```ts
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("");
}
```

`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.

```ts
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(" ");
}
```

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:

```ts
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);
}
```

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:

```ts
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();
}
```

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:
```tsx
const ComponentA = (): RenderedNode => <div>Hello</div>;

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

const rendered = <ComponentB />;

console.log(rendered);
```

Running this code you will see this printed out:
```html
<div><span>Hello</span></div>
```

#### 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