创建主题

本页内容

EmDash 主题是一个完整的 Astro 站点 — 页面、布局、组件、样式 — 还包括一个 seed 文件来引导内容模型。构建一个主题来与他人分享您的设计,或为您的代理机构标准化站点创建。

核心概念

  • 主题是一个可运行的 Astro 项目。 没有主题 API 或抽象层。主题是作为模板发布的站点。seed 文件告诉 EmDash 在首次运行时创建哪些集合、字段、菜单、重定向和分类法。
  • seed 文件声明内容模型。 它准确列出每个集合需要哪些字段。基于标准的 postspages 集合构建,并根据设计需要添加字段和分类法,而不是发明全新的内容类型。
  • 主题内容页面必须服务器渲染。 在主题中,内容通过管理 UI 在运行时更改,因此显示 EmDash 内容的页面不能预渲染。不要在主题内容路由中使用 getStaticPaths()。(使用 EmDash 作为构建时数据源的静态站点构建_可以_使用 getStaticPaths,但主题始终是 SSR。)
  • 没有硬编码的内容。 站点标题、标语、导航和其他动态内容来自 CMS 的 API 调用 — 而不是模板字符串。

项目结构

主题使用以下结构:

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) 位于根目录,因此具有 about slug 的页面在 /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。文章是带有时间戳的条目,具有摘要和特色图片,出现在订阅源和归档中。页面是顶级 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" }
	]
}

文章获得 excerptfeatured_image,因为它们出现在列表和订阅源中。页面不需要它们 — 它们是独立的内容。根据主题需要向任一集合添加字段。

有关包括章节、小部件区域和媒体引用的完整规范,请参阅 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>

Pages

页面在根目录使用 catch-all 路由,因此它们的 slug 直接映射到顶级 URL — 具有 about slug 的页面在 /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/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 选择字段,并在您的 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} />

编辑者在编辑页面时从管理 UI 的下拉菜单中选择模板。

添加章节

章节是可重用的内容块,编辑者可以使用 /section 斜杠命令将其插入任何 Portable Text 字段。如果您的主题有常见的内容模式(英雄横幅、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"),但主题提供的章节无法从管理 UI 中删除。

添加示例内容

在 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 词干提取和高亮结果片段的防抖即时搜索。搜索必须在管理 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. 通过管理 UI 创建新内容以验证所有字段是否正常工作

发布您的主题

发布到 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}>
  <!-- content -->
</section>

这使得像 /#features 这样的导航链接成为可能。

主题检查清单

在发布之前,验证您的主题包括:

  • 带有 emdash 字段(标签、描述、seed 路径)的 package.json
  • 具有有效架构的 .emdash/seed.json
  • 页面中引用的所有集合都存在于 seed 中
  • 布局中使用的菜单在 seed 中定义
  • 示例内容演示了主题的设计
  • 具有数据库和存储配置的 astro.config.mjs
  • 具有 EmDash 加载器的 src/live.config.ts
  • 内容页面上没有 getStaticPaths()
  • 没有硬编码的站点标题、标语或导航
  • 图片字段作为对象(image.src)访问,而不是字符串
  • 带有设置说明的 README
  • 任何非标准 Portable Text 类型的自定义块组件

下一步