Netlify CMSのプレビューでHightlight.js + KaTeX(LaTeX)をサポートする

HugoのMarkdownで数式組版ライブラリであるKaTeXをサポートするで、Hugoに\(KaTeX\)を組み込みました。\(KaTeX\)は、MathJaxより高速にレンダリングできる、ブラウザで動作する数式組版ライブラリです。

今回は、Netlify CMSのプレビューで、リアルタイムで\(KaTeX\)による変換を行います。また、おまけ要素ですが、ついでにHighlight.jsを組み込み、プレビューでシンタックスハイライトが可能になるようにします。

ブラウザだけで完結します。

2020/6/14更新:Netlify CMSのプレビューでイメージファイルが正常に表示されない

Marked.jsでMarkdownをそのままレンダリングした場合、Netlify CMSへアップロードした画像ファイルがプレビューに表示されない問題があったため、以下のコードを追加しました。

        // 2020/6/14 imageのパスをNetlifyCMSが提供するgetAsset関数で正規のパスに変換
        // 2020/6/14 Netlify CMSのプレビューでイメージファイルが表示されない問題に対応
        const renderer = new marked.Renderer()
        renderer.image = (href, title, text) => {
          if (!href) return text;
          // https://www.netlifycms.org/docs/customization/
          const uri = this.props.getAsset(href).url;
          return `<img src="${uri}" title="${title}" alt="${text}"/>`
        }

Netlify CMS

/static/admin/index.htmlを以下のように書き換えます。

<head>
...
  <!-- KaTeX -->
  <script defer src="https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/katex.min.js" integrity="sha384y23I5Q6l+B6vatafAwxRu/0oK/79VlbSz7Q9aiSZUvyWYIYsd+qj+o24G5ZU2zJz" crossorigin="anonymous"></script>
  <!-- KaTeX Auto rendering Extension -->
  <script defer src="https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/contrib/auto-render.min.js" integrity="sha384-kWPLUVMOks5AQFrykwIup5lo0m3iMkkHrD0uJ4H5cjeGihAutqP0yW0J6dpFiVkI" crossorigin="anonymous"></script>
  <!-- Marked.js -->
  <script src="https://cdn.jsdelivr.net/npm/marked@1.1.0/marked.min.js" integrity="sha256-GGbzkRkTtLnv3bOg61WAnkjYHxtsiVqu+tjMj6ssDVw=" crossorigin="anonymous"></script>
  <!-- highlight.js -->
  <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@10.0.3/build/highlight.min.js"></script>
...
</head>

まず、必要なライブラリを読み込みます。Netlify CMSのMarkdown Parserは、remarkjs/remarkです(記事執筆時点)。Remarkは、ブラウザから利用できません。今回は、ブラウザで利用可能なmarkedjs/markedを代わりに利用します。標準で組み込まれている機能を利用しないのは気が引けますが、現時点で代替手段が見当たりませんでした。(Netlify CMSで公開されているAPIが存在しない)

\(KaTeX\)に必要なスクリプトは、冒頭の記事でご紹介しているため、そちらを参照してください。

<body>
...
  <script>
    <!-- GitHub Markdown(Option) -->
    CMS.registerPreviewStyle("https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/4.0.0/github-markdown.min.css");
    <!-- KaTeX Stylesheet -->
    CMS.registerPreviewStyle("https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/katex.min.css");
    <!-- Hightlight.js Stylesheet -->
    CMS.registerPreviewStyle("https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@10.0.3/build/styles/github-gist.min.css")
  </script>
...

続いて、Netlify CMSへスタイルシートを適用します。スタイルシートは、<head>タグに記述するのではなく、Netlify CMS専用の関数であるregisterPreviewStyleを用いて行います。こうすると、プレビューの<iframe>タグ中の<head>タグ内で自動的に読み込まれます。

なお、GitHub MarkdownのCSSは必須ではありません。GitHub風のスタイルシートを適用するためのCSSファイルであり、お好みです。markdown-bodyというクラスの子要素に対して、スタイルが適用されます。

また、Hightlight.jsを必要としない場合も、CSSファイルを読み込む必要はありません。

<body>
...
 <!-- "import"構文を利用するため"module"として読み込み -->
 <script type="module">
    // JavaScriptでJSXスタイルの構文を利用できる軽量ライブラリ。トランスパイル不要。HTMLのレンダリング目的のみで利用
    import htm from 'https://unpkg.com/htm?module';
    // Netlify CMSのh関数(React.createElementのエイリアス)のバインド
    const html = htm.bind(h);

    const Post = createClass({
      render() {
        const entry = this.props.entry;
        const title = entry.getIn(['data', 'title'], null);
        // bodyが空の場合は、空文字列とする。デフォルトはnull。nullだと後述のMarkdown変換の際にエラーとなる
        let body = entry.getIn(['data', 'body'], '');
        // KaTeXのレンダリング関数へ渡すためのDOMを生成
        let div = document.createElement('div');

        // 2020/6/14 imageのパスをNetlifyCMSが提供するgetAsset関数で正規のパスに変換
        // 2020/6/14 Netlify CMSのプレビューでイメージファイルが表示されない問題に対応
        const renderer = new marked.Renderer()
        renderer.image = (href, title, text) => {
          if (!href) return text;
          // https://www.netlifycms.org/docs/customization/
          const uri = this.props.getAsset(href).url;
          return `<img src="${uri}" title="${title}" alt="${text}"/>`
        }

        // Marked.jsのオプションでhighlight.jsをサポート
        // https://madogiwa0124.hatenablog.com/entry/2019/01/03/203334
        marked.setOptions({
          highlight: function (code, lang) {
            return hljs.highlightAuto(code, [lang]).value;
          },
          renderer: renderer,
        });
        // 事前に生成したDOMにMarkdown→HTML変換済みの文字列を格納
        div.innerHTML = marked(body);
        // KaTeXのAuto Rendering Extensionを利用して変換
        // デフォルトのデリミタから変更
        // Hugo 0.85.0でのMarkdownパーサの仕様変更に伴う対応
        renderMathInElement(div, {
          delimiters: [
            // display: true はHTMLのBlock Element
            { left: '$$', right: '$$', display: true },
            // display; falseはHTMLのInline Element
            { left: '\\(', right: '\\)', display: false },
          ],
        });

        // htmによるレンダリング。dangerouslySetInnerHTMLを利用する場合、事前にサニタイズによるXSS対策を推奨
        return html`
          <body>
            <main>
              <article class="markdown-body">
                <h1>${title}</h1>
                <div dangerouslySetInnerHTML=${{ __html: div.innerHTML }}></div>
              </article>
            </main>
          </body>
        `;
      },
    });

    CMS.registerPreviewTemplate('blog', Post);
  </script>
...
</body>

Netlify CMSの実装は、Reactコンポーネントの集まりです。

Netlify CMS用にカスタマイズされたcreateClass関数、およびReact.createElementのエイリアスであるh関数を利用して、Reactコンポーネントを作成します。createClass関数でコンポーネントを宣言し、Netlify CMS専用のregisterPreviewTemplate関数でコンポーネントを登録します。

createClass関数で宣言したコンポーネントで実装したクラスに含める必要のある関数は、render関数のみです。戻り値は、React.Elementです。通常、h関数による実装を行いますが、記述方法が直感的でないことと、JSXライクな構文を利用できるdevelopit/htmが便利であるため、こちらを利用しています。

Markdownパーサとして、ブラウザで利用可能なmarkedjs/markedを使用します。また、シンタックスハイライト用のライブラリで、Hugoでも標準で使用されているhighlight.jsと連携が可能です。詳細は、上記のソースコードを参照してください。setOptions関数を使用します。

ポイントとなる\(KaTeX\)との連携は、少々小細工しています。\(KaTeX\)ライブラリでは、renderrenderToString関数の2種類が用意されています。前者は指定したDOMへ描画、後者文字列として変換後の文字列を返却する関数です。いずれの関数も、前提としてHTMLでマークアップしたい文字列のみ、あらかじめ抽出した上で関数へと渡す必要があります。Markdownから、特定の文字列(例:$$)に囲まれた部分を抽出し、renderToString関数でHTMLに変換する、もしくはMarked.jsのプラグインとして別途実装するという方法も考えられます。しかし、高度な正規表現や、別途ライブラリを用意する必要があるため手間がかかります。

そこで、利用したいのが、\(KaTeX\)のAuto-render Extension · KaTeXです。renderMathInElement関数のみが用意されている、シンプルなライブラリです。HTMLElementを引数として渡すと、HTMLを解釈して必要な部分のみ自動的に変換してHTMLElementとして返却してくれる便利なライブラリです。renderMathInElement関数を利用するためには、事前にHTMLElementを生成して準備しておく必要があることから、今回はダミーのdivElementを生成しています。より良い方法はあるかもしれません。

なお、renderMathInElement関数のオプションとして、デリミタや無視するHTMLタグ等を変更できます。今回は、デリミタを$$$に変更しました。無視するタグは、デフォルトでは<pre><code><script>等です。通常はデフォルトのままで良いでしょう。その他、無視する(変換したくない)クラス名を指定することもできます。

最後に、htmによるレンダリングです。事前に生成したdivElementを埋め込みます。dangerouslySetInnerHTMLを宣言することで、生のHTMLをそのまま埋め込めますが、悪質なスクリプト実行を防ぐめたのXSS対策をした方が良いでしょう。利用しなくて良い方法があれば、そちらを利用してください。

参考リンク

comments powered by Disqus