メインコンテンツまで移動する
FirstLayout

Astro + Shiki でファイル名や diff などを表示するコードブロックを作る

  • 公開日

Akira Web デザイナー

ファイル名をつける

Astro でのマークダウンのコードブロックは、Shiki がシンタックスハイライトとしてデフォルトで有効になっています。

ただ、ファイル名を出力する機能はありません。Shiki の Transformers API を使い、ファイル名を出力するようにしてみます。

まずは型をインストールします。

 npm install -D @shikijs/types 

Shiki は hast を使って処理しています。コードブロックで言語を表す lang の後に何か書いた場合は、その情報は meta に格納されます。Shiki の Transformers では、metathis.options.meta で取得できます。

meta の部分に title="file-name" と書いた場合に、<pre>data-title="file-name" 属性をつける Transformers の関数を作ります。

src/utils/shikiTransformers.ts
 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.shikiConfigtransformers で使用します。

astro.config.mjs
 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 を確認するだけにしておきます。

src/components/SyntaxHighlight.astro
 ---
console.log(Astro.props);
--- 

マークダウンを表示するページを作ります。今回は getCollection() を使います。<Content>components プロパティを使い、MDX の カスタムコンポーネント としてシンタックスハイライト用のコンポーネントを使用します。

src/pages/[...postSlug].astro
 ---
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 でコードブロックを書きます。

src/content/posts/first-post.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> が入ります。

src/components/SyntaxHighlight.astro
 ---
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 を試します。

src/utils/shikiTransformers.ts
 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 でコードブロックを書きます。

src/content/posts/first-post.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 でシンタックスハイライト用のコンポーネントを作るとします。

src/components/SyntaxHighlight/index.tsx
 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:* を追加します。

src/components/SyntaxHighlight/client.astro
 ---
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 のカスタムコンポーネントとして使用します。

src/pages/[...postSlug].astro
 ---
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! を読み上げません。私はそれが気になり使いませんでしたが、問題視しないのであれば魅力的な選択肢です。

シェアする

著者

Akira

福岡在住の Web デザイナー。役に立ちそうなことや学んだことを書き留めています。オンラインツールのブックマークサイト Benrito も運営しています。

フォローする

興味があるかも