📖

Docusaurusで数式をクライアントサイドレンダリングする

2023/01/15に公開

Docusaurusは静的サイトジェネレータなので、すべてのページがビルド時にレンダリングされます.
DocusaurusのマークダウンからHTMLにレンダリングする部分は remarkrehype が処理しています.数式を有効にするには、Docusaurusのドキュメント によれば、remark-mathrehype-katex (または rehype-mathjax)を使います.
しかし、2,3個の数式ならともかく、それなりの数があるとビルドに時間がかかるようになり、生成されるhtmlやjsファイルが肥大化します.もちろん、数式も含めて事前にレンダリングするので、閲覧する場合はブラウザで数式の処理が入らず高速に表示できますが、個人的にはデメリットの方が大きいので、数式処理はクライアントサイドでしたいところです.ここでは、その方法を記録しておきます.

📌必要なスクリプトやCSSファイルを読み込む

今回は katex を使って数式処理をします.バージョンは 0.16.4 です.
必要なファイルは以下の通りです.

Docusaurusでこれらを読み込むようにするには docusaurus.config.jsscripts および stylesheets で指定します.

./docusaurus.config.js
const config = {
  scripts: [
    {
      src: "https://cdn.jsdelivr.net/npm/katex@0.16.4/dist/katex.min.js",
      integrity: "sha384-PwRUT/YqbnEjkZO0zZxNqcxACrXe+j766U2amXcgMg5457rve2Y7I6ZJSm2A0mS4",
      crossorigin: "anonymous",
      defer: true,
    },
    {
      src: "https://cdn.jsdelivr.net/npm/katex@0.16.4/dist/contrib/auto-render.min.js",
      integrity: "sha384-+VBxd3r6XgURycqtZ117nYw44OOcIax56Z4dCRWbxyPt0Koah1uHoK0o4+/RRE05",
      crossorigin: "anonymous",
      defer: true,
    }
  ],
  stylesheets: [
    {
      href: 'https://cdn.jsdelivr.net/npm/katex@0.16.4/dist/katex.min.css',
      type: 'text/css',
      integrity: 'sha384-vKruj+a13U8yHIkAyGgK1J3ArTLzrFGBbBc0tDp4ad/EyewESeXE/Iv67Aj8gKZ0',
      crossorigin: 'anonymous',
    },
  ],
}

📌数式をレンダリングする

クライアントサイドでレンダリングするには、ページがロードし終わったときに renderMathInElement を呼び出す必要があります.
そのために、プラグインを作ります.ここでは ./plugins/docusaurus-plugin-katex-client というプラグインを作ります(以降、katex-clientプラグイン).以下のようなフォルダ・ファイル構成にします.

└─ plugins
    └─ docusaurus-plugins-katex-client
        ├─ index.js
        └─ render.js

ここから、どのような仕組みになっているか説明します.

🔹プラグイン

Docusaurusに機能を追加するにはさまざまな方法があります. プラグイン はその1つです.Docusaurusのプラグインは公式サイトによれば、次のように説明されています.

Docusaurus' implementation of the plugins system provides us with a convenient way to hook into the website's lifecycle to modify what goes on during development/build, which involves (but is not limited to) extending the webpack config, modifying the data loaded, and creating new components to be used in a page.

要するに、開発やビルドの中でフックして処理を入れることができるよ、という自分なりの解釈です.katex-clientプラグインでは、すべてのページ(グローバル)に対してフックし、特定のページだけ処理するといったようにします.

🔹Client Modules

追加のJavascriptをすべてのページで処理するために Client Modules を使います. 具体的には renderMathInElement を呼び出すJavascriptファイル(render.js)をClient Moduleとして指定します.指定する場所はプラグインファイル(index.js)です.

./plugins/docusaurus-plugin-katex-client/index.js
const path = require("path");
module.exports = function (context, options) {
  return {
    name: 'docusaurus-plugin-katex-client',
    getClientModules() {
      return [path.resolve(__dirname, "./render")];
    },
  };
};

🔹ExecutionEnvironment

DocusaurusのSSGについてはこちらに説明があります.プラグインは開発やビルドにも影響があるため、ブラウザに表示されるときのみ処理するようにしたいです.そのために ExecutionEnvironment を使います.ExecutionEnvironment.canUseDOM でブラウザに表示されるかどうかが判定できます.

import ExecutionEnvironment from "@docusaurus/ExecutionEnvironment";

export default (function () {
  if (!ExecutionEnvironment.canUseDOM) {
    return null;
  }

  // browser only
}

次に、ページがロードし終わったときに処理するためには Client Module のライフサイクル関数 onRouteDidUpdate を使います.これはルートが変わってDOMに描画し終わった後に呼ばれます.ドキュメントによれば

  1. The user clicks a link, which causes the router to change its current location.
  2. Docusaurus preloads the next route's assets, while keeping displaying the current page's content.
  3. The next route's assets have loaded.
  4. The new location's route component gets rendered to DOM.

onRouteUpdate will be called at event (2), and onRouteDidUpdate will be called at (4). They both receive the current location and the previous location (which can be null, if this is the first screen).

となっています.この関数の中で renderMathInElement を呼び出します. 次のようになります.

import ExecutionEnvironment from "@docusaurus/ExecutionEnvironment";

export default (function () {
  if (!ExecutionEnvironment.canUseDOM) {
    return null;
  }

  return {
    onRouteDidUpdate({ location }) {
      renderMathInElement(document.body, {
        delimiters: [
            {left: '$$', right: '$$', display: true},
            {left: '$', right: '$', display: false},
            {left: '\\(', right: '\\)', display: false},
            {left: '\\[', right: '\\]', display: true}
        ],
        throwOnError : false,
        strict: false
      });
    },
  };
})();

onRouteDidUpdate の引数にある location でルートを取得できます.これを使って処理するページを特定します.パスは
location.pathname です.katex-clientプラグインのClient Moduleであるrender.jsは次のようになります.

./plugins/docusaurus-plugin-katex-client/render.js
import ExecutionEnvironment from "@docusaurus/ExecutionEnvironment";

export default (function () {
  if (!ExecutionEnvironment.canUseDOM) {
    return null;
  }

  return {
    onRouteDidUpdate({ location }) {

      const pathIsIncluded = 
        location.pathname.startsWith("/docs/note") ||
        location.pathname.startsWith("/blog");
      if (pathIsIncluded == false) {
        
        return null;
      }

      renderMathInElement(document.body, {
        delimiters: [
            {left: '$$', right: '$$', display: true},
            {left: '$', right: '$', display: false},
            {left: '\\(', right: '\\)', display: false},
            {left: '\\[', right: '\\]', display: true}
        ],
        throwOnError : false,
        strict: false
      });
    },
  };
})();

🔹katex-clientプラグインを有効にする

後は作成したkatex-clientプラグインを有効にします.docusaurus.config.jspluginsで指定します.

./docusaurus.config.js
plugins: [
    './plugins/docusaurus-plugin-katex-client',
],

📌結果

ビルド時間が改善されて、生成されるファイルも小さくなりました.以下は、GitHub Actionsでビルドしたときのログです.まずは、remark-math+rehype-katexの場合:

Run yarn build
yarn run v1.22.19
$ docusaurus build
[INFO] [ja] Creating an optimized production build...
[info] [webpackbar] Compiling Client
[info] [webpackbar] Compiling Server
[BABEL] Note: The code generator has deoptimised the styling of /home/runner/work/mebiusbox.github.com/mebiusbox.github.com/docs/note/math-3dtransformation.md as it exceeds the max of 500KB.
[BABEL] Note: The code generator has deoptimised the styling of /home/runner/work/mebiusbox.github.com/mebiusbox.github.com/docs/note/math-3dtransformation.md as it exceeds the max of 500KB.
[success] [webpackbar] Client: Compiled successfully in 1.40m
[success] [webpackbar] Server: Compiled successfully in 1.53m
[SUCCESS] Generated static files in "build".
[INFO] Use `npm run serve` command to test your build locally.
Done in 103.03s.

そして、katex-clientプラグインに変えた場合は次の結果となりました.

Run yarn build
yarn run v1.22.19
$ docusaurus build
[INFO] [ja] Creating an optimized production build...
[info] [webpackbar] Compiling Client
[info] [webpackbar] Compiling Server
[success] [webpackbar] Client: Compiled successfully in 26.71s
[success] [webpackbar] Server: Compiled successfully in 32.79s
[SUCCESS] Generated static files in "build".
[INFO] Use `npm run serve` command to test your build locally.
Done in 42.66s.

実際のページはこちらです.

📌注意点

katex-clientプラグインにしたことによる注意点です.

🔹クライアント側の負荷

もちろん、クライアント側で数式処理をするのでページを開いたときに負荷がかかってしまいます.

🔹マークダウンでエスケープが必要

マークダウンの数式をそのまま remark や rehype に通すので、エスケープする必要があります.これは手間ではありますが、置換で対応できるので許容範囲としています.

🔹remark-math + rehype-katex(or rehype-mathjax) と併用できない

現状は併用できないです.katex-clientプラグインを正常に動かすには remark-math を有効にできません.

🔹ページをリロードすると一部の数式処理に失敗する

原因不明.

📌余談

🔹rehypeプラグイン

remark-math で数式がASTに変換されるので、rehype のプラグインを自作して、デリミタを加えてそのまま出力することができれば、余計なエスケープ処理をする必要がないのでは、と考えています.ただ、そこまで突っ込んで作る気にはなれなかったです.

🔹Babelの警告

remark-math+rehype-katexで構築していたときに、数式の多いページをビルドすると以下の警告がたくさん出てきます.

[BABEL] Note: The code generator has deoptimised the styling of XXX as it exceeds the max of 500KB.

500KBを超えると最適化が無効になるとのことです.検索してみるとBabelの設定で、compactfalseにすれば解決するとありましたが、どうも上手くいきませんでした.また、Docusaurusでプロジェクトを作成したときの babel.config.js は次のようになっています.

./bable.config.js
module.exports = {
  presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
};

そして、@docusaurus/core/lib/bable/presetの中身を見てみるとcompacttrueと指定されています.今回の問題のために、compactfalseにするのはどうかと思いますので、結局そのままにしました.

以上です.

Discussion