建立主題

本頁內容

EmDash 主題是一個完整的 Astro 網站——頁面、版面配置、元件、樣式——並包含種子檔案來初始化內容模型。建立主題可以與他人分享你的設計,或為你的工作室標準化網站建立流程。

核心概念

  • 主題就是一個可運行的 Astro 專案。 沒有主題 API 或抽象層。你建立一個網站並以範本形式發布。種子檔案只是告訴 EmDash 首次執行時要建立哪些集合、欄位、選單、重新導向和分類法。
  • EmDash 給你比 WordPress 更多的內容模型控制權。 主題利用了這一點——種子檔案精確宣告每個集合需要的欄位。在標準的 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),因此 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(選用)

預設的內容模型

大多數主題需要兩種集合類型:postspages。文章是帶時間戳的條目,具有摘要和精選圖片,會出現在動態消息和存檔中。頁面是位於頂層 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" }
	]
}

文章有 excerptfeatured_image,因為它們會出現在清單和動態消息中。頁面不需要——它們是獨立內容。依主題需求為任一集合新增欄位。

完整規格(包含區塊、小工具區域和媒體參考)請見種子檔案格式

建立頁面

所有顯示 EmDash 內容的頁面都是伺服器渲染的。使用 Astro.params 從 URL 取得 slug,在請求時查詢內容。

首頁

---
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 路由,因此其 slug 直接對應到頂層 URL——slug 為 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 路由中將其對應到版面元件。

在種子檔案的 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 中編輯頁面時,可從下拉選單選擇範本。

新增區塊

區塊(Sections)是可重複使用的內容區塊,編輯者可使用 /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." }
					]
				}
			]
		},
		{
			"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."
						}
					]
				}
			]
		}
	]
}

從種子檔案建立的區塊標記為 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)。

測試主題

  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 託管的主題:

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.jsonemdash 欄位(label、description、seed path)
  • .emdash/seed.json 含有效的結構描述
  • 頁面中參考的所有集合都存在於種子中
  • 版面配置中使用的選單已在種子中定義
  • 範例內容展示了主題的設計
  • astro.config.mjs 含資料庫與儲存設定
  • src/live.config.ts 含 EmDash 載入器
  • 內容頁面無 getStaticPaths()
  • 無寫死的網站標題、標語或導覽
  • 圖片欄位以物件方式存取(image.src),而非字串
  • 含設定說明的 README
  • 非標準 Portable Text 類型的自訂區塊元件

下一步