テーマの作成

このページ

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(オプション)

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

ほとんどのテーマには2つのコレクションタイプが必要です:postspages。postsは抜粋とアイキャッチ画像を持つタイムスタンプ付きのエントリで、フィードやアーカイブに表示されます。pagesはトップレベル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" }
	]
}

postsはexcerptfeatured_imageを取得します。これはリストやフィードに表示されるためです。pagesはこれらを必要としません — 独立したコンテンツです。テーマの要求に応じて任意のコレクションにフィールドを追加してください。

セクション、ウィジェットエリア、メディア参照を含む完全な仕様については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>

ページ

ページはルートに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}
      priority
    />
  )}
  <h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
  <p>{post.data.excerpt}</p>
</article>

priorityはページのファーストビューに表示される可能性が高い画像(ヒーローや最初のカード画像など)にのみ使用してください。画像をloading="eager"fetchpriority="high"でレンダリングします。loadingはブラウザがロードを遅延できるかどうかを制御し、fetchpriorityはブラウザがリクエストを発見した後の重要度を示します。

メニューの使用

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

---
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. 依存関係をインストールして開発サーバーを起動します:

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

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

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

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

テーマの公開

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}>
  <!-- コンテンツ -->
</section>

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

テーマチェックリスト

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

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

次のステップ