空雲 Blog

Eye catchunified を使って Markdown を React コンポーネントへ変換する

publication: 2025/09/11
update:2025/09/12

今回の記事に使用しているサンプルプログラム

テキストエディタで編集したマークダウンをプレビュー出来るようにします。また、編集位置のハイライトやタイトル一覧表示機能の実装も行います。

https://github.com/SoraKumo001/react-router-markdown

unified を扱う上で知っておいたほうが良いこと

unified に関して

  • 特定の文書フォーマットを抽象構文木(AST)で扱うためのライブラリ

  • プラグインによって拡張していく

  • 単体では動作しない

記事の有効性判別

駄目なパターンは旧バージョンの書き方なので、検索などで引っ掛けてしまった場合はスルーしてください

  • 有効なパターン

1import { unified } from "unified";

  • 駄目なパターン

1import unified from "unified";

Markdown 変換 AST の流れ

フェーズ処理内容プラグイン
ParserMarkdown を AST に変換remark-parse
TransformerHTML の構造に近い AST に変換remark-rehype
CompilerReactComponent に変換rehype-react

Markdown を React コンポーネントへ変換する手段としてunifiedに必要なプラグインがあらかじめ組み込まれているreact-markdownを使うという選択肢もありますが、カスタマイズすることを考えると、直接unifiedを扱った方が柔軟性が増します。

mdast に関して

remark-parseによる Markdown の変換は内部でmdastを使用しています。直接パッケージを使うことは非推奨となっているようですが、remark-parse以降の AST を TypeScript で操作する場合は、@types/mdastが必要になります。

unified を使う最低限の記述

最低限の実装は以下のようになります。rehype-reactreact/jsx-runtimeのインスタンスを必要とするので注意してください。

1import prod from "react/jsx-runtime";
2import rehypeReact from "rehype-react";
3import remarkParse from "remark-parse";
4import remarkRehype from "remark-rehype";
5import { unified } from "unified";
6
7export const markdownConverter = unified()
8 .use(remarkParse)
9 .use(remarkRehype)
10 .use(rehypeReact, {
11 ...prod,
12 })
13 .processSync("markdown");

unified をカスタマイズして使う

基本部分

適宜プラグインを追加して動作をカスタマイズします。ここで注意点があります

  • remark 系:remark-parseが変換した mdast の AST を扱う

  • rehype 系:remark-rehypeが変換した hast の AST を扱う

プラグインで AST を扱う時、最初は汎用的な文章フォーマット用の AST だったのが、途中で HTML よりの AST に変換されます。そのため、プラグインを組み込み順序に注意が必要になります。

追加でcompilerResultTreeというプラグインを入れています。変換最終段階でrehype-reactが React ノードを出力するのですが、この部分を細工して MastRoot の情報も出力させています。これによってヘッダ項目の一覧が表示可能になります。

1import rehypeRaw from "rehype-raw";
2import rehypeReact from "rehype-react";
3import remarkBreaks from "remark-breaks";
4import remarkGfm from "remark-gfm";
5import remarkParse from "remark-parse";
6import remarkRehype from "remark-rehype";
7import { unified, type Processor } from "unified";
8import { compilerResultTree } from "./plugins/compilerResultTree";
9import { rehypeAddLineNumber } from "./plugins/rehypeAddLineNumber";
10import { rehypeAddTargetBlank } from "./plugins/rehypeAddTargetBlank";
11import { rehypeReactOptions } from "./plugins/rehypeReactOptions";
12import { remarkCode } from "./plugins/remarkCode";
13import { remarkDepth } from "./plugins/remarkDepth";
14import { remarkEmptyParagraphs } from "./plugins/remarkEmptyParagraphs";
15import { remarkHeadingId } from "./plugins/remarkHeadingId";
16import type { Root } from "mdast";
17import type { ReactNode } from "react";
18
19export const markdownCompiler: Processor<
20 undefined,
21 undefined,
22 undefined,
23 undefined,
24 [ReactNode, Root]
25> = unified()
26 // ASTの作成
27 .use(remarkParse)
28 // 表やテキスト中のリンクなど変換を追加
29 .use(remarkGfm)
30 // 段落内の改行を有効に
31 .use(remarkBreaks)
32 // 空行を復元
33 .use(remarkEmptyParagraphs)
34 // ヘッダにIDとリンクを付ける
35 .use(remarkHeadingId)
36 // コードブロックに追加情報を加える
37 .use(remarkCode)
38 // ノードに対してヘッダーに対応するインデント用の深度情報を与える
39 .use(remarkDepth)
40 // HAST(HTML用のASTに変換)
41 .use(remarkRehype, {
42 allowDangerousHtml: true,
43 })
44 // ノードに対して行番号情報を付与
45 .use(rehypeAddLineNumber)
46 // 埋め込みHTMLを有効にする
47 .use(rehypeRaw)
48 // aタグにtarget="_blank"を設定
49 .use(rehypeAddTargetBlank)
50 // Reactコンポーネントに変換
51 .use(rehypeReact, rehypeReactOptions)
52 // 出力情報を[Reactコンポーネント,MdastTree]の形式に変換
53 .use(compilerResultTree);

プラグインの作り方

remark 系と rehype 系で、操作する AST の構造が異なります。

空行を復元

Markdown の標準仕様では空行が連続で続いた場合は除去されます。これを復元するプラグインです。ノードが存在しないポジションを確認して、改行を挿入しています。

1import type { Root as MdastRoot, RootContent } from "mdast";
2import type { Plugin } from "unified";
3
4/**
5 * 空白行をbreakに変換する
6 */
7export const remarkEmptyParagraphs: Plugin = () => {
8 return (tree: MdastRoot) => {
9 const lastLine = (tree.position?.end.line ?? 0) + 1;
10 tree.children = tree.children.flatMap((node, index) => {
11 const start = tree.children[index + 1]?.position?.start.line ?? lastLine;
12 const end = node.position?.end.line;
13 if (typeof start === "undefined" || typeof end === "undefined")
14 return [node];
15 const length = start - end - 1;
16 if (length > 0) {
17 return [
18 node,
19 ...Array(length)
20 .fill(null)
21 .map<RootContent>((_, index) => ({
22 type: "paragraph",
23 position: {
24 start: {
25 offset: end + index + 1,
26 line: (node.position?.end?.line ?? 0) + index + 1,
27 column: 1,
28 },
29 end: {
30 offset: end + index + 1,
31 line: (node.position?.end?.line ?? 0) + index + 1,
32 column: 1,
33 },
34 },
35 children: [{ type: "break" }],
36 })),
37 ];
38 }
39 return [node];
40 });
41 };
42};

ヘッダに ID とリンクを付ける

ヘッダに対するページ内リンクを作成します。これによって、ページ内の特定の見出しにリンクが可能になります。

1import { visit } from "unist-util-visit";
2import type { Node, Root } from "mdast";
3import type { Plugin } from "unified";
4
5/**
6 * 子ノードから文字列を抽出
7 */
8const getNodeText = (node: Node | Root) => {
9 const values: string[] =
10 "children" in node
11 ? node.children.map((v) =>
12 "value" in v && typeof v.value === "string"
13 ? v.value
14 : getNodeText(v) || ""
15 )
16 : [];
17 return values.join("");
18};
19
20/**
21 * Header内の文字列をIDとして埋め込み、リンクを作成
22 */
23export const remarkHeadingId: Plugin = () => {
24 return (tree: Root) => {
25 visit(tree, "heading", (node) => {
26 const id = getNodeText(node);
27 node.data = { hProperties: { id } };
28 node.children = [
29 ...node.children,
30 {
31 type: "link",
32 children: [{ type: "text", value: "🔗" }],
33 url: `#${id}`,
34 data: { hProperties: { className: "inner-link" } },
35 },
36 ];
37 });
38 };
39};

コードブロックに追加情報を加える

code と inlineCode をremark-rehype以降で識別可能なようにデータを追加します。

1import { visit } from "unist-util-visit";
2import type { Node, Root } from "mdast";
3import type { Plugin } from "unified";
4
5/**
6 * 子ノードから文字列を抽出
7 */
8const getNodeText = (node: Node | Root) => {
9 const values: string[] =
10 "children" in node
11 ? node.children.map((v) =>
12 "value" in v && typeof v.value === "string"
13 ? v.value
14 : getNodeText(v) || ""
15 )
16 : [];
17 return values.join("");
18};
19
20/**
21 * codeに言語情報、inlineCodeにインラインフラグを追加
22 */
23export const remarkCode: Plugin = () => {
24 return (tree: Root) => {
25 visit(tree, "code", (node) => {
26 node.data = { ...node.data, hProperties: { "data-language": node.lang } };
27 });
28 visit(tree, "inlineCode", (node) => {
29 node.data = { ...node.data, hProperties: { "data-inline-code": "true" } };
30 });
31 };
32};

ノードに対してヘッダーに対応するインデント用の深度情報を与える

<h1><h6>の後続のエレメントに対して、ヘッダのレベル情報を付加しています。この情報によって、ヘッダに対応したインデントを CSS で記述することが可能になります。

1import type { Root } from "mdast";
2import type { Plugin } from "unified";
3
4export const remarkDepth: Plugin = () => {
5 return (tree: Root) => {
6 tree.children.reduce((depth, node) => {
7 if (node.type === "heading") {
8 const index = node.depth;
9 if (index) {
10 return Number(index);
11 }
12 }
13 node.data = {
14 ...node.data,
15 hProperties: {
16 ...node.data?.hProperties,
17 "data-depth": depth,
18 },
19 };
20 return depth;
21 }, 0);
22 };
23};

ノードに対して行番号情報を付与

ポジションを持つノードに対して、行番号の情報を与えます。

1import { visit } from "unist-util-visit";
2import type { Root } from "hast";
3import type { Plugin } from "unified";
4import type { VFile } from "vfile";
5
6/**
7 * 各ノードに行番号とカーソル位置の情報を埋め込む
8 */
9export const rehypeAddLineNumber: Plugin = () => {
10 return (tree: Root) => {
11 visit(
12 tree,
13 "element",
14 (node) => {
15 const start = node.position?.start?.line;
16 const end = node.position?.end?.line;
17 if (node.tagName === "code") {
18 }
19 if (start && end && !node.properties["data-inline-code"]) {
20 node.properties = {
21 ...node.properties,
22 ["data-line"]: start,
23 };
24 }
25 },
26 true
27 );
28 };
29};

<a>target="_blank"を設定

<a>_blankの追加プロパティを与えています。ページ内リンクの場合は何もしません。

1import { visit } from "unist-util-visit";
2import type { Root } from "hast";
3import type { Plugin } from "unified";
4
5export const rehypeAddTargetBlank: Plugin = () => {
6 return (tree: Root) => {
7 visit(tree, "element", (node) => {
8 if (
9 node.tagName === "a" &&
10 typeof node.properties?.href === "string" &&
11 node.properties.href[0] !== "#"
12 ) {
13 node.properties.target = "_blank";
14 node.properties.rel = "noopener noreferrer";
15 }
16 });
17 };
18};

コードにハイライトを加える

こちらはプラグインではなくrehype-reactに加えるオプションです。与えられたコードがハイライトされるようにします。また、変換したノードをキャッシュして、極力処理を省いています。

1import { Highlight, themes } from "prism-react-renderer";
2import { useMemo, type ComponentProps } from "react";
3import prod from "react/jsx-runtime";
4import type { Options as RehypeReactOptions } from "rehype-react";
5import { classNames } from "~/libs/classNames";
6
7const Code = ({
8 ref,
9 children,
10 ...props
11}: ComponentProps<"code"> & {
12 "data-language": string;
13 "data-line": number;
14 "data-inline-code": boolean;
15}) => {
16 const dataLine = Number(props["data-line"] ?? 0);
17 const dataLanguage = props["data-language"];
18 const dataInlineCode = props["data-inline-code"];
19 const component = useMemo(() => {
20 if (dataInlineCode) {
21 return <code data-inline-code>{children}</code>;
22 }
23 return (
24 <Highlight
25 theme={themes.shadesOfPurple}
26 code={String(children)}
27 language={dataLanguage ?? "txt"}
28 >
29 {({ style, tokens, getLineProps, getTokenProps }) => {
30 const numberWidth = Math.floor(Math.log10(tokens.length)) + 1;
31 return (
32 <div
33 style={style}
34 className="overflow-x-auto rounded py-1 font-mono"
35 >
36 {tokens.slice(0, -1).map((line, i) => (
37 <div
38 key={i}
39 {...getLineProps({ line })}
40 data-line={dataLine + i + 1}
41 >
42 <span
43 className={`sticky left-0 z-10 inline-block bg-blue-900 px-2 text-gray-300 select-none`}
44 >
45 <span
46 className="inline-block text-right"
47 style={{ width: `${numberWidth}ex` }}
48 >
49 {i + 1}
50 </span>
51 </span>
52 <span>
53 {line.map((token, key) => (
54 <span
55 key={key}
56 {...getTokenProps({ token })}
57 className={classNames(
58 getTokenProps({ token }).className
59 )}
60 />
61 ))}
62 </span>
63 </div>
64 ))}
65 </div>
66 );
67 }}
68 </Highlight>
69 );
70 }, [dataInlineCode, children, dataLanguage, dataLine]);
71 return component;
72};
73export const rehypeReactOptions: RehypeReactOptions = {
74 ...prod,
75 components: { code: Code },
76};

スタイル設定

Markdown 表示用のスタイルを一括設定します

1@reference "tailwindcss";
2.markdown {
3 @apply px-2;
4 h1,
5 h2,
6 h3,
7 h4,
8 h5,
9 h6 {
10 @apply font-bold border-b-1 mb-2;
11 }
12 [data-depth="2"] {
13 @apply ml-4;
14 }
15 [data-depth="3"] {
16 @apply ml-8;
17 }
18 [data-depth="4"] {
19 @apply ml-4;
20 }
21 [data-depth="5"] {
22 @apply ml-8;
23 }
24 [data-depth="6"] {
25 @apply ml-8;
26 }
27 h1 {
28 @apply text-4xl;
29 }
30 h2 {
31 @apply text-3xl ml-4;
32 }
33 h3 {
34 @apply text-2xl ml-8;
35 }
36 h4 {
37 @apply text-xl ml-12;
38 }
39 h5 {
40 @apply text-lg ml-16;
41 }
42 h6 {
43 @apply text-base ml-20;
44 }
45 p {
46 @apply leading-relaxed p-0.5;
47 }
48 h1,
49 h2,
50 h3,
51 h4,
52 h5,
53 h6,
54 p,
55 li,
56 tr,
57 :global(.token-line) {
58 @apply relative;
59 }
60 em {
61 @apply italic;
62 }
63 b {
64 @apply font-bold;
65 }
66 strong {
67 @apply font-bold;
68 }
69 [data-inline-code] {
70 @apply inline-block bg-black/5 px-1 rounded;
71 }
72 a {
73 @apply underline text-blue-700;
74 }
75 :global(.inner-link) {
76 @apply no-underline text-base;
77 }
78 table {
79 @apply rounded border;
80 }
81 td,
82 th {
83 @apply px-2;
84 }
85 th {
86 @apply border-b;
87 }
88 td {
89 @apply border border-black/20;
90 }
91 li {
92 @apply ml-[1em] list-disc py-0.5;
93 }
94 img,
95 canvas {
96 margin: 0 auto;
97 max-width: 80%;
98 height: auto;
99 }
100 [data-line] {
101 @apply relative;
102 }
103 [data-line]::after {
104 @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;
105 content: "";
106 }
107}

テキストエディタとの連携

テキストエディタには扱いが簡単な Monaco エディタを使用します

1import { Editor as MonacoEditor, type OnMount } from "@monaco-editor/react";
2import styled from "./MarkdownEditor.module.css";
3import type { FC } from "react";
4import { classNames } from "~/libs/classNames";
5
6export const MarkdownEditor: FC<{
7 onCurrentLine: (
8 line: number,
9 top: number,
10 linePos: number,
11 source: string
12 ) => void;
13 onUpdate: (value: string) => void;
14 value: string;
15 refEditor: React.RefObject<Parameters<OnMount>[0] | null>;
16 className?: string;
17}> = ({ onCurrentLine, onUpdate, value, refEditor, className }) => {
18 const handleEditorDidMount: OnMount = (editor) => {
19 refEditor.current = editor;
20 editor.onDidChangeCursorPosition((event) => {
21 const currentLine = event.position.lineNumber;
22 const top = editor.getScrollTop();
23 const linePos = editor.getTopForLineNumber(currentLine);
24 onCurrentLine(currentLine, top, linePos, event.source);
25 });
26 };
27 return (
28 <MonacoEditor
29 className={classNames(styled["markdown-editor"], className)}
30 onMount={handleEditorDidMount}
31 language="markdown"
32 defaultValue={value}
33 onChange={(e) => onUpdate(e ?? "")}
34 options={{
35 renderControlCharacters: true,
36 renderWhitespace: "boundary",
37 automaticLayout: true,
38 scrollBeyondLastLine: false,
39 wordWrap: "on",
40 wrappingStrategy: "advanced",
41 minimap: { enabled: false },
42 dragAndDrop: true,
43 dropIntoEditor: { enabled: true },
44 contextmenu: false,
45 occurrencesHighlight: "off",
46 renderLineHighlight: "none",
47 quickSuggestions: false,
48 wordBasedSuggestions: "off",
49 language: "markdown",
50 selectOnLineNumbers: true,
51 }}
52 />
53 );
54};

Markdown 表示部分

テキストエディタと連携してマークダウン表示をさせます。

1import { useMemo } from "react";
2import { markdownCompiler } from "../markdownCompiler";
3
4export const useMarkdown = ({ markdown }: { markdown?: string }) => {
5 return useMemo(() => {
6 return markdownCompiler.processSync({
7 value: markdown,
8 }).result;
9 }, [markdown]);
10};

マウスクリックに対応して対象ノードの強調表示し、エディタにイベントを送ります。

1import styled from "./MarkdownContent.module.css";
2import { MarkdownHeaders } from "./MarkdownHeaders";
3import type { FC } from "react";
4import { classNames } from "~/libs/classNames";
5import { useMarkdown } from "~/libs/MarkdownConverter";
6
7export const MarkdownContext: FC<{
8 className?: string;
9 markdown?: string;
10 line?: number;
11 onClick?: (line: number, offset: number) => void;
12}> = ({ className, markdown, line, onClick }) => {
13 const [node, tree] = useMarkdown({ markdown });
14
15 return (
16 <div
17 className={classNames(className, styled["markdown"])}
18 onClick={(e) => {
19 const framePos = e.currentTarget.getBoundingClientRect();
20 let node = e.target as HTMLElement | null;
21 while (node && !node.dataset.line) {
22 node = node.parentElement;
23 }
24 if (node) {
25 const p = node.getBoundingClientRect();
26 onClick?.(Number(node.dataset.line), p.top - framePos.top);
27 }
28 }}
29 >
30 <style>{`[data-line="${line}"]:not(:has([data-line="${line}"]))::after {
31 visibility: visible;
32 }`}</style>
33 {node}
34 <MarkdownHeaders tree={tree} />
35 </div>
36 );
37};

ヘッダ情報を収集して、一覧表示を行います

1import { useMemo, type FC } from "react";
2import { visit } from "unist-util-visit";
3import type { Root } from "mdast";
4
5export const MarkdownHeaders: FC<{ tree: Root }> = ({ tree }) => {
6 const headers = useMemo(() => {
7 const titles: { id: number; text?: string; depth: number }[] = [];
8 const property = { count: 0 };
9 visit(tree, "heading", (node) => {
10 titles.push({
11 id: property.count,
12 text: node.data?.hProperties?.id as string | undefined,
13 depth: node.depth,
14 });
15 });
16 return titles;
17 }, [tree]);
18 return (
19 headers.length > 0 && (
20 <ul className="sticky bottom-0 left-full z-10 h-60 w-80 overflow-y-auto rounded bg-white/90 p-2 text-sm">
21 {headers.map(({ id, text, depth }) => (
22 <li key={id} style={{ marginLeft: `${depth * 16}px` }}>
23 <a href={`#${text}`}>{text}</a>
24 </li>
25 ))}
26 </ul>
27 )
28 );
29};

テキストエディタとマークダウン表示を連携させるときは、文字列の更新にuseTransitionを使います。文書量が多い状態で連続で文字を入力したときに、ある程度負荷が回避できます。

1import { useRef, useState, useTransition } from "react";
2import type { OnMount } from "@monaco-editor/react";
3import { MarkdownContext } from "~/components/MarkdownContent";
4import { MarkdownEditor } from "~/components/MarkdownEditor";
5
6const initText = "";
7
8const Page = () => {
9 const [content, setContent] = useState(initText);
10 const refEditor = useRef<Parameters<OnMount>[0]>(null);
11 const [currentLine, setCurrentLine] = useState(1);
12 const refMarkdown = useRef<HTMLDivElement>(null);
13 const [, startTransition] = useTransition();
14 return (
15 <div className="flex h-screen gap-2 divide-x divide-blue-100 overflow-hidden p-2">
16 <div className="flex-1 overflow-hidden rounded border border-gray-200">
17 <MarkdownEditor
18 refEditor={refEditor}
19 value={content}
20 onUpdate={(value) => startTransition(() => setContent(value))}
21 onCurrentLine={(line, top, linePos, source) => {
22 startTransition(() => {
23 setCurrentLine(line);
24 const node = refMarkdown.current;
25 if (node && source !== "api") {
26 const nodes = node.querySelectorAll<HTMLElement>("[data-line]");
27 const target = Array.from(nodes).find((node) => {
28 const nodeLine = node.dataset.line?.match(/(\d+)/)?.[1];
29 if (!line) return false;
30 return line === Number(nodeLine);
31 });
32 if (target) {
33 const { top: targetTop } = target.getBoundingClientRect();
34 const { top: nodeTop } = node.getBoundingClientRect();
35 node.scrollTop =
36 targetTop - nodeTop + node.scrollTop - (linePos - top);
37 }
38 }
39 });
40 }}
41 />
42 </div>
43 <div
44 ref={refMarkdown}
45 className="flex-1 overflow-auto rounded border-2 border-gray-200"
46 >
47 <MarkdownContext
48 markdown={content}
49 line={currentLine}
50 onClick={(line, offset) => {
51 const editor = refEditor.current;
52 const node = refMarkdown.current;
53 if (editor && node) {
54 const linePos = editor.getTopForLineNumber(line);
55 editor.setScrollTop(linePos - offset + node.scrollTop);
56 editor.setPosition({ lineNumber: line, column: 1 });
57 }
58 }}
59 />
60 </div>
61 </div>
62 );
63};
64
65export default Page;

まとめ

unifiedで Markdown 用のプラグインを作る時に気になるのは、定義されている型情報が色々なパッケージに分散している上、定義そのものがかなり中途半端だという部分です。どこから何を持ってくるのかを理解するまでがそれなりに面倒です。