EmDash 插件内容 API 全指南:schema、翻译、发布策略与恢复操作的权限边界
2026/9/23 21:01:29 网站建设 项目流程
  • CMS
  • 后端
  • 前端
  • 插件系统

【免费下载链接】emdash

EmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress

项目地址:https://gitcode.com/gh_mirrors/emdas/emdash
点击查看免费下载

EmDash(基于 Astro 的全栈 TypeScript CMS)为插件提供了一整套按能力(capability)门控的内容 API,覆盖 schema 读取、内容读写、翻译管理、发布策略钩子与回收站恢复。本文以 creating-plugins 技能参考文档 为主线,结合 核心实现、发布策略实现 与 能力词表定义 展开,帮助你理解每个能力边界、调用形态、稳定错误语义,以及如何在真实项目中安全地读写与发布内容。

能力门控总览:同一套 API,三种执行形态

插件内容 API 是能力门控(capability-gated)的,并且由原生插件、Cloudflare Worker Loader 与 Node/workerd 三种执行形态共享。这意味着无论你的插件以何种方式被宿主加载,面对的都是同一套ctx.schemactx.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)的,然后按站点的urlPatterntrailingSlash与 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:writecontent: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:beforePublishcontent:beforeSchedulecontent:beforeUnpublish但本身不授予任何内容读取、写入或发布操作。这实现了关注点分离——审查/审批类插件可以只做策略判断,拿不到内容数据。

决策形态与校验

钩子返回void表示放行;返回{ cancel: true, reason }表示拒绝。拒绝语义由 content-policy.ts 中的inspectContentPolicyDecision严格校验:

  • reason必须是非空字符串,长度不超过500 个字符(按码点计数);
  • 不允许包含控制字符(tab、换行、回车除外);
  • 决策对象必须恰好包含cancelreason两个键;
  • 非法决策或意外中止错误会让整个动作以通用失败告终;
  • 显式取消分别返回PUBLISH_REJECTEDSCHEDULE_REJECTEDUNPUBLISH_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还包含scheduledAtorigin标识动作来源:apimcpvisual-editor(人类操作,同时携带相同的actor.source,visual-editor 要求来自已认证工具栏渲染的签名短期令牌)、plugin(附pluginId)、schedulersystem。这意味着策略插件可以针对不同来源差异化放行——例如只允许管理员在管理界面发布,拒绝 API 令牌直接发布。

调度行为有一个容易忽略的细节:到点执行的调度发布会再次运行发布策略。若调度器执行content:beforePublish时被拒绝,条目会被取消调度(unschedule),拒绝原因被记录在案,管理后台会展示该原因,直到条目被重新调度、发布、删除或记录被消除。从源码看,这个记录使用前缀emdash:scheduled-policy-rejection:的存储键(见 content-policy.ts),并携带collectionidpluginIdreasonrejectedAt等字段。同时,因为没有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:readctx.schema.listCollections()/getCollection()
读内容、翻译、公开 URLcontent:readctx.content.get()/list()/getTranslations()/getPublicUrl()
读修订历史content:revisions:readlistRevisions()/getRevision()(无修订者身份)
创建 / 更新 / 删除 / 建翻译content:writectx.content.create()/update()/delete()
发布 / 取消发布 / 调度content:publishgetVersioned()+publish()/unpublish()/schedule()/unschedule(),必须传_rev
恢复回收站内容content:restoregetTrashedVersioned()/restore()
拦截发布 / 调度 / 取消发布hooks.content-policy:registercontent: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

项目地址:https://gitcode.com/gh_mirrors/emdas/emdash
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询