返回 slidev
021-consolidate-toc-tree.md
根目录 / plans / 021-consolidate-toc-tree.md
1 # Plan 021: Consolidate the two divergent `addToTree` TOC builders
2
3 > **Executor instructions**: Follow this plan step by step. Run every
4 > verification command and confirm the expected result. If anything in "STOP
5 > conditions" occurs, stop and report. When done, update the status row in
6 > `plans/README.md`.
7 >
8 > **Drift check (run first)**: `git diff --stat c63cb120..HEAD -- packages/client/composables/useTocTree.ts packages/slidev/node/commands/export.ts packages/parser/src`
9 > On a mismatch with the excerpts below, treat it as a STOP condition.
10
11 ## Status
12
13 - **Priority**: P2
14 - **Effort**: M
15 - **Risk**: MED
16 - **Depends on**: none (coordinate with 022, which tests export)
17 - **Category**: tech-debt
18 - **Planned at**: commit `c63cb120`, 2026-07-10
19
20 ## Why this matters
21
22 TOC-tree nesting is implemented **twice** — once for the in-app TOC and once for
23 the PDF outline — over the same `TocItem` shape, and the copies have **already
24 drifted**: the export copy has an extra `tree[tree.length-1].titleLevel < titleLevel`
25 guard the client copy lacks. So the app TOC and the exported PDF outline can nest
26 the same deck differently, and every future TOC fix must be applied in two
27 places. Extracting one shared pure builder removes the divergence.
28
29 ## Current state
30
31 **Client** — `packages/client/composables/useTocTree.ts:6-22`:
32 ```ts
33 function addToTree(tree: TocItem[], route: SlideRoute, level = 1) {
34 const titleLevel = route.meta.slide.level ?? level
35 if (titleLevel && titleLevel > level && tree.length > 0) { // ← no titleLevel comparison
36 addToTree(tree[tree.length - 1].children, route, level + 1)
37 }
38 else {
39 tree.push({ no: route.no, children: [], level, titleLevel,
40 path: getSlidePath(route.meta.slide?.frontmatter?.routeAlias ?? route.no, false),
41 hideInToc: Boolean(route.meta?.slide?.frontmatter?.hideInToc),
42 title: route.meta?.slide?.title })
43 }
44 }
45 ```
46
47 **Export** — `packages/slidev/node/commands/export.ts:51-67`:
48 ```ts
49 function addToTree(tree: TocItem[], info: SlideInfo, slideIndexes: Record<number, number>, level = 1) {
50 const titleLevel = info.level
51 if (titleLevel && titleLevel > level && tree.length > 0
52 && tree[tree.length - 1].titleLevel < titleLevel) { // ← extra guard here
53 addToTree(tree[tree.length - 1].children, info, slideIndexes, level + 1)
54 }
55 else {
56 tree.push({ no: info.index, children: [], level, titleLevel: titleLevel ?? level,
57 path: String(slideIndexes[info.index + 1]),
58 hideInToc: Boolean(info.frontmatter?.hideInToc), title: info.title })
59 }
60 }
61 ```
62 Differences: (a) the extra `titleLevel` comparison, (b) node source
63 (`SlideRoute` w/ reactive `meta` vs raw `SlideInfo`), (c) how `path`/`no` are
64 derived. `TocItem` is defined in `@slidev/types`. **Both** `@slidev/client` and
65 `@slidev/slidev` depend on `@slidev/parser` (`workspace:*`) — a good shared home.
66
67 ## Commands you will need
68
69 | Purpose | Command | Expected |
70 |---------|---------|----------|
71 | Install | `pnpm install` | exit 0 |
72 | Build | `pnpm build` | exit 0 |
73 | Test | `pnpm test` | pass (incl. new builder test) |
74 | Typecheck | `pnpm typecheck` | exit 0 |
75
76 ## Scope
77
78 **In scope**:
79 - `packages/parser/src/` — a new pure `buildTocTree` (+ its export in `index.ts`)
80 - `packages/client/composables/useTocTree.ts` — call the shared builder
81 - `packages/slidev/node/commands/export.ts` — call the shared builder
82 - A unit test for `buildTocTree`
83
84 **Out of scope**:
85 - The active-status/`filterTree` decoration in the client (keep as thin wrappers).
86 - The PDF `makeOutline` serialization (keep; it consumes the tree).
87 - Deciding *which* nesting behavior is correct beyond "make both consistent" (see
88 STOP conditions).
89
90 ## Git workflow
91
92 - Branch: `refactor/shared-toc-tree`.
93 - Conventional commit: `refactor: share TOC tree builder between client and export`.
94 - Do NOT push/PR unless instructed.
95
96 ## Steps
97
98 ### Step 1: Decide the canonical nesting rule
99
100 The two copies differ by the extra `tree[tree.length-1].titleLevel < titleLevel`
101 guard. **STOP and confirm** with the operator which is intended (the export guard
102 prevents nesting under a shallower-or-equal previous item and is the more correct
103 one). Default recommendation: adopt the export copy's guard as canonical.
104
105 ### Step 2: Add a pure, node-agnostic `buildTocTree`
106
107 In `@slidev/parser`, add a builder parameterized over a minimal item shape so both
108 callers can adapt their node type to it:
109 ```ts
110 export interface TocBuilderItem {
111 no: number
112 titleLevel?: number
113 title?: string
114 path: string
115 hideInToc?: boolean
116 }
117 export function buildTocTree(items: TocBuilderItem[]): TocItem[] {
118 const tree: TocItem[] = []
119 function add(nodes: TocItem[], item: TocBuilderItem, level = 1) {
120 const titleLevel = item.titleLevel ?? level
121 const last = nodes[nodes.length - 1]
122 if (titleLevel > level && last && last.titleLevel < titleLevel)
123 add(last.children, item, level + 1)
124 else
125 nodes.push({ no: item.no, children: [], level, titleLevel,
126 path: item.path, hideInToc: Boolean(item.hideInToc), title: item.title })
127 }
128 for (const item of items) add(tree, item)
129 return tree
130 }
131 ```
132 (Use the canonical rule from Step 1.)
133
134 ### Step 3: Adapt the client to use it
135
136 In `useTocTree.ts`, map each titled `SlideRoute` to a `TocBuilderItem`
137 (`no: route.no`, `titleLevel: route.meta.slide.level`, `title`, `path` via
138 `getSlidePath`, `hideInToc`) and call `buildTocTree`. Keep
139 `getTreeWithActiveStatuses`/`filterTree` as-is on the result.
140
141 ### Step 4: Adapt export to use it
142
143 In `export.ts`, replace the local `addToTree` reduce (`:562-566`) by mapping
144 titled `SlideInfo`s to `TocBuilderItem` (`no: info.index`,
145 `titleLevel: info.level`, `path: String(slideIndexes[info.index + 1])`, `title`,
146 `hideInToc`) and calling `buildTocTree`. Keep `makeOutline` consuming the result.
147
148 ### Step 5: Test the builder
149
150 Add a colocated parser test (e.g. `packages/parser/src/toc.test.ts`) covering:
151 flat list, nested by increasing `titleLevel`, and the guard case (a shallower
152 previous item must not receive a deeper child). Use `toMatchInlineSnapshot`.
153
154 **Verify**: `pnpm build && pnpm test` → new test passes; existing parser and
155 export-related snapshots unchanged (if any drift, it reflects the intentional
156 nesting-rule unification — confirm it's the Step 1 decision, not an accident).
157
158 ## Test plan
159
160 - Unit-test `buildTocTree` (pure) for flat/nested/guard cases — the single source
161 of truth for nesting.
162 - Client and export become thin adapters; their behavior is verified by build +
163 typecheck and (for export) plan 022's tests if present.
164
165 ## Done criteria
166
167 - [ ] One `buildTocTree` in `@slidev/parser`, used by both client and export
168 - [ ] The two former `addToTree` copies are gone (no duplicated nesting logic)
169 - [ ] Nesting rule is consistent between app TOC and PDF outline
170 - [ ] `buildTocTree` is unit-tested
171 - [ ] `pnpm build && pnpm typecheck && pnpm test` pass
172 - [ ] Only in-scope files modified (`git status`)
173 - [ ] `plans/README.md` status row updated
174
175 ## STOP conditions
176
177 Stop and report if:
178
179 - Step 1's canonical-rule decision is unresolved.
180 - Unifying the rule changes an existing export/PDF-outline snapshot in a way the
181 operator hasn't approved.
182 - `@slidev/client` cannot import the new `@slidev/parser` export without a build
183 ordering problem — report the resolution/order error.
184
185 ## Maintenance notes
186
187 - Future TOC nesting changes now happen in exactly one place.
188 - Reviewer: confirm both adapters map their node type to `TocBuilderItem`
189 faithfully (especially `no`/`path` derivation, which differs by caller).
190
190 lines MARKDOWN