テーマを作成する

このページ

EmDash テーマは、ほかの開発者が create-astro でスキャフォールドできる Astro プロジェクトです。プロジェクトを完全なサイトとして構築・テストし、そのルートとコンポーネントが想定するコンテンツモデルを作成する seed を含めてください。

現行テンプレートから始める

目的のサイトとデプロイ先にもっとも近い既存のテンプレートを選びます。Node 版と Cloudflare 版は、データベース、ストレージ、アダプター、ミドルウェアの設定をまとめて保持しています。

ブログテンプレートはコンテンツサイトの出発点として便利です。

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

完成したテーマが Cloudflare Workers、D1、R2 を対象にする場合は、@emdash-cms/template-blog-cloudflare を使ってください。

テンプレート構造を維持する

現行のブログテンプレートは、次の関連パスを使います。

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 テンプレートは異なるルートを使います。どのテーマにも catch-all のページルートがあると仮定せず、選んだベースから実際のファイルをコピーしてください。

seed を指し示す

現行のテンプレートは、package.json で seed のパスを宣言します。

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

EmDash は .emdash/seed.json と、慣例の seed/seed.json へのフォールバックも検出します。テンプレートを配布するときは、対象のファイルを明示するために package フィールドを使ってください。

コンテンツモデルを定義する

ブログテンプレートから始める場合は、無関係なモデルに置き換えるのではなく、既存の seed/seed.json を編集してください。次の簡略化した seed は、このガイドの例で使うコレクションと構造データを保持しています。

{
  "$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 は、参照に使われる seed ローカルの識別子です。ルーティング可能なエントリのデータベース ID になるよう強制されるものではありません。その slug が、クエリ API で entry.id として公開されるルート識別子になります。

タクソノミー、バイライン、メニュー参照、メディア、リダイレクト、ウィジェットエリア、セクション、ローカライゼーション、競合動作を追加する前に、シードファイルを読んでください。

サーバー側レンダリングルートを構築する

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

次のアーカイブは、保存されている published_at フィールドを使ってデータベース側で並べ替えます。

---
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 は、フィールドから方向へのオブジェクトです。sort、sortBy、JavaScript のコールバックではなく、orderBy: { published_at: "desc" } を使ってください。

次のルートは、1 件の投稿を解決して表示します。

---
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.rewrite("/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.rewrite("/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 を使ってください。

サイト管理のナビゲーションをクエリする

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

---
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 コンポーネントに渡してください。

---
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 はファーストビューに表示される想定の画像に限って使い、それ以外の画像は遅延読み込みのままにしてください。

ページレイアウトの選択肢を提供する

上の starter seed には、編集者が複数のページレイアウトを必要とするサイト向けに select フィールドが含まれています。この機能をほかの seed に追加するときは、既知のコンポーネントに対応する安定した値を使ってください。

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

ルートは、明示的なコンポーネントマップから選択できます。

---
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.rewrite("/404");

const { entry: page } = await getEmDashEntry("pages", slug);
if (!page) return Astro.rewrite("/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 を使っています。

---
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 を使うサイトでは、LiveSearch は Astro.currentLocale を使います。ページが意図的にすべてのロケールを検索する場合にのみ、locale={null} を渡してください。

再利用可能な section を seed する

section は、編集者に再利用可能な Portable Text の出発点を提供します。コールトゥアクションのような繰り返される内容パターンがデザインに含まれる場合に追加してください。

{
  "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" }
          ]
        }
      ]
    }
  ]
}

セットアップウィザードでは、ユーザーが seed されたエントリ、バイライン、タクソノミーの用語を除外できます。section と、そのほかの構造モデルは引き続き適用されます。

構造化ページ block を追加する

ページ自体が型付きの section を順序立てて構成したものである場合は、blocks フィールドを使ってください。オブジェクトがリッチテキスト文書の内側にあり、その段落の流れに加わる場合は、カスタム Portable Text block を使ってください。

marketing テンプレートは、保持される 5 つの block タイプを seed し、Pages の content フィールドでそれらを許可しています。そのラッパーは、生成されたすべての _type を Astro コンポーネントにマップします。

---
import { Blocks, defineBlockComponents } from "emdash/ui";
import type { PageContentBlock } from "../../emdash-env";
import FAQ from "./blocks/FAQ.astro";
import Features from "./blocks/Features.astro";
import Hero from "./blocks/Hero.astro";
import Pricing from "./blocks/Pricing.astro";
import Testimonials from "./blocks/Testimonials.astro";

const components = defineBlockComponents<PageContentBlock>({
  marketing_hero: Hero,
  marketing_features: Features,
  marketing_testimonials: Testimonials,
  marketing_pricing: Pricing,
  marketing_faq: FAQ,
});
---

<Blocks value={Astro.props.value} components={components} />

生成される PageContentBlock ユニオン型により、コンポーネントマップは許可されたすべての block タイプと保持されたバージョンに対応した状態に保たれます。seed された各 block の値には、_type、_version、安定した _key が含まれます。

記事に埋め込まれた図のように、段落の間に置かれるオブジェクトには、カスタム Portable Text block を使ってください。そのオブジェクトは、PortableText コンポーネントの components.type オプションでマップします。seed には保存された値を含められますが、その Portable Text オブジェクトを作成・編集するには、ネイティブプラグインがカスタムエディタを登録する必要があります。

block に再利用可能なカスタムエディタとパッケージ化されたレンダリングコンポーネントが必要な場合は、ネイティブプラグインを使ってください。サンドボックス化されたプラグインは、Astro のレンダリングコンポーネントをサイトのビルドに同梱できません。

テンプレートをテストする

  1. create-astro でテンプレートをクリーンなディレクトリにスキャフォールドします。

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

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

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

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

  6. 管理画面でサイト設定、メニュー、タクソノミーの割り当て、Portable Text、block を編集します。block については、許可された各タイプを追加、並べ替え、複製、削除します。公開ルートが変更後の値を使っていることを確認します。

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

テンプレートを公開する

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

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

公開する前に、ローカルのデータベースとアップロードを削除し、シークレットをリポジトリに含めず、クリーンなチェックアウトからパッケージのパスを確認し、テンプレートが設定するデプロイ先を文書化してください。

チェックリスト

  • astro.config.mjs がサーバー出力と意図したデプロイ用アダプターを使っている。
  • src/live.config.ts が emdashLoader() を登録している。
  • package.json#emdash.seed が存在するファイルを指している。
  • クエリされるすべてのコレクションとフィールドが seed で宣言されている。
  • クエリの例が orderBy と正しいエントリ識別子を使っている。
  • CMS が管理するサイトの値が、テンプレートの固定定数として重複していない。
  • クリーンなセットアップが、サンプルコンテンツありでもなしでも動作する。
  • サポート対象のターゲットで、テンプレートのビルドと型チェックが通る。