开发文档
主题小到可以完整读完,但在改动之前了解内部构造是值得的:这里的多数坑不在代码本身,而 在某个值是从哪来的。
目录结构
src/
├── node/
│ ├── index.js 公开入口(`githubTheme` 与工具函数)
│ ├── githubTheme.js 主题对象:hooks、插件、页面生成
│ ├── types.js 选项面的 JSDoc typedef
│ └── utils/
│ ├── collectPosts.js 什么算一篇文章、阅读时间、日期
│ ├── createBlogPages.js 生成列表 / 标签 / 归档页面
│ ├── defineBlogData.js 注入客户端 bundle 的 payload
│ ├── resolveLocaleOptions.js 按 locale 的默认值
│ ├── resolveThemePlugins.js 内置行为插件
│ └── slugify.js Node 与客户端共用的标签 → URL 段落规则
└── client/
├── index.js 客户端公开入口(`vuepress-theme-github/client`)
├── config.js 布局注册、全局组件、深色模式
├── layouts/ Layout、NotFound
├── components/ navbar、sidebar、toc、blog、home、内容组件
├── composables/ 主题数据、侧边栏、博客数据、深色模式、抽屉
├── events/ 唯一的跨组件信号通道
├── utils/ 纯函数 —— 测试主要指向这里
└── styles/ Primer token 与每个区块一份 SCSS两个入口
| 入口 | 谁 import | 包含 |
|---|---|---|
vuepress-theme-github | config.js(Node 侧) | githubTheme、构建期工具 |
vuepress-theme-github/client | 组件、用户页面 | 组件、composables、纯函数 |
两者都由 npm run build 生成,它把 src/ 复制到 lib/。没有编译步骤:源码是纯 ESM 与 .vue 文件,由 VuePress 在站点构建时编译。
一个页面是怎样被渲染的
<Page>来自用户的站点;Layout由client/config.js注册。Layout读取 frontmatter 并选择视图:home、某个blogView值,或普通页面。- 每个视图都包在同一套外壳里 —— skip link、导航栏、可选侧边栏、main landmark、 页内目录、页脚。
- 页内目录在挂载之后从 DOM 构建,因为 VuePress 只给 Node 侧提取标题。这也是它必须 放在
<ClientOnly>里的原因。
博客是怎样构建的
onInitialized(Node)
└─ 对每个 locale:createBlogPages
├─ collectPosts(app, { 该 locale 的 postsDir })
└─ createPage(...) → app.pages.push(...)
define(Node)
└─ defineBlogData → 客户端 bundle 里的 __GITHUB_BLOG__ 常量
└─ usePosts() 按当前路由的 locale 过滤这份列表从这个形状里掉出来两条规则,它们在成为规则之前都是 bug:
postsDir相对 locale 目录解析。/zh/下的posts指的是zh/posts/,也就是 VuePress 为那些页面报告的文件路径。- 一个 locale 只列出自己的文章。
/zh/posts/a.html与/posts/a.html都以/开头,所以过滤器比较的是最长匹配的 locale 前缀(localeOf)。
生成的页面由手工 push 进 app.pages:createPage 只是构建页面,并不注册路由。
状态放在哪
| 状态 | 归属 | 为什么放这 |
|---|---|---|
| 配色方案 | composables/useDarkMode.js | 只 provide 一次,导航栏读取,持久化到 localStorage |
| 阅读进度 | 元素上的 CSS 变量 | 滚动是高频事件,走响应式会让整条父链重渲染 |
| 抽屉是否打开 | composables/useDrawer.js | 一个模态面只需要一个布尔量;它还负责 Escape 与滚动锁 |
| 侧边栏分组展开 | components/GithubSidebarItem.vue | 放在本地,点击才是即时的 |
| 「全部折叠」 | Layout → provide | 这是关闭一个自己持有状态的分组的唯一办法 |
| 跨组件信号 | events/index.js | 侧边栏被渲染两次(栅栏 + 抽屉);抽屉必须在另一份副本导航时关闭 |
坑(Sharp edges)
这些点消耗过真实的调试时间,写在这里以免被重新发现。
- markdown 渲染器把标题文字嵌在
.header-anchor内部。 匹配标题时如果不忽略这个 选择器,就会把标题换成锚点,在页面上看起来像一个空标题。 - 页面路径用
encodeURI(path.split('/').map(sanitizeFileName).join('/'))解析。 标签 slug 必须 原样通过这一层,否则生成的目录和指向它的链接会对不上。这也是slugify.js里[a-z0-9-]规则的由来。 inferRoutePath是唯一正确的 markdown → 路由转换。 手写规则会把/guide/README.md处理错:它服务于/guide/。useHeaders读 DOM,所以由它构建的东西只能是客户端的。 在服务端渲染会吐出一个 空<nav>、再由客户端填充 —— hydration 不一致。阅读进度条同理(它读滚动位置)。- locale 前缀要在
inferRoutePath之前加。 先算路由再补前缀会把前缀拼进路径 中间(/guide/在/zh/下变成/guide/zh/)。侧边栏配置的 key 匹配是同一个问题的 另一面:key 通常不写语言前缀而路由写,所以两种拼法都要比较。 - 两端共用的规则要把结果放进 payload,而不是各算一遍。 标签 slug 走
assignTagSlugs:构建期用它生成目录,客户端从__GITHUB_BLOG__里查同一张表。 两边各跑一次同样的函数看起来更简单,但只要有一侧开始做去重就会分叉。 - 组件名解析失败是运行时错误。 文档里让用户直接写进 markdown 的组件必须全局注册, 否则构建照样打印
success,而页面那块是空的 —— 只有浏览器测试拦得住。 - 客户端用的选择器要以最弱的渲染器为准。 代码块的
div.language-x包装是可选 (关掉高亮器就没有),pre > code才是稳定锚点。 - 任何读
window的代码都必须在onMounted里。@vueuse会在 setup 期间解析window并弄坏 SSR;渲染器会吞掉异常、照样打印success,所以要去看日志,而不是 退出码。 - 一个从
false起步、在onMounted里被纠正的 ref,在它被渲染的那一刻就会造成 hydration 不一致(aria-pressed、图标、class)。应该从 DOM 初始化 —— 见useDarkMode。 overflow: hidden会静默废掉position: sticky。 任何overflow不是visible的元素都会成为其后代的滚动容器,里面的 sticky 元素改为相对它吸附,表现是 「跟着内容一起滚走」。要裁横向溢出请用overflow-x: clip。scroll-padding-top和scroll-margin-top会相加。 锚点偏移只写一处(本主题 写在标题的scroll-margin-top上)。写两处不会报错,只是每个锚点多偏一个导航栏的高度。- 同一个数值要用同一个来源,不要抄算式。 页内目录的高亮阈值必须读标题自己的
scroll-margin-top,而不是把--github-navbar-height + n再算一遍 —— 抄一份出来, 某次改样式时就会漂移,而症状(高亮差一格)离病因很远。 - 独立布局要遵守同一份外壳契约。 404 与首页都因为「单独渲染」而漏掉过
<main id="github-main-content">和 skip link。新增布局时先对着Layout.vue核对 地标、skip link 与tabindex。 - 别用
scrollWidth判断横向溢出。overflow-x: hidden会把它一起截断,于是被挤出 视口的控件不会让断言失败。要遍历渲染盒,并跳过自带滚动条的容器(代码块、宽表格)。 - 外部数据的字段名要核实。 git 插件给的是
{ name, username, email, commits }; 有些托管平台不提供username,也没有avatar。按猜的字段名读会渲染出空元素。 - 同一个功能别既用插件又自己实现。 主题曾同时启用
@vuepress/plugin-back-to-top并自己渲染按钮,于是每页两个返回顶部控件叠在一起。
新增一个选项
- 在
src/node/utils/resolveLocaleOptions.js里给它一个默认值。 - 在
types.js与configuration.md里写文档。 - 在客户端通过
useThemeLocaleData()读取它。 - 加测试:纯规则写进
scripts/test.mjs,会改变生成产物的写进scripts/test-build.mjs,只在浏览器里可见的(焦点、剪贴板、console)写进scripts/test-browser.mjs。 - 如果是「文档里让用户直接用」的组件,记得在
src/client/components/index.js里导出 —— 那里导出什么,client/config.js就注册什么。
新增一个组件
决定它要不要全局注册,再动手写。
| 类型 | 放哪 | 注册方式 |
|---|---|---|
内容组件(markdown 里直接用,如 WCard) | src/client/components/vue/ | 加进 components/index.js |
布局块(外壳的一部分,如 GithubNavbar) | src/client/components/ | 只在 page/index.js 导出,不注册全局 |
| 博客视图 | src/client/components/blog/ | 只在 blog/index.js 导出 |
| 首页块 | src/client/components/home/ | 只在 home/index.js 导出 |
为什么布局块不注册全局:全局注册会覆盖用户自己写的同名组件。GithubPage 是现实里 会撞的名字,主题自己占掉它就等于替用户做了决定。内容组件相反 —— 它们本来就只在 markdown 里出现,全局注册是让 <WCard> 能直接用的唯一方式。
写完之后三件事:
- 在
docs/components/里补用法,并在docs/components/README.md的对照表里标明 「全局」还是「要 import」。 - 纯逻辑抽到
utils/,在scripts/test.mjs里加用例。组件里的模板错误靠 lint 拦。 - 若组件会出现在生成页面上(博客视图、首页块),在
scripts/test-build.mjs里断言 它渲染出了预期的标记。
排查「构建成功但页面不对」
先在浏览器里量位置,而不是盯着构建日志 —— 下面这一类问题日志里一个字都不会有。
| 症状 | 先看哪里 |
|---|---|
| 滚动时导航栏跟着走 | 祖先元素有没有 overflow 不是 visible/clip(html、body 最常见) |
| 锚点落点偏下、被导航栏挡住 | scroll-padding-top 和 scroll-margin-top 是不是都设了(它们会相加) |
| 页内目录高亮差一格 | 高亮阈值的来源是否和锚点的 scroll-margin-top 一致 |
| 某个控件点不到 / 看不见 | 它是不是被挤出了视口,而 overflow-x 又把滚动条藏了 |
| 同一功能出现两个控件 | 插件与主题自带组件是不是都启用了 |
| 只在某个页面出问题 | 那个页面是不是走了一条独立的渲染分支(首页、404) |
# 量位置最快的方式:起一个静态服务器,在真实浏览器里读几何信息
npx http-server docs/.vuepress/dist -p 8080排查构建问题
构建打印 success 不代表没问题:渲染期异常会被 VuePress 吞掉,只有翻全量日志才看得见。
第一步永远是看日志,不是看退出码。
npm run docs:build 2>&1 | tee /tmp/build.log
grep -iE "error|warn|not defined|deprecated" /tmp/build.log按症状对照:
| 症状 | 常见原因 |
|---|---|
Could not resolve '../../lib/node/index.js' | lib/ 不存在。先 npm run build,或用 npm run docs:build(它已经先构建主题) |
window is not defined | setup 期间读了 window。挪进 onMounted |
el.addEventListener is not a function | 同上,useEventListener 在 SSR 阶段拿到的是 undefined |
Hydration ... mismatch | 服务端与客户端渲染不一致:SSR 吐了空节点再由客户端填(如 TOC),或某个 ref 从 false 起步、在 onMounted 才被纠正 |
| 标签页 404 但页面确实生成了 | slug 没通过 VuePress 的路径归一化。见下方 slugify 规则 |
Cannot find module '../rolldown-binding.*.node' | Node 版本低于 rolldown 绑定的要求,npm 静默跳过了 optionalDependency。scripts/ensure-rolldown-binding.mjs 会补齐,或把 Node 升到 22.12+ |
定位到具体改动后,优先给对应的纯函数补一条单测 —— 这类问题几乎都能收敛成一个可测的 规则。
风格
- 纯函数放在
utils/,只接受普通参数,因此不需要浏览器就能测试。测试套件大多指向这里。 - SCSS 只用 Primer token 名(
var(--github-*)),不写字面色值。 - 每个颜色 token 都要在
_variables.scss里有深色对应项。 - 注释解释为什么,而不是做了什么。一条不显然的规则应当带上它存在的原因,最好 连带它防止的症状一起写。
