EmDash テーマは完全な Astro サイト — ページ、レイアウト、コンポーネント、スタイル — にコンテンツモデルをブートストラップするシードファイルが含まれたものです。デザインを他者と共有したり、代理店でサイト作成を標準化するために構築します。
主なコンセプト
- テーマは動作する Astro プロジェクトです。 テーマ API や抽象化レイヤーはありません。サイトを構築し、テンプレートとして配布します。シードファイルは初回実行時に作成するコレクション、フィールド、メニュー、リダイレクト、タクソノミーを EmDash に伝えるだけです。
- EmDash は WordPress よりもコンテンツモデルをきめ細かく制御できます。 テーマはこれを活用し、シードファイルで各コレクションに必要なフィールドを正確に宣言します。標準の posts と pages コレクションを基盤にし、まったく新しいコンテンツタイプを発明するのではなく、デザインに必要なフィールドやタクソノミーを追加します。
- テーマのコンテンツページはサーバーレンダリングが必須です。 テーマではコンテンツが管理 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 # ページ(キャッチオール)
│ │ ├── posts/
│ │ │ ├── index.astro # 投稿アーカイブ
│ │ │ └── [slug].astro # 単一投稿
│ │ ├── categories/
│ │ │ └── [slug].astro # カテゴリアーカイブ
│ │ ├── tags/
│ │ │ └── [slug].astro # タグアーカイブ
│ │ ├── search.astro # 検索ページ
│ │ └── 404.astro # 404 ページ
│ ├── layouts/
│ │ └── Base.astro # ベースレイアウト
│ └── components/ # コンポーネント
├── .emdash/
│ ├── seed.json # スキーマとサンプルコンテンツ
│ └── uploads/ # 任意のローカルメディアファイル
└── public/ # 静的アセット
ページはキャッチオールルート([...slug].astro)としてルートに配置されるため、slug about のページは /about でレンダリングされます。投稿、カテゴリ、タグにはそれぞれ専用のディレクトリがあります。.emdash/ ディレクトリにはシードファイルとサンプルコンテンツで使用されるローカルメディアファイルが含まれます。
package.json の設定
package.json に emdash フィールドを追加します。
{
"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.seed | シードファイルのパス |
emdash.preview | ライブデモの URL(任意) |
デフォルトコンテンツモデル
ほとんどのテーマには posts と pages の 2 つのコレクションが必要です。Posts は抜粋とアイキャッチ画像を持つタイムスタンプ付きエントリで、フィードやアーカイブに表示されます。Pages はトップレベル URL の独立したコンテンツです。
これが推奨の出発点です。テーマの必要に応じてコレクション、タクソノミー、フィールドを追加しますが、ここから始めてください。
シードファイル
シードファイルは初回実行時に 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 にはリストやフィードに表示されるため excerpt と featured_image があります。Pages にはそれらは不要 — 独立したコンテンツです。テーマの要件に応じてどちらのコレクションにもフィールドを追加してください。
完全な仕様についてはシードファイル形式を参照してください。セクション、ウィジェットエリア、メディア参照も含まれます。
ページの構築
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>
ページ
ページはキャッチオールルートを使い、スラッグがトップレベル 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>
キャッチオールルートなので、より具体的なルートがない 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>
画像の使用
画像フィールドは文字列ではなく、src と alt プロパティを持つオブジェクトです。最適化された画像レンダリングには emdash/ui の Image コンポーネントを使用します。
---
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 では、ページコレクションに template セレクトフィールドを追加し、キャッチオールルートでレイアウトコンポーネントにマッピングします。
シードファイルのページコレクションにフィールドを追加します。
{
"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"
}
キャッチオールルートで値をレイアウトコンポーネントにマッピングします。
---
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、機能グリッド)がある場合、シードファイルでセクションとして定義します。
{
"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." }
]
}
]
}
]
}
シードファイルから作成されたセクションは source: "theme" としてマークされます。編集者は独自のセクション(source: "user" でマーク)も作成できますが、テーマ提供のセクションは管理 UI から削除できません。
サンプルコンテンツの追加
テーマのデザインを示すためにシードファイルにサンプルコンテンツを含めます。
{
"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 構文でサンプルコンテンツに画像を参照します。
リモート画像の場合:
{
"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)。
テーマのテスト
-
テーマからテストプロジェクトを作成:
npm create astro@latest -- --template ./path/to/my-theme -
依存関係をインストールして開発サーバーを起動:
cd test-site npm install npm run dev -
http://localhost:4321/_emdash/adminでセットアップウィザードを完了 -
コレクション、メニュー、リダイレクト、コンテンツが正しく作成されたことを確認
-
すべてのページテンプレートが正しくレンダリングされることをテスト
-
管理画面から新しいコンテンツを作成して全フィールドが動作することを確認
テーマの公開
npm に公開して配布します。
npm publish --access public
ユーザーは次のようにテーマをインストールできます。
npm create astro@latest -- --template @your-org/emdash-theme-blog
GitHub でホストされたテーマの場合:
npm create astro@latest -- --template github:your-org/emdash-theme-blog
カスタム Portable Text ブロック
テーマは特殊なコンテンツ用にカスタム Portable Text ブロックタイプを定義できます。マーケティングページ、ランディングページ、標準リッチテキスト以上の構造化コンポーネントが必要なコンテンツに便利です。
シードコンテンツでのカスタムブロック定義
シードファイルの 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"
}
コンポーネントでアンカーとして使用します。
<section id={value._key}>
<!-- content -->
</section>
これにより /#features のようなナビゲーションリンクが機能します。
テーマチェックリスト
公開前に、テーマに以下が含まれていることを確認します。
-
package.jsonにemdashフィールド(label、description、seed パス) - 有効なスキーマの
.emdash/seed.json - ページで参照されるすべてのコレクションがシードに存在
- レイアウトで使用されるメニューがシードで定義済み
- テーマのデザインを示すサンプルコンテンツ
- データベースとストレージ設定を含む
astro.config.mjs - EmDash ローダー付きの
src/live.config.ts - コンテンツページに
getStaticPaths()がない - ハードコードされたサイトタイトル、タグライン、ナビゲーションがない
- 画像フィールドがオブジェクト(
image.src)としてアクセスされ、文字列でない - セットアップ手順を含む README
- 非標準 Portable Text タイプ用のカスタムブロックコンポーネント
次のステップ
- シードファイル形式 — シードファイルの完全なリファレンス
- テーマの概要 — EmDash でのテーマの仕組み
- WordPress テーマの移植 — 既存の WordPress テーマの変換