Written by dustbringer on 04 February 2021 . View source.
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 renderering.
For your ease of access, here are links to my code
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.
From the tutorial link above, there are some changes that I made for my purposes.
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.
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
],
// ...
}
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>,
// ...
}
See the next section.
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:
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.
ref
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.
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.
ReactMarkdown
module, I decide to abandon the idea in favor of something more practical...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 ref
s 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 ref
s and headings on the side with links that scrolls the heading into view. The scrolling, again, is handed by React's ref.current.scrollIntoView()
.
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.
Some websites that got me started,