Written by dustbringer on 04 February 2021 . View source.

[Old] Rendering Markdown in ReactJS

Note: This is the old implementation in create-react-app. The current one involves tapping into GatsbyJS's markdown engine.

This post will take you through my process of creating this markdown renderer you're seeing right now for Markdown.

My aim is to render each Markdown component cleanly in HTML and have LaTeX\LaTeX renderering.

For your ease of access, here are links to my code

Foundation

We will be using the ReactMarkdown module that is based on remark.

remark is a Markdown processor built on micromark powered by plugins part of the unified collective.

Installing the module is straightfoward

npm install react-markdown
yarn add react-markdown

Next, the wrapper for the Markdown rendering component will be taken straight from this helpful guide.

Variation

From the tutorial link above, there are some changes that I made for my purposes.

Wrapping div

I am using Google's MaterialUI to replace the traditional HTML components in many places, so the fonts rendered by primitive HTML tags are default. So we wrap the <ReactMarkdown /> component with a div with our new fonts and colors, so everything displayed will be consistent.

Remark Plugins

Since the ReactMarkdown package is made by remark, it supports some of the remark plugins. Not all of the plugins work, from my trial and error, it seems like we can only use ones that do not directly change the source Markdown.

There is a chonky list of plugins here but I have used remark-math, remark-gfm, and remark-frontmatter.

You will need to install the packages through npm or yarn, import them into the component we are working on, and add them to the props as

{
  // ...
  plugins: [
    RemarkMathPlugin
  ],
  // ...
}

Math renderer

The basic react-katex package is old and throws some warnings in the newest React versions (17.0.1 at the time of writing). So instead use @matejmazur/react-katex which includes many improvements over the original react-katex.

After installing and importing, the two math renderers will need to be changed as follows

{
  // ...
  math: ({ value }) => <TeX block>{value}</TeX>,
  inlineMath: ({ value }) => <TeX>{value}</TeX>,
  // ...
}

More custom renderers

See the next section.

Custom Renderers

Now everthing is rendered in very basic HTML (e.g. p, h1, h2, ...) without any styling, so its not too pleasing to the eye. Fortunately we can write our own custom renderers to replace the default ones.

We can add them into the props, for example code blocks

renderers: {
    ...props.renderers,
    code: BlockCodeRenderer,
    // ...
  },

we need a component that will replace the default <code> tags.

We can find the default renderers and their implementations in the git repo. This will tell us the name of the part to be rendered and how information is passed around behind the scenes in the ReactMarkdown component.

With this starting point, we can see what props are passed to each component and write our own components with custom styling that can render similarly to the original.

Some more inspiration:

Anchors (removed)

Taking inspiration from Github's and HackMD's Markdown viewer (along with many other sites that have anchors), I tried to tackle the links to headings on the rendered Markdown.

The little link icon that showed up as you hover over the link, was simple enough. So the difficult part is dealing with the url.

My website will be hosted on GitHub Pages, where you are forced to use react-router's HashRouter for routing. Thus, the classic "just link to a hash with the same id as the heading" will not work, and we must resort to another method of storing the heading and scrolling.

To solve the problem of not having the hash part of the URL available, I used the 'search' part of the url to store that data. To scroll, we use React's useRef() hook (by ref.current.scrollIntoView()) to scroll to the correct heading, when the URL search query matches up.

  • However, to avoid having to store all the refs in some Global Storage, I run a comparison between the URL search query and the heading's unique name for each heading as it loads in.
  • Since the search query only matches to a unique heading, it will be scrolled to with its ref

How do we generate these unique names?

Take the heading content, then run some regex to remove non-alphanumeric characters and replace whitespace. Some of the following is taken and inspired by replies here.

Get the heading content,

function flatten(text, child) {
  return typeof child === "string"
    ? text + child
    : React.Children.toArray(child.props.children).reduce(flatten, text);
}

// Get text in children
const children = React.Children.toArray(props.children);
const text = children.reduce(flatten, "");

then strip the bad characters

text
  .toLowerCase()
  .replace(/[^0-9A-Z\s]+/gi, "")
  .replace(/\W/g, "-")

and store an incrementing id outside the functional commponent to keep the generated names unique.

Problems

Now after adding an initial scroll to a ref if a URL query is present (using the useEffect() hook) everything seems to be working fine. However, on first load of a webpage (without caching), some further titles do not get scrolled to the top of the screen.

  • The cause is not immediately apparent to me, but it seems to be due to components that have not rendered before the scrolling starts.
  • Without further rewriting the ReactMarkdown module, I decide to abandon the idea in favor of something more practical...

Contents List

Taking inspiration from HackMD's markdown previewer (and ontop of the failed framework of the anchors), I wrote a component that renders a contents table which can be used for navigation.

Since the inner workings of ReactMarkdown is hidden from us, I used a janky workaround. It involves storing the heading refs from the Anchors attempt in Global State and using them as links to scroll the user around.

For this to work, I had to trust that all the headings rendered in the correct order, and displayed the list of refs and headings on the side with links that scrolls the heading into view. The scrolling, again, is handed by React's ref.current.scrollIntoView().

Conclusion

It was very insightful and interesting experience digging into ReactMarkdown and reworking its innards to acheive my view of an "OKAY" markdown renderer. Even if some additions were inefficient or work-aroundy to keep ReactMarkdown from falling apart, I still learnt lots about React best practices, components and how they use their props.


(Appendix) Helpful Sources

Some websites that got me started,

Copyright © 2024 dustbringer