blog.anqou.net に使うシンタックスハイライトの仕組みを Shiki に移しました。Shiki は静的サイトジェネレータ(SSG)である soupault の実行中に呼び出され、そのハイライト結果のみが HTML に埋め込まれるようにしました。soupault がハイライトを行うたびに単に Shiki を実行すると Shiki の初期化処理のせいか生成速度が遅かったので、Unix domain socket を使ったサーバ・クライアント方式を採用して高速にハイライトが行えるようにしました。
#モチベーション
以前記事にもしたように、blog.anqou.net に書かれているソースコードのシンタックスハイライトには tree-sitter を使ってきました。もう少し具体的には tree-sitter highlight
コマンドにソースコードを入力すると ANSI escape sequences を使ったハイライト結果が出力されるので、これを aha を使って HTML に変換していました。
blog.anqou.net は、各記事の一番下に表示されているように、soupault という OCaml 製のツールを使って生成されています。soupault 自体は HTML タグをパーズして Lua を使って好きに書き換えてから出力するという機能を持った静的サイトジェネレータ(SSG)になっていて、入力する HTML ファイルは外部コマンドを起動して生成させることができるます。そこで tree-sitter highlight
の出力を aha に入力し、aha が生成した HTML を soupault に読み込ませることで各ページにシンタックスハイライトされた HTML が埋め込まれるという仕組みになっていました。
しばらく blog.anqou.net を運用してくると、この tree-sitter highlight
コマンドの使い勝手が悪いことが分かってきました。例えば今まで blog.anqou.net で登場したことがない言語のハイライトを行いたい場合、tree-sitter はプラグイン式になっているので、まずその言語のプラグインを探してダウンロードしてきた上で、tree-sitter highlight
コマンドから呼び出せる位置にいい感じに配置する必要があります。また tree-sitter highlight
コマンドは --scope
オプションで言語を指定するのですが、このオプションにどのような値を渡せばよいかがプラグインごとに異なる上、ものによっては明確ではありませんでした。
ということで、シンタックスハイライトを行う別の方法を探し始めました。ツールの要件としては何よりもまず blog.anqou.net で登場しうるプログラミング言語の全てをカバーしていることが大前提で、有名な GNU Source-highlight や highlight.js は Jsonnet をサポートしていないので選択肢から外れました。
最終的に Shiki という TypeScript の実装を使うことにしました。Shiki は VS Code と同じ仕組み(TextMate)を使っているらしく、対応言語がかなり豊富です。一覧を見ると Jsonnet の他、Nix や JSON5 などのサポートもあり、自分には十分そうでした。
さて Shiki の公式サイトを見ると、Shiki は HTML 中で動的に読み込んで使うことを想定されているようです。ただそのような仕組みだと blog.anqou.net に接続してきた各々のブラウザが、ページを表示するたびに毎回毎回シンタックスハイライトの処理を行う必要があり、この SDGs の時代にそぐわないでしょう。
そこで、soupault を動かして HTML を生成するタイミングでシンタックスハイライトの処理を行い、その結果を埋め込むことにしました。つまり仕組みは以前と同じで、tree-sitter highlight
を使っていたところを Shiki に置き換えるような形です。実際にハイライトを行うツールは Shiki をライブラリとして呼び出す Node.js プログラムを書いて自作することにしました。一応 Shiki には tree-sitter highlight
と同じ様に ansi escape sequences を用いてハイライト結果を出力する CLI ツールがあるのですが、これを aha に入力するとローディングの絵文字(e.g., ⠹)が表示されてしまうことが分かったため使いませんでした。
#実装
Shiki のサンプルコードを見ながら Node.js のコードを書くと、標準入力からコードを受け取り標準出力にハイライト結果の HTML を出力するようなものが書けました:
// Shiki をライブラリとして読み込む。
import { codeToHtml } from "shiki";
// ここでコマンドライン引数を解析して
// 言語(syntax)とテーマ(theme)を受け取る。
// 標準入力を読み込む。
process.stdin.setEncoding("utf8");
let code = "";
for await (const chunk of process.stdin) code += chunk;
// シンタックスハイライトを行い HTML を得る。
const html = await codeToHtml(code, {
lang: syntax, // コマンドライン引数から指定された言語
theme: theme, // コマンドライン引数から指定されたテーマ
// そのまま出力すると <pre> と <code> タグで囲まれてしまうので適当に消す。
transformers: [
{
root(node) {
const pre = node.children[0];
if (pre !== undefined && pre.tagName === "pre") {
const code = pre.children[0];
if (code !== undefined && code.tagName === "code") {
node.children = code.children;
}
}
},
},
],
});
// できた HTML を標準出力に出す。
process.stdout.write(html);
実際にこれを組み込んで blog.anqou.net をビルドしてみると、一応ハイライトはできるのですが、以前使っていた tree-sitter と比べて圧倒的に速度が遅くなってしまいました。blog.anqou.net 全体をビルドするために soupault を起動すると、以前は 2〜3 秒ですんでいたのが、Shiki を組み込んだあとは 10 秒近くかかるようになってしまいました。これでは使いものになりません。
Node.js の process.hrtime()
を呼び出してコードの実行速度を計測しながら原因を探ってみると、どうやらハイライトそのものにはそれほど時間がかかっていないようでした。そこで、Shiki の初期化に時間がかかっているのだろうとあたりをつけ、改善方法を考えてみることにしました。
soupault の仕組み上、各コードブロックに対して一回ずつ直列にハイライト用のコマンドが起動されるという挙動は変えられません。本当は soupault が並列に Shiki を呼び出してくれればよいのですが、残念ながらマニュアルを見てもそのような仕組みはなさそうです。
そこで、ハイライト処理側でサーバ・クライアント方式を採用することを思いつきました。つまり、Shiki を呼び出す部分はサーバとして継続的に動作させることで初期化の処理を繰り返し動かすことを避け、soupault から呼び出すクライアントはこのサーバにコードを投げてハイライトを行ってもらうというアーキテクチャです。サーバ・クライアント方式を簡単に実装するには HTTP を使うのが一番でしょう。Node.js には標準で HTTP サーバ(http.createServer
)とクライアント(fetch
)がついてくるのでこれを使って実装することを当初は考えました。
しかし実際にはこれは動きません。より正確には、Nix では動きません。Nix ではビルド中にネットワークを使うことができないため、HTTP サーバを立ててもクライアントから接続することができません。最近 NixOS に乗り換えたこともあり blog.anqou.net もこの機会に Nix に載せ替えたので、blog.anqou.net が Nix でビルドできることは必要不可欠でした。
そこで、代わりに Unix domain socket を使って通信することにしました。これであれば実態はただの(ソケットの)ファイルなので Nix のビルド中でも使うことができます。Node.js には Unix domain socket のサポートも標準で存在するので(node:ns
)これを使うと実装はすぐに書けます。
というわけで、先程のコードを修正します。まずサーバとして動く部分を別ファイルに切り出します:
// server.js
import { codeToHtml } from "shiki";
import { createServer } from "node:net";
import { socketPath } from "./common.js";
// Unix domain socket 経由でコードを受け取り、
// Shiki でのハイライト結果を送り返すサーバ。
const server = createServer((conn) => {
conn.on("close", () => {});
conn.on("error", (err) => {});
conn.on("data", (data) => {
// コードと言語(syntax)、テーマ(theme)は
// JSON でエンコードして送られてくる。
const q = JSON.parse(data);
const options = {
lang: q.syntax,
theme: q.theme,
transformers: [
{
root(node) {
const pre = node.children[0];
if (pre !== undefined && pre.tagName === "pre") {
const code = pre.children[0];
if (code !== undefined && code.tagName === "code") {
node.children = code.children;
}
}
},
},
],
};
// ハイライト結果を送り返す。
codeToHtml(q.code, options).then((html) => conn.write(html));
});
});
// socketPath にあるファイルを指定して
// Unix domain socket を開き接続を受け付ける。
server.listen(socketPath);
次に、元のコードをクライアントになるように書き換えます。さらに、サーバがまだ起動していない場合にはその起動も行うようにしておきます:
import { parseArgs } from "node:util";
import { readFileSync, existsSync } from "node:fs";
import { spawn } from "node:child_process";
import { createConnection } from "node:net";
import { socketPath } from "./common.js";
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
// コマンドライン引数の解析は省略。
// 標準入力からのコードの読み込みも省略。
// サーバが起動していなければ起動する。
if (!existsSync(socketPath)) {
const server = spawn(process.argv[0], [import.meta.dirname + "/server.js"], {
// クライアントが終了したあともサーバは生き続けるようにする。
detached: true,
stdio: "ignore",
});
// サーバの終了を待たずにクライアントは終了するようにする。
server.unref();
// サーバが立ち上がるのを適当に待つ。
for (let i = 0; i < 3; i++) {
if (existsSync(socketPath)) break;
await sleep(100);
}
}
// サーバに接続する。
const client = createConnection(socketPath);
client.on("connect", () => {
// ハイライト対象のコードをサーバに投げつける。
client.write(
JSON.stringify({
code: code,
syntax: syntax,
theme: values.theme,
})
);
});
client.on("data", (html) => {
// ハイライト結果が返ってきたらそのまま出力する。
process.stdout.write(html);
client.end();
});
client.on("error", (e) => {
process.stderr.write(`${e}`);
process.exit(1);
});
以上の変更を行って再度 blog.anqou.net のビルドを走らせると 3〜4 秒程度となり、tree-sitter のときよりは若干まだ遅いですが[1]、許容できる速度になりました。よかったよかった。
というわけで、みなさんが今読んでいるこの blog.anqou.net は Shiki によって事前にシンタックスハイライトが行われた記事を提供しています。今回書いたコードは以下の GitHub レポジトリにあるので、よければ使ってみてください:
https://github.com/ushitora-anqou/highlight
以上、お相手は艮鮟鱇でした。またね。
この記事は VALORANT Challengers Japan 2025 Split 1 Main Stage Day 3 を見ながら書きました。
#注釈
-
この過程で Nix への移行も行ったので、Nix のオーバーヘッドも含まれている気はします。 ↩