feat: 基于原生目录折叠深层分支并保留前两层 - #152
Conversation
Deploying documents with
|
| Latest commit: |
5bf0d69
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://492194d5.documents-dq4.pages.dev |
| Branch Preview URL: | https://codex-adaptive-page-outline.documents-dq4.pages.dev |
m1ngsama
left a comment
There was a problem hiding this comment.
Thanks for the careful write-up and the verification notes — the underlying need is real, long tutorial pages do want a deeper outline. But I can't merge this as it stands: two of the defects below mean the feature does not actually work as described, and the approach itself costs more than it returns for this site.
Scope and positioning
.vitepress/theme/is currently 17 files / 794 lines; this PR adds 276 lines in one file, growing the theme layer by ~35% for a single interaction nicety.- Roughly 90% of the visible gain comes from the one-line
outline.level: [2, 4]inconfig.mts; the component only adds "collapse the non-active sections". Layout.vuehides.VPDocAsideOutlinewithdisplay: noneinstead of replacing it, so the native component stays mounted and itsuseActiveAnchorscroll handler keeps running alongside the new one — two outline engines on every page.- The native outline is built from build-time page data and ships in the static HTML;
CurrentOutlinescans the DOM inonMounted, so the desktop outline is empty in the build output. That is a real regression for a static docs site.
Cross-device behaviour
outline.level: [2, 4]is global and also feeds the mobileVPLocalNavdropdown (getHeaders(frontmatter.outline ?? theme.outline)). The default is2, so mobile currently lists onlyh2; after this change it flattensh2/h3/h4into one dropdown — 53 entries ontutorial/manual/windows-from-scratch.md— with none of the collapsing this PR adds. The PR notes mobile keeps the native outline, but not that the native outline gets several times longer.- The aside is
display: nonebelow 1280px, yet the component still mounts there. On phones, tablets and narrow desktop windows the scroll handler runs onegetBoundingClientRect()per heading per animation frame — 53 forced layout reads per frame on that same page — for UI nobody can see. VitePress guards this withif (!isAsideEnabled.value) returnplusthrottleAndDebounce(fn, 100); neither was ported. - Desktop and mobile will list different entries: desktop uses the custom grouping (which drops level-skipping headings), mobile uses the native
buildTree(which keeps them).
Defects, most severe first
CurrentOutline.vue:228— the active marker never renders..current-outline-linkis bothposition: relativeandoverflow: hidden(needed for the ellipsis), so its own::beforeatleft: -17pxis clipped by its containing block. Even withoutoverflow,-17pxonly lines up for level two; the nested<ol>s add 14px and 14+12px of padding, putting the bar inside the text column for levels three and four. VitePress sidesteps this by making.outline-markera sibling in.content, not a child of the link.CurrentOutline.vue:70— nothing in the last viewport ever activates. The test isabsoluteTop <= scrollY + getScrollOffset() + 4, and at maximum scrollscrollY = docHeight - innerHeight, so any heading in the finalinnerHeight - scrollOffsetpixels (~700px on a laptop) can never match. Since children only render whenactiveSectionId === section.id, the last##of every page never expands its subheadings — exactly the case this PR is meant to serve. VitePress'ssetActiveLinkhas an explicitisBottombranch for this; the port dropped it.CurrentOutline.vue:55-61— level-skipping headings are silently dropped:h4is kept only after anh3,h3only after anh2.archived/2023/developer/2023-10-newcomer-training.md:20,26hits this today —#### 基础and#### 进阶sit under a list-nested## Blog From Scratchwith no###between, so both vanish from the desktop outline with no fallback, since the native one is hidden unconditionally.CurrentOutline.vue:41— theignore-headerguard mis-parents. On an ignoredh2the loopcontinues without resettingcurrentSection/currentSubsection, so every followingh3is pushed into the previoush2and appears when the wrong section is active. No page uses{.ignore-header}yet, so it is latent, but the guard as written is worse than none. VitePress marks the whole ignored subtree instead.CurrentOutline.vue:127-128— noisAsideEnabledguard and no throttle on thescroll/resizelisteners (see cross-device note above).CurrentOutline.vue:29-33—headingTitlestrips only.header-anchor; VitePress'sserializeHeaderalso dropsVPBadge,footnote-refandignore-headerchildren. No heading contains a badge today, so this is a robustness gap rather than a live bug.
Smaller points
- 276 new lines with no test. The repo has 8 vitest files; the
readHeadingsgrouping is plain, testable logic. - Active items are missing
aria-current, which the native outline sets. - Only
frontmatter.outline === falseis honoured; a per-page level override is ignored. - The checklist marks browser verification of the two→three→four expansion, but items 1 and 2 are visible at a glance in a browser, so that pass needs redoing.
- The four
verify-distfailures were attributed to backslash paths on Windows and left unconfirmed; please settle that on Linux CI before the next round.
Suggested path
My preference is the minimal one: keep outline.level: [2, 4] in config.mts and drop the component. One line gets the deeper outline, with zero maintenance, identical desktop and mobile output, and the static HTML intact. The cost is no collapsing, which I think is the right trade for a site optimised for being correct and durable rather than clever. If the longer mobile dropdown is a concern we can settle on [2, 3] separately.
If you want to keep the collapsing behaviour, please rebuild it on the native data rather than a DOM scan: reuse getHeaders/buildTree and add collapsing only in the render layer, then add the isAsideEnabled guard, the 100ms throttle and the isBottom branch, move the marker to a sibling of the links, and cover the grouping with a unit test. That keeps the two devices consistent and the build-time outline intact.
Either way the direction is worth having — happy to review the follow-up.


Summary
长文目录需要展示更深的标题,同时保留前两层供快速定位。本次直接使用 VitePress 的原生目录树和 active-anchor 引擎,仅在 CSS 渲染层折叠深层分支:桌面前两层始终显示,第三层及以下随当前活动分支或键盘焦点展开。移动端固定显示原生树的前两层。
默认标题范围为 h2–h4;跳级标题沿用原生 buildTree 的提升规则。没有自定义 DOM 扫描器、额外的 scroll/resize 监听器,也没有隐藏另一个仍在运行的桌面目录。
原生 useActiveAnchor 负责 isBottom 激活末尾标题、100ms throttleAndDebounce、isAsideEnabled 可见性判断和独立的 outline-marker。深层链接保留在 DOM 中,原生引擎添加 active 类后 CSS 立即展开对应分支,然后原生指示条测量位置。没有随滚动执行的高度动画;不支持 :has 的浏览器回退到完整原生桌面目录。
Scope
Checklist
Verification
本地使用已安装依赖的 CLI(Node 24、独立 LF 检出);标准 Node 22 / pnpm 9 环境由 Linux CI 验证。
新增 7 项原生引擎回归测试,直接调用安装的 VitePress 代码,覆盖跳级标题、忽略子树、页面级范围/禁用、标题序列化、底部激活与指示条、隐藏侧栏不测量、100ms 节流及尾随调用、监听器卸载。Vitest 配置仅增加对 VitePress 浏览器模块的转换,以解析其无扩展名的导入。
Notes
保留 wen-templari 提出的前两层常驻,并按 m1ngsama 建议复用原生数据、只修改渲染层。生产侧新增的逻辑仅为样式,不增加自定义 Vue 组件或 JS 滚动处理。
源码核实:VitePress 1.6.4 的原生 getHeaders 在 onContentUpdated 中读取标题,并非构建时生成完整目录;本 PR 保持原生 SSR/客户端行为,不另行承诺静态目录。后续升级 VitePress 时需确认目录 DOM 类名和原生引擎契约,现有回归测试用于检测行为变化。