空雲 Blog

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

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

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

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

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

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

unified に関して

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

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

  • 単体では動作しない

記事の有効性判別

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

  • 有効なパターン

import { unified } from "unified";

  • 駄目なパターン

import 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のインスタンスを必要とするので注意してください。

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() .use(remarkParse) .use(remarkRehype) .use(rehypeReact, { ...prod, }) .processSync("markdown");

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

基本部分

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

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

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

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

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

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() // ASTの作成 .use(remarkParse) // 表やテキスト中のリンクなど変換を追加 .use(remarkGfm) // 段落内の改行を有効に .use(remarkBreaks) // 空行を復元 .use(remarkEmptyParagraphs) // ヘッダにIDとリンクを付ける .use(remarkHeadingId) // コードブロックに追加情報を加える .use(remarkCode) // ノードに対してヘッダーに対応するインデント用の深度情報を与える .use(remarkDepth) // HAST(HTML用のASTに変換) .use(remarkRehype, { allowDangerousHtml: true, }) // ノードに対して行番号情報を付与 .use(rehypeAddLineNumber) // 埋め込みHTMLを有効にする .use(rehypeRaw) // aタグにtarget="_blank"を設定 .use(rehypeAddTargetBlank) // Reactコンポーネントに変換 .use(rehypeReact, rehypeReactOptions) // 出力情報を[Reactコンポーネント,MdastTree]の形式に変換 .use(compilerResultTree);

プラグインの作り方

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

空行を復元

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

import type { Root as MdastRoot, RootContent } from "mdast"; import type { Plugin } from "unified"; /** * 空白行をbreakに変換する */ 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]; }); }; };

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

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

import { visit } from "unist-util-visit"; import type { Node, Root } from "mdast"; import type { Plugin } from "unified"; /** * 子ノードから文字列を抽出 */ 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(""); }; /** * Header内の文字列をIDとして埋め込み、リンクを作成 */ 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" } }, }, ]; }); }; };

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

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

import { visit } from "unist-util-visit"; import type { Node, Root } from "mdast"; import type { Plugin } from "unified"; /** * 子ノードから文字列を抽出 */ 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(""); }; /** * codeに言語情報、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" } }; }); }; };

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

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

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); }; };

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

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

import { visit } from "unist-util-visit"; import type { Root } from "hast"; import type { Plugin } from "unified"; import type { VFile } from "vfile"; /** * 各ノードに行番号とカーソル位置の情報を埋め込む */ 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 ); }; };

<a>target="_blank"を設定

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

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"; } }); }; };

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

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

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 }, };

スタイル設定

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

@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: ""; } }

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

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

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 表示部分

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

import { useMemo } from "react"; import { markdownCompiler } from "../markdownCompiler"; export const useMarkdown = ({ markdown }: { markdown?: string }) => { return useMemo(() => { return markdownCompiler.processSync({ value: markdown, }).result; }, [markdown]); };

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

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> ); };

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

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> ) ); };

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

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;

まとめ

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