EmDash 内容查询与渲染指南:用 getEmDashCollection / getEmDashEntry 构建 Astro 驱动的内容站点

原创2026-09-23 19:25:58300 阅读
文章标签:CMS后端前端插件系统

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, "&amp;")
		.replace(/</g, "&lt;")
		.replace(/>/g, "&gt;")
		.replace(/"/g, "&quot;")
		.replace(/'/g, "&apos;");
}

要点:过滤掉未发布的条目(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、空状态、分页、日期格式化,可直接复制上面的代码模式改造到自己的站点。
登录后查看全文
emdash