- CMS
- 后端
- 前端
- 插件系统
【免费下载链接】emdash
EmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress
EmDash 是一套基于 Astro 的全栈 TypeScript CMS,其插件系统将内容能力封装为一组按能力(capability)门控的 API,供沙箱插件、原生插件在 Native、Cloudflare Worker Loader 与 Node/workerd 三种执行模式下共享使用。本篇指南以官方技能文档 content.md 为骨架,结合 plugins/context.ts、plugins/content-access.ts 等核心源码,系统讲解ctx.schema、ctx.content的读取、翻译、发布、恢复全流程,以及对应的发布策略钩子与运行时测试方法。读完本文,你将掌握如何在 EmDash 插件中安全地读写内容、创建多语言翻译、以修订号(_rev)为护栏执行发布与恢复动作,并理解底层实现与测试边界。
内容 API 的权限门控模型
插件内容 API 的核心设计是声明即授权:插件作者必须在emdash-plugin.jsonc中显式声明所需能力,宿主在运行时按清单门控每一次调用。与内容直接相关的能力如下(完整词汇表见 packages/plugin-types/src/index.ts 中的PluginCapability联合类型):
| 能力 | 授予的操作 |
|---|---|
schema:read | 公开的集合与字段定义(ctx.schema) |
content:read | 内容身份、翻译组与已发布的公开 URL(ctx.content读取) |
content:revisions:read | 保留的修订数据;隐含content:read |
content:write | 创建、更新、删除与翻译创建;隐含content:read |
content:publish | 修订号保护的发布、取消发布、定时与取消定时;隐含content:read |
content:restore | 修订号保护的回收站内容读取与恢复 |
hooks.content-policy:register | 发布前、定时前、取消发布前策略钩子注册 |
从源码看,能力与钩子的绑定关系由 hooks.ts 中的映射表维护(如content:beforePublish、content:beforeSchedule、content:beforeUnpublish均映射到hooks.content-policy:register),而运行时通过createContentAccess/createContentAccessWithWrite(content-access.ts、context.ts)按是否授予对应能力决定挂载哪些方法。
一个值得注意的细节:content:revisions:read、content:write、content:publish在 declared-access.ts 中都被设计为隐含content:read——例如capabilitiesToDeclaredAccess会在声明content:write时同时写入content.read = {}。这意味着写权限永远不小于读权限,避免了"只能写不能读"的荒谬状态。
发现与读取:schema:read与content:read
Schema 发现
schema:read暴露ctx.schema.listCollections()与ctx.schema.getCollection(),用于批量获取公开的集合与字段定义。对应实现createSchemaAccess(context.ts)内部基于SchemaRegistry查询并转换为插件可见形态CollectionSchemaInfo,包含:
- 集合身份:
slug、label、labelSingular、description - 路由信息:
urlPattern、routable(是否可路由)、hidden - 功能开关:
hasSeo、titleField、dateField - 字段定义数组
fields:每个字段含slug、label、type、required、unique、default、validation、widget、options、searchable、indexed、translatable、sortOrder等元数据
这为插件在运行时动态了解站点内容结构(而非硬编码集合名)提供了基础。
内容读取
content:read暴露ctx.content上的四个读取方法:
get(collection, id):按 ID 取单条内容,不存在返回nulllist(collection, options?):分页列表,支持limit(默认 50)、cursor、orderBy、wheregetTranslations(collection, id):返回翻译组 ID 及该组下所有翻译的摘要(ID、locale、slug、status、updatedAt)getPublicUrl(collection, id):解析内容对外可访问的公开 URL
内容结果字段是插件读取的核心契约。从 content-access.ts 的实现可见,每条ContentItem包含:
- 身份与状态:
id、type(集合名)、slug、status、locale - 数据与时间戳:
data(字段值)、createdAt、updatedAt、publishedAt、scheduledAt - 归属与分组:
authorId、translationGroup - 修订指针:
liveRevisionId、draftRevisionId(线上/草稿版本指针) - 版本号:
version(行版本) - 可选
seo:当集合启用 SEO 时附带
公开 URL 解析规则非常严格:getPublicUrl只有在站点配置了 URL、内容状态为published、存在 slug、且集合routable时才返回完整 URL(site.url + 路由路径),否则返回null。关键点是——它只返回已发布的、可路由的 URL,绝不返回预览地址。这意味着插件拿到的永远是真实可对外访问的链接,不会意外泄露草稿或预览路由。
修订读取:content:revisions:read
content:revisions:read在只读访问之上追加listRevisions()与getRevision()。实现位于 content-access.ts,通过RevisionRepository查询可见修订。两个语义要点:
- 修订快照可以保留后续被删除的字段值:历史修订记录的是当时保存的完整字段快照,即使当前条目已删除了某字段,旧修订中仍可能持有该值——这是审计与回滚的价值所在。
- 修订结果会剔除修订作者身份:从源码可见,
listRevisions与getRevision在返回前都会通过解构authorId将其剥离(const { authorId: _authorId, ...revision }),即插件能看到修订内容与时间线,但看不到"谁改的"这一身份信息,保护用户隐私。
写入与翻译:content:write
content:write追加create、update、delete三个写操作(实现于createContentAccessWithWrite,context.ts)。
创建翻译
创建翻译是插件最常用的写场景之一,官方示例:
await ctx.content!.create("posts", data, { locale: "fr", translationOf: sourceId });其中translationOf指向同一集合中的源条目 ID。从 types.ts 的ContentCreateOptions可见,创建选项仅有两个:locale(默认为站点配置语言,再退化为en)与translationOf。
翻译的继承与约束
翻译创建的语义约束(与ContentRepository.create的translationOf参数对应):
- 源必须是同一集合中的活跃条目:不能跨集合引用,也不能引用已删除/已回收的条目。
- 新行加入源条目的翻译组,并继承不可翻译字段、署名(byline credits)与分类(taxonomy)分配——翻译行只携带本语言的内容字段,站点级归属信息保持一致。
- 一个翻译组每个语言只允许一个活跃行:同一语言重复创建会被拒绝(这正是运行时测试要覆盖的 "duplicate locales" 场景)。
- 校验与保存钩子照常运行:创建翻译同样走完整的校验管线,且对发起创建的插件启用重入围栏(re-entrancy fencing),防止插件在自己的保存钩子中再次写同一内容造成死循环。
写操作的其他细节
update(collection, id, data)是"草稿感知"更新:updateDraftAware会根据条目当前状态决定写入草稿还是直接更新字段,且仅在有字段更新时才触碰updated_at与version——纯 SEO 更新不会无谓地制造修订噪音(context.ts)。data支持保留的seo键(ContentWriteInput,types.ts),会被拆出并路由到_emdash_seo表,与条目写入同一事务;但对未启用 SEO 的集合传入seo会抛校验错误。delete是移入回收站(软删除)而非永久删除,同时会释放条目锁并标记内容媒体用量缓存失效。
稳定错误码
写操作失败时,插件应捕获以下稳定错误码(宿主保证跨执行模式一致):
| 错误码 | 含义 |
|---|---|
CONFLICT | 修订冲突或同 locale 活跃行重复 |
NOT_FOUND | 目标条目/集合不存在 |
VALIDATION_ERROR | 字段校验失败(含 SEO 未启用等约束) |
SAVE_REJECTED | 保存钩子拒绝了本次写入 |
发布策略:hooks.content-policy:register
发布策略是一组只读优先的钩子:注册hooks.content-policy:register即可获得content:beforePublish、content:beforeSchedule、content:beforeUnpublish三个钩子,而无需同时获得内容读取、写入或发布动作权限。也就是说,一个仅声明策略能力的插件可以在不接触内容数据的前提下否决发布动作。
拒绝机制
在钩子中返回{ cancel: true, reason }即可拒绝对应动作:
// 示例:拒绝包含特定字段值的条目发布 const plugin: SandboxedPlugin = { hooks: { "content:beforePublish": async (event) => { if (event.content.data.missingCopyright) { return { cancel: true, reason: "Copyright attribution is required before publication" }; } return undefined; // 放行 }, }, };从 hooks.ts 的管线实现可见:策略钩子以决策(decision)形式返回,decision.kind === "cancel"时携带{ pluginId, reason }记录取消来源;钩子异常则按错误策略(continue/abort)处理并记录告警。content:beforeSchedule还要求事件必须携带scheduledAt,否则直接报错。
动作来源与定时的二次校验
发布策略事件会标识动作来源,覆盖:API、MCP、可视化编辑器(visual-editor)、插件、调度器(scheduler)、系统(system)。对于经过认证的人类操作,事件还会携带actor 身份与来源——这让策略可以根据"是谁、通过什么入口"做差异化判断。
调度发布有特殊语义:定时任务真正执行发布时,发布策略会再次运行。如果此时策略拒绝,则取消该条目的定时(unschedule),并把有界的原因(bounded reason,即截断后的拒绝理由)记录给管理员查看。这避免了"定时时放行、执行时反悔"导致条目悬空。
发布与恢复动作:content:publish与content:restore
修订号保护的发布动作
content:publish在读取之上追加四个动作方法与一个版本化读取方法:
getVersioned(collection, id):返回带_rev(不透明修订号)的VersionedContentItempublish(collection, id, { _rev }):发布unpublish(collection, id, { _rev }):取消发布schedule(collection, id, { _rev, scheduledAt }):定时发布unschedule(collection, id, { _rev }):取消定时
类型定义见 types.ts。使用铁律:先读后写,每次变更必须传递不透明的_rev。_rev是乐观并发控制的凭证——如果条目在你读取后被他人修改,你的_rev已过期,宿主会拒绝本次变更(这正是"测试过期修订(stale revisions)"要覆盖的场景)。
成功的动作会返回下一修订,并完整执行常规行为链:发布策略(policy)→ 同步(synchronization)→ 媒体用量(media-usage)→ 缓存失效(cache-invalidation)→ 后置钩子(after-hook)。也就是说,插件发起的发布与后台 UI 发起的发布走完全相同的生产路径,不会绕过任何环节。
恢复动作
content:restore是刻意收窄的能力:它只追加getTrashedVersioned()与restore(),用于读取回收站中条目的版本化快照并恢复,既不授予普通内容读取权限,也不授予永久删除权限——插件永远无法通过该能力彻底抹除内容。
动作溯源与重入拒绝
所有插件动作都会报告{ source: "plugin", pluginId },让下游(策略、审计、钩子事件)能识别动作来自哪个插件。同时,同一插件对同一 canonical entry 再次进入同一动作会被拒绝。运行时有对应集成测试:plugin-action-settlement.test.ts 演示了在content:afterPublish钩子中嵌套调用unpublish会得到CONTENT_ACTION_REENTRANT错误码——这就是"跨动作重入围栏"的行为验证。此外该测试文件还覆盖了发布期间媒体用量激活进行中时的MEDIA_USAGE_ACTIVATION_IN_PROGRESS拒绝、完整内容契约字段(authorId、translationGroup、liveRevisionId、draftRevisionId、version)的保留等边界。
运行时测试策略
文档最后给出针对内容能力的测试方法论,核心是区分三种测试工具:
- 运行时夹具(runtime fixtures):用于建立初始条目、翻译、署名(bylines)与分类(taxonomy)分配——只造状态,不触发钩子,适合搭建测试前置条件。
- 运行时动作(runtime actions):用于走发布路径等生产边界——每次动作都真实执行策略、同步、媒体用量与缓存逻辑。
- 检查器(inspectors):用于读取持久化状态,验证动作后的真实落库结果。
当以下行为对你重要时,务必显式覆盖测试:过期修订(stale revisions)、策略拒绝(policy rejection)、重复 locale(duplicate locales)、重入(re-entrancy)、重启(restart)与缓存失效(cache invalidation)。
完整的两级测试宿主说明见技能主文档 SKILL.md:createPluginTestHost()用于快速的钩子/路由/清单/能力/KV/设置/存储传输测试;createPluginRuntimeTestHost()用于需要真实内容动作、插件激活、媒体、评论、重定向、调度、重启、授权、CSRF、缓存、Block Kit 校验或已保存条目扩展的测试。注意每次使用后都要释放(dispose)宿主。
小结
EmDash 的插件内容 API 通过"能力声明 → 运行时门控 → 修订号护栏"三层设计,在沙箱与原生插件之间提供了统一且安全的内容访问契约:
- 读:
schema:read发现结构,content:read读取内容与公开 URL,content:revisions:read读取修订快照(但剥离作者身份); - 写:
content:write支持创建、更新、删除与翻译创建,翻译继承不可翻译字段、署名与分类,同 locale 唯一,且全程带重入围栏; - 策略:
hooks.content-policy:register以最小权限提供发布前/定时前/取消发布前的否决权,定时执行会二次校验; - 动作:
content:publish/content:restore强制先读后写、_rev防并发,成功即走完整生产行为链,插件动作全程可溯源。
设计插件时,请始终以"最小能力声明"为原则,按需组合上述能力,并用运行时测试宿主覆盖失败路径——这既是安全边界,也是生产级插件的质量底线。更多相邻能力可继续阅读 hooks.md(钩子管线)与 publishing.md(发布机制)等参考文档。
- CMS
- 后端
- 前端
- 插件系统
【免费下载链接】emdash
EmDash is a full-stack TypeScript CMS based on Astro; the spiritual successor to WordPress
相关推荐
EmDash CMS 插件内容 API 完全指南:schema、翻译、发布策略与修订栅栏
EmDash CMS 插件内容 API 完全指南:schema、翻译、发布策略与修订栅栏 EmDash(基于 Astro 的全栈 TypeScript CMS)
CMS后端前端插件系统Spring IoC 容器初始化源码解析(一):BeanDefinition 的资源定位过程 —— 以 FileSystemXmlApplicationContext 为例
Spring IoC 容器初始化源码解析(一):BeanDefinition 的资源定位过程 —— 以 FileSystemXmlApplicationCont
CMS后端前端插件系统三步接入 Surface Pen 压感:用 windows-rs 让 Rust 应用听懂笔尖轻重
三步接入 Surface Pen 压感:用 windows rs 让 Rust 应用听懂笔尖轻重 在绘画应用里写字,如果笔触粗细永远是固定值,画出来的线条像 P
CMS后端前端插件系统
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考