创建主题
一个 EmDash 主题是一个完整的 Astro 站点——包含页面、布局、组件、样式——并且还包含一个用于引导内容模型的种子文件。构建一个主题来与他人分享您的设计,或者为您的机构标准化站点创建流程。
- 主题是一个可运行的 Astro 项目。 没有专门的主题 API 或抽象层。您构建一个站点并将其作为模板发布。种子文件只是告诉 EmDash 在首次运行时需要创建哪些集合、字段、菜单、重定向和分类法。
- EmDash 让您对内容模型拥有比 WordPress 更多的控制权。 主题充分利用了这一点——种子文件精确声明了每个集合所需的字段。基于标准的 posts 和 pages 集合进行构建,并根据您的设计需求添加字段和分类法,而不是发明全新的内容类型。
- 主题的内容页面必须是服务器端渲染的。 在主题中,内容通过管理界面在运行时更改,因此显示 EmDash 内容的页面不能被预渲染。不要在主题内容路由中使用
getStaticPaths()。(使用 EmDash 作为构建时数据源的静态站点构建可以使用getStaticPaths,但主题始终是 SSR。) - 没有硬编码的内容。 站点标题、标语、导航和其他动态内容都通过 API 调用从 CMS 获取——而不是来自模板字符串。
使用以下结构创建主题:
my-emdash-theme/├── package.json # Theme metadata├── astro.config.mjs # Astro + EmDash configuration├── src/│ ├── live.config.ts # Live Collections setup│ ├── pages/│ │ ├── index.astro # Homepage│ │ ├── [...slug].astro # Pages (catch-all)│ │ ├── posts/│ │ │ ├── index.astro # Post archive│ │ │ └── [slug].astro # Single post│ │ ├── categories/│ │ │ └── [slug].astro # Category archive│ │ ├── tags/│ │ │ └── [slug].astro # Tag archive│ │ ├── search.astro # Search page│ │ └── 404.astro # Not found│ ├── layouts/│ │ └── Base.astro # Base layout│ └── components/ # Your components├── .emdash/│ ├── seed.json # Schema and sample content│ └── uploads/ # Optional local media files└── public/ # Static assets页面位于根目录作为全匹配路由([...slug].astro),因此一个 slug 为 about 的页面将在 /about 渲染。文章、分类和标签拥有自己的目录。.emdash/ 目录包含种子文件以及示例内容中使用的任何本地媒体文件。
配置 package.json
Section titled “配置 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(可选) |
默认内容模型
Section titled “默认内容模型”大多数主题需要两种集合类型: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" } ]}文章包含 excerpt 和 featured_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>页面在根目录使用全匹配路由,因此它们的 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} /> )} <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 选择字段,并在您的全匹配路由中将其映射到布局组件。
在种子文件中将此字段添加到您的 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} />编辑者在管理界面编辑页面时,可以从下拉菜单中选择模板。
区块是可重用的内容块,编辑者可以使用 /section 斜杠命令将其插入到任何 Portable Text 字段中。如果您的主题有常见的内容模式(横幅、行动号召、功能网格),请在种子文件中将它们定义为区块:
{ "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"),但主题提供的区块无法从管理界面删除。
添加示例内容
Section titled “添加示例内容”在种子文件中包含示例内容以展示您的主题设计:
{ "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 词干提取和高亮显示结果片段。必须在管理界面中为每个集合启用搜索(内容类型 > 编辑 > 功能 > 搜索)。
测试您的主题
Section titled “测试您的主题”-
从您的主题创建一个测试项目:
Terminal window npm create astro@latest -- --template ./path/to/my-theme -
安装依赖项并启动开发服务器:
Terminal window cd test-sitenpm installnpm run dev -
在
http://localhost:4321/_emdash/admin -
验证集合、菜单、重定向和内容是否正确创建
-
测试所有页面模板是否正确渲染
-
通过管理界面创建新内容以验证所有字段正常工作
发布您的主题
Section titled “发布您的主题”发布到 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 块
Section titled “自定义 Portable Text 块”主题可以定义自定义的 Portable Text 块类型,用于特殊内容。这对于营销页面、落地页或任何需要超越标准富文本的结构化组件的内容非常有用。
在种子内容中定义自定义块
Section titled “在种子内容中定义自定义块”在种子文件的 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>渲染自定义块
Section titled “渲染自定义块”将您的自定义块组件传递给 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
Section titled “用于导航的锚点 ID”为应可链接的块添加 _key:
{ "_type": "marketing.features", "_key": "features", "headline": "Features"}然后在您的组件中将其用作锚点:
<section id={value._key}> <!-- content --></section>这支持像 /#features 这样的导航链接。
发布前,请验证您的主题包含:
- 带有
emdash字段(label、description、seed path)的package.json - 具有有效模式的
.emdash/seed.json - 页面中引用的所有集合在种子文件中都存在
- 布局中使用的菜单在种子文件中已定义
- 示例内容展示了主题的设计
- 包含数据库和存储配置的
astro.config.mjs - 包含 EmDash 加载器的
src/live.config.ts - 内容页面上没有
getStaticPaths() - 没有硬编码的站点标题、标语或导航
- 图片字段作为对象(
image.src)访问,而不是字符串 - 包含设置说明的 README
- 针对任何非标准 Portable Text 类型的自定义块组件
- 种子文件格式 —— 种子文件的完整参考
- 主题概述 —— EmDash 中主题的工作原理
- 移植 WordPress 主题 —— 转换现有的 WordPress 主题