多包仓库的构建与发布工程

3 分钟阅读
·

上一篇看了 test-builder,测试文档怎么写的问题解决了。这篇离开单个包的源码,往上一层看工程组织:22 个包、每个包一个独立 git 仓库,另有 buildhelper、rfcs、website 三个辅助仓库,没有 lerna,没有 changesets,统一管理靠的是 meta 仓库里一个 384 行的脚本。参考代码是 meta 仓库 prosemirror 的 c7f2f1d,全文主角是它的 bin/pm.js;构建和测试脚本在 buildhelper 的 60d1bac;包结构举例用 prosemirror-model 的 6264de0 和 prosemirror-view 的 ca4c78e;prosemirror-tables 的 eb522f2 会作为对照出现。

系列目录

日期 标题
05-10 ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17 ProseMirror 仓库全景:22 个包怎么分工
05-24 跑通一个最小 ProseMirror:先看文档长什么样
06-07 ProseMirror model(上):Node 与 Fragment,文档树的骨架
06-14 ProseMirror model(中):Mark,内联格式怎么挂在文本上
06-21 ProseMirror model(下):Schema 与 content expression,文档的类型系统
07-05 ResolvedPos:一个数字位置怎么变成路径
07-12 Slice 与 replace:切一块文档出来再塞回去
07-19 DOMSerializer:文档怎么变成 DOM 和 HTML
08-02 DOMParser:parseDOM 规则与 HTML 解析
08-09 findDiffStart / findDiffEnd:两份文档怎么求差
08-16 model 收官:Node 上的辅助方法与位置约定总结
09-06 ProseMirror transform(上):Step 抽象,所有修改的最小单位
09-20 ProseMirror transform(下):ReplaceStep 与 Fitter,最复杂的一步
10-03 StepMap:一步修改怎么映射每个位置
10-11 Mapping:多步映射的链式合并,rebase 的地基
10-18 structure.ts:split/join/lift/wrap 的可达性判断
10-25 Transform 类:构建修改的 API 层
11-08 ProseMirror state(上):EditorState,不可变编辑器状态
11-15 Selection 体系:四种选区与选区书签
11-22 Transaction:Transform 加上状态语义
12-06 Plugin 系统(上):StateField 与插件状态
12-13 Plugin 系统(下):props、appendTransaction 与 filterTransaction
12-20 state 收官:动手写三个插件验证理解
01-03 ProseMirror view(上):EditorView,状态与 DOM 之间的桥
01-10 ViewDesc(上):文档到 DOM 的描述树
01-17 ViewDesc(下):增量更新怎么做到只改动的部分
02-07 DOMObserver 与 readDOMChange:浏览器改了 DOM,怎么读回文档
02-14 input.ts:从 keydown 到 dispatchTransaction 的输入管线
02-21 选区同步:state 选区与 DOM 选区的双向对齐
02-28 Composition 与 IME:中文输入法事件的处理
03-07 NodeView 与 MarkView:把渲染权交给你
03-14 Decoration 体系:不修改文档的视觉标注
03-21 clipboard:复制粘贴的序列化与解析
04-04 domcoords:屏幕坐标与文档位置的双向换算
04-11 browser.ts:浏览器差异补丁集
04-18 view 收官:不用官方扩展,手写一个最小可用编辑器
05-09 扩展(上):keymap,最小的插件
05-16 commands:命令的签名约定与组合器
05-23 history:undo/redo 栈与 rebasing
06-06 inputrules:「# 空格」变成标题是怎么实现的
06-13 schema-basic:官方基础文档结构
06-20 schema-list:列表节点与最复杂的一批命令
07-04 gapcursor:光标落不进去的地方怎么办
07-11 dropcursor:拖拽时的插入位置指示
07-18 menu:菜单栏组件体系
08-01 collab(上):协作编辑的 rebase 原理
08-08 collab(下):receiveTransaction 与整个收发循环
08-15 changeset:变更集的计算与展示
09-05 markdown:文档与 Markdown 的双向转换
09-19 search:查找替换插件
10-03 表格专题(上):表格 schema 与 TableMap
10-10 表格专题(中):CellSelection,矩形的选区
10-17 表格专题(下):addColumn/mergeCells 等编辑命令
10-24 columnresizing:列宽拖拽的实现
11-07 example-setup:官方起手式是怎么装配的
11-14 test-builder:测试文档怎么写得像代码
11-21 多包仓库的构建与发布工程(本篇)

pm.js:一个命令分发器

bin/pm.js 的 start() 维护一张命令表:install、build、test、status、commit、push、pull、grep、run、watch、changes、modules、release、unreleased、dev-start、dev-stop、mass-change。每个命令对应一个函数,从 process.argv 取参数后直接调用。脚本没有用任何命令行解析库,文件顶部有一行注释说明原因:这里不能 require node_modules 里的东西,因为 install 命令必须能在依赖还没装的时候跑起来。bin/pm 只是指向 pm.js 的符号链接。

文件开头是三份名单。main 列出 12 个包:model、transform、state、view、keymap、inputrules、history、collab、commands、gapcursor、schema-basic、schema-list,即核心四层加最常用的扩展。mods 在此基础上再加 menu、example-setup、markdown、dropcursor、test-builder、changeset、search,共 19 个。modsAndWebsite 再加 website。这三份名单决定每条命令的作用范围:构建和测试跑 mods,状态类命令跑 modsAndWebsite,modules —core 只打印 main。22 个包里不在名单上的有 prosemirror 自己、prosemirror-tables、prosemirror-schema-table,它们的位置后面会交代。

名单之外还有一条隐含约定,mainFile():定位包的入口文件时先试 src/index.ts,再试 src/<包名>.ts,两个都不存在直接抛错。model 的入口是 src/index.ts,commands 的入口是 src/commands.ts,都落在约定内。构建、watch、dev server 全靠这个函数找入口,约定替代了配置文件。

install 是整个工作区的入口命令。它遍历 modsAndWebsite,目录已存在就跳过,否则从统一的 git 托管地址逐个 clone,地址是 code.haverbeke.berlin 下的 prosemirror-<包名>.git,website 仓库不带包名前缀,—ssh 参数切换协议。clone 完成后跑 npm install,meta 仓库的 package.json 里 workspaces 声明为 [”*”],npm 会把根目录下每个子包链接进 node_modules,跨包引用直接解析到本地仓库的 dist,不需要手动 link。最后调一次 build() 把全部包构建出来。也就是说搭建环境的完整动作是 clone meta 仓库加一条 bin/pm install。

类型检查同样在 meta 仓库统一做。根目录的 tsconfig.json 用 paths 把每个 prosemirror-* 包名映射到对应仓库的 src 入口,include 覆盖 /src/.ts、/test/.ts 和 demo/demo.ts,strict 打开,noEmit 打开。各包自己不配 tsconfig,整个工作区一次 tsc 就能查完,跨包改接口时类型错误在哪个包里冒出来一眼可见。

批处理类命令的实现都很直接。status 对每个仓库跑 git status -sb,输出等于干净基准行(## master…origin/master 或 ## main…origin/main)就跳过,只打印有改动的仓库。commit 先看 git diff 和 git diff —cached,有改动才在该仓库执行 git commit,参数原样透传,于是一条 pm commit -m ”…” 会在所有有改动的仓库里各提交一笔。push 在 git status -sb 的输出里找 ahead 字样,有就推,没有新提交的仓库静默跳过。这三条配成一组日常循环:改完代码先 pm status 看波及了哪几个仓库,pm commit 逐个提交,pm push 一次推完。pull 更朴素,对每个仓库逐个 git pull,某个仓库失败时异常直接终止整个循环。modules 把名单逐行打印出来,加 —core 只打 main 的 12 个,主要给外部脚本做管道输入用。run 把任意命令在每个仓库目录下逐个执行,输出前带上仓库名,某个仓库里执行失败会打印错误并中断批处理,适合跑各包的自定义脚本。grep 用 glob 收集所有包的 src 和 test 下的 .ts 文件,加上 website 的源码,再调一次系统 grep,带行号和文件名输出,跨 19 个仓库搜符号就是这一条命令,读这个系列的代码时我用它追了无数次跨包调用。mass-change 接收文件模式、正则和替换串,对所有仓库做批量正则替换,每改写一个文件打印一行 Updated 路径,改许可证年份、批量换 API 调用点这类场景用。

开发体验另有一组命令。build 调 @marijn/buildtool 把 mods 的 19 个入口一次构建完并打印耗时。watch 在同一个库上挂文件监听,顺带构建 demo/demo.ts。dev-start 用 esmoduleserve 起一个服务,把 ES module 源码按需编译给浏览器,端口默认 8080、可用 PORT 环境变量改,默认只绑 127.0.0.1。每个请求先交给按需编译的模块服务处理,处理不了再退到静态文件服务,都找不到就返回 404;/test 路径下不返回静态文件,由服务器动态生成一个包含全部浏览器测试用例的 HTML 页面。进程号写进 .pm-dev.pid,重复执行 dev-start 会先按 pid 探测旧进程,还活着就直接退出,dev-stop 按 pid 杀进程。本地改任何一个包,demo 页面刷新即可看到效果,中间没有打包等待。

每个包只有两个 npm script

打开 prosemirror-model 的 package.json,scripts 只有两行:

"prepare": "pm-buildhelper src/index.ts",
"test": "pm-runtests"

所有受管包共用同一套构建和测试行为,靠的就是 buildhelper 提供的这两个 bin。

pm-buildhelper 拿到入口文件后做四件事(bin/pm-buildhelper.js,实际构建逻辑在它的依赖 @marijn/buildtool 里)。第一,把源码里的 /// 文档注释改写成 /** */ 形式,否则 TypeScript 编译时会将它们剥掉,文档站点的 API 参考全靠这些注释生成。第二,在内存里跑一遍 TypeScript 编译,传了 —type-check 就让类型错误直接 fail 掉构建。第三,用 rollup 产出两种格式:dist/index.js 是 ES module,dist/index.cjs 是 CommonJS,后者额外过一遍 babel 的 preset-env 把语法降下来。第四,用 rollup-plugin-dts 把类型声明打包成单个 dist/index.d.ts。buildhelper 自身以 @prosemirror/buildhelper 的名字发布,出现在每个受管包的 devDependencies 里,版本要求统一,升级构建行为只需要发这一个包。

出口字段跟着这套产物布局走。model 的 package.json 里 type 是 module,main 指 dist/index.cjs,module 指 dist/index.js,types 指 dist/index.d.ts,exports 按 import 和 require 分条件,sideEffects 标 false 让打包器放心摇树。view 多两件事:exports 里额外暴露 ./style/prosemirror.css,sideEffects 改成数组把这个 css 列为例外,因为引入样式文件本身就是副作用。prepare 脚本会在本地 npm install 和发布前自动执行,dist 目录在 .gitignore 里,不进 git。

测试一侧,pm-runtests 按文件名前缀分流(bin/pm-runtests.js):test- 开头的是普通单测,直接在 node 里跑;webtest- 开头的交给 Selenium 控制的无头浏览器,默认 Chrome,可以用 —firefox 换。transform 的测试目录里是 test-mapping.ts、test-replace_step.ts 这种纯数据操作用例,跑在 node 里;view 的目录里是 webtest-clipboard.ts、webtest-composition.ts 一大片,剪贴板、输入法、选区这些行为离开浏览器没法测。上一篇介绍的 test-builder 在这里接上:它作为 devDependencies 出现在几乎每个包里,测试文件用它拼出带选区标记的文档,再断言 step 或命令的结果。传 —server 时不跑测试,起一个本地服务器在真实浏览器里边点边看,调 webtest 时很有用。

meta 仓库的 pm test 是另一层封装:用 @marijn/testtool 把 19 个包的用例一次性收集起来,再按 —chrome、—firefox、—no-browser、—grep 这些参数分流执行,一个浏览器参数都不传时默认补一个 chrome。顺带照实记一笔:—chrome 分支里把 browsers 误写成了 browser,真传这个参数会直接抛 ReferenceError;默认浏览器本来就是 chrome,几乎没有人需要传它,这个问题就这么留下来了。

release 命令:changelog 驱动的版本管理

发布是 pm.js 里最重的一段逻辑,围绕一条约定展开:commit message 里写 FIX:、FEATURE:、BREAKING: 开头的段落。翻任何一个包的 git log 都能看到这种格式。

release(mod) 的执行路径是这样。changelog() 先跑 git log —format=%B,取上个版本号到 HEAD 之间全部提交的完整 message,用正则把三类标记段落抓出来,分成 fix、feature、breaking 三组;bumpVersion() 按语义化版本升级,有 breaking 升 major 并把后两位归零,有 feature 升 minor 并把 patch 归零,只有 fix 升 patch,三组全空直接抛错,没有变更就不许发版。releaseNotes() 生成 CHANGELOG 的新节,标题是版本号加当天日期,正文按 Breaking changes、Bug fixes、New features 的顺序分节,顺手把 message 里 ](## 形式的文档锚点链接改写成文档站点的完整 URL。

接着落盘。setModuleVersion() 用正则替换本仓库 package.json 的 version 字段,生成的新节插到 CHANGELOG.md 头部。如果这次有 breaking,setDepVersion() 遍历 modsAndWebsite 里其余每一个仓库,把它们 package.json 中 “prosemirror-”: “^旧版本” 的依赖声明改成 ^新版本,有改动的仓库就地提交一笔 Upgrade prosemirror- dependency。最后 git add package.json 和 CHANGELOG.md,提交一笔 Mark version x.y.z,打一个带注解的 tag,tag message 就是这次的发布说明。

pm release 执行路径

两个辅助命令围着同一份数据转。unreleased 对 mods 里每个包跑一遍 changelog(),把已提交但未发版的变更按三类打印出来,发布前看一眼有没有漏。changes 先用 git describe —tags —abbrev=0 找到每个仓库最近的 tag,再列出它到 HEAD 的提交历史,用来回忆这段时间改了什么;找不到 tag 的仓库打印一行提示后跳过。website 仓库在 modsAndWebsite 里而不在 mods 里,status、commit、push 这组批处理覆盖它,unreleased 和 changes 这两个只管 mods 的命令不涉及它,文档站点平时也不发版本。rfcs 目录则完全在工具视野之外,纯文本提案不需要构建和发布。

文档与代码同源这条线也值得看。/// 注释在构建时被保留进产物,文档站点从这些注释生成 API 参考;releaseNotes() 生成 CHANGELOG 时把 ](##anchor 形式的短链接改写成文档站点的完整 URL,commit message 里写的文档引用到了 CHANGELOG 里依然能点。注释、类型声明、API 文档、CHANGELOG 四处内容,源头全是同一份源码和提交信息。

照实记录一个小问题。release 支持 —edit 参数,本意是把生成的发布说明放进编辑器让你改完再落盘,但实现里赋值写成了 nodes = editReleaseNotes(notes),结果存进了一个没声明的变量,notes 本身没变。脚本是 CommonJS 非严格模式,这行不报错,只是 —edit 静默失效。另有一个 —notes 参数,作用是把一段额外文本拼进 commit message 再参与解析,适合提交时忘了写标记、发布前补一条说明的场景。

setDepVersion 的触发条件值得单独说:只有 breaking 才重写下游依赖。fix 和 feature 不碰其他仓库,因为依赖声明是 ^ 范围,下游下次装依赖自然拿到新版。破坏式变更才需要显式抬高下游的依赖下限,而且每个仓库各提交一笔,历史里能看清是哪个包的 breaking 波及了谁。

还有一件事 release 故意不做:npm publish。整段流程只动 git 和文件,版本号、CHANGELOG、依赖联动、tag 全部落盘之后就停了,推到远端、发到 registry 是之后人工执行 pm push 和进对应仓库手动 publish 的事。发版动作里机器做的是可逆的本地部分,不可逆的对外部分留给人,这个分工和整套脚本其他地方的做法一致。

这套组织能借鉴什么

第一,约定的密度决定工具的厚度。pm.js 只有 384 行,buildhelper 两个脚本合计 64 行,能这么薄是因为处处是约定:入口文件两个候选位置、测试文件 test- 与 webtest- 前缀、CHANGELOG 的分节格式、commit message 的三类标记、版本号即 tag。任何一条改成可配置,工具链就要长出解析和校验的代码。前提也要看到:这个项目只有一个主要维护者,约定靠自觉就能守住。

第二,版本语义从提交信息里来。FIX、FEATURE、BREAKING 三个标记同时承担两个角色:决定版本号怎么升,以及生成 CHANGELOG 正文。写 commit 的时候就把发布说明写了,发布时没有额外的整理工作,版本号和变更内容也不会对不上。思路和 changesets 同构,但连变更描述文件都省了,代价是发布说明的粒度就是 commit 的粒度,想写得细就得多拆提交。

第三,跨仓库操作全部批量化,升级传播保持单向最小。status、commit、push、grep、mass-change 把 20 个仓库当一个仓库操作;依赖升级只在 breaking 时发生,日常的 fix 和 feature 发布对其他仓库零打扰。多仓库最烦的两件事,改一处要同步多处,发一版要追一串依赖,分别被这两组机制压住了。

第四,名单显式维护,不靠目录扫描推断。prosemirror-tables 不在 mods 里,它有自己的 scripts:vite 跑 demo、vitest 跑测试、tsdown 做构建,完全独立于 buildhelper 体系。核心包的演进不会波及它,它的发布节奏也不进 pm release 的视野。哪些包归这套工具管,看名单就知道,prosemirror-schema-table 这种历史包留在原地也不会被任何命令扫到。新增一个包要多改一处名单,这是显式维护的代价,换来的是每条命令的作用范围都可以预判。

限制同样说清楚。这套工具是单维护者工作流:release 在本地跑,仓库里没有 CI 配置兜底;版本各自独立,没有 lockstep,使用者要自己面对 19 个包各自的版本号。团队规模上去之后,这两块都得补。

工程与测试的三篇到这里结束。下一篇换角度,把 ProseMirror 的文档模型和更新模型拿出来,和 Draft.js、Slate、Quill 放在一起对比。


1291 字 · 35 段落
xi ming

Written by xi mingFollow onGitHub