iTranslated by AI
Creating a Markdown Editor for React and unified Plugins
Implementing a Markdown Editor in React
For the text editing part, we use the Monaco editor, and for the Markdown parser, we use unified. This article also introduces how to create the necessary plugins.
Note: Source code used in this article
- Next.js version
- React Router version
- Demo image

Things to Know When Working with unified
About unified
- A library for handling specific document formats using an Abstract Syntax Tree (AST)
- Extended through plugins
- Does not work on its own
Markdown to AST Conversion Flow
| Phase | Processing Content | Plugin | Base Library |
|---|---|---|---|
| Parser | Converts Markdown to AST | remark-parse | mdast |
| Transformer | Converts to AST closer to HTML structure | remark-rehype | hast |
| Compiler | Converts to React Components | rehype-react | hast-util-to-jsx-runtime |
Since mdast and hast are called by plugins, they are not used directly. However, TypeScript type information for @types/mdast and @types/hast is needed when creating plugins.
Minimal Code to Use unified
The minimal implementation is as follows. Note that rehype-react requires an instance of react/jsx-runtime.
import prod from "react/jsx-runtime";
import rehypeReact from "rehype-react";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
export const markdownConverter = unified()
// MDAST (converts to Markdown AST)
.use(remarkParse)
// HAST (converts to HTML AST)
.use(remarkRehype)
// Converts to React components
.use(rehypeReact, {
...prod,
})
.processSync("markdown");
Checking if an Article is Valid
The invalid pattern is the old version syntax, so skip it if you find it in search results.
- Valid pattern
import { unified } from "unified";
- Invalid pattern
import unified from "unified";
Customizing unified
Basic Part
Add plugins as needed to customize behavior. Note the following points:
- remark-based plugins: handle the mdast AST converted by
remark-parse - rehype-based plugins: handle the hast AST converted by
remark-rehype
When plugins handle the AST, it starts as a general document format AST and is later converted to an HTML-like AST. Therefore, the order of plugin integration is important.
Additionally, we include a plugin called compilerResultTree. At the final conversion stage, rehype-react outputs React nodes, but we modify this part to also output MdastRoot information. This enables flexible data usage, such as extracting a list of header items.
import rehypeRaw from "rehype-raw";
import rehypeReact from "rehype-react";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified, type Processor } from "unified";
import { compilerResultTree } from "./plugins/compilerResultTree";
import { rehypeAddLineNumber } from "./plugins/rehypeAddLineNumber";
import { rehypeAddTargetBlank } from "./plugins/rehypeAddTargetBlank";
import { rehypeReactOptions } from "./plugins/rehypeReactOptions";
import { remarkCode } from "./plugins/remarkCode";
import { remarkDepth } from "./plugins/remarkDepth";
import { remarkEmptyParagraphs } from "./plugins/remarkEmptyParagraphs";
import { remarkHeadingId } from "./plugins/remarkHeadingId";
import type { Root } from "mdast";
import type { ReactNode } from "react";
export const markdownCompiler: Processor<
undefined,
undefined,
undefined,
undefined,
[ReactNode, Root]
> = unified()
// MDAST (converts to Markdown AST)
.use(remarkParse)
// Adds conversions for tables and links in text
.use(remarkGfm)
// Enables line breaks within paragraphs
.use(remarkBreaks)
// Restores empty lines
.use(remarkEmptyParagraphs)
// Adds IDs and links to headers
.use(remarkHeadingId)
// Adds additional information to code blocks
.use(remarkCode)
// Assigns depth information for indentation corresponding to headers to nodes
.use(remarkDepth)
// HAST (converts to HTML AST)
.use(remarkRehype, {
allowDangerousHtml: true,
})
// Adds line number information to nodes
.use(rehypeAddLineNumber)
// Enables embedded HTML
.use(rehypeRaw)
// Sets target="_blank" on anchor tags
.use(rehypeAddTargetBlank)
// Converts to React components
.use(rehypeReact, rehypeReactOptions)
// Converts output to [React component, MdastTree] format
.use(compilerResultTree);
How to Create Plugins
The remark-based and rehype-based systems have different AST structures to manipulate. Also, the rules for attaching information differ, so careful attention is needed.
Restoring Empty Lines
In the standard Markdown specification, consecutive empty lines are removed. This plugin restores them. It calculates positions where nodes do not exist and inserts line breaks.
import type { Root as MdastRoot, RootContent } from "mdast";
import type { Plugin } from "unified";
/**
* Converts blank lines into break nodes
*/
export const remarkEmptyParagraphs: Plugin = () => {
return (tree: MdastRoot) => {
const lastLine = (tree.position?.end.line ?? 0) + 1;
tree.children = tree.children.flatMap((node, index) => {
const start = tree.children[index + 1]?.position?.start.line ?? lastLine;
const end = node.position?.end.line;
if (typeof start === "undefined" || typeof end === "undefined")
return [node];
const length = start - end - 1;
if (length > 0) {
return [
node,
...Array(length)
.fill(null)
.map<RootContent>((_, index) => ({
type: "paragraph",
position: {
start: {
offset: end + index + 1,
line: (node.position?.end?.line ?? 0) + index + 1,
column: 1,
},
end: {
offset: end + index + 1,
line: (node.position?.end?.line ?? 0) + index + 1,
column: 1,
},
},
children: [{ type: "break" }],
})),
];
}
return [node];
});
};
};
Adding IDs and Links to Headers
Embeds the header's text as an ID. This allows linking to specific headings within the page.
import { visit } from "unist-util-visit";
import type { Node, Root } from "mdast";
import type { Plugin } from "unified";
/**
* Extracts text from child nodes
*/
const getNodeText = (node: Node | Root) => {
const values: string[] =
"children" in node
? node.children.map((v) =>
"value" in v && typeof v.value === "string"
? v.value
: getNodeText(v) || ""
)
: [];
return values.join("");
};
/**
* Embeds the header's text as an ID and creates a link
*/
export const remarkHeadingId: Plugin = () => {
return (tree: Root) => {
visit(tree, "heading", (node) => {
const id = getNodeText(node);
node.data = { hProperties: { id } };
node.children = [
...node.children,
{
type: "link",
children: [{ type: "text", value: "🔗" }],
url: `#${id}`,
data: { hProperties: { className: "inner-link" } },
},
];
});
};
};
Adding Additional Information to Code Blocks
Adds data to code and inlineCode so they can be identified after remark-rehype.
import { visit } from "unist-util-visit";
import type { Node, Root } from "mdast";
import type { Plugin } from "unified";
/**
* Extracts text from child nodes
*/
const getNodeText = (node: Node | Root) => {
const values: string[] =
"children" in node
? node.children.map((v) =>
"value" in v && typeof v.value === "string"
? v.value
: getNodeText(v) || ""
)
: [];
return values.join("");
};
/**
* Adds language info to code and inline flag to inlineCode
*/
export const remarkCode: Plugin = () => {
return (tree: Root) => {
visit(tree, "code", (node) => {
node.data = { ...node.data, hProperties: { "data-language": node.lang } };
});
visit(tree, "inlineCode", (node) => {
node.data = { ...node.data, hProperties: { "data-inline-code": "true" } };
});
};
};
Assigning Depth Information for Header-Based Indentation to Nodes
Adds header level information to elements following <h1> through <h6>. This allows using CSS to apply indentation corresponding to the header level.
import type { Root } from "mdast";
import type { Plugin } from "unified";
export const remarkDepth: Plugin = () => {
return (tree: Root) => {
tree.children.reduce((depth, node) => {
if (node.type === "heading") {
const index = node.depth;
if (index) {
return Number(index);
}
}
node.data = {
...node.data,
hProperties: {
...node.data?.hProperties,
"data-depth": depth,
},
};
return depth;
}, 0);
};
};
Adding Line Number Information to Nodes
Provides line number information to nodes that have position data.
import { visit } from "unist-util-visit";
import type { Root } from "hast";
import type { Plugin } from "unified";
import type { VFile } from "vfile";
/**
* Embeds line number and cursor position info into each node
*/
export const rehypeAddLineNumber: Plugin = () => {
return (tree: Root) => {
visit(
tree,
"element",
(node) => {
const start = node.position?.start?.line;
const end = node.position?.end?.line;
if (node.tagName === "code") {
}
if (start && end && !node.properties["data-inline-code"]) {
node.properties = {
...node.properties,
["data-line"]: start,
};
}
},
true
);
};
};
Setting target="_blank" on Anchor Tags
Adds _blank property to <a> tags. Does nothing for internal page links.
import { visit } from "unist-util-visit";
import type { Root } from "hast";
import type { Plugin } from "unified";
export const rehypeAddTargetBlank: Plugin = () => {
return (tree: Root) => {
visit(tree, "element", (node) => {
if (
node.tagName === "a" &&
typeof node.properties?.href === "string" &&
node.properties.href[0] !== "#"
) {
node.properties.target = "_blank";
node.properties.rel = "noopener noreferrer";
}
});
};
};
Adding Syntax Highlighting to Code
This is not a plugin but an option added to rehype-react. It highlights the given code. It also caches converted nodes to minimize processing.
import { Highlight, themes } from "prism-react-renderer";
import { useMemo, type ComponentProps } from "react";
import prod from "react/jsx-runtime";
import type { Options as RehypeReactOptions } from "rehype-react";
import { classNames } from "~/libs/classNames";
const Code = ({
ref,
children,
...props
}: ComponentProps<"code"> & {
"data-language": string;
"data-line": number;
"data-inline-code": boolean;
}) => {
const dataLine = Number(props["data-line"] ?? 0);
const dataLanguage = props["data-language"];
const dataInlineCode = props["data-inline-code"];
const component = useMemo(() => {
if (dataInlineCode) {
return <code data-inline-code>{children}</code>;
}
return (
<Highlight
theme={themes.shadesOfPurple}
code={String(children)}
language={dataLanguage ?? "txt"}
>
{({ style, tokens, getLineProps, getTokenProps }) => {
const numberWidth = Math.floor(Math.log10(tokens.length)) + 1;
return (
<div
style={style}
className="overflow-x-auto rounded py-1 font-mono"
>
{tokens.slice(0, -1).map((line, i) => (
<div
key={i}
{...getLineProps({ line })}
data-line={dataLine + i + 1}
>
<span
className={`sticky left-0 z-10 inline-block bg-blue-900 px-2 text-gray-300 select-none`}
>
<span
className="inline-block text-right"
style={{ width: `${numberWidth}ex` }}
>
{i + 1}
</span>
</span>
<span>
{line.map((token, key) => (
<span
key={key}
{...getTokenProps({ token })}
className={classNames(
getTokenProps({ token }).className
)}
/>
))}
</span>
</div>
))}
</div>
);
}}
</Highlight>
);
}, [dataInlineCode, children, dataLanguage, dataLine]);
return component;
};
export const rehypeReactOptions: RehypeReactOptions = {
...prod,
components: { code: Code },
};
Adding Extra Information to the Output
Customizes the final output of processSync so that instead of only returning a component, it also returns the mdast tree information. This enables special displays that utilize tree information.
import type { Root } from "mdast";
import type { Processor } from "unified";
export function compilerResultTree(this: Processor<Root, Root, Root, Root>) {
const originalCompiler = this.compiler;
const originalParse = this.parse;
let mdastTree: Root | undefined;
this.parse = function (...args) {
const tree = originalParse.apply(this, args) as Root;
mdastTree = tree;
return tree;
};
this.compiler = (...props) => {
return [originalCompiler?.apply(this, props), mdastTree];
};
}
Styling Configuration
Sets styles for Markdown display all at once.
@reference "tailwindcss";
.markdown {
@apply px-2;
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-bold border-b-1 mb-2;
}
[data-depth="2"] {
@apply ml-4;
}
[data-depth="3"] {
@apply ml-8;
}
[data-depth="4"] {
@apply ml-4;
}
[data-depth="5"] {
@apply ml-8;
}
[data-depth="6"] {
@apply ml-8;
}
h1 {
@apply text-4xl;
}
h2 {
@apply text-3xl ml-4;
}
h3 {
@apply text-2xl ml-8;
}
h4 {
@apply text-xl ml-12;
}
h5 {
@apply text-lg ml-16;
}
h6 {
@apply text-base ml-20;
}
p {
@apply leading-relaxed p-0.5;
}
h1,
h2,
h3,
h4,
h5,
h6,
p,
li,
tr,
:global(.token-line) {
@apply relative;
}
em {
@apply italic;
}
b {
@apply font-bold;
}
strong {
@apply font-bold;
}
[data-inline-code] {
@apply inline-block bg-black/5 px-1 rounded;
}
a {
@apply underline text-blue-700;
}
:global(.inner-link) {
@apply no-underline text-base;
}
table {
@apply rounded border;
}
td,
th {
@apply px-2;
}
th {
@apply border-b;
}
td {
@apply border border-black/20;
}
li {
@apply ml-[1em] list-disc py-0.5;
}
img,
canvas {
margin: 0 auto;
max-width: 80%;
height: auto;
}
[data-line] {
@apply relative;
}
[data-line]::after {
@apply absolute -inset-0.5 w-full rounded pointer-events-none z-10 bg-blue-300/10 invisible border-b-blue-300 border-b-2 border-dotted;
content: "";
}
}
Integration with Text Editor
Using the Monaco Editor
We use the easy-to-handle Monaco editor as the text editor.
import { Editor as MonacoEditor, type OnMount } from "@monaco-editor/react";
import styled from "./MarkdownEditor.module.css";
import type { FC } from "react";
import { classNames } from "~/libs/classNames";
export const MarkdownEditor: FC<{
onCurrentLine: (
line: number,
top: number,
linePos: number,
source: string
) => void;
onUpdate: (value: string) => void;
value: string;
refEditor: React.RefObject<Parameters<OnMount>[0] | null>;
className?: string;
}> = ({ onCurrentLine, onUpdate, value, refEditor, className }) => {
const handleEditorDidMount: OnMount = (editor) => {
refEditor.current = editor;
editor.onDidChangeCursorPosition((event) => {
const currentLine = event.position.lineNumber;
const top = editor.getScrollTop();
const linePos = editor.getTopForLineNumber(currentLine);
onCurrentLine(currentLine, top, linePos, event.source);
});
};
return (
<MonacoEditor
className={classNames(styled["markdown-editor"], className)}
onMount={handleEditorDidMount}
language="markdown"
defaultValue={value}
onChange={(e) => onUpdate(e ?? "")}
options={{
renderControlCharacters: true,
renderWhitespace: "boundary",
automaticLayout: true,
scrollBeyondLastLine: false,
wordWrap: "on",
wrappingStrategy: "advanced",
minimap: { enabled: false },
dragAndDrop: true,
dropIntoEditor: { enabled: true },
contextmenu: false,
occurrencesHighlight: "off",
renderLineHighlight: "none",
quickSuggestions: false,
wordBasedSuggestions: "off",
language: "markdown",
selectOnLineNumbers: true,
}}
/>
);
};
Markdown Display Section
Displaying Markdown in conjunction with the text editor.
import { useMemo } from "react";
import { markdownCompiler } from "../markdownCompiler";
export const useMarkdown = ({ markdown }: { markdown?: string }) => {
return useMemo(() => {
return markdownCompiler.processSync({
value: markdown,
}).result;
}, [markdown]);
};
Highlight the target node on mouse click and send events to the editor.
import styled from "./MarkdownContent.module.css";
import { MarkdownHeaders } from "./MarkdownHeaders";
import type { FC } from "react";
import { classNames } from "~/libs/classNames";
import { useMarkdown } from "~/libs/MarkdownConverter";
export const MarkdownContext: FC<{
className?: string;
markdown?: string;
line?: number;
onClick?: (line: number, offset: number) => void;
}> = ({ className, markdown, line, onClick }) => {
const [node, tree] = useMarkdown({ markdown });
return (
<div
className={classNames(className, styled["markdown"])}
onClick={(e) => {
const framePos = e.currentTarget.getBoundingClientRect();
let node = e.target as HTMLElement | null;
while (node && !node.dataset.line) {
node = node.parentElement;
}
if (node) {
const p = node.getBoundingClientRect();
onClick?.(Number(node.dataset.line), p.top - framePos.top);
}
}}
>
<style>{`[data-line="${line}"]:not(:has([data-line="${line}"]))::after {
visibility: visible;
}`}</style>
{node}
<MarkdownHeaders tree={tree} />
</div>
);
};
Collect header information and display a list.
import { useMemo, type FC } from "react";
import { visit } from "unist-util-visit";
import type { Root } from "mdast";
export const MarkdownHeaders: FC<{ tree: Root }> = ({ tree }) => {
const headers = useMemo(() => {
const titles: { id: number; text?: string; depth: number }[] = [];
const property = { count: 0 };
visit(tree, "heading", (node) => {
titles.push({
id: property.count,
text: node.data?.hProperties?.id as string | undefined,
depth: node.depth,
});
});
return titles;
}, [tree]);
return (
headers.length > 0 && (
<ul className="sticky bottom-0 left-full z-10 h-60 w-80 overflow-y-auto rounded bg-white/90 p-2 text-sm">
{headers.map(({ id, text, depth }) => (
<li key={id} style={{ marginLeft: `${depth * 16}px` }}>
<a href={`#${text}`}>{text}</a>
</li>
))}
</ul>
)
);
};
When linking the text editor and Markdown display, use useTransition for string updates. This can somewhat reduce the load when typing continuously with large amounts of text.
import { useRef, useState, useTransition } from "react";
import type { OnMount } from "@monaco-editor/react";
import { MarkdownContext } from "~/components/MarkdownContent";
import { MarkdownEditor } from "~/components/MarkdownEditor";
const initText = "";
const Page = () => {
const [content, setContent] = useState(initText);
const refEditor = useRef<Parameters<OnMount>[0]>(null);
const [currentLine, setCurrentLine] = useState(1);
const refMarkdown = useRef<HTMLDivElement>(null);
const [, startTransition] = useTransition();
return (
<div className="flex h-screen gap-2 divide-x divide-blue-100 overflow-hidden p-2">
<div className="flex-1 overflow-hidden rounded border border-gray-200">
<MarkdownEditor
refEditor={refEditor}
value={content}
onUpdate={(value) => startTransition(() => setContent(value))}
onCurrentLine={(line, top, linePos, source) => {
startTransition(() => {
setCurrentLine(line);
const node = refMarkdown.current;
if (node && source !== "api") {
const nodes = node.querySelectorAll<HTMLElement>("[data-line]");
const target = Array.from(nodes).find((node) => {
const nodeLine = node.dataset.line?.match(/(\d+)/)?.[1];
if (!line) return false;
return line === Number(nodeLine);
});
if (target) {
const { top: targetTop } = target.getBoundingClientRect();
const { top: nodeTop } = node.getBoundingClientRect();
node.scrollTop =
targetTop - nodeTop + node.scrollTop - (linePos - top);
}
}
});
}}
/>
</div>
<div
ref={refMarkdown}
className="flex-1 overflow-auto rounded border-2 border-gray-200"
>
<MarkdownContext
markdown={content}
line={currentLine}
onClick={(line, offset) => {
const editor = refEditor.current;
const node = refMarkdown.current;
if (editor && node) {
const linePos = editor.getTopForLineNumber(line);
editor.setScrollTop(linePos - offset + node.scrollTop);
editor.setPosition({ lineNumber: line, column: 1 });
}
}}
/>
</div>
</div>
);
};
export default Page;
Summary
When creating Markdown plugins with unified, one concern is that the defined type information is scattered across various packages and the definitions themselves are quite incomplete. Understanding where to import what is somewhat cumbersome until you get the hang of it.
Discussion