Skip to content

Commit 288aa48

Browse files
brc-ddfi3ework
andauthored
feat(theme): support dynamic headers and nesting in outline (#1281)
Co-authored-by: fi3ework <fi3ework@gmail.com>
1 parent 8d6a20d commit 288aa48

File tree

12 files changed

+368
-65
lines changed

12 files changed

+368
-65
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { describe, test, expect } from 'vitest'
2+
import * as outline from 'client/theme-default/composables/outline'
3+
4+
describe('client/theme-default/composables/outline', () => {
5+
describe('resolveHeader', () => {
6+
test('levels range', () => {
7+
expect(
8+
outline.resolveHeaders(
9+
[
10+
{
11+
level: 2,
12+
title: 'h2 - 1',
13+
link: '#h2-1'
14+
},
15+
{
16+
level: 3,
17+
title: 'h3 - 1',
18+
link: '#h3-1'
19+
}
20+
],
21+
[2, 3]
22+
)
23+
).toEqual([
24+
{
25+
level: 2,
26+
title: 'h2 - 1',
27+
link: '#h2-1',
28+
children: [
29+
{
30+
level: 3,
31+
title: 'h3 - 1',
32+
link: '#h3-1'
33+
}
34+
]
35+
}
36+
])
37+
})
38+
39+
test('specific level', () => {
40+
expect(
41+
outline.resolveHeaders(
42+
[
43+
{
44+
level: 2,
45+
title: 'h2 - 1',
46+
link: '#h2-1'
47+
},
48+
{
49+
level: 3,
50+
title: 'h3 - 1',
51+
link: '#h3-1'
52+
}
53+
],
54+
2
55+
)
56+
).toEqual([
57+
{
58+
level: 2,
59+
title: 'h2 - 1',
60+
link: '#h2-1'
61+
}
62+
])
63+
})
64+
65+
test('complex deep', () => {
66+
expect(
67+
outline.resolveHeaders(
68+
[
69+
{
70+
level: 2,
71+
title: 'h2 - 1',
72+
link: '#h2-1'
73+
},
74+
{
75+
level: 3,
76+
title: 'h3 - 1',
77+
link: '#h3-1'
78+
},
79+
{
80+
level: 4,
81+
title: 'h4 - 1',
82+
link: '#h4-1'
83+
},
84+
{
85+
level: 3,
86+
title: 'h3 - 2',
87+
link: '#h3-2'
88+
},
89+
{
90+
level: 4,
91+
title: 'h4 - 2',
92+
link: '#h4-2'
93+
},
94+
{
95+
level: 2,
96+
title: 'h2 - 2',
97+
link: '#h2-2'
98+
},
99+
{
100+
level: 3,
101+
title: 'h3 - 3',
102+
link: '#h3-3'
103+
},
104+
{
105+
level: 4,
106+
title: 'h4 - 3',
107+
link: '#h4-3'
108+
}
109+
],
110+
'deep'
111+
)
112+
).toEqual([
113+
{
114+
level: 2,
115+
title: 'h2 - 1',
116+
link: '#h2-1',
117+
children: [
118+
{
119+
level: 3,
120+
title: 'h3 - 1',
121+
link: '#h3-1',
122+
children: [
123+
{
124+
level: 4,
125+
title: 'h4 - 1',
126+
link: '#h4-1'
127+
}
128+
]
129+
},
130+
{
131+
level: 3,
132+
title: 'h3 - 2',
133+
link: '#h3-2',
134+
children: [
135+
{
136+
level: 4,
137+
title: 'h4 - 2',
138+
link: '#h4-2'
139+
}
140+
]
141+
}
142+
]
143+
},
144+
{
145+
level: 2,
146+
title: 'h2 - 2',
147+
link: '#h2-2',
148+
children: [
149+
{
150+
level: 3,
151+
title: 'h3 - 3',
152+
link: '#h3-3',
153+
children: [
154+
{
155+
level: 4,
156+
title: 'h4 - 3',
157+
link: '#h4-3'
158+
}
159+
]
160+
}
161+
]
162+
}
163+
])
164+
})
165+
})
166+
})

docs/.vitepress/config.ts

+6
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ export default defineConfig({
99
lastUpdated: true,
1010
cleanUrls: 'without-subfolders',
1111

12+
markdown: {
13+
headers: {
14+
level: [0, 0]
15+
}
16+
},
17+
1218
themeConfig: {
1319
nav: nav(),
1420

docs/config/frontmatter-configs.md

+7
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,10 @@ If you want the right aside component in `doc` layout not to be shown, set this
212212
aside: false
213213
---
214214
```
215+
216+
## outline
217+
218+
- Type: `number | [number, number] | 'deep' | false`
219+
- Default: `2`
220+
221+
The levels of header in the outline to display for the page. It's same as [config.themeConfig.outline](../config/theme-configs#outline), and it overrides the theme config.

docs/config/theme-configs.md

+7
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ interface SidebarItem {
135135
}
136136
```
137137

138+
## outline
139+
140+
- Type: `number | [number, number] | 'deep' | false`
141+
- Default: `2`
142+
143+
The levels of header to display in the outline. You can specify a particular level by passing a number, or you can provide a level range by passing a tuple containing the bottom and upper limits. When passing `'deep'` which equals `[2, 6]`, all header levels are shown in the outline except `h1`. Set `false` to hide outline.
144+
138145
## outlineTitle
139146

140147
- Type: `string`

examples/configured/__test__/outline.spec.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,27 @@ describe('outline', () => {
1818
expect(outlineLinksContent).toEqual([
1919
'h2 - 1',
2020
'h3 - 1',
21+
'h4 - 1',
2122
'h3 - 2',
23+
'h4 - 2',
2224
'h2 - 2',
23-
'h3 - 3'
25+
'h3 - 3',
26+
'h4 - 3'
2427
])
2528

2629
const linkHrefs = await outlineLinksLocator.evaluateAll((element) =>
2730
element.map((element) => element.getAttribute('href'))
2831
)
2932

30-
expect(linkHrefs).toEqual(['#h2-1', '#h3-1', '#h3-2', '#h2-2', '#h3-3'])
33+
expect(linkHrefs).toEqual([
34+
'#h2-1',
35+
'#h3-1',
36+
'#h4-1',
37+
'#h3-2',
38+
'#h4-2',
39+
'#h2-2',
40+
'#h3-3',
41+
'#h4-3'
42+
])
3143
})
3244
})

src/client/app/components/Content.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1-
import { defineComponent, h } from 'vue'
1+
import { defineComponent, h, onUpdated } from 'vue'
22
import { useRoute } from '../router.js'
33

44
export const Content = defineComponent({
55
name: 'VitePressContent',
6-
setup() {
6+
props: {
7+
onContentUpdated: Function
8+
},
9+
setup(props) {
710
const route = useRoute()
11+
onUpdated(() => {
12+
props.onContentUpdated?.()
13+
})
814
return () =>
915
h('div', { style: { position: 'relative' } }, [
1016
route.component ? h(route.component) : null

src/client/theme-default/components/VPDoc.vue

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script setup lang="ts">
2-
import { computed } from 'vue'
32
import { useRoute } from 'vitepress'
3+
import { computed, provide, ref } from 'vue'
44
import { useSidebar } from '../composables/sidebar.js'
55
import VPDocAside from './VPDocAside.vue'
66
import VPDocFooter from './VPDocFooter.vue'
@@ -11,6 +11,9 @@ const { hasSidebar, hasAside } = useSidebar()
1111
const pageName = computed(() =>
1212
route.path.replace(/[./]+/g, '_').replace(/_html$/, '')
1313
)
14+
15+
const onContentUpdated = ref()
16+
provide('onContentUpdated', onContentUpdated)
1417
</script>
1518

1619
<template>
@@ -39,7 +42,7 @@ const pageName = computed(() =>
3942
<div class="content-container">
4043
<slot name="doc-before" />
4144
<main class="main">
42-
<Content class="vp-doc" :class="pageName" />
45+
<Content class="vp-doc" :class="pageName" :onContentUpdated="onContentUpdated" />
4346
</main>
4447
<slot name="doc-footer-before" />
4548
<VPDocFooter />

src/client/theme-default/components/VPDocAside.vue

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ import { useData } from 'vitepress'
33
import VPDocAsideOutline from './VPDocAsideOutline.vue'
44
import VPDocAsideCarbonAds from './VPDocAsideCarbonAds.vue'
55
6-
const { page, theme } = useData()
6+
const { theme } = useData()
77
</script>
88

99
<template>
1010
<div class="VPDocAside">
1111
<slot name="aside-top" />
1212

1313
<slot name="aside-outline-before" />
14-
<VPDocAsideOutline v-if="page.headers.length" />
14+
<VPDocAsideOutline />
1515
<slot name="aside-outline-after" />
1616

1717
<div class="spacer" />

src/client/theme-default/components/VPDocAsideOutline.vue

+19-47
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
11
<script setup lang="ts">
2-
import { ref } from 'vue'
32
import { useData } from 'vitepress'
3+
import type { DefaultTheme } from 'vitepress/theme'
4+
import { computed, inject, ref, type Ref } from 'vue'
45
import {
5-
useOutline,
6-
useActiveAnchor
6+
getHeaders,
7+
useActiveAnchor,
8+
type MenuItem
79
} from '../composables/outline.js'
10+
import VPDocAsideOutlineItem from './VPDocAsideOutlineItem.vue'
811
9-
const { page, frontmatter, theme } = useData()
12+
const { frontmatter, theme } = useData()
1013
11-
const { hasOutline } = useOutline()
14+
const pageOutline = computed<DefaultTheme.Config['outline']>(
15+
() => frontmatter.value.outline ?? theme.value.outline
16+
)
17+
18+
const onContentUpdated = inject('onContentUpdated') as Ref<() => void>
19+
onContentUpdated.value = () => {
20+
headers.value = getHeaders(pageOutline.value)
21+
}
22+
23+
const headers = ref<MenuItem[]>([])
24+
const hasOutline = computed(() => headers.value.length > 0)
1225
1326
const container = ref()
1427
const marker = ref()
@@ -37,23 +50,7 @@ function handleClick({ target: el }: Event) {
3750
<span class="visually-hidden" id="doc-outline-aria-label">
3851
Table of Contents for current page
3952
</span>
40-
41-
<ul class="root">
42-
<li
43-
v-for="{ title, link, children } in page.headers"
44-
>
45-
<a class="outline-link" :href="link" @click="handleClick">
46-
{{ title }}
47-
</a>
48-
<ul v-if="children && frontmatter.outline === 'deep'">
49-
<li v-for="{ title, link } in children">
50-
<a class="outline-link nested" :href="link" @click="handleClick">
51-
{{ title }}
52-
</a>
53-
</li>
54-
</ul>
55-
</li>
56-
</ul>
53+
<VPDocAsideOutlineItem :headers="headers" :root="true" :onClick="handleClick" />
5754
</nav>
5855
</div>
5956
</div>
@@ -94,29 +91,4 @@ function handleClick({ target: el }: Event) {
9491
font-size: 13px;
9592
font-weight: 600;
9693
}
97-
98-
.outline-link {
99-
display: block;
100-
line-height: 28px;
101-
color: var(--vp-c-text-2);
102-
white-space: nowrap;
103-
overflow: hidden;
104-
text-overflow: ellipsis;
105-
transition: color 0.5s;
106-
}
107-
108-
.outline-link:hover,
109-
.outline-link.active {
110-
color: var(--vp-c-text-1);
111-
transition: color 0.25s;
112-
}
113-
114-
.outline-link.nested {
115-
padding-left: 13px;
116-
}
117-
118-
.root {
119-
position: relative;
120-
z-index: 1;
121-
}
12294
</style>

0 commit comments

Comments
 (0)