EmDash 内容查询与渲染指南:用 getEmDashCollection / getEmDashEntry 构建 Astro 驱动的内容站点
EmDash 内容查询与渲染指南:用 getEmDashCollection / getEmDashEntry 构建 Astro 驱动的内容站点
EmDash 是一个基于 Astro 构建的全栈 TypeScript CMS(WordPress 的现代后继者),其核心能力之一是把 CMS 内容无缝暴露给前端页面:在 .astro 页面中通过 emdash 包提供的查询函数按集合(collection)取数、渲染 Portable Text 富文本、输出 CMS 图片并开启可视化编辑。本篇指南以 querying-and-rendering.md 为骨架,逐项讲解内容查询 API、缓存集成、富文本与图片渲染、分页、SEO 元数据与常见页面模式,并结合 query.ts 等源码说明底层实现,帮助你在自己的 EmDash 站点上写出类型安全、可缓存、可点选编辑的页面。
内容查询 API 总览
EmDash 的查询函数统一从 "emdash" 包导入,它们在内部包装 Astro 的 getLiveCollection / getLiveEntry 并附加类型过滤与内容水合(bylines、taxonomy terms),源码入口见 index.ts 的 re-export 与 query.ts。核心函数有两个:
getEmDashCollection(type, filter?)—— 获取一个集合的多条条目,返回{ entries, error, cacheHint, nextCursor };getEmDashEntry(type, id, options?)—— 按 slug(或数据库 ID)获取单条条目,返回{ entry, error, isPreview, cacheHint }。
两个函数都遵循“错误不抛出、随结果返回”的 Astro 风格:集合查询在出错时返回空 entries 数组并附带 error 字段;单条查询在未找到时 entry 为 null(这不属于错误),只有真正的数据库异常才会设置 error。因此页面里可以直接用 if (!post) return Astro.redirect("/404") 处理不存在的情况。
集合查询:getEmDashCollection
最基本的用法是不带任何过滤条件,取回整个集合:
import { getEmDashCollection } from "emdash";
// Basic
const { entries: posts } = await getEmDashCollection("posts");
带选项的用法如下:
// With options
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
status: "published",
limit: 10,
orderBy: { published_at: "desc" },
where: { category: "news" },
});
选项说明(与源码中 CollectionFilterBase 的定义一致):
| 选项 | 类型 | 说明 |
|---|---|---|
status |
"draft" | "published" | "archived" |
按内容状态过滤 |
limit |
number |
返回的最大条目数 |
cursor |
string |
不透明游标,用于 keyset 分页;把上一次结果的 nextCursor 传进来即可翻页 |
offset |
number |
偏移分页的跳过条数,与 cursor 互斥(同时传入在编译期报错) |
orderBy |
{ field: "asc" | "desc" } |
排序字段与方向,默认 { created_at: "desc" },也支持多字段如 { published_at: "desc", title: "asc" } |
where |
Record<string, WhereValue> |
按字段值、taxonomy 词条或 byline 过滤;数组表示 OR 语义,如 { category: ["news", "featured"] };还支持日期范围 { published_at: { gte: "2024-01-01", lt: "2025-01-01" } } |
locale |
string |
配置了 i18n 时按语言过滤,如 "en" / "fr" |
where 的细节值得展开:taxonomy 名称会被自动识别并走 JOIN 过滤(如 { category: 'news' } 过滤归类到该词条的条目);保留键 byline 会通过 _emdash_content_bylines 中间表按署名过滤(含合著条目,{ byline: <a href="https://link.gitcode.com/i/35127258f2570a0a4d5bb5626f619c57" target="_blank">'01HXYZ...', '01HABC...'] } 表示任一署名匹配);其余键则作为内容表的列过滤。见 [query.ts 的注释与示例。
单条查询:getEmDashEntry
import { getEmDashEntry } from "emdash";
const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
if (!post) {
return Astro.redirect("/404");
}
getEmDashEntry 接受可选的 { locale } 选项。在配置了 i18n 时,它会沿着“请求语言 → fallback 语言 → 默认语言”的 fallback 链解析,命中的条目会在结果中带出 fallbackLocale 字段;预览(_preview token)与编辑模式由中间件通过 AsyncLocalStorage 注入请求上下文,查询函数自动读取,无需额外传参(见 query.ts 的 locale 链与 draft 分支逻辑)。
Entry 结构与“两个 id”的陷阱
查询返回的每条 entry 都是统一的 ContentEntry<T> 形状:
interface ContentEntry<T> {
id: string; // The slug (used in URLs)
data: T; // All fields, including system fields
edit: EditProxy; // Visual editing attributes (spread onto elements)
}
data 中既包含系统字段,也包含你在 CMS 里定义的自定义字段。以一篇 post 为例:
interface PostData {
id: string; // Database ULID (use for taxonomy lookups, etc.)
slug: string;
status: string;
title: string;
featured_image?: {
id: string;
src?: string;
alt?: string;
width?: number;
height?: number;
};
content?: PortableTextBlock[];
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
// Bylines (eagerly loaded)
byline: BylineSummary | null; // Primary author
bylines: ContentBylineCredit[]; // All credits (with roleLabel, source)
// ... your custom fields
}
最重要的一个约定:entry.id 是 slug(用于拼 URL),entry.data.id 才是数据库 ULID(用于调用 getEntryTerms 等 API)。 千万不要混用二者:URL 用 entry.id(如 /posts/${post.id}),而需要按数据库主键做查询(例如取 taxonomy terms)时必须传 entry.data.id。从源码看,entryDatabaseId 正是读取 data.id(query.ts)。
另外注意:bylines / byline 是**急切水合(eagerly hydrated)**的字段——查询返回时已附带署名数据,不需要额外请求;taxonomy terms 则被水合到 entry.data.terms(按 taxonomy 名称分组的 TaxonomyTerm<a href="https://link.gitcode.com/i/c879bf02ecc129071a63bcd28d1ecff9" target="_blank">] 对象),这意味着列表页循环条目时无需再逐条调用 getEntryTerms 造成 N+1 查询(见 [query.ts 的批量 JOIN 实现)。
缓存:永远调用 Astro.cache.set(cacheHint)
查询结果都带有 cacheHint,用于 Astro 的 Route Caching(内容变更时自动失效缓存):
---
const { entries: posts, cacheHint } = await getEmDashCollection("posts");
Astro.cache.set(cacheHint);
---
请务必调用 Astro.cache.set(cacheHint)——它让页面在 CMS 内容更新时自动失效并重建,是生产站点缓存一致性的前提。从实现看,集合与单条查询都经过请求级缓存(同一渲染周期内相同 (type, filter) 的重复查询只执行一次)与分布式对象缓存(L2,按 collection + filter + 有效 locale 缓存 JSON 快照,见 query.ts),cacheHint 携带的 tags / lastModified 就是给路由缓存做失效判断的依据。此外,对于 limit 小于 10 的小型“最近 N 篇”组件,查询层会把 limit 归并到共享桶(bucket)以合并重复取数,所以多个侧边栏小组件同时渲染也不会各自查库(query.ts)。
渲染 Portable Text
CMS 里的富文本字段(如 content)是 Portable Text 块数组,用 emdash/ui 的 PortableText 组件渲染:
---
import { PortableText } from "emdash/ui";
---
<PortableText value={post.data.content} />
它内置支持标准块(段落、标题、列表、引用、代码块、图片)与行内标记(加粗、斜体、代码、删除线、链接)。组件源码见 PortableText.astro,并从 components/index.ts 统一导出。
自定义块类型
营销页通常需要自定义块(如 hero、features 等 CMS 区块)。通过 components prop 传入类型到组件的映射:
---
import { PortableText } from "emdash/ui";
import Hero from "./blocks/Hero.astro";
import Features from "./blocks/Features.astro";
const customTypes = {
"marketing.hero": Hero,
"marketing.features": Features,
};
---
<PortableText value={page.data.content} components={{ type: customTypes }} />
每个自定义组件会收到该 block 的数据作为 props,你可以在组件内部读取字段并自行排版。
Image 组件:CMS 图片字段是对象,不是字符串
CMS 中的图片字段一律是对象(含 id、src、alt、width、height 等),必须使用 EmDash 的 Image 组件渲染。 正确与错误的写法对比:
---
import { Image } from "emdash/ui";
---
{/* Correct -- passes the image object */}
<Image image={post.data.featured_image} />
{/* Also works with explicit props */}
{post.data.featured_image?.src && (
<img src={post.data.featured_image.src} alt={post.data.featured_image.alt || ""} />
)}
{/* WRONG -- image is an object, not a string */}
<img src={post.data.featured_image} />
把对象直接塞给原生 <img src> 是新手最常见的错误(渲染结果是 <a href="https://link.gitcode.com/i/b72ec7916ffd3b6a0300a7ef0e1c6d36" target="_blank">object Object])。Image 组件(源码见 [Image.astro 与 EmDashImage.astro)处理对象并输出正确的 <img> 属性;如果确实要用原生标签,务必像上面的“显式 props”写法那样取 .src 与 .alt。
可视化编辑:展开 entry.edit 属性
每条 entry 都携带 edit 代理对象,把它展开到展示对应字段的元素上即可启用“点击即编辑”:
<h1 {...post.edit.title}>{post.data.title}</h1>
<p {...post.edit.excerpt}>{post.data.excerpt}</p>
<div {...post.edit.featured_image}>
<Image image={post.data.featured_image} />
</div>
当管理员登录并浏览站点时,这些属性会附加编辑标注,实现行内点选编辑;普通访客拿到的是 no-op 版本,展开后不产生任何副作用。实现上,createEditable / createNoop(visual-editing/editable.ts 的引入处)会根据请求上下文(是否编辑模式)决定附加真实代理还是空操作,Portable Text 数组还会被贴上非枚举的编辑元数据(tagEditableFields,见 query.ts),让富文本字段同样可被编辑定位。
常见页面模式
列表页(如 /posts/index.astro)
---
import { getEmDashCollection, getEntryTerms } from "emdash";
import { Image } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
});
Astro.cache.set(cacheHint);
const sortedPosts = posts.toSorted((a, b) => {
const dateA = a.data.publishedAt?.getTime() ?? 0;
const dateB = b.data.publishedAt?.getTime() ?? 0;
return dateB - dateA;
});
---
<Base title="Posts">
{sortedPosts.map(post => (
<article>
{post.data.featured_image && <Image image={post.data.featured_image} />}
<a href={`/posts/${post.id}`}>{post.data.title}</a>
{post.data.excerpt && <p>{post.data.excerpt}</p>}
</article>
))}
</Base>
注意:publishedAt 是 Date 对象,所以手动排序要用 getTime() 比较;链接一律使用 post.id(slug)。
详情页(如 /posts/[slug].astro)
---
import { getEmDashEntry, getEntryTerms, getSeoMeta } from "emdash";
import { Image, PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
if (!slug) return Astro.redirect("/404");
const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
if (!post) return Astro.redirect("/404");
Astro.cache.set(cacheHint);
const seo = getSeoMeta(post, {
siteTitle: "My Blog",
siteUrl: Astro.url.origin,
path: `/posts/${slug}`,
});
const tags = await getEntryTerms("posts", post.data.id, "tag");
---
<Base title={seo.title} description={seo.description}>
<article>
{post.data.featured_image && (
<div {...post.edit.featured_image}>
<Image image={post.data.featured_image} />
</div>
)}
<h1 {...post.edit.title}>{post.data.title}</h1>
<PortableText value={post.data.content} />
{tags.length > 0 && (
<div>
{tags.map(t => <a href={`/tag/${t.slug}`}>{t.label}</a>)}
</div>
)}
</article>
</Base>
这里示范了三个要点:getSeoMeta(导出自 seo/index.ts)根据 entry 生成 SEO 标题与描述;getEntryTerms("posts", post.data.id, "tag") 必须传数据库 ULID(post.data.id)而非 slug;可视化编辑属性与 Image / PortableText 组合使用。
分类归档页(如 /category/[slug].astro)
---
import { getTerm, getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const term = slug ? await getTerm("category", slug) : null;
if (!term) return Astro.redirect("/404");
const { entries: posts } = await getEmDashCollection("posts", {
where: { category: term.slug },
orderBy: { published_at: "desc" },
});
---
<Base title={`${term.label} posts`}>
<h1>{term.label}</h1>
{posts.map(post => (
<a href={`/posts/${post.id}`}>{post.data.title}</a>
))}
</Base>
getTerm 按 (taxonomy, slug) 取单个词条(含 label、slug、children 等),并支持 locale fallback 链与可选的可见条目计数(includeCounts: false 可跳过计数查询),实现见 taxonomies/index.ts。归档页随后用 where: { category: term.slug } 过滤出该分类下的条目。
RSS 源(如 /rss.xml.ts)
import type { APIRoute } from "astro";
import { getEmDashCollection } from "emdash";
const siteTitle = "My Site";
export const GET: APIRoute = async ({ url }) => {
const siteUrl = url.origin;
const { entries: posts } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 20,
});
const items = posts
.filter((p) => p.data.publishedAt)
.map((post) => {
const postUrl = `${siteUrl}/posts/${post.id}`;
return ` <item>
<title>${escapeXml(post.data.title)}</title>
<link>${postUrl}</link>
<guid isPermaLink="true">${postUrl}</guid>
<pubDate>${post.data.publishedAt!.toUTCString()}</pubDate>
<description>${escapeXml(post.data.excerpt || "")}</description>
</item>`;
})
.join("\n");
return new Response(
`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(siteTitle)}</title>
<link>${siteUrl}</link>
<atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml"/>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>`,
{
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
},
);
};
function escapeXml(s: string): string {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
要点:过滤掉未发布的条目(p.data.publishedAt 为 null 的过滤掉)、用 entry.id 拼 URL、用 toUTCString() 输出 RFC 822 时间,并对所有动态文本做 XML 转义。
404 页面(/404.astro)
---
import Base from "../layouts/Base.astro";
---
<Base title="Not Found">
<h1>Page not found</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="/">Go home</a>
</Base>
空状态
当集合还没有内容时,展示一个友好的空状态,引导去后台创建第一篇内容:
{posts.length === 0 ? (
<section>
<h2>No posts yet</h2>
<p>Create your first post in the admin panel.</p>
<a href="/_emdash/admin/content/posts/new">Create a post</a>
</section>
) : (
/* ... render posts ... */
)}
分页:cursor 与 offset 两种方式
getEmDashCollection 内置两种互斥的分页方式:keyset 游标分页(cursor)与偏移分页(offset)。源码中的类型定义保证了二者不能同时传入(同时提供在编译期报错,见 query.ts)。
游标分页(推荐用于“下一页”翻页)
把上一次结果的 nextCursor 传入下一次请求的 cursor 即可;nextCursor 为 undefined 表示没有更多结果:
---
const cursor = Astro.url.searchParams.get("cursor") ?? undefined;
const { entries, nextCursor, cacheHint } = await getEmDashCollection("posts", {
limit: 10,
cursor,
orderBy: { published_at: "desc" },
});
Astro.cache.set(cacheHint);
---
{entries.map(post => (
<a href={`/posts/${post.id}`}>{post.data.title}</a>
))}
{nextCursor && <a href={`?cursor=${nextCursor}`}>Next page</a>}
游标是不透明字符串,由查询层按“排序值 + 数据库 ID”编码(encodeEntryCursor,见 query.ts),编码时对日期列优先使用原始存储字符串以避免时区/精度损耗,因此翻页稳定、不受新插入数据影响。
偏移分页(用于 /page/2 这类编号归档)
结果中的 hasMore 表示是否还有后续条目,适合渲染“下一页”链接而无需计算总数:
const perPage = 20;
const { entries, hasMore } = await getEmDashCollection("posts", {
limit: perPage,
offset: (page - 1) * perPage,
orderBy: { published_at: "desc" },
});
两种方式下,只要传了 limit,结果都会附带 hasMore 字段(默认 limit + 1 探测法判断,见 query.ts)。
日期格式化
查询返回的日期字段(createdAt、updatedAt、publishedAt)都是 Date 对象,直接用 toLocaleDateString 或 Intl.DateTimeFormat 格式化:
const formatted = post.data.publishedAt?.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
注意 publishedAt 可能为 null(未发布),取值时要先判空(如可选链 ?.)。
实战要点小结
- 所有查询函数从
"emdash"导入,类型由生成到站点里的emdash-env.d.ts提供(EmDashCollections接口扩展后,集合名与字段自动获得类型推断,见 query.ts),拼错集合名或字段名会得到编译期错误; - 每次查询后都调用
Astro.cache.set(cacheHint),保证内容变更自动失效缓存; - URL 用
entry.id(slug),数据库 API(如getEntryTerms)用entry.data.id(ULID); - CMS 图片必须用
Image组件传对象,不要直接src={对象}; - 富文本用
PortableText,自定义 CMS 区块通过components={{ type: {...} }}注入; - 管理端登录后,把
post.edit.*展开到元素上即获得行内点选编辑能力; - 列表、详情、归档、RSS、404、空状态、分页、日期格式化,可直接复制上面的代码模式改造到自己的站点。