unified を使って Markdown を React コンポーネントへ変換する
今回の記事に使用しているサンプルプログラム
テキストエディタで編集したマークダウンをプレビュー出来るようにします。また、編集位置のハイライトやタイトル一覧表示機能の実装も行います。
https://github.com/SoraKumo001/react-router-markdown

unified を扱う上で知っておいたほうが良いこと
unified に関して
特定の文書フォーマットを抽象構文木(AST)で扱うためのライブラリ
プラグインによって拡張していく
単体では動作しない
記事の有効性判別
駄目なパターンは旧バージョンの書き方なので、検索などで引っ掛けてしまった場合はスルーしてください
有効なパターン
1import { unified } from "unified";
駄目なパターン
1import unified from "unified";
Markdown 変換 AST の流れ
| フェーズ | 処理内容 | プラグイン |
|---|---|---|
| Parser | Markdown を AST に変換 | remark-parse |
| Transformer | HTML の構造に近い AST に変換 | remark-rehype |
| Compiler | ReactComponent に変換 | rehype-react |
Markdown を React コンポーネントへ変換する手段としてunifiedに必要なプラグインがあらかじめ組み込まれているreact-markdownを使うという選択肢もありますが、カスタマイズすることを考えると、直接unifiedを扱った方が柔軟性が増します。
mdast に関して
remark-parseによる Markdown の変換は内部でmdastを使用しています。直接パッケージを使うことは非推奨となっているようですが、remark-parse以降の AST を TypeScript で操作する場合は、@types/mdastが必要になります。
unified を使う最低限の記述
最低限の実装は以下のようになります。rehype-reactはreact/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";67export 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";1819export 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";34/**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";45/**6 * 子ノードから文字列を抽出7 */8const getNodeText = (node: Node | Root) => {9 const values: string[] =10 "children" in node11 ? node.children.map((v) =>12 "value" in v && typeof v.value === "string"13 ? v.value14 : getNodeText(v) || ""15 )16 : [];17 return values.join("");18};1920/**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";45/**6 * 子ノードから文字列を抽出7 */8const getNodeText = (node: Node | Root) => {9 const values: string[] =10 "children" in node11 ? node.children.map((v) =>12 "value" in v && typeof v.value === "string"13 ? v.value14 : getNodeText(v) || ""15 )16 : [];17 return values.join("");18};1920/**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";34export 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";56/**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 true27 );28 };29};
<a>にtarget="_blank"を設定
<a>に_blankの追加プロパティを与えています。ページ内リンクの場合は何もしません。
1import { visit } from "unist-util-visit";2import type { Root } from "hast";3import type { Plugin } from "unified";45export 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";67const Code = ({8 ref,9 children,10 ...props11}: 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 <Highlight25 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 <div33 style={style}34 className="overflow-x-auto rounded py-1 font-mono"35 >36 {tokens.slice(0, -1).map((line, i) => (37 <div38 key={i}39 {...getLineProps({ line })}40 data-line={dataLine + i + 1}41 >42 <span43 className={`sticky left-0 z-10 inline-block bg-blue-900 px-2 text-gray-300 select-none`}44 >45 <span46 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 <span55 key={key}56 {...getTokenProps({ token })}57 className={classNames(58 getTokenProps({ token }).className59 )}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";56export const MarkdownEditor: FC<{7 onCurrentLine: (8 line: number,9 top: number,10 linePos: number,11 source: string12 ) => 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 <MonacoEditor29 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";34export 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";67export 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 });1415 return (16 <div17 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";45export 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";56const initText = "";78const 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 <MarkdownEditor18 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 <div44 ref={refMarkdown}45 className="flex-1 overflow-auto rounded border-2 border-gray-200"46 >47 <MarkdownContext48 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};6465export default Page;
まとめ
unifiedで Markdown 用のプラグインを作る時に気になるのは、定義されている型情報が色々なパッケージに分散している上、定義そのものがかなり中途半端だという部分です。どこから何を持ってくるのかを理解するまでがそれなりに面倒です。