首页
/ Ghost 内部包 TypeScript 与 ESM 迁移实战:保留 Git 历史的三步转换工作流

Ghost 内部包 TypeScript 与 ESM 迁移实战:保留 Git 历史的三步转换工作流

2026-09-05 23:41:01作者:董斯意

本文基于 Ghost monorepo 中定义内部包迁移工作流的技能文档 SKILL.md 及其配套参考 history-and-verification.md 展开,完整讲解如何将 packages/ 下的遗留 JavaScript/CommonJS 内部包,按"金路径"(golden path)契约转换为 TypeScript + ESM 单一构建产物。读完本文,你将掌握迁移前的适用性判定、三个职责单一 commit 的拆分策略(lib 目录搬迁、扩展名改写、实现转换)、生产级类型规范、打包与兼容性验证清单,并能对照仓库中已完成的 admin-api-schema 案例与强制校验脚本 pnpm lint:packages 复现整个流程。

一、背景:Ghost 的 packages 金路径契约

Ghost 仓库中 packages/ 目录存放的是仅供 monorepo 内部使用的 Node.js 库。packages/README.md 是这些包整个生命周期内架构约定的权威来源,而本文讨论的迁移工作流文档只负责"怎么转换",不负责定义"转换成什么样"——这正是两份文档的分工边界。

金路径对新内部包的定义是:私有的、TypeScript 专属的 ESM 库,具体契约包括(见 packages/README.md#L11-L21):

  • 包名使用 @tryghost/<name> 前缀;
  • "version": "0.0.0""private": true
  • 声明 "ghostPackage": {"goldenPath": "compliant"}
  • "type": "module"
  • 源码放在 src/**/*.ts、测试放在 test/**/*.ts
  • tsc 将生产代码编译到 build/
  • 不做独立的 npm 发布。

每个私有包都必须在 ghostPackage.goldenPath 字段中声明生命周期状态(见 packages/README.md#L27-L41):

状态 含义
compliant 该包已符合金路径,会被机械化规则逐条检查
migration 过渡状态:保留历史的导入已合入,正在等待独立的现代化 PR
exempt 有意保留的长期例外(如纯测试辅助包、多运行时包)

migrationexempt 都必须附带非空的 ghostPackage.reason。这些状态和所有可机械化执行的规则由 scripts/check-internal-packages.jspnpm lint:packages 中统一校验——该脚本在 根 package.json#L52 中被挂到仓库级 lint 流程里。值得注意的是,该脚本对标准脚本与 devDependencies 的期望值是硬编码常量,注释中明确说明这是刻意与 _template 模板 解耦的,避免模板漂移后"自己批准自己"。

金路径的构建细节同样值得在迁移前读懂(见 packages/README.md#L128-L140):

  • 共享 TypeScript 配置使用 NodeNext 语义,相对导入必须写真实的 .ts 扩展名,编译器在输出时改写为 .js
  • Ghost Core 本身是 CommonJS,但运行在支持 require(esm) 的 Node 版本上,因此单个 ESM 构建可同时服务 importrequire() 两类消费者——前提是整个模块图不得出现顶层 await(由 ESLint 强制执行);
  • 默认不添加 CommonJS 构建或转发 shim,只有经过验证的消费者无法使用标准 ESM 产物时才允许多格式。

仓库中的共享 TS 配置 configs/typescript/esm.json 把这些约定落实为具体编译器选项:module: "nodenext"moduleResolution: "nodenext"allowImportingTsExtensions: truerewriteRelativeImportExtensions: trueresolveJsonModule: truestrict: true,并叠加 noUncheckedIndexedAccessnoUnusedLocalsnoUnusedParameters 等严格开关。这些选项正是后文"生产级标准"在工具链层面的兜底。

二、迁移前:确认工作流适用

迁移文档要求:先完整阅读 packages/README.md,再检查目标包、它的消费者以及可对比的现行包,确认该包"仅内部使用"且能够采用文档化的单一构建 TypeScript + ESM 契约。

动手编辑之前必须完成四步准备(对应 SKILL.md#L19-L31):

  1. 找到该包的所有导入点、require() 调用、导出、运行时资源与路径引用;
  2. 检查包元数据、构建与测试配置、发布/归档(archive)包含关系,以及任何动态模块加载;
  3. 运行包现有测试和有代表性的消费者检查,建立基线(baseline);
  4. 识别出那些无法加载金路径 ESM 产物的受支持消费者。

文档同时划定了停止条件:如果包拥有活跃的独立发布线、第三方支持契约,或需要与金路径冲突的输出格式,应停下来先为其确立支持契约,而不是机械套用本工作流。一个容易误判的细节是:历史上已废弃的 npm 版本本身不构成迁移障碍——只有当前的支持契约才能阻止转换。

此外,文档要求在规划 commit 之前完整阅读 references/history-and-verification.md。该参考文档提供的内容在下一节展开。

三、用三个聚焦 commit 保留文件历史

这是整个工作流的核心方法论:把"机械搬迁"与"语义改写"严格分离,让 Git 的重命名检测(rename detection)能够工作。参考文档解释了原理:Git 记录的是快照而非显式重命名操作,重命名识别依赖内容相似度;如果同一个 commit 里既移动文件又大幅改写内容,Git 很可能把它呈现为"删除 + 新增",历史就此断裂。

三个 commit 各自的要求如下(对应 SKILL.md#L37-L69):

1. 将源码从 lib 迁到 src

  • 使用 git mv 移动整棵源码树;
  • 只更新必须随路径变化的引用:测试导入、构建输入、包元数据;
  • 本 commit 不改动模块语法、文件扩展名或任何实现;
  • 若改动内容与之相符,commit subject 使用 Moved <package> sources from lib to src

2. 将文件扩展名改为 TypeScript

  • git mv 完成 .js.ts 的批量重命名;
  • 只添加让重命名后的源码能够解析、让本 commit 保持自洽所必需的最小配置与语法调整;
  • 保持行为不变,把有意义的类型标注和 ESM 转换推迟到第三个 commit;
  • subject 使用 Changed <package> file extensions to TypeScript

3. 将实现转换为 TypeScript 与 ESM

  • packages/README.md 的包契约落地:共享配置包、最小化的包内配置、ESM 元数据与 exports、标准脚本,以及单一编译产物(除非有已验证的消费者需要例外);
  • 只有当所有金路径机械化检查全部通过后,才把包的 migration 状态替换为 ghostPackage.goldenPath: "compliant"
  • subject 使用 Converted <package> to TypeScript

文档对机械 commit 还有一条纪律:避免夹带格式化或顺手清理(opportunistic cleanup),让每个 commit 都能独立通过验证。

已验证的 commit 形态:admin-api-schema 案例

参考文档 history-and-verification.md 记录了 admin-api-schema 这次转换使用的三个 commit:

  1. 688807ca052 Moved Admin API schema sources from lib to src
  2. be31b36ffab Changed Admin API schema file extensions to TypeScript
  3. b825dafdc19 Converted Admin API schemas to TypeScript

文档强调:这个顺序的价值在于让 Git 与评审者能够区分"重定位、机械改名、语义转换"三类变更;它是形态示例而非实现模板,不应照搬其中的包特定实现。

今天的 packages/admin-api-schema/ 正是转换完成后的样子,可以作为"目标态"的实物参照:

  • package.json"type": "module""private": trueghostPackage.goldenPath: "compliant",exports 按 sourcetypesdefault 顺序暴露 ./src/index.ts./build/index.d.ts./build/index.jsfiles 只含 build——与 packages/README.md#L51-L65 给出的契约模板逐字段一致;
  • tsconfig.json:继承 @internal/cfg-typescript/esm.json,仅覆写 rootDir: "src"outDir: "build"include 只含生产源码;
  • test/tsconfig.json:继承源配置、noEmit: true,同时 include 源码与测试,供 test:types 脚本使用;
  • vitest.config.ts:一行 createVitestConfig(),来自共享的 @internal/cfg-vitest

这些"包内配置保持最小"的样例,恰好呼应了 packages/README 中"只在行为偏离共享契约时才加 override"的原则。

四、转换必须达到的生产级标准

迁移文档列出的质量标准(SKILL.md#L71-L88)可以归纳为六条纪律,每一条都对应仓库里已有的工具链支撑:

1. 类型要建模真实形状,禁止用 any 兜底。 对输入、输出与注册表(registry)形状建模,不得以 any 替代缺失的类型。仓库的共享 TS 配置开了 strictnoUncheckedIndexedAccess(见 configs/typescript/esm.json),转换后代码必须在这套开关下干净通过。

2. unknown 只出现在真正不可信边界,且要立刻收窄。 这是防止"未知输入一路裸奔"的关键约束。

3. 禁止用宽泛断言、非空断言、@ts-ignore 或 lint disable 来"让编译器闭嘴"。 参考文档在最终 diff 审查清单中再次点名"unexplained unknown or casts"作为重点检查项。

4. 保持运行时 API 不变。 除非 API 变更被明确纳入范围且所有消费者同步更新,否则包对外暴露的行为必须原样保留。

5. NodeNext 下相对导入必须写显式 .ts 扩展名。 这不是风格偏好,而是 configs/typescript/esm.jsonallowImportingTsExtensions + rewriteRelativeImportExtensions 的组合所要求的写法——编译器会在 emit 时把 .ts 改写为 .js

6. ESM 表达不了旧的 CJS 动态加载模式时,用显式的类型化注册表替代。 典型场景是旧代码靠 __dirname + 目录扫描动态 require() 一堆模块,ESM 无法安全表达这种发现式加载,正确做法是改成静态、带类型的注册表结构。

此外还有两条面向产物的约束:

  • JSON 等运行时资源必须被发射并可在 build/ 中找到。 packages/README.md#L142-L150 要求"生产行为只能依赖 build/ 单独工作":编译器能拷贝的(如通过 resolveJsonModule 导入的 JSON)就直接从 TS 导入,否则加一个显式、可移植的拷贝步骤。仓库中 packages/admin-api-schema/src/schemas/ 下的大批量 *.json 校验模式(posts、pages、members 等 Admin API 请求 schema)正是这类运行时资源的典型实例;
  • 避免顶层 await,以便受支持的 CommonJS 消费者能够通过 Node 的 require(esm) 互操作直接 require() 该包——这是单一 ESM 构建同时服务两类消费者的前提。

最后一条红线:不得为了通过转换而放宽共享的 TypeScript、ESLint 或 Vitest 规则。 这一点由 scripts/check-internal-packages.js 机械化保障——它硬编码了标准脚本(build: "tsc"test:unittest:typeslint:code 等)和标准 devDependencies 的期望值,任何在包内私改共享规则或偷换脚本的行为都会在 pnpm lint:packages 中暴露。

五、验证体系:从工作区到打包产物

迁移的验证分四层,逐层收紧证据边界。

1. 标准命令

在包目录内运行 pnpm buildpnpm testpnpm lint(即 packages/README "Verification" 一节要求的最小集合)。

2. 消费者侧双路径验证

参考文档要求按顺序做两件事(history-and-verification.md#L34-L50):

  • 先在工作区(worktree)验证 source 条件:确认某个使用 source 条件的消费者能在开发/测试中解析并加载原始 TypeScript"source": "./src/index.ts" 这一条 exports 就是为此设计的,纯 Node 会忽略它而加载 build/ 中的编译产物);
  • 再构建并 pack,针对打包后的产物验证四件事:
    1. 纯 Node 能 import 编译后的 ESM 入口;
    2. 现有的 CommonJS 消费者能在 Ghost 支持的 Node 版本上 require() 编译入口;
    3. 编译后的模块图中不存在顶层 await
    4. 产物中的 typesdefaultmain 目标都能解析到真实存在的文件。

文档同时警告:不要投机性地把第二个 CommonJS 构建加回去;任何必需的例外必须记录在包的 README 与配置中,并被测试覆盖。

3. 资源与打包检查

  • 从干净的包输出开始构建,逐一检查 build/ 中每个运行时文件;
  • JSON schema 之类的资源,编译器能发射就从 TS 导入,否则用显式拷贝步骤;
  • 复用仓库既有的 pack / 发布归档检查,确认三件事:生产执行不从 src/ 读文件;files 包含构建产物、排除手写源码(除非包契约明确另有规定);消费者行为是针对产物而非仅工作区测试的。

4. 历史可读性与最终 diff 审查

对每个机械 commit 运行并检查重命名是否被正确识别:

git diff --summary HEAD^
git log --follow -- packages/<name>/src/<representative-file>.ts

参考文档补充了一个边界情形:一个小的 CommonJS 转发文件在新 ESM 入口替换它时,合理地显示为删除是可接受的——此时应验证实现代码的血缘,而不是硬造一个有误导性的 rename。

开 PR 前,除了总 diff,还要逐 commit 独立审查,重点排查:意外的格式化噪音、被削弱的编译器/lint 规则、来历不明的 unknown 或类型断言、过时的 CommonJS 配置、缺失的运行时资源、以及绕过包 exports 直取内部路径的消费者。

关于合并方式,SKILL.md 明确:当这些 commit 独立有效且顺序有意为之时,现代化 PR 可以 rebase-merge;这不影响前置子树历史导入所要求的 merge-commit 规则。

六、快速核对清单

结合仓库证据,执行一次完整迁移可按下表自查:

环节 动作 仓库依据
适用性 确认包为内部使用、无独立发布/第三方契约;废弃的历史 npm 版本不阻塞 SKILL.md
基线 跑通现有测试与代表性消费者 同上
Commit 1 git mvlib/ 移到 src/,不动语法与扩展名 SKILL.md#L42-L49
Commit 2 git mv 批量 .js.ts,只加最小解析配置 SKILL.md#L51-L58
Commit 3 落地 TS + ESM 契约,检查全过后再改 goldenPath: "compliant" SKILL.md#L60-L69
类型纪律 真实建模、unknown 仅限边界、禁 any/断言/ts-ignore、保持运行时 API SKILL.md#L71-L88
配置形态 继承共享 cfg 包、source/types/default exports、标准脚本面 packages/README.mdpackages/_template/package.json
验证 build/test/lint → worktree source 条件 → pack 产物四查 → 资源与归档检查 → git diff --summary / git log --follow history-and-verification.md
机械化兜底 pnpm lint:packages 校验脚本面、devDeps 与 goldenPath 状态 scripts/check-internal-packages.js根 package.json#L52

七、总结

这套工作流的价值在于把一次"语言升级"拆成了可审计的历史事件:libsrc 的重定位、.js.ts 的机械改名、以及真正的语义转换各自独立成立,Git 重命名检测因此全程有效,评审者与未来的 git log --follow 读者都能看到清晰的文件血缘。而 ghostPackage.goldenPath 状态机配合 scripts/check-internal-packages.js 的机械化校验,则保证"声明 compliant"与"实际通过金路径检查"是同一件事。对维护者而言,这套方法可以直接作为 Ghost packages/ 下任何遗留内部包现代化的操作手册;对读者而言,packages/admin-api-schema/ 是转换完成后的完整参照实现。

登录后查看全文
热门项目推荐
相关项目推荐