プログラミング
プログラミング
  • 2024/09/18
  • 2024/09/18

シンタックスハイライトをHighlight.jsからShikiへリプレイスしました

Astroで構築している当ブログのコードブロックのシンタックスハイライトをHighlight.jsからShikiへリプレイスしました。

ライブラリの候補


JavaScriptを利用したシンタックスハイライトを行う場合、有用なライブラリはおおむね以下の3つになるかと思います。

Shikiを選択した理由


要件・要求

  1. 対応言語の多さ
  2. ダークモード対応
  3. 記事ページをAstroでSSGしているため、サーバサイドでハイライト処理できる

対応言語の多さ

参考

Languages | Shiki

Shikiはかなり多くの言語をサポートしています。対して、HljsはReact JSXに対応していなかったりと、デファクトスタンダードになりつつあるモダンな言語・構文をサポートしていません。

Prismもかなり多くの言語に対応していますが、特にWeb系・JavaScriptフレームワーク関連はShikiが非常に充実しています。Web系言語のみのBundle もあるくらいです。

ダークモード対応

参考

Light/Dark Dual Themes | Shiki

他に比べ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ボタンなどをつけるといい感じの仕上がりになります。

関連記事


    記事がありません

Shota Inoue
Shota Inoue

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