monolithic kernel

Astro でコードブロックのシンタックスハイライトをしつつタイトルも付ける

Astro 標準のシンタックスハイライトには、タイトルを付ける仕組みが存在しない。remark-code-title という Remark のプラグインを使うことで実現できるが、シンタックスハイライトと干渉することを防ぐために、code 要素の手前に div 要素を置く形になっており、AST や最終的な HTML の構造上、コードブロック本体とタイトルに関係性がない。

# 似たようなプラグインに rehype-code-titles もあるが、Astro の場合はプラグインとシンタックスハイライトの処理順の兼ね合いで動作しない。

構造上コードとタイトルが結びつくようにすべく、標準のシンタックスハイライトを無効化して自前でシンタックスハイライトを実装した。

と言っても、シンタックスハイライト自体は Astro のコンポーネントとして提供されているので、そんなに難しいことはしていない。コードとタイトルをひとまとめで Astro のコンポーネントに置換する Remark プラグインを実装し、あとは Astro のコンポーネントの世界で Shikiji を呼び出しつつ HTML を出力すればよい。

今回は、figure 要素でラップして figcaption 要素でタイトルを付ける形の HTML を出力した。

yarn add --dev @types/mdast
src/remark/remark-code-block.ts
import type { Code, Paragraph } from "mdast"
import type { Plugin } from "unified"
import type { Node } from "unist"
import { visit, type Visitor } from "unist-util-visit"

const remarkCodeBlock: Plugin = () => {
  return (tree: Node) => {
    const visitor: Visitor<Code> = (node, index, parent) => {
      if (!parent || index === undefined) {
        return
      }

      const { lang, meta, value } = node
      const title = meta

      parent.children.splice(index, 1, {
        type: "paragraph",
        data: {
          hName: "code-block",
          hProperties: {
            // Workaround for preventing loss of line breaks in code blocks.
            // Need to decode the value in the code block component.
            code: encodeURIComponent(value),
            ...(lang ? { lang } : {}),
            ...(title ? { title } : {}),
          },
        },
        children: [],
      } as Paragraph)
    }
    visit(tree, "code", visitor)
  }
}

export default remarkCodeBlock
astro.config.mjs
import mdx from "@astrojs/mdx"
import { defineConfig } from "astro/config"

import remarkCodeBlock from "./src/remark/remark-code-block"

export default defineConfig({
  integrations: [
    mdx(),
  ],
  markdown: {
    syntaxHighlight: false, // default false なので実際には書かなくてよい
    remarkPlugins: [
      remarkCodeBlock,
    ],
  },
})
src/components/CodeBlock.astro
---
import { Code } from "astro:components"
import { type BuiltinLanguage, type SpecialLanguage } from "shikiji"

interface Props {
  lang?: string
  title?: string
  code: string
}

const { lang, title, code } = Astro.props
---

<figure>
  {title && <figcaption>{title}</figcaption>}
  <Code
    lang={lang as BuiltinLanguage | SpecialLanguage | undefined}
    code={decodeURIComponent(code)}
    theme="min-light"
  />
</figure>
src/pages/[...slug].astro
---
import CodeBlock from "../components/CodeBlock.astro"

const { Content } await entry.render()
---

<Content
  components={{
    "code-block": CodeBlock,
  }}
/>

あとは、Markdown を書くときに以下のように言語名の後ろにスペースを挟んでタイトルを書けばよい (シンタックスハイライトを使わずにタイトルだけ付けたい場合には言語を plaintext とする)。

```astro hello.astro
---
---

<p>Hello, world!</p>
```

この例は以下のようにレンダリングされる。

hello.astro
---
---

<p>Hello, world!</p>

ちなみに、Markdown の AST 仕様である mdast では、言語名の後ろにスペースを挟んで書かれた文字列は meta として保持されることになっている。今回は meta 全体をそのままタイトルとして使ったものの、他にも情報を含めておいてレンダリングに活用することも考えられる。


Related articles