feat(theme): support dynamic headers and nesting in outline (#1281)

Co-authored-by: fi3ework <fi3ework@gmail.com>
pull/1290/head
Divyansh Singh 2 years ago committed by GitHub
parent 8d6a20d665
commit 288aa48b92
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,166 @@
import { describe, test, expect } from 'vitest'
import * as outline from 'client/theme-default/composables/outline'
describe('client/theme-default/composables/outline', () => {
describe('resolveHeader', () => {
test('levels range', () => {
expect(
outline.resolveHeaders(
[
{
level: 2,
title: 'h2 - 1',
link: '#h2-1'
},
{
level: 3,
title: 'h3 - 1',
link: '#h3-1'
}
],
[2, 3]
)
).toEqual([
{
level: 2,
title: 'h2 - 1',
link: '#h2-1',
children: [
{
level: 3,
title: 'h3 - 1',
link: '#h3-1'
}
]
}
])
})
test('specific level', () => {
expect(
outline.resolveHeaders(
[
{
level: 2,
title: 'h2 - 1',
link: '#h2-1'
},
{
level: 3,
title: 'h3 - 1',
link: '#h3-1'
}
],
2
)
).toEqual([
{
level: 2,
title: 'h2 - 1',
link: '#h2-1'
}
])
})
test('complex deep', () => {
expect(
outline.resolveHeaders(
[
{
level: 2,
title: 'h2 - 1',
link: '#h2-1'
},
{
level: 3,
title: 'h3 - 1',
link: '#h3-1'
},
{
level: 4,
title: 'h4 - 1',
link: '#h4-1'
},
{
level: 3,
title: 'h3 - 2',
link: '#h3-2'
},
{
level: 4,
title: 'h4 - 2',
link: '#h4-2'
},
{
level: 2,
title: 'h2 - 2',
link: '#h2-2'
},
{
level: 3,
title: 'h3 - 3',
link: '#h3-3'
},
{
level: 4,
title: 'h4 - 3',
link: '#h4-3'
}
],
'deep'
)
).toEqual([
{
level: 2,
title: 'h2 - 1',
link: '#h2-1',
children: [
{
level: 3,
title: 'h3 - 1',
link: '#h3-1',
children: [
{
level: 4,
title: 'h4 - 1',
link: '#h4-1'
}
]
},
{
level: 3,
title: 'h3 - 2',
link: '#h3-2',
children: [
{
level: 4,
title: 'h4 - 2',
link: '#h4-2'
}
]
}
]
},
{
level: 2,
title: 'h2 - 2',
link: '#h2-2',
children: [
{
level: 3,
title: 'h3 - 3',
link: '#h3-3',
children: [
{
level: 4,
title: 'h4 - 3',
link: '#h4-3'
}
]
}
]
}
])
})
})
})

@ -9,6 +9,12 @@ export default defineConfig({
lastUpdated: true, lastUpdated: true,
cleanUrls: 'without-subfolders', cleanUrls: 'without-subfolders',
markdown: {
headers: {
level: [0, 0]
}
},
themeConfig: { themeConfig: {
nav: nav(), nav: nav(),

@ -212,3 +212,10 @@ If you want the right aside component in `doc` layout not to be shown, set this
aside: false aside: false
--- ---
``` ```
## outline
- Type: `number | [number, number] | 'deep' | false`
- Default: `2`
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.

@ -135,6 +135,13 @@ interface SidebarItem {
} }
``` ```
## outline
- Type: `number | [number, number] | 'deep' | false`
- Default: `2`
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.
## outlineTitle ## outlineTitle
- Type: `string` - Type: `string`

@ -18,15 +18,27 @@ describe('outline', () => {
expect(outlineLinksContent).toEqual([ expect(outlineLinksContent).toEqual([
'h2 - 1', 'h2 - 1',
'h3 - 1', 'h3 - 1',
'h4 - 1',
'h3 - 2', 'h3 - 2',
'h4 - 2',
'h2 - 2', 'h2 - 2',
'h3 - 3' 'h3 - 3',
'h4 - 3'
]) ])
const linkHrefs = await outlineLinksLocator.evaluateAll((element) => const linkHrefs = await outlineLinksLocator.evaluateAll((element) =>
element.map((element) => element.getAttribute('href')) element.map((element) => element.getAttribute('href'))
) )
expect(linkHrefs).toEqual(['#h2-1', '#h3-1', '#h3-2', '#h2-2', '#h3-3']) expect(linkHrefs).toEqual([
'#h2-1',
'#h3-1',
'#h4-1',
'#h3-2',
'#h4-2',
'#h2-2',
'#h3-3',
'#h4-3'
])
}) })
}) })

@ -1,10 +1,16 @@
import { defineComponent, h } from 'vue' import { defineComponent, h, onUpdated } from 'vue'
import { useRoute } from '../router.js' import { useRoute } from '../router.js'
export const Content = defineComponent({ export const Content = defineComponent({
name: 'VitePressContent', name: 'VitePressContent',
setup() { props: {
onContentUpdated: Function
},
setup(props) {
const route = useRoute() const route = useRoute()
onUpdated(() => {
props.onContentUpdated?.()
})
return () => return () =>
h('div', { style: { position: 'relative' } }, [ h('div', { style: { position: 'relative' } }, [
route.component ? h(route.component) : null route.component ? h(route.component) : null

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vitepress' import { useRoute } from 'vitepress'
import { computed, provide, ref } from 'vue'
import { useSidebar } from '../composables/sidebar.js' import { useSidebar } from '../composables/sidebar.js'
import VPDocAside from './VPDocAside.vue' import VPDocAside from './VPDocAside.vue'
import VPDocFooter from './VPDocFooter.vue' import VPDocFooter from './VPDocFooter.vue'
@ -11,6 +11,9 @@ const { hasSidebar, hasAside } = useSidebar()
const pageName = computed(() => const pageName = computed(() =>
route.path.replace(/[./]+/g, '_').replace(/_html$/, '') route.path.replace(/[./]+/g, '_').replace(/_html$/, '')
) )
const onContentUpdated = ref()
provide('onContentUpdated', onContentUpdated)
</script> </script>
<template> <template>
@ -39,7 +42,7 @@ const pageName = computed(() =>
<div class="content-container"> <div class="content-container">
<slot name="doc-before" /> <slot name="doc-before" />
<main class="main"> <main class="main">
<Content class="vp-doc" :class="pageName" /> <Content class="vp-doc" :class="pageName" :onContentUpdated="onContentUpdated" />
</main> </main>
<slot name="doc-footer-before" /> <slot name="doc-footer-before" />
<VPDocFooter /> <VPDocFooter />

@ -3,7 +3,7 @@ import { useData } from 'vitepress'
import VPDocAsideOutline from './VPDocAsideOutline.vue' import VPDocAsideOutline from './VPDocAsideOutline.vue'
import VPDocAsideCarbonAds from './VPDocAsideCarbonAds.vue' import VPDocAsideCarbonAds from './VPDocAsideCarbonAds.vue'
const { page, theme } = useData() const { theme } = useData()
</script> </script>
<template> <template>
@ -11,7 +11,7 @@ const { page, theme } = useData()
<slot name="aside-top" /> <slot name="aside-top" />
<slot name="aside-outline-before" /> <slot name="aside-outline-before" />
<VPDocAsideOutline v-if="page.headers.length" /> <VPDocAsideOutline />
<slot name="aside-outline-after" /> <slot name="aside-outline-after" />
<div class="spacer" /> <div class="spacer" />

@ -1,14 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import { useData } from 'vitepress' import { useData } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { computed, inject, ref, type Ref } from 'vue'
import { import {
useOutline, getHeaders,
useActiveAnchor useActiveAnchor,
type MenuItem
} from '../composables/outline.js' } from '../composables/outline.js'
import VPDocAsideOutlineItem from './VPDocAsideOutlineItem.vue'
const { page, frontmatter, theme } = useData() const { frontmatter, theme } = useData()
const { hasOutline } = useOutline() const pageOutline = computed<DefaultTheme.Config['outline']>(
() => frontmatter.value.outline ?? theme.value.outline
)
const onContentUpdated = inject('onContentUpdated') as Ref<() => void>
onContentUpdated.value = () => {
headers.value = getHeaders(pageOutline.value)
}
const headers = ref<MenuItem[]>([])
const hasOutline = computed(() => headers.value.length > 0)
const container = ref() const container = ref()
const marker = ref() const marker = ref()
@ -37,23 +50,7 @@ function handleClick({ target: el }: Event) {
<span class="visually-hidden" id="doc-outline-aria-label"> <span class="visually-hidden" id="doc-outline-aria-label">
Table of Contents for current page Table of Contents for current page
</span> </span>
<VPDocAsideOutlineItem :headers="headers" :root="true" :onClick="handleClick" />
<ul class="root">
<li
v-for="{ title, link, children } in page.headers"
>
<a class="outline-link" :href="link" @click="handleClick">
{{ title }}
</a>
<ul v-if="children && frontmatter.outline === 'deep'">
<li v-for="{ title, link } in children">
<a class="outline-link nested" :href="link" @click="handleClick">
{{ title }}
</a>
</li>
</ul>
</li>
</ul>
</nav> </nav>
</div> </div>
</div> </div>
@ -94,29 +91,4 @@ function handleClick({ target: el }: Event) {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
} }
.outline-link {
display: block;
line-height: 28px;
color: var(--vp-c-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.5s;
}
.outline-link:hover,
.outline-link.active {
color: var(--vp-c-text-1);
transition: color 0.25s;
}
.outline-link.nested {
padding-left: 13px;
}
.root {
position: relative;
z-index: 1;
}
</style> </style>

@ -0,0 +1,51 @@
<script setup lang="ts">
import type { MenuItem } from '../composables/outline.js'
defineProps<{
headers: MenuItem[]
onClick: (e: MouseEvent) => void
root?: boolean
}>()
</script>
<template>
<ul :class="root ? 'root' : 'nested'">
<li v-for="{ children, link, title } in headers">
<a class="outline-link" :href="link" @click="onClick">{{ title }}</a>
<template v-if="children?.length">
<VPDocAsideOutlineItem :headers="children" :onClick="onClick" />
</template>
</li>
</ul>
</template>
<style scoped>
.root {
position: relative;
z-index: 1;
}
.nested {
padding-left: 13px;
}
.outline-link {
display: block;
line-height: 28px;
color: var(--vp-c-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.5s;
}
.outline-link:hover,
.outline-link.active {
color: var(--vp-c-text-1);
transition: color 0.25s;
}
.outline-link.nested {
padding-left: 13px;
}
</style>

@ -1,21 +1,87 @@
import { Ref, computed, onMounted, onUpdated, onUnmounted } from 'vue' import type { DefaultTheme } from 'vitepress/theme'
import { useData } from 'vitepress' import { onMounted, onUnmounted, onUpdated, type Ref } from 'vue'
import type { Header } from '../../shared.js'
import { useAside } from '../composables/aside.js' import { useAside } from '../composables/aside.js'
import { throttleAndDebounce } from '../support/utils.js' import { throttleAndDebounce } from '../support/utils.js'
// magic number to avoid repeated retrieval // magic number to avoid repeated retrieval
const PAGE_OFFSET = 56 const PAGE_OFFSET = 71
export type MenuItem = Omit<Header, 'slug' | 'children'> & {
children?: MenuItem[]
}
export function getHeaders(pageOutline: DefaultTheme.Config['outline']) {
if (pageOutline === false) return []
let updatedHeaders: MenuItem[] = []
document
.querySelectorAll<HTMLHeadingElement>('h2, h3, h4, h5, h6')
.forEach((el) => {
if (el.textContent && el.id) {
updatedHeaders.push({
level: Number(el.tagName[1]),
title: el.innerText.split('\n')[0],
link: `#${el.id}`
})
}
})
return resolveHeaders(updatedHeaders, pageOutline)
}
export function resolveHeaders(
headers: MenuItem[],
levelsRange: Exclude<DefaultTheme.Config['outline'], false> = 2
) {
const levels: [number, number] =
typeof levelsRange === 'number'
? [levelsRange, levelsRange]
: levelsRange === 'deep'
? [2, 6]
: levelsRange
return groupHeaders(headers, levels)
}
export function useOutline() { function groupHeaders(headers: MenuItem[], levelsRange: [number, number]) {
const { page } = useData() const result: MenuItem[] = []
const hasOutline = computed(() => { headers = headers.map((h) => ({ ...h }))
return page.value.headers.length > 0 headers.forEach((h, index) => {
if (h.level >= levelsRange[0] && h.level <= levelsRange[1]) {
if (addToParent(index, headers, levelsRange)) {
result.push(h)
}
}
}) })
return { return result
hasOutline
} }
function addToParent(
currIndex: number,
headers: MenuItem[],
levelsRange: [number, number]
) {
if (currIndex === 0) {
return true
}
const currentHeader = headers[currIndex]
for (let index = currIndex - 1; index >= 0; index--) {
const header = headers[index]
if (
header.level < currentHeader.level &&
header.level >= levelsRange[0] &&
header.level <= levelsRange[1]
) {
if (header.children == null) header.children = []
header.children.push(currentHeader)
return false
}
}
return true
} }
export function useActiveAnchor( export function useActiveAnchor(
@ -108,7 +174,7 @@ export function useActiveAnchor(
} }
function getAnchorTop(anchor: HTMLAnchorElement): number { function getAnchorTop(anchor: HTMLAnchorElement): number {
return anchor.parentElement!.offsetTop - PAGE_OFFSET - 15 return anchor.parentElement!.offsetTop - PAGE_OFFSET
} }
function isAnchorActive( function isAnchorActive(

@ -13,6 +13,13 @@ export namespace DefaultTheme {
*/ */
siteTitle?: string | false siteTitle?: string | false
/**
* Custom header levels of outline in the aside component.
*
* @default 2
*/
outline?: number | [number, number] | 'deep' | false
/** /**
* Custom outline title in the aside component. * Custom outline title in the aside component.
* *

Loading…
Cancel
Save