Merge pull request #26 from pengzhanbo/rc-4

RC-4
This commit is contained in:
pengzhanbo 2023-12-26 13:28:06 +08:00 committed by GitHub
commit 5b881e861d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1204 additions and 277 deletions

View File

@ -7,7 +7,7 @@ import { enNotes, zhNotes } from './notes.js'
export default defineUserConfig({
base: '/',
lang: 'zh',
lang: 'zh-CN',
title: 'Plume Theme',
description: '',
source: path.resolve(__dirname, '../'),
@ -16,7 +16,7 @@ export default defineUserConfig({
'/': {
title: 'Plume主题',
description: '',
lang: 'zh',
lang: 'zh-CN',
},
'/en/': {
title: 'Plume Theme',
@ -77,6 +77,7 @@ export default defineUserConfig({
},
],
},
{ text: '友情链接', link: '/friends/', icon: 'emojione-monotone:roller-coaster' },
],
footer: {
copyright: 'Copyright © 2022-present pengzhanbo',

View File

@ -140,10 +140,14 @@ function foo() {
}
```
::: info 注释
::: note 注释
注释内容
:::
::: info 信息
信息内容
:::
::: tip 提示
提示内容
:::

42
docs/friends.md Normal file
View File

@ -0,0 +1,42 @@
---
friends: true
title: 友情链接
description: 这里是友情链接的描述文字
permalink: /friends/
list:
-
name: pengzhanbo
link: https://github.com/pengzhanbo
avatar: https://github.com/pengzhanbo.png
desc: 即使慢,驰而不息,纵会落后,纵会失败,但必须能够到达他所向的目标。
-
name: pengzhanbo
link: https://github.com/pengzhanbo
avatar: https://github.com/pengzhanbo.png
desc: 即使慢,驰而不息,纵会落后,纵会失败,但必须能够到达他所向的目标。
-
name: pengzhanbo
link: https://github.com/pengzhanbo
avatar: https://github.com/pengzhanbo.png
desc: 即使慢,驰而不息,纵会落后,纵会失败,但必须能够到达他所向的目标。
-
name: pengzhanbo
link: https://github.com/pengzhanbo
avatar: https://github.com/pengzhanbo.png
desc: 即使慢,驰而不息,纵会落后,纵会失败,但必须能够到达他所向的目标。
-
name: pengzhanbo
link: https://github.com/pengzhanbo
avatar: https://github.com/pengzhanbo.png
desc: 即使慢,驰而不息,纵会落后,纵会失败,但必须能够到达他所向的目标。
-
name: pengzhanbo
link: https://github.com/pengzhanbo
avatar: https://github.com/pengzhanbo.png
desc: 即使慢,驰而不息,纵会落后,纵会失败,但必须能够到达他所向的目标。
-
name: pengzhanbo
link: https://github.com/pengzhanbo
avatar: https://github.com/pengzhanbo.png
desc: 即使慢,驰而不息,纵会落后,纵会失败,但必须能够到达他所向的目标。
---

View File

@ -0,0 +1,61 @@
<script lang="ts" setup>
import { useArchives, useBlogExtract } from '../composables/index.js'
import IconArchive from './icons/IconArchive.vue'
import ShortPostList from './ShortPostList.vue';
const { archives: archivesLink } = useBlogExtract()
const { archives } = useArchives()
</script>
<template>
<div class="archives-wrapper">
<h2 class="archives-title">
<IconArchive class="icon" />
<span>{{ archivesLink.text }}</span>
</h2>
<div v-if="archives.length" class="archives">
<template v-for="archive in archives" :key="archive.label">
<div class="archive">
<h3 class="archive-title">{{ archive.label }}</h3>
<ShortPostList :post-list="archive.list" />
</div>
</template>
</div>
</div>
</template>
<style scoped>
.archives-wrapper {
padding: 32px 24px;
flex: 1;
}
.archives-title {
display: flex;
align-items: center;
font-size: 24px;
font-weight: 700;
color: var(--vp-c-brand-1);
margin-bottom: 40px;
}
.archives-title .icon {
width: 1em;
height: 1em;
margin-right: 8px;
}
.archive {
padding-bottom: 1rem;
border-bottom: 1px dashed var(--vp-c-divider);
}
.archive:last-of-type {
border-bottom: none;
}
.archive-title {
font-size: 18px;
font-weight: 700;
margin-top: 2rem;
}
</style>

View File

@ -1,11 +1,20 @@
<script lang="ts" setup>
import BlogAvatar from './BlogAvatar.vue'
import { usePageData } from '@vuepress/client'
import type { PlumeThemePageData } from '../../shared/index.js'
import Archives from './Archives.vue'
import BlogAside from './BlogAside.vue'
import PostList from './PostList.vue'
import Tags from './Tags.vue'
const page = usePageData<PlumeThemePageData>()
</script>
<template>
<div class="blog-wrapper">
<PostList />
<BlogAvatar />
<PostList v-if="page.type === 'blog'" />
<Tags v-if="page.type === 'blog-tags'" />
<Archives v-if="page.type === 'blog-archives'" />
<BlogAside />
</div>
</template>

View File

@ -0,0 +1,96 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useBlogExtract, useThemeLocaleData } from '../composables/index.js'
import AutoLink from './AutoLink.vue'
import IconArchive from './icons/IconArchive.vue'
import IconTag from './icons/IconTag.vue'
const theme = useThemeLocaleData()
const avatar = computed(() => theme.value.avatar)
const { hasBlogExtract, tags, archives } = useBlogExtract()
</script>
<template>
<div v-if="avatar" class="blog-aside-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 v-if="hasBlogExtract" class="blog-nav">
<AutoLink class="nav-link" :href="tags.link">
<IconTag class="icon" />
<span>{{ tags.text }}</span>
</AutoLink>
<AutoLink class="nav-link" :href="archives.link">
<IconArchive class="icon" />
<span>{{ archives.text }}</span>
</AutoLink>
</div>
</div>
</template>
<style lang="scss" scoped>
.blog-aside-wrapper {
position: sticky;
top: calc(var(--vp-nav-height) + 2rem);
width: 270px;
margin-left: 2rem;
margin-top: 2rem;
margin-bottom: 12rem;
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-aside-wrapper {
display: none;
}
}
.blog-nav {
padding: 10px 24px 0;
margin: 24px 24px 0;
display: grid;
gap: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-top: solid 1px var(--vp-c-divider);
}
.nav-link {
display: flex;
align-items: center;
padding: 3px;
color: var(--vp-c-brand-1);
font-weight: 600;
border-radius: 8px;
transition: all var(--t-color);
}
.nav-link:hover {
color: var(--vp-c-brand-2);
}
.nav-link .icon {
margin-right: 4px;
width: 1em;
height: 1em;
}
</style>

View File

@ -1,52 +0,0 @@
<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

@ -0,0 +1,123 @@
<script lang="ts" setup>
import { usePageFrontmatter } from '@vuepress/client'
import { computed } from 'vue'
import type { PlumeThemeFriendsFrontmatter } from '../../shared/index.js'
import { useEditNavLink } from '../composables/index.js'
import AutoLink from './AutoLink.vue'
import FriendsItem from './FriendsItem.vue'
import IconEdit from './icons/IconEdit.vue'
const matter = usePageFrontmatter<PlumeThemeFriendsFrontmatter>()
const editNavLink = useEditNavLink()
const list = computed(() => matter.value.list || [])
</script>
<template>
<div class="friends-wrapper">
<h2 class="title">{{ matter.title || 'My Friends' }}</h2>
<p v-if="matter.description" class="description">{{ matter.description }}</p>
<section v-if="list.length" class="friends-list">
<FriendsItem v-for="(friend, index) in list" :key="friend.name + index" :friend="friend" />
</section>
<div v-if="editNavLink" class="edit-link">
<AutoLink class="edit-link-button" :href="editNavLink.link" :no-icon="true">
<IconEdit class="edit-link-icon" aria-label="edit icon"/>
{{ editNavLink.text }}
</AutoLink>
</div>
</div>
</template>
<style scoped>
.friends-wrapper {
width: 100%;
margin: 0 auto;
padding-top: var(--vp-nav-height);
padding-bottom: 5rem;
}
.friends-wrapper .title {
font-size: 24px;
font-weight: 700;
color: var(--vp-c-brand-1);
padding-left: 1rem;
padding-top: 3rem;
margin-bottom: 1rem;
outline: none;
}
.friends-wrapper .description {
color: var(--vp-c-text-1);
padding-left: 1rem;
margin-bottom: 16px;
line-height: 28px;
}
.friends-list {
display: grid;
gap: 16px;
margin-top: 64px;
padding: 0 16px;
}
.edit-link {
margin-top: 64px;
padding-left: 1rem;
}
@media (min-width: 640px) {
.friends-wrapper .title,
.friends-wrapper .description,
.edit-link {
padding-left: 0;
}
.friends-list {
padding: 0 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 960px) {
.friends-wrapper {
max-width: 784px;
padding-top: 0;
}
.friends-list {
padding: 0;
}
}
@media (min-width: 1440px) {
.friends-wrapper {
max-width: 1104px;
}
.friends-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.edit-link-button {
display: flex;
align-items: center;
border: 0;
line-height: 32px;
font-size: 14px;
font-weight: 500;
color: var(--vp-c-brand-1);
transition: color 0.25s;
}
.edit-link-button:hover {
color: var(--vp-c-brand-2);
}
.edit-link-icon {
margin-right: 8px;
width: 14px;
height: 14px;
fill: currentColor;
}
</style>

View File

@ -0,0 +1,78 @@
<script lang="ts" setup>
import type {FriendsItem} from '../../shared/index';
import AutoLink from './AutoLink.vue'
defineProps<{
friend: FriendsItem
}>()
</script>
<template>
<div class="friend">
<AutoLink class="avatar-link" :href="friend.link" no-icon>
<div class="avatar" :style="{ backgroundImage: `url(${friend.avatar})` }"></div>
</AutoLink>
<div class="content">
<AutoLink class="title" :href="friend.link" no-icon>{{ friend.name }}</AutoLink>
<p v-if="friend.desc">{{ friend.desc }}</p>
</div>
</div>
</template>
<style scoped>
.friend {
display: flex;
align-items: flex-start;
padding: 16px;
border-radius: 6px;
border: 1px solid var(--vp-friends-border-color);
margin-bottom: 8px;
transition: all 0.25s;
background-color: var(--vp-friends-bg-color);
box-shadow: var(--vp-shadow-1);
}
.friend:hover {
box-shadow: var(--vp-shadow-3);
}
.avatar-link {
display: inline-block;
margin-right: 16px;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 100%;
background-size: cover;
background-color: var(--vp-c-default-soft);
}
.content {
flex: 1;
}
.content .title {
display: block;
font-weight: 700;
font-size: 18px;
color: var(--vp-friends-link-color);
padding-left: 16px;
padding-bottom: 8px;
margin-left: -16px;
margin-bottom: 8px;
border-bottom: 1px dashed var(--vp-friends-border-color);
}
.content p {
font-size: 0.875rem;
line-height: 1.5;
padding-top: 8px;
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
color: var(--vp-friends-text-color);
}
</style>

View File

@ -44,12 +44,18 @@ const classes = computed(() => {
}
})
const showLocalNav = computed(() => {
return (hasSidebar.value || page.value.isBlogPost) && (!empty.value || y.value >= navHeight.value)
})
</script>
<template>
<div v-if="hasSidebar && (!empty || y >= navHeight)" :class="classes">
<div v-if="showLocalNav" :class="classes">
<button
class="menu"
:class="{ 'hidden': page.isBlogPost }"
:disabled="page.isBlogPost"
:aria-expanded="open"
aria-controls="SidebarNav"
@click="$emit('open-menu')"
@ -105,6 +111,10 @@ const classes = computed(() => {
transition: color 0.5s;
}
.menu.hidden {
visibility: hidden;
}
.menu:hover {
color: var(--vp-c-text-1);
transition: color 0.25s;

View File

@ -35,7 +35,7 @@ const isLocked = useScrollLock(inBrowser ? document.body : null)
<style scoped>
.nav-screen {
position: fixed;
top: calc(var(--vp-nav-height) + var(--vp-layout-top-height, 0px) + 1px);
top: calc(var(--vp-nav-height) + var(--vp-layout-top-height, 0px));
/*rtl:ignore*/
right: 0;
bottom: 0;
@ -43,6 +43,7 @@ const isLocked = useScrollLock(inBrowser ? document.body : null)
left: 0;
padding: 0 32px;
width: 100%;
border-top: 1px solid var(--vp-c-divider);
background-color: var(--vp-nav-screen-bg-color);
overflow-y: auto;
transition: background-color 0.5s;

View File

@ -10,8 +10,10 @@ const page = usePageData<PlumeThemePageData>()
const { isScreenOpen, closeScreen, toggleScreen } = useNav()
const fixedInclude = ['blog', 'friends']
const fixed = computed(() => {
return page.value.isBlogPost || page.value.frontmatter.type === 'blog'
return fixedInclude.includes(page.value.frontmatter.type as string)
})
provide('close-screen', closeScreen)

View File

@ -2,6 +2,7 @@
import { usePageData } from '@vuepress/client'
import { computed, ref } from 'vue'
import { useActiveAnchor, useThemeLocaleData } from '../composables/index.js'
import IconPrint from './icons/IconPrint.vue'
import PageAsideItem from './PageAsideItem.vue'
const page = usePageData()
@ -33,7 +34,10 @@ function handleClick({ target: el }: Event) {
<div class="content">
<div ref="marker" class="outline-marker" />
<div class="outline-title">{{ theme.outlineLabel || 'On this page' }}</div>
<div class="outline-title">
<span>{{ theme.outlineLabel || 'On this page' }}</span>
<IconPrint class="icon" />
</div>
<nav aria-labelledby="doc-outline-aria-label">
<span id="doc-outline-aria-label" class="visually-hidden">
@ -87,9 +91,17 @@ function handleClick({ target: el }: Event) {
}
.outline-title {
display: flex;
align-items: center;
letter-spacing: 0.4px;
line-height: 28px;
font-size: 13px;
font-weight: 600;
}
.outline-title .icon {
margin-left: 4px;
width: 1em;
height: 1em;
font-size: 1.2em;
}
</style>

View File

@ -32,7 +32,7 @@ const tags = computed(() => {
return []
})
const hasMeta = computed(() => tags.value.length || createTime.value)
const hasMeta = computed(() => readingTime.value.times || tags.value.length || createTime.value)
</script>
<template>
<div

View File

@ -1,35 +1,30 @@
<script lang="ts" setup>
import { usePageLang } from '@vuepress/client'
import { useBlogPostData } from '@vuepress-plume/plugin-blog-data/client'
import { computed } from 'vue'
import type { Ref } from 'vue'
import type { PlumeThemeBlogPostItem } from '../../shared/index.js'
import { usePostListControl } from '../composables/index.js'
import PostItem from './PostItem.vue'
const locale = usePageLang()
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,
].filter((item) => item.lang === locale.value)
})
const {
pagination,
postList,
page,
totalPage,
isLastPage,
isFirstPage,
isPaginationEnabled,
changePage,
} = usePostListControl()
</script>
<template>
<div class="post-list">
<PostItem v-for="post in postList" :key="post.path" :post="post" />
<div v-if="isPaginationEnabled" class="pagination">
<button type="button" class="btn prev" :disabled="isFirstPage" @click="changePage(-1)">
{{ pagination?.prevPageText || 'Prev' }}
</button>
<span class="page-info">{{ page }} / {{ totalPage }}</span>
<button type="button" class="btn next" :disabled="isLastPage" @click="changePage(1)">
{{ pagination?.nextPageText || 'Next' }}
</button>
</div>
</div>
</template>
@ -38,4 +33,34 @@ const postList = computed(() => {
padding-top: 2rem;
flex: 1;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2rem 1.25rem 4rem;
}
.btn {
color: var(--vp-c-brand-1);
font-weight: 500;
border: 1px solid var(--vp-c-brand-1);
padding: 0 4px;
border-radius: 4px;
transition: all var(--t-color);
}
.btn:hover {
color: var(--vp-c-brand-2);
border-color: var(--vp-c-brand-2);
}
.btn[disabled] {
color: var(--vp-c-gray-1);
border-color: var(--vp-c-divider);
}
.page-info {
color: var(--vp-c-brand-2);
font-weight: 500;
}
</style>

View File

@ -0,0 +1,59 @@
<script lang="ts" setup>
import AutoLink from './AutoLink.vue'
defineProps<{
postList: {
title: string
path: string
createTime: string
}[]
}>()
</script>
<template>
<div class="post-list">
<p v-for="post in postList" :key="post.path">
<AutoLink class="post-title" :href="post.path">{{ post.title }}</AutoLink>
<span class="post-time">{{ post.createTime }}</span>
</p>
</div>
</template>
<style scoped>
.post-list {
margin-top: 32px;
padding: 0 12px;
}
.post-list p {
display: flex;
align-items: center;
justify-content: space-between;
margin: 14px 0;
color: var(--vp-c-text-1);
}
.post-list .post-title {
flex: 1;
margin-right: 20px;
font-weight: 600;
transition: all var(--t-color);
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
line-clamp: 1;
}
.post-list .post-time {
color: var(--vp-c-text-3);
transition: all var(--t-color);
}
.post-list p:hover .post-title {
color: var(--vp-c-brand-1);
}
.post-list p:hover .post-time {
color: var(--vp-c-text-2);
}
</style>

View File

@ -0,0 +1,82 @@
<script lang="ts" setup>
import { useBlogExtract, useTags } from '../composables/index.js'
import IconTag from './icons/IconTag.vue'
import ShortPostList from './ShortPostList.vue'
const { tags, currentTag, postList, handleTagClick } = useTags()
const { tags: tagsLink } = useBlogExtract()
</script>
<template>
<div class="tags-wrapper">
<h2 class="tags-title">
<IconTag class="icon" />
<span>{{ tagsLink.text }}</span>
</h2>
<div class="tags">
<p
v-for="tag in tags"
:key="tag.name"
class="tag"
:class="{ active: tag.name === currentTag }"
@click="handleTagClick(tag.name)"
>
<span class="tag-name">{{ tag.name }}</span>
<span class="tag-count">({{ tag.count }})</span>
</p>
</div>
<ShortPostList v-if="postList.length" :post-list="postList" />
</div>
</template>
<style scoped >
.tags-wrapper {
padding: 32px 24px;
flex: 1;
}
.tags-title {
display: flex;
align-items: center;
font-size: 24px;
font-weight: 700;
color: var(--vp-c-brand-1);
margin-bottom: 40px;
}
.tags-title .icon {
width: 1em;
height: 1em;
margin-right: 8px;
}
.tags {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
align-items: center;
}
.tags .tag {
display: inline-block;
word-wrap: break-word;
margin: 8px;
padding: 2px 10px;
background-color: var(--vp-c-default-soft);
color: var(--vp-c-text-3);
border-radius: 4px;
cursor: pointer;
transition: all var(--t-color);
}
.tags .tag:hover,
.tags .tag.active {
background-color: var(--vp-c-brand-1);
color: var(--vp-c-bg);
}
.tag-name {
font-weight: 600;
}
.tag-count {
margin-left: 4px;
}
</style>

View File

@ -0,0 +1,3 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><path fill="currentColor" d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v176a16 16 0 0 0 16 16h36a4 4 0 0 0 4-4v-20h-7.73a8.17 8.17 0 0 1-8.27-7.47a8 8 0 0 1 8-8.53h8v-16h-7.73a8.17 8.17 0 0 1-8.27-7.47a8 8 0 0 1 8-8.53h8v-16h-7.73a8.17 8.17 0 0 1-8.27-7.47a8 8 0 0 1 8-8.53h8v-7.73a8.18 8.18 0 0 1 7.47-8.25a8 8 0 0 1 8.53 8v8h7.73a8.17 8.17 0 0 1 8.25 7.47a8 8 0 0 1-8 8.53h-8v16h7.73a8.17 8.17 0 0 1 8.25 7.47a8 8 0 0 1-8 8.53h-8v16h7.73a8.17 8.17 0 0 1 8.25 7.47a8 8 0 0 1-8 8.53h-8v20a4 4 0 0 0 4 4h84a16 16 0 0 0 16-16V88a8 8 0 0 0-2.28-5.66M152 88V44l44 44Z"/></svg>
</template>

View File

@ -0,0 +1,3 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path 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"/></svg>
</template>

View File

@ -0,0 +1,189 @@
import { usePageLang } from '@vuepress/client'
import { useBlogPostData } from '@vuepress-plume/plugin-blog-data/client'
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import type { PlumeThemeBlogPostItem } from '../../shared/index.js'
import { useLocaleLink, useThemeLocaleData } from '../composables/index.js'
import { toArray } from '../utils/index.js'
export const usePostListControl = () => {
const locale = usePageLang()
const themeData = useThemeLocaleData()
const list = useBlogPostData() as unknown as Ref<PlumeThemeBlogPostItem[]>
const blog = computed(() => themeData.value.blog || {})
const pagination = computed(() => blog.value.pagination || {})
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,
].filter((item) => item.lang === locale.value)
})
const page = ref(1)
const totalPage = computed(() => {
if (blog.value.pagination === false) return 0
const perPage = blog.value.pagination?.perPage || 20
return Math.ceil(postList.value.length / perPage)
})
const isLastPage = computed(() => page.value >= totalPage.value)
const isFirstPage = computed(() => page.value <= 1)
const isPaginationEnabled = computed(() => blog.value.pagination !== false && totalPage.value > 1)
const finalList = computed(() => {
if (blog.value.pagination === false) return postList.value
const perPage = blog.value.pagination?.perPage || 20
if (postList.value.length <= perPage) return postList.value
return postList.value.slice(
(page.value - 1) * perPage,
page.value * perPage
)
})
const changePage = (offset: number) => {
page.value += offset
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
}
return {
pagination,
postList: finalList,
page,
totalPage,
isLastPage,
isFirstPage,
isPaginationEnabled,
changePage,
}
}
const extractLocales: Record<string, { tags: string; archives: string }> = {
'zh-CN': { tags: '标签', archives: '归档' },
en: { tags: 'Tags', archives: 'Archives' },
'zh-TW': { tags: '標籤', archives: '歸檔' },
}
export const useBlogExtract = () => {
const theme = useThemeLocaleData()
const locale = usePageLang()
const hasBlogExtract = computed(() => theme.value.blog?.archives !== false || theme.value.blog?.tags !== false)
const tagsLink = useLocaleLink('blog/tags/')
const archiveLink = useLocaleLink('blog/archives/')
const tags = computed(() => ({
link: tagsLink.value,
text: extractLocales[locale.value]?.tags || extractLocales.en.tags,
}))
const archives = computed(() => ({
link: archiveLink.value,
text: extractLocales[locale.value]?.archives || extractLocales.en.archives,
}))
return {
hasBlogExtract,
tags,
archives,
}
}
export type ShortPostItem = Pick<PlumeThemeBlogPostItem, 'title' | 'path' | 'createTime'>
export const useTags = () => {
const locale = usePageLang()
const list = useBlogPostData() as unknown as Ref<PlumeThemeBlogPostItem[]>
const filteredList = computed(() =>
list.value.filter((item) => item.lang === locale.value)
)
const tags = computed(() => {
const tagMap: Record<string, number> = {}
filteredList.value.forEach((item) => {
if (item.tags) {
toArray(item.tags).forEach((tag) => {
if (tagMap[tag]) {
tagMap[tag] += 1
} else {
tagMap[tag] = 1
}
})
}
})
return Object.keys(tagMap).map((tag) => ({
name: tag,
count: tagMap[tag],
}))
})
const postList = ref<ShortPostItem[]>([])
const currentTag = ref<string>()
const handleTagClick = (tag: string) => {
currentTag.value = tag
postList.value = filteredList.value.filter((item) => {
if (item.tags) {
return toArray(item.tags).includes(tag)
}
return false
}).map((item) => ({
title: item.title,
path: item.path,
createTime: item.createTime.split(' ')[0],
}))
}
return {
tags,
currentTag,
postList,
handleTagClick
}
}
export const useArchives = () => {
const locale = usePageLang()
const list = useBlogPostData() as unknown as Ref<PlumeThemeBlogPostItem[]>
const filteredList = computed(() =>
list.value.filter((item) => item.lang === locale.value)
)
const archives = computed(() => {
const archives: { label: string, list: ShortPostItem[] }[] = []
filteredList.value.forEach(item => {
const createTime = item.createTime.split(' ')[0]
const year = createTime.split('/')[0]
let current = archives.find(archive => archive.label === year)
if (!current) {
current = { label: year, list: [] }
archives.push(current)
}
current.list.push({
title: item.title,
path: item.path,
createTime: createTime.slice(year.length + 1),
})
})
return archives
})
return {
archives
}
}

View File

@ -6,3 +6,5 @@ export * from './sidebar.js'
export * from './aside.js'
export * from './page.js'
export * from './readingTime.js'
export * from './blog.js'
export * from './locale.js'

View File

@ -0,0 +1,23 @@
import { usePageLang, useSiteData } from '@vuepress/client'
import { computed } from 'vue'
import { normalizeLink } from '../utils'
export const useLocaleLink = (link: string) => {
const site = useSiteData()
const locale = usePageLang()
const links = computed(() => {
const locales = site.value.locales
const links: Record<string, string> = {}
Object.keys(locales).forEach((key) => {
const locale = locales[key]
locale.lang && (links[locale.lang] = key)
})
return links
})
return computed(() => {
const prefix = links.value[locale.value] || '/'
return normalizeLink(prefix + link)
})
}

View File

@ -14,13 +14,13 @@ export const readingTimeLocales = {
time: "About $time min",
},
"zh": {
"zh-CN": {
word: "约 $word 字",
less1Minute: "小于 1 分钟",
time: "大约 $time 分钟",
},
"zh-tw": {
"zh-TW": {
word: "約 $word 字",
less1Minute: "小於 1 分鐘",
time: "大约 $time 分鐘",
@ -132,7 +132,7 @@ export const readingTimeLocales = {
export const useReadingTime = () => {
const page = usePageData<PlumeThemePageData>()
return computed(() => {
return computed<{ times: string; words: string }>(() => {
if (!page.value.readingTime) return { times: '', words: '' }
const locale = readingTimeLocales[page.value.lang] ?? readingTimeLocales.en

View File

@ -1,10 +1,11 @@
<script setup lang="ts">
import { usePageData } from '@vuepress/client'
import { provide, watch } from 'vue'
import { computed, 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 Friends from '../components/Friends.vue'
import Home from '../components/Home.vue'
import LayoutContent from '../components/LayoutContent.vue'
import LocalNav from '../components/LocalNav.vue'
@ -15,7 +16,6 @@ import SkipLink from '../components/SkipLink.vue'
import VFooter from '../components/VFooter.vue'
import {
useCloseSidebarOnEscape,
// useScrollPromise,
useSidebar,
} from '../composables/index.js'
@ -30,15 +30,18 @@ const {
const route = useRoute()
watch(() => route.path, closeSidebar)
const isBlogLayout = computed(() => {
return (
page.value.type === 'blog' ||
page.value.type === 'blog-archives' ||
page.value.type === 'blog-tags'
)
})
useCloseSidebarOnEscape(isSidebarOpen, closeSidebar)
provide('close-sidebar', closeSidebar)
provide('is-sidebar-open', isSidebarOpen)
// handle scrollBehavior with transition
// const scrollPromise = useScrollPromise()
// const onBeforeEnter = scrollPromise.resolve
// const onBeforeLeave = scrollPromise.pending
</script>
<template>
<div class="theme-plume">
@ -49,7 +52,8 @@ provide('is-sidebar-open', isSidebarOpen)
<Sidebar :open="isSidebarOpen" />
<LayoutContent>
<Home v-if="page.frontmatter.home" />
<Blog v-else-if="page.frontmatter.type === 'blog'" />
<Friends v-else-if="page.frontmatter.friends" />
<Blog v-else-if="isBlogLayout" />
<Page v-else />
<VFooter />
</LayoutContent>

View File

@ -1,90 +1,151 @@
.plume-content .hint-container {
border-radius: 8px;
padding: 16px 16px 8px;
line-height: 24px;
font-size: var(--vp-custom-block-font-size);
color: var(--vp-c-text-2);
}
.plume-content .hint-container .hint-container-title {
font-weight: 600;
margin-top: 0;
}
.hint-container.note {
color: var(--vp-custom-block-info-text);
}
.hint-container.tip {
color: var(--vp-custom-block-tip-text);
}
.hint-container.warning {
color: var(--vp-custom-block-warning-text);
}
.hint-container.caution {
color: var(--vp-custom-block-danger-text);
}
.hint-container.detail {
color: var(--vp-custom-block-detail-text);
}
.plume-content .hint-container.details summary {
margin: -1.5rem -1.5rem -1.1rem;
font-weight: 700;
cursor: pointer;
color: var(--vp-c-text-1);
}
.plume-content .hint-container.details summary + p {
margin: 8px 0;
}
.plume-content .hint-container p + p {
margin: 8px 0;
}
.plume-content .hint-container code {
font-size: var(--vp-custom-block-code-font-size);
}
.plume-content .hint-container {
&.note,
&.tip,
&.detail,
&.important {
a,
code {
color: var(--vp-c-brand-1);
}
a:hover {
color: var(--vp-c-brand-2);
}
}
&.warning {
a,
code {
color: var(--vp-c-warning-1);
}
a:hover {
color: var(--vp-c-warning-2);
}
}
&.caution {
a,
code {
color: var(--vp-c-danger-1);
}
a:hover {
color: var(--vp-c-danger-2);
}
}
th,
blockquote > p {
.plume-content {
.hint-container {
border-radius: 8px;
padding: 16px 16px 8px;
line-height: 24px;
font-size: var(--vp-custom-block-font-size);
color: inherit;
color: var(--vp-c-text-2);
.hint-container-title {
font-weight: 600;
margin-top: 0;
}
&.note {
border-radius: 0;
color: var(--vp-c-text-3);
}
&.info {
color: var(--vp-custom-block-info-text);
}
&.tip {
color: var(--vp-custom-block-tip-text);
}
&.warning {
color: var(--vp-custom-block-warning-text);
}
&.caution {
color: var(--vp-custom-block-danger-text);
}
&.detail {
color: var(--vp-custom-block-detail-text);
summary {
margin: -1.5rem -1.5rem -1.1rem;
font-weight: 700;
cursor: pointer;
color: var(--vp-c-text-1);
}
summary + p {
margin: 8px 0;
}
}
p + p {
margin: 8px 0;
}
code {
font-size: var(--vp-custom-block-code-font-size);
}
&.note,
&.tip,
&.detail,
&.important {
a,
code {
color: var(--vp-c-brand-1);
}
a:hover {
color: var(--vp-c-brand-2);
}
}
&.warning {
a,
code {
color: var(--vp-c-warning-1);
}
a:hover {
color: var(--vp-c-warning-2);
}
}
&.caution {
a,
code {
color: var(--vp-c-danger-1);
}
a:hover {
color: var(--vp-c-danger-2);
}
}
th,
blockquote > p {
font-size: var(--vp-custom-block-font-size);
color: inherit;
}
}
.vp-code-demo {
border: solid 1px var(--vp-c-divider);
overflow: hidden;
&:hover {
box-shadow: none;
}
.vp-code-demo-header {
padding: 8px 12px;
}
.vp-code-demo-code-wrapper {
margin-bottom: -0.9rem;
}
.vp-code-demo-toggle-button {
margin: 0 12px 0 8px;
background-color: var(--vp-c-gray-2);
&:hover {
background-color: var(--vp-c-gray-1);
}
}
.vp-code-demo-title {
font-size: 1rem;
line-height: 1.75;
}
.vp-code-demo-display {
border-bottom: transparent;
}
.code-demo-jsfiddle .jsfiddle-button,
.code-demo-codepen .codepen-button {
background-color: transparent;
}
.vp-code-demo-codes div[class*='language-'] {
border-bottom: 2px dashed var(--vp-c-divider);
&:first-of-type {
border-top: 1px solid var(--vp-c-divider);
}
&:last-of-type {
border-bottom: none;
}
}
.vp-code-demo-codes div[class*='language-'] pre {
margin-bottom: 0;
border-radius: 0;
}
}
}

View File

@ -69,6 +69,8 @@
--vp-c-red-3: #e0575b;
--vp-c-red-soft: rgba(244, 63, 94, 0.14);
--vp-c-purple: #f4eefe;
--vp-c-sponsor: #db2777;
}
@ -93,6 +95,8 @@
--vp-c-yellow-3: #a46a0a;
--vp-c-yellow-soft: rgba(234, 179, 8, 0.16);
--vp-c-purple: #423655;
--vp-c-red-1: #f66f81;
--vp-c-red-2: #f14158;
--vp-c-red-3: #b62a3c;
@ -297,7 +301,7 @@
--vp-code-bg: var(--vp-c-default-soft);
--vp-code-block-color: var(--vp-c-text-2);
--vp-code-block-bg: var(--vp-c-bg-alt);
--vp-code-block-bg: var(--vp-c-bg-soft);
--vp-code-block-divider-color: var(--vp-c-gutter);
--vp-code-lang-color: var(--vp-c-text-3);
@ -476,6 +480,15 @@
--vp-c-text-hero-text: var(--vp-c-text-dark-1);
}
/**
* Component: Friends
* -------------------------------------------------------------------------- */
:root {
--vp-friends-text-color: var(--vp-c-text-1);
--vp-friends-bg-color: var(--vp-c-bg);
--vp-friends-link-color: var(--vp-c-brand-1);
--vp-friends-border-color: var(--vp-c-border);
}
/**
* Component: Badge
* -------------------------------------------------------------------------- */
@ -549,12 +562,14 @@
}
/* md enhance hints */
:root {
// important
:root,
html.dark {
/* important */
--important-title-color: var(--vp-c-text-1);
--important-bg-color: #f4eefe;
--important-border-color: #f4eefe;
--important-bg-color: var(--vp-c-purple);
--important-border-color: var(--vp-c-purple);
--important-code-bg-color: rgb(163 113 247 / 10%);
// info
--info-title-color: var(--vp-c-text-1);
--info-bg-color: var(--vp-custom-block-info-bg);
@ -563,7 +578,7 @@
// note
--note-title-color: var(--vp-c-text-3);
--note-bg-color: var(--vp-c-bg-elv);
--note-bg-color: var(--vp-c-bg);
--note-border-color: var(--vp-c-divider);
--note-code-bg-color: var(--vp-c-default-soft);
@ -591,6 +606,11 @@
--detail-code-bg-color: var(--vp-custom-block-details-code-bg);
}
/* md enhance code-demo */
:root {
--code-demo-header-bg-color: var(--vp-c-bg-soft);
}
:root {
--t-color: 250ms ease;
--code-bg-color: var(--vp-code-block-bg);

View File

@ -0,0 +1,3 @@
export const toArray = <T>(value: T | T[]): T[] => {
return Array.isArray(value) ? value : [value]
}

View File

@ -4,3 +4,4 @@ export * from './socialIcons.js'
export * from './dom.js'
export * from './resolveEditLink.js'
export * from './resolveRepoType.js'
export * from './base.js'

View File

@ -50,12 +50,14 @@ export default function autoFrontmatter(
.filter(Boolean)
const baseFrontmatter: FrontmatterObject = {
author(author: string) {
author(author: string, _, data: any) {
if (author) return author
if (data.friends) return
return localeOption.avatar?.name || pkg.author || ''
},
createTime(formatTime: string, { createTime }) {
createTime(formatTime: string, { createTime }, data: any) {
if (formatTime) return formatTime
if (data.friends) return
return format(new Date(createTime), 'yyyy/MM/dd HH:mm:ss')
},
}
@ -107,8 +109,9 @@ export default function autoFrontmatter(
return getCurrentDirname(note, filepath) || ''
},
...baseFrontmatter,
permalink(permalink: string, { filepath }) {
permalink(permalink: string, { filepath }, data: any) {
if (permalink) return permalink
if (data.friends) return
const locale = resolveLocale(filepath)
const notes = notesByLocale(locale)
const note = findNote(filepath)
@ -136,8 +139,9 @@ export default function autoFrontmatter(
return basename
},
...baseFrontmatter,
permalink(permalink: string, { filepath }) {
permalink(permalink: string, { filepath }, data: any) {
if (permalink) return permalink
if (data.friends) return
const locale = resolveLocale(filepath)
const note = findNote(filepath)
const notes = notesByLocale(locale)

View File

@ -7,6 +7,10 @@ import type {
PlumeThemePageData,
} from '../shared/index.js'
const normalizePath = (dir: string) => {
return dir.replace(/\\+/g, '/')
}
export async function setupPage(
app: App,
localeOption: PlumeThemeLocaleOptions
@ -14,20 +18,68 @@ export async function setupPage(
const locales = Object.keys(app.siteData.locales || {})
for (const [, locale] of locales.entries()) {
const blog = localeOption.locales?.[locale]?.blog
const defaultBlog = localeOption.blog
const link = blog?.link
? blog.link
: normalizePath(path.join('/', locale, defaultBlog?.link || ''))
const blogPage = await createPage(app, {
path: blog?.link
? blog.link
: path.join('/', locale, localeOption.blog?.link || ''),
path: link,
frontmatter: {
lang: locale.replace(/^\/|\/$/g, '') || app.siteData.lang,
type: 'blog',
},
})
app.pages.push(blogPage)
if (blog?.tags !== false || defaultBlog?.tags !== false) {
const tagsPage = await createPage(app, {
path: normalizePath(path.join(link, 'tags/')),
frontmatter: {
lang: locale.replace(/^\/|\/$/g, '') || app.siteData.lang,
type: 'blog-tags',
},
})
app.pages.push(tagsPage)
}
if (blog?.archives !== false || defaultBlog?.archives !== false) {
const archivesPage = await createPage(app, {
path: normalizePath(path.join(link, 'archives/')),
frontmatter: {
lang: locale.replace(/^\/|\/$/g, '') || app.siteData.lang,
type: 'blog-archives',
},
})
app.pages.push(archivesPage)
}
}
}
export function extendsPageData(
app: App,
page: Page<PlumeThemePageData>,
localeOptions: PlumeThemeLocaleOptions
) {
page.data.filePathRelative = page.filePathRelative
page.routeMeta.title = page.title
if (page.frontmatter.friends) {
page.frontmatter.article = false
page.frontmatter.type = 'friends'
page.data.isBlogPost = false
page.permalink = page.permalink ?? '/friends/'
}
if ((page.frontmatter.type as string)?.startsWith('blog')) {
page.data.isBlogPost = false
page.frontmatter.article = false
page.data.type = page.frontmatter.type as any
}
autoCategory(app, page, localeOptions)
pageContentRendered(page)
}
let uuid = 10000
const cache: Record<string, number> = {}
const RE_CATEGORY = /^(\d+)?(?:\.?)([^]+)$/

View File

@ -1,12 +1,12 @@
import type { App, Page, Theme } from '@vuepress/core'
import { fs, getDirname, path } from '@vuepress/utils'
import { getDirname, path } from '@vuepress/utils'
import type { PlumeThemeOptions, PlumeThemePageData } from '../shared/index.js'
import { mergeLocaleOptions } from './defaultOptions.js'
import { setupPlugins } from './plugins.js'
import { autoCategory, pageContentRendered, setupPage } from './setupPages.js'
import { extendsPageData, setupPage } from './setupPages.js'
const __dirname = getDirname(import.meta.url)
const name = '@vuepress-plume/theme-plume'
const name = 'vuepress-theme-plume'
const resolve = (...args: string[]) => path.resolve(__dirname, '../', ...args)
const templates = (url: string) => resolve('../templates', url)
@ -20,25 +20,11 @@ export const plumeTheme = ({
name,
templateBuild: templates('build.html'),
clientConfigFile: resolve('client/config.js'),
alias: {
...Object.fromEntries(
fs
.readdirSync(resolve('client/components'))
.filter((file) => file.endsWith('.vue'))
.map((file) => [
`@theme/${file}`,
resolve('client/components', file),
])
),
},
plugins: setupPlugins(app, themePlugins, localeOptions),
onInitialized: async (app) => await setupPage(app, localeOptions),
extendsPage: (page: Page<PlumeThemePageData>) => {
page.data.filePathRelative = page.filePathRelative
page.routeMeta.title = page.title
autoCategory(app, page, localeOptions)
pageContentRendered(page)
},
extendsPage: (page: Page<PlumeThemePageData>) =>
extendsPageData(app, page, localeOptions)
,
}
}
}

View File

@ -39,3 +39,17 @@ export interface PlumeThemePostFrontmatter extends PlumeThemePageFrontmatter {
export interface PlumeThemeNoteFrontmatter extends PlumeThemePageFrontmatter {
createTime?: string
}
export interface FriendsItem {
name: string
link: string
avatar?: string
desc?: string
}
export interface PlumeThemeFriendsFrontmatter {
friends: boolean
title?: string
description?: string
list?: FriendsItem[]
}

View File

@ -1,8 +1,6 @@
import type { LocaleData } from '@vuepress/core'
import type { NotesDataOptions } from '@vuepress-plume/plugin-notes-data'
import type { NavItem } from './navbar.js'
// import type { NavbarConfig, NavLink } from '../layout/index.js'
// import type { PlumeThemeNotesOptions } from './notes.js'
export interface PlumeThemeAvatar {
/**
@ -39,6 +37,67 @@ export type SocialLinkIcon =
| 'bilibili'
| { svg: string }
export interface PlumeThemeBlog {
/**
* blog
*
* @default './' vuepress source
*/
dir?: string
/**
* blog list link
*
* @default '/blog/'
*/
link?: string
/**
* `blog.dir` glob string
*
* @default - ['**\*.md']
*/
include?: string[]
/**
* `blog.dir` glob string
*
* _README.md blog文章_
*
* @default - ['.vuepress/', 'node_modules/', '{README,index}.md']
*/
exclude?: string[]
pagination?: false | {
/**
*
* @default 20
*/
perPage?: number
/**
*
* @default 'Prev'
*/
prevPageText?: string
/**
*
* @default 'Next'
*/
nextPageText?: string
}
/**
*
* @default true
*/
tags?: boolean
/**
*
* @default true
*/
archives?: boolean
}
export interface PlumeThemeLocaleData extends LocaleData {
/**
*
@ -76,37 +135,10 @@ export interface PlumeThemeLocaleData extends LocaleData {
*/
social?: SocialLink[]
blog?: {
/**
* blog
*
* @default './' vuepress source
*/
dir?: string
/**
* blog list link
*
* @default '/blog/'
*/
link?: string
/**
* `blog.dir` glob string
*
* @default - ['**\*.md']
*/
include?: string[]
/**
* `blog.dir` glob string
*
* _README.md blog文章_
*
* @default - ['.vuepress/', 'node_modules/', '{README,index}.md']
*/
exclude?: string[]
}
/**
*
*/
blog?: PlumeThemeBlog
/**
*
@ -115,29 +147,6 @@ export interface PlumeThemeLocaleData extends LocaleData {
*/
article?: string
/**
* navbar配置
*
* @def{ text: '标签', link: '/tag/' }
*/
// tag?: false | NavItemWithLink
/**
* navbar配置
*
* @default { text: '分类', link: '/category/ }
*/
// category?: false | NavItemWithLink
/**
* navbar
*
* ( timeline timeline )
*
* @default { text: '归档', link: '/timeline/' }
*/
// archive?: false | NavItemWithLink
/**
*
*

View File

@ -9,7 +9,7 @@ interface ReadingTime {
export interface PlumeThemePageData extends GitPluginPageData {
isBlogPost: boolean
type: 'blog' | 'product'
type: 'blog' | 'friends' | 'blog-tags' | 'blog-archives'
categoryList?: PageCategoryData[]
filePathRelative: string | null
readingTime?: ReadingTime