1 月末にリニューアルした本サイトについて、今回は Markdown と、その拡張言語である MDX を取り上げます。

本サイトのフレームワークには Astro を使用しており、ブログコンテンツとプライバシーポリシーのコンテンツを MDX ファイルで管理しています。この記事では Markdown だけでは残る課題とその解決方法および、使用している Markdown のプラグインについて説明します。

MDX のロゴ

Markdown は簡易的に文書構造を示すことができる軽量なマークアップ言語です。

Astro では標準で Markdown 形式のファイルに対応しており/src/pages/ ディレクトリ以下に直接 .md ファイルを置くか、Astro 2.0 以上で使用できる Content Collection の機能を使用することでページを構築することができます。

以下はブログ記事のコードの抜粋です。

Astro における Markdown の例
---
title: リニューアル振り返り - パフォーマンス編
tags: 
  - パフォーマンス
  - Web Vitals
draft: false
---

## Core Web Vitals

パフォーマンス最適化で基本となるのは Core Web Vitals の以下の 3 つの指標です。

- **LCP**(Largest Contentful Paint、最大視覚コンテンツの表示時間)
- **FID**(First Input Delay、初回入力までの遅延時間)
- **CLS**(Cumulative Layout Shift、累積レイアウトシフト数)

LCP は `2.5s` 以下、FID は `100ms` 以下、CLS は `0.1` 以下が良好なスコアのボーダーラインです。

先頭の --- から --- までは、Front Matter(フロントマター)と呼ばれるエリアで、こちらにページのメタデータを記述しています。それ以降が Markdown(本文)のエリアです。

Astro ではこのハイフン 3 つ(---)の区切りをコードフェンスと呼びます。

見出しやリンク、段落テキスト、画像、リスト、コード、引用といった、ブログコンテンツに必要な基本的な要素は、標準の Markdown で表現できます。

また、Astro では Markdown のパーサ(構文解析)に remark を使用しており、あらかじめ GitHub Flavored Markdown(GFM)Smartypants のプラグインが導入されています。

この GFM によって、以下のような Markdown の拡張機能が使用できます。

GFM の拡張機能を使用した Markdown コードの例
## 自動リンク

www.example.com
https://example.com
[email protected]

## 脚注

GFM[^1]によって Markdown の機能を拡張することができます。

[^1]: GFM は GitHub Flavored Markdown の略称です。

## 打ち消し線

~3月13日公開予定~ 3月20日公開

## 表組み

| 指定なし | 左揃え | 右揃え | 中央揃え |
| --- | :--- | ---: | :---: |
| a | b | c | d |

## タスクリスト

- [ ] タスク 1
- [x] タスク 2

出力結果は以下のような見た目になります(一部スタイルを調整しています)。

GFM の拡張機能を使用した Markdown コードの出力結果

このようにあらかじめ組み込まれている機能によって、ある程度自由にコンテンツを作成することができますが、独自の要素を使いたいケースまではカバーできません。

Markdown エリアには直接 HTML を記述することもできるので、その方法で独自要素を実装する方法も考えられますが、コードを一元管理できないのでメンテナンス性や拡張性の観点から推奨できません。

ただ、要素の折りたたみを表現する <details><summary> のように、最小限の HTML 要素のみで機能する場合には、直書きでも問題ないでしょう。

この課題を解決するために MDX を導入します。

Astro の @astrojs/mdx インテグレーションを導入し、Markdown の拡張言語である MDX を有効にすることで、コンポーネントを使用することができるようになり、独自に定義した要素が使えるようになります。

たとえば、以下は「SVG の使い方」という記事で使用している SVG デモのコンポーネントです。クリックや Enter キーで三角形のオブジェクトがランダムでアニメーションします。

SVG デモのコンポーネント 1
SVG デモのコンポーネント 2

この 2 つのデモは同一のコンポーネントファイルを参照していますが、以下のように異なるプロパティ(Props)を指定することで見た目や内容を出し分けています。

SVG デモのコンポーネントのコード
<!-- コンポーネントの読み込み -->
import SvgDemo from '@components/post-helpers/blog/how-to-use-svg/SvgDemo.astro';

<!-- 同一のコンポーネントを異なるプロパティで読み込み -->
<SvgDemo 
  alt="「SVG」の文字を使用したタイポグラフィで「V」の文字は錯視効果を取り入れた三角形になっている"
  caption="SVG デモのコンポーネント 1"
/>

<SvgDemo
  alt="錯視効果を取り入れた三角形のイラスト"
  caption="SVG デモのコンポーネント 2"
  onlyTriangle={true}
/>

また、コンポーネント内に Markdown 記法を含めることもできます。以下は補足情報のコンポーネント <Aside> に、Markdown コンテンツを内包する例です。

コンポーネントが Markdown を内包する例
<Aside>
### Custom Media Queries

将来的に `@custom-media` が使用できるようになれば、メディアクエリに CSS 変数を指定できない問題は解消します。
</Aside>

コンポーネント内にコンポーネントを含めることもできます。以下は、キャプション付きでコードブロックを表示する <CodeBlock> コンポーネントを追加した例です。

コンポーネントが Markdown とコンポーネントを内包する例
<Aside>
### Custom Media Queries

将来的に `@custom-media` が使用できるようになれば、メディアクエリに CSS 変数を指定できない問題は解消します。

<CodeBlock caption="Custom Media Queries の例">
```css
@custom-media --breakpoint (width >= 768px);

@media (--breakpoint) {
  /* ... */
}
```
</CodeBlock>
</Aside>

このように、1 つのコンポーネントで複数のバリエーションを表現できるようになるので、前述したメンテナンス性と拡張性を確保することができます。

そのほかにも、MDX によって以下の機能を使うことができます。

  • エクスポートされた変数を使用する
  • frontmatter 変数を使用する
  • カスタムコンポーネントを HTML 要素に割り当てる

詳細は Astro のドキュメントを参照してください。

MDX を採用することにマイナス面がないわけではありません。例えば、RSS フィードで記事コンテンツを取得することができますが、MDX ではサポート外です。

Astro 2.1 より @astrojs/markdoc インテグレーションが追加されました。

これにより Markdoc の機能を使って、独自要素を定義することも可能です。

ただ、現時点では v0.0.1 で、試験的な機能(Experimental)として提供されているので、今後大幅な仕様変更が発生する可能性がある前提で使う必要があります。

シンタックスハイライト

見出し「シンタックスハイライト」

Astro ではコードのシンタックスハイライトの機能として、標準で Shiki が有効になっており、設定により Prism も選択できます。

本サイトでのシンタックスハイライトの設定を以下に抜粋します。

astro.config.mjs
import { defineConfig } from 'astro/config';

export default defineConfig({
  markdown: {
    syntaxHighlight: 'shiki',
    shikiConfig: {
      theme: 'github-dark',
      wrap: true,
    },
  },
});

シンタックスハイライトは shiki を選択しており、shikiConfig でテーマを theme: 'github-dark' と指定し、wrap: true でコードの折り返しを許容しています。

ただ、github-dark テーマの CSS コードなどのカラーリングがしっくりこないので、他のテーマに乗り換えるか、独自にカスタマイズするか思案しているところです。

個人的には、Astro のドキュメントサイトのシンタックスハイライトが理想なのですが、こちらはカスタムテーマを使用しているようです。

また、コードの折り返しはモバイルだと可読性が著しく低下することがあるので、こちらもモバイルのみ折り返しを禁止して横スクロールできるように、設定を変更するかもしれません。

見出しにアンカーリンクを付与

見出し「見出しにアンカーリンクを付与」

2023/04/06 追記 VoiceOver での検証を踏まえて、アンカーリンクのマークアップを変更しました。

Astro で使用する Markdown や MDX には、見出し要素に自動的に固有の ID が振られます。

この見出しにホバーしたときに、アンカーリンクを表示したかったので rehype-autolink-headings をインストールし、カスタマイズすることで実現しています。

本サイトでの rehype プラグインの設定を以下に抜粋します。

astro.config.mjs
import { defineConfig } from 'astro/config';
import { h } from 'hastscript';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';

export default defineConfig({
  markdown: {
    rehypePlugins: [
      rehypeSlug,
      [rehypeAutolinkHeadings, {
        behavior: 'after',
        properties: {
          class: 'anchor'
        },
        group: (node) => {
          return h('.heading.--lv' + node.tagName.charAt(1))
        },
        content: (node) => {
          const heading = node?.children[0]?.value;
          const headingText = (typeof heading === 'string') ? `見出し「${heading}」` : 'この見出しのリンク';

          return [
            h(
              'svg.anchor-icon',
              {
                width: 27,
                height: 27,
                viewBox: '0 0 27 27',
                ariaHidden: 'true'
              },
              h('path', {
                d: 'M20.6,12l0.4-2h-3.3l0.7-4h-2l-0.7,4h-3l0.7-4h-2l-0.7,4H7l-0.4,2h3.6l-0.5,3H6.1l-0.4,2h3.6l-0.7,4h2l0.7-4h3l-0.7,4h2 l0.7-4h3.3l0.4-2h-3.3l0.5-3H20.6z M14.7,15h-3l0.5-3h3L14.7,15z',
              }),
            ),
            h(
              'span.sr-only',
              headingText
            )
          ];
        }
      }]
    ]
  },
});

若干込み入ったコードになっていますが、behavior: 'after' で見出し要素の直後にアンカーリンクを追加し、class="anchor" を付与しています。

content でアンカーリンク内のコンテンツを指定しており、hastscripth() を使用して、ハッシュ(#)の形をしたアイコンの SVG を挿入しています。

VoiceOver での検証を踏まえて、この SVG は aria-hidden="true" でアクセシビリティツリーから除外し、読み上げの対象外とし、スクリーンリーダー用のテキストを別途追加しました。

このテキストを、変数 headingText に格納していますが、念のため、フォールバック用に「この見出しのリンク」というテキストも残しています。

content: (node) => {
  const heading = node?.children[0]?.value;
  const headingText = (typeof heading === 'string') ? `見出し「${heading}」` : 'この見出しのリンク';

出力される HTML は、以下のようなコードになります。

出力される見出しの HTML の例
<div class="heading --lv2">
  <h2 id="markdown" tabindex="-1">Markdown</h2>
  <a class="anchor" href="#markdown">
    <svg class="anchor-icon" width="27" height="27" viewBox="0 0 27 27" aria-hidden="true">
      <!-- ... -->
    </svg>
    <span class="sr-only">見出し「Markdown」</span>
  </a>
</div>

ちなみに、GitHub の README.md で出力される見出しのアンカーリンクを見ると、<a> 要素に aria-hidden="true" が指定されています。アクセシビリティツリーの構造を単純化するという考えであれば、これでもよいのかもしれません。

GitHub の Markdown で出力された見出しの HTML(抜粋、一部改変)
<h1 tabindex="-1" dir="auto">
  <!-- `<a>` に `aria-hidden="true"` が指定されている -->
  <a id="user-content-heading" class="anchor" aria-hidden="true" href="#heading">
    <svg class="octicon octicon-link" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true">
      <!-- ... -->
    </svg>
  </a>
  Heading
</h1>

ただ、この場合、アクセシビリティツリーからアンカーリンクが完全に無視され、スクリーンリーダーを使用するユーザにはアンカーリンクが存在しないものとされてしまいます。その点を考慮して、このサイトではアンカーリンクに aria-hidden="true" は指定しませんでした。

元の astro.config.mjs のコードに戻ると、group は見出し要素を囲う要素で、アンカーリンクを絶対配置(position: absolute)にしたときの起点の役割をしています。

アンカーリンクを見出し要素の直後に配置(behavior: after)としたのは、Astro のドキュメントサイトのコードで引用されている以下の記事を参考にしました。

これにより、スクリーンリーダーの見出し検索のようなナビゲーション機能の妨げになることを回避できると考えています。