AstroのMarkdownコンテンツをカスタマイズして分割表示する（ただし非推奨）

## この記事の概要

AstroのContent Collectionに対して公式ドキュメントに記載されていない、ハック的なアプローチで、Markdownコンテンツをページ内で分割表示するためのやり方の記事です。

:::note alert
この記事で紹介する方法は、個人的に見つけたものであり、後述するように「たまたま動いている」可能性があるため、プロダクション環境での使用は推奨されません。

今回の方法は個人サイトを作る中で見つけたものなので、私一人で考えたものに過ぎません（= 他者のレビューを通っていません）。
:::

## 環境

この記事を執筆している時点でのAstroの最新、4.15.9を使っています。
動作検証や説明のために書いたコードは以下のリポジトリにあります。

https://github.com/xrxoxcxox/qiita-astro-content-collection-multi-markdown

## 実施したいこと

Astroプロジェクトにおいて、以下のようなフォルダ構成でcontent collectionを利用しているとします。

```:フォルダ構成
src
├── content
│   └── blog
│       ├── alfa
│       │   └── index.md
│       ├── bravo
│       │   └── index.md
│       └── charlie
│           └── index.md
└── pages
    ├── blog
    │   └── [...slug].astro
    └── index.astro
```

このとき、`src/pages/blog/[slug].astro`は大まかには以下のようになります。

```jsx:src/pages/blog/[slug].astro
---
import { getCollection } from "astro:content";

export async function getStaticPaths() {
  const entries = await getCollection("blog");
  return entries.map((entry) => ({
    params: { slug: entry.slug },
    props: { entry },
  }));
}

const { entry } = Astro.props;
const { Content } = await entry.render();
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width" />
    <meta name="generator" content={Astro.generator} />
    <title>{entry.data.title}</title>
  </head>
  <body>
    <h1>{entry.data.title}</h1>
    <Content />
  </body>
</html>
```

このとき、`Content`を分割してページを構成したいとします。
例えばブログの最初に要約を載せ、要約とメインコンテンツとの間に特定のコンポーネントを挿入したいとします。

:::note warn
以下のコードは動きません。
:::

```diff_jsx:src/pages/blog/[slug].astro
+ import { SomeComponent } from "path/to/component"

  const { entry } = Astro.props;
- const { Content } = await entry.render();
+ const { SummaryContent, MainContent } = await entry.render();
---

// 中略

  <body>
    <h1>{entry.data.title}</h1>
-   <Content />
+   <SummaryContent />
+   <SomeComponent />
+   <MainContent />
  </body>
```

この通りでは動かないのですが、どうにか似たような挙動ができるように頑張ります。

## 問題点

まず`Content`にはMarkdownファイルを**完全に**レンダリングした内容が入っています。

そのため途中で分割することができません。

https://docs.astro.build/ja/guides/markdown-content/#content-コンポーネント

また`entry.render()`には`Content`, `headings`, `remarkPluginFrontmatter`のみが入っており、やはり分割はできなさそうです。

https://github.com/withastro/astro/blob/2594eb088d53a98181ac820243bcb1a765856ecf/packages/astro/src/content/runtime.ts#L358

## 解決策

かなりゴリ押しの解決ではありますが、解決できました。

### ファイル構成の変更

まずは以下のようにファイル構成を変更します。

```diff:フォルダ構成
  src
  ├── content
  │   └── blog
  │       ├── alfa
  │       │   ├── index.md
+ │       │   └── _summary.md
  │       ├── bravo
  │       │   ├── index.md
+ │       │   └── _summary.md
  │       └── charlie
  │           ├── index.md
+ │           └── _summary.md
  └── pages
      ├── blog
      │   └── [...slug].astro
      └── index.astro
```

ファイル名にアンダースコアをつけているのは、ビルド対象から外すためです。
逆に、つけないと`example.com/blog/alfa/summary` でアクセスできてしまいます。

https://docs.astro.build/ja/guides/routing/#ページの除外

### ルーティング処理の変更

ひとまず完成形のコードを載せます。

```diff_jsx:src/pages/blog/[slug].astro
  ---
- import { getCollection } from "astro:content";
+ import { getCollection, type CollectionEntry } from "astro:content"; 

+ export async function getSummary(slug: CollectionEntry<"blog">["slug"]) {
+   try {
+     const summary = await import(`../../content/blog/${slug}/_summary.md`);
+     return summary;
+   } catch (error) {
+     return null;
+   }
+ }

  export async function getStaticPaths() {
    const entries = await getCollection("blog");
-   return entries.map((entry) => ({
-     params: { slug: entry.slug },
-     props: { entry },
-   }));
+   const paths = await Promise.all(
+     entries.map(async (entry) => {
+       const SummaryContent = await getSummary(entry.slug);
+       return {
+         params: { slug: entry.slug },
+         props: { entry, SummaryContent },
+       };
+     }),
+   );
+   return paths;
  }

- const { entry } = Astro.props;
+ const { entry, SummaryContent } = Astro.props;
  const { Content } = await entry.render();
  ---

  <html lang="en">
    <head>
      <meta charset="utf-8" />
      <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
      <meta name="viewport" content="width=device-width" />
      <meta name="generator" content={Astro.generator} />
      <title>{entry.data.title}</title>
    </head>
    <body>
      <h1>{entry.data.title}</h1>
+     {SummaryContent ? <SummaryContent.default /> : null}
      <Content />
    </body>
  </html>
```

全体像をざっと箇条書きにすると以下のようになります。

1. await importを利用した`getSummary()`を定義する
1. `getCollection()`の中で`getSummary()`を使い、propsに`SummaryContent`を渡す
1. マークアップ内で`<SummaryContent.default>`を呼ぶ

### 怪しい箇所

`SummaryContent`は`<SummaryContent />`だと動かず`<SummaryContent.default />`とする必要があります。

最初は[Astro.glob()](https://docs.astro.build/ja/guides/imports/#astroglob)の説明にあるように`AstroInstance`型だからと思いましたが、実際には`any`型でした。

https://docs.astro.build/ja/reference/api-reference/#astro-files

`SummaryContent`の中身を見ると次のようになっていました。

```
{
  frontmatter: [Getter],
  file: [Getter],
  url: [Getter],
  rawContent: [Getter],
  compiledContent: [Getter],
  getHeadings: [Getter],
  Content: [Getter],
  default: [Function (anonymous)] {
    isAstroComponentFactory: true,
    moduleId: undefined,
    propagation: undefined
  },
  [Symbol(Symbol.toStringTag)]: 'Module'
}
```

ここで`Content`を見ると次のようになっていました。

```
[Function (anonymous)] {
  isAstroComponentFactory: true,
  moduleId: undefined,
  propagation: 'self'
}
```

「似てるな……」と思って試しに`<SummaryContent.default>`を指定したら、やりたかった挙動が叶いました。

こんなコードの書き方は仕事では絶対にしない方が良いと思うのですが、それはそれとして似たような挙動を実装したいときもあるような気がして、備忘録のため記事にしました。