diff --git a/theme/src/client/components/Page.vue b/theme/src/client/components/Page.vue index c480a120..e057046a 100644 --- a/theme/src/client/components/Page.vue +++ b/theme/src/client/components/Page.vue @@ -3,6 +3,7 @@ import { usePageData } from '@vuepress/client' import type { PlumeThemePageData } from '../../shared/index.js' import { useDarkMode, useSidebar } from '../composables/index.js' import PageAside from './PageAside.vue' +import PageFooter from './PageFooter.vue' import PageMeta from './PageMeta.vue' const { hasSidebar, hasAside } = useSidebar() @@ -32,6 +33,7 @@ const page = usePageData()
+
diff --git a/theme/src/client/components/PageFooter.vue b/theme/src/client/components/PageFooter.vue new file mode 100644 index 00000000..d0bd76bf --- /dev/null +++ b/theme/src/client/components/PageFooter.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/theme/src/client/composables/index.ts b/theme/src/client/composables/index.ts index 124f8dbe..ae7aa372 100644 --- a/theme/src/client/composables/index.ts +++ b/theme/src/client/composables/index.ts @@ -4,3 +4,4 @@ export * from './themeData.js' export * from './useResolveRouteWithRedirect.js' export * from './sidebar.js' export * from './aside.js' +export * from './page.js' diff --git a/theme/src/client/composables/page.ts b/theme/src/client/composables/page.ts new file mode 100644 index 00000000..f6fb6ecb --- /dev/null +++ b/theme/src/client/composables/page.ts @@ -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 => { + const themeLocale = useThemeLocaleData() + const page = usePageData() + const frontmatter = usePageFrontmatter() + + 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 => { + const themeLocale = useThemeLocaleData() + const page = usePageData() + const frontmatter = usePageFrontmatter() + + 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['contributors'] +> => { + const themeLocale = useThemeLocaleData() + const page = usePageData() + const frontmatter = usePageFrontmatter() + + 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(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() + const frontmatter = usePageFrontmatter() + const { sidebar } = useSidebar() + const postList = useBlogPostData() as unknown as Ref + 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, + } +} diff --git a/theme/src/client/composables/useResolveRouteWithRedirect.ts b/theme/src/client/composables/useResolveRouteWithRedirect.ts index 297b0e95..8dc3c25a 100644 --- a/theme/src/client/composables/useResolveRouteWithRedirect.ts +++ b/theme/src/client/composables/useResolveRouteWithRedirect.ts @@ -1,6 +1,13 @@ import { isFunction, isString } from '@vuepress/shared' import { useRouter } 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 @@ -26,3 +33,21 @@ export const useResolveRouteWithRedirect = ( ...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, + } +} diff --git a/theme/src/client/utils/index.ts b/theme/src/client/utils/index.ts index 7d319f6e..3961eb76 100644 --- a/theme/src/client/utils/index.ts +++ b/theme/src/client/utils/index.ts @@ -2,3 +2,5 @@ export * from './shared.js' export * from './normalizeLink.js' export * from './socialIcons.js' export * from './dom.js' +export * from './resolveEditLink.js' +export * from './resolveRepoType.js' diff --git a/theme/src/client/utils/resolveEditLink.ts b/theme/src/client/utils/resolveEditLink.ts new file mode 100644 index 00000000..7694786e --- /dev/null +++ b/theme/src/client/utils/resolveEditLink.ts @@ -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, 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}`), + ) +} diff --git a/theme/src/client/utils/resolveRepoType.ts b/theme/src/client/utils/resolveRepoType.ts new file mode 100644 index 00000000..c63b5d22 --- /dev/null +++ b/theme/src/client/utils/resolveRepoType.ts @@ -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 +} diff --git a/theme/src/node/plugins.ts b/theme/src/node/plugins.ts index 08a6efb8..63233d6d 100644 --- a/theme/src/node/plugins.ts +++ b/theme/src/node/plugins.ts @@ -97,9 +97,9 @@ export const setupPlugins = ( options.git !== false ? gitPlugin({ - createdTime: true, - updatedTime: true, - contributors: false, + createdTime: false, + updatedTime: localeOptions.lastUpdated !== false, + contributors: localeOptions.contributors !== false, }) : [], @@ -141,7 +141,10 @@ export const setupPlugins = ( ? 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 ? copyCodePlugin({ @@ -154,7 +157,7 @@ export const setupPlugins = ( ? mdEnhancePlugin( Object.assign( { - container: true, // info note tip warning danger details + hint: true, // info note tip warning danger details d codetabs: true, tabs: true, align: true, diff --git a/theme/src/node/theme.ts b/theme/src/node/theme.ts index 86e09aee..8f5f0391 100644 --- a/theme/src/node/theme.ts +++ b/theme/src/node/theme.ts @@ -34,6 +34,8 @@ export const plumeTheme = ({ plugins: setupPlugins(app, themePlugins, localeOptions), onInitialized: async (app) => await setupPage(app, localeOptions), extendsPage: (page: Page) => { + page.data.filePathRelative = page.filePathRelative + page.routeMeta.title = page.title autoCategory(app, page, localeOptions) pageContentRendered(page) }, diff --git a/theme/src/shared/frontmatter.ts b/theme/src/shared/frontmatter.ts index f39a80f0..8ed637c8 100644 --- a/theme/src/shared/frontmatter.ts +++ b/theme/src/shared/frontmatter.ts @@ -1,3 +1,5 @@ +import type { NavItemWithLink } from "."; + export interface PlumeThemeHomeFrontmatter { home?: true banner?: string @@ -15,7 +17,17 @@ export interface PlumeThemeHeroAction { text: 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 author?: string tags?: string[] @@ -24,6 +36,6 @@ export interface PlumeThemePostFrontmatter { banner?: string } -export interface PlumeThemeNoteFrontmatter { +export interface PlumeThemeNoteFrontmatter extends PlumeThemePageFrontmatter { createTime?: string } diff --git a/theme/src/shared/options/locale.ts b/theme/src/shared/options/locale.ts index 2e148b33..cb2261a1 100644 --- a/theme/src/shared/options/locale.ts +++ b/theme/src/shared/options/locale.ts @@ -160,11 +160,11 @@ export interface PlumeThemeLocaleData extends LocaleData { /** * repository of navbar */ - // repo?: null | string + repo?: null | string /** * repository text of navbar */ - // repoLabel?: string + repoLabel?: string /** * Navbar config @@ -172,6 +172,76 @@ export interface PlumeThemeLocaleData extends LocaleData { * Set to `false` to disable navbar in current locale */ 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 + prevPageLabel?: string + + nextPageLabel?: string + footer?: | false | { diff --git a/theme/src/shared/page.ts b/theme/src/shared/page.ts index 0af684f0..90e4c1b3 100644 --- a/theme/src/shared/page.ts +++ b/theme/src/shared/page.ts @@ -1,11 +1,10 @@ -export interface PlumeThemePageData { - git: { - createTime: number - updateTime: number - } +import type { GitPluginPageData } from '@vuepress/plugin-git' + +export interface PlumeThemePageData extends GitPluginPageData { isBlogPost: boolean type: 'blog' | 'product' categoryList?: PageCategoryData[] + filePathRelative: string | null } export interface PageCategoryData {