Nextjs / TailwindCSS 環境でunifiedを使ってMarkdown to JSXを実装したときのメモ

2024-07-02T13:46:12.380Z

Markdownすこすこ系エンジニアのtaconaです。
すでにObsidian(Markdown Editor)がないエンジニア生活には戻れなくなっています。

このサイトを構築するにあたってブログをどうやって管理できるようにしようかな〜〜と考えていた時に、unifiedjsに出会って運命を感じたので使ってみました。(厳密には存在は元々知っていたんですが、あまり活用したことがなかった感じです)

このブログは表題に書いてあるとおりでNext.jsで構築したアプリケーションにTailwindCSSを導入しています。
Markdownの書式に対応するHTML要素にTailwindCSSのユーティリティクラスを適用するために一部自作でプラグインを書いたんですが、それを除けば9割エコシステムの軌道に乗るだけでMarkdownの解析からJSXでのレンダリングまで出来てしまったので、その感動の勢いのままにメモしていきます。

unifiedとは?

公式の説明(unifiedjs)を読み込むのが最適ですが、「特定のコンテンツを対応する抽象木構文に変換し、別のコンテンツに変換する」といったことが可能なエコシステムの総称です。

unifiedjs/unified自体はパーサーや変換の機能は持っておらず、コンテンツの種類ごとに関連する個別のエコシステムやプラグインが別途提供されています。

今回はMarkdownをJSXに変換するというのが目的ですが、正確には「Markdownをmdastに変換して、mdastをhastに変換してからJSXとして出力する」といったことをしています。(これに加えて、さらに途中でプラグインを挟んだりして挙動をカスタマイズできます)
これだけだと「パルスのファルシのルシがコクーンでパージ」みたいな文章に見えてしまうので、もう少し噛み砕いて表現します。

先ほどunifiedは特定のコンテンツを抽象木構文に変換する仕組みを持っていると書いたんですが、コンテンツごとに抽象木構文(AST)が定義されていて、それぞれに名称がついています。
MarkdownであればmdastHTMLであればhastといった具合で言語の省略表記 + astみたいな命名規則になっているっぽいです。

なので、先ほどの呪文をもう少し丁寧に表現すると、
「MarkdownテキストをMarkdown向けの抽象木構文(mdast)に変換し、mdastをHTML向けの抽象木構文(hast)に変換した上で、最終的にJSXを出力する」といったことをしたい。ということになります。

最初はこの概念に慣れてなくて変換の手順が多いなと感じたんですが、一旦ASTに変換するこの仕組みのおかげでコンテンツ間の変換がめちゃくちゃ柔軟に出来るようになっていて、今ではASTをconsole.logしてNodeを眺めてニヤニヤ出来るようになりました。

今回利用するunifiedライブラリ・プラグインたち

Markdownを解析してJSXを出力したい場合、単純に変換するだけならunifiedによって提供されているプラグインのみで実現できてしまいます。便利過ぎて爆発しそう。(?) 今回やりたいMarkdown to JSXのために利用するライブラリ・プラグインを以下に列挙します。これらすべてがunifiedのコミュニティによって提供されているため、信頼度がとても高いです。

ちなみに、remarkrehypeのように特定のコンテンツ専用のエコシステムも提供されています。
今回のようにMarkdownをJSXに変換するといった別のコンテンツとして出力させたい場合は利用しませんが、特定のコンテンツに対する操作のみ(Markdownを解析してMarkdownを出力するなど)であればunifiedのプロセッサを用いずとも更に小さなエコシステム上で実現できるみたいです。

Sample Code

事前に利用するライブラリ・プラグイン群をインストールしておきましょう。
なお、Next.jsなどの前提となるアプリケーションの構築は完了している前提とします。

pnpm add unified remark-parse remark-rehype rehype-sanitize rehype-highlight rehype-react

また、rehype-highlightを使う場合は別途highlightjsもインストールしましょう。

pnpm add highlight.js

これらのunifiedのエコシステムを使った場合、以下のようなシンプルなコードでMarkdown to JSXが実現します。

import { Fragment, jsx, jsxs } from 'react/jsx-runtime'
import rehypeHighlight from 'rehype-highlight'
import rehypeReact from 'rehype-react'
import rehypeSanitize from 'rehype-sanitize'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import { unified } from 'unified'

/**
 * Markdonw形式の文字列を受け取ってJSX.Elementに変換する
 *
 * @param content Markdown形式の文字列
 * @returns JSX.Element
 */
export const parseMarkdownToJsx = (content: string) => {
  const { result } = unified()
    .use(remarkParse)
    .use(remarkRehype)
    .use(rehypeSanitize)
    .use(rehypeHighlight)
    // @ts-expect-error: the react types are missing.
    .use(rehypeReact, { Fragment, jsx, jsxs })
    .processSync(content)

  return result
}

あとはこのparseMarkdownToJsxに対してMarkdownテキストを渡し、結果として生成されたJSXを画面に出力しましょう。
以下はNextjsのApp RouterにおけるSSGの場合を想定したサンプルです。

import React from 'react'

import { parseMarkdownToJsx } from '@/libs/parser'

export const PostPage: React.FC = async () => {
  const contentComponent = parseMarkdownToJsx('# Hello World!')

  return (
    <div>
      <h1 className="text-XXL">コンテンツタイトル</h1>
      <div className="flex flex-col gap-1">
        {/* <h1>Hello World!</h1> */}
        {contentComponent}
      </div>
    </div>
  )
}

あとは、rehype-highlightによってコードブロックが整形されているため、highlightjsの好きなテーマのスタイルをimportして、装飾が反映されるようにしましょう。
Nextjs(App Router)の場合は、最上位のlayout.tsxなどで以下をのように好きなテーマをimportします。

import 'highlight.js/styles/atom-one-dark.min.css'

これで基本的な変換処理および表示処理は完成です。簡単すぎる〜〜〜〜。

最後に、要素ごとにTailwindCSSのユーティリティクラスによるスタイルを適用するための自作プラグインのサンプルを紹介します。

要素ごとにTailwindCSSのユーティリティクラスを付与するrehypeプラグイン

今回のような最終的に出力されるHTMLに対する加工(class属性の追加など)をする場合は、hastのプラグインを作成することになります。
まずは、プラグインで利用するためのhastの型定義ファイルを導入しておきましょう。

pnpm add -D @types/hast

unifiedのプラグインの作成方法については公式がCreating a plugin with unifiedというチュートリアルを用意してくれているので、それを参考にするといいかも。

先に完成品のコードをぺたり。

import { Element, Nodes } from 'hast'

// 要素ごとに適用したいクラス名を定義
// NOTE: 一部TailwindCSSのトークンが自作で用意しているものになっているので、利用する場合は雰囲気で調整してください。
const ElementMap: Record<string, string> = {
  // 見出し
  h1: 'text-h1 font-semibold border-b-[1px] border-border-grey',
  h2: 'text-h2',
  h3: 'text-h3 font-semibold',
  h4: 'text-h4',
  h5: 'text-h5 font-semibold',
  h6: 'text-h6',
  // リスト
  ul: 'list-disc pl-2',
  ol: 'list-decimal pl-2',
  // コード
  code: 'bg-code px-0.25 py-[2px] rounded-md border-[1px] border-border-code',
  // リンク
  a: 'text-link border-b-[1px] border-link',
  // 引用文
  blockquote: 'text-blockquote border-l-[4px] border-border-blockquote pl-1',
}

export const rehypeAddClass = () =>
  function (node: Nodes, _vfile: any, done: any) {
    if (node.type === 'root' && node.children.length > 0) {
      node.children.forEach((node) => {
        if (node.type === 'element') addClass(node)
      })
    }
    done()
  }

const addClass = (node: Element) => {
  // NOTE: pre要素配下はrehype-highlightでhighlight.jsの装飾で表現するため何もしない
  if (node.type === 'element' && node.tagName === 'pre') return

  // className 付与
  if (ElementMap[node.tagName]) node.properties.className = ElementMap[node.tagName]

  // 子要素を持っている場合は再起的に評価
  if (node.children.length > 0) {
    node.children.forEach((node) => {
      if (node.type === 'element' && node.children.length > 0) addClass(node)
    })
  }
}

hastはroot NodeからDOM構造と同じ階層構造で親子関係を持っているので、再帰的にNodeを検証して、tagNameの要素名ごとに任意のユーティリティクラスを追加する。みたいなことをやっています。
また、今回はpre要素配下のコンテンツ編集はrehype-highlightによって行なうため、tagNameがpreであればその配下のNodeに対する処理をスキップするようにしています。(すごい雑な作りで実装していますが...)

あとは、先ほどのunifiedのプロセッサで以下のように呼び出してあげるだけです。(一部import文を省略しています)

// プラグインの配置場所から参照
import { rehypeAddClass } from './plugins/rehype-add-class'

/**
 * Markdonw形式の文字列を受け取ってJSX.Elementに変換する
 *
 * @param content Markdown形式の文字列
 * @returns JSX.Element
 */
export const parseMarkdownToJsx = (content: string) => {
  const { result } = unified()
    .use(remarkParse)
    .use(remarkRehype)
    .use(rehypeSanitize)
    // 要素ごとにtailwindcssのクラスを用意するための自作プラグイン
    .use(rehypeAddClass)
    .use(rehypeHighlight)
    // @ts-expect-error: the react types are missing.
    .use(rehypeReact, { Fragment, jsx, jsxs })
    .processSync(content)

  return result
}

おわり

正直エコシステムの軌道に乗るだけでほとんどの処理が完結するので、どちらかというとunifiedの全体像の把握や理解のための調べ物の時間の方が長かったかも。Usageを理解するとあとは有り物を使うだけで大抵のことはなんとかなるので本当に良く出来たエコシステムだな〜〜と思いました。
開発者としてはMarkdownを使ってドキュメントを管理するシチュエーションはまだまだ多いと感じているので、何かの機会にunified(もしくはremark)のエコシステムを別の用途に応用してみたいなと思いました。おわり〜