使用 pnpm + Docusaurus 初始化的仓库,用于验证主题覆盖机制,并给出「多主题链式覆盖」的可落地方案。
这份 README 只管仓库级的事实:怎么跑、有哪些分包、什么东西放在哪。 每个方案与包的细节在它自己的 README 里,这里不抄第二份。
pnpm install
pnpm start # 本地预览 http://localhost:3000
pnpm build # 编译需要编译的分包 + 构建静态站点
pnpm build:core # 只用 tsc 编译那九个「构建包」(核心包 + theme/theme-* 那八个主题包)到各自的 dist(即包出口);theme/theme-iframe 的 lib/ 不在里面,那份共享源码不发编译产物
pnpm lint:type # tsc -p tsconfig.json:唯一把站点根与 *.config.mjs 纳进来的一道(提交钩子也跑)
pnpm typecheck # tsc -b:demo 与 test 两份 composite 项目
pnpm lint:eslint # eslint
pnpm test # vitest 单元测试(解析语义 + 不变量 + 输入校验 + 真实 resolver 端到端)
pnpm test:coverage # 同上 + 覆盖率(红线 100% 只挂在这里:pnpm test 全绿 ≠ CI 全绿)
pnpm test:e2e # 构建产物起服务,Playwright 在真浏览器里点开每个主题的示例页(只本地跑)
pnpm verify:chain # 读真实 build 产物,回读主页那条四层渲染链(src/theme > theme-c > theme-b > theme-a)
pnpm verify # 一键全链路(组成见 package.json 的 verify;本表不复述,避免两处说法漂移)Node 版本要求以
package.json的engines为准(当前^24.0.0);CI 另外跑current/lts/*/lts/-1(见.github/workflows/bring-it.yaml)。 这条下界的来历是「跟着最老的受支持运行时」:@types/node与engines钉在同一档, 「只在新版才有的 API」才不会在 CI 里悄悄绿。当前@types/node是^22.18.0,比engines低一档。
站点根 package.json 刻意不写 "type": "module",配置用 .mjs 扩展名拿 ESM。
这是修 .docusaurus/ 里 require.resolveWeak 报错的唯一产地:
webpack 按「扩展名 + 最近 package.json 的 type」给模块判型。根上写了 "type": "module",
Docusaurus 生成的 .docusaurus/*.js(CJS 风格)就会被判成 javascript/esm,
require.resolveWeak(...) 不被改写、原样进产物,而 SSG 注入的 require 没有它 ——
pnpm build 当场死在 TypeError: require.resolveWeak is not a function。
这不是本仓库代码的问题(零自定义插件的最小站点 + "type": "module" 同样红)。
不该为它给 webpack 打判型补丁(那会让所有源码被降级成 javascript/auto,import 语义变了
比原报错更难查),也不该让源码为它改回 CJS —— 去掉根上的 "type" 才是对准根因:
.docusaurus/ 是构建中间产物,模块格式该由生成它的代码(CJS 风格)决定。
实际落点只有一处,靠扩展名而不是 "type":
| 文件 | 为什么是 .mjs |
|---|---|
docusaurus.config.mjs |
Docusaurus 按 .mjs 加载配置;无 "type" 时 .js 会被当 CJS |
仓库里的分包按「src/ 要不要先编译」分两种形态,不是按「像不像一个包」:
| 目录 | 是什么 | 形态 | 细节 |
|---|---|---|---|
plugins/plugin-prev-layer-resolver/ |
核心包:@theme-prev/X 解析器 + 层栈自动发现 |
构建包(src/ → dist/,改完要编译) |
README · internals |
theme/theme-markmap/ |
主题包:md 的 frontmatter 写 markmap: true 即在正文上方渲染导图 |
构建包(只编译入口与 loader;层与组件是源码) | README · 示例页 |
theme/theme-erd/ |
主题包:.dbml 文件在正文上方渲染数据库关系图,正文附上固定四种格式加一串由插件选项决定的导出模板 |
构建包(只编译入口、清单判据与 loader;层与组件是源码) | README · internals · 示例页 |
theme/theme-diff/ |
主题包:md 的 frontmatter 写 diff: true(或一个路径)即在正文上方渲染这份文件最近一次提交的差异视图;写成 diff: {old: 旧, new: 新} 时比的是磁盘上那两份文件,不读 git 历史 |
构建包(只编译入口与 loader;层与组件是源码) | README · internals · 示例页 · 两份对照示例页 |
theme/theme-iframe/ |
主题包,也是「整页交给一个框」这一族唯一的外层套壳:判四把 frontmatter 键,命中就把那一页换成一个通用框;lib/ 里另住着三条 API 层共用的跨 iframe 约定与配置期工厂 |
构建包(同上,src/ 里只有入口那一份;lib/ 免编译) |
README · internals · 协议 |
theme/theme-stoplight/ |
主题包:md 的 frontmatter 写 oas3: <URL> 时整页换成一个 iframe(外层那格由 theme-iframe 承接),框里是本包自己那条 /stoplight-view 页面,Elements 在那份文档里渲染 API 文档 |
构建包(同上,src/ 里只有入口那一份,且是调工厂的一次) |
README · internals |
theme/theme-redoc/ |
主题包:md 的 frontmatter 写 redoc: <URL> 时整页换成一个 iframe(外层那格由 theme-iframe 承接),框里是本包自己那条 /redoc-view 页面,Redoc 在那份文档里渲染 API 文档 |
构建包(同上,src/ 里只有入口那一份,且是调工厂的一次) |
README · internals |
theme/theme-scalar/ |
主题包:md 的 frontmatter 写 scalar: <URL> 时整页换成一个 iframe(外层那格由 theme-iframe 承接),框里是本包自己那条 /scalar-view 页面,Scalar 在那份文档里渲染 API 文档 |
构建包(同上,src/ 里只有入口那一份,且是调工厂的一次) |
README · internals |
theme/theme-viewer/ |
主题包:文档正文里有图片时,那些图片交给查看器 —— 点开放大、原尺寸、重置、前后切换;没有图片的页面这一层等于透明 | 构建包(同上,src/ 里只有入口那一份) |
README · internals · 示例页 |
demo/theme-{a,b,c}/ |
验证 / 调研用主题插件(不属于核心包) | 免编译(没有 src/:入口与层都是源码,站点直接吃) |
README |
九个构建包都是 tsc 编译包:Node 侧要跑的源码 .mts 在包内 src/,产物在包内 dist/,
dist/index.mjs 就是包出口(exports)—— 所以 pnpm build:core 是「编译这九个包」,
pnpm start / pnpm build / pnpm test 都已经把它内置在前面。
主题包的主题层与组件不进这份编译:theme/*/theme/** 与 theme/*/components/** 是未编译的
.tsx / .jsx,随包发布,由用它们的站点的 babel 编译 —— 判据只有一条:谁编译,就不编译第二遍。
这一步不会自动成立:Docusaurus 官方那条 babel 规则把 node_modules 整个排除在外(只给
docusaurus*.js 开例外),所以每个主题包的入口都在 configureWebpack 里用官方
utils.getJSLoader() 补一条只覆盖本包根的 .tsx? 规则(下表「层与组件为什么不编译」)。
demo/theme-*/ 是这个形态的完整版,连入口 index.mts 都由 Docusaurus 的 jiti 直接加载。
theme/theme-iframe/lib/ 里那一份(注册表、判定清单的分发、跨 iframe 共享约定、配置期工厂)是同一判据推到头的
另一格:它没有编译产物 —— 浏览器那几份由用它的包直接引源码;注册表是纯数据,配置期与浏览器期
引的是同一份;唯一跑在 Node 配置期的 api-frame-theme.mts 由 Docusaurus 的 jiti 直接吃这份
源码(.mts 后缀按上游 lint preset 标 Node 环境,与 src/index.mts 同一口径)。exports 用
./lib/* 把源码交给用它的包,build:core 编译本包时也只产入口那一份。
theme/theme-iframe/、theme/theme-stoplight/、theme/theme-redoc/与theme/theme-scalar/已接进站点 (plugins里的顺序 =@theme/层栈的覆写顺序,四者都在theme-markmap往外那一侧), 接入位置、触发键与各自验证到哪一步以各包维护者文档 iframe、 stoplight、 redoc、 scalar 为准,用法与限制在各自的 README,这里不抄第二份。 四把键的判定顺序由 theme-iframe 那一层决定(口径在 iframe-protocol.md 的「注册表与分发」一节);theme/theme-iframe/lib/不在plugins的重名层里 —— 它不是插件也不是主题层,被三条 API 层 当普通依赖用(版本契约写在各自的peerDependencies,口径在 theme-iframe 的 internals)。插件身份只属于theme-iframe包本身。
九份 tsconfig.build.json 是同一份差量:extends: @nice-move/tsconfig/node-bundle.json,
再写 noEmit: false 与 rootDir / outDir,能从 extends 拿的一律不重抄。这张表是这些口径的产地,
包里的注释只留指针:
| 口径 | 内容 |
|---|---|
| 编译范围 | 只有 src/,而 src/ 里只放 Node 侧要跑的代码:核心包的解析器、主题包的入口与 webpack loader、类型。JSX 一份都不进 tsc。 |
| 层与组件为什么不编译 | 用它们的站点会编译,编译两遍只是多出一份要 files 点名、会和源码漂移的产物。但「站点会编译」要主动接上:官方 babel 规则按 excludeJS 排除整个 node_modules、只给 docusaurus*.js 开例外,从 npm 装进真项目时未编译的 .tsx 会报 Module parse failed。因此每个主题包的入口都用官方 utils.getJSLoader({ isServer }) 补一条只覆盖本包根的 .tsx? 规则(三条 API 层经 lib/api-frame-theme.mts 的工厂统一补);工作区里 pnpm 的软链把源码解析到 node_modules 之外,两条规则各跑一遍 babel,产物不变。实测根据:demo/theme-*/theme/** 一直是未编译的 .tsx,站点收进层栈照样绿 —— pnpm verify:chain 打印的层栈里就有那一格。 |
让 tsc 编 JSX 要付什么 |
多一条产物口径:react.json 给的 react-jsx 会把 JSX 编成 .js,而包是 "type": "module",服务端构建里 babel 先把 import() 变成 require('./x.js') —— 纯 ESM 模块的 parser 不认 require,依赖进不了 bundle,静态渲染阶段报 Cannot find module;要躲它得单独钉 jsx: "preserve"。少编译一层就少一条这种口径。 |
files 点名与 docs/ |
发布只点站点运行时与类型要吃的格子(dist、theme、components、lib、types),各包的 docs/(维护者文档与那份共享约定)不进 files —— 实现记录留在仓库里读,代价与判据见 theme-iframe 的编译口径。 |
| 相对 import | 统一写源码名(./x.mts / ./x.tsx)。编译过的由 node-bundle.json 的 rewriteRelativeImportExtensions 在产出时改成产物名(./x.mjs);未编译的那份原样交给 webpack,磁盘上就是这个文件,所以两种写法都指得到。allowImportingTsExtensions 不用写 —— base.json 已给。 |
| 样式表 | 不是模块,tsc 也不搬:theme-markmap 与 theme-viewer 那两份留在包根 styles/,走 getClientModules()(viewer 那一份连 viewerjs 自带的样式表一起从这一格交给站点,只是那份写的是 CSS 原生嵌套,得先由该包入口的 configurePostCss() 摊平,口径见它包内 docs/internals.md 的「第三方那份样式表要补的两处」一节);theme-diff 自己没有样式表,只把 @git-diff-view/react 那份第三方样式表从同一格交给站点;iframe 那份 CSS Module 直接放在用它的组件旁边。第三方那份静态样式表(@stoplight/elements)不进这条流水线:由 theme-stoplight 的入口在构建配置期把它从「打进全站那一份 css」改成「单独一个产物」,只由框内那份文档 <link>(口径见它包内 docs/internals.md 的「样式」一节)。 |
| 未编译源码的类型检查 | 由仓库根 tsconfig.json 接手(默认 include **/*,types 同时列 node 与 react / react-dom)。所以构建项目不再需要 jsx、不需要 react 那两条 types。@theme* 那三条 specifier 的声明归解析器包(plugins/plugin-prev-layer-resolver/types/),由 tsconfig.demo/test 列进 files。*.module.css、@docusaurus/router 这类只在构建期存在的依赖,由仓库根一份 module-aliases.d.ts 引用官方 @docusaurus/module-type-aliases 说明(根 tsconfig.json 的 include 收得到它,tsconfig.test.json 按 files 点名)。 |
其余目录不是包,只是仓库根的一部分:
| 目录 | 职责 | 细节 |
|---|---|---|
docs/ |
站点文档源:intro.md 就是主页(卡片列表),每个主题包一份示例页(*-demo.md,ERD 是 erd-demo.dbml) |
docs/intro.md |
test/ |
核心包单元测试(按契约面组织) | README |
src/ |
只剩站点层 swizzle src/theme/(主页由 docs 承担,站点侧没有页面层) |
README |
static/ |
原样复制到站点根的静态文件(三条 API 示例页各取一份 OpenAPI 文档,图片查看器示例页那两张图在 img/) |
— |
上表的 plugins/*、theme/*、demo/* 是 pnpm workspace 包(各自有 package.json,
也在 pnpm-workspace.yaml 的 packages: 里);其余是仓库根的目录,不是包 —— 判据是
「有没有 package.json」。
分包要被解析,判据是用它的那个 importer 声明了它:
packages:里收了、目录里有package.json都不算,站点根不写"@show-docs/theme-a": "workspace:^",pnpm 就不会建链接, 一路顺到docusaurus build才炸Cannot find module。test/plugin.test.mts有一条用例 把核心包与 demo 的三个主题按 Node 的 ESM 规则解析一遍,把这一步提前到pnpm test(清单里的theme/*还没进那条用例的目录列表)。
- 多主题链式覆盖(
@theme-prev/X怎么解析、怎么接入、有哪些约束): plugins/plugin-prev-layer-resolver/README.md; 机制为什么这样设计、实测与内部契约清单在同包 docs/internals.md - 导图主题(frontmatter 触发、渲染位置、明暗跟随全站主题):
行为见 docs/markmap-demo.md,用法见
theme/theme-markmap/README.md;实现见
theme/theme-markmap/(入口与 loader 在src/,层与组件在theme/、components/) - 关系图主题(
.dbml触发、渲染位置、正文里附哪些导出、那些导出由谁生成): 行为见 docs/erd-demo.dbml,用法见 theme/theme-erd/README.md;实现(入口、清单判据与 loader 在src/,层与组件在theme/、components/,@dbml/core实测到的形状与导出模板插拔在实现上的 三个约束)在同包 docs/internals.md - 差异视图主题(frontmatter 触发、比的是哪一份或哪两份、取不到差异时怎么办):
行为见 docs/diff-demo.md 与
两份文件对照那一页,用法见
theme/theme-diff/README.md;实现(入口与 loader 在
src/, 层与组件在theme/、components/,git那几步与取不到差异的几种落点)在同包 docs/internals.md - iframe 主题(frontmatter 触发、URL 怎么算、在层栈里放哪一格、命中时丢掉的是哪一块): 行为见 docs/iframe-demo.md,用法见 theme/theme-iframe/README.md;实现与包内组成在同包 docs/internals.md,跨 iframe 的口径在 iframe-protocol.md
- Stoplight 主题(
oas3:触发、渲染为什么挪到本包自己那条/stoplight-view页面): 行为见 docs/stoplight-demo.md(示例取static/openapi-stoplight.yaml),用法见 theme/theme-stoplight/README.md;实现(含那份静态样式表 怎么只出现在框内那一页、浏览器 bundle 要补哪几样 Node 侧依赖)在同包 docs/internals.md - Redoc 主题(
redoc:触发、渲染为什么挪到本包自己那条/redoc-view页面): 行为见 docs/redoc-demo.md(示例取static/openapi-redoc.yaml),用法见 theme/theme-redoc/README.md;实现(含样式为什么不需要构建期 处理而暗色表仍要在运行期改挂)在同包 docs/internals.md - Scalar 主题(
scalar:触发、渲染为什么挪到本包自己那条/scalar-view页面): 行为见 docs/scalar-demo.md(示例取static/openapi-scalar.yaml),用法见 theme/theme-scalar/README.md;实现(含它的静态样式表为什么 拦不住而只能靠.scalar-app那层前缀、亮暗为什么落在<body>那两个类名上)在同包 docs/internals.md - 图片查看器主题(这一层没有开关,判的是正文里有没有图片;查看器接管的是哪一格):
行为见 docs/viewer-demo.md(示例取
static/img/那两张图),用法见 theme/theme-viewer/README.md;实现(入口在src/,层与组件在theme/、components/,两处样式收在styles/,第三方那份样式表要补的那两处)在同包 docs/internals.md - 把渲染器关进一个框之后要对齐的那两格(地址栏里
#那一段与站点的亮暗模式,两边怎么跨文档 送、为什么要各推一次): theme/theme-iframe/docs/iframe-protocol.md —— 上面三条 API 层共用同一份实现 - 链是否真的成立:
pnpm build && pnpm verify:chain,判据清单以脚本输出为准 - 每个主题在浏览器里是否真的工作:
pnpm test:e2e(跑的是构建产物,只本地跑)。用例是 Given/When/Then 句子(Gherkin 写在test/e2e/features/,步骤实现在test/e2e/steps/), 用例清单与首次要装的浏览器见 test/README.md
「职责」比「位置」重要 —— 放错地方的代价不是不好看,而是行为变了(把 specifier 写在层外文件里 就是硬失败,不是「有时灵有时不灵」)。先按「你想改什么」找,再在对应文档里看细节。
| 你想改什么 | 动哪里 | 细节 |
|---|---|---|
| 层栈怎么推导、specifier 怎么解析 | plugins/plugin-prev-layer-resolver/ |
README · internals |
| 导图怎么触发、渲染在哪 | theme/theme-markmap/ |
README · 示例页 |
| 关系图怎么触发、渲染在哪 | theme/theme-erd/ |
README · internals · 示例页 |
| 上一次改动怎么看 | theme/theme-diff/ |
README · internals · 示例页 · 两份对照示例页 |
| 整页交给外部站点或外部文档 | theme/theme-iframe/(跨 iframe 那两格在同包 lib/ 与 docs/iframe-protocol.md)、theme/theme-stoplight/、theme/theme-redoc/、theme/theme-scalar/ —— 四包的内部实现与实测记录在各包 docs/internals.md |
示例页 · stoplight 示例页 · redoc 示例页 · scalar 示例页 · iframe · 跨 iframe 约定 · stoplight · redoc · scalar |
| 正文图片怎么被接管 | theme/theme-viewer/ |
README · internals · 示例页 |
| 某层包在组件外面的样子 | demo/theme-{a,b,c}/theme/、src/theme/ |
demo · src/theme |
| 验证「链真的成立」 | demo/scripts/verify-chain.mts |
demo |
| 站点主页与文档 | docs/(intro.md 带 slug: /,路由前缀在配置里挪到根) |
docs/intro.md |
| 单测、浏览器用例与夹具 | test/(e2e 的句子在 test/e2e/features/,实现在 test/e2e/steps/) |
README |
@theme* 的类型声明 |
plugins/plugin-prev-layer-resolver/types/ |
README「类型声明」 |
仓库级只有这些不在分包里的东西:
dist/.tsbuildinfo/ # `tsc -b` 的增量信息(git 忽略);构建包的产物在各包自己目录里
tsconfig.json # 共享编译选项(noEmit);demo / test 各一份 extends,八个构建包在包内自带 tsconfig.build.json
tsconfig.demo.json / tsconfig.test.json # 两份 composite 项目,由 pnpm typecheck(tsc -b)跑
docusaurus.config.mjs # 站点配置(.mjs:站点根不是 ESM 包,靠扩展名拿 ESM;侧栏由 docs 插件按目录自动生成;docs 的路由前缀是 '/',所以主页就是 docs/intro.md)
pnpm-workspace.yaml # pnpm 12 的配置落点:分包清单(packages:)+ 安装期依赖(configDependencies)+ 依赖安装脚本放行名单(allowBuilds)+ 扁平化(shamefullyHoist)
eslint.config.mjs / syncpack.config.mjs / vitest.config.mts / playwright.config.mts # 四道检查各自的落点(后者同时是 BDD 的落点:`defineBddConfig()` 说清 `.feature` 与步骤在哪,`testDir` 由它给出)
.features-gen/ # `bddgen` 从 `.feature` 生成的 Playwright 文件(git 忽略,别手改)
.github/workflows/ # CI(GitHub Actions)
关于 src/:它现在只有主题层那一种东西 —— src/theme/ 是层栈最外圈的哨兵槽位。
站点不再有自己的页面层:主页就是 docs/intro.md(配置把 docs 的路由前缀挪到根,那份
frontmatter 写 slug: /)。历史上 src/pages/ 在的那一格讲的是「specifier 写在层外文件里
必定硬失败」,那条口径的产地在
插件 README 的使用约束①。