テーマの作成

このページ

EmDash テーマは、完全な Astro サイト(ページ、レイアウト、コンポーネント、スタイル)であり、コンテンツモデルを初期化するための seed ファイルも含まれています。他の人とデザインを共有したり、代理店のサイト作成を標準化するために作成します。

主要な概念

  • テーマは動作する Astro プロジェクトです。 テーマ API や抽象化レイヤーはありません。テーマはテンプレートとして提供されるサイトです。seed ファイルは、最初の実行時に作成するコレクション、フィールド、メニュー、リダイレクト、タクソノミーを EmDash に伝えます。
  • seed ファイルはコンテンツモデルを宣言します。 各コレクションに必要なフィールドを正確にリストします。完全に新しいコンテンツタイプを発明するのではなく、標準の postspages コレクションを基に構築し、デザインが必要とするフィールドとタクソノミーを追加します。
  • テーマのコンテンツページはサーバーレンダリングする必要があります。 テーマでは、管理 UI を通じて実行時にコンテンツが変更されるため、EmDash コンテンツを表示するページは事前レンダリングしてはいけません。テーマのコンテンツルートで getStaticPaths() を使用しないでください。(ビルド時のデータソースとして EmDash を使用する静的サイトビルドは getStaticPaths を使用_できます_が、テーマは常に SSR です。)
  • ハードコードされたコンテンツはありません。 サイトのタイトル、タグライン、ナビゲーション、その他の動的コンテンツは、テンプレート文字列からではなく、API 呼び出しを介して CMS から取得されます。

プロジェクト構造

テーマは次の構造を使用します:

my-emdash-theme/
├── package.json              # テーマのメタデータ
├── astro.config.mjs          # Astro + EmDash 設定
├── src/
│   ├── live.config.ts        # Live Collections セットアップ
│   ├── pages/
│   │   ├── index.astro       # ホームページ
│   │   ├── [...slug].astro   # ページ (catch-all)
│   │   ├── posts/
│   │   │   ├── index.astro   # 投稿アーカイブ
│   │   │   └── [slug].astro  # 単一投稿
│   │   ├── categories/
│   │   │   └── [slug].astro  # カテゴリーアーカイブ
│   │   ├── tags/
│   │   │   └── [slug].astro  # タグアーカイブ
│   │   ├── search.astro      # 検索ページ
│   │   └── 404.astro         # 見つかりません
│   ├── layouts/
│   │   └── Base.astro        # ベースレイアウト
│   └── components/           # コンポーネント
├── .emdash/
│   ├── seed.json             # スキーマとサンプルコンテンツ
│   └── uploads/              # オプションのローカルメディアファイル
└── public/                   # 静的アセット

ページはルートに catch-all ルート ([...slug].astro) として配置されるため、スラッグ about を持つページは /about でレンダリングされます。投稿、カテゴリー、タグは独自のディレクトリを取得します。.emdash/ ディレクトリには seed ファイルと、サンプルコンテンツで使用されるローカルメディアファイルが含まれています。

package.json の設定

package.jsonemdash フィールドを追加します:

{
	"name": "@your-org/emdash-theme-blog",
	"version": "1.0.0",
	"description": "A minimal blog theme for EmDash",
	"keywords": ["astro-template", "emdash", "blog"],
	"emdash": {
		"label": "Minimal Blog",
		"description": "A clean, minimal blog with posts, pages, and categories",
		"seed": ".emdash/seed.json",
		"preview": "https://your-theme-demo.pages.dev"
	}
}
フィールド説明
emdash.labelテーマピッカーに表示される表示名
emdash.descriptionテーマの簡単な説明
emdash.seedseed ファイルへのパス
emdash.previewライブデモの URL(オプション)

デフォルトのコンテンツモデル

ほとんどのテーマには、postspages の 2 つのコレクションタイプが必要です。投稿は、フィードやアーカイブに表示される抜粋と注目画像を持つタイムスタンプ付きエントリです。ページは、トップレベル URL の独立したコンテンツです。

これが推奨される出発点です。テーマが必要とするコレクション、タクソノミー、またはフィールドをさらに追加しますが、ここから始めてください。

Seed ファイル

seed ファイルは、最初の実行時に作成するものを EmDash に伝えます。.emdash/seed.json を作成します:

{
	"$schema": "https://emdashcms.com/seed.schema.json",
	"version": "1",
	"meta": {
		"name": "Minimal Blog",
		"description": "A clean blog with posts and pages",
		"author": "Your Name"
	},
	"settings": {
		"title": "My Blog",
		"tagline": "Thoughts and ideas",
		"postsPerPage": 10
	},
	"collections": [
		{
			"slug": "posts",
			"label": "Posts",
			"labelSingular": "Post",
			"supports": ["drafts", "revisions"],
			"fields": [
				{ "slug": "title", "label": "Title", "type": "string", "required": true },
				{ "slug": "content", "label": "Content", "type": "portableText" },
				{ "slug": "excerpt", "label": "Excerpt", "type": "text" },
				{ "slug": "featured_image", "label": "Featured Image", "type": "image" }
			]
		},
		{
			"slug": "pages",
			"label": "Pages",
			"labelSingular": "Page",
			"supports": ["drafts", "revisions"],
			"fields": [
				{ "slug": "title", "label": "Title", "type": "string", "required": true },
				{ "slug": "content", "label": "Content", "type": "portableText" }
			]
		}
	],
	"taxonomies": [
		{
			"name": "category",
			"label": "Categories",
			"labelSingular": "Category",
			"hierarchical": true,
			"collections": ["posts"],
			"terms": [
				{ "slug": "news", "label": "News" },
				{ "slug": "tutorials", "label": "Tutorials" }
			]
		}
	],
	"menus": [
		{
			"name": "primary",
			"label": "Primary Navigation",
			"items": [
				{ "type": "custom", "label": "Home", "url": "/" },
				{ "type": "custom", "label": "Blog", "url": "/posts" }
			]
		}
	],
	"redirects": [
		{ "source": "/category/news", "destination": "/categories/news" },
		{ "source": "/old-about", "destination": "/about" }
	]
}

投稿は、リストやフィードに表示されるため excerptfeatured_image を取得します。ページはそれらを必要としません — 独立したコンテンツです。テーマが必要とするフィールドを両方のコレクションに追加します。

セクション、ウィジェット領域、メディア参照を含む完全な仕様については、Seed ファイル形式 を参照してください。

ページの構築

EmDash コンテンツを表示するすべてのページは、サーバーレンダリングされます。Astro.params を使用して URL からスラッグを取得し、リクエスト時にコンテンツをクエリします。

ホームページ

---
import { getEmDashCollection, getSiteSettings } from "emdash";
import Base from "../layouts/Base.astro";

const settings = await getSiteSettings();
const { entries: posts } = await getEmDashCollection("posts", {
  where: { status: "published" },
  orderBy: { publishedAt: "desc" },
  limit: settings.postsPerPage ?? 10,
});
---

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

単一投稿

---
import { getEmDashEntry, getEntryTerms } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";

const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug!);

if (!post) {
  return Astro.redirect("/404");
}

const categories = await getEntryTerms("posts", post.id, "categories");
---

<Base title={post.data.title}>
  <article>
    <h1>{post.data.title}</h1>
    <PortableText value={post.data.content} />
    <div class="post-meta">
      {categories.map((cat) => (
        <a href={`/categories/${cat.slug}`}>{cat.label}</a>
      ))}
    </div>
  </article>
</Base>

Pages

ページはルートで catch-all ルートを使用するため、そのスラッグはトップレベル URL に直接マップされます — スラッグ about を持つページは /about でレンダリングされます:

---
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../layouts/Base.astro";

const { slug } = Astro.params;
const { entry: page } = await getEmDashEntry("pages", slug!);

if (!page) {
  return Astro.redirect("/404");
}
---

<Base title={page.data.title}>
  <article>
    <h1>{page.data.title}</h1>
    <PortableText value={page.data.content} />
  </article>
</Base>

これは catch-all ルートであるため、より具体的なルートを持たない URL にのみ一致します。/posts/hello-world はこのファイルではなく、posts/[slug].astro にヒットします。

カテゴリーアーカイブ

---
import { getTerm, getEntriesByTerm } from "emdash";
import Base from "../../layouts/Base.astro";

const { slug } = Astro.params;
const category = await getTerm("categories", slug!);
const posts = await getEntriesByTerm("posts", "categories", slug!);

if (!category) {
  return Astro.redirect("/404");
}
---

<Base title={category.label}>
  <h1>{category.label}</h1>
  {posts.map((post) => (
    <article>
      <h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
    </article>
  ))}
</Base>

画像の使用

画像フィールドは、文字列ではなく srcalt プロパティを持つオブジェクトです。最適化された画像レンダリングには emdash/uiImage コンポーネントを使用します:

---
import { Image } from "emdash/ui";

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

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

メニューの使用

レイアウトで管理者定義のメニューをクエリします。ナビゲーションリンクをハードコードしないでください:

---
import { getMenu, getSiteSettings } from "emdash";

const settings = await getSiteSettings();
const primaryMenu = await getMenu("primary");
---

<html>
  <head>
    <title>{Astro.props.title} | {settings.title}</title>
  </head>
  <body>
    <header>
      {settings.logo ? (
        <img src={settings.logo.url} alt={settings.title} />
      ) : (
        <span>{settings.title}</span>
      )}
      <nav>
        {primaryMenu?.items.map((item) => (
          <a href={item.url}>{item.label}</a>
        ))}
      </nav>
    </header>
    <main>
      <slot />
    </main>
  </body>
</html>

ページテンプレート

テーマには、デフォルトレイアウト、フルワイドレイアウト、ランディングページレイアウトなど、複数のページレイアウトが必要な場合がよくあります。EmDash では、pages コレクションに template 選択フィールドを追加し、catch-all ルートでレイアウトコンポーネントにマップします。

seed ファイルの pages コレクションにフィールドを追加します:

{
	"slug": "template",
	"label": "Page Template",
	"type": "string",
	"widget": "select",
	"options": {
		"choices": [
			{ "value": "default", "label": "Default" },
			{ "value": "full-width", "label": "Full Width" },
			{ "value": "landing", "label": "Landing Page" }
		]
	},
	"defaultValue": "default"
}

次に、catch-all ルートで値をレイアウトコンポーネントにマップします:

---
import { getEmDashEntry } from "emdash";
import PageDefault from "../layouts/PageDefault.astro";
import PageFullWidth from "../layouts/PageFullWidth.astro";
import PageLanding from "../layouts/PageLanding.astro";

const { slug } = Astro.params;
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={page} />

編集者は、ページを編集するときに管理 UI のドロップダウンからテンプレートを選択します。

セクションの追加

セクションは、編集者が /section スラッシュコマンドを使用して任意の Portable Text フィールドに挿入できる再利用可能なコンテンツブロックです。テーマに一般的なコンテンツパターン(ヒーローバナー、CTA、機能グリッド)がある場合は、seed ファイルでセクションとして定義します:

{
	"sections": [
		{
			"slug": "hero-centered",
			"title": "Centered Hero",
			"description": "Full-width hero with centered heading and CTA",
			"keywords": ["hero", "banner", "header", "landing"],
			"content": [
				{
					"_type": "block",
					"style": "h1",
					"children": [{ "_type": "span", "text": "Welcome to Our Site" }]
				},
				{
					"_type": "block",
					"children": [
						{ "_type": "span", "text": "Your compelling tagline goes here." }
					]
				}
			]
		},
		{
			"slug": "newsletter-cta",
			"title": "Newsletter Signup",
			"keywords": ["newsletter", "subscribe", "email"],
			"content": [
				{
					"_type": "block",
					"style": "h3",
					"children": [{ "_type": "span", "text": "Subscribe to our newsletter" }]
				},
				{
					"_type": "block",
					"children": [
						{
							"_type": "span",
							"text": "Get the latest updates delivered to your inbox."
						}
					]
				}
			]
		}
	]
}

seed ファイルから作成されたセクションは source: "theme" でマークされます。編集者は独自のセクション(source: "user" でマーク)を作成することもできますが、テーマ提供のセクションは管理 UI から削除できません。

サンプルコンテンツの追加

テーマのデザインを実演するために、seed ファイルにサンプルコンテンツを含めます:

{
	"content": {
		"posts": [
			{
				"id": "hello-world",
				"slug": "hello-world",
				"status": "published",
				"data": {
					"title": "Hello World",
					"content": [
						{
							"_type": "block",
							"style": "normal",
							"children": [{ "_type": "span", "text": "Welcome to your new blog!" }]
						}
					],
					"excerpt": "Your first post on EmDash."
				},
				"taxonomies": {
					"category": ["news"]
				}
			}
		]
	}
}

メディアの含め方

$media 構文を使用して、サンプルコンテンツで画像を参照します。リモート画像は URL で参照されます:

{
	"data": {
		"featured_image": {
			"$media": {
				"url": "https://images.unsplash.com/photo-xxx",
				"alt": "A descriptive alt text",
				"filename": "hero.jpg"
			}
		}
	}
}

ローカル画像の場合、.emdash/uploads/ にファイルを配置し、ファイル名で参照します:

{
	"data": {
		"featured_image": {
			"$media": {
				"file": "hero.jpg",
				"alt": "A descriptive alt text"
			}
		}
	}
}

シーディング中、メディアファイルはダウンロード(またはローカルで読み取り)され、ストレージにアップロードされます。

検索

テーマに検索ページが含まれている場合は、即座の結果のために 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>

LiveSearch は、プレフィックスマッチング、Porter ステミング、ハイライトされた結果スニペットを備えたデバウンス付きの即座の検索を提供します。検索は、管理 UI でコレクションごとに有効にする必要があります(Content Types > Edit > Features > Search)。

テーマのテスト

  1. テーマからテストプロジェクトを作成します:

    npm create astro@latest -- --template ./path/to/my-theme
  2. 依存関係をインストールし、dev サーバーを起動します:

    cd test-site
    npm install
    npm run dev
  3. http://localhost:4321/_emdash/admin でセットアップウィザードを完了します

  4. コレクション、メニュー、リダイレクト、コンテンツが正しく作成されたことを確認します

  5. すべてのページテンプレートが適切にレンダリングされることをテストします

  6. 管理 UI を通じて新しいコンテンツを作成して、すべてのフィールドが機能することを確認します

テーマの公開

配布のために npm に公開します:

npm publish --access public

その後、ユーザーはテーマをインストールできます:

npm create astro@latest -- --template @your-org/emdash-theme-blog

GitHub でホストされているテーマは、github: テンプレートプレフィックスでインストールされます:

npm create astro@latest -- --template github:your-org/emdash-theme-blog

カスタム Portable Text ブロック

テーマは、専門的なコンテンツのためのカスタム Portable Text ブロックタイプを定義できます。これは、マーケティングページ、ランディングページ、または標準のリッチテキストを超えた構造化コンポーネントを必要とするコンテンツに役立ちます。

Seed コンテンツでカスタムブロックを定義する

seed ファイルの Portable Text コンテンツで名前空間付き _type を使用します:

{
	"content": {
		"pages": [
			{
				"id": "home",
				"slug": "home",
				"status": "published",
				"data": {
					"title": "Home",
					"content": [
						{
							"_type": "marketing.hero",
							"headline": "Build something amazing",
							"subheadline": "The all-in-one platform for modern teams.",
							"primaryCta": { "label": "Get Started", "url": "/signup" }
						},
						{
							"_type": "marketing.features",
							"_key": "features",
							"headline": "Everything you need",
							"features": [
								{
									"icon": "zap",
									"title": "Lightning fast",
									"description": "Built for speed."
								}
							]
						}
					]
				}
			}
		]
	}
}

ブロックコンポーネントの作成

各カスタムブロックタイプ用の Astro コンポーネントを作成します:

---
interface Props {
  value: {
    headline: string;
    subheadline?: string;
    primaryCta?: { label: string; url: string };
  };
}

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

<section class="hero">
  <h1>{value.headline}</h1>
  {value.subheadline && <p>{value.subheadline}</p>}
  {value.primaryCta && (
    <a href={value.primaryCta.url} class="btn">
      {value.primaryCta.label}
    </a>
  )}
</section>

カスタムブロックのレンダリング

カスタムブロックコンポーネントを PortableText コンポーネントに渡します:

---
import { PortableText } from "emdash/ui";
import Hero from "./blocks/Hero.astro";
import Features from "./blocks/Features.astro";

interface Props {
  value: unknown[];
}

const { value } = Astro.props;

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

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

ページでラッパーコンポーネントをレンダリングします:

---
import { getEmDashEntry } from "emdash";
import MarketingBlocks from "../components/MarketingBlocks.astro";

const { entry: page } = await getEmDashEntry("pages", "home");
---

<MarketingBlocks value={page.data.content} />

ナビゲーションのためのアンカー ID

リンク可能にすべきブロックに _key を追加します:

{
	"_type": "marketing.features",
	"_key": "features",
	"headline": "Features"
}

ブロックコンポーネントでアンカーとして _key 値を使用します:

<section id={value._key}>
  <!-- content -->
</section>

これにより、/#features のようなナビゲーションリンクが可能になります。

テーマチェックリスト

公開する前に、テーマに以下が含まれていることを確認してください:

  • emdash フィールド(ラベル、説明、seed パス)を持つ package.json
  • 有効なスキーマを持つ .emdash/seed.json
  • ページで参照されているすべてのコレクションが seed に存在する
  • レイアウトで使用されているメニューが seed で定義されている
  • サンプルコンテンツがテーマのデザインを実演している
  • データベースとストレージ構成を持つ astro.config.mjs
  • EmDash ローダーを持つ src/live.config.ts
  • コンテンツページに getStaticPaths() がない
  • ハードコードされたサイトタイトル、タグライン、ナビゲーションがない
  • 画像フィールドが文字列ではなくオブジェクト(image.src)としてアクセスされている
  • セットアップ手順を含む README
  • 非標準の Portable Text タイプのカスタムブロックコンポーネント

次のステップ