建立主題

本頁內容

EmDash 主題是一個完整的 Astro 網站 — 頁面、佈局、元件、樣式 — 同時包含一個 seed 檔案來初始化內容模型。建立一個主題來與他人分享你的設計,或為你的機構標準化網站建立流程。

核心概念

  • 主題是一個可運行的 Astro 專案。 沒有主題 API 或抽象層。主題是打包為範本的網站。seed 檔案告訴 EmDash 在首次執行時建立哪些集合、欄位、選單、重新導向和分類法。
  • seed 檔案宣告內容模型。 它精確列出每個集合需要的欄位。基於標準的 postspages 集合進行構建,並根據設計需求新增欄位和分類法,而不是發明全新的內容類型。
  • 主題的內容頁面必須是伺服器端渲染的。 在主題中,內容透過管理介面在執行時變更,因此顯示 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.seedseed 檔案的路徑
emdash.preview線上示範的 URL(選擇性)

預設內容模型

大多數主題需要兩種集合類型: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 取得 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}
      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)。

測試你的主題

  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.json 中有 emdash 欄位(label、description、seed 路徑)
  • .emdash/seed.json 具有有效的架構
  • 頁面中參考的所有集合存在於 seed 中
  • 佈局中使用的選單在 seed 中定義
  • 範例內容展示了主題設計
  • astro.config.mjs 設定了資料庫和儲存空間
  • src/live.config.ts 包含 EmDash 載入器
  • 內容頁面中沒有 getStaticPaths()
  • 沒有硬編碼的網站標題、標語或導覽
  • 圖片欄位作為物件存取(image.src),而不是字串
  • 包含設定說明的 README
  • 非標準 Portable Text 類型的自訂區塊元件

下一步