Merge pull request #113 from pengzhanbo/RC-73

RC 73
This commit is contained in:
pengzhanbo 2024-07-09 01:40:14 +08:00 committed by GitHub
commit 589999b40f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
93 changed files with 3011 additions and 2194 deletions

View File

@ -21,10 +21,6 @@ export default defineUserConfig({
pagePatterns: ['**/*.md', '!**/*.snippet.md', '!.vuepress', '!node_modules'],
markdown: {
code: false,
},
bundler: viteBundler(),
theme,

View File

@ -0,0 +1,62 @@
import { defineThemeConfig } from 'vuepress-theme-plume'
import { enNotes, zhNotes } from './notes.js'
import { enNavbar, zhNavbar } from './navbar.js'
export default defineThemeConfig({
logo: '/plume.png',
docsRepo: 'https://github.com/pengzhanbo/vuepress-theme-plume',
docsDir: 'docs',
profile: {
avatar: '/plume.png',
name: 'Plume Theme',
description: 'The Theme for Vuepress 2.0',
location: 'GuangZhou, China',
organization: 'pengzhanbo',
},
social: [
{ icon: 'github', link: 'https://github.com/pengzhanbo/vuepress-theme-plume' },
{ icon: 'gitlab', link: 'https://pengzhanbo.cn' },
{ icon: 'npm', link: 'https://pengzhanbo.cn' },
{ icon: 'docker', link: 'https://pengzhanbo.cn' },
{ icon: 'stackoverflow', link: 'https://pengzhanbo.cn' },
{ icon: 'juejin', link: 'https://pengzhanbo.cn' },
{ icon: 'discord', link: 'https://pengzhanbo.cn' },
{ icon: 'instagram', link: 'https://pengzhanbo.cn' },
{ icon: 'mastodon', link: 'https://pengzhanbo.cn' },
{ icon: 'slack', link: 'https://pengzhanbo.cn' },
{ icon: 'bilibili', link: 'https://pengzhanbo.cn' },
{ icon: 'linkedin', link: 'https://pengzhanbo.cn' },
{ icon: 'qq', link: 'https://pengzhanbo.cn' },
{ icon: 'twitter', link: 'https://pengzhanbo.cn' },
{ icon: 'x', link: 'https://pengzhanbo.cn' },
{ icon: 'weibo', link: 'https://pengzhanbo.cn' },
{ icon: 'youtube', link: 'https://pengzhanbo.cn' },
{ icon: 'zhihu', link: 'https://pengzhanbo.cn' },
{ icon: 'douban', link: 'https://pengzhanbo.cn' },
{ icon: 'steam', link: 'https://pengzhanbo.cn' },
{ icon: 'xbox', link: 'https://pengzhanbo.cn' },
],
navbarSocialInclude: ['github'],
footer: { copyright: 'Copyright © 2021-present pengzhanbo' },
locales: {
'/': {
notes: zhNotes,
navbar: zhNavbar,
},
'/en/': {
notes: enNotes,
navbar: enNavbar,
},
},
encrypt: {
rules: {
'/article/enx7c9s/': '123456',
},
},
autoFrontmatter: { exclude: ['**/*.snippet.*'] },
})

View File

@ -1,62 +1,10 @@
import process from 'node:process'
import themePlume from 'vuepress-theme-plume'
import { plumeTheme } from 'vuepress-theme-plume'
import type { Theme } from 'vuepress'
import { enNotes, zhNotes } from './notes.js'
import { enNavbar, zhNavbar } from './navbar.js'
export const theme: Theme = themePlume({
logo: '/plume.png',
export const theme: Theme = plumeTheme({
hostname: process.env.SITE_HOST || 'https://plume.pengzhanbo.cn',
docsRepo: 'https://github.com/pengzhanbo/vuepress-theme-plume',
docsDir: 'docs',
profile: {
avatar: '/plume.png',
name: 'Plume Theme',
description: 'The Theme for Vuepress 2.0',
location: 'GuangZhou, China',
organization: 'pengzhanbo',
},
social: [
{ icon: 'github', link: 'https://github.com/pengzhanbo/vuepress-theme-plume' },
{ icon: 'gitlab', link: 'https://pengzhanbo.cn' },
{ icon: 'npm', link: 'https://pengzhanbo.cn' },
{ icon: 'docker', link: 'https://pengzhanbo.cn' },
{ icon: 'stackoverflow', link: 'https://pengzhanbo.cn' },
{ icon: 'juejin', link: 'https://pengzhanbo.cn' },
{ icon: 'discord', link: 'https://pengzhanbo.cn' },
{ icon: 'instagram', link: 'https://pengzhanbo.cn' },
{ icon: 'mastodon', link: 'https://pengzhanbo.cn' },
{ icon: 'slack', link: 'https://pengzhanbo.cn' },
{ icon: 'bilibili', link: 'https://pengzhanbo.cn' },
{ icon: 'linkedin', link: 'https://pengzhanbo.cn' },
{ icon: 'qq', link: 'https://pengzhanbo.cn' },
{ icon: 'twitter', link: 'https://pengzhanbo.cn' },
{ icon: 'x', link: 'https://pengzhanbo.cn' },
{ icon: 'weibo', link: 'https://pengzhanbo.cn' },
{ icon: 'youtube', link: 'https://pengzhanbo.cn' },
{ icon: 'zhihu', link: 'https://pengzhanbo.cn' },
{ icon: 'douban', link: 'https://pengzhanbo.cn' },
{ icon: 'steam', link: 'https://pengzhanbo.cn' },
{ icon: 'xbox', link: 'https://pengzhanbo.cn' },
],
navbarSocialInclude: ['github'],
footer: { copyright: 'Copyright © 2021-present pengzhanbo' },
locales: {
'/': {
notes: zhNotes,
navbar: zhNavbar,
},
'/en/': {
notes: enNotes,
navbar: enNavbar,
},
},
plugins: {
frontmatter: { exclude: ['**/*.snippet.*'] },
shiki: { twoslash: true },
@ -108,9 +56,4 @@ export const theme: Theme = themePlume({
},
},
encrypt: {
rules: {
'/article/enx7c9s/': '123456',
},
},
})

View File

@ -6,18 +6,18 @@
"docs:build": "vuepress build --clean-cache --clean-temp",
"docs:clean": "rimraf .vuepress/.temp .vuepress/.cache .vuepress/dist",
"docs:dev": "vuepress dev",
"docs:serve": "anywhere -s -h localhost -d .vuepress/dist"
"docs:serve": "http-server .vuepress/dist -d 0"
},
"peerDependencies": {
"vuepress": "2.0.0-rc.14"
},
"dependencies": {
"@iconify/json": "^2.2.223",
"@iconify/json": "^2.2.225",
"@vuepress/bundler-vite": "2.0.0-rc.14",
"anywhere": "^1.6.0",
"chart.js": "^4.4.3",
"echarts": "^5.5.1",
"flowchart.ts": "^3.0.0",
"http-server": "^14.1.1",
"mermaid": "^10.9.1",
"vue": "^3.4.31",
"vuepress-theme-plume": "workspace:~"

View File

@ -3,7 +3,7 @@
"type": "module",
"version": "1.0.0-rc.72",
"private": true,
"packageManager": "pnpm@9.4.0",
"packageManager": "pnpm@9.5.0",
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
"license": "MIT",
"keywords": [
@ -52,13 +52,13 @@
"conventional-changelog-cli": "^5.0.0",
"cpx2": "^7.0.1",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^9.5.0",
"eslint": "^9.6.0",
"husky": "^9.0.11",
"lint-staged": "^15.2.7",
"rimraf": "^5.0.7",
"rimraf": "^5.0.8",
"stylelint": "^16.6.1",
"tsconfig-vuepress": "^4.5.0",
"typescript": "^5.5.2"
"typescript": "^5.5.3"
},
"lint-staged": {
"*": "eslint --fix"

View File

@ -2,6 +2,7 @@
"name": "@vuepress-plume/plugin-auto-frontmatter",
"type": "module",
"version": "1.0.0-rc.72",
"private": true,
"description": "The Plugin for VuePress 2 - auto frontmatter",
"author": "pengzhanbo <volodymyr@foxmail.com>",
"license": "MIT",

View File

@ -2,6 +2,7 @@
"name": "@vuepress-plume/plugin-blog-data",
"type": "module",
"version": "1.0.0-rc.72",
"private": "true",
"description": "The Plugin for VuePress 2 - blog data",
"author": "pengzhanbo <volodymyr@foxmail.com>",
"license": "MIT",

View File

@ -52,13 +52,13 @@
"local-pkg": "^0.5.0",
"markdown-it-container": "^4.0.0",
"nanoid": "^5.0.7",
"shiki": "^1.10.0",
"tm-grammars": "^1.13.0",
"tm-themes": "^1.5.0",
"shiki": "^1.10.3",
"tm-grammars": "^1.13.7",
"tm-themes": "^1.5.1",
"vue": "^3.4.31"
},
"devDependencies": {
"@iconify/json": "^2.2.223",
"@iconify/json": "^2.2.225",
"@types/markdown-it": "^14.1.1"
},
"publishConfig": {

View File

@ -2,6 +2,7 @@
"name": "@vuepress-plume/plugin-notes-data",
"type": "module",
"version": "1.0.0-rc.72",
"private": "true",
"description": "The Plugin for VuePress 2 - notes data",
"author": "pengzhanbo <volodymyr@foxmail.com>",
"license": "MIT",

View File

@ -30,7 +30,7 @@ export default defineClientConfig({
id: PLUGIN_ID,
label: PLUGIN_LABEL,
packageName: '@vuepress-plume/plugin-notes-data',
homepage: 'https://pengzhanbo.cn',
homepage: 'https://theme-plume.vuejs.press/',
logo: 'https://v2.vuepress.vuejs.org/images/hero.png',
componentStateTypes: ['VuePress'],
},

View File

@ -46,7 +46,7 @@
"chokidar": "^3.6.0",
"focus-trap": "^7.5.4",
"mark.js": "^8.11.1",
"minisearch": "^6.3.0",
"minisearch": "^7.0.0",
"p-map": "^7.0.2",
"vue": "^3.4.31"
},

View File

@ -36,8 +36,8 @@
"vuepress": "2.0.0-rc.14"
},
"dependencies": {
"@shikijs/transformers": "^1.10.0",
"@shikijs/twoslash": "^1.10.0",
"@shikijs/transformers": "^1.10.3",
"@shikijs/twoslash": "^1.10.3",
"@types/hast": "^3.0.4",
"@vuepress/helper": "2.0.0-rc.37",
"floating-vue": "^5.2.2",
@ -45,7 +45,7 @@
"mdast-util-gfm": "^3.0.0",
"mdast-util-to-hast": "^13.2.0",
"nanoid": "^5.0.7",
"shiki": "^1.10.0",
"shiki": "^1.10.3",
"twoslash": "^0.2.9",
"twoslash-vue": "^0.2.9"
},

2192
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
"description": "A Blog&Document Theme for VuePress 2.0",
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
"license": "MIT",
"homepage": "https://pengzhanbo.cn/note/vuepress-theme-plume",
"homepage": "https://theme-plume.vuejs.press/",
"repository": {
"type": "git",
"url": "git+https://github.com/pengzhanbo/vuepress-theme-plume.git"
@ -67,13 +67,11 @@
},
"dependencies": {
"@pengzhanbo/utils": "^1.1.2",
"@vuepress-plume/plugin-auto-frontmatter": "workspace:*",
"@vue/devtools-api": "6.6.3",
"@vuepress-plume/plugin-baidu-tongji": "workspace:*",
"@vuepress-plume/plugin-blog-data": "workspace:*",
"@vuepress-plume/plugin-content-update": "workspace:*",
"@vuepress-plume/plugin-fonts": "workspace:*",
"@vuepress-plume/plugin-iconify": "workspace:*",
"@vuepress-plume/plugin-notes-data": "workspace:*",
"@vuepress-plume/plugin-search": "workspace:*",
"@vuepress-plume/plugin-shikiji": "workspace:*",
"@vuepress/helper": "2.0.0-rc.37",
@ -87,14 +85,17 @@
"@vuepress/plugin-reading-time": "2.0.0-rc.37",
"@vuepress/plugin-seo": "2.0.0-rc.37",
"@vuepress/plugin-sitemap": "2.0.0-rc.37",
"@vuepress/plugin-theme-data": "2.0.0-rc.37",
"@vuepress/plugin-watermark": "2.0.0-rc.37",
"@vueuse/core": "^10.11.0",
"bcrypt-ts": "^5.0.2",
"chokidar": "^3.6.0",
"create-filter": "^1.1.0",
"date-fns": "^3.6.0",
"katex": "^0.16.10",
"lodash.merge": "^4.6.2",
"esbuild": "~0.21.5",
"fast-glob": "^3.3.2",
"gray-matter": "^4.0.3",
"json2yaml": "^1.1.0",
"katex": "^0.16.11",
"nanoid": "^5.0.7",
"vue": "^3.4.31",
"vue-router": "^4.4.0",

View File

@ -1,19 +1,19 @@
<script lang="ts" setup>
import VPNavBarMenuGroup from '@theme/Nav/VPNavBarMenuGroup.vue'
import VPNavBarMenuLink from '@theme/Nav/VPNavBarMenuLink.vue'
import { useData } from '../../composables/data.js'
import { useNavbarData } from '../../composables/nav.js'
const { theme } = useData()
const navbar = useNavbarData()
</script>
<template>
<nav
v-if="theme.navbar"
v-if="navbar.length"
aria-labelledby="main-nav-aria-label"
class="vp-navbar-menu"
>
<span id="main-nav-aria-label" class="visually-hidden">Main Navigation</span>
<template v-for="item in theme.navbar" :key="item.text">
<template v-for="item in navbar" :key="item.text">
<VPNavBarMenuLink v-if="'link' in item" :item="item" />
<VPNavBarMenuGroup v-else :item="item" />
</template>

View File

@ -2,17 +2,20 @@
import { computed } from 'vue'
import { resolveRouteFullPath } from 'vuepress/client'
import VPFlyout from '@theme/VPFlyout.vue'
import type { NavItem, NavItemWithChildren } from '../../../shared/index.js'
import type {
ResolvedNavItem,
ResolvedNavItemWithChildren,
} from '../../../shared/resolved/navbar.js'
import { isActive } from '../../utils/index.js'
import { useData } from '../../composables/data.js'
const props = defineProps<{
item: NavItemWithChildren
item: ResolvedNavItemWithChildren
}>()
const { page } = useData()
function isChildActive(navItem: NavItem) {
function isChildActive(navItem: ResolvedNavItem): boolean {
if ('link' in navItem) {
return isActive(
page.value.path,

View File

@ -2,12 +2,12 @@
import { resolveRouteFullPath } from 'vuepress/client'
import VPLink from '@theme/VPLink.vue'
import VPIcon from '@theme/VPIcon.vue'
import type { NavItemWithLink } from '../../../shared/index.js'
import type { ResolvedNavItemWithLink } from '../../../shared/resolved/navbar.js'
import { isActive } from '../../utils/index.js'
import { useData } from '../../composables/data.js'
defineProps<{
item: NavItemWithLink
item: ResolvedNavItemWithLink
}>()
const { page } = useData()
@ -23,7 +23,7 @@ const { page } = useData()
),
}"
:href="item.link"
no-icon
:no-icon="item.noIcon"
:target="item.target"
:rel="item.rel"
tabindex="0"

View File

@ -1,19 +1,17 @@
<script lang="ts" setup>
import VPNavScreenMenuGroup from '@theme/Nav/VPNavScreenMenuGroup.vue'
import VPNavScreenMenuLink from '@theme/Nav/VPNavScreenMenuLink.vue'
import { useData } from '../../composables/data.js'
import { useNavbarData } from '../../composables/nav.js'
const { theme } = useData()
const navbar = useNavbarData()
</script>
<template>
<nav v-if="theme.navbar" class="vp-nav-screen-menu">
<template v-for="item in theme.navbar" :key="item.text">
<nav v-if="navbar.length" class="vp-nav-screen-menu">
<template v-for="item in navbar" :key="item.text">
<VPNavScreenMenuLink
v-if="'link' in item"
:text="item.text"
:link="item.link"
:icon="item.icon"
:item="item"
/>
<VPNavScreenMenuGroup
v-else

View File

@ -3,10 +3,11 @@ import { computed, ref } from 'vue'
import VPIcon from '@theme/VPIcon.vue'
import VPNavScreenMenuGroupLink from '@theme/Nav/VPNavScreenMenuGroupLink.vue'
import VPNavScreenMenuGroupSection from '@theme/Nav/VPNavScreenMenuGroupSection.vue'
import type { ThemeIcon } from '../../../shared/index.js'
const props = defineProps<{
text: string
icon?: string | { svg: string }
icon?: ThemeIcon
items: any[]
}>()
@ -39,11 +40,7 @@ function toggle() {
<div :id="groupId" class="items">
<template v-for="item in items" :key="item.text">
<div v-if="'link' in item" :key="item.text" class="item">
<VPNavScreenMenuGroupLink
:text="item.text"
:link="item.link"
:icon="item.icon"
/>
<VPNavScreenMenuGroupLink :item="item" />
</div>
<div v-else class="group">

View File

@ -2,11 +2,10 @@
import { inject } from 'vue'
import VPLink from '@theme/VPLink.vue'
import VPIcon from '@theme/VPIcon.vue'
import type { ResolvedNavItemWithLink } from '../../../shared/resolved/navbar.js'
defineProps<{
icon?: string | { svg: string }
text: string
link: string
item: ResolvedNavItemWithLink
}>()
const closeScreen = inject('close-screen') as () => void
@ -15,11 +14,14 @@ const closeScreen = inject('close-screen') as () => void
<template>
<VPLink
class="vp-nav-screen-menu-group-link"
:href="link"
:href="item.link"
:target="item.target"
:rel="item.rel"
:no-icon="item.noIcon"
@click="closeScreen"
>
<VPIcon v-if="icon" :name="icon" />
<i v-text="text" />
<VPIcon v-if="item.icon" :name="item.icon" />
<span v-html="item.text" />
</VPLink>
</template>

View File

@ -1,10 +1,10 @@
<script lang="ts" setup>
import VPIcon from '@theme/VPIcon.vue'
import VPNavScreenMenuGroupLink from '@theme/Nav/VPNavScreenMenuGroupLink.vue'
import type { NavItemWithLink } from '../../../shared/index.js'
import type { NavItemWithLink, ThemeIcon } from '../../../shared/index.js'
defineProps<{
icon?: string | { svg: string }
icon?: ThemeIcon
text?: string
items: NavItemWithLink[]
}>()
@ -19,9 +19,7 @@ defineProps<{
<VPNavScreenMenuGroupLink
v-for="item in items"
:key="item.text"
:text="item.text"
:link="item.link"
:icon="item.icon"
:item="item"
/>
</div>
</template>

View File

@ -2,20 +2,26 @@
import { inject } from 'vue'
import VPLink from '@theme/VPLink.vue'
import VPIcon from '@theme/VPIcon.vue'
import type { ResolvedNavItemWithLink } from '../../../shared/resolved/navbar.js'
defineProps<{
text: string
link: string
icon?: string | { svg: string }
item: ResolvedNavItemWithLink
}>()
const closeScreen = inject('close-screen') as () => void
</script>
<template>
<VPLink class="vp-nav-screen-menu-link" :href="link" @click="closeScreen">
<VPIcon v-if="icon" :name="icon" />
<i v-text="text" />
<VPLink
class="vp-nav-screen-menu-link"
:href="item.link"
:target="item.target"
:rel="item.rel"
:no-icon="item.noIcon"
@click="closeScreen"
>
<VPIcon v-if="item.icon" :name="item.icon" />
<span v-html="item.text" />
</VPLink>
</template>

View File

@ -6,7 +6,7 @@ import VPDocAside from '@theme/VPDocAside.vue'
import VPDocFooter from '@theme/VPDocFooter.vue'
import VPEncryptPage from '@theme/VPEncryptPage.vue'
import VPDocMeta from '@theme/VPDocMeta.vue'
import { usePageEncrypt } from '../composables/encrypt.js'
import { useEncrypt } from '../composables/encrypt.js'
import { useSidebar } from '../composables/sidebar.js'
import { useData } from '../composables/data.js'
@ -14,10 +14,10 @@ const { page, theme, frontmatter, isDark } = useData()
const route = useRoute()
const { hasSidebar, hasAside, leftAside } = useSidebar()
const { isPageDecrypted } = usePageEncrypt()
const { isPageDecrypted } = useEncrypt()
const hasComments = computed(() => {
return page.value.frontmatter.comments !== false
return page.value.frontmatter.comments !== false && isPageDecrypted.value
})
const enableAside = computed(() => {
@ -114,7 +114,7 @@ watch(
:class="[pageName, enabledExternalLinkIcon && 'external-link-icon-enabled']"
/>
</main>
<VPDocFooter>
<VPDocFooter v-if="isPageDecrypted">
<template #doc-footer-before>
<slot name="doc-footer-before" />
</template>

View File

@ -1,20 +1,28 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useData } from '../composables/data.js'
import { useEncryptCompare } from '../composables/encrypt.js'
const props = defineProps<{
compare: (password: string) => boolean
global?: boolean
info?: string
}>()
const { theme } = useData()
const { compareGlobal, comparePage } = useEncryptCompare()
const password = ref('')
const errorCode = ref(0) // 0: no error, 1: wrong password
const unlocking = ref(false)
function onSubmit() {
const result = props.compare(password.value)
async function onSubmit() {
if (unlocking.value)
return
const compare = props.global ? compareGlobal : comparePage
unlocking.value = true
const result = await compare(password.value)
unlocking.value = false
if (!result) {
errorCode.value = 1
}
@ -40,8 +48,9 @@ function onSubmit() {
@input="password && (errorCode = 0)"
>
</p>
<button class="encrypt-button" @click="onSubmit">
{{ theme.encryptButtonText ?? 'Confirm' }}
<button class="encrypt-button" :class="{ unlocking }" @click="onSubmit">
<span v-if="!unlocking">{{ theme.encryptButtonText ?? 'Confirm' }}</span>
<span v-else class="vpi-loading" />
</button>
</div>
</template>
@ -104,4 +113,14 @@ function onSubmit() {
.encrypt-button:hover {
background-color: var(--vp-c-brand-2);
}
.encrypt-button.unlocking {
color: var(--vp-c-brand-1);
background-color: var(--vp-c-gray-1);
}
.vpi-loading {
display: inline-block;
transform: scale(5);
}
</style>

View File

@ -3,29 +3,29 @@ import { computed } from 'vue'
import VPFooter from '@theme/VPFooter.vue'
import VPEncryptForm from '@theme/VPEncryptForm.vue'
import { useData } from '../composables/data.js'
import { useGlobalEncrypt } from '../composables/encrypt.js'
const { theme, site } = useData()
const { compareGlobal } = useGlobalEncrypt()
const profile = computed(() => theme.value.profile)
const title = computed(() => profile.value?.name || site.value.title)
</script>
<template>
<div class="vp-global-encrypt">
<div class="global-encrypt-container">
<div v-if="profile || title" class="profile">
<p v-if="profile" class="avatar" :class="{ circle: profile.circle }">
<img :src="profile.avatar ?? profile.url" :alt="profile.name">
</p>
<h3 v-if="title">
{{ title }}
</h3>
<ClientOnly>
<div class="vp-global-encrypt">
<div class="global-encrypt-container">
<div v-if="profile || title" class="profile">
<p v-if="profile" class="avatar" :class="{ circle: profile.circle }">
<img :src="profile.avatar ?? profile.url" :alt="profile.name">
</p>
<h3 v-if="title">
{{ title }}
</h3>
</div>
<VPEncryptForm global :info="theme.encryptGlobalText" />
</div>
<VPEncryptForm :compare="compareGlobal" :info="theme.encryptGlobalText" />
</div>
</div>
</ClientOnly>
<VPFooter />
</template>

View File

@ -1,19 +1,19 @@
<script setup lang="ts">
import VPEncryptForm from '@theme/VPEncryptForm.vue'
import { usePageEncrypt } from '../composables/encrypt.js'
import { useData } from '../composables/data.js'
const { theme } = useData()
const { comparePage } = usePageEncrypt()
</script>
<template>
<div class="vp-page-encrypt">
<div class="logo">
<span class="vpi-lock icon-lock-head" />
<ClientOnly>
<div class="vp-page-encrypt">
<div class="logo">
<span class="vpi-lock icon-lock-head" />
</div>
<VPEncryptForm :info="theme.encryptPageText" />
</div>
<VPEncryptForm :compare="comparePage" :info="theme.encryptPageText" />
</div>
</ClientOnly>
</template>
<style scoped>
@ -33,11 +33,15 @@ const { comparePage } = usePageEncrypt()
width: 400px;
padding: 20px;
margin: 40px auto 0;
background-color: var(--vp-c-bg-alt);
border: solid 1px var(--vp-c-divider);
border-radius: 8px;
box-shadow: var(--vp-shadow-2);
box-shadow: var(--vp-shadow-1);
transition: var(--t-color);
transition-property: box-shadow, background-color;
transition-property: box-shadow, border-color;
}
.vp-page-encrypt:hover {
box-shadow: var(--vp-shadow-2);
}
}
</style>

View File

@ -1,12 +1,12 @@
<script setup lang="ts">
import type { NotesSidebarItem } from '@vuepress-plume/plugin-notes-data'
import { computed } from 'vue'
import VPLink from '@theme/VPLink.vue'
import VPIcon from '@theme/VPIcon.vue'
import { useSidebarControl } from '../composables/sidebar.js'
import type { ResolvedSidebarItem } from '../../shared/resolved/sidebar.js'
const props = defineProps<{
item: NotesSidebarItem
item: ResolvedSidebarItem
depth: number
}>()
@ -98,7 +98,7 @@ function onCaretClick() {
<div v-if="item.items && item.items.length" class="items">
<template v-if="depth < 5">
<VPSidebarItem
v-for="i in (item.items as NotesSidebarItem[])"
v-for="i in item.items"
:key="i.text"
:item="i"
:depth="depth + 1"

View File

@ -0,0 +1,20 @@
import {
blogPostData as blogPostDataRaw,
} from '@internal/blogData'
import { ref } from 'vue'
import type { Ref } from 'vue'
import type { PlumeThemeBlogPostData } from '../../shared/index.js'
export type BlogDataRef = Ref<PlumeThemeBlogPostData>
export const blogPostData: BlogDataRef = ref(blogPostDataRaw)
export function useBlogPostData(): BlogDataRef {
return blogPostData as BlogDataRef
}
if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot)) {
__VUE_HMR_RUNTIME__.updateBlogPostData = (data: PlumeThemeBlogPostData) => {
blogPostData.value = data
}
}

View File

@ -1,8 +1,8 @@
import { usePageLang } from 'vuepress/client'
import { useBlogPostData } from '@vuepress-plume/plugin-blog-data/client'
import { computed } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import type { PlumeThemeBlogPostItem } from '../../shared/index.js'
import { useBlogPostData } from './blog-data.js'
import { useData } from './data.js'
import { useRouteQuery } from './route-query.js'
@ -23,7 +23,7 @@ export function usePostListControl() {
const postList = computed(() => {
const stickyList = list.value.filter(item =>
typeof item.sticky === 'boolean' ? item.sticky : item.sticky >= 0,
item.sticky === true || typeof item.sticky === 'number',
)
const otherList = list.value.filter(
item => item.sticky === undefined || item.sticky === false,
@ -33,7 +33,7 @@ export function usePostListControl() {
...stickyList.sort((prev, next) => {
if (next.sticky === true && prev.sticky === true)
return 0
return next.sticky > prev.sticky ? 1 : -1
return next.sticky! > prev.sticky! ? 1 : -1
}),
...otherList,
] as PlumeThemeBlogPostItem[]

View File

@ -1,5 +1,4 @@
import type { Ref } from 'vue'
import type { ThemeLocaleDataRef } from '@vuepress/plugin-theme-data/client'
import {
usePageData,
usePageFrontmatter,
@ -19,6 +18,7 @@ import type {
PlumeThemePageFrontmatter,
PlumeThemePostFrontmatter,
} from '../../shared/index.js'
import type { ThemeLocaleDataRef } from './theme-data.js'
import { useThemeLocaleData } from './theme-data.js'
import { useDarkMode } from './dark-mode.js'

View File

@ -0,0 +1,55 @@
import { encrypt as rawEncrypt } from '@internal/encrypt'
import { ref } from 'vue'
import type { Ref } from 'vue'
export type EncryptConfig = readonly [
boolean, // global
string, // separator
string, // admin
string[], // keys
Record<string, string>, // rules
]
export interface EncryptDataRule {
key: string
match: string
rules: string[]
}
export interface EncryptData {
global: boolean
separator: string
admins: string[]
matches: string[]
ruleList: EncryptDataRule[]
}
export type EncryptRef = Ref<EncryptData>
export const encrypt: EncryptRef = ref(resolveEncryptData(rawEncrypt))
export function useEncryptData(): EncryptRef {
return encrypt as EncryptRef
}
function resolveEncryptData(
[global, separator, admin, matches, rules]: EncryptConfig,
): EncryptData {
return {
global,
separator,
matches,
admins: admin.split(separator),
ruleList: Object.keys(rules).map(key => ({
key,
match: matches[key] as string,
rules: rules[key].split(separator),
})),
}
}
if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot)) {
__VUE_HMR_RUNTIME__.updateEncrypt = (data: EncryptConfig) => {
encrypt.value = resolveEncryptData(data)
}
}

View File

@ -1,28 +1,21 @@
import { compareSync, genSaltSync } from 'bcrypt-ts/browser'
import { type Ref, computed } from 'vue'
import { compare, genSaltSync } from 'bcrypt-ts/browser'
import type { InjectionKey, Ref } from 'vue'
import { computed, inject, provide } from 'vue'
import { hasOwn, useSessionStorage } from '@vueuse/core'
import { useRoute } from 'vuepress/client'
import { useData } from './data.js'
import { type EncryptDataRule, useEncryptData } from './encrypt-data.js'
declare const __PLUME_ENCRYPT_GLOBAL__: boolean
declare const __PLUME_ENCRYPT_SEPARATOR__: string
declare const __PLUME_ENCRYPT_ADMIN__: string
declare const __PLUME_ENCRYPT_KEYS__: string[]
declare const __PLUME_ENCRYPT_RULES__: Record<string, string>
export interface Encrypt {
hasPageEncrypt: Ref<boolean>
isGlobalDecrypted: Ref<boolean>
isPageDecrypted: Ref<boolean>
hashList: Ref<EncryptDataRule[]>
}
const global = __PLUME_ENCRYPT_GLOBAL__
const separator = __PLUME_ENCRYPT_SEPARATOR__
const admin = __PLUME_ENCRYPT_ADMIN__
const matches = __PLUME_ENCRYPT_KEYS__
const rules = __PLUME_ENCRYPT_RULES__
const admins = admin.split(separator)
const ruleList = Object.keys(rules).map(key => ({
key,
match: matches[key] as string,
rules: rules[key].split(separator),
}))
export const EncryptSymbol: InjectionKey<Encrypt> = Symbol(
__VUEPRESS_DEV__ ? 'Encrypt' : '',
)
const storage = useSessionStorage('2a0a3d6afb2fdf1f', () => ({
s: [genSaltSync(10), genSaltSync(10)] as const,
@ -43,67 +36,82 @@ function splitHash(hash: string) {
return hash.slice(left.length, hash.length - right.length)
}
const cache = new Map<string, boolean>()
function compare(content: string, hash: string) {
const compareCache = new Map<string, boolean>()
async function compareDecrypt(content: string, hash: string, separator = ':'): Promise<boolean> {
const key = [content, hash].join(separator)
if (cache.has(key))
return cache.get(key)
if (compareCache.has(key))
return compareCache.get(key)!
const result = compareSync(content, hash)
cache.set(key, result)
return result
try {
const result = await compare(content, hash)
compareCache.set(key, result)
return result
}
catch {
compareCache.set(key, false)
return false
}
}
export function useGlobalEncrypt(): {
isGlobalDecrypted: Ref<boolean>
compareGlobal: (password: string) => boolean
} {
const matchCache = new Map<string, RegExp>()
function createMatchRegex(match: string) {
if (matchCache.has(match))
return matchCache.get(match)!
const regex = new RegExp(match)
matchCache.set(match, regex)
return regex
}
function toMatch(match: string, pagePath: string, filePathRelative: string | null) {
const relativePath = filePathRelative || ''
if (match[0] === '^') {
const regex = createMatchRegex(match)
return regex.test(pagePath) || (relativePath && regex.test(relativePath))
}
if (match.endsWith('.md'))
return relativePath && relativePath.endsWith(match)
return pagePath.startsWith(match) || relativePath.startsWith(match)
}
export function setupEncrypt() {
const { page } = useData()
const route = useRoute()
const encrypt = useEncryptData()
const hasPageEncrypt = computed(() => {
const pagePath = route.path
const filePathRelative = page.value.filePathRelative
return encrypt.value.ruleList.length
? encrypt.value.matches.some(match => toMatch(match, pagePath, filePathRelative))
: false
})
const isGlobalDecrypted = computed(() => {
if (!global)
if (!encrypt.value.global)
return true
const hash = splitHash(storage.value.g)
return !!hash && admins.includes(hash)
return !!hash && encrypt.value.admins.includes(hash)
})
function compareGlobal(password: string) {
if (!password)
return false
for (const admin of admins) {
if (compare(password, admin)) {
storage.value.g = mergeHash(admin)
return true
}
}
return false
}
return {
isGlobalDecrypted,
compareGlobal,
}
}
export function usePageEncrypt() {
const { page } = useData()
const route = useRoute()
const hasPageEncrypt = computed(() => ruleList.length ? matches.some(toMatch) : false)
const hashList = computed(() => ruleList.length
? ruleList
.filter(item => toMatch(item.match))
: [])
const hashList = computed(() => {
const pagePath = route.path
const filePathRelative = page.value.filePathRelative
return encrypt.value.ruleList.length
? encrypt.value.ruleList
.filter(item => toMatch(item.match, pagePath, filePathRelative))
: []
})
const isPageDecrypted = computed(() => {
if (!hasPageEncrypt.value)
return true
const hash = splitHash(storage.value.p.__GLOBAL__ || '')
if (hash && admins.includes(hash))
if (hash && encrypt.value.admins.includes(hash))
return true
for (const { key, rules } of hashList.value) {
@ -117,60 +125,75 @@ export function usePageEncrypt() {
return false
})
function toMatch(match: string) {
const relativePath = page.value.filePathRelative || ''
if (match[0] === '^') {
const regex = new RegExp(match)
return regex.test(route.path) || (relativePath && regex.test(relativePath))
}
if (match.endsWith('.md'))
return relativePath && relativePath.endsWith(match)
provide(EncryptSymbol, {
hasPageEncrypt,
isGlobalDecrypted,
isPageDecrypted,
hashList,
})
}
return route.path.startsWith(match) || relativePath.startsWith(match)
}
export function useEncrypt(): Encrypt {
const result = inject(EncryptSymbol)
function comparePage(password: string) {
if (!result)
throw new Error('useEncrypt() is called without setup')
return result
}
export function useEncryptCompare() {
const encrypt = useEncryptData()
const { page } = useData()
const route = useRoute()
const { hashList } = useEncrypt()
async function compareGlobal(password: string) {
if (!password)
return false
let decrypted = false
// check global
for (const admin of admins) {
if (compare(password, admin)) {
decrypted = true
storage.value.p = {
...storage.value.p,
__GLOBAL__: mergeHash(admin),
}
break
for (const admin of encrypt.value.admins) {
if (await compareDecrypt(password, admin, encrypt.value.separator)) {
storage.value.g = mergeHash(admin)
return true
}
}
// check page
if (!decrypted) {
for (const { match, key, rules } of hashList.value) {
if (toMatch(match)) {
for (const rule of rules) {
if (compare(password, rule)) {
decrypted = true
storage.value.p = {
...storage.value.p,
[key]: mergeHash(rule),
}
break
return false
}
async function comparePage(password: string) {
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, encrypt.value.separator)) {
decrypted = true
storage.value.p = {
...storage.value.p,
[key]: mergeHash(rule),
}
}
if (decrypted)
break
}
}
if (decrypted)
break
}
}
if (!decrypted) {
decrypted = await compareGlobal(password)
}
return decrypted
}
return {
isPageDecrypted,
comparePage,
}
return { compareGlobal, comparePage }
}

View File

@ -13,12 +13,16 @@ export * from './edit-link.js'
export * from './latest-updated.js'
export * from './contributors.js'
export * from './blog-data.js'
export * from './blog-post-list.js'
export * from './blog-extract.js'
export * from './blog-tags.js'
export * from './blog-archives.js'
export * from './tag-colors.js'
export * from './encrypt-data.js'
export * from './encrypt.js'
export * from './link.js'
export * from './locale.js'
export * from './route-query.js'

View File

@ -1,8 +1,9 @@
import { resolveRoute, useRouteLocale, withBase } from 'vuepress/client'
import { computed } from 'vue'
import { normalizeLink } from '../utils/index.js'
import { useThemeData } from './theme-data.js'
import { useData } from './data.js'
import { getSidebarFirstLink, getSidebarList, normalizePath, useNotesData } from './sidebar.js'
import { getSidebarFirstLink, useSidebarData } from './sidebar.js'
export function useLangs({
removeCurrent = true,
@ -10,7 +11,7 @@ export function useLangs({
const theme = useThemeData()
const { page } = useData()
const routeLocale = useRouteLocale()
const notesData = useNotesData()
const sidebar = useSidebarData()
const currentLang = computed(() => {
const link = routeLocale.value
@ -22,17 +23,15 @@ export function useLangs({
const getPageLink = (locale: string) => {
const pagePath = page.value.path.slice(routeLocale.value.length)
const targetPath = normalizePath(`${locale}${pagePath}`)
const targetPath = normalizeLink(locale, pagePath)
const { notFound, path } = resolveRoute(targetPath)
if (!notFound)
return path
const locales = theme.value.locales || {}
const blog = locales[`/${locale}/`]?.blog
const fallback = locales['/']?.blog ?? theme.value.blog
const blog = theme.value.blog
if (page.value.isBlogPost)
return withBase(blog?.link || normalizePath(`${locale}${fallback?.link || 'blog/'}`))
return withBase(blog?.link || normalizeLink(locale, 'blog/'))
const sidebarList = getSidebarList(targetPath, notesData.value)
const sidebarList = sidebar.value
if (sidebarList.length > 0) {
const link = getSidebarFirstLink(sidebarList)

View File

@ -1,6 +1,43 @@
import type { Ref } from 'vue'
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vuepress/client'
import type { NavItem } from '../../shared/index.js'
import type {
ResolvedNavItem,
ResolvedNavItemWithLink,
} from '../../shared/resolved/navbar.js'
import { normalizeLink, resolveNavLink } from '../utils/index.js'
import { useData } from './data.js'
export function useNavbarData(): Ref<ResolvedNavItem[]> {
const { theme } = useData()
return computed(() => resolveNavbar(theme.value.navbar || []))
}
function resolveNavbar(navbar: NavItem[], _prefix = ''): ResolvedNavItem[] {
const resolved: ResolvedNavItem[] = []
navbar.forEach((item) => {
if (typeof item === 'string') {
resolved.push(resolveNavLink(normalizeLink(_prefix, item)))
}
else {
const { items, prefix, ...args } = item
const res = { ...args } as ResolvedNavItem
if ('link' in res) {
res.link = normalizeLink(_prefix, res.link)
}
if (items?.length) {
res.items = resolveNavbar(
items,
normalizeLink(_prefix, prefix),
) as ResolvedNavItemWithLink[]
}
resolved.push(res)
}
})
return resolved
}
export interface UseNavReturn {
isScreenOpen: Ref<boolean>

View File

@ -1,11 +1,10 @@
import { resolveRouteFullPath, usePageLang, useRoute } from 'vuepress/client'
import { isPlainObject, isString } from 'vuepress/shared'
import { useBlogPostData } from '@vuepress-plume/plugin-blog-data/client'
import type { NotesSidebarItem } from '@vuepress-plume/plugin-notes-data'
import { computed } from 'vue'
import type { Ref } from 'vue'
import type { NavItemWithLink, PlumeThemeBlogPostItem } from '../../shared/index.js'
import type { NavItemWithLink, PlumeThemeBlogPostItem, SidebarItem } from '../../shared/index.js'
import { resolveNavLink } from '../utils/index.js'
import { useBlogPostData } from './blog-data.js'
import { useSidebar } from './sidebar.js'
import { useData } from './data.js'
@ -72,13 +71,13 @@ function resolveFromFrontmatterConfig(conf: unknown): null | false | NavItemWith
return false
}
function flatSidebar(sidebar: NotesSidebarItem[], res: NavItemWithLink[] = []): NavItemWithLink[] {
function flatSidebar(sidebar: SidebarItem[], res: NavItemWithLink[] = []): NavItemWithLink[] {
for (const item of sidebar) {
if (item.link)
res.push({ link: item.link, text: item.text || item.dir || '' })
if (Array.isArray(item.items) && item.items.length)
flatSidebar(item.items as NotesSidebarItem[], res)
flatSidebar(item.items as SidebarItem[], res)
}
return res

View File

@ -1,91 +1,261 @@
import { resolveRouteFullPath, useRoute, withBase } from 'vuepress/client'
import type {
NotesData,
NotesSidebarItem,
} from '@vuepress-plume/plugin-notes-data'
import { useNotesData } from '@vuepress-plume/plugin-notes-data/client'
import { resolveRouteFullPath, useRoute, useRouteLocale } from 'vuepress/client'
import {
ensureLeadingSlash,
isArray,
isPlainObject,
isString,
} from '@vuepress/helper/client'
import { useMediaQuery } from '@vueuse/core'
import type { ComputedRef, Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch, watchEffect } from 'vue'
import { isActive } from '../utils/index.js'
import type { ComputedRef, InjectionKey, Ref } from 'vue'
import {
computed,
inject,
onMounted,
onUnmounted,
provide,
ref,
watch,
watchEffect,
watchPostEffect,
} from 'vue'
import { sidebar as sidebarRaw } from '@internal/sidebar'
import { isActive, normalizeLink, normalizePrefix, resolveNavLink } from '../utils/index.js'
import type { Sidebar, SidebarItem } from '../../shared/index.js'
import type { ResolvedSidebarItem } from '../../shared/resolved/sidebar.js'
import { useData } from './data.js'
export { useNotesData }
export type SidebarData = Record<string, Sidebar>
export function normalizePath(path: string) {
return path.replace(/\/\\+/g, '/').replace(/\/+/g, '/')
}
export type SidebarDataRef = Ref<SidebarData>
export type AutoDirSidebarRef = Ref<SidebarItem[]>
export function getSidebarList(path: string, notesData: NotesData) {
const link = Object.keys(notesData).find(link =>
path.startsWith(normalizePath(link)),
)
const sidebar = link ? notesData[link] : []
const { __auto__, ...items } = sidebarRaw
const groups: NotesSidebarItem[] = []
const sidebarData: SidebarDataRef = ref(items)
const autoDirSidebar: AutoDirSidebarRef = ref(__auto__)
let lastGroupIndex: number = 0
for (const index in sidebar) {
const item = sidebar[index]
if (item.items && item.items.length) {
lastGroupIndex = groups.push(item)
continue
}
if (!groups[lastGroupIndex])
groups.push({ items: [] })
groups[lastGroupIndex]!.items!.push(item)
if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot)) {
__VUE_HMR_RUNTIME__.updateSidebar = (data: SidebarData) => {
const { __auto__, ...items } = data
sidebarData.value = items
autoDirSidebar.value = __auto__ as SidebarItem[]
}
return groups
}
export function getSidebarFirstLink(sidebar: NotesSidebarItem[]) {
for (const item of sidebar) {
if (item.link)
return item.link
if (item.items)
return getSidebarFirstLink(item.items as NotesSidebarItem[])
}
return ''
}
const sidebarSymbol: InjectionKey<Ref<ResolvedSidebarItem[]>> = Symbol(
__VUEPRESS_DEV__ ? 'sidebar' : '',
)
export function useSidebar() {
const route = useRoute()
const notesData = useNotesData()
const { frontmatter, theme } = useData()
export function setupSidebar() {
const { page, frontmatter } = useData()
const is960 = useMediaQuery('(min-width: 960px)')
const isOpen = ref(false)
const sidebarKey = computed(() => {
const link = Object.keys(notesData.value).find(link =>
route.path.startsWith(normalizePath(withBase(link))),
)
return link
})
const sidebar = computed(() => {
const link = typeof frontmatter.value.sidebar === 'string'
? frontmatter.value.sidebar
: route.path
return getSidebarList(link, notesData.value)
})
const routeLocale = useRouteLocale()
const hasSidebar = computed(() => {
return (
frontmatter.value.pageLayout !== 'home'
&& sidebar.value.length > 0
&& frontmatter.value.pageLayout !== 'friends'
&& frontmatter.value.sidebar !== false
&& frontmatter.value.layout !== 'NotFound'
)
})
const sidebarData = computed(() => {
return hasSidebar.value
? getSidebar(typeof frontmatter.value.sidebar === 'string'
? frontmatter.value.sidebar
: page.value.path, routeLocale.value)
: []
})
provide(sidebarSymbol, sidebarData)
}
export function useSidebarData(): Ref<ResolvedSidebarItem[]> {
const sidebarData = inject(sidebarSymbol)
if (!sidebarData) {
throw new Error('useSidebarData() is called without provider.')
}
return sidebarData
}
/**
* Get the `Sidebar` from sidebar option. This method will ensure to get correct
* sidebar config from `MultiSideBarConfig` with various path combinations such
* as matching `guide/` and `/guide/`. If no matching config was found, it will
* return empty array.
*/
export function getSidebar(routePath: string, routeLocal: string): ResolvedSidebarItem[] {
const _sidebar = sidebarData.value[routeLocal]
if (_sidebar === 'auto') {
return resolveSidebarItems(autoDirSidebar.value[routeLocal])
}
else if (isArray(_sidebar)) {
return resolveSidebarItems(_sidebar, routeLocal)
}
else if (isPlainObject(_sidebar)) {
const dir
= Object.keys(_sidebar)
.sort((a, b) => b.split('/').length - a.split('/').length)
.find((dir) => {
// make sure the multi sidebar key starts with slash too
return routePath.startsWith(ensureLeadingSlash(dir))
}) || ''
const sidebar = dir ? _sidebar[dir] : undefined
if (sidebar === 'auto') {
return resolveSidebarItems(
dir ? autoDirSidebar.value[dir] : [],
routeLocal,
)
}
else if (isArray(sidebar)) {
return resolveSidebarItems(sidebar, dir)
}
else if (isPlainObject(sidebar)) {
const prefix = normalizePrefix(dir, sidebar.prefix)
return resolveSidebarItems(
sidebar.items === 'auto'
? autoDirSidebar.value[prefix]
: sidebar.items,
prefix,
)
}
}
return []
}
function resolveSidebarItems(sidebarItems: (string | SidebarItem)[], _prefix = ''): ResolvedSidebarItem[] {
const resolved: ResolvedSidebarItem[] = []
sidebarItems.forEach((item) => {
if (isString(item)) {
resolved.push(resolveNavLink(normalizeLink(_prefix, item)))
}
else {
const { link, items, prefix, dir, ...args } = item
const navLink = { ...args } as ResolvedSidebarItem
if (link) {
navLink.link = normalizeLink(_prefix, link)
const nav = resolveNavLink(navLink.link)
navLink.icon = nav.icon
}
const nextPrefix = normalizePrefix(_prefix, prefix || dir)
if (items === 'auto') {
navLink.items = autoDirSidebar.value[nextPrefix]
}
else {
navLink.items = items?.length
? resolveSidebarItems(items, nextPrefix)
: undefined
}
resolved.push(navLink)
}
})
return resolved
}
/**
* Get or generate sidebar group from the given sidebar items.
*/
export function getSidebarGroups(sidebar: ResolvedSidebarItem[]): ResolvedSidebarItem[] {
const groups: ResolvedSidebarItem[] = []
let lastGroupIndex = 0
for (const index in sidebar) {
const item = sidebar[index]
if (item.items) {
lastGroupIndex = groups.push(item)
continue
}
if (!groups[lastGroupIndex]) {
groups.push({ items: [] })
}
groups[lastGroupIndex]!.items!.push(item)
}
return groups
}
/**
* Check if the given sidebar item contains any active link.
*/
export function hasActiveLink(path: string, items: ResolvedSidebarItem | ResolvedSidebarItem[]): boolean {
if (Array.isArray(items)) {
return items.some(item => hasActiveLink(path, item))
}
return isActive(
path,
items.link ? resolveRouteFullPath(items.link) : undefined,
)
? true
: items.items
? hasActiveLink(path, items.items)
: false
}
export interface SidebarControl {
collapsed: Ref<boolean>
collapsible: ComputedRef<boolean>
isLink: ComputedRef<boolean>
isActiveLink: Ref<boolean>
hasActiveLink: ComputedRef<boolean>
hasChildren: ComputedRef<boolean>
toggle: () => void
}
export interface UseSidebarReturn {
isOpen: Ref<boolean>
sidebar: Ref<ResolvedSidebarItem[]>
sidebarKey: Ref<string>
sidebarGroups: Ref<ResolvedSidebarItem[]>
hasSidebar: ComputedRef<boolean>
hasAside: ComputedRef<boolean>
leftAside: ComputedRef<boolean>
isSidebarEnabled: ComputedRef<boolean>
open: () => void
close: () => void
toggle: () => void
}
const containsActiveLink = hasActiveLink
export function useSidebar(): UseSidebarReturn {
const { theme, frontmatter, page } = useData()
const routeLocal = useRouteLocale()
const is960 = useMediaQuery('(min-width: 960px)')
const isOpen = ref(false)
const sidebarKey = computed(() => {
const _sidebar = sidebarData.value[routeLocal.value]
if (!_sidebar || _sidebar === 'auto' || isArray(_sidebar))
return routeLocal.value
return Object.keys(_sidebar)
.sort((a, b) => b.split('/').length - a.split('/').length)
.find((dir) => {
return page.value.path.startsWith(ensureLeadingSlash(dir))
}) || ''
})
const sidebar = useSidebarData()
const hasSidebar = computed(() => {
return (
frontmatter.value.sidebar !== false
&& sidebar.value.length > 0
&& frontmatter.value.pageLayout !== 'home'
)
})
const hasAside = computed(() => {
if (frontmatter.value.pageLayout === 'home')
if (frontmatter.value.pageLayout === 'home' || frontmatter.value.home)
return false
if (frontmatter.value.aside != null)
return !!frontmatter.value.aside
@ -107,37 +277,38 @@ export function useSidebar() {
return hasSidebar.value ? getSidebarGroups(sidebar.value) : []
})
function open() {
const open = (): void => {
isOpen.value = true
}
function close() {
const close = (): void => {
isOpen.value = false
}
function toggle() {
const toggle = (): void => {
isOpen.value ? close() : open()
}
return {
isOpen,
sidebar,
sidebarKey,
sidebarGroups,
hasSidebar,
hasAside,
leftAside,
isSidebarEnabled,
sidebarGroups,
sidebarKey,
open,
close,
toggle,
}
}
export function useCloseSidebarOnEscape(
isOpen: Ref<boolean>,
close: () => void,
) {
/**
* a11y: cache the element that opened the Sidebar (the menu button) then
* focus that button again when Menu is closed with Escape key.
*/
export function useCloseSidebarOnEscape(isOpen: Ref<boolean>, close: () => void): void {
let triggerElement: HTMLButtonElement | undefined
watchEffect(() => {
@ -154,7 +325,7 @@ export function useCloseSidebarOnEscape(
window.removeEventListener('keyup', onEscape)
})
function onEscape(e: KeyboardEvent) {
function onEscape(e: KeyboardEvent): void {
if (e.key === 'Escape' && isOpen.value) {
close()
triggerElement?.focus()
@ -162,14 +333,14 @@ export function useCloseSidebarOnEscape(
}
}
export function useSidebarControl(item: ComputedRef<NotesSidebarItem>) {
export function useSidebarControl(item: ComputedRef<ResolvedSidebarItem>): SidebarControl {
const { page } = useData()
const route = useRoute()
const collapsed = ref(item.value.collapsed ?? false)
const collapsed = ref(false)
const collapsible = computed(() => {
return item.value.collapsed !== null && item.value.collapsed !== undefined
return item.value.collapsed != null
})
const isLink = computed(() => {
@ -177,22 +348,23 @@ export function useSidebarControl(item: ComputedRef<NotesSidebarItem>) {
})
const isActiveLink = ref(false)
const updateIsActiveLink = () => {
isActiveLink.value = isActive(page.value.path, item.value.link ? resolveRouteFullPath(item.value.link) : undefined)
const updateIsActiveLink = (): void => {
isActiveLink.value = isActive(
page.value.path,
item.value.link ? resolveRouteFullPath(item.value.link) : undefined,
)
}
watch([page, item, () => route.hash], updateIsActiveLink)
onMounted(updateIsActiveLink)
const hasActiveLink = computed(() => {
if (isActiveLink.value)
if (isActiveLink.value) {
return true
}
return item.value.items
? containsActiveLink(
page.value.path,
item.value.items as NotesSidebarItem[],
)
? containsActiveLink(page.value.filePathRelative || '', item.value.items)
: false
})
@ -204,13 +376,14 @@ export function useSidebarControl(item: ComputedRef<NotesSidebarItem>) {
collapsed.value = !!(collapsible.value && item.value.collapsed)
})
watchEffect(() => {
watchPostEffect(() => {
;(isActiveLink.value || hasActiveLink.value) && (collapsed.value = false)
})
function toggle() {
if (collapsible.value)
const toggle = (): void => {
if (collapsible.value) {
collapsed.value = !collapsed.value
}
}
return {
@ -224,43 +397,12 @@ export function useSidebarControl(item: ComputedRef<NotesSidebarItem>) {
}
}
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 ? resolveRouteFullPath(items.link) : undefined)
? true
: items.items
? containsActiveLink(path, items.items as NotesSidebarItem[])
: false
}
/**
* Get or generate sidebar group from the given sidebar items.
*/
export function getSidebarGroups(
sidebar: NotesSidebarItem[],
): NotesSidebarItem[] {
const groups: NotesSidebarItem[] = []
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)
export function getSidebarFirstLink(sidebar: ResolvedSidebarItem[]): string {
for (const item of sidebar) {
if (item.link)
return item.link
if (item.items)
return getSidebarFirstLink(item.items)
}
return groups
return ''
}

View File

@ -10,7 +10,7 @@ const tagColorsRef: TagColorsRef = ref(articleTagColors)
export const useTagColors = (): TagColorsRef => tagColorsRef
if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot)) {
__VUE_HMR_RUNTIME__.updateArticleTagColor = (data: TagColors) => {
__VUE_HMR_RUNTIME__.updateArticleTagColors = (data: TagColors) => {
tagColorsRef.value = data
}
}

View File

@ -1,16 +1,76 @@
import {
useThemeData as _useThemeData,
useThemeLocaleData as _useThemeLocaleData,
} from '@vuepress/plugin-theme-data/client'
import type {
ThemeDataRef,
ThemeLocaleDataRef,
} from '@vuepress/plugin-theme-data/client'
import { themeData as themeDataRaw } from '@internal/themePlumeData'
import { computed, inject, ref } from 'vue'
import type { App, ComputedRef, InjectionKey, Ref } from 'vue'
import { type ClientData, type RouteLocale, clientDataSymbol } from 'vuepress/client'
import type { PlumeThemeData } from '../../shared/index.js'
export function useThemeData(): ThemeDataRef<PlumeThemeData> {
return _useThemeData<PlumeThemeData>()
declare const __VUE_HMR_RUNTIME__: Record<string, any>
export type ThemeDataRef<T extends PlumeThemeData = PlumeThemeData> = Ref<T>
export type ThemeLocaleDataRef<T extends PlumeThemeData = PlumeThemeData> = ComputedRef<T>
export const themeLocaleDataSymbol: InjectionKey<ThemeLocaleDataRef> = Symbol(
__VUEPRESS_DEV__ ? 'themeLocaleData' : '',
)
export const themeData: ThemeDataRef = ref(themeDataRaw)
export function useThemeData<
T extends PlumeThemeData = PlumeThemeData,
>(): ThemeDataRef<T> {
return themeData as ThemeDataRef<T>
}
export function useThemeLocaleData(): ThemeLocaleDataRef<PlumeThemeData> {
return _useThemeLocaleData<PlumeThemeData>()
if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot)) {
__VUE_HMR_RUNTIME__.updateThemeData = (data: PlumeThemeData) => {
themeData.value = data
}
}
export function useThemeLocaleData<
T extends PlumeThemeData = PlumeThemeData,
>(): ThemeLocaleDataRef<T> {
const themeLocaleData = inject(themeLocaleDataSymbol)
if (!themeLocaleData) {
throw new Error('useThemeLocaleData() is called without provider.')
}
return themeLocaleData as ThemeLocaleDataRef<T>
}
/**
* Merge the locales fields to the root fields
* according to the route path
*/
function resolveThemeLocaleData(theme: PlumeThemeData, routeLocale: RouteLocale): PlumeThemeData {
const { locales, ...baseOptions } = theme
return {
...baseOptions,
...locales?.[routeLocale],
}
}
export function setupThemeData(app: App) {
// provide theme data & theme locale data
const themeData = useThemeData()
const clientData: ClientData
= app._context.provides[clientDataSymbol as unknown as symbol]
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
},
},
})
}

View File

@ -2,18 +2,28 @@ import './styles/index.css'
import { defineClientConfig } from 'vuepress/client'
import type { ClientConfig } from 'vuepress/client'
import { enhanceScrollBehavior, setupDarkMode, setupWatermark } from './composables/index.js'
import {
enhanceScrollBehavior,
setupDarkMode,
setupEncrypt,
setupSidebar,
setupThemeData,
setupWatermark,
} from './composables/index.js'
import { globalComponents } from './globalComponents.js'
import Layout from './layouts/Layout.vue'
import NotFound from './layouts/NotFound.vue'
export default defineClientConfig({
enhance({ app, router }) {
setupThemeData(app)
setupDarkMode(app)
enhanceScrollBehavior(router)
globalComponents(app)
},
setup() {
setupSidebar()
setupEncrypt()
setupWatermark()
},
layouts: { Layout, NotFound },

View File

@ -11,7 +11,7 @@ import VPFooter from '@theme/VPFooter.vue'
import VPBackToTop from '@theme/VPBackToTop.vue'
import VPEncryptGlobal from '@theme/VPEncryptGlobal.vue'
import { useCloseSidebarOnEscape, useSidebar } from '../composables/sidebar.js'
import { useGlobalEncrypt, usePageEncrypt } from '../composables/encrypt.js'
import { useEncrypt } from '../composables/encrypt.js'
import { useData } from '../composables/data.js'
const {
@ -21,8 +21,7 @@ const {
} = useSidebar()
const { frontmatter } = useData()
const { isGlobalDecrypted } = useGlobalEncrypt()
const { isPageDecrypted } = usePageEncrypt()
const { isGlobalDecrypted, isPageDecrypted } = useEncrypt()
const route = useRoute()
watch(() => route.path, closeSidebar)
@ -65,7 +64,11 @@ useCloseSidebarOnEscape(isSidebarOpen, closeSidebar)
</template>
</VPNav>
<VPLocalNav :open="isSidebarOpen" :show-outline="isPageDecrypted" @open-menu="openSidebar" />
<VPLocalNav
:open="isSidebarOpen"
:show-outline="isPageDecrypted"
@open-menu="openSidebar"
/>
<VPSidebar :open="isSidebarOpen">
<template #sidebar-nav-before>

View File

@ -13,3 +13,48 @@ declare module '@internal/articleTagColors' {
articleTagColors,
}
}
declare module '@internal/themePlumeData' {
import type { PlumeThemeData } from '../shared/index.js'
const themeData: PlumeThemeData
export {
themeData,
}
}
declare module '@internal/blogData' {
import type { PlumeThemeBlogPostData } from '../shared/index.js'
const blogPostData: PlumeThemeBlogPostData
export {
blogPostData,
}
}
declare module '@internal/sidebar' {
import type { Sidebar, SidebarItem } from '../shared/index.js'
const sidebar: {
__auto__: SidebarItem[]
[key: string]: Sidebar
}
export {
sidebar,
}
}
declare module '@internal/encrypt' {
const encrypt: readonly [
boolean, // global
string, // separator
string, // admin
string[], // keys
Record<string, string>, // rules
]
export {
encrypt,
}
}

View File

@ -89,6 +89,10 @@
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E %3Cpath fill='currentColor' d='M18 8h-1V7c0-2.757-2.243-5-5-5S7 4.243 7 7v1H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2M9 7c0-1.654 1.346-3 3-3s3 1.346 3 3v1H9zm4 8.723V18h-2v-2.277c-.595-.346-1-.984-1-1.723a2 2 0 1 1 4 0c0 .738-.405 1.376-1 1.723' /%3E %3C/svg%3E");
}
.vpi-loading {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Ccircle cx='18' cy='12' r='0' fill='%23000'%3E%3Canimate attributeName='r' begin='.67' calcMode='spline' dur='1.5s' keySplines='0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8' repeatCount='indefinite' values='0;2;0;0'/%3E%3C/circle%3E%3Ccircle cx='12' cy='12' r='0' fill='%23000'%3E%3Canimate attributeName='r' begin='.33' calcMode='spline' dur='1.5s' keySplines='0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8' repeatCount='indefinite' values='0;2;0;0'/%3E%3C/circle%3E%3Ccircle cx='6' cy='12' r='0' fill='%23000'%3E%3Canimate attributeName='r' begin='0' calcMode='spline' dur='1.5s' keySplines='0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8' repeatCount='indefinite' values='0;2;0;0'/%3E%3C/circle%3E%3C/svg%3E");
}
.vpi-print {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='currentColor' d='M16 8V5H8v3H6V3h12v5zM4 10h16zm14 2.5q.425 0 .713-.288T19 11.5q0-.425-.288-.712T18 10.5q-.425 0-.712.288T17 11.5q0 .425.288.713T18 12.5M16 19v-4H8v4zm2 2H6v-4H2v-6q0-1.275.875-2.137T5 8h14q1.275 0 2.138.863T22 11v6h-4zm2-6v-4q0-.425-.288-.712T19 10H5q-.425 0-.712.288T4 11v4h2v-2h12v2z' /%3E%3C/svg%3E");
}

View File

@ -1,5 +1,11 @@
import { resolveRoute } from 'vuepress/client'
import type { NavItemWithLink } from '../../shared/index.js'
import {
ensureEndingSlash,
ensureLeadingSlash,
isLinkAbsolute,
isLinkWithProtocol,
} from '@vuepress/helper/client'
import type { ResolvedNavItemWithLink } from '../../shared/resolved/navbar.js'
/**
* Resolve NavLink props from string
@ -8,9 +14,10 @@ import type { NavItemWithLink } from '../../shared/index.js'
* - Input: '/README.md'
* - Output: { text: 'Home', link: '/' }
*/
export function resolveNavLink(link: string): NavItemWithLink {
export function resolveNavLink(link: string): ResolvedNavItemWithLink {
const { notFound, meta, path } = resolveRoute<{
title?: string
icon?: string
}>(link)
return notFound
@ -18,5 +25,16 @@ export function resolveNavLink(link: string): NavItemWithLink {
: {
text: meta.title || path,
link: path,
icon: meta.icon,
}
}
export function normalizeLink(base = '', link = ''): string {
return isLinkAbsolute(link) || isLinkWithProtocol(link)
? link
: ensureLeadingSlash(`${base}/${link}`.replace(/\/+/g, '/'))
}
export function normalizePrefix(base: string, link = ''): string {
return ensureEndingSlash(normalizeLink(base, link))
}

View File

@ -0,0 +1,122 @@
import { fs } from 'vuepress/utils'
import chokidar from 'chokidar'
import { createFilter } from 'create-filter'
import grayMatter from 'gray-matter'
import jsonToYaml from 'json2yaml'
import { isArray, isEmptyObject, promiseParallel, toArray } from '@pengzhanbo/utils'
import type { App } from 'vuepress'
import type {
AutoFrontmatter,
AutoFrontmatterArray,
AutoFrontmatterMarkdownFile,
AutoFrontmatterObject,
} from '../../shared/auto-frontmatter.js'
import type { PlumeThemeLocaleOptions } from '../../shared/index.js'
import { readMarkdown, readMarkdownList } from './readFile.js'
import { resolveOptions } from './resolveOptions.js'
export interface Generate {
globFilter: (id?: string) => boolean
global: AutoFrontmatterObject
rules: {
include: string | string[]
filter: (id?: string) => boolean
frontmatter: AutoFrontmatterObject
}[]
}
let generate: Generate | null = null
export function initAutoFrontmatter(
localeOptions: PlumeThemeLocaleOptions,
autoFrontmatter: AutoFrontmatter = {},
) {
const { include, exclude, frontmatter = {} } = resolveOptions(localeOptions, autoFrontmatter)
const globFilter = createFilter(include, exclude, { resolve: false })
const userConfig: AutoFrontmatterArray = isArray(frontmatter)
? frontmatter
: [{ include: '*', frontmatter }]
const globalConfig: AutoFrontmatterObject
= userConfig.find(({ include }) => include === '*')?.frontmatter || {}
const rules = userConfig
.filter(({ include }) => include !== '*')
.map(({ include, frontmatter }) => {
return {
include,
filter: createFilter(toArray(include), undefined, { resolve: false }),
frontmatter,
}
})
generate = {
globFilter,
global: globalConfig,
rules,
}
}
export async function generateAFrontmatter(app: App) {
if (!generate)
return
const markdownList = await readMarkdownList(app.dir.source(), generate.globFilter)
await promiseParallel(
markdownList.map(file => () => generator(file)),
64,
)
}
export async function watchAutoFrontmatter(app: App, watchers: any[]) {
if (!generate)
return
const watcher = chokidar.watch('**/*.md', {
cwd: app.dir.source(),
ignoreInitial: true,
ignored: /(node_modules|\.vuepress)\//,
})
watcher.on('add', async (relativePath) => {
if (!generate!.globFilter(relativePath))
return
const file = await readMarkdown(app.dir.source(), relativePath)
await generator(file)
})
watchers.push(watcher)
}
async function generator(file: AutoFrontmatterMarkdownFile): Promise<void> {
if (!generate)
return
const { filepath, relativePath } = file
const current = generate.rules.find(({ filter }) => filter(relativePath))
const formatter = current?.frontmatter || generate.global
const { data, content } = grayMatter(file.content)
for (const key in formatter) {
const value = await formatter[key](data[key], file, data)
data[key] = value ?? data[key]
}
try {
const yaml = isEmptyObject(data)
? ''
: jsonToYaml
.stringify(data)
.replace(/\n\s{2}/g, '\n')
.replace(/"/g, '')
.replace(/\s+\n/g, '\n')
const newContent = yaml ? `${yaml}---\n${content}` : content
fs.writeFileSync(filepath, newContent, 'utf-8')
}
catch (e) {
console.error(e)
}
}

View File

@ -0,0 +1,3 @@
export * from './generator.js'
export * from './readFile.js'
export * from './resolveOptions.js'

View File

@ -0,0 +1,38 @@
import { fs, path } from 'vuepress/utils'
import fg from 'fast-glob'
import type { AutoFrontmatterMarkdownFile } from '../../shared/auto-frontmatter.js'
export async function readMarkdownList(
sourceDir: string,
filter: (id: string) => boolean,
): Promise<AutoFrontmatterMarkdownFile[]> {
const files: string[] = await fg(['**/*.md'], {
cwd: sourceDir,
ignore: ['node_modules', '.vuepress'],
})
return await Promise.all(
files
.filter(filter)
.map(file => readMarkdown(sourceDir, file)),
)
}
export async function readMarkdown(
sourceDir: string,
relativePath: string,
): Promise<AutoFrontmatterMarkdownFile> {
const filepath = path.join(sourceDir, relativePath)
const stats = await fs.promises.stat(filepath)
return {
filepath,
relativePath,
content: await fs.promises.readFile(filepath, 'utf-8'),
createTime: getFileCreateTime(stats),
stats,
}
}
export function getFileCreateTime(stats: fs.Stats): Date {
return stats.birthtime.getFullYear() !== 1970 ? stats.birthtime : stats.atime
}

View File

@ -1,17 +1,14 @@
import { path } from 'vuepress/utils'
import { removeLeadingSlash, resolveLocalePath } from 'vuepress/shared'
import { ensureLeadingSlash } from '@vuepress/helper'
import type {
AutoFrontmatterOptions,
FrontmatterArray,
FrontmatterObject,
} from '@vuepress-plume/plugin-auto-frontmatter'
import { format } from 'date-fns'
import { uniq } from '@pengzhanbo/utils'
import type { NotesSidebar } from '@vuepress-plume/plugin-notes-data'
import type {
AutoFrontmatter,
AutoFrontmatterArray,
AutoFrontmatterObject,
PlumeThemeLocaleOptions,
PlumeThemePluginOptions,
SidebarItem,
} from '../../shared/index.js'
import {
getCurrentDirname,
@ -20,16 +17,15 @@ import {
normalizePath,
pathJoin,
withBase,
} from '../utils.js'
} from '../utils/index.js'
import { resolveNotesOptions } from '../config/index.js'
export function resolveAutoFrontmatterOptions(
pluginOptions: PlumeThemePluginOptions,
export function resolveOptions(
localeOptions: PlumeThemeLocaleOptions,
): AutoFrontmatterOptions {
frontmatter: AutoFrontmatter,
): AutoFrontmatter {
const pkg = getPackage()
const { locales = {}, article: articlePrefix = '/article/' } = localeOptions
const { frontmatter } = pluginOptions
const resolveLocale = (relativeFilepath: string) => {
const file = ensureLeadingSlash(relativeFilepath)
@ -50,7 +46,7 @@ export function resolveAutoFrontmatterOptions(
})
.filter(Boolean)
const baseFrontmatter: FrontmatterObject = {
const baseFrontmatter: AutoFrontmatterObject = {
author(author: string, { relativePath }, data: any) {
if (author)
return author
@ -197,26 +193,33 @@ export function resolveAutoFrontmatterOptions(
},
},
},
].filter(Boolean) as FrontmatterArray,
].filter(Boolean) as AutoFrontmatterArray,
}
}
function resolveLinkBySidebar(
sidebar: NotesSidebar,
prefix: string,
sidebar: 'auto' | (string | SidebarItem)[],
_prefix: string,
) {
const res: Record<string, string> = {}
if (sidebar === 'auto') {
return res
}
for (const item of sidebar) {
if (typeof item !== 'string') {
const { dir = '', link = '/', items, text = '' } = item
SidebarLink(items, link, text, pathJoin(prefix, dir), res)
const { prefix, dir = '', link = '/', items, text = '' } = item
getSidebarLink(items, link, text, pathJoin(_prefix, prefix || dir), res)
}
}
return res
}
function SidebarLink(items: NotesSidebar | undefined, link: string, text: string, dir = '', res: Record<string, string> = {}) {
function getSidebarLink(items: 'auto' | (string | SidebarItem)[] | undefined, link: string, text: string, dir = '', res: Record<string, string> = {}) {
if (items === 'auto')
return
if (!items) {
res[pathJoin(dir, `${text}.md`)] = link
return
@ -237,8 +240,8 @@ function SidebarLink(items: NotesSidebar | undefined, link: string, text: string
res[dir] = link
}
else {
const { dir: subDir = '', link: subLink = '/', items: subItems, text: subText = '' } = item
SidebarLink(subItems, pathJoin(link, subLink), subText, pathJoin(dir, subDir), res)
const { prefix, dir: subDir = '', link: subLink = '/', items: subItems, text: subText = '' } = item
getSidebarLink(subItems, pathJoin(link, subLink), subText, pathJoin(prefix || dir, subDir), res)
}
}
}

View File

@ -9,5 +9,4 @@ export * from './templateBuildRenderer.js'
export * from './resolveSearchOptions.js'
export * from './resolvePageHead.js'
export * from './resolveEncrypt.js'
export * from './resolveNotesOptions.js'

View File

@ -1,5 +1,5 @@
import { fs, path } from 'vuepress/utils'
import { resolve } from '../utils.js'
import { resolve } from '../utils/index.js'
export function resolveAlias() {
return {

View File

@ -2,7 +2,7 @@ import { entries, fromEntries, getLocaleConfig } from '@vuepress/helper'
import type { App } from 'vuepress'
import { LOCALE_OPTIONS } from '../locales/index.js'
import type { PlumeThemeLocaleData, PlumeThemeLocaleOptions } from '../../shared/index.js'
import { THEME_NAME } from '../utils.js'
import { THEME_NAME } from '../utils/index.js'
const FALLBACK_OPTIONS: PlumeThemeLocaleData = {
appearance: true,
@ -18,6 +18,10 @@ const FALLBACK_OPTIONS: PlumeThemeLocaleData = {
editLink: true,
contributors: true,
footer: {
message:
'Power by <a target="_blank" href="https://v2.vuepress.vuejs.org/">VuePress</a> & <a target="_blank" href="https://theme-plume.vuejs.press">vuepress-theme-plume</a>',
},
}
export function resolveLocaleOptions(app: App, { locales, ...options }: PlumeThemeLocaleOptions): PlumeThemeLocaleOptions {

View File

@ -1,8 +1,7 @@
import type { NotesDataOptions } from '@vuepress-plume/plugin-notes-data'
import { entries } from '@vuepress/helper'
import { uniq } from '@pengzhanbo/utils'
import type { PlumeThemeLocaleOptions } from '../..//shared/index.js'
import { withBase } from '../utils.js'
import type { NotesOptions, PlumeThemeLocaleOptions } from '../../shared/index.js'
import { withBase } from '../utils/index.js'
export function resolveNotesLinkList(localeOptions: PlumeThemeLocaleOptions) {
const locales = localeOptions.locales || {}
@ -22,9 +21,9 @@ export function resolveNotesLinkList(localeOptions: PlumeThemeLocaleOptions) {
return uniq(notesLinks)
}
export function resolveNotesOptions(localeOptions: PlumeThemeLocaleOptions): NotesDataOptions[] {
export function resolveNotesOptions(localeOptions: PlumeThemeLocaleOptions): NotesOptions[] {
const locales = localeOptions.locales || {}
const notesOptionsList: NotesDataOptions[] = []
const notesOptionsList: NotesOptions[] = []
for (const [locale, opt] of entries(locales)) {
const options = locale === '/' ? (opt.notes || localeOptions.notes) : opt.notes
if (options) {

View File

@ -1,20 +1,15 @@
import type { App } from 'vuepress'
import { entries, fromEntries, getRootLangPath, isPlainObject } from '@vuepress/helper'
import type { PlumeThemeEncrypt, PlumeThemePluginOptions } from '../../shared/index.js'
import type { PlumeThemePluginOptions } from '../../shared/index.js'
import { PRESET_LOCALES } from '../locales/index.js'
import { resolveEncrypt } from './resolveEncrypt.js'
export function resolveProvideData(
app: App,
plugins: PlumeThemePluginOptions,
encrypt?: PlumeThemeEncrypt,
): Record<string, any> {
const root = getRootLangPath(app)
return {
// 注入 加密配置
...resolveEncrypt(encrypt),
// 注入水印配置
__PLUME_WM_FP__: isPlainObject(plugins.watermark)
? plugins.watermark.fullPage !== false

View File

@ -2,9 +2,9 @@ import { entries, getRootLangPath } from '@vuepress/helper'
import type { App } from 'vuepress'
import type { NavItem, PlumeThemeLocaleOptions } from '../../shared/index.js'
import { PRESET_LOCALES } from '../locales/index.js'
import { withBase } from '../utils.js'
import { withBase } from '../utils/index.js'
const EXCLUDE_LIST = ['locales', 'sidebar', 'navbar', 'notes', 'article', 'avatar']
const EXCLUDE_LIST = ['locales', 'sidebar', 'navbar', 'notes', 'sidebar', 'article', 'avatar']
// 过滤不需要出现在多语言配置中的字段
const EXCLUDE_LOCALE_LIST = [...EXCLUDE_LIST, 'blog', 'appearance']

View File

@ -1,7 +1,13 @@
import type { PlumeThemeOptions } from '../../shared/index.js'
import { logger } from '../utils.js'
import { logger } from '../utils/index.js'
export function resolveThemeOptions({ themePlugins, plugins, encrypt, hostname, ...localeOptions }: PlumeThemeOptions) {
export function resolveThemeOptions({
themePlugins,
plugins,
hostname,
configFile,
...localeOptions
}: PlumeThemeOptions) {
const pluginOptions = plugins ?? themePlugins ?? {}
if (themePlugins) {
@ -11,8 +17,8 @@ export function resolveThemeOptions({ themePlugins, plugins, encrypt, hostname,
}
return {
configFile,
pluginOptions,
encrypt,
hostname,
localeOptions,
}

View File

@ -1,5 +1,5 @@
import { type TemplateRendererContext, templateRenderer } from 'vuepress/utils'
import { getThemePackage } from '../utils.js'
import { getThemePackage } from '../utils/index.js'
export function templateBuildRenderer(template: string, context: TemplateRendererContext) {
const pkg = getThemePackage()

View File

@ -1,14 +1,15 @@
import type {
NotesDataOptions,
NotesItemOptions,
} from '@vuepress-plume/plugin-notes-data'
NoteItem,
NotesOptions,
} from '../shared/notes.js'
import type { NavItem } from '../shared/index.js'
import type { ThemeConfig } from '../shared/theme-data.js'
export function definePlumeNotesConfig(notes: NotesDataOptions): NotesDataOptions {
export function definePlumeNotesConfig(notes: NotesOptions): NotesOptions {
return notes
}
export function definePlumeNotesItemConfig(item: NotesItemOptions): NotesItemOptions {
export function definePlumeNotesItemConfig(item: NoteItem): NoteItem {
return item
}
@ -24,7 +25,14 @@ export function defineNavbar(navbar: NavItem[]): NavItem[] {
}
export type {
NotesDataOptions,
NotesItemOptions,
NotesItemOptions as NotesItem,
NotesOptions,
NoteItem,
NoteItem as NotesItem,
}
/**
* `plume.config.ts` 使
*/
export function defineThemeConfig(config: ThemeConfig): ThemeConfig {
return config
}

View File

@ -11,11 +11,11 @@
* 使 13s 1.2s
* vuepress shiki 0.5s
*/
import { createHash } from 'node:crypto'
import process from 'node:process'
import { fs, path } from 'vuepress/utils'
import type { App } from 'vuepress'
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
import { hash } from './utils/index.js'
export interface CacheData {
content: string
@ -88,10 +88,6 @@ export async function extendsMarkdown(md: Markdown, app: App): Promise<void> {
}
}
function hash(data: string): string {
return createHash('md5').update(data).digest('hex')
}
function normalizeFilename(filename: string): string {
return hash(filename).slice(0, 10)
}

View File

@ -0,0 +1,89 @@
import { promises as fsp } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
import { importFileDefault } from 'vuepress/utils'
import type { ThemeConfig } from '../../shared/theme-data.js'
import { hash } from '../utils/index.js'
export async function compiler(configPath?: string,
): Promise<{
config: ThemeConfig
dependencies: string[]
}> {
if (!configPath) {
return { config: {}, dependencies: [] }
}
const dirnameVarName = '__vite_injected_original_dirname'
const filenameVarName = '__vite_injected_original_filename'
const importMetaUrlVarName = '__vite_injected_original_import_meta_url'
const result = await build({
absWorkingDir: process.cwd(),
entryPoints: [configPath],
outfile: 'out.js',
write: false,
target: ['node18'],
platform: 'node',
bundle: true,
format: 'esm',
sourcemap: 'inline',
metafile: true,
define: {
'__dirname': dirnameVarName,
'__filename': filenameVarName,
'import.meta.url': importMetaUrlVarName,
},
plugins: [
{
name: 'externalize-deps',
setup(build) {
build.onResolve({ filter: /.*/ }, ({ path: id }) => {
// externalize bare imports
if (id[0] !== '.' && !path.isAbsolute(id)) {
return { external: true }
}
return null
})
},
},
{
name: 'inject-file-scope-variables',
setup(build) {
build.onLoad({ filter: /\.[cm]?[jt]s$/ }, async (args) => {
const contents = await fsp.readFile(args.path, 'utf8')
const injectValues
= `const ${dirnameVarName} = ${JSON.stringify(
path.dirname(args.path),
)};`
+ `const ${filenameVarName} = ${JSON.stringify(args.path)};`
+ `const ${importMetaUrlVarName} = ${JSON.stringify(
pathToFileURL(args.path).href,
)};`
return {
loader: args.path.endsWith('ts') ? 'ts' : 'js',
contents: injectValues + contents,
}
})
},
},
],
})
const { text } = result.outputFiles[0]
const tempFilePath = `${configPath}.${hash(text)}.mjs`
let config: ThemeConfig
try {
await fsp.writeFile(tempFilePath, text)
config = await importFileDefault(tempFilePath)
}
finally {
await fsp.rm(tempFilePath)
}
return {
config,
dependencies: Object.keys(result.metafile?.inputs ?? {}),
}
}

View File

@ -0,0 +1,49 @@
import fs, { constants, promises as fsp } from 'node:fs'
import { resolve } from 'node:path'
import process from 'node:process'
import type { App } from 'vuepress'
import { colors } from 'vuepress/utils'
import { logger } from '../utils/index.js'
const CONFIG_FILE_NAME = 'plume.config'
const extensions: string[] = ['ts', 'js', 'mts', 'cts', 'mjs', 'cjs']
export async function findConfigPath(app: App, configPath?: string): Promise<string | undefined> {
const cwd = process.cwd()
const source = app.dir.source('.vuepress')
const paths: string[] = []
if (configPath) {
const path = resolve(cwd, configPath)
if (existsSync(path) && (await fsp.stat(path)).isFile()) {
return path
}
}
extensions.forEach((ext) => {
paths.push(resolve(cwd, `./${configPath}.${ext}`))
paths.push(resolve(cwd, `${source}/${CONFIG_FILE_NAME}.${ext}`))
paths.push(resolve(cwd, `./.vuepress/${CONFIG_FILE_NAME}.${ext}`))
})
let current: string | undefined
for (const path of paths) {
if (existsSync(path) && (await fsp.stat(path)).isFile()) {
current = path
break
}
}
if (configPath && current) {
logger.warn(`Can not find config file: ${colors.gray(configPath)}\nUse config file: ${colors.gray(current)}`)
}
return current
}
function existsSync(fp: string) {
try {
fs.accessSync(fp, constants.R_OK)
return true
}
catch {
return false
}
}

View File

@ -0,0 +1,8 @@
/**
* vuepress
* node
*
*/
export * from './findConfigPath.js'
export * from './compiler.js'
export * from './loader.js'

View File

@ -0,0 +1,163 @@
import type { App } from 'vuepress'
import type { FSWatcher } from 'chokidar'
import { path } from 'vuepress/utils'
import { watch } from 'chokidar'
import { deepMerge } from '@pengzhanbo/utils'
import type { ThemeConfig } from '../../shared/theme-data.js'
import type { AutoFrontmatter, PlumeThemeEncrypt, PlumeThemeLocaleOptions } from '../../shared/index.js'
import { resolveLocaleOptions } from '../config/resolveLocaleOptions.js'
import { findConfigPath } from './findConfigPath.js'
import { compiler } from './compiler.js'
export interface ResolvedConfig {
localeOptions: PlumeThemeLocaleOptions
encrypt?: PlumeThemeEncrypt
autoFrontmatter?: false | Omit<AutoFrontmatter, 'frontmatter'>
}
export interface InitConfigLoaderOptions {
configFile?: string
onChange?: ChangeEvent
}
export type ChangeEvent = (config: ResolvedConfig) => void | Promise<void>
export interface Loader {
configFile: string | undefined
dependencies: string[]
load: () => Promise<{ config: ThemeConfig, dependencies: string[] }>
loaded: boolean
watcher: FSWatcher | null
changeEvents: ChangeEvent[]
whenLoaded: ChangeEvent[]
defaultConfig: ThemeConfig
resolvedConfig: ResolvedConfig
}
let loader: Loader | null = null
export async function initConfigLoader(
app: App,
defaultConfig: ThemeConfig,
{ configFile, onChange }: InitConfigLoaderOptions = {},
) {
configFile = await findConfigPath(app, configFile)
const { encrypt, autoFrontmatter, ...localeOptions } = defaultConfig
loader = {
configFile,
dependencies: [],
load: () => compiler(configFile),
loaded: false,
watcher: null,
changeEvents: [],
whenLoaded: [],
defaultConfig,
resolvedConfig: {
localeOptions: resolveLocaleOptions(app, localeOptions),
encrypt,
autoFrontmatter,
},
}
onChange && loader.changeEvents.push(onChange)
const { config, dependencies = [] } = await loader.load()
loader.loaded = true
addDependencies(dependencies)
updateResolvedConfig(app, config)
runChangeEvents()
loader.whenLoaded.forEach(fn => fn(loader!.resolvedConfig))
loader.whenLoaded = []
}
export function watchConfigFile(app: App, watchers: any[]) {
if (!loader || !loader.configFile)
return
const watcher = watch(loader.configFile, {
ignoreInitial: true,
cwd: path.join(path.dirname(loader.configFile), '../'),
})
addDependencies()
watcher.on('change', async () => {
if (loader) {
loader.loaded = false
const { config, dependencies = [] } = await loader.load()
loader.loaded = true
addDependencies(dependencies)
updateResolvedConfig(app, config)
runChangeEvents()
}
})
watcher.on('unlink', async () => {
updateResolvedConfig(app)
runChangeEvents()
})
loader.watcher = watcher
watchers.push(watcher)
}
export async function onConfigChange(onChange: ChangeEvent) {
if (loader && !loader.changeEvents.includes(onChange)) {
loader.changeEvents.push(onChange)
loader.loaded && onChange(loader.resolvedConfig)
}
}
export function waitForConfigLoaded() {
return new Promise<ResolvedConfig>((resolve) => {
if (loader?.loaded) {
resolve(loader.resolvedConfig)
}
else {
loader?.whenLoaded.push(resolve)
}
})
}
export function getResolvedThemeConfig() {
return loader!.resolvedConfig
}
export function isConfigLoaded() {
return loader?.loaded ?? false
}
function updateResolvedConfig(app: App, userConfig: ThemeConfig = {}) {
if (loader) {
const { encrypt, autoFrontmatter, ...localeOptions } = deepMerge({}, loader.defaultConfig, userConfig)
loader.resolvedConfig = {
localeOptions: resolveLocaleOptions(app, localeOptions),
encrypt,
autoFrontmatter,
}
}
}
function runChangeEvents() {
if (loader) {
loader.changeEvents.forEach(fn => fn(loader!.resolvedConfig))
}
}
function addDependencies(dependencies?: string[]) {
if (!loader)
return
if (dependencies?.length) {
const deps = dependencies
.filter(dep => !loader!.dependencies.includes(dep) && dep[0] === '.')
loader.dependencies.push(...deps)
deps.length && loader.watcher?.add(deps)
}
else {
loader.watcher?.add(loader.dependencies)
}
}

View File

@ -4,12 +4,8 @@ import { docsearchPlugin } from '@vuepress/plugin-docsearch'
import { gitPlugin } from '@vuepress/plugin-git'
import { photoSwipePlugin } from '@vuepress/plugin-photo-swipe'
import { nprogressPlugin } from '@vuepress/plugin-nprogress'
import { themeDataPlugin } from '@vuepress/plugin-theme-data'
import { autoFrontmatterPlugin } from '@vuepress-plume/plugin-auto-frontmatter'
import { baiduTongjiPlugin } from '@vuepress-plume/plugin-baidu-tongji'
import { blogDataPlugin } from '@vuepress-plume/plugin-blog-data'
import { iconifyPlugin } from '@vuepress-plume/plugin-iconify'
import { notesDataPlugin } from '@vuepress-plume/plugin-notes-data'
import { shikiPlugin } from '@vuepress-plume/plugin-shikiji'
import { commentPlugin } from '@vuepress/plugin-comment'
import { type MarkdownEnhancePluginOptions, mdEnhancePlugin } from 'vuepress-plugin-md-enhance'
@ -21,54 +17,31 @@ import { searchPlugin } from '@vuepress-plume/plugin-search'
import { markdownPowerPlugin } from 'vuepress-plugin-md-power'
import { watermarkPlugin } from '@vuepress/plugin-watermark'
import { fontsPlugin } from '@vuepress-plume/plugin-fonts'
import type {
PlumeThemeEncrypt,
PlumeThemeLocaleOptions,
PlumeThemePluginOptions,
} from '../../shared/index.js'
import type { PlumeThemePluginOptions } from '../../shared/index.js'
import {
resolveDocsearchOptions,
resolveNotesOptions,
resolveSearchOptions,
resolveThemeData,
} from '../config/index.js'
import { resolveAutoFrontmatterOptions } from './resolveAutoFrontmatterOptions.js'
import { resolveBlogDataOptions } from './resolveBlogDataOptions.js'
import { customContainerPlugins } from './containerPlugins.js'
export interface SetupPluginOptions {
app: App
pluginOptions: PlumeThemePluginOptions
localeOptions: PlumeThemeLocaleOptions
encrypt?: PlumeThemeEncrypt
hostname?: string
}
export function getPlugins({
app,
pluginOptions,
localeOptions,
encrypt,
hostname,
}: SetupPluginOptions): PluginConfig {
const isProd = !app.env.isDev
const plugins: PluginConfig = [
themeDataPlugin({ themeData: resolveThemeData(app, localeOptions) }),
autoFrontmatterPlugin(resolveAutoFrontmatterOptions(pluginOptions, localeOptions)),
blogDataPlugin(resolveBlogDataOptions(localeOptions, encrypt)),
notesDataPlugin(resolveNotesOptions(localeOptions)),
iconifyPlugin(),
fontsPlugin(),
contentUpdatePlugin(),
activeHeaderLinksPlugin({
headerLinkSelector: 'a.outline-link',
headerAnchorSelector: '.header-anchor',
@ -185,10 +158,7 @@ export function getPlugins({
}
if (pluginOptions.seo !== false && hostname && isProd) {
plugins.push(seoPlugin({
hostname,
author: localeOptions.locales?.['/'].profile?.name || localeOptions.profile?.name || localeOptions.locales?.['/'].avatar?.name || localeOptions.avatar?.name,
}))
plugins.push(seoPlugin({ hostname }))
}
return plugins

View File

@ -1 +1,2 @@
export * from './getPlugins.js'
export * from './containerPlugins.js'

View File

@ -1,45 +0,0 @@
import type { BlogDataPluginOptions } from '@vuepress-plume/plugin-blog-data'
import { removeLeadingSlash } from '@vuepress/helper'
import {
isEncryptPage,
resolveNotesOptions,
} from '../config/index.js'
import { normalizePath } from '../utils.js'
import type { PlumeThemeEncrypt, PlumeThemeLocaleOptions } from '../..//shared/index.js'
export function resolveBlogDataOptions(
localeOptions: PlumeThemeLocaleOptions,
encrypt?: PlumeThemeEncrypt,
): BlogDataPluginOptions {
const blog = localeOptions.blog || {}
const notesList = resolveNotesOptions(localeOptions)
const notesDirList = notesList
.map(notes => removeLeadingSlash(normalizePath(`${notes.dir}/**`)))
.filter(Boolean)
return {
include: blog?.include ?? ['**/*.md'],
exclude: [
'**/{README,readme,index}.md',
'.vuepress/',
'node_modules/',
...(blog.exclude ?? []),
...notesDirList,
].filter(Boolean),
sortBy: 'createTime',
excerpt: true,
pageFilter: (page: any) => page.frontmatter.draft !== true,
extendBlogData: (page: any) => {
const tags = page.frontmatter.tags
const data: Record<string, any> = {
categoryList: page.data.categoryList,
tags,
sticky: page.frontmatter.sticky,
createTime: page.data.frontmatter.createTime,
lang: page.lang,
}
isEncryptPage(page, encrypt) && (data.encrypt = true)
return data
},
}
}

View File

@ -1,20 +1,34 @@
import type { App } from 'vuepress'
import { watch } from 'chokidar'
import { prepareArticleTagColors, updateArticleTagColor } from './prepareArticleTagColor.js'
import { getResolvedThemeConfig } from '../loadConfig/index.js'
import { prepareArticleTagColors } from './prepareArticleTagColor.js'
import { preparedBlogData } from './prepareBlogData.js'
import { prepareEncrypt } from './prepareEncrypt.js'
import { prepareSidebar } from './prepareSidebar.js'
export async function setupPrepare(app: App): Promise<void> {
await prepareArticleTagColors(app)
export async function prepareData(
app: App,
): Promise<void> {
const { localeOptions, encrypt } = getResolvedThemeConfig()
await Promise.all([
prepareArticleTagColors(app),
preparedBlogData(app, localeOptions, encrypt),
prepareSidebar(app, localeOptions),
prepareEncrypt(app, encrypt),
])
}
export function watchPrepare(app: App, watchers: any[]): void {
const watcher = watch('pages/**', {
export function watchPrepare(
app: App,
watchers: any[],
): void {
const pagesWatcher = watch('pages/**', {
cwd: app.dir.temp(),
ignoreInitial: true,
})
watchers.push(pagesWatcher)
watcher.on('change', () => updateArticleTagColor(app))
watcher.on('add', () => updateArticleTagColor(app))
watcher.on('unlink', () => updateArticleTagColor(app))
watchers.push(watcher)
pagesWatcher.on('change', () => prepareData(app))
pagesWatcher.on('add', () => prepareData(app))
pagesWatcher.on('unlink', () => prepareData(app))
}

View File

@ -1,7 +1,6 @@
import { toArray } from '@pengzhanbo/utils'
import type { App } from 'vuepress'
import { fs } from 'vuepress/utils'
import { hash, nanoid } from '../utils.js'
import { nanoid, resolveContent, writeTemp } from '../utils/index.js'
export type TagsColorsItem = readonly [
string, // normal color
@ -9,9 +8,6 @@ export type TagsColorsItem = readonly [
string, // background color
]
const TEMP_JS = 'internal/articleTagColors.js'
const TEMP_CSS = 'internal/articleTagColors.css'
export const PRESET: TagsColorsItem[] = [
['#6aa1b7', '#5086a1', 'rgba(131, 208, 218, 0.314)'],
['#299764', '#18794e', 'rgba(16, 185, 129, 0.14)'],
@ -33,59 +29,13 @@ export const PRESET: TagsColorsItem[] = [
['#8ecaef', '#55afe7', 'rgba(42, 155, 225, 0.147)'],
]
const HMR_CODE = `
if (import.meta.webpackHot) {
import.meta.webpackHot.accept()
if (__VUE_HMR_RUNTIME__.updateArticleTagColors) {
__VUE_HMR_RUNTIME__.updateArticleTagColor(articleTagColors)
}
}
if (import.meta.hot) {
import.meta.hot.accept(({ articleTagColors }) => {
__VUE_HMR_RUNTIME__.updateArticleTagColor(articleTagColors)
})
}
`
// { index: className }
const cache: Record<number, string> = {}
const hashMap: {
js: string
css: string
} = { js: '', css: '' }
export async function prepareArticleTagColors(app: App): Promise<void> {
const [tempJS, tempCSS] = await Promise.all([
readFile(app.dir.temp(TEMP_JS)),
readFile(app.dir.temp(TEMP_CSS)),
])
if (tempJS) {
hashMap.js = hash(tempJS)
}
if (tempCSS) {
hashMap.css = hash(tempCSS)
}
await updateArticleTagColor(app)
}
export async function updateArticleTagColor(app: App): Promise<void> {
const { js, css } = genCode(app)
const cssHash = hash(css)
if (!css || hashMap.css !== cssHash) {
hashMap.css = cssHash
await app.writeTemp(TEMP_CSS, css)
}
const jsHash = hash(js)
if (hashMap.js !== jsHash) {
hashMap.js = jsHash
await app.writeTemp(TEMP_JS, js)
}
await writeTemp(app, 'internal/articleTagColors.css', css)
await writeTemp(app, 'internal/articleTagColors.js', js)
}
export function genCode(app: App): { js: string, css: string } {
@ -111,13 +61,11 @@ export function genCode(app: App): { js: string, css: string } {
}
})
let js = `\
import './articleTagColors.css'
export const articleTagColors = ${JSON.stringify(articleTagColors)}
`
if (app.env.isDev) {
js += HMR_CODE
}
const js = resolveContent(app, {
name: 'articleTagColors',
content: articleTagColors,
before: `import './articleTagColors.css'`,
})
const css = genCSS()
return { js, css }
@ -150,11 +98,3 @@ function genCSS(): string {
return css
}
async function readFile(filepath: string): Promise<string> {
try {
return await fs.readFile(filepath, 'utf-8')
}
catch {}
return ''
}

View File

@ -0,0 +1,92 @@
import type { App, Page } from 'vuepress/core'
import { createFilter } from 'create-filter'
import { removeLeadingSlash } from '@vuepress/helper'
import { logger, normalizePath, resolveContent, writeTemp } from '../utils/index.js'
import type {
PlumeThemeBlogPostData,
PlumeThemeBlogPostItem,
PlumeThemeEncrypt,
PlumeThemeLocaleOptions,
PlumeThemePageData,
PlumeThemePostFrontmatter,
} from '../../shared/index.js'
import { resolveNotesOptions } from '../config/index.js'
import { isEncryptPage } from './prepareEncrypt.js'
const HEADING_RE = /<h(\d)[^>]*>.*?<\/h\1>/gi
const EXCERPT_SPLIT = '<!-- more -->'
function getTimestamp(time: Date): number {
return new Date(time).getTime()
}
export async function preparedBlogData(
app: App,
localeOptions: PlumeThemeLocaleOptions,
encrypt?: PlumeThemeEncrypt,
): Promise<void> {
const start = performance.now()
const blog = localeOptions.blog || {}
const notesList = resolveNotesOptions(localeOptions)
const notesDirList = notesList
.map(notes => removeLeadingSlash(normalizePath(`${notes.dir}/**`)))
.filter(Boolean)
const filter = createFilter(
blog.include ?? ['**/*.md'],
[
'**/{README,readme,index}.md',
'.vuepress/',
'node_modules/',
...(blog.exclude ?? []),
...notesDirList,
].filter(Boolean),
{ resolve: false },
)
const pages = app.pages.filter(page =>
page.filePathRelative
&& filter(page.filePathRelative)
&& page.frontmatter.draft !== true,
).sort((prev, next) =>
getTimestamp((prev.frontmatter.createTime as Date) || prev.date)
< getTimestamp(next.frontmatter.createTime as Date || next.date)
? 1
: -1,
) as Page<PlumeThemePageData, PlumeThemePostFrontmatter>[]
const blogData: PlumeThemeBlogPostData = pages.map((page) => {
page.data.isBlogPost = true
const tags = page.frontmatter.tags
const data: PlumeThemeBlogPostItem = {
path: page.path,
title: page.title,
categoryList: page.data.categoryList,
tags,
sticky: page.frontmatter.sticky,
createTime: page.data.frontmatter.createTime!,
lang: page.lang,
excerpt: '',
}
isEncryptPage(page, encrypt) && (data.encrypt = true)
if (page.contentRendered.includes(EXCERPT_SPLIT)) {
const contents = page.contentRendered.split(EXCERPT_SPLIT)
let excerpt = contents[0]
// 删除摘要中的标题
excerpt = excerpt.replace(HEADING_RE, '')
data.excerpt = excerpt
}
return data
})
const content = resolveContent(app, { name: 'blogPostData', content: blogData })
await writeTemp(app, 'internal/blogData.js', content)
if (app.env.isDebug)
logger.info(`prepare blog data time spent: ${(performance.now() - start).toFixed(2)}ms`)
}

View File

@ -1,14 +1,37 @@
import { genSaltSync, hashSync } from 'bcrypt-ts'
import type { App } from 'vuepress'
import { isNumber, isString, random, toArray } from '@pengzhanbo/utils'
import type { Page } from 'vuepress/core'
import type { PlumeThemeEncrypt, PlumeThemePageData } from '../../shared/index.js'
import { hash, resolveContent, writeTemp } from '../utils/index.js'
export type EncryptConfig = readonly [
boolean, // global
string, // separator
string, // admin
string[], // keys
Record<string, string>, // rules
]
const isStringLike = (value: unknown): boolean => isString(value) || isNumber(value)
const separator = ':'
let contentHash = ''
export function resolveEncrypt(encrypt?: PlumeThemeEncrypt) {
export async function prepareEncrypt(app: App, encrypt?: PlumeThemeEncrypt) {
const currentHash = encrypt ? hash(JSON.stringify(encrypt)) : ''
if (!contentHash || contentHash !== currentHash) {
contentHash = currentHash
const content = resolveContent(app, {
name: 'encrypt',
content: resolveEncrypt(encrypt),
})
await writeTemp(app, 'internal/encrypt.js', content)
}
}
function resolveEncrypt(encrypt?: PlumeThemeEncrypt): EncryptConfig {
const salt = () => genSaltSync(random(8, 16))
const admin = encrypt?.admin
? toArray(encrypt.admin)
.filter(isStringLike)
@ -30,13 +53,7 @@ export function resolveEncrypt(encrypt?: PlumeThemeEncrypt) {
})
}
return {
__PLUME_ENCRYPT_GLOBAL__: encrypt?.global ?? false,
__PLUME_ENCRYPT_SEPARATOR__: separator,
__PLUME_ENCRYPT_ADMIN__: admin,
__PLUME_ENCRYPT_KEYS__: keys,
__PLUME_ENCRYPT_RULES__: rules,
}
return [encrypt?.global ?? false, separator, admin, keys, rules]
}
export function isEncryptPage(page: Page<PlumeThemePageData>, encrypt?: PlumeThemeEncrypt) {

View File

@ -0,0 +1,162 @@
import type { App, Page } from 'vuepress'
import { entries, isArray, isPlainObject, removeLeadingSlash } from '@vuepress/helper'
import type { PlumeThemeLocaleOptions, PlumeThemePageData, Sidebar, SidebarItem, ThemeIcon } from '../../shared/index.js'
import { normalizeLink, resolveContent, writeTemp } from '../utils/index.js'
import type { ResolvedSidebarItem } from '../../shared/resolved/sidebar.js'
export async function prepareSidebar(app: App, localeOptions: PlumeThemeLocaleOptions) {
const sidebar = getAllSidebar(localeOptions)
sidebar.__auto__ = getSidebarData(app, sidebar)
await writeTemp(app, 'internal/sidebar.js', resolveContent(app, { name: 'sidebar', content: sidebar }))
}
function getSidebarData(
app: App,
locales: Record<string, Sidebar>,
): Sidebar {
const autoDirList: string[] = []
const resolved: Sidebar = {}
entries(locales).forEach(([localePath, sidebar]) => {
if (!sidebar)
return
if (isArray(sidebar)) {
autoDirList.push(...findAutoDirList(sidebar))
}
else if (isPlainObject(sidebar)) {
entries(sidebar).forEach(([dirname, config]) => {
const prefix = normalizeLink(localePath, dirname)
config === 'auto'
? autoDirList.push(prefix)
: isArray(config)
? autoDirList.push(...findAutoDirList(config, prefix))
: config.items === 'auto'
? autoDirList.push(normalizeLink(prefix, config.prefix))
: autoDirList.push(
...findAutoDirList(
config.items || [],
normalizeLink(prefix, config.prefix),
),
)
})
}
else if (sidebar === 'auto') {
autoDirList.push(localePath)
}
})
autoDirList.forEach((localePath) => {
resolved[localePath] = getAutoDirSidebar(app, localePath)
})
return resolved
}
function getAutoDirSidebar(
app: App,
localePath: string,
): SidebarItem[] {
const locale = removeLeadingSlash(localePath)
let pages = (app.pages as Page<PlumeThemePageData>[])
.filter(page => page.data.filePathRelative?.startsWith(locale))
.map((page) => {
return { ...page, splitPath: page.data.filePathRelative?.split('/') || [] }
})
const maxIndex = Math.max(...pages.map(page => page.splitPath.length))
let nowIndex = 0
while (nowIndex < maxIndex) {
pages = pages.sort((prev, next) => {
const pi = prev.splitPath?.[nowIndex]?.match(/(\d+)\.(?=[^/]+$)/)?.[1]
const ni = next.splitPath?.[nowIndex]?.match(/(\d+)\.(?=[^/]+$)/)?.[1]
if (!pi || !ni)
return 0
return Number.parseFloat(pi) < Number.parseFloat(ni) ? -1 : 1
})
nowIndex++
}
const RE_INDEX = ['index.md', 'README.md', 'readme.md']
const result: ResolvedSidebarItem[] = []
for (const page of pages) {
const { data, title, path, frontmatter } = page
const paths = (data.filePathRelative || '')
.slice(localePath.replace(/^\/|\/$/g, '').length + 1)
.split('/')
let index = 0
let dir: string
let items = result
// eslint-disable-next-line no-cond-assign
while ((dir = paths[index])) {
const text = dir.replace(/\.md$/, '').replace(/^\d+\./, '')
let current = items.find(item => item.text === text)
if (!current) {
current = { text, link: undefined, items: [] } as ResolvedSidebarItem
!RE_INDEX.includes(dir) ? items.push(current) : items.unshift(current)
}
if (dir.endsWith('.md')) {
current.link = path
current.text = title
}
if (frontmatter.icon)
current.icon = frontmatter.icon as ThemeIcon
if (index > 0) {
current.collapsed = false
}
items = current.items as ResolvedSidebarItem[]
index++
}
}
return result
}
function findAutoDirList(sidebar: (string | SidebarItem)[], prefix = ''): string[] {
const list: string[] = []
if (!sidebar.length)
return list
sidebar.forEach((item) => {
if (isPlainObject(item)) {
const nextPrefix = normalizeLink(prefix, item.prefix || item.dir)
if (item.items === 'auto') {
list.push(nextPrefix)
}
else {
item.items?.length
&& list.push(...findAutoDirList(item.items, nextPrefix))
}
}
})
return list
}
function getAllSidebar(localeOptions: PlumeThemeLocaleOptions): Record<string, Sidebar> {
const locales: Record<string, Sidebar> = {}
for (const [locale, opt] of entries(localeOptions.locales || {})) {
const notes = locale === '/' ? (opt.notes || localeOptions.notes) : opt.notes
const sidebar = locale === '/' ? (opt.sidebar || localeOptions.sidebar) : opt.sidebar
locales[locale] = { ...sidebar }
if (notes && notes.notes?.length) {
const prefix = notes.link || ''
for (const note of notes.notes) {
if (note.sidebar) {
locales[locale][normalizeLink(prefix, note.link || '/')] = {
items: note.sidebar,
prefix: normalizeLink(notes.dir, note.dir),
}
}
}
}
}
return locales
}

View File

@ -0,0 +1,11 @@
import type { App } from 'vuepress'
import { resolveThemeData } from '../config/resolveThemeData.js'
import { resolveContent, writeTemp } from '../utils/index.js'
import type { PlumeThemeLocaleOptions } from '../../shared/index.js'
export async function prepareThemeData(app: App, localeOptions: PlumeThemeLocaleOptions): Promise<void> {
const resolvedThemeData = resolveThemeData(app, localeOptions)
const content = resolveContent(app, { name: 'themeData', content: resolvedThemeData })
await writeTemp(app, 'internal/themePlumeData.js', content)
}

View File

@ -10,7 +10,7 @@ import type {
PlumeThemeLocaleOptions,
PlumeThemePageData,
} from '../shared/index.js'
import { withBase } from './utils.js'
import { withBase } from './utils/index.js'
import { PRESET_LOCALES } from './locales/index.js'
import { resolveNotesLinkList } from './config/index.js'
@ -63,6 +63,10 @@ export function extendsPageData(
page.data.filePathRelative = page.filePathRelative
page.routeMeta.title = page.frontmatter.title || page.title
if (page.frontmatter.icon) {
page.routeMeta.icon = page.frontmatter.icon
}
if (page.frontmatter.home) {
page.frontmatter.pageLayout = 'home'
delete page.frontmatter.home

View File

@ -2,34 +2,59 @@ import type { Page, Theme } from 'vuepress/core'
import type { PlumeThemeOptions, PlumeThemePageData } from '../shared/index.js'
import { getPlugins } from './plugins/index.js'
import { extendsPageData, setupPage } from './setupPages.js'
import { THEME_NAME, resolve, templates } from './utils.js'
import { THEME_NAME, resolve, templates } from './utils/index.js'
import {
extendsBundlerOptions,
resolveAlias,
resolveLocaleOptions,
resolvePageHead,
resolveProvideData,
resolveThemeOptions,
templateBuildRenderer,
} from './config/index.js'
import { setupPrepare, watchPrepare } from './prepare/index.js'
import {
getResolvedThemeConfig,
initConfigLoader,
onConfigChange,
waitForConfigLoaded,
watchConfigFile,
} from './loadConfig/index.js'
import {
generateAFrontmatter,
initAutoFrontmatter,
watchAutoFrontmatter,
} from './autoFrontmatter/index.js'
import { prepareData, watchPrepare } from './prepare/index.js'
import { prepareThemeData } from './prepare/prepareThemeData.js'
import { extendsMarkdown } from './extendsMarkdown.js'
export function plumeTheme(options: PlumeThemeOptions = {}): Theme {
const {
localeOptions: rawLocaleOptions,
localeOptions,
pluginOptions,
hostname,
encrypt,
configFile,
} = resolveThemeOptions(options)
return (app) => {
const localeOptions = resolveLocaleOptions(app, rawLocaleOptions)
initConfigLoader(app, localeOptions, {
configFile,
onChange: ({ localeOptions, autoFrontmatter }) => {
autoFrontmatter ??= pluginOptions.frontmatter
autoFrontmatter !== false && initAutoFrontmatter(localeOptions, autoFrontmatter)
},
})
waitForConfigLoaded().then(({ autoFrontmatter }) => {
autoFrontmatter ??= pluginOptions.frontmatter
if (autoFrontmatter !== false) {
generateAFrontmatter(app)
}
})
return {
name: THEME_NAME,
define: resolveProvideData(app, pluginOptions, encrypt),
define: resolveProvideData(app, pluginOptions),
templateBuild: templates('build.html'),
@ -37,21 +62,31 @@ export function plumeTheme(options: PlumeThemeOptions = {}): Theme {
alias: resolveAlias(),
plugins: getPlugins({ app, pluginOptions, localeOptions, encrypt, hostname }),
plugins: getPlugins({ app, pluginOptions, hostname }),
onInitialized: async (app) => {
const { localeOptions } = await waitForConfigLoaded()
await setupPage(app, localeOptions)
},
onPrepared: async (app) => {
await setupPrepare(app)
onPrepared: (app) => {
onConfigChange(({ localeOptions }) => {
prepareThemeData(app, localeOptions)
prepareData(app)
})
},
onWatched: (app, watchers) => {
watchConfigFile(app, watchers)
watchPrepare(app, watchers)
const autoFrontmatter = getResolvedThemeConfig().autoFrontmatter ?? pluginOptions.frontmatter
if (autoFrontmatter !== false) {
watchAutoFrontmatter(app, watchers)
}
},
extendsPage: (page) => {
extendsPage: async (page) => {
const { localeOptions } = await waitForConfigLoaded()
extendsPageData(page as Page<PlumeThemePageData>, localeOptions)
resolvePageHead(page, localeOptions)
},

View File

@ -1,62 +0,0 @@
import process from 'node:process'
import { createHash } from 'node:crypto'
import { customAlphabet } from 'nanoid'
import { fs, getDirname, path } from 'vuepress/utils'
import { Logger, ensureEndingSlash, ensureLeadingSlash } from '@vuepress/helper'
export const THEME_NAME = 'vuepress-theme-plume'
const __dirname = getDirname(import.meta.url)
export const resolve = (...args: string[]) => path.resolve(__dirname, '../', ...args)
export const templates = (url: string) => resolve('../templates', url)
export const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 8)
export const hash = (content: string) => createHash('md5').update(content).digest('hex')
export const logger = new Logger(THEME_NAME)
export function getPackage() {
let pkg = {} as any
try {
const content = fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8')
pkg = JSON.parse(content)
}
catch { }
return pkg
}
export function getThemePackage() {
let pkg = {} as any
try {
const content = fs.readFileSync(resolve('../package.json'), 'utf-8')
pkg = JSON.parse(content)
}
catch {}
return pkg
}
const RE_SLASH = /(\\|\/)+/g
export function normalizePath(path: string) {
return path.replace(RE_SLASH, '/')
}
export function pathJoin(...args: string[]) {
return normalizePath(path.join(...args))
}
const RE_START_END_SLASH = /^\/|\/$/g
export function getCurrentDirname(basePath: string | undefined, filepath: string) {
const dirList = normalizePath(basePath || path.dirname(filepath))
.replace(RE_START_END_SLASH, '')
.split('/')
return dirList.length > 0 ? dirList[dirList.length - 1] : ''
}
export function withBase(path = '', base = '/'): string {
path = ensureEndingSlash(ensureLeadingSlash(path))
if (path.startsWith(base))
return normalizePath(path)
return normalizePath(`${base}${path}`)
}

View File

@ -0,0 +1,6 @@
import { createHash } from 'node:crypto'
import { customAlphabet } from 'nanoid'
export const hash = (content: string) => createHash('md5').update(content).digest('hex')
export const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 8)

View File

@ -0,0 +1,11 @@
import { Logger } from '@vuepress/helper'
export const THEME_NAME = 'vuepress-theme-plume'
export const logger = new Logger(THEME_NAME)
export * from './hash.js'
export * from './path.js'
export * from './package.js'
export * from './resolveContent.js'
export * from './writeTemp.js'

View File

@ -0,0 +1,23 @@
import process from 'node:process'
import { fs, path } from 'vuepress/utils'
import { resolve } from './path.js'
export function getPackage() {
let pkg = {} as any
try {
const content = fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8')
pkg = JSON.parse(content)
}
catch { }
return pkg
}
export function getThemePackage() {
let pkg = {} as any
try {
const content = fs.readFileSync(resolve('.../../package.json'), 'utf-8')
pkg = JSON.parse(content)
}
catch {}
return pkg
}

View File

@ -0,0 +1,37 @@
import { getDirname, path } from 'vuepress/utils'
import { ensureEndingSlash, ensureLeadingSlash, isLinkAbsolute, isLinkWithProtocol } from '@vuepress/helper'
const __dirname = getDirname(import.meta.url)
export const resolve = (...args: string[]) => path.resolve(__dirname, '../../', ...args)
export const templates = (url: string) => resolve('../templates', url)
const RE_SLASH = /(\\|\/)+/g
export function normalizePath(path: string) {
return path.replace(RE_SLASH, '/')
}
export function pathJoin(...args: string[]) {
return normalizePath(path.join(...args))
}
export function normalizeLink(base: string, link = ''): string {
return isLinkAbsolute(link) || isLinkWithProtocol(link)
? link
: ensureLeadingSlash(normalizePath(`${base}/${link}/`))
}
const RE_START_END_SLASH = /^\/|\/$/g
export function getCurrentDirname(basePath: string | undefined, filepath: string) {
const dirList = normalizePath(basePath || path.dirname(filepath))
.replace(RE_START_END_SLASH, '')
.split('/')
return dirList.length > 0 ? dirList[dirList.length - 1] : ''
}
export function withBase(path = '', base = '/'): string {
path = ensureEndingSlash(ensureLeadingSlash(path))
if (path.startsWith(base))
return normalizePath(path)
return normalizePath(`${base}${path}`)
}

View File

@ -0,0 +1,31 @@
import type { App } from 'vuepress'
export interface ResolveContentOptions {
name: string
content: any
before?: string
after?: string
}
export function resolveContent(app: App, { name, content, before, after }: ResolveContentOptions): string {
content = `${before ? `${before}\n` : ''}export const ${name} = ${JSON.stringify(content)}${after ? `\n${after}` : ''}`
if (app.env.isDev) {
const func = `update${name[0].toUpperCase()}${name.slice(1)}`
content += `\n
if (import.meta.webpackHot) {
import.meta.webpackHot.accept()
if (__VUE_HMR_RUNTIME__.${func}) {
__VUE_HMR_RUNTIME__.${func}(${name})
}
}
if (import.meta.hot) {
import.meta.hot.accept(({ ${name} }) => {
__VUE_HMR_RUNTIME__.${func}(${name})
})
}
`
}
return content
}

View File

@ -0,0 +1,29 @@
/**
*
*/
import type { App } from 'vuepress'
import { hash } from './hash.js'
export const contentHash: Map<string, string> = new Map()
export async function writeTemp(
app: App,
filepath: string,
content: string,
): Promise<void> {
const currentHash = hash(content)
if (!contentHash.has(filepath) || contentHash.get(filepath) !== currentHash) {
contentHash.set(filepath, currentHash)
await app.writeTemp(filepath, content)
}
}
export function setContentHash(filepath: string, content: string): void {
if (content) {
const currentHash = hash(content)
contentHash.set(filepath, currentHash)
}
else {
contentHash.delete(filepath)
}
}

View File

@ -0,0 +1,40 @@
import type { Stats } from 'node:fs'
export interface AutoFrontmatterMarkdownFile {
filepath: string
relativePath: string
content: string
createTime: Date
stats: Stats
}
export type FrontmatterFn<T = any, K = object> = (
value: T,
file: AutoFrontmatterMarkdownFile,
data: K
) => T | PromiseLike<T>
export type AutoFrontmatterObject<K = object, T = any> = Record<string, FrontmatterFn<T, K>>
export type AutoFrontmatterArray = {
include: string | string[]
frontmatter: AutoFrontmatterObject
}[]
export interface AutoFrontmatter {
/**
* FilterPattern
*/
include?: string | string[]
exclude?: string | string[]
/**
* {
* key(value, file, data) {
* return value
* }
* }
*/
frontmatter?: AutoFrontmatterArray | AutoFrontmatterObject
}

View File

@ -3,6 +3,8 @@ export type ThemeImage =
| { src: string, alt?: string }
| { dark: string, light: string, alt?: string }
export type ThemeIcon = string | { svg: string }
export type ThemeColor = string | { light: string, dark: string }
export type ThemeOutline = false | number | [number, number] | 'deep'

View File

@ -1,10 +1,12 @@
import type { BlogPostDataItem } from '@vuepress-plume/plugin-blog-data'
import type { PageCategoryData } from './page-data.js'
export interface PlumeThemeBlogPostItem extends BlogPostDataItem {
tags: string[]
sticky: boolean
categoryList: PageCategoryData[]
export interface PlumeThemeBlogPostItem {
title: string
excerpt: string
path: string
tags?: string[]
sticky?: boolean | number
categoryList?: PageCategoryData[]
createTime: string
lang: string
encrypt?: boolean
@ -15,16 +17,21 @@ export type PlumeThemeBlogPostData = PlumeThemeBlogPostItem[]
export interface PlumeThemeBlog {
/**
* blog list link
*
*
* @default '/blog/'
*/
link?: string
/**
* glob string
* glob string
*
* @default - ['**\*.md']
* `.md` `notes`
*
* `blog`
* `['blog/**\/*.md']`
*
* @default - ['**\/*.md']
*/
include?: string[]

View File

@ -3,4 +3,7 @@ export * from './frontmatter/index.js'
export * from './options/index.js'
export * from './page-data.js'
export * from './blog.js'
export * from './sidebar.js'
export * from './navbar.js'
export * from './notes.js'
export * from './auto-frontmatter.js'

View File

@ -1,33 +1,75 @@
export type NavItem = NavItemWithLink | NavItemWithChildren
import type { ThemeIcon } from './base.js'
export type NavItem = string | NavItemWithLink | NavItemWithChildren
export interface NavItemWithLink {
/**
*
*/
text: string
/**
*
*/
link: string
icon?: string | { svg: string }
rel?: string
target?: string
/**
*
*/
icon?: ThemeIcon
prefix?: never
items?: never
/**
* `activeMatch` is expected to be a regex string. We can't use actual
* RegExp object here because it isn't serializable
*/
activeMatch?: string
rel?: string
target?: string
noIcon?: boolean
}
export interface NavItemChildren {
/**
*
*/
text?: string
icon?: string | { svg: string }
items: NavItemWithLink[]
/**
*
*
*/
prefix?: string
/**
*
*/
items: (string | NavItemWithLink)[]
}
export interface NavItemWithChildren {
text?: string
icon?: string | { svg: string }
items: (NavItemChildren | NavItemWithLink)[]
/**
*
*/
prefix?: string
/**
*
*/
icon?: ThemeIcon
/**
*
*/
items: (string | NavItemChildren | NavItemWithLink)[]
/**
* `activeMatch` is expected to be a regex string. We can't use actual
* RegExp object here because it isn't serializable
*
* `activeMatch`
* 使 RegExp
*/
activeMatch?: string
}

37
theme/src/shared/notes.ts Normal file
View File

@ -0,0 +1,37 @@
import type { SidebarItem } from './sidebar.js'
export interface NotesOptions {
/**
*
* @default '/notes/'
*/
dir: string
/**
*
* @default '/'
*/
link: string
/**
*
*/
notes: NoteItem[]
}
export interface NoteItem {
/**
*
*/
dir: string
/**
* `notes.link`
*/
link: string
/**
*
*/
text?: string
/**
*
*/
sidebar?: 'auto' | (string | SidebarItem)[]
}

View File

@ -1,4 +1,5 @@
import type { ThemeData } from '@vuepress/plugin-theme-data'
import type { LocaleConfig } from 'vuepress/shared'
import type { AutoFrontmatter } from '../auto-frontmatter.js'
import type { PlumeThemeLocaleData } from './locale.js'
import type { PlumeThemePluginOptions } from './plugins.js'
import type { PlumeThemeEncrypt } from './encrypt.js'
@ -23,15 +24,24 @@ export interface PlumeThemeOptions extends PlumeThemeLocaleOptions {
hostname?: string
/**
*
*
*/
encrypt?: PlumeThemeEncrypt
/**
*
*/
configFile?: string
autoFrontmatter?: false | Omit<AutoFrontmatter, 'frontmatter'>
}
export type PlumeThemeLocaleOptions = PlumeThemeData
export type PlumeThemeData = ThemeData<PlumeThemeLocaleData>
export type PlumeThemeData = PlumeThemeLocaleData & {
locales?: LocaleConfig<Omit<PlumeThemeLocaleData, 'blog'>>
}
export * from './locale.js'
export * from './plugins.js'

View File

@ -1,8 +1,9 @@
import type { LocaleData } from 'vuepress/core'
import type { NotesDataOptions } from '@vuepress-plume/plugin-notes-data'
import type { SocialLink, SocialLinkIconUnion, ThemeOutline, ThemeTransition } from '../base.js'
import type { PlumeThemeBlog } from '../blog.js'
import type { NavItem } from '../navbar.js'
import type { SidebarMulti } from '../sidebar.js'
import type { NotesOptions } from '../notes.js'
export interface PlumeThemeLocaleData extends LocaleData {
/**
@ -77,7 +78,12 @@ export interface PlumeThemeLocaleData extends LocaleData {
*
* notes配置到navbar中
*/
notes?: false | NotesDataOptions
notes?: false | NotesOptions
/**
*
*/
sidebar?: SidebarMulti
/**
*

View File

@ -1,6 +1,5 @@
import type { DocsearchOptions } from '@vuepress/plugin-docsearch'
import type { SearchPluginOptions } from '@vuepress-plume/plugin-search'
import type { AutoFrontmatterOptions } from '@vuepress-plume/plugin-auto-frontmatter'
import type { BaiduTongjiOptions } from '@vuepress-plume/plugin-baidu-tongji'
import type { ShikiPluginOptions } from '@vuepress-plume/plugin-shikiji'
import type { CommentPluginOptions } from '@vuepress/plugin-comment'
@ -8,6 +7,7 @@ import type { MarkdownEnhancePluginOptions } from 'vuepress-plugin-md-enhance'
import type { ReadingTimePluginOptions } from '@vuepress/plugin-reading-time'
import type { MarkdownPowerPluginOptions } from 'vuepress-plugin-md-power'
import type { WatermarkPluginOptions } from '@vuepress/plugin-watermark'
import type { AutoFrontmatter } from '../auto-frontmatter.js'
export interface PlumeThemePluginOptions {
/**
@ -59,7 +59,10 @@ export interface PlumeThemePluginOptions {
baiduTongji?: false | BaiduTongjiOptions
frontmatter?: Omit<AutoFrontmatterOptions, 'frontmatter'>
/**
* @deprecated 使 `autoFrontmatter`
*/
frontmatter?: Omit<AutoFrontmatter, 'frontmatter'>
readingTime?: false | ReadingTimePluginOptions

View File

@ -0,0 +1,39 @@
import type { ThemeIcon } from '../base.js'
export type ResolvedNavItem =
| ResolvedNavItemWithLink
| ResolvedNavItemWithChildren
export interface ResolvedNavItemWithLink {
text: string
link: string
icon?: ThemeIcon
items?: never
/**
* `activeMatch` is expected to be a regex string. We can't use actual
* RegExp object here because it isn't serializable
*/
activeMatch?: string
rel?: string
target?: string
noIcon?: boolean
}
export interface ResolvedNavItemChildren {
text?: string
icon?: ThemeIcon
items: ResolvedNavItemWithLink[]
}
export interface ResolvedNavItemWithChildren {
text?: string
icon?: ThemeIcon
items: (ResolvedNavItemChildren | ResolvedNavItemWithLink)[]
/**
* `activeMatch` is expected to be a regex string. We can't use actual
* RegExp object here because it isn't serializable
*/
activeMatch?: string
}

View File

@ -0,0 +1,52 @@
import type { ThemeIcon } from '../base.js'
export type ResolvedSidebar = ResolvedSidebarItem[] | ResolvedSidebarMulti
export type ResolvedSidebarMulti = Record<
string,
ResolvedSidebarItem[] | { items: ResolvedSidebarItem[] }
>
export interface ResolvedSidebarItem {
/**
*
*/
text?: string
/**
*
*/
link?: string
/**
*
*/
icon?: ThemeIcon
/**
*
*/
items?: ResolvedSidebarItem[]
/**
*
*
* `true`
*
* `false`
*/
collapsed?: boolean
/**
*
*/
prefix?: string
/**
* @deprecated 使 `prefix`
*/
dir?: string
rel?: string
target?: string
}

View File

@ -0,0 +1,54 @@
import type { ThemeIcon } from './base.js'
export type Sidebar = 'auto' | (string | SidebarItem)[] | SidebarMulti
export type SidebarMulti = Record<
string,
| 'auto'
| (string | SidebarItem)[]
| { items: 'auto' | (string | SidebarItem)[], prefix?: string }
>
export interface SidebarItem {
/**
*
*/
text?: string
/**
*
*/
link?: string
/**
*
*/
icon?: ThemeIcon
/**
*
*/
items?: 'auto' | (string | SidebarItem)[]
/**
*
*
* `true`
*
* `false`
*/
collapsed?: boolean
/**
*
*/
prefix?: string
/**
* @deprecated 使 `prefix`
*/
dir?: string
rel?: string
target?: string
}

View File

@ -0,0 +1,19 @@
import type { LocaleConfig } from 'vuepress/shared'
import type { PlumeThemeLocaleData } from './options/locale.js'
import type { PlumeThemeEncrypt } from './options/encrypt.js'
import type { AutoFrontmatter } from './auto-frontmatter.js'
export type ThemeConfig = PlumeThemeLocaleData & {
locales?: LocaleConfig<Omit<PlumeThemeLocaleData, 'blog'>>
/**
* frontmatter
*/
autoFrontmatter?: false | Omit<AutoFrontmatter, 'frontmatter'>
/**
*
*/
encrypt?: PlumeThemeEncrypt
}