ハウテレビジョンブログ

『外資就活ドットコム』『Liiga』『Mond』を開発している株式会社ハウテレビジョンのブログです。

unified, remark, rehypeでマークダウンをHTMLに変換する

ハウテレビジョンでフロントエンドエンジニアをやっている菅と申します。

外資就活ドットコムでは技術負債解消を進めております。その中で、就活コラム(https://gaishishukatsu.com/column/categories)の改修に着手しており、Github上でマークダウン形式で記事を管理する方式にリプレースを進めています。

現在就活コラムはCMSで運用していますが、様々な制約があるために記事の品質管理と機能開発に時間的コストがかかっています。脱CMSによってこれを解消し、よりよいコンテンツを作っていくことが狙いです。

本記事ではマークダウン記法の文字列をHTML形式に変換してNext.jsで描画する方法を紹介します。

使用するものの紹介

主に unified, remark, rehype を使用します。remarkはNext.js公式でも紹介されているライブラリです。

https://nextjs.org/learn-pages-router/basics/dynamic-routes/render-markdown

代替として https://github.com/micromark/micromark も検討しましたが、こちらのみの使用ではマークダウン内にHTMLがある場合に単に文字列として判定されてしまい、描画の際にHTMLとしてブラウザに評価されなかったため、remarkを使用することにしました。

ライブラリ

プラグイン

それぞれのライブラリのプラグインは以下を使用します。

実装

マークダウンからHTMLへ

前述したライブラリとプラグインを使ってマークダウン形式の文章をHTML形式に変換します。

import rehypeStringify from 'rehype-stringify';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import {unified} from 'unified';

const turnMarkdownToHtml = async (markdown: string): Promise<string> => {
  const parsed = await unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkRehype)
    .use(rehypeStringify)
    .process(markdown);

  return parsed.toString();
};

const CONTENTS = `
# h1 大見出し

## h2 中見出し

### h3 小見出し

**太字**

*斜体*

~~取り消し線~~

***太字斜体***

\`\`\`ruby
   puts "Hello World"
\`\`\`

\`\`\`mermaid
graph TD;
    A-->B;
    A-->C;
    B-->D;  
    C-->D;
\`\`\`

`

console.log(turnMarkdownToHtml(CONTENTS))
// 出力結果
<h1>h1 大見出し</h1>
<h2>h2 中見出し</h2>
<h3>h3 小見出し</h3>
<p>
    <strong>太字</strong>
</p>
<p>
    <em>斜体</em>
</p>
<p>
    <del>取り消し線</del>
</p>
<p>
    <em><strong>太字斜体</strong></em>
</p>
<pre>
    <code class="language-ruby">
        puts "Hello World"
    </code>
</pre>
<pre>
    <code class="language-mermaid">
        graph TD;
    A--&gt;B;
    A--&gt;C;
    B--&gt;D;  
    C--&gt;D;
    </code>
</pre>

できました。主なマークダウン記法での表現は問題なくHTMLに変換されました。

コードブロックに言語指定のclassが自動で付与されるので、CSSの設定次第でリッチな表示にすることも可能です。

変換ができなかった事例としては

がありました。

const CONTENTS = `
This is a <sub>subscript</sub> text

This is a <sup>superscript</sup> text

<details>

<summary>Tips for collapsed sections</summary>

You can add text within a collapsed section. 

</details>

`

console.log(turnMarkdownToHtml(CONTENTS))
// 出力結果
<p>This is a subscript text</p>
<p>This is a superscript text</p>
<p>You can add text within a collapsed section.</p>

classの自動付与がないため、CSSで解決することも難しそうです。また、<details> ブロックの <summary> 内の文章が出力されていません。

GFMのプラグインを介しても特殊タグは変換の範囲外のようです。

スタイルの付与

rehypeの公式プラグインでは指定したタグにclassやstyleプロパティを付与するものはありません。非公式プラグインでは作ってくださっている方がいらっしゃいます。

https://github.com/martypdx/rehype-add-classes

今回は、プラグインを増やすと学習コストが増えること、複雑なセレクタ指定を使ったマークアップはしないことを理由に、正規表現でclassを付与していく方針をとりました。

const markupRule: MarkupRuleType = {
    h1: '任意のクラス',
  h2: '任意のクラス',
  h3: '任意のクラス',
    .
    .
    .
};

const addStyleToHtml = (html: string): string => {
  let addedStyle = html;
  for (const key of Object.keys(markupRule) as MarkupRuleKeyType[]) {
    const regExp = new RegExp(`<${key}(?=s|>)`, 'g'); // Ex. <h2 or <h2>
    addedStyle = addedStyle.replaceAll(
      regExp,
      `<${key} class='${markupRule[key]}'`,
    );
  }

  return addedStyle;
};

export const parseContent = async (markdown: string): Promise<string> => {
  const turnedToHtml = await turnMarkdownToHtml(markdown);

  return addStyleToHtml(turnedToHtml);
};

Next.jsで描画する

下記のように dangerouslySetInnerHTML を使ってHTMLをcomponentに描画します。

参考: https://nextjs.org/learn-pages-router/basics/dynamic-routes/render-markdown

export default function Post({ postData }) {
    const parsedContent = await parseContent(postData);

  return (
    <Layout>
      <div dangerouslySetInnerHTML={{ __html: parsedContent }} />
    </Layout>
  );
}

これで完了です。

おわりに

今回は正規表現で乗り切りましたが、もっと高度なCSS設定がしたい場合はremarkやrehypeのプラグインを使うor作る方がよさそうです。

CMSに頼らない記事管理システム開発の一助となればと思います。