ライブラリの候補
JavaScriptを利用したシンタックスハイライトを行う場合、有用なライブラリはおおむね以下の3つになるかと思います。
Shikiを選択した理由
要件・要求
- 対応言語の多さ
- ダークモード対応
- 記事ページをAstroでSSGしているため、サーバサイドでハイライト処理できる
対応言語の多さ
参考
Shikiはかなり多くの言語をサポートしています。対して、HljsはReact JSXに対応していなかったりと、デファクトスタンダードになりつつあるモダンな言語・構文をサポートしていません。
Prismもかなり多くの言語に対応していますが、特にWeb系・JavaScriptフレームワーク関連はShikiが非常に充実しています。Web系言語のみのBundle もあるくらいです。
ダークモード対応
参考
他に比べShikiが長けている部分はここだと思います。Shikiはコードブロックに対してインラインスタイルでハイライト処理を行いますが、変換メソッドの引数にライトモードとダークモードのテーマを個別に設定できます。ダークテーマを設定した場合、インラインスタイルに --shiki-dark
というCSS変数を出力します。
HljsやPrismは特定のプレフィックスをもつCSSセレクタにスタイルを適用するため、ライト/ダークモードのスタイルファイルをコピペでもして1つのCSSファイルにまとめてしまえば対応は可能です。Hljsはこのレポジトリに、Prismはこのレポジトリにスタイルファイルが公開されています。
サーバサイドでのハイライト処理
この部分はどのライブラリでも大きく変わらず、インスタンスを呼び出してコードブロック内のテキスト(code
)と言語名(lang
)を変換用のメソッドに渡すのみです。
- Hljs -
highlightElement()
- Prism -
highlight()
- Shiki -
codeToHtml()
これらのメソッドは引数に含まれるコードブロックの1つにハイライト処理を実行するため、全文を処理するには for
ループに含める必要があります。HljsとPrismには highlightAll()
という引数なしでページの <code>
をすべてハイライト処理するメソッドがありますが、内部的にJSのDocument APIを用いているためサーバサイド(Node.js)では動作しません。
その他
インラインスタイルを用いるため、テーマの設定はCSSで読み込むのではなくメソッドの引数に明示的に含めることになります。
したがって、見た目を弄ろうとすると記述が冗長になったり少し面倒な部分があると感じました。
実装
Cheerio を使って実装していきます。
$ yarn add shiki cheerio
createHighlighter
でShikiのインスタンスを作ることができます。今回は、CMSから返ってきたDOMから lang
を取り出し、インスタンスの langs
プロパティに逐次追加していくことで処理の効率化を図ります。
import { createHighlighter } from 'shiki';
import type { HighlighterGeneric, BundledLanguage, BundledTheme } from 'shiki';
const highlighter = await createHighlighter({
themes: ["min-light", "min-dark"],
langs: [],
});
const loadLang = async (highlighter: HighlighterGeneric<BundledLanguage, BundledTheme>, lang: BundledLanguage) => {
await highlighter.loadLanguage(lang);
}
const isLang = (lang: string): lang is BundledLanguage => {
return lang !== undefined;
}
const getHighlightedCode = async (highlighter: HighlighterGeneric<BundledLanguage, BundledTheme>, lang: BundledLanguage, code: string) => {
const highlightedCode = highlighter.codeToHtml(code, {
lang,
themes: {
light: "min-light",
dark: "min-dark",
},
// テーマの背景色を変更
colorReplacements: {
"min-light": {
"#ffffff": "#f3f3f3",
},
"min-dark": {
"#1f1f1f": "#111827",
}
}
});
return highlightedCode;
}
isLang()
はType Guardというやつです。実際うまく機能しているかはわかりません。
const pres = await Promise.all($("pre code").map(async (idx, elm) => {
if (!lang || lang === "" || !isLang(lang)) {
return;
}
const code = $(elm).text();
await loadLang(highlighter, lang);
const highlightedCode = await getHighlightedCode(highlighter, lang, code);
return highlightedCode;
}
highlighter.dispose();
$("pre").each((idx, elm) => {
$(elm).replaceWith(pres[idx] as string);
});
async/awaitとmap配列における組み込みメソッドのコールバックで
async/await
を用いる場合、map
メソッドやfilter
メソッドがよさそうです。forEach
メソッド中ではうまくいきません。
参考:JavaScriptの配列のmapでasync/awaitを使う方法
Astroであれば [slug].astro
のように、静的生成のテンプレートとなるページに処理をはさめばビルド時にハイライトを行ってくれます。
あとは適当にスタイルやタイトル、Copyボタンなどをつけるといい感じの仕上がりになります。