mirror of
https://github.com/pengzhanbo/vuepress-theme-plume.git
synced 2026-04-23 10:58:13 +08:00
feat: 添加 标签页、归档页 支持
This commit is contained in:
parent
25d703b906
commit
3ab460b01b
61
theme/src/client/components/Archives.vue
Normal file
61
theme/src/client/components/Archives.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
|
||||
96
theme/src/client/components/BlogAside.vue
Normal file
96
theme/src/client/components/BlogAside.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
59
theme/src/client/components/ShortPostList.vue
Normal file
59
theme/src/client/components/ShortPostList.vue
Normal 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>
|
||||
82
theme/src/client/components/Tags.vue
Normal file
82
theme/src/client/components/Tags.vue
Normal 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>
|
||||
3
theme/src/client/components/icons/IconArchive.vue
Normal file
3
theme/src/client/components/icons/IconArchive.vue
Normal 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>
|
||||
189
theme/src/client/composables/blog.ts
Normal file
189
theme/src/client/composables/blog.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
|
||||
23
theme/src/client/composables/locale.ts
Normal file
23
theme/src/client/composables/locale.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
3
theme/src/client/utils/base.ts
Normal file
3
theme/src/client/utils/base.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const toArray = <T>(value: T | T[]): T[] => {
|
||||
return Array.isArray(value) ? value : [value]
|
||||
}
|
||||
@ -4,3 +4,4 @@ export * from './socialIcons.js'
|
||||
export * from './dom.js'
|
||||
export * from './resolveEditLink.js'
|
||||
export * from './resolveRepoType.js'
|
||||
export * from './base.js'
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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+)?(?:\.?)([^]+)$/
|
||||
|
||||
@ -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)
|
||||
,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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[]
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
/**
|
||||
* 笔记配置, 笔记中的文章默认不会出现在首页文章列表
|
||||
*
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user