Ghost Koenig kg-default-nodes 全解析:卡片 Lexical 节点定义与统一 HTML 渲染的事实源
kg-default-nodes(@tryghost/kg-default-nodes)是 Ghost Koenig 编辑器体系中所有卡片(Card)的 Lexical 节点定义包,同时为每个节点提供了 HTML 渲染器。它把"编辑器侧编辑数据"与"服务器侧渲染正文"统一到同一套节点实现上,是 Ghost 帖子正文(Editor → 存储 → 渲染)全链路的事实源(single source of truth)。读完本文,你将掌握它的安装集成方式、DEFAULT_NODES / DEFAULT_CONFIG 的用法、卡片节点的声明式定义机制,以及它在编辑器与服务器两侧的底层调用链路。
包定位:节点定义与渲染的双重职责
在 kg-default-nodes/README.md 中,这个包被定义为:Ghost 全部卡片的 Lexical 节点定义 + 每个节点各自的 HTML renderer。它有两个关键设计约束:
- 单一事实源:编辑器(前端)和服务器(后端)在渲染正文时都经过这一套节点,避免前后端各写一套渲染逻辑导致的不一致。
- 必须保持浏览器安全(browser-safe):该包既运行在编辑器内部(浏览器环境),也会运行在服务器上。因此源码中不能引入依赖 DOM/BOM 全局对象、不能在模块顶层访问
window/document的代码。例如 export-dom.ts 里dom选项就是通过{window: {document: Document}}这样可注入的形式访问文档对象的。
从源码目录结构可以更直观地看到它的职责分层(见 src 目录):
nodes/:每个卡片一个子目录,如image/、callout/、signup/、email-cta/等,内含节点类、*-parser.ts(HTML → 节点)与*-renderer.ts(节点 → HTML);serializers/:粘贴/导入 HTML 时把基础标签映射到节点的序列化器(linebreak.ts、paragraph.ts);KoenigDecoratorNode.ts:所有卡片节点共用的基类与卡片契约;generate-decorator-node.ts:批量生成 Decorator 节点类的工厂函数;visibility.ts:会员可见性(Visibility)的旧/新格式解析与迁移工具。
安装与工程环境
作为独立 npm 包,安装命令为:
npm install @tryghost/kg-default-nodes
但在 Ghost monorepo 内部开发时并不需要单独安装——它通过 pnpm workspace 解析。开发步骤为:先在仓库根目录执行 pnpm setup,然后直接在 koenig/kg-default-nodes 目录内工作即可(参见 kg-default-nodes/README.md 的 "Develop" 一节)。相关共享的构建、测试、发布流程统一在 Koenig README 中说明。
从 package.json 可以看到这个包的工程细节:
- 双模块产物:
main指向build/cjs/index.js(CommonJS),module与types指向build/esm/index.js与build/esm/index.d.ts,exports字段中还对./visibility做了子路径导出; - Node 版本要求:
engines声明为^22.22.2 || ^24.15.0; - 关键依赖:Lexical 生态固定为
0.13.1(lexical、@lexical/clipboard、@lexical/rich-text等),并通过workspace:~依赖同一仓库中的@tryghost/kg-clean-basic-html、@tryghost/kg-markdown-html-renderer; - 发布内容:
files只包含LICENSE、README.md与build目录。
核心用法:DEFAULT_NODES 与 DEFAULT_CONFIG
README 给出了最小集成示例:
const {createEditor} = require('lexical');
const {DEFAULT_NODES, DEFAULT_CONFIG} = require('@tryghost/kg-default-nodes');
const editor = createEditor({
nodes: DEFAULT_NODES,
html: DEFAULT_CONFIG.html
});
DEFAULT_NODES:开箱即用的卡片节点集合
DEFAULT_NODES 是一个完整的节点类数组,覆盖了 Ghost 自有卡片。在 kg-default-nodes.ts 中可以看到它包含:
- 基础扩展节点:
ExtendedTextNode、ExtendedHeadingNode、ExtendedQuoteNode(连同各自的Replacement变体,用于替换 Lexical 默认节点以支持 Koenig 的额外行为); - 内容与媒体卡片:
ImageNode、VideoNode、AudioNode、GalleryNode、BookmarkNode、FileNode、CodeBlockNode、MarkdownNode、EmbedNode、HtmlNode、HorizontalRuleNode; - 会员转化卡片:
SignupNode、EmailCtaNode、EmailNode、CalloutNode、CallToActionNode、ProductNode、ButtonNode、ToggleNode、HeaderNode、AsideNode、PaywallNode、TransistorNode; - 内部辅助节点:
TKNode(占位)、AtLinkNode/AtLinkSearchNode(@ 提及)、ZWNJNode(零宽不连字符处理)。
需要强调的是,DEFAULT_NODES 只覆盖 Ghost 自有卡片。它不包含基础的 ParagraphNode、TextNode、HeadingNode 等标准节点,因此 README 提醒:要把它与你的内容真正需要的 Lexical 基础节点搭配使用。
DEFAULT_CONFIG.html:粘贴内容导入的序列化器
DEFAULT_CONFIG.html 提供的是导入侧(import)的序列化器,作用是让用户粘贴进来的 HTML 能被正确映射到对应节点。其构成见 kg-default-nodes.ts:
export const serializers = {
linebreak: linebreakSerializers,
paragraph: paragraphSerializers
};
export const DEFAULT_CONFIG = {
html: {
import: {
...serializers.linebreak.import,
...serializers.paragraph.import
}
}
};
即把 serializers/linebreak.ts 与 serializers/paragraph.ts 中的 import 序列化器合并而成。这些序列化器往往针对真实场景做了细致处理,例如 paragraph.ts 会识别 Google Docs 用 docs-internal-guid- 包裹的空段落并直接丢弃,避免把 Google Docs 复制内容转成编辑器里一堆空段落:
import: {
p: (node) => {
const isGoogleDocs = !!node.closest('[id^="docs-internal-guid-"]');
if (isGoogleDocs && node.textContent === '') {
return {conversion: () => null, priority: 1};
}
return null;
}
}
按需引入单个节点
并不是所有场景都需要全集。当只需要部分节点时,包会按名称逐个导出——ImageNode、CalloutNode、SignupNode 等都可以单独 import(导出清单见 kg-default-nodes.ts)。同时每个节点通常还配有 $createXxxNode 与 $isXxxNode 的 type guard 辅助函数,例如 ImageNode.ts 中的 $createImageNode(dataset) 与 $isImageNode(node)。
其它同包导出:utils 与 visibility
从 kg-default-nodes.ts 可以看到包还聚合导出了若干实用工具(utils 对象):generateDecoratorNode、visibility、rgbToHex、taggedTemplateFns、replacementStrings,方便其它包与测试复用。此外 card-widths.ts 也随包导出,定义了卡片宽度枚举 ['regular', 'wide', 'full'] 及 isCardWidth / normalizeCardWidth 校验函数(见 card-widths.ts)。
会员可见性工具还可通过子路径 @tryghost/kg-default-nodes/visibility 单独引入(package.json),其中定义了各会员分段的 NQL 常量与格式迁移逻辑(详见 visibility.ts):
ALL_MEMBERS_SEGMENT = 'status:free,status:-free'(全部会员)PAID_MEMBERS_SEGMENT = 'status:-free'(付费会员,含 comped 与 gift)FREE_MEMBERS_SEGMENT = 'status:free'(免费会员)NO_MEMBERS_SEGMENT = ''(无会员限制)
源码级深入:卡片节点如何被"声明式"定义
几乎全部卡片节点都由 generateDecoratorNode 工厂生成,而不是手写一个完整的 Lexical DecoratorNode 子类。这在 generate-decorator-node.ts 中实现,其核心选项包括:
| 选项 | 说明 |
|---|---|
nodeType |
节点的唯一类型名(如 image、callout),运行时校验不可为空 |
properties |
节点数据字段声明,每个属性需含 default;urlType 只能是 url/html/markdown;wordCount 用于标记是否计入字数统计 |
version |
序列化数据结构版本,默认 1 |
hasVisibility |
是否在节点上加入会员可见性(visibility)属性 |
defaultRenderFn |
默认渲染函数,返回与 @tryghost/kg-lexical-html-renderer 兼容的对象(形如 {element: ..., type: 'inner'/'outer'/'value'/'html'}) |
以最基础的 ImageNode.ts 为例,属性声明如下:
const imageProperties = {
src: {default: '', urlType: 'url'},
caption: {default: '', urlType: 'html', wordCount: true},
title: {default: ''},
alt: {default: ''},
cardWidth: {default: 'regular' as CardWidth},
width: {default: null as number | null},
height: {default: null as number | null},
href: {default: '', urlType: 'url'}
};
export class ImageNode extends generateDecoratorNode({
nodeType: 'image',
properties: imageProperties,
defaultRenderFn: renderImageNode
}) {
/* @override */
exportJSON() {
// src 为 data: base64 时输出占位符 <base64String>,避免巨型数据被序列化入库
...
}
static importDOM() {
return parseImageNode(this); // 来自 image-parser.ts
}
}
generateDecoratorNode 在生成类之前还会做参数校验(validateArguments):nodeType 必须唯一存在、每个属性必须带 default、urlType 必须是三种合法值之一、wordCount 必须是布尔值,任何不满足都会在开发期直接抛出带 [generateDecoratorNode] 前缀的错误,提前暴露问题(见 generate-decorator-node.ts)。
卡片契约:KoenigDecoratorNode 与 $isKoenigCard
所有生成/手写的卡片节点都继承自 KoenigDecoratorNode.ts 中的 KoenigDecoratorNode(它自身继承 Lexical 的 DecoratorNode)。KoeningCard 类型定义了一套统一的卡片接口,包含:
isKoenigCard(): true与hasEditMode():标记是否为 Koenig 卡片、是否支持编辑模式;exportDOM(editor, options):把节点导出为 DOM,即渲染入口;getDataset():返回节点的数据集合;hasDynamicData()与getDynamicData?():判断/获取需要异步拉取的动态数据(如 embed 的预览信息);getIsVisibilityActive():判断当前可见性是否激活。
配套的 $isKoenigCard(node) 类型守卫会逐项检查这些方法是否存在且返回正确,从而把"任意 Lexical 节点"安全地收窄为"Koenig 卡片"。
渲染选项:export-dom 的类型骨架
export-dom.ts 定义了渲染相关的全部类型骨架,也是理解渲染上下文(render options)的关键:
ExportDOMOutputType:'inner' | 'outer' | 'value' | 'html',表示渲染结果以何种形式挂回 DOM;ExportDOMNodeRenderers:渲染器可以是单个函数,也可以是按版本(Record<string|number, renderer>)组织的版本化渲染器——这解释了为什么 Header 等迭代过多个版本的卡片能同时支持 v1/v2 的差异化渲染;ExportDOMOptionsBase:服务器渲染时最常用的字段有target(渲染目标,如 email/web)、postUrl、siteUrl、siteUuid、imageBaseUrl、imageOptimization、canTransformImage、canTransformImageToFormat、feature、design等,且支持[key: string]: unknown的开放扩展。
实际落地:编辑器与服务器如何共用这一套节点
"同一套节点既在编辑器跑、也在服务器跑"不是一句口号,而是有明确的调用证据。
服务器侧:ghost/core/core/server/lib/lexical.js 中通过 require('@tryghost/kg-default-nodes') 取出 DEFAULT_NODES 注入 LexicalHTMLRenderer(来自 @tryghost/kg-lexical-html-renderer),从而把帖子正文序列化数据渲染为最终 HTML。同一文件中的 buildRenderOptions 还负责构造上面提到的渲染选项——把 site_uuid、站点 url、图片 base URL、imageOptimization 配置,以及 canTransformImage 之类的适配器能力注入渲染流程(见 lexical.js),这与 ExportDOMOptionsBase 的字段一一对应。
编辑器侧:在 Admin 前端中,Koenig 编辑器通过 koenig-loader / koenig-editor-base.tsx(见 apps/admin/src/settings/components 相关组件)加载节点配置并以 DEFAULT_NODES 作为默认节点集合,用户每次对卡片的编辑、预览、拷贝粘贴都会实时走到相同的节点类与 renderer 上。
这意味着:你在一篇文章里插入的图片、会员专享内容(paywall)、Signup/Email 卡片等,无论在编辑器的实时预览里,还是在服务器最终生成网页/邮件时,渲染行为都由 koenig/kg-default-nodes/src/nodes/* 中同一份代码决定,不会出现"所见"与"所得"两套实现漂移的问题。
开发与测试
在 monorepo 内开发该包时,常用命令(源自 package.json 的 scripts,与 README 描述一致):
| 命令 | 作用 |
|---|---|
pnpm dev |
tsc --watch 监听编译,开发时持续产出构建产物 |
pnpm build |
清理后编译 ESM 与 CJS 双产物,并为 ESM 目录写入 {"type":"module"} |
pnpm test:unit |
以 NODE_ENV=testing 运行 Vitest 单元测试,并开启覆盖率统计 |
pnpm test:types |
tsc --noEmit 运行类型测试 |
pnpm test |
依次运行 test:unit 与 test:types(含覆盖率阈值) |
pnpm lint |
分别对 src/ 与 test/ 执行 ESLint |
测试组织与节点结构一一对应:test/renderers/ 下每个卡片都有一个渲染器测试,例如 image-renderer.test.ts、callout-renderer.test.ts、header-v1-renderer.test.ts、header-v2-renderer.test.ts 等(见 test/renderers),它们逐个验证节点 exportDOM 产出的 HTML 是否符合预期;另有 test/nodes/、test/serializers/、test/utils/ 等目录分别覆盖节点数据逻辑、HTML 导入序列化与可见性工具,visibility-smoke.test.ts 则对可见性工具链做冒烟验证。新增一个卡片时,惯例就是同时补齐对应节点的 parser、renderer 与渲染测试。
结语:把"一次定义"贯穿全链路的工程设计
kg-default-nodes 是理解 Ghost 内容体系的一个高质量切入点:卡片节点通过 generateDecoratorNode 声明式生成,属性、版本、可见性与渲染函数高度收敛;DEFAULT_NODES + DEFAULT_CONFIG 让编辑器与服务器共用同一定义与序列化逻辑,从源头避免前后端渲染分裂;visibility、card-widths、版本化渲染器等导出则把会员功能、版式宽度、历史格式兼容这些真实业务需求沉淀为可复用工具。整个包采用 MIT 许可(见仓库根目录 LICENSE),并随 Ghost monorepo 的 pnpm workspace 统一构建与发布。
如果你正打算深入 Ghost 的编辑器内核、扩展自己的 Koenig 卡片,或研究"一套节点模型同时驱动编辑器与服务器渲染"的工程实践,从 koenig/kg-default-nodes/src 开始阅读是最高效的路径。
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