跳到正文

开发文档

主题小到可以完整读完,但在改动之前了解内部构造是值得的:这里的多数坑不在代码本身,而 在某个值是从哪来的。

目录结构

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-githubconfig.js(Node 侧)githubTheme、构建期工具
vuepress-theme-github/client组件、用户页面组件、composables、纯函数

两者都由 npm run build 生成,它把 src/ 复制到 lib/。没有编译步骤:源码是纯 ESM 与 .vue 文件,由 VuePress 在站点构建时编译。

一个页面是怎样被渲染的

  1. <Page> 来自用户的站点;Layout 由 client/config.js 注册。
  2. Layout 读取 frontmatter 并选择视图:home、某个 blogView 值,或普通页面。
  3. 每个视图都包在同一套外壳里 —— skip link、导航栏、可选侧边栏、main landmark、 页内目录、页脚。
  4. 页内目录在挂载之后从 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 并自己渲染按钮,于是每页两个返回顶部控件叠在一起。

新增一个选项

  1. 在 src/node/utils/resolveLocaleOptions.js 里给它一个默认值。
  2. 在 types.js 与 configuration.md 里写文档。
  3. 在客户端通过 useThemeLocaleData() 读取它。
  4. 加测试:纯规则写进 scripts/test.mjs,会改变生成产物的写进 scripts/test-build.mjs,只在浏览器里可见的(焦点、剪贴板、console)写进 scripts/test-browser.mjs。
  5. 如果是「文档里让用户直接用」的组件,记得在 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> 能直接用的唯一方式。

写完之后三件事:

  1. 在 docs/components/ 里补用法,并在 docs/components/README.md 的对照表里标明 「全局」还是「要 import」。
  2. 纯逻辑抽到 utils/,在 scripts/test.mjs 里加用例。组件里的模板错误靠 lint 拦。
  3. 若组件会出现在生成页面上(博客视图、首页块),在 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 definedsetup 期间读了 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 里有深色对应项。
  • 注释解释为什么,而不是做了什么。一条不显然的规则应当带上它存在的原因,最好 连带它防止的症状一起写。