連載「Astro × microCMS でブログを作る」第3回

microCMS のコードブロックを shiki でハイライトする|Astro のビルド時に cheerio で変換(ファイル名表示つき)

この回でやること

前回までで、microCMS の記事から記事一覧と記事詳細のページを作りました。記事の本文は、microCMS のリッチエディタが返す HTML を set:html でそのまま表示しています。

この状態だと、本文中のコードブロックは色の付かないただのテキストです。技術ブログでは読みにくいので、今回はコードブロックをシンタックスハイライト(言語に合わせた色分け)します。

方針は次のとおりです。

  • ハイライトは、ブラウザではなく Astro のビルド時に行う
  • microCMS の HTML を cheerio で読み込み、コードブロックだけを shiki でハイライトした HTML に差し替える
  • microCMS で付けたファイル名は、コードブロックの上に見出しとして出す

ビルド時に処理するので、ブラウザ側でハイライト用の JavaScript を読み込む必要はありません。この記事のコードブロックも、ここで作る処理で色が付いています。

microCMS のコードブロックはどんな HTML か

microCMS のリッチエディタでは、コードブロックに言語とファイル名を設定できます。microCMS のドキュメントによると、コードブロックの HTML は次の形です。

  • コードは <pre> の中の <code> に入る
  • 言語は <code> の class に language-javascript のような形で入る
  • ファイル名は、<pre> を包む外側の <div> の data-filename 属性に入る

ファイル名と言語を付けたコードブロックなら、おおよそ次のような HTML になります。

<div data-filename="greeting.js">
  <pre><code class="language-javascript">const greeting = 'Hello microCMS!';</code></pre>
</div>

つまり、pre > code を探して、class から言語を、親の親の data-filename からファイル名を取り出せば、必要な情報がそろいます。

参考:リッチエディタのWRITE API | microCMS ドキュメント

1. cheerio と shiki を入れる

npm install cheerio shiki

パッケージ

役割

バージョン(package.json の指定)

cheerio

HTML を読み込んで、jQuery のような書き方で要素を探したり書き換えたりする

^1.2.0

shiki

コードを、色付けした HTML に変換する

^4.5.0

shiki は VS Code と同じ仕組み(TextMate の文法)で色分けするライブラリで、出力する HTML には色がインラインの style で付いています。そのため、テーマの色を CSS で用意する必要はありません。

参考:Shiki

2. 本文の HTML を変換する関数を作る

本文の HTML を受け取って、コードブロックをハイライトした HTML を返す関数を、src/lib/richtext.ts に作りました。

src/lib/richtext.ts
import * as cheerio from 'cheerio';
import { bundledLanguages, codeToHtml } from 'shiki';

const CODE_THEME = 'github-dark';

/**
 * microCMS リッチエディタのHTMLを表示用に整形する。
 * - コードブロックを shiki でハイライト(ビルド時に処理するのでブラウザ側のJSは不要)
 */
export async function renderRichText(html: string): Promise<string> {
	const $ = cheerio.load(html, null, false);

	for (const el of $('pre > code').toArray()) {
		const $code = $(el);
		const $pre = $code.parent();
		// ファイル名付きのコードブロックは <div data-filename="..."> で包まれて返ってくる
		const $wrapper = $pre.parent('[data-filename]');
		const lang = /language-(\S+)/.exec($code.attr('class') ?? '')?.[1];

		const highlighted = await codeToHtml($code.text().replace(/\n$/, ''), {
			lang: lang && lang in bundledLanguages ? lang : 'text',
			theme: CODE_THEME,
		});

		const $figure = $('<figure class="code-block"></figure>');
		const filename = $wrapper.attr('data-filename');
		if (filename) $figure.append($('<figcaption></figcaption>').text(filename));
		$figure.append(highlighted);
		($wrapper.length ? $wrapper : $pre).replaceWith($figure);
	}

	return $.html();
}

上から順に見ていきます。

HTML の断片として読み込む

src/lib/richtext.ts
const $ = cheerio.load(html, null, false);

cheerio の load は、ふつうはブラウザと同じように <html>・<head>・<body> を補って、1つの文書として読み込みます。本文は文書の一部(断片)なので、3つ目の引数に false を渡して、断片のまま読み込んでいます。こうすると、最後の $.html() で余計な <html> などが付かずに、本文の HTML だけが返ってきます。

参考:Loading Documents | cheerio

for...of で1つずつ await する

src/lib/richtext.ts
for (const el of $('pre > code').toArray()) {

shiki の codeToHtml は非同期の関数(Promise を返す)です。cheerio の .each() のコールバックの中では await で完了を待てないので、.toArray() で配列にしてから for...of で回し、1つずつ await しています。

言語を取り出し、知らない言語は text 扱いにする

src/lib/richtext.ts
const lang = /language-(\S+)/.exec($code.attr('class') ?? '')?.[1];

class="language-ts" から ts の部分を取り出します。言語が設定されていないコードブロックでは undefined になります。

shiki は、知らない言語名を渡すとエラーになります。そこで、取り出した言語名が shiki の対応言語の一覧(bundledLanguages)にあるかを確かめ、なければ text(色付けなし)として扱っています。

src/lib/richtext.ts
lang: lang && lang in bundledLanguages ? lang : 'text',

これで、microCMS 側で言語を選ばなかったコードブロックや、shiki が知らない言語名のコードブロックが1つあっても、ビルド全体が失敗することはありません。

参考:Languages | Shiki

コードの取り出し方

src/lib/richtext.ts
const highlighted = await codeToHtml($code.text().replace(/\n$/, ''), {

$code.text() は、タグを除いたテキストを返します。HTML の中では < が &lt; のようにエスケープされていますが、.text() は元の文字に戻した状態で返すので、そのまま shiki に渡せます。shiki は出力するときに改めてエスケープします。

末尾の改行を1つ取り除いているのは、ハイライト後のコードの最後に空の行ができるのを防ぐためです。

ファイル名を figcaption にする

src/lib/richtext.ts
const $figure = $('<figure class="code-block"></figure>');
const filename = $wrapper.attr('data-filename');
if (filename) $figure.append($('<figcaption></figcaption>').text(filename));
$figure.append(highlighted);
($wrapper.length ? $wrapper : $pre).replaceWith($figure);

ハイライトしたコードを <figure> で包み、ファイル名があれば <figcaption> として上に付けます。ファイル名は .text() で入れているので、ファイル名に < などの記号が入っていても、HTML として解釈されることはありません。

最後に、元のコードブロックを <figure> で置き換えます。ファイル名付きのときは外側の <div data-filename> ごと、ファイル名なしのときは <pre> だけを置き換えます。

shiki の codeToHtml について

codeToHtml は shiki の「ショートハンド」と呼ばれる関数で、必要な言語とテーマをその場で読み込み、メモリにキャッシュしてくれます。記事にコードブロックがたくさんあっても、同じ言語やテーマを毎回読み込み直すことはありません。

参考:Installation & Usage | Shiki

3. 記事詳細ページで使う

記事詳細ページでは、本文を renderRichText に通してから set:html に渡すように変えました。

src/pages/blog/[id].astro
import { renderRichText } from '../../lib/richtext';
src/pages/blog/[id].astro
const content = await renderRichText(blog.content);
src/pages/blog/[id].astro
<div class="prose" set:html={content} />

変換は記事詳細ページを生成するときに1回だけ行われ、結果の HTML がそのまま静的ファイルになります。

4. スタイルを整える

コードの色は shiki が付けてくれるので、CSS で用意するのは枠の形とファイル名の見た目だけです。

src/styles/global.css
/* コードブロック(shiki の出力を包む figure) */
.code-block figcaption {
	display: inline-block;
	padding: 2px 12px;
	border-radius: var(--radius) var(--radius) 0 0;
	background: #3a4049;
	color: #e1e4e8;
	font-family: var(--font-mono);
	font-size: 0.8rem;
}

.code-block pre {
	margin: 0;
	padding: 16px;
	overflow-x: auto;
	border-radius: var(--radius);
	font-family: var(--font-mono);
	font-size: 0.875rem;
	line-height: 1.6;
}

.code-block figcaption + pre {
	border-top-left-radius: 0;
}

ファイル名があるときは、ファイル名のタブとコードの枠がつながって見えるように、コードの左上の角だけを四角にしています。長い行は overflow-x: auto で横にスクロールできるようにしました。

文中の インラインコード は <pre> の外にある <code> なので、ハイライトの対象にはなりません。こちらは CSS で背景色だけ付けています。

src/styles/global.css
/* インラインコード */
.prose :not(pre) > code {
	padding: 0.15em 0.4em;
	border-radius: 4px;
	background: var(--color-surface);
	font-family: var(--font-mono);
	font-size: 0.875em;
}

注意点・ハマりどころ

言語名が shiki と一致しないと色が付かない

microCMS で選んだ言語の名前(class に入る値)が、shiki の言語名(bundledLanguages のキー)と一致しないと、そのコードブロックは text 扱いになり、色が付きません。エラーにはならないので気づきにくい点です。

記事を公開する前に、プレビューやビルド結果でコードブロックに色が付いているかを確認しておくと安心です。

テーマを変えるときは CSS も見直す

テーマは CODE_THEME の1か所で変えられます。ただし、ファイル名の見出しの色(#3a4049 など)は、暗い色のテーマ github-dark に合わせて CSS で決めています。明るいテーマに変えるときは、こちらも合わせて変える必要があります。

set:html はエスケープされない

変換した HTML は、前回と同じく set:html で表示しています。Astro は set:html に渡した文字列をエスケープしません。自分が書いた記事の HTML なので問題ありませんが、利用者が入力した内容を通す処理には使わないようにしましょう。

まとめ

  • microCMS のコードブロックは <pre><code class="language-xxx"> で、ファイル名は外側の <div data-filename> に入る
  • cheerio で HTML を断片として読み込み、pre > code を shiki のハイライト結果に置き換えた
  • 言語名が shiki の bundledLanguages になければ text にして、ビルドが失敗しないようにした
  • ファイル名は <figcaption> にして、コードブロックの上に表示した
  • ビルド時に処理するので、ブラウザ側の JavaScript はいらない

renderRichText は、のちに本文の画像を WebP に変換する処理も担うようになります。次回は、OGP・サイトマップ・画像の最適化をまとめて入れます。→ 第4回:OGP・サイトマップ・画像の最適化