Plate UI 的 Shadcn Proofing:让 registry 组件保持可识别的开源代码形态
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
Plate 是一个基于 Slate 的富文本编辑器,其官网(apps/www)通过 shadcn 风格的组件注册表对外发布编辑器的节点渲染器与工具栏 UI。shadcn-proofing.md是 Plate 团队为自己定下的"组件质检"规则:当你在 Plate 中编写或重构一个从 shadcn 衍生出来的组件时,如何确保它仍然是用户一眼可认、可拷贝、可 diff、可二次拥有的"开源代码",而不是被层层抽象包裹、无法与上游对照的框架胶水。读完本文,你将掌握 Plate 组件体系中的三条 proofing 规则、判断代码该留在组件文件还是抽到 package 的完整标准,以及这些规则在 registry 真实代码(footnote kit、media/TOC/equation 节点)中的落地方式。
一、Shadcn Proofing 是什么:文档定位与仓库上下文
shadcn-proofing.md位于 Plate 的.agents/skills/plate-ui/rules/目录下,是plate-ui技能(见 SKILL.md)中的"Shadcn Proofing"规则条目。该技能自述为"仓库专属的 shadcn 配套技能":通用的 shadcn CLI、上游文档和通用规则走shadcn技能,而 Plate 特有的组件编写规范——开源代码保持(open-code preservation)、包抽取边界(package extraction boundaries)、base/live kit 拆分、跨平台分层与 registry 接线——则由plate-ui技能管辖。
原文档只有简短的三节,但每一节都是一条硬约束,全文结构如下:
- Preserve recognizable open code(保持可识别的开源代码)
- Prefer readable files over abstraction churn(可读的文件优先于抽象搅动)
- Review like an upstream diff(像审查上游 diff 一样审查代码)
这三条规则共同服务于plate-ui技能声明的第一原则:"Preserve open code.A shadcn-derived component should still look like source code a user can own, read, diff, and tweak."(保留开源代码:shadcn 衍生的组件看起来仍应该是用户可以拥有、阅读、diff 和微调的源码。)
理解这条规则的前提,是理解 Plate 的四个代码面(Repo Surfaces),原文档虽未逐一展开,但plate-ui技能给出了完整清单:
| 代码面 | 路径 | 角色 |
|---|---|---|
| 组件与节点渲染器 | apps/www/src/registry/ui | 活的(live)组件源码,即本文讨论的主体 |
| base/live kit 接线 | apps/www/src/registry/components/editor/plugins | 插件与组件的绑定装配 |
| registry 元数据与依赖 | apps/www/src/registry/registry-*.ts | 注册表条目、registryDependencies |
| 持久化包 | packages/* | transforms、queries、controllers 与公开 hooks |
Plate 的上游基线同样在仓库内可见:registry-shadcn.json 是上游 shadcn/ui(new-york-v4 风格)的注册表定义,列出了 accordion、button、popover、sidebar 等组件的依赖与文件路径;components.json 则是应用侧的接入配置。所谓"像审查上游 diff 一样审查",正是以这些上游文件为参照系。
二、规则一:Preserve recognizable open code(保持可识别的开源代码)
原文档给出了一条总纲加四个检查点:
Keep the component source close to normal shadcn expectations(让组件源码贴近常规 shadcn 预期):
- clear local composition(清晰的局部组合)
- obvious
asChild/data-slot/data-state(显眼的 Radix 组合与状态约定)- variants and classes near the JSX that uses them(变体与类名放在使用它们的 JSX 附近)
- no abstraction maze for simple UI(简单 UI 不做抽象迷宫)
逐条展开:
1. clear local composition(清晰的局部组合)。shadcn 组件的典型形态是:一个文件内导出XxxRoot与若干XxxItem子组件,子组件通过data-slot属性与父组件协作,文件之间几乎无隐式依赖。Plate 要求编辑器侧的组件(如 media-image-node.tsx、equation-node.tsx)也保持这种形态——组合逻辑写在文件内部,读者不需要跳转三层 helper 才能理解"这个 popover 什么时候打开"。
2. obviousasChild/data-slot/data-state。这三个是 Radix/shadcn 体系的"方言标记":asChild让组件把行为委托给子元素而不额外产生 DOM 节点;data-slot用于在组合内部识别部件;data-state把状态暴露到 DOM 以便 CSS 选择器生效。shadcn-proofing.md把它们并列为"必须显眼存在"的要素,因为它们是上游代码可识别性的指纹——一旦这些约定被包 hooks 吞掉,用户就无法把 Plate 组件与上游 shadcn 源码对上号。
3. variants and classes near the JSX that uses them(变体与类名就近)。使用class-variance-authority(cva)定义 variants 时,variant函数应当与消费它的 JSX 在同一文件、同一视线范围内。把variant拆到独立工具文件、再让 JSX 通过多层转发引用,会让"哪个类名决定这个按钮的样式"变得不可追溯——这正是"抽象迷宫"。
4. no abstraction maze for simple UI(简单 UI 不做抽象迷宫)。这条是对前三条的反向兜底:如果一个工具栏按钮只需要读一个状态、发一个命令,就不值得为它发明一套子组件体系。
从仓库结构看,这条规则的落点非常具体:apps/www/src/registry/ui下的节点渲染器文件本身就是被分发的"开源代码",用户通过 registry 安装后会直接持有这些文件的所有权。因此它们的可读性不是风格偏好,而是产品契约。
三、规则二:Prefer readable files over abstraction churn(可读的文件优先于抽象搅动)
原文档的完整表述是:
A component file is allowed to be a little long if the alternative is hiding everything behind package hooks and helper wrappers.(如果替代方案是把一切都藏到 package hooks 和 helper 包装器后面,那么组件文件被允许稍微长一点。)
Long but readable open code beats "clean" indirection that nobody can diff against upstream.(长但可读的开源代码,胜过没人能拿去和上游 diff 的"干净"间接层。)
这条规则直接回答了一个常见的工程纠结:"文件太长了,抽个 hook 吧。"Plate 的答案是:长度本身不是罪,把语义藏进不可 diff 的间接层才是罪。
plate-ui体系为这个判断提供了量化工具。与 proofing 规则同目录的 ownership.md 给出了"包抽取气味测试"(smell test):当 hook 的返回值大部分是以下类型时,不要抽取——
- labels(文案)
- 只被一个组件使用的 booleans
- class decisions(类名决策)
- 一个组件的 menu items
- 一个组件的 event handlers
如果 hook 的名字实际上等价于"这个渲染器的私有状态",就把它留在组件文件里。ownership.md同时明确列举了两个"坏的抽取理由":"the file feels long"(文件感觉太长了)和"the types are annoying"(类型写起来烦人)。这两条理由恰好是抽象搅动(abstraction churn)最典型的动机来源——它们优化的是维护者的舒适区,而不是用户的可 diff 性。
SKILL.md 则把它升级为一组可勾选的"抽取测试"(Extraction Test)——满足以下任一条才抽到 package:
- 代码拥有文档语义、序列化、transforms 或导航契约;
- 多个 UI 面或多个平台需要同一个行为契约;
- 代码是稳定的 controller/hook,其输出不绑定某一个 shadcn 组件的标记结构;
- 否则同一逻辑会在多个包或适配器间重复;
- 未来的 native 消费者有可能复用同一份概念契约。
而只要命中以下任一条,就留在本地:
- 代码只服务于一个组件;
- 返回形状主要是 labels、JSX 接线、类名决策或 popover/menu 状态;
- 抽取的主要动机是"文件感觉太长"或"类型写起来烦";
- 抽取会向用户隐藏开源代码结构;
- 抽象只在 React/web 下有意义,没有合理的 native 对应物。
注意第 4 条——它与 proofing 文档完全同源:即使通过了语义测试,只要抽取损害了可识别性,也要撤回。可 diff 性在 Plate 的决策顺序里,优先级高于模块化洁癖。
四、规则三:Review like an upstream diff(像审查上游 diff 一样审查)
原文档的最后一节给出三个自问和一个裁决:
在抽取之前,问自己:
- would a user still recognize this as open source component code?(用户还会把它认作开源组件代码吗?)
- can they copy, tweak, and own it easily?(他们能轻松地拷贝、微调并拥有它吗?)
- did we move semantics, or just move clutter?(我们移动的是语义,还是仅仅挪动了杂物?)
If the answer is "we mostly moved clutter," put it back.(如果答案是"我们主要是在挪杂物",就把它放回去。)
这三个问题构成一个完整的验收流程:第一个问题检验"可识别性"(规则一的落点),第二个问题检验"可拥有性"(代码是否仍能以文件为单位被用户带走),第三个问题做归因——把"抽出去的东西"按"语义(semantics)"与"杂物(clutter)"分类。只有当抽出去的是语义时,重构才创造了价值;当抽出去的主要是杂物时,重构实际上是把开源代码的"表"拆散了,裁决是明确且不容商量的:put it back(放回去)。
结合 ownership.md 中的正反例,"语义 vs 杂物"的边界可以具体化:
// 错误:package hook 只被一个组件使用,且返回值大部分是 UI 胶水 const state = useSingleComponentOnlyState(); // 正确:package 拥有稳定语义,app 拥有局部组合 const { activeContentId, headingList } = useTocElementState(); return headingList.map((item) => ( <Button key={item.id}>{item.title}</Button> ));plate-ui技能中的 Key Patterns 给出了同一思想的四个"好/坏"对照,其中两个坏例值得特别记诵:
// Bad: package hook 只为了喂一个 shadcn 组件的局部 UI const state = useSingleComponentOnlyState(); return <Popover open={state.open}>...</Popover>; // Bad: 返回渲染器胶水的 React-only package hook const { dialogTitle, menuItems, onOpenChange, popoverOpen, } = useToolbarMenuState();后一个坏例揭示了一个更微妙的陷阱:dialogTitle、menuItems、onOpenChange、popoverOpen这样的返回形状,乍看像"状态管理",实质是一个组件的 UI 状态集合——它既不可 diff 回上游 shadcn,也无法被 native 层复用(没有合理的 native 对应物),因此同时命中抽取测试的本地化条款 2、4、5。
五、规则在 Plate registry 真实代码中的落地
证明一套 proofing 规则是否可信,最好的方式是看它管辖的真实产物。以下两个案例均来自 component-audit.md 列出的"本仓库最强模式"。
5.1 base/live kit 拆分:footnote 组件
plate-ui技能要求"干净地拆分 static/base 与 live kits"。footnote 的两个 kit 文件是这一要求的标准示范。
base kit(footnote-base-kit.tsx)绑定的是静态渲染器:
import { BaseFootnoteDefinitionPlugin, BaseFootnoteReferencePlugin, } from '@platejs/footnote'; import { FootnoteDefinitionElementStatic, FootnoteReferenceElementStatic, } from '@/registry/ui/footnote-node-static'; export const BaseFootnoteKit = [ BaseFootnoteReferencePlugin.withComponent(FootnoteReferenceElementStatic), BaseFootnoteDefinitionPlugin.withComponent(FootnoteDefinitionElementStatic), ];live kit(footnote-kit.tsx)绑定的是可交互渲染器:
'use client'; import { FootnoteDefinitionPlugin, FootnoteInputPlugin, FootnoteReferencePlugin, } from '@platejs/footnote/react'; import { FootnoteDefinitionElement, FootnoteInputElement, FootnoteReferenceElement, } from '@/registry/ui/footnote-node'; export const FootnoteKit = [ FootnoteInputPlugin.withComponent(FootnoteInputElement), FootnoteReferencePlugin.withComponent(FootnoteReferenceElement), FootnoteDefinitionPlugin.withComponent(FootnoteDefinitionElement), ];用 proofing 规则检验:两个 kit 文件短、平、直,没有任何 helper 包装——Plugin.withComponent(Element)的配对关系一目了然(可识别性 ✓);拆分本身只是把"插件 ↔ 组件"的映射从 registry 装配文件中显式列出,没有移动任何语义(✓ move semantics, not clutter);用户拷贝这两个文件后可以独立理解并替换任意一侧(✓ copy, tweak, own)。component-audit.md还列出了同模式的 math kit(math-base-kit.tsx、math-kit.tsx),说明这是被刻意复制而非偶发形成的惯例。
5.2 语义下沉 package、组合留在 app:media / TOC / equation
component-audit.md给出的三组"好抽取"案例,恰好是 proofing 规则"抽语义不抽杂物"的实证:
| 面 | app 组件(保持 shadcn 形态) | package hook(拥有语义) | 为什么成立 |
|---|---|---|---|
| Media | media-image-node.tsx | useMediaState.ts | package hook 拥有真实的 media/editor 状态;app 仍负责组合工具栏、caption、resize handles 与 shadcn 风格 UI |
| TOC | toc-node.tsx | useTocElement.ts | package hook 拥有稳定的导航契约;app 仍渲染行与本地按钮样式 |
| Equation | equation-node.tsx | useEquationElement.ts | package hook 只做一件持久的事:KaTeX 渲染 effect;app 拥有 popover 组合与本地 UI |
三者共同的形状是:const { ...state } = useXxxState()之后,JSX 里继续出现标准的 shadcn 组件组合。这与SKILL.md中"Good: package owns stable semantics, UI composes locally"的范式一致:
// Good: package 拥有稳定语义,UI 在本地组合 const { align, focused, readOnly, selected } = useMediaState(); return ( <MediaToolbar plugin={ImagePlugin}> <PlateElement {...props}>...</PlateElement> </MediaToolbar> );component-audit.md末尾还给出了一条清醒的提醒:仓库中确实存在"可能把过多东西抽进了 package hook"的组件,这些应被当作警告而非先例("Treat that as a warning, not a precedent")。SKILL.md更进一步,为未来大版本重设计立下"Major-Release Law":以返回渲染器专用 UI props/状态为主的 package React hooks 应被弃用并移回 app 本地,package 层只保留跨平台语义/view-model 契约——已存在的违反此法的 hook,不应因为"它已经在那了"而被新代码模仿。
六、配套规则:registry 接线与样式依赖
"开源代码保持"还延伸到 registry 元数据层:一个组件如果装出来的依赖不完整,用户拷贝走的"开源代码"就是跑不起来的残片。registry.md 为此定下三条:
- Kits 与 UI 条目保持对齐:新增组件时,在正确的 registry 文件中加 UI 条目、按需加 base/live kit 条目,并确保 kit 的
registryDependencies指向真实的 node/ui 条目——"Do not leave the registry half-wired."(不要把 registry 接一半。)元数据落在 registry-kits.ts、registry-examples.ts 等文件中,三者(registry-kits.ts、registry-ui.ts、registry-examples.ts)需要一起更新。 - 示例需要显式依赖:example 应依赖它使用的 kit、它直接导入的额外组件、以及它需要的样式 registry 条目。
- 样式依赖是真实依赖:如果组件使用了共享 CSS 变量或纯样式 registry 条目,必须显式声明。
registry.md给出的错误/正确对照是:
// 错误:example 实际还依赖共享样式 token registryDependencies: ['editor-kit'] // 正确 registryDependencies: ['editor-kit', 'highlight-style']component-audit.md的收尾提醒与之一致:"别忘了当组件或 example 使用共享 highlight token 时加上highlight-style这类样式依赖。" 这条规则看似琐碎,但它保障的正是用户"copy, tweak, and own"体验的最后一公里——装出来的代码可以独立运行。
七、实操清单:编写或重构 Plate 组件时如何执行 proofing
把三条规则与配套标准合并,可以得到一份可执行的自检清单,建议在提交组件改动前逐项过一遍:
- 可识别性检查(对应规则一)
- 文件内组合是否清晰,读者无需跳转 helper 即可理解 UI 行为?
asChild/data-slot/data-state是否按 shadcn 惯例显眼存在?cvavariants 与类名是否就近于消费它们的 JSX?
- 长度 vs 间接层检查(对应规则二)
- 如果为了缩短文件而抽取:抽取动机是"文件感觉太长"还是"类型烦人"?是则停止。
- 过一遍抽取测试的五条"必须"与五条"保留",特别核对返回形状是否主要是 labels / 类名决策 / popover 状态。
- 上游 diff 检查(对应规则三)
- 把改动当作与上游 shadcn 的 diff 来读:用户能认出这是开源组件代码吗?能拷贝微调吗?
- 抽出去的是语义还是杂物?"主要是杂物"则放回去。
- 接线与 changelog 检查(配套规则)
registry-kits.ts/registry-ui.ts/registry-examples.ts是否同步更新?- 共享样式 token(如
highlight-style)是否已声明为registryDependencies? - 用户可见的 registry 改动是否有 registry changelog 条目或明确的
N/A: <reason>? - package 导出变化时按仓库流程刷新导出(
SKILL.md给出的步骤是运行pnpm brl)。
- 最小诚实验证(来自
SKILL.mdWorkflow)- 纯 UI 改动:组件 spec 即可;
- 动了 package 代码:跑 package 构建/类型检查;
- 交互面:浏览器实机验证。
八、小结
shadcn-proofing.md的核心命题只有一句话:Plate 对外分发的 shadcn 风格组件,其可读性与可 diff 性是产品功能的一部分,而非风格偏好。规则一锁定"形态"(asChild、data-slot、variants 就近),规则二锁定"代价权衡"(长文件优于不可 diff 的间接层),规则三提供"裁决程序"(移动语义留下,移动杂物撤回)。这些规则由仓库内的真实产物支撑:footnote base/live kit 的显式装配、useMediaState/useTocElement/useEquationElement的语义下沉,以及 registry 元数据层的显式依赖声明,共同构成一套可复制、可审计的组件编写标准。对于维护 Plate 或参考其 registry 模式的项目而言,这套 proofing 清单是组件级代码评审中最具操作性的一份检查单。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考