Ghost Koenig 的 kg-default-transforms:解析编辑器与服务端共享的 Lexical 节点归一化变换
Koenig 是 Ghost 的现代文章编辑器,其文档结构与数据模型构建在 Lexical 之上。为了让「编辑器中允许的结构」与「服务端渲染器能正确处理的结构」保持一致,Ghost 将一组节点归一化逻辑抽成了独立包 @tryghost/kg-default-transforms。本文以 kg-default-transforms/README.md 为骨架,结合其源码、测试与 monorepo 集成方式,系统讲解这批 transform 的职责、注册方式、内部实现与适用场景,读完你既能直接上手调用,也能理解去嵌套、列表合并等规则在粘贴导入与渲染两条路径中各自扮演的角色。
一、包定位:为什么编辑器与服务端需要“共享”变换
先看包自身的声明。在 package.json 中,该包名为 @tryghost/kg-default-transforms(当前版本 1.3.4),描述为 "Shared Lexical node transforms for Ghost's Koenig editor",同时对外暴露 CJS、ESM 与类型声明三套产物(build/cjs、build/esm、types),其 exports 的 source 字段直接指向 ./src/index.ts。
README 第一句即点明其本质定位:
Lexical node transforms shared between the editor and the server, such as denesting and list merging.
也就是说,这套变换不是编辑器专属的内部实现,而是被「编辑器侧」与「服务端侧」共同复用的通用规则。为什么需要这种共享?在 koenig/README.md 的包一览表中给出了消费关系:
| 包 | 说明 |
|---|---|
kg-default-transforms |
Lexical 节点变换(denesting、blockquote children 等),编辑器与服务端共享 |
| 消费方 | koenig-lexical(编辑器)、kg-lexical-html-renderer(服务端渲染)、kg-html-to-lexical(HTML 导入) |
这一消费关系揭示了核心矛盾:HTML 导入(kg-html-to-lexical)与文章 API 的 ?source=html 导入路径本质上是“粘贴 HTML”的同一路径,它们生成的节点树不一定会遵循 Ghost 卡片模型所要求的“顶层结构规范”;而 kg-lexical-html-renderer 渲染的是序列化后的 Lexical state,渲染器同样只针对“规范化后的结构”设计。若中间状态里混入了非法嵌套(如标题套在段落里、图片卡片嵌进列表项),编辑器端会出现选区/删除混乱,服务端则可能“看起来渲染正常实则漏渲或不渲染”。因此这套 transform 的存在目的,就是把两端共享的文档模型约束固化为一组可注册的修正规则。
二、安装与 monorepo 开发环境
以 npm 方式独立安装(外部使用者)
对于不参与 Ghost monorepo 开发、只想复用规则的消费者,README 给出的安装命令是:
npm install @tryghost/kg-default-transforms
值得注意的是,npm 上发布的版本是给“外部消费者”用的。从 koenig/README.md 的 Shipping 说明可以看出,Ghost 自身从不从 npm 安装这些包:
- 开发与 CI 阶段通过
workspace:~依赖解析到本地工作区; - 正式发布归档时,由
ghost/core/scripts/pack.js将各 kg-* 包作为组件 tarball 内嵌,保证部署的 Ghost 与其构建时的版本完全一致,且由于各包files字段仅包含build/,原始src/源码不会随包分发。
在 Ghost 仓库内开发(本仓库视角)
README 的 Develop 一节明确指出该包是 Ghost monorepo(pnpm workspace)的一部分:
- 无需手动 link 或逐包安装;
- 在 monorepo 根目录执行一次
pnpm setup即可让全部依赖解析; - 之后直接在
koenig/kg-default-transforms目录内进行开发; - 构建、测试与发布的共享工作流见 koenig/README.md。
在共享构建细节上(来自 koenig README):kg-* 包被 ghost/core 消费时走 source export 条件指向原始 src/*.ts,因此改动源码后运行中的 Ghost dev server 与 core 的测试可以无需 tsc 重建直接生效;只有类型检查、浏览器构建与生产产物才需要执行 pnpm build。
依赖关系速览
从 package.json 可见其直接依赖:
lexical与@lexical/list、@lexical/rich-text、@lexical/utils(均为 0.13.1);@tryghost/kg-default-nodes(workspace:~ 本地版本),因为 denest 等逻辑需要引用 Ghost 自定义节点(如ExtendedHeadingNode、AtLinkNode);- 开发依赖中包含
@lexical/headless(无 DOM 的 Lexical 环境,供测试创建编辑器实例)与@lexical/link。
三、快速上手:注册默认变换
README 的 Usage 部分给出了一行式核心 API:
const {registerDefaultTransforms} = require('@tryghost/kg-default-transforms');
const teardown = registerDefaultTransforms(editor);
调用后返回一个 teardown 函数,用于反注册它添加的所有 transform。这在 React 组件卸载、编辑器实例销毁等场景中非常有用——只需持有返回值并在适当时候调用,即可彻底清理监听。
同时,包也按名称导出了各个独立 transform,供需要“子集”的场景单独注册。
从源码看,registerDefaultTransforms 的实现位于 default-transforms.ts,它用 Lexical 自带的 mergeRegister 把以下变换合并注册为一个整体:
export function registerDefaultTransforms(editor: LexicalEditor) {
return mergeRegister(
// 1) 剥离不需要的对齐格式
registerRemoveAlignmentTransform(editor, ParagraphNode),
registerRemoveAlignmentTransform(editor, HeadingNode),
registerRemoveAlignmentTransform(editor, ExtendedHeadingNode),
registerRemoveAlignmentTransform(editor, QuoteNode),
// 2) 修正节点非法嵌套
registerDenestTransform(editor, ParagraphNode, () => $createParagraphNode()),
registerDenestTransform(editor, HeadingNode, (node) => $createHeadingNode(node.getTag())),
registerDenestTransform(editor, ExtendedHeadingNode, (node: ExtendedHeadingNode) =>
$createHeadingNode(node.getTag()),
),
registerDenestTransform(editor, QuoteNode, () => $createQuoteNode()),
registerDenestTransform(editor, ListNode, (node) =>
$createListNode(node.getListType(), node.getStart()),
),
registerDenestTransform(editor, ListItemNode, () => $createListItemNode()),
// 3) 合并相邻的同类型列表
registerMergeListNodesTransform(editor),
);
}
可见“默认集合”由三类工作组成:
- 去除对齐格式(
ParagraphNode/HeadingNode/ExtendedHeadingNode/QuoteNode); - 去嵌套(针对段落、两种标题、引用、列表节点、列表项六种容器);
- 合并相邻同类型列表。
四、逐项剖析:默认集合内的三个 transform
4.1 去除对齐(remove-alignment)
实现位于 transforms/remove-alignment.ts,核心逻辑只有几行:
export function removeAlignmentTransform(node: ElementNode) {
// on element nodes format===text-align in Lexical
if (node.getFormatType() !== '') {
node.setFormat('');
}
}
关键点在于注释中的约定:在 Lexical 中,元素节点(ElementNode)上的 format 语义等同于 CSS 的 text-align。Ghost 的卡片模型并不希望段落/标题/引用自带对齐(对齐属于卡片级样式),因此该变换会把带格式的元素节点格式强制清空为 ''。注册时通过 editor.hasNodes([klass]) 先确认编辑器确实注册了对应节点类型,再调用 editor.registerNodeTransform(klass, ...);若编辑器未包含该类型,则直接返回空操作函数,保证在不支持某节点类型的(如无头渲染)环境下调用安全。
4.2 去嵌套(denest)
这是整个包最复杂、注释也最详尽的变换,实现见 transforms/denest.ts。
为什么需要去嵌套? 源码开头的注释给出了完整背景:通过“粘贴”或 API 的 HTML 导入(二者最终同一条路径)进入编辑器的内容可能产生嵌套的元素节点,Ghost 实际遇到过的情形包括:
- 段落里嵌套了标题(headers inside paragraphs);
- 段落里出现图片装饰节点(image decorator nodes inside paragraphs);
- 列表/列表项里出现图片装饰节点;
- 大段内容被错误地包进段落或标题节点。
由于 Ghost 的卡片型装饰节点只支持顶层存在,这种非法嵌套会让编辑器在选择、删除等交互上“非常混乱”;同时渲染器也并未针对嵌套元素节点设计,可能出现编辑器里看着正常、渲染出来却不对或完全不渲染的情况。
解决策略:检测出非行内子节点后,把它们“拔出”并插入到该节点顶层父节点之后——是移动而非删除,以保证粘贴/导入的内容不丢失。
三条合法性判定(源码中的三个 $is* 助手函数):
$isInvalidListNode:列表只能位于顶层或列表项内,其余位置(如直接嵌在段落/引用中)均非法;$isInvalidListItemNode:列表项只能存在于列表节点内;$isInvalidChildNode:除文本、换行、列表及列表项外,凡是“非行内”元素出现在非顶层即非法(文本/换行节点没有isInline方法,需要特判)。
算法流程(对应 denest.ts):
- 检查当前节点子节点是否存在非法子节点,没有则直接返回;
- 创建一个临时分离的段落节点(temp paragraph)作为容器——这样可避免“每移动一个子节点变换就被再次触发”导致的无限循环;
- 用
createNode按当前节点类型生成一个“承接容器”,遍历子节点:行内子节点留在容器内维持顺序,遇到非法子节点则先把容器整体挂到临时段落再新建一个同类型容器,然后把该非法子节点也挂入临时段落——从而保证原节点顺序; - 找到节点的顶层父节点(用于处理“图片嵌在列表里”这类更深层嵌套,需向上走到仅剩 root 之上的祖先);
- 逆序遍历临时段落的子节点并逐个
parent.insertAfter(child)——由于只能相对父节点后插,逆序插入才能保住原顺序; - 特例处理:若顶层父节点直接位于 root 下且待移动的是 ListItemNode(root 不允许直接挂列表项),则把列表项内容展开为一个段落再插入;
- 最后移除已经清空的原始节点与临时段落。
注册函数 registerDenestTransform 的签名(denest.ts)为:
registerDenestTransform<T extends ElementNode>(
editor: LexicalEditor,
klass: Klass<T>,
createNode: CreateNodeFn<T>, // (originalNode: T) => T,用于重建同类型容器
): () => void
createNode 回调解决了一个实现难点:段落直接 $createParagraphNode() 即可,但标题需要继承原始节点的 tag($createHeadingNode(node.getTag())),列表则需要继承 listType 与 start($createListNode(node.getListType(), node.getStart())),否则拔出来后节点语义会被破坏。
4.3 合并相邻同类型列表(merge-list-nodes)
实现见 transforms/merge-list-nodes.ts,逻辑非常紧凑:
export function mergeListNodesTransform(node: ListNode) {
const nextSibling = node.getNextSibling();
if (
$isListNode(nextSibling) &&
$isListNode(node) &&
nextSibling.getListType() === node.getListType()
) {
node.append(...nextSibling.getChildren());
nextSibling.remove();
}
}
当某个 ListNode 的下一个兄弟节点同为列表且列表类型一致(如都是 bullet,或都是 number)时,把后一个列表的所有子节点搬进前一个列表并移除后节点。这解决了导入/编辑过程中因分段粘贴等原因出现的“两个本可连成一体的列表被割裂”问题。由于注册对象是 ListNode 本身,Lexical 会在每次该节点树变化后自动触发检查,从而实现持续的自动收敛。
五、默认集合之外:registerRemoveAtLinkNodesTransform
README 特别强调了一个“刻意不在默认集合中”的变换:registerRemoveAtLinkNodesTransform——只在渲染(rendering)时才需要。
实现见 transforms/remove-at-link-nodes.ts。背景是编辑器中“搜索站内内部链接”时使用临时 AtLinkNode(@ 前缀形式)做占位,这类节点在最终渲染时必须被清除:
export function removeAtLinkNodesTransform(node: AtLinkNode) {
const prevSibling = node.getPreviousSibling();
const nextSibling = node.getNextSibling();
// AtLink 节点正常情况前后都有空格(除非位于文本开头/结尾)
// 删除时若前一兄弟是文本且以空格结尾,则去掉该空格,避免出现双空格
if (prevSibling) {
if ($isTextNode(prevSibling) && prevSibling.getTextContent().endsWith(' ')) {
prevSibling.setTextContent(prevSibling.getTextContent().slice(0, -1));
}
} else if (nextSibling) {
if ($isTextNode(nextSibling) && nextSibling.getTextContent().startsWith(' ')) {
nextSibling.setTextContent(nextSibling.getTextContent().slice(1));
}
}
node.remove();
}
细节上还处理了空格折叠:AtLinkNode 通常被前后空格包围,直接移除会在文本中留下双空格,因此会顺带修剪紧邻文本节点中多余的一个空格。
为什么不能进默认集合?因为默认集合同时服务于编辑会话——编辑期间用户正在输入 @ 进行站内链接搜索,若默认就移除 AtLink 节点,编辑流程会直接失效。只有进入渲染(如序列化输出、服务端渲染 HTML)时,才需要把仍在文档树中的残留临时节点清理干净。
六、如何验证:单元测试与 coverage
测试位于 test/ 目录,与 src 一一对应:
- test/kg-default-transforms.test.ts(对
registerDefaultTransforms的整体行为) - test/transforms/denest.test.ts(共 1000+ 行,覆盖图片进段落、图片进列表、标题嵌套等多类场景)
test/transforms/merge-list-nodes.test.ts、remove-alignment.test.ts、remove-at-link-nodes.test.ts- 公共测试工具 test/utils.ts
denest 测试的写法很有参考价值:利用 @lexical/headless 创建无 DOM 编辑器(createEditor),构造一个包含非法嵌套的完整 JSON 序列化状态(如 paragraph 里直接挂 image 节点),注册对应 transform 后用 assertTransform(editor, registerTransforms, before, after) 断言输出状态精确等于期望的规范化结果。例如“图片在段落内”的用例期望图:图片被拔到 root 顶层、原来的空段落消失(见 denest.test.ts)。这类“先构造脏状态 → 断言干净状态”的黑盒测试模式,也正是验证变换不丢内容、不乱序的最直接手段。
在命令层面,README 定义了三级测试口径:
| 命令 | 内容 |
|---|---|
pnpm test:unit |
运行单元测试(含覆盖率统计,配置见 package.json 的 NODE_ENV=testing vitest run --coverage) |
pnpm test |
运行全部 test: 前缀脚本(单元 + 类型检查 tsc --noEmit),并执行覆盖率阈值约束 |
pnpm lint |
ESLint 检查 |
此外可执行 pnpm dev(tsc --watch)进行带源码映射的增量编译,方便边改边跑测试。
七、调用上下文:编辑器、渲染器与导入器如何分工
综合 koenig/README.md 与默认集合/独立导出的设计,可以画出清晰的调用分工:
koenig-lexical(编辑器):调用registerDefaultTransforms(editor),在编辑会话中持续保持文档树符合 Ghost 卡片模型(去对齐、去嵌套、合并列表);kg-html-to-lexical(HTML 导入/?source=html):与粘贴共享同一条路径,是非法嵌套结构的主要“生产者”,同样在导入侧应用默认变换以在源头净化;kg-lexical-html-renderer(服务端渲染):除必要的结构归一化外,额外注册默认集合中刻意排除的registerRemoveAtLinkNodesTransform,确保最终输出的前端/邮件 HTML 不会泄漏编辑态的内部链接占位节点。
这也正是包被设计成“默认集合 + 按名导出子集”的原因:不同消费方对同一批规则的需求边界不同,把“是否包含渲染专用清理”作为唯一差异点通过导出面暴露,既避免重复实现,也防止把渲染逻辑误注册进编辑流程。
八、小结
@tryghost/kg-default-transforms 用不到十个短小函数回答了 Ghost 内容模型的一个根本问题:当外界输入的 HTML/粘贴内容与 Lexical 结构约束不一致时,如何在不丢失内容的前提下把节点树收敛回规范形态。无论是 registerDefaultTransforms 的一键注册、各独立 transform 的有选择性引用、registerRemoveAtLinkNodesTransform 只在渲染侧注册的设计,还是每类 register* 函数都带有的 editor.hasNodes 防御式判空,都体现了一套面向“编辑器与服务端共享运行”的健壮约定。若你正要在自己的 Lexical 项目中解决类似的“导入脏结构归一化”问题,这个包既是开箱即用的工具,也是值得逐行阅读的参考实现。
说明:
kg-default-transforms遵循 MIT 协议(Copyright © 2013-2026 Ghost Foundation),完整协议见仓库根目录 LICENSE。该包属于 Koenig(koenig/README.md)编辑器家族中的纯 TypeScript Node 库,与浏览器端渲染隔离、可在服务端安全运行。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00