Astro x microcms JAMSTACKブログ構築 - テンプレート開発

  • ホーム
  • Astro x microcms JAMSTACKブログ構築 - テンプレート開発
Astro x microcms JAMSTACKブログ構築 - テンプレート開発
目次

前回は「👈 Astro x microcms JAMSTACKブログ構築 - 環境設定

「SRC」で開発を行なう

ポイントとなる覚書

第1階層について

📦src
 ┣ 📂 components //UI関連使いまわし
 ┣ 📂 icons //svgアイコン等
 ┣ 📂 layouts //Header,Footer,Layoutなどレイアウト類
 ┣ 📂 pages //home,archive,post,static-page
 ┣ 📂 scss //共通cssや使い回し
 ┗ 📜 content.config.ts //microCMSのデータを読み込むための、Content Loader

「content.config.ts」ここにサイト内共通の情報やメソッドを用意する

import { defineCollection, z } from "astro:content";//「コンテンツコレクション(Content Collections)」 という機能を使い、ブログ記事やマークダウン(Markdown/MDX)ファイルを安全かつ便利に管理・型定義(TypeScript化)するために必須となるモジュール
import { createClient } from "microcms-js-sdk";//microCMS JavaScript SDKをインストール

// サイト共通設定 - 様々な箇所で呼び出す
export const siteConfig = {
  siteName: "ブログの名前",
  siteUrl: "https://------.com",
  defaultDescription:"デスクリプション",
  subcopy: "サブコピー",
  defaultImage: "/assets/og.png",
  locale: "ja_JP",
};

//型宣言
export type PageMeta = {
  title: string;
  description?: string;
  ogType?: "website" | "article";
  ogImage?: string;
  canonicalUrl?: string;
};

//microCMSとの接続
export const client = createClient({
  serviceDomain: import.meta.env.MICROCMS_SERVICE_DOMAIN,
  apiKey: import.meta.env.MICROCMS_API_KEY,
});


// microCMSのコンテンツローダー

const microCMSLoader = (endpoint: string) => {
  return async () => {
    try {
      console.log(`microCMSから${endpoint}データを取得中...`);
      const response = await client.getAllContents({
        endpoint,
      });
      console.log(`${response.length}件の${endpoint}を取得しました`);
      return response;
    } catch (error) {
      console.error(`microCMSからの${endpoint}取得に失敗:`, error);
      return [];
    }
  };
};

// 共通のフィールド
const microCMSDateFields = {
  createdAt: z.string(),
  updatedAt: z.string(),
  publishedAt: z.string(),
  revisedAt: z.string(),
};

export function filterBlogsByIds<T extends { id: string }>(
  blogs: T[],
  ids: readonly string[],
  limit = 5,
) {
  return blogs.filter((blog) => ids.includes(blog.id)).slice(0, limit);
}

// コレクションの定義
// 基本的にはテンプレートを使用。新しいものを追加するなどはGemini等で聞きながら構築を行なった。

const blog = defineCollection({
  loader: microCMSLoader("blog"),
  schema: z
    .object({
      title: z.string(),
      content: z.string(),
      insert_toc: z.boolean().optional(),
      eyecatch: z
        .object({
          url: z.string(),
          height: z.number().optional(),
          width: z.number().optional(),
        })
        .optional()
        .nullable(),
      categories: z
        .array(
          z
            .object({
              id: z.string(),
              name: z.string(),
            })
            .passthrough(),
        )
        .optional()
        .nullable(),
      tags: z
        .array(
          z
            .object({
              id: z.string(),
              name: z.string(),
            })
            .passthrough(),
        )
        .optional()
        .nullable(),
      ...microCMSDateFields,
    })
    .passthrough(),
});
/**
 * カテゴリーコレクションの定義
 * カテゴリー一覧ページで使用するために追加
 */
const categories = defineCollection({
  loader: microCMSLoader("categories"),
  schema: z
    .object({
      name: z.string(), // カテゴリー名
      ...microCMSDateFields,
    })
    .passthrough(),
});

const tags = defineCollection({
  loader: microCMSLoader("tags"),
  schema: z
    .object({
      name: z.string(), // カテゴリー名
      ...microCMSDateFields,
    })
    .passthrough(),
});

// コレクションのエクスポート
export const collections = {
  blog,
  categories,
  tags,
};
// 型定義
export type Blog = z.infer<typeof blog.schema>;

全ページ設定が反映する「Layout.astro」

Meta出力をSEOを意識した構造

---
import "@scss/_index.scss";//全体スタイルを構築
import { siteConfig, type PageMeta } from "../content.config";//サイト基本設定
import Header from "@layouts/Header.astro";//ヘッダーモジュール
import Footer from "@layouts/Footer.astro";//フッターモジュール
〜〜〜〜〜〜〜〜
//型宣言
interface Props extends PageMeta {
  title: string;
  description?: string;
  ogType?: "website" | "article";
  ogImage?: string;
  canonicalUrl?: string;
  bodyClassName?: string;
}
//Props設定
const {
  title,
  description = siteConfig.defaultDescription,
  ogType = "website",
  ogImage = siteConfig.defaultImage,
  canonicalUrl,
  bodyClassName = "",
} = Astro.props as Props;

//ページタイトル
const fullTitle = `${title} | ${siteConfig.siteName}`;

//URL設定
const pageUrl = new URL(
  Astro.url.pathname,
  `${siteConfig.siteUrl}${Astro.url.pathname}`,
);
〜〜〜〜〜〜〜〜
---
<!doctype html>
<html lang="ja" data-open-menu="false" data-header-show="true">
<head prefix={`og: http://ogp.me/ns# fb: http://ogp.me/ns/fb# ${ogType}: http://ogp.me/ns/${ogType}#`}>
<script is:inline data-astro-rerun>//「Astro」において、ページ遷移(View Transitions)後もスクリプトを毎回再実行させ、かつコードをバンドルせずそのままHTMLに埋め込むための記述
/*タグマネージャーのコード*/
</script>
<title>{fullTitle}</title>//ページタイトル
<!-- OGP タグ -->
<meta property="og:type" content={ogType} />
<meta property="og:site_name" content={siteConfig.siteName} />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:image" content={new URL(ogImage, siteConfig.siteUrl).toString()} />
<meta property="og:url" content={pageUrl} />
<meta property="og:locale" content={siteConfig.locale} />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={new URL(ogImage, siteConfig.siteUrl).toString()}/>
<link rel="canonical" href={pageUrl} />
<meta property="og:image" data-pagefind-meta="image[content]" content={pageUrl} />
<ClientRouter />
</head>

トップページ記事の呼び出し

---
import { getCollection } from "astro:content";
import Layout from "@layouts/Layout.astro";
import ArticleCard from "@components/blog/ArticleCard.astro";
import { Image } from "astro:assets";
// 全ブログ記事を取得
const allBlogs = await getCollection("blog");

// ブログをソート(新しい順)
const sortedBlogs = allBlogs.sort((a, b) => {
  return (
    new Date(b.data.publishedAt).getTime() -
    new Date(a.data.publishedAt).getTime()
  );
});
const ITEMS_PER_PAGE = 12; // 1ページあたりの記事数
const currentPage = 1;

const currentPageBlogs = sortedBlogs;
const totalPages = Math.ceil(currentPageBlogs.length / ITEMS_PER_PAGE);

// 現在のページのデータをスライス
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const pagedBlogs = currentPageBlogs.slice(startIndex, endIndex);
〜〜〜〜〜〜〜〜〜〜〜〜〜〜
---
<Layout title="ホーム" ogType="website" bodyClassName="p-home">//メタを設定
  <main>
<div class="c-container">
  {
//件数文の記事を出力
    pagedBlogs.length > 0 &&
      pagedBlogs.map((blog) => (
//ArticleCardcコンポーネントへ
        <ArticleCard
          id={blog.id}
          title={blog.data.title}
          publishedAt={blog.data.publishedAt}
          imageUrl={blog.data.eyecatch?.url || "/assets/no-image.webp"}
          categories={blog.data.categories}
          tags={blog.data.tags}
          articleClass="page"
        />
      ))
  }
</div>
  </main>
</Layout>
<script>
//このページのみのjsを直接記述
  import { Splide } from "@splidejs/splide";
  import { AutoScroll } from "@splidejs/splide-extension-auto-scroll";
  import { Intersection } from "@splidejs/splide-extension-intersection";
  const initFunc = () => {
    ~~~~~~~~~~~~~~~~
  }
//「DOMContentLoaded」ではなく「ClientRouter」を使用している場合「astro:page-load」で行うとこのらしい
  document.addEventListener("astro:page-load", initFunc);//Astroのページ遷移完了時
  ~~~~~~~~~~~~~~~~
<style lang="scss">//「lang="scss"」で「scss」が使える
//このページのみのcssを直接記述
~~~~~~~~~~~~~~~~
</style>

記事サムネイルを表示するコンポーネント

---
import { Image } from "astro:assets";
//型宣言
interface Props {
  id: string;
  title: string;
  publishedAt: string;
  imageUrl: string;
  categories?: { id: string; name: string; [key: string]: any }[] | null;
  tags?: { id: string; name: string; [key: string]: any }[] | null;
  articleClass?: string;
}
//Props
const {
  id,
  title,
  publishedAt,
  imageUrl,
  categories,
  tags,
  articleClass,
} = Astro.props;

//日付の調整
const date = new Date(publishedAt);
// 年と月を取得して「YYYY.MM」の形式にする
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0"); // 月は0から始まるため+1
const year_month = `${year}.${month}`; // 結果: "2026.05"

// 日を取得して「DD」の形式にする
const day = String(date.getDate()).padStart(2, "0"); // 結果: "08"

// 出力結果: "2026年5月8日"
const formattedDate = `${year}年${month}月${date.getDate()}日`;
---
<article
  class=`article article__${articleClass}`
  data-category={categories && categories[0].id}
>
  <div class="article__inner">
    <span class="article__figure-wrapper">
      <figure class="article__figure">
        //Astro Imageオブジェクトを利用
        <Image
          width={1200}
          height={630}
          src={imageUrl}
          alt={title}
          loading="lazy"
          decoding="async"
        />
      </figure></span
    >
    <hgroup class="article__hgroup">
        //投稿日を出力
      <time
        class="article__hgroup__time"
        datetime={formattedDate}
        aria-label={formattedDate}
      >
        {year_month}
        <span>{day}</span>
      </time>
      <div class="article__hgroup__meta">
      //カテゴリーを出力
        {
          categories && (
            <ul class="article__hgroup__category">
              {categories.map((category) => (
                <li>
                  <CategoryButton id={category.id} name={category.name} />
                </li>
              ))}
            </ul>
          )
        }
        //記事タイトル&リンク設定
        <a class="article__hgroup__title" href={`/${id}/`}
          ><em><span>{title}</span></em></a
        >
        //タグを出力
        {
          tags && (
            <ul class="article__hgroup__tag">
              {tags.map((tag) => (
                <li>
                  <TagButton id={tag.id} name={`#${tag.name}`} />
                </li>
              ))}
            </ul>
          )
        }
      </div>
    </hgroup>
  </div>
</article>
<style lang="scss">
~~~~~~~~~~~
</style>

次回は、難しと感じた箇所とこだわりをまとめたいと思います。