EmDash 主題是一個完整的 Astro 網站 — 頁面、佈局、元件、樣式 — 同時包含一個 seed 檔案來初始化內容模型。建立一個主題來與他人分享你的設計,或為你的機構標準化網站建立流程。
核心概念
- 主題是一個可運行的 Astro 專案。 沒有主題 API 或抽象層。主題是打包為範本的網站。seed 檔案告訴 EmDash 在首次執行時建立哪些集合、欄位、選單、重新導向和分類法。
- seed 檔案宣告內容模型。 它精確列出每個集合需要的欄位。基於標準的 posts 和 pages 集合進行構建,並根據設計需求新增欄位和分類法,而不是發明全新的內容類型。
- 主題的內容頁面必須是伺服器端渲染的。 在主題中,內容透過管理介面在執行時變更,因此顯示 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/ 目錄包含 seed 檔案和範例內容中使用的任何本機媒體檔案。
設定 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 | seed 檔案的路徑 |
emdash.preview | 線上示範的 URL(選擇性) |
預設內容模型
大多數主題需要兩種集合類型:posts 和 pages。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 擁有 excerpt 和 featured_image 是因為它們出現在列表和資訊流中。pages 不需要這些 — 它們是獨立的內容。根據你的主題需求為任何集合新增欄位。
完整規格請參閱 Seed 檔案格式,包括區段、小工具區域和媒體參考。
建構頁面
所有顯示 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>
使用圖片
圖片欄位是具有 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}
priority
/>
)}
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
<p>{post.data.excerpt}</p>
</article>
僅對頁面中可能在首屏可見的圖片(如 hero 或第一張卡片圖片)使用 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} />
編輯者在編輯頁面時從管理介面的下拉選單中選擇範本。
新增區段
區段是可重用的內容區塊,編輯者可以使用斜線命令 /section 將其插入任何 Portable Text 欄位。如果你的主題有常見的內容模式(hero 橫幅、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"),但主題提供的區段不能從管理介面刪除。
新增範例內容
在 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 詞幹提取和醒目標示的結果片段。搜尋必須在管理介面中按集合啟用(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 上的主題使用 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.json中有emdash欄位(label、description、seed 路徑) -
.emdash/seed.json具有有效的架構 - 頁面中參考的所有集合存在於 seed 中
- 佈局中使用的選單在 seed 中定義
- 範例內容展示了主題設計
-
astro.config.mjs設定了資料庫和儲存空間 -
src/live.config.ts包含 EmDash 載入器 - 內容頁面中沒有
getStaticPaths() - 沒有硬編碼的網站標題、標語或導覽
- 圖片欄位作為物件存取(
image.src),而不是字串 - 包含設定說明的 README
- 非標準 Portable Text 類型的自訂區塊元件
下一步
- Seed 檔案格式 — seed 檔案的完整參考
- 主題概覽 — EmDash 中主題的運作方式
- 移植 WordPress 主題 — 轉換現有的 WordPress 主題