plans/021-consolidate-toc-tree.md
addToTree TOC buildersExecutor instructions: Follow this plan step by step. Run every verification command and confirm the expected result. If anything in "STOP conditions" occurs, stop and report. When done, update the status row in
plans/README.md.Drift check (run first):
git diff --stat c63cb120..HEAD -- packages/client/composables/useTocTree.ts packages/slidev/node/commands/export.ts packages/parser/srcOn a mismatch with the excerpts below, treat it as a STOP condition.
c63cb120, 2026-07-10TOC-tree nesting is implemented twice — once for the in-app TOC and once for
the PDF outline — over the same TocItem shape, and the copies have already
drifted: the export copy has an extra tree[tree.length-1].titleLevel < titleLevel
guard the client copy lacks. So the app TOC and the exported PDF outline can nest
the same deck differently, and every future TOC fix must be applied in two
places. Extracting one shared pure builder removes the divergence.
Client — packages/client/composables/useTocTree.ts:6-22:
function addToTree(tree: TocItem[], route: SlideRoute, level = 1) {
const titleLevel = route.meta.slide.level ?? level
if (titleLevel && titleLevel > level && tree.length > 0) { // ← no titleLevel comparison
addToTree(tree[tree.length - 1].children, route, level + 1)
}
else {
tree.push({ no: route.no, children: [], level, titleLevel,
path: getSlidePath(route.meta.slide?.frontmatter?.routeAlias ?? route.no, false),
hideInToc: Boolean(route.meta?.slide?.frontmatter?.hideInToc),
title: route.meta?.slide?.title })
}
}
Export — packages/slidev/node/commands/export.ts:51-67:
function addToTree(tree: TocItem[], info: SlideInfo, slideIndexes: Record<number, number>, level = 1) {
const titleLevel = info.level
if (titleLevel && titleLevel > level && tree.length > 0
&& tree[tree.length - 1].titleLevel < titleLevel) { // ← extra guard here
addToTree(tree[tree.length - 1].children, info, slideIndexes, level + 1)
}
else {
tree.push({ no: info.index, children: [], level, titleLevel: titleLevel ?? level,
path: String(slideIndexes[info.index + 1]),
hideInToc: Boolean(info.frontmatter?.hideInToc), title: info.title })
}
}
Differences: (a) the extra titleLevel comparison, (b) node source
(SlideRoute w/ reactive meta vs raw SlideInfo), (c) how path/no are
derived. TocItem is defined in @slidev/types. Both @slidev/client and
@slidev/slidev depend on @slidev/parser (workspace:*) — a good shared home.
| Purpose | Command | Expected |
|---|---|---|
| Install | pnpm install | exit 0 |
| Build | pnpm build | exit 0 |
| Test | pnpm test | pass (incl. new builder test) |
| Typecheck | pnpm typecheck | exit 0 |
In scope:
packages/parser/src/ — a new pure buildTocTree (+ its export in index.ts)packages/client/composables/useTocTree.ts — call the shared builderpackages/slidev/node/commands/export.ts — call the shared builderbuildTocTreeOut of scope:
filterTree decoration in the client (keep as thin wrappers).makeOutline serialization (keep; it consumes the tree).refactor/shared-toc-tree.refactor: share TOC tree builder between client and export.The two copies differ by the extra tree[tree.length-1].titleLevel < titleLevel
guard. STOP and confirm with the operator which is intended (the export guard
prevents nesting under a shallower-or-equal previous item and is the more correct
one). Default recommendation: adopt the export copy's guard as canonical.
buildTocTreeIn @slidev/parser, add a builder parameterized over a minimal item shape so both
callers can adapt their node type to it:
export interface TocBuilderItem {
no: number
titleLevel?: number
title?: string
path: string
hideInToc?: boolean
}
export function buildTocTree(items: TocBuilderItem[]): TocItem[] {
const tree: TocItem[] = []
function add(nodes: TocItem[], item: TocBuilderItem, level = 1) {
const titleLevel = item.titleLevel ?? level
const last = nodes[nodes.length - 1]
if (titleLevel > level && last && last.titleLevel < titleLevel)
add(last.children, item, level + 1)
else
nodes.push({ no: item.no, children: [], level, titleLevel,
path: item.path, hideInToc: Boolean(item.hideInToc), title: item.title })
}
for (const item of items) add(tree, item)
return tree
}
(Use the canonical rule from Step 1.)
In useTocTree.ts, map each titled SlideRoute to a TocBuilderItem
(no: route.no, titleLevel: route.meta.slide.level, title, path via
getSlidePath, hideInToc) and call buildTocTree. Keep
getTreeWithActiveStatuses/filterTree as-is on the result.
In export.ts, replace the local addToTree reduce (:562-566) by mapping
titled SlideInfos to TocBuilderItem (no: info.index,
titleLevel: info.level, path: String(slideIndexes[info.index + 1]), title,
hideInToc) and calling buildTocTree. Keep makeOutline consuming the result.
Add a colocated parser test (e.g. packages/parser/src/toc.test.ts) covering:
flat list, nested by increasing titleLevel, and the guard case (a shallower
previous item must not receive a deeper child). Use toMatchInlineSnapshot.
Verify: pnpm build && pnpm test → new test passes; existing parser and
export-related snapshots unchanged (if any drift, it reflects the intentional
nesting-rule unification — confirm it's the Step 1 decision, not an accident).
buildTocTree (pure) for flat/nested/guard cases — the single source
of truth for nesting.buildTocTree in @slidev/parser, used by both client and exportaddToTree copies are gone (no duplicated nesting logic)buildTocTree is unit-testedpnpm build && pnpm typecheck && pnpm test passgit status)plans/README.md status row updatedStop and report if:
@slidev/client cannot import the new @slidev/parser export without a build
ordering problem — report the resolution/order error.TocBuilderItem
faithfully (especially no/path derivation, which differs by caller).