feat(theme): add home components

This commit is contained in:
pengzhanbo 2023-02-13 09:18:52 +08:00
parent 764c58693e
commit 2c5bc7d29e
43 changed files with 1421 additions and 61 deletions

View File

@ -38,6 +38,7 @@ export default defineUserConfig({
// },
notes,
navbar: [
{ text: 'Blog', link: '/blog/', activeMatch: '/blog/' },
{
text: 'VuePress',
items: [
@ -57,7 +58,7 @@ export default defineUserConfig({
],
footer: {
copyright: 'Copyright © 2022-present pengzhanbo',
content: '',
message: '',
},
themePlugins: {
search: {

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@ -1,7 +1,17 @@
---
home: true
banner: /images/big-banner.jpg
motto: 世间的美好总是不期而遇,恬静而自然。
author: pengzhanbo
createTime: 2022/03/26 07:46:50
banner: /images/bg-home.jpg
hero:
name: 鹏展博
profession: 前端开发工程师
text: 简单介绍专业技能信息相关的描述
actions:
-
theme: brand
text: Blog
link: /
-
theme: alt
text: Github
link: /
---

BIN
docs/home-banner.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

View File

@ -5,7 +5,7 @@
"scripts": {
"docs:build": "vuepress-cli build --clean-cache",
"docs:clean": "rimraf .vuepress/.temp .vuepress/.cache .vuepress/dist",
"docs:dev": "vuepress-cli dev --clean-cache",
"docs:dev": "vuepress-cli dev --clean-cache --clean-temp",
"docs:serve": "anywhere -s -h localhost -d .vuepress/dist"
},
"dependencies": {

View File

@ -1,4 +1,4 @@
import type { App } from '@vuepress/core'
import type { App, Page } from '@vuepress/core'
import type { BlogPostData, BlogPostDataItem } from '../shared/index.js'
import type { PluginOption } from './plugin.js'
@ -21,6 +21,8 @@ const getTimestamp = (time: Date): number => {
return new Date(time).getTime()
}
const EXCERPT_SPLIT = '<!-- more -->'
export const preparedBlogData = async (
app: App,
pageFilter: (id: string) => boolean,
@ -45,7 +47,7 @@ export const preparedBlogData = async (
})
}
const blogData: BlogPostData = pages.map((page) => {
const blogData: BlogPostData = pages.map((page: Page) => {
let extended: Partial<BlogPostDataItem> = {}
if (typeof options.extendBlogData === 'function') {
extended = options.extendBlogData(page)
@ -56,7 +58,10 @@ export const preparedBlogData = async (
...extended,
}
if (options.excerpt) data.excerpt = (page as any).excerpt
if (options.excerpt && page.contentRendered.includes(EXCERPT_SPLIT)) {
const contents = page.contentRendered.split(EXCERPT_SPLIT)
data.excerpt = contents[0]
}
return data as BlogPostDataItem
})

View File

@ -3,7 +3,7 @@ export interface BlogDataPluginOptions {
exclude?: string | string[]
sortBy?: 'createTime' | false | (<T>(prev: T, next: T) => boolean)
excerpt?: boolean
extendBlogData?: <T>(page: T) => Partial<BlogPostDataItem>
extendBlogData?: <T = any>(page: T) => Record<string, any>
}
export type BlogPostData<T extends object = object> = BlogPostDataItem<T>[]

View File

@ -94,7 +94,6 @@ export const watchNotesData = (
}
function initSidebar(note: NotesItem, pages: NotePage[]): NotesSidebarItem[] {
console.log('pages:', pages)
if (!note.sidebar) return []
if (note.sidebar === 'auto') return []
return initSidebarByConfig(note, pages)
@ -105,7 +104,6 @@ function initSidebarByConfig(
pages: NotePage[]
): NotesSidebarItem[] {
return (sidebar as NotesSidebar).map((item) => {
console.log('text: ', text, 's-item: ', item, 'dir: ', dir)
if (typeof item === 'string') {
const current = findNotePage(item, dir, pages)
return {

View File

@ -0,0 +1,41 @@
<script lang="ts" setup>
defineProps<{
show: boolean
}>()
</script>
<template>
<Transition name="fade">
<div v-if="show" class="backdrop" />
</Transition>
</template>
<style scoped>
.backdrop {
position: fixed;
top: 0;
/*rtl:ignore*/
right: 0;
bottom: 0;
/*rtl:ignore*/
left: 0;
z-index: var(--vp-z-index-backdrop);
background: var(--vp-backdrop-bg-color);
transition: opacity 0.5s;
}
.backdrop.fade-enter-from,
.backdrop.fade-leave-to {
opacity: 0;
}
.backdrop.fade-leave-active {
transition-duration: 0.25s;
}
@media (min-width: 1280px) {
.backdrop {
display: none;
}
}
</style>

View File

@ -0,0 +1,34 @@
<script lang="ts" setup>
import BlogAvatar from './BlogAvatar.vue'
import PostList from './PostList.vue'
</script>
<template>
<div class="blog-wrapper">
<PostList />
<BlogAvatar />
</div>
</template>
<style scoped>
.blog-wrapper {
display: flex;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
margin: 0 auto;
padding-top: var(--vp-nav-height);
}
@media (min-width: 960px) {
.blog-wrapper {
max-width: 784px;
padding-top: 0;
}
}
@media (min-width: 1440px) {
.blog-wrapper {
max-width: 1104px;
}
}
</style>

View File

@ -0,0 +1,52 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useThemeLocaleData } from '../composables/index.js'
const theme = useThemeLocaleData()
const avatar = computed(() => theme.value.avatar)
</script>
<template>
<div v-if="avatar" class="blog-avatar-wrapper">
<div class="avatar-profile">
<p v-if="avatar.url">
<img :src="avatar.url" :alt="avatar.name" />
</p>
<div>
<h3>{{ avatar.name }}</h3>
<p>{{ avatar.description }}</p>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.blog-avatar-wrapper {
position: sticky;
top: calc(var(--vp-nav-height) + 2rem);
width: 270px;
margin-left: 2rem;
margin-top: 2rem;
border-left: solid 1px var(--vp-c-divider);
text-align: center;
padding: 1rem 0;
img {
width: 50%;
margin: auto;
}
h3 {
font-size: 16px;
font-weight: 600;
margin-top: 1.5rem;
}
}
@media (max-width: 768px) {
.blog-avatar-wrapper {
display: none;
}
}
</style>

View File

@ -1,28 +0,0 @@
<script lang="ts" setup>
import Page from './Page.vue'
</script>
<template>
<div class="plume-blog-page">
<Page />
</div>
</template>
<style scoped>
.plume-blog-page {
flex-grow: 1;
flex-shrink: 0;
margin: var(--vp-layout-top-height, 0px) auto 0;
width: 100%;
}
@media (min-width: 960px) {
.plume-blog-page {
padding-top: var(--vp-nav-height);
}
.plume-blog-page.has-sidebar {
margin: var(--vp-layout-top-height, 0px) 0 0;
padding-left: var(--vp-sidebar-width);
}
}
</style>

View File

@ -1,3 +1,138 @@
<script lang="ts" setup>
import { usePageFrontmatter, withBase } from '@vuepress/client'
import { computed } from 'vue'
import type { PlumeThemeHomeFrontmatter } from '../../shared/index.js'
import { useThemeLocaleData } from '../composables/index.js'
import VButton from './VButton.vue'
const matter = usePageFrontmatter<PlumeThemeHomeFrontmatter>()
const theme = useThemeLocaleData()
const homeStyle = computed(() => {
return {
'background-image': `url(${withBase(matter.value.banner || '')})`,
}
})
const name = computed(() => matter.value.hero?.name)
const profession = computed(() => matter.value.hero?.profession)
const text = computed(() => matter.value.hero?.text)
const actions = computed(() => {
return matter.value.hero?.actions ?? []
})
</script>
<template>
<div>home</div>
<div class="plume-home" :style="homeStyle">
<div class="container">
<div v-if="matter.hero" class="content">
<h2 v-if="name" class="hero-name">{{ name }}</h2>
<p v-if="profession" class="hero-profession">
<span class="line"></span> <span>{{ profession }}</span>
</p>
<p v-if="text" class="hero-text">{{ text }}</p>
<div v-if="actions" class="actions">
<div v-for="action in actions" :key="action.link" class="action">
<VButton
tag="a"
size="medium"
:theme="action.theme"
:text="action.text"
:href="action.link"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.plume-home {
width: 100%;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
min-height: calc(100vh - var(--vp-nav-height));
filter: var(--vp-home-hero-image-filter);
}
.plume-home .container {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
margin: 0 auto;
padding-top: 4rem;
}
.plume-home .content {
padding: 0 2rem;
}
@media (min-width: 960px) {
.plume-home .container {
max-width: 768px;
padding-top: 8rem;
}
}
@media (min-width: 1440px) {
.plume-home .container {
max-width: 1104px;
padding-top: 8rem;
}
.plume-home .content .hero-profession {
font-size: 32px;
}
}
.plume-home .content .hero-name {
font-size: 100px;
font-weight: 600;
line-height: 1;
color: var(--vp-c-text-hero-name);
}
.plume-home .content .hero-profession {
display: flex;
align-items: center;
font-size: 24px;
font-weight: 500;
margin-top: 1rem;
color: var(--vp-c-text-hero-profession);
line-height: 1.25;
}
.plume-home .content .hero-profession .line {
display: inline-block;
width: 80px;
height: 0;
border-top: solid 1px var(--vp-c-text-hero-profession);
margin-right: 1rem;
}
.plume-home .content .hero-text {
width: 100%;
max-width: 700px;
font-size: 16px;
font-weight: 500;
margin-top: 1.5rem;
color: var(--vp-c-text-hero-text);
padding: 6px 20px;
border-radius: 5px;
background-color: rgba(0, 0, 0, 0.25);
}
.actions {
display: flex;
flex-wrap: wrap;
margin: -6px;
padding-top: 24px;
}
.action {
flex-shrink: 0;
padding: 6px;
}
</style>

View File

@ -4,7 +4,11 @@ import { useSidebar } from '../composables/index.js'
const { hasSidebar } = useSidebar()
</script>
<template>
<div class="layout-content" :class="{ 'has-sidebar': hasSidebar }">
<div
id="LayoutContent"
class="layout-content"
:class="{ 'has-sidebar': hasSidebar }"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,109 @@
<script lang="ts" setup>
import { useSidebar, useThemeLocaleData } from '../composables/index.js'
import IconAlignLeft from './icons/IconAlignLeft.vue'
defineProps<{
open: boolean
}>()
defineEmits<{
(e: 'open-menu'): void
}>()
const theme = useThemeLocaleData()
const { hasSidebar } = useSidebar()
function scrollToTop() {
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
}
</script>
<template>
<div v-if="hasSidebar" class="local-nav">
<button
class="menu"
:aria-expanded="open"
aria-controls="SidebarNav"
@click="$emit('open-menu')"
>
<IconAlignLeft class="menu-icon" />
<span class="menu-text"> Menu </span>
</button>
<a class="top-link" href="#" @click="scrollToTop"> Return to top </a>
</div>
</template>
<style scoped>
.local-nav {
position: sticky;
top: 0;
/*rtl:ignore*/
left: 0;
z-index: var(--vp-z-index-local-nav);
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--vp-c-gutter);
padding-top: var(--vp-layout-top-height, 0px);
width: 100%;
background-color: var(--vp-local-nav-bg-color);
transition: border-color 0.5s, background-color 0.5s;
}
@media (min-width: 960px) {
.local-nav {
display: none;
}
}
.menu {
display: flex;
align-items: center;
padding: 12px 24px 11px;
line-height: 24px;
font-size: 12px;
font-weight: 500;
color: var(--vp-c-text-2);
transition: color 0.5s;
}
.menu:hover {
color: var(--vp-c-text-1);
transition: color 0.25s;
}
@media (min-width: 768px) {
.menu {
padding: 0 32px;
}
}
.menu-icon {
margin-right: 8px;
width: 16px;
height: 16px;
fill: currentColor;
}
.top-link {
display: block;
padding: 12px 24px 11px;
line-height: 24px;
font-size: 12px;
font-weight: 500;
color: var(--vp-c-text-2);
transition: color 0.5s;
}
.top-link:hover {
color: var(--vp-c-text-1);
transition: color 0.25s;
}
@media (min-width: 768px) {
.top-link {
padding: 12px 32px 11px;
}
}
</style>

View File

@ -6,6 +6,7 @@ import NavBarAppearance from './NavBarAppearance.vue'
import NavBarExtra from './NavBarExtra.vue'
import NavBarHamburger from './NavBarHamburger.vue'
import NavBarMenu from './NavBarMenu.vue'
import NavBarSearch from './NavBarSearch.vue'
import NavBarSocialLinks from './NavBarSocialLinks.vue'
import NavBarTitle from './NavBarTitle.vue'
@ -35,7 +36,7 @@ const classes = computed(() => ({
<div class="content">
<div class="curtain"></div>
<div class="content-body">
<NavbarSearch class="search" />
<NavBarSearch class="search" />
<NavBarMenu class="menu" />
<NavBarAppearance class="appearance" />
<NavBarSocialLinks class="social-links" />

View File

@ -0,0 +1,209 @@
<template>
<div class="navbar-search">
<DocSearch />
</div>
</template>
<style>
.navbar-search {
display: flex;
align-items: center;
}
@media (min-width: 768px) {
.navbar-search {
flex-grow: 1;
padding-left: 24px;
}
}
@media (min-width: 960px) {
.navbar-search {
padding-left: 32px;
}
}
.DocSearch {
--docsearch-primary-color: var(--vp-c-brand);
--docsearch-highlight-color: var(--docsearch-primary-color);
--docsearch-text-color: var(--vp-c-text-1);
--docsearch-muted-color: var(--vp-c-text-2);
--docsearch-searchbox-shadow: none;
--docsearch-searchbox-focus-background: transparent;
--docsearch-key-gradient: transparent;
--docsearch-key-shadow: none;
--docsearch-modal-background: var(--vp-c-bg-soft);
--docsearch-footer-background: var(--vp-c-bg);
}
.dark .DocSearch {
--docsearch-modal-shadow: none;
--docsearch-footer-shadow: none;
--docsearch-logo-color: var(--vp-c-text-2);
--docsearch-hit-background: var(--vp-c-bg-soft-mute);
--docsearch-hit-color: var(--vp-c-text-2);
--docsearch-hit-shadow: none;
}
.DocSearch-Button {
display: flex;
justify-content: center;
align-items: center;
margin: 0;
padding: 0;
width: 32px;
height: 55px;
background: transparent;
transition: border-color 0.25s;
}
.DocSearch-Button:hover {
background: transparent;
}
.DocSearch-Button:focus {
outline: 1px dotted;
outline: 5px auto -webkit-focus-ring-color;
}
.DocSearch-Button:focus:not(:focus-visible) {
outline: none !important;
}
@media (min-width: 768px) {
.DocSearch-Button {
justify-content: flex-start;
border: 1px solid transparent;
border-radius: 8px;
padding: 0 10px 0 12px;
width: 100%;
height: 40px;
background-color: var(--vp-c-bg-alt);
}
.DocSearch-Button:hover {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-alt);
}
}
.DocSearch-Button .DocSearch-Button-Container {
display: flex;
align-items: center;
}
.DocSearch-Button .DocSearch-Search-Icon {
position: relative;
width: 16px;
height: 16px;
color: var(--vp-c-text-1);
fill: currentColor;
transition: color 0.5s;
}
.DocSearch-Button:hover .DocSearch-Search-Icon {
color: var(--vp-c-text-1);
}
@media (min-width: 768px) {
.DocSearch-Button .DocSearch-Search-Icon {
top: 1px;
margin-right: 8px;
width: 14px;
height: 14px;
color: var(--vp-c-text-2);
}
}
.DocSearch-Button .DocSearch-Button-Placeholder {
display: none;
margin-top: 2px;
padding: 0 16px 0 0;
font-size: 13px;
font-weight: 500;
color: var(--vp-c-text-2);
transition: color 0.5s;
}
.DocSearch-Button:hover .DocSearch-Button-Placeholder {
color: var(--vp-c-text-1);
}
@media (min-width: 768px) {
.DocSearch-Button .DocSearch-Button-Placeholder {
display: inline-block;
}
}
.DocSearch-Button .DocSearch-Button-Keys {
/*rtl:ignore*/
direction: ltr;
display: none;
min-width: auto;
}
@media (min-width: 768px) {
.DocSearch-Button .DocSearch-Button-Keys {
display: flex;
align-items: center;
}
}
.DocSearch-Button .DocSearch-Button-Key {
display: block;
margin: 2px 0 0 0;
border: 1px solid var(--vp-c-divider);
/*rtl:begin:ignore*/
border-right: none;
border-radius: 4px 0 0 4px;
padding-left: 6px;
/*rtl:end:ignore*/
min-width: 0;
width: auto;
height: 22px;
line-height: 22px;
font-family: var(--vp-font-family-base);
font-size: 12px;
font-weight: 500;
transition: color 0.5s, border-color 0.5s;
}
.DocSearch-Button .DocSearch-Button-Key + .DocSearch-Button-Key {
/*rtl:begin:ignore*/
border-right: 1px solid var(--vp-c-divider);
border-left: none;
border-radius: 0 4px 4px 0;
padding-left: 2px;
padding-right: 6px;
/*rtl:end:ignore*/
}
.DocSearch-Button .DocSearch-Button-Key:first-child {
font-size: 1px;
letter-spacing: -12px;
color: transparent;
}
.DocSearch-Button .DocSearch-Button-Key:first-child:after {
font-size: 12px;
letter-spacing: normal;
color: var(--docsearch-muted-color);
}
.DocSearch-Button .DocSearch-Button-Key:first-child > * {
display: none;
}
.dark .DocSearch-Footer {
border-top: 1px solid var(--vp-c-divider);
}
.DocSearch-Form {
border: 1px solid var(--vp-c-brand);
background-color: var(--vp-c-white);
}
.dark .DocSearch-Form {
background-color: var(--vp-c-bg-soft-mute);
}
</style>

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useSiteLocaleData } from '@vuepress/client'
import { useSiteLocaleData, withBase } from '@vuepress/client'
import { useSidebar } from '../../composables/index.js'
import { useThemeLocaleData } from '../../composables/themeData.js'
import AutoLink from '../AutoLink.vue'
@ -12,7 +12,7 @@ const { hasSidebar } = useSidebar()
<template>
<div class="navbar-title" :class="{ 'has-sidebar': hasSidebar }">
<AutoLink class="title" :href="theme.home ? theme.home.link : ''">
<AutoLink class="title" :href="theme.home || withBase('/')">
<VImage
v-if="theme.logo"
class="logo"

View File

@ -1,15 +1,23 @@
<script lang="ts" setup>
import { provide } from 'vue'
import { usePageData } from '@vuepress/client'
import { computed, provide } from 'vue'
import type { PlumeThemePageData } from '../../../shared/index.js'
import { useNav } from '../../composables/nav.js'
import Navbar from './NavBar.vue'
import NavScreen from './NavScreen.vue'
const page = usePageData<PlumeThemePageData>()
const { isScreenOpen, closeScreen, toggleScreen } = useNav()
const fixed = computed(() => {
return page.value.isBlogPost || page.value.type === 'blog'
})
provide('close-screen', closeScreen)
</script>
<template>
<div class="nav-wrapper">
<div class="nav-wrapper" :class="{ fixed }">
<Navbar :is-screen-open="isScreenOpen" @toggle-screen="toggleScreen" />
<NavScreen :open="isScreenOpen" />
</div>
@ -27,6 +35,15 @@ provide('close-screen', closeScreen)
transition: background-color 0.5s;
}
.nav-wrapper.fixed {
position: fixed;
}
.nav-wrapper.fixed :deep(.navbar-wrapper) {
border-bottom-color: var(--vp-c-gutter);
background-color: var(--vp-nav-bg-color);
}
@media (min-width: 960px) {
.nav-wrapper {
position: fixed;

View File

@ -1,13 +1,22 @@
<script lang="ts" setup>
import { useSidebar } from '../composables/index.js'
import { usePageData } from '@vuepress/client'
import type { PlumeThemePageData } from '../../shared/index.js'
import { useDarkMode, useSidebar } from '../composables/index.js'
import PageAside from './PageAside.vue'
import PageMeta from './PageMeta.vue'
const { hasSidebar, hasAside } = useSidebar()
const isDark = useDarkMode()
const page = usePageData<PlumeThemePageData>()
</script>
<template>
<div
class="plume-page"
:class="{ 'has-sidebar': hasSidebar, 'has-aside': hasAside }"
:class="{
'has-sidebar': hasSidebar,
'has-aside': hasAside,
'is-blog': page.isBlogPost,
}"
>
<div class="container">
<div v-if="hasAside" class="aside">
@ -21,7 +30,9 @@ const { hasSidebar, hasAside } = useSidebar()
<div class="content">
<div class="content-container">
<main class="main">
<PageMeta />
<Content class="plume-content" />
<PageComment :darkmode="isDark" />
</main>
</div>
</div>
@ -39,6 +50,10 @@ const { hasSidebar, hasAside } = useSidebar()
width: 100%;
}
.plume-page.is-blog {
padding-top: calc(var(--vp-nav-height) + 32px);
}
@media (min-width: 768px) {
.plume-page {
padding: 48px 32px 128px;
@ -46,7 +61,8 @@ const { hasSidebar, hasAside } = useSidebar()
}
@media (min-width: 960px) {
.plume-page {
.plume-page,
.plume-page.is-blog {
padding: 32px 32px 0;
}

View File

@ -0,0 +1,131 @@
<script lang="ts" setup>
import { usePageData, usePageFrontmatter } from '@vuepress/client'
import { computed } from 'vue'
import type {
PlumeThemePageData,
PlumeThemePostFrontmatter,
} from '../../shared/index.js'
import IconClock from './icons/IconClock.vue'
import IconTag from './icons/IconTag.vue'
const page = usePageData<PlumeThemePageData>()
const matter = usePageFrontmatter<PlumeThemePostFrontmatter>()
const createTime = computed(() => {
if (matter.value.createTime) {
return matter.value.createTime.split(' ')[0].replace(/\//g, '-')
}
return ''
})
const categoryList = computed(() => {
return page.value.categoryList ?? []
})
const tags = computed(() => {
if (matter.value.tags) {
return matter.value.tags.slice(0, 4)
}
return []
})
const hasMeta = computed(() => tags.value.length || createTime.value)
</script>
<template>
<div
v-if="page.isBlogPost && categoryList.length"
class="page-category-wrapper"
>
<template
v-for="({ type, name }, index) in categoryList"
:key="index + '-' + type"
>
<span class="category">{{ name }}</span>
<span v-if="index !== categoryList.length - 1" class="dot">&rsaquo;</span>
</template>
</div>
<h2 v-if="page.isBlogPost" class="page-title" :class="{ padding: !hasMeta }">
{{ page.title }}
</h2>
<div v-if="hasMeta" class="page-meta-wrapper">
<p v-if="tags.length > 0">
<IconTag class="icon" />
<span v-for="tag in tags" :key="tag" class="tag">
{{ tag }}
</span>
</p>
<p v-if="createTime">
<IconClock class="icon" /><span>{{ createTime }}</span>
</p>
</div>
</template>
<style scoped>
.page-category-wrapper {
font-size: 16px;
font-weight: 400;
margin-bottom: 2rem;
border-left: solid 4px var(--vp-c-brand);
padding-left: 1rem;
}
.page-category-wrapper .category {
color: var(--vp-c-text-2);
transition: color var(--t-color);
}
.page-category-wrapper .category:hover {
color: var(--vp-c-brand);
}
.page-category-wrapper .dot {
margin: 0 0.2rem;
color: var(--vp-c-text-3);
}
.page-title {
font-size: 28px;
font-weight: 600;
margin-bottom: 1rem;
}
.page-title.padding {
padding-bottom: 4rem;
}
.page-meta-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 0 0.5rem;
margin-bottom: 2rem;
color: var(--vp-c-text-3);
font-size: 14px;
border-bottom: solid 1px var(--vp-c-divider);
}
.page-meta-wrapper p {
display: flex;
align-items: center;
margin-right: 1rem;
}
.page-meta-wrapper .icon {
width: 14px;
height: 14px;
margin-right: 0.3rem;
}
.page-meta-wrapper .tag {
display: inline-block;
line-height: 1;
margin-right: 0.3rem;
padding: 3px 6px;
color: var(--vp-c-text-2);
background-color: var(--vp-c-mute);
border-radius: 4px;
}
.page-meta-wrapper .tag:last-of-type {
margin-right: 0;
}
</style>

View File

@ -0,0 +1,126 @@
<script lang="ts" setup>
import { computed } from 'vue'
import type { PlumeThemeBlogPostItem } from '../../shared/index.js'
import AutoLink from './AutoLink.vue'
import IconClock from './icons/IconClock.vue'
import IconFolder from './icons/IconFolder.vue'
import IconTag from './icons/IconTag.vue'
const props = defineProps<{
post: PlumeThemeBlogPostItem
}>()
const categoryList = computed(() => {
return props.post.categoryList ?? []
})
const tags = computed(() => {
return (props.post.tags ?? []).slice(0, 4)
})
const createTime = computed(() => {
return props.post.createTime?.split(' ')[0].replace(/\//g, '-')
})
</script>
<template>
<div class="post-item">
<h3>
<AutoLink :href="post.path">{{ post.title }}</AutoLink>
<div
v-if="typeof post.sticky === 'boolean' ? post.sticky : post.sticky >= 0"
class="sticky"
>
TOP
</div>
</h3>
<div class="post-meta">
<div v-if="categoryList.length" class="category-list">
<IconFolder class="icon" />
<template v-for="(cate, i) in categoryList" :key="i">
<span>{{ cate.name }}</span>
<span v-if="i !== categoryList.length - 1">/</span>
</template>
</div>
<div v-if="tags.length" class="tag-list">
<IconTag class="icon" />
<span v-for="tag in tags" :key="tag">{{ tag }}</span>
</div>
<div v-if="createTime" class="create-time">
<IconClock class="icon" />
<span>{{ createTime }}</span>
</div>
</div>
<!-- eslint-disable vue/no-v-html -->
<div v-if="post.excerpt" class="plume-content" v-html="post.excerpt"></div>
</div>
</template>
<style lang="scss" scoped>
.post-item {
padding-top: 1rem;
margin: 0 1.75rem 3rem;
// border-bottom: solid 1px var(--vp-c-divider);
&:last-of-type {
border-bottom: none;
}
h3 {
display: flex;
align-items: center;
font-size: 18px;
font-weight: 600;
transition: color var(--t-color);
margin-bottom: 1rem;
}
h3:hover {
color: var(--vp-c-brand);
.sticky {
color: var(--vp-c-text-2);
}
}
.sticky {
display: inline-block;
font-weight: 600;
padding: 3px 6px;
margin-left: 0.5rem;
border-radius: 4px;
line-height: 1;
font-size: 13px;
color: var(--vp-c-text-2);
background-color: var(--vp-c-bg-soft-mute);
}
}
.post-meta {
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
font-size: 14px;
font-weight: 400;
color: var(--vp-c-text-2);
margin-bottom: 1rem;
> div {
display: flex;
align-items: center;
justify-content: flex-start;
margin-right: 1rem;
&:last-of-type {
margin-right: 0;
}
}
.icon {
width: 14px;
height: 14px;
margin: 0.3rem;
color: var(--vp-c-text-3);
}
}
</style>

View File

@ -0,0 +1,38 @@
<script lang="ts" setup>
import { useBlogPostData } from '@vuepress-plume/vuepress-plugin-blog-data/client'
import { computed } from 'vue'
import type { Ref } from 'vue'
import type { PlumeThemeBlogPostItem } from '../../shared/index.js'
import PostItem from './PostItem.vue'
const list = useBlogPostData() as unknown as Ref<PlumeThemeBlogPostItem[]>
const postList = computed(() => {
const stickyList = list.value.filter((item) =>
typeof item.sticky === 'boolean' ? item.sticky : item.sticky >= 0
)
const otherList = list.value.filter(
(item) => item.sticky === undefined || item.sticky === false
)
return [
...stickyList.sort((prev, next) => {
if (next.sticky === true && prev.sticky === true) return 0
return next.sticky > prev.sticky ? 1 : -1
}),
...otherList,
]
})
</script>
<template>
<div class="post-list">
<PostItem v-for="post in postList" :key="post.path" :post="post" />
</div>
</template>
<style scoped>
.post-list {
padding-top: 2rem;
flex: 1;
}
</style>

View File

@ -0,0 +1,75 @@
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const backToTop = ref()
watch(
() => route.path,
() => backToTop.value.focus()
)
function focusOnTargetAnchor({ target }: Event) {
const el = document.querySelector<HTMLAnchorElement>(
(target as HTMLAnchorElement).hash
)
if (el) {
const removeTabIndex = () => {
el.removeAttribute('tabindex')
el.removeEventListener('blur', removeTabIndex)
}
el.setAttribute('tabindex', '-1')
el.addEventListener('blur', removeTabIndex)
el.focus()
window.scrollTo(0, 0)
}
}
</script>
<template>
<span ref="backToTop" tabindex="-1" />
<a
href="#LayoutContent"
class="skip-link visually-hidden"
@click="focusOnTargetAnchor"
>
Skip to content
</a>
</template>
<style scoped>
.skip-link {
top: 8px;
left: 8px;
padding: 8px 16px;
z-index: 999;
border-radius: 8px;
font-size: 12px;
font-weight: bold;
text-decoration: none;
color: var(--vp-c-brand);
box-shadow: var(--vp-shadow-3);
background-color: var(--vp-c-bg);
}
.skip-link:focus {
height: auto;
width: auto;
clip: auto;
clip-path: none;
}
.dark .skip-link {
color: var(--vp-c-green);
}
@media (min-width: 1280px) {
.skip-link {
top: 14px;
left: 16px;
}
}
</style>

View File

@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed } from 'vue'
import { EXTERNAL_URL_RE } from '../utils/index.js'
const props = defineProps<{
tag?: string
size?: 'medium' | 'big'
theme?: 'brand' | 'alt' | 'sponsor'
text: string
href?: string
}>()
const classes = computed(() => [props.size ?? 'medium', props.theme ?? 'brand'])
const isExternal = computed(
() => props.href && EXTERNAL_URL_RE.test(props.href)
)
const component = computed(() => {
if (props.tag) {
return props.tag
}
return props.href ? 'a' : 'button'
})
</script>
<template>
<Component
:is="component"
class="VPButton"
:class="classes"
:href="href"
:target="isExternal ? '_blank' : undefined"
:rel="isExternal ? 'noreferrer' : undefined"
>
{{ text }}
</Component>
</template>
<style scoped>
.VPButton {
display: inline-block;
border: 1px solid transparent;
text-align: center;
font-weight: 600;
white-space: nowrap;
transition: color 0.25s, border-color 0.25s, background-color 0.25s;
}
.VPButton:active {
transition: color 0.1s, border-color 0.1s, background-color 0.1s;
}
.VPButton.medium {
border-radius: 20px;
padding: 0 20px;
line-height: 38px;
font-size: 14px;
}
.VPButton.big {
border-radius: 24px;
padding: 0 24px;
line-height: 46px;
font-size: 16px;
}
.VPButton.brand {
border-color: var(--vp-button-brand-border);
color: var(--vp-button-brand-text);
background-color: var(--vp-button-brand-bg);
}
.VPButton.brand:hover {
border-color: var(--vp-button-brand-hover-border);
color: var(--vp-button-brand-hover-text);
background-color: var(--vp-button-brand-hover-bg);
}
.VPButton.brand:active {
border-color: var(--vp-button-brand-active-border);
color: var(--vp-button-brand-active-text);
background-color: var(--vp-button-brand-active-bg);
}
.VPButton.alt {
border-color: var(--vp-button-alt-border);
color: var(--vp-button-alt-text);
background-color: var(--vp-button-alt-bg);
}
.VPButton.alt:hover {
border-color: var(--vp-button-alt-hover-border);
color: var(--vp-button-alt-hover-text);
background-color: var(--vp-button-alt-hover-bg);
}
.VPButton.alt:active {
border-color: var(--vp-button-alt-active-border);
color: var(--vp-button-alt-active-text);
background-color: var(--vp-button-alt-active-bg);
}
.VPButton.sponsor {
border-color: var(--vp-button-sponsor-border);
color: var(--vp-button-sponsor-text);
background-color: var(--vp-button-sponsor-bg);
}
.VPButton.sponsor:hover {
border-color: var(--vp-button-sponsor-hover-border);
color: var(--vp-button-sponsor-hover-text);
background-color: var(--vp-button-sponsor-hover-bg);
}
.VPButton.sponsor:active {
border-color: var(--vp-button-sponsor-active-border);
color: var(--vp-button-sponsor-active-text);
background-color: var(--vp-button-sponsor-active-bg);
}
</style>

View File

@ -0,0 +1,70 @@
<script setup lang="ts">
import { useSidebar, useThemeLocaleData } from '../composables/index.js'
const theme = useThemeLocaleData()
const { hasSidebar } = useSidebar()
</script>
<template>
<!-- eslint-disable vue/no-v-html -->
<footer
v-if="theme.footer"
class="plume-footer"
:class="{ 'has-sidebar': hasSidebar }"
>
<div class="container">
<p
v-if="theme.footer.message"
class="message"
v-html="theme.footer.message"
></p>
<p
v-if="theme.footer.copyright"
class="copyright"
v-html="theme.footer.copyright"
></p>
</div>
</footer>
</template>
<style scoped>
.plume-footer {
position: relative;
z-index: var(--vp-z-index-footer);
border-top: 1px solid var(--vp-c-gutter);
padding: 32px 24px;
background-color: var(--vp-c-bg);
}
.plume-footer.has-sidebar {
display: none;
}
@media (min-width: 768px) {
.plume-footer {
padding: 32px;
}
}
.container {
margin: 0 auto;
max-width: var(--vp-layout-max-width);
text-align: center;
}
.message,
.copyright {
line-height: 24px;
font-size: 14px;
font-weight: 500;
color: var(--vp-c-text-2);
}
.message {
order: 2;
}
.copyright {
order: 1;
}
</style>

View File

@ -0,0 +1,11 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<g fill="currentColor">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
/>
</g>
</svg>
</template>

View File

@ -0,0 +1,11 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<g fill="currentColor">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
/>
</g>
</svg>
</template>

View File

@ -0,0 +1,11 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<g fill="currentColor">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z"
/>
</g>
</svg>
</template>

View File

@ -9,7 +9,7 @@ import './styles/index.scss'
export default defineClientConfig({
enhance({ app }) {
// eslint-disable-next-line vue/match-component-file-name
app.component('NavbarSearch', () => {
app.component('DocSearch', () => {
const SearchComponent =
app.component('Docsearch') || app.component('SearchBox')
if (SearchComponent) {
@ -17,6 +17,15 @@ export default defineClientConfig({
}
return null
})
// eslint-disable-next-line vue/match-component-file-name
app.component('PageComment', (props) => {
const CommentService = app.component('CommentService')
if (CommentService) {
return h(CommentService, props)
}
return null
})
},
setup() {
setupDarkMode()

View File

@ -3,15 +3,20 @@ import { usePageData } from '@vuepress/client'
import { provide, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { PlumeThemePageData } from '../../shared/index.js'
import Backdrop from '../components/Backdrop.vue'
import Blog from '../components/Blog.vue'
import Home from '../components/Home.vue'
import LayoutContent from '../components/LayoutContent.vue'
import LocalNav from '../components/LocalNav.vue'
import Nav from '../components/Nav/index.vue'
import Page from '../components/Page.vue'
import Sidebar from '../components/Sidebar.vue'
import SkipLink from '../components/SkipLink.vue'
import VFooter from '../components/VFooter.vue'
import {
useCloseSidebarOnEscape,
useScrollPromise,
useSidebar,
useThemeLocaleData,
} from '../composables/index.js'
const page = usePageData<PlumeThemePageData>()
@ -37,10 +42,16 @@ const onBeforeLeave = scrollPromise.pending
</script>
<template>
<div class="theme-plume">
<SkipLink />
<Backdrop :show="isSidebarOpen" @click="closeSidebar" />
<Nav />
<LocalNav :open="isSidebarOpen" @open-menu="openSidebar" />
<Sidebar :open="isSidebarOpen" />
<LayoutContent>
<Page />
<Home v-if="page.frontmatter.home" />
<Blog v-else-if="page.type === 'blog'" />
<Page v-else />
<VFooter />
</LayoutContent>
</div>
</template>

View File

@ -165,10 +165,10 @@ pre[class*='language-'] {
border-top-right-radius: 0;
}
}
}
.code-tabs-nav {
margin-bottom: 0rem;
}
.code-tabs-nav {
margin-bottom: 0rem;
}
div[class*='language-'] {
@ -274,4 +274,8 @@ div[class*='language-'] {
border-radius: 0;
}
}
.code-tabs-nav {
margin-bottom: -0.85rem;
}
}

View File

@ -28,6 +28,10 @@
font-size: 24px;
}
.plume-content h2:first-of-type {
border-top: none;
}
.plume-content h3 {
margin: 32px 0 0;
letter-spacing: -0.01em;
@ -68,6 +72,18 @@
}
}
.plume-content img {
display: inline-block;
}
.plume-content img + img {
margin-left: 0.5rem;
}
.plume-content a img + span {
margin-left: 0.2rem;
}
/**
* Paragraph and inline elements
* -------------------------------------------------------------------------- */

View File

@ -7,3 +7,8 @@
clip-path: inset(50%);
overflow: hidden;
}
.icon {
width: 20px;
height: 20px;
}

View File

@ -84,6 +84,10 @@
--vp-c-text-inverse-2: var(--vp-c-text-dark-2);
--vp-c-text-inverse-3: var(--vp-c-text-dark-3);
--vp-c-text-hero-name: var(--vp-c-text-dark-1);
--vp-c-text-hero-profession: var(--vp-c-text-dark-2);
--vp-c-text-hero-text: var(--vp-c-text-dark-1);
--vp-c-text-code: #476582;
--vp-c-brand: var(--vp-c-green);

View File

@ -47,6 +47,16 @@ export const setupPlugins = (
blogDataPlugin({
include: ['**/*.md'],
exclude: ['**/{README,index}.md', 'notes/**'],
sortBy: 'createTime',
excerpt: true,
extendBlogData(page: any) {
return {
categoryList: page.data.categoryList,
tags: page.frontmatter.tags,
sticky: page.frontmatter.sticky,
createTime: page.data.frontmatter.createTime,
}
},
}),
localeOptions.notes ? notesDataPlugin(localeOptions.notes) : [],
activeHeaderLinksPlugin({

View File

@ -0,0 +1,52 @@
import type { App, Page } from '@vuepress/core'
import { createPage } from '@vuepress/core'
import type {
PageCategoryData,
PlumeThemeLocaleOptions,
PlumeThemePageData,
} from '../shared/index.js'
export async function setupPage(app: App) {
const blogPage = await createPage(app, {
path: '/blog/',
})
const productPage = await createPage(app, {
path: '/product/',
})
app.pages.push(blogPage, productPage)
}
let uuid = 10000
const cache: Record<string, number> = {}
export function autoCategory(
page: Page<PlumeThemePageData>,
options: PlumeThemeLocaleOptions
) {
const pagePath = page.filePathRelative
if (page.data.type || !pagePath) return
const { notes } = options
if (notes && notes.link && page.path.startsWith(notes.link)) return
const categoryList: PageCategoryData[] = pagePath
.split('/')
.slice(0, -1)
.map((category) => {
const match = category.match(/^(\d+)?(?:\.?)([^]+)$/) || []
!cache[match[2]] && !match[1] && (cache[match[2]] = uuid++)
return {
type: Number(match[1] || cache[match[2]]),
name: match[2],
}
})
page.data.categoryList = categoryList
}
export function pageContentRendered(page: Page<PlumeThemePageData>) {
const EXCERPT_SPLIT = '<!-- more -->'
if (page.data.isBlogPost && page.contentRendered.includes(EXCERPT_SPLIT)) {
const [excerpt, content] = page.contentRendered.split(EXCERPT_SPLIT)
page.contentRendered = `<div class="excerpt">${excerpt}</div>${EXCERPT_SPLIT}${content}`
}
}

View File

@ -1,7 +1,8 @@
import type { App, Theme } from '@vuepress/core'
import type { App, Page, Theme } from '@vuepress/core'
import { fs, getDirname, path } from '@vuepress/utils'
import type { PlumeThemeOptions } from '../shared/index.js'
import type { PlumeThemeOptions, PlumeThemePageData } from '../shared/index.js'
import { setupPlugins } from './plugins.js'
import { autoCategory, pageContentRendered, setupPage } from './setupPages.js'
const __dirname = getDirname(import.meta.url)
@ -26,6 +27,19 @@ export const plumeTheme = ({
},
clientConfigFile: path.resolve(__dirname, '../client/config.js'),
plugins: setupPlugins(app, themePlugins, localeOptions),
onInitialized: async (app) => {
await setupPage(app)
},
extendsPage: (page: Page<PlumeThemePageData>) => {
if (page.path === '/blog/') {
page.data.type = 'blog'
}
if (page.path === '/product/') {
page.data.type = 'product'
}
autoCategory(page, localeOptions)
pageContentRendered(page)
},
}
}
}

View File

@ -0,0 +1,11 @@
import type { BlogPostDataItem } from '@vuepress-plume/vuepress-plugin-blog-data'
import type { PageCategoryData } from './page.js'
export interface PlumeThemeBlogPostItem extends BlogPostDataItem {
tags: string[]
sticky: boolean
categoryLost: PageCategoryData[]
createTime: string
}
export type PlumeThemeBlogPostData = PlumeThemeBlogPostItem[]

View File

@ -1,7 +1,19 @@
export interface PlumeThemeHomeFrontmatter {
home?: true
banner?: string
hero: {
name: string
profession?: string
text?: string
actions: PlumeThemeHeroAction[]
}
}
export interface PlumeThemeHeroAction {
theme?: 'brand' | 'alt'
text: string
link?: string
}
export interface PlumeThemePostFrontmatter {
createTime?: string
author?: string

View File

@ -3,3 +3,4 @@ export * from './frontmatter.js'
export * from './note.js'
export * from './options/index.js'
export * from './page.js'
export * from './blog.js'

View File

@ -40,7 +40,7 @@ export interface PlumeThemeLocaleData extends LocaleData {
/**
*
*/
home?: false | NavItemWithLink
home?: string
/**
* logo
*/
@ -110,8 +110,6 @@ export interface PlumeThemeLocaleData extends LocaleData {
*/
notes?: false | NotesDataOptions
footer?: false | { content: string; copyright: string }
/**
* language text
*/
@ -147,4 +145,11 @@ export interface PlumeThemeLocaleData extends LocaleData {
notFound?: string[]
backToHome?: string
footer?:
| false
| {
message?: string
copyright?: string
}
}

View File

@ -4,4 +4,11 @@ export type PlumeThemePageData = {
updateTime: number
}
isBlogPost: boolean
type: 'blog' | 'product'
categoryList?: PageCategoryData[]
}
export type PageCategoryData = {
type: string | number
name: string
}