diff --git a/theme/src/client/components/Archives.vue b/theme/src/client/components/Archives.vue new file mode 100644 index 00000000..c7f9f1d9 --- /dev/null +++ b/theme/src/client/components/Archives.vue @@ -0,0 +1,61 @@ + + + + + + {{ archivesLink.text }} + + + + + {{ archive.label }} + + + + + + + + diff --git a/theme/src/client/components/Blog.vue b/theme/src/client/components/Blog.vue index fd6f311e..7432ed94 100644 --- a/theme/src/client/components/Blog.vue +++ b/theme/src/client/components/Blog.vue @@ -1,11 +1,20 @@ - - + + + + diff --git a/theme/src/client/components/BlogAside.vue b/theme/src/client/components/BlogAside.vue new file mode 100644 index 00000000..c977a251 --- /dev/null +++ b/theme/src/client/components/BlogAside.vue @@ -0,0 +1,96 @@ + + + + + + + + + + {{ avatar.name }} + {{ avatar.description }} + + + + + + {{ tags.text }} + + + + {{ archives.text }} + + + + + + diff --git a/theme/src/client/components/BlogAvatar.vue b/theme/src/client/components/BlogAvatar.vue deleted file mode 100644 index 1ba61122..00000000 --- a/theme/src/client/components/BlogAvatar.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - {{ avatar.name }} - {{ avatar.description }} - - - - - - diff --git a/theme/src/client/components/Nav/NavScreen.vue b/theme/src/client/components/Nav/NavScreen.vue index 0cb255f5..86cf10dc 100644 --- a/theme/src/client/components/Nav/NavScreen.vue +++ b/theme/src/client/components/Nav/NavScreen.vue @@ -35,7 +35,7 @@ const isLocked = useScrollLock(inBrowser ? document.body : null) diff --git a/theme/src/client/components/ShortPostList.vue b/theme/src/client/components/ShortPostList.vue new file mode 100644 index 00000000..4e5f48f7 --- /dev/null +++ b/theme/src/client/components/ShortPostList.vue @@ -0,0 +1,59 @@ + + + + + + {{ post.title }} + {{ post.createTime }} + + + + + diff --git a/theme/src/client/components/Tags.vue b/theme/src/client/components/Tags.vue new file mode 100644 index 00000000..42d9ee69 --- /dev/null +++ b/theme/src/client/components/Tags.vue @@ -0,0 +1,82 @@ + + + + + + + {{ tagsLink.text }} + + + + {{ tag.name }} + ({{ tag.count }}) + + + + + + + + diff --git a/theme/src/client/components/icons/IconArchive.vue b/theme/src/client/components/icons/IconArchive.vue new file mode 100644 index 00000000..12405033 --- /dev/null +++ b/theme/src/client/components/icons/IconArchive.vue @@ -0,0 +1,3 @@ + + + diff --git a/theme/src/client/composables/blog.ts b/theme/src/client/composables/blog.ts new file mode 100644 index 00000000..a2221f3b --- /dev/null +++ b/theme/src/client/composables/blog.ts @@ -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 + 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 = { + '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 + +export const useTags = () => { + const locale = usePageLang() + const list = useBlogPostData() as unknown as Ref + const filteredList = computed(() => + list.value.filter((item) => item.lang === locale.value) + ) + + const tags = computed(() => { + const tagMap: Record = {} + 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([]) + const currentTag = ref() + + 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 + 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 + } +} diff --git a/theme/src/client/composables/index.ts b/theme/src/client/composables/index.ts index e28eca86..77c180f4 100644 --- a/theme/src/client/composables/index.ts +++ b/theme/src/client/composables/index.ts @@ -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' diff --git a/theme/src/client/composables/locale.ts b/theme/src/client/composables/locale.ts new file mode 100644 index 00000000..9995606c --- /dev/null +++ b/theme/src/client/composables/locale.ts @@ -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 = {} + 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) + }) +} diff --git a/theme/src/client/composables/readingTime.ts b/theme/src/client/composables/readingTime.ts index 99d70ae0..3a579a1c 100644 --- a/theme/src/client/composables/readingTime.ts +++ b/theme/src/client/composables/readingTime.ts @@ -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() - return computed(() => { + return computed<{ times: string; words: string }>(() => { if (!page.value.readingTime) return { times: '', words: '' } const locale = readingTimeLocales[page.value.lang] ?? readingTimeLocales.en diff --git a/theme/src/client/layouts/Layout.vue b/theme/src/client/layouts/Layout.vue index ef3e2aca..d5876900 100644 --- a/theme/src/client/layouts/Layout.vue +++ b/theme/src/client/layouts/Layout.vue @@ -1,10 +1,11 @@ @@ -49,7 +52,8 @@ provide('is-sidebar-open', isSidebarOpen) - + + diff --git a/theme/src/client/utils/base.ts b/theme/src/client/utils/base.ts new file mode 100644 index 00000000..fa7c644c --- /dev/null +++ b/theme/src/client/utils/base.ts @@ -0,0 +1,3 @@ +export const toArray = (value: T | T[]): T[] => { + return Array.isArray(value) ? value : [value] +} diff --git a/theme/src/client/utils/index.ts b/theme/src/client/utils/index.ts index 3961eb76..14a67353 100644 --- a/theme/src/client/utils/index.ts +++ b/theme/src/client/utils/index.ts @@ -4,3 +4,4 @@ export * from './socialIcons.js' export * from './dom.js' export * from './resolveEditLink.js' export * from './resolveRepoType.js' +export * from './base.js' diff --git a/theme/src/node/autoFrontmatter.ts b/theme/src/node/autoFrontmatter.ts index 706612fd..513f17f2 100644 --- a/theme/src/node/autoFrontmatter.ts +++ b/theme/src/node/autoFrontmatter.ts @@ -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) diff --git a/theme/src/node/setupPages.ts b/theme/src/node/setupPages.ts index b1c67042..3bf01528 100644 --- a/theme/src/node/setupPages.ts +++ b/theme/src/node/setupPages.ts @@ -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, + 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 = {} const RE_CATEGORY = /^(\d+)?(?:\.?)([^]+)$/ diff --git a/theme/src/node/theme.ts b/theme/src/node/theme.ts index 8f5f0391..6383310a 100644 --- a/theme/src/node/theme.ts +++ b/theme/src/node/theme.ts @@ -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) => { - page.data.filePathRelative = page.filePathRelative - page.routeMeta.title = page.title - autoCategory(app, page, localeOptions) - pageContentRendered(page) - }, + extendsPage: (page: Page) => + extendsPageData(app, page, localeOptions) + , } } } diff --git a/theme/src/shared/frontmatter.ts b/theme/src/shared/frontmatter.ts index 8ed637c8..193ba0c4 100644 --- a/theme/src/shared/frontmatter.ts +++ b/theme/src/shared/frontmatter.ts @@ -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[] +} diff --git a/theme/src/shared/options/locale.ts b/theme/src/shared/options/locale.ts index cb2261a1..e543477b 100644 --- a/theme/src/shared/options/locale.ts +++ b/theme/src/shared/options/locale.ts @@ -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 - /** * 笔记配置, 笔记中的文章默认不会出现在首页文章列表 * diff --git a/theme/src/shared/page.ts b/theme/src/shared/page.ts index b38bd16b..d74431f5 100644 --- a/theme/src/shared/page.ts +++ b/theme/src/shared/page.ts @@ -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
+ +
{{ avatar.description }}
- -
+ {{ post.title }} + {{ post.createTime }} +
+ {{ tag.name }} + ({{ tag.count }}) +