SätteriAstroMarkdownKaTeX

Sätteriプラグイン開発 #2 数式のレンダリング

Sätteriプラグイン開発 #2 数式のレンダリング

はじめに

前回の記事では,defineMdastPluginを使って本文中の単独リンクをOGPカードに変換するプラグイン実装した話をしました。

それに続いて,今回は↓この記事を書くために実装した数式をレンダリングするプラグインの話をしようと思います。

Sätteri向けにKaTeXレンダリングを行うコミュニティ製プラグインもあるにはあるものの実績(GitHubリポジトリのスター数とか)が薄く,且つKaTeXでのレンダリング処理自体はkatexパッケージを使うだけで完結するシンプルな処理なので,外部依存を1つ増やすコストには見合わないなーと思い,結局自前で実装することにしました。

KaTeXとは?

物知り顔で「KaTeX」と書いていますが,正直この記事を書くまでTeXとLaTeXしか聞いたことありませんでした。更にいうとTeXとLaTeXの違いもちゃんとわかってなかったので,この機会に諸々調べました。

  • TeX(古代ギリシャ語の τέχνη 「技術・技芸」が由来):
    アメリカの数学者・計算機科学者である Donald Knuth が開発した組版システムそのもので,数式に限らず文書全体のレイアウトを低レベルに制御できます。

  • LaTeX(開発者 Leslie Lamport の姓「Lamport」+「TeX」):
    TeXの上に乗るマクロパッケージ集で,\section\begin{equation}のような高レベルなコマンドを提供し,論文・学術文書の事実上の標準になっています。

  • KaTeX(恐らく開発元である Khan Academy の頭文字+「TeX」):
    これらとは別に開発されたJavaScript製のライブラリです。GitHubのREADMEには次のように説明されています。

KaTeX is a fast, easy-to-use JavaScript library for TeX math rendering on the web. KaTeX supports much (but not all) of LaTeX and many LaTeX packages.

つまりKaTeXは,文書全体ではなく数式部分だけをブラウザ上(またはNode.jsでのSSR)でレンダリングする専用ツールで,LaTeXの数式記法(\frac\sum等)をサブセットとしてサポートしているだけです。本物のTeXエンジンを動かすわけではなく,KaTeX独自の実装でHTML+CSSの数式表現に変換します。まぁ,TeX/LaTeXの良いとこどりって感じですかね。

つまり,TeXはドキュメントのレイアウトを自由に制御できるようにするための仕組み(言語+エンジン),LaTeXはTeXを書く労力を減らすための便利なコマンドセット,KaTeXはWebページ作る時に慣れ親しんだTeX/LaTeXで数式を書けるようにするためのレンダリングツールということですね。

TeX・LaTeX・KaTeXの関係を表したイラスト
TeX・LaTeX・KaTeXの関係を表したイラスト

TeXとLaTexの違いについては↓こちらが参考になりました。

features.mathで数式をパースする

「Sätteriとは」の記事で見たように,レンダリングする時はまずRustでマークダウンをパースしてASTを作り,そのASTのノードを各プラグインに渡して処理していくという仕組みなので,プラグインが数式を処理するためには,パーサーが数式を数式としてパース出来る必要があります。

「数式なんてみんな使ってる(認知の歪み)し,特別な設定とか要らないっしょ」なんて思ってたんですが,実際はfeatures.mathという組み込みオプションを有効化しないと$$ ... $$(ディスプレイ数式)や$ ... $(インライン数式)といった記法をパースしてくれない仕様になっていました。オプションの設定方法は↓こんな感じです。

// astro.config.mjs
export default defineConfig({
  markdown: {
    processor: satteri({
      features: { math: true },
    }),
  },
});

パース結果はそれぞれmath/inlineMathというASTノードになり,TeXの生文字列がnode.valueに入ります。

なお,singleDollarTextMath: falseを指定すると単一$によるインライン数式のパースを無効化できます(「$50から$100」のような通貨表記と誤認させたくない場合に有効です)。

特定の$だけリテラル扱いにしたい場合

singleDollarTextMath: falseはサイト全体で単一$のインライン数式パースを無効化するオプションなので,「他の箇所では数式として$...$を使いたいが,この一箇所の通貨表記だけはリテラル扱いにしたい」というケースには使えません。

このような局所的な回避には,CommonMark標準のバックスラッシュエスケープ(\$)が使えます。ただし単純に思われがちなこの方法には,Sätteri実装上の落とし穴があります。

  • 「\$50から\$100」 → 「$50から$100」……リテラル扱い(正しく回避できる)
  • 「$50から\$100」 → 「50から\100」……数式として解釈される(閉じ側だけのエスケープでは回避できない)

Sätteriのソースには,この挙動を説明するコメントがあります。

In math context, \$ should still produce a MaybeMath delimiter so it can close a math span. The backslash only prevents opening.

つまりバックスラッシュは,その$が数式を開くのを防ぐだけで,閉じるのを防ぐわけではありません。$x\$y$という入力があった場合,先に出てくる未エスケープの$が数式を開き,後ろのエスケープ済み\$がそのまま閉じデリミタとして使われてしまいます。

通貨表記を確実にリテラル扱いにするには,開き側・閉じ側の$を両方ともエスケープする必要があります。片方だけで済ませようとせず,常に両方エスケープするのが安全です。

余談:なぜ数式のパースはデフォルトで無効なのか?

Sätteriのfeatures一覧を見ると,デフォルトで有効なのはgfmfrontmatterだけで,mathを含むそれ以外の機能はすべてデフォルト無効です。

そもそもの話として,これはSätteri固有の設計ではありません。CommonMarkの仕様自体に,数式に関する規定が存在しないため,$をどう扱うかは各処理系の裁量に委ねられています。

remark/rehypeエコシステムのremark-mathやmarkdown-it向けの数式プラグイン群は,いずれもコアに含まれない独立した拡張・プラグインとして提供されていて,明示的に追加しない限り数式はパースされません。 一方でPandoc独自の拡張Markdown方言や,GitHub(github.com)上のMarkdownレンダリングのように,デフォルトで$...$を数式として解釈する実装も存在します(この場合は通貨表記との曖昧性を避けるための個別ルールを併せ持っています)。

つまり「オプトインにするか,個別の曖昧性回避ルールを作り込んでデフォルト有効にするか」という2択に各処理系が向き合っていて,Sätteriは前者を選んでいるという位置づけです。

特に$という文字は,数式のデリミタとして使うには便利な反面,文章中に普通に登場する記号でもあります。実際,remark/rehypeエコシステムで同じ$...$記法を扱うremark-mathでは,「“$29 and $199”のような通貨表記が意図せず数式として解釈されてしまう」という不具合がissueとして報告されています。

features.mathsingleDollarTextMath: falseという,単一$のパースだけをオフにできるオプションが用意されているのも,この曖昧さが実際に問題視されていることの裏付けと言えます。

もし$のパースがデフォルトで有効だったら,数式を書くつもりが無い記事でも,価格や金額に言及しただけで意図せず数式として誤解釈され,レンダリングが崩れる事故が起こり得ます。mathを含む一群の機能がデフォルトオプトインになっているのは,こうした後方互換性の壊れやすさを避けるための設計だと考えられます。

長くなったのでイラストでまとめます。

featuresのデフォルト有効/無効の理由をまとめたイラスト
featuresのデフォルト有効/無効の理由をまとめたイラスト

mdastPluginで数式(KaTeX)をレンダリングする

さて,mathを有効化してパースできるようにしたら,パースされたmath/inlineMathノードを,katexパッケージのrenderToString()でそれぞれHTML文字列に変換して差し替えます。

import { defineMdastPlugin } from "satteri";
import katex from "katex";

export const mathPlugin = defineMdastPlugin({
  name: "math",
  math(node, ctx) {
    ctx.replaceNode(node, {
      type: "html",
      value: katex.renderToString(node.value, { displayMode: true, throwOnError: false }),
    });
  },
  inlineMath(node, ctx) {
    ctx.replaceNode(node, {
      type: "html",
      value: katex.renderToString(node.value, { displayMode: false, throwOnError: false }),
    });
  },
});

renderToString()の第二引数のオプションについては以下の通りです。

  • displayMode(デフォルト false):数式をブロック表示(独立行・中央配置,\int\sumが大きく表示される)にするかインライン表示にするかを切り替えるオプションです。mathノードではtrueinlineMathノードではfalseを指定しています。
  • throwOnError(デフォルト true):未対応コマンドや不正なLaTeXに遭遇した時に例外を投げるかどうかを指定します。どうせ公開前に目検するのでfalseにしています。

あとはglobal.cssでKaTeXのCSS(katex/dist/katex.min.css)を読み込めばおしまいです。前回のOGPカードに比べて遥かにシンプルですね。

{ type: "html", value }について

math(ブロック数式)・inlineMath(インライン数式)のどちらも,ctx.replaceNode()の戻り値に{ type: "html", value }を使っています。replaceNode()が受け取れる戻り値にはいくつか種類があるので,まずそこを整理しておきます。Sätteri公式のプラグインAPIドキュメントによると,主に以下の3パターンです。

  • mdastノード:  別のノードで置き換える
  • { raw: string, mdxExpressions?: boolean }1:  文字列をMarkdownとして再パースし,その結果をノードの位置に差し込む
  • { type: "html", value: string }:  mdastのhtmlリテラルノードとして,再パースせずそのままツリーに挿入する

実際,mathinlineMathの置換に{ raw }(または非推奨のrawHtml)を使うと,インライン数式でこういう症状が出ます。

<li><p><span class="katex">...</span></p>...</li>

<span class="katex">だけが<p>にラップされて浮いてしまい,前後のテキストと分断されています。

これは,{ raw }が「渡した文字列をMarkdownとして再パースする」という仕様である以上,避けられない副作用です。CommonMarkの仕様では,divのような特定のブロックレベルタグをルートに持つHTML断片だけが「HTMLブロック」として認識され,そのまま独立した要素になります。一方spanのようなタグはこの対象に含まれないため,段落中に現れた「インラインの生HTML」として扱われ,周囲のテキストごと<p>で包まれてしまいます。KaTeXのrenderToString()が返すのは常に<span class="katex">(または<span class="katex-display">)なので,再パースを経由すると必ずこの扱いになります。

一方{ type: "html", value }は再パースを経ずmdastツリーに直接挿入されるため,このような暗黙のラップは起こりません。

ここでややこしいのが,公式ドキュメントが一般的な推奨として{ raw, mdxExpressions: false }{ type: "html", value }より優先するよう案内している点です(しかもKaTeXの出力がその代表例として名指しされています)。ただしこれはMDX対応(mdxToJs)を見据えた案内で,「{ type: "html", value }markdownToHtmlでは動くがmdxToJsでは例外を投げる」というのが理由です。

というわけで,MDXを使わずSätteriをmarkdownToHtml専用で使っているこのBlogでは,公式の一般的な推奨({ raw, mdxExpressions: false })よりも,あえて{ type: "html", value }を選ぶという判断をしました。

まとめ

  • features.mathでパースされた数式ノードは,katexrenderToString()でレンダリングする
  • { raw }(および非推奨のrawHtml)は文字列をMarkdownとして再パースするため,KaTeXが返す<span>ルートのHTMLはインライン生HTML扱いとなり<p>に暗黙包装される
  • 公式は{ raw, mdxExpressions: false }を推奨しているが,これはMDX互換性のためであり,敢えて{ type: "html", value }を選択した方が良いケースもある。

参考

注釈

  1. 以前は{ rawHtml: string }という戻り値もありましたが,Sätteri 0.10.0(2026-08-18)で非推奨になっており{ raw, mdxExpressions: false }と同じ挙動をするとドキュメントに明記されています。

この記事のシリーズ

Sätteriプラグイン開発