Back to Portfolio
March 3, 2026

Astro Content collections のCSS運用

このサイトのノートはAstroのContent collectionsによって管理しています。
(具体的には、src/content/notes/配下にmdxファイルを配置しています。)
こうしたMarkdownによるコンテンツ管理の手法、すごく便利ではあるんですがスタイルシートの管理を自分好みに調整しようと思うとちょっと大変だったりします。

mdxで管理しているコンテンツのスタイル管理の手法はいくつかあるんですが、個人的にはrehypeプラグインによるクラス適用がしっくりきたのでその運用方法を書きます。

前提: 構成

content.config.ts

Content collectionではcontent.config.tsで特定のファイルパターンに対してzodを用いたプロパティの定義をすることで型安全に記事を管理できるようになっています。
参考: The collection config file

僕が管理しているnoteの場合、以下のような設定になっています。

import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const notes = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/notes" }),
  schema: ({ image }) => z.object({
    id: z.string(),
    title: z.string(),
    description: z.string().optional(),
    thumbnail: image().optional(),
    published_at: z.date(),
    updated_at: z.date().optional(),
  }),
});

export const collections = { notes };

こうすることで、src/content/notes/配下にあるmdファイルおよびmdxファイルを再帰的に探索し、それらをnotesのコンテンツとして扱うようになります。
この実装の場合、対象のコンテンツはFrontMatterにid, title, description, thumbnail, published_at, updated_atのプロパティを持つmdファイルおよびmdxファイルとなります。
thumbnailupdated_atはoptionalなので指定しなくても良いですが、それ以外の必須項目の設定が不足していたり、値が型と一致しないものになっている場合はビルド時にエラーが発生します。

---
id: hoge
title: テストタイトル
description: 僕はFrontMatter
published_at: 2026-03-03T22:17:00+09:00
---

## 本文

Hello World!

pages/notes/[id].astro

コンテンツを表示する画面です。今あなたが見ているこの画面もpages/notes/[id].astroです。

Content collectionsではgetCollection()などのAPIを用いてコンテンツを取得できます。

以下は、content.config.tsで定義したnotesに対応するコンテンツ一覧を取得し、その数だけページを生成するための実装です。

---
import { getCollection, render } from "astro:content";

import Layout from "@/layouts/Layout.astro";

export const getStaticPaths = async () => {
  const notes = await getCollection("notes");
  return notes.map((note) => ({
    params: { id: note.data.id }, // ノートのFrontMatterで設定しているidをパスに使う
    props: { note }, // コンポーネントでノートのコンテンツ(プロパティや本文など)を利用できるようにする
  }));
};

const { note } = Astro.props;
const { title } = note.data;
const { Content } = await note.render(); // 本文のレンダリングコンテンツ
---

<Layout>
  <h1>{title}</h1>
  <Content />
</Layout>

ボツ案: 親要素に適用したクラスセレクタと要素セレクタ

rehypeプラグインでの実装の紹介の前に、それ以前にどのような実装をしていたかを紹介します。
これは素朴にやるのであればおそらく一番手っ取り早い手法です。

まず以下のようなスタイルシートを用意します。

.note-content h2 {
  font-size: 1.5rem;
  font-weight: bold;
  margin-top: 2rem;
  margin-bottom: 1rem;
}

/* and other styles... */

そして、pages/notes/[id].astroで以下のようにスタイルシートの読み込みと、Contentの親要素にスタイル適用のためのclassを付与します。

---
import { getCollection, render } from "astro:content";

import Layout from "@/layouts/Layout.astro"; // 👈 用意したスタイルシートのimport

export const getStaticPaths = async () => {
  const notes = await getCollection("notes");
  return notes.map((note) => ({
    params: { id: note.data.id },
    props: { note },
  }));
};

const { note } = Astro.props;
const { title } = note.data;
const { Content } = await note.render();
---

<Layout>
  <h1>{title}</h1>
  <div class="note-content"> <!-- 👈 コンテンツにスタイルを適用するためのclassを付与 -->
    <Content />
  </div>
</Layout>

今回の場合、## testのような見出し2のコンテンツに対して.note-content h2のスタイルが適用されます。

この方法の良いところは、シンプルかつ直感的であることです。
Chrome Dev Toolsを使えばDOM構造も確認できるのでセレクタのコントロールもしやすく、容易にスタイルを調整できます。

一方でmdx内でAstroコンポーネントを呼び出す場合、その内部でh2要素を利用しているとスタイルを汚染してしまう可能性があります。
これを回避するためだけに詳細度を調整したり最悪important宣言をしたりみたいなことになるのは個人的に絶対に避けたかったので、僕の場合はこの方法で運用するのは避ける判断にしました。

採用案: rehypeプラグインによる要素ごとのクラス付与

ボツ案のように要素セレクタを用いることはスタイルの意図せぬ汚染を招くため、クラスセレクタのみで解決するために要素ごとに指定のクラスを付与できるプラグインを作ることにしました。

AstroはMarkdownをHTMLに変換するためにunifiedのエコシステムを利用しているため、その仕組みを利用します。

unifiedは言語間の変換をするために変換前の言語をASTにパースし、変換先のASTへの変換処理を経てから変換先の言語のデータを生成する仕組みになっています。
今回の場合、MarkdownをmdastというASTに変換し、それをhastというHTMLのASTに変換、最終的にそれをHTMLにするみたいな感じです。

unifiedはそれぞれの変換処理のためのモジュールを提供しているだけでなく、自由にプラグインを適用できる仕組みも提供しています。
Astroもユーザーがこれを利用できるようにプラグイン適用のためのインタフェースを用意しています。
参考: Adding remark and rehype plugins

事前準備

今回はHTMLのAST(hast)を操作するプラグインを作るので、rehypeの型情報をインストールします。

pnpm add -D @types/hast

プラグインの実装

今回は以下のようなプラグインを実装しました。

import type { Element, Nodes } from 'hast'

// 要素ごとに適用したいクラス名を定義
const ElementMap: Record<string, string> = {
  // 見出し
  h1: 'content-h1',
  h2: 'content-h2',
  h3: 'content-h3',
  h4: 'content-h4',
  h5: 'content-h5',
  h6: 'content-h6',
  // リスト
  ul: 'content-ul',
  ol: 'content-ol',
  // コード
  pre: 'content-pre',
  code: 'content-code',
  // リンク
  a: 'content-anchor',
  // 引用文
  blockquote: 'content-blockquote',
  // テキスト
  p: 'content-paragraph',
  // 画像
  img: 'content-image',
  // 区切り線
  hr: 'content-hr',
}

// https://unifiedjs.com/learn/guide/create-a-plugin/
export const rehypeAddContentClass = () =>
  function (node: Nodes, _vfile: any, done: any) {
    if (node.type === 'root' && node.children.length > 0) {
      node.children.forEach((node) => {
        if (node.type === 'element') addClass(node)
      })
    }
    done()
  }

const addClass = (node: Element) => {
  // className 付与
  if (ElementMap[node.tagName]) node.properties.className = ElementMap[node.tagName]

  // 子要素を持っている場合は再起的に評価
  if (node.children.length > 0) {
    node.children.forEach((node) => {
      if (node.type === 'element') addClass(node)
    })
  }
}

hastのノードを再帰的に検証して、要素のノードであればその種類に応じたクラスを付与するという実装になっています。

プラグインの適用

astro.config.mjsに上述したプラグインを適用します。

import mdx from "@astrojs/mdx";
import { defineConfig } from "astro/config";
import { rehypeAddContentClass } from "./src/utils/rehype-content-class.ts";

// https://astro.build/config
export default defineConfig({
	site: "https://tacona.net",
	integrations: [mdx()],
	markdown: {
		rehypePlugins: [rehypeAddContentClass], // 👈 rehypeプラグインを適用
	},
});

これでmdxのコンテンツとして記述している各要素に対して.content={element}という命名規則のクラスが付与されるようになります。

スタイルシートの準備

ボツ案のスタイルシートのセレクタを以下のように変更します。

- .note-content h2 {
+ .content-h2 {
  font-size: 1.5rem;
  font-weight: bold;
  margin-top: 2rem;
  margin-bottom: 1rem;
}

/* and other styles... */

これでmdx内で利用しているAstroコンポーネント内の要素を汚染しなくなります。めでたしめでたし。

余談: Astroコンポーネントはhastではどう解釈されるのか

Astroコンポーネントも内部ではh1とかpとか普通に使うやん。それに対して.content-{element}のclassは付与されへんの?と感じる人がいるかもしれません。
例えば以下のようなmdxがあった場合、hastはどのようなノードの状態になるのかという話です。

---
id: hoge-hoge-hoge-hoge
title: test
description: test
published_at: 2026-03-03T22:17:00+09:00
---

## test

<NoteCard id="b2836863-c2dc-4d63-9bcb-13b1b3483f8b" />

テスト

先ほどのプラグインの実装の内部でnodeをログ出力したところ、以下のような結果になりました。

{
  type: 'root',
  children: [
    {
      type: 'element',
      tagName: 'h2',
      properties: {},
      children: [Array],
      position: [Object]
    },
    { type: 'text', value: '\n' },
    {
      type: 'mdxJsxFlowElement',
      name: 'NoteCard',
      attributes: [Array],
      position: [Object],
      data: [Object],
      children: []
    },
    { type: 'text', value: '\n' },
    {
      type: 'element',
      tagName: 'p',
      properties: {},
      children: [Array],
      position: [Object]
    }
  ],
  data: { quirksMode: false },
  position: {
    start: { line: 1, column: 1, offset: 0 },
    end: { line: 13, column: 1, offset: 189 }
  }
}

Astroコンポーネント(NodeCard)はmdxJsxFlowElementという型として評価されていました。
僕が作成しているプラグインではelementの型のみを付与対象としているので、その配下の要素に対してclassが付与されることもないわけですね。

そもそもmdxJsxFlowElementにはchildrenもいないので、mdxで呼び出しているAstroコンポーネントはこれらのMarkdown→HTMLへのパース処理よりも後続で評価されているということになっていると思います。(実際に実装を見ていないので推測です)
まぁそんなこんなでrehypeプラグインでのクラス付与はmdx内のAstroコンポーネントのスタイル汚染の防止に有効であると言えるわけでした。


実は今回のrehypeプラグイン、このサイトの前身であるNext.js(SSG)・MicroCMS構成のサイトでMicroCMSで管理していたマークダウンテキストをunifiedエコシステムを用いてパースする仕組みを作っていた時の遺物なんですよね。
このサイトを公開したタイミングで古いリポジトリを抹消しようと考えていたんですが、まさかここでも利用できるとは思っていなくてすごい得をした気分になりました。

unifiedは、いいぞ😋