Astro + Shiki でファイル名や diff などを表示するコードブロックを作る
ファイル名をつける
Astro でのマークダウンのコードブロックは、Shiki がシンタックスハイライトとしてデフォルトで有効になっています。
ただ、ファイル名を出力する機能はありません。Shiki の Transformers API を使い、ファイル名を出力するようにしてみます。
まずは型をインストールします。
npm install -D @shikijs/types
Shiki は hast を使って処理しています。コードブロックで言語を表す lang
の後に何か書いた場合は、その情報は meta
に格納されます。Shiki の Transformers では、meta
は this.options.meta
で取得できます。
meta
の部分に title="file-name"
と書いた場合に、<pre>
に data-title="file-name"
属性をつける Transformers の関数を作ります。
import type { ShikiTransformer } from '@shikijs/types'; function transformerMetaDataTitle(): ShikiTransformer { return { name: 'transformer:meta-data-title', pre(node) { const raw = this.options.meta?.__raw; if (!raw) return; const titleMatch = raw.match(/title\s*=\s*("[^"]+"|'[^']+'|\S+)/); if (titleMatch) { const title = titleMatch[1].trim().replace(/^["']|["']$/g, ''); node.properties ||= {}; node.properties['data-title'] = title; } return node; }, }; } export const shikiTransformers = [transformerMetaDataTitle()];
エクスポートした shikiTransformers
を Astro のマークダウンのオプション markdown.shikiConfig
の transformers
で使用します。
import mdx from '@astrojs/mdx'; import { defineConfig } from 'astro/config'; import { shikiTransformers } from './src/utils/shikiTransformers'; export default defineConfig({ integrations: [mdx()], markdown: { shikiConfig: { transformers: shikiTransformers }, }, });
シンタックスハイライト用のコンポーネントを作ります。<pre>
に data-title
属性がついているかを確かめるために Astro.props
を確認するだけにしておきます。
--- console.log(Astro.props); ---
マークダウンを表示するページを作ります。今回は getCollection()
を使います。<Content>
の components
プロパティを使い、MDX の カスタムコンポーネント としてシンタックスハイライト用のコンポーネントを使用します。
--- import SyntaxHighlight from '@components/SyntaxHighlight.astro'; import Layout from '@layouts/Layout.astro'; import { getCollection, render } from 'astro:content'; export async function getStaticPaths() { const posts = await getCollection('posts'); return posts.map((post) => ({ params: { postSlug: post.id }, props: { post }, })); } const { post } = Astro.props; const { Content } = await render(post); --- <Layout> <Content components={{ pre: SyntaxHighlight }} /> </Layout>
MDX でコードブロックを書きます。
```css title="タイトルのテスト" a { color: red; } ```
シンタックスハイライト用コンポーネントの Astro.props
を確認します。data-title
が期待通りに <pre>
に追加できています。また、Shiki が追加する他の属性も確認できます。
{ class: 'astro-code github-dark', style: { backgroundColor: '#24292e', color: '#e1e4e8', overflowX: 'auto' }, tabindex: '0', 'data-language': 'css', 'data-title': 'タイトルのテスト' }
シンタックスハイライト用コンポーネントを変更します。今回は <figure>
を使います。data-title
がある場合は <figcaption>
として出力します。<slot>
にはハイライトされた <code>
が入ります。
--- import type { HTMLAttributes } from 'astro/types'; interface Props extends HTMLAttributes<'pre'> { 'data-title'?: string; } const { 'data-title': dataTitle, ...rest } = Astro.props; --- <figure> {dataTitle && <figcaption>{dataTitle}</figcaption>} <pre {...rest}> <slot /> </pre> </figure>
これでファイル名をつけることができました。
diff など他の機能も追加する
Shiki には様々な機能を追加できる @shikijs/transformers が用意されています。
いくつかの機能を試します。まずはインストールします。
npm install @shikijs/transformers
そして、使用する機能をインポートします。今回は任意の行をハイライトする transformerMetaHighlight
、任意の文字をハイライトする transformerMetaWordHighlight
、追加した行と削除した行を表す transformerNotationDiff
を試します。
import type { ShikiTransformer } from '@shikijs/types'; import { transformerMetaHighlight, transformerMetaWordHighlight, transformerNotationDiff, } from '@shikijs/transformers'; // 先ほど作ったタイトルをつける関数 function transformerMetaDataTitle(): ShikiTransformer { return { name: 'transformer:meta-data-title', pre(node) { const raw = this.options.meta?.__raw; if (!raw) return; const titleMatch = raw.match(/title\s*=\s*("[^"]+"|'[^']+'|\S+)/); if (titleMatch) { const title = titleMatch[1].trim().replace(/^["']|["']$/g, ''); node.properties ||= {}; node.properties['data-title'] = title; } return node; }, }; } export const shikiTransformers = [ transformerMetaDataTitle(), transformerMetaHighlight(), transformerMetaWordHighlight(), transformerNotationDiff(), ];
MDX でコードブロックを書きます。
```css title="タイトルのテスト" {1} /red/ a { background-color: green; /* [\!code --] */ background-color: blue; /* [\!code ++] */ color: red; } ```
@shikijs/transformers は該当箇所にクラスをつけます。例えば、transformerMetaHighlight()
の {1}
を指定しているため、1 行目のクラスは class="line highlighted"
と highlighted
が追加されます。
ただし、@shikijs/transformers は CSS は提供しておらず、自分でスタイルを用意する必要があります。
コピーボタンを作る
コードをコピーするボタンをつける場合は、.astro
コンポーネントであれば <script>
を追加するだけで済みます。
一方、React や Svelte などを使ってコンポーネントを作る場合は、Client ディレクティブ を使用する必要があります。ただ、MDX のカスタムコンポーネントでは Client ディレクティブを使用できないため、一度 .astro
を経由しなくてはいけません。
例えば、SolidJS でシンタックスハイライト用のコンポーネントを作るとします。
import { type ComponentProps, type JSX, Show, splitProps } from 'solid-js'; export interface Props extends Omit<ComponentProps<'pre'>, 'children'> { children: JSX.Element; 'data-title'?: string; } export default function SyntaxHighlight(props: Props) { const [local, rest] = splitProps(props, ['data-title', 'children', 'style']); let preRef: HTMLPreElement | undefined; const copyPreText = async () => { try { const text = preRef?.textContent ?? ''; if (!text) { console.warn('コピーするテキストがありません'); return; } await navigator.clipboard.writeText(text); } catch (error) { console.error('クリップボードへの書き込みに失敗しました:', error); } }; return ( <figure> <Show when={local['data-title']}> <figcaption>{local['data-title']}</figcaption> </Show> <button onClick={copyPreText} type="button"> Copy </button> <pre ref={preRef} style={ // Astro.props で渡す style は camelCase 例: backgroundColor // 一方、SolidJS は kebab-case で書く必要がある 例: background-color local.style ? Object.entries(local.style) .map(([key, value]) => `${camelToKebab(key)}: ${value}`) .join('; ') : undefined } {...rest}> {local.children} </pre> </figure> ); } function camelToKebab(input: string) { return input.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()); }
SolidJS で作ったコンポーネントを .astro
で読み込み、client:*
を追加します。
--- import SyntaxHighlight, { type Props as SyntaxHighlightProps, } from '@components/SyntaxHighlight'; type Props = SyntaxHighlightProps; const { ...props } = Astro.props; --- <SyntaxHighlight client:visible {...props}> <slot /> </SyntaxHighlight>
そして、マークダウンを表示するページで client:*
を追加した .astro
を読み込み、MDX のカスタムコンポーネントとして使用します。
--- import SyntaxHighlight from '@components/SyntaxHighlight/client.astro'; import Layout from '@layouts/Layout.astro'; import { getCollection, render } from 'astro:content'; export async function getStaticPaths() { const posts = await getCollection('posts'); return posts.map((post) => ({ params: { postSlug: post.id }, props: { post }, })); } const { post } = Astro.props; const { Content } = await render(post); --- <Layout> <Content components={{ pre: SyntaxHighlight }} /> </Layout>
React などのコンポーネントと MDX のカスタムコンポーネントの組み合わせは、Astro では少し手間がかかります。
その他の方法
Shiki の Transformers API は使わず、Shiki を利用している高機能なシンタックスハイライトライブラリ Expressive Code を使う方法もあります。
Expressive Code は、Astro や Starlight のドキュメントで使われています。ファイル名の出力やコピーボタン、diff など多くの機能を備えています。また、背景色と文字色のコントラスト比の調整 に対応しており、アクセシビリティを考慮しているのも特徴です。
ただ、コピーボタンをクリックした後にコピーが成功したら Copied! と表示はされるのですが、スクリーンリーダーは Copied! を読み上げません。私はそれが気になり使いませんでしたが、問題視しないのであれば魅力的な選択肢です。