AstroSätteriMarkdownTailwind CSS

Astro + Tailwindで脚注を書いたら勝手に「Footnotes」と出る

Astro + Tailwindで脚注を書いたら勝手に「Footnotes」と出る

はじめに

このサイトで初めて記事に脚注を書いた時,記事を公開した後で内容を確認したら,脚注一覧の直前に見覚えのない「Footnotes」という英語の見出しが。

生成されたHTMLを見ると,こういう要素が挿入されていました。

<h2 class="sr-only" id="footnote-label">Footnotes</h2>

は「スクリーンリーダーには読ませるが,視覚的には隠す」ためのクラスで,読み上げたときに「ここから先は脚注です」と分かるようにするためのものです。

つまり,本来は目に見えないアクセシビリティ用のラベルが何らかの仕組みによって挿入された上,画面上に露出してしまっているという状況です。

当初ClaudeはSätteriが生成した見出しを後から書き換えるhastプラグインを実装することで対応していたんですが,後々調べたらイケてない対応だったとわかったので,反省の意も込めて正しい原因と対策について記事にしようと思います。

前提

この記事の内容は,以下の構成で確認したものです。

パッケージ バージョン
astro 7.1.5
@astrojs/markdown-satteri 0.3.8
satteri 0.10.5
tailwindcss 4.3.3

「Footnotes」の出所

まず,この<h2>を挿入しているのは誰なんでしょうか?

.mdの時点で存在しなかったものがHTMLになった時に出現するということは,Markdown→HTMLの変換過程で「混入」していると考えるのが自然です。それを行うのがconvertMdastToHastHandle(),つまりmdastをhastに変換する工程ですね。

その中で呼ばれるemit_footnotes_section()という関数があるので,実際にソースを見てみましょう。

// crates/satteri-ast/src/emit.rs
fn emit_footnotes_section<S: ConvertSink>(ctx: &EmitCtx<'_, '_>, sink: &mut S, depth: u32) {
    // ...
    sink.open_element("h2", Pos::None);
    sink.attr(CLASS_NAME, AttrValue::class_list("sr-only"));
    sink.attr(ID, AttrValue::text("footnote-label"));
    sink.finish_attrs();
    sink.text(&ctx.options.footnote_label, Pos::None);
    sink.close_element("h2");

Rustが読めなくても,"sr-only"とか"footnote-label"とか書いてあるので,ここで最終的なHTML出力に出てくる値が指定されているのがわかりますね。

ちなみに呼び出しチェーンは

convert_mdast_to_hast_handle()(Node-APIバインディング)

mdast_arena_to_hast_arena_into()

emit_node()

emit_footnotes_section()

となっています。これで「犯人」はわかりましたね。

emit_footnotes_section()を定義しているemit.rsは,mdastを要素へ変換する処理をhast用とHTML文字列用で共用する設計になっています(ファイルの冒頭に「The mdast → element mapping, written once and driven into either a HAST arena or an HTML string.」というコメントがあります)。

つまり,markdownToHtml()でhastを経由せず直接HTMLを得る場合も同じ関数を通るので,どちらのAPIを使っても脚注の見出しは必ず付いてくることになります。

また,「Footnotes」という文言(emit_footnotes_section()が参照するctx.options.footnote_label)も同じの中にありました。

// crates/satteri-ast/src/convert.rs
impl Default for ConvertOptions {
    fn default() -> Self {
        Self {
            footnote_label: "Footnotes".to_string(),

なお,「Footnotes」という文言の由来については,Sätteriの型定義ファイル(node_modules/satteri/dist/compile.d.ts)のコメントに答えが書いてありました。

/**
 * i18n strings for the GFM footnotes section. Mirrors `footnoteLabel`,
 * `footnoteBackLabel`, and `footnoteBackContent` from remark-rehype.
 */
export interface FootnoteOptions {
    /** `<h2>` label opening the footnotes section. Default: `"Footnotes"`. */
    label?: string;

つまり,remark-rehype ―― 正確にはその内部で使われるmdast-util-to-hastに合わせた値だということですね。Sätteri独自の判断ではなく,既存のエコシステムと同じ出力になるよう揃えてあるわけです。

sr-onlyのラベルが露出した原因

.sr-onlyが付いているh2が画面上に現れてしまったということは,このクラスが効いていないということです。考えられる原因は2つあります。

  1. .sr-onlyのCSSルールがそもそも生成されていない
  2. ルールは生成されているが,他のルールに上書きされて無効化されている

実際に生成されたCSSを確認したところ,答えは1でした。.sr-onlyに対応するルールがどこにも存在していなかったのです。そして.sr-onlyはTailwindが提供しているクラスなので,これはTailwindがルールを生成しなかったということになります。

Tailwind CSS v4では,ビルド時にソースファイルを自動検出&スキャンして,実際に書かれているクラス名の分だけCSSを生成しているんですが,このsr-onlyはSätteriがレンダリング時に動的に注入しているだけなので,.astroにも.mdにもリテラルとして存在しません。Tailwindから見れば「誰も使っていないクラス」なので,CSSを出す理由がありません。その結果,意味のないクラス名としてsr-onlyが付いているだけの<h2>が通常の見出しとして表示されてしまったというわけです。

問題の本質

上の議論で,この問題には2つの要素が組み合わさっていることがわかりました。つまり,

  1. sr-onlyの挙動が想定とずれる問題: SätteriとTailwindの仕様のズレ
  2. ラベルが英語になっている: Astroの設定漏れ(後述)

ということですね。

対処

今回は最終的に,astro.config.mjsfeaturesにオプションを渡して「Footnotes」ではなく「注釈」と表示されるようにしました。

processor: satteri({
  features: {
    gfm: { footnotes: { label: '注釈' } }
  }
})

このように設定しておくと,SätteriのJS層(satteri/dist/compile.js)で次のようにRust側に受け渡してくれます。

// 指定されたラベルをネイティブ側に渡す(未指定なら何も渡さない)
if (label !== undefined) convertOptions.footnoteLabel = label;

labelを書いたときだけconvertOptions.footnoteLabelに値が入り,emit_footnotes_section()が参照していたctx.options.footnote_labelを上書きします。逆に書かなければネイティブ側には何も渡らないので,ConvertOptions::default()"Footnotes"がそのまま出てくる,というわけですね。

ちなみに,脚注のラベル表記の問題はAstroで「設定から文言を変えられない」という話が過去(2022年4月……Astro 1.0のbeta時代)Issueとして報告されており,その対応としてオプションが公開されるようになったという経緯があります。Sätteriが同じ役割のオプションを持っているのも,この流れの延長線上にありそうです。

sr-onlyが隠れない問題については,global.css@source inline("sr-only")と書けばTailwindが強制的に.sr-onlyのCSSを生成してくれると判明したものの,今回は「まぁ見出しがあって困ることは特にないか」と思ったので,結果として採用は見送りました。

まとめ

処理系が勝手に足してくれる要素は,ソースのどこにも書かれていないぶん見落としやすいです。今回は「隠れるはずのものが見えている」という分かりやすい形で表に出ましたが,backLabelのように読み上げ環境でしか出てこないものは,意識して探しにいかないと英語のまま残り続けることになるので注意が必要ですね。

参考

screen reader onlyの略で,「スクリーンリーダー(画面読み上げソフト)にだけ伝えたい情報」に付けるCSSクラスの慣用名。

視覚的には見えない状態にしつつ,支援技術からは読み取れるように残す。display: noneで消してしまうと読み上げからも外れてしまうため,画面外に追い出す・1pxに潰すといった手法で実現する。

Rustにおけるコンパイルの単位。1つのライブラリ,または1つの実行可能ファイルがそれぞれ1つのクレートにあたる。

規模の大きいプロジェクトでは,機能ごとに複数のクレートへ分割し,それらをまとめて1つのリポジトリで管理する構成がよく使われる。ソースがcrates/というディレクトリの下に並んでいるのはこの形。

他の言語で「ライブラリ」「モジュール」と呼ばれるものに近いが,Rustにはmodで作る「モジュール」がクレート内部の区切りとして別に存在するため,両者は区別される。外部のクレートはCargo.tomlに依存として書けば取り込める。

パッケージとクレート - The Rust Programming Language 日本語版

ブラウザのキャッシュを意図的に無効化(bust)するための手法。

ブラウザはCSSやJavaScriptを一度読み込むとキャッシュし,次回は再ダウンロードしない。そのため,ファイル名が同じままだと,中身を更新しても古いキャッシュが使われ続けてしまう。

そこでファイルの中身から計算したハッシュをファイル名に含める(例: index.CFsWQAmR.css)。中身が変われば名前も変わるためブラウザには別ファイルとして扱われ,確実に取り直される。逆に中身が変わっていなければ名前も同じままなので,キャッシュがそのまま効く。

HTMLのidname属性を使って,JavaScriptのグローバル変数やDOMのプロパティを上書きしてしまう攻撃手法。「clobber」は「殴り倒す・上書きする」の意。

ブラウザには「idが付いた要素がwindowのプロパティとして自動的に生える」という古い仕様がある。そのためスクリプト側が使っている名前と同じidを書かれると,本来のオブジェクトの代わりにDOM要素が入り込む。<form name="getElementById">のように書けばdocument.getElementByIdを関数でなくしてしまうこともできる。

スクリプトを注入できなくてもid属性さえ書ければ成立するため,ユーザーが書いた内容をHTMLに変換する処理では対策が必要になる。ユーザー由来のidに共通の接頭辞を付けてアプリ側の名前空間から隔離するのが一般的な対策。