概要
ブログに Wiki 用語機能を追加した。用語の一覧・詳細ページ、ブログ記事本文への自動リンク変換、管理画面からの CRUD、ブログトップへの最新用語表示が主な機能だ。
実装の過程で、Astro の CSS スコープに起因するバグが複数回発生した。サイト管理者から「なぜ毎回起きるのか、AI の問題か、Astro の問題か」という問いがあったので、原因と対策をまとめておく。

Wiki 機能について
できること
/blog/wiki/に用語一覧ページ(五十音・A-Z 索引タブ、カテゴリ絞り込み、キーワード検索)/blog/wiki/{slug}に用語詳細ページ(難易度バッジ、関連用語、著者情報)- ブログ記事本文に登録済み用語が自動でリンク変換される(
<code>や<a>の内側は変換しない) - ブログトップ下部に最新 6 件の用語を表示
- 管理画面から用語の作成・編集・削除が可能(著者・AI モデル情報も記録)
設計のポイント
自動リンク変換はステートマシンで実装した。
Marked でレンダリングしたあとの HTML を 1 文字ずつ走査し、<a> / <code> / <pre> タグの内側ではリンク変換を行わないようにしている。同一用語は 1 記事内で最初の 1 回のみリンク化し、英字用語は前後の文字種を確認して単語の一部にマッチしないよう正規表現で制御している。
D1 テーブルが存在しない環境でも動く。 すべての D1 クエリを try/catch で囲み、テーブル未作成でもエラーページにならないようにした。Content Collections をフォールバックとして使う構成にしている。
CSS スコープ問題:なぜ繰り返し起きるのか
今回発生したバグ
ページネーションの margin-top を設定しても、ブラウザの DevTools では 0px と表示されてまったく効かなかった。display: flex は正しく適用されているのに、margin だけが消えるという不可解な挙動だった。
原因
CSS は次の 2 箇所に分かれて書かれていた。
<style> ← Astro がスコープを付与する通常ブロック
* { margin: 0; padding: 0; }
</style>
<style is:global> ← スコープなしのグローバルブロック
.pagination { margin-top: 48px; }
</style>
Astro はスコープ付きブロックのセレクタを [data-astro-cid-xxx] 属性付きに変換する。* は *[data-astro-cid-xxx] となり、詳細度は (0, 1, 0) になる。
一方、グローバルブロックの .pagination { margin-top: 48px } は詳細度 (0, 1, 0) のまま変わらない。
詳細度が同じ場合、CSS ファイル内で後に書かれたルールが勝つ。Astro がバンドルを生成するとき、スコープ付きスタイルがグローバルスタイルの後に出力される場合、*[data-astro-cid-xxx] { margin: 0 } が .pagination { margin-top: 48px } を上書きしてしまう。
display: flex が生き残っていたのは、* ルールに display プロパティが含まれていなかったからだ。margin は * の margin: 0 によってすべて 0 に戻されていた。
これは Astro の問題か、AI の問題か
どちらでもある。 正確には「Astro 固有の落とし穴と、AI がそれを見落とすことの組み合わせ」だ。
Astro のスコープ CSS は、コンポーネントの意図しない副作用を防ぐための機能として設計されている。ただし、is:global と通常 <style> ブロックを同じページに混在させたとき、出力 CSS の順序によって詳細度が同じルール同士が予想外の順番で適用されることがある。これは Astro のドキュメントに明記されているわけではなく、動かして初めて気づく類の問題だ。
AI(Claude)側の問題としては、「is:global を使うときは * リセットとの詳細度の衝突を事前にチェックする」という習慣がなかった点が挙げられる。実装時点では display: flex が効いているように見え、margin が消えているとは気づきにくかった。
起きやすい状況
*やhtml,bodyなどのグローバルリセットとis:globalスタイルが共存するページ- JS で動的に生成される要素に CSS を当てるために
is:globalを使うケース(今回のページネーションがまさにこれ) - ページが複数の
<style>ブロックを持ち、スコープ付きとグローバルが混在しているとき
今後の解決策
1. is:global のマージン指定には !important を使う(暫定対応)
<style is:global>
.pagination {
margin-top: 48px !important;
margin-bottom: 48px !important;
}
</style>
詳細度の勝負をせず、強制的に優先させる。乱用すると保守性が下がるが、is:global 側に限定して使うなら影響範囲が明確になる。
2. JS 生成要素のスタイルは is:global に統一し、リセットも同じブロックに書く(根本対応)
スコープ付きブロックの * リセットを is:global 側に移すか、あるいは is:global 要素のリセットを明示的に上書きするルールをセットで書く。
<style is:global>
* { box-sizing: border-box; margin: 0; padding: 0; }
.pagination { margin-top: 48px; margin-bottom: 48px; }
</style>
3. 実装後に DevTools で computed style を確認する(プロセス対応)
margin-top をセットした後、ブラウザの DevTools で computed 値が期待通りか確認する。0px と出たら即座にスコープ問題を疑う。
ページネーション以外で起きたバグ
戻るリンクがクリックできない問題
詳細ページ最下部の「← 一覧に戻る」リンクが、中央付近をクリックしても反応しなかった。
原因は、フローティングメニューのラッパー要素(position: fixed; z-index: 300)がリンクの上に重なっていたことだ。ラッパー自体には pointer-events が設定されておらず、メニューカード(非表示状態)の DOM 領域がクリックを横取りしていた。
elementFromPoint() で実際に最前面にある要素を確認したところ、フローティングメニューのラッパー div が返ってきた。これで原因が特定できた。
修正はラッパーに pointer-events: none を付け、内側のトリガーボタンに pointer-events: auto を戻すだけで解決した。
まとめ
| 問題 | 原因 | 修正 |
|---|---|---|
| pagination の margin が 0 になる | is:global と <style> の出力順序で * リセットが勝っていた |
!important で強制優先 |
| 戻るリンクがクリックできない | フローティングメニューのラッパーが pointer-events を横取り |
ラッパーに pointer-events: none |
Astro の CSS スコープは強力な機能だが、is:global を混在させると詳細度とバンドル順序の組み合わせで予想外の上書きが起きる。グローバルなリセット CSS と is:global スタイルを同じページで使う場合は、computed style を必ず確認する習慣が必要だ。