feat(theme): add navbar components
This commit is contained in:
parent
86f51ff271
commit
4b0078d010
@ -19,31 +19,32 @@ export default defineUserConfig({
|
||||
theme: themePlume({
|
||||
logo: 'https://pengzhanbo.cn/g.gif',
|
||||
hostname: 'https://pengzhanbo.cn',
|
||||
appearance: true,
|
||||
avatar: {
|
||||
url: '/images/blogger.jpg',
|
||||
name: 'Plume Theme',
|
||||
description: 'The Theme for Vuepress 2.0',
|
||||
},
|
||||
social: {
|
||||
email: 'volodymyr@foxmail.com',
|
||||
github: 'pengzhanbo',
|
||||
QQ: '942450674',
|
||||
weiBo: 'https://weibo.com',
|
||||
zhiHu: 'https://zhihu.com',
|
||||
facebook: 'https://baidu.com',
|
||||
twitter: 'https://baidu.com',
|
||||
linkedin: 'https://baidu.com',
|
||||
},
|
||||
social: [{ icon: 'github', link: 'https://github.com/pengzhanbo' }],
|
||||
// {
|
||||
// email: 'volodymyr@foxmail.com',
|
||||
// github: 'pengzhanbo',
|
||||
// QQ: '942450674',
|
||||
// weiBo: 'https://weibo.com',
|
||||
// zhiHu: 'https://zhihu.com',
|
||||
// facebook: 'https://baidu.com',
|
||||
// twitter: 'https://baidu.com',
|
||||
// linkedin: 'https://baidu.com',
|
||||
// },
|
||||
notes,
|
||||
darkMode: true,
|
||||
navbar: [
|
||||
{
|
||||
text: 'VuePress',
|
||||
children: [
|
||||
items: [
|
||||
{ text: 'theme-plume', link: '/note/vuepress-theme-plume/' },
|
||||
{
|
||||
text: 'Plugin',
|
||||
children: [
|
||||
items: [
|
||||
{ text: 'caniuse', link: '/note/vuepress-plugin/caniuse/' },
|
||||
{
|
||||
text: 'netlify-functions',
|
||||
|
||||
@ -101,7 +101,7 @@ function initSidebar(note: NotesItem, pages: NotePage[]): NotesSidebarItem[] {
|
||||
}
|
||||
|
||||
function initSidebarByConfig(
|
||||
{ text, link, dir, sidebar }: NotesItem,
|
||||
{ text, dir, sidebar }: NotesItem,
|
||||
pages: NotePage[]
|
||||
): NotesSidebarItem[] {
|
||||
return (sidebar as NotesSidebar).map((item) => {
|
||||
@ -114,11 +114,11 @@ function initSidebarByConfig(
|
||||
items: [],
|
||||
}
|
||||
} else {
|
||||
// link = path.join(link || '', item.link || '')
|
||||
const current = findNotePage(item.link || '', dir, pages)
|
||||
return {
|
||||
text: item.text || item.dir || current?.title,
|
||||
link: current?.link,
|
||||
collapsed: item.collapsed,
|
||||
items: initSidebarByConfig(
|
||||
{
|
||||
link: item.link || '',
|
||||
|
||||
@ -19,6 +19,7 @@ export type NotesSidebarItem = {
|
||||
text?: string
|
||||
link?: string
|
||||
dir?: string
|
||||
collapsed?: boolean
|
||||
items?: NotesSidebar
|
||||
}
|
||||
|
||||
|
||||
42
packages/theme/src/client/components/AutoLink.vue
Normal file
42
packages/theme/src/client/components/AutoLink.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { EXTERNAL_URL_RE, normalizeLink } from '../utils/index.js'
|
||||
import IconExternalLink from './icons/IconExternalLink.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
tag?: string
|
||||
href?: string
|
||||
noIcon?: boolean
|
||||
}>()
|
||||
|
||||
const tag = computed(() => (props.tag ?? props.href ? 'a' : 'span'))
|
||||
const isExternal = computed(
|
||||
() => props.href && EXTERNAL_URL_RE.test(props.href)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Component
|
||||
:is="tag"
|
||||
class="auto-link"
|
||||
:class="{ link: href }"
|
||||
:href="href ? normalizeLink(href) : undefined"
|
||||
:target="isExternal ? '_blank' : undefined"
|
||||
:rel="isExternal ? 'noreferrer' : undefined"
|
||||
>
|
||||
<slot />
|
||||
<IconExternalLink v-if="isExternal && !noIcon" class="icon" />
|
||||
</Component>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.icon {
|
||||
display: inline-block;
|
||||
margin-top: -1px;
|
||||
margin-left: 4px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
fill: var(--vp-c-text-3);
|
||||
transition: fill 0.25s;
|
||||
}
|
||||
</style>
|
||||
46
packages/theme/src/client/components/Flyout/MenuGroup.vue
Normal file
46
packages/theme/src/client/components/Flyout/MenuGroup.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<script lang="ts" setup>
|
||||
import MenuLink from './MenuLink.vue'
|
||||
|
||||
defineProps<{
|
||||
text?: string
|
||||
items: any[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="menu-group">
|
||||
<p v-if="text" class="title">{{ text }}</p>
|
||||
|
||||
<template v-for="item in items">
|
||||
<MenuLink v-if="'link' in item" :key="item?.link" :item="item" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.menu-group {
|
||||
margin: 12px -12px 0;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
padding: 12px 12px 0;
|
||||
}
|
||||
|
||||
.menu-group:first-child {
|
||||
margin-top: 0;
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.menu-group + .menu-group {
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: 0 12px;
|
||||
line-height: 32px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: color 0.25s;
|
||||
}
|
||||
</style>
|
||||
57
packages/theme/src/client/components/Flyout/MenuLink.vue
Normal file
57
packages/theme/src/client/components/Flyout/MenuLink.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<script lang="ts" setup>
|
||||
import { usePageData } from '@vuepress/client'
|
||||
import { isActive } from '../../utils/index.js'
|
||||
import AutoLink from '../AutoLink.vue'
|
||||
|
||||
defineProps<{
|
||||
item: any
|
||||
}>()
|
||||
|
||||
const page = usePageData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="menu-link">
|
||||
<AutoLink
|
||||
:class="{
|
||||
active: isActive(
|
||||
page.path,
|
||||
item.activeMatch || item.link,
|
||||
!!item.activeMatch
|
||||
),
|
||||
}"
|
||||
:href="item.link"
|
||||
>
|
||||
{{ item.text }}
|
||||
</AutoLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.menu-group + .menu-link {
|
||||
margin: 12px -12px 0;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
padding: 12px 12px 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
line-height: 32px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.25s, color 0.25s;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--vp-c-brand);
|
||||
background-color: var(--vp-c-bg-elv-mute);
|
||||
}
|
||||
|
||||
.link.active {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
</style>
|
||||
72
packages/theme/src/client/components/Flyout/VMenu.vue
Normal file
72
packages/theme/src/client/components/Flyout/VMenu.vue
Normal file
@ -0,0 +1,72 @@
|
||||
<script lang="ts" setup>
|
||||
import MenuGroup from './MenuGroup.vue'
|
||||
import MenuLink from './MenuLink.vue'
|
||||
|
||||
defineProps<{
|
||||
items?: any[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="menu-wrapper">
|
||||
<div v-if="items" class="items">
|
||||
<template v-for="item in items" :key="item.text">
|
||||
<MenuLink v-if="'link' in item" :item="item" />
|
||||
<MenuGroup v-else :text="item.text" :items="item.items" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.menu-wrapper {
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
min-width: 128px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background-color: var(--vp-c-bg-elv);
|
||||
box-shadow: var(--vp-shadow-3);
|
||||
transition: background-color 0.5s;
|
||||
max-height: calc(100vh - var(--vp-nav-height));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.menu-wrapper :deep(.group) {
|
||||
margin: 0 -12px;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.menu-wrapper :deep(.group + .group) {
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
padding: 11px 12px 12px;
|
||||
}
|
||||
|
||||
.menu-wrapper :deep(.group:last-child) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.menu-wrapper :deep(.group + .item) {
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
padding: 11px 16px 0;
|
||||
}
|
||||
|
||||
.menu-wrapper :deep(.item) {
|
||||
padding: 0 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-wrapper :deep(.label) {
|
||||
flex-grow: 1;
|
||||
line-height: 28px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: color 0.5s;
|
||||
}
|
||||
|
||||
.menu-wrapper :deep(.action) {
|
||||
padding-left: 24px;
|
||||
}
|
||||
</style>
|
||||
138
packages/theme/src/client/components/Flyout/index.vue
Normal file
138
packages/theme/src/client/components/Flyout/index.vue
Normal file
@ -0,0 +1,138 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { useFlyout } from '../../composables/flyout.js'
|
||||
import IconChevronDown from '../icons/IconChevronDown.vue'
|
||||
import IconMoreHorizontal from '../icons/IconMoreHorizontal.vue'
|
||||
import VMenu from './VMenu.vue'
|
||||
|
||||
defineProps<{
|
||||
icon?: any
|
||||
button?: string
|
||||
label?: string
|
||||
items?: any[]
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const el = ref<HTMLElement>()
|
||||
|
||||
useFlyout({ el, onBlur })
|
||||
|
||||
function onBlur() {
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="el"
|
||||
class="flyout-wrapper"
|
||||
@mouseenter="open = true"
|
||||
@mouseleave="open = false"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="open"
|
||||
:aria-label="label"
|
||||
@click="open = !open"
|
||||
>
|
||||
<span v-if="button || icon" class="text">
|
||||
<Component :is="icon" v-if="icon" class="option-icon" />
|
||||
{{ button }}
|
||||
<IconChevronDown class="text-icon" />
|
||||
</span>
|
||||
|
||||
<IconMoreHorizontal v-else class="icon" />
|
||||
</button>
|
||||
|
||||
<div class="menu">
|
||||
<VMenu :items="items">
|
||||
<slot />
|
||||
</VMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flyout-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flyout-wrapper:hover {
|
||||
color: var(--vp-c-brand);
|
||||
transition: color 0.25s;
|
||||
}
|
||||
|
||||
.flyout-wrapper:hover .text {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.flyout-wrapper:hover .icon {
|
||||
fill: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.flyout-wrapper.active .text {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.flyout-wrapper.active:hover .text {
|
||||
color: var(--vp-c-brand-dark);
|
||||
}
|
||||
|
||||
.flyout-wrapper:hover .menu,
|
||||
.button[aria-expanded='true'] + .menu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
height: var(--vp-nav-height);
|
||||
color: var(--vp-c-text-1);
|
||||
transition: color 0.5s;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: var(--vp-nav-height);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
transition: color 0.25s;
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
margin-right: 0px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.text-icon {
|
||||
margin-left: 4px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
transition: fill 0.25s;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(var(--vp-nav-height) / 2 + 20px);
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.25s, visibility 0.25s, transform 0.25s;
|
||||
}
|
||||
</style>
|
||||
@ -2,6 +2,11 @@
|
||||
import { useWindowScroll } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { useSidebar } from '../../composables/sidebar.js'
|
||||
import NavBarAppearance from './NavBarAppearance.vue'
|
||||
import NavBarExtra from './NavBarExtra.vue'
|
||||
import NavBarHamburger from './NavBarHamburger.vue'
|
||||
import NavBarMenu from './NavBarMenu.vue'
|
||||
import NavBarSocialLinks from './NavBarSocialLinks.vue'
|
||||
import NavBarTitle from './NavBarTitle.vue'
|
||||
|
||||
defineProps<{
|
||||
@ -31,6 +36,15 @@ const classes = computed(() => ({
|
||||
<div class="curtain"></div>
|
||||
<div class="content-body">
|
||||
<NavbarSearch class="search" />
|
||||
<NavBarMenu class="menu" />
|
||||
<NavBarAppearance class="appearance" />
|
||||
<NavBarSocialLinks class="social-links" />
|
||||
<NavBarExtra class="extra" />
|
||||
<NavBarHamburger
|
||||
class="hamburger"
|
||||
:active="isScreenOpen"
|
||||
@click="$emit('toggle-screen')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
<script lang="ts" setup>
|
||||
import { useThemeLocaleData } from '../../composables/index.js'
|
||||
import SwitchAppearance from '../SwitchAppearance.vue'
|
||||
|
||||
const theme = useThemeLocaleData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="theme.appearance" class="navbar-appearance">
|
||||
<SwitchAppearance />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.navbar-appearance {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.navbar-appearance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
78
packages/theme/src/client/components/Nav/NavBarExtra.vue
Normal file
78
packages/theme/src/client/components/Nav/NavBarExtra.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useThemeLocaleData } from '../../composables/index.js'
|
||||
import Flyout from '../Flyout/index.vue'
|
||||
import SocialLinks from '../SocialLinks.vue'
|
||||
import SwitchAppearance from '../SwitchAppearance.vue'
|
||||
|
||||
const theme = useThemeLocaleData()
|
||||
|
||||
const hasExtraContent = computed(
|
||||
() => theme.value.appearance || theme.value.social
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Flyout v-if="hasExtraContent" class="navbar-extra" label="extra navigation">
|
||||
<div v-if="theme.appearance" class="group">
|
||||
<div class="item appearance">
|
||||
<p class="label">Appearance</p>
|
||||
<div class="appearance-action">
|
||||
<SwitchAppearance />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="theme.social" class="group">
|
||||
<div class="item social-links">
|
||||
<SocialLinks class="social-links-list" :links="theme.social" />
|
||||
</div>
|
||||
</div>
|
||||
</Flyout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.navbar-extra {
|
||||
display: none;
|
||||
margin-right: -12px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.navbar-extra {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.navbar-extra {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.trans-title {
|
||||
padding: 0 24px 0 12px;
|
||||
line-height: 32px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.item.appearance,
|
||||
.item.social-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.item.appearance {
|
||||
min-width: 176px;
|
||||
}
|
||||
|
||||
.appearance-action {
|
||||
margin-right: -2px;
|
||||
}
|
||||
|
||||
.social-links-list {
|
||||
margin: -4px -8px;
|
||||
}
|
||||
</style>
|
||||
118
packages/theme/src/client/components/Nav/NavBarHamburger.vue
Normal file
118
packages/theme/src/client/components/Nav/NavBarHamburger.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="navbar-hamburger"
|
||||
:class="{ active }"
|
||||
aria-label="mobile navigation"
|
||||
:aria-expanded="active"
|
||||
aria-controls="nav-screen"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<span class="container">
|
||||
<span class="top" />
|
||||
<span class="middle" />
|
||||
<span class="bottom" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.navbar-hamburger {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 48px;
|
||||
height: var(--vp-nav-height);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.navbar-hamburger {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.navbar-hamburger:hover .top {
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.navbar-hamburger:hover .middle {
|
||||
top: 6px;
|
||||
left: 0;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.navbar-hamburger:hover .bottom {
|
||||
top: 12px;
|
||||
left: 0;
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
.navbar-hamburger.active .top {
|
||||
top: 6px;
|
||||
transform: translateX(0) rotate(225deg);
|
||||
}
|
||||
|
||||
.navbar-hamburger.active .middle {
|
||||
top: 6px;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.navbar-hamburger.active .bottom {
|
||||
top: 6px;
|
||||
transform: translateX(0) rotate(135deg);
|
||||
}
|
||||
|
||||
.navbar-hamburger.active:hover .top,
|
||||
.navbar-hamburger.active:hover .middle,
|
||||
.navbar-hamburger.active:hover .bottom {
|
||||
background-color: var(--vp-c-text-2);
|
||||
transition: top 0.25s, background-color 0.25s, transform 0.25s;
|
||||
}
|
||||
|
||||
.top,
|
||||
.middle,
|
||||
.bottom {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 2px;
|
||||
background-color: var(--vp-c-text-1);
|
||||
transition: top 0.25s, background-color 0.5s, transform 0.25s;
|
||||
}
|
||||
|
||||
.top {
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.middle {
|
||||
top: 6px;
|
||||
left: 0;
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
.bottom {
|
||||
top: 12px;
|
||||
left: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
</style>
|
||||
35
packages/theme/src/client/components/Nav/NavBarMenu.vue
Normal file
35
packages/theme/src/client/components/Nav/NavBarMenu.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<script lang="ts" setup>
|
||||
import { useThemeLocaleData } from '../../composables/themeData.js'
|
||||
import NavBarMenuGroup from './NavBarMenuGroup.vue'
|
||||
import NavBarMenuLink from './NavBarMenuLink.vue'
|
||||
|
||||
const theme = useThemeLocaleData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
v-if="theme.navbar"
|
||||
aria-labelledby="main-nav-aria-label"
|
||||
class="navbar-menu"
|
||||
>
|
||||
<span id="main-nav-aria-label" class="visually-hidden"
|
||||
>Main Navigation</span
|
||||
>
|
||||
<template v-for="item in theme.navbar" :key="item.text">
|
||||
<NavBarMenuLink v-if="'link' in item" :item="item" />
|
||||
<NavBarMenuGroup v-else :item="item" />
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.navbar-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.navbar-menu {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
23
packages/theme/src/client/components/Nav/NavBarMenuGroup.vue
Normal file
23
packages/theme/src/client/components/Nav/NavBarMenuGroup.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
import { usePageData } from '@vuepress/client'
|
||||
import type { NavItemWithChildren } from '../../../shared/index.js'
|
||||
import { isActive } from '../../utils/index.js'
|
||||
import Flyout from '../Flyout/index.vue'
|
||||
|
||||
defineProps<{
|
||||
item: NavItemWithChildren
|
||||
}>()
|
||||
|
||||
const page = usePageData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Flyout
|
||||
:class="{
|
||||
'navbar-menu-group': true,
|
||||
'active': isActive(page.path, item.activeMatch, !!item.activeMatch),
|
||||
}"
|
||||
:button="item.text"
|
||||
:items="item.items"
|
||||
/>
|
||||
</template>
|
||||
50
packages/theme/src/client/components/Nav/NavBarMenuLink.vue
Normal file
50
packages/theme/src/client/components/Nav/NavBarMenuLink.vue
Normal file
@ -0,0 +1,50 @@
|
||||
<script lang="ts" setup>
|
||||
import { usePageData } from '@vuepress/client'
|
||||
import type { NavItemWithLink } from '../../../shared/index.js'
|
||||
import { isActive } from '../../utils/index.js'
|
||||
import AutoLink from '../AutoLink.vue'
|
||||
|
||||
defineProps<{
|
||||
item: NavItemWithLink
|
||||
}>()
|
||||
|
||||
const page = usePageData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AutoLink
|
||||
:class="{
|
||||
'navbar-menu-link': true,
|
||||
'active': isActive(
|
||||
page.path,
|
||||
item.activeMatch || item.link,
|
||||
!!item.activeMatch
|
||||
),
|
||||
}"
|
||||
:href="item.link"
|
||||
:no-icon="true"
|
||||
>
|
||||
{{ item.text }}
|
||||
</AutoLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.navbar-menu-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
line-height: var(--vp-nav-height);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
transition: color 0.25s;
|
||||
}
|
||||
|
||||
.navbar-menu-link.active {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.navbar-menu-link:hover {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,27 @@
|
||||
<script lang="ts" setup>
|
||||
import { useThemeLocaleData } from '../../composables/index.js'
|
||||
import SocialLinks from '../SocialLinks.vue'
|
||||
|
||||
const theme = useThemeLocaleData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SocialLinks
|
||||
v-if="theme.social"
|
||||
class="navbar-social-links"
|
||||
:links="theme.social"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.navbar-social-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.navbar-social-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
50
packages/theme/src/client/components/SocialLink.vue
Normal file
50
packages/theme/src/client/components/SocialLink.vue
Normal file
@ -0,0 +1,50 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import type { SocialLinkIcon } from '../../shared/index.js'
|
||||
import { icons } from '../utils/index.js'
|
||||
|
||||
const props = defineProps<{
|
||||
icon: SocialLinkIcon
|
||||
link: string
|
||||
}>()
|
||||
|
||||
const svg = computed(() => {
|
||||
if (typeof props.icon === 'object') return props.icon.svg
|
||||
return icons[props.icon]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<a
|
||||
class="social-link"
|
||||
:href="link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
v-html="svg"
|
||||
>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.social-link {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: color 0.5s;
|
||||
}
|
||||
|
||||
.social-link:hover {
|
||||
color: var(--vp-c-text-1);
|
||||
transition: color 0.25s;
|
||||
}
|
||||
|
||||
.social-link > :deep(svg) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
27
packages/theme/src/client/components/SocialLinks.vue
Normal file
27
packages/theme/src/client/components/SocialLinks.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SocialLink as SocialLinkType } from '../../shared/index.js'
|
||||
import SocialLink from './SocialLink.vue'
|
||||
|
||||
defineProps<{
|
||||
links: SocialLinkType[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="social-links">
|
||||
<SocialLink
|
||||
v-for="{ link, icon } in links"
|
||||
:key="link"
|
||||
:icon="icon"
|
||||
:link="link"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.social-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
63
packages/theme/src/client/components/Switch.vue
Normal file
63
packages/theme/src/client/components/Switch.vue
Normal file
@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<button class="switch-wrapper" type="button" role="switch">
|
||||
<span class="check">
|
||||
<span v-if="$slots.default" class="icon">
|
||||
<slot />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.switch-wrapper {
|
||||
position: relative;
|
||||
border-radius: 11px;
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--vp-input-border-color);
|
||||
background-color: var(--vp-input-switch-bg-color);
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
|
||||
.switch-wrapper:hover {
|
||||
border-color: var(--vp-input-hover-border-color);
|
||||
}
|
||||
|
||||
.check {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
/*rtl:ignore*/
|
||||
left: 1px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--vp-c-neutral-inverse);
|
||||
box-shadow: var(--vp-shadow-1);
|
||||
transition: transform 0.25s;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon :deep(svg) {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
fill: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.dark .icon :deep(svg) {
|
||||
fill: var(--vp-c-text-1);
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
</style>
|
||||
118
packages/theme/src/client/components/SwitchAppearance.vue
Normal file
118
packages/theme/src/client/components/SwitchAppearance.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useDarkMode } from '../composables/index.js'
|
||||
import { useThemeLocaleData } from '../composables/themeData.js'
|
||||
import { APPEARANCE_KEY } from '../utils/index.js'
|
||||
import IconMoon from './icons/IconMoon.vue'
|
||||
import IconSun from './icons/IconSun.vue'
|
||||
import Switch from './Switch.vue'
|
||||
|
||||
const theme = useThemeLocaleData()
|
||||
const checked = ref(false)
|
||||
const isDark = useDarkMode()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const toggle = typeof localStorage !== 'undefined' ? useAppearance() : () => {}
|
||||
|
||||
onMounted(() => {
|
||||
checked.value = document.documentElement.classList.contains('dark')
|
||||
})
|
||||
|
||||
function useAppearance() {
|
||||
const query = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const classList = document.documentElement.classList
|
||||
|
||||
let userPreference = localStorage.getItem(APPEARANCE_KEY)
|
||||
|
||||
let isDark =
|
||||
(theme.value.appearance === 'dark' && userPreference == null) ||
|
||||
(userPreference === 'auto' || userPreference == null
|
||||
? query.matches
|
||||
: userPreference === 'dark')
|
||||
|
||||
query.onchange = (e) => {
|
||||
if (userPreference === 'auto') {
|
||||
setClass((isDark = e.matches))
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
setClass((isDark = !isDark))
|
||||
|
||||
userPreference = isDark
|
||||
? query.matches
|
||||
? 'auto'
|
||||
: 'dark'
|
||||
: query.matches
|
||||
? 'light'
|
||||
: 'auto'
|
||||
|
||||
localStorage.setItem(APPEARANCE_KEY, userPreference)
|
||||
}
|
||||
|
||||
function setClass(dark: boolean): void {
|
||||
const css = document.createElement('style')
|
||||
css.type = 'text/css'
|
||||
css.appendChild(
|
||||
document.createTextNode(
|
||||
`:not(.VPSwitchAppearance):not(.VPSwitchAppearance *) {
|
||||
-webkit-transition: none !important;
|
||||
-moz-transition: none !important;
|
||||
-o-transition: none !important;
|
||||
-ms-transition: none !important;
|
||||
transition: none !important;
|
||||
}`
|
||||
)
|
||||
)
|
||||
document.head.appendChild(css)
|
||||
|
||||
checked.value = dark
|
||||
classList[dark ? 'add' : 'remove']('dark')
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const _ = window.getComputedStyle(css).opacity
|
||||
document.head.removeChild(css)
|
||||
}
|
||||
|
||||
return toggle
|
||||
}
|
||||
|
||||
watch(checked, (newIsDark) => {
|
||||
isDark.value = newIsDark
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Switch
|
||||
class="switch-appearance"
|
||||
aria-label="toggle dark mode"
|
||||
:aria-checked="checked"
|
||||
@click="toggle"
|
||||
>
|
||||
<IconSun class="sun" />
|
||||
<IconMoon class="moon" />
|
||||
</Switch>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sun {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.moon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dark .sun {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dark .moon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dark .switch-appearance :deep(.check) {
|
||||
/*rtl:ignore*/
|
||||
transform: translateX(18px);
|
||||
}
|
||||
</style>
|
||||
@ -1,7 +1,5 @@
|
||||
import { usePreferredDark, useStorage } from '@vueuse/core'
|
||||
import { computed, inject, onMounted, onUnmounted, provide, watch } from 'vue'
|
||||
import { inject, provide, ref } from 'vue'
|
||||
import type { InjectionKey, WritableComputedRef } from 'vue'
|
||||
import { useThemeLocaleData } from './themeData.js'
|
||||
|
||||
export type DarkModeRef = WritableComputedRef<boolean>
|
||||
|
||||
@ -13,60 +11,17 @@ export const darkModeSymbol: InjectionKey<DarkModeRef> = Symbol(
|
||||
* Inject dark mode global computed
|
||||
*/
|
||||
export const useDarkMode = (): DarkModeRef => {
|
||||
const isDarkMode = inject(darkModeSymbol)
|
||||
if (!isDarkMode) {
|
||||
const isDark = inject(darkModeSymbol)
|
||||
if (isDark === undefined) {
|
||||
throw new Error('useDarkMode() is called without provider.')
|
||||
}
|
||||
return isDarkMode
|
||||
return isDark
|
||||
}
|
||||
|
||||
/**
|
||||
* Create dark mode ref and provide as global computed in setup
|
||||
*/
|
||||
export const setupDarkMode = (): void => {
|
||||
const themeLocale = useThemeLocaleData()
|
||||
const isDarkPreferred = usePreferredDark()
|
||||
const darkStorage = useStorage(
|
||||
'vuepress-color-scheme',
|
||||
themeLocale.value.colorMode
|
||||
)
|
||||
|
||||
const isDarkMode = computed<boolean>({
|
||||
get() {
|
||||
// disable color mode switching
|
||||
if (!themeLocale.value.colorModeSwitch) {
|
||||
return themeLocale.value.colorMode === 'dark'
|
||||
}
|
||||
// auto detected from prefers-color-scheme
|
||||
if (darkStorage.value === 'auto') {
|
||||
return isDarkPreferred.value
|
||||
}
|
||||
// storage value
|
||||
return darkStorage.value === 'dark'
|
||||
},
|
||||
set(val) {
|
||||
if (val === isDarkPreferred.value) {
|
||||
darkStorage.value = 'auto'
|
||||
} else {
|
||||
darkStorage.value = val ? 'dark' : 'light'
|
||||
}
|
||||
},
|
||||
})
|
||||
provide(darkModeSymbol, isDarkMode)
|
||||
|
||||
updateHtmlDarkClass(isDarkMode)
|
||||
}
|
||||
|
||||
export const updateHtmlDarkClass = (isDarkMode: DarkModeRef): void => {
|
||||
const update = (value = isDarkMode.value): void => {
|
||||
// set `class="dark"` on `<html>` element
|
||||
const htmlEl = window?.document.querySelector('html')
|
||||
htmlEl?.classList.toggle('dark', value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
watch(isDarkMode, update, { immediate: true })
|
||||
})
|
||||
|
||||
onUnmounted(() => update())
|
||||
const isDark = ref<boolean>(false)
|
||||
provide(darkModeSymbol, isDark)
|
||||
}
|
||||
|
||||
59
packages/theme/src/client/composables/flyout.ts
Normal file
59
packages/theme/src/client/composables/flyout.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { onUnmounted, readonly, ref, type Ref, watch } from 'vue'
|
||||
import { inBrowser } from '../utils/index.js'
|
||||
|
||||
interface UseFlyoutOptions {
|
||||
el: Ref<HTMLElement | undefined>
|
||||
onFocus?(): void
|
||||
onBlur?(): void
|
||||
}
|
||||
|
||||
export const focusedElement = ref<HTMLElement>()
|
||||
|
||||
let active = false
|
||||
let listeners = 0
|
||||
|
||||
export function useFlyout(options: UseFlyoutOptions) {
|
||||
const focus = ref(false)
|
||||
|
||||
if (inBrowser) {
|
||||
!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)
|
||||
}
|
||||
|
||||
function activateFocusTracking() {
|
||||
document.addEventListener('focusin', handleFocusIn)
|
||||
active = true
|
||||
focusedElement.value = document.activeElement as HTMLElement
|
||||
}
|
||||
|
||||
function deactivateFocusTracking() {
|
||||
document.removeEventListener('focusin', handleFocusIn)
|
||||
}
|
||||
|
||||
function handleFocusIn() {
|
||||
focusedElement.value = document.activeElement as HTMLElement
|
||||
}
|
||||
@ -1,11 +1,169 @@
|
||||
import { computed } from 'vue'
|
||||
import type {
|
||||
NotesData,
|
||||
NotesSidebarItem,
|
||||
} from '@vuepress-plume/vuepress-plugin-notes-data'
|
||||
import { useNotesData } from '@vuepress-plume/vuepress-plugin-notes-data/client'
|
||||
import type { PageData } from '@vuepress/client'
|
||||
import { usePageData, usePageFrontmatter, withBase } from '@vuepress/client'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { isActive } from '../utils/index.js'
|
||||
import { useThemeLocaleData } from './themeData.js'
|
||||
|
||||
export function getSidebarList(path: string, notesData: NotesData) {
|
||||
const link = Object.keys(notesData).find((link) =>
|
||||
path.startsWith(withBase(link))
|
||||
)
|
||||
return link ? notesData[link] : []
|
||||
}
|
||||
|
||||
export function useSidebar() {
|
||||
const route = useRoute()
|
||||
const notesData = useNotesData()
|
||||
const theme = useThemeLocaleData()
|
||||
const frontmatter = usePageFrontmatter()
|
||||
|
||||
const is960 = useMediaQuery('(min-width: 960px)')
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const sidebar = computed(() => {
|
||||
return theme.value.notes ? getSidebarList(route.path, notesData.value) : []
|
||||
})
|
||||
const hasSidebar = computed(() => {
|
||||
return false
|
||||
return !frontmatter.value.home && sidebar.value.length > 0
|
||||
})
|
||||
|
||||
const hasAside = computed(() => {
|
||||
return !frontmatter.value.home && frontmatter.value.aside !== false
|
||||
})
|
||||
|
||||
const isSidebarEnabled = computed(() => hasSidebar.value && is960.value)
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
isOpen.value ? close() : open()
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
sidebar,
|
||||
hasSidebar,
|
||||
hasAside,
|
||||
isSidebarEnabled,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
}
|
||||
}
|
||||
|
||||
export function useCloseSidebarOnEscape(
|
||||
isOpen: Ref<boolean>,
|
||||
close: () => 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) {
|
||||
if (e.key === 'Escape' && isOpen.value) {
|
||||
close()
|
||||
triggerElement?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useSidebarControl(item: ComputedRef<NotesSidebarItem>) {
|
||||
const page = usePageData<PageData>()
|
||||
|
||||
const collapsed = ref(false)
|
||||
|
||||
const collapsible = computed(() => {
|
||||
return item.value.collapsed != null
|
||||
})
|
||||
|
||||
const isLink = computed(() => {
|
||||
return !!item.value.link
|
||||
})
|
||||
|
||||
const isActiveLink = computed(() => {
|
||||
return isActive(page.value.path, item.value.link)
|
||||
})
|
||||
|
||||
const hasActiveLink = computed(() => {
|
||||
if (isActiveLink.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
return item.value.items
|
||||
? containsActiveLink(
|
||||
page.value.path,
|
||||
item.value.items as NotesSidebarItem[]
|
||||
)
|
||||
: false
|
||||
})
|
||||
|
||||
const hasChildren = computed(() => {
|
||||
return !!(item.value.items && item.value.items.length)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
collapsed.value = !!(collapsible.value && item.value.collapsed)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
;(isActiveLink.value || hasActiveLink.value) && (collapsed.value = false)
|
||||
})
|
||||
|
||||
function toggle() {
|
||||
if (collapsible.value) {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
collapsed,
|
||||
collapsible,
|
||||
isLink,
|
||||
isActiveLink,
|
||||
hasActiveLink,
|
||||
hasChildren,
|
||||
toggle,
|
||||
}
|
||||
}
|
||||
|
||||
export function containsActiveLink(
|
||||
path: string,
|
||||
items: NotesSidebarItem | NotesSidebarItem[]
|
||||
): boolean {
|
||||
if (Array.isArray(items)) {
|
||||
return items.some((item) => containsActiveLink(path, item))
|
||||
}
|
||||
|
||||
return isActive(path, items.link)
|
||||
? true
|
||||
: items.items
|
||||
? containsActiveLink(path, items.items as NotesSidebarItem[])
|
||||
: false
|
||||
}
|
||||
|
||||
@ -2,3 +2,4 @@
|
||||
@use 'fonts';
|
||||
@use 'normalize';
|
||||
@use 'nprogress';
|
||||
@use 'utils';
|
||||
|
||||
9
packages/theme/src/client/styles/utils.scss
Normal file
9
packages/theme/src/client/styles/utils.scss
Normal file
@ -0,0 +1,9 @@
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
white-space: nowrap;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
overflow: hidden;
|
||||
}
|
||||
3
packages/theme/src/client/utils/index.ts
Normal file
3
packages/theme/src/client/utils/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './shared.js'
|
||||
export * from './normalizeLink.js'
|
||||
export * from './socialIcons.js'
|
||||
20
packages/theme/src/client/utils/normalizeLink.ts
Normal file
20
packages/theme/src/client/utils/normalizeLink.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { withBase } from '@vuepress/client'
|
||||
import { isExternal, PATHNAME_PROTOCOL_RE } from './shared.js'
|
||||
|
||||
export function normalizeLink(url: string): string {
|
||||
if (isExternal(url)) {
|
||||
return url.replace(PATHNAME_PROTOCOL_RE, '')
|
||||
}
|
||||
|
||||
const { pathname, search, hash } = new URL(url, 'http://example.com')
|
||||
|
||||
const normalizedPath =
|
||||
pathname.endsWith('/') || pathname.endsWith('.html')
|
||||
? url
|
||||
: url.replace(
|
||||
/(?:(^\.+)\/)?.*$/,
|
||||
`$1${pathname.replace(/(\.md)?$/, '.html')}${search}${hash}`
|
||||
)
|
||||
|
||||
return withBase(normalizedPath)
|
||||
}
|
||||
43
packages/theme/src/client/utils/shared.ts
Normal file
43
packages/theme/src/client/utils/shared.ts
Normal file
@ -0,0 +1,43 @@
|
||||
export const EXTERNAL_URL_RE = /^[a-z]+:/i
|
||||
export const PATHNAME_PROTOCOL_RE = /^pathname:\/\//
|
||||
export const APPEARANCE_KEY = 'vuepress-theme-appearance'
|
||||
export const HASH_RE = /#.*$/
|
||||
export const EXT_RE = /(index)?\.(md|html)$/
|
||||
|
||||
export const inBrowser = typeof document !== 'undefined'
|
||||
|
||||
export function isActive(
|
||||
currentPath: string,
|
||||
matchPath?: string,
|
||||
asRegex = false
|
||||
): boolean {
|
||||
if (matchPath === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
currentPath = normalize(`/${currentPath}`)
|
||||
|
||||
if (asRegex) {
|
||||
return new RegExp(matchPath).test(currentPath)
|
||||
}
|
||||
|
||||
if (normalize(matchPath) !== currentPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const hashMatch = matchPath.match(HASH_RE)
|
||||
|
||||
if (hashMatch) {
|
||||
return (inBrowser ? location.hash : '') === hashMatch[0]
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function normalize(path: string): string {
|
||||
return decodeURI(path).replace(HASH_RE, '').replace(EXT_RE, '')
|
||||
}
|
||||
|
||||
export function isExternal(path: string): boolean {
|
||||
return EXTERNAL_URL_RE.test(path)
|
||||
}
|
||||
22
packages/theme/src/client/utils/socialIcons.ts
Normal file
22
packages/theme/src/client/utils/socialIcons.ts
Normal file
@ -0,0 +1,22 @@
|
||||
// Used under CC0 1.0 from https://simpleicons.org/
|
||||
|
||||
export const icons = {
|
||||
discord:
|
||||
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>',
|
||||
facebook:
|
||||
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Facebook</title><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>',
|
||||
github:
|
||||
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>',
|
||||
instagram:
|
||||
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Instagram</title><path d="M12 0C8.74 0 8.333.015 7.053.072 5.775.132 4.905.333 4.14.63c-.789.306-1.459.717-2.126 1.384S.935 3.35.63 4.14C.333 4.905.131 5.775.072 7.053.012 8.333 0 8.74 0 12s.015 3.667.072 4.947c.06 1.277.261 2.148.558 2.913.306.788.717 1.459 1.384 2.126.667.666 1.336 1.079 2.126 1.384.766.296 1.636.499 2.913.558C8.333 23.988 8.74 24 12 24s3.667-.015 4.947-.072c1.277-.06 2.148-.262 2.913-.558.788-.306 1.459-.718 2.126-1.384.666-.667 1.079-1.335 1.384-2.126.296-.765.499-1.636.558-2.913.06-1.28.072-1.687.072-4.947s-.015-3.667-.072-4.947c-.06-1.277-.262-2.149-.558-2.913-.306-.789-.718-1.459-1.384-2.126C21.319 1.347 20.651.935 19.86.63c-.765-.297-1.636-.499-2.913-.558C15.667.012 15.26 0 12 0zm0 2.16c3.203 0 3.585.016 4.85.071 1.17.055 1.805.249 2.227.415.562.217.96.477 1.382.896.419.42.679.819.896 1.381.164.422.36 1.057.413 2.227.057 1.266.07 1.646.07 4.85s-.015 3.585-.074 4.85c-.061 1.17-.256 1.805-.421 2.227-.224.562-.479.96-.899 1.382-.419.419-.824.679-1.38.896-.42.164-1.065.36-2.235.413-1.274.057-1.649.07-4.859.07-3.211 0-3.586-.015-4.859-.074-1.171-.061-1.816-.256-2.236-.421-.569-.224-.96-.479-1.379-.899-.421-.419-.69-.824-.9-1.38-.165-.42-.359-1.065-.42-2.235-.045-1.26-.061-1.649-.061-4.844 0-3.196.016-3.586.061-4.861.061-1.17.255-1.814.42-2.234.21-.57.479-.96.9-1.381.419-.419.81-.689 1.379-.898.42-.166 1.051-.361 2.221-.421 1.275-.045 1.65-.06 4.859-.06l.045.03zm0 3.678c-3.405 0-6.162 2.76-6.162 6.162 0 3.405 2.76 6.162 6.162 6.162 3.405 0 6.162-2.76 6.162-6.162 0-3.405-2.76-6.162-6.162-6.162zM12 16c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm7.846-10.405c0 .795-.646 1.44-1.44 1.44-.795 0-1.44-.646-1.44-1.44 0-.794.646-1.439 1.44-1.439.793-.001 1.44.645 1.44 1.439z"/></svg>',
|
||||
linkedin:
|
||||
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>LinkedIn</title><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>',
|
||||
mastodon:
|
||||
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Mastodon</title><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>',
|
||||
slack:
|
||||
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Slack</title><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>',
|
||||
twitter:
|
||||
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Twitter</title><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>',
|
||||
youtube:
|
||||
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>YouTube</title><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>',
|
||||
} as const
|
||||
@ -34,7 +34,14 @@ export const setupPlugins = (
|
||||
|
||||
return [
|
||||
palettePlugin({ preset: 'sass' }),
|
||||
themeDataPlugin({ themeData: localeOptions }),
|
||||
themeDataPlugin({
|
||||
themeData: {
|
||||
...localeOptions,
|
||||
notes: localeOptions.notes
|
||||
? { dir: localeOptions.notes.dir, link: localeOptions.notes.link }
|
||||
: undefined,
|
||||
} as any,
|
||||
}),
|
||||
autoFrontmatterPlugin(autoFrontmatter(app, localeOptions)),
|
||||
blogDataPlugin({
|
||||
include: ['**/*.md'],
|
||||
|
||||
@ -47,3 +47,4 @@ export type PlumeThemeData = ThemeData<PlumeThemeLocaleData>
|
||||
|
||||
export * from './locale.js'
|
||||
export * from './plugins.js'
|
||||
export * from './navbar.js'
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
import type { NotesDataOptions } from '@vuepress-plume/vuepress-plugin-notes-data'
|
||||
import type { LocaleData } from '@vuepress/core'
|
||||
import type { NavItem, NavItemWithLink } from './navbar.js'
|
||||
// import type { NavbarConfig, NavLink } from '../layout/index.js'
|
||||
// import type { PlumeThemeNotesOptions } from './notes.js'
|
||||
|
||||
// todo type
|
||||
type NavbarConfig = any
|
||||
type NavLink = any
|
||||
|
||||
export interface PlumeThemeAvatar {
|
||||
/**
|
||||
* 头像链接
|
||||
@ -22,46 +19,28 @@ export interface PlumeThemeAvatar {
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface PlumeThemeSocialOption {
|
||||
/**
|
||||
* 邮箱
|
||||
*/
|
||||
email?: string
|
||||
/**
|
||||
* github链接 支持仅填写 organization / Repositories
|
||||
*/
|
||||
github?: string
|
||||
/**
|
||||
* 微博
|
||||
*/
|
||||
weiBo?: string
|
||||
/**
|
||||
* 知乎
|
||||
*/
|
||||
zhiHu?: string
|
||||
/**
|
||||
* QQ
|
||||
*/
|
||||
QQ?: string
|
||||
/**
|
||||
* facebook
|
||||
*/
|
||||
facebook?: string
|
||||
/**
|
||||
* twitter
|
||||
*/
|
||||
twitter?: string
|
||||
/**
|
||||
* linkedin 英领
|
||||
*/
|
||||
linkedin?: string
|
||||
export interface SocialLink {
|
||||
icon: SocialLinkIcon
|
||||
link: string
|
||||
}
|
||||
|
||||
export type SocialLinkIcon =
|
||||
| 'discord'
|
||||
| 'facebook'
|
||||
| 'github'
|
||||
| 'instagram'
|
||||
| 'linkedin'
|
||||
| 'mastodon'
|
||||
| 'slack'
|
||||
| 'twitter'
|
||||
| 'youtube'
|
||||
| { svg: string }
|
||||
|
||||
export interface PlumeThemeLocaleData extends LocaleData {
|
||||
/**
|
||||
* 网站站点首页
|
||||
*/
|
||||
home?: false | NavLink
|
||||
home?: false | NavItemWithLink
|
||||
/**
|
||||
* 网站站点logo
|
||||
*/
|
||||
@ -73,13 +52,7 @@ export interface PlumeThemeLocaleData extends LocaleData {
|
||||
/**
|
||||
* 是否启用深色模式切换按钮
|
||||
*/
|
||||
colorModeSwitch?: boolean
|
||||
|
||||
colorMode?: 'auto' | 'light' | 'dark'
|
||||
|
||||
toggleDarkMode?: string
|
||||
|
||||
toggleSidebar?: string
|
||||
appearance?: boolean | 'dark'
|
||||
|
||||
/**
|
||||
* 部署站点域名。
|
||||
@ -98,7 +71,7 @@ export interface PlumeThemeLocaleData extends LocaleData {
|
||||
/**
|
||||
* 社交账号配置
|
||||
*/
|
||||
social?: PlumeThemeSocialOption
|
||||
social?: SocialLink[]
|
||||
|
||||
/**
|
||||
* 文章链接前缀
|
||||
@ -112,14 +85,14 @@ export interface PlumeThemeLocaleData extends LocaleData {
|
||||
*
|
||||
* @def:{ text: '标签', link: '/tag/' }
|
||||
*/
|
||||
tag?: false | NavLink
|
||||
tag?: false | NavItemWithLink
|
||||
|
||||
/**
|
||||
* 文章分类 与 navbar配置
|
||||
*
|
||||
* @default: { text: '分类', link: '/category/ }
|
||||
*/
|
||||
category?: false | NavLink
|
||||
category?: false | NavItemWithLink
|
||||
|
||||
/**
|
||||
* 归档页 链接与 navbar 配置
|
||||
@ -128,7 +101,7 @@ export interface PlumeThemeLocaleData extends LocaleData {
|
||||
*
|
||||
* @default: { text: '归档', link: '/timeline/' }
|
||||
*/
|
||||
archive?: false | NavLink
|
||||
archive?: false | NavItemWithLink
|
||||
|
||||
/**
|
||||
* 笔记配置, 笔记中的文章默认不会出现在首页文章列表
|
||||
@ -165,7 +138,7 @@ export interface PlumeThemeLocaleData extends LocaleData {
|
||||
*
|
||||
* Set to `false` to disable navbar in current locale
|
||||
*/
|
||||
navbar?: false | NavbarConfig
|
||||
navbar?: false | NavItem[]
|
||||
/**
|
||||
* 外部链接打开方式
|
||||
*/
|
||||
|
||||
28
packages/theme/src/shared/options/navbar.ts
Normal file
28
packages/theme/src/shared/options/navbar.ts
Normal file
@ -0,0 +1,28 @@
|
||||
export type NavItem = NavItemWithLink | NavItemWithChildren
|
||||
|
||||
export type NavItemWithLink = {
|
||||
text: string
|
||||
link: string
|
||||
|
||||
/**
|
||||
* `activeMatch` is expected to be a regex string. We can't use actual
|
||||
* RegExp object here because it isn't serializable
|
||||
*/
|
||||
activeMatch?: string
|
||||
}
|
||||
|
||||
export type NavItemChildren = {
|
||||
text?: string
|
||||
items: NavItemWithLink[]
|
||||
}
|
||||
|
||||
export interface NavItemWithChildren {
|
||||
text?: string
|
||||
items: (NavItemChildren | NavItemWithLink)[]
|
||||
|
||||
/**
|
||||
* `activeMatch` is expected to be a regex string. We can't use actual
|
||||
* RegExp object here because it isn't serializable
|
||||
*/
|
||||
activeMatch?: string
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user