EmDash 主题是一个完整的 Astro 站点——页面、布局、组件、样式——同时包含一个用于引导内容模型的种子文件。构建一个主题来与他人分享您的设计,或为您的机构标准化站点创建流程。
核心概念
- 主题就是一个可运行的 Astro 项目。 没有主题 API 或抽象层。主题是一个作为模板发布的站点。种子文件告诉 EmDash 在首次运行时创建哪些集合、字段、菜单、重定向和分类法。
- 种子文件声明内容模型。 它精确列出每个集合需要的字段。在标准的 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 # 未找到页面
│ ├── layouts/
│ │ └── Base.astro # 基础布局
│ └── components/ # 您的组件
├── .emdash/
│ ├── seed.json # Schema 和示例内容
│ └── 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。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 不需要它们——它们是独立的内容。根据主题需要向任一集合添加字段。
参见种子文件格式了解完整规范,包括 sections、小部件区域和媒体引用。
构建页面
所有显示 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>
页面
页面使用根目录的全匹配路由,这样它们的 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>
因为这是全匹配路由,它只匹配没有更具体路由的 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>
仅对页面上可能位于首屏的图片使用 priority,例如 hero 或第一张卡片图片。它使用 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 选择字段,并在全匹配路由中将其映射到布局组件。
在种子文件中向 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"
}
然后在全匹配路由中将值映射到布局组件:
---
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
Sections 是可复用的内容块,编辑者可以使用 /section 斜杠命令将其插入到任何 Portable Text 字段中。如果您的主题有常见的内容模式(hero 横幅、CTA、功能网格),请在种子文件中将它们定义为 sections:
{
"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."
}
]
}
]
}
]
}
从种子文件创建的 Sections 标记为 source: "theme"。编辑者也可以创建自己的 sections(标记为 source: "user"),但主题提供的 sections 不能从管理 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 语法在示例内容中引用图片。远程图片通过 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)。
在多语言站点上,结果的范围限定在搜索所在页面的语言区域。该组件读取 Astro.currentLocale(Astro 从 URL 派生),并将其转发到搜索端点——因此翻译的站点以访问者的语言返回每个条目一次。
显式传递 locale 以搜索不同的语言,或传递 null 以跨所有语言搜索:
<LiveSearch
placeholder="Search posts and pages..."
collections={["posts", "pages"]}
locale={null}
/>
没有 Astro i18n 配置的站点不受影响:Astro.currentLocale 在那里是 undefined,因此不会发送语言区域,搜索涵盖所有条目。
测试您的主题
-
从主题创建测试项目:
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 块类型。这对营销页面、着陆页或任何需要超越标准富文本的结构化组件的内容很有用。
在种子内容中定义自定义块
在种子文件的 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}>
<!-- content -->
</section>
这启用了像 /#features 这样的导航链接。
主题检查清单
发布前,验证您的主题包含:
- 带有
emdash字段的package.json(label、description、seed 路径) - 带有有效 schema 的
.emdash/seed.json - 页面中引用的所有集合都存在于种子中
- 布局中使用的菜单在种子中已定义
- 示例内容展示了主题的设计
- 带有数据库和存储配置的
astro.config.mjs - 带有 EmDash loader 的
src/live.config.ts - 内容页面上没有
getStaticPaths() - 没有硬编码的站点标题、标语或导航
- 图片字段作为对象(
image.src)访问,而非字符串 - 带有设置说明的 README
- 任何非标准 Portable Text 类型的自定义块组件
后续步骤
- 种子文件格式 — 种子文件的完整参考
- 主题概述 — EmDash 中主题的工作原理
- 移植 WordPress 主题 — 转换现有的 WordPress 主题