创建主题

本页内容

EmDash 主题是一个 Astro 项目,其他开发者可以用 create-astro 为其生成脚手架。请把项目当作完整站点来构建和测试,然后附带一个 seed,用来创建其路由和组件所期望的内容模型。

从现有模板开始

选择最接近目标站点和部署目标的现有模板。Node 与 Cloudflare 变体会把数据库、存储、适配器和中间件配置放在一起。

博客模板是内容类站点的有用起点:

npm create astro@latest -- --template @emdash-cms/template-blog

若最终主题面向 Cloudflare Workers、D1 与 R2,请使用 @emdash-cms/template-blog-cloudflare。

保持模板结构

当前博客模板使用以下相关路径:

astro.config.mjs
emdash-env.d.ts
package.json
seed/
└── seed.json
src/
├── components/
│   └── PostCard.astro
├── layouts/
│   └── Base.astro
├── live.config.ts
├── pages/
│   ├── index.astro
│   ├── category/[slug].astro
│   ├── pages/[slug].astro
│   ├── posts/index.astro
│   ├── posts/[slug].astro
│   ├── search.astro
│   └── tag/[slug].astro
└── styles/

starter、portfolio 和 marketing 模板使用不同的路由。请从所选基础模板中复制实际文件,而不要假定每个主题都有一个通配的页面路由。

指向 seed

当前模板在 package.json 中声明其 seed 路径:

{
  "name": "@example/emdash-theme-publication",
  "private": true,
  "type": "module",
  "emdash": {
    "seed": "seed/seed.json"
  }
}

EmDash 还会发现 .emdash/seed.json 以及约定的 seed/seed.json 回退路径。分发模板时请使用 package 字段,使目标文件一目了然。

定义内容模型

从博客模板开始时,请编辑其现有的 seed/seed.json,而不要用毫不相干的模型替换它。下面这个精简的 seed 保留了本指南示例所用的 collection 和结构数据:

{
  "$schema": "https://emdashcms.com/seed.schema.json",
  "version": "1",
  "meta": {
    "name": "Publication",
    "description": "A publication with posts"
  },
  "settings": {
    "title": "Publication",
    "tagline": "Latest articles"
  },
  "collections": [
    {
      "slug": "posts",
      "label": "Posts",
      "labelSingular": "Post",
      "supports": ["drafts", "revisions", "search", "seo"],
      "fields": [
        {
          "slug": "title",
          "label": "Title",
          "type": "string",
          "required": true,
          "searchable": true
        },
        {
          "slug": "excerpt",
          "label": "Excerpt",
          "type": "text"
        },
        {
          "slug": "featured_image",
          "label": "Featured image",
          "type": "image"
        },
        {
          "slug": "content",
          "label": "Content",
          "type": "portableText",
          "searchable": true
        }
      ]
    },
    {
      "slug": "pages",
      "label": "Pages",
      "labelSingular": "Page",
      "supports": ["drafts", "revisions", "search"],
      "fields": [
        {
          "slug": "title",
          "label": "Title",
          "type": "string",
          "required": true,
          "searchable": true
        },
        {
          "slug": "content",
          "label": "Content",
          "type": "portableText",
          "searchable": true
        },
        {
          "slug": "template",
          "label": "Page template",
          "type": "select",
          "defaultValue": "default",
          "validation": {
            "options": ["default", "full-width", "landing"]
          }
        }
      ]
    }
  ],
  "menus": [
    {
      "name": "primary",
      "label": "Primary navigation",
      "items": [
        { "type": "custom", "label": "Home", "url": "/" },
        { "type": "custom", "label": "Posts", "url": "/posts" }
      ]
    }
  ],
  "widgetAreas": [
    {
      "name": "sidebar",
      "label": "Sidebar",
      "widgets": []
    }
  ],
  "content": {
    "posts": [
      {
        "id": "post-welcome",
        "slug": "welcome",
        "status": "published",
        "data": {
          "title": "Welcome",
          "excerpt": "The first article",
          "content": []
        }
      }
    ]
  }
}

内容条目的 id 是供引用使用的 seed 本地标识符,不必成为可路由条目的数据库 ID。其 slug 成为查询 API 以 entry.id 暴露的路由标识符。

在添加分类法、署名、菜单引用、媒体、重定向、widget 区域、section、本地化或冲突行为之前,请先阅读 Seed 文件。

构建服务端渲染路由

当前的 EmDash 模板使用 output: "server"。路由会为每个请求查询实时内容。除非模板有意只把 EmDash 用作构建时的数据源,否则不要在主题的内容路由中添加 getStaticPaths()。

下面的归档页使用存储的 published_at 字段在数据库中排序:

---
import { getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro";

const { entries: posts, error, cacheHint } = await getEmDashCollection("posts", {
  orderBy: { published_at: "desc" },
});

if (error) return new Response("Could not load posts", { status: 500 });
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---

<Base title="Posts">
  {posts.map((post) => (
    <article>
      <h2><a href={`/posts/${post.id}`}>{post.data.title}</a></h2>
      {post.data.excerpt && <p>{post.data.excerpt}</p>}
    </article>
  ))}
</Base>

orderBy 是字段到方向的对象。请使用 orderBy: { published_at: "desc" },而不是 sort、sortBy 或 JavaScript 回调。

下面的路由解析并渲染单篇文章:

---
import { decodeSlug, getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";

const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.rewrite("/404");

const { entry: post, error, cacheHint } = await getEmDashEntry("posts", slug);
if (error) return new Response("Could not load the post", { status: 500 });
if (!post) return Astro.rewrite("/404");
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---

<Base title={post.data.title} content={{ collection: "posts", id: post.data.id, slug }}>
  <article>
    <h1 {...post.edit.title}>{post.data.title}</h1>
    <PortableText value={post.data.content} />
  </article>
</Base>

在路由 URL 中使用 post.id,在辅助函数需要已存储的内容 ID 时使用 post.data.id。

查询站点管理的导航

由 CMS 管理的值应来自相应的 API。当前模板在其布局中使用 getSiteSettings()、getMenu() 和 <WidgetArea />:

---
import { getMenu, getSiteSettings } from "emdash";
import { WidgetArea } from "emdash/ui";

const [settings, primary] = await Promise.all([
  getSiteSettings(),
  getMenu("primary"),
]);
---

<header>
  <a href="/">{settings.title}</a>
  <nav>
    {primary?.items.map((item) => <a href={item.url}>{item.label}</a>)}
  </nav>
</header>

<main><slot /></main>
<aside><WidgetArea name="sidebar" /></aside>

静态的设计文案可以保留在 Astro 文件中。凡是预期由管理员编辑的值,都必须通过设置、内容、菜单或 widget 来表示。

渲染图片字段

图片字段是媒体值,而不是 URL 字符串。请把完整的值传给 Image 组件,以便本地存储和图片提供方得到一致的解析:

---
import { Image } from "emdash/ui";

const { post } = Astro.props;
---

<article>
  {post.data.featured_image && (
    <Image
      image={post.data.featured_image}
      alt={post.data.title}
      width={800}
      height={450}
    />
  )}
  <h2><a href={`/posts/${post.id}`}>{post.data.title}</a></h2>
</article>

除非你覆盖,否则该组件会使用字段自带的替代文本。只对预期位于首屏的图片保留 priority;其他图片仍保持懒加载。

提供页面布局选项

上面的 starter seed 包含一个 select 字段,适用于编辑需要多种页面布局的站点。把该功能添加到其他 seed 时,请使用稳定的值,并让它们映射到已知组件:

{
  "slug": "template",
  "label": "Page template",
  "type": "select",
  "defaultValue": "default",
  "validation": {
    "options": ["default", "full-width", "landing"]
  }
}

随后路由可以从显式的组件映射中选择:

---
import { decodeSlug, getEmDashEntry } from "emdash";
import PageDefault from "../../layouts/PageDefault.astro";
import PageFullWidth from "../../layouts/PageFullWidth.astro";
import PageLanding from "../../layouts/PageLanding.astro";

const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.rewrite("/404");

const { entry: page } = await getEmDashEntry("pages", slug);
if (!page) return Astro.rewrite("/404");

const layouts = {
  default: PageDefault,
  "full-width": PageFullWidth,
  landing: PageLanding,
};
const Layout = layouts[page.data.template as keyof typeof layouts] ?? PageDefault;
---

<Layout {page} />

显式映射可以避免把存储的字段值变成任意的模块路径。

添加搜索

请在每个应出现在结果中的 collection 上启用 search,并把相关字段标记为 searchable。当前模板使用 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>

在启用 Astro i18n 的站点上,LiveSearch 使用 Astro.currentLocale。仅当页面有意搜索所有 locale 时,才传入 locale={null}。

Seed 可复用的 section

Section 为编辑提供可复用的 Portable Text 起点。当设计中包含重复出现的内容模式(例如行动号召)时,请添加它们:

{
  "version": "1",
  "sections": [
    {
      "slug": "newsletter-signup",
      "title": "Newsletter signup",
      "description": "Heading and copy for the newsletter form",
      "keywords": ["newsletter", "email"],
      "content": [
        {
          "_type": "block",
          "_key": "newsletter-heading",
          "style": "h2",
          "children": [
            { "_type": "span", "_key": "newsletter-heading-text", "text": "Get new articles by email" }
          ]
        }
      ]
    }
  ]
}

配置向导允许用户省略已 seed 的条目、署名和分类法术语。Section 以及其余结构模型仍会被应用。

添加结构化页面 block

当页面本身是由带类型的 section 有序组成时,请使用 blocks 字段。当对象属于富文本文档内部并参与其段落流时,请使用自定义 Portable Text block。

marketing 模板 seed 了十一种保留的 block 类型,并允许它们出现在 Pages 的 content 字段中。其包装器把每个生成的 _type 映射到一个 Astro 组件:

---
import { Blocks, defineBlockComponents } from "emdash/ui";
import type { PageContentBlock } from "../../emdash-env";
import CTA from "./blocks/CTA.astro";
import Contact from "./blocks/Contact.astro";
import FAQ from "./blocks/FAQ.astro";
import Features from "./blocks/Features.astro";
import Hero from "./blocks/Hero.astro";
import Logos from "./blocks/Logos.astro";
import Pricing from "./blocks/Pricing.astro";
import Split from "./blocks/Split.astro";
import Stats from "./blocks/Stats.astro";
import Testimonials from "./blocks/Testimonials.astro";
import Text from "./blocks/Text.astro";

const components = defineBlockComponents<PageContentBlock>({
  marketing_hero: Hero,
  marketing_features: Features,
  marketing_testimonials: Testimonials,
  marketing_pricing: Pricing,
  marketing_faq: FAQ,
  marketing_logos: Logos,
  marketing_split: Split,
  marketing_stats: Stats,
  marketing_cta: CTA,
  marketing_contact: Contact,
  marketing_text: Text,
});
---

<Blocks value={Astro.props.value} components={components} />

生成的 PageContentBlock 联合类型让组件映射与每种允许的 block 类型及保留的版本保持一致。每个 seed 的 block 值都包含 _type、_version 和稳定的 _key。

对于属于段落之间的对象(例如文章中嵌入的图表),请使用自定义 Portable Text block。通过 PortableText 组件的 components.type 选项映射该对象。seed 可以包含已存储的值,但原生插件必须注册自定义编辑器,才能创建和编辑该 Portable Text 对象。

当 block 需要可复用的自定义编辑器与打包的渲染组件时,使用原生插件。沙箱插件无法将 Astro 渲染组件打入站点构建。

测试模板

  1. 使用 create-astro 将模板脚手架到干净目录。

  2. 安装依赖并运行站点的构建与类型检查命令。

  3. 从空数据库开始并完成 /_emdash/admin/setup。

  4. 分别测试包含与不包含示例内容的设置。

  5. 在 seed 内容、新创建内容、无可选图片与空 collection 等情况下打开每条路由。

  6. 在管理后台编辑站点设置、菜单、分类法分配、Portable Text 与 block。对 block,添加、重排、复制并删除每种允许的类型。确认公开路由使用变更后的值。

  7. 在一次性环境中应用部署相关设置,并验证媒体存储、预览与服务端渲染。

发布模板

GitHub 仓库可以直接配合 Astro 的 github: 模板语法使用:

npm create astro@latest -- --template github:example/emdash-theme-publication

发布前请删除本地数据库与上传文件、避免将密钥放入仓库、从干净 checkout 验证包路径,并文档化模板所配置的部署目标。

检查清单

  • astro.config.mjs 使用服务端输出与预期的部署适配器。
  • src/live.config.ts 注册 emdashLoader()。
  • package.json#emdash.seed 指向存在的文件。
  • 每个被查询的 collection 与字段均在 seed 中声明。
  • 查询示例使用 orderBy 与正确的条目标识符。
  • CMS 管理的站点值未作为永久模板常量重复。
  • 干净设置在有无示例内容时均可工作。
  • 模板构建与类型检查在支持的目标上通过。