One of our project requirements was to support Markdown rendering, aiming to replicate effects similar to ChatGPT and Claude.

This article aims to document the problems I encountered and their solutions while implementing this feature.

This is an English translation of an article originally published in Chinese on February 20, 2024. The content of the original article may be outdated or deprecated. Please verify the current status of any tools, libraries, or methods mentioned before implementing them in your projects.

react-markdown

First, install react-markdown.

bash
1
npm install react-markdown

Let's test using the official example text from the react-markdown library:

plaintext
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# A demo of `react-markdown`

`react-markdown` is a markdown component for React.

👉 Changes are re-rendered as you type.

👈 Try writing some markdown on the left.

## Overview

* Follows [CommonMark](https://commonmark.org)
* Optionally follows [GitHub Flavored Markdown](https://github.github.com/gfm/)
* Renders actual React elements instead of using `dangerouslySetInnerHTML`
* Lets you define your own components (to render `MyHeading` instead of `'h1'`)
* Has a lot of plugins

## Contents

Here is an example of a plugin in action
([`remark-toc`](https://github.com/remarkjs/remark-toc)).
**This section is replaced by an actual table of contents**.

## Syntax highlighting

Here is an example of a plugin to highlight code:
[`rehype-highlight`](https://github.com/rehypejs/rehype-highlight).

```js
import React from 'react'
import ReactDOM from 'react-dom'
import Markdown from 'react-markdown'
import rehypeHighlight from 'rehype-highlight'

const markdown = `
# Your markdown here
`

ReactDOM.render(
<Markdown rehypePlugins={[rehypeHighlight]}>{markdown}</Markdown>,
document.querySelector('#content')
)
\```

> Pretty neat, eh?

## GitHub flavored markdown (GFM)

For GFM, you can *also* use a plugin:
[`remark-gfm`](https://github.com/remarkjs/react-markdown#use).
It adds support for GitHub-specific extensions to the language:
tables, strikethrough, tasklists, and literal URLs.

These features **do not work by default**.
👆 Use the toggle above to add the plugin.

| Feature | Support |
| ---------: | :------------------- |
| CommonMark | 100% |
| GFM | 100% w/ `remark-gfm` |

~~strikethrough~~

* [ ] task list
* [x] checked item

https://example.com

## HTML in markdown

⚠️ HTML in markdown is quite unsafe, but if you want to support it, you can
use [`rehype-raw`](https://github.com/rehypejs/rehype-raw).
You should probably combine it with
[`rehype-sanitize`](https://github.com/rehypejs/rehype-sanitize).

<blockquote>
👆 Use the toggle above to add the plugin.
</blockquote>

## Components

You can pass components to change things:

```markdown
import React from 'react'
import ReactDOM from 'react-dom'
import Markdown from 'react-markdown'
import MyFancyRule from './components/my-fancy-rule.js'

const markdown = `
# Your markdown here
`

ReactDOM.render(
<Markdown
components={{
// Use h2s instead of h1s
h1: 'h2',
// Use a component instead of hrs
hr(props) {
const {node, ...rest} = props
return <MyFancyRule {...rest} />
}
}}
>
{markdown}
</Markdown>,
document.querySelector('#content')
)
/```

In the test text, there are also code blocks, but since I'm using Hexo for my blog, these texts that should be in code blocks (as I set) were all rendered as code blocks.

Therefore, I added a backslash after each code block to prevent them from being rendered as code blocks. You can remove these backslashes when testing.

jsx
1
2
3
4
5
6
7
8
9
10
11
import ReactMarkdown from "react-markdown";

function App() {
const markdownContent = `{The Markdown test text mentioned earlier}`;

return (
<>
<ReactMarkdown>{markdownContent}</ReactMarkdown>
</>
);
}

The rendered effect (remember to replace the value of markdownContent):

Rendered Markdown without table, task lists, and strikethrough

As you can see, there are some differences compared to Typora or GitHub's rendering effects. For example, the code blocks and regular text styles are too similar, and we'd prefer code blocks with distinct background colors and syntax highlighting.

Additionally, special styles like tables, task lists, and strikethrough weren't rendered.

remark-gfm

Actually, the test text already told us why: these are GFM (GitHub Flavored Markdown) features, and react-markdown doesn't support GFM by default.

So we need to install the remark-gfm plugin to support GFM:

bash
1
npm install remark-gfm

Using plugins with react-markdown is quite simple:

jsx
1
2
3
4
5
import remarkGfm from "remark-gfm";

// ...

<ReactMarkdown remarkPlugins={[remarkGfm]}>{markdownContent}</ReactMarkdown>

The rendered effect:

Rendered Markdown without table borders

At this point, you're probably wondering: where are the table borders??

If we inspect the table's styles using developer tools, we'll find that the table is indeed rendered as a table tag, but the User Agent Stylesheet has applied some processing to the table tag. So we need to write some styles to override these default styles:

css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
table {
border-spacing: 0 !important;
border-collapse: collapse !important;
border-color: inherit !important;
display: block !important;
width: max-content !important;
max-width: 100% !important;
overflow: auto !important;
}

tbody, td, tfoot, th, thead, tr {
border-color: inherit !important;
border-style: solid !important;
border-width: 2px !important;
}

Add !important to all styles to override the User Agent Stylesheet styles.

The rendered effect now:

Rendered Markdown with bold table border

Due to security considerations, I decided not to use HTML support, so I won't demonstrate it here.

Now we've completed a simple Markdown rendering feature. However, there's one crucial feature in this requirement: code block highlighting.

Code Block Highlighting

Due to project requirements, code blocks must be prominent and have syntax highlighting, which applies to inline code blocks as well.

Here we can use react-syntax-highlighter.

bash
1
npm install react-syntax-highlighter

react-syntax-highlighter has two engines: prism and highlight.js. You can search online for their detailed differences.

Here we'll use the prism engine with the oneDark theme:

jsx
1
2
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";

In the component prop of ReactMarkdown, we can customize how code blocks are rendered. If you've seen other tutorials online, they almost all write it like this:

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
return !inline && match ? (
<SyntaxHighlighter
style={nightOwl}
language={match[1]}
PreTag="div"
children={String(children).replace(/\n$/, '')}
{...props}
/>
) : (
<code className={className} {...props} children={children} />
)
}
}}
>
{markdownContent}
</ReactMarkdown>

Here we customized the rendering behavior of the code component.

The parameters in code({ node, inline, className, children, ...props }) {} represent:

  • node: current node
  • inline: whether it's an inline code block
  • className: class name
  • children: child nodes (content in code block)
  • ...props: other properties

Then we use regex to match language-xxx in className, and if there's a match and it's not an inline code block, we use SyntaxHighlighter to render the code block; otherwise, we use the default code tag.

But!!!

The inline property has been deprecated and won't be passed as a parameter in the new version of react-markdown!!!

This really gave me a headache at the time, and I couldn't find a solution after searching online. I decided to temporarily solve another problem: rendering code blocks without defined languages.

After rendering, the biggest difference between code blocks and inline code blocks is that code blocks have a pre tag wrapper. Since the code tag as a child tag can't get the parent tag's styles from props, but thinking reversely, the pre tag can get the code tag's styles!

So I wrote this wild solution:

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<Markdown
remarkPlugins={[remarkGfm]}
components={{
pre({node, className, children, ...props}) {
if (children["type"] === "code") {
try {
const match = children["props"]["className"].match(/language-(\w+)/)
return (
<pre>
<SyntaxHighlighter
style={oneDark}
language={match[1]}
PreTag="div"
showLineNumbers
wrapLongLines
children={String(children["props"]["children"]).replace(/\n$/, '')}
/>
</pre>
)
} catch (e) {
return (
<pre>
<SyntaxHighlighter
style={oneDark}
language="python"
PreTag="div"
showLineNumbers
wrapLongLines
children={String(children["props"]["children"]).replace(/\n$/, '')}
/>
</pre>
)
}
}
}
}}
>
{markdownContent}
</Markdown>

Directly check the children's type in the pre tag - if it's code, then it must be a code block, and then match the language type in className.

If there's no match, it will cause an error, so I temporarily added a try...catch statement - if there's no match, just default to rendering it as a Python language code block.

The effect is not bad:

Rendered Code Block with styles

Afterword

There are still some issues with this implementation, such as unresolved inline code block rendering, the try...catch statement not being a good solution, and the technical debt of defaulting to Python language for undefined language code blocks is quite messy!

However, due to project time constraints, I didn't delve deeper into these issues. Perhaps when I have more time in the future, I'll revisit and solve these problems. That's for another time.