chocolat

tips_nextjs

Next.js + Markdown (marked) で作るブログサイト

September 01, 2022

当サイトでは記事は全てMarkdownファイルで作成・管理しています。Markdown ファイルをサイトで表示させるためにはHTMLに変換する必要があります。Markdown を解析してHTMLに変換する軽量のコンパイラ marked を使用しています。

marked 公式ドキュメント
marked GitHub

marked を使用した Next.js での Markdown のカスタマイズ方法を紹介します。(記事内のコードはTypeScriptで記述しています。)

インストール

npm install marked

sanitize(XSS攻撃対策)

sanitize (サニタイズ・サニタイジング)とは

《消毒の意》インターネット上で、掲示板(BBS)などの入力フォームに入力されたテキストデータから、HTMLのタグやスクリプト言語などを検出し、別の安全な文字列に置き換えること。悪意あるプログラムなどが実行されるのを防ぐためのもの。無害化。サニタイズ。サニタイゼーション。
引用: サニタイジングとは何?わかりやすく解説 Weblio辞書

marked は出力するHTMLを sanitize しないため、sanitize 用のライブラリで別途対応する必要があります。公式ドキュメントでは sanitize 用のライブラリとして、DOMPurify が推奨されており、ほかに js-xsssanitize-htmlinsane が紹介されています。

当サイトでは公式推奨の DOMPurify を使用しますが、Next.js の SSG や ISR を使用しているためか公式サイトの記述そのままでは機能しませんでした。

DOMPurify.sanitize is not a function」 の issue を参考に isomorphic-dompurify を使用することでエラーが解消され、Markdownが表示されるようになりました。

インストール

npm i isomorphic-dompurify

導入例

import DOMPurify from 'isomorphic-dompurify';
import { marked } from 'marked';

const Post = ({ post }) => {
  const dirty = marked(post);
  return (
    <article dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(dirty) }} />
  );
}

シンタックスハイライト(Prism.js)

記事内のコードブロックのシンタックスハイライトには Prism.js を使用しています。

marked のThe parse function を使用して設定します。

インストール

npm i prismjs

導入例

import Prism from 'prismjs';
import { marked } from 'marked';

const Post = ({ post }) => {
  const renderer = new marked.Renderer();
  
  // Set options
  marked.setOptions({
    renderer,
    highlight: function (code, lang) {
      if (Prism.languages[lang]) {
        return Prism.highlight(code, Prism.languages[lang], lang)
      } else {
        return code
      }
    }
  });

  return (
    <article dangerouslySetInnerHTML={{ __html: marked.parse(post) }} />
  );
}

目次(Table of Contents)

parser に渡されるトークンの配列を構築する lexer を使用します。

marked.lexer(post)

以下のようなMarkdown本文のトークン配列が取得できます。

[
  {
    type: 'heading',
    raw: '## Headline Text',
    depth: 2,
    text: 'Headline Text',
    tokens: Array(1)
  },
  {
    type: 'paragraph',
    raw: 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod te'
  },
  {
    type: 'heading',
    raw: '### Headline Text',
    depth: 3,
    text: 'Headline Text',
    tokens: Array(1)
  },
  {
    type: 'paragraph',
    raw: 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod te'
  },
  {
    type: 'paragraph',
    raw: 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod te'
  },
]

取得したトークン配列にフィルターをかけて、見出しに当たる heading のみを取得して、 map() でhtmlタグなどを適宜追加して整えます。

h2, h3 などの見出しのレベルは depth で取得できるので、当サイトではカスタムデータ属性の data-*depth を設定して見出しのレベルによってCSSを変えられるようにしています。

導入例

import { marked } from 'marked';

const Post = ({ post }) => {
  let toc;
  // トークン配列を取得
  const tokens = marked.lexer(post);
  // フィルターをかけて見出しのみを取得
  const headings = tokens.filter(token => token.type === "heading");
  // map()で整える
  toc = headings.map((heading: any, index: any) => {
    let target = heading.text.replace(/ /g, "-").replace(".", "").toLowerCase()
    return (
      <li key={index} data-depth={heading.depth}>
        <Link href={`#${target}`}>
          <a>{heading.text}</a>
        </Link>
      </li>
    )
  });

  return (
    <ul>{toc}</ul>
  );
}

出力されるhtmlタグのカスタマイズ

renderer を使用して特定の html 出力を定義することができます。

カスタマイズできるタグ例

  • code(string code, string infostring, boolean escaped)
  • list(string body, boolean ordered, number start)
  • paragraph(string text)
  • link(string href, string title, string text)
  • image(string href, string title, string text) など

その他のオプション等は公式ドキュメントでご確認ください。

導入例: link(aタグ)

import { marked } from 'marked';

const Post = ({ post }) => {
  const renderer = new marked.Renderer();

  renderer.link = function (href: string, title: string, text: string) {
    return `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`
  }

  marked.setOptions({
    renderer,
  });

  return (
    <article dangerouslySetInnerHTML={{ __html: marked.parse(post) }} />
  );
}

Reference 参照