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 のような書き方で要素を探したり書き換えたりする |
|
shiki | コードを、色付けした HTML に変換する |
|
shiki は VS Code と同じ仕組み(TextMate の文法)で色分けするライブラリで、出力する HTML には色がインラインの style で付いています。そのため、テーマの色を CSS で用意する必要はありません。
参考:Shiki
2. 本文の HTML を変換する関数を作る
本文の HTML を受け取って、コードブロックをハイライトした HTML を返す関数を、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 の断片として読み込む
const $ = cheerio.load(html, null, false);cheerio の load は、ふつうはブラウザと同じように <html>・<head>・<body> を補って、1つの文書として読み込みます。本文は文書の一部(断片)なので、3つ目の引数に false を渡して、断片のまま読み込んでいます。こうすると、最後の $.html() で余計な <html> などが付かずに、本文の HTML だけが返ってきます。
参考:Loading Documents | cheerio
for...of で1つずつ await する
for (const el of $('pre > code').toArray()) {shiki の codeToHtml は非同期の関数(Promise を返す)です。cheerio の .each() のコールバックの中では await で完了を待てないので、.toArray() で配列にしてから for...of で回し、1つずつ await しています。
言語を取り出し、知らない言語は text 扱いにする
const lang = /language-(\S+)/.exec($code.attr('class') ?? '')?.[1];class="language-ts" から ts の部分を取り出します。言語が設定されていないコードブロックでは undefined になります。
shiki は、知らない言語名を渡すとエラーになります。そこで、取り出した言語名が shiki の対応言語の一覧(bundledLanguages)にあるかを確かめ、なければ text(色付けなし)として扱っています。
lang: lang && lang in bundledLanguages ? lang : 'text',これで、microCMS 側で言語を選ばなかったコードブロックや、shiki が知らない言語名のコードブロックが1つあっても、ビルド全体が失敗することはありません。
コードの取り出し方
const highlighted = await codeToHtml($code.text().replace(/\n$/, ''), {$code.text() は、タグを除いたテキストを返します。HTML の中では < が < のようにエスケープされていますが、.text() は元の文字に戻した状態で返すので、そのまま shiki に渡せます。shiki は出力するときに改めてエスケープします。
末尾の改行を1つ取り除いているのは、ハイライト後のコードの最後に空の行ができるのを防ぐためです。
ファイル名を figcaption にする
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 に渡すように変えました。
import { renderRichText } from '../../lib/richtext';const content = await renderRichText(blog.content);<div class="prose" set:html={content} />変換は記事詳細ページを生成するときに1回だけ行われ、結果の HTML がそのまま静的ファイルになります。
4. スタイルを整える
コードの色は shiki が付けてくれるので、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 で背景色だけ付けています。
/* インラインコード */
.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・サイトマップ・画像の最適化