commit
589999b40f
@ -21,10 +21,6 @@ export default defineUserConfig({
|
||||
|
||||
pagePatterns: ['**/*.md', '!**/*.snippet.md', '!.vuepress', '!node_modules'],
|
||||
|
||||
markdown: {
|
||||
code: false,
|
||||
},
|
||||
|
||||
bundler: viteBundler(),
|
||||
|
||||
theme,
|
||||
|
||||
62
docs/.vuepress/plume.config.ts
Normal file
62
docs/.vuepress/plume.config.ts
Normal 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.*'] },
|
||||
})
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@ -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:~"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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'],
|
||||
},
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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
2192
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
20
theme/src/client/composables/blog-data.ts
Normal file
20
theme/src/client/composables/blog-data.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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[]
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
55
theme/src/client/composables/encrypt-data.ts
Normal file
55
theme/src/client/composables/encrypt-data.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ''
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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>
|
||||
|
||||
45
theme/src/client/shim.d.ts
vendored
45
theme/src/client/shim.d.ts
vendored
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
122
theme/src/node/autoFrontmatter/generator.ts
Normal file
122
theme/src/node/autoFrontmatter/generator.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
3
theme/src/node/autoFrontmatter/index.ts
Normal file
3
theme/src/node/autoFrontmatter/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './generator.js'
|
||||
export * from './readFile.js'
|
||||
export * from './resolveOptions.js'
|
||||
38
theme/src/node/autoFrontmatter/readFile.ts
Normal file
38
theme/src/node/autoFrontmatter/readFile.ts
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,5 +9,4 @@ export * from './templateBuildRenderer.js'
|
||||
|
||||
export * from './resolveSearchOptions.js'
|
||||
export * from './resolvePageHead.js'
|
||||
export * from './resolveEncrypt.js'
|
||||
export * from './resolveNotesOptions.js'
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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']
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
89
theme/src/node/loadConfig/compiler.ts
Normal file
89
theme/src/node/loadConfig/compiler.ts
Normal 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 ?? {}),
|
||||
}
|
||||
}
|
||||
49
theme/src/node/loadConfig/findConfigPath.ts
Normal file
49
theme/src/node/loadConfig/findConfigPath.ts
Normal 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
|
||||
}
|
||||
}
|
||||
8
theme/src/node/loadConfig/index.ts
Normal file
8
theme/src/node/loadConfig/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 每次修改 主题配置 都会导致 vuepress 服务重启,成本太高了,严重影响了用户体验。
|
||||
* 实际上 主题配置 中的大部分 选项 跟 node 的构建过程是无关的,根本无需重启服务。
|
||||
* 因此,将 主题配置 抽离到独立的文件中进行配置,避免服务重启,是非常有必要的。
|
||||
*/
|
||||
export * from './findConfigPath.js'
|
||||
export * from './compiler.js'
|
||||
export * from './loader.js'
|
||||
163
theme/src/node/loadConfig/loader.ts
Normal file
163
theme/src/node/loadConfig/loader.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from './getPlugins.js'
|
||||
export * from './containerPlugins.js'
|
||||
|
||||
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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 ''
|
||||
}
|
||||
|
||||
92
theme/src/node/prepare/prepareBlogData.ts
Normal file
92
theme/src/node/prepare/prepareBlogData.ts
Normal 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`)
|
||||
}
|
||||
@ -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) {
|
||||
162
theme/src/node/prepare/prepareSidebar.ts
Normal file
162
theme/src/node/prepare/prepareSidebar.ts
Normal 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
|
||||
}
|
||||
11
theme/src/node/prepare/prepareThemeData.ts
Normal file
11
theme/src/node/prepare/prepareThemeData.ts
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
6
theme/src/node/utils/hash.ts
Normal file
6
theme/src/node/utils/hash.ts
Normal 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)
|
||||
11
theme/src/node/utils/index.ts
Normal file
11
theme/src/node/utils/index.ts
Normal 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'
|
||||
23
theme/src/node/utils/package.ts
Normal file
23
theme/src/node/utils/package.ts
Normal 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
|
||||
}
|
||||
37
theme/src/node/utils/path.ts
Normal file
37
theme/src/node/utils/path.ts
Normal 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}`)
|
||||
}
|
||||
31
theme/src/node/utils/resolveContent.ts
Normal file
31
theme/src/node/utils/resolveContent.ts
Normal 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
|
||||
}
|
||||
29
theme/src/node/utils/writeTemp.ts
Normal file
29
theme/src/node/utils/writeTemp.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
40
theme/src/shared/auto-frontmatter.ts
Normal file
40
theme/src/shared/auto-frontmatter.ts
Normal 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
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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[]
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
37
theme/src/shared/notes.ts
Normal 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)[]
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
/**
|
||||
* 要显示的标题级别。
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
39
theme/src/shared/resolved/navbar.ts
Normal file
39
theme/src/shared/resolved/navbar.ts
Normal 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
|
||||
}
|
||||
52
theme/src/shared/resolved/sidebar.ts
Normal file
52
theme/src/shared/resolved/sidebar.ts
Normal 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
|
||||
}
|
||||
54
theme/src/shared/sidebar.ts
Normal file
54
theme/src/shared/sidebar.ts
Normal 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
|
||||
}
|
||||
19
theme/src/shared/theme-data.ts
Normal file
19
theme/src/shared/theme-data.ts
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user