import type { ComputedRef, InjectionKey, Ref } from 'vue' import type { ResolvedSidebarItem, ThemeSidebar, ThemeSidebarItem } from '../../shared/index.js' import { sidebar as sidebarRaw } from '@internal/sidebar' import { ensureLeadingSlash, isArray, isPlainObject, isString, removeLeadingSlash, } from '@vuepress/helper/client' import { useMediaQuery } from '@vueuse/core' import { computed, inject, onMounted, onUnmounted, provide, ref, watch, watchEffect, } from 'vue' import { resolveRouteFullPath, useRoute, useRouteLocale } from 'vuepress/client' import { isActive, normalizeLink, normalizePrefix, resolveNavLink } from '../utils/index.js' import { useData } from './data.js' import { useEncrypt } from './encrypt.js' export type SidebarData = Record export type SidebarDataRef = Ref export type AutoDirSidebarRef = Ref export type AutoHomeDataRef = Ref> const { __auto__, __home__, ...items } = sidebarRaw const sidebarData: SidebarDataRef = ref(items) const autoDirSidebar: AutoDirSidebarRef = ref(__auto__) const autoHomeData: AutoHomeDataRef = ref(__home__) if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot)) { __VUE_HMR_RUNTIME__.updateSidebar = (data: SidebarData) => { const { __auto__, __home__, ...items } = data sidebarData.value = items autoDirSidebar.value = __auto__ as ThemeSidebarItem[] autoHomeData.value = __home__ as Record } } const sidebarSymbol: InjectionKey> = Symbol( __VUEPRESS_DEV__ ? 'sidebar' : '', ) export function setupSidebar() { const { page, frontmatter } = useData() const routeLocale = useRouteLocale() const hasSidebar = computed(() => { return ( frontmatter.value.pageLayout !== 'home' && frontmatter.value.pageLayout !== 'friends' && frontmatter.value.sidebar !== false && frontmatter.value.layout !== 'NotFound' ) }) const sidebarData = computed(() => { return hasSidebar.value ? getSidebar(typeof frontmatter.value.sidebar === 'string' ? frontmatter.value.sidebar : page.value.path, routeLocale.value) : [] }) provide(sidebarSymbol, sidebarData) } export function useSidebarData(): Ref { const sidebarData = inject(sidebarSymbol) if (!sidebarData) { throw new Error('useSidebarData() is called without provider.') } return sidebarData } /** * Get the `Sidebar` from sidebar option. This method will ensure to get correct * sidebar config from `MultiSideBarConfig` with various path combinations such * as matching `guide/` and `/guide/`. If no matching config was found, it will * return empty array. */ export function getSidebar(routePath: string, routeLocal: string): ResolvedSidebarItem[] { const _sidebar = sidebarData.value[routeLocal] if (_sidebar === 'auto') { return resolveSidebarItems(autoDirSidebar.value[routeLocal]) } else if (isArray(_sidebar)) { return resolveSidebarItems(_sidebar, routeLocal) } else if (isPlainObject(_sidebar)) { routePath = decodeURIComponent(routePath) const dir = Object.keys(_sidebar) .sort((a, b) => b.split('/').length - a.split('/').length) .find((dir) => { // make sure the multi sidebar key starts with slash too return routePath.startsWith(`${routeLocal}${removeLeadingSlash(dir)}`) }) || '' const sidebar = dir ? _sidebar[dir] : undefined if (sidebar === 'auto') { return resolveSidebarItems( dir ? autoDirSidebar.value[dir] : [], routeLocal, ) } else if (isArray(sidebar)) { return resolveSidebarItems(sidebar, dir) } else if (isPlainObject(sidebar)) { const prefix = normalizePrefix(dir, sidebar.prefix) return resolveSidebarItems( sidebar.items === 'auto' ? autoDirSidebar.value[prefix] : sidebar.items, prefix, ) } } return [] } function resolveSidebarItems( sidebarItems: (string | ThemeSidebarItem)[], _prefix = '', ): ResolvedSidebarItem[] { const resolved: ResolvedSidebarItem[] = [] sidebarItems.forEach((item) => { if (isString(item)) { resolved.push(resolveNavLink(normalizeLink(_prefix, item))) } else { const { link, items, prefix, dir, ...args } = item const navLink = { ...args } as ResolvedSidebarItem if (link) { navLink.link = link.startsWith('---') ? link : normalizeLink(_prefix, link) const nav = resolveNavLink(navLink.link) navLink.icon = nav.icon || navLink.icon navLink.badge = nav.badge || navLink.badge } const nextPrefix = normalizePrefix(_prefix, prefix || dir) if (items === 'auto') { navLink.items = autoDirSidebar.value[nextPrefix] if (!navLink.link && autoHomeData.value[nextPrefix]) { navLink.link = normalizeLink(autoHomeData.value[nextPrefix]) const nav = resolveNavLink(navLink.link) navLink.icon = nav.icon || navLink.icon navLink.badge = nav.badge || navLink.badge } } else { navLink.items = items?.length ? resolveSidebarItems(items, nextPrefix) : undefined } resolved.push(navLink) } }) return resolved } /** * Get or generate sidebar group from the given sidebar items. */ export function getSidebarGroups(sidebar: ResolvedSidebarItem[]): ResolvedSidebarItem[] { const groups: ResolvedSidebarItem[] = [] let lastGroupIndex = 0 for (const index in sidebar) { const item = sidebar[index] if (item.items) { lastGroupIndex = groups.push(item) continue } if (!groups[lastGroupIndex]) { groups.push({ items: [] }) } groups[lastGroupIndex]!.items!.push(item) } return groups } /** * Check if the given sidebar item contains any active link. */ export function hasActiveLink(path: string, items: ResolvedSidebarItem | ResolvedSidebarItem[]): boolean { if (Array.isArray(items)) { return items.some(item => hasActiveLink(path, item)) } return isActive( path, items.link ? resolveRouteFullPath(items.link) : undefined, ) ? true : items.items ? hasActiveLink(path, items.items) : false } export interface SidebarControl { collapsed: Ref collapsible: ComputedRef isLink: ComputedRef isActiveLink: Ref hasActiveLink: ComputedRef hasChildren: ComputedRef toggle: () => void } export interface UseSidebarReturn { isOpen: Ref sidebar: Ref sidebarKey: Ref sidebarGroups: Ref hasSidebar: ComputedRef hasAside: ComputedRef leftAside: ComputedRef isSidebarEnabled: ComputedRef open: () => void close: () => void toggle: () => void } const containsActiveLink = hasActiveLink export function useSidebar(): UseSidebarReturn { const { theme, frontmatter, page } = useData() const routeLocal = useRouteLocale() const is960 = useMediaQuery('(min-width: 960px)') const { isPageDecrypted } = useEncrypt() const isOpen = ref(false) const sidebarKey = computed(() => { const _sidebar = sidebarData.value[routeLocal.value] if (!_sidebar || _sidebar === 'auto' || isArray(_sidebar)) return routeLocal.value return Object.keys(_sidebar) .sort((a, b) => b.split('/').length - a.split('/').length) .find((dir) => { return page.value.path.startsWith(ensureLeadingSlash(dir)) }) || '' }) const sidebar = useSidebarData() const hasSidebar = computed(() => { return ( frontmatter.value.sidebar !== false && sidebar.value.length > 0 && frontmatter.value.pageLayout !== 'home' ) }) const hasAside = computed(() => { if (frontmatter.value.pageLayout === 'home' || frontmatter.value.home) return false if (frontmatter.value.pageLayout === 'friends' || frontmatter.value.friends) return false if (!isPageDecrypted.value) return false if (frontmatter.value.aside != null) return !!frontmatter.value.aside return theme.value.aside !== false }) const leftAside = computed(() => { if (hasAside.value) { return frontmatter.value.aside == null ? theme.value.aside === 'left' : frontmatter.value.aside === 'left' } return false }) const isSidebarEnabled = computed(() => hasSidebar.value && is960.value) const sidebarGroups = computed(() => { return hasSidebar.value ? getSidebarGroups(sidebar.value) : [] }) const open = (): void => { isOpen.value = true } const close = (): void => { isOpen.value = false } const toggle = (): void => { if (isOpen.value) { close() } else { open() } } return { isOpen, sidebar, sidebarKey, sidebarGroups, hasSidebar, hasAside, leftAside, isSidebarEnabled, open, close, toggle, } } /** * a11y: cache the element that opened the Sidebar (the menu button) then * focus that button again when Menu is closed with Escape key. */ export function useCloseSidebarOnEscape(isOpen: Ref, close: () => void): void { let triggerElement: HTMLButtonElement | undefined watchEffect(() => { triggerElement = isOpen.value ? (document.activeElement as HTMLButtonElement) : undefined }) onMounted(() => { window.addEventListener('keyup', onEscape) }) onUnmounted(() => { window.removeEventListener('keyup', onEscape) }) function onEscape(e: KeyboardEvent): void { if (e.key === 'Escape' && isOpen.value) { close() triggerElement?.focus() } } } export function useSidebarControl(item: ComputedRef): SidebarControl { const { page } = useData() const route = useRoute() const collapsed = ref(false) const collapsible = computed(() => { return item.value.collapsed != null }) const isLink = computed(() => { return !!item.value.link }) const isActiveLink = ref(false) const updateIsActiveLink = (): void => { isActiveLink.value = isActive( page.value.path, item.value.link ? resolveRouteFullPath(item.value.link) : undefined, ) } watch([() => page.value.path, item, () => route.hash], updateIsActiveLink) onMounted(updateIsActiveLink) const hasActiveLink = computed(() => { if (isActiveLink.value) { return true } return item.value.items ? containsActiveLink(page.value.path, item.value.items) : false }) const hasChildren = computed(() => { return !!(item.value.items && item.value.items.length) }) watch(() => [collapsible.value, item.value.collapsed], (n, o) => { if (n[0] !== o?.[0] || n[1] !== o?.[1]) collapsed.value = !!(collapsible.value && item.value.collapsed) }, { immediate: true }) watch(() => [page.value.path, isActiveLink.value, hasActiveLink.value], () => { if (isActiveLink.value || hasActiveLink.value) { collapsed.value = false } }, { immediate: true, flush: 'post' }) const toggle = (): void => { if (collapsible.value) { collapsed.value = !collapsed.value } } return { collapsed, collapsible, isLink, isActiveLink, hasActiveLink, hasChildren, toggle, } } export function getSidebarFirstLink(sidebar: ResolvedSidebarItem[]): string { for (const item of sidebar) { if (item.link) return item.link if (item.items) return getSidebarFirstLink(item.items) } return '' }