fix: ensure HMR works properly for page outline

The client-side based implementation in #1281 makes HMR update for the
outlines flaky and unreliable.

The headers payload for each page chunk is relatively cheap in return
for the correctness, since they are inlined as a JSON string.
pull/2044/head
Evan You 2 years ago
parent a7e3102e9d
commit 1457681484

@ -7,14 +7,23 @@ describe('client/theme-default/composables/outline', () => {
resolveHeaders( resolveHeaders(
[ [
{ {
level: 2, level: 1,
title: 'h2 - 1', title: 'h1 - 1',
link: '#h2-1' link: '#h1-1',
}, children: [
{ {
level: 3, level: 2,
title: 'h3 - 1', title: 'h2 - 1',
link: '#h3-1' link: '#h2-1',
children: [
{
level: 3,
title: 'h3 - 1',
link: '#h3-1'
}
]
}
]
} }
], ],
[2, 3] [2, 3]
@ -42,12 +51,14 @@ describe('client/theme-default/composables/outline', () => {
{ {
level: 2, level: 2,
title: 'h2 - 1', title: 'h2 - 1',
link: '#h2-1' link: '#h2-1',
}, children: [
{ {
level: 3, level: 3,
title: 'h3 - 1', title: 'h3 - 1',
link: '#h3-1' link: '#h3-1'
}
]
} }
], ],
2 2
@ -68,42 +79,52 @@ describe('client/theme-default/composables/outline', () => {
{ {
level: 2, level: 2,
title: 'h2 - 1', title: 'h2 - 1',
link: '#h2-1' link: '#h2-1',
}, children: [
{ {
level: 3, level: 3,
title: 'h3 - 1', title: 'h3 - 1',
link: '#h3-1' link: '#h3-1',
}, children: [
{ {
level: 4, level: 4,
title: 'h4 - 1', title: 'h4 - 1',
link: '#h4-1' link: '#h4-1'
}, }
{ ]
level: 3, },
title: 'h3 - 2', {
link: '#h3-2' level: 3,
}, title: 'h3 - 2',
{ link: '#h3-2',
level: 4, children: [
title: 'h4 - 2', {
link: '#h4-2' level: 4,
title: 'h4 - 2',
link: '#h4-2'
}
]
}
]
}, },
{ {
level: 2, level: 2,
title: 'h2 - 2', title: 'h2 - 2',
link: '#h2-2' link: '#h2-2',
}, children: [
{ {
level: 3, level: 3,
title: 'h3 - 3', title: 'h3 - 3',
link: '#h3-3' link: '#h3-3',
}, children: [
{ {
level: 4, level: 4,
title: 'h4 - 3', title: 'h4 - 3',
link: '#h4-3' link: '#h4-3'
}
]
}
]
} }
], ],
'deep' 'deep'

@ -14,12 +14,6 @@ export default defineConfig({
head: [['meta', { name: 'theme-color', content: '#3c8772' }]], head: [['meta', { name: 'theme-color', content: '#3c8772' }]],
markdown: {
headers: {
level: [0, 0]
}
},
themeConfig: { themeConfig: {
nav: nav(), nav: nav(),

@ -1,17 +1,13 @@
import { defineComponent, h, onUpdated } from 'vue' import { defineComponent, h } from 'vue'
import { useRoute } from '../router.js' import { useRoute } from '../router.js'
export const Content = defineComponent({ export const Content = defineComponent({
name: 'VitePressContent', name: 'VitePressContent',
props: { props: {
onContentUpdated: Function,
as: { type: [Object, String], default: 'div' } as: { type: [Object, String], default: 'div' }
}, },
setup(props) { setup(props) {
const route = useRoute() const route = useRoute()
onUpdated(() => {
props.onContentUpdated?.()
})
return () => return () =>
h(props.as, { style: { position: 'relative' } }, [ h(props.as, { 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 { useRoute } from 'vitepress' import { useRoute } from 'vitepress'
import { computed, provide, ref } from 'vue' import { computed } 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,9 +11,6 @@ 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>
@ -42,7 +39,7 @@ provide('onContentUpdated', onContentUpdated)
<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" :onContentUpdated="onContentUpdated" /> <Content class="vp-doc" :class="pageName" />
</main> </main>
<slot name="doc-footer-before" /> <slot name="doc-footer-before" />
<VPDocFooter /> <VPDocFooter />

@ -1,26 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DefaultTheme } from 'vitepress/theme' import { computed, ref } from 'vue'
import { computed, inject, ref, type Ref } from 'vue'
import { useData } from '../composables/data.js' import { useData } from '../composables/data.js'
import { import {
getHeaders, resolveHeaders,
useActiveAnchor, useActiveAnchor
type MenuItem
} from '../composables/outline.js' } from '../composables/outline.js'
import VPDocAsideOutlineItem from './VPDocAsideOutlineItem.vue' import VPDocAsideOutlineItem from './VPDocAsideOutlineItem.vue'
const { frontmatter, theme } = useData() const { frontmatter, page, theme } = useData()
const pageOutline = computed<DefaultTheme.Config['outline']>( const headers = computed(() => {
() => frontmatter.value.outline ?? theme.value.outline return resolveHeaders(
) page.value.headers,
frontmatter.value.outline ?? theme.value.outline
const onContentUpdated = inject('onContentUpdated') as Ref<() => void> )
onContentUpdated.value = () => { })
headers.value = getHeaders(pageOutline.value, theme.value.outlineBadges)
}
const headers = ref<MenuItem[]>([])
const hasOutline = computed(() => headers.value.length > 0) const hasOutline = computed(() => headers.value.length > 0)
const container = ref() const container = ref()

@ -11,41 +11,14 @@ export type MenuItem = Omit<Header, 'slug' | 'children'> & {
children?: MenuItem[] children?: MenuItem[]
} }
export function getHeaders(
pageOutline: DefaultTheme.Config['outline'],
outlineBadges: DefaultTheme.Config['outlineBadges']
) {
if (pageOutline === false) return []
let updatedHeaders: MenuItem[] = []
document
.querySelectorAll<HTMLHeadingElement>('h2, h3, h4, h5, h6')
.forEach((el) => {
if (el.textContent && el.id) {
let title = el.textContent
if (outlineBadges === false) {
const clone = el.cloneNode(true) as HTMLElement
for (const child of clone.querySelectorAll('.VPBadge')) {
child.remove()
}
title = clone.textContent || ''
}
updatedHeaders.push({
level: Number(el.tagName[1]),
title: title.replace(/\s+#\s*$/, ''),
link: `#${el.id}`
})
}
})
return resolveHeaders(updatedHeaders, pageOutline)
}
export function resolveHeaders( export function resolveHeaders(
headers: MenuItem[], headers: MenuItem[],
range?: Exclude<DefaultTheme.Config['outline'], false> range?: DefaultTheme.Config['outline']
) { ) {
if (range === false) {
return []
}
const levelsRange = const levelsRange =
(typeof range === 'object' && !Array.isArray(range) (typeof range === 'object' && !Array.isArray(range)
? range.level ? range.level
@ -58,51 +31,38 @@ export function resolveHeaders(
? [2, 6] ? [2, 6]
: levelsRange : levelsRange
return groupHeaders(headers, levels) const isInRange = (h: MenuItem): boolean =>
h.level >= levels[0] && h.level <= levels[1]
return filterHeaders(headers, isInRange)
} }
function groupHeaders(headers: MenuItem[], levelsRange: [number, number]) { function filterHeaders(
headers: MenuItem[],
isInRange: (h: MenuItem) => boolean
) {
const result: MenuItem[] = [] const result: MenuItem[] = []
headers = headers.map((h) => ({ ...h })) headers = headers.map((h) => ({ ...h }))
headers.forEach((h, index) => { headers.forEach((h) => {
if (h.level >= levelsRange[0] && h.level <= levelsRange[1]) { if (isInRange(h)) {
if (addToParent(index, headers, levelsRange)) { if (h.children) {
result.push(h) const filteredChildren = filterHeaders(h.children, isInRange)
if (filteredChildren.length) {
h.children = filteredChildren
} else {
delete h.children
}
} }
result.push(h)
} else if (h.children) {
result.push(...filterHeaders(h.children, isInRange))
} }
}) })
return result return result
} }
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(
container: Ref<HTMLElement>, container: Ref<HTMLElement>,
marker: Ref<HTMLElement> marker: Ref<HTMLElement>

@ -1,4 +1,9 @@
<% if (defaultTheme) { %>/** <% if (defaultTheme) { %>/**
* Customize default theme styling by overriding CSS variables:
* https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css
*/
/**
* Colors * Colors
* -------------------------------------------------------------------------- */ * -------------------------------------------------------------------------- */

Loading…
Cancel
Save