ブログシステムを作る(3) - marked.jsでparseしたMarkdownにshikiでSyntax Highlighting

Sun Jan 15 2023

今日は前から試したかったshikiを使ってSyntax Highlightingを実装してみました。

Shiki - a beautiful syntax highlighter

ShikiはMicrosoftでVS Codeの開発に従事していたPineさんが実装しているSyntax Highlighterで、仲間内のDiscordで話題になっていたので試してみたいと思っていたものです。

TextMateのgrammerを構文解析に使っているため、かなりの数の言語をハイライトすることができて便利です。

使用感

READMEから引っ張ってきたものですが、こんな感じ。HighlighterをAsyncで取ってこないといけないのがちょっと微妙ではあります。 getHighlighterThemeやLanguageの情報を非同期で取ってくる仕組みになっているので、仕方ない感じも。

const shiki = require('shiki')

shiki
  .getHighlighter({
    theme: 'nord'
  })
  .then(highlighter => {
    console.log(highlighter.codeToHtml(`console.log('shiki');`, { lang: 'js' }))
  })

// <pre class="shiki nord" style="background-color: #2e3440"><code>
//   <!-- Highlighted Code -->
// </code></pre>

Markedと組み合わせて使う

Markedのハイライト機能を使う

これ、思ったより苦戦しました。まず、元々Markedでrenderしていた部分のコードが以下の通り。

// https://github.com/mactkg/makerbox.net/blob/eedce8477aa0efa35f8bba85b336f510111a8d7b/lib/blogs/article.ts#L36-L41
async renderHTML(): Promise<string> {
  if (this.renderedHTML) return this.renderedHTML;

  const html = await marked.parse(this.body, { async: true });
  this.renderedHTML = html;
  return html;
}

シンプルですね。MarkedにはSyntax Highlightingの機能があり、これを使えばうまく動きそうです。

async renderHTML(): Promise<string> {
  if (this.renderedHTML) return this.renderedHTML;

  const html = await marked.parse(this.body, {
    async: true,
    highlight: (code, lang, callback) {
      shiki
        .getHighlighter({ theme: 'nord' })
        .then((highlighter) => {
          const html = highlighter.codeToHtml(code, { lang })
          callback ? callback(html) : null;
        });
    }
  });
  this.renderedHTML = html;
  return html;
}

が、これが動かない。。。よくよくみてみると、highlightの第三引数の callback が渡ってきていないのです。何事?

highlightの処理はここでやっているようなのですが、この処理が呼ばれるのは marked.parse の第三引数にcallback関数が渡っている時だけ

動いた!

仕方なく、このようなコードにしました。 highlight の中でPromiseを扱う必要がなくなって、見通しも良くなったきも。

async renderHTML(): Promise<string> {
  if (this.renderedHTML) return this.renderedHTML;

  const highlighter = await shiki.getHighlighter({ theme: 'nord' })
  const html = await marked.parse(this.body, {
    async: true,
    highlight: (code, lang, callback) {
      return highlighter.codeToHtml(code, { lang })
    }
  });
  this.renderedHTML = html;
  return html;
}

スタイリングの調整

ということでひとまずSyntax Highlightingはできたのですが、見た目が微妙な感じになってしまっています。

Image from Gyazo

よくよく見てみると、DOM構造がネストしていました。

Image from Gyazo

markedの highlight 関数は <pre><code> の中身だけを返すことを想定している一方で、shikiは返してしまっているみたいです。今回は、shikiのカスタマイズ機能を使って実装をしてみます。

Custom rendering

shikiのREADMEにある、Custom rendering of code blocksを参考にしながら実装していけば特に迷いませんでした。codeToThemedTokens でトークンに分けた後、 renderToHTML で描画をすればOK。 renderToHTML のオプション引数で描画方式をカスタマイズできる。

import shiki, { getHighlighter } from 'shiki'

const highlighter = await getHighlighter({
  theme: 'nord',
  langs: ['javascript', 'python']
})

const code = `console.log("Here is your code.");`

const tokens = highlighter.codeToThemedTokens(code, 'javascript')

const html = shiki.renderToHTML(tokens)

このブログを書く過程でREADMEがおかしいことに気づいたのでPRを出しました。 doc: Fix a way to access renderToHTML by mactkg · Pull Request #415 · shikijs/shiki

完成!

最終的にはこんな感じになりました。

async renderHTML(): Promise<string> {
  if (this.renderedHTML) return this.renderedHTML;

  const highlighter = await shiki.getHighlighter({
    theme: "github-light",
  });
  const html = await marked(this.body, {
    async: true,
    highlight(code, lang) {
      const tokens = highlighter.codeToThemedTokens(code, lang);
      return shiki.renderToHtml(tokens, {
        elements: {
          pre({ children }) {
            return children;
          },
          code({ children }) {
            return children;
          },
        },
      });
    },
  });

  this.renderedHTML = html;
  return html;
}

まとめ

  • marked.jsで書いたMarkdown内のコードブロックをshikiでSyntax Highlightingした
    • markedのasyncを使っていると、highlightオプションのcallbackが配置されない
    • shikiでレンダリングするHTMLは renderToHTML を使えばカスタマイズできる
  • 早々にmarked脱出しておくと良さそう

次はアクセスカウンタなど動的なアイテムを入れてみたいなあという気がしています。