本サイトのフレームワークには Astro を使用していますが、2026 年 6 月 22 日に Astro 7.0 がリリースされたので、v6 から v7 へのアップグレードを実施しました。

この記事ではおもに、Astro v7 のアップグレード作業をとおして、個人的に影響の大きかった、Markdown(MDX)のプラグインの変更について取り上げます。

Astro 公式のプレスリリースとアップグレードのガイドは以下です。

ハイライトとしては、以下の項目が挙げられています。

  • Vite 8
  • パフォーマンス
  • 高度なルーティング
  • ルートキャッシング
  • AI 機能の強化

プレスリリースで「This release is all about speed」と書かれているように、パフォーマンスに関連するものがほとんどです。特に、コンパイラが Go ベースから Rust ベースに置き換わったことが今回のメインのようです。

例によって、本サイトでもアップグレードを実施したのですが、Markdown と MDX の処理(パイプライン)を、従来の unified から Sätteri1 に移行する際に、思わぬ苦戦を強いられ、設定ファイルの大幅な書き換えが必要になりました。

なお、Astro 7.0 では、引き続き unified を使用できますが、パフォーマンスの向上や依存ライブラリのスリム化に加え、将来的なサポートを考慮して、Sätteri に移行することにしました。

unified から Sätteri への移行

見出し「unified から Sätteri への移行」

プレスリリースに掲載されている、unified と Sätteri の比較表を、一部改変して以下に抜粋します。unified では拡張したい機能ごとに remark や rehype のプラグインが必要になるのに対し、Sätteri では最初からビルトインされていることがわかります。

機能 unified Sätteri
GFM プラグイン(remark-gfm) ビルトイン(デフォルト)
スマート句読点 プラグイン(remark-smartypants) ビルトイン
見出しの ID プラグイン(remark-heading-id など) ビルトイン
コンテナディレクティブ プラグイン(remark-directive) ビルトイン
Math プラグイン(remark-math) ビルトイン
Frontmatter(YAML、TOML) プラグイン(remark-frontmatter) ビルトイン
上付き文字、下付き文字 プラグイン(remark-supersub など) ビルトイン
Wiki リンク プラグイン(remark-wiki-link) ビルトイン

Sätteri では、GFM(GitHub Flavored Markdown)がデフォルトで有効になっていますが、それ以外の機能を使用するには、設定ファイル(astro.config.mjs)の features で有効にする必要があります

このサイトのブログコンテンツ(MDX)では、以下のカスタマイズを実現するために remark や rehype のプラグインを使用していましたが、Sätteri で完結できる方法に移行していきます。

  1. 見出しのカスタマイズ(アンカーリンク追加)
  2. 脚注のカスタマイズ

以下は、変更前の unified の設定の抜粋です。変更後のコードとの比較用ですので、詳しく内容を確認する必要はありません。

unified の設定(Astro 6)
// 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: {
    gfm: true,
    smartypants: true,

    /* 1. 見出しのカスタマイズ */
    rehypePlugins: [
      rehypeSlug,
      [rehypeAutolinkHeadings, {
        behavior: 'after',
        properties: {
          class: 'anchor'
        },
        /* 見出しの class 属性を指定 */
        group: (node) => {
          return h('.heading.--lv' + node.tagName.charAt(1))
        },
        /* 脚注の見出しは対象外 */
        test: (node) => {
          return (node?.properties?.id !== 'footnotes-label');
        },
        /* 見出しの直後に要素を追加 */
        content: (node) => {
          const heading = node?.children[0]?.value;
          const headingText = (typeof heading === 'string') ? `見出し「${heading}」` : 'この見出しのリンク';

          return [
            /* SVG アイコン */
            h(
              /* ... */
            ),
            h(
              /* ... */
            ),
          ];
        },
      }],
    ],

    /* 2. 脚注のカスタマイズ */
    remarkRehype: {
      footnoteLabel: '脚注',
      footnoteLabelProperties: {className: ['footnotes-label'], dataIgnoreTocCount: [true]},
      footnoteBackLabel: 'コンテンツに戻る',
    },
  },
});

このような HTML のカスタマイズを Sätteri で実現するためには、HAST プラグインを使用します。Sätteri では HAST と MDAST2 のプラグインがビルトインされています。

以下は、変更後の Sätteri の設定の抜粋です。

Sätteri の設定(Astro 7)
// astro.config.mjs
import { defineConfig } from 'astro/config';
import { satteri, satteriHeadingIdsPlugin } from '@astrojs/markdown-satteri';
import { satteriCustomHeadingsPlugin, satteriCustomFootnotesPlugin } from './plugins/satteri.ts';

export default defineConfig({
  markdown: {
    processor: satteri({
      features: {
        gfm: {
          /* 脚注のラベル変更 */
          footnotes: {
            label: '脚注',
            backLabel: 'コンテンツに戻る',
          }
        },
      },
      hastPlugins: [
        satteriHeadingIdsPlugin(),

        /* 1. 見出しのカスタマイズ */
        satteriCustomHeadingsPlugin,

        /* 2. 脚注のカスタマイズ */
        satteriCustomFootnotesPlugin,
      ],
    }),
  },
});

まず、脚注のラベルについては footnotes の設定で変更しています。

デフォルトの挙動として、脚注の見出し(h2)に、自動的に class="sr-only" が付与されますが、こちらは footnotes の設定では変更できません。細かく HTML の要素や属性をカスタマイズするには、HAST プラグインを使用する必要があります。

見出しと脚注の HTML のカスタマイズは、それぞれ satteriCustomHeadingsPluginsatteriCustomFootnotesPlugin というモジュールを作成し、外部ファイル化しています。

なお、Markdown の見出しに id を付与する機能は、Astro にデフォルトで組み込まれているのですが、id が付与されるタイミングはプラグインの処理よりも後になります。

そのため、独自のプラグインで id の値を使用する場合には、それらのプラグインの指定より前に、satteriHeadingIdsPlugin() を指定する必要があります。

hastPlugins の抜粋
hastPlugins: [
  satteriHeadingIdsPlugin(),

  /* 1. 見出しのカスタマイズ */
  satteriCustomHeadingsPlugin,

  /* 2. 脚注のカスタマイズ */
  satteriCustomFootnotesPlugin,
],

ここから、見出しと脚注の HTML をカスタマイズしていきます。

以下は、外部ファイル化したモジュールのソースコードです。少し長いですが、処理の内容としては、対象となる見出し要素を絞り込んで(filter)、要素を組み立て、生成した要素を返す(return)といったことをしています。

見出しと脚注の HTML のカスタマイズ
// plugins/satteri.ts
import { defineHastPlugin } from 'satteri';
import type { HastPluginDefinition } from 'satteri';

// 1. 見出しのカスタマイズ
export const satteriCustomHeadingsPlugin: HastPluginDefinition = defineHastPlugin({
  name: 'headings',
  element: {
    filter: ['h2', 'h3', 'h4', 'h5', 'h6'],
    visit(node, ctx) {
      const id = node.properties?.id;
      if (typeof id !== 'string' || !id || id === 'footnote-label') return;

      const heading = ctx.textContent(node);
      const label = (typeof heading === 'string') ? `見出し「${heading}」` : 'この見出しのリンク';

      return {
        type: 'element',
        tagName: 'div',
        properties: {
          class: `heading --lv${node.tagName.charAt(1)}`,
        },
        children: [
          node,
          {
            type: 'element',
            tagName: 'a',
            properties: {
              href: `#${id}`,
              class: 'anchor',
            },
            children: [
              {
                /* SVG アイコン */
                /* ... */
              },
              {
                type: 'element',
                tagName: 'span',
                properties: {
                  class: 'sr-only',
                },
                children: [
                  {
                    type: 'text',
                    value: label,
                  },
                ],
              },
            ],
          },
        ],
      }
    },
  },
});

// 2. 脚注のカスタマイズ
export const satteriCustomFootnotesPlugin: HastPluginDefinition = defineHastPlugin({
  name: 'footnotes',
  element: {
    filter: ['h2'],
    visit(node, ctx) {
      const id = node.properties?.id;
      if (typeof id !== 'string' || !id || id !== 'footnote-label') return;

      const label = ctx.textContent(node);

      return {
        type: 'element',
        tagName: 'h2',
        properties: {
          id: 'footnote-label',
          class: 'footnotes-label',
          dataIgnoreTocCount: 'true',
        },
        children: [
          {
            type: 'text',
            value: label,
          }
        ],
      }
    },
  },
});

こちらの具体的な実装方法は、おもに Sätteri のドキュメントに加え、Astro 製のドキュメントサイト生成ツールである Starlight のソースコードを参考にしました。

以上で、unified で実装していたときと同様に、Sätteri で見出しと脚注の HTML のカスタマイズが実現できました。モジュールを外部ファイル化することで、設定ファイル(astro.config.mjs)の記述はコンパクトになり、ライブラリの依存も減らすことができました。

ところが、これで完了と思いきや、予期せぬ問題が潜んでいました。

Sätteri プラグインの問題

見出し「Sätteri プラグインの問題」

ここまでの対応で、見出しと脚注の HTML のカスタマイズは問題なく完了したかに思われました。しかし、いくつかのページで見出しの id の値を確認すると、不思議な現象が起こっていることに気づきます。

通常、ページ内で同様の見出しラベルが存在する場合、id の値が衝突しないように、末尾に -1 -2 -3 といった値が付与されます。

これは、ページ内でユニークな id の値を設定するための正しい挙動です。

ページ内における id 衝突の回避の例
<h2 id="foo">foo</h2>

<h2 id="foo-1">foo</h2>

<h2 id="foo-2">foo</h2>

しかし、不思議な現象は、同一ページ内のみならず、サイト全体の Markdown のデータを横断して、id の重複を回避するように振る舞います。

たとえば、このブログでは「おわりに」や「参考文献」といった見出しラベルが、複数の記事に登場します。このとき、ページ内で唯一の見出しラベルだとしても、他のページに同一の見出しラベルが存在すると、重複しているとみなされてしまうようです。

すべてのページを横断して id 衝突を回避しようと処理される
<h2 id="おわりに-24">おわりに</h2>

<h2 id="参考文献-11">参考文献</h2>

ちなみに、Astro の開発モードでは、ホットリロードするたびに -1 -2 -3 のように値がインクリメントされ、Markdown のすべての見出しの id に付与されます。

この状態でも、ページ内リンクの機能としては支障はありません。しかし、URL として不恰好なことは当然ながら、これまでと id が変わってしまうこと、さらに、他のページの見出しの有無によって採番が変わることで、URL の安定性や永続性が損なわれることが懸念されます。

いろいろと試した結果、どうやら原因は satteriHeadingIdsPlugin() にあるようです。まずは、このプラグインの使用をやめます。

設定ファイルから satteriHeadingIdsPlugin を削除
// astro.config.mjs
import { defineConfig } from 'astro/config';
import { satteri, satteriHeadingIdsPlugin } from '@astrojs/markdown-satteri'; 
import { satteri } from '@astrojs/markdown-satteri'; 
import { satteriCustomHeadingsPlugin, satteriCustomFootnotesPlugin } from './plugins/satteri.ts';

export default defineConfig({
  markdown: {
    processor: satteri({
      features: {
        gfm: {
          /* 脚注のラベル変更 */
          footnotes: {
            label: '脚注',
            backLabel: 'コンテンツに戻る',
          }
        },
      },
      hastPlugins: [
        satteriHeadingIdsPlugin(), 

        /* 1. 見出しのカスタマイズ */
        satteriCustomHeadingsPlugin,

        /* 2. 脚注のカスタマイズ */
        satteriCustomFootnotesPlugin,
      ],
    }),
  },
});

ただ、このままでは、見出しのカスタマイズで id が取得できなくなってしまうため、github-slugger を使用して3、モジュール内で id の値を生成します。

見出しと脚注の HTML のカスタマイズ
// plugins/satteri.ts
import { defineHastPlugin } from 'satteri';
import type { HastPluginDefinition } from 'satteri';
import GithubSlugger from 'github-slugger'; 

// 1. 見出しのカスタマイズ
export const satteriCustomHeadingsPlugin = (): HastPluginDefinition => {
  const slugger = new GithubSlugger(); 

  return defineHastPlugin({
    name: 'headings',
    element: {
      filter: ['h2', 'h3', 'h4', 'h5', 'h6'],
      visit(node, ctx) {
        const heading = ctx.textContent(node);
        if (!heading || node.properties?.id === 'footnote-label') return;

        const label = (typeof heading === 'string') ? `見出し「${heading}」` : 'この見出しのリンク';

        // `id` の値を生成
        const slug = slugger.slug(heading); 
        node.properties.id = slug;

        return {
          type: 'element',
          tagName: 'div',
          properties: {
            class: `heading --lv${node.tagName.charAt(1)}`,
          },
          children: [
           node,
            {
              type: 'element',
              tagName: 'a',
              properties: {
                href: `#${slug}`,
                class: 'anchor',
              },
              children: [
                /* ... */
              ],
            },
          ],
        }
      },
    },
  });
};

// 2. 脚注のカスタマイズ
export const satteriCustomFootnotesPlugin: HastPluginDefinition = defineHastPlugin({
  /* ... */
});

以上で、末尾に -1 -2 -3 などの値が付与されるのは、同一ページ内で重複する見出しラベルが出現するときだけになり、意図したとおりの正常な動作になりました。

ただ、なぜこの現象が発生したかの原因は特定できていないので、この方法で解消しているかどうかの保証はありません。引き続き、問題が生じていないかをチェックしていきたいと思います。

その他のアップレードに関連する調整としては、コンパイラが Rust に変わったことにより、HTML 構文のチェックが厳しくなったので、閉じタグが不足している箇所をいくつか修正しました。

たとえば、以下のように空要素でスラッシュ(/)の記述が抜けている箇所を修正しています。

SVG 閉じタグの修正例
<rect width="200" height="200" ... stroke-linecap="square"> 
<rect width="200" height="200" ... stroke-linecap="square" /> 

この記事では、本サイトを Astro v7 にアップグレードした際に、特に大きな影響が及んだ、Markdown(MDX)のプラグインの変更について説明しました。

Sätteri プラグインの satteriHeadingIdsPlugin() による、不思議な現象については、今後のアップデートで修正される可能性はありますが、暫定的な対応として github-slugger を経由して回避する方法を紹介しました。

冒頭で述べたように、Astro の今回のアップグレードは、内部的なパフォーマンスの向上(Rust への移行)がほとんどなので、ビルド時間が重大な課題になっているといったケースでなければ、急いで対応する必要はないのかもしれません。

本記事の作成にあたり、以下のウェブページを参考にしました。

脚注

  1. sätteri はスウェーデン語で「組版室」を意味するようです(Sätteri | Rosenlöfs Vänner)。発音をカタカナで表すと「セッテリー」でしょうか(自信なし)。

  2. HAST は「Hypertext Abstract Syntax Tree」、MDAST は「Markdown Abstract Syntax Tree」の略であり、それぞれ HTML と Markdown の抽象構文木を扱うときの仕様です。

  3. github-slugger は Astro が Markdown の見出しに ID を自動で付与するときに使用しているライブラリです(Heading IDs | Astro)。