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 \/>| |amp;)/g, ""), { output: "html", displayMode: true });
});
// インラインをレンダリング
const renderedText = renderedDisplayText.replaceAll(/\$[^\$]*\$/g, (text: string) => {
return katex.renderToString(text.replaceAll("$", "").replaceAll(/(<br>|<\\br>|<br \/>| |amp;)/g, ""), { output: "html", displayMode: false });
});
2024/4/7 追記不等号をうまくレンダリングできないことに気づきました。不等号はHTML上でescapeされて扱われるので、
replaceAll
メソッドチェインの部分に以下を追記してください:.replaceAll(/(<:)/g, "<").replaceAll(/(>:)/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 \/>| |amp;)/g, "").replaceAll(/(<:)/g, "<").replaceAll(/(>:)/g, ">"), { output: "html", displayMode: true });
});
// インラインをレンダリング
const renderedText = renderedDisplayText.replaceAll(/\$[^\$]*\$/g, (text: string) => {
return katex.renderToString(text.replaceAll("$", "").replaceAll(/(<br>|<\\br>|<br \/>| |amp;)/g, "").replaceAll(/(<:)/g, "<").replaceAll(/(>:)/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での実装が望ましいといえます(まあまあ面倒ですが)。