1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-09-28

この記事の概要

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

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

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

環境

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

実施したいこと

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は大まかには以下のようになります。

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を分割してページを構成したいとします。
例えばブログの最初に要約を載せ、要約とメインコンテンツとの間に特定のコンポーネントを挿入したいとします。

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

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ファイルを完全にレンダリングした内容が入っています。

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

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

解決策

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

ファイル構成の変更

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

フォルダ構成
  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 でアクセスできてしまいます。

ルーティング処理の変更

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

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()を定義する
  2. getCollection()の中でgetSummary()を使い、propsにSummaryContentを渡す
  3. マークアップ内で<SummaryContent.default>を呼ぶ

怪しい箇所

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

最初はAstro.glob()の説明にあるようにAstroInstance型だからと思いましたが、実際にはany型でした。

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>を指定したら、やりたかった挙動が叶いました。

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?