查询内容

本页内容

EmDash 页面在请求时使用 getEmDashCollection() 和 getEmDashEntry() 读取内容。 前者返回列表,后者按 slug 或内容 ID 返回一条条目。两者都将错误作为数据 返回,以便页面决定如何响应。

内置模板使用 Astro 的服务器输出。因此,编辑者发布之后,访问者会在下一次渲染时收到最新已发布的 修订。已保存的草稿在发布之前,或通过有效的预览 URL 请求之前,始终保持私密。

查询集合

下面的页面加载最近发布的七篇文章。公开集合查询默认使用 status: "published",因此过滤条件无需重复写出。

---
import { getEmDashCollection } from "emdash";

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

if (error) {
  console.error("Failed to load posts:", error);
  return new Response("Unable to load posts", { status: 500 });
}

if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---

<h1>Posts</h1>
<ul>
  {posts.map((post) => (
    <li>
      <a href={`/posts/${post.id}`}>{post.data.title}</a>
    </li>
  ))}
</ul>

排序和限制数量在 EmDash 组装条目之前就在数据库中完成。这样可以避免 加载整个集合,再在页面中排序或截取。

结果包含:

  • entries:没有匹配项或查询失败时为空数组;
  • error:查询失败时设置,空结果时不设置;
  • cacheHint:携带 Astro 缓存所需的标签和最后修改时间;
  • nextCursor:有限的游标分页还有更多条目时设置;以及
  • hasMore:报告有限的游标或偏移分页是否还有下一页。

条目标识符

每个结果有两个标识符,各有不同用途:

  • entry.id 是内容加载器生成的面向 URL 的标识符。它通常就是 slug。 当国际化为其添加语言区域前缀时,该前缀也包含在内。根据集合结果构建 链接时使用此值。
  • entry.data.id 是存储在数据库中的稳定内容 ID。编辑者更改 slug 时它不会 改变。当 API、分类法辅助函数、页面上下文或关联需要内容 ID 时使用它。

getEmDashEntry() 既接受 slug,也接受稳定的内容 ID。启用国际化时,按 slug 的查找 限定在某个语言区域内;内容 ID 则直接标识对应的行。

过滤集合

在第二个参数中传入过滤条件。下面的查询返回 news 分类下、series 字段为 engineering 的已发布文章:

const { entries } = await getEmDashCollection("posts", {
  where: {
    category: "news",
    series: "engineering",
  },
});

以分类法命名的键匹配已分配的术语 slug。其他键匹配集合字段。多个 键以 AND 逻辑组合。同一个键上的数组会匹配列出的任一值,因此 { category: ["news", "updates"] } 会匹配任一分类。

使用 locale 显式请求某一种语言:

const { entries: frenchPosts } = await getEmDashCollection("posts", {
  locale: "fr",
  orderBy: { published_at: "desc" },
});

如果省略 locale,EmDash 会使用请求的语言区域,然后是配置的默认语言区域。有关回退规则 和翻译路由,请参见国际化 (i18n)。

status 接受 "published"、"draft" 或 "archived"。不要在公开 路由中请求草稿。当访问者需要临时访问某一条未发布条目时,请使用预览流程。

在数据库中排序结果

orderBy 将字段名映射到 "asc" 或 "desc"。请使用存储的字段名,而不是 entry.data 中 返回的驼峰命名:

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

系统列使用其数据库名称,如 created_at、updated_at 和 published_at。 自定义字段使用其集合 slug,如 title 或 priority。返回的数据将系统 日期映射为 createdAt、updatedAt 和 publishedAt,但这些驼峰命名的属性名不是 有效的 orderBy 字段。

EmDash 使用第一个有效的 orderBy 字段作为分页键,并以内容 ID 作为稳定的 决胜项。没有 orderBy 时,集合默认按 created_at 降序排列。当站点经常按自定义标量 字段排序或过滤时,请将这些字段标记为已索引;随着集合增长,索引可避免全表 扫描。

对集合分页

连续滚动的信息流用游标,带页码的分页用偏移。它们是相互独立的分页 模型,不能在同一个带类型的查询中组合使用。

游标分页

游标分页从上一次查询返回的最后一个条目之后继续。请在多次请求之间保持排序 不变,并原样传回 nextCursor,不要检查或修改它。

下面的路由在存在下一页时渲染 Older posts 链接:

---
import { getEmDashCollection } from "emdash";

const cursor = Astro.url.searchParams.get("cursor") ?? undefined;
const { entries: posts, nextCursor, error } = await getEmDashCollection("posts", {
  limit: 10,
  cursor,
  orderBy: { published_at: "desc" },
});

if (error) return new Response("Unable to load posts", { status: 500 });
---

<ul>
  {posts.map((post) => <li><a href={`/posts/${post.id}`}>{post.data.title}</a></li>)}
</ul>

{nextCursor && (
  <a href={`/posts?cursor=${encodeURIComponent(nextCursor)}`}>Older posts</a>
)}

最后一页没有 nextCursor。游标分页不会计算总页数,也不提供 上一页游标;如果界面需要返回导航,请在浏览器历史记录中保留之前的 URL。

偏移分页

偏移分页适合 /posts/page/3 这样的路由。将页码转换为偏移量,并使用 hasMore 生成下一页链接:

---
import { getEmDashCollection } from "emdash";

const parsedPage = Number(Astro.params.page ?? "1");
if (!Number.isInteger(parsedPage) || parsedPage < 1) {
  return Astro.rewrite("/404");
}

const perPage = 10;
const { entries: posts, hasMore, error } = await getEmDashCollection("posts", {
  limit: perPage,
  offset: (parsedPage - 1) * perPage,
  orderBy: { published_at: "desc" },
});

if (error) return new Response("Unable to load posts", { status: 500 });
---

<ul>
  {posts.map((post) => <li><a href={`/posts/${post.id}`}>{post.data.title}</a></li>)}
</ul>

<nav aria-label="Post pages">
  {parsedPage > 1 && <a href={`/posts/page/${parsedPage - 1}`}>Newer posts</a>}
  {hasMore && <a href={`/posts/page/${parsedPage + 1}`}>Older posts</a>}
</nav>

偏移必须是非负整数。第 1 页使用偏移量零,表示 “从第一个条目开始”。偏移分页便于按页码定位,但在两次请求之间新增的条目 可能使后面的页发生位移。当这种位移会造成困惑时,请使用游标。

查询并渲染单条条目

下面的运行时路由按 slug 读取一篇文章,渲染其特色图片和 Portable Text 正文, 并将查询失败与条目不存在区分开:

---
import { decodeSlug, getEmDashEntry } from "emdash";
import { Image, PortableText } from "emdash/ui";

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

const { entry: post, error, isPreview, cacheHint } = await getEmDashEntry("posts", slug);

if (error) {
  console.error("Failed to load post:", error);
  return new Response("Unable to load post", { status: 500 });
}

if (!post) return Astro.rewrite("/404");
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---

{isPreview && <p>This is an unpublished preview.</p>}

<article>
  {post.data.featured_image && <Image image={post.data.featured_image} priority />}
  <h1>{post.data.title}</h1>
  <PortableText value={post.data.content} />
</article>

PortableText 为 EmDash 的标准块提供渲染器,包括图片、视频、图库、代码、 表格、HTML 块和 iframe 块。当站点添加自己的 Portable Text 块时,请传入自定义组件。

带有 isolated: true 的 HTML 块会在沙盒 iframe 中渲染其 html、css 和 js,该 iframe 会根据内容自动调整大小。 其他 HTML 块会将经过清理的 html 直接渲染在页面中。如果替换 htmlBlock 渲染器,请将隔离块传给 emdash/ui 中的 HtmlBlock,或在 sandbox 不含 allow-same-origin 的 iframe 中渲染它们。请自行清理内联 HTML, 并且只允许站点打算信任的 iframe 主机。

iframe 块通过 emdash/ui 中的 Iframe 渲染为响应式、延迟加载的 iframe,并带有 sandbox 和 strict-origin-when-cross-origin 引用来源策略 (referrer policy)。位于你站点自身主机上的页面会失去同源访问权限, 并且只会渲染 https 来源。

视频块通过 emdash/ui 中的 Video 渲染。媒体库中的视频使用浏览器自带的播放器播放,带有控件和说明文字;如果配置了存储的公开 URL,则从该 URL 播放。没有公开 URL 时,视频通过 /_emdash/api/media/file/ 提供。来自媒体提供商的视频由该提供商渲染。没有 asset 的空视频块不会渲染任何内容。

启用 Astro 的内容安全策略后, 浏览器会阻止隔离 HTML 块中的样式和脚本,因此这些块渲染时不带 CSS、JavaScript,也没有自动高度。iframe 块同样会失去 自定义尺寸,并且策略中的 frame-src 必须允许它们所嵌入的主机。媒体位于其他主机上的视频块(例如存储的公开 URL 或媒体提供商)需要在 media-src 中允许该主机。

PortableText 在编辑模式下将表格显示为只读占位符。要本地化其初始标签,请传入 tablePlaceholder={translatedLabel}。默认值为 "Table (edit in admin)",不影响已发布的表格内容。

读取引用字段

reference 字段将条目链接到另一个集合中的条目,关联介绍如何定义。其值不属于 data。向 getEmDashEntry 传入 references 选项,以字段 slug 为键,指明页面要渲染的字段,每个字段都会返回为一页条目:

---
import { getEmDashEntry } from "emdash";

const { entry: post } = await getEmDashEntry("posts", Astro.params.slug, {
  references: { author: true, related_posts: { limit: 6 } },
});

if (!post) return Astro.rewrite("/404");

const author = post.references?.author.entries[0];
---

<article>
  <h1>{post.data.title}</h1>
  {author && <p>By {author.data.name}</p>}
  <ul>
    {post.references?.related_posts.entries.map((related) => (
      <li><a href={`/posts/${related.data.slug}`}>{related.data.title}</a></li>
    ))}
  </ul>
</article>

true 表示以默认上限 50 个条目请求第一页。对于容纳更多条目的字段,请使用 { limit, cursor },每页最多 100 个。

每个被引用的条目都是一个 ContentEntry,其形状与直接加载的条目相同:一个 id,一个 data 对象(日期为 Date 对象,媒体值已解析),以及一个限定在该被引用条目上的 edit 代理,因此在可视化编辑中点击某张卡片时,会打开该卡片所指向的条目。Byline 和分类法术语是例外——EmDash 不会把它们注入到被引用的条目上,因此请从条目本身读取 data.bylines 和 data.terms。

当字段位于关联的父端时,条目按编辑者排列的顺序到达。位于子端的字段则列出指向它的条目,这些条目本身没有顺序。

该选项在两个方向上都是按需启用的。没有传入 references 的调用不会运行任何额外查询,选择中未列出的字段也不会被读取。选择了字段的调用,每个字段会产生一次链接查询,并且无论每个字段包含多少条目,每个不同的目标集合再产生一次条目查询。

对引用字段分页

getEmDashReferences 使用上一页返回的游标,获取单个字段的下一页:

import { getEmDashReferences } from "emdash";

const { entries, nextCursor } = await getEmDashReferences("posts", post.id, "related_posts", {
	cursor,
	limit: 20,
});

它从与 getEmDashEntry 相同的请求上下文中读取草稿可见性,因此在预览中开始的遍历会继续看到待定的选择。页面 id 在 i18n 添加前缀的情况下会带上其语言区域(fr/about),回传时应使用的就是这个 id。

在缓存其输出的路由上,请将页面返回的 cacheHint 合并到路由自己的缓存提示中,这样对其所指明条目之一的写入就会使该页面失效。

预览中的引用字段

公开渲染看到的是已发布的条目和已发布的选择。条目的预览,或可视化编辑中的编辑者,看到的是未发布的条目和暂存在条目草稿中的选择,因此预览链接显示的是页面发布之后将拥有的引用。

应用 SEO 与编辑功能

对于支持 SEO 的集合,getSeoMeta() 会解析编辑者设置的 SEO 标题、描述、 图片、规范 URL 和 no-index 选项,并以条目中的值作为回退。当前的博客模板 把这个结果传给其基础布局:

---
import { getSeoMeta } from "emdash";

const seo = getSeoMeta(post, {
  siteTitle: "My Blog",
  siteUrl: Astro.url.origin,
  path: Astro.url.pathname,
});
---

<Base
  title={seo.title}
  pageTitle={seo.ogTitle}
  description={seo.description}
  image={seo.ogImage}
  canonical={seo.canonical}
  robots={seo.robots}
>
  <!-- Post content -->
</Base>

包含 <EmDashHead> 的布局可以把相同的 SEO 字段和插件贡献应用到 服务器渲染的内容页面。仅读取 data.title 或 data.excerpt 的手写 meta 标签 不会应用编辑者设置的规范 URL 或 no-index 设置。

预览 URL 不需要单独的查询。中间件会验证 _preview 令牌, getEmDashEntry() 会返回对应的草稿,并带有 isPreview: true。 预览与可视化编辑指南说明了 URL 的生成方式,以及用于内联编辑的 entry.edit 注解。

生成 TypeScript 类型

开发服务器会根据当前模式生成 emdash-env.d.ts。请让该文件保持包含在 项目的 TypeScript 配置中,这样像 "posts" 这样的集合名称就会自动选用生成的 Post 数据类型。

对于远程 EmDash 实例,CLI 可以获取模式并将类型写入 .emdash/types.ts:

npx emdash types --url https://cms.example.com

该命令接受 API 令牌或自定义认证标头。有关这些选项,请参见 生成类型。

每个带有绑定到关联的引用字段的集合,都会获得第二个接口 {Collection}References,并以相同的 slug 注册。getEmDashEntry 会将其结果收窄到 references 选项所指明的字段,每一页的条目都带有目标集合的 接口:

const { entry: post } = await getEmDashEntry("posts", "my-post", {
	references: { author: true },
});

// post?.references?.author.entries[0] carries the Author interface
// post?.references?.related_posts is a type error: it was not selected

运行时渲染与缓存

Node.js 和 Cloudflare 博客模板在 astro.config.mjs 中设置了 output: "server"。它们的内容 查询在每次服务器渲染期间运行,因此新发布的修订有资格出现在下一次 请求中。如果你有意对某条路由做预渲染,其 HTML 包含构建时可用的 内容,只有在再次构建之后才会改变。

启用 Astro 缓存后,请将查询的 cacheHint 传给 Astro.cache.set()。EmDash 会把 响应与集合和条目标签关联起来,这样发布就可以使受影响的缓存页面失效。 除非延迟更新是明确的产品决策,否则不要用固定的长 Cache-Control 有效期 来替代这一集成。

有关确切的签名和更少用到的过滤条件,请参见 JavaScript API 参考。若要围绕这些 查询构建一个可运行的示例,请继续阅读创建博客。