跳到主要内容

Clepit

概要一览

类别
开发者平台
网站
clepit.com
控制台
app.clepit.com
文档
clepit.com/en/docs
GraphQL 接口
api.clepit.com/graphql
实时通道
ws.clepit.com
MCP 接口
api.clepit.com/mcp
已发布页面
clepit.space

大多数富文本最终以一整块 HTML 存进数据库。只要你不打算拿它做别的事,这没什么问题:可一旦你想找出所有提到某位客户的页面,把同一份文档同时呈现为网页、邮件和手机应用,或者让两个人同时编辑而不丢段落,麻烦就来了。到那时文字和它的排版已经缠在一起,唯一还能可靠读懂这份文档的,只剩下当初写下它的那个编辑器。

Clepit 把两者分开。一个页面就是一串区块,每个区块都是一个带类型的小对象,而文档本身就是 JSON:没有需要解析的标记,也不需要编辑器才能读懂。这也正是这个编辑器能独立存在的原因。@clepit/core 以 MIT 许可发布在 npm 上,对托管的那一半一无所知;而 app.clepit.com 上的工作区,则是同样这些文档在获得协作者、权限、历史记录和一个公开网址之后的样子。

一个页面就是一串区块

一个区块有四个字段:一个 id、一个类型、该类型所定义的数据,以及施加于它的调节项。段落的数据和表格的数据形状不同,而类型正是告诉你该期待哪种形状的东西,于是存下来的文档可以被校验,而不只是被解析。整个页面就是一个时间戳、一个版本号,加上按顺序排列的区块,小到可以用肉眼读完,也可以在一个 pull request 里逐行审阅。其中没有任何一处描述这个页面该长什么样:那是绘制它的一方的事。正因如此,同一份文档才能变成网页、邮件和手机屏幕,而不必存在三份副本。

在没有浏览器的地方绘制文档

渲染器从不直接写入浏览器。它通过一层很薄的中间层来绘制,这层背后有两种支撑:一种构建真实的页面节点,另一种构建字符串,而同一份区块代码在两者上都能运行。已发布的页面正是这样在一台根本没有浏览器的服务器上被绘制出来的,也正因如此,服务器产出的就是编辑器本会呈现的那份文档,而不是另一套迟早会跑偏的实现。字符串支撑要求把清洗函数作为必填参数传入,并且刻意不设默认值:这个包平常用的清洗函数需要先构建一个页面才能解析,在那里根本跑不起来;而悄悄退回到转义,则会一声不吭地剥掉每份文档的行内格式。于是这个缺口被留在调用处显眼的地方,而不是藏进一个默认值里。

阅读不需要读者付出任何 JavaScript

React 适配器把两半分开发布,因为它们要的恰好相反。内容组件在服务器上运行,在页面被构建的同时输出成品标记,于是读者在第一次响应里就拿到了整份文档。编辑器组件则只在客户端运行,因为它掌管编辑器的生命周期,而在浏览器出现之前根本没有什么可掌管。因此,读一份 Clepit 文档完全不需要 JavaScript。要写,才是运行时登场的时候。

一个页面能装下什么

包里自带二十六种区块。多数是任何编辑器都需要的:标题、段落、列表、清单、引用、代码、表格、图片、音频、视频、文件、提示框和分隔线。其余那些之所以存在,是因为写文档会用到写作工具通常忽略的东西。一个目录区块,自己从文档的标题生成,并链接到每一处。可折叠段落与分栏。一张代替另一个页面出现的卡片。一幅手绘草图。还有一个动态区块,它存的是要盯着哪份文档,而不是那份文档动态的副本,因此它一直显示此刻正在发生的事,而不会停在被插入的那一天。区块内部的格式涵盖常见的几种标记:粗体、斜体、下划线、删除线、行内代码、高亮和链接,另外还有提示气泡、状态标签和提及。这个包本身没有任何运行时依赖。

公式与图表,在包内绘制

其中两个区块负责呈现 LaTeX 和 Mermaid,而且两者都把整件事做在包内:解析源码、算出版面、画出结果。底下没有任何绘图库,也不会为了把一条公式或一张流程图变成图像而去调用某个服务。与其说这是对依赖的偏好,不如说这是字符串支撑带来的结果。一个伸手去要浏览器专有能力、或是伸手去要网络的区块,根本无法在那台负责提供已发布页面的服务器上被绘制出来;到那时,同一个页面会因为谁来请求而显示得不一样。

住在页面里的 API 参考

给 OpenAPI 区块一份规范,粘贴进去或者给出地址,它就会把规范所描述的东西画出来:各个操作、它们的路径和参数、请求与响应的结构,以及认证方式。它还会为每个操作生成一段请求示例代码,涵盖 cURL、TypeScript、Dart 和 Python,这些都是从规范生成的,而不是由某位迟早会忘记更新的作者手打出来的。嵌入区块看待外部世界的方式也是一样的:它只认得少数几个自己确实能呈现的服务,对其余一切,把一个普通链接当作正当的结果而不是失败,而对于自己不信任的地址,则干脆拒绝渲染。

两个人写在同一个段落里

一个正在被实时编辑的页面,由服务器上的单个任务持有,每页一个,所有更新都按顺序经过它。这正是并发编辑之所以还能被讲清楚的原因:不存在第二个写入者去和第一个抢。文档本身是一个 CRDT,因此两个人在同一段落里打字会合并而不是互相覆盖,落后的客户端则通过交换双方各自缺少的部分追上进度。每一次更新在广播给任何人之前,都会先追加到预写日志里,所以文档中其他人看到的内容早已被持久记录,而不只是被转发。权限在服务器上执行,而不是在界面里:没有编辑权限的连接方加入后会被降级为只读,而且只要文档还开着,会话就会周期性地重新核查这项权限,于是被收回的权限会落在一个正在打字的人身上,而不必等他重新加载页面。

每一次写入都走同一道门

一份文档既可能被正在里面打字的人改动,也可能被调用 API 的程序改动,而这两条路径过去可以各自独立地写同一个页面。现在不行了。来自 API 的写入会被转交给持有实时文档的那个会话,在那里与实时编辑一起作为同一个事务被应用,于是只存在一条事件顺序,而不是两个写入者对页面内容各执一词。文档写回时会保留区块 id,因为评论正锚定在它们上面,一次重新生成 id 的对账会让每一条评论都指向空处。而当算出来的区块集合与已经存下来的完全相同时,就什么也不写。

机器密钥唯一到不了的通道

个人 API 密钥在 REST、GraphQL、GraphQL 订阅套接字和 MCP 上都可用。它在协作套接字上不可用,这是有意为之。每一次协作更新都会盖上做出这次改动的人,而这些印记会成为页面历史中记录下来的著作权。一个机器身份在那里编辑,就会写下没有任何人写过的作者;日后要撤销它,意味着改写历史,而不是删掉一行。分界线并不在 WebSocket 与 HTTP 之间,而在于这条通道是否写入带署名的历史。这条规则由代码的形状来保证,而不是靠人记住:要接受密钥,必须刻意切换到另一个认证调用,而一旦某条通道这么做了,就会有一个测试失败。

拥有自己地址的工作区

每个工作区都是一个租户,从创建的那一刻起就拥有自己的子域名,而租户是从请求抵达的那个地址解析出来的。因此,你身处哪个租户,是在读取你的任何数据之前就已决定的,而不是事后加上、可能被谁忘掉的一道过滤。再往下,数据库通过行级安全自己守住这条边界:每个请求取出一条连接,把调用者的身份盖在上面,连接归还时连接池会抹掉那份状态,于是一个请求的身份无法渗进下一个请求的查询里。

声明一个域名,不等于证明它是你的

企业方案下的工作区可以用自己的域名来提供页面。声明一个域名与真正从它提供服务,被刻意分成两步:域名先以未验证的状态存下来,而解析器会完全无视它,直到 DNS 里出现那条验证记录为止。任何人都能在表单里敲进另一家公司的地址。但只有真正掌控那个域名的人,才能发布让它生效的那条记录。

这个页面经历过的每一版

Clepit 保留的是版本,而不是一个单一的当前状态。人们工作时会自动生成快照,并加以节制,免得普通打字就造出几百个:过了十分钟,或者有十个区块发生变化,先到者为准,就写下新的一份。恢复是一次事务:应用旧快照、对齐区块,并把这次恢复本身写成一个新版本,于是回退是被记录下来的,而不是悄无声息地被抹掉。对齐时会刻意保留来源的区块 id,因为评论锚定在区块上,用全新的 id 恢复一个页面,会让它上面的每一条评论都失去锚点。

把它重新找出来

搜索跑在页面的一个投影上,查询会交给 Postgres 自己的网页搜索解析器,而不是靠手工拼接 SQL,因此人们可以照常敲引号和减号,而这些都不构成注入面。但对一个共享工作区来说,真正要紧的是权限检查落在哪里。搜索会连接 pages 表,而那张表的行级安全就作用在这次连接上,于是结果一开始就被限制在提问者有权看到的页面里。至于过滤条件,页面树的某个子树、最后由谁修订、最后何时编辑,都是叠加在其上的额外条件。它们每一个都只会收窄,没有一个能放宽,因为它们全都待在同一道检查的后面。

发布会冻结,分享不会

这是两件不同的事,Clepit 也刻意用不同的方式对待它们。发布一个页面,会把当下的文档冻结成一个版本,让页面指向它,并将其公开:访客在 clepit.space 上、通过形如 acme.clepit.space/handbook 的地址读到的,正是那个被冻住的版本,而不是之后所做的编辑。取消发布会清掉这些指针,却保留那个公开地址,因此日后重新发布会回到同一个网址,而不是把指向它的每一条链接都弄断。分享链接则恰恰相反:它提供的是活的文档,收到链接的人看到的内容会随页面一起变化。

一条你可以收回的链接

分享链接是一枚你可以吊销的令牌,铸出来的时候还能给它设一个到期时间。库里只存这枚令牌的哈希,所以链接在创建那一刻只显示一次,之后再也无法从数据库里还原出来,我们不行,任何拿到那份数据的人也不行。这类链接授予的是阅读,而不是评论:评论需要一位作者,而持有链接的人并不是作者。

从你自己的目录登录

一个工作区可以把认证交给自己的身份提供方:商业版用 OpenID Connect,企业版用 SAML,并配以 SCIM 做目录同步。SCIM 覆盖的正是 Okta 和 Entra 实际会驱动的那个用户资源,并且在标准最直白的读法上做了一处有意的偏离:删除是把成员停用,而不是抹掉。规范允许这样做,而另一种做法,是让一次目录同步仅仅因为把某人移出了某个组,就能毁掉一个工作区的内容。

和它对话的四种方式

REST 覆盖 /v1 下的四十四个操作,描述它们的是一份从路由本身生成的 OpenAPI 文档,而不是写在旁边的说明;持续集成会把生成结果与仓库里那份对照,因此绕过注册表的路由改动无法悄悄落地。GraphQL 覆盖应用模型,并在自己的套接字上承载订阅。实时 是一个独立进程,所以网关重启不会把请求面一起拖下水;而且它按套接字授权,而不是按房间:每有一个事件,租户内的每条连接都会在一次批量检查中被评估,只有被允许看到的那些才会收到。MCP 把同样这些操作作为工具开放给 AI 代理,并且没有任何工具会相信调用方给出的 id 来决定自己正在哪个工作区里操作;写入走的是界面所用的同一批服务,因此权限检查和审计记录也是同一套。

适用人群

适合需要自主掌控编辑器的团队,以及要在自家产品中嵌入结构化内容的开发者。

访问 Clepit: clepit.com