- CMS
- 后端
- 前端
- 插件系统
【免费下载链接】emdash
EmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress
EmDash(基于 Astro 的全栈 TypeScript CMS)为插件提供了一整套按能力(capability)门控的内容 API,覆盖 schema 读取、内容读写、翻译管理、发布策略钩子与回收站恢复。本文以 creating-plugins 技能参考文档 为主线,结合 核心实现、发布策略实现 与 能力词表定义 展开,帮助你理解每个能力边界、调用形态、稳定错误语义,以及如何在真实项目中安全地读写与发布内容。
能力门控总览:同一套 API,三种执行形态
插件内容 API 是能力门控(capability-gated)的,并且由原生插件、Cloudflare Worker Loader 与 Node/workerd 三种执行形态共享。这意味着无论你的插件以何种方式被宿主加载,面对的都是同一套ctx.schema、ctx.content语义;差异只体现在沙箱边界与传输层,而不是 API 形状上。
在 SandboxedPlugin 类型 中,插件需要在emdash-plugin.jsonc里声明所需能力。与内容直接相关的能力及其授予范围如下(完整能力表见 SKILL.md):
| 能力 | 授予范围 |
|---|---|
schema:read | 公开的集合(collection)与字段(field)定义 |
content:read | 内容身份、翻译与已发布公开 URL |
content:revisions:read | 保留的修订数据;隐含内容读取 |
content:write | 创建、更新、删除与翻译创建;隐含读取 |
content:publish | 修订围栏(revision-fenced)的发布、取消发布、调度与取消调度;隐含读取 |
content:restore | 回收站内容的修订围栏读取与恢复 |
hooks.content-policy:register | 发布前、调度前、取消发布前策略钩子 |
值得强调的设计原则:能力写在emdash-plugin.jsonc,而不是src/plugin.ts。宿主管道会跳过缺少所需能力的钩子与 API 调用(参见 hooks.md),因此声明与实现必须保持一致,这也是 插件发布校验 中"declared capabilities and allowed hosts that match the plugin implementation"的来源。
发现与读取:schema:read 与 content:read
集合与字段定义
schema:read暴露两个批量方法:ctx.schema.listCollections()用于获取全部公开集合,ctx.schema.getCollection()用于获取单个集合的字段定义。它们在宿主侧由SchemaRegistry提供支持(参见 content-access.ts 中对new SchemaRegistry(db).getCollection(collection)的使用),因此插件读取到的定义与站点后台管理界面看到的 schema 是同一份数据。
内容项的数据形状
schema:read之外,读取内容本体需要content:read,它暴露:
ctx.content.get(collection, id):按 id 获取单条内容;ctx.content.list(collection, options?):分页列表,支持limit(默认 50)、cursor游标、where过滤与orderBy排序;ctx.content.getTranslations(collection, id):获取翻译组信息;ctx.content.getPublicUrl(collection, id):解析公开可路由 URL。
返回的内容项(ContentItem)结构,可以从 createContentAccess 的实现中完整看到,包含以下字段:
| 字段 | 含义 |
|---|---|
id | 内容项唯一 id |
type/slug | 内容类型与 slug |
status | 状态(如 draft / published) |
data | 结构化字段数据 |
createdAt/updatedAt | 时间戳 |
locale | 语言区域 |
publishedAt/scheduledAt | 发布时间 / 计划时间 |
authorId | 作者 id |
translationGroup | 翻译组标识 |
liveRevisionId/draftRevisionId | 线上版与草稿版修订指针 |
version | 行版本号(乐观并发用) |
如果集合启用了 SEO 模块,结果中还会附带seo字段。
公开 URL 只解析已发布内容
getPublicUrl是一个需要特别注意安全语义的方法。查看 实现:它要求item.status === "published"、slug存在、且集合是可路由(routable)的,然后按站点的urlPattern、trailingSlash与 locale 规则解析出完整 URL。也就是说,公开 URL 解析永远只返回已发布的、可路由的 URL,绝不返回预览。插件在生成外链、sitemap 或社交分享链接时应依赖这个方法,而不是自己拼 URL。
修订读取:content:revisions:read
content:revisions:read额外暴露listRevisions()与getRevision(),用于读取修订历史。从 实现 可以看到一个隐私细节:修订快照可以保留后来被删除的字段值,但返回时通过解构去掉了authorId——修订数据不携带修订者身份。如果你的插件需要审计"谁改的",需要另行借助users:read或其他来源,而不能依赖修订记录。
写入与翻译:content:write
content:write在读取能力之上增加创建、更新与删除。创建一条翻译是最典型的用法,参考文档给出的调用形态:
await ctx.content!.create("posts", data, { locale: "fr", translationOf: sourceId });这条调用的语义约束非常明确:
- 源必须是同一集合中的活动条目(active entry);不存在的源会触发
NOT_FOUND; - 新行会加入源所在的翻译组,并继承不可翻译字段、署名(byline)与分类(taxonomy)指派;
- 校验与保存钩子(validation 与 save hooks)都会运行,且创建者插件会收到"可重入围栏"(re-entrancy fencing)保护,避免同一插件在钩子内再次进入产生死循环;
- 一个翻译组每个 locale 只允许一条活动行,重复 locale 会被拒绝。
从 hooks.md 可知,保存钩子content:beforeSave/content:afterSave分别需要content:write与content:read能力;beforeSave可以返回修改后的内容,或在沙箱中返回{ __emdashSandboxHookResult: true, version: 1, error: { code: "SAVE_REJECTED", reason } }拒绝保存(reason 必须为 1–500 字符的纯文本),宿主进程则抛出ContentSaveRejectedError(见 save-rejection.ts 相关实现)。
稳定的失败语义
写入路径上插件可以依赖以下稳定错误码:
CONFLICT——并发冲突(通常与行版本version不匹配相关);NOT_FOUND——目标内容或源不存在;VALIDATION_ERROR——字段校验失败;SAVE_REJECTED——被钩子显式拒绝。
插件应按这些错误码编写重试与用户提示逻辑,而不是依赖不稳定的错误消息文本。
发布策略钩子:不授予任何读写权也能拦截发布
hooks.content-policy:register是一个"零读写权限"的拦截能力:它使插件能够注册content:beforePublish、content:beforeSchedule与content:beforeUnpublish,但本身不授予任何内容读取、写入或发布操作。这实现了关注点分离——审查/审批类插件可以只做策略判断,拿不到内容数据。
决策形态与校验
钩子返回void表示放行;返回{ cancel: true, reason }表示拒绝。拒绝语义由 content-policy.ts 中的inspectContentPolicyDecision严格校验:
reason必须是非空字符串,长度不超过500 个字符(按码点计数);- 不允许包含控制字符(tab、换行、回车除外);
- 决策对象必须恰好包含
cancel与reason两个键; - 非法决策或意外中止错误会让整个动作以通用失败告终;
- 显式取消分别返回
PUBLISH_REJECTED、SCHEDULE_REJECTED或UNPUBLISH_REJECTED。
一个基于字段审批状态的示例(源自 hooks.md):
"content:beforePublish": async (event) => { const data = event.content.data; const approvalStatus = typeof data === "object" && data !== null && "approval_status" in data ? data.approval_status : undefined; if (approvalStatus !== "approved") { return { cancel: true, reason: "Approve this entry before publishing." }; } },事件来源与调度拒绝
策略钩子的事件携带{ content, collection, origin, actor? },其中content:beforeSchedule还包含scheduledAt。origin标识动作来源:api、mcp、visual-editor(人类操作,同时携带相同的actor.source,visual-editor 要求来自已认证工具栏渲染的签名短期令牌)、plugin(附pluginId)、scheduler与system。这意味着策略插件可以针对不同来源差异化放行——例如只允许管理员在管理界面发布,拒绝 API 令牌直接发布。
调度行为有一个容易忽略的细节:到点执行的调度发布会再次运行发布策略。若调度器执行content:beforePublish时被拒绝,条目会被取消调度(unschedule),拒绝原因被记录在案,管理后台会展示该原因,直到条目被重新调度、发布、删除或记录被消除。从源码看,这个记录使用前缀emdash:scheduled-policy-rejection:的存储键(见 content-policy.ts),并携带collection、id、pluginId、reason、rejectedAt等字段。同时,因为没有content:beforeUnschedule钩子,管理员永远可以取消一次未来发布,不会被策略插件锁死。
发布与恢复动作:content:publish 与 content:restore
修订围栏(revision fence)
content:publish在读取能力之上增加:
getVersioned():读取带版本号的内容;publish()/unpublish():发布 / 取消发布;schedule()/unschedule():调度 / 取消调度。
这里最关键的模式是先读后写 + 携带_rev:每次变更前先用getVersioned()读取,并把不透明的_rev传给每一次变更动作。_rev是乐观并发锁,防止两个进程同时基于同一版本做发布决策导致状态错乱。发布成功的动作会返回下一个修订(next revision),并正常运行策略、同步(synchronization)、媒体使用(media-usage)、缓存失效(cache-invalidation)以及 after 钩子等完整行为链。
恢复不隐含删除权
content:restore是一个刻意最小化的能力:它只增加getTrashedVersioned()与restore(),用于读取并恢复回收站中的内容,不授予普通内容读取,也不授予永久删除。这保证了"能恢复"的插件不会因此拥有"能读全部内容"或"能清空回收站"的权限,是权限最小化设计的典型例子。
插件动作的标识与重入防护
所有插件动作都会报告{ source: "plugin", pluginId }。宿主对同一插件针对同一规范条目(canonical entry)的相同动作实施重入拒绝——即插件发起发布后,若该插件在钩子中再次对同一条目发起发布,会被拒绝。这配合前面提到的保存钩子可重入围栏,构成对插件递归行为的双层防护(发布侧的重入语义见 hooks.ts 中钩子系统的运行机制)。
运行时测试:用 fixture 与 action 覆盖关键行为
参考文档给出的测试方法论非常实用:用运行时 fixture 建立初始状态,用运行时 action 调用生产边界,用 inspector 读取持久化状态。
- fixture 用于初始数据:初始条目(entries)、翻译、署名、分类指派都不应通过触发钩子的方式创建,而应直接建立状态,避免污染被测行为;
- action 用于发布路径:发布、调度、取消发布等必须走真实的生产边界(runtime actions),因为它们涉及修订围栏、策略运行与缓存失效;
- inspector 用于验证持久化状态:读取最终落库状态,断言行为副作用是否符合预期。
当以下行为"对你的插件有意义"时,务必编写对应测试:过期修订(stale revisions,即_rev已失效的并发场景)、策略拒绝(policy rejection)、重复 locale、重入(re-entrancy)、重启(restart,验证调度拒绝记录与状态在进程重启后仍正确)以及缓存失效(cache invalidation)。
测试宿主的选择可以参考 SKILL.md:createPluginTestHost()适合快速验证钩子、路由、清单、能力、KV、设置与存储传输;而涉及真实内容动作、插件激活、媒体、评论、重定向、调度、重启、授权、CSRF、缓存、Block Kit 校验或已保存条目扩展时,应使用createPluginRuntimeTestHost()。每次使用后都要 dispose 宿主。
能力边界速查表
| 你想做的事 | 需要的能力 | 关键 API / 钩子 |
|---|---|---|
| 读集合与字段定义 | schema:read | ctx.schema.listCollections()/getCollection() |
| 读内容、翻译、公开 URL | content:read | ctx.content.get()/list()/getTranslations()/getPublicUrl() |
| 读修订历史 | content:revisions:read | listRevisions()/getRevision()(无修订者身份) |
| 创建 / 更新 / 删除 / 建翻译 | content:write | ctx.content.create()/update()/delete() |
| 发布 / 取消发布 / 调度 | content:publish | getVersioned()+publish()/unpublish()/schedule()/unschedule(),必须传_rev |
| 恢复回收站内容 | content:restore | getTrashedVersioned()/restore() |
| 拦截发布 / 调度 / 取消发布 | hooks.content-policy:register | content:beforePublish/beforeSchedule/beforeUnpublish,返回{ cancel: true, reason } |
小结
EmDash 插件内容 API 的核心设计可以总结为三句话:能力即权限(每个读、写、发布、恢复动作都有独立能力开关,且能力声明在清单而非代码中);修订围栏保一致(_rev贯穿所有发布与恢复动作,先读后写是唯一正确姿势);策略与执行分离(发布策略钩子不授予任何读写权,却能在发布、调度、取消发布三条路径上执行审批,并通过调度拒绝记录让管理员始终保有最终控制权)。理解这三点,你就能写出既安全又实用的内容类插件。
- CMS
- 后端
- 前端
- 插件系统
【免费下载链接】emdash
EmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress
相关推荐
EmDash 插件内容 API 指南:schema 读取、翻译创建、发布策略与版本化发布操作
EmDash 插件内容 API 指南:schema 读取、翻译创建、发布策略与版本化发布操作 EmDash 是一套基于 Astro 的全栈 TypeScript
CMS后端前端插件系统EmDash 插件内容 API 指南:Schema、翻译、发布与恢复的完整实现
EmDash 插件内容 API 指南:Schema、翻译、发布与恢复的完整实现 EmDash 是一款基于 Astro 的全栈 TypeScript CMS(Wo
CMS后端前端插件系统EmDash 插件内容 API 实战:Schema 读取、多语言翻译、发布策略与版本化恢复
EmDash 插件内容 API 实战:Schema 读取、多语言翻译、发布策略与版本化恢复 EmDash(全栈 TypeScript CMS,Astro 生态)
CMS后端前端插件系统
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考