プログラミング
プログラミング
  • 2024/03/15
  • 2024/09/17

AstroブログでKaTeXをサーバサイドレンダリングする

AstroブログでKaTeXを利用して数式をサーバサイドレンダリング(SSR)する実装を行いました。その備忘録です。

MathJaxとKaTeX


いずれも 形式でWeb上の複雑な数式をマークアップするためのライブラリですが、いくつか違いがあります。

SSR可能かどうか

Server-side rendering or rendering to a string のセクションにあるように、katex.renderToString APIを利用して 形式の形式の構文が埋め込まれた文字列をSSRすることができます。

mhchemへのアクセス

npmやyarnでローカルに落として使う場合、MathJaxでは特段の設定なしで化学式や化学反応式を表現するためのmhchem extensionを使うことができますが、 では少々使いづらいです。ドキュメントにもあるように、

const katex = require('katex');
require('katex/contrib/mhchem');  // modify katex module

とするとNode.jsでmhchemを読み込むことができますが、require 文はCommonJSでの記法であるため使いにくいです。ECMAScript Modulesの記法である import 文による方法は見つけることができませんでした(CDNから読み込むしかないのでしょうか…)。

スタック


  • Astro
  • APIベースのHeadless CMS[1]
  • KaTeX
  • Cheerio

今回は、Headless CMSからAPIで取得したコンテンツを静的生成(SG)するという場合について実装していきます。

Astroのページをつくる


Astroで記事を表示するページを作ります。まずはAstroをインストールしましょう。

$ yarn create astro

src/pages/article/[slug].astro を作成し、フロントマターを以下のように書きましょう。

---
import Layout from "../../layouts/Layout.astro";
// 記事を取得する関数をほかのtsファイル(../lib/api.ts)に作っておきます
import { getArticles } from "../../lib/api";
/* 
  interface Article {
    slug: string;
    content: string;
  }
 */
import type { Article } from "../../lib/types";

// KaTeXのCSS
import "katex/dist/katex.min.css";

// SG用のpathを生成
export const getStaticPaths = async () => {
    const articles = await getArticles();

    if (!articles || articles.length <= 0) {
        return null;
    }

    return articles.map((article: Article) => ({
        params: { slug: article.slug },
        props: { article },
    }));
};

// props
interface Props {
    article: Article;
}
const { article } = Astro.props;
---

<Layout>
    <article set:html={article.content} />
</Layout>

記事を読み込む関数を src/lib/api.ts に作ってあります。JSのfetch APIや、CMSに付属するSDKで作っておきましょう。
詳しい実装の方法は以下の記事などを参考にしてください。

propした article のプロパティ content にCMSから取得したDOMの文字列が入っています。

ここで、article.content をHTMLタグに渡す前に katex.renderToString を挟むことで、数式をSSRします。

数式をSSRする関数をつくる


だいぶ苦戦しました。


処理の方針

デリミタを決める

単に katex.renderToString にCMSから返却されたHTMLを投げれば万事解決!というわけでもありません。
まずは、HTMLのどの部分を選択的にレンダリングするかを決めるために、区切り文字列(デリミタ) を設定する必要があります。

ここでは、インライン数式用に \$、別行立て数式用に \$\$ を使用します。


特定のタグをエスケープする

<code><input /> のような特定のタグ内では、たいていの場合デリミタで囲った文字列を変換したくないでしょう。そのためのエスケープ処理をしなくてはなりません。今回は <code> のみを対象にエスケープ処理を実装します。


HTMLをどう解析するか

CMSから返却されたHTMLをすべて katex.renderToString に投げるのは雑過ぎます。デリミタで区切った文字列のみを投げるほうが現実的でしょう。
当初は正規表現を使ってよしなにやろうと思っていたのですがやめました。ハゲそうになった


Node.jsベースのHTMLパーサである Cheerio は、jQueryライクなAPIで自在にHTMLを操作できる優れものです。今回、別の機能の実装でCheerioを用いていたため、流用することにしました。

$ yarn add cheerio

どのようなフローで処理するか

いろいろ試しましたが、一度 <code></code> でラップされた部分のテキストを配列に保存しておき、 のレンダリングを掛けたあとで<code></code> にもとのテキストを差し戻すという方法をとることにします(もっといい方法があるでしょうが…)。


codeタグ内のテキストを抽出する

import { load } from "cheerio";

// 初期化
let $ = load(article.content);

// <code>の文字列を抽出
const innerTexts: string[] = [];
$("code").each((_, elm) => {
   innerTexts.push($(elm).text());
    $(elm).text("");
});

<code> をすべて抜き出し、each() メソッドで innerTexts という配列にタグ内の文字列を格納していきます。CheerioのAPIが力を発揮します。
また、この後のレンダリングに支障をきたさないために <code></code> 内は一度空文字で置換してあります。

数式をレンダリングする

以下の記事を ほぼパクリ 参考にしました:

細かい処理の意図などは記事を参照してください。別行立て数式とインライン数式で2回に分けてレンダリングします:

import katex from "katex";

// 先に別行立てをレンダリング
const renderedDisplayText = $.html().replaceAll(/\$\$[^\$]*\$\$/g, (text: string) => {
	return katex.renderToString(text.replaceAll("$", "").replaceAll(/(<br>|<\\br>|<br \/>|&nbsp;|amp;)/g, ""), { output: "html", displayMode: true });
});

// インラインをレンダリング
const renderedText = renderedDisplayText.replaceAll(/\$[^\$]*\$/g, (text: string) => {
	return katex.renderToString(text.replaceAll("$", "").replaceAll(/(<br>|<\\br>|<br \/>|&nbsp;|amp;)/g, ""), { output: "html", displayMode: false });
});
2024/4/7 追記

不等号をうまくレンダリングできないことに気づきました。不等号はHTML上でescapeされて扱われるので、replaceAll メソッドチェインの部分に以下を追記してください:

.replaceAll(/(&lt:)/g, "<").replaceAll(/(&gt:)/g, ">")

codeタグ内のテキストを差し戻す

Cheerioのインスタンスをレンダリング済みのHTML renderedText で再度初期化し、ふたたび each() メソッドを使います。

// 再代入
$ = load(renderedText);

// <code>にテキストを差し戻す
$("code").each((idx, elm) => {
	$(elm).text(innerTexts[idx]);
});

// もとの変数にもどす
article.content = $.html();

完成したものがこちらです


フロントマター

---
import Layout from "../../layouts/Layout.astro";
import { getArticles } from "../../lib/api";
import type { Article } from "../../lib/types";
import katex from "katex";
import { load } from "cheerio";

// KaTeXのCSS
import "katex/dist/katex.min.css";

// SG用のpathを生成
export const getStaticPaths = async () => {
    const articles = await getArticles();

    if (!articles || articles.length <= 0) {
        return null;
    }

    return articles.map((article: Article) => ({
        params: { slug: article.slug },
        props: { article },
    }));
};

// props
interface Props {
    article: Article;
}
const { article } = Astro.props;

// 初期化
let $ = load(article.content);

// <code>の文字列を抽出
const innerTexts: string[] = [];
$("code").each((_, elm) => {
   innerTexts.push($(elm).text());
    $(elm).text("");
});

// 先に別行立てをレンダリング
const renderedDisplayText = $.html().replaceAll(/\$\$[^\$]*\$\$/g, (text: string) => {
	return katex.renderToString(text.replaceAll("$", "").replaceAll(/(<br>|<\\br>|<br \/>|&nbsp;|amp;)/g, "").replaceAll(/(&lt:)/g, "<").replaceAll(/(&gt:)/g, ">"), { output: "html", displayMode: true });
});

// インラインをレンダリング
const renderedText = renderedDisplayText.replaceAll(/\$[^\$]*\$/g, (text: string) => {
	return katex.renderToString(text.replaceAll("$", "").replaceAll(/(<br>|<\\br>|<br \/>|&nbsp;|amp;)/g, "").replaceAll(/(&lt:)/g, "<").replaceAll(/(&gt:)/g, ">"), { output: "html", displayMode: false });
});

// 再代入
$ = load(renderedText);

// <code>にテキストを差し戻す
$("code").each((idx, elm) => {
	$(elm).text(innerTexts[idx]);
});

// もとの変数にもどす
article.content = $.html();
---

<Layout>
    <article set:html={article.content} />
</Layout>

実際に使ってみる


このブログでも同様の考え方で を実装しているので、以下に使用例を示しておきます。

コードブロック内

% ちゃんと間をあけないとうまくparseできないことがあります
$$ \mathcal{F} [f(t)] (\omega) = \frac{1}{\sqrt{2\pi}}\int_{-\infty}^{\infty} dt~f(t)e^{-i\omega t} $$

レンダリング結果

ちゃんと <code></code> 内にデリミタがあってもescapeされていることがわかります。

おまけ:CSRによる実装


以上の実装は Auto-render Extension を代わりに使うことで簡単に達成できます。
AstroではクライアントサイドJSを <script></script>内に記述します。

<script>
    import { renderMathInElement } from "katex/dist/contrib/auto-render";

    document.addEventListener("DOMContentLoaded", () => {
        renderMathInElement(document.body, {
            delimiters: [
                { left: '$$', right: '$$', display: true },
                { left: '$', right: '$', display: false },
                { left: '\\(', right: '\\)', display: false },
                { left: '\\[', right: '\\]', display: true },
            ],
            ignoredTags: ["code"],
        });
    });
</script>

しかしながら、 のような重たいリソースをクライアントサイドで処理する行為はAstroのコアコンセプトである クライアントサイドJSの排除 に反するので、できることならSSRでの実装が望ましいといえます(まあまあ面倒ですが)。

脚注


  1. MicroCMSNewtは国産のHeadless CMSで、ドキュメントも充実しています。 ↩︎

関連記事


    記事がありません

Shota Inoue
Shota Inoue

大学生 | 化学・Webプログラミング・統計学など