I like writing in plain text, but I still want my blog content to become good HTML.

Markdown is usually enough for that, and I still use it in many places. But for this site, I wanted small semantic hints to live directly in the document. A figure should be written like text, carry its caption with it, and render as proper <figure> and <figcaption> HTML without raw HTML, MDX, or a custom Markdown convention.

That is what made Djot interesting to me. It feels close to Markdown, but includes attributes for marking inline text or blocks as note, figure, details, or whatever else the renderer should understand.

The existing Djot JavaScript library already handles parsing. I wanted the React layer around it, so I made react-djot: a small renderer for turning Djot AST nodes into React elements, with custom component overrides when needed.

This post walks through the basic API, attribute-driven components, and structural overrides with renderNode.

Basic Usage

Install the renderer:

npm install @willwang-io/react-djot

Then render some Djot:

import { Djot } from "@willwang-io/react-djot";

export function Article({ content }: { content: string }) {
  return <Djot>{content}</Djot>;
}

That gives you the default rendering. To customize output, pass a components object:

import { Djot } from "@willwang-io/react-djot";
import type { DjotComponents } from "@willwang-io/react-djot";

const components: DjotComponents = {
  para: ({ children, node, ...props }) => {
    return (
      <p className="leading-7" {...props}>
        {children}
      </p>
    );
  },
};

export function Article({ content }: { content: string }) {
  return <Djot components={components}>{content}</Djot>;
}

Attribute-Driven Components

In Djot, I can write a block like this:

::: note
This is a note.
:::

That becomes a div node with the class of note. Then the React side can decide what note means:

const components: DjotComponents = {
  div: ({ className, children, node, ...props }) => {
    const classes = className?.split(/\s+/) ?? [];

    if (classes.includes("note")) {
      return (
        <aside className="note" {...props}>
          {children}
        </aside>
      );
    }

    return (
      <div className={className} {...props}>
        {children}
      </div>
    );
  },
};

The writing stays clean:

::: note
Remember to run the type checker before publishing.
:::

The output can still be semantic:

<aside class="note">
  Remember to run the type checker before publishing.
</aside>

That is the main reason I like Djot for this site. I do not need a large extension system just to add a semantic hint to a paragraph, span, or block. The hint is part of the document.

Structural Overrides with renderNode

Sometimes children is enough. Sometimes you need to inspect the AST and move pieces around. For example, this site supports figure blocks with caption written like this:

::: figure
![image-alt](/image.png)

I am a caption.
:::

The content is still plain Djot. But when React renders it, I want proper HTML:

<figure>
  <img src="image.png" alt="image-alt">
  <figcaption>
    I am a caption.
  </figcaption>
</figure>

For that, import renderNode:

import { Djot, renderNode } from "@willwang-io/react-djot";
import type { DjotComponents } from "@willwang-io/react-djot";

Then use a div override that looks for the figure class:

const components: DjotComponents = {
  div: ({ className, children, node, ...props }) => {
    const classes = className?.split(/\s+/) ?? [];

    if (!classes.includes("figure")) {
      return (
        <div className={className} {...props}>
          {children}
        </div>
      );
    }

    const firstChild = node.children[0];

    if (
      firstChild?.tag !== "para" ||
      firstChild.children?.length !== 1 ||
      firstChild.children?.[0]?.tag !== "image"
    ) {
      return (
        <div className={className} {...props}>
          {children}
        </div>
      );
    }

    const imageNode = firstChild.children[0];
    const captionNodes = node.children.slice(1);

    return (
      <figure>
        {renderNode(imageNode, { components })}
        {captionNodes.length > 0 && (
          <figcaption>
            {captionNodes.map((child, index) =>
              renderNode(child, { key: index, components }),
            )}
          </figcaption>
        )}
      </figure>
    );
  },
};

This is the key pattern:

  • use Djot attributes to mark intent in the source
  • use components to intercept a node
  • use the raw node when children is not enough
  • use renderNode to render child AST nodes after rearranging them