feat: add PageFooter

This commit is contained in:
pengzhanbo 2023-12-24 02:22:35 +08:00
parent b9ffac184a
commit 918fba3e5e
13 changed files with 614 additions and 14 deletions

View File

@ -3,6 +3,7 @@ import { usePageData } from '@vuepress/client'
import type { PlumeThemePageData } from '../../shared/index.js' import type { PlumeThemePageData } from '../../shared/index.js'
import { useDarkMode, useSidebar } from '../composables/index.js' import { useDarkMode, useSidebar } from '../composables/index.js'
import PageAside from './PageAside.vue' import PageAside from './PageAside.vue'
import PageFooter from './PageFooter.vue'
import PageMeta from './PageMeta.vue' import PageMeta from './PageMeta.vue'
const { hasSidebar, hasAside } = useSidebar() const { hasSidebar, hasAside } = useSidebar()
@ -32,6 +33,7 @@ const page = usePageData<PlumeThemePageData>()
<main class="main"> <main class="main">
<PageMeta /> <PageMeta />
<Content class="plume-content" /> <Content class="plume-content" />
<PageFooter />
<PageComment :darkmode="isDark" /> <PageComment :darkmode="isDark" />
</main> </main>
</div> </div>

View File

@ -0,0 +1,190 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useContributors, useEditNavLink, useLastUpdated, usePageNav, useThemeLocaleData } from '../composables/index.js'
import AutoLink from './AutoLink.vue'
import IconEdit from './icons/IconEdit.vue'
const themeLocale = useThemeLocaleData()
const editNavLink = useEditNavLink()
const lastUpdated = useLastUpdated()
const contributors = useContributors()
const { prev, next } = usePageNav()
const showFooter = computed(() => {
return editNavLink.value || lastUpdated.value || contributors.value || prev.value || next.value
})
</script>
<template>
<footer v-if="showFooter" class="page-footer">
<div v-if="editNavLink || lastUpdated" class="edit-info">
<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 v-if="lastUpdated" class="last-updated">
<p class="last-updated-text">
{{ themeLocale.lastUpdatedText || 'Last updated' }}:
<time :datetime="lastUpdated" class="last-updated-time">{{ lastUpdated }}</time>
</p>
</div>
</div>
<div v-if="contributors && contributors.length" class="contributors">
<span class="contributors-label">{{ themeLocale.contributorsText || 'Contributors' }}:</span>
<span class="contributors-info">
<template v-for="(contributor, index) in contributors" :key="contributor">
<span class="contributor" :title="`email: ${contributor.email}`">
{{ contributor.name }}
</span>
<template v-if="index !== contributors.length - 1">, </template>
</template>
</span>
</div>
<nav v-if="prev?.link || next?.link" class="prev-next">
<div class="pager">
<AutoLink v-if="prev?.link" class="pager-link prev" :href="prev.link">
<!--eslint-disable-next-line vue/no-v-html-->
<span class="desc" v-html="themeLocale.prevPageLabel || 'Previous page'"></span>
<!--eslint-disable-next-line vue/no-v-html-->
<span class="title" v-html="prev.text"></span>
</AutoLink>
</div>
<div class="pager">
<AutoLink v-if="next?.link" class="pager-link next" :href="next.link">
<!--eslint-disable-next-line vue/no-v-html-->
<span class="desc" v-html="themeLocale.nextPageLabel || 'Next page'"></span>
<!--eslint-disable-next-line vue/no-v-html-->
<span class="title" v-html="next.text"></span>
</AutoLink>
</div>
</nav>
</footer>
</template>
<style lang="scss" scoped>
.page-footer {
margin-top: 96px;
}
@media (min-width: 640px) {
.edit-info {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 6px;
}
}
.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;
}
.last-updated-text {
line-height: 24px;
font-size: 14px;
font-weight: 500;
color: var(--vp-c-text-2);
}
@media (min-width: 640px) {
.last-updated-text {
line-height: 32px;
font-size: 14px;
font-weight: 500;
}
}
.contributors {
padding-bottom: 6px;
line-height: 32px;
font-size: 14px;
}
.contributors-label {
padding-right: 10px;
font-weight: 500;
color: var(--vp-c-text-2);
}
.contributors-info {
color: var(--vp-c-text-2);
.contributor {
color: var(--vp-c-brand-2);
}
}
.prev-next {
border-top: 1px solid var(--vp-c-divider);
margin-top: 10px;
padding-top: 24px;
display: grid;
grid-row-gap: 8px;
}
@media (min-width: 640px) {
.prev-next {
grid-template-columns: repeat(2, 1fr);
grid-column-gap: 16px;
}
}
.pager-link {
display: block;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 11px 16px 13px;
width: 100%;
height: 100%;
transition: border-color 0.25s;
}
.pager-link:hover {
border-color: var(--vp-c-brand-1);
}
.pager-link.next {
margin-left: auto;
text-align: right;
}
.desc {
display: block;
line-height: 20px;
font-size: 12px;
font-weight: 500;
color: var(--vp-c-text-2);
}
.title {
display: block;
line-height: 20px;
font-size: 14px;
font-weight: 500;
color: var(--vp-c-brand-1);
transition: color 0.25s;
}
</style>

View File

@ -4,3 +4,4 @@ export * from './themeData.js'
export * from './useResolveRouteWithRedirect.js' export * from './useResolveRouteWithRedirect.js'
export * from './sidebar.js' export * from './sidebar.js'
export * from './aside.js' export * from './aside.js'
export * from './page.js'

View File

@ -0,0 +1,215 @@
import { usePageData, usePageFrontmatter, usePageLang } from '@vuepress/client'
import { isArray, isPlainObject, isString } from '@vuepress/shared'
import { useBlogPostData } from '@vuepress-plume/plugin-blog-data/client'
import type { NotesSidebarItem } from '@vuepress-plume/plugin-notes-data'
import { computed } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import { useRoute } from 'vue-router'
import type {
NavItemWithLink,
PlumeThemeBlogPostItem,
PlumeThemePageData,
PlumeThemePageFrontmatter,
} from '../../shared/index.js'
import { useNavLink, useSidebar, useThemeLocaleData } from '../composables/index.js'
import { resolveEditLink } from '../utils/index.js'
export const useEditNavLink = (): ComputedRef<null | NavItemWithLink> => {
const themeLocale = useThemeLocaleData()
const page = usePageData<PlumeThemePageData>()
const frontmatter = usePageFrontmatter<PlumeThemePageFrontmatter>()
return computed(() => {
const showEditLink =
frontmatter.value.editLink ?? themeLocale.value.editLink ?? true
if (!showEditLink) {
return null
}
const {
repo,
docsRepo = repo,
docsBranch = 'main',
docsDir = '',
editLinkText,
} = themeLocale.value
if (!docsRepo) return null
const editLink = resolveEditLink({
docsRepo,
docsBranch,
docsDir,
filePathRelative: page.value.filePathRelative,
editLinkPattern:
frontmatter.value.editLinkPattern ?? themeLocale.value.editLinkPattern,
})
if (!editLink) return null
return {
text: editLinkText ?? 'Edit this page',
link: editLink,
}
})
}
export const useLastUpdated = (): ComputedRef<null | string> => {
const themeLocale = useThemeLocaleData()
const page = usePageData<PlumeThemePageData>()
const frontmatter = usePageFrontmatter<PlumeThemePageFrontmatter>()
return computed(() => {
const showLastUpdated =
frontmatter.value.lastUpdated ?? themeLocale.value.lastUpdated ?? true
if (!showLastUpdated) return null
if (!page.value.git?.updatedTime) return null
const updatedDate = new Date(page.value.git?.updatedTime)
return updatedDate.toLocaleString()
})
}
export const useContributors = (): ComputedRef<
null | Required<PlumeThemePageData['git']>['contributors']
> => {
const themeLocale = useThemeLocaleData()
const page = usePageData<PlumeThemePageData>()
const frontmatter = usePageFrontmatter<PlumeThemePageFrontmatter>()
return computed(() => {
const showContributors =
frontmatter.value.contributors ?? themeLocale.value.contributors ?? true
if (!showContributors) return null
return page.value.git?.contributors ?? null
})
}
/**
* Resolve `prev` or `next` config from frontmatter
*/
const resolveFromFrontmatterConfig = (
conf: unknown,
): null | false | NavItemWithLink => {
if (conf === false) {
return null
}
if (isString(conf)) {
return useNavLink(conf)
}
if (isPlainObject<NavItemWithLink>(conf)) {
return conf
}
return false
}
const flatSidebar = (
sidebar: NotesSidebarItem[],
res: NavItemWithLink[] = []
): NavItemWithLink[] => {
for (const item of sidebar) {
if (item.link) {
res.push({ link: item.link, text: item.text || item.dir || '' })
}
if (isArray(item.items) && item.items.length) {
flatSidebar(item.items as NotesSidebarItem[], res)
}
}
return res
}
/**
* Resolve `prev` or `next` config from sidebar items
*/
const resolveFromSidebarItems = (
sidebarItems: NavItemWithLink[],
currentPath: string,
offset: number,
): null | NavItemWithLink => {
const index = sidebarItems.findIndex((item) => item.link === currentPath)
if (index !== -1) {
const targetItem = sidebarItems[index + offset]
if (targetItem?.link) {
return {
link: targetItem.link,
text: targetItem.text,
}
}
}
return null
}
const resolveFromBlogPostData = (
postList: PlumeThemeBlogPostItem[],
currentPath: string,
offset: number,
): null | NavItemWithLink => {
const index = postList.findIndex((item) => item.path === currentPath)
if (index !== -1) {
const targetItem = postList[index + offset]
if (!targetItem?.path) {
return null
}
return {
link: targetItem.path,
text: targetItem.title,
}
}
return null
}
export const usePageNav = () => {
const route = useRoute()
const page = usePageData<PlumeThemePageData>()
const frontmatter = usePageFrontmatter<PlumeThemePageFrontmatter>()
const { sidebar } = useSidebar()
const postList = useBlogPostData() as unknown as Ref<PlumeThemeBlogPostItem[]>
const locale = usePageLang()
const prevNavList = computed(() => {
const prevConfig = resolveFromFrontmatterConfig(frontmatter.value.prev)
if (prevConfig !== false) {
return prevConfig
}
if (page.value.isBlogPost) {
return resolveFromBlogPostData(
postList.value.filter(item => item.lang === locale.value),
route.path,
-1
)
} else {
return resolveFromSidebarItems(flatSidebar(sidebar.value), route.path, -1)
}
})
const nextNavList = computed(() => {
const nextConfig = resolveFromFrontmatterConfig(frontmatter.value.next)
if (nextConfig !== false) {
return nextConfig
}
if (page.value.isBlogPost) {
return resolveFromBlogPostData(
postList.value.filter(item => item.lang === locale.value),
route.path,
1
)
} else {
return resolveFromSidebarItems(flatSidebar(sidebar.value), route.path, 1)
}
})
return {
prev: prevNavList,
next: nextNavList,
}
}

View File

@ -1,6 +1,13 @@
import { isFunction, isString } from '@vuepress/shared' import { isFunction, isString } from '@vuepress/shared'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import type { Router } from 'vue-router' import type { Router } from 'vue-router'
import type { NavItemWithLink } from '../../shared/index.js'
declare module 'vue-router' {
interface RouteMeta {
title?: string
}
}
/** /**
* Resolve a route with redirection * Resolve a route with redirection
@ -26,3 +33,21 @@ export const useResolveRouteWithRedirect = (
...resolvedRedirectObj, ...resolvedRedirectObj,
}) })
} }
/**
* Resolve NavLink props from string
*
* @example
* - Input: '/README.md'
* - Output: { text: 'Home', link: '/' }
*/
export const useNavLink = (item: string): NavItemWithLink => {
// the route path of vue-router is url-encoded, and we expect users are using
// non-url-encoded string in theme config, so we need to url-encode it first to
// resolve the route correctly
const resolved = useResolveRouteWithRedirect(encodeURI(item))
return {
text: resolved.meta.title || item,
link: resolved.name === '404' ? item : resolved.fullPath,
}
}

View File

@ -2,3 +2,5 @@ export * from './shared.js'
export * from './normalizeLink.js' export * from './normalizeLink.js'
export * from './socialIcons.js' export * from './socialIcons.js'
export * from './dom.js' export * from './dom.js'
export * from './resolveEditLink.js'
export * from './resolveRepoType.js'

View File

@ -0,0 +1,64 @@
import {
isLinkHttp,
removeEndingSlash,
removeLeadingSlash,
} from '@vuepress/shared'
import { resolveRepoType } from './resolveRepoType.js'
import type { RepoType } from './resolveRepoType.js'
export const editLinkPatterns: Record<Exclude<RepoType, null>, string> = {
GitHub: ':repo/edit/:branch/:path',
GitLab: ':repo/-/edit/:branch/:path',
Gitee: ':repo/edit/:branch/:path',
Bitbucket:
':repo/src/:branch/:path?mode=edit&spa=0&at=:branch&fileviewer=file-view-default',
}
const resolveEditLinkPatterns = ({
docsRepo,
editLinkPattern,
}: {
docsRepo: string
editLinkPattern?: string
}): string | null => {
if (editLinkPattern) {
return editLinkPattern
}
const repoType = resolveRepoType(docsRepo)
if (repoType !== null) {
return editLinkPatterns[repoType]
}
return null
}
export const resolveEditLink = ({
docsRepo,
docsBranch,
docsDir,
filePathRelative,
editLinkPattern,
}: {
docsRepo: string
docsBranch: string
docsDir: string
filePathRelative: string | null
editLinkPattern?: string
}): string | null => {
if (!filePathRelative) return null
const pattern = resolveEditLinkPatterns({ docsRepo, editLinkPattern })
if (!pattern) return null
return pattern
.replace(
/:repo/,
isLinkHttp(docsRepo) ? docsRepo : `https://github.com/${docsRepo}`,
)
.replace(/:branch/, docsBranch)
.replace(
/:path/,
removeLeadingSlash(`${removeEndingSlash(docsDir)}/${filePathRelative}`),
)
}

View File

@ -0,0 +1,11 @@
import { isLinkHttp } from '@vuepress/shared'
export type RepoType = 'GitHub' | 'GitLab' | 'Gitee' | 'Bitbucket' | null
export const resolveRepoType = (repo: string): RepoType => {
if (!isLinkHttp(repo) || /github\.com/.test(repo)) return 'GitHub'
if (/bitbucket\.org/.test(repo)) return 'Bitbucket'
if (/gitlab\.com/.test(repo)) return 'GitLab'
if (/gitee\.com/.test(repo)) return 'Gitee'
return null
}

View File

@ -97,9 +97,9 @@ export const setupPlugins = (
options.git !== false options.git !== false
? gitPlugin({ ? gitPlugin({
createdTime: true, createdTime: false,
updatedTime: true, updatedTime: localeOptions.lastUpdated !== false,
contributors: false, contributors: localeOptions.contributors !== false,
}) })
: [], : [],
@ -141,7 +141,10 @@ export const setupPlugins = (
? docsearchPlugin(options.docsearch!) ? docsearchPlugin(options.docsearch!)
: [], : [],
options.shikiji !== false ? shikijiPlugin(options.shikiji) : [], options.shikiji !== false ? shikijiPlugin({
theme: { light: 'vitesse-light', dark: 'vitesse-dark' },
...(options.shikiji ?? {}),
}) : [],
options.copyCode !== false options.copyCode !== false
? copyCodePlugin({ ? copyCodePlugin({
@ -154,7 +157,7 @@ export const setupPlugins = (
? mdEnhancePlugin( ? mdEnhancePlugin(
Object.assign( Object.assign(
{ {
container: true, // info note tip warning danger details hint: true, // info note tip warning danger details d
codetabs: true, codetabs: true,
tabs: true, tabs: true,
align: true, align: true,

View File

@ -34,6 +34,8 @@ export const plumeTheme = ({
plugins: setupPlugins(app, themePlugins, localeOptions), plugins: setupPlugins(app, themePlugins, localeOptions),
onInitialized: async (app) => await setupPage(app, localeOptions), onInitialized: async (app) => await setupPage(app, localeOptions),
extendsPage: (page: Page<PlumeThemePageData>) => { extendsPage: (page: Page<PlumeThemePageData>) => {
page.data.filePathRelative = page.filePathRelative
page.routeMeta.title = page.title
autoCategory(app, page, localeOptions) autoCategory(app, page, localeOptions)
pageContentRendered(page) pageContentRendered(page)
}, },

View File

@ -1,3 +1,5 @@
import type { NavItemWithLink } from ".";
export interface PlumeThemeHomeFrontmatter { export interface PlumeThemeHomeFrontmatter {
home?: true home?: true
banner?: string banner?: string
@ -15,7 +17,17 @@ export interface PlumeThemeHeroAction {
text: string text: string
link?: string link?: string
} }
export interface PlumeThemePostFrontmatter {
export interface PlumeThemePageFrontmatter {
editLink?: boolean
editLinkPattern?: string
lastUpdated?: boolean
contributors?: boolean
prev?: string | NavItemWithLink
next?: string | NavItemWithLink
}
export interface PlumeThemePostFrontmatter extends PlumeThemePageFrontmatter {
createTime?: string createTime?: string
author?: string author?: string
tags?: string[] tags?: string[]
@ -24,6 +36,6 @@ export interface PlumeThemePostFrontmatter {
banner?: string banner?: string
} }
export interface PlumeThemeNoteFrontmatter { export interface PlumeThemeNoteFrontmatter extends PlumeThemePageFrontmatter {
createTime?: string createTime?: string
} }

View File

@ -160,11 +160,11 @@ export interface PlumeThemeLocaleData extends LocaleData {
/** /**
* repository of navbar * repository of navbar
*/ */
// repo?: null | string repo?: null | string
/** /**
* repository text of navbar * repository text of navbar
*/ */
// repoLabel?: string repoLabel?: string
/** /**
* Navbar config * Navbar config
@ -172,6 +172,76 @@ export interface PlumeThemeLocaleData extends LocaleData {
* Set to `false` to disable navbar in current locale * Set to `false` to disable navbar in current locale
*/ */
navbar?: false | NavItem[] navbar?: false | NavItem[]
/**
* Page meta - edit link config
*
* Whether to show "Edit this page" or not
*/
editLink?: boolean
/**
* Page meta - edit link config
*
* The text to replace the default "Edit this page"
*/
editLinkText?: string
/**
* Page meta - edit link config
*
* Pattern of edit link
*
* @example ':repo/edit/:branch/:path'
*/
editLinkPattern?: string
/**
* Page meta - edit link config
*
* Use `repo` config by default
*
* Set this config if your docs is placed in a different repo
*/
docsRepo?: string
/**
* Page meta - edit link config
*
* Set this config if the branch of your docs is not 'main'
*/
docsBranch?: string
/**
* Page meta - edit link config
*
* Set this config if your docs is placed in sub dir of your `docsRepo`
*/
docsDir?: string
/**
* Page meta - last updated config
*
* Whether to show "Last Updated" or not
*/
lastUpdated?: boolean
/**
* Page meta - last updated config
*
* The text to replace the default "Last Updated"
*/
lastUpdatedText?: string
/**
* Page meta - contributors config
*
* Whether to show "Contributors" or not
*/
contributors?: boolean
/**
* Page meta - contributors config
*
* The text to replace the default "Contributors"
*/
contributorsText?: string
/** /**
* *
*/ */
@ -191,6 +261,10 @@ export interface PlumeThemeLocaleData extends LocaleData {
outlineLabel?: string outlineLabel?: string
prevPageLabel?: string
nextPageLabel?: string
footer?: footer?:
| false | false
| { | {

View File

@ -1,11 +1,10 @@
export interface PlumeThemePageData { import type { GitPluginPageData } from '@vuepress/plugin-git'
git: {
createTime: number export interface PlumeThemePageData extends GitPluginPageData {
updateTime: number
}
isBlogPost: boolean isBlogPost: boolean
type: 'blog' | 'product' type: 'blog' | 'product'
categoryList?: PageCategoryData[] categoryList?: PageCategoryData[]
filePathRelative: string | null
} }
export interface PageCategoryData { export interface PageCategoryData {