このページで分かること

  • 既存のテンプレートを土台にする方法、テンプレートの構成、シードファイルの指定とコンテンツモデルの定義
  • サーバーで描画するルート、サイト設定・メニュー・ウィジェットエリアの取得、画像フィールドの描画、レイアウトの選択肢、検索、セクション、独自のPortable Textブロック
  • テンプレートのテスト手順、GitHubでの公開方法、公開前のチェックリスト
難易度
実践
読む時間
5分
このページの目次

EmDashのテーマは、他の開発者が create-astro で雛形として使えるAstroのプロジェクトです。プロジェクトを1つの完成したサイトとしてビルドしてテストし、そのうえで、ルートとコンポーネントが前提にするコンテンツモデルを作成するシードを含めます。

現在のテンプレートからの開始

作りたいサイトとデプロイ先に最も近い既存のテンプレートを選びます。Node版とCloudflare版は、それぞれデータベース、ストレージ、アダプター、ミドルウェアの設定をひとまとめにしています。

コンテンツ中心のサイトには、blogテンプレートが土台として役立ちます。

npm create astro@latest -- --template @emdash-cms/template-blog

作るテーマがCloudflare Workers、D1、R2を対象にする場合は、@emdash-cms/template-blog-cloudflare を使います。

テンプレートの構成の維持

現在のblogテンプレートでは、関係するパスは次のとおりです。

Template structure
astro.config.mjs
emdash-env.d.ts
package.json
seed/
└── seed.json
src/
├── components/
│   └── PostCard.astro
├── layouts/
│   └── Base.astro
├── live.config.ts
├── pages/
│   ├── index.astro
│   ├── category/[slug].astro
│   ├── pages/[slug].astro
│   ├── posts/index.astro
│   ├── posts/[slug].astro
│   ├── search.astro
│   └── tag/[slug].astro
└── styles/

starter、portfolio、marketingのテンプレートは、別のルートを使います。すべてのテーマにすべてのパスを受けるページのルートがあると決めつけず、選んだ土台から実際のファイルをコピーします。

シードの指定

現在のテンプレートは、シードのパスを package.json で宣言しています。

package.json
{
  "name": "@example/emdash-theme-publication",
  "private": true,
  "type": "module",
  "emdash": {
    "seed": "seed/seed.json"
  }
}

EmDashは、.emdash/seed.json と、慣例的な代替の seed/seed.json も見つけます。テンプレートを配布するときは、どのファイルを使うかが明確になるように、パッケージのフィールドを使います。

コンテンツモデルの定義

blogテンプレートから始める場合は、既存の seed/seed.json を関係のないモデルで置き換えるのではなく、そのファイルを編集します。次の縮小したシードは、このガイドの例で使うコレクションと構造のデータを残しています。

seed/seed.json
{
  "$schema": "https://emdashcms.com/seed.schema.json",
  "version": "1",
  "meta": {
    "name": "Publication",
    "description": "A publication with posts"
  },
  "settings": {
    "title": "Publication",
    "tagline": "Latest articles"
  },
  "collections": [
    {
      "slug": "posts",
      "label": "Posts",
      "labelSingular": "Post",
      "supports": ["drafts", "revisions", "search", "seo"],
      "fields": [
        {
          "slug": "title",
          "label": "Title",
          "type": "string",
          "required": true,
          "searchable": true
        },
        {
          "slug": "excerpt",
          "label": "Excerpt",
          "type": "text"
        },
        {
          "slug": "featured_image",
          "label": "Featured image",
          "type": "image"
        },
        {
          "slug": "content",
          "label": "Content",
          "type": "portableText",
          "searchable": true
        }
      ]
    },
    {
      "slug": "pages",
      "label": "Pages",
      "labelSingular": "Page",
      "supports": ["drafts", "revisions", "search"],
      "fields": [
        {
          "slug": "title",
          "label": "Title",
          "type": "string",
          "required": true,
          "searchable": true
        },
        {
          "slug": "content",
          "label": "Content",
          "type": "portableText",
          "searchable": true
        },
        {
          "slug": "template",
          "label": "Page template",
          "type": "select",
          "defaultValue": "default",
          "validation": {
            "options": ["default", "full-width", "landing"]
          }
        }
      ]
    }
  ],
  "menus": [
    {
      "name": "primary",
      "label": "Primary navigation",
      "items": [
        { "type": "custom", "label": "Home", "url": "/" },
        { "type": "custom", "label": "Posts", "url": "/posts" }
      ]
    }
  ],
  "widgetAreas": [
    {
      "name": "sidebar",
      "label": "Sidebar",
      "widgets": []
    }
  ],
  "content": {
    "posts": [
      {
        "id": "post-welcome",
        "slug": "welcome",
        "status": "published",
        "data": {
          "title": "Welcome",
          "excerpt": "The first article",
          "content": []
        }
      }
    ]
  }
}

コンテンツのエントリーの id は、参照で使う、シードの中だけの識別子です。ルートを持つエントリーの場合、この値がそのままデータベースのIDになるとは限りません。slug は、クエリのAPIが entry.id として公開する、ルートの識別子になります。

タクソノミー、バイライン、メニューの参照、メディア、リダイレクト、ウィジェットエリアセクション、ローカライズ、競合したときの動作を追加する前に、シードファイル形式を読みます。

サーバーで描画するルートの作成

現在のEmDashのテンプレートは output: "server" を使います。ルートは、リクエストのたびに最新のコンテンツをクエリします。テンプレートが意図的にEmDashをビルド時のデータソースとしてだけ使う場合を除き、テーマのコンテンツのルートに getStaticPaths() を追加しないでください。

次の一覧ページは、保存されている published_at フィールドを使い、データベースの中で並べ替えます。

src/pages/posts/index.astro
---
import { getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro";

const { entries: posts, error, cacheHint } = await getEmDashCollection("posts", {
  orderBy: { published_at: "desc" },
});

if (error) return new Response("Could not load posts", { status: 500 });
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---

<Base title="Posts">
  {posts.map((post) => (
    <article>
      <h2><a href={`/posts/${post.id}`}>{post.data.title}</a></h2>
      {post.data.excerpt && <p>{post.data.excerpt}</p>}
    </article>
  ))}
</Base>

orderBy は、フィールドと並び順の向きを対応させたオブジェクトです。sortsortBy、JavaScriptのコールバックではなく、orderBy: { published_at: "desc" } を使います。

次のルートは、1件の投稿を特定して描画します。

src/pages/posts/[slug].astro
---
import { decodeSlug, getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";

const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.redirect("/404");

const { entry: post, error, cacheHint } = await getEmDashEntry("posts", slug);
if (error) return new Response("Could not load the post", { status: 500 });
if (!post) return Astro.redirect("/404");
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---

<Base title={post.data.title} content={{ collection: "posts", id: post.data.id, slug }}>
  <article>
    <h1 {...post.edit.title}>{post.data.title}</h1>
    <PortableText value={post.data.content} />
  </article>
</Base>

ルートのURLには post.id を、保存されたコンテンツのIDが必要なヘルパーには post.data.id を使います。

本サイトの補足 やさしい解説

WordPressのテーマでは、single.phparchive.php のようなテンプレート階層に沿ってファイルを置きます。EmDashのテーマでは、Astroの src/pages/ にルートのファイルを置き、ファイルの中で getEmDashCollection()getEmDashEntry() を呼び出してコンテンツを取得します。公式テンプレートはすべてのページをサーバーで描画するため、管理画面で公開した変更は次のリクエストから表示されます。URLに使うのは post.id(スラッグ)で、post.data.id とは別の値である点に注意します。

サイトで管理するナビゲーションの取得

CMSで管理する値は、対応するAPIから取得します。現在のテンプレートは、レイアウトで getSiteSettings()getMenu()<WidgetArea /> を使っています。

src/layouts/Base.astro
---
import { getMenu, getSiteSettings } from "emdash";
import { WidgetArea } from "emdash/ui";

const [settings, primary] = await Promise.all([
  getSiteSettings(),
  getMenu("primary"),
]);
---

<header>
  <a href="/">{settings.title}</a>
  <nav>
    {primary?.items.map((item) => <a href={item.url}>{item.label}</a>)}
  </nav>
</header>

<main><slot /></main>
<aside><WidgetArea name="sidebar" /></aside>

デザインの一部として固定の文言は、Astroのファイルに残してかまいません。管理者が編集することを想定している値は、設定、コンテンツ、メニュー、ウィジェットで表す必要があります。

画像フィールドの描画

画像フィールドの値はメディアの値で、URLの文字列ではありません。ローカルのストレージと画像のプロバイダーを同じように解決できるように、値をそのまま Image コンポーネントに渡します。

src/components/PostCard.astro
---
import { Image } from "emdash/ui";

const { post } = Astro.props;
---

<article>
  {post.data.featured_image && (
    <Image
      image={post.data.featured_image}
      alt={post.data.title}
      width={800}
      height={450}
    />
  )}
  <h2><a href={`/posts/${post.id}`}>{post.data.title}</a></h2>
</article>

このコンポーネントは、上書きしない限り、フィールド自体の代替テキストを使います。priority は、最初に見える範囲(above the fold)に表示される想定の画像にだけ使います。それ以外の画像は遅延読み込みのままです。

ページのレイアウトの選択肢

上のシードには、編集者が複数のページのレイアウトを必要とするサイト向けに、select フィールドが含まれています。この機能を別のシードに追加する場合は、既知のコンポーネントに対応する、変わらない値を使います。

{
  "slug": "template",
  "label": "Page template",
  "type": "select",
  "defaultValue": "default",
  "validation": {
    "options": ["default", "full-width", "landing"]
  }
}

これで、ルートは明示的なコンポーネントの対応表から選べます。

src/pages/pages/[slug].astro
---
import { decodeSlug, getEmDashEntry } from "emdash";
import PageDefault from "../../layouts/PageDefault.astro";
import PageFullWidth from "../../layouts/PageFullWidth.astro";
import PageLanding from "../../layouts/PageLanding.astro";

const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.redirect("/404");

const { entry: page } = await getEmDashEntry("pages", slug);
if (!page) return Astro.redirect("/404");

const layouts = {
  default: PageDefault,
  "full-width": PageFullWidth,
  landing: PageLanding,
};
const Layout = layouts[page.data.template as keyof typeof layouts] ?? PageDefault;
---

<Layout {page} />

明示的な対応表にしておくと、保存されたフィールドの値が任意のモジュールのパスとして扱われることを防げます。

検索結果に表示する各コレクションで search を有効にし、関係するフィールドを searchable にします。現在のテンプレートは、すぐに使える検索のルートとして LiveSearch を使っています。

src/pages/search.astro
---
import LiveSearch from "emdash/ui/search";
import Base from "../layouts/Base.astro";
---

<Base title="Search">
  <h1>Search</h1>
  <LiveSearch placeholder="Search posts and pages" collections={["posts", "pages"]} />
</Base>

Astroのi18nを使うサイトでは、LiveSearchAstro.currentLocale を使います。locale={null} を渡すのは、ページが意図的にすべてのロケールを検索する場合だけにします。

再利用できるセクションのシード

セクションは、編集者が再利用できるPortable Textの出発点です。行動を促す部分のように、デザインに繰り返し現れるコンテンツのパターンがある場合に追加します。

seed/seed.json
{
  "version": "1",
  "sections": [
    {
      "slug": "newsletter-signup",
      "title": "Newsletter signup",
      "description": "Heading and copy for the newsletter form",
      "keywords": ["newsletter", "email"],
      "content": [
        {
          "_type": "block",
          "_key": "newsletter-heading",
          "style": "h2",
          "children": [
            { "_type": "span", "_key": "newsletter-heading-text", "text": "Get new articles by email" }
          ]
        }
      ]
    }
  ]
}

セットアップウィザードでは、シードに含まれるエントリー、バイライン、タクソノミーのタームを含めないことを選べます。その場合も、セクションと、その他の構造のモデルは適用されます。

独自のPortable Textブロックの追加

テーマに独自の公開側のレンダラーが必要な場合は、シードのPortable Textにブロックの形を追加し、テンプレートでその _type をAstroのコンポーネントに対応付けます。シードは、新しいブロックの種類のための編集画面のUIを登録しません。

marketingテンプレートは、marketing.hero のような名前空間付きの値を使います。そのラッパーが、これらの値をAstroのコンポーネントに対応付けます。

src/components/MarketingBlocks.astro
---
import type { PortableTextBlock } from "emdash";
import { PortableText } from "emdash/ui";
import Hero from "./blocks/Hero.astro";
import Features from "./blocks/Features.astro";

interface Props {
  value: PortableTextBlock[];
}

const { value } = Astro.props;
const marketingTypes = {
  "marketing.hero": Hero,
  "marketing.features": Features,
};
---

<PortableText value={value} components={{ type: marketingTypes }} />

シードのデータの形と、コンポーネントのpropsを一致させておきます。編集画面が確実に参照できるように、Portable Textのすべてのオブジェクトに変わらない _key を付けます。

ブロックに再利用できる独自の編集画面と、パッケージにした描画用のコンポーネントが必要な場合は、ネイティブ型プラグインを使います。サンドボックス型プラグインは、Astroの描画用のコンポーネントをサイトのビルドに組み込めません。

テンプレートのテスト

  1. create-astro で、空のディレクトリにテンプレートから雛形を作ります。

  2. 依存パッケージをインストールし、サイトのビルドと型チェックのコマンドを実行します。

  3. 空のデータベースから始めて、/_emdash/admin/setup を完了します。

  4. サンプルコンテンツを含めた場合と含めない場合の両方で、セットアップをテストします。

  5. シードのコンテンツ、新しく作成したコンテンツ、任意の画像がない状態、空のコレクションのそれぞれで、すべてのルートを開きます。

  6. 管理画面から、サイトの設定、メニュー、タクソノミーの割り当て、Portable Textを編集します。公開側のルートが変更後の値を使うことを確認します。

  7. 使い捨ての環境で、デプロイ先に固有のセットアップを適用し、メディアのストレージ、プレビュー、サーバーでの描画を確認します。

テンプレートの公開

GitHubのリポジトリは、Astroの github: のテンプレートの書き方でそのまま使えます。

npm create astro@latest -- --template github:example/emdash-theme-publication

公開する前に、ローカルのデータベースとアップロードしたファイルを削除し、秘密情報をリポジトリに含めないようにし、クリーンなチェックアウトからパッケージのパスを確認し、テンプレートがどのデプロイ先を設定しているかを文書に書きます。

チェックリスト

  • [ ] astro.config.mjs がサーバー出力と、想定するデプロイ用のアダプターを使っている。
  • [ ] src/live.config.tsemdashLoader() を登録している。
  • [ ] package.json#emdash.seed が存在するファイルを指している。
  • [ ] クエリするすべてのコレクションとフィールドを、シードが宣言している。
  • [ ] クエリの例が orderBy と正しいエントリーの識別子を使っている。
  • [ ] CMSで管理するサイトの値を、テンプレートの固定の定数として重複して持っていない。
  • [ ] サンプルコンテンツを含めた場合も含めない場合も、まっさらな状態からのセットアップが動く。
  • [ ] 対応するデプロイ先で、テンプレートのビルドと型チェックに通る。