UNPKG

vuepress-theme-plume

Version:

A Blog&Document Theme for VuePress 2.0

1,647 lines 80.3 kB
import { computedAsync, tryOnScopeDispose, useDark, useEventListener, useLocalStorage, useSessionStorage, useThrottleFn, watchDebounced } from "@vueuse/core"; import { computed, customRef, inject, nextTick, onMounted, onUnmounted, onUpdated, provide, readonly, ref, shallowRef, toValue, watch, watchEffect } from "vue"; import { clientDataSymbol, onContentUpdated, resolveRoute, resolveRouteFullPath, usePageData, usePageFrontmatter, usePageLang, useRoute, useRouteLocale, useRouter, useSiteLocaleData } from "vuepress/client"; import { collections } from "@internal/collectionsData"; import { ensureEndingSlash, isPlainObject, isString, removeEndingSlash, removeLeadingSlash } from "vuepress/shared"; import { inBrowser, isActive, normalizeLink, normalizePrefix, resolveEditLink, resolveNavLink, toArray } from "../utils/index.js"; import { themeData as themeData$1 } from "@internal/themePlumeData"; import { useContributors as useContributors$1 } from "@vuepress/plugin-git/client"; import { encrypt as encrypt$1 } from "@internal/encrypt"; import { decodeData, ensureLeadingSlash, isArray, isLinkAbsolute, isLinkExternal, isLinkWithProtocol, isPlainObject as isPlainObject$1, isString as isString$1, removeLeadingSlash as removeLeadingSlash$1 } from "@vuepress/helper/client"; import { bcryptVerify, md5 } from "hash-wasm"; import { icons } from "@internal/iconify"; import { sidebar } from "@internal/sidebar"; import { postsData as postsData$1 } from "@internal/postsData"; import { articleTagColors } from "@internal/articleTagColors"; import { defineWatermarkConfig } from "@vuepress/plugin-watermark/client"; //#region src/client/composables/collections.ts /** * Global reference to all collections data. * * 所有集合数据的全局引用。 */ const collectionsRef = ref(collections); /** * Global reference to the current collection item. * * 当前集合项的全局引用。 */ const collectionItemRef = ref(); const forceCollection = ref(); if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot)) __VUE_HMR_RUNTIME__.updateCollections = (data) => { collectionsRef.value = data; }; /** * Use collections data. * Returns the global collections reference. * * 获取集合数据。 * 返回全局集合引用。 * * @returns Collections data reference / 集合数据引用 */ const useCollections = () => collectionsRef; /** * Use current collection item. * Returns the current collection based on the page context. * * 获取当前集合项。 * 根据页面上下文返回当前集合。 * * @template T - Collection type / 集合类型 * @returns Current collection item reference / 当前集合项引用 */ const useCollection = () => collectionItemRef; /** * Force update the current collection. * Used to programmatically switch the active collection. * * 强制更新当前集合。 * 用于以编程方式切换活动集合。 * * @param dir - Collection directory or true for first posts collection / 集合目录或 true 表示第一个文章集合 */ function forceUpdateCollection(dir) { forceCollection.value = dir; } /** * Setup collection tracking. * Automatically determines the current collection based on route and page path. * * 设置集合跟踪。 * 根据路由和页面路径自动确定当前集合。 */ function setupCollection() { const routeLocale = useRouteLocale(); const { page } = useData(); const startWith = (link) => link ? page.value.path.startsWith(normalizeLink(routeLocale.value, removeLeadingSlash(link))) : false; watchEffect(() => { collectionItemRef.value = collectionsRef.value[routeLocale.value]?.find((item) => { if (forceCollection.value) { if (forceCollection.value === true) return item.type === "post"; return item.dir === forceCollection.value; } if (page.value.filePathRelative) return page.value.filePathRelative?.startsWith(normalizeLink(routeLocale.value, item.dir).slice(1)); else { const { link, linkPrefix, dir, tagsLink, categoriesLink, archivesLink } = item; return startWith(link) || startWith(linkPrefix) || startWith(dir) || startWith(tagsLink) || startWith(categoriesLink) || startWith(archivesLink); } }); }); } //#endregion //#region src/client/composables/theme-data.ts /** * Injection key for theme locale data * * 主题本地化数据的注入键 */ const themeLocaleDataSymbol = Symbol(__VUEPRESS_DEV__ ? "themeLocaleData" : ""); /** * Theme data ref * Global reference to the theme configuration data * * 主题数据引用 * 主题配置数据的全局引用 */ const themeData = ref(themeData$1); /** * Use theme data * Returns the global theme data reference * * 获取主题数据 * 返回全局主题数据引用 * * @returns Theme data reference / 主题数据引用 */ function useThemeData() { return themeData; } if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot)) __VUE_HMR_RUNTIME__.updateThemeData = (data) => { themeData.value = data; }; /** * Use theme locale data * Returns the theme data for the current route's locale * * 获取当前路由对应的语言环境的主题数据 * * @returns Theme locale data computed reference / 主题本地化数据计算引用 * @throws Error if called without provider / 如果没有提供者则抛出错误 */ function useThemeLocaleData() { const themeLocaleData = inject(themeLocaleDataSymbol); if (!themeLocaleData) throw new Error("useThemeLocaleData() is called without provider."); return themeLocaleData; } /** * Merge the locales fields to the root fields according to the route path * Combines base theme options with locale-specific options * * 根据路由路径将本地化字段合并到根字段 * 将基础主题选项与特定语言环境的选项合并 * * @param theme - Base theme data / 基础主题数据 * @param routeLocale - Current route locale / 当前路由的语言环境 * @returns Merged theme data for the locale / 合并后的语言环境主题数据 */ function resolveThemeLocaleData(theme, routeLocale) { const { locales, ...baseOptions } = theme; return { ...baseOptions, ...locales?.[routeLocale] }; } /** * Setup theme data for the Vue app * Provides theme data and theme locale data to the application * * 为 Vue 应用设置主题数据 * 向应用程序提供主题数据和主题本地化数据 * * @param app - Vue application instance / Vue 应用实例 */ function setupThemeData(app) { const themeData = useThemeData(); const clientData = app._context.provides[clientDataSymbol]; const themeLocaleData = computed(() => resolveThemeLocaleData(themeData.value, clientData.routeLocale.value)); app.provide(themeLocaleDataSymbol, themeLocaleData); Object.defineProperties(app.config.globalProperties, { $theme: { get() { return themeData.value; } }, $themeLocale: { get() { return themeLocaleData.value; } } }); } //#endregion //#region src/client/composables/dark-mode.ts /** * Injection key for dark mode * * 暗黑模式的注入键 */ const darkModeSymbol = Symbol(__VUEPRESS_DEV__ ? "darkMode" : ""); /** * Check if view transitions are enabled and supported * Considers prefers-reduced-motion preference * * 检查视图过渡是否启用且受支持 * 考虑 prefers-reduced-motion 偏好设置 * * @returns Whether transitions are enabled / 过渡是否启用 */ function enableTransitions() { if (typeof document === "undefined") return false; return "startViewTransition" in document && window.matchMedia("(prefers-reduced-motion: no-preference)").matches; } /** * Setup dark mode for the Vue application * Configures dark mode based on theme settings and user preferences * * 为 Vue 应用设置暗黑模式 * 根据主题设置和用户偏好配置暗黑模式 * * @param app - Vue application instance / Vue 应用实例 */ function setupDarkMode(app) { const theme = useThemeData(); const transition = theme.value.transition; const disableTransition = enableTransitions() || (typeof transition === "object" ? transition.appearance === false : transition === false); const appearance = theme.value.appearance; const isDark = appearance === "force-dark" ? ref(true) : appearance ? useDark({ storageKey: "vuepress-theme-appearance", attribute: "data-theme", valueLight: "light", valueDark: "dark", disableTransition, initialValue: () => typeof appearance === "string" ? appearance : "auto", ...typeof appearance === "object" ? appearance : {} }) : ref(false); app.provide(darkModeSymbol, isDark); if (__VUEPRESS_DEV__ && appearance === "force-dark" && typeof document !== "undefined") document.documentElement.dataset.theme = "dark"; Object.defineProperty(app.config.globalProperties, "$isDark", { get: () => isDark }); useEventListener("beforeprint", () => { if (isDark.value) document.documentElement.dataset.theme = "light"; }); useEventListener("afterprint", () => { if (isDark.value) document.documentElement.dataset.theme = "dark"; }); } /** * Use dark mode * Returns the dark mode reactive reference * * 获取暗黑模式状态 * * @returns Dark mode reference / 暗黑模式引用 * @throws Error if called without provider / 如果没有提供者则抛出错误 */ function useDarkMode() { const isDarkMode = inject(darkModeSymbol); if (!isDarkMode) throw new Error("useDarkMode() is called without provider."); return isDarkMode; } //#endregion //#region src/client/composables/data.ts /** * Use data * * 获取页面数据,包括主题配置、页面数据、frontmatter、语言、站点信息、暗黑模式和集合信息 */ function useData() { const theme = useThemeLocaleData(); const page = usePageData(); const frontmatter = usePageFrontmatter(); const site = useSiteLocaleData(); const isDark = useDarkMode(); return { theme, page, frontmatter, lang: usePageLang(), site, isDark, collection: useCollection() }; } //#endregion //#region src/client/composables/bulletin.ts const showBulletin = ref(false); function useBulletin() { const { theme } = useData(); return computed(() => theme.value.bulletin === true ? {} : theme.value.bulletin); } function useBulletinControl() { const session = useSessionStorage("plume:bulletin", ""); const local = useLocalStorage("plume:bulletin", ""); const { page } = useData(); const bulletin = useBulletin(); const enableBulletin = computed(() => page.value.bulletin ?? true); watch(() => bulletin.value?.lifetime, (lifetime) => { const id = bulletin.value?.id; if (lifetime === "session") showBulletin.value = session.value !== id; else if (lifetime === "once") showBulletin.value = local.value !== id; else showBulletin.value = true; }, { immediate: true }); function close() { showBulletin.value = false; const lifetime = bulletin.value?.lifetime; const id = bulletin.value?.id; if (lifetime === "session") session.value = id; else if (lifetime === "once") local.value = id; } return { bulletin, enableBulletin, showBulletin, close }; } //#endregion //#region src/client/composables/contributors.ts function useContributors() { const { frontmatter } = useData(); const list = useContributors$1(); const theme = useThemeData(); const mode = computed(() => { const config = theme.value.contributors; if (isPlainObject(config)) return config.mode || "inline"; return "inline"; }); const contributors = computed(() => { if ((frontmatter.value.contributors ?? !!theme.value.contributors) === false) return []; return list.value; }); return { mode, contributors, hasContributors: computed(() => contributors.value.length > 0) }; } //#endregion //#region src/client/composables/preset-locales.ts const presetLocales = __PLUME_PRESET_LOCALE__; function getPresetLocaleData(locale, name) { return presetLocales[locale]?.[name] || presetLocales["/"][name]; } //#endregion //#region src/client/composables/copyright.ts const LICENSE_URL = { "CC0": { url: "https://creativecommons.org/publicdomain/zero/1.0/", icons: ["zero"] }, "CC-BY-4.0": { url: "https://creativecommons.org/licenses/by/4.0/", icons: ["cc", "by"] }, "CC-BY-NC-4.0": { url: "https://creativecommons.org/licenses/by-nc/4.0/", icons: [ "cc", "by", "nc" ] }, "CC-BY-NC-SA-4.0": { url: "https://creativecommons.org/licenses/by-nc-sa/4.0/", icons: [ "cc", "by", "nc", "sa" ] }, "CC-BY-NC-ND-4.0": { url: "https://creativecommons.org/licenses/by-nc-nd/4.0/", icons: [ "cc", "by", "nc", "nd" ] }, "CC-BY-ND-4.0": { url: "https://creativecommons.org/licenses/by-nd/4.0/", icons: [ "cc", "by", "nd" ] }, "CC-BY-SA-4.0": { url: "https://creativecommons.org/licenses/by-sa/4.0/", icons: [ "cc", "by", "sa" ] } }; function useCopyright(copyright) { const { theme } = useData(); const routeLocale = useRouteLocale(); const { contributors } = useContributors(); const hasCopyright = computed(() => Boolean(copyright.value)); const creation = computed(() => copyright.value.creation || "original"); const license = computed(() => resolveLicense(copyright.value.license, routeLocale.value)); const author = computed(() => resolveAuthor(copyright.value.author, creation.value, contributors.value)); const sourceUrl = computed(() => { if (creation.value === "original") { if (__VUEPRESS_SSR__) return ""; const url = new URL(location.href.split("#")[0]); url.searchParams.delete("giscus"); return url.toString(); } return copyright.value.source; }); return { license, author, hasCopyright, creation, creationText: computed(() => { const creation = copyright.value.creation; if (creation === "translate") return theme.value.copyrightCreationTranslateText || "This article is translated from"; else if (creation === "reprint") return theme.value.copyrightCreationReprintText || "This article is reprint from"; return theme.value.copyrightCreationOriginalText || "This article link: "; }), sourceUrl }; } function resolveLicense(license = "CC-BY-4.0", locale) { const result = typeof license === "string" ? { name: license } : { ...license }; const fallback = LICENSE_URL[result.name]; const name = getPresetLocaleData(locale, result.name); if (name) result.name = `${name} (${result.name})`; result.url ||= fallback?.url; result.icons = fallback?.icons; return result; } function resolveAuthor(author, creation, contributors) { const contributor = contributors[0]; if (!author && contributor && creation === "original") return contributor; const options = typeof author === "string" ? { name: author } : author; if (options && !options.url) { const contributor = contributors.find((c) => c.name === options.name); if (contributor) options.url = contributor.url; } return options; } //#endregion //#region src/client/composables/css-var.ts /** * Get css variable * @param prop css variable name * @param initialValue */ function useCssVar(prop, initialValue = "") { const isDark = useDarkMode(); const variable = shallowRef(initialValue); function updateCssVar() { const _window = typeof window !== "undefined" ? window : null; const target = _window?.document?.documentElement; const key = toValue(prop); if (target && key) variable.value = _window.getComputedStyle(target).getPropertyValue(key)?.trim() || variable.value || initialValue; } watch([isDark, () => toValue(prop)], () => { updateCssVar(); }, { immediate: true, flush: "post" }); return computed(() => variable.value); } //#endregion //#region src/client/composables/edit-link.ts function useEditLink() { const { theme, page, frontmatter } = useData(); const themeData = useThemeData(); return computed(() => { if (!(frontmatter.value.editLink ?? themeData.value.editLink ?? true)) return null; const { docsRepo, docsBranch = "main", docsDir = "" } = themeData.value; const { editLinkText } = theme.value; if (!docsRepo) return null; const editLink = resolveEditLink({ docsRepo, docsBranch, docsDir, filePathRelative: page.value.filePathRelative, editLinkPattern: frontmatter.value.editLinkPattern ?? theme.value.editLinkPattern }); if (!editLink) return null; return { text: editLinkText ?? "Edit this page", link: editLink }; }); } //#endregion //#region src/client/composables/encrypt-data.ts /** * Global encrypt data reference * * 全局加密数据引用 */ const encrypt = ref(resolveEncryptData(encrypt$1)); /** * Use encrypt data * Returns the global encrypt data reference * * 获取加密数据 * 返回全局加密数据引用 * * @returns Encrypt data reference / 加密数据引用 */ function useEncryptData() { return encrypt; } /** * Resolve encrypt data from raw configuration * Decodes and parses the raw encrypt configuration * * 从原始配置解析加密数据 * 解码并解析原始加密配置 * @param data - Raw encrypt configuration / 原始加密配置 * @param data."0" rawKeys - Encoded keys string / 编码的密钥字符串 * @param data."1" rawRules - Encoded rules string / 编码的规则字符串 * @param data."2" global - Global encryption flag / 全局加密标志 * @param data."3" admin - Admin passwords string / 管理员密码字符串 * @returns Parsed encrypt data / 解析后的加密数据 */ function resolveEncryptData([rawKeys, rawRules, global, admin]) { const keys = unwrapData(rawKeys).map((key) => decodeData(key)); const rules = unwrapData(rawRules); const separator = ":"; return { global: !!global, matches: keys, admins: admin.split(separator), ruleList: Object.keys(rules).map((key) => ({ key, match: keys[key], rules: rules[key].split(separator) })) }; } /** * Unwrap and decode data from raw string * * 从原始字符串解包和解码数据 * * @param raw - Raw encoded string / 原始编码字符串 * @returns Parsed data / 解析后的数据 */ function unwrapData(raw) { return JSON.parse(decodeData(raw)); } if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot)) __VUE_HMR_RUNTIME__.updateEncrypt = (data) => { encrypt.value = resolveEncryptData(data); }; //#endregion //#region src/client/composables/encrypt.ts /** * Injection key for encrypt functionality * * 加密功能的注入键 */ const EncryptSymbol = Symbol(__VUEPRESS_DEV__ ? "Encrypt" : ""); /** * Session storage for encryption state * Stores global and page decryption states * * 加密状态的会话存储 * 存储全局和页面解密状态 */ const storage = useSessionStorage("2a0a3d6afb2fdf1f", () => { if (__VUEPRESS_SSR__) return { g: "", p: [] }; return { g: "", p: [] }; }); /** * Cache for password comparison results * Improves performance by caching bcrypt verification results * * 密码比较结果的缓存 * 通过缓存 bcrypt 验证结果提高性能 */ const compareCache = /* @__PURE__ */ new Map(); const separator = ":"; /** * Compare password with hash using bcrypt * Caches results to avoid repeated computations * * 使用 bcrypt 比较密码和哈希 * 缓存结果以避免重复计算 * * @param content - Password to verify / 要验证的密码 * @param hash - Bcrypt hash to compare against / 要比较的 bcrypt 哈希 * @returns Whether the password matches / 密码是否匹配 */ async function compareDecrypt(content, hash) { const key = [content, hash].join(separator); if (compareCache.has(key)) return compareCache.get(key); try { const result = await bcryptVerify({ password: content, hash }); compareCache.set(key, result); return result; } catch { compareCache.set(key, false); return false; } } /** * Cache for regex patterns * Improves performance by caching compiled regexes * * 正则表达式模式的缓存 * 通过缓存编译后的正则表达式提高性能 */ const matchCache = /* @__PURE__ */ new Map(); /** * Create or retrieve cached regex pattern * * 创建或获取缓存的正则表达式模式 * * @param match - Pattern string / 模式字符串 * @returns Compiled regex / 编译后的正则表达式 */ function createMatchRegex(match) { if (matchCache.has(match)) return matchCache.get(match); const regex = new RegExp(match); matchCache.set(match, regex); return regex; } /** * Check if a match pattern applies to the current page * Supports regex patterns (starting with ^) and path patterns * * 检查匹配模式是否适用于当前页面 * 支持正则表达式模式(以 ^ 开头)和路径模式 * * @param match - Match pattern / 匹配模式 * @param pagePath - Current page path / 当前页面路径 * @param filePathRelative - Relative file path / 相对文件路径 * @returns Whether the pattern matches / 模式是否匹配 */ function toMatch(match, pagePath, filePathRelative) { const relativePath = filePathRelative || ""; if (match[0] === "^") { const regex = createMatchRegex(match); return regex.test(pagePath) || regex.test(relativePath); } if (match.endsWith(".md")) return relativePath && relativePath.endsWith(match); return pagePath.startsWith(match) || relativePath.startsWith(removeLeadingSlash(match)); } /** * Setup encrypt functionality for the application * Initializes encryption state and provides it to child components * * 为应用程序设置加密功能 * 初始化加密状态并提供给子组件 */ function setupEncrypt() { const { page } = useData(); const route = useRoute(); const encrypt = useEncryptData(); /** * Whether the current page has encryption enabled * Checks page-specific encryption and rule-based encryption */ const hasPageEncrypt = computed(() => { const pagePath = route.path; const filePathRelative = page.value.filePathRelative; if (page.value._e) return true; return encrypt.value.ruleList.length ? encrypt.value.matches.some((match) => toMatch(match, pagePath, filePathRelative)) : false; }); /** * Whether global encryption is decrypted * Checks if any admin password hash matches the stored hash */ const isGlobalDecrypted = computedAsync(async () => { const hash = storage.value.g; if (!encrypt.value.global) return true; for (const admin of encrypt.value.admins) if (hash && hash === await md5(admin)) return true; return false; }, !encrypt.value.global); /** * List of encryption rules applicable to the current page * Includes page-specific rules and matching pattern rules */ const hashList = computed(() => { const pagePath = route.path; const filePathRelative = page.value.filePathRelative; const passwords = typeof page.value._e === "string" ? page.value._e.split(":") : []; return [passwords.length ? { key: pagePath.replace(/\//g, "").replace(/\.html$/, ""), match: pagePath, rules: passwords } : void 0, ...encrypt.value.ruleList.length ? encrypt.value.ruleList.filter((item) => toMatch(item.match, pagePath, filePathRelative)) : []].filter(Boolean); }); provide(EncryptSymbol, { hasPageEncrypt, isGlobalDecrypted, isPageDecrypted: computedAsync(async () => { if (!hasPageEncrypt.value) return true; const hash = storage.value.g; for (const admin of encrypt.value.admins) if (hash && hash === await md5(admin)) return true; for (const { key, rules } of hashList.value) { const hash = storage.value.p[key]; for (const rule of rules) if (hash && hash === await md5(rule)) return true; } return false; }, !hasPageEncrypt.value), hashList }); } /** * Use encrypt * Returns the encryption state and properties * * 获取加密功能的状态和方法,包括全局解密、页面解密、密码验证等 * * @returns Encrypt state and properties / 加密状态和属性 * @throws Error if called without setup / 如果没有设置则抛出错误 */ function useEncrypt() { const result = inject(EncryptSymbol); if (!result) throw new Error("useEncrypt() is called without setup"); return result; } /** * Use encrypt compare * Provides password verification functions for global and page encryption * * 密码比较功能,提供全局密码和页面密码的验证方法 * * @returns Object with compareGlobal and comparePage functions / 包含 compareGlobal 和 comparePage 函数的对象 */ function useEncryptCompare() { const encrypt = useEncryptData(); const { page } = useData(); const route = useRoute(); const { hashList } = useEncrypt(); /** * Compare global password * Verifies against admin passwords * * @param password - Password to verify / 要验证的密码 * @returns Whether the password is valid / 密码是否有效 */ async function compareGlobal(password) { if (!password) return false; for (const admin of encrypt.value.admins) if (await compareDecrypt(password, admin)) { storage.value.g = await md5(admin); return true; } return false; } /** * Compare page password * Verifies against page-specific rules and falls back to global password * * @param password - Password to verify / 要验证的密码 * @returns Whether the password is valid / 密码是否有效 */ async function comparePage(password) { if (!password) return false; const pagePath = route.path; const filePathRelative = page.value.filePathRelative; let decrypted = false; for (const { match, key, rules } of hashList.value) if (toMatch(match, pagePath, filePathRelative)) { for (const rule of rules) if (await compareDecrypt(password, rule)) { decrypted = true; storage.value.p[key] = await md5(rule); break; } if (decrypted) break; } if (!decrypted) decrypted = await compareGlobal(password); return decrypted; } return { compareGlobal, comparePage }; } //#endregion //#region src/client/composables/flyout.ts /** * Currently focused element reference. * Shared across all flyout instances. * * 当前聚焦元素的引用。 * 在所有弹出框实例之间共享。 */ const focusedElement = ref(); let active = false; let listeners = 0; /** * Use flyout focus tracking. * Tracks focus state for dropdown menus and flyout components. * * 弹出框焦点跟踪。 * 跟踪下拉菜单和弹出框组件的焦点状态。 * * @param options - Flyout options / 弹出框选项 * @returns Readonly reference to focus state / 焦点状态的只读引用 */ function useFlyout(options) { const focus = ref(false); if (inBrowser) { if (!active) activateFocusTracking(); listeners++; const unwatch = watch(focusedElement, (el) => { if (el === options.el.value || options.el.value?.contains(el)) { focus.value = true; options.onFocus?.(); } else { focus.value = false; options.onBlur?.(); } }); onUnmounted(() => { unwatch(); listeners--; if (!listeners) deactivateFocusTracking(); }); } return readonly(focus); } /** * Activate global focus tracking. * Adds focusin event listener to document. * * 激活全局焦点跟踪。 * 向文档添加 focusin 事件监听器。 */ function activateFocusTracking() { document.addEventListener("focusin", handleFocusIn); active = true; focusedElement.value = document.activeElement; } /** * Deactivate global focus tracking. * Removes focusin event listener from document. * * 停用全局焦点跟踪。 * 从文档移除 focusin 事件监听器。 */ function deactivateFocusTracking() { document.removeEventListener("focusin", handleFocusIn); } /** * Handle focusin event. * Updates the focused element reference. * * 处理 focusin 事件。 * 更新聚焦元素引用。 */ function handleFocusIn() { focusedElement.value = document.activeElement; } //#endregion //#region src/client/composables/icons.ts const iconsData = ref(resolveIconsData(icons)); /** * Use icons data. * Returns the processed icons data reference. * * 获取图标数据。 * 返回处理后的图标数据引用。 * * @returns Icons data reference / 图标数据引用 */ const useIconsData = () => iconsData; if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot)) __VUE_HMR_RUNTIME__.updateIcons = (data) => { iconsData.value = resolveIconsData(data); }; /** * Fallback mappings for old icon aliases. * Maps legacy icon names to their current equivalents. * * 旧版本内置图标别名的后备映射。 * 将旧图标名称映射到当前等效名称。 */ const socialFallbacks = { twitter: "x", weibo: "sinaweibo" }; /** * Resolve raw icons data to processed format. * Converts indexed data to flat icon lists. * * 将原始图标数据解析为处理后的格式。 * 将索引数据转换为平面图标列表。 * * @param data - Raw icons data / 原始图标数据 * @param data.co - Collection names / 集合名称 * @param data.bg - Background icons by collection index / 按集合索引的背景图标 * @param data.mask - Mask icons by collection index / 按集合索引的遮罩图标 * @returns Processed icons data / 处理后的图标数据 */ function resolveIconsData({ co, bg, mask }) { return { bg: processIcons(co, bg), mask: processIcons(co, mask) }; } /** * Normalize icon to CSS class name. * Returns the appropriate class name based on icon type. * * 将图标规范化为 CSS 类名。 * 根据图标类型返回适当的类名。 * * @param icon - Icon name in format "collection:name" / 格式为 "collection:name" 的图标名称 * @returns CSS class name or empty string if not found / CSS 类名,如果未找到则返回空字符串 */ function normalizeIconClassname(icon) { const [collect, name] = icon.split(":"); const iconName = `vpi-${collect}-${name}`; if (iconsData.value.bg.includes(icon)) return `${iconName} bg`; if (iconsData.value.mask.includes(icon)) return iconName; return ""; } /** * Process indexed icons into flat list. * Converts collection-indexed data to "collection:icon" format. * * 将索引图标处理为平面列表。 * 将集合索引数据转换为 "collection:icon" 格式。 * * @param collects - Array of collection names / 集合名称数组 * @param raw - Indexed icon data / 索引图标数据 * @returns Flat array of icon identifiers / 图标标识符的平面数组 */ function processIcons(collects, raw) { const data = []; for (const [key, list] of Object.entries(raw)) { const collect = collects[Number(key)]; if (collect) data.push(...list.map((icon) => `${collect}:${icon}`)); } return data; } //#endregion //#region src/client/utils/resolveNavLink.ts /** * Normalize a link by combining base and link * Handles absolute links and protocol links correctly * * 通过组合 base 和 link 来规范化链接 * 正确处理绝对链接和协议链接 * * @param base - Base URL / 基础 URL * @param link - Link to normalize / 要规范化的链接 * @returns Normalized link / 规范化后的链接 */ function normalizeLink$1(base = "", link = "") { return isLinkAbsolute(link) || isLinkWithProtocol(link) ? link : ensureLeadingSlash(`${base}/${link}`.replace(/\/+/g, "/")); } //#endregion //#region src/client/composables/internal-link.ts function useInternalLink() { const { collection, theme } = useData(); const themeData = useThemeData(); const routeLocale = useRouteLocale(); function resolveLink(link, fallback) { link = link ? removeLeadingSlash(link) : ""; return ensureEndingSlash(normalizeLink$1(routeLocale.value, link || fallback)); } const postCollection = computed(() => collection.value?.type === "post" ? collection.value : void 0); const home = computed(() => ({ link: normalizeLink$1(routeLocale.value), text: theme.value.homeText || themeData.value.homeText || "Home" })); const postsLink = computed(() => normalizeLink$1(routeLocale.value, resolveLink(postCollection.value?.link || postCollection.value?.dir, "posts/"))); return { home, posts: computed(() => postCollection.value?.postList !== false ? { text: postCollection.value?.title || removeEndingSlash(postCollection.value?.dir || "").split("/").pop() || theme.value.postsText, link: postsLink.value } : void 0), tags: computed(() => postCollection.value?.tags !== false ? { text: postCollection.value?.tagsText || theme.value.tagText || themeData.value.tagText || "Tags", link: resolveLink(postCollection.value?.tagsLink, "tags/") } : void 0), archive: computed(() => postCollection.value?.archives !== false ? { text: postCollection.value?.archivesText || theme.value.archiveText || themeData.value.archiveText || "Archives", link: resolveLink(postCollection.value?.archivesLink, "archives/") } : void 0), categories: computed(() => postCollection.value?.categories !== false ? { text: postCollection.value?.categoriesText || theme.value.categoryText || themeData.value.categoryText || "Categories", link: resolveLink(postCollection.value?.categoriesLink, "categories/") } : void 0) }; } //#endregion //#region src/client/composables/page.ts /** * Use posts page data * * 获取文章页面数据,判断当前页面是否为文章类型或文章列表布局 */ function usePostsPageData() { const { collection, page } = useData(); return { isPosts: computed(() => collection.value?.type === "post"), isPostsLayout: computed(() => { const type = page.value.type; return type === "posts" || type === "posts-archives" || type === "posts-tags" || type === "posts-categories"; }) }; } //#endregion //#region src/client/composables/langs.ts function useLangs({ removeCurrent = true } = {}) { const theme = useThemeData(); const { page, collection } = useData(); const routeLocale = useRouteLocale(); const { isPosts } = usePostsPageData(); const currentLang = computed(() => { const link = routeLocale.value; return { text: theme.value.locales?.[link]?.selectLanguageName || link, link }; }); const resolvePath = (locale, url) => { const { notFound, path } = resolveRoute(normalizeLink(locale, url.slice(routeLocale.value.length))); return notFound ? void 0 : path; }; const getPageLink = (locale) => { let path; if (page.value.filePathRelative) path = resolvePath(locale, `/${page.value.filePathRelative}`); path ??= resolvePath(locale, page.value.path); if (path) return path; if (isPosts.value && collection.value) { const col = collection.value; return normalizeLink(locale, removeLeadingSlash(col.link || col.dir)); } const home = theme.value.home || "/"; const fallbackResolve = resolveRoute(locale); return fallbackResolve.notFound ? home : fallbackResolve.path; }; return { localeLinks: computed(() => Object.entries(theme.value.locales || {}).flatMap(([key, locale]) => removeCurrent && currentLang.value.text === locale.selectLanguageName ? [] : { text: locale.selectLanguageName || key, link: getPageLink(key) })), currentLang }; } //#endregion //#region src/client/composables/latest-updated.ts function useLastUpdated() { const { theme, page, frontmatter } = useData(); const themeData = useThemeData(); const lang = usePageLang(); const date = computed(() => page.value.git?.updatedTime ? new Date(page.value.git.updatedTime) : null); const isoDatetime = computed(() => date.value?.toISOString()); const datetime = ref(""); const lastUpdatedText = computed(() => { if (themeData.value.lastUpdated === false) return ""; return theme.value.lastUpdatedText || "Last updated"; }); onMounted(() => { watchEffect(() => { if (frontmatter.value.lastUpdated === false || themeData.value.lastUpdated === false) return; datetime.value = date.value ? new Intl.DateTimeFormat(themeData.value.lastUpdated?.formatOptions?.forceLocale ? lang.value : void 0, themeData.value.lastUpdated?.formatOptions ?? { dateStyle: "short", timeStyle: "short" }).format(date.value) : ""; }); }); return { datetime, isoDatetime, lastUpdatedText }; } //#endregion //#region src/client/composables/outline.ts const resolvedHeaders = []; const headers = ref([]); /** * Setup headers for the current page * Initializes header extraction based on frontmatter and theme configuration * * 为当前页面设置标题 * 根据 frontmatter 和主题配置初始化标题提取 * * @returns Reference to the headers array / 标题数组的引用 */ function setupHeaders() { const { frontmatter, theme } = useData(); onContentUpdated(() => { headers.value = getHeaders(frontmatter.value.outline ?? theme.value.outline); }); return headers; } /** * Use headers * Returns the reactive headers reference for the current page * * 获取当前页面的标题列表 * * @returns Reactive reference to menu items / 菜单项的响应式引用 */ function useHeaders() { return headers; } const IGNORE_WRAPPERS = [".vp-bulletin", ".vp-demo-wrapper"]; /** * Get headers from the page content * Extracts and filters headings based on the outline configuration * * 从页面内容获取标题 * 根据目录配置提取和过滤标题 * * @param range - Outline configuration for header levels / 标题级别的目录配置 * @returns Array of menu items representing headers / 表示标题的菜单项数组 */ function getHeaders(range) { const heading = [ "h1", "h2", "h3", "h4", "h5", "h6" ]; const ignores = Array.from(document.querySelectorAll(heading.map((h) => IGNORE_WRAPPERS.map((w) => `${w} ${h}`)).flat().join(","))); const headers = Array.from(document.querySelectorAll(heading.map((h) => `.vp-doc ${h}`).join(","))).filter((el) => !ignores.includes(el) && el.id && el.hasChildNodes()).map((el) => { const level = Number(el.tagName[1]); return { element: el, title: serializeHeader(el), link: `#${el.id}`, level, lowLevel: getLowLevel(el, level) }; }); if (range === false) return []; const [high, low] = getRange(range); return resolveSubRangeHeader(resolveHeaders(headers, high), low); } /** * Get the header level range from outline configuration * * 从目录配置获取标题级别范围 * * @param range - Outline configuration / 目录配置 * @returns Tuple of [high, low] levels / [高, 低] 级别元组 */ function getRange(range) { const levelsRange = range || 2; return typeof levelsRange === "number" ? [levelsRange, levelsRange] : levelsRange === "deep" ? [2, 6] : levelsRange; } /** * Get the lowest outline level for a specific header * Checks for data-outline or outline attributes * * 获取特定标题的最低目录级别 * 检查 data-outline 或 outline 属性 * * @param el - Header element / 标题元素 * @param level - Current header level / 当前标题级别 * @returns Lowest level or undefined / 最低级别或未定义 */ function getLowLevel(el, level) { if (!el.hasAttribute("data-outline") && !el.hasAttribute("outline")) return; const str = (el.getAttribute("data-outline") || el.getAttribute("outline"))?.trim(); if (!str) return; const num = Number(str); if (!Number.isNaN(num) && num >= level) return num; } /** * Serialize a header element to text * Extracts text content while ignoring badges and ignored elements * * 将标题元素序列化为文本 * 提取文本内容,同时忽略徽章和被忽略的元素 * * @param h - Header element / 标题元素 * @returns Serialized header text / 序列化的标题文本 */ function serializeHeader(h) { const anchor = h.firstChild; const el = anchor?.firstChild; let ret = ""; for (const node of Array.from(el?.childNodes ?? [])) if (node.nodeType === 1) { if (node.classList.contains("vp-badge") || node.classList.contains("ignore-header")) continue; const clone = node.cloneNode(true); clearHeaderNodeList(Array.from(clone.childNodes)); ret += clone.textContent; } else if (node.nodeType === 3) ret += node.textContent; let next = anchor?.nextSibling; while (next) { if (next.nodeType === 1 || next.nodeType === 3) ret += next.textContent; next = next.nextSibling; } return ret.trim(); } /** * Clear ignored nodes from a list of child nodes * Recursively removes elements with 'ignore-header' class * * 从子节点列表中清除被忽略的节点 * 递归移除带有 'ignore-header' 类的元素 * * @param list - Array of child nodes / 子节点数组 */ function clearHeaderNodeList(list) { if (list?.length) { for (const node of list) if (node.nodeType === 1) if (node.classList.contains("ignore-header")) node.remove(); else clearHeaderNodeList(Array.from(node.childNodes)); } } /** * Resolve headers into a hierarchical structure * Organizes flat headers into nested menu items based on levels * * 将标题解析为层次结构 * 根据级别将平面标题组织为嵌套菜单项 * * @param headers - Flat array of menu items / 平面菜单项数组 * @param high - Minimum header level to include / 要包含的最小标题级别 * @returns Hierarchical array of menu items / 层次化的菜单项数组 */ function resolveHeaders(headers, high) { headers = headers.filter((h) => h.level >= high); resolvedHeaders.length = 0; for (const { element, link } of headers) resolvedHeaders.push({ element, link }); const ret = []; outer: for (let i = 0; i < headers.length; i++) { const cur = headers[i]; if (i === 0) { ret.push(cur); continue; } else { for (let j = i - 1; j >= 0; j--) { const prev = headers[j]; if (prev.level < cur.level) { (prev.children || (prev.children = [])).push(cur); continue outer; } } ret.push(cur); } } return ret; } /** * Resolve sub range header * Filters children headers based on the lowest level * * 解析子范围标题 * 根据最低级别过滤子标题 * * @param headers - Array of menu items / 菜单项数组 * @param low - Lowest level to include / 要包含的最低级别 * @returns Filtered menu items / 过滤后的菜单项 */ function resolveSubRangeHeader(headers, low) { return headers.map((header) => { if (header.children?.length) { const current = header.lowLevel ? Math.max(header.lowLevel, low) : low; header.children = resolveSubRangeHeader(header.children.filter(({ level }) => level <= current), header.lowLevel || low); } return header; }); } /** * Use active anchor * Tracks scroll position and updates the active outline item * * 活跃锚点处理,监听滚动并更新目录中高亮的锚点 * * @param container - Reference to the outline container / 目录容器的引用 * @param marker - Reference to the active marker element / 活动标记元素的引用 */ function useActiveAnchor(container, marker) { const { isAsideEnabled } = useLayout(); const router = useRouter(); const routeHash = ref(router.currentRoute.value.hash); let prevActiveLink = null; /** * Set the active link based on scroll position * Determines which header is currently in view */ const setActiveLink = () => { if (!isAsideEnabled.value) return; const scrollY = Math.round(window.scrollY); const innerHeight = window.innerHeight; const offsetHeight = document.body.offsetHeight; const isBottom = Math.abs(scrollY + innerHeight - offsetHeight) < 1; const headers = resolvedHeaders.map(({ element, link }) => ({ link, top: getAbsoluteTop(element) })).filter(({ top }) => !Number.isNaN(top)).sort((a, b) => a.top - b.top); if (!headers.length) { activateLink(null); return; } if (scrollY < 1) { activateLink(null); return; } if (isBottom) { activateLink(headers[headers.length - 1].link); return; } let activeLink = null; for (const { link, top } of headers) { if (top > scrollY + 80) break; activeLink = link; } activateLink(activeLink); }; /** * Activate a specific link in the outline * Updates visual indicators and marker position * * @param hash - Hash of the link to activate / 要激活的链接哈希 */ function activateLink(hash) { routeHash.value = hash || ""; if (prevActiveLink) prevActiveLink.classList.remove("active"); if (hash == null) prevActiveLink = null; else prevActiveLink = container.value?.querySelector(`a[href="${decodeURIComponent(hash)}"]`) ?? null; const activeLink = prevActiveLink; if (activeLink) { activeLink.classList.add("active"); if (marker.value) { marker.value.style.top = `${activeLink.offsetTop + 39}px`; marker.value.style.opacity = "1"; } } else if (marker.value) { marker.value.style.top = "33px"; marker.value.style.opacity = "0"; } } const onScroll = useThrottleFn(setActiveLink, 100); watchDebounced(routeHash, () => { updateHash(router, routeHash.value); }, { debounce: 500 }); onMounted(() => { setTimeout(() => { setActiveLink(); window.addEventListener("scroll", onScroll); }, 1e3); }); onUpdated(() => { activateLink(location.hash); }); onUnmounted(() => { window.removeEventListener("scroll", onScroll); }); } /** * Get the absolute top position of an element * Accounts for fixed positioned ancestors * * 获取元素的绝对顶部位置 * 考虑固定定位的祖先元素 * * @param element - Element to measure / 要测量的元素 * @returns Absolute top position or NaN / 绝对顶部位置或 NaN */ function getAbsoluteTop(element) { let offsetTop = 0; while (element && element !== document.body) { if (window.getComputedStyle(element).position === "fixed") return element.offsetTop; offsetTop += element.offsetTop; element = element.offsetParent; } return element ? offsetTop : NaN; } /** * Update current hash without triggering scrollBehavior * Temporarily disables scroll behavior during hash update * * 更新当前哈希而不触发 scrollBehavior * 在哈希更新期间临时禁用滚动行为 * * @param router - Vue Router instance / Vue Router 实例 * @param hash - New hash value / 新的哈希值 */ async function updateHash(router, hash) { const { path, query } = router.currentRoute.value; const { scrollBehavior } = router.options; router.options.scrollBehavior = void 0; await router.replace({ path, query, hash }); router.options.scrollBehavior = scrollBehavior; } //#endregion //#region src/client/composables/sidebar-data.ts const { __auto__, __home__, ...items } = sidebar; /** * Global sidebar data reference. * * 全局侧边栏数据引用。 */ const sidebarData = ref(items); /** * Auto-generated directory sidebar reference. * * 自动生成目录侧边栏引用。 */ const autoDirSidebar = ref(__auto__); const autoHomeData = ref(__home__); if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot)) __VUE_HMR_RUNTIME__.updateSidebar = (data) => { const { __auto__, __home__, ...items } = data; sidebarData.value = items; autoDirSidebar.value = __auto__; autoHomeData.value = __home__; }; const sidebar$1 = ref([]); /** * Setup sidebar tracking. * Automatically updates sidebar based on route and frontmatter changes. * * 设置侧边栏跟踪。 * 根据路由和 frontmatter 变化自动更新侧边栏。 */ 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"; }); watch([ hasSidebar, routeLocale, () => frontmatter.value.sidebar, () => page.value.path ], () => { sidebar$1.value = hasSidebar.value ? getSidebar(typeof frontmatter.value.sidebar === "string" ? frontmatter.value.sidebar : page.value.path, routeLocale.value) : []; }, { immediate: true }); } /** * Use sidebar data. * Returns the resolved sidebar items for the current page. * * 获取侧边栏数据。 * 返回当前页面的解析侧边栏项目。 * * @returns Resolved sidebar items reference / 解析侧边栏项目引用 */ function useSidebarData() { return sidebar$1; } /** * Get the sidebar configuration from sidebar options. * Ensures correct sidebar config from MultiSideBarConfig with various path combinations. * Returns empty array if no matching config is found. * * 从侧边栏选项获取侧边栏配置。 * 确保从 MultiSideBarConfig 获取正确的侧边栏配置,支持各种路径组合。 * 如果未找到匹配的配置,则返回空数组。 * * @param routePath - Current route path / 当前路由路径 * @param routeLocal - Current route locale / 当前路由语言环境 * @returns Resolved sidebar items / 解析的侧边栏项目 */ function getSidebar(routePath, routeLocal) { const _sidebar = sidebarData.value[routeLocal]; if (_sidebar === "auto") return resolveSidebarItems(autoDirSidebar.value[routeLocal]); else if (isArray(_sidebar)) return resolveSidebarItems(_sidebar, routeLocal); else if (isPlainObject$1(_sidebar)) { routePath = decodeURIComponent(routePath); const dir = Object.keys(_sidebar).sort((a, b) => b.split("/").length - a.split("/").length).find((dir) => { return routePath.startsWith(`${routeLocal}${removeLeadingSlash$1(dir)}`); }) || ""; const sidebar = dir ? _sidebar[dir] : void 0; if (sidebar === "auto") return resolveSidebarItems(dir ? autoDirSidebar.value[dir] : [], routeLocal); else if (isArray(sidebar)) return resolveSidebarItems(sidebar, dir); else if (isPlainObject$1(sidebar)) { const prefix = normalizePrefix(routeLocal, sidebar.prefix); return resolveSidebarItems(sidebar.items === "auto" ? autoDirSidebar.value[prefix] : sidebar.items, prefix); } } return []; } /** * Resolve sidebar items from raw configuration. * Converts string items and nested structures to resolved format. * * 从原始配置解析侧边栏项目。 * 将字符串项目和嵌套结构转换为解析格式。 * * @param sidebarItems - Raw sidebar items / 原始侧边栏项目 * @param _prefix - URL prefix for nested items / 嵌套项目的 URL 前缀 * @returns Resolved sidebar items / 解析的侧边栏项目 */ function resolveSidebarItems(sidebarItems, _prefix = "") { const resolved = []; sidebarItems.forEach((item) => { if (isString$1(item)) resolved.push(resolveNavLink(normalizeLink(_prefix, item))); else { const { link, items, prefix, dir, ...args } = item; const navLink = { ...args }; 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 = resolveSidebarItems(autoDirSidebar.value[nextPrefix], 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) : void 0; resolved.push(navLink); } }); return resolved; } /** * Get or generate sidebar groups from the given sidebar items. * Groups consecutive items without children into a single group. * * 从给定的侧边栏项目获取或生成侧边栏分组。 * 将没有子项目的连续项目分组到单个组中。 * * @param sidebar - Flat array of sidebar items / 平面侧边栏项目数组 * @returns Grouped sidebar items / 分组的侧边栏项目 */ function getSidebarGroups(sidebar) { const groups = []; 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; } /** * Get the first link from sidebar items. * Recursively searches through nested items to find the first link. * * 从侧边栏项目获取第一个链接。 * 递归搜索嵌套项目以找到第一个链接。 * * @param sidebar - Sidebar items to search / 要搜索的侧边栏项目 * @returns First link found or empty string / 找到的第一个链接或空字符串 */ function getSidebarFirstLink(sidebar) { for (const item of sidebar) { if (item.link) return item.link; if (item.items) return getSidebarFirstLink(item.items); } return ""; } //#endregion //#region src/client/composables/sidebar.ts /** * Check if the given sidebar item contains any active link. * * 检查给定的侧边栏项是否包含活动链接 */ function hasActiveLink(path, items) { if (Array.isArray(items)) return items.some((item) => hasActiveLink(path, item)); return isActive(path, items.link ? resolveRouteFullPath(items.link) : void 0) ? true : items.items ? hasActiveLink(path, items.items) : false; } const containsActiveLink = hasActiveLink; const isSidebarEnabled = ref(false); const isSidebarCollapsed = ref(false); /** * Use sidebar control * * 侧边栏控制,提供启用/禁用侧边栏和折叠/展开的控制函数 */ function useSidebarControl() { const enableSidebar = () => { isSidebarEnabled.value = true; }; const disableSidebar = () => { isSidebarEnabled.value = false; }; const toggleSidebarEnabled = () => { if (isSidebarEnabled.value) disableSidebar(); else enableSidebar(); }; function toggleSidebarCollapse(collapse) { isSidebarCollapsed.value = collapse ?? !isSidebarCollapsed.value; } return { isSidebarEnabled, enableSidebar, disableSidebar, toggleSidebarEnabled, isSidebarCollapsed, toggleSidebarCollapse }; } /** * Use sidebar * * 侧边栏数据,获取当前路由的侧边栏项目和分组 */ function useSidebar() { const { page } = useData(); const routeLocal = useRouteLocale(); const { hasSidebar } = useLayout(); const sidebar = useSidebarData(); const sidebarGroups = computed(() => { return hasSidebar.value ? getSidebarGroups(sidebar.value) : []; }); return { sidebar, 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)); }) || ""; }), sidebarGroups }; } /** * Use close sidebar on escape * * a11y: 缓存打开侧边栏的元素(菜单按钮),当使用 Escape 关闭菜单时重新聚焦该按钮 */ function useCloseSidebarOnEscape() { const { disableSidebar } = useSidebarControl(); let triggerElement; watchEffect(() => { triggerElement = isSidebarEnabled.value ? document.activeElement : void 0; }); onMounted(() => { window.addEventListener("keyup", onEscape); }); onUnmounted(() => { window.removeEventListener("keyup", onEscape); }); function onEscape(e) { if (e.key === "Escape" &&