本サイトのフレームワークには Astro を使用していますが、2026 年 6 月 22 日に Astro 7.0 がリリースされたので、v6 から v7 へのアップグレードを実施しました。
この記事ではおもに、Astro v7 のアップグレード作業をとおして、個人的に影響の大きかった、Markdown(MDX)のプラグインの変更について取り上げます。
Astro v7 のハイライト
見出し「Astro v7 のハイライト」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 で完結できる方法に移行していきます。
- 見出しのカスタマイズ(アンカーリンク追加)
- 脚注のカスタマイズ
以下は、変更前の unified の設定の抜粋です。変更後のコードとの比較用ですので、詳しく内容を確認する必要はありません。
// 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 の設定の抜粋です。
// 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 のカスタマイズは、それぞれ satteriCustomHeadingsPlugin と satteriCustomFootnotesPlugin というモジュールを作成し、外部ファイル化しています。
なお、Markdown の見出しに id を付与する機能は、Astro にデフォルトで組み込まれているのですが、id が付与されるタイミングはプラグインの処理よりも後になります。
そのため、独自のプラグインで id の値を使用する場合には、それらのプラグインの指定より前に、satteriHeadingIdsPlugin() を指定する必要があります。
hastPlugins: [
satteriHeadingIdsPlugin(),
/* 1. 見出しのカスタマイズ */
satteriCustomHeadingsPlugin,
/* 2. 脚注のカスタマイズ */
satteriCustomFootnotesPlugin,
],ここから、見出しと脚注の HTML をカスタマイズしていきます。
以下は、外部ファイル化したモジュールのソースコードです。少し長いですが、処理の内容としては、対象となる見出し要素を絞り込んで(filter)、要素を組み立て、生成した要素を返す(return)といったことをしています。
// 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 の値を設定するための正しい挙動です。
<h2 id="foo">foo</h2>
<h2 id="foo-1">foo</h2>
<h2 id="foo-2">foo</h2>しかし、不思議な現象は、同一ページ内のみならず、サイト全体の Markdown のデータを横断して、id の重複を回避するように振る舞います。
たとえば、このブログでは「おわりに」や「参考文献」といった見出しラベルが、複数の記事に登場します。このとき、ページ内で唯一の見出しラベルだとしても、他のページに同一の見出しラベルが存在すると、重複しているとみなされてしまうようです。
<h2 id="おわりに-24">おわりに</h2>
<h2 id="参考文献-11">参考文献</h2>ちなみに、Astro の開発モードでは、ホットリロードするたびに -1 -2 -3 のように値がインクリメントされ、Markdown のすべての見出しの id に付与されます。
この状態でも、ページ内リンクの機能としては支障はありません。しかし、URL として不恰好なことは当然ながら、これまでと id が変わってしまうこと、さらに、他のページの見出しの有無によって採番が変わることで、URL の安定性や永続性が損なわれることが懸念されます。
いろいろと試した結果、どうやら原因は 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 の値を生成します。
// 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 などの値が付与されるのは、同一ページ内で重複する見出しラベルが出現するときだけになり、意図したとおりの正常な動作になりました。
ただ、なぜこの現象が発生したかの原因は特定できていないので、この方法で解消しているかどうかの保証はありません。引き続き、問題が生じていないかをチェックしていきたいと思います。
HTML 構文の解釈の変更
見出し「HTML 構文の解釈の変更」その他のアップレードに関連する調整としては、コンパイラが Rust に変わったことにより、HTML 構文のチェックが厳しくなったので、閉じタグが不足している箇所をいくつか修正しました。
たとえば、以下のように空要素でスラッシュ(/)の記述が抜けている箇所を修正しています。
<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 への移行)がほとんどなので、ビルド時間が重大な課題になっているといったケースでなければ、急いで対応する必要はないのかもしれません。
参考文献
見出し「参考文献」本記事の作成にあたり、以下のウェブページを参考にしました。
- Astro 7.0 | Astro(外部リンクを開く)
- Upgrade to Astro v7 | Astro(外部リンクを開く)
- Markdown in Astro | Astro(外部リンクを開く)
- Features | Sätteri(外部リンクを開く)
- Plugins | Sätteri(外部リンクを開く)
脚注
-
sätteri はスウェーデン語で「組版室」を意味するようです(Sätteri | Rosenlöfs Vänner)。発音をカタカナで表すと「セッテリー」でしょうか(自信なし)。 ↩
-
HAST は「Hypertext Abstract Syntax Tree」、MDAST は「Markdown Abstract Syntax Tree」の略であり、それぞれ HTML と Markdown の抽象構文木を扱うときの仕様です。 ↩
-
github-sluggerは Astro が Markdown の見出しに ID を自動で付与するときに使用しているライブラリです(Heading IDs | Astro)。 ↩