diff --git a/.stylelintrc.yml b/.stylelintrc.yml index a12f147ce1..537239f230 100644 --- a/.stylelintrc.yml +++ b/.stylelintrc.yml @@ -14,6 +14,18 @@ rules: declaration-block-no-redundant-longhand-properties: true media-feature-range-notation: prefix no-descending-specificity: null + custom-property-empty-line-before: null + selector-max-class: null + selector-max-attribute: null + selector-pseudo-class-no-unknown: + - true + - ignorePseudoClasses: + - input-placeholder + + selector-pseudo-element-no-unknown: + - true + - ignorePseudoElements: + - input-placeholder # workaround for mixed-declarations no-duplicate-selectors: null @@ -48,3 +60,18 @@ overrides: - v-deep - v-global - v-slotted + + declaration-block-no-redundant-longhand-properties: null + scss/operator-no-newline-after: null + + - files: + - 'themes/theme-next/src/client/styles/vars.css' + + rules: + no-duplicate-selectors: null + + - files: + - 'themes/theme-next/src/client/styles/compat.css' + + rules: + selector-class-pattern: null diff --git a/docs-next/.vuepress/client.ts b/docs-next/.vuepress/client.ts new file mode 100644 index 0000000000..db9baaf263 --- /dev/null +++ b/docs-next/.vuepress/client.ts @@ -0,0 +1,67 @@ +import { defineGiscusConfig } from '@vuepress/plugin-comment/client' +import { defineDocSearchConfig } from '@vuepress/plugin-docsearch/client' +import { defineClientConfig } from 'vuepress/client' +import CommentPage from './layouts/CommentPage.vue' + +defineGiscusConfig({ + repo: 'vuepress/ecosystem', + repoId: 'R_kgDOKPxScA', + category: 'Announcements', + categoryId: 'DIC_kwDOKPxScM4CbWy7', +}) + +defineDocSearchConfig({ + appId: 'N7UOPMVZ5B', + apiKey: 'aa626dfa43a5e32cd519ba84735ad384', + indexName: 'ecosystem-vuejs', + locales: { + '/zh/': { + placeholder: '搜索文档', + translations: { + button: { + buttonText: '搜索文档', + buttonAriaLabel: '搜索文档', + }, + modal: { + searchBox: { + resetButtonTitle: '清除查询条件', + resetButtonAriaLabel: '清除查询条件', + cancelButtonText: '取消', + cancelButtonAriaLabel: '取消', + }, + startScreen: { + recentSearchesTitle: '搜索历史', + noRecentSearchesText: '没有搜索历史', + saveRecentSearchButtonTitle: '保存至搜索历史', + removeRecentSearchButtonTitle: '从搜索历史中移除', + favoriteSearchesTitle: '收藏', + removeFavoriteSearchButtonTitle: '从收藏中移除', + }, + errorScreen: { + titleText: '无法获取结果', + helpText: '你可能需要检查你的网络连接', + }, + footer: { + selectText: '选择', + navigateText: '切换', + closeText: '关闭', + searchByText: '搜索提供者', + }, + noResultsScreen: { + noResultsText: '无法找到相关结果', + suggestedQueryText: '你可以尝试查询', + reportMissingResultsText: '你认为该查询应该有结果?', + reportMissingResultsLinkText: '点击反馈', + }, + }, + }, + }, + }, +}) + +export default defineClientConfig({ + layouts: { + // We override the default layout to provide comment service + CommentPage, + }, +}) diff --git a/docs-next/.vuepress/components/NpmBadge.vue b/docs-next/.vuepress/components/NpmBadge.vue new file mode 100644 index 0000000000..7e171f0741 --- /dev/null +++ b/docs-next/.vuepress/components/NpmBadge.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/docs-next/.vuepress/components/PaletteDemo.vue b/docs-next/.vuepress/components/PaletteDemo.vue new file mode 100644 index 0000000000..af6b97f618 --- /dev/null +++ b/docs-next/.vuepress/components/PaletteDemo.vue @@ -0,0 +1,203 @@ + + + + + diff --git a/docs-next/.vuepress/components/PaletteDisplay.vue b/docs-next/.vuepress/components/PaletteDisplay.vue new file mode 100644 index 0000000000..84b4235497 --- /dev/null +++ b/docs-next/.vuepress/components/PaletteDisplay.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/docs-next/.vuepress/config.ts b/docs-next/.vuepress/config.ts new file mode 100644 index 0000000000..d624cc02cb --- /dev/null +++ b/docs-next/.vuepress/config.ts @@ -0,0 +1,161 @@ +import process from 'node:process' +import { viteBundler } from '@vuepress/bundler-vite' +import { webpackBundler } from '@vuepress/bundler-webpack' +import { getRealPath } from '@vuepress/helper' +import { cachePlugin } from '@vuepress/plugin-cache' +import { catalogPlugin } from '@vuepress/plugin-catalog' +import { commentPlugin } from '@vuepress/plugin-comment' +import { docsearchPlugin } from '@vuepress/plugin-docsearch' +import { feedPlugin } from '@vuepress/plugin-feed' +import { markdownExtPlugin } from '@vuepress/plugin-markdown-ext' +import { markdownImagePlugin } from '@vuepress/plugin-markdown-image' +import { markdownIncludePlugin } from '@vuepress/plugin-markdown-include' +import { markdownMathPlugin } from '@vuepress/plugin-markdown-math' +import { markdownStylizePlugin } from '@vuepress/plugin-markdown-stylize' +import { prismjsPlugin } from '@vuepress/plugin-prismjs' +import { registerComponentsPlugin } from '@vuepress/plugin-register-components' +import { revealJsPlugin } from '@vuepress/plugin-revealjs' +import { defineUserConfig } from 'vuepress' +import { getDirname, path } from 'vuepress/utils' +import { head } from './configs/index.js' +import theme from './theme.js' + +const __dirname = getDirname(import.meta.url) + +// const isProd = process.env.NODE_ENV === 'production' + +export default defineUserConfig({ + // set site base to default value + base: (process.env.BASE as '/' | `/${string}/` | undefined) || '/', + lang: 'en-US', + + // extra tags in `` + head, + + // site-level locales config + locales: { + '/': { + lang: 'en-US', + title: 'VuePress Ecosystem', + description: 'VuePress official themes and plugins', + }, + '/zh/': { + lang: 'zh-CN', + title: 'VuePress 生态系统', + description: 'VuePress 官方主题和插件', + }, + }, + + // specify bundler via environment variable + bundler: + process.env.DOCS_BUNDLER === 'webpack' ? webpackBundler() : viteBundler(), + + // configure markdown + markdown: { + importCode: { + handleImportPath: (importPath) => { + // handle @vuepress packages import path + if (importPath.startsWith('@vuepress/')) { + const packageName = importPath.match(/^(@vuepress\/[^/]*)/)![1] + const realPath = importPath.replace( + packageName, + path.dirname( + getRealPath(`${packageName}/package.json`, import.meta.url), + ), + ) + + return realPath + } + return importPath + }, + }, + }, + + plugins: [ + catalogPlugin(), + docsearchPlugin(), + commentPlugin({ provider: 'Giscus' }), + markdownExtPlugin({ + gfm: true, + component: true, + vPre: true, + }), + markdownImagePlugin({ + figure: true, + mark: true, + size: true, + }), + markdownIncludePlugin({ + deep: true, + }), + markdownMathPlugin(), + registerComponentsPlugin({ + componentsDir: path.resolve(__dirname, './components'), + }), + feedPlugin({ + hostname: 'https://ecosystem.vuejs.press', + atom: true, + json: true, + rss: true, + }), + markdownStylizePlugin({ + align: true, + attrs: true, + mark: true, + spoiler: true, + sub: true, + sup: true, + custom: [ + { + matcher: 'Recommended', + replacer: ({ tag }) => { + if (tag === 'em') + return { + tag: 'Badge', + attrs: { type: 'tip' }, + content: 'Recommended', + } + + return null + }, + }, + ], + }), + + revealJsPlugin({ + plugins: ['highlight', 'math', 'search', 'notes', 'zoom'], + themes: [ + 'auto', + 'beige', + 'black', + 'blood', + 'league', + 'moon', + 'night', + 'serif', + 'simple', + 'sky', + 'solarized', + 'white', + ], + }), + + process.env.HIGHLIGHTER === 'prismjs' + ? prismjsPlugin({ + themes: { light: 'one-light', dark: 'one-dark' }, + lineNumbers: 10, + notationDiff: true, + notationErrorLevel: true, + notationFocus: true, + notationHighlight: true, + notationWordHighlight: true, + whitespace: true, + }) + : [], + + cachePlugin(), + ], + + // configure default theme + theme, +}) diff --git a/docs-next/.vuepress/configs/head.ts b/docs-next/.vuepress/configs/head.ts new file mode 100644 index 0000000000..a869976d67 --- /dev/null +++ b/docs-next/.vuepress/configs/head.ts @@ -0,0 +1,40 @@ +import type { HeadConfig } from 'vuepress/core' + +export const head: HeadConfig[] = [ + [ + 'link', + { + rel: 'icon', + type: 'image/png', + sizes: '16x16', + href: `/images/icons/favicon-16x16.png`, + }, + ], + [ + 'link', + { + rel: 'icon', + type: 'image/png', + sizes: '32x32', + href: `/images/icons/favicon-32x32.png`, + }, + ], + ['link', { rel: 'manifest', href: '/manifest.webmanifest' }], + ['meta', { name: 'application-name', content: 'VuePress' }], + ['meta', { name: 'apple-mobile-web-app-title', content: 'VuePress' }], + ['meta', { name: 'apple-mobile-web-app-status-bar-style', content: 'black' }], + [ + 'link', + { rel: 'apple-touch-icon', href: `/images/icons/apple-touch-icon.png` }, + ], + [ + 'link', + { + rel: 'mask-icon', + href: '/images/icons/safari-pinned-tab.svg', + color: '#3eaf7c', + }, + ], + ['meta', { name: 'msapplication-TileColor', content: '#3eaf7c' }], + ['meta', { name: 'theme-color', content: '#3eaf7c' }], +] diff --git a/docs-next/.vuepress/configs/index.ts b/docs-next/.vuepress/configs/index.ts new file mode 100644 index 0000000000..b23dc923d2 --- /dev/null +++ b/docs-next/.vuepress/configs/index.ts @@ -0,0 +1,3 @@ +export * from './head.js' +export * from './navbar/index.js' +export * from './sidebar/index.js' diff --git a/docs-next/.vuepress/configs/navbar/en.ts b/docs-next/.vuepress/configs/navbar/en.ts new file mode 100644 index 0000000000..09803862e3 --- /dev/null +++ b/docs-next/.vuepress/configs/navbar/en.ts @@ -0,0 +1,84 @@ +import type { NavItem } from '@vuepress/theme-default' + +export const navbarEn: NavItem[] = [ + { + text: 'Themes', + prefix: '/themes/', + items: [ + { + text: 'Theme Guidelines', + link: 'guidelines', + }, + { + text: 'Default Theme', + link: 'default/', + }, + { + text: 'Hope Theme', + link: 'https://theme-hope.vuejs.press', + }, + { + text: 'Plume Theme', + link: 'https://theme-plume.vuejs.press', + }, + { + text: 'Reco Theme', + link: 'https://theme-reco.vuejs.press/en', + }, + ], + }, + { + text: 'Plugins', + prefix: '/plugins/', + activeMatch: '^/plugins/', + items: [ + { + text: 'Common Features', + link: 'features/', + }, + { + text: 'Markdown', + link: 'markdown/', + }, + { + text: 'Search', + link: 'search/', + }, + { + text: 'Blogging', + link: 'blog/', + }, + { + text: 'PWA', + link: 'pwa/', + }, + { + text: 'Analytics', + link: 'analytics/', + }, + { + text: 'SEO', + link: 'seo/', + }, + { + text: 'Theme Development', + link: 'development/', + }, + { + text: 'Tools', + link: 'tools/', + }, + ], + }, + { + text: 'Tools', + prefix: '/tools/', + activeMatch: '^/tools/', + items: [ + { + text: 'helper', + link: 'helper/', + }, + ], + }, +] diff --git a/docs-next/.vuepress/configs/navbar/index.ts b/docs-next/.vuepress/configs/navbar/index.ts new file mode 100644 index 0000000000..7183393c31 --- /dev/null +++ b/docs-next/.vuepress/configs/navbar/index.ts @@ -0,0 +1,2 @@ +export * from './en.js' +export * from './zh.js' diff --git a/docs-next/.vuepress/configs/navbar/zh.ts b/docs-next/.vuepress/configs/navbar/zh.ts new file mode 100644 index 0000000000..60d5076c84 --- /dev/null +++ b/docs-next/.vuepress/configs/navbar/zh.ts @@ -0,0 +1,84 @@ +import type { NavItem } from '@vuepress/theme-default' + +export const navbarZh: NavItem[] = [ + { + text: '主题', + prefix: '/zh/themes/', + items: [ + { + text: '主题指南', + link: 'guidelines', + }, + { + text: '默认主题', + link: 'default/', + }, + { + text: 'Hope 主题', + link: 'https://theme-hope.vuejs.press', + }, + { + text: 'Plume 主题', + link: 'https://theme-plume.vuejs.press', + }, + { + text: 'Reco 主题', + link: 'https://theme-reco.vuejs.press', + }, + ], + }, + { + text: '插件', + prefix: '/zh/plugins/', + activeMatch: '^/zh/plugins/', + items: [ + { + text: '常用功能', + link: 'features/', + }, + { + text: 'Markdown', + link: 'markdown/', + }, + { + text: '搜索', + link: 'search/', + }, + { + text: '博客', + link: 'blog/', + }, + { + text: 'PWA', + link: 'pwa/', + }, + { + text: '统计分析', + link: 'analytics/', + }, + { + text: '搜索引擎增强', + link: 'seo/', + }, + { + text: '主题开发', + link: 'development/', + }, + { + text: '工具', + link: 'tools/', + }, + ], + }, + { + text: '工具', + prefix: '/zh/tools/', + activeMatch: '^/zh/tools/', + items: [ + { + text: 'helper', + link: 'helper/', + }, + ], + }, +] diff --git a/docs-next/.vuepress/configs/sidebar/en.ts b/docs-next/.vuepress/configs/sidebar/en.ts new file mode 100644 index 0000000000..0a0dd47c9f --- /dev/null +++ b/docs-next/.vuepress/configs/sidebar/en.ts @@ -0,0 +1,182 @@ +import type { Sidebar } from '@vuepress/theme-default' + +export const sidebarEn: Sidebar = { + '/plugins/': [ + { + text: 'Common Features', + link: 'features/', + }, + { + text: 'Markdown', + link: 'markdown/', + }, + { + text: 'Content Search', + link: 'search/', + }, + { + text: 'Blogging', + link: 'blog/', + }, + + { + text: 'Analytics', + link: 'analytics/', + }, + { + text: 'SEO', + link: 'seo/', + }, + { + text: 'PWA', + link: 'pwa/', + }, + { + text: 'Theme Development', + link: 'development/', + }, + { + text: 'Tools', + link: 'tools/', + }, + ], + + '/plugins/analytics/': [ + 'baidu-analytics', + 'google-analytics', + 'umami-analytics', + ], + + '/plugins/blog/': [ + { + text: 'Blog', + prefix: 'blog/', + link: 'blog/', + items: ['guide', 'config'], + }, + { + text: 'Comment', + prefix: 'comment/', + link: 'comment/', + items: ['guide', 'giscus/', 'waline/', 'artalk/', 'twikoo/'], + }, + { + text: 'Feed', + prefix: 'feed/', + link: 'feed/', + items: ['guide', 'config', 'frontmatter', 'channel', 'getter'], + }, + ], + + '/plugins/development/': [ + 'active-header-links', + 'git', + 'palette', + 'reading-time', + 'rtl', + { + text: 'Sass Palette', + prefix: 'sass-palette/', + link: 'sass-palette/', + items: ['guide', 'config'], + }, + 'theme-data', + 'toc', + ], + + '/plugins/features/': [ + 'back-to-top', + 'catalog', + 'copy-code', + 'copyright', + 'medium-zoom', + 'notice', + 'nprogress', + 'photo-swipe', + 'watermark', + ], + + '/plugins/markdown/': [ + 'append-date', + 'markdown-container', + 'markdown-ext', + 'markdown-image', + 'markdown-include', + 'markdown-hint', + 'markdown-math', + 'markdown-stylize', + 'markdown-tab', + 'links-check', + 'prismjs', + { + text: 'revealjs', + prefix: 'revealjs/', + link: 'revealjs/', + children: ['', 'demo', 'themes'], + }, + 'shiki', + ], + + '/plugins/pwa/': [ + { + text: 'PWA', + prefix: 'pwa/', + link: 'pwa/', + items: ['guide', 'config'], + }, + '/plugins/pwa/remove-pwa', + ], + + '/plugins/tools/': [ + 'cache', + 'google-tag-manager', + 'redirect', + 'register-components', + ], + + '/plugins/search/': ['guidelines', 'docsearch', 'search', 'slimsearch'], + + '/plugins/seo/': [ + { + text: 'SEO', + prefix: 'seo/', + link: 'seo/', + items: ['guide', 'config'], + }, + { + text: 'Sitemap', + prefix: 'sitemap/', + link: 'sitemap/', + items: ['guide', 'config', 'frontmatter'], + }, + ], + + '/themes/': [ + 'guidelines', + { + text: 'Default Theme', + prefix: 'default/', + link: 'default/', + items: [ + 'config', + 'plugin', + 'locale', + 'sidebar', + 'frontmatter', + 'components', + 'markdown', + 'styles', + 'extending', + ], + }, + ], + + '/tools/': [ + { + text: '@vuepress/helper', + prefix: 'helper/', + link: 'helper/', + items: ['node/bundler', 'node/page', 'client', 'shared', 'style'], + }, + ], +} diff --git a/docs-next/.vuepress/configs/sidebar/index.ts b/docs-next/.vuepress/configs/sidebar/index.ts new file mode 100644 index 0000000000..7183393c31 --- /dev/null +++ b/docs-next/.vuepress/configs/sidebar/index.ts @@ -0,0 +1,2 @@ +export * from './en.js' +export * from './zh.js' diff --git a/docs-next/.vuepress/configs/sidebar/zh.ts b/docs-next/.vuepress/configs/sidebar/zh.ts new file mode 100644 index 0000000000..26c98af29a --- /dev/null +++ b/docs-next/.vuepress/configs/sidebar/zh.ts @@ -0,0 +1,182 @@ +import type { Sidebar } from '@vuepress/theme-default' + +export const sidebarZh: Sidebar = { + '/zh/plugins/': [ + { + text: '常用功能', + link: 'features/', + }, + { + text: 'Markdown', + link: 'markdown/', + }, + { + text: '搜索', + link: 'search/', + }, + { + text: '博客', + link: 'blog/', + }, + + { + text: '分析统计', + link: 'analytics/', + }, + { + text: '搜索引擎优化', + link: 'seo/', + }, + { + text: '渐进式应用', + link: 'pwa/', + }, + { + text: '主题开发', + link: 'development/', + }, + { + text: '工具', + link: 'tools/', + }, + ], + + '/zh/plugins/analytics/': [ + 'baidu-analytics', + 'google-analytics', + 'umami-analytics', + ], + + '/zh/plugins/blog/': [ + { + text: '博客', + prefix: 'blog/', + link: 'blog/', + items: ['guide', 'config'], + }, + { + text: '评论', + prefix: 'comment/', + link: 'comment/', + items: ['guide', 'giscus/', 'waline/', 'artalk/', 'twikoo/'], + }, + { + text: 'Feed', + prefix: 'feed/', + link: 'feed/', + items: ['guide', 'config', 'frontmatter', 'channel', 'getter'], + }, + ], + + '/zh/plugins/development/': [ + 'active-header-links', + 'git', + 'palette', + 'reading-time', + 'rtl', + { + text: 'Sass Palette', + prefix: 'sass-palette/', + link: 'sass-palette/', + items: ['guide', 'config'], + }, + 'theme-data', + 'toc', + ], + + '/zh/plugins/features/': [ + 'back-to-top', + 'catalog', + 'copy-code', + 'copyright', + 'medium-zoom', + 'notice', + 'nprogress', + 'photo-swipe', + 'watermark', + ], + + '/zh/plugins/markdown/': [ + 'append-date', + 'markdown-container', + 'markdown-ext', + 'markdown-image', + 'markdown-include', + 'markdown-hint', + 'markdown-math', + 'markdown-stylize', + 'markdown-tab', + 'links-check', + 'prismjs', + { + text: 'revealjs', + prefix: 'revealjs/', + link: 'revealjs/', + items: ['', 'demo', 'themes'], + }, + 'shiki', + ], + + '/zh/plugins/pwa/': [ + { + text: 'PWA', + prefix: 'pwa/', + link: 'pwa/', + items: ['guide', 'config'], + }, + '/plugins/pwa/remove-pwa', + ], + + '/zh/plugins/tools/': [ + 'cache', + 'google-tag-manager', + 'redirect', + 'register-components', + ], + + '/zh/plugins/search/': ['guidelines', 'docsearch', 'search', 'slimsearch'], + + '/zh/plugins/seo/': [ + { + text: '搜索引擎增强', + prefix: 'seo/', + link: 'seo/', + items: ['guide', 'config'], + }, + { + text: '站点地图', + prefix: 'sitemap/', + link: 'sitemap/', + items: ['guide', 'config', 'frontmatter'], + }, + ], + + '/zh/themes/': [ + 'guidelines', + { + text: '默认主题', + prefix: 'default/', + link: 'default/', + items: [ + 'config', + 'plugin', + 'locale', + 'sidebar', + 'frontmatter', + 'components', + 'markdown', + 'styles', + 'extending', + ], + }, + ], + + '/zh/tools/': [ + { + text: '@vuepress/helper', + prefix: 'helper/', + link: 'helper/', + items: ['node/bundler', 'node/page', 'client', 'shared', 'style'], + }, + ], +} diff --git a/docs-next/.vuepress/layouts/CommentPage.vue b/docs-next/.vuepress/layouts/CommentPage.vue new file mode 100644 index 0000000000..52d6b90485 --- /dev/null +++ b/docs-next/.vuepress/layouts/CommentPage.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/docs-next/.vuepress/public/favicon.ico b/docs-next/.vuepress/public/favicon.ico new file mode 100644 index 0000000000..e481e5dda7 Binary files /dev/null and b/docs-next/.vuepress/public/favicon.ico differ diff --git a/docs-next/.vuepress/public/images/comment/vercel-1.png b/docs-next/.vuepress/public/images/comment/vercel-1.png new file mode 100644 index 0000000000..5cb34f3c0a Binary files /dev/null and b/docs-next/.vuepress/public/images/comment/vercel-1.png differ diff --git a/docs-next/.vuepress/public/images/comment/vercel-2.png b/docs-next/.vuepress/public/images/comment/vercel-2.png new file mode 100644 index 0000000000..447e587c0b Binary files /dev/null and b/docs-next/.vuepress/public/images/comment/vercel-2.png differ diff --git a/docs-next/.vuepress/public/images/comment/vercel-3.png b/docs-next/.vuepress/public/images/comment/vercel-3.png new file mode 100644 index 0000000000..2d29503234 Binary files /dev/null and b/docs-next/.vuepress/public/images/comment/vercel-3.png differ diff --git a/docs-next/.vuepress/public/images/comment/vercel-4.png b/docs-next/.vuepress/public/images/comment/vercel-4.png new file mode 100644 index 0000000000..0298d1e8da Binary files /dev/null and b/docs-next/.vuepress/public/images/comment/vercel-4.png differ diff --git a/docs-next/.vuepress/public/images/comment/vercel-5.png b/docs-next/.vuepress/public/images/comment/vercel-5.png new file mode 100644 index 0000000000..811af4dee9 Binary files /dev/null and b/docs-next/.vuepress/public/images/comment/vercel-5.png differ diff --git a/docs-next/.vuepress/public/images/comment/vercel-6.png b/docs-next/.vuepress/public/images/comment/vercel-6.png new file mode 100644 index 0000000000..d7367e15f4 Binary files /dev/null and b/docs-next/.vuepress/public/images/comment/vercel-6.png differ diff --git a/docs-next/.vuepress/public/images/comment/vercel-7.png b/docs-next/.vuepress/public/images/comment/vercel-7.png new file mode 100644 index 0000000000..a8bb786970 Binary files /dev/null and b/docs-next/.vuepress/public/images/comment/vercel-7.png differ diff --git a/docs-next/.vuepress/public/images/comment/vercel-8.png b/docs-next/.vuepress/public/images/comment/vercel-8.png new file mode 100644 index 0000000000..77ca69d69a Binary files /dev/null and b/docs-next/.vuepress/public/images/comment/vercel-8.png differ diff --git a/docs-next/.vuepress/public/images/comment/vercel-9.png b/docs-next/.vuepress/public/images/comment/vercel-9.png new file mode 100644 index 0000000000..259bfee7de Binary files /dev/null and b/docs-next/.vuepress/public/images/comment/vercel-9.png differ diff --git a/docs-next/.vuepress/public/images/cookbook/extending-a-theme-01.png b/docs-next/.vuepress/public/images/cookbook/extending-a-theme-01.png new file mode 100644 index 0000000000..9ba6d7e812 Binary files /dev/null and b/docs-next/.vuepress/public/images/cookbook/extending-a-theme-01.png differ diff --git a/docs-next/.vuepress/public/images/hero.png b/docs-next/.vuepress/public/images/hero.png new file mode 100644 index 0000000000..ac6beaff06 Binary files /dev/null and b/docs-next/.vuepress/public/images/hero.png differ diff --git a/docs-next/.vuepress/public/images/icon/github-dark.svg b/docs-next/.vuepress/public/images/icon/github-dark.svg new file mode 100644 index 0000000000..37fa923df3 --- /dev/null +++ b/docs-next/.vuepress/public/images/icon/github-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs-next/.vuepress/public/images/icon/github-light.svg b/docs-next/.vuepress/public/images/icon/github-light.svg new file mode 100644 index 0000000000..d5e6491854 --- /dev/null +++ b/docs-next/.vuepress/public/images/icon/github-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs-next/.vuepress/public/images/icons/android-chrome-192x192.png b/docs-next/.vuepress/public/images/icons/android-chrome-192x192.png new file mode 100644 index 0000000000..ddd043910e Binary files /dev/null and b/docs-next/.vuepress/public/images/icons/android-chrome-192x192.png differ diff --git a/docs-next/.vuepress/public/images/icons/android-chrome-384x384.png b/docs-next/.vuepress/public/images/icons/android-chrome-384x384.png new file mode 100644 index 0000000000..86e1fd58b3 Binary files /dev/null and b/docs-next/.vuepress/public/images/icons/android-chrome-384x384.png differ diff --git a/docs-next/.vuepress/public/images/icons/apple-touch-icon.png b/docs-next/.vuepress/public/images/icons/apple-touch-icon.png new file mode 100644 index 0000000000..208915f1de Binary files /dev/null and b/docs-next/.vuepress/public/images/icons/apple-touch-icon.png differ diff --git a/docs-next/.vuepress/public/images/icons/favicon-16x16.png b/docs-next/.vuepress/public/images/icons/favicon-16x16.png new file mode 100644 index 0000000000..ca5047e7b8 Binary files /dev/null and b/docs-next/.vuepress/public/images/icons/favicon-16x16.png differ diff --git a/docs-next/.vuepress/public/images/icons/favicon-32x32.png b/docs-next/.vuepress/public/images/icons/favicon-32x32.png new file mode 100644 index 0000000000..e275ce9ba1 Binary files /dev/null and b/docs-next/.vuepress/public/images/icons/favicon-32x32.png differ diff --git a/docs-next/.vuepress/public/images/icons/mstile-150x150.png b/docs-next/.vuepress/public/images/icons/mstile-150x150.png new file mode 100644 index 0000000000..d0b1439483 Binary files /dev/null and b/docs-next/.vuepress/public/images/icons/mstile-150x150.png differ diff --git a/docs-next/.vuepress/public/images/icons/safari-pinned-tab.svg b/docs-next/.vuepress/public/images/icons/safari-pinned-tab.svg new file mode 100644 index 0000000000..dc0b992c04 --- /dev/null +++ b/docs-next/.vuepress/public/images/icons/safari-pinned-tab.svg @@ -0,0 +1,23 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + diff --git a/docs-next/.vuepress/public/images/logo.png b/docs-next/.vuepress/public/images/logo.png new file mode 100644 index 0000000000..60e17006ad Binary files /dev/null and b/docs-next/.vuepress/public/images/logo.png differ diff --git a/docs-next/.vuepress/public/manifest.webmanifest b/docs-next/.vuepress/public/manifest.webmanifest new file mode 100644 index 0000000000..d2e935f1ae --- /dev/null +++ b/docs-next/.vuepress/public/manifest.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "VuePress", + "short_name": "VuePress", + "description": "Vue-powered Static Site Generator", + "start_url": "/index.html", + "display": "standalone", + "background_color": "#fff", + "theme_color": "#3eaf7c", + "icons": [ + { + "src": "/images/icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/images/icons/android-chrome-384x384.png", + "sizes": "384x384", + "type": "image/png" + } + ] +} diff --git a/docs-next/.vuepress/theme.ts b/docs-next/.vuepress/theme.ts new file mode 100644 index 0000000000..b3ceb1b0e0 --- /dev/null +++ b/docs-next/.vuepress/theme.ts @@ -0,0 +1,71 @@ +import { defaultTheme } from '@vuepress/theme-default' +import { navbarEn, navbarZh, sidebarEn, sidebarZh } from './configs/index.js' + +const IS_PROD = process.env.NODE_ENV === 'production' + +export default defaultTheme({ + logo: '/images/logo.png', + hostname: 'https://ecosystem.vuejs.press', + + // theme-level locales config + locales: { + '/': { + navbar: navbarEn, + sidebar: sidebarEn, + }, + '/zh/': { + navbar: navbarZh, + sidebar: sidebarZh, + }, + }, + + socialLinks: [ + { icon: 'github', link: 'https://github.com/vuepress/ecosystem' }, + ], + + docsRepo: 'https://github.com/vuepress/ecosystem', + docsBranch: 'main', + docsDir: 'docs-next', + + footer: { + message: 'Released under the MIT License.', + copyright: 'Copyright © 2022-PRESENT VuePress', + }, + themePlugins: { + git: IS_PROD, + shiki: + process.env.HIGHLIGHTER !== 'prismjs' + ? { + langs: [ + 'bash', + 'diff', + 'json', + 'md', + 'scss', + 'ts', + 'vue', + 'less', + 'java', + 'py', + 'vb', + 'bat', + 'cs', + 'cpp', + 'yaml', + ], + themes: { + light: 'one-light', + dark: 'one-dark-pro', + }, + lineNumbers: 10, + notationDiff: true, + notationErrorLevel: true, + notationFocus: true, + notationHighlight: true, + notationWordHighlight: true, + whitespace: true, + collapsedLines: false, + } + : false, + }, +}) diff --git a/docs-next/README.md b/docs-next/README.md new file mode 100644 index 0000000000..60e4ffe5a8 --- /dev/null +++ b/docs-next/README.md @@ -0,0 +1,40 @@ +--- +home: true +title: Home +hero: + image: /images/hero.png + name: VuePress Ecosystem + text: VuePress official themes and plugins +actions: + - text: Themes + link: /themes/default/ + theme: brand + - text: Plugins + link: ./plugins/ + theme: brand + - text: GitHub → + link: https://github.com/vuepress/ecosystem + theme: alt +--- + + diff --git a/docs-next/package.json b/docs-next/package.json new file mode 100644 index 0000000000..9d5183f764 --- /dev/null +++ b/docs-next/package.json @@ -0,0 +1,44 @@ +{ + "name": "@vuepress/ecosystem-docs-new", + "private": true, + "scripts": { + "docs:build": "vuepress build . --clean-cache --clean-temp", + "docs:build-webpack": "DOCS_BUNDLER=webpack pnpm docs:build", + "docs:dev": "vuepress dev .", + "docs:dev-webpack": "DOCS_BUNDLER=webpack pnpm docs:dev", + "docs:serve": "http-server -a localhost .vuepress/dist" + }, + "dependencies": { + "@vuepress/bundler-vite": "2.0.0-rc.18", + "@vuepress/bundler-webpack": "2.0.0-rc.18", + "@vuepress/helper": "workspace:*", + "@vuepress/plugin-catalog": "workspace:*", + "@vuepress/plugin-comment": "workspace:*", + "@vuepress/plugin-feed": "workspace:*", + "@vuepress/plugin-photo-swipe": "workspace:*", + "@vuepress/plugin-docsearch": "workspace:*", + "@vuepress/plugin-register-components": "workspace:*", + "@vuepress/plugin-pwa": "workspace:*", + "@vuepress/plugin-search": "workspace:*", + "@vuepress/plugin-markdown-ext": "workspace:*", + "@vuepress/plugin-markdown-include": "workspace:*", + "@vuepress/plugin-markdown-stylize": "workspace:*", + "@vuepress/plugin-revealjs": "workspace:*", + "@vuepress/plugin-back-to-top": "workspace:*", + "@vuepress/plugin-copy-code": "workspace:*", + "@vuepress/plugin-markdown-image": "workspace:*", + "@vuepress/plugin-markdown-math": "workspace:*", + "@vuepress/plugin-medium-zoom": "workspace:*", + "@vuepress/plugin-nprogress": "workspace:*", + "@vuepress/plugin-redirect": "workspace:*", + "@vuepress/plugin-prismjs": "workspace:*", + "@vuepress/theme-default": "workspace:*", + "@vuepress/plugin-cache": "workspace:*", + "katex": "0.16.11", + "mathjax-full": "3.2.2", + "sass-embedded": "1.82.0", + "sass-loader": "^16.0.4", + "vue": "^3.5.13", + "vuepress": "2.0.0-rc.18" + } +} diff --git a/docs-next/plugins/README.md b/docs-next/plugins/README.md new file mode 100644 index 0000000000..357366d9c5 --- /dev/null +++ b/docs-next/plugins/README.md @@ -0,0 +1,3 @@ +# Plugins + + diff --git a/docs-next/plugins/analytics/README.md b/docs-next/plugins/analytics/README.md new file mode 100644 index 0000000000..341eeb0e8d --- /dev/null +++ b/docs-next/plugins/analytics/README.md @@ -0,0 +1,3 @@ +# Analytics Plugins + + diff --git a/docs-next/plugins/analytics/baidu-analytics.md b/docs-next/plugins/analytics/baidu-analytics.md new file mode 100644 index 0000000000..565771de03 --- /dev/null +++ b/docs-next/plugins/analytics/baidu-analytics.md @@ -0,0 +1,43 @@ +# baidu-analytics + + + +Integrate [Baidu Analytics](https://tongji.baidu.com/) into VuePress. + +::: tip + +Do not enable [SPA mode in Baidu Analytics](https://tongji.baidu.com/web/help/article?id=324&type=0). The plugin will report page view events correctly. + +::: + +## Usage + +```bash +npm i -D @vuepress/plugin-baidu-analytics@next +``` + +```ts +import { baiduAnalyticsPlugin } from '@vuepress/plugin-baidu-analytics' + +export default { + plugins: [ + baiduAnalyticsPlugin({ + // options + }), + ], +} +``` + +### Reporting Events + +The plugin will automatically report page view events when visiting and switching pages. + +Besides, a global `hmt` array is available on the `window` object, and you can use it for [custom events reporting](https://tongji.baidu.com/holmes/Analytics/%E6%8A%80%E6%9C%AF%E6%8E%A5%E5%85%A5%E6%8C%87%E5%8D%97/JS%20API/JS%20API%20%E4%BD%BF%E7%94%A8%E6%89%8B%E5%86%8C). + +## Options + +### id + +- Type: `string` + +- Details: The ID of Baidu Analytics, which is the query of `hm.js` URL. diff --git a/docs-next/plugins/analytics/google-analytics.md b/docs-next/plugins/analytics/google-analytics.md new file mode 100644 index 0000000000..168bc5b798 --- /dev/null +++ b/docs-next/plugins/analytics/google-analytics.md @@ -0,0 +1,78 @@ +# google-analytics + + + +Integrate [Google Analytics](https://analytics.google.com/) into VuePress. + +This plugin will import [gtag.js](https://developers.google.com/analytics/devguides/collection/gtagjs) for [Google Analytics 4](https://support.google.com/analytics/answer/10089681). + +## Usage + +```bash +npm i -D @vuepress/plugin-google-analytics@next +``` + +```ts +import { googleAnalyticsPlugin } from '@vuepress/plugin-google-analytics' + +export default { + plugins: [ + googleAnalyticsPlugin({ + // options + }), + ], +} +``` + +### Reporting Events + +Google Analytics will [automatically collect some events](https://support.google.com/analytics/answer/9234069), such as `page_view`, `first_visit`, etc. + +So if you only want to collect some basic data of your site, you don't need to do anything else except setting the [Measurement ID](#id) correctly. + +After using this plugin, the global `gtag()` function is available on the `window` object, and you can use it for [custom events reporting](https://developers.google.com/analytics/devguides/collection/ga4/events). + +## Options + +### id + +- Type: `string` + +- Details: + + The Measurement ID of Google Analytics 4, which should start with `'G-'`. + + You can follow the instructions [here](https://support.google.com/analytics/answer/9539598) to find your Measurement ID. Notice the difference between Google Analytics 4 Measurement ID (i.e. "G-" ID) and Universal Analytics Tracking ID (i.e. "UA-" ID). + +- Example: + +```ts +export default { + plugins: [ + googleAnalyticsPlugin({ + id: 'G-XXXXXXXXXX', + }), + ], +} +``` + +### debug + +- Type: `boolean` + +- Details: + + Set to `true` to enable sending events to DebugView. [See more information on DebugView](https://support.google.com/analytics/answer/7201382). + +- Example: + +```ts +export default { + plugins: [ + googleAnalyticsPlugin({ + id: 'G-XXXXXXXXXX', + debug: true, + }), + ], +} +``` diff --git a/docs-next/plugins/analytics/umami-analytics.md b/docs-next/plugins/analytics/umami-analytics.md new file mode 100644 index 0000000000..70f317561b --- /dev/null +++ b/docs-next/plugins/analytics/umami-analytics.md @@ -0,0 +1,70 @@ +# umami-analytics + + + +Integrate [Umami Analytics](https://umami.is/) into VuePress. + +## Usage + +```bash +npm i -D @vuepress/plugin-umami-analytics@next +``` + +```ts +import { umamiAnalyticsPlugin } from '@vuepress/plugin-umami-analytics' + +export default { + plugins: [ + umamiAnalyticsPlugin({ + // options + }), + ], +} +``` + +You can use [Umami Cloud](https://cloud.umami.is/login) or [Self-host Umami](https://umami.is/docs/install). + +### Reporting Events + +The plugin will automatically report page view events when visiting and switching pages. + +Besides, a global `umami` object is available on the `window` object, and you can call `umami.track` for [custom tracker function](https://umami.is/docs/tracker-functions). + +## Options + +### id + +- Type: `string` +- Details: The website ID in Umami Analytics + +### link + +- Type: `string` +- Details: Link of umami analytics script + +### autoTrack + +- Type: `boolean` +- Default: `true` +- Details: + + By default, Umami tracks all pageviews and events for you automatically. You can disable this behavior and track events yourself using the tracker functions. + +### cache + +- Type: `boolean` +- Details: + + Cache data to improve the performance of the tracking script. + + Note: This will use session storage so you may need to inform your users. + +### domains + +- Type: `string[]` +- Details: Let the tracker only run on specific domains. + +### hostUrl + +- Type: `string` +- Details: Location to send data diff --git a/docs-next/plugins/blog/README.md b/docs-next/plugins/blog/README.md new file mode 100644 index 0000000000..5072d7899a --- /dev/null +++ b/docs-next/plugins/blog/README.md @@ -0,0 +1,3 @@ +# Blog Plugins + + diff --git a/docs-next/plugins/blog/blog/README.md b/docs-next/plugins/blog/blog/README.md new file mode 100644 index 0000000000..a7d9894727 --- /dev/null +++ b/docs-next/plugins/blog/blog/README.md @@ -0,0 +1,21 @@ +# blog + + + +## Usage + +```bash +npm i -D @vuepress/plugin-blog@next +``` + +```ts title=".vuepress/config.ts" +import { blogPlugin } from '@vuepress/plugin-blog' + +export default { + plugins: [ + blogPlugin({ + // options + }), + ], +} +``` diff --git a/docs-next/plugins/blog/blog/config.md b/docs-next/plugins/blog/blog/config.md new file mode 100644 index 0000000000..1bfc50c0eb --- /dev/null +++ b/docs-next/plugins/blog/blog/config.md @@ -0,0 +1,328 @@ +# Config + +## Plugin Options + +### getInfo + +- Type: `(page: Page) => Record` +- Required: No +- Reference: + - [Guide → Gathering Info](./guide.md#gathering-info) +- Details: + + Function getting article info. + + Article info will be injected in route meta so that they will be available later in client composables. + +### filter + +- Type: `(page: Page) => boolean` +- Default: `(page) => Boolean(page.filePathRelative) && !page.frontmatter.home` +- Reference: + - [Guide → Collecting Articles](./guide.md#collecting-articles) +- Details: + + Page filter, determine whether a page should be included. + + By default, all the pages generated from Markdown files but not homepage will be included as articles. + +### category + +- Type: `BlogCategoryOptions[]` +- Required: No +- Reference: + - [Guide → Customizing Categories and Types](./guide.md#customizing-categories-and-types) +- Details: + Blog category config, see [Blog Category Config](#blog-category-config) + +### type + +- Type: `BlogTypeOptions[]` +- Required: No +- Reference: + - [Guide → Customizing Categories and Types](./guide.md#customizing-categories-and-types) +- Details: + Blog type config, see [Blog Type Config](#blog-type-config) + +### slugify + +- Type: `(name: string) => string` +- Default: `(name) => name.replace(/ _/g, '-').replace(/[:?*|\\/<>]/g, "").toLowerCase()` +- Details: Slugify function, used to convert key name which they are register in routes. + +### excerpt + +- Type: `boolean` +- Default: `true` +- Reference: + - [Guide → Generating Excerpt](./guide.md#generating-excerpt) +- Details: Whether generate excerpt for page. + +### excerptSeparator + +- Type: `string` +- Default: `` +- Reference: + - [Guide → Generating Excerpt](./guide.md#generating-excerpt) +- Details: Separator used to split excerpt from page content. + +### excerptLength + +- Type: `number` +- Default: `300` +- Reference: + - [Guide → Generating Excerpt](./guide.md#generating-excerpt) +- Details: + + Length of excerpt when auto generating. + + ::: tip + + Excerpt length will be the minimal possible length reaching this value. + + You can set it to `0` to disable auto excerpt generation. + + ::: + +### excerptFilter + +- Type: `(page: Page) => boolean` +- Default: `filter` option +- Reference: + - [Guide → Generating Excerpt](./guide.md#generating-excerpt) +- Details: + + Page filter, determine whether the plugin should generate excerpt for it. + + ::: tip + + You should use this to skip pages that you don't need to generate excerpt for. E.g.: If users set `excerpt` or `description` in frontmatter, you may want to use them directly. + + ::: + +### isCustomElement + +- Type: `(tagName: string) => boolean` +- Default: `() => false` +- Reference: + - [Guide → Generating Excerpt](./guide.md#generating-excerpt) +- Details: + + Tags which is considered as custom elements. + + This is used to determine whether a tag is a custom element since all unknown tags are removed in excerpt. + +### metaScope + +- Type: `string` +- Default: `"_blog"` +- Details: + + Key used when injecting info to route meta. + + ::: tip + + Setting to an empty key will inject to route meta directly instead of a field. + + ::: + +### hotReload + +- Type: `boolean` +- Default: Whether using `--debug` flag +- Details: + + Whether enable hotReload in devServer. + + ::: tip To theme developers + + It's disabled by default because it does have performance impact in sites with a lot of categories and types. And it can slow down hotReload speed when editing Markdown. + + If users are adding or organizing your categories or tags, you may tell them to enable this, for the rest it's better to keep it disabled. + + Also, you can try to detect number of pages in users project and decide whether to enable it. + + ::: + +## Blog Category Config + +Blog category config should be an array, while each item is controlling a "category" rule. + +```ts +interface BlogCategoryOptions { + /** + * Unique category name + */ + key: string + + /** + * Function getting category from page + */ + getter: (page: Page) => string[] + + /** + * A custom function to sort the pages + */ + sorter?: (pageA: Page, pageB: Page) => number + + /** + * Path pattern of page to be registered + * + * @description `:key` will be replaced by the "slugify" result of the original key + * + * @default `/:key/` + */ + path?: string + + /** + * Page layout name + * + * @default 'Layout' + */ + layout?: string + + /** + * Frontmatter + */ + frontmatter?: (localePath: string) => Record + + /** + * Item page path pattern or custom function to be registered + * + * @description When filling in a string, `:key` and `:name` will be replaced by the "slugify" result of the original key and name + * + * @default `/:key/:name/` + */ + itemPath?: string | ((name: string) => string) + + /** + * Item page layout name + * + * @default 'Layout' + */ + itemLayout?: string + + /** + * Items Frontmatter + */ + itemFrontmatter?: (name: string, localePath: string) => Record +} +``` + +## Blog Type Config + +Blog type config should be an array, while each item is controlling a "type" rule. + +```ts +interface BlogTypeOptions { + /** + * Unique type name + */ + key: string + + /** + * A filter function to determine whether a page should be the type + */ + filter: (page: Page) => boolean + + /** + * A custom function to sort the pages + */ + sorter?: (pageA: Page, pageB: Page) => number + + /** + * Page path to be registered + * + * @default '/:key/' + */ + path?: string + + /** + * Layout name + * + * @default 'Layout' + */ + layout?: string + + /** + * Frontmatter + */ + frontmatter?: (localePath: string) => Record +} +``` + +## Composition API + +You can import the following API from `@vuepress/plugin-blog/client`. + +- Blog category + + ```ts + const useBlogCategory: < + T extends Record = Record, + >( + key?: string, + ) => ComputedRef> + ``` + + Argument `key` should be the category unique key. + + If no key is passed, the plugin will try to use the key in current path. + +- Blog category + + ```ts + const useBlogType: < + T extends Record = Record, + >( + key?: string, + ) => ComputedRef> + ``` + + Argument `key` should be the type unique key. + + If no key is passed, the plugin will try to use the key in current path. + +Returning values are: + +```ts +interface Article = Record> { + /** Article path */ + path: string + /** Article info */ + info: T +} + +interface BlogCategoryData< + T extends Record = Record, +> { + /** Category path */ + path: string + + /** + * Only available when current route matches an item path + */ + currentItems?: Article[] + + /** Category map */ + map: { + /** Unique key under current category */ + [key: string]: { + /** Category path of the key */ + path: string + /** Category items of the key */ + items: Article[] + } + } +} + +interface BlogTypeData< + T extends Record = Record, +> { + /** Type path */ + path: string + + /** Items under current type */ + items: Article[] +} +``` diff --git a/docs-next/plugins/blog/blog/guide.md b/docs-next/plugins/blog/blog/guide.md new file mode 100644 index 0000000000..497e1d83cc --- /dev/null +++ b/docs-next/plugins/blog/blog/guide.md @@ -0,0 +1,339 @@ +--- +title: Guide +icon: lightbulb +--- + +With `@vuepress/plugin-blog`, you can easily bring blog feature into your theme. + +## Collecting Articles + +The plugin filters all pages using `filter` option to drop pages you don't want. + +::: tip By default, all pages generating from Markdown files but not homepage are considered as articles. + +::: + +You can fully customize pages to collect through option `filter`, which accepts a function `(page: Page) => boolean`. + +## Gathering Info + +You should set `getInfo` option with a function accepting `Page` as argument and returning an object containing the info you want. + +The plugin will collect all the info you want and write them to `routeMeta` field of each page, so you will be able to get this information through Composition API later. + +::: details Demo + +```ts title="theme entrance" +import { blogPlugin } from '@vuepress/plugin-blog' + +export default { + name: 'vuepress-theme-xxx', + plugins: [ + blogPlugin({ + filter: ({ filePathRelative, frontmatter }) => { + // drop those pages which is NOT generated from file + if (!filePathRelative) return false + + // drop those pages in `archives` directory + if (filePathRelative.startsWith('archives/')) return false + + // drop those pages which do not use default layout + if (frontmatter.home || frontmatter.layout) return false + + return true + }, + + getInfo: ({ frontmatter, git = {}, data = {} }) => { + // getting page info + const info: Record = { + author: frontmatter.author || '', + categories: frontmatter.categories || [], + date: frontmatter.date || git.createdTime || null, + tags: frontmatter.tags || [], + excerpt: data.excerpt || '', + } + + return info + }, + }), + // other plugins ... + ], +} +``` + +::: + +## Customizing Categories and Types + +Basically, you would want 2 types of collection in your blog: + +- Category: + + "Category" means grouping articles with their labels. + + For example, each article may have their "categories" and "tags". + +- Type: + + "Type" means identifying articles with conditions. + + For example, you may want to describe some of your articles as diary. + +After understanding description of these 2 types, you can set `category` and `type` options, each accepts an array, and each element represents a configuration. + +Let's start with 2 examples here. + +Imagine you are setting tags for each articles with `tag` field in page frontmatter. You want a tag mapping page in `/tag/` with `TagMap` layout, and group each tag list with tagName in `/tag/tagName` with `TagList` layout, you probably need a configuration like this: + +```ts title="theme entrance" +import { blogPlugin } from '@vuepress/plugin-blog' + +export default { + name: 'vuepress-theme-xxx', + plugins: [ + blogPlugin({ + // other options ... + category: [ + { + key: 'tag', + getter: ({ frontmatter }) => frontmatter.tag || [], + path: '/tag/', + layout: 'TagMap', + frontmatter: () => ({ title: 'Tag page' }), + itemPath: '/tag/:name/', + itemLayout: 'TagList', + itemFrontmatter: (name) => ({ title: `Tag ${name}` }), + }, + ], + }), + // other plugins ... + ], +} +``` + +Also, you may want to star some of your articles, and display them to visitors. When you are setting `star: true` in frontmatter to mark them, you probably need a configuration like this to display them in `/star/` path with `StarList` layout: + +```ts title="theme entrance" +import { blogPlugin } from '@vuepress/plugin-blog' + +export default { + name: 'vuepress-theme-xxx', + plugins: [ + blogPlugin({ + // other options ... + type: [ + { + key: 'star', + filter: ({ frontmatter }) => frontmatter.star, + path: '/star/', + layout: 'StarList', + frontmatter: () => ({ title: 'Star page' }), + }, + ], + }), + // other plugins ... + ], +} +``` + +See, setting these 2 types is easy. For full options, please see [Category Config](./config.md#blog-category-config) and [Type Config](./config.md#blog-type-config). + +## Using Composition API in Client-side + +When generating each page, the plugin will set following information under `frontmatter.blog`: + +```ts +interface BlogFrontmatterOptions { + /** Current type of the page */ + type: 'category' | 'type' + /** Unique key under current category or tag */ + key: string + /** + * Current category name + * + * @description Only available in category item page + */ + name?: string +} +``` + +So you can invoke `useBlogCategory()` and `useBlogType()` directly, and the result will be the category or type bind to current route. + +Also, you can pass `key` you want as argument, then you will get information bind to that key. + +So with node side settings above, you can get information about "tag" and "star" in client side: + +`TagMap` layout: + +```vue + + + +``` + +`TagList` layout: + +```vue + + + +``` + +`StarList` layout: + +```vue + + + +``` + +For return types, please see [Composition API Return Types](./config.md#composition-api). + +## I18n Support + +This plugin adds native i18n support, so your settings will be automatically applied to each language. + +For example, if user has the following locales' config, and you are setting the "star" example above: + +```ts title=".vuepress/config.ts" +export default { + locales: { + '/': { + lang: 'en-US', + }, + '/zh/': { + lang: 'zh-CN', + }, + }, +} +``` + +Then `/zh/star/` and `/star/` will both be available, and only articles under the correct locale will appear. + +## Generating Excerpt + +This plugin provides a built-in excerpt generator, which can be enabled by setting `excerpt` option to `true`. + +::: tip Excerpt introduction + +An excerpt is an HTML fragment that is used to display a short description of an article in the blog list, so the excerpt has the following restrictions: + +- It doesn't support any unknown tags (including all Vue components) and Vue syntax, so these contents will be removed when generating. If you have custom components (non-Vue components), set `isCustomElement` option. +- Since the snippet is an HTML fragment, you will not be able to import any images via relative paths or aliases, they will be removed directly. If you want to keep images, please use absolute path based on `.vuepress/public` or full URL to ensure they can be accessed in other places. + +::: + +The excerpt generator will try to find a valid excerpt separator from markdown contents, if it finds one, it will use content before the separator. The separator is default ``, and you can customize it by setting `excerptSeparator` option. + +If it cannot find a valid separator, it will parse content from the beginning of markdown file, and stop till its length reaches a preset value. The value is default `300`, and you can customize it by setting `excerptLength` option. + +To choose which page should generate excerpt, you can use `excerptFilter` option. + +::: tip Example + +Normally you may want to use `frontmatter.description` if users set them, so you can let filter function return `false` if `frontmatter.description` is not empty. + +::: diff --git a/docs-next/plugins/blog/comment/README.md b/docs-next/plugins/blog/comment/README.md new file mode 100644 index 0000000000..3971c65072 --- /dev/null +++ b/docs-next/plugins/blog/comment/README.md @@ -0,0 +1,21 @@ +# comment + + + +## Usage + +```bash +npm i -D @vuepress/plugin-comment@next +``` + +```ts title=".vuepress/config.ts" +import { commentPlugin } from '@vuepress/plugin-comment' + +export default { + plugins: [ + commentPlugin({ + // options + }), + ], +} +``` diff --git a/docs-next/plugins/blog/comment/artalk/README.md b/docs-next/plugins/blog/comment/artalk/README.md new file mode 100644 index 0000000000..0b57d2fd28 --- /dev/null +++ b/docs-next/plugins/blog/comment/artalk/README.md @@ -0,0 +1,33 @@ +# Artalk + +Artalk is a neat self-hosted commenting system that you can easily deploy on your server and put into your front-end page. + +Come to your blog, or anywhere, place the Artalk comment box, so that the page has rich social functions. + + + +## Install + +```bash +npm i -D artalk +``` + +## Deploy Artalk Server + +See the [Artalk documentation](https://artalk.js.org/guide/deploy.html). + +## Configuration + +Please set `provider: "Artalk"` and pass your server link to `server` in the plugin options. + +For other configuration items, see [Artalk Config](./config.md). + +::: tip + +The plugin retains the `el` option and inserts Artalk itself on the page. At the same time, the plugin will automatically set the `pageTitle`, `pageKey` and `site` options for you according to the VuePress information. + +::: + +## Darkmode + +To let Artalk apply the correct theme, you need to pass a boolean value to `` through `darkmode` prop, representing whether the dark mode is currently enabled. diff --git a/docs-next/plugins/blog/comment/artalk/config.md b/docs-next/plugins/blog/comment/artalk/config.md new file mode 100644 index 0000000000..b3a145a368 --- /dev/null +++ b/docs-next/plugins/blog/comment/artalk/config.md @@ -0,0 +1,41 @@ +# Artalk Options + +## Config + +See [Artalk Configuration](https://artalk.js.org/guide/frontend/config.html) for details. + +- The `el` `pageTitle`, `pageKey` and `site` options are reserved for plugins, they will be inferred from VuePress config. + +- Two function options `imgUploader` and `avatarURLBuilder` can only be set on client side. + +## Plugin Config + +You can directly configure serializable options in the plugin options: + +```ts title=".vuepress/config.ts" +import { commentPlugin } from '@vuepress/plugin-comment' +import { defineUserConfig } from 'vuepress' + +export default defineUserConfig({ + plugins: [ + commentPlugin({ + provider: 'Artalk', + // other options + // ... + }), + ], +}) +``` + +## Client Config + +You can use the `defineArtalkConfig` function to customize Artalk: + +```ts title=".vuepress/client.ts" +import { defineArtalkConfig } from '@vuepress/plugin-comment/client' +import { defineClientConfig } from 'vuepress/client' + +defineArtalkConfig({ + // Artalk config +}) +``` diff --git a/docs-next/plugins/blog/comment/giscus/README.md b/docs-next/plugins/blog/comment/giscus/README.md new file mode 100644 index 0000000000..1f9384d7e7 --- /dev/null +++ b/docs-next/plugins/blog/comment/giscus/README.md @@ -0,0 +1,31 @@ +# Giscus + +Giscus is a commenting system based on GitHub Discussion that is easy to start. + + + +## Preparation + +1. Create a public repository and open discussion panel as a place to store comments. +1. Install the [Giscus App](https://github.com/apps/giscus) to have permission to access the corresponding repository. +1. After completing the above steps, please go to the [Giscus page](https://giscus.app) to get your settings. + + You just need to fill in the repository and Discussion categories, then scroll to the "Enable giscus" section at the bottom of the page and obtain four attributes: `data-repo`, `data-repo-id`, `data-category` and `data-category-id`. + +## Config + +Please set `provider: "Giscus"` and pass `data-repo`, `data-repo-id`, `data-category` and `data-category-id` as plugin options as `repo`, `repoId`, `category` `categoryId`. + +For other options, see [Giscus Config](./config.md). + +## Theme + +By default, the theme of Giscus is `light` or `dark` (based on darkmode status). + +::: tip Darkmode + +To let Giscus apply the correct theme, you need to pass a boolean value to `` via `darkmode` property, indicating whether darkmode is currently enabled. + +::: + +If you want to customize theme in lightmode and darkmode, you can set `lightTheme` and `darkTheme` option with a built-in theme keyword or a custom CSS link starting with `https://`. diff --git a/docs-next/plugins/blog/comment/giscus/config.md b/docs-next/plugins/blog/comment/giscus/config.md new file mode 100644 index 0000000000..98bb02c1a8 --- /dev/null +++ b/docs-next/plugins/blog/comment/giscus/config.md @@ -0,0 +1,143 @@ +# Giscus Options + +## Config + +### repo + +- Type: `string` +- Details: The name of repository to store discussions. + +### repoId + +- Type: `string` +- Details: + The ID of repository to store discussions. Generate through [Giscus Page](https://giscus.app/) + +### category + +- Type: `string` +- Details: + The name of the discussion category. + +### categoryId + +- Type: `string` +- Details: + The ID of the discussion category. Generate through [Giscus Page](https://giscus.app/) + +### mapping + +- Type: `string` +- Default: `"pathname"` +- Details: + Page - Discussion mapping. For details see [Giscus Page](https://giscus.app/) + +### strict + +- Type: `boolean` +- Default: `true` +- Details: + Whether enable strict mapping or not + +### lazyLoading + +- Type: `boolean` +- Default: `true` +- Details: + Whether enable lazy loading or not + +### reactionsEnabled + +- Type: `boolean` +- Default: `true` +- Details: + Whether enable reactions or not + +### inputPosition + +- Type: `"top" | "bottom"` +- Default: `"top"` +- Details: + Input position + +### lightTheme + +- Type: `GiscusTheme` + + ```ts + type GiscusTheme = + | 'dark_dimmed' + | 'dark_high_contrast' + | 'dark_protanopia' + | 'dark' + | 'light_high_contrast' + | 'light_protanopia' + | 'light' + | 'preferred_color_scheme' + | 'transparent_dark' + | `https://${string}` + ``` + +- Default: `"light"` +- Details: + + Giscus theme used in lightmode + + Should be a built-in theme keyword or a css link starting with `https://`. + +### darkTheme + +- Type: `GiscusTheme` + + ```ts + type GiscusTheme = + | 'dark_dimmed' + | 'dark_high_contrast' + | 'dark_protanopia' + | 'dark' + | 'light_high_contrast' + | 'light_protanopia' + | 'light' + | 'preferred_color_scheme' + | 'transparent_dark' + | `https://${string}` + ``` + +- Default: `"dark"` +- Details: + + Giscus theme used in darkmode + + Should be a built-in theme keyword or a css link starting with `https://`. + +## Plugin Config + +You can directly configure serializable options in the plugin options: + +```ts title=".vuepress/config.ts" +import { commentPlugin } from '@vuepress/plugin-comment' +import { defineUserConfig } from 'vuepress' + +export default defineUserConfig({ + plugins: [ + commentPlugin({ + provider: 'Giscus', + // other options + // ... + }), + ], +}) +``` + +## Client Config + +You can use the `defineGiscusConfig` function to customize Giscus: + +```ts title=".vuepress/client.ts" +import { defineGiscusConfig } from '@vuepress/plugin-comment/client' +import { defineClientConfig } from 'vuepress/client' + +defineGiscusConfig({ + // Giscus config +}) +``` diff --git a/docs-next/plugins/blog/comment/guide.md b/docs-next/plugins/blog/comment/guide.md new file mode 100644 index 0000000000..f46ac3dcba --- /dev/null +++ b/docs-next/plugins/blog/comment/guide.md @@ -0,0 +1,98 @@ +--- +layout: CommentPage +--- + +# Guide + +## Setting Options + +You can both set options with plugin options on Node side and set options in [client config file][client-config] on Browser side. + +### With Plugin Options + +```ts +import { commentPlugin } from '@vuepress/plugin-comment' + +// .vuepress/config.ts +export default { + plugins: [ + commentPlugin({ + provider: 'Artalk', // Artalk | Giscus | Waline | Twikoo + + // other options here + // ... + }), + ], +} +``` + +### With Client Config File + +```ts title=".vuepress/client.ts" +import { + defineArtalkConfig, + // defineGiscusConfig, + // defineTwikooConfig, + // defineWalineConfig, +} from '@vuepress/plugin-comment/client' +import { defineClientConfig } from 'vuepress/client' + +defineArtalkConfig({ + // 选项 +}) +``` + +But here are some limitations you should remember: + +- `provider`, locales and other resource related option must be set in plugin options. + + To ensure tree-shaking works, we must optimize entries at node so that bundler can understand which resource should be included in the final bundle. + + These options will be marked with in config reference. + +- Options which can not be serialized to JSON must be set in client config. + + Options that receives function values can not be set in plugin options, as plugins are running under Node.js environment, so we can not pass these values and their contexts to browser. + + These options will be marked with in config reference. + +## Adding Comment + +This plugin globally registers a component ``. + +- If you are a user, you should use `alias` and layout slots to insert the component. We recommended you to insert the comment component (``) after the `` component, and the current page is a demo with default theme. +- If you are a theme developer, you should insert this component in the layout of your theme. + +By default, `` component is enabled globally, and you can use `comment` option in both plugin options and page frontmatter to control it. + +- You can disable it locally by setting `comment: false` in page frontmatter. +- To keep it globally disabled, please set `comment` to `false` in the plugin options. Then you can set `comment: true` in page frontmatter to enable it locally. + +You can set `commentID` option in page frontmatter to customize comment ID, which is used to identify comment storage item to use for a page. By default it will be the `path` of the page, which means if you are deploying the site to multiple places, page with same content across sites will share the same comment data. + +## Available Comment Services + +Currently, you can choose from [Giscus](giscus/README.md), [Waline](waline/README.md), [Artalk](artalk/README.md) and [Twikoo](twikoo/README.md). + +::: tip Recommended comment services + +- Facing programmers and developers: Giscus +- Facing general public: Waline + +::: + +## Common Options + +### provider + +- Type: `"Artalk" | "Giscus" | "Twikoo" | "Waline" | "None"` +- Default: `"None"` +- Details: Comment service provider. + +### comment + +- Type: `boolean` +- Default: `true` +- Details: Whether to enable comment feature by default. + +[client-config]: https://vuejs.press/guide/configuration.html#client-config-file diff --git a/docs-next/plugins/blog/comment/twikoo/README.md b/docs-next/plugins/blog/comment/twikoo/README.md new file mode 100644 index 0000000000..0f41c86d90 --- /dev/null +++ b/docs-next/plugins/blog/comment/twikoo/README.md @@ -0,0 +1,31 @@ +# Twikoo + +A concise, safe and free static site commenting system, based on [Tencent Cloud Development](https://curl.qcloud.com/KnnJtUom). + + + +## Install + +```bash +npm i -D twikoo +``` + +## Getting started + +1. Apply for [MongoDB](https://www.mongodb.com/cloud/atlas/register) account +1. Create a free MongoDB database, the recommended region is `AWS / N. Virginia (us-east-1)` +1. Click CONNECT on the Clusters page, follow the steps to allow connections from all IP addresses ([Why?](https://vercel.com/support/articles/how-to-allowlist-deployment-ip-address)), create Database user, and record the database connection string, please change the `` in the connection string to the database password +1. Sign up for a [Vercel](https://vercel.com/signup) account +1. Click the button below to deploy Twikoo to Vercel in one click + + [![Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/imaegoo/twikoo/tree/dev/src/vercel-min) + +1. Go to Settings - Environment Variables, add the environment variable `MONGODB_URI`, the value is the database connection string in step 3 +1. Go to Overview, click the link under Domains, if the environment configuration is correct, you can see the prompt "Twikoo cloud function is running normally" +1. Vercel Domains (with `https://` prefix, e.g. `https://xxx.vercel.app`) is your environment ID + +## Configuration + +Please set `provider: "Twikoo"` and set `envId` in the plugin options. + +For other configuration items, see [Twikoo Config](./config.md). diff --git a/docs-next/plugins/blog/comment/twikoo/config.md b/docs-next/plugins/blog/comment/twikoo/config.md new file mode 100644 index 0000000000..80a4383ad2 --- /dev/null +++ b/docs-next/plugins/blog/comment/twikoo/config.md @@ -0,0 +1,41 @@ +# Twikoo Options + +## Config + +### envId + +- Type: `string` +- Required: Yes +- Details: Vercel address. + +## Plugin Config + +You can directly configure serializable options in the plugin options: + +```ts title=".vuepress/config.ts" +import { commentPlugin } from '@vuepress/plugin-comment' +import { defineUserConfig } from 'vuepress' + +export default defineUserConfig({ + plugins: [ + commentPlugin({ + provider: 'Twikoo', + // other options + // ... + }), + ], +}) +``` + +## Client Config + +You can use the `defineTwikooConfig` function to customize Twikoo: + +```ts title=".vuepress/client.ts" +import { defineTwikooConfig } from '@vuepress/plugin-comment/client' +import { defineClientConfig } from 'vuepress/client' + +defineTwikooConfig({ + // Twikoo config +}) +``` diff --git a/docs-next/plugins/blog/comment/waline/README.md b/docs-next/plugins/blog/comment/waline/README.md new file mode 100644 index 0000000000..f7493d87e8 --- /dev/null +++ b/docs-next/plugins/blog/comment/waline/README.md @@ -0,0 +1,100 @@ +# Waline + +A safe comment system with backend. + + + +## Install + +```bash +npm i -D @waline/client +``` + +## LeanCloud Settings (Database) + +1. [sign in](https://console.leancloud.app/login) or [sign up](https://console.leancloud.app/register) LeanCloud and enter [Console](https://console.leancloud.app/apps). + +1. Click [Create app](https://console.leancloud.app/apps) button to create a new app and enter a name you like: + + ![Create App](./assets/leancloud-app-1.jpg) + +1. Enter the app, then select `Settings` > `App Keys` at the left bottom corner. You will see `APP ID`, `APP Key` and `Master Key` of your app. We will use them later + + ![ID and Key](./assets/leancloud-app-2.jpg) + +## Deploy to Vercel (Server) + +[![Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwalinejs%2Fwaline%2Ftree%2Fmain%2Fexample) + +1. Click the dark button above, it will redirect you to vercel to deploy with waline template. + + ::: tip + + If you haven't logged in, we recommend you to sign in with GitHub. + + ::: + +1. Input your Vercel project name then click `Create`. + + ![skip team](/images/comment/vercel-2.png) + +1. Repo which named you input before will be created and initialized automatically base on waline example template by Vercel. + + ![deploy](/images/comment/vercel-3.png) + + After one minute or two, vercel should finish the deployment. Click `Go to Dashboard` button to redirect to your application dashboard. + + ![deploy](/images/comment/vercel-4.png) + +1. Click `Settings` menu on the top, and `Environment Variables` button on the side to go to environment variables setting page. Then set `LEAN_ID`, `LEAN_KEY` and `LEAN_MASTER_KEY`. The variables' value should be the ones you got in the previous step. `APP ID` is the value of `LEAN_ID`, and `APP Key` to `LEAN_KEY`, `Master Key` to `LEAN_MASTER_KEY`. + + ![set environment variables](/images/comment/vercel-5.png) + +1. To let your environment variables setting active, you need redeploy your application. Click `Deployments` menu on the top and find the latest deployment at the top of list, click `Redeploy` button in the right dropdown menu. + + ![redeploy](/images/comment/vercel-6.png) + +1. If everything is ok, vercel will redirect to `Overview` page to start redeployment. Wait a moment the `STATUS` will change to `Ready`. Now you can click `Visit` to visit the site. This link is your server address. + + ![redeploy success](/images/comment/vercel-7.png) + +## Assign Domain (Optional) + +1. Click `Settings` - `Domains` to go to domain setting page. + +1. Input domain you want to assign and click `Add` button. + + ![Add domain](/images/comment/vercel-8.png) + +1. Add a new `CNAME` record in your domain service server. + + | Type | Name | Value | + | ----- | ------- | -------------------- | + | CNAME | example | cname.vercel-dns.com | + +1. You can use your own domain to visit waline comment system after go into effect. :tada: + + - serverURL:example.your-domain.com + - admin panel:example.your-domain.com/ui + + ![success](/images/comment/vercel-9.png) + +## Client + +### Using plugin + +Set `provider: "Waline"` in the plugin options, and set `serverURL` as the link obtained in the previous step. + +Then, place the `` component at a suitable location in your site (usually at the bottom of the page), you will be able to see the comment box. + +::: tip + +You can also pass in other options supported by Waline (except `el`). For details, see [Waline Config](config.md) + +::: + +## Comment Management (Management) + +1. After the deployment is complete, please visit `/ui/register` to register. The first person to register will be set as an administrator. +1. After you log in as administrator, you can see the comment management interface. You can edit, mark or delete comments here. +1. Users can also register their account through comment box, and they will be redirected to their profile page after logging in. diff --git a/docs-next/plugins/blog/comment/waline/assets/leancloud-app-1.jpg b/docs-next/plugins/blog/comment/waline/assets/leancloud-app-1.jpg new file mode 100644 index 0000000000..49330b3592 Binary files /dev/null and b/docs-next/plugins/blog/comment/waline/assets/leancloud-app-1.jpg differ diff --git a/docs-next/plugins/blog/comment/waline/assets/leancloud-app-2.jpg b/docs-next/plugins/blog/comment/waline/assets/leancloud-app-2.jpg new file mode 100644 index 0000000000..9d4006903f Binary files /dev/null and b/docs-next/plugins/blog/comment/waline/assets/leancloud-app-2.jpg differ diff --git a/docs-next/plugins/blog/comment/waline/config.md b/docs-next/plugins/blog/comment/waline/config.md new file mode 100644 index 0000000000..bfdb89ff5f --- /dev/null +++ b/docs-next/plugins/blog/comment/waline/config.md @@ -0,0 +1,307 @@ +# Waline Config + +## Config + +### serverURL + +- Type: `string` +- Required: Yes +- Details: Waline server address url + +### emoji + +- Type: `(string | WalineEmojiInfo)[] | false` + + ```ts + type WalineEmojiPresets = `http://${string}` | `https://${string}` + + interface WalineEmojiInfo { + /** + * Emoji name show on tab + */ + name: string + /** + * Current folder link + */ + folder?: string + /** + * Common prefix of Emoji icons + */ + prefix?: string + /** + * Type of Emoji icons, will be regarded as file extension + */ + type?: string + /** + * Emoji icon show on tab + */ + icon: string + /** + * Emoji image list + */ + items: string[] + } + ``` + +- Default: `['//unpkg.com/@waline/emojis@1.1.0/weibo']` +- Reference: + - [Guide → Emoji](https://waline.js.org/en/guide/features/emoji.html) +- Details: Emoji settings. + +### dark + +- Type: `string | boolean` +- Default: `false` +- Reference: + + - [Custom Style](https://waline.js.org/en/guide/features/style.html) + +- Details: + + Darkmode support + + - Setting a boolean will set the dark mode according to its value. + - Set it to `'auto'` will display darkmode due to device settings. + - Filling in a CSS selector will enable darkmode only when the selector match waline ancestor nodes. + +### commentSorting + +- Type: `WalineCommentSorting` +- Default: `'latest'` +- Details: + + Comment list sorting methods. + + Optional values: `'latest'`, `'oldest'`, `'hottest'` + +### meta + +- Type: `string[]` +- Default: `['nick','mail','link']` +- Details: + + Reviewer attributes. + + Optional values: `'nick'`, `'mail'`, `'link'` + +### requiredMeta + +- Type: `string[]` +- Default: `[]` +- Details: + + Set required fields, optional values: + + - `[]` + - `['nick']` + - `['nick','mail']` + +### login + +- Type: `string` +- Default value: `'enable'` +- Details: + + Login mode status, optional values: + + - `'enable'`: enable login (default) + - `'disable'`: Login is disabled, users should fill in information to comment + - `'force'`: Forced login, users must login to comment + +### wordLimit + +- Type: `number | [number, number]` +- Default: `0` +- Details: + + Comment words limit. When a single number is filled in, it 's the maximum number of comment words. No limit when set to `0`. + +### pageSize + +- Type: `number` +- Default: `10` +- Details: + + Number of comments per page. + +### imageUploader + +- Type: `WalineImageUploader | false` + + ```ts + type WalineImageUploader = (image: File) => Promise + ``` + +- Reference: + + - [Cookbook → Upload Image](https://waline.js.org/en/cookbook/customize/upload-image.html) + +- Details: + + Custom image upload method. The default behavior is to embed images Base 64 encoded, you can set this to `false` to disable image uploading. + + The function should receive an image object and return a Promise that provides the image address. + +### highlighter + +- Type: `WalineHighlighter | false` + + ```ts + type WalineHighlighter = (code: string, lang: string) => string + ``` + +- Reference: + + - [Cookbook → Customize Highlighter](https://waline.js.org/en/cookbook/customize/highlighter.html) + +- Details: + + **Code highlighting**, use `hanabi` by default. The function passes in original content of code block and language of the code block. You should return a string directly. + + You can pass in a code highlighter of your own, or set to `false` to disable code highlighting. + +### texRenderer + +- Type: `WalineTexRenderer | false` + + ```ts + type WalineTexRenderer = (blockMode: boolean, tex: string) => string + ``` + +- Reference: + + - [Cookbook → Customize TeX Renderer](https://waline.js.org/en/cookbook/customize/tex-renderer.html) + - [MathJax](https://www.mathjax.org/) + - [KaTeX](https://katex.org/) + +- Details: + + Customize TeX rendering, the default behavior is to prompt that the preview mode does not support TeX. The function provides two parameters, the first parameter indicates whether it should be rendered in block level, and the second parameter is the string of the TeX content, and return a HTML string as render result. + + You can import TeX renderer to provide preview feature. We recommend you to use Katex or MathJax, or you can set to `false` to disable parsing TeX. + +### search + +- Type: `WalineSearchOptions | false` + + ```ts + interface WalineSearchImageData extends Record { + /** + * Image link + */ + src: string + + /** + * Image title + * + * @description Used for alt attribute of image + */ + title?: string + + /** + * Image preview link + * + * @description For better loading performance, we will use this thumbnail first in the list + * + * @default src + */ + preview?: string + } + + type WalineSearchResult = WalineSearchImageData[] + + interface WalineSearchOptions { + /** + * Search action + */ + search: (word: string) => Promise + + /** + * Default result when opening list + * + * @default () => search('') + */ + default?: () => Promise + + /** + * Fetch more action + * + * @description It will be triggered when the list scrolls to the bottom. If your search service supports paging, you should set this to achieve infinite scrolling + * + * @default (word) => search(word) + */ + more?: (word: string, currentCount: number) => Promise + } + ``` + +- Details: Customize search features, you can disable search function by setting it to `false`. + +### recaptchaV3Key + +- Type: `string` +- Details: + + reCAPTCHA V3 is a captcha service provided by Google. You can add reCAPTCHA V3 site key with `recaptchaV3Key` to enable it. + + You should also set environment variable `RECAPTCHA_V3_SECRET` for server. + +### reaction + +- Type: `boolean | string[]` +- Default: `false` +- Details: + + Add emoji interaction function to the article, set it to `true` to provide the default emoji, you can also customize the emoji image by setting the emoji url array, and supports a maximum of 8 emojis. + +### metaIcon + +- Type: `boolean` +- Default: `true` +- Details: Whether import meta icon. + +### locales + +- Type: `WalineLocales` + + ```ts + interface WalineLocales { + [localePath: string]: WalineLocale + } + ``` + +- Reference: + - [Waline Locales](https://waline.js.org/en/cookbook/customize/locale.html) +- Details: + Waline locales. + +## Plugin Config + +You can directly configure serializable options in the plugin options: + +```ts title=".vuepress/config.ts" +import { commentPlugin } from '@vuepress/plugin-comment' +import { defineUserConfig } from 'vuepress' + +export default defineUserConfig({ + plugins: [ + commentPlugin({ + provider: 'Waline', + // other options + // ... + }), + ], +}) +``` + +## Client Config + +You can use the `defineWalineConfig` function to customize Waline: + +```ts title=".vuepress/client.ts" +import { defineWalineConfig } from '@vuepress/plugin-comment/client' +import { defineClientConfig } from 'vuepress/client' + +defineWalineConfig({ + // Waline config +}) +``` diff --git a/docs-next/plugins/blog/feed/README.md b/docs-next/plugins/blog/feed/README.md new file mode 100644 index 0000000000..3a2e0f56c6 --- /dev/null +++ b/docs-next/plugins/blog/feed/README.md @@ -0,0 +1,21 @@ +# feed + + + +## Usage + +```bash +npm i -D @vuepress/plugin-feed@next +``` + +```ts title=".vuepress/config.ts" +import { feedPlugin } from '@vuepress/plugin-feed' + +export default { + plugins: [ + feedPlugin({ + // options + }), + ], +} +``` diff --git a/docs-next/plugins/blog/feed/channel.md b/docs-next/plugins/blog/feed/channel.md new file mode 100644 index 0000000000..2081055072 --- /dev/null +++ b/docs-next/plugins/blog/feed/channel.md @@ -0,0 +1,120 @@ +# Channel Config + +The channel plugin option is used to config the feed channel. + +## channel.title + +- Type: `string` +- Default: `SiteConfig.title` + +Channel title + +## channel.link + +- Type: `string` +- Default: Deployment link (generated by `options.hostname` and `context.base`) + +Channel address + +## channel.description + +- Type: `string` +- Default: `SiteConfig.description` + +Channel description + +## channel.language + +- Type: `string` + +- Default: + - `siteConfig.locales['/'].lang` + - If the above is not provided, fall back to `"en-US"` + +The language of the channel + +## channel.copyright + +- Type: `string` + +- Default: + + - Try to read the `author.name` in channel options, and fall back to `Copyright by $author` + +- Recommended to set manually: **Yes** + +Channel copyright information + +## channel.pubDate + +- Type: `string` (must be a valid Date ISOString) +- Default: time when the plugin is called each time +- Recommended to set manually: **Yes** + +Publish date of the Channel + +## channel.lastUpdated + +- Type: `string` (must be a valid Date ISOString) +- Default: time when the plugin is called each time + +Last update time of channel content + +## channel.ttl + +- Type: `number` +- Recommended to set manually: **Yes** + +The effective time of the content. It's the time to keep the cache after request without making new requests. + +## channel.image + +- Type: `string` +- Recommended to set manually: **Yes** + +A picture presenting the channel. A square picture with a size not smaller than 512×512 is recommended. + +## channel.icon + +- Type: `string` +- Recommended to set manually: **Yes** + +An icon representing a channel, a square picture, with not less than 128×128 in size, and transparent background color is recommended. + +## channel.author + +- Type: `FeedAuthor` +- Recommended to set manually: **Yes** + +The author of the channel. + +::: details FeedAuthor format + +```ts +interface FeedAuthor { + /** Author name */ + name: string + /** Author's email */ + email?: string + /** Author's site */ + url?: string + /** + * Author's avatar address + * + * Square, preferably not less than 128×128 with transparent background + */ + avatar?: string +} +``` + +## channel.hub + +- Type: `string` + +Link to Websub. Websub requires a server backend, which is inconsistent with VuePress, so ignore it if there is no special need. + +::: tip WebSub + +For details, see [Websub](https://w3c.github.io/websub/#subscription-migration). + +::: diff --git a/docs-next/plugins/blog/feed/config.md b/docs-next/plugins/blog/feed/config.md new file mode 100644 index 0000000000..60a9e94234 --- /dev/null +++ b/docs-next/plugins/blog/feed/config.md @@ -0,0 +1,192 @@ +# Plugin Config + +## hostname + +- Type: `string` +- Required: Yes + +The domain name of the deployment site. + +## atom + +- Type: `boolean` +- Default: `false` + +Whether to output Atom syntax files. + +## json + +- Type: `boolean` +- Default: `false` + +Whether output JSON syntax files. + +## rss + +- Type: `boolean` +- Default: `false` + +Whether to output RSS syntax files. + +## image + +- Type: `string` + +A large image/icon of the feed, probably used as banner. + +## icon + +- Type: `string` + +A small icon of the feed, probably used as favicon. + +## count + +- Type: `number` +- Default: `100` + +Set the maximum number of items in the feed. After all pages are sorted, the first `count` items will be intercepted. + +If your site has a lot of articles, you may consider this option to reduce feed file size. + +## preservedElements + +- Type: `(RegExp | string)[] | (tagName: string) => boolean` + +Custom element or component which should be preserved in feed. + +::: tip By default, all unknown tags will be removed. + +::: + +## filter + +- Type: `(page: Page)=> boolean` +- Default: + + ```js + ;({ frontmatter, filePathRelative }) => + Boolean(frontmatter.feed ?? (filePathRelative && !frontmatter.home)) + ``` + +A custom filter function, used to filter feed items. + +## sorter + +- Type: `(pageA: Page, pageB: Page)=> number` + +- Default: + + ```ts + // dateSorter is from @vuepress/helper + ;(pageA: Page, pageB: Page): number => + dateSorter( + pageA.data.git?.createdTime + ? new Date(pageA.data.git?.createdTime) + : pageA.frontmatter.date, + pageB.data.git?.createdTime + ? new Date(pageB.data.git?.createdTime) + : pageB.frontmatter.date, + ) + ``` + +Custom sorter function for feed items. + +The default sorting behavior is by file adding time coming from git (needs `@vuepress/plugin-git`). + +::: tip + +You should enable `@vuepress/plugin-git` to get the newest created pages as feed items. Otherwise, the feed items will be sorted by the default order of pages in VuePress. + +::: + +## channel + +`channel` option is used to config _Feed Channels_. + +For available options, please see [Config → Channel](channel.md) + +## devServer + +- Type: `boolean` +- Default: `false` + +Whether enabled in devServer. + +::: tip + +For performance reasons, we do not provide hot reload. Reboot your devServer to sync your changes. + +::: + +## devHostname + +- Type: `string` +- Default: `"http://localhost:${port}"` + +Hostname to use in devServer + +## atomOutputFilename + +- Type: `string` +- Default: `"atom.xml"` + +Atom syntax output filename, relative to dest folder. + +## atomXslTemplate + +- Type: `string` +- Default: Content of `@vuepress/plugin-feed/templates/atom.xsl` + +Atom xsl template file content. + +## atomXslFilename + +- Type: `string` +- Default: `"atom.xsl"` + +Atom xsl filename, relative to dest folder. + +## jsonOutputFilename + +- Type: `string` +- Default: `"feed.json"` + +JSON syntax output filename, relative to dest folder. + +## rssOutputFilename + +- Type: `string` +- Default: `"rss.xml"` + +RSS syntax output filename, relative to dest folder. + +## rssXslTemplate + +- Type: `string` +- Default: Content of `@vuepress/plugin-feed/templates/rss.xsl` + +RSS xsl template file content. + +## rssXslFilename + +- Type: `string` +- Default: `"rss.xsl"` + +RSS syntax xsl filename, relative to dest folder. + +## getter + +Feed generation controller, see [Feed Getter](./getter.md). + +::: tip The plugin has a built-in getter, only set this if you want full control of feed generation. + +::: + +## locales + +- Type: `Record` + +You can use it to specific options for each locale. + +Any options above are supported except `hostname`. diff --git a/docs-next/plugins/blog/feed/frontmatter.md b/docs-next/plugins/blog/feed/frontmatter.md new file mode 100644 index 0000000000..971d906c71 --- /dev/null +++ b/docs-next/plugins/blog/feed/frontmatter.md @@ -0,0 +1,153 @@ +# Frontmatter Config + +You can control each feed item generation by setting page frontmatter. + +## Additions and Removals + +By default, all articles are added to the feed stream. Set `feed: false` in frontmatter to remove a page from feed. + +## Frontmatter Information + +### title + +- Type: `string` + +Automatically generated by VuePress, defaults to the h1 content of the page + +### description + +- Type: `string` + +Description of the page + +### date + +- Type: `Date` + +Date when the page was published + +### article + +- Type: `boolean` + +Whether the page is an article + +> If this is set to `false`, the page will not be included in the final feed. + +### copyright + +- Type: `string` + +Page copyright information + +### cover / image / banner + +- Type: `string` + +Image used as page cover , should be full link or absolute link. + +## Frontmatter Options + +### feed.title + +- Type: `string` + +The title of the feed item + +### feed.description + +- Type: `string` + +Description of the feed item + +### feed.content + +- Type: `string` + +The content of the feed item + +### feed.author + +- Type: `FeedAuthor[] | FeedAuthor` + +The author of the feed item + +::: details FeedAuthor format + +```ts +interface FeedAuthor { + /** + * Author name + */ + name?: string + + /** + * Author email + */ + email?: string + + /** + * Author site + * + * @description json format only + */ + url?: string + + /** + * Author avatar + * + * @description json format only + */ + avatar?: string +} +``` + +::: + +### feed.contributor + +- Type: `FeedContributor[] | FeedContributor` + +Contributors to feed item + +::: details FeedContributor format + +```ts +interface FeedContributor { + /** + * Author name + */ + name?: string + + /** + * Author email + */ + email?: string + + /** + * Author site + * + * @description json format only + */ + url?: string + + /** + * Author avatar + * + * @description json format only + */ + avatar?: string +} +``` + +::: + +### feed.guid + +- Type: `string` + +The identifier of feed item, used to identify the feed item. + +::: tip You should ensure every feed has a unique guid. + +::: diff --git a/docs-next/plugins/blog/feed/getter.md b/docs-next/plugins/blog/feed/getter.md new file mode 100644 index 0000000000..1ee93e30f5 --- /dev/null +++ b/docs-next/plugins/blog/feed/getter.md @@ -0,0 +1,211 @@ +# Feed Getter + +You can take full control of feed items generation by setting `getter` in the plugin options. + +## getter.title + +- Type: `(page: Page, app: App) => string` + +Item title getter + +## getter.link + +- Type: `(page: Page, app: App) => string` + +Item link getter + +## getter.description + +- Type: `(page: Page, app: App) => string | undefined` + +Item description getter + +::: tip + +Due to Atom support HTML in summary, so you can return HTML content here if possible, but the content must start with mark `html:`. + +::: + +## getter.content + +- Type: `(page: Page, app: App) => string` + +Item content getter + +## getter.author + +- Type: `(page: Page, app: App) => FeedAuthor[]` + +Item author getter. + +::: tip The getter should return an empty array when author information is missing. + +::: + +::: details FeedAuthor format + +```ts +interface FeedAuthor { + /** + * Author name + */ + name?: string + + /** + * Author email + */ + email?: string + + /** + * Author site + * + * @description json format only + */ + url?: string + + /** + * Author avatar + * + * @description json format only + */ + avatar?: string +} +``` + +::: + +## getter.category + +- Type: `(page: Page, app: App) => FeedCategory[] | undefined` + +Item category getter. + +::: details FeedCategory format + +```ts +interface FeedCategory { + /** + * Category Name + */ + name: string + + /** + * A string that identifies a categorization taxonomy + * + * @description rss format only + */ + domain?: string + + /** + * the categorization scheme via a URI + * + * @description atom format only + */ + scheme?: string +} +``` + +::: + +## getter.enclosure + +- Type: `(page: Page, app: App) => FeedEnclosure | undefined` + +Item enclosure getter. + +::: details FeedEnclosure format + +```ts +interface FeedEnclosure { + /** + * Enclosure link + */ + url: string + + /** + * what its type is + * + * @description should be a standard MIME Type, rss format only + */ + Type: string + + /** + * Size in bytes + * + * @description rss format only + */ + length?: number +} +``` + +::: + +## getter.publishDate + +- Type: `(page: Page, app: App) => Date | undefined` + +Item release date getter + +## getter.lastUpdateDate + +- Type: `(page: Page, app: App) => Date` + +Item last update date getter + +## getter.image + +- Type: `(page: Page, app: App) => string` + +Item Image Getter + +::: tip Ensure it's returning a full URL + +::: + +## getter.contributor + +- Type: `(page: Page, app: App) => FeedContributor[]` + +Item Contributor Getter + +::: tip The getter should return an empty array when contributor information is missing. + +::: + +::: details FeedContributor format + +```ts +interface FeedContributor { + /** + * Author name + */ + name?: string + + /** + * Author email + */ + email?: string + + /** + * Author site + * + * @description json format only + */ + url?: string + + /** + * Author avatar + * + * @description json format only + */ + avatar?: string +} +``` + +::: + +## getter.copyright + +- Type: `(page: Page, app: App) => string | undefined` + +Item copyright getter diff --git a/docs-next/plugins/blog/feed/guide.md b/docs-next/plugins/blog/feed/guide.md new file mode 100644 index 0000000000..bff5166712 --- /dev/null +++ b/docs-next/plugins/blog/feed/guide.md @@ -0,0 +1,46 @@ +# Guide + +## Usage + +The plugin can generate feed files in the following three formats for you: + +- Atom 1.0 +- JSON 1.1 +- RSS 2.0 + +Please set `atom`, `json` or `rss` to `true` in the plugin options according to the formats you want to generate. + +To correctly generate feed links, you need to set `hostname` in the plugin options, + +## Readable Preview + +When you open the feed file in browser, we magically convert atom and rss feed xml to human readable html via xsl template. Check [atom](/atom.xml) and [rss](/rss.xml) feed of this site as an example! + +If you want to preview your feed in devServer, set `devServer: true` in plugin options. You may also need to set `devHostname` if you are not using the default `http://localhost:{port}`. + +## Channel settings + +You can customize the feed channel information by setting the `channel` option. + +We recommend the following settings: + +- Convert the date of creating the feed to ISOString and write it into `channel.pubDate` +- The update period of the content set in `channel.ttl` (unit: minutes) +- Set copyright information via `channel.copyright` +- Set the channel author via `channel.author`. + +For detailed options and their default values, see [Channel Config](./channel.md) + +## Feed Generation + +By default, all articles are added to the feed stream. + +You can set `feed` and other options in page frontmatter to control contents of feed item. See [Frontmatter Config](./frontmatter.md) for how they are converted. + +You can take full control of feed items generation by configuring the `getter` in the plugin options. For detailed options and their default values, see [Configuration → Feed Getter](./getter.md). + +### I18n Config + +The plugin generates separate feeds for each language. + +You can provide different settings for different languages via `locales` in the plugin options. diff --git a/docs-next/plugins/development/README.md b/docs-next/plugins/development/README.md new file mode 100644 index 0000000000..b205a0b9a9 --- /dev/null +++ b/docs-next/plugins/development/README.md @@ -0,0 +1,3 @@ +# Theme Development Plugins + + diff --git a/docs-next/plugins/development/active-header-links.md b/docs-next/plugins/development/active-header-links.md new file mode 100644 index 0000000000..8d2f015cb1 --- /dev/null +++ b/docs-next/plugins/development/active-header-links.md @@ -0,0 +1,74 @@ +# active-header-links + + + +This plugin will listen to page scroll event. When the page scrolls to a certain _header anchor_, this plugin will change the route hash to that _header anchor_ if there is a corresponding _header link_. + +This plugin is mainly used to develop themes, and has been integrated into the default theme. You won't need to use it directly in most cases. + +## Usage + +```bash +npm i -D @vuepress/plugin-active-header-links@next +``` + +```ts +import { activeHeaderLinksPlugin } from '@vuepress/plugin-active-header-links' + +export default { + plugins: [ + activeHeaderLinksPlugin({ + // options + }), + ], +} +``` + +## Options + +### headerLinkSelector + +- Type: `string` + +- Default: `'a.vp-sidebar-item'` + +- Details: + + Selector of _header link_. + + If a _header anchor_ does not have a corresponding _header link_, this plugin won't change the route hash to that anchor when scrolling to it. + +### headerAnchorSelector + +- Type: `string` + +- Default: `'.header-anchor'` + +- Details: + + Selector of _header anchor_. + + You don't need to specify this option unless you have changed the `permalinkClass` option of [markdown-it-anchor](https://github.com/valeriangalliat/markdown-it-anchor#readme) via [markdown.anchor](https://vuejs.press/reference/config.html#markdown-anchor). + +- Also see: + - [Guide > Markdown > Syntax Extensions > Header Anchors](https://vuejs.press/guide/markdown.html#header-anchors) + +### delay + +- Type: `number` + +- Default: `200` + +- Details: + + The delay of the debounced scroll event listener. + +### offset + +- Type: `number` + +- Default: `5` + +- Details: + + Even if you click the link of the _header anchor_ directly, the `scrollTop` might not be exactly equal to `offsetTop` of the _header anchor_, so we add an offset to avoid the error. diff --git a/docs-next/plugins/development/git.md b/docs-next/plugins/development/git.md new file mode 100644 index 0000000000..d5a6f3cbc5 --- /dev/null +++ b/docs-next/plugins/development/git.md @@ -0,0 +1,330 @@ +# git + + + +This plugin will collect git information of your pages, including the created and updated time, the contributors, the changelog, etc. + +The [lastUpdated](../../themes/default/config.md#lastupdated) and [contributors](../../themes/default/config.md#contributors) of default theme is powered by this plugin. + +This plugin is mainly used to develop themes. You won't need to use it directly in most cases. + +## Usage + +```bash +npm i -D @vuepress/plugin-git@next +``` + +```ts +import { gitPlugin } from '@vuepress/plugin-git' + +export default { + plugins: [ + gitPlugin({ + // options + }), + ], +} +``` + +## Git Repository + +This plugin requires your project to be inside a [Git Repository](https://git-scm.com/book/en/Git-Basics-Getting-a-Git-Repository), so that it can collect information from the commit history. + +You should ensure all commits are available when building your site. For example, CI workflows usually clone your repository with [--depth 1](https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---depthltdepthgt) to avoid fetching all commits, so you should disable the behavior to make this plugin work properly in CI. + +::: warning +This plugin will significantly slow down the speed of data preparation, especially when you have a lot of pages. You can consider disabling this plugin in `dev` mode to get better development experience. +::: + +## Options + +### createdTime + +- Type: `boolean` + +- Default: `true` + +- Details: + + Whether to collect page created time or not. + +### updatedTime + +- Type: `boolean` + +- Default: `true` + +- Details: + + Whether to collect page updated time or not. + +### contributors + +- Type: `boolean | ContributorsOptions` + + ```ts + interface ContributorInfo { + /** + * Contributor's username on the git hosting service + */ + username: string + /** + * Contributor name displayed on the page, default is `username` + */ + name?: string + /** + * The alias of the contributor, + * Since contributors may have different usernames saved in their local git configuration + * compared to their usernames on the hosting service, In this case, aliases can be used to + * map to the actual usernames. + */ + alias?: string[] | string + /** + * The avatar url of the contributor. + * + * If the git hosting service is `github`, it can be ignored and left blank, + * as the plugin will automatically fill it in. + */ + avatar?: string + /** + * The url of the contributor + * + * If the git hosting service is `github`, it can be ignored and left blank, + * as the plugin will automatically fill it in. + */ + url?: string + } + + interface ContributorsOptions { + /** + * Contributor information + */ + info?: ContributorInfo[] + + /** + * Whether to add avatar in contributor information + * @default false + */ + avatar?: boolean + + /** + * Functions to transform contributors, e.g. remove duplicates ones and sort them. + * The input is the contributors collected by this plugin, and the output should be the transformed contributors. + */ + transform?: (contributors: GitContributor[]) => GitContributor[] + } + ``` + +- Default: `true` + +- Details: + + Whether to collect page contributors or not. + +### changelog + +- Type: `false | ChangelogOptions` + + ```ts + interface ChangelogOptions { + /** + * Maximum number of changelog + */ + maxCount?: number + + /** + * The url of the git repository, e.g: https://github.com/vuepress/ecosystem + */ + repoUrl?: string + + /** + * Commit url pattern + * Default: ':repo/commit/:hash' + * + * - `:repo` - The url of the git repository + * - `:hash` - Hash of the commit record + */ + commitUrlPattern?: string + + /** + * Issue url pattern + * Default: ':repo/issues/:issue' + * + * - `:repo` - The url of the git repository + * - `:issue` - Id of the issue + */ + issueUrlPattern?: string + + /** + * Tag url pattern + * Default: ':repo/releases/tag/:tag' + * + * - `:repo` - The url of the git repository + * - `:tag` - Name of the tag + */ + tagUrlPattern?: string + } + ``` + +- Default: `false` + +- Details: + + Whether to collect page changelog or not. + +### filter + +- Type: `(page: Page) => boolean` + +- Details: + + Page filter, if it returns `true`, the page will collect git information. + +## Frontmatter + +### gitInclude + +- Type: `string[]` + +- Details: + + An array of relative paths to be included when calculating page data. + +- Example: + +```md +--- +gitInclude: + - relative/path/to/file1 + - relative/path/to/file2 +--- +``` + +### contributors + +- Type: `boolean | string[]` + +- Details: + + Whether to collect contributor information for the current page, this value will override the [contributors](#contributors) configuration item. + + - `true` - Collect contributor information + - `false` - Do not collect contributor information + - `string[]` - List of additional contributors, sometimes there are additional contributors on the page, and this configuration item can be used to specify the list of additional contributors to obtain contributor information + +### changelog + +- Type: `boolean` + +- Details: + + Whether to collect the change history for the current page, this value will override the [changelog](#changelog) configuration item. + +## Page Data + +This plugin will add a `git` field to page data. + +After using this plugin, you can get the collected git information in page data: + +```ts +import type { GitPluginPageData } from '@vuepress/plugin-git' +import { usePageData } from 'vuepress/client' + +export default { + setup(): void { + const page = usePageData() + console.log(page.value.git) + }, +} +``` + +### git.createdTime + +- Type: `number` + +- Details: + + Unix timestamp in milliseconds of the first commit of the page. + + This attribute would take the minimum of the first commit timestamps of the current page and the files listed in [gitInclude](#gitinclude). + +### git.updatedTime + +- Type: `number` + +- Details: + + Unix timestamp in milliseconds of the last commit of the page. + + This attribute would take the maximum of the last commit timestamps of the current page and the files listed in [gitInclude](#gitinclude). + +### git.contributors + +- Type: `GitContributor[]` + +```ts +interface GitContributor { + name: string + email: string + commits: number + avatar?: string + url?: string +} +``` + +- Details: + + The contributors information of the page. + + This attribute would also include contributors to the files listed in [gitInclude](#gitinclude). + +### git.changelog + +- 类型: `GitChangelog[]` + +```ts +interface GitChangelog { + /** + * Commit hash + */ + hash: string + /** + * Unix timestamp in milliseconds + */ + date: number + /** + * Commit message + */ + message: string + /** + * Commit author name + */ + author: string + /** + * Commit author email + */ + email: string + /** + * The url of the commit + */ + commitUrl?: string + /** + * The url of the release tag + */ + tagUrl?: string + + /** + * The list of co-authors + */ + coAuthors?: { + name: string + email: string + }[] +} +``` + +- Details: + + The changelog of the page. + + This attribute would also include contributors to the files listed in [gitInclude](#gitinclude). diff --git a/docs-next/plugins/development/palette.md b/docs-next/plugins/development/palette.md new file mode 100644 index 0000000000..4385b8b26c --- /dev/null +++ b/docs-next/plugins/development/palette.md @@ -0,0 +1,203 @@ +# palette + + + +Provide palette support for your theme. + +This plugin is mainly used to develop themes, and has been integrated into the default theme. You won't need to use it directly in most cases. + +For theme authors, this plugin will help you to provide styles customization for users. + +## Usage + +```bash +npm i -D @vuepress/plugin-palette@next +``` + +```ts +import { palettePlugin } from '@vuepress/plugin-palette' + +export default { + plugins: [ + palettePlugin({ + // options + }), + ], +} +``` + +## Palette and Style + +This plugin will provide a `@vuepress/plugin-palette/palette` (palette file) and a `@vuepress/plugin-palette/style` (style file) to be imported in your theme styles. + +The palette file is used for defining style variables, so it's likely to be imported at the beginning of your theme styles. For example, users can define [CSS variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties), [SASS variables](https://sass-lang.com/documentation/variables), [LESS variables](http://lesscss.org/features/#variables-feature) or [Stylus variables](https://stylus-lang.com/docs/variables.html) in the palette, and then you can use those variables in your theme styles. + +The style file is used for overriding the default styles or adding extra styles, so it's likely to be imported at the end of your theme styles. + +## Usage + +Use this plugin in your theme, assuming you are using SASS: + +```ts +export default { + // ... + plugins: [palettePlugin({ preset: 'sass' })], +} +``` + +### Usage of Palette + +Import the plugin's palette file where your theme needs to use the corresponding variables, such as in the `Layout.vue` file: + +```vue + + + +``` + +Then users can customize variables in `.vuepress/styles/palette.scss`: + +```scss +$color: green; +``` + +### Usage of Style + +Import the plugin's style file after your theme's styles, for example, in the `clientConfigFile`: + +```ts +// import your theme's style file +import 'path/to/your/theme/style' +// import the plugin's style file +import '@vuepress/plugin-palette/style' +``` + +Then users can add extra styles in `.vuepress/styles/index.scss` and override the default styles of your theme: + +```scss +h1 { + font-size: 2.5rem; +} +``` + +## Options + +### preset + +- Type: `'css' | 'sass' | 'less' | 'stylus'` + +- Default: `'css'` + +- Details: + + Set preset for other options. + + If you don't need advanced customization of the plugin, it's recommended to only set this option and omit other options. + +### userPaletteFile + +- Type: `string` + +- Default: + + - css: `'.vuepress/styles/palette.css'` + - sass: `'.vuepress/styles/palette.scss'` + - less: `'.vuepress/styles/palette.less'` + - stylus: `'.vuepress/styles/palette.styl'` + +- Details: + + File path of the user palette file, relative to source directory. + + The default value depends on the [preset](#preset) option. + + The file is where users define style variables, and it's recommended to keep the default file path as a convention. + +### tempPaletteFile + +- Type: `string` + +- Default: + + - css: `'styles/palette.css'` + - sass: `'styles/palette.scss'` + - less: `'styles/palette.less'` + - stylus: `'styles/palette.styl'` + +- Details: + + File path of the generated palette temp file, relative to temp directory. + + The default value depends on the [preset](#preset) option. + + You should import the palette file via `'@vuepress/plugin-palette/palette'` alias, so you don't need to change this option in most cases. + +### userStyleFile + +- Type: `string` + +- Default: + + - css: `'.vuepress/styles/index.css'` + - sass: `'.vuepress/styles/index.scss'` + - less: `'.vuepress/styles/index.less'` + - stylus: `'.vuepress/styles/index.styl'` + +- Details: + + File path of the user style file, relative to source directory. + + The default value depends on the [preset](#preset) option. + + The file is where users override default styles or add extra styles, and it's recommended to keep the default file path as a convention. + +### tempStyleFile + +- Type: `string` + +- Default: + + - css: `'styles/index.css'` + - sass: `'styles/index.scss'` + - less: `'styles/index.less'` + - stylus: `'styles/index.styl'` + +- Details: + + File path of the generated style temp file, relative to temp directory. + + The default value depends on the [preset](#preset) option. + + You should import the style file via `'@vuepress/plugin-palette/style'` alias, so you don't need to change this option in most cases. + +### importCode + +- Type: `(filePath: string) => string` + +- Default: + + - css: `` (filePath) => `@import '${filePath}';\n` `` + - sass: `` (filePath) => `@forward 'file:///${filePath}';\n` `` + - less: `` (filePath) => `@import '${filePath}';\n` `` + - stylus: `` (filePath) => `@require '${filePath}';\n` `` + +- Details: + + Function to generate import code. + + The default value depends on the [preset](#preset) option. + + This option is used for generating [tempPaletteFile](#temppalettefile) and [tempStyleFile](#tempstylefile), and you don't need to change this option in most cases. diff --git a/docs-next/plugins/development/reading-time.md b/docs-next/plugins/development/reading-time.md new file mode 100644 index 0000000000..712f41f69d --- /dev/null +++ b/docs-next/plugins/development/reading-time.md @@ -0,0 +1,216 @@ +# reading-time + + + +This plugin will generate word count and estimated reading time for each page. + +## Usage + +```bash +npm i -D @vuepress/plugin-reading-time@next +``` + +```ts +import { readingTimePlugin } from '@vuepress/plugin-reading-time' + +export default { + plugins: [ + readingTimePlugin({ + // options + }), + ], +} +``` + +The plugin will inject reading time information into the `readingTime` of the page data, where: + +- `readingTime.minutes`: estimated reading time (minutes) `number` +- `readingTime.words`: word count `number` + +### Getting data on Node Side + +For any page, you can get estimated reading time and word count from `page.data.readingTime`: + +```ts +page.data.readingTime // { minutes: 3.2, words: 934 } +``` + +You can access it for further processing in the `extendsPage` lifecycle and other lifecycle: + +```js +export default { + // ... + extendsPage: (page) => { + page.data.readingTime // { minutes: 3.2, words: 934 } + }, + + onInitialized: (app) => { + app.pages.forEach((page) => { + page.data.readingTime // { minutes: 3.2, words: 934 } + }) + }, +} +``` + +### Getting data on Client Side + +You can import `useReadingTimeData` and `useReadingTimeLocale` from `@vuepress/plugin-reading-time/client` to get the reading time data and locale data of the current page: + +```vue + +``` + +## Options + +### wordPerMinute + +- Type: `number` +- Default: `300` +- Details: + Reading speed (words per minute) + +### locales + +- Type: `ReadingTimePluginLocaleConfig` + + ```ts + interface ReadingTimePluginLocaleData { + /** + * Word template, `$word` will be automatically replaced by actual words + */ + word: string + + /** + * Text for less than one minute + */ + less1Minute: string + + /** + * Time template + */ + time: string + } + + interface ReadingTimePluginLocaleConfig { + [localePath: string]: ReadingTimePluginLocaleData + } + ``` + +- Required: No + +- Details: + + Locales config for reading-time plugin. + +::: details Built-in Supported Languages + +- **Simplified Chinese** (zh-CN) +- **Traditional Chinese** (zh-TW) +- **English (United States)** (en-US) +- **German** (de-DE) +- **German (Australia)** (de-AT) +- **Russian** (ru-RU) +- **Ukrainian** (uk-UA) +- **Vietnamese** (vi-VN) +- **Portuguese (Brazil)** (pt-BR) +- **Polish** (pl-PL) +- **French** (fr-FR) +- **Spanish** (es-ES) +- **Slovak** (sk-SK) +- **Japanese** (ja-JP) +- **Turkish** (tr-TR) +- **Korean** (ko-KR) +- **Finnish** (fi-FI) +- **Indonesian** (id-ID) +- **Dutch** (nl-NL) + +::: + +## Client API + +You can import and use these APIs from `@vuepress/plugin-reading-time/client`: + +::: tip These APIs won't throw even you disable the plugin. + +::: + +### useReadingTimeData + +```ts +interface ReadingTime { + /** Expect reading time in minute unit */ + minutes: number + /** Words count of content */ + words: number +} + +const useReadingTimeData: () => ComputedRef +``` + +`null` is returned when the plugin is disabled. + +### useReadingTimeLocale + +```ts +interface ReadingTimeLocale { + /** Expect reading time text in locale */ + time: string + /** Word count text in locale */ + words: string +} + +const useReadingTimeLocale: () => ComputedRef +``` + +## Advanced Usage + +This plugin is targeting plugin and theme developers mostly, so we provide a "Use API": + +```js title="your plugin or theme entry" +import { useReadingTimePlugin } from '@vuepress/plugin-reading-time' + +export default (options) => (app) => { + useReadingTimePlugin(app, { + // your options + }) + + return { + name: 'vuepress-plugin-xxx', // or vuepress-theme-xxx + } +} +``` + +::: tip Why you should use "Use API" + +1. When you register a plugin multiple times, vuepress will gives you warning about that telling you only the first one takes effect. The `useReadingTimePlugin` automatically detects if the plugin is registered and avoid registering multiple times. +1. If you access reading time data in `extendsPage` lifecycle, then `@vuepress/plugin-reading-time` must be called before your theme or plugin, otherwise you will get `undefined` for `page.data.readingTime`. The `useReadingTimePlugin` ensures that `@vuepress/plugin-reading-time` is called before your theme or plugin. + +::: + +We also provides a `removeReadingTimePlugin` api to remove the plugin.You can use this to ensure your call take effect or clear the plugin: + +```js title="your plugin or theme entry" +import { useReadingTimePlugin } from '@vuepress/plugin-reading-time' + +export default (options) => (app) => { + // this removes any existing reading time plugin at this time + removeReadingTimePlugin(app) + + // so this will take effect even if there is a reading time plugin registered before + useReadingTimePlugin(app, { + // your options + }) + + return { + name: 'vuepress-plugin-xxx', // or vuepress-theme-xxx + } +} +``` diff --git a/docs-next/plugins/development/rtl.md b/docs-next/plugins/development/rtl.md new file mode 100644 index 0000000000..51ba5af3dd --- /dev/null +++ b/docs-next/plugins/development/rtl.md @@ -0,0 +1,53 @@ +# rtl + + + +This plugin will set direction to rtl on configured locales. + +## Usage + +```bash +npm i -D @vuepress/plugin-rtl@next +``` + +```ts +import { rtlPlugin } from '@vuepress/plugin-rtl' + +export default { + plugins: [ + rtlPlugin({ + // options + locales: ['/ar/'], + }), + ], +} +``` + +## Options + +### locales + +- Type: `string[]` +- Default: `['/']` +- Details: + Locale path to enable rtl. + +### selector + +- Type: `SelectorOptions` + + ```ts + interface SelectorOptions { + [cssSelector: string]: { + [attr: string]: string + } + } + ``` + +- Default: `{ 'html': { dir: 'rtl' } }` + +- Details: + + Selector to enable rtl. + + The default settings mean that the `dir` attribute of the `html` element will be set to `rtl` in rtl locales. diff --git a/docs-next/plugins/development/sass-palette/README.md b/docs-next/plugins/development/sass-palette/README.md new file mode 100644 index 0000000000..cef5e670a2 --- /dev/null +++ b/docs-next/plugins/development/sass-palette/README.md @@ -0,0 +1,36 @@ +# sass-palette + + + +This plugin is mainly facing plugin and theme developers, it is more powerful than [`@vuepress/plugin-palette`](../palette.md). + +::: tip + +You should manually install these deps in your project: + +- When using Vite bundler: `sass-embedded` +- When using Webpack bundler: `sass-embedded` and `sass-loader` + +::: + +## Usage + +You must invoke `useSassPalettePlugin` function during plugin initialization to use this plugin. + +```bash +npm i -D @vuepress/plugin-sass-palette@next +``` + +```js title="Your plugin or theme entry" +import { useSassPalettePlugin } from 'vuepress-plugin-sass-palette' + +export const yourPlugin = (options) => (app) => { + useSassPalettePlugin(app, { + // plugin options + }) + + return { + // your plugin api + } +} +``` diff --git a/docs-next/plugins/development/sass-palette/config.md b/docs-next/plugins/development/sass-palette/config.md new file mode 100644 index 0000000000..422f8ba7e3 --- /dev/null +++ b/docs-next/plugins/development/sass-palette/config.md @@ -0,0 +1,91 @@ +# Config + +## Options + +### id + +- Type: `string` +- Required: Yes + +Identifier for palette, used to avoid duplicate registration. + +### config + +- Type: `string` +- Default: `` `.vuepress/styles/${id}-palette.scss` `` + +User config file path, relative to source dir. + +::: tip + +This is the file where user set style variables. + +The default filename is starting with ID above. + +::: + +### defaultConfig + +- Type: `string` +- Default: `"@vuepress/plugin-sass-palette/styles/default/config.scss"` + +Default config file path, should be absolute path. + +::: tip + +This is the file you should use to provide default values with `!default`. + +::: + +### palette + +- Type: `string` +- Default: `` `.vuepress/styles/${id}-palette.scss` `` + +User palette file path, relative to source dir. + +::: tip + +This is the file where user control injected CSS variables. All the variables will be converted in to kebab-case and injected. + +The default filename is starting with ID above. + +::: + +### defaultPalette + +- Type: `string` +- Default: `"@vuepress/plugin-sass-palette/styles/default/palette.scss"` + +Default palette file path, should be absolute path. + +::: tip + +This is the file you should use to provide default CSS variables with `!default`. All the variable will be converted in to kebab-case and injected. + +::: + +### generator + +- Type: `string` +- Required: No + +Custom generator, used to generate derivative values with the above config. + +E.g.: You may want to provide a `$theme-color-light` based on `$theme-color`. + +### style + +- Type: `string` +- Required: No + +User style file path, relative to source dir. + +## Alias + +Available alias are: + +- config: `@sass-palette/${id}-config` (based on `id`) +- palette: `@sass-palette/${id}-palette` (based on `id`) +- style: `@sass-palette/${id}-style` (only available when `style` option is set) +- helper: `@sass-palette/helper` diff --git a/docs-next/plugins/development/sass-palette/guide.md b/docs-next/plugins/development/sass-palette/guide.md new file mode 100644 index 0000000000..3e528c1861 --- /dev/null +++ b/docs-next/plugins/development/sass-palette/guide.md @@ -0,0 +1,266 @@ +# Guide + +Comparing to [`@vuepress/plugin-palette`](../palette.md), this plugin allows you to: + +- Derive related styles based on user configuration +- Provide style customization similar to theme in plugins +- Group applications across multiple plugins or themes via id option + +Before using the plugin, you need to understand the id option, as well as three styling concepts: configuration, palette and generator. + +## ID Option + +To get started, you should understand that this plugin is designed to take across plugins and theme (unlike the official one only for theme). + +We are providing `id` option to do that, and using this plugin (by calling `useSassPalette`) with same ID won't have any side effects. Also, all the alias and module names have an ID prefix. + +This will allow you to: + +- Share same style system across your plugins (or themes) using same ID without any side effects. + + All aliases and module names have an ID prefix, which means you can use a set of style variables within your plugins (or theme) to unify your styles without being affected by other plugins (or themes). + + Users can configure all color variables, breakpoints, and other style configurations in the same file and have them automatically applied on themes and plugins with the same ID. + + ::: tip Example + + `vuepress-theme-hope` and other related plugins use the same ID `hope` to call the plugin, so the styles configured by the user in the theme will automatically take effect in all plugins. + + ::: + +- With different ID, plugins and theme won't affect others. We recommend you to set the `id` variable with your plugin name. + + With the default settings, users will set your plugin style under `.vuepress/styles` directory with Sass files starting with your ID prefix. And you can access the variables you need with `${id}-config` and `${id}-palette`. + + ::: tip Example + + `vuepress-theme-hope` is using ID `"hope"`, and just imagine a `vuepress-plugin-abc` is using `"abc"`. They can get their own variables with module name `hope-config` `hope-palette` and `abc-config` `abc-palette`. + + ::: + +- Calling the plugin with the same ID has no side effects. + + ::: tip example + + `vuepress-theme-hope` and other related plugins use the same ID `hope` to call the plugin. + + ::: + +## Config + +Config file is used for Sass variable only. It holds Sass variables which can be used via `${id}-config` in other files later. + +You can specify a file (probably in `.vuepress/styles/` directory) as user config file. So you can get the module containing every variable later in Sass files. Also, you are able to provide a default config files where you can place fallback values for variables with `!default`. + +::: details An example + +Imagine you are invoking the plugin with the following options in `vuepress-plugin-abc`: + +```js +useSassPalette(app, { + id: 'abc', + defaultConfig: 'vuepress-plugin-abc/styles/config.scss', +}) +``` + +User config file: + +```scss title=".vuepress/styles/abc-palette.scss" +$navbar-height: 3.5rem; +``` + +Default config file: + +```scss title="vuepress-plugin-abc/styles/palette.scss" +$navbar-height: 2rem !default; +$sidebar-width: 18rem !default; +``` + +You can get the following variables in the plugin Sass files: + +```scss +// +``` + +Then the default `Layout` layout has been overridden by your own local layout, which will add a custom footer to every normal pages in default theme (excluding homepage): + +![extending-a-theme](/images/cookbook/extending-a-theme-01.png) + +## Components Replacement + +The layout slots are useful, but sometimes you might find it's not flexible enough. Default theme also provides the ability to replace a single component. + +Default theme has registered [alias](https://v2.vuepress.vuejs.org/plugin-api.html#alias) for every [non-global components](https://github.com/vuepress/ecosystem/tree/main/themes/theme-default/src/client/components) with a `@theme` prefix. For example, the alias of `HomeFooter.vue` is `@theme/HomeFooter.vue`. + +Then, if you want to replace the `HomeFooter.vue` component, just override the alias in your config file `.vuepress/config.ts`: + +```ts +import { defaultTheme } from '@vuepress/theme-default' +import { defineUserConfig } from 'vuepress' +import { getDirname, path } from 'vuepress/utils' + +const __dirname = getDirname(import.meta.url) + +export default defineUserConfig({ + theme: defaultTheme(), + alias: { + '@theme/HomeFooter.vue': path.resolve( + __dirname, + './components/MyHomeFooter.vue', + ), + }, +}) +``` + +## Developing a Child Theme + +Instead of extending the default theme directly in `.vuepress/config.ts` and `.vuepress/client.ts`, you can also develop your own theme extending the default theme: + +```ts +import type { DefaultThemeOptions } from '@vuepress/theme-default' +import { defaultTheme } from '@vuepress/theme-default' +import type { Theme } from 'vuepress/core' +import { getDirname, path } from 'vuepress/utils' + +const __dirname = getDirname(import.meta.url) + +export const childTheme = (options: DefaultThemeOptions): Theme => ({ + name: 'vuepress-theme-child', + extends: defaultTheme(options), + + // override layouts in child theme's client config file + // notice that you would build ts to js before publishing to npm, + // so this should be the path to the js file + clientConfigFile: path.resolve(__dirname, './client.js'), + + // override component alias + alias: { + '@theme/HomeFooter.vue': path.resolve( + __dirname, + './components/MyHomeFooter.vue', + ), + }, +}) +``` diff --git a/docs-next/themes/default/frontmatter.md b/docs-next/themes/default/frontmatter.md new file mode 100644 index 0000000000..cf73d9649c --- /dev/null +++ b/docs-next/themes/default/frontmatter.md @@ -0,0 +1,448 @@ +# Frontmatter + + + +## All Pages + +Frontmatter in this section will take effect in all types of pages. + +### pageClass + +- Type: `string` + +- Details: + + Add extra class name to this page. + +- Example: + +```md +--- +pageClass: custom-page-class +--- +``` + +Then you can customize styles of this page in `.vuepress/styles/custom.css` file: + +```scss +.custom-page-class { + /* page styles */ +} +``` + +- Also see: + - [Default Theme > Styles > Style File](./styles.md#style-file) + +### pageLayout + +- Type: `doc | home | page` + +- Default: `doc` + +- Details: + + Determines the layout of the page. + + - `doc` - It applies default documentation styles to the markdown content. + - `home` - Special layout for "Home Page". You may add extra options such as `hero` and `features` to rapidly create beautiful landing page. + - `page` - Behave similar to `doc` but it applies no styles to the content. Useful when you want to create a fully custom page. + +```yaml +--- +pageLayout: doc +--- +``` + +### navbar + +- Type: `boolean` + +- Default: `true` + +- Details: + + Whether to display [navbar](./config.md#navbar). + +```md +--- +navbar: false +--- +``` + +### externalLinkIcon + +- Type: `boolean` + +- Details: + + Show external link icon in Markdown links. + +```md +--- +externalLinkIcon: false +--- +``` + +### footer + +- Type: `boolean` + +- Details: + + Whether to display [footer](./config.md#footer). + +## Home Page + +Frontmatter in this section will only take effect in home pages. + +### home + +- Type: `boolean` + +- Details: + + Specify whether the page is homepage or a normal page. + + If you don't set this frontmatter or set it to `false`, the page would be a [normal page](#normal-page). + +- Example: + +```md +--- +home: true +--- +``` + +### hero + +Defines contents of home hero section when `pageLayout` is set to `home`. + +```md +--- +home: true +hero: + image: /images/hero.png + name: VuePress Ecosystem + text: VuePress official themes and plugins + tagline: A Vue-powered Static Site Generator +--- +``` + +#### hero.image + +- Type: `DefaultThemeImage` + +- Details: + + Specify the url of the hero image. + +- Also see: + - [Guide > Assets > Public Files](https://v2.vuepress.vuejs.org/guide/assets.html#public-files) + +#### hero.name + +- Type: `string` + +- Details: + + Specify the the hero name. + +#### hero.text + +- Type: `string` + +- Details: + + Specify the the hero text. + +#### hero.tagline + +- Type: `string` + +- Details: + + Specify the the tagline. + +### actions + +- Type: `HeroAction[]` + +```ts +interface HeroAction { + theme?: 'alt' | 'brand' + text: string + link: string + target?: string + rel?: string +} +``` + +- Details: + + Configuration of the action buttons. + +- Example: + +```md +--- +actions: + - text: Get Started + link: /guide/getting-started.html + theme: brand + - text: Introduction + link: /guide/introduction.html + theme: alt +--- +``` + +### features + +- Type: `Feature[]` + +```ts +interface Feature { + icon?: FeatureIcon + title: string + details: string + link?: string + linkText?: string + rel?: string + target?: string +} + +export type FeatureIcon = + | string + | { + light: string + dark: string + alt?: string + width?: string + height?: string + wrap?: boolean + } + | { + src: string + alt?: string + width?: string + height?: string + wrap?: boolean + } +``` + +- Details: + + Configuration of the features list. + +- Example: + +```md +--- +features: + - title: Simplicity First + details: Minimal setup with markdown-centered project structure helps you focus on writing. + - title: Vue-Powered + details: Enjoy the dev experience of Vue, use Vue components in markdown, and develop custom themes with Vue. + - title: Performant + details: VuePress generates pre-rendered static HTML for each page, and runs as an SPA once a page is loaded. +--- +``` + +### markdownStyles + +- Type: `boolean` + +- Default: `true` + +- Details: + + Whether use markdown styles + +## Normal Page + +Frontmatter in this section will only take effect in normal pages. + +### editLink + +- Type: `boolean` + +- Details: + + Enable the _edit this page_ link in this page or not. + +- Also see: + - [Default Theme > Config > editLink](./config.md#editlink) + +### editLinkPattern + +- Type: `string` + +- Details: + + Specify the pattern of the _edit this page_ link of this page. + +- Also see: + - [Default Theme > Config > editLinkPattern](./config.md#editlinkpattern) + +### lastUpdated + +- Type: `boolean` + +- Details: + + Enable the _last updated timestamp_ in this page or not. + +- Also see: + - [Default Theme > Config > lastUpdated](./config.md#lastupdated) + +### contributors + +- Type: `boolean` + +- Details: + + Enable the _contributors list_ in this page or not. + +- Also see: + - [Default Theme > Config > contributors](./config.md#contributors) + +### sidebar + +- Type: `boolean` + +- Details: + + Whether to display the sidebar of this page. + +- Also see: + - [Default Theme > Config > sidebar](./config.md#sidebar) + +### aside + +- Type: `boolean | 'left'` + +- Default: `true` + +- Details: + + Defines the location of the aside component in the `doc` layout. + + - Setting this value to `false` prevents rendering of aside container. + - Setting this value to `true` renders the aside to the right. + - Setting this value to `'left'` renders the aside to the left. + +```yaml +--- +aside: false +--- +``` + +### outline + +- Type: `number | [number, number] | 'deep' | false` + +- Default: `2` + +- Details: + + The levels of header in the outline to display for the page. + It's same as [outline](./config.md#outline), and it overrides the value set in site-level config. + +### prev + +- Type: `string | false | { text?: string; link?: string }` + +- Details: + + Specifies the text/link to show on the link to the previous page. If you don't set this in frontmatter, the text/link will be inferred from the sidebar config. + +- Example: + +```md +--- +# NavLink +prev: + text: Get Started + link: /guide/getting-started.html + +# NavLink - external url +prev: + text: GitHub + link: https://github.com + +# string - page file path +prev: /guide/getting-started.md + +# string - page file relative path +prev: ../../guide/getting-started.md +--- +``` + +### next + +- Type: `string | false | { text?: string; link?: string }` + +- Details: + + Specify the link of the next page. + + If you don't set this frontmatter, the link will be inferred from the sidebar config. + + The type is the same as [prev](#prev) frontmatter. + +### dir + +Sidebar group information used for structure sidebar. + +#### dir.text + +- Type: `string` + +- Default: title of `README.md` + +- Details: + + Group title. + +#### dir.collapsible + +- Type: `boolean` + +- Default: `true` + +- Details: + + Whether group is collapsible + +#### dir.link + +- Type: `boolean` + +- Default: `false` + +- Details: + +- Whether Dir is clickable. + + :::info Setting to `true` means setting group link to link of `README.md`. + ::: + +#### dir.index + +- Type: `boolean` + +- Default: `true` + +- Details: + + Whether index current dir + +#### dir.order + +- Type: `number` + +- Details: + + Group order in sidebar. + + - By filling in a positive number, the page will appear in the front, while the smaller number comes to the front. + - By filling in a negative number, the page will appear in the end, while the greater number comes to the front. (e.g. -1 is after -2) diff --git a/docs-next/themes/default/locale.md b/docs-next/themes/default/locale.md new file mode 100644 index 0000000000..edd615b456 --- /dev/null +++ b/docs-next/themes/default/locale.md @@ -0,0 +1,248 @@ +# Locale Config + +These options configure locale-related texts. + +If your site is served in a different language besides **Built-in Language Support**, you should set these options per locale to provide translations. + +::: details Built-in Language Support + +- Simplified Chinese (zh-CN) +- Traditional Chinese (zh-TW) +- English (United States) (en-US) +- German (de-DE) +- German (Australia) (de-AT) +- Russian (ru-RU) +- Ukrainian (uk-UA) +- Vietnamese (vi-VN) +- Portuguese (Brazil) (pt-BR) +- Polish (pl-PL) +- French (fr-FR) +- Spanish (es-ES) +- Slovak (sk-SK) +- Japanese (ja-JP) +- Turkish (tr-TR) +- Korean (ko-KR) +- Finnish (fi-FI) +- Indonesian (id-ID) +- Dutch (nl-NL) + +::: + +## selectLanguageText + +- Type: `string` + +- Default: `'Languages'` + +- Details: + + Can be used to customize the `aria-label` of the language toggle button in navbar. + + This option will **only take effect inside** the [locales](./config.md#locales) of your theme config. + +## selectLanguageName + +- Type: `string` + +- Details: + + Specify the name of the language of a locale. + + This option will **only take effect inside** the [locales](./config.md#locales) of your theme config. It will be used as the language name of the locale, which will be displayed in the _select language menu_. + +- Example: + +```ts +export default { + locales: { + '/': { + lang: 'en-US', + }, + '/zh/': { + lang: 'zh-CN', + }, + }, + theme: defaultTheme({ + locales: { + '/': { + selectLanguageName: 'English', + }, + '/zh/': { + selectLanguageName: '简体中文', + }, + }, + }), +} +``` + +## outlineTitle + +- Type: `string` + +- Default: `'On this page'` + +- Details: + + The title to be displayed on the outline. + +## sidebarMenuLabel + +- Type: `string` + +- Default: `'Menu'` + +- Details: + + Can be used to customize the sidebar menu label. This label is only displayed in the mobile view. + +## darkModeSwitchLabel + +- Type: `string` + +- Default: `'Appearance'` + +- Details: + + Can be used to customize the dark mode switch label. This label is only displayed in the mobile view. + +## lightModeSwitchTitle + +- Type: `string` + +- Default: `'Switch to light theme'` + +- Details: + + Can be used to customize the light mode switch title that appears on hovering. + +## darkModeSwitchTitle + +- Type: `string` + +- Default: `'Switch to dark theme'` + +- Details: + + Can be used to customize the dark mode switch title that appears on hovering. + +## editLinkText + +- Type: `string` + +- Default: `'Edit this page'` + +- Details: + + Specify the text of the _edit this page_ link. + +## lastUpdatedText + +- Type: `string` + +- Default: `'Last Updated'` + +- Details: + + Specify the text of the _last updated timestamp_ label. + +## contributorsText + +- Type: `string` + +- Default: `'Contributors'` + +- Details: + + Specify the text of the _contributors list_ label. + +## returnToTopLabel + +- Type: `string` + +- Default: `Return to top` + +- Details: + + Can be used to customize the label of the return to top button. This label is only displayed in the mobile view. + +## notFound + +- Type: `NotFoundOptions` + +- Details: + + Customize text of 404 page. + +```ts +export default { + theme: defaultTheme({ + notFound: { + title: 'PAGE NOT FOUND', + quote: + "But if you don't change your direction, and if you keep looking, you may end up where you are heading.", + linkLabel: 'go to home', + linkText: 'Take me home', + code: '404', + }, + }), +} +``` + +```ts +interface NotFoundOptions { + /** + * Set custom not found message. + * @default 'PAGE NOT FOUND' + */ + title?: string + + /** + * Set custom not found description. + * @default "But if you don't change your direction, and if you keep looking, you may end up where you are heading." + */ + quote?: string + + /** + * Set aria label for home link. + * @default 'go to home' + */ + linkLabel?: string + + /** + * Set custom home link text. + * @default 'Take me home' + */ + linkText?: string + + /** + * @default '404' + */ + code?: string +} +``` + +## docFooter + +- Type: `DocFooter` + +- Details: + + Can be used to customize text appearing above previous and next links. Helpful if not writing docs in English. Also can be used to disable prev/next links globally. + +```ts +export default { + theme: defaultTheme({ + docFooter: { + prev: 'Previous page', + next: 'Next page', + }, + }), +} +``` + +```ts +export interface DocFooter { + prev?: string | false + next?: string | false +} +``` diff --git a/docs-next/themes/default/markdown.md b/docs-next/themes/default/markdown.md new file mode 100644 index 0000000000..6896fe5ae5 --- /dev/null +++ b/docs-next/themes/default/markdown.md @@ -0,0 +1,458 @@ +# Markdown + + + +## Tables + +**Input:** + +```md +| Tables | Are | Cool | +| ------------- | :-----------: | ----: | +| col 3 is | right-aligned | $1600 | +| col 2 is | centered | $12 | +| zebra stripes | are neat | $1 | +``` + +**Output:** + +| Tables | Are | Cool | +| ------------- | :-----------: | -----: | +| col 3 is | right-aligned | \$1600 | +| col 2 is | centered | \$12 | +| zebra stripes | are neat | \$1 | + +## Emoji :tada: + +**Input:** + +```txt +:tada: :100: +``` + +**Output:** + +:tada: :100: + +A [list of all emojis](https://github.com/markdown-it/markdown-it-emoji/blob/master/lib/data/full.mjs) is available. + +## Custom Containers + +- Usage: + + ```md + ::: [title] + [content] + ::: + ``` + + The `type` is required, and the `title` and `content` are optional. + + Supported `type` : + + - `info` + - `tip` + - `warning` + - `danger` ( alias `caution` ) + - `details` + - `important` + +- Example 1 (default title): + +**Input:** + +```md +::: info +This is a info +::: + +::: tip +This is a tip +::: + +::: warning +This is a warning +::: + +::: danger +This is a dangerous warning +::: + +::: important +This is an important +::: + +::: details +This is a details block +::: +``` + +**Output:** + +::: info +This is a info +::: + +::: tip +This is a tip +::: + +::: warning +This is a warning +::: + +::: danger +This is a dangerous warning +::: + +::: important +This is an important +::: + +::: details +This is a details block +::: + +- Example 2 (custom title): + +**Input:** + +````md +::: danger STOP +Danger zone, do not proceed +::: + +::: details Click me to view the code + +```ts +console.log('Hello, VuePress!') +``` + +::: +```` + +**Output:** + +::: danger STOP +Danger zone, do not proceed +::: + +::: details Click me to view the code + +```ts +console.log('Hello, VuePress!') +``` + +::: + +## GitHub-flavored Alerts + +VuePress Theme Default also supports [GitHub-flavored alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) to render as callouts. + +They will be rendered the same as the [custom containers](#custom-containers). + +```md +> [!NOTE] +> Highlights information that users should take into account, even when skimming. + +> [!TIP] +> Optional information to help a user be more successful. + +> [!IMPORTANT] +> Crucial information necessary for users to succeed. + +> [!WARNING] +> Critical content demanding immediate user attention due to potential risks. + +> [!CAUTION] +> Negative potential consequences of an action. +``` + +> [!NOTE] +> Highlights information that users should take into account, even when skimming. + +> [!TIP] +> Optional information to help a user be more successful. + +> [!IMPORTANT] +> Crucial information necessary for users to succeed. + +> [!WARNING] +> Critical content demanding immediate user attention due to potential risks. + +> [!CAUTION] +> Negative potential consequences of an action. + +## Line Highlighting in Code Blocks + +**Input:** + +```` +```js{4} +export default { + data () { + return { + msg: 'Highlighted!' + } + } +} +``` +```` + +**Output:** + +```js{4} +export default { + data () { + return { + msg: 'Highlighted!' + } + } +} +``` + +In addition to a single line, you can also specify multiple single lines, ranges, or both: + +- Line ranges: for example `{5-8}`, `{3-10}`, `{10-17}` +- Multiple single lines: for example `{4,7,9}` +- Line ranges and single lines: for example `{4,7-13,16,23-27,40}` + +**Input:** + +```` +```js{1,4,6-8} +export default { // Highlighted + data () { + return { + msg: `Highlighted! + This line isn't highlighted, + but this and the next 2 are.`, + motd: 'VitePress is awesome', + lorem: 'ipsum' + } + } +} +``` +```` + +**Output:** + +```js{1,4,6-8} +export default { // Highlighted + data () { + return { + msg: `Highlighted! + This line isn't highlighted, + but this and the next 2 are.`, + motd: 'VitePress is awesome', + lorem: 'ipsum', + } + } +} +``` + +Alternatively, it's possible to highlight directly in the line by using the `// [!code highlight]` comment. + +**Input:** + +```` +```js +export default { + data () { + return { + msg: 'Highlighted!' // [!!code highlight] + } + } +} +``` +```` + +**Output:** + +```js +export default { + data() { + return { + msg: 'Highlighted!', // [!code highlight] + } + }, +} +``` + +## Focus in Code Blocks + +Adding the `// [!code focus]` comment on a line will focus it and blur the other parts of the code. + +Additionally, you can define a number of lines to focus using `// [!code focus:]`. + +**Input:** + +```` +```js +export default { + data () { + return { + msg: 'Focused!' // [!!code focus] + } + } +} +``` +```` + +**Output:** + +```js +export default { + data() { + return { + msg: 'Focused!', // [!code focus] + } + }, +} +``` + +## Colored Diffs in Code Blocks + +Adding the `// [!code --]` or `// [!code ++]` comments on a line will create a diff of that line, while keeping the colors of the codeblock. + +**Input:** + +```` +```js +export default { + data () { + return { + msg1: 'Removed', // [!!code --] + msg2: 'Added', // [!!code ++] + } + } +} +``` +```` + +**Output:** + +```js +export default { + data() { + return { + msg1: 'Removed', // [!code --] + msg2: 'Added', // [!code ++] + } + }, +} +``` + +## Errors and Warnings in Code Blocks + +Adding the `// [!code warning]` or `// [!code error]` comments on a line will color it accordingly. + +**Input:** + +```` +```js +export default { + data () { + return { + msg1: 'Error', // [!!code error] + msg2: 'Warning' // [!!code warning] + } + } + +``` +```` + +**Output:** + +```js +export default { + data() { + return { + msg1: 'Error', // [!code error] + msg2: 'Warning', // [!code warning] + } + }, +} +``` + +## Code Tabs + +You can group multiple code blocks like this: + +**Input:** + +````md +::: code-tabs + +@tab Foo + +```ts +const foo = 'foo' +``` + +@tab Bar + +```ts +const bar = 'bar' +``` + +::: +```` + +**Output:** + +::: code-tabs + +@tab Foo + +```ts +const foo = 'foo' +``` + +@tab Bar + +```ts +const bar = 'bar' +``` + +::: + +You can also add the `:active` option to display the code block by default. + +**Input:** + +````md +::: code-tabs + +@tab Foo + +```ts +const foo = 'foo' +``` + +@tab:active Bar + +```ts +const bar = 'bar' +``` + +::: +```` + +**Output:** + +::: code-tabs + +@tab Foo + +```ts +const foo = 'foo' +``` + +@tab:active Bar + +```ts +const bar = 'bar' +``` + +::: diff --git a/docs-next/themes/default/plugin.md b/docs-next/themes/default/plugin.md new file mode 100644 index 0000000000..1a430843c2 --- /dev/null +++ b/docs-next/themes/default/plugin.md @@ -0,0 +1,133 @@ +# Plugins Config + +You can configure the plugins that used by default theme with `themePlugins`. + +Default theme is using some plugins by default. You can disable a plugin if you really do not want to use it. Make sure you understand what the plugin is for before disabling it. + +```ts +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + themePlugins: { + // customize theme plugins here + }, + }), +} +``` + +## themePlugins.activeHeaderLinks + +- Type: `boolean` + +- Default: `true` + +- Details: + + Enable [@vuepress/plugin-active-header-links](../../plugins/development/active-header-links.md) or not. + +## themePlugins.copyCode + +- Type: `CopyCodePluginOptions | boolean` + +- Default: `true` + +- Details: + + Enable [@vuepress/plugin-copy-code](../../plugins/features/copy-code.md) or not. + + Object value is supported as plugin options. + +## themePlugins.git + +- Type: `boolean` + +- Default: `true` + +- Details: + + Enable [@vuepress/plugin-git](../../plugins/development/git.md) or not. + +## themePlugins.hint + +- Type: `MarkdownHintPluginOptions | boolean` + +- Default: `{ alert: true, hint: true }` + +- Details: + + Enable [@vuepress/plugin-markdown-hint](../../plugins/markdown/markdown-hint.md) or not. + +## themePlugins.tab + +- Type: `MarkdownTabPluginOptions | boolean` + +- Default: `{ codeTabs: true, tabs: true }` + +- Details: + + Enable [@vuepress/plugin-markdown-tab](../../plugins/markdown/markdown-tab.md) or not. + +## themePlugins.linksCheck + +- Type: `LinksCheckPluginOptions | boolean` + +- Default: `true` + +- Details: + + Enable [@vuepress/plugin-links-check](../../plugins/markdown/links-check.md) or not. + +## themePlugins.photoSwipe + +- Type: `boolean` + +- Default: `true` + +- Details: + + Enable [@vuepress/plugin-photo-swipe](../../plugins/features/photo-swipe.md) or not. + +## themePlugins.nprogress + +- Type: `boolean` + +- Default: `true` + +- Details: + + Enable [@vuepress/plugin-nprogress](../../plugins/features/nprogress.md) or not. + +## themePlugins.shiki + +- Type: `boolean | ShikiPluginOptions` + +- Default: `true` + +- Details: + + Enable [@vuepress/plugin-shiki](../../plugins/markdown/shiki.md) or not. + +## themePlugins.seo + +- Type: `SeoPluginOptions | boolean` + +- Default: `true` + +- Details: + + Enable [@vuepress/plugin-seo](../../plugins/seo/seo/README.md) or not. + + Object value is supported as plugin options. + +## themePlugins.sitemap + +- Type: `SitemapPluginOptions | boolean` + +- Default: `true` + +- Details: + + Enable [@vuepress/plugin-sitemap](../../plugins/seo/sitemap/README.md) or not. + + Object value is supported as plugin options. diff --git a/docs-next/themes/default/sidebar.md b/docs-next/themes/default/sidebar.md new file mode 100644 index 0000000000..7722cfed9e --- /dev/null +++ b/docs-next/themes/default/sidebar.md @@ -0,0 +1,442 @@ +# Sidebar + +The sidebar is the main navigation block for your documentation. You can configure the sidebar menu in [`sidebar`](./config.md#sidebar). + +```js +export default { + theme: defaultTheme({ + sidebar: [ + { + text: 'Guide', + items: [ + { text: 'Introduction', link: '/introduction' }, + { text: 'Getting Started', link: '/getting-started' }, + // ... + ], + }, + ], + }), +} +``` + +The theme allows you to generate side bar from [file structure](#generate-sidebar-from-file-structure) automatically, or you can [customize](#sidebar-links) it manually. + +## Sidebar Links + +You should use `sidebar` in theme options to control sidebar. + +### String Format + +Just like navbar, you can fill in an array of multiple file links as the basic configuration of the sidebar: + +```js {5-9} title=".vuepress/config.js" +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + sidebar: ['/README.md', '/guide/README.md', '/config/README.md'], + }), +} +``` + +Each item of the array will be rendered as a sidebar item. + +::: tip + +You can omit the `.md` extension, and paths ending with `/` are inferred as `/README.md`. + +::: + +### Object Format + +Just like navbar, if you are not satisfied with the page's icon or feel that the page title is too long, you can configure an object instead. Available configuration items are: + +- `text:`: item text +- `link`: item link +- `activeMatch`: item active math (optional), support regexp strings + +```js {5-22} title=".vuepress/config.js" +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + sidebar: [ + { + text: 'Guide', + link: '/guide/README.md', + }, + { text: 'Config', link: '/config/README.md' }, + { + text: 'FAQ', + link: '/faq.md', + // active in path starting with `/faq` + // so it will active in path like `/faq/xxx.html` + activeMatch: '^/zh/faq/', + }, + ], + }), +} +``` + +::: tip Advanced usage of activeMatch + +`activeMatch` gives you the ability to control whether the path is active through RegExps. + +::: + +### Grouping and Nesting + +If you need a sidebar that displays a nested structure, you can group similar links. + +You should use [object format](#object-format) and provide an additional `items` option to set the list of links. + +Like navbar, you can use `prefix` in the sidebar to add a default path prefix to each link in the group. + +The sidebar additionally supports setting `collapsible` to make menu groups collapsible, setting `collapsible: true` to have collapsible groups expanded by default. + +```js {18-22,26-30} title=".vuepress/config.js" +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + sidebar: [ + { + // required, title of group + text: 'Group 1', + // optional, link of group title + path: '/foo/', + // optional, will be appended to each item link + prefix: '/foo/', + // optional, set whether the group can be collapsed, + // `false` for collapsed, `true` for expanded, + // and not collapsible when unconfigured. + collapsible: true, + // required, items of group + items: [ + 'README.md' /* /foo/index.html */, + /* ... */ + 'geo.md' /* /foo/geo.html */, + ], + }, + { + text: 'Group 2', + items: [ + /* ... */ + 'bar.md' /* /ray/bar.html */, + 'baz.md' /* /ray/baz.html */, + ], + }, + ], + }), +} +``` + +You can also nest Sidebar grouping: + +```js {11-22} title=".vuepress/config.js" +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + sidebar: [ + { + text: 'Group', + prefix: '/', + items: [ + 'baz' /* /baz.html */, + { + text: 'Sub Group 1', + items: ['quz' /* /quz.html */, 'xyzzy' /* /xyzzy.html */], + }, + { + text: 'Sub Group 2', + prefix: 'corge/', + children: [ + 'fred' /* /corge/fred.html */, + 'grault' /* /corge/grault.html */, + ], + }, + 'foo' /* /foo.html */, + ], + }, + ], + }), +} +``` + +You may want to use it with `prefix` to restore the structure of the document easily. + +For example, suppose you have a following directory structure: + +```sh +. +├─ README.md +├─ contact.md +├─ about.md +├─ foo/ +│ ├─ README.md +│ ├─ one.md +│ └─ two.md +└─ bar/ + ├─ README.md + ├─ three.md + └─ four.md +``` + +Then you can use the following config: + +```js title=".vuepress/config.js" +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + sidebar: [ + '/' /* / */, + { + text: 'Foo', + prefix: '/foo/', + items: [ + '' /* /foo/ */, + 'one' /* /foo/one.html */, + 'two' /* /foo/two.html */, + ], + }, + { + text: 'Bar', + prefix: '/bar/', + items: [ + '' /* /bar/ */, + 'three' /* /bar/three.html */, + 'four' /* /bar/four.html */, + ], + }, + '/contact' /* /contact.html */, + '/about' /* /about.html */, + ], + }), +} +``` + +### Multiple Sidebars + +To display different sidebars for different page groups, set an object for the sidebar in the format of `path: config`. + +For example, if you have the following structure: + +```sh +. +├─ README.md +├─ contact.md +├─ about.md +├─ foo/ +│ ├─ README.md +│ ├─ one.md +│ └─ two.md +└─ bar/ + ├─ README.md + ├─ three.md + └─ four.md +``` + +You can define your sidebar for each section using below configuration: + +```js title=".vuepress/config.js" +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + sidebar: { + '/foo/': [ + '' /* /foo/ */, + 'one' /* /foo/one.html */, + 'two' /* /foo/two.html */, + ], + + '/bar/': [ + '' /* /bar/ */, + 'three' /* /bar/three.html */, + 'four' /* /bar/four.html */, + ], + + // fallback + '/': [ + '' /* / */, + 'contact' /* /contact.html */, + 'about' /* /about.html */, + ], + }, + }), +} +``` + +::: warning + +You need to pay special attention to the order of object key declaration. Generally speaking, you should put the more precise path first, because VuePress will traverse the key names of the sidebar configuration to find the matching configuration. Once a key name is successfully matched with the current path, it will display the corresponding sidebar configuration. + +In this case, the fallback sidebar must be defined last for this reason. + +::: + +## Generate Sidebar from File Structure + +You can replace the original "sidebarConfig array" with `'structure'` keyword in any of the above sidebar config. This will allow the theme to automatically read local files, then generate sidebar from file structure for you, to reduce your config workload. + +For example, for the following example mentioned earlier in [multiple sidebars](#multiple-sidebars): + +```sh +. +├─ README.md +├─ contact.md +├─ about.md +├─ foo/ +│ ├─ README.md +│ ├─ one.md +│ └─ two.md +└─ bar/ + ├─ README.md + ├─ three.md + └─ four.md +``` + +You can change the original config to: + +```js {6,8} title=".vuepress/config.js" +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + sidebar: { + '/foo/': 'structure', + + '/bar/': 'structure', + + // fallback + '/': [ + '' /* / */, + 'contact' /* /contact.html */, + 'about' /* /about.html */, + ], + }, + }), +} +``` + +In the above modification, since the original sidebar array is all files under the relevant path, you can easily replace it with the `'structure'` keyword. + +If you use the structure to generate a folder with other folders nested under it, the corresponding folder will be rendered as a group. So you can even be more aggressive, for example setting `sidebar: 'structure'` to have your sidebars all auto-generated from the file structure. + +::: warning Limitations + +Since structure sidebar is depending on file structure and markdown frontmatter, any changes in markdown may update the structure sidebar. (E.g: setting `index: false` in frontmatter as described below) + +::: + +### Advanced Control + +During the automatic generation from structure, you can control whether files in the same folder are included through the `index` option in the page Frontmatter, and control how they are sorted through `order`. + +When you don't want the page to be included in the sidebar, you need to set `index: false` in Frontmatter. + +By default, the sidebar will be sorted according to the current language according to the title text of the file name. You can control how they are sorted by `order`. When you set a positive number, they will appear at the front of the group, the smaller the more forward, when you set a negative number, it will appear at the back of the group, and the larger the more backward: + +- page -> order: 1 +- page -> order: 2 +- page -> order: 3 +- ... +- pages with positive `order` will be sorted by `order` here +- ... +- page without `order` option -> title: Axxx +- ... +- pages without `order` option will be sorted by title here +- ... +- page without `order` option -> title: Zxxx +- ... +- pages with negative `order` will be sorted by `order` here +- ... +- page -> order: -3 +- page -> order: -2 +- page -> order: -1 + +::: tip + +`README.md` is an exception, as long as you don't disable it from the sidebar via `index: false` or make it as group link, it will always be the first item after sorting. + +::: + +For nested folders, the grouping information is controlled by `README.md` under that folder. You can control the behavior of folder grouping through the `dir` option in Frontmatter. The relevant optional items are as follows: + +- `dir.text`: Directory title, default to `README.md` title +- `dir.icon`: Directory icon, default to `README.md` icon +- `dir.collapsible`: Whether the directory is collapsible, default to `true` +- `dir.expanded`: Whether the directory is default expanded, default to `false` +- `dir.link`: Whether the directory is clickable, default to `false` +- `dir.index`: Whether index current dir, default to `true` +- `dir.order`: Dir order in sidebar, default to `0` + +Here is an example: + +```md +--- +dir: + order: 1 + text: Group 1 +--- +``` + +If no `README.md` file exists for the corresponding folder, only the group header will be generated from the folder name. + +#### Customize Sorter + +In addition to the above implementation, we also added a more powerful `sidebarSorter` option to the theme options. You can pass one or a series of built-in sorter names, or you can pass a sorter function you need to sort sidebar items at the same level. + +Available keywords are: + +- `readme`: `README.md` or `readme.md` first +- `order`: positive order first with its value ascending, negative order last with its value descending +- `date`: sort by date ascending +- `date-desc`: sort by date descending +- `title`: alphabetically sort by title +- `filename`: alphabetically sort by filename + +Corresponding to the above advanced control, its default value is `['readme', 'order', 'title', 'filename']` + +### Disabling Sidebar + +You can disable the sidebar on a specific page via frontmatter: + +```md +--- +sidebar: false +--- +``` + +::: note + +Sidebar is disabled by default in home page. + +::: + +## I18n Support + +The theme's navbar supports [I18n](https://vuejs.press/guide/i18n.html), so you can set sidebar individually in each language: + +```js {7-9,12-14} title=".vuepress/config.js" +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + locales: { + '/': { + sidebar: [ + /* English config under root */ + ], + }, + '/zh/': { + sidebar: [ + /* Chinese config under zh folder */ + ], + }, + }, + }), +} +``` diff --git a/docs-next/themes/default/styles.md b/docs-next/themes/default/styles.md new file mode 100644 index 0000000000..3dcca6849a --- /dev/null +++ b/docs-next/themes/default/styles.md @@ -0,0 +1,27 @@ +# Styles + + + +The default theme is styled using CSS, with CSS Variables defining the style variables. + +Users can override the default CSS variables and write additional styles through the [style file](#style-file). + +## CSS Variable File + +You can override the default CSS variables in the [style file](#style-file). + +::: details Click to expand CSS variables +@[code scss](@vuepress/theme-default/src/client/styles/vars.css) +::: + +## Style File + +The path of the style file is `.vuepress/styles/index.css`. + +You can add extra styles here, or override the default styles: + +```scss +:root { + scroll-behavior: smooth; +} +``` diff --git a/docs-next/themes/guidelines.md b/docs-next/themes/guidelines.md new file mode 100644 index 0000000000..f639add180 --- /dev/null +++ b/docs-next/themes/guidelines.md @@ -0,0 +1,82 @@ +# Theme Guidelines + +To avoid theme developers and users setting unneeded options, we have a set of guidelines that should be followed when creating a theme. + +## DOM Structure + +A theme must implement the following DOM structure: + +- Container: An element which contains the entire theme. This element should have an attribute `vp-container`. +- Content: An element which holds markdown render results. This element should have an attribute `vp-content`. + +A theme may have the following optional elements: + +- Navbar: Navbar of the site. This element should have an attribute `vp-navbar`. +- Sidebar: Sidebar of the site. This element should have an attribute `vp-sidebar`. +- Outline: Headings or outline of the main content. This element should have an attribute `vp-outline`. +- Comment: Comment service (comment box and comment list). This element should have an attribute `vp-comment`. +- Footer: Footer of the site. This element should have an attribute `vp-footer`. + +A theme must: + +- Set `data-theme` to `dark` on html in darkmode. +- Set `data-theme` to `light` on html in lightmode. + +If it only have one color scheme, it still needs to set `data-theme` to `light` or `dark` to indicate the default color scheme. + +## Components + +To support search plugins, a theme shall check whether `` is globally registered and render it in it's own navbar or sidebar if it is available. + +## Color Variables + +A theme must implement the following color variables: + +### Text + +- `--vp-c-text`: Default text color. +- `--vp-c-text-mute`: Colors for muted texts, such as "inactive menu" or "info texts". +- `--vp-c-text-subtle`: Color for subtle text, such as as "placeholders" or "caret icon". + +### Background + +- `--vp-c-bg`: The bg color used for main screen. +- `--vp-c-bg-alt`: The alternative bg color used in places such as "sidebar", or "code block". +- `--vp-c-bg-elv`: The elevated bg color. This is used at parts where it "floats", such as "dialog". + +### Shadow + +- `--vp-c-shadow`: Shadow color + +### Accent + +Accent color and brand colors which used for interactive components. + +- `--vp-c-accent`: The most solid color used mainly for colored text. It must satisfy the contrast ratio against when used on top of `--vp-c-accent-soft`. +- `--vp-c-accent-hover`: Color used for hover state. +- `--vp-c-accent-bg`: Color used for solid background. It must satisfy the contrast ratio with `--vp-c-accent-text` on top of it. +- `--vp-c-accent-text`: Color used for text with `--vp-c-accent-bg` background. It must satisfy the contrast ratio with `--vp-c-accent-bg`. +- `--vp-c-accent-soft`: The color used for subtle background such as custom container or badges. It must satisfy the contrast ratio when putting `--vp-c-accent` colors on top of it. + + The soft color must be semi transparent alpha channel. This is crucial because it allows adding multiple "soft" colors on top of each other to create a accent, such as when having inline code block inside custom containers. + +### Borders + +- `--vp-c-border`: Border color for interactive components. For example this should be used for a button outline. +- `--vp-c-border-hard`: Darker border colors, which is used for "hard" borders closed to text, such as table and kbd. +- `--vp-c-divider`: Color for separators, used to divide sections within the same components, such as having separator on "h2" heading. + +### Controls + +- `--vp-c-control`: Background color for interactive controls, such as buttons or checkboxes. +- `--vp-c-control-hover`: Background color for hover state of interactive controls. +- `--vp-c-control-disabled`: Color for disabled state of interactive controls. + +## Transition timing + +- `--vp-t-color`: Color transition timing. +- `--vp-t-transform`: Transform transition timing. + +## Demo + + diff --git a/docs-next/tools/helper/README.md b/docs-next/tools/helper/README.md new file mode 100644 index 0000000000..bdc27890b1 --- /dev/null +++ b/docs-next/tools/helper/README.md @@ -0,0 +1,13 @@ +# @vuepress/helper + + + +This package is a helper utility for VuePress developers. + +- `@vuepress/helper`: Node.js side helper utilities. + + - [Bundler Related](node/bundler.md) + - [Page Related](node/page.md) + +- [`@vuepress/helper/client`](client.md): Client side helper utilities. +- [`@vuepress/helper/shared`](shared.md): Utilities that are both available at Node.js side or Client. diff --git a/docs-next/tools/helper/client.md b/docs-next/tools/helper/client.md new file mode 100644 index 0000000000..1325aa7443 --- /dev/null +++ b/docs-next/tools/helper/client.md @@ -0,0 +1,159 @@ +# Client Related + +## Composables APIs + +### hasGlobalComponent + +Check whether a component is registered globally. + +::: tip + +1. Local import of the component does not affect the result. +1. When calling outside `setup` scope, you need to pass the `app` instance as the second parameter. + +::: + +```ts +export const hasGlobalComponent: (name: string, app?: App) => boolean +``` + +::: details Example + +```ts +// if you globally register `` +hasGlobalComponent('MyComponent') // true +hasGlobalComponent('my-component') // true + +hasGlobalComponent('MyComponent2') // false +``` + +::: + +### useLocaleConfig + +Get current locale config from locales settings. + +```ts +export const useLocaleConfig: ( + localesConfig: RequiredLocaleConfig, +) => ComputedRef +``` + +::: details Example + +```ts +const localesCOnfig = { + '/': 'Title', + '/zh/': '标题', +} + +const locale = useLocaleConfig(localesConfig) + +// under `/page` +locale.value // 'Title' + +// under `/zh/page` +locale.value // '标题' +``` + +::: + +## Utils + +### getHeaders + +Get headers from current page. + +```ts +export const getHeaders: (options: GetHeadersOptions) => MenuItem[] +``` + +**Params:** + +```ts +export interface GetHeadersOptions { + /** + * The selector of the headers. + * + * It will be passed as an argument to `document.querySelectorAll(selector)`, + * so you should pass a `CSS Selector` string. + * + * @default '[vp-content] h1, [vp-content] h2, [vp-content] h3, [vp-content] h4, [vp-content] h5, [vp-content] h6' + */ + selector?: string + /** + * Ignore specific elements within the header. + * + * The Array of `CSS Selector` + * + * @default [] + */ + ignore?: string[] + /** + * The levels of the headers + * + * - `false`: No headers. + * - `number`: only headings of that level will be displayed. + * - `[number, number]: headings level tuple, where the first number should be less than the second number, for example, `[2, 4]` which means all headings from `

` to `

` will be displayed. + * - `deep`: same as `[2, 6]`, which means all headings from `

` to `

` will be displayed. + * + * @default 2 + */ + levels?: HeaderLevels +} +``` + +**Result:** + +```ts +export interface Header { + /** + * The level of the header + * + * `1` to `6` for `

` to `

` + */ + level: number + /** + * The title of the header + */ + title: string + /** + * The slug of the header + * + * Typically the `id` attr of the header anchor + */ + slug: string + /** + * Link of the header + * + * Typically using `#${slug}` as the anchor hash + */ + link: string + /** + * The children of the header + */ + children: Header[] +} + +export type HeaderLevels = number | 'deep' | false | [number, number] + +export type MenuItem = Omit & { + element: HTMLHeadElement + children?: MenuItem[] +} +``` + +::: details Examples + +```ts +onMounted(() => { + const headers = getHeaders({ + selector: '[vp-content] :where(h1,h2,h3,h4,h5,h6)', + levels: [2, 3], // only h2 and h3 + ignore: ['.badge'], // ignore the within the header + }) + console.log(headers) +}) +``` + +::: diff --git a/docs-next/tools/helper/node/bundler.md b/docs-next/tools/helper/node/bundler.md new file mode 100644 index 0000000000..ebc7ae46b9 --- /dev/null +++ b/docs-next/tools/helper/node/bundler.md @@ -0,0 +1,347 @@ +# Bundler Related + +Bundler function is for appending or modifying bundler options in theme and plugins. + +All functions should be called in `extendsBundlerOptions` lifecycle hook. + +::: tip + +We are omitting that in examples. The actual code should be like this: + +```js +// import functions you need +import { addCustomElement } from '@vuepress/helper' + +export const yourPlugin = { + // ... + extendsBundlerOptions: (bundlerOptions, app) => { + // add them here + addCustomElement(bundlerOptions, app, 'my-custom-element') + }, +} +``` + +::: + +## Common methods + +### getBundlerName + +Get current bundler name. + +```ts +export const getBundlerName: (app: App) => string +``` + +::: details Example + +```ts +// @vuepress/bundler-vite +getBundleName(app) === 'vite' // true +// @vuepress/bundler-webpack +getBundleName(app) === 'webpack' // true +``` + +::: + +### addCustomElement + +Add a custom element declaration to the current bundler. + +```ts +/** + * Add tags as customElement + * + * @param bundlerOptions VuePress Bundler config + * @param app VuePress Node App + * @param customElements tags recognized as custom element + */ +export const addCustomElement: ( + bundlerOptions: unknown, + app: App, + customElement: RegExp | string[] | string, +) => void +``` + +::: details Example + +```ts +import { addCustomElement } from '@vuepress/helper' + +addCustomElement(bundlerConfig, app, 'my-custom-element') +addCustomElement(bundlerOptions, app, [ + 'custom-element1', + 'custom-element2', + // all tags start with `math-` + /^math-/, +]) +``` + +::: + +### customizeDevServer + +Provides contents for specific path in dev server. + +```ts +export interface DevServerOptions { + /** + * Path to be responded + */ + path: string + /** + * Respond function + */ + response: (request?: IncomingMessage) => Promise + + /** + * error msg + */ + errMsg?: string +} + +/** + * Handle specific path when running VuePress Dev Server + * + * @param bundlerOptions VuePress Bundler config + * @param app VuePress Node App + * @param path Path to be responded + * @param response respond function + * @param errMsg error msg + */ +export const customizeDevServer: ( + bundlerOptions: unknown, + app: App, + { + errMsg = 'The server encountered an error', + response, + path, + }: CustomServerOptions, +) => void +``` + +::: details Example + +```ts +import { useCustomDevServer } from '@vuepress/helper' + +// handle `/api/` path +useCustomDevServer(bundlerOptions, app, { + path: '/api/', + response: async () => getData(), + errMsg: 'Unexpected api error', +}) +``` + +::: + +## Vite Related + +- addViteOptimizeDepsInclude + + Add modules to Vite `optimizeDeps.include` list + + ::: tip + + If a package meets one of the following conditions, you should consider adding it here. + + - It's in CJS format + - It's dependencies include CJS package + - It's dynamically imported via `import()` + + ::: + +- addViteOptimizeDepsExclude + + Add modules to Vite `optimizeDeps.exclude` list + + ::: tip If a package and its dependencies are all pure ESM packages, you should consider adding it here. + + ::: + +- addViteSsrExternal + + Add modules to Vite `ssr.external` list + + ::: tip If a package is a pure ESM package and does not use aliases or define variables, you should consider adding it here. + + ::: + +- addViteSsrNoExternal + + Add modules to Vite `ssr.noExternal` list + + ::: warning If an alias or define is used within a package, you must add it here. + + ::: + + ```ts + /** + * Add modules to Vite `optimizeDeps.include` list + */ + export const addViteOptimizeDepsInclude: ( + bundlerOptions: unknown, + app: App, + module: string[] | string, + ) => void + + /** + * Add modules to Vite `optimizeDeps.exclude` list + */ + export const addViteOptimizeDepsExclude: ( + bundlerOptions: unknown, + app: App, + module: string[] | string, + ) => void + + /** + * Add modules to Vite `ssr.external` list + */ + export const addViteSsrExternal: ( + bundlerOptions: unknown, + app: App, + module: string[] | string, + ) => void + + /** + * Add modules to Vite `ssr.noExternal` list + */ + export const addViteSsrNoExternal: ( + bundlerOptions: unknown, + app: App, + module: string[] | string, + ) => void + ``` + + ::: details Examples + + ```ts + import { + addViteOptimizeDepsExclude, + addViteOptimizeDepsInclude, + addViteSsrExternal, + addViteSsrNoExternal, + } from '@vuepress/helper' + + addViteOptimizeDepsInclude(bundlerOptions, app, ['vue', 'vue-router']) + addViteOptimizeDepsExclude(bundlerOptions, app, 'packageA') + addViteSsrNoExternal(bundlerOptions, app, ['vue', 'vue-router']) + addViteSsrExternal(bundlerOptions, app, 'packageA') + ``` + + ::: + +- addViteConfig + + A function for you to add vite config + + ```ts + export const addViteConfig: ( + bundlerOptions: unknown, + app: App, + config: Record, + ) => void + ``` + + ::: details Example + + ```ts + import { addViteConfig } from '@vuepress/helper' + + addViteConfig(bundlerOptions, app, { + build: { + charset: 'utf8', + }, + }) + ``` + + ::: + +- mergeViteConfig + + A function for you to merge vite config. + + ::: warning + + Your users may choose to use other bundler so it's pretty bad to declare vite as deps! + + ::: + + ```ts + export const mergeViteConfig: ( + defaults: Record, + overrides: Record, + ) => Record + ``` + + ::: details Example + + ```ts + import { mergeViteConfig } from '@vuepress/helper' + + config.viteOptions = mergeViteConfig(config.viteOptions, { + build: { + charset: 'utf8', + }, + }) + ``` + + ::: + +## Webpack Related + +- chainWebpack + + Chain webpack config. + + ```ts + export const chainWebpack: ( + bundlerOptions: unknown, + app: App, + chainWebpack: ( + config: WebpackChainConfig, + isServer: boolean, + isBuild: boolean, + ) => void, + ) => void + ``` + + ::: details Example + + ```ts + import { chainWebpack } from '@vuepress/helper' + + chainWebpack(bundlerOptions, app, (config, isServer, isBuild) => { + // do some customize here + }) + ``` + + ::: + +- configWebpack + + Config Webpack + + ```ts + export const configWebpack: ( + bundlerOptions: unknown, + app: App, + configureWebpack: ( + config: WebpackConfiguration, + isServer: boolean, + isBuild: boolean, + ) => void, + ) => void + ``` + + ::: details Example + + ```ts + import { configWebpack } from '@vuepress/helper' + + configWebpack(bundlerOptions, app, (config, isServer, isBuild) => { + // do some customize here + }) + ``` + + ::: diff --git a/docs-next/tools/helper/node/page.md b/docs-next/tools/helper/node/page.md new file mode 100644 index 0000000000..3956cc3933 --- /dev/null +++ b/docs-next/tools/helper/node/page.md @@ -0,0 +1,93 @@ +# Page Related + +These functions generate common information for your pages. + +## getPageExcerpt + +Get the excerpt of the page. + +```ts +export interface PageExcerptOptions { + /** + * Excerpt separator + * + * @default "" + */ + separator?: string + + /** + * Length of excerpt + * + * @description Excerpt length will be the minimal possible length reaching this value + * + * @default 300 + */ + length?: number + + /** + * Tags which is considered as custom elements + * + * @description This is used to determine whether a tag is a custom element since all unknown tags are removed in excerpt. + */ + isCustomElement?: (tagName: string) => boolean + + /** + * Whether keep page title (first h1) in excerpt + * + * @default false + */ + keepPageTitle?: boolean + + /** + * Whether preserve tags like line numbers and highlight lines for code blocks + * + * @default false + */ + keepFenceDom?: boolean +} + +export const getPageExcerpt: ( + app: App, + page: Page, + options?: PageExcerptOptions, +) => string +``` + +## getPageText + +Get plain text of the page. + +```ts +export interface PageTextOptions { + /** + * Whether convert text to single line content + * + * @default false + */ + singleLine?: boolean + + /** + * Length of text + * + * @description Text length will be the minimal possible length reaching this value + * + * @default 300 + */ + length?: number + + /** + * Tags to be removed + * + * @description Table and code blocks are removed by default. + * + * @default ['table', 'pre'] + */ + removedTags?: string[] +} + +export const getPageText: ( + app: App, + page: Page, + options?: PageTextOptions, +) => string +``` diff --git a/docs-next/tools/helper/shared.md b/docs-next/tools/helper/shared.md new file mode 100644 index 0000000000..e1a0cc1d70 --- /dev/null +++ b/docs-next/tools/helper/shared.md @@ -0,0 +1,194 @@ +# Shared Methods + +The following functions are available on both Node.js and Client. + +## Data Related + +Encode/decode and zip/unzip data. + +This is useful in markdown plugins when you want to encode string content and pass it to the component through props. + +You may simply achieve this with `encodeURIComponent` and `decodeURIComponent`, but it can be very large if the content contains lots of special characters. + +So we provide `encodeData` and `decodeData` to zip and encode content. + +```ts +export const encodeData: ( + data: string, + level: DeflateOptions['level'] = 6, +) => string + +export const decodeData: (compressed: string) => string +``` + +::: details + +```ts +const content = ` +{ + "type": "bar", + "data": { + "labels": ["Red", "Blue", "Yellow", "Green", "Purple", "Orange"], + "datasets": [ + { + "label": "# of Votes", + "data": [12, 19, 3, 5, 2, 3], + "backgroundColor": [ + "rgba(255, 99, 132, 0.2)", + "rgba(54, 162, 235, 0.2)", + "rgba(255, 206, 86, 0.2)", + "rgba(75, 192, 192, 0.2)", + "rgba(153, 102, 255, 0.2)", + "rgba(255, 159, 64, 0.2)" + ], + "borderColor": [ + "rgba(255, 99, 132, 1)", + "rgba(54, 162, 235, 1)", + "rgba(255, 206, 86, 1)", + "rgba(75, 192, 192, 1)", + "rgba(153, 102, 255, 1)", + "rgba(255, 159, 64, 1)" + ], + "borderWidth": 1 + } + ] + }, + "options": { + "scales": { + "y": { + "beginAtZero": true + } + } + } +} +` + +const prop = encodeData(content) // "eJyNUsFOwzAMve8rrHABKZqWlg5WxAE4cARxAMHEIV1NmQhNlaaCCe3fcdKtW0sLWGpjxy/v+UV512mlcIyfhTa2hHP4GgHYVYExsEQaxqlMpZWxbwAomaAqY5izO0wZB3apKnTrIyqlP1x2bRBzl9xWplC+eWNkniF7dmw1X4nWsfgaNtwNP2kfgH6Be22x9CPUUQ8yFwEHMeMQcog4UBFuiF0kcvGWGV3l6ZVW2uw0XDCTJfIwiOjYjAhESIcn4+BoT2MLio6pP6V+EBJ6AOSZgsmUwyl9A6ATwoiZn3lYTkTkRkycnuP8TU9ENPqUxuuA9i9BmxTNPy9A/G2/F9I23wtpW++FdIwPKzW2W5Afph+WqX2NQWz313XicT7XhV3qnB5f/ejKhVTYVACrXUqUmC3zC/uERsdgTYUdVr/Qb302+gZxe7S/" + +decodeData(prop) // will be the original content + +// if you use `encodeURIComponent`, it will be much longer +encodeURIComponent(content) // '%0A%7B%0A%20%20%22type%22%3A%20%22bar%22%2C%0A%20%20%22data%22%3A%20%7B%0A%20%20%20%20%22labels%22%3A%20%5B%22Red%22%2C%20%22Blue%22%2C%20%22Yellow%22%2C%20%22Green%22%2C%20%22Purple%22%2C%20%22Orange%22%5D%2C%0A%20%20%20%20%22datasets%22%3A%20%5B%0A%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%22label%22%3A%20%22%23%20of%20Votes%22%2C%0A%20%20%20%20%20%20%20%20%22data%22%3A%20%5B12%2C%2019%2C%203%2C%205%2C%202%2C%203%5D%2C%0A%20%20%20%20%20%20%20%20%22backgroundColor%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%22rgba(255%2C%2099%2C%20132%2C%200.2)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(54%2C%20162%2C%20235%2C%200.2)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(255%2C%20206%2C%2086%2C%200.2)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(75%2C%20192%2C%20192%2C%200.2)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(153%2C%20102%2C%20255%2C%200.2)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(255%2C%20159%2C%2064%2C%200.2)%22%0A%20%20%20%20%20%20%20%20%5D%2C%0A%20%20%20%20%20%20%20%20%22borderColor%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%22rgba(255%2C%2099%2C%20132%2C%201)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(54%2C%20162%2C%20235%2C%201)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(255%2C%20206%2C%2086%2C%201)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(75%2C%20192%2C%20192%2C%201)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(153%2C%20102%2C%20255%2C%201)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(255%2C%20159%2C%2064%2C%201)%22%0A%20%20%20%20%20%20%20%20%5D%2C%0A%20%20%20%20%20%20%20%20%22borderWidth%22%3A%201%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%5D%0A%20%20%7D%2C%0A%20%20%22options%22%3A%20%7B%0A%20%20%20%20%22scales%22%3A%20%7B%0A%20%20%20%20%20%20%22y%22%3A%20%7B%0A%20%20%20%20%20%20%20%20%22beginAtZero%22%3A%20true%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A' +``` + +::: + +## Type Helper + +- `isDef(x)`: Check if `x` is defined. +- `isBoolean(x)`: Check if `x` is a boolean. +- `isString(x)`: Check if `x` is a string. +- `isNumber(x)`: Check if `x` is a number. +- `isPlainObject(x)`: Check if `x` is a plain object. +- `isArray(x)`: Check if `x` is an array. +- `isFunction(x)`: Check if `x` is a function. +- `isRegExp(x)`: Check if `x` is a regular expression. + +## String Related + +- `startsWith(a, b)`: Check if string `a` starts with string `b`. +- `endsWith(a, b)`: Check if string `a` ends with string `b`. + +Return `false` if `a` is not a string. + +## Object Related + +- `keys(x)`: Return an array of keys of object `x`. +- `values(x)`: Return an array of values of object `x`. +- `entries(x)`: Convert object `x` to an array of key-value pairs. +- `fromEntries(x)`: Convert an array of key-value pairs `x` to an object. +- `deepAssign(x, y, ...)`: A deep version of `Object.assign`. + + ::: details Example + + ```ts + // or @vuepress/helper/client + import { deepAssign } from '@vuepress/helper' + + const defaultOptions = { + optionA: { + optionA1: 'defaultOptionA1', + optionA2: 'defaultOptionA2', + optionA3: 'defaultOptionA3', + }, + optionB: true, + optionC: 'optionC', + } + + const userOptions = { + optionA: { + optionA1: 'optionA1', + optionA2: 'optionA2', + }, + optionB: false, + } + + deepAssign(defaultOptions, userOptions) + // { + // optionA: { + // optionA1: "optionA1", + // optionA2: "optionA2", + // optionA3: "defaultOptionA3", + // }, + // optionB: false, + // optionC: "optionC", + // } + ``` + + ::: + +## Date Related + +- `getDate(x)`: Convert input `x` to a date. It can support `Date`, timestamp, and date string. The support degree of date string depends on the `Date.parse` support degree of the environment. Return `null` when it cannot be converted to a date. + + ::: details Example + + ```ts + getDate('2021-01-01') // a Date object represents 2021-01-01 + getDate(1609459200000) // a Date object represents 2021-01-01 + getDate('2021-01-01T00:00:00.000Z') // a Date object represents 2021-01-01 + getDate('2021/01/01') // a Date object represents 2021-01-01 (might be null in some browsers) + getDate('invalid date') // null + getDate(undefined) // null + getDate(-32) // null + ``` + + ::: + +- `dateSorter`: Sort the values that can be converted to dates from new to old, and the values that cannot be converted to dates will be at the end. + + ::: details Example + + ```ts + const arr = [ + '2020-01-01', + 1609459200000, + '2022-01-01T00:00:00.000Z', + '2023/01/01', + 'invalid date', + undefined, + -32, + ] + + arr.sort(dateSorter) + // [ + // '2023/01/01', + // '2022-01-01T00:00:00.000Z', + // 1609459200000, + // '2020-01-01', + // 'invalid date', + // undefined, + // -32, + // ] + ``` + +## Link Related + +- `isLinkHttp(x)`: Check if `x` is a valid HTTP URL. +- `isLinkWithProtocol(x)`: Check if `x` is a valid URL with protocol. +- `isLinkExternal(x)`: Check if `x` is a valid external URL. +- `isLinkAbsolute(x)`: Check if `x` is a valid absolute URL. +- `ensureEndingSlash(x)`: Ensure `x` ends with a slash. +- `ensureLeadingSlash(x)`: Ensure `x` starts with a slash. +- `removeEndingSlash(x)`: Ensure `x` does not end with a slash. +- `removeLeadingSlash(x)`: Ensure `x` does not start with a slash. diff --git a/docs-next/tools/helper/style.md b/docs-next/tools/helper/style.md new file mode 100644 index 0000000000..01f71b4442 --- /dev/null +++ b/docs-next/tools/helper/style.md @@ -0,0 +1,7 @@ +# Styles + +The following styles are provided. + +## Normalize + +`@vuepress/helper/normalize.css` is a CSS file that normalizes the default styles of the browser. It is recommended to import it in community themes. diff --git a/docs-next/zh/README.md b/docs-next/zh/README.md new file mode 100644 index 0000000000..e9218f6d91 --- /dev/null +++ b/docs-next/zh/README.md @@ -0,0 +1,40 @@ +--- +home: true +title: Home +hero: + image: /images/hero.png + name: VuePress 生态系统 + text: VuePress 官方主题和插件 +actions: + - text: 主题 + link: ./themes/default/ + theme: brand + - text: 插件 + link: ./plugins/ + theme: brand + - text: GitHub → + link: https://github.com/vuepress/ecosystem + theme: alt +--- + + diff --git a/docs-next/zh/plugins/README.md b/docs-next/zh/plugins/README.md new file mode 100644 index 0000000000..7d01d6f43a --- /dev/null +++ b/docs-next/zh/plugins/README.md @@ -0,0 +1,3 @@ +# 插件 + + diff --git a/docs-next/zh/plugins/analytics/README.md b/docs-next/zh/plugins/analytics/README.md new file mode 100644 index 0000000000..57ba9d775f --- /dev/null +++ b/docs-next/zh/plugins/analytics/README.md @@ -0,0 +1,3 @@ +# 统计分析插件 + + diff --git a/docs-next/zh/plugins/analytics/baidu-analytics.md b/docs-next/zh/plugins/analytics/baidu-analytics.md new file mode 100644 index 0000000000..dc709452e4 --- /dev/null +++ b/docs-next/zh/plugins/analytics/baidu-analytics.md @@ -0,0 +1,42 @@ +# baidu-analytics + + + +将 [百度统计](https://tongji.baidu.com/) 集成到 VuePress 中。 + +::: tip + +请勿打开[百度统计内置的“单页应用数据统计”功能](https://tongji.baidu.com/web/help/article?id=324&type=0),本插件会正确处理相关逻辑。 + +::: + +## 使用方法 + +```bash +npm i -D @vuepress/plugin-baidu-analytics@next +``` + +```ts +import { baiduAnalyticsPlugin } from '@vuepress/plugin-baidu-analytics' + +export default { + plugins: [ + baiduAnalyticsPlugin({ + // 配置项 + }), + ], +} +``` + +### 上报事件 + +插件会在访问和切换页面时自动上报页面浏览事件。 + +另外,一个全局的 `hmt` 数组会被挂载到 `window` 对象上,你可以使用它进行 [自定义事件的上报](https://tongji.baidu.com/holmes/Analytics/%E6%8A%80%E6%9C%AF%E6%8E%A5%E5%85%A5%E6%8C%87%E5%8D%97/JS%20API/JS%20API%20%E4%BD%BF%E7%94%A8%E6%89%8B%E5%86%8C) 。 + +## 选项 + +### id + +- 类型: `string` +- 详情: 百度统计的 ID ,即 `hm.js` URL 中的查询参数。 diff --git a/docs-next/zh/plugins/analytics/google-analytics.md b/docs-next/zh/plugins/analytics/google-analytics.md new file mode 100644 index 0000000000..cf26060848 --- /dev/null +++ b/docs-next/zh/plugins/analytics/google-analytics.md @@ -0,0 +1,78 @@ +# google-analytics + + + +将 [Google Analytics](https://analytics.google.com/) 集成到 VuePress 中。 + +该插件会通过引入 [gtag.js](https://developers.google.com/analytics/devguides/collection/gtagjs) 来启用 [Google Analytics 4](https://support.google.com/analytics/answer/10089681) 。 + +## 使用方法 + +```bash +npm i -D @vuepress/plugin-google-analytics@next +``` + +```ts +import { googleAnalyticsPlugin } from '@vuepress/plugin-google-analytics' + +export default { + plugins: [ + googleAnalyticsPlugin({ + // 配置项 + }), + ], +} +``` + +### 上报事件 + +Google Analytics 会 [自动收集部分事件](https://support.google.com/analytics/answer/9234069) ,比如 `page_view`, `first_visit` 等。 + +因此,如果你只是想收集站点的一些基础数据,你只需要正确设置 [Measurement ID](#id) ,不需要再额外做其他事情。 + +在引入该插件之后,一个全局的 `gtag()` 函数会被挂载到 `window` 对象上,你可以使用它进行 [自定义事件的上报](https://developers.google.com/analytics/devguides/collection/ga4/events) 。 + +## 选项 + +### id + +- 类型: `string` + +- 详情: + + Google Analytics 4 的 Measurement ID ,应以 `'G-'` 开头。 + + 你可以通过 [这里](https://support.google.com/analytics/answer/9539598) 的指引来找到你的 Measurement ID 。注意区分 Google Analytics 4 的 Measurement ID (即 "G-" 开头的 ID) 和 Universal Analytics 的 Tracking ID (即 "UA-" 开头的 ID)。 + +- 示例: + +```ts +export default { + plugins: [ + googleAnalyticsPlugin({ + id: 'G-XXXXXXXXXX', + }), + ], +} +``` + +### debug + +- 类型: `boolean` + +- 详情: + + 设置为 `true` 可以向 DebugView 发送事件。[了解更多关于 DebugView 的信息](https://support.google.com/analytics/answer/7201382) 。 + +- 示例: + +```ts +export default { + plugins: [ + googleAnalyticsPlugin({ + id: 'G-XXXXXXXXXX', + debug: true, + }), + ], +} +``` diff --git a/docs-next/zh/plugins/analytics/umami-analytics.md b/docs-next/zh/plugins/analytics/umami-analytics.md new file mode 100644 index 0000000000..a59cb6ec5f --- /dev/null +++ b/docs-next/zh/plugins/analytics/umami-analytics.md @@ -0,0 +1,70 @@ +# umami-analytics + + + +将 [Umami 统计](https://umami.is/) 集成到 VuePress 中。 + +## 使用方法 + +```bash +npm i -D @vuepress/plugin-umami-analytics@next +``` + +```ts +import { umamiAnalyticsPlugin } from '@vuepress/plugin-umami-analytics' + +export default { + plugins: [ + umamiAnalyticsPlugin({ + // 配置项 + }), + ], +} +``` + +你可以使用 [Umami Cloud](https://cloud.umami.is/login) 或 [自行托管 Umami](https://umami.is/docs/install)。 + +### 上报事件 + +插件会在访问和切换页面时自动上报页面浏览事件。 + +另外,一个全局的 `umami` 对象会被挂载到 `window` 上,你可以使用 `umami.track` 设置 [自定义追踪](https://umami.is/docs/tracker-functions) 。 + +## 选项 + +### id + +- 类型: `string` +- 详情: Umami 统计中的网站 ID。 + +### link + +- 类型:`string` +- 详情:Umami 统计的脚本链接 + +### autoTrack + +- 类型:`boolean` +- 默认值:`true` +- 详情: + + 默认情况下,Umami 会自动跟踪所有页面浏览量和事件。你可以禁用此行为并使用追踪器功能自行追踪事件。 + +### cache + +- 类型:`boolean` +- 详情: + + 缓存数据以提高追踪脚本的性能。 + + 注意:这将使用会话存储,因此您可能需要通知您的用户。 + +### domains + +- 类型:`string[]` +- 详情: 让跟踪器仅在特定的域名上运行。 + +### hostUrl + +- 类型:`string` +- 详情:发送数据的位置 diff --git a/docs-next/zh/plugins/blog/README.md b/docs-next/zh/plugins/blog/README.md new file mode 100644 index 0000000000..a0b65fb777 --- /dev/null +++ b/docs-next/zh/plugins/blog/README.md @@ -0,0 +1,3 @@ +# 博客插件 + + diff --git a/docs-next/zh/plugins/blog/blog/README.md b/docs-next/zh/plugins/blog/blog/README.md new file mode 100644 index 0000000000..5bb3e5e457 --- /dev/null +++ b/docs-next/zh/plugins/blog/blog/README.md @@ -0,0 +1,21 @@ +# blog + + + +## 使用 + +```bash +npm i -D @vuepress/plugin-blog@next +``` + +```ts title=".vuepress/config.ts" +import { blogPlugin } from '@vuepress/plugin-blog' + +export default { + plugins: [ + blogPlugin({ + // 选项 + }), + ], +} +``` diff --git a/docs-next/zh/plugins/blog/blog/config.md b/docs-next/zh/plugins/blog/blog/config.md new file mode 100644 index 0000000000..48d528f877 --- /dev/null +++ b/docs-next/zh/plugins/blog/blog/config.md @@ -0,0 +1,320 @@ +# 配置 + +## 插件选项 + +### getInfo + +- 类型: `(page: Page) => Record` +- 必填: 否 +- 参考: + - [指南 → 收集文章并生成信息](./guide.md#收集文章并生成信息) +- 详情: + + 获取文章信息的函数。 + + 获取到的信息会被稍后注入至路由元数据,以便你可以在客户端中通过组合式 API 获取。 + +### filter + +- 类型: `(page: Page) => boolean` +- 默认值: `(page) => Boolean(page.filePathRelative) && !page.frontmatter.home` +- 参考: + + - [指南 → 收集文章并生成信息](./guide.md#收集文章并生成信息) + +- 详情: + + 页面过滤器,此函数用于鉴别页面是否作为文章。 + + 默认情况下,所有从 Markdown 源文件中生成的非主页页面,会被作为文章。 + +### category + +- 类型: `BlogCategoryOptions[]` +- 必填: 否 +- 详情: + + - [指南 → 自定义类别和类型](./guide.md#自定义类别和类型) + +- 详情: + + 博客分类配置,详见 [博客分类配置](#博客分类配置)。 + +### type + +- 类型: `BlogTypeOptions[]` +- 必填: 否 +- 参考: + - [指南 → 自定义类别和类型](./guide.md#自定义类别和类型) +- 详情: + + 博客分类配置,详见 [博客类型配置](#博客类型配置)。 + +### slugify + +- 类型: `(name: string) => string` +- 默认值: `(name) => name.replace(/ _/g, '-').replace(/[:?*|\\/<>]/g, "").toLowerCase()` +- 详情:Slugify 函数,用于转换 key 在路由中注册的形式。 + +### excerpt + +- 类型: `boolean` +- 默认值: `true` +- 详情:是否生成摘要。 + +### excerptSeparator + +- 类型: `string` +- 默认值: `` +- 详情:摘要分隔符。 + +### excerptLength + +- 类型: `number` +- 默认值: `300` +- 参考: + - [指南 → 摘要生成](./guide.md#摘要生成) +- 详情: + + 自动生成的摘要的长度。 + + ::: tip + + 摘要的长度会尽可能的接近这个值。如果设置为 `0`,意味着不自动生成摘要。 + + ::: + +### excerptFilter + +- 类型: `(page: Page) => boolean` +- 默认值: `filter` 选项 +- 参考: + - [指南 → 摘要生成](./guide.md#摘要生成) +- 详情: + 页面过滤器,此函数用于鉴别插件是否需要生成摘要。 + + ::: tip + + 你可以使用此函数来跳过你不需要生成摘要的页面。例如:如果用户在 frontmatter 中设置了 `excerpt` 或 `description`,你可能希望直接使用它们。 + + ::: + +### isCustomElement + +- 类型: `(tagName: string) => boolean` +- 默认值: `() => false` +- 参考: + - [指南 → 摘要生成](./guide.md#摘要生成) +- 详情: + 被认为是自定义元素的标签。 + + 用于判断一个标签是否是自定义元素,因为在摘要中,所有的未知标签都会被移除。 + +### metaScope + +- 类型: `string` +- 默认值: `"_blog"` +- 详情: + 注入文章信息至路由元数据时使用的键名。 + + ::: tip + + 设置为空字符串会直接注入路由元数据 (而不是一个键下)。 + + ::: + +### hotReload + +- 类型: `boolean` +- 默认值: 是否使用 `--debug` 标记 +- 详情: + 是否在开发服务器中启用实时热重载。 + + ::: tip 致主题开发者 + + 默认情况下它是禁用的,因为它确实会对具有很多分类和类别的站点产生性能影响,并且在编辑 Markdown 时会减慢热重载的速度。 + + 如果用户正在添加或组织类别或标签,你可以告诉他们启用此功能,其余的时间最好禁用它。 + + 此外,你可以尝试检测用户项目中的页面数并决定是否启用它。 + + ::: + +## 博客分类配置 + +博客分类配置应为一个数组,每一项控制一个分类规则。 + +```ts +interface BlogCategoryOptions { + /** + * 唯一的分类名称 + */ + key: string + + /** + * 从页面中获取分类的函数 + */ + getter: (page: Page) => string[] + + /** + * 页面排序器 + */ + sorter?: (pageA: Page, pageB: Page) => number + + /** + * 待注册的页面路径图案 + * + * @description `:key` 将会被替换为原 key 的 slugify 结果 + * + * @default `/:key/` + */ + path?: string | false + + /** + * 页面布局组件名称 + * + * @default 'Layout' + */ + layout?: string + + /** + * Front Matter 配置 + */ + frontmatter?: (localePath: string) => Record + + /** + * 待注册的项目页面路径图案或自定义函数 + * + * @description 当填入字符串的时候, `:key` 和 `:name` 会被自动替换为原始的 key、name 的 slugify 结果。 + * + * @default `/:key/:name/` + */ + itemPath?: string | false | ((name: string) => string) + + /** + * 项目页面布局组件名称 + * + * @default 'Layout' + */ + itemLayout?: string + + /** + * 项目 Front Matter 配置 + */ + itemFrontmatter?: (name: string, localePath: string) => Record +} +``` + +## 博客类型配置 + +博客类型配置应为一个数组,每一项控制一个类型规则。 + +```ts +interface BlogTypeOptions { + /** + * 唯一的类型名称 + */ + key: string + + /** + * 一个过滤函数来决定页面是否满足此类型 + */ + filter: (page: Page) => boolean + + /** + * 页面排序器 + */ + sorter?: (pageA: Page, pageB: Page) => number + + /** + * 待注册的页面路径 + * + * @default '/:key/' + */ + path?: string | false + + /** + * 页面布局组件名称 + * + * @default 'Layout' + */ + layout?: string + + /** + * Front Matter 配置 + */ + frontmatter?: (localePath: string) => Record +} +``` + +## 可组合式 API + +你可以从 `@vuepress/plugin-blog/client` 导入下列 API: + +- 博客分类 + + ```ts + const useBlogCategory: < + T extends Record = Record, + >( + key?: string, + ) => ComputedRef> + ``` + + 参数 `key` 为需要获取的键名。如果未传入 key,会尝试使用与当前路径匹配的 key。 + +- 博客类型 + + ```ts + const useBlogType: < + T extends Record = Record, + >( + key?: string, + ) => ComputedRef> + ``` + + 参数 `key` 为需要获取的键名。如果未传入 key,会尝试使用与当前路径匹配的 key。 + +详细的返回值如下: + +```ts +interface Article = Record> { + /** 文章路径 */ + path: string + /** 文章信息 */ + info: T +} + +interface BlogCategoryData< + T extends Record = Record, +> { + /** 分类路径 */ + path: string + + /** + * 仅当当前路径和某个子项目匹配时可用 + */ + currentItems?: Article[] + + /** 分类映射 */ + map: { + /** 当前分类下全局唯一的 key */ + [key: string]: { + /** 对应键值的分类路径 */ + path: string + /** 对应键值的项目 */ + items: Article[] + } + } +} + +interface BlogTypeData< + T extends Record = Record, +> { + /** 类别路径 */ + path: string + + /** 当前类别下的项目 */ + items: Article[] +} +``` diff --git a/docs-next/zh/plugins/blog/blog/guide.md b/docs-next/zh/plugins/blog/blog/guide.md new file mode 100644 index 0000000000..ad029d9d5a --- /dev/null +++ b/docs-next/zh/plugins/blog/blog/guide.md @@ -0,0 +1,332 @@ +--- +title: 指南 +icon: lightbulb +--- + +使用 `@vuepress/plugin-blog`,你可以轻松地将博客功能引入主题。 + +## 收集文章并生成信息 + +起步时,插件会首选过滤并选择那些需要作为文章的页面。这将剔除你不想要的页面,并在后续处理中排除它们。 + +::: tip 默认情况下,所有从 Markdown 文件生成但不是主页的页面,都将被视作文章。 + +::: + +你可能需要设置 `filter` 选项来完全自定义要收集的页面。 `filter` 接受一个形状为 `(page: Page) => boolean` 的函数。 + +接着,你应该设置 `getInfo` 选项为一个接受 `Page` 作为参数并返回包含所需信息的对象的函数。这样稍后,你可以从组合 API 中获取这些信息。 + +::: details 案例 + +```ts title="主题入口" +import { blogPlugin } from '@vuepress/plugin-blog' + +export default { + name: 'vuepress-theme-xxx', + plugins: [ + blogPlugin({ + filter: ({ filePathRelative, frontmatter }) => { + // 舍弃那些不是从 Markdown 文件生成的页面 + if (!filePathRelative) return false + + // 舍弃 `archives` 文件夹的页面 + if (filePathRelative.startsWith('archives/')) return false + + // 舍弃那些没有使用默认布局的页面 + if (frontmatter.home || frontmatter.layout) return false + + return true + }, + + getInfo: ({ frontmatter, git = {}, data = {} }) => { + // 获取页面信息 + const info: Record = { + author: frontmatter.author || '', + categories: frontmatter.categories || [], + date: frontmatter.date || git.createdTime || null, + tags: frontmatter.tags || [], + excerpt: data.excerpt || '', + } + + return info + }, + }), + // 其他插件 ... + ], +} +``` + +::: + +## 自定义类别和类型 + +基本上,你的博客中需要两种“类型”: + +- 类别: + + “类别”是用文章的标签 (或类别) 对它们进行分组。 + + 例如,每篇文章可能都有对应的“分类”和“标签”。 + +- 类型: + + “类型”是过滤不同条件的文章。 + + 例如,你的帖子中可能有日记或笔记。当帖子带有写作日期信息时,它可以称为“时间线项目”。 + +了解这两种类型的描述后,你可以设置 `category` 和 `type` 选项,它们都接受一个数组,每个元素代表一个配置。 + +让我们从此处 2 个例子开始。 + +假设你想为每篇文章设置标签,并且你正在通过 `frontmatter.tag` 设置它们。同时,你想要在 `/tag/` 中使用 `TagMap` 布局的标签页面,并在`/tag/标签名称` 中使用 `TagList` 布局对标签按名称进行分组,你可能需要这样的配置: + +```ts title="主题入口" +import { blogPlugin } from '@vuepress/plugin-blog' + +export default { + name: 'vuepress-theme-xxx', + plugins: [ + blogPlugin({ + // 其他配置 ... + category: [ + { + key: 'tag', + getter: ({ frontmatter }) => frontmatter.tag || [], + path: '/tag/', + layout: 'TagMap', + frontmatter: () => ({ title: '标签页' }), + itemPath: '/tag/:name/', + itemLayout: 'TagList', + itemFrontmatter: (name) => ({ title: `${name}标签` }), + }, + ], + }), + // 其他插件 ... + ], +} +``` + +此外,你可能希望为你的一些文章加注星标,并将其展示给访问者。当你在 frontmatter 中设置 `star: true` 来标记它们时,你可能需要这样的配置来在 `/star/` 路径中以 `StarList` 布局显示它们: + +```ts title="主题入口" +import { blogPlugin } from '@vuepress/plugin-blog' + +export default { + name: 'vuepress-theme-xxx', + plugins: [ + blogPlugin({ + // 其他配置 ... + type: [ + { + key: 'star', + filter: ({ frontmatter }) => frontmatter.star, + path: '/star/', + layout: 'StarList', + frontmatter: () => ({ title: '星标文章' }), + }, + ], + }), + // 其他插件 ... + ], +} +``` + +看,设置这两种类型很容易。有关完整选项,请参阅 [博客分类配置](./config.md#博客分类配置) 和 [博客分类配置](./config.md#博客类型配置)。 + +## 在客户端使用组合 API + +当生成每个页面时,插件将在 `frontmatter.blog` 中设置如下信息 + +```ts +interface BlogFrontmatterOptions { + /** 当前页面的类型 */ + type: 'category' | 'type' + /** 在当前分类或类别下全局唯一的 key */ + key: string + /** + * 当前的分类名称 + * + * @description 仅在分类子项目页面中可用 + */ + name?: string +} +``` + +所以你可以直接调用 `useBlogCategory()` 和 `useBlogType()`,结果将是当前路由绑定的类别或类型。 + +此外,你可以通过传递所需的 `key` 作为参数,来将获得绑定到该 `key` 的信息。 + +对于上方的 Node 配置而言,你可以在客户端通过如下方式获取 tag 和 star 的信息: + +`TagMap` 布局: + +```vue + + +``` + +`TagList` 布局: + +```vue + + +``` + +`StarList` 布局: + +```vue + + +``` + +有关返回类型,请参阅 [Composition API 返回类型](./config.md#可组合式-API)。 + +## 多语言支持 + +该插件添加了原生多语言支持,因此你的设置将自动应用于每种语言。 + +例如,如果用户进行了以下 locales 配置,并且你正在设置上面的“star”示例: + +```ts title=".vuepress/config.ts" +export default { + locales: { + '/': { + lang: 'en-US', + }, + '/zh/': { + lang: 'zh-CN', + }, + }, +} +``` + +那么 `/zh/star/` 和 `/star/` 都将可用,并且只会显示对应语言下的文章。 + +## 摘要生成 + +这个插件提供了一个内置的摘要生成器,可以通过将 `excerpt` 选项设置为 `true` 来启用。 + +::: tip 摘要介绍 + +摘要是一个 HTML 片段,被用于在博客列表中显示文章的简短描述,所以摘要有如下限制: + +- 摘要不支持任何未知标签以及 Vue 语法,所以此类内容会在生成时被移除。如果你有自定义组件 (非 Vue 组件),请配置 `isCustomElement` 选项。 +- 由于摘要是一个 HTML 片段,所以你将无法通过相对路径或别名引入任何图片,这些图片会被直接移除。如果你想要保留图片,请使用基于 `.vuepress/public` 的绝对路径或完整路径以确保它们可以在其他地址被访问。 + +::: + +摘要生成器将尝试从 Frontmatter 内容中找到有效的摘要分隔符,如果找到,它将使用分隔符之前的内容,分隔符默认为 ``,并且你可以通过 `excerptSeparator` 选项来自定义它。 + +如果找不到有效的分隔符,它将从 Markdown 文件的开头开始解析内容,直到长度达到预设值时停止。该值默认为 `300`,你可以通过设置 `excerptLength` 选项来自定义它。 + +要选择哪个页面应该生成摘要,你可以使用 `excerptFilter` 选项。 + +::: tip 示例 + +通常,如果用户设置了 `frontmatter.description`,你可能希望使用它们,因此如果 `frontmatter.description` 不为空,你可以让过滤器函数返回 `false`。 + +::: diff --git a/docs-next/zh/plugins/blog/comment/README.md b/docs-next/zh/plugins/blog/comment/README.md new file mode 100644 index 0000000000..7f2b19f53b --- /dev/null +++ b/docs-next/zh/plugins/blog/comment/README.md @@ -0,0 +1,21 @@ +# comment + + + +## 使用 + +```bash +npm i -D @vuepress/plugin-comment@next +``` + +```ts title=".vuepress/config.ts" +import { commentPlugin } from '@vuepress/plugin-comment' + +export default { + plugins: [ + commentPlugin({ + // 选项 + }), + ], +} +``` diff --git a/docs-next/zh/plugins/blog/comment/artalk/README.md b/docs-next/zh/plugins/blog/comment/artalk/README.md new file mode 100644 index 0000000000..298f5b5841 --- /dev/null +++ b/docs-next/zh/plugins/blog/comment/artalk/README.md @@ -0,0 +1,33 @@ +# Artalk + +Artalk 是一款简洁的自托管评论系统,你可以在服务器上轻松部署并置入前端页面中。 + +来到你的博客,或是任意位置,放置 Artalk 评论框,让页面具备丰富的社会化功能。 + + + +## 安装 + +```bash +npm i -D artalk +``` + +## 部署 Artalk 服务端 + +请参见 [Artalk 文档](https://artalk.js.org/guide/deploy.html)。 + +## 配置 + +请配置 `provider: "Artalk"` 并将你的服务端地址传入插件选项中的 `server`。 + +其他的配置项详见 [Artalk 配置](config.md)。 + +::: tip + +插件保留 `el` 选项在页面自行插入 Artalk。同时插件会自动根据 VuePress 信息为你自动设置 `pageTitle`, `pageKey` 和 `site` 选项。 + +::: + +## 夜间模式 + +为了能使 Artalk 应用正确的主题,你需要通过 `darkmode` 属性向 `` 传入一个布尔值,代表当前是否开启夜间模式。 diff --git a/docs-next/zh/plugins/blog/comment/artalk/config.md b/docs-next/zh/plugins/blog/comment/artalk/config.md new file mode 100644 index 0000000000..9f957fa8ce --- /dev/null +++ b/docs-next/zh/plugins/blog/comment/artalk/config.md @@ -0,0 +1,41 @@ +# Artalk 选项 + +## 配置 + +详见 [Artalk 配置](https://artalk.js.org/guide/frontend/config.html)。 + +- `el` `pageTitle`, `pageKey` 和 `site` 选项为插件的保留选项,将从 VuePress 配置中自动推断,不可设置。 + +- `imgUploader` 和 `avatarURLBuilder` 这两个函数选项只能在客户端配置。 + +## 插件配置 + +你可以直接在插件选项中配置可序列化的选项: + +```ts title=".vuepress/config.ts" +import { commentPlugin } from '@vuepress/plugin-comment' +import { defineUserConfig } from 'vuepress' + +export default defineUserConfig({ + plugins: [ + commentPlugin({ + provider: 'Artalk', + // 其他选项 + // ... + }), + ], +}) +``` + +## 客户端配置 + +你可以使用 `defineArtalkConfig` 函数来配置 Artalk。 + +```ts title=".vuepress/client.ts" +import { defineArtalkConfig } from '@vuepress/plugin-comment/client' +import { defineClientConfig } from 'vuepress/client' + +defineArtalkConfig({ + // Artalk 选项 +}) +``` diff --git a/docs-next/zh/plugins/blog/comment/giscus/README.md b/docs-next/zh/plugins/blog/comment/giscus/README.md new file mode 100644 index 0000000000..1d743b7d09 --- /dev/null +++ b/docs-next/zh/plugins/blog/comment/giscus/README.md @@ -0,0 +1,31 @@ +# Giscus + +Giscus 是一个基于 GitHub Discussion 的评论系统,启用简便。 + + + +## 准备工作 + +1. 你需要创建一个公开仓库,并开启评论区,以作为评论存放的地点 +1. 你需要安装 [Giscus App](https://github.com/apps/giscus),使其有权限访问对应仓库。 +1. 在完成以上步骤后,请前往 [Giscus 页面](https://giscus.app/zh-CN) 获得你的设置。 + + 你只需要填写仓库和 Discussion 分类,之后滚动到页面下部的 “启用 giscus” 部分,获取 `data-repo`, `data-repo-id`, `data-category` 和 `data-category-id` 这四个属性。 + +## 配置 + +请配置 `provider: "Giscus"` 并将 `data-repo`, `data-repo-id`, `data-category` 和 `data-category-id` 作为插件选项传入 `repo`, `repoId`, `category` `categoryId`。 + +其他的配置项详见 [Giscus 配置](./config.md)。 + +## 主题 + +默认情况下,Giscus 使用 `light` 或 `dark` 主题 (基于夜间模式状态)。 + +::: info 夜间模式 + +为了能使 Giscus 应用正确的主题,你需要为 `` 通过 `darkmode` 属性传入一个布尔值,代表当前是否开启夜间模式。 + +::: + +如果你想在日间模式和夜间模式下自定义主题,你可以设置 `lightTheme` 和 `darkTheme` 选项,使用内置主题关键字或以 `https://` 开头的自定义 css 链接。 diff --git a/docs-next/zh/plugins/blog/comment/giscus/config.md b/docs-next/zh/plugins/blog/comment/giscus/config.md new file mode 100644 index 0000000000..afe1565a03 --- /dev/null +++ b/docs-next/zh/plugins/blog/comment/giscus/config.md @@ -0,0 +1,135 @@ +# Giscus 选项 + +## 配置 + +### repo + +- 类型: `string` +- 详情:存放评论的仓库 + +### repoId + +- 类型: `string` +- 详情:仓库 ID,请从 [Giscus 页面](https://giscus.app/zh-CN) 生成。 + +### category + +- 类型: `string` +- 详情:讨论分类 + +### categoryId + +- 类型: `string` +- 详情:讨论分类 ID,请从 [Giscus 页面](https://giscus.app/zh-CN) 生成。 + +### mapping + +- 类型: `string` +- 默认值: `"pathname"` +- 详情:页面 ↔️ discussion 映射关系,详见 [Giscus 页面](https://giscus.app/zh-CN)。 + +### strict + +- 类型: `boolean` +- 默认值: `true` +- 详情:是否启用严格匹配 + +### lazyLoading + +- 类型: `boolean` +- 默认值: `true` +- 详情:是否启用懒加载 + +### reactionsEnabled + +- 类型: `boolean` +- 默认值: `true` +- 详情:是否启用主帖子上的反应 + +### inputPosition + +- 类型: `"top" | "bottom"` +- 默认值: `"top"` +- 详情:输入框的位置 + +### lightTheme + +- 类型: `GiscusTheme` + + ```ts + type GiscusTheme = + | 'dark_dimmed' + | 'dark_high_contrast' + | 'dark_protanopia' + | 'dark' + | 'light_high_contrast' + | 'light_protanopia' + | 'light' + | 'preferred_color_scheme' + | 'transparent_dark' + | `https://${string}` + ``` + +- 默认值: `"light"` +- 详情: + + Giscus 在日间模式下使用的主题 + + 应为一个内置主题关键词或者一个 CSS 链接。 + +### darkTheme + +- 类型: `GiscusTheme` + + ```ts + type GiscusTheme = + | 'dark_dimmed' + | 'dark_high_contrast' + | 'dark_protanopia' + | 'dark' + | 'light_high_contrast' + | 'light_protanopia' + | 'light' + | 'preferred_color_scheme' + | 'transparent_dark' + | `https://${string}` + ``` + +- 默认值: `"dark"` +- 详情: + + Giscus 在夜间模式下使用的主题 + + 应为一个内置主题关键词或者一个 CSS 链接。 + +## 插件配置 + +你可以直接在插件选项中配置可序列化的选项: + +```ts title=".vuepress/config.ts" +import { commentPlugin } from '@vuepress/plugin-comment' +import { defineUserConfig } from 'vuepress' + +export default defineUserConfig({ + plugins: [ + commentPlugin({ + provider: 'Giscus', + // 其他选项 + // ... + }), + ], +}) +``` + +## 客户端配置 + +你可以使用 `defineGiscusConfig` 函数来配置 Giscus。 + +```ts title=".vuepress/client.ts" +import { defineGiscusConfig } from '@vuepress/plugin-comment/client' +import { defineClientConfig } from 'vuepress/client' + +defineGiscusConfig({ + // Giscus 选项 +}) +``` diff --git a/docs-next/zh/plugins/blog/comment/guide.md b/docs-next/zh/plugins/blog/comment/guide.md new file mode 100644 index 0000000000..750a51659d --- /dev/null +++ b/docs-next/zh/plugins/blog/comment/guide.md @@ -0,0 +1,98 @@ +--- +layout: CommentPage +--- + +# 指南 + +## 设置选项 + +你既可以在 Node.js 一侧使用插件选项设置选项,也可以通过[客户端配置文件][client-config]在浏览器一侧设置选项。 + +### 通过插件选项 + +```ts +import { commentPlugin } from '@vuepress/plugin-comment' + +// .vuepress/config.ts +export default { + plugins: [ + commentPlugin({ + provider: 'Artalk', // Artalk | Giscus | Waline | Twikoo + + // 在这里放置其他选项 + // ... + }), + ], +} +``` + +### 通过客户端配置文件 + +```ts title=".vuepress/client.ts" +import { + defineArtalkConfig, + // defineGiscusConfig, + // defineTwikooConfig, + // defineWalineConfig, +} from '@vuepress/plugin-comment/client' +import { defineClientConfig } from 'vuepress/client' + +defineArtalkConfig({ + // 选项 +}) +``` + +有以下你需要注意的限制: + +- `provider`、多语言设置和其他资源相关选项必须在插件选项中设置。 + + 为确保 tree-shaking 有效,我们必须在 Node 一侧优化入口,以便打包器可以了解最终打包中应包含哪些资源。 + + 这些选项将在配置参考中用 标记。 + +- 不能序列化为 JSON 的选项必须在客户端配置中设置。 + + 接收复杂值的选项(例如:函数)不能在插件选项中设置,因为插件运行在 Node.js 环境下,所以我们无法将这些值和它们的上下文传递给浏览器。 + + 这些选项将在配置参考中用 标记。 + +## 添加评论 + +该插件全局注册了一个组件 ``。 + +- 如果你是用户,你应该使用 `alias` 和布局槽来插入组件。 我们建议你在 `` 组件之后插入评论组件 (``),本页可作为一个 Demo 作为参考。 +- 如果你是主题开发者,你应该将这个组件插入到你的主题布局中。 + +默认情况下,`` 组件是全局启用的,你可以在插件选项和页面 frontmatter 中使用 `comment` 选项来控制它。 + +- 你可以通过在页面 frontmatter 中设置 `comment: false` 在本地禁用它。 +- 要使其全局禁用,请在插件选项中将 `comment` 设置为 `false`。 然后你可以在页面 frontmatter 中设置 comment: true 以在局部启用它。 + +你可以在页面 frontmatter 中设置 commentID 选项来自定义评论 ID,该 ID 用于标识要用于页面的评论存储项。默认情况下,它将是页面的 `path` ,这意味着如果你将站点部署到多个位置,站点间具有相同内容的页面将共享相同的评论数据。 + +## 可用的评论服务 + +目前你可以选择 [Giscus](giscus/README.md)、[Waline](waline/README.md)、[Artalk](artalk/README.md) 和 [Twikoo](twikoo/README.md)。 + +::: tip 推荐的评论服务 + +- 面向程序员和开发人员: Giscus +- 面向公众: Waline + +::: + +## 通用选项 + +### provider + +- 类型: `"Artalk" | "Giscus" | "Twikoo" | "Waline" | "None"` +- 默认值: `"None"` +- 详情:评论服务提供者。 + +### comment + +- 类型: `boolean` +- 默认值: `true` +- 详情:是否默认启用评论功能。 + +[client-config]: https://vuejs.press/zh/guide/configuration.html#%E5%AE%A2%E6%88%B7%E7%AB%AF%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6 diff --git a/docs-next/zh/plugins/blog/comment/twikoo/README.md b/docs-next/zh/plugins/blog/comment/twikoo/README.md new file mode 100644 index 0000000000..cf8b8b9b21 --- /dev/null +++ b/docs-next/zh/plugins/blog/comment/twikoo/README.md @@ -0,0 +1,135 @@ +--- +title: Twikoo +icon: t +--- + +一个简洁、安全、免费的静态网站评论系统,基于 [腾讯云开发](https://curl.qcloud.com/KnnJtUom)。 + + + +## 安装 + +```bash +npm i -D twikoo +``` + +## 快速上手 + +部署共有四种方式。 + +| 部署方式 | 描述 | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [一键部署](#一键部署) | \[不建议\] 虽然方便,但是仅支持按量计费环境——也就是说,**一键部署的环境,当免费资源用尽后,将会产生费用**。且按量计费环境无法切换为包年包月环境。免费额度数据库读操作数只有 500 次 / 天,**无法支撑 Twikoo 的运行需求**。 | +| [手动部署](#手动部署) | \[建议\] 手动部署到腾讯云云开发环境,在中国大陆访问速度较快。由于基础版 1 已从 0 元涨价至 6.9 元 / 月,需要付费购买环境才能部署。 | +| [命令行部署](#命令行部署) | \[不建议\] 仅针对有 Node.js 经验的开发者。 | +| [Vercel 部署](#vercel-部署) | \[建议\] 适用于想要免费部署的用户,在中国大陆访问速度较慢。 | + +### 一键部署 + +1. 点击以下按钮将 Twikoo 一键部署到云开发 + + [![部署到云开发](https://main.qcloudimg.com/raw/67f5a389f1ac6f3b4d04c7256438e44f.svg)](https://console.cloud.tencent.com/tcb/env/index?action=CreateAndDeployCloudBaseProject&appUrl=https%3A%2F%2Fgithub.com%2Fimaegoo%2Ftwikoo&branch=dev) + +1. 进入[环境 - 登录授权](https://console.cloud.tencent.com/tcb/env/login),启用“匿名登录” +1. 进入[环境 - 安全配置](https://console.cloud.tencent.com/tcb/env/safety),将网站域名添加到“WEB 安全域名” + +### 手动部署 + +如果你打算部署到一个现有的云开发环境,请直接从第 3 步开始。 + +1. 进入[云开发 CloudBase](https://curl.qcloud.com/KnnJtUom)活动页面,滚动到“新用户专享”部分,选择适合的套餐,点击“立即购买”,按提示创建好环境。 + + ::: tip 提示 + + - 推荐创建上海环境。如选择广州环境,需要在 `twikoo.init()` 时额外指定环境 `region: "ap-guangzhou"` + - 环境名称自由填写 + - 推荐选择计费方式`包年包月`,套餐版本`基础版 1`,超出免费额度不会收费 + - 如果提示选择“应用模板”,请选择“空模板” + + ::: + +1. 进入[云开发控制台](https://console.cloud.tencent.com/tcb/)
+1. 进入[环境-登录授权](https://console.cloud.tencent.com/tcb/env/login),启用“匿名登录” +1. 进入[环境-安全配置](https://console.cloud.tencent.com/tcb/env/safety),将网站域名添加到“WEB 安全域名” +1. 进入[环境-云函数](https://console.cloud.tencent.com/tcb/scf/index),点击“新建云函数” +1. 函数名称请填写 `twikoo`,创建方式请选择 `空白函数`,运行环境请选择 `Nodejs 10.15`,函数内存请选择 `128MB`,点击“下一步” +1. 清空输入框中的示例代码,复制以下代码、粘贴到“函数代码”输入框中,点击“确定” + + ```js + exports.main = require('twikoo-func').main + ``` + +1. 创建完成后,点击“twikoo”进入云函数详情页,进入“函数代码”标签,点击“文件 - 新建文件”,输入 `package.json`,回车 +1. 复制以下代码、粘贴到代码框中,点击“保存并安装依赖” + + ```json + { "dependencies": { "twikoo-func": "1.5.0" } } + ``` + +### 命令行部署 + +::: warning 注意 + +- 请确保你已经安装了 [Node.js](https://nodejs.org/en/download/) +- 请将命令、代码中“你的环境 ID”替换为你自己的环境 ID +- 第 7 步会弹出浏览器要求授权,需在有图形界面的系统下进行 + +::: + +如果你打算部署到一个现有的云开发环境,请直接从第 3 步开始。 + +1. 进入[云开发 CloudBase](https://curl.qcloud.com/KnnJtUom)活动页面,滚动到“新用户专享”部分,选择适合的套餐 (一般 0 元套餐即可) ,点击“立即购买”,按提示创建好环境。 +1. 进入[云开发控制台](https://console.cloud.tencent.com/tcb/) +1. 进入[环境 - 登录授权](https://console.cloud.tencent.com/tcb/env/login),启用“匿名登录” +1. 进入[环境 - 安全配置](https://console.cloud.tencent.com/tcb/env/safety),将网站域名添加到“WEB 安全域名” +1. 克隆本仓库 + + ```sh + git clone https://github.com/imaegoo/twikoo.git # 或 git clone https://e.coding.net/imaegoo/twikoo/twikoo.git + cd twikoo + ``` + + > 如果你没有安装 Git,也可以从 [Release](https://github.com/imaegoo/twikoo/releases) 页面下载最新的 Source code + > + > 如果你所在的地区访问 GitHub 速度慢,也可以尝试另一个仓库地址: [https://imaegoo.coding.net/public/twikoo/twikoo/git](https://imaegoo.coding.net/public/twikoo/twikoo/git) + +1. 安装依赖项 + + ```sh + npm install -g yarn # 如 yarn 已安装,可以跳过此步 + yarn install + ``` + +1. 授权云开发环境 (此命令会弹出浏览器要求授权,需在有图形界面的系统下进行) + + ```sh + yarn run login + ``` + +1. 自动部署 + + ```sh + yarn deploy -e 你的环境id + ``` + +### Vercel 部署 + +[查看视频教程](https://www.bilibili.com/video/BV1Fh411e7ZH) + +1. 申请 [MongoDB](https://www.mongodb.com/cloud/atlas/register) 账号 +1. 创建免费 MongoDB 数据库,区域推荐选择 `AWS / N. Virginia (us-east-1)` +1. 在 Clusters 页面点击 CONNECT,按步骤设置允许所有 IP 地址的连接 ([为什么?](https://vercel.com/support/articles/how-to-allowlist-deployment-ip-address)) ,创建数据库用户,并记录数据库连接字符串,请将连接字符串中的 `` 修改为数据库密码 +1. 申请 [Vercel](https://vercel.com/signup) 账号 +1. 点击以下按钮将 Twikoo 一键部署到 Vercel + + [![Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/imaegoo/twikoo/tree/dev/src/vercel-min) + +1. 进入 Settings - Environment Variables,添加环境变量 `MONGODB_URI`,值为第 3 步的数据库连接字符串 +1. 进入 Overview,点击 Domains 下方的链接,如果环境配置正确,可以看到 “Twikoo 云函数运行正常” 的提示 +1. Vercel Domains (包含 `https://` 前缀,例如 `https://xxx.vercel.app`) 即为你的环境 ID + +## 配置 + +请配置 `provider: "Twikoo"` 并将你的服务端地址传入插件选项中的 `server`。 + +其他的配置项详见 [Twikoo 配置](config.md)。 diff --git a/docs-next/zh/plugins/blog/comment/twikoo/config.md b/docs-next/zh/plugins/blog/comment/twikoo/config.md new file mode 100644 index 0000000000..e4daaed440 --- /dev/null +++ b/docs-next/zh/plugins/blog/comment/twikoo/config.md @@ -0,0 +1,47 @@ +# Twikoo 选项 + +## 配置 + +### envId + +- 类型: `string` +- 必填: 是 +- 详情:腾讯云环境 ID 或 Vercel 地址。 + +### repoId + +- 类型: `string` +- 默认值: `"ap-shanghai"` +- 详情:腾讯云区域。 + +## 插件配置 + +你可以直接在插件选项中配置可序列化的选项: + +```ts title=".vuepress/config.ts" +import { commentPlugin } from '@vuepress/plugin-comment' +import { defineUserConfig } from 'vuepress' + +export default defineUserConfig({ + plugins: [ + commentPlugin({ + provider: 'Twikoo', + // 其他选项 + // ... + }), + ], +}) +``` + +## 客户端配置 + +你可以使用 `defineTwikooConfig` 函数来配置 Twikoo。 + +```ts title=".vuepress/client.ts" +import { defineTwikooConfig } from '@vuepress/plugin-comment/client' +import { defineClientConfig } from 'vuepress/client' + +defineTwikooConfig({ + // Twikoo 选项 +}) +``` diff --git a/docs-next/zh/plugins/blog/comment/waline/README.md b/docs-next/zh/plugins/blog/comment/waline/README.md new file mode 100644 index 0000000000..3158b85e65 --- /dev/null +++ b/docs-next/zh/plugins/blog/comment/waline/README.md @@ -0,0 +1,119 @@ +# Waline + +一个有后端的安全评论系统。 + + + +## 安装 + +```bash +npm i -D @waline/client +``` + +## LeanCloud 设置 (数据库) + +1. [登录](https://console.leancloud.app/login) 或 [注册](https://console.leancloud.app/register) `LeanCloud 国际版` 并进入 [控制台](https://console.leancloud.app/apps) + +1. 点击左上角 [创建应用](https://console.leancloud.app/apps) 并起一个你喜欢的名字 (请选择免费的开发版): + + ![创建应用](./assets/leancloud-1.png) + +1. 进入应用,选择左下角的 `设置` > `应用 Key`。你可以看到你的 `APP ID`,`APP Key` 和 `Master Key`。请记录它们,以便后续使用。 + + ![ID 和 Key](./assets/leancloud-2.png) + +::: warning 国内版需要完成备案接入 + +如果你正在使用 Leancloud 国内版 ([leancloud.cn](https://leancloud.cn)),我们推荐你切换到国际版 ([leancloud.app](https://leancloud.app))。否则,你需要为应用额外绑定**已备案**的域名,同时购买独立 IP 并完成备案接入: + +- 登录国内版并进入需要使用的应用 +- 选择 `设置` > `域名绑定` > `API 访问域名` > `绑定新域名` > 输入域名 > `确定`。 +- 按照页面上的提示按要求在 DNS 上完成 CNAME 解析。 +- 购买独立 IP 并提交工单完成备案接入。(独立 IP 目前价格为 ¥ 50/个/月) + +![域名设置](./assets/leancloud-3.png) + +::: + +## Vercel 部署 (服务端) + +[![Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwalinejs%2Fwaline%2Ftree%2Fmain%2Fexample) + +1. 点击上方按钮,跳转至 Vercel 进行 Server 端部署。 + + ::: tip + + 如果你未登录的话,Vercel 会让你注册或登录,请使用 GitHub 账户进行快捷登录。 + + ::: + +1. 输入一个你喜欢的 Vercel 项目名称并点击 `Create` 继续: + + ![创建项目](/images/comment/vercel-1.png) + +1. 此时 Vercel 会基于 Waline 模板帮助你新建并初始化仓库,仓库名为你之前输入的项目名。 + + ![deploy](/images/comment/vercel-3.png) + + 一两分钟后,满屏的烟花会庆祝你部署成功。此时点击 `Go to Dashboard` 可以跳转到应用的控制台。 + + ![deploy](/images/comment/vercel-4.png) + +1. 点击顶部的 `Settings` - `Environment Variables` 进入环境变量配置页,并配置三个环境变量 `LEAN_ID`, `LEAN_KEY` 和 `LEAN_MASTER_KEY` 。它们的值分别对应上一步在 LeanCloud 中获得的 `APP ID`, `APP KEY`, `Master Key`。 + + ![设置环境变量](/images/comment/vercel-5.png) + + ::: tip + + 如果你使用 LeanCloud 国内版,请额外配置 `LEAN_SERVER` 环境变量,值为你绑定好的域名。 + + ::: + +1. 环境变量配置完成之后点击顶部的 `Deployments` 点击顶部最新的一次部署右侧的 `Redeploy` 按钮进行重新部署。该步骤是为了让刚才设置的环境变量生效。 + + ![redeploy](/images/comment/vercel-6.png) + +1. 此时会跳转到 `Overview` 界面开始部署,等待片刻后 `STATUS` 会变成 `Ready`。此时请点击 `Visit` ,即可跳转到部署好的网站地址,此地址即为你的服务端地址。 + + ![redeploy success](/images/comment/vercel-7.png) + +## 绑定域名 (可选) + +1. 点击顶部的 `Settings` - `Domains` 进入域名配置页 + +1. 输入需要绑定的域名并点击 `Add` + + ![Add domain](/images/comment/vercel-8.png) + +1. 在域名服务器商处添加新的 `CNAME` 解析记录 + + | Type | Name | Value | + | ----- | ------- | -------------------- | + | CNAME | example | cname.vercel-dns.com | + +1. 等待生效,你可以通过自己的域名来访问了:tada: + + - 评论系统:example.your-domain.com + - 评论管理:example.your-domain.com/ui + + ![success](/images/comment/vercel-9.png) + +## 客户端 + +### 使用插件 + +在插件选项中设置 `provider: "Waline"`,同时设置服务端地址 `serverURL` 为上一步获取到的值。 + +此时,将 `` 组件放置在你网站中合适的位置 (通常是页面的底部),即可使用 Waline 评论功能。 + +::: tip + +你也可以传入其他 Waline 支持的选项 (除了 `el`)。详情请见 [Waline 配置](config.md) + +::: + +## 评论管理 (管理端) + +1. 部署完成后,请访问 `/ui/register` 进行注册。首个注册的人会被设定成管理员。 +1. 管理员登陆后,即可看到评论管理界面。在这里可以修改、标记或删除评论。 +1. 用户也可通过评论框注册账号,登陆后会跳转到自己的档案页。 diff --git a/docs-next/zh/plugins/blog/comment/waline/assets/leancloud-1.png b/docs-next/zh/plugins/blog/comment/waline/assets/leancloud-1.png new file mode 100644 index 0000000000..9ffbd2e6b3 Binary files /dev/null and b/docs-next/zh/plugins/blog/comment/waline/assets/leancloud-1.png differ diff --git a/docs-next/zh/plugins/blog/comment/waline/assets/leancloud-2.png b/docs-next/zh/plugins/blog/comment/waline/assets/leancloud-2.png new file mode 100644 index 0000000000..969d1a3df9 Binary files /dev/null and b/docs-next/zh/plugins/blog/comment/waline/assets/leancloud-2.png differ diff --git a/docs-next/zh/plugins/blog/comment/waline/assets/leancloud-3.png b/docs-next/zh/plugins/blog/comment/waline/assets/leancloud-3.png new file mode 100644 index 0000000000..4acf707598 Binary files /dev/null and b/docs-next/zh/plugins/blog/comment/waline/assets/leancloud-3.png differ diff --git a/docs-next/zh/plugins/blog/comment/waline/config.md b/docs-next/zh/plugins/blog/comment/waline/config.md new file mode 100644 index 0000000000..da795ff1ab --- /dev/null +++ b/docs-next/zh/plugins/blog/comment/waline/config.md @@ -0,0 +1,300 @@ +# Waline 选项 + +## 配置 + +### serverURL + +- 类型: `string` +- 详情:Waline 的服务端地址。 + +### emoji + +- 类型: `(WalineEmojiInfo | WalineEmojiPresets)[] | false` + + ```ts + type WalineEmojiPresets = `http://${string}` | `https://${string}` + + interface WalineEmojiInfo { + /** + * 选项卡上的 Emoji 名称 + */ + name: string + /** + * 所在文件夹链接 + */ + folder?: string + /** + * Emoji 通用路径前缀 + */ + prefix?: string + /** + * Emoji 图片的类型,会作为文件扩展名使用 + */ + type?: string + /** + * 选项卡显示的 Emoji 图标 + */ + icon: string + /** + * Emoji 图片列表 + */ + items: string[] + } + ``` + +- 默认值: `['//unpkg.com/@waline/emojis@1.1.0/weibo']` +- 参考: + - [自定义表情](https://waline.js.org/guide/features/emoji.html) +- 详情:表情设置 + +### dark + +- 类型: `string | boolean` +- 默认值: `false` +- 参考: + - [自定义样式](https://waline.js.org/guide/features/style.html) +- 详情: + + 暗黑模式适配。 + + - 设置布尔值会根据其值来设置暗黑模式。 + - 设置 `'auto'` 会根据设备暗黑模式自适应。 + - 填入 CSS 选择器会在对应选择器生效时启用夜间模式。 + +### commentSorting + +- 类型: `WalineCommentSorting` +- 默认值: `'latest'` +- 详情: + + 评论列表排序方式。可选值: `'latest'`, `'oldest'`, `'hottest'` + +### meta + +- 类型: `string[]` +- 默认值: `['nick', 'mail', 'link']` +- 详情: + + 评论者相关属性。可选值: `'nick'`, `'mail'`, `'link'` + +### requiredMeta + +- 类型: `string[]` +- 默认值: `[]` +- 详情: + + 设置**必填项**,默认匿名,可选值: + + - `[]` + - `['nick']` + - `['nick', 'mail']` + +### login + +- 类型: `string` +- 默认值: `'enable'` + +登录模式状态,可选值: + +- `'enable'`: 启用登录 (默认) +- `'disable'`: 禁用登录,用户只能填写信息评论 +- `'force'`: 强制登录,用户必须注册并登录才可发布评论 + +### wordLimit + +- 类型: `number | [number, number]` +- 默认值: `0` +- 详情: + + 评论字数限制。填入单个数字时为最大字数限制。设置为 `0` 时无限制。 + +### pageSize + +- 类型: `number` +- 默认值: `10` +- 详情:评论列表分页,每页条数。 + +### imageUploader + +- 类型: `WalineImageUploader | false` + +- 详情: + + ```ts + type WalineImageUploader = (image: File) => Promise + ``` + +- 参考: + + - [Cookbook → 自定义图片上传](https://waline.js.org/cookbook/customize/upload-image.html) + +- 详情: + + 自定义图片上传方法。默认行为是将图片 Base 64 编码嵌入,你可以设置为 `false` 以禁用图片上传功能。 + + 函数应该接收图片对象,返回一个提供图片地址的 Promise。 + +### highlighter + +- 类型: `WalineHighlighter | false` + + ```ts + type WalineHighlighter = (code: string, lang: string) => string + ``` + +- 参考: + - [Cookbook → 自定义代码高亮](https://waline.js.org/cookbook/customize/highlighter.html) +- 详情: + + **代码高亮**,默认使用一个 < 1kb 的简单高亮器。函数传入代码块的原始字符和代码块的语言。你应该直接返回一个字符串。 + + 你可以传入一个自己的代码高亮器,也可以设置为 `false` 以禁用代码高亮功能。 + +### texRenderer + +- 类型: `WalineTexRenderer | false` + + ```ts + type WalineTexRenderer = (blockMode: boolean, tex: string) => string + ``` + +- 参考: + + - [Cookbook → 自定义 TeX 渲染器](https://waline.js.org/cookbook/customize/tex-renderer.html) + - [MathJax](https://www.mathjax.org/) + - [KaTeX](https://katex.org/) + +- 详情: + + 自定义 TeX 渲染,默认行为是提示预览模式不支持 TeX。函数提供两个参数,第一个参数表示渲染模式是否为块级,第二个参数是 TeX 的字符串,并返回一段 HTML 字符串作为渲染结果。 + + 你可以自行引入 TeX 渲染器并提供预览渲染,建议使用 Katex 或 MathJax,也可以设置为 `false` 以禁止渲染 TeX。 + +### search + +- 类型: `WalineSearchOptions | false` + + ```ts + interface WalineSearchImageData extends Record { + /** + * 图片链接 + */ + src: string + + /** + * 图片标题 + * + * @description 用于图片的 alt 属性 + */ + title?: string + + /** + * 图片缩略图 + * + * @description 为了更好的加载性能,我们会优先在列表中使用此缩略图 + * + * @default src + */ + preview?: string + } + + type WalineSearchResult = WalineSearchImageData[] + + interface WalineSearchOptions { + /** + * 搜索操作 + */ + search: (word: string) => Promise + + /** + * 打开列表时展示的默认结果 + * + * @default () => search('') + */ + default?: () => Promise + + /** + * 获取更多的操作 + * + * @description 会在列表滚动到底部时触发,如果你的搜索服务支持分页功能,你应该设置此项实现无限滚动 + * + * @default (word) => search(word) + */ + more?: (word: string, currentCount: number) => Promise + } + ``` + +- 详情:自定义搜索功能,设置 `false` 可禁用搜索。 + +### recaptchaV3Key + +- 类型: `string` +- 详情: + + reCAPTCHA V3 是 Google 提供的验证码服务,配置 reCAPTCHA V3 网站密钥即可开启该功能。 + + 服务端需要同步配置 `RECAPTCHA_V3_SECRET` 环境变量。 + +### reaction + +- 类型: `boolean | string[]` +- 默认值: `false` +- 详情: + + 为文章增加表情互动功能,设置为 `true` 提供默认表情,也可以通过设置表情地址数组来自定义表情图片,最大支持 8 个表情。 + +### metaIcon + +- 类型: `boolean` +- 默认值: `true` +- 详情: + + 是否导入 Meta 图标。 + +### locales + +- 类型: `WalineLocales` + + ```ts + interface WalineLocales { + [localePath: string]: WalineLocale + } + ``` + +- 参考: + - [Waline 多语言配置](https://waline.js.org/cookbook/customize/locale.html) +- 详情: + + Waline 多语言配置 + +## 插件配置 + +你可以直接在插件选项中配置可序列化的选项: + +```ts title=".vuepress/config.ts" +import { commentPlugin } from '@vuepress/plugin-comment' +import { defineUserConfig } from 'vuepress' + +export default defineUserConfig({ + plugins: [ + commentPlugin({ + provider: 'Waline', + // 其他选项 + // ... + }), + ], +}) +``` + +## 客户端配置 + +你可以使用 `defineWalineConfig` 函数来配置 Waline。 + +```ts title=".vuepress/client.ts" +import { defineWalineConfig } from '@vuepress/plugin-comment/client' +import { defineClientConfig } from 'vuepress/client' + +defineWalineConfig({ + // Waline 选项 +}) +``` diff --git a/docs-next/zh/plugins/blog/feed/README.md b/docs-next/zh/plugins/blog/feed/README.md new file mode 100644 index 0000000000..7f4b0b4c19 --- /dev/null +++ b/docs-next/zh/plugins/blog/feed/README.md @@ -0,0 +1,21 @@ +# feed + + + +## 使用 + +```bash +npm i -D @vuepress/plugin-feed@next +``` + +```ts title=".vuepress/config.ts" +import { feedPlugin } from '@vuepress/plugin-feed' + +export default { + plugins: [ + feedPlugin({ + // 选项 + }), + ], +} +``` diff --git a/docs-next/zh/plugins/blog/feed/channel.md b/docs-next/zh/plugins/blog/feed/channel.md new file mode 100644 index 0000000000..09497d62f7 --- /dev/null +++ b/docs-next/zh/plugins/blog/feed/channel.md @@ -0,0 +1,118 @@ +# 频道设置 + +`channel` 插件选项用于配置 feed 的频道。 + +## channel.title + +- 类型:`string` +- 默认值:`SiteConfig.title` + +频道的标题 + +## channel.link + +- 类型:`string` +- 默认值:部署的网址 (通过 `options.hostname` 和 `context.base` 生成) + +频道地址 + +## channel.description + +- 类型:`string` +- 默认值:`SiteConfig.description` + +频道描述信息 + +## channel.language + +- 类型:`string` +- 默认值: + - `siteConfig.locales['/'].locales` + - 如果上述未提供,回退到 `"en-US"` + +频道使用的语言 + +## channel.copyright + +- 类型:`string` +- 默认值: + - 尝试读取 channel 选项中的 `author.name` 生成 `Copyright by $author` +- 建议自行设置: **是** + +频道版权信息 + +## channel.pubDate + +- 类型:`string` (需是合法的 Date ISOString) +- 默认值:每次插件构建时刻 +- 建议自行设置: **是** + +频道内容的发布时间 + +## channel.lastUpdated + +- 类型:`string` (需是合法的 Date ISOString) +- 默认值:每次插件构建时刻 + +频道内容的上次更新时间 + +## channel.ttl + +- 类型:`number` +- 建议自行设置: **是** + +内容有效时间,即获取后保持缓存而不进行新获取的时间 + +## channel.image + +- 类型:`string` +- 建议自行设置: **是** + +这是一个会在频道中使用的图片,建议设置正方形图片、尺寸最好不小于 512×512。 + +## channel.icon + +- 类型:`string` +- 建议自行设置: **是** + +一个代表频道的图标,建议设置正方形图片、尺寸最好不小于 128×128,背景色透明。 + +## channel.author + +- 类型:`FeedAuthor` +- 建议自行设置: **是** + +频道的作者。 + +::: details FeedAuthor 格式 + +```ts +interface FeedAuthor { + /** 作者姓名 */ + name: string + /** 作者电子邮箱 */ + email?: string + /** 作者网站 */ + url?: string + /** + * 作者头像地址 + * + * 正方形,最好不小于 128×128,透明背景 + */ + avatar?: string +} +``` + +::: + +## channel.hub + +- 类型:`string` + +Websub 的链接。Websub 需要服务器后端,与 VuePress 主旨不符,如无特殊需要忽略即可。 + +::: tip WebSub + +有关信息,详见 [Websub](https://w3c.github.io/websub/#subscription-migration)。 + +::: diff --git a/docs-next/zh/plugins/blog/feed/config.md b/docs-next/zh/plugins/blog/feed/config.md new file mode 100644 index 0000000000..3f791db917 --- /dev/null +++ b/docs-next/zh/plugins/blog/feed/config.md @@ -0,0 +1,192 @@ +# 插件配置 + +## hostname + +- 类型:`string` +- 必填:是 + +部署网站的域名。 + +## atom + +- 类型:`boolean` +- 默认值:`false` + +是否启用 Atom 格式输出。 + +## json + +- 类型:`boolean` +- 默认值:`false` + +是否启用 JSON 格式输出。 + +## rss + +- 类型:`boolean` +- 默认值:`false` + +是否启用 RSS 格式输出。 + +## image + +- 类型:`string` + +一个大的图片,用作 feed 展示。 + +## icon + +- 类型:`string` + +一个小的图标,显示在订阅列表中。 + +## count + +- 类型:`number` +- 默认值:`100` + +设置 feed 的最大项目数量。在所有页面排序好后,插件会截取前 count 个项目。 + +如果你的站点文章很多,你应该考虑设置这个选项以减少 feed 文件大小。 + +## preservedElements + +- 类型:`(RegExp | string)[] | (tagName:string) => boolean` + +应在 Feed 中保留的自定义元素或组件。 + +::: tip 默认情况下,所有未知标签均会被移除。 + +::: + +## filter + +- 类型:`(page: Page)=> boolean` +- 默认值: + + ```js + ;({ frontmatter, filePathRelative }) => + Boolean(frontmatter.feed ?? (filePathRelative && !frontmatter.home)) + ``` + +自定义的过滤函数,用于过滤哪些项目在 feed 中显示。 + +## sorter + +- 类型: `(pageA: Page, pageB: Page)=> number` + +- 默认值: + + ```ts + // dateSorter 来源于 @vuepress/helper + ;(pageA, pageB): number => + dateSorter( + pageA.data.git?.createdTime + ? new Date(pageA.data.git?.createdTime) + : pageA.frontmatter.date, + pageB.data.git?.createdTime + ? new Date(pageB.data.git?.createdTime) + : pageB.frontmatter.date, + ) + ``` + +Feed 项目的排序器。 + +默认的排序行为是通过 Git 的文件添加日期 (需要 `@vuepress/plugin-git`)。 + +::: tip + +你应该启用 `@vuepress/plugin-git` 来获取最新创建的页面作为 feed 项目。否则,feed 项目将按照 VuePress 中页面的默认顺序排序。 + +::: + +## channel + +`channel` 选项用于配置 Feed 频道。 + +可用选项详见 [配置 → 频道设置](channel.md) + +## devServer + +- 类型:`boolean` +- 默认值:`false` + +是否在开发服务器中启用 + +::: tip + +由于性能原因,我们不提供热更新。重启开发服务器以同步你的变更。 + +::: + +## devHostname + +- 类型:`string` +- 默认值:`"http://localhost:${port}"` + +开发服务器使用的主机名 + +## atomOutputFilename + +- 类型:`string` +- 默认值:`"atom.xml"` + +Atom 格式输出路径,相对于输出路径。 + +## atomXslTemplate + +- 类型:`string` +- 默认值:`@vuepress/plugin-feed/templates/atom.xsl` 的内容 + +Atom xsl 模板文件没人陪美国 + +## atomXslFilename + +- 类型:`string` +- 默认值:`"atom.xsl"` + +Atom xsl 输出路径,相对于输出路径。 + +## jsonOutputFilename + +- 类型:`string` +- 默认值:`"feed.json"` + +JSON 格式输出路径,相对于输出路径。 + +## rssOutputFilename + +- 类型:`string` +- 默认值:`"rss.xml"` + +RSS 格式输出路径,相对于输出路径。 + +## rssXslTemplate + +- 类型:`string` +- 默认值:`@vuepress/plugin-feed/templates/rss.xsl` 的内容 + +RSS xsl 模板文件内容。 + +## rssXslFilename + +- 类型:`string` +- 默认值:`"rss.xsl"` + +RSS xsl 输出路径,相对于输出路径。 + +## getter + +Feed 生成控制器,详见 [Feed 生成器](./getter.md)。 + +::: tip 此插件内置了生成器,只有当你想完全控制 feed 生成时才需要设置此选项。 + +::: + +## locales + +- 类型:`Record` + +你可以将它用于每个语言环境的特定选项。 + +除 `hostname` 外,上述任何选项均受支持。 diff --git a/docs-next/zh/plugins/blog/feed/frontmatter.md b/docs-next/zh/plugins/blog/feed/frontmatter.md new file mode 100644 index 0000000000..982e69d2b0 --- /dev/null +++ b/docs-next/zh/plugins/blog/feed/frontmatter.md @@ -0,0 +1,153 @@ +# Frontmatter 配置 + +你可以通过配置每个页面的 Frontmatter,来对每个 Feed 项目生成进行单独的控制。 + +## 添加与移除 + +默认情况下,所有文章均会被添加至 feed 流。如果你想在 feed 中移除特定页面,你可以在 frontmatter 中设置 `feed: false`。 + +## 读取的 Frontmatter 信息 + +### title + +- 类型:`string` + +由 VuePress 自动生成,默认为页面的 h1 内容 + +### description + +- 类型:`string` + +页面描述 + +### date + +- 类型:`Date` + +页面的发布日期 + +### article + +- 类型:`boolean` + +该页面是否是文章 + +> 如果此项设置为 `false`,则该页不会包含在最终的 feed 中。 + +### copyright + +- 类型:`string` + +页面版权信息 + +### cover / image / banner + +- 类型:`string` + +页面的封面/分享图,需为完整链接或绝对链接。 + +## Frontmatter 选项 + +### feed.title + +- 类型:`string` + +Feed 项目的标题 + +### feed.description + +- 类型:`string` + +Feed 项目的描述 + +### feed.content + +- 类型:`string` + +Feed 项目的内容 + +### feed.author + +- 类型:`FeedAuthor[] | FeedAuthor` + +Feed 项目的作者 + +::: details FeedAuthor 格式 + +```ts +interface FeedAuthor { + /** + * 作者名字 + */ + name?: string + + /** + * 作者邮件 + */ + email?: string + + /** + * 作者网站 + * + * @description json format only + */ + url?: string + + /** + * 作者头像 + * + * @description json format only + */ + avatar?: string +} +``` + +::: + +### feed.contributor + +- 类型:`FeedContributor[] | FeedContributor` + +Feed 项目的贡献者 + +::: details FeedContributor 格式 + +```ts +interface FeedContributor { + /** + * 作者名字 + */ + name?: string + + /** + * 作者邮件 + */ + email?: string + + /** + * 作者网站 + * + * @description json format only + */ + url?: string + + /** + * 作者头像 + * + * @description json format only + */ + avatar?: string +} +``` + +::: + +### feed.guid + +- 类型:`string` + +Feed 项目的标识符,用于标识 Feed 项目。 + +::: tip 你应该确保每个 Feed 项目有全局唯一的 guid。 + +::: diff --git a/docs-next/zh/plugins/blog/feed/getter.md b/docs-next/zh/plugins/blog/feed/getter.md new file mode 100644 index 0000000000..3d1812418d --- /dev/null +++ b/docs-next/zh/plugins/blog/feed/getter.md @@ -0,0 +1,211 @@ +# Feed 获取器 + +你可以通过控制插件选项中的 `getter` 来完全控制 Feed 项目的生成。 + +## getter.title + +- 类型:`(page: Page, app: App) => string` + +项目标题获取器 + +## getter.link + +- 类型:`(page: Page, app: App) => string` + +项目链接获取器 + +## getter.description + +- 类型:`(page: Page, app: App) => string | undefined` + +项目描述获取器 + +::: tip + +因为 Atom 在摘要中支持 HTML,所以如果可能的话,你可以在这里返回 HTML 内容,但内容必须以标记 `html:` 开头。 + +::: + +## getter.content + +- 类型:`(page: Page, app: App) => string` + +项目内容获取器 + +## getter.author + +- 类型:`(page: Page, app: App) => FeedAuthor[]` + +项目作者获取器。 + +::: tip 获取器应在作者信息缺失时返回空数组。 + +::: + +::: details FeedAuthor 格式 + +```ts +interface FeedAuthor { + /** + * 作者名字 + */ + name?: string + + /** + * 作者邮件 + */ + email?: string + + /** + * 作者网站 + * + * @description json format only + */ + url?: string + + /** + * 作者头像 + * + * @description json format only + */ + avatar?: string +} +``` + +::: + +## getter.category + +- 类型:`(page: Page, app: App) => FeedCategory[] | undefined` + +项目分类获取器。 + +::: details FeedCategory 格式 + +```ts +interface FeedCategory { + /** + * 分类名称 + */ + name: string + + /** + * 标识分类法的字符串 + * + * @description rss format only + */ + domain?: string + + /** + * URI 标识的分类 scheme + * + * @description atom format only + */ + scheme?: string +} +``` + +::: + +## getter.enclosure + +- 类型:`(page: Page, app: App) => FeedEnclosure | undefined` + +项目附件获取器。 + +::: details FeedEnclosure 格式 + +```ts +interface FeedEnclosure { + /** + * Enclosure 地址 + */ + url: string + + /** + * 类型 + * + * @description 应为一个标准的 MIME 类型,rss format only + */ + type: string + + /** + * 按照字节数计算的大小 + * + * @description rss format only + */ + length?: number +} +``` + +::: + +## getter.publishDate + +- 类型:`(page: Page, app: App) => Date | undefined` + +项目发布日期获取器 + +## getter.lastUpdateDate + +- 类型:`(page: Page, app: App) => Date` + +项目最后更新日期获取器 + +## getter.image + +- 类型:`(page: Page, app: App) => string` + +项目图片获取器 + +::: tip 确保返回一个完整的 URL。 + +::: + +## getter.contributor + +- 类型:`(page: Page, app: App) => FeedContributor[]` + +项目贡献者获取器 + +::: tip 获取器应在贡献者信息缺失时返回空数组。 + +::: + +::: details FeedContributor 格式 + +```ts +interface FeedContributor { + /** + * 作者名字 + */ + name?: string + + /** + * 作者邮件 + */ + email?: string + + /** + * 作者网站 + * + * @description json format only + */ + url?: string + + /** + * 作者头像 + * + * @description json format only + */ + avatar?: string +} +``` + +::: + +## getter.copyright + +- 类型:`(page: Page, app: App) => string | undefined` + +项目版权获取器 diff --git a/docs-next/zh/plugins/blog/feed/guide.md b/docs-next/zh/plugins/blog/feed/guide.md new file mode 100644 index 0000000000..df6ae764ae --- /dev/null +++ b/docs-next/zh/plugins/blog/feed/guide.md @@ -0,0 +1,46 @@ +# 指南 + +## 使用 + +插件可为你生成以下三种格式的 feed 文件: + +- Atom 1.0 +- JSON 1.1 +- RSS 2.0 + +请按照需要生成的格式,在插件选项中设置 `atom`, `json` 或 `rss` 为 `true`。 + +为了正确生成 Feed 链接,你需要在插件选项中设置 `hostname`。 + +## 可读的预览 + +当你在浏览器中打开 Feed 文件时,我们会通过 xsl 模板将 atom 和 rss feed xml 魔法般地转换为可读的 html。你可以查看本站的 [atom](/zh/atom.xml) 和 [rss](/zh/rss.xml) feed 作为案例! + +如果你想在开发服务器中预览 Feed,你需要在插件选项中设置 `devServer: true`。如果你没有使用默认的 `http://localhost:{port}`,你还需要设置 `devHostname`。 + +## 频道设置 + +你可以通过设置 `channel` 选项来自自定义 Feed 频道的各项信息。 + +我们推荐进行如下设置: + +- 将建立 Feed 的日期转换为 ISOString 写入到 `channel.pubDate` 中 +- 通过 `channel.ttl` 中设置内容的更新周期(单位: 分钟) +- 通过 `channel.copyright` 设置版权信息 +- 通过 `channel.author` 设置频道作者。 + +详细的选项及其默认值详见 [配置 → 频道设置](./channel.md) + +## Feed 生成 + +默认情况下,所有文章均会被添加至 feed 流。 + +你可以在 frontmatter 中配置 `feed` 和其他选项控制每个页面的 Feed 项目内容,详见 [Frontmatter 选项](./frontmatter.md) 了解它们如何被转换。 + +你可以通过配置插件选项中的 `getter` 完全控制 Feed 项目的生成逻辑。 详细的选项及其默认值详见 [配置 → Feed 获取器](./getter.md) + +### 多语言配置 + +插件会针对每个语言生成单独的 Feed。 + +你可以通过插件选项中的 `locales` 分别对不同语言提供不同的默认设置。 diff --git a/docs-next/zh/plugins/development/README.md b/docs-next/zh/plugins/development/README.md new file mode 100644 index 0000000000..b007115c80 --- /dev/null +++ b/docs-next/zh/plugins/development/README.md @@ -0,0 +1,3 @@ +# 主题开发插件 + + diff --git a/docs-next/zh/plugins/development/active-header-links.md b/docs-next/zh/plugins/development/active-header-links.md new file mode 100644 index 0000000000..6ffd23eedb --- /dev/null +++ b/docs-next/zh/plugins/development/active-header-links.md @@ -0,0 +1,74 @@ +# active-header-links + + + +该插件会监听页面滚动事件。当页面滚动至某个 _标题锚点_ 后,如果存在对应的 _标题链接_ ,那么该插件会将路由 Hash 更改为该 _标题锚点_ 。 + +该插件主要用于开发主题,并且已经集成到默认主题中。大部分情况下你不需要直接使用它。 + +## 使用方法 + +```bash +npm i -D @vuepress/plugin-active-header-links@next +``` + +```ts +import { activeHeaderLinksPlugin } from '@vuepress/plugin-active-header-links' + +export default { + plugins: [ + activeHeaderLinksPlugin({ + // 配置项 + }), + ], +} +``` + +## 配置项 + +### headerLinkSelector + +- 类型: `string` + +- 默认值: `'a.vp-sidebar-item'` + +- 详情: + + _标题链接_ 的选择器。 + + 如果一个 _标题锚点_ 没有对应的 _标题链接_ ,那么即使滚动到这个 _标题锚点_ ,该插件也不会更改路由 Hash 。 + +### headerAnchorSelector + +- 类型: `string` + +- 默认值: `'.header-anchor'` + +- 详情: + + _标题锚点_ 的选择器。 + + 你通常不需要设置该选项,除非你通过 [markdown.anchor](https://vuejs.press/zh/reference/config.html#markdown-anchor) 修改了 [markdown-it-anchor](https://github.com/valeriangalliat/markdown-it-anchor#readme) 的 `permalinkClass` 选项。 + +- 参考: + - [指南 > Markdown > 语法扩展 > 标题锚点](https://vuejs.press/zh/guide/markdown.html#标题锚点) + +### delay + +- 类型: `number` + +- 默认值: `200` + +- 详情: + + 滚动事件监听器的 Debounce 延迟。 + +### offset + +- 类型: `number` + +- 默认值: `5` + +- 详情: + + 即便直接点击 _标题锚点_ 的链接, `scrollTop` 也可能不会完全等于 _标题锚点_ 的 `offsetTop` ,所以我们添加一个 Offset 偏移量来避免这个误差。 diff --git a/docs-next/zh/plugins/development/git.md b/docs-next/zh/plugins/development/git.md new file mode 100644 index 0000000000..15f28e9b44 --- /dev/null +++ b/docs-next/zh/plugins/development/git.md @@ -0,0 +1,323 @@ +# git + + + +该插件会收集你的页面的 Git 信息,包括创建和更新时间、贡献者、变更历史记录等。 + +默认主题的 [lastUpdated](../../themes/default/config.md#lastupdated) 和 [contributors](../../themes/default/config.md#contributors) 就是由该插件支持的。 + +该插件主要用于开发主题,大部分情况下你不需要直接使用它。 + +## 使用方法 + +```bash +npm i -D @vuepress/plugin-git@next +``` + +```ts +import { gitPlugin } from '@vuepress/plugin-git' + +export default { + plugins: [ + gitPlugin({ + // 配置项 + }), + ], +} +``` + +## Git 仓库 + +该插件要求你的项目在 [Git 仓库](https://git-scm.com/book/en/Git-Basics-Getting-a-Git-Repository) 下,这样它才能从提交历史记录中收集信息。 + +在构建站点时,你应该确保所有的提交记录是可以获取到的。举例来说, CI 工作流通常会在克隆你的仓库时添加 [--depth 1](https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---depthltdepthgt) 参数来避免拉取全部的提交记录,因此你需要禁用这个功能,以便该插件在 CI 可以中正常使用。 + +::: warning +该插件会显著降低准备数据的速度,特别是在你的页面数量很多的时候。你可以考虑在 `dev` 模式下禁用该插件来获取更好的开发体验。 +::: + +## 配置项 + +### createdTime + +- 类型: `boolean` + +- 默认值: `true` + +- 详情: + + 是否收集页面的创建时间。 + +### updatedTime + +- 类型: `boolean` + +- 默认值: `true` + +- 详情: + + 是否收集页面的更新时间。 + +### contributors + +- 类型: `boolean | ContributorsOptions` + + ```ts + interface ContributorInfo { + /** + * 贡献者在 git 托管服务中的用户名 + */ + username: string + /** + * 贡献者显示在页面上的名字, 默认为 `username` + */ + name?: string + /** + * 贡献者别名, 由于贡献者可能在本地 git 配置中保存的 用户名与 git 托管服务用户名不一致, + * 这时候可以通过别名映射到真实的用户名 + */ + alias?: string[] | string + /** + * 贡献者头像地址 + * 如果 git 托管服务为 `github`,则可以忽略不填,由插件自动填充 + */ + avatar?: string + /** + * 贡献者访问地址 + * 如果 git 托管服务为 `github`,则可以忽略不填,由插件自动填充 + */ + url?: string + } + + interface ContributorsOptions { + /** + * 贡献者信息 + */ + info?: ContributorInfo[] + + /** + * 是否在贡献者信息中添加头像 + * @default false + */ + avatar?: boolean + + /** + * 贡献者转换函数,例如去重和排序 + * 该函数接收一个贡献者信息数组,返回一个新的贡献者信息数组。 + */ + transform?: (contributors: GitContributor[]) => GitContributor[] + } + ``` + +- 默认值: `true` + +- 详情: + + 是否收集页面的贡献者。 + +### changelog + +- 类型: `false | ChangelogOptions` + + ```ts + interface ChangelogOptions { + /** + * 最大变更记录条数 + */ + maxCount?: number + + /** + * git 仓库的访问地址,例如:https://github.com/vuepress/ecosystem + */ + repoUrl?: string + + /** + * 提交记录访问地址模式 + * 默认值:':repo/commit/:hash' + * + * - `:repo` - git 仓库的访问地址 + * - `:hash` - 提交记录的 hash + */ + commitUrlPattern?: string + + /** + * issue 访问地址模式 + * 默认值:':repo/issues/:issue' + * + * - `:repo` - git 仓库的访问地址 + * - `:issue` - issue 的 id + */ + issueUrlPattern?: string + + /** + * tag 访问地址模式, + * 默认值:':repo/releases/tag/:tag' + * + * - `:repo` - git 仓库的访问地址 + * - `:tag` - tag 的名称 + */ + tagUrlPattern?: string + } + ``` + +- 默认值: `false` + +- 详情: + + 是否收集页面变更历史记录。 + +### filter + +- 类型: `(page: Page) => boolean` + +- 详情: + + 页面过滤器,如果返回 `true` ,该页面将收集 git 信息 + +## Frontmatter + +### gitInclude + +- 类型: `string[]` + +- 详情: + + 文件相对路径组成的数组,该数组中的文件会在计算页面数据时被包含在内。 + +- 示例: + +```md +--- +gitInclude: + - relative/path/to/file1 + - relative/path/to/file2 +--- +``` + +### contributors + +- 类型: `boolean | string[]` + +- 详情: + + 当前页面是否获取贡献者信息,该值会覆盖 [contributors](#contributors) 配置项。 + + - `true` - 获取贡献者信息 + - `false` - 不获取贡献者信息 + - `string[]` - 额外的贡献者名单,有时候页面存在额外的贡献者,可以通过这个配置项来指定额外的贡献者名单来获取贡献者信息 + +### changelog + +- 类型: `boolean` + +- 详情: + + 当前页面是否获取变更历史记录,该值会覆盖 [changelog](#changelog) 配置项。 + +## 页面数据 + +该插件会向页面数据中添加一个 `git` 字段。 + +在使用该插件后,可以在页面数据中获取该插件收集到的 Git 信息: + +```ts +import type { GitPluginPageData } from '@vuepress/plugin-git' +import { usePageData } from 'vuepress/client' + +export default { + setup(): void { + const page = usePageData() + console.log(page.value.git) + }, +} +``` + +### git.createdTime + +- 类型: `number` + +- 详情: + + 页面第一次提交的 Unix 毫秒时间戳。 + + 该属性将取当前页面及 [gitInclude](#gitinclude) 中所列文件的第一次提交的时间戳的最小值。 + +### git.updatedTime + +- 类型: `number` + +- 详情: + + 页面最后一次提交的 Unix 毫秒时间戳。 + + 该属性将取当前页面及 [gitInclude](#gitinclude) 中所列文件的最后一次提交的时间戳的最大值。 + +### git.contributors + +- 类型: `GitContributor[]` + +```ts +interface GitContributor { + name: string + email: string + commits: number + avatar?: string + url?: string +} +``` + +- 详情: + + 页面的贡献者信息。 + + 该属性将会包含 [gitInclude](#gitinclude) 所列文件的贡献者。 + +### git.changelog + +- 类型: `GitChangelog[]` + +```ts +interface GitChangelog { + /** + * 提交 hash + */ + hash: string + /** + * Unix 时间戳,单位毫秒,提交时间 + */ + date: number + /** + * 提交信息 + */ + message: string + /** + * 提交者名称 + */ + author: string + /** + * 提交者邮箱 + */ + email: string + /** + * 提交访问地址 + */ + commitUrl?: string + /** + * tag 访问地址 + */ + tagUrl?: string + /** + * 协同作者列表 + */ + coAuthors?: { + name: string + email: string + }[] +} +``` + +- 详情: + + 页面的变更历史记录。 + + 该属性将会包含 [gitInclude](#gitinclude) 所列文件的变更历史记录。 diff --git a/docs-next/zh/plugins/development/palette.md b/docs-next/zh/plugins/development/palette.md new file mode 100644 index 0000000000..2a94132331 --- /dev/null +++ b/docs-next/zh/plugins/development/palette.md @@ -0,0 +1,203 @@ +# palette + + + +为你的主题提供调色板功能。 + +该插件主要用于开发主题,并且已经集成到默认主题中。大部分情况下你不需要直接使用它。 + +对于主题作者,该插件可以帮助你提供用户自定义样式的能力。 + +## 使用方法 + +```bash +npm i -D @vuepress/plugin-palette@next +``` + +```ts +import { palettePlugin } from '@vuepress/plugin-palette' + +export default { + plugins: [ + palettePlugin({ + // 配置项 + }), + ], +} +``` + +## 调色板和样式 + +该插件会提供一个 `@vuepress/plugin-palette/palette` (调色板文件)和一个 `@vuepress/plugin-palette/style` (样式文件),用于在你的主题样式中引入。 + +调色板文件用于定义样式变量,因此它一般会在你主题样式的开头引入。举例来说,用户可以在调色板中定义 [CSS 变量](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) 、 [SASS 变量](https://sass-lang.com/documentation/variables) 、 [LESS 变量](http://lesscss.org/features/#variables-feature) 或 [Stylus 变量](https://stylus-lang.com/docs/variables.html) ,然后你可以在你的主题样式中使用这些变量。 + +样式文件用于覆盖默认样式或添加额外样式,因此它一般会在你主题样式的末尾引入。 + +## 使用 + +在你的主题中使用该插件,假设你使用 SASS 作为 CSS 预处理器: + +```ts +export default { + // ... + plugins: [palettePlugin({ preset: 'sass' })], +} +``` + +### 使用调色板 + +在你主题需要使用对应变量的地方引入该插件的调色板文件,比如在 `Layout.vue` 中: + +```vue + + + +``` + +然后,用户就可以在 `.vuepress/styles/palette.scss` 中自定义变量: + +```scss +$color: green; +``` + +### 使用样式 + +在你主题的样式之后引入该插件的样式文件,比如在 `clientConfigFile` 中: + +```ts +// 引入你主题本身的样式文件 +import 'path/to/your/theme/style' +// 引入该插件的样式文件 +import '@vuepress/plugin-palette/style' +``` + +然后,用户就可以在 `.vuepress/styles/index.scss` 中添加额外样式,并可以覆盖你主题本身的样式: + +```scss +h1 { + font-size: 2.5rem; +} +``` + +## 配置项 + +### preset + +- 类型: `'css' | 'sass' | 'less' | 'stylus'` + +- 默认值: `'css'` + +- 详情: + + 设置其他选项的预设。 + + 如果你没有对该插件进行进阶定制化的需要,建议只设置该配置项并忽略其他选项。 + +### userPaletteFile + +- 类型: `string` + +- 默认值: + + - css: `'.vuepress/styles/palette.css'` + - sass: `'.vuepress/styles/palette.scss'` + - less: `'.vuepress/styles/palette.less'` + - stylus: `'.vuepress/styles/palette.styl'` + +- 详情: + + 用户调色板文件的路径,是针对源文件目录的相对路径。 + + 默认值依赖于 [preset](#preset) 配置项。 + + 该文件用于用户定义样式变量,建议保持默认值作为约定的文件路径。 + +### tempPaletteFile + +- 类型: `string` + +- 默认值: + + - css: `'styles/palette.css'` + - sass: `'styles/palette.scss'` + - less: `'styles/palette.less'` + - stylus: `'styles/palette.styl'` + +- 详情: + + 生成的调色板临时文件的路径,是针对临时文件文件目录的相对路径。 + + 默认值依赖于 [preset](#preset) 配置项。 + + 你应该使用 `'@vuepress/plugin-palette/palette'` 别名来引入调色板文件,因此在绝大多数情况下你不需要修改该配置项。 + +### userStyleFile + +- 类型: `string` + +- 默认值: + + - css: `'.vuepress/styles/index.css'` + - sass: `'.vuepress/styles/index.scss'` + - less: `'.vuepress/styles/index.less'` + - stylus: `'.vuepress/styles/index.styl'` + +- 详情: + + 用户样式文件的路径,是针对源文件目录的相对路径。 + + 默认值依赖于 [preset](#preset) 配置项。 + + 该文件用于用户覆盖默认样式和添加额外样式,建议保持默认值作为约定的文件路径。 + +### tempStyleFile + +- 类型: `string` + +- 默认值: + + - css: `'styles/index.css'` + - sass: `'styles/index.scss'` + - less: `'styles/index.less'` + - stylus: `'styles/index.styl'` + +- 详情: + + 生成的样式临时文件的路径,是针对临时文件文件目录的相对路径。 + + 默认值依赖于 [preset](#preset) 配置项。 + + 你应该使用 `'@vuepress/plugin-palette/style'` 别名来引入样式文件,因此在绝大多数情况下你不需要修改该配置项。 + +### importCode + +- 类型: `(filePath: string) => string` + +- 默认值: + + - css: `` (filePath) => `@import '${filePath}';\n` `` + - sass: `` (filePath) => `@forward 'file:///${filePath}';\n` `` + - less: `` (filePath) => `@import '${filePath}';\n` `` + - stylus: `` (filePath) => `@require '${filePath}';\n` `` + +- 详情: + + 用于生成引入代码的函数。 + + 默认值依赖于 [preset](#preset) 配置项。 + + 该配置项用于生成 [tempPaletteFile](#temppalettefile) 和 [tempStyleFile](#tempstylefile) ,在绝大多数情况下你不需要修改该配置项。 diff --git a/docs-next/zh/plugins/development/reading-time.md b/docs-next/zh/plugins/development/reading-time.md new file mode 100644 index 0000000000..793f084961 --- /dev/null +++ b/docs-next/zh/plugins/development/reading-time.md @@ -0,0 +1,217 @@ +# reading-time + + + +此插件会为每个页面生成字数统计与预计阅读时间。 + +## 使用方法 + +```bash +npm i -D @vuepress/plugin-reading-time@next +``` + +```ts title=".vuepress/config.ts" +import { readingTimePlugin } from '@vuepress/plugin-reading-time' + +export default { + plugins: [ + readingTimePlugin({ + // 配置项 + }), + ], +} +``` + +插件会将相关信息注入到页面数据的 `readingTime`,其中: + +- `readingTime.minutes`:为预计阅读时间(分钟)`number` +- `readingTime.words`:字数统计,`number` + +### 在 Node 侧获取数据 + +对于任何页面,你可以从 `page.data.readingTime` 获取预计阅读时间与字数统计: + +```ts +page.data.readingTime // { minutes: 3.2, words: 934 } +``` + +你可以在 `extendsPage` 以及其他生命周期获取它做进一步处理: + +```js +export default { + // ... + extendsPage: (page) => { + page.data.readingTime // { minutes: 3.2, words: 934 } + }, + + onInitialized: (app) => { + app.pages.forEach((page) => { + page.data.readingTime // { minutes: 3.2, words: 934 } + }) + }, +} +``` + +### 在客户端侧获取数据 + +你可以从 `@vuepress/plugin-reading-time/client` 导入 `useReadingTimeData` 和 `useReadingTimeLocale` 来获取当前页面的阅读时间数据和语言环境数据: + +```vue + +``` + +## 选项 + +### wordPerMinute + +- 类型:`number` +- 默认值:`300` +- 详情: + 每分钟阅读字数 + +### locales + +- 类型:`ReadingTimePluginLocaleConfig` + + ```ts + interface ReadingTimePluginLocaleData { + /** + * 字数模板,模板中 `$word` 会被自动替换为字数 + */ + word: string + + /** + * 小于一分钟文字 + */ + less1Minute: string + + /** + * 时间模板 + */ + time: string + } + + interface ReadingTimePluginLocaleConfig { + [localePath: string]: ReadingTimePluginLocaleData + } + ``` + +- 必填:否 + +- 详情: + + 阅读时间插件的国际化配置。 + +::: details 内置支持语言 + +- **简体中文** (zh-CN) +- **繁体中文** (zh-TW) +- **英文(美国)** (en-US) +- **德语** (de-DE) +- **德语(澳大利亚)** (de-AT) +- **俄语** (ru-RU) +- **乌克兰语** (uk-UA) +- **越南语** (vi-VN) +- **葡萄牙语(巴西)** (pt-BR) +- **波兰语** (pl-PL) +- **法语** (fr-FR) +- **西班牙语** (es-ES) +- **斯洛伐克** (sk-SK) +- **日语** (ja-JP) +- **土耳其语** (tr-TR) +- **韩语** (ko-KR) +- **芬兰语** (fi-FI) +- **印尼语** (id-ID) +- **荷兰语** (nl-NL) + +::: + +## 客户端 API + +你可以从 `@vuepress/plugin-reading-time/client` 导入并使用这些 API: + +::: tip 即使插件被禁用,这些 API 也不会抛出错误。 + +::: + +### useReadingTimeData + +```ts +interface ReadingTime { + /** 分钟为单位的预计阅读时长 */ + minutes: number + /** 内容的字数 */ + words: number +} + +const useReadingTimeData: () => ComputedRef +``` + +当插件被禁用时会返回 `null`。 + +### useReadingTimeLocale + +```ts +interface ReadingTimeLocale { + /** 当前语言的预计阅读时间 */ + time: string + /** 当前语言的字数文字 */ + words: string +} + +const useReadingTimeLocale: () => ComputedRef +``` + +## 高级使用 + +由于此插件主要面向插件和主题开发者,所以提供了 "使用 API": + +```js title="你插件或主题的入口" +import { useReadingTimePlugin } from '@vuepress/plugin-reading-time' + +export default (options) => (app) => { + useReadingTimePlugin(app, { + // 你的选项 + }) + + return { + name: 'vuepress-plugin-xxx', // or vuepress-theme-xxx + } +} +``` + +::: tip 为什么你应该使用 "使用 API" + +1. 当你多次注册一个插件时,vuepress 会给你一个警告,告诉你只有第一个插件会生效。`useReadingTimePlugin` 会自动检测插件是否已经注册,避免多次注册。 + +1. 如果你在 `extendsPage` 生命周期访问阅读时间数据,那么 `@vuepress/plugin-reading-time` 必须在你的主题或插件之前被调用,否则你会得到未定义的 `page.data.readingTime`。`useReadingTimePlugin` 确保了 `@vuepress/plugin-reading-time` 在你的主题或插件之前被调用。 + +::: + +我们也提供了一个 `removeReadingTimePlugin` api 来移除插件。你可以使用它来确保你的调用生效或清除插件: + +```js title="你插件或主题的入口" +import { useReadingTimePlugin } from '@vuepress/plugin-reading-time' + +export default (options) => (app) => { + // 这会移除任何当前存在的阅读时间插件 + removeReadingTimePlugin(app) + + // 所以这会生效,即使之前已经注册了一个阅读时间插件 + useReadingTimePlugin(app, { + // 你的选项 + }) + + return { + name: 'vuepress-plugin-xxx', // or vuepress-theme-xxx + } +} +``` diff --git a/docs-next/zh/plugins/development/rtl.md b/docs-next/zh/plugins/development/rtl.md new file mode 100644 index 0000000000..1d11d36ed0 --- /dev/null +++ b/docs-next/zh/plugins/development/rtl.md @@ -0,0 +1,53 @@ +# rtl + + + +此插件会在配置的语言上设置 rtl 方向。 + +## 使用方法 + +```bash +npm i -D @vuepress/plugin-rtl@next +``` + +```ts +import { rtlPlugin } from '@vuepress/plugin-rtl' + +export default { + plugins: [ + rtlPlugin({ + // 配置项 + locales: ['/ar/'], + }), + ], +} +``` + +## 选项 + +### locales + +- 类型:`string[]` +- 默认值:`['/']` +- 详情: + 开启 RTL 布局的多语言路径。 + +### selector + +- 类型:`SelectorOptions` + + ```ts + interface SelectorOptions { + [cssSelector: string]: { + [attr: string]: string + } + } + ``` + +- 默认值:`{ 'html': { dir: 'rtl' } }` + +- 详情: + + 开启 RTL 的选择器。 + + 默认设置意味着在 RTL 多语言中,`html` 元素的 `dir` 属性将被设置为 `rtl`。 diff --git a/docs-next/zh/plugins/development/sass-palette/README.md b/docs-next/zh/plugins/development/sass-palette/README.md new file mode 100644 index 0000000000..c7d53bb016 --- /dev/null +++ b/docs-next/zh/plugins/development/sass-palette/README.md @@ -0,0 +1,36 @@ +# sass-palette + + + +这个插件主要面向插件和主题开发者,相比 [`@vuepress/plugin-palette`](../palette.md) 更加强大。 + +::: tip + +你应该在你的项目中手动安装这些依赖: + +- 当使用 Vite 打包工具时:`sass-embedded` +- 当使用 Webpack 打包工具时:`sass-embedded` 和 `sass-loader` + +::: + +## 使用 + +你必须在插件初始化期间调用 `useSassPalettePlugin` 函数来使用此插件。 + +```bash +npm i -D @vuepress/plugin-sass-palette@next +``` + +```js title="你的插件或主题入口" +import { useSassPalettePlugin } from 'vuepress-plugin-sass-palette' + +export const yourPlugin = (options) => (app) => { + useSassPalettePlugin(app, { + // 插件选项 + }) + + return { + // 你的插件 API + } +} +``` diff --git a/docs-next/zh/plugins/development/sass-palette/config.md b/docs-next/zh/plugins/development/sass-palette/config.md new file mode 100644 index 0000000000..c0921b4660 --- /dev/null +++ b/docs-next/zh/plugins/development/sass-palette/config.md @@ -0,0 +1,91 @@ +# 配置 + +## 插件选项 + +### id + +- 类型: `string` +- 必填: 是 + +调色板的唯一 ID,用于避免重复注册。 + +### config + +- 类型: `string` +- 默认值: `` `.vuepress/styles/${id}-palette.scss` `` + +用户配置文件路径,相对于源文件夹。 + +::: tip + +这是用户设置样式变量的文件。 + +默认路径的文件名拥有上方的 ID 前缀。 + +::: + +### defaultConfig + +- 类型: `string` +- 默认值: `"@vuepress/plugin-sass-palette/styles/default/config.scss"` + +默认的配置文件路径,应为绝对路径。 + +::: tip + +这是你应该通过 `!default` 来提供默认样式变量的文件。 + +::: + +### palette + +- 类型: `string` +- 默认值: `` `.vuepress/styles/${id}-palette.scss` `` + +用户的调色板文件路径,相对于源文件夹。 + +::: tip + +这是用户控制注入 CSS 变量的文件。所有的变量会被转换为连字符格式然后被注入。 + +默认路径的文件名拥有上方的 ID 前缀。 + +::: + +### defaultPalette + +- 类型: `string` +- 默认值: `"@vuepress/plugin-sass-palette/styles/default/palette.scss"` + +默认的调色板文件路径,应为绝对路径。 + +::: tip + +这是你应该通过 `!default` 来提供默认调色板值的文件。所有的变量会被转换为连字符格式然后被注入。 + +::: + +### generator + +- 类型: `string` +- 必填: 否 + +自定义的生成器,用于生成调色板配置的衍生值。 + +如: 你可能想要根据 `$theme-color` 的值提供一个 `$theme-color-light`。 + +### style + +- 类型: `string` +- 必填: 否 + +用户的样式文件路径,相对于源文件夹。. + +## 别名 + +可用的别名如下: + +- 配置: `@sass-palette/${id}-config` (基于 `id`) +- 调色板: `@sass-palette/${id}-palette` (基于 `id`) +- 样式: `@sass-palette/${id}-style` (仅在设置了 `style` 选项时可用) +- 助手: `@sass-palette/helper` diff --git a/docs-next/zh/plugins/development/sass-palette/guide.md b/docs-next/zh/plugins/development/sass-palette/guide.md new file mode 100644 index 0000000000..e0df528678 --- /dev/null +++ b/docs-next/zh/plugins/development/sass-palette/guide.md @@ -0,0 +1,264 @@ +# 指南 + +相比于 [`@vuepress/plugin-palette`](../palette.md) 插件,本插件允许你: + +- 基于用户配置派生相关样式 +- 在插件中调用并提供和主题类似的样式自定义 +- 跨越多个插件或主题通过 id 选项分组应用 + +在使用插件前,你需要了解 id 选项,以及三个样式概念: 配置、调色板和派生器。 + +## ID 选项 + +首先,你应该了解此插件的设计目标是提供跨越插件和主题的支持 (而并不像官方插件仅面向主题)。 + +我们提供了 `id` 选项来完成此目标,它将允许你: + +- 在插件 (或主题) 间共享同一个样式系统。 + + 所有别名和模块名称都具有 ID 前缀,这意味着你可以在你的插件 (或主题) 中使用一套样式变量来统一你的样式,而不会受到其他插件 (或主题) 的影响。 + + 用户可以在同一个文件中配置所有颜色变量、断点和其他样式配置,并自动应用在具有相同 ID 的主题和插件上。 + + ::: tip 示例 + + `vuepress-theme-hope` 及其它的相关插件都使用相同 ID `hope` 调用插件,因此用户在主题中配置的样式会自动在所有插件中生效。 + + ::: + +- 设置不同的 ID 时,插件们和主题之间互相完全独立。我们建议你使用你的插件名称设置 `id` 变量。 + + 使用默认设置,用户将在 `.vuepress/styles` 文件夹下设置你的插件样式,其中 Sass 文件以你的 ID 前缀开头。你可以使用 `${id}-config` 和 `${id}-palette` 访问所需的变量。 + + ::: tip 示例 + + `vuepress-theme-hope` 正在使用 ID `"hope"`,而假设 `vuepress-plugin-abc` 正在使用 `"abc"`。他们可以分别使用 `hope-config` `hope-palette` 和 `abc-config` `abc-palette` 模块名称获取自己的变量。 + + ::: + +- 通过相同 ID 调用插件不会有任何副作用。 + + ::: tip 示例 + + `vuepress-theme-hope` 及其它的相关插件都使用相同 ID `hope` 调用插件。 + + ::: + +## 配置 + +配置文件仅用于提供 Sass 变量。它所包含 Sass 变量可以在其他文件中通过 `${id}-config` 使用。 + +你可以指定一个文件作为用户配置文件。这样你可以稍后在插件 Sass 文件中访问包含每个变量的模块。此外,你还可以提供默认配置文件,你可以在其中使用 `!default` 为变量设置默认值。 + +::: details 一个例子 + +假设,你正在 `vuepress-plugin-abc` 中这样调用插件: + +```js +useSassPalette(app, { + id: 'abc', + defaultConfig: 'vuepress-plugin-abc/styles/config.scss', +}) +``` + +用户配置: + +```scss title=".vuepress/styles/abc-palette.scss" +$navbar-height: 3.5rem; +``` + +默认配置: + +```scss title="vuepress-plugin-abc/styles/palette.scss" +$navbar-height: 2rem !default; +$sidebar-width: 18rem !default; +``` + +你可以在插件 Sass 文件中获取到如下变量: + +```scss +// Vue 单文件组件的 +``` + +布局插槽十分实用,但有时候你可能会觉得它不够灵活。默认主题同样提供了替换单个组件的能力。 + +默认主题将所有 [非全局的组件](https://github.com/vuepress/ecosystem/tree/main/themes/theme-default/src/client/components) 都注册了一个带 `@theme` 前缀的 [alias](https://v2.vuepress.vuejs.org/zh/reference/plugin-api.html#alias) 。例如,`HomeFooter.vue` 的别名是 `@theme/HomeFooter.vue` 。 + +接下来,如果你想要替换 `HomeFooter.vue` 组件,只需要在配置文件 `.vuepress/config.ts` 中覆盖这个别名即可: + +```ts +import { defaultTheme } from '@vuepress/theme-default' +import { defineUserConfig } from 'vuepress' +import { getDirname, path } from 'vuepress/utils' + +const __dirname = getDirname(import.meta.url) + +export default defineUserConfig({ + theme: defaultTheme(), + alias: { + '@theme/HomeFooter.vue': path.resolve( + __dirname, + './components/MyHomeFooter.vue', + ), + }, +}) +``` + +## 开发一个子主题 + +除了在 `.vuepress/config.ts` 和 `.vuepress/client.ts` 中直接扩展默认主题以外,你可以通过继承默认主题来开发一个你自己的主题: + +```ts +import type { DefaultThemeOptions } from '@vuepress/theme-default' +import { defaultTheme } from '@vuepress/theme-default' +import type { Theme } from 'vuepress/core' +import { getDirname, path } from 'vuepress/utils' + +const __dirname = getDirname(import.meta.url) + +export const childTheme = (options: DefaultThemeOptions): Theme => ({ + name: 'vuepress-theme-child', + extends: defaultTheme(options), + + // 在子主题的客户端配置文件中覆盖布局 + // 注意,你在发布到 NPM 之前会将 TS 构建为 JS ,因此这里需要设置为 JS 文件的路径 + clientConfigFile: path.resolve(__dirname, './client.js'), + + // 覆盖组件别名 + alias: { + '@theme/VPHomeFooter.vue': path.resolve( + __dirname, + './components/MyHomeFooter.vue', + ), + }, +}) +``` diff --git a/docs-next/zh/themes/default/frontmatter.md b/docs-next/zh/themes/default/frontmatter.md new file mode 100644 index 0000000000..a9c93e1dc0 --- /dev/null +++ b/docs-next/zh/themes/default/frontmatter.md @@ -0,0 +1,469 @@ +# Frontmatter + + + +## 所有页面 + +本章节中的 Frontmatter 会在所有类型的页面中生效。 + +### pageClass + +- 类型: `string` + +- 详情: + + 将额外的类名称添加到特定页面。 + +- 示例: + +```md +--- +pageClass: custom-page-class +--- +``` + +然后可以在 `.vuepress/styles/custom.css` 文件中自定义该特定页面的样式: + +```scss +.custom-page-class { + /* page styles */ +} +``` + +- 参考: + - [Default Theme > Styles > Style File](./styles.md#style-file) + +### pageLayout + +- 类型: `doc | home | page` + +- 默认值: `doc` + +- 详情: + + 指定页面的布局。 + + - `doc` —— 它将默认文档样式应用于 markdown 内容。 + - `home` —— “主页”的特殊布局。可以添加额外的选项,例如 `hero` 和 `features` ,以快速创建漂亮的落地页。 + - `page` —— 表现类似于 `doc`,但它不对内容应用任何样式。当想创建一个完全自定义的页面时很有用。 + +```yaml +--- +pageLayout: doc +--- +``` + +### navbar + +- 类型: `boolean` + +- 默认值: `true` + +- 详情: + + 是否显示 [navbar](./config.md#navbar). + +```md +--- +navbar: false +--- +``` + +### externalLinkIcon + +- 类型: `boolean` + +- 详情: + + 是否在外部链接上显示外部链接图标。 + +```md +--- +externalLinkIcon: false +--- +``` + +### footer + +- 类型: `boolean` + +- 详情: + + 是否显示 [footer](./config.md#footer). + +## 首页 + +本章节中的 Frontmatter 只会在首页中生效。 + +### home + +- 类型: `boolean` + +- 详情: + + 设定该页面是首页还是普通页面。 + + 如果你不设置该 Frontmatter 或将其设为 `false` ,则该页面会是一个 [普通页面](#普通页面). + + > home 为 `true` 等价于将 `pageLayout` 设为 `home`。 + +- 示例: + +```md +--- +home: true +--- +``` + +### hero + +主页布局设置为“home”时,定义 主页 `hero` 部分的内容。 + +```md +--- +home: true +hero: + image: /images/hero.png + name: VuePress Ecosystem + text: VuePress official themes and plugins + tagline: A Vue-powered Static Site Generator +--- +``` + +#### hero.image + +- 类型: `DefaultThemeImage` + +- 详情: + + 首页 Hero 部分图片的 URL 。 + +- 参考: + - [Guide > Assets > Public Files](https://v2.vuepress.vuejs.org/zh/guide/assets.html#public-files) + +#### hero.name + +- 类型: `string` + +- 详情: + + `text` 上方的字符,带有品牌颜色, 尽量简短,例如产品名称 + +#### hero.text + +- 类型: `string` + +- 详情: + + hero 部分的主要文字,被定义为 `h1` 标签 + +#### hero.tagline + +- 类型: `string` + +- 详情: + + `text` 下方的标语 + +### actions + +- 类型: `HeroAction[]` + +```ts +interface HeroAction { + theme?: 'alt' | 'brand' + text: string + link: string + target?: string + rel?: string +} +``` + +- 详情: + + 配置首页按钮。 + +- 示例: + +```md +--- +actions: + - text: Get Started + link: /guide/getting-started.html + theme: brand + - text: Introduction + link: /guide/introduction.html + theme: alt +--- +``` + +### features + +- 类型: `Feature[]` + +```ts +interface Feature { + icon?: FeatureIcon + title: string + details: string + link?: string + linkText?: string + rel?: string + target?: string +} + +export type FeatureIcon = + | string + | { + light: string + dark: string + alt?: string + width?: string + height?: string + wrap?: boolean + } + | { + src: string + alt?: string + width?: string + height?: string + wrap?: boolean + } +``` + +- 详情: + + 配置首页特性列表。 + +- 示例: + +```md +--- +features: + - title: Simplicity First + details: Minimal setup with markdown-centered project structure helps you focus on writing. + - title: Vue-Powered + details: Enjoy the dev experience of Vue, use Vue components in markdown, and develop custom themes with Vue. + - title: Performant + details: VuePress generates pre-rendered static HTML for each page, and runs as an SPA once a page is loaded. +--- +``` + +### markdownStyles + +- 类型:`boolean` + +- 默认值:`true` + +- 详情: + + 是否为首页 其它的内容 使用 markdown 样式。 + +## 普通页面 + +本章节中的 Frontmatter 只会在普通页面中生效。 + +### editLink + +- 类型: `boolean` + +- 详情: + + 是否在本页面中启用 _编辑此页_ 链接。 + +- 参考: + - [Default Theme > Config > editLink](./config.md#editlink) + +### editLinkPattern + +- 类型: `string` + +- 详情: + + 本页面中 _编辑此页_ 链接的 Pattern 。 + +- 参考: + - [Default Theme > Config > editLinkPattern](./config.md#editlinkpattern) + +### lastUpdated + +- 类型: `boolean` + +- 详情: + + 是否在本页面中启用 _最近更新时间戳_ 。 + +- 参考: + - [Default Theme > Config > lastUpdated](./config.md#lastupdated) + +### contributors + +- 类型: `boolean` + +- 详情: + + 是否在本页面中启用 _贡献者列表_ 。 + +- 参考: + - [Default Theme > Config > contributors](./config.md#contributors) + +### sidebar + +- 类型: `boolean` + +- 详情: + + 是否显示 侧边栏 + +- 参考: + - [Default Theme > Config > sidebar](./config.md#sidebar) + +### aside + +- 类型: `boolean | 'left'` + +- 默认值: `true` + +- 详情: + + 定义侧边栏组件在 `doc` 布局中的位置。 + + - 将此值设置为 `false` 可禁用侧边栏容器。 + - 将此值设置为 `true` 会将侧边栏渲染到右侧。 + - 将此值设置为 `left` 会将侧边栏渲染到左侧。 + +```yaml +--- +aside: false +--- +``` + +### outline + +- 类型: `number | [number, number] | 'deep' | false` + +- 默认值: `[2,3]` + +- 详情: + +大纲中显示的标题级别。它与 [outline](./config.md#outline) 相同,它会覆盖站点级的配置。 + +### prev + +- 类型: `string | false | { text?: string; link?: string }` + +- 详情: + + 上一个页面的链接。 + + 如果你不设置该 Frontmatter ,该链接会自动根据侧边栏配置进行推断。 + +- 示例: + +```md +--- +# NavLink +prev: + text: Get Started + link: /guide/getting-started.html + +# NavLink - external url +prev: + text: GitHub + link: https://github.com + +# string - page file path +prev: /guide/getting-started.md + +# string - page file relative path +prev: ../../guide/getting-started.md +--- +``` + +### next + +- 类型: `string | false | { text?: string; link?: string }` + +- 详情: + + 下一个页面的链接。 + + 如果你不设置该 Frontmatter ,该链接会自动根据侧边栏配置进行推断。 + + 类型和 prev Frontmatter 相同。 + +### index + +- 类型: `boolean` + +- 默认值: `true` + +- 详情: + + 是否在侧边栏或目录中索引当前页面。 + +### order + +- 类型: `number` + +- 详情: + + 指定当前页面在侧边栏或目录中的排序。 + + - 当填写正数的时候,页面将排在靠前的位置,数字越小出现的位置越前。 + - 当填写负数的时候,页面将排在靠后的位置,数字越大出现的位置越前(比如 -1 在 -2 之后)。 + +### dir + +用于 结构侧边栏 的分组信息。 + +#### dir.text + +- 类型: `string` + +- 默认值: `README.md` 的标题 + +- 详情: + + 分组标题。 + +#### dir.collapsible + +- 类型: `boolean` + +- 默认值: `true` + +- 详情: 分组是否可折叠。 + +#### dir.link + +- 类型: `boolean` + +- 默认值: `false` + +- 详情: + + 分组是否可点击。 + + :::info 设置为 `true` 意味着将分组链接设置为 `README.md` 链接。 + ::: + +#### dir.index + +- 类型: `boolean` + +- 默认值: `true` + +- 详情: + + 是否索引当前目录。 + +#### dir.order + +- 类型: `number` +- 详情: + + 分组在侧边栏的顺序。 + + - 填写正数,页面会出现在最前,较小的数字会出现在前面。 + - 填写负数,页面会出现在最后,较大的数字会出现在前面。 (如 -1 在 -2 之后) diff --git a/docs-next/zh/themes/default/locale.md b/docs-next/zh/themes/default/locale.md new file mode 100644 index 0000000000..432fc057e5 --- /dev/null +++ b/docs-next/zh/themes/default/locale.md @@ -0,0 +1,246 @@ +# 语言配置 + +这些选项用于配置与语言相关的文本。 + +如果你的站点是以 **内置语言支持** 以外的其他语言提供服务的,你应该为每个语言设置这些选项来提供翻译。 + +::: details 内置语言支持 + +- 简体中文 (zh-CN) +- 繁体中文 (zh-TW) +- 英文(美国) (en-US) +- 德语 (de-DE) +- 德语(澳大利亚) (de-AT) +- 俄语 (ru-RU) +- 乌克兰语 (uk-UA) +- 越南语 (vi-VN) +- 葡萄牙语(巴西) (pt-BR) +- 波兰语 (pl-PL) +- 法语 (fr-FR) +- 西班牙语 (es-ES) +- 斯洛伐克 (sk-SK) +- 日语 (ja-JP) +- 土耳其语 (tr-TR) +- 韩语 (ko-KR) +- 芬兰语 (fi-FI) +- 印尼语 (id-ID) +- 荷兰语 (nl-NL) + +::: + +## selectLanguageText + +- 类型: `string` +- 默认值: `'选择语言'` +- 详情: + + 用于自定义导航栏中语言切换按钮的 `aria-label` 。 + + 此选项仅在您主题配置的[locales](./config.md#locales)中生效。 + +## selectLanguageName + +- 类型: `string` + +- 详情: + + Locale 的语言名称。 + + 该配置项 **仅能在主题配置的 [locales](./config.md#locales) 的内部生效** 。它将被用作 locale 的语言名称,展示在 _选择语言菜单_ 内。 + +- 示例: + +```ts +export default { + locales: { + '/': { + lang: 'en-US', + }, + '/zh/': { + lang: 'zh-CN', + }, + }, + theme: defaultTheme({ + locales: { + '/': { + selectLanguageName: 'English', + }, + '/zh/': { + selectLanguageName: '简体中文', + }, + }, + }), +} +``` + +## outlineTitle + +- 类型: `string` + +- 默认值: `'此页内容'` + +- 详情: + + 显示在 outline 上的标题。 + +## sidebarMenuLabel + +- 类型: `string` + +- 默认值: `'Menu'` + +- 详情: + + 用于自定义侧边栏菜单标签,该标签仅在移动端视图中显示。 + +## darkModeSwitchLabel + +- 类型: `string` + +- 默认值: `'Appearance'` + +- 详情: + + 用于自定义深色模式开关标签,该标签仅在移动端视图中显示。 + +## lightModeSwitchTitle + +- 类型: `string` + +- 默认值: `'Switch to light theme'` + +- 详情: + + 用于自定义悬停时显示的浅色模式开关标题。 + +## darkModeSwitchTitle + +- 类型: `string` + +- 默认值: `'Switch to dark theme'` + +- 详情: + + 用于自定义悬停时显示的深色模式开关标题。 + +## editLinkText + +- 类型: `string` + +- 默认值: `'Edit this page'` + +- 详情: + + _编辑此页_ 链接的文字。 + +## lastUpdatedText + +- 类型: `string` + +- 默认值: `'Last Updated'` + +- 详情: + + _最近更新时间戳_ 标签的文字。 + +## contributorsText + +- 类型: `string` + +- 默认值: `'Contributors'` + +- 详情: + + _贡献者列表_ 标签的文字。 + +## returnToTopLabel + +- 类型: `string` + +- 默认值: `Return to top` + +- 详情: + + 用于自定义返回顶部按钮的标签,该标签仅在移动端视图中显示。 + +## notFound + +- 类型: `NotFoundOptions` + +- 详情: + + 用于自定义 404 页面 的内容. + +```ts +export default { + theme: defaultTheme({ + notFound: { + title: '页面未找到', + quote: '如果你不改变方向,继续寻找,最终可能会到达你正在前往的地方。', + linkLabel: '回到首页', + linkText: '回到首页', + code: '404', + }, + }), +} +``` + +```ts +interface NotFoundOptions { + /** + * 自定义 页面未找到 的标题 + * @default '页面未找到' + */ + title?: string + + /** + * 自定义 页面的未找到 的描述。 + * @default '如果你不改变方向,继续寻找,最终可能会到达你正在前往的地方。' + */ + quote?: string + + /** + * 设置首页链接的 aria 标签。 + * @default '回到首页' + */ + linkLabel?: string + + /** + * 设置首页链接文本。 + * @default '回到首页' + */ + linkText?: string + + /** + * @default '404' + */ + code?: string +} +``` + +## docFooter + +- 类型: `DocFooter` + +- 详情: + + 可用于自定义出现在上一页和下一页链接上方的文本。 + 如果不是用英语编写文档,则非常有帮助。还可以用于全局禁用上一页/下一页链接。 + +```ts +export default { + theme: defaultTheme({ + docFooter: { + prev: '上一页', + next: '下一页', + }, + }), +} +``` + +```ts +export interface DocFooter { + prev?: string | false + next?: string | false +} +``` diff --git a/docs-next/zh/themes/default/markdown.md b/docs-next/zh/themes/default/markdown.md new file mode 100644 index 0000000000..a2aa330194 --- /dev/null +++ b/docs-next/zh/themes/default/markdown.md @@ -0,0 +1,458 @@ +# Markdown + + + +## 表格 + +**输入:** + +```md +| Tables | Are | Cool | +| ------------- | :-----------: | ----: | +| col 3 is | right-aligned | $1600 | +| col 2 is | centered | $12 | +| zebra stripes | are neat | $1 | +``` + +**输出:** + +| Tables | Are | Cool | +| ------------- | :-----------: | -----: | +| col 3 is | right-aligned | \$1600 | +| col 2 is | centered | \$12 | +| zebra stripes | are neat | \$1 | + +## Emoji :tada: + +**输入:** + +```txt +:tada: :100: +``` + +**输出:** + +:tada: :100: + +这里可以找到 [所有支持的 emoji 列表](https://github.com/markdown-it/markdown-it-emoji/blob/master/lib/data/full.mjs) 。 + +## 自定义容器 + +- 使用: + + ```md + ::: [title] + [content] + ::: + ``` + + `type` 是必需的, `title` 和 `content` 是可选的。 + + 支持的 `type` 有: + + - `info` + - `tip` + - `warning` + - `danger` (别名 `caution` ) + - `details` + - `important` + +- 示例 1 (默认标题): + +**输入:** + +```md +::: info +这是一个 信息 +::: + +::: tip +这是一个提示 +::: + +::: warning +这是一个警告 +::: + +::: danger +这是一个危险警告 +::: + +::: important +这是一个重要内容 +::: + +::: details +这是一个details 标签 +::: +``` + +**输出:** + +::: info +这是一个 信息 +::: + +::: tip +这是一个提示 +::: + +::: warning +这是一个警告 +::: + +::: danger +这是一个危险警告 +::: + +::: important +这是一个重要内容 +::: + +::: details +这是一个details 标签 +::: + +- 示例 2 (自定义标题): + +**输入** + +````md +::: danger STOP +危险区域,禁止通行 +::: + +::: details 点击查看代码 + +```ts +console.log('你好,VuePress!') +``` + +::: +```` + +**输出** + +::: danger STOP +危险区域,禁止通行 +::: + +::: details 点击查看代码 + +```ts +console.log('你好,VuePress!') +``` + +::: + +## GitHub 风格的警报 + +VuePress 默认主题同样支持以标注的方式渲染 [GitHub-flavored alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) 。 + +它们和 [自定义容器](#自定义容器) 的渲染方式相同。 + +```md +> [!NOTE] +> 强调用户在快速浏览文档时也不应忽略的重要信息。 + +> [!TIP] +> 有助于用户更顺利达成目标的建议性信息。 + +> [!IMPORTANT] +> 对用户达成目标至关重要的信息。 + +> [!WARNING] +> 因为可能存在风险,所以需要用户立即关注的关键内容。 + +> [!CAUTION] +> 行为可能带来的负面影响。 +``` + +> [!NOTE] +> 强调用户在快速浏览文档时也不应忽略的重要信息。 + +> [!TIP] +> 有助于用户更顺利达成目标的建议性信息。 + +> [!IMPORTANT] +> 对用户达成目标至关重要的信息。 + +> [!WARNING] +> 因为可能存在风险,所以需要用户立即关注的关键内容。 + +> [!CAUTION] +> 行为可能带来的负面影响。 + +## 在代码块中实现行高亮 + +**输入:** + +````txt +```js{4} +export default { + data () { + return { + msg: 'Highlighted!' + } + } +} +``` +```` + +**输出:** + +```js{4} +export default { + data () { + return { + msg: 'Highlighted!' + } + } +} +``` + +除了单行之外,还可以指定多个单行、多行,或两者均指定: + +- 多行:例如 `{5-8}` 、`{3-10}` 、`{10-17}` +- 多个单行:例如 `{4,7,9}` +- 多行与单行:例如 `{4,7-13,16,23-27,40}` + +**输入:** + +````txt +```js{1,4,6-8} +export default { // Highlighted + data () { + return { + msg: `Highlighted! + This line isn't highlighted, + but this and the next 2 are.`, + motd: 'VitePress is awesome', + lorem: 'ipsum' + } + } +} +``` +```` + +**输出:** + +```js{1,4,6-8} +export default { // Highlighted + data () { + return { + msg: `Highlighted! + This line isn't highlighted, + but this and the next 2 are.`, + motd: 'VitePress is awesome', + lorem: 'ipsum', + } + } +} +``` + +也可以使用 `// [!code highlight]` 注释实现行高亮。 + +**输入:** + +````txt +```js +export default { + data () { + return { + msg: 'Highlighted!' // [!!code highlight] + } + } +} +``` +```` + +**输出:** + +```js +export default { + data() { + return { + msg: 'Highlighted!', // [!code highlight] + } + }, +} +``` + +## 代码块中聚焦 + +在某一行上添加 `// [!code focus]` 注释将聚焦它并模糊代码的其他部分。 + +此外,可以使用 `// [!code focus:]` 定义要聚焦的行数。 + +**输入:** + +````txt +```js +export default { + data () { + return { + msg: 'Focused!' // [!!code focus] + } + } +} +``` +```` + +**输出:** + +```js +export default { + data() { + return { + msg: 'Focused!', // [!code focus] + } + }, +} +``` + +## 代码块中的颜色差异 + +在某一行添加 `// [!code --]` 或 `// [!code ++]` 注释将会为该行创建 diff,同时保留代码块的颜色。 + +**输入:** + +````txt +```js +export default { + data () { + return { + msg1: 'Removed', // [!!code --] + msg2: 'Added', // [!!code ++] + } + } +} +``` +```` + +**输出:** + +```js +export default { + data() { + return { + msg1: 'Removed', // [!code --] + msg2: 'Added', // [!code ++] + } + }, +} +``` + +## 高亮“错误”和“警告” + +在某一行添加 `// [!code warning]` 或 `// [!code error]` 注释将会为该行相应的着色。 + +**输入:** + +````txt +```js +export default { + data () { + return { + msg1: 'Error', // [!!code error] + msg2: 'Warning' // [!!code warning] + } + } + +``` +```` + +**输出:** + +```js +export default { + data() { + return { + msg1: 'Error', // [!code error] + msg2: 'Warning', // [!code warning] + } + }, +} +``` + +## 代码组 + +可以像这样将多个代码块分组: + +**输入:** + +````md +::: code-tabs + +@tab Foo + +```ts +const foo = 'foo' +``` + +@tab Bar + +```ts +const bar = 'bar' +``` + +::: +```` + +**输出:** + +::: code-tabs + +@tab Foo + +```ts +const foo = 'foo' +``` + +@tab Bar + +```ts +const bar = 'bar' +``` + +::: + +还可以添加`:active`选项以默认显示代码块。 + +**输入:** + +````md +::: code-tabs + +@tab Foo + +```ts +const foo = 'foo' +``` + +@tab:active Bar + +```ts +const bar = 'bar' +``` + +::: +```` + +**输出:** + +::: code-tabs + +@tab Foo + +```ts +const foo = 'foo' +``` + +@tab:active Bar + +```ts +const bar = 'bar' +``` + +::: diff --git a/docs-next/zh/themes/default/plugin.md b/docs-next/zh/themes/default/plugin.md new file mode 100644 index 0000000000..53f0acc104 --- /dev/null +++ b/docs-next/zh/themes/default/plugin.md @@ -0,0 +1,133 @@ +# 插件配置 + +你可以通过 `themePlugins` 设置默认主题使用的插件。 + +默认主题使用了一些插件,如果你确实不需要该插件,你可以选择禁用它。在禁用插件之前,请确保你已了解它的用途。 + +```ts +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + themePlugins: { + // 在这里自定义主题插件 + }, + }), +} +``` + +## themePlugins.activeHeaderLinks + +- 类型: `boolean` + +- 默认值: `true` + +- 详情: + + 是否启用 [@vuepress/plugin-active-header-links](../../plugins/development/active-header-links.md)。 + +## themePlugins.copyCode + +- 类型: `CopyCodePluginOptions | boolean` + +- 默认值: `true` + +- 详情: + + 是否启用 [@vuepress/plugin-copy-code](../../plugins/features/copy-code.md)。 + + Object value is supported as plugin options. + +## themePlugins.git + +- 类型: `boolean` + +- 默认值: `true` + +- 详情: + + 是否启用 [@vuepress/plugin-git](../../plugins/development/git.md)。 + +## themePlugins.hint + +- 类型: `MarkdownHintPluginOptions | boolean` + +- 默认值: `{ alert: true, hint: true }` + +- 详情: + + 是否启用 [@vuepress/plugin-markdown-hint](../../plugins/markdown/markdown-hint.md)。 + +## themePlugins.tab + +- 类型: `MarkdownTabPluginOptions | boolean` + +- 默认值: `{ codeTabs: true, tabs: true }` + +- 详情: + + 是否启用 [@vuepress/plugin-markdown-tab](../../plugins/markdown/markdown-tab.md)。 + +## themePlugins.linksCheck + +- 类型: `LinksCheckPluginOptions | boolean` + +- 默认值: `true` + +- 详情: + + 是否启用 [@vuepress/plugin-links-check](../../plugins/markdown/links-check.md)。 + +## themePlugins.photoSwipe + +- 类型: `boolean` + +- 默认值: `true` + +- 详情: + + 是否启用 [@vuepress/plugin-photo-swipe](../../plugins/features/photo-swipe.md)。 + +## themePlugins.nprogress + +- 类型: `boolean` + +- 默认值: `true` + +- 详情: + + 是否启用 [@vuepress/plugin-nprogress](../../plugins/features/nprogress.md)。 + +## themePlugins.shiki + +- 类型: `boolean | ShikiPluginOptions` + +- 默认值: `true` + +- 详情: + + 是否启用 [@vuepress/plugin-shiki](../../plugins/markdown/shiki.md)。 + +## themePlugins.seo + +- 类型: `SeoPluginOptions | boolean` + +- 默认值: `true` + +- 详情: + + 是否启用 [@vuepress/plugin-seo](../../plugins/seo/seo/README.md)。 + + Object value is supported as plugin options. + +## themePlugins.sitemap + +- 类型: `SitemapPluginOptions | boolean` + +- 默认值: `true` + +- 详情: + + 是否启用 [@vuepress/plugin-sitemap](../../plugins/seo/sitemap/README.md)。 + + 支持对象格式以作为插件选项。 diff --git a/docs-next/zh/themes/default/sidebar.md b/docs-next/zh/themes/default/sidebar.md new file mode 100644 index 0000000000..726f0e1ed4 --- /dev/null +++ b/docs-next/zh/themes/default/sidebar.md @@ -0,0 +1,429 @@ +# 侧边栏 + +侧边栏是文档的主要导航块。可以在 [sidebar](./config.md#sidebar) 中配置侧边栏菜单。 + +```js +export default { + theme: defaultTheme({ + sidebar: [ + { + text: 'Guide', + items: [ + { text: 'Introduction', link: '/introduction' }, + { text: 'Getting Started', link: '/getting-started' }, + // ... + ], + }, + ], + }), +} +``` + +主题支持你通过 [文件结构](#通过文件结构自动生成侧边栏) 自动生成侧边栏,也可以手动配置。 + +## 侧边栏链接 + +站点侧边栏的配置由主题选项中的 `sidebar` 控制。 + +### 字符串格式 + +同导航栏,你可以填入一个包含多个文件链接的数组,作为侧边栏基本的配置: + +```js {5-9} title=".vuepress/config.js" +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + sidebar: ['/zh/README.md', '/zh/guide/README.md', '/zh/config/README.md'], + }), +} +``` + +数组的每一项会自动提取对应文件的图标与标题,渲染为一个侧边栏项目。 + +::: tip +你可以省略 `.md` 扩展名,以 `/` 结尾的路径会被推断为 `/README.md`。 +::: + +### 对象格式 + +同导航栏,如果你对页面的图标不满意或者觉得页面标题太长,你可以改为配置一个对象。可用的配置项有: + +- `text:` 项目文字 +- `link` 项目链接 +- `activeMatch`: 项目激活匹配 (可选),支持正则字符串。 + +```js {5-22} title=".vuepress/config.js" +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + sidebar: [ + { + text: '指南', + link: '/zh/guide/README.md', + }, + { text: '配置', link: '/zh/config/README.md' }, + { + text: '常见问题', + link: '/zh/faq.md', + // 会在 `/zh/faq` 开头的路径激活 + // 所以当你前往 `/zh/faq/xxx.html` 时也会激活 + activeMatch: '^/zh/faq', + }, + ], + }), +} +``` + +::: tip activeMatch 的高级用法 + +`activeMatch` 给予你通过正则表达式控制路径是否激活的能力。 + +::: + +### 分组与嵌套 + +如果你需要展示嵌套结构的侧边栏,你可以将同类链接整理成菜单分组。 + +你需要使用 [对象格式](#对象格式) ,并提供额外的 `items` 选项设置链接列表。 + +和导航栏一样,你可以在侧边栏中使用 `prefix` 来为组内的每个链接添加默认的路径前缀, + +侧边栏额外支持设置 `collapsible` 来使菜单分组可折叠,设置 `collapsible: true` 使可折叠的分组默认展开。 + +```js {18-22,26-30} title=".vuepress/config.js" +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + sidebar: [ + { + // 必要的,分组的标题文字 + text: '分组 1', + // 可选的, 分组标题对应的链接 + link: '/foo/', + // 可选的,会添加到每个 item 链接地址之前 + prefix: '/foo/', + // 可选的, 设置分组是否可以折叠,false 为折叠, `true` 为展开,未配置时不可折叠 + collapsible: true, + // 必要的,分组的子项目 + items: [ + 'README.md' /* /foo/index.html */, + /* ... */ + 'geo.md' /* /foo/geo.html */, + ], + }, + { + text: '分组 2', + items: [ + /* ... */ + 'bar.md' /* /ray/bar.html */, + 'baz.md' /* /ray/baz.html */, + ], + }, + ], + }), +} +``` + +侧边栏分组也可以进行嵌套: + +```js {11-22} title=".vuepress/config.js" +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + sidebar: [ + { + text: 'Group', + prefix: '/', + items: [ + 'baz' /* /baz.html */, + { + text: 'Sub Group 1', + children: ['quz' /* /quz.html */, 'xyzzy' /* /xyzzy.html */], + }, + { + text: 'Sub Group 2', + prefix: 'corge/', + items: [ + 'fred' /* /corge/fred.html */, + 'grault' /* /corge/grault.html */, + ], + }, + 'foo' /* /foo.html */, + ], + }, + ], + }), +} +``` + +通常情况下,你可能希望搭配 `prefix` 使用来快速还原文档的结构。 + +比如,将你的页面文件为下述的目录结构: + +```sh +. +├─ README.md +├─ contact.md +├─ about.md +├─ foo/ +│ ├─ README.md +│ ├─ one.md +│ └─ two.md +└─ bar/ + ├─ README.md + ├─ three.md + └─ four.md +``` + +你就可以进行以下配置: + +```js title=".vuepress/config.js" +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + sidebar: [ + '/' /* / */, + { + text: 'Foo', + prefix: '/foo/', + items: [ + '' /* /foo/ */, + 'one' /* /foo/one.html */, + 'two' /* /foo/two.html */, + ], + }, + { + text: 'Bar', + prefix: '/bar/', + items: [ + '' /* /bar/ */, + 'three' /* /bar/three.html */, + 'four' /* /bar/four.html */, + ], + }, + '/contact' /* /contact.html */, + '/about' /* /about.html */, + ], + }), +} +``` + +### 多个侧边栏 + +如果你想为不同的页面组来显示不同的侧边栏,你需要通过 `路径前缀: 侧边栏配置` 的格式为侧边栏配置一个对象。 + +比如,将你的页面文件为下述的目录结构: + +```sh +. +├─ README.md +├─ contact.md +├─ about.md +├─ foo/ +│ ├─ README.md +│ ├─ one.md +│ └─ two.md +└─ bar/ + ├─ README.md + ├─ three.md + └─ four.md +``` + +你就可以遵循以下的侧边栏配置,来为不同路径显示不同的分组: + +```js title=".vuepress/config.js" +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + sidebar: { + '/foo/': [ + '' /* /foo/ */, + 'one' /* /foo/one.html */, + 'two' /* /foo/two.html */, + ], + + '/bar/': [ + '' /* /bar/ */, + 'three' /* /bar/three.html */, + 'four' /* /bar/four.html */, + ], + + // fallback + '/': [ + '' /* / */, + 'contact' /* /contact.html */, + 'about' /* /about.html */, + ], + }, + }), +} +``` + +## 通过文件结构自动生成侧边栏 + +你可以在上述任意侧边栏配置中,将原来的“侧边栏数组”替换为 `'structure'` 关键词。这会让主题自动读取本地文件,为你生成对应的侧边栏结构,以大大减少你的配置工作量。 + +比如对于之前在 [多个侧边栏](#多个侧边栏) 提到的如下例子: + +```sh +. +├─ README.md +├─ contact.md +├─ about.md +├─ foo/ +│ ├─ README.md +│ ├─ one.md +│ └─ two.md +└─ bar/ + ├─ README.md + ├─ three.md + └─ four.md +``` + +你可以将原来的配置改为: + +```js {6,8} title=".vuepress/config.js" +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + sidebar: { + '/foo/': 'structure', + + '/bar/': 'structure', + + // fallback + '/': [ + '' /* / */, + 'contact' /* /contact.html */, + 'about' /* /about.html */, + ], + }, + }), +} +``` + +在上述的修改中,由于原侧边栏数组即为相关路径下的全部文件,你可以轻松将其替换为 `'structure'` 关键词。 + +如果你使用结构生成的文件夹下嵌套了其他文件夹,则对应的文件夹会被渲染成一个分组。所以你甚至可以更加激进,比如直接设置 `sidebar: 'structure'` 让你的侧边栏全部从文件结构中自动生成。 + +::: warning 限制 + +由于结构侧边栏取决于文件结构和 Markdown Frontmatter,因此 Markdown 的任何更改都可能更新结构侧边栏。(例如: 如下所述在 Frontmatter 中设置 `index: false`) + +::: + +### 进阶控制 + +在从结构自动生成的过程中,你可以通过页面 Frontmatter 中的 `index` 选项控制同一文件夹下的文件是否被包含、并通过 `order` 控制它们的排序方式。 + +当你不希望页面被侧边栏收录时,你需要在 Frontmatter 中设置 `index: false`。 + +默认情况下,侧边栏会按照文件名的标题文字按照当前语言排序,你可以通过 `order` 来控制它们的排序方式,当你设置为正数时,它们会出现在分组最前方,越小的越靠前,当你设置为负数时,会出现在分组最后方,越大的越靠后: + +- 页面 -> order: 1 +- 页面 -> order: 2 +- 页面 -> order: 3 +- ... +- 含有正数 `order` 的页面在此处会根据 order 大小排序 +- ... +- 不含有 `order` 选项的页面 -> 标题: Axxx +- ... +- 不含有 `order` 选项的页面在此处会根据标题排序 +- ... +- 不含有 `order` 选项的页面 -> 标题: Zxxx +- ... +- 含有负数 `order` 的页面在此处会根据 order 大小排序 +- ... +- 页面 -> order: -3 +- 页面 -> order: -2 +- 页面 -> order: -1 + +::: tip + +`README.md` 是一个例外,只要你不通过 `index: false` 或使其成为分组链接禁止其出现在侧边栏中,它总会在排序中成为第一项。 + +::: + +对于嵌套文件夹,其分组信息由对应文件夹下的 `README.md` 控制,你可以通过 Frontmatter 中的 `dir` 选项控制文件夹分组的行为,相关可选项目如下: + +- `dir.text`: 目录标题,默认为 README.md 标题 +- `dir.collapsible`: 目录是否可折叠,默认为 `true` +- `dir.expanded`: 目录是否默认展开,默认为 `false` +- `dir.link`: 目录是否可点击,默认为 `false` +- `dir.index`: 是否索引此目录,默认为 `true` +- `dir.order`: 目录在侧边栏中的顺序,默认为 `0` + +以下是一个案例: + +```md +--- +dir: + order: 1 + text: Group 1 +--- +``` + +如果对应文件夹不存在 `README.md` 文件,则只有分组标题会从文件夹名称中生成。 + +#### 自定义排序 + +除了上面的实现外,我们还在主题选项中添加了更为强大的 `sidebarSorter` 选项。你可以传入一个或一系列内置排序器名称,也可以传递一个自己需要的排序函数对同级的侧边栏项目进行排序。 + +可用的关键字有: + +- `readme`: `README.md` 或 `readme.md` 在前 +- `order`: 正序在前并按其值升序排列,负序在后并按其值降序排列 +- `date`: 按日期升序排序 +- `date-desc`: 按日期降序排序 +- `title`: 按标题字母顺序排序 +- `filename`: 按文件名字母顺序排序 + +对应上述的进阶控制,它的默认值是 `['readme', 'order', 'title', 'filename']` + +## 禁用侧边栏 + +你可以通过 `Frontmatter 来禁用指定页面的侧边栏: + +```md +--- +sidebar: false +--- +``` + +::: note + +侧边栏在主页中默认禁用。 + +::: + +## 多语言 + +主题的侧边栏支持 [多语言](https://vuejs.press/zh/guide/i18n.html),所以你可以为每个语言单独设置侧边栏: + +```js {7-9,12-14} title=".vuepress/config.js" +import { defaultTheme } from '@vuepress/theme-default' + +export default { + theme: defaultTheme({ + locales: { + '/': { + sidebar: [ + /* 根目录下的英文配置 */ + ], + }, + '/zh/': { + sidebar: [ + /* 中文目录下的中文配置 */ + ], + }, + }, + }), +} +``` diff --git a/docs-next/zh/themes/default/styles.md b/docs-next/zh/themes/default/styles.md new file mode 100644 index 0000000000..c9d8d77c0b --- /dev/null +++ b/docs-next/zh/themes/default/styles.md @@ -0,0 +1,27 @@ +# 样式 + + + +默认主题使用 CSS 编写样式,使用 CSS Variables 定义样式变量。 + +用户可以通过 [样式文件](#样式文件) 覆盖默认的 CSS 变量,以及编写额外的样式。 + +## CSS 变量文件 + +你可以在 [样式文件](#样式文件) 中覆盖默认的 CSS 变量。 + +::: details 点击展开 CSS 变量 +@[code scss](@vuepress/theme-default/src/client/styles/vars.css) +::: + +## 样式文件 + +样式文件的路径是 `.vuepress/styles/index.scss`. + +你可以在这里编写额外的样式,或覆盖默认样式: + +```css +:root { + scroll-behavior: smooth; +} +``` diff --git a/docs-next/zh/themes/guidelines.md b/docs-next/zh/themes/guidelines.md new file mode 100644 index 0000000000..716a35ecb7 --- /dev/null +++ b/docs-next/zh/themes/guidelines.md @@ -0,0 +1,82 @@ +# 主题指南 + +为了避免主题开发者和用户设置不必要的选项,我们制定了一套主题创建时应遵循的指南。 + +## DOM 结构 + +一个主题必须实现以下 DOM 结构: + +- 容器:一个包含整个主题的元素。此元素应该有一个 `vp-container` 属性。 +- 内容:一个包含 markdown 渲染结果的元素。此元素应该有一个 `vp-content` 属性。 + +一个主题可以有以下可选元素: + +- 导航栏:站点的导航栏。此元素应该有一个 `vp-navbar` 属性。 +- 侧边栏:站点的侧边栏。此元素应该有一个 `vp-sidebar` 属性。 +- 大纲:主要内容的标题或大纲。此元素应该有一个 `vp-outline` 属性。 +- 评论:评论服务(评论框和评论列表)。此元素应该有一个 `vp-comment` 属性。 +- 页脚:站点的页脚。此元素应该有一个 `vp-footer` 属性。 + +一个主题必须: + +- 在暗色模式下,将 html 元素的 `data-theme` 设置为 `dark`。 +- 在亮色模式下,将 html 元素的 `data-theme` 设置为 `light`。 + +如果主题只有一种颜色方案,主题仍然需要将 `data-theme` 设置为 `light` 或 `dark`,以指示默认颜色方案。 + +## 组件 + +为了支持搜索插件,主题应检查 `` 是否已全局注册,并在其自己的导航栏或侧边栏中呈现(如果可用)。 + +## 颜色变量 + +一个主题必须实现以下颜色变量: + +### 文字 + +- `--vp-c-text`:默认文本颜色。 +- `--vp-c-text-mute`:用于静音文本的颜色,例如“非活动菜单”或“信息文本”。 +- `--vp-c-text-subtle`:用于细微文本的颜色,例如“占位符”或“插入符号”。 + +### 背景 + +- `--vp-c-bg`:用于主屏幕的背景颜色。 +- `--vp-c-bg-alt`:用于“侧边栏”或“代码块”等地方的备用背景颜色。 +- `--vp-c-bg-elv`:用于“浮动”部分的提升背景颜色,例如“对话框”。 + +### 阴影 + +- `--vp-c-shadow`:阴影颜色 + +### 强调 + +用于交互组件的强调颜色和品牌颜色。 + +- `--vp-c-accent`:主要用于彩色文本的最实色。它必须满足与放在 `--vp-c-accent-soft` 顶部时的对比度。 +- `--vp-c-accent-hover`:用于悬停状态的颜色。 +- `--vp-c-accent-bg`:用于实色背景的颜色。它必须满足与放在其顶部的 `--vp-c-accent-text` 的对比度。 +- `--vp-c-accent-text`:用于 `--vp-c-accent-bg` 背景的文本颜色。它必须满足与 `--vp-c-accent-bg` 的对比度。 +- `--vp-c-accent-soft`:用于自定义容器或徽章等细微背景的颜色。当将 `--vp-c-accent` 颜色放在其顶部时,它必须满足对比度。 + + 软色必须是半透明的 alpha 通道。这是至关重要的,因为它允许将多个“软”颜色叠加在一起以创建强调,例如在自定义容器内部有内联代码块时。 + +### 边框 + +- `--vp-c-border`:交互组件的边框颜色。例如,这应该用于按钮轮廓。 +- `--vp-c-border-hard`:较暗的边框颜色,用于紧贴文本的“硬”边框,例如表格和 kbd。 +- `--vp-c-divider`:分隔符的颜色,用于在同一组件内分隔部分,例如在“h2”标题上放置分隔符。 + +### 控件 + +- `--vp-c-control`:用于交互控件(例如按钮或复选框)的背景颜色。 +- `--vp-c-control-hover`:用于交互控件悬停状态的背景颜色。 +- `--vp-c-control-disabled`:用于交互控件禁用状态的颜色。 + +## 过渡时间 + +- `--vp-t-color`:颜色过渡时间。 +- `--vp-t-transform`:变换过渡时间。 + +## 案例 + + diff --git a/docs-next/zh/tools/helper/README.md b/docs-next/zh/tools/helper/README.md new file mode 100644 index 0000000000..9c1f3c8ca5 --- /dev/null +++ b/docs-next/zh/tools/helper/README.md @@ -0,0 +1,13 @@ +# @vuepress/helper + + + +此包为 VuePress 开发者提供辅助函数。 + +- `@vuepress/helper`: Node.js 一侧的辅助函数。 + + - [打包器相关](node/bundler.md) + - [页面相关](node/page.md) + +- [`@vuepress/helper/client`](client.md): 客户端一侧的辅助函数。 +- [`@vuepress/helper/shared`](shared.md): Node.js 和客户端共享的辅助函数。 diff --git a/docs-next/zh/tools/helper/client.md b/docs-next/zh/tools/helper/client.md new file mode 100644 index 0000000000..985f88c425 --- /dev/null +++ b/docs-next/zh/tools/helper/client.md @@ -0,0 +1,159 @@ +# 客户端相关 + +## 可组合 API + +### hasGlobalComponent + +检查组件是否已全局注册。 + +::: tip + +1. 组件的局部导入不影响结果。 +1. 当在 `setup` 之外调用时,你需要将 `app` 实例作为第二个参数传递。 + +::: + +```ts +export const hasGlobalComponent: (name: string, app?: App) => boolean +``` + +::: details 示例 + +```ts +// 如果你全局注册了 `` +hasGlobalComponent('MyComponent') // true +hasGlobalComponent('my-component') // true + +hasGlobalComponent('MyComponent2') // false +``` + +::: + +### useLocaleConfig + +从语言环境设置中获取当前语言环境配置。 + +```ts +export const useLocaleConfig: ( + localesConfig: RequiredLocaleConfig, +) => ComputedRef +``` + +::: details 示例 + +```ts +const localesCOnfig = { + '/': 'Title', + '/zh/': '标题', +} + +const locale = useLocaleConfig(localesConfig) + +// under `/page` +locale.value // 'Title' + +// under `/zh/page` +locale.value // '标题' +``` + +::: + +## 工具 + +### getHeaders + +获取当前页面指定的 标题列表。 + +```ts +export const getHeaders: (options: GetHeadersOptions) => MenuItem[] +``` + +**参数:** + +```ts +export interface GetHeadersOptions { + /** + * 页面标题选择器 + * + * @default '[vp-content] h1, [vp-content] h2, [vp-content] h3, [vp-content] h4, [vp-content] h5, [vp-content] h6' + */ + selector?: string + /** + * 忽略标题内的特定元素选择器 + * + * 它将作为 `document.querySelectorAll` 的参数。 + * 因此,你应该传入一个 `CSS 选择器` 字符串 + * + * @default [] + */ + ignore?: string[] + /** + * 指定获取的标题层级 + * + * `1` 至 `6` 表示 `

` 至 `

` + * + * - `false`: 不返回标题列表 + * - `number`: 只获取指定的单个层级的标题。 + * - `[number, number]: 标题层级元组,第一个数字应小于第二个数字。例如,`[2, 4]` 表示显示从 `

` 到 `

` 的所有标题。 + * - `deep`: 等同于 `[2, 6]`, 表示获取从 `

` 到 `

` 的所有标题。 + * + * @default 2 + */ + levels?: HeaderLevels +} +``` + +**返回结果:** + +```ts +export interface Header { + /** + * 当前标题的层级 + * + * `1` 至 `6` 表示 `

` 至 `

` + */ + level: number + /** + * 当前标题的内容 + */ + title: string + /** + * 标题的 标识 + * + * 这通常是标题元素的 `id` 属性值 + */ + slug: string + /** + * 标题的链接 + * + * 通常使用`#${slug}`作为锚点哈希 + */ + link: string + /** + * 标题的子标题列表 + */ + children: Header[] +} + +export type HeaderLevels = number | 'deep' | false | [number, number] + +export type MenuItem = Omit & { + element: HTMLHeadElement + children?: MenuItem[] +} +``` + +::: details Examples + +```ts +onMounted(() => { + const headers = getHeaders({ + selector: '[vp-content] :where(h1,h2,h3,h4,h5,h6)', + levels: [2, 3], // 只有 h2 和 h3 + ignore: ['.badge'], // 忽略标题内的 + }) + console.log(headers) +}) +``` + +::: diff --git a/docs-next/zh/tools/helper/node/bundler.md b/docs-next/zh/tools/helper/node/bundler.md new file mode 100644 index 0000000000..5d01afe04a --- /dev/null +++ b/docs-next/zh/tools/helper/node/bundler.md @@ -0,0 +1,350 @@ +# 打包器相关 + +打包器函数用于在主题和插件中追加或修改打包器选项。 + +所有函数都应在 `extendsBundlerOptions` 生命周期挂钩中调用。 + +::: tip + +我们在示例中省略了它。 实际代码应该是这样的: + +```js +// 导入你需要的函数 +import { addCustomElement } from '@vuepress/helper' + +export const yourPlugin = { + // ... + extendsBundlerOptions: (bundlerOptions, app) => { + // 在此添加它们 + addCustomElement(bundlerOptions, app, 'my-custom-element') + }, +} +``` + +::: + +## 通用方法 + +### getBundlerName + +获取当前打包器的名称。 + +```ts +export const getBundlerName: (app: App) => string +``` + +::: details 示例 + +```ts +// @vuepress/bundler-vite +getBundleName(app) === 'vite' // true +// @vuepress/bundler-webpack +getBundleName(app) === 'webpack' // true +``` + +::: + +### addCustomElement + +将自定义元素声明添加到当前的打包器。 + +```ts +interface CustomElementCommonOptions { + app: App + config: unknown +} +/** + * Add tags as customElement + * + * @param bundlerOptions VuePress Bundler config + * @param app VuePress Node App + * @param customElements tags recognized as custom element + */ +export const addCustomElement: ( + bundlerOptions: unknown, + app: App, + customElement: RegExp | string[] | string, +) => void +``` + +::: details 示例 + +```ts +import { addCustomElement } from '@vuepress/helper' + +addCustomElement(bundlerConfig, app, 'my-custom-element') +addCustomElement(bundlerOptions, app, [ + 'custom-element1', + 'custom-element2', + // all tags start with `math-` + /^math-/, +]) +``` + +::: + +### customizeDevServer + +为开发服务器中的特定路径提供内容。 + +```ts +export interface DevServerOptions { + /** + * Path to be responded + */ + path: string + /** + * Respond function + */ + response: (request?: IncomingMessage) => Promise + + /** + * error msg + */ + errMsg?: string +} + +/** + * Handle specific path when running VuePress Dev Server + * + * @param bundlerOptions VuePress Bundler config + * @param app VuePress Node App + * @param path Path to be responded + * @param response respond function + * @param errMsg error msg + */ +export const customizeDevServer: ( + bundlerOptions: unknown, + app: App, + { + errMsg = 'The server encountered an error', + response, + path, + }: CustomServerOptions, +) => void +``` + +::: details 示例 + +```ts +import { useCustomDevServer } from '@vuepress/helper' + +// handle `/api/` path +useCustomDevServer(bundlerOptions, app, { + path: '/api/', + response: async () => getData(), + errMsg: 'Unexpected api error', +}) +``` + +::: + +## Vite 相关 + +- addViteOptimizeDepsInclude + + 向 Vite `optimizeDeps.include` 列表中添加模块 + + ::: tip + + 如果一个包满足下列条件之一,你应该考虑将它添加至此。 + + - 为 CJS 格式 + - 包的依赖包含 CJS 包 + - 包通过 `import()` 动态导入 + + ::: + +- addViteOptimizeDepsExclude + + 向 Vite `optimizeDeps.exclude` 列表中添加模块 + + ::: tip 如果一个包和它的依赖都是纯 ESM 包,你应该考虑将它添加至此。 + + ::: + +- addViteSsrExternal + + 向 Vite `ssr.external` 列表中添加模块 + + ::: tip 如果一个包是纯 ESM 包,且未使用别名 (alias) 或定义变量 (define),你应该考虑将它添加至此。 + + ::: + +- addViteSsrNoExternal + + 向 Vite `ssr.noExternal` 列表中添加模块 + + ::: warning 如果一个包内使用了别名 (alias) 或定义变量 (define),你必须将它添加至此。 + + ::: + + ```ts + /** + * Add modules to Vite `optimizeDeps.include` list + */ + export const addViteOptimizeDepsInclude: ( + bundlerOptions: unknown, + app: App, + module: string[] | string, + ) => void + + /** + * Add modules to Vite `optimizeDeps.exclude` list + */ + export const addViteOptimizeDepsExclude: ( + bundlerOptions: unknown, + app: App, + module: string[] | string, + ) => void + + /** + * Add modules to Vite `ssr.external` list + */ + export const addViteSsrExternal: ( + bundlerOptions: unknown, + app: App, + module: string[] | string, + ) => void + + /** + * Add modules to Vite `ssr.noExternal` list + */ + export const addViteSsrNoExternal: ( + bundlerOptions: unknown, + app: App, + module: string[] | string, + ) => void + ``` + + ::: details 示例 + + ```ts + import { + addViteOptimizeDepsExclude, + addViteOptimizeDepsInclude, + addViteSsrExternal, + addViteSsrNoExternal, + } from '@vuepress/helper' + + addViteOptimizeDepsInclude(bundlerOptions, app, ['vue', 'vue-router']) + addViteOptimizeDepsExclude(bundlerOptions, app, 'packageA') + addViteSsrNoExternal(bundlerOptions, app, ['vue', 'vue-router']) + addViteSsrExternal(bundlerOptions, app, 'packageA') + ``` + + ::: + +- addViteConfig + + A function for you to add vite config + + ```ts + export const addViteConfig: ( + bundlerOptions: unknown, + app: App, + config: Record, + ) => void + ``` + + ::: details Example + + ```ts + import { addViteConfig } from '@vuepress/helper' + + addViteConfig(bundlerOptions, app, { + build: { + charset: 'utf8', + }, + }) + ``` + + ::: + +- mergeViteConfig + + 无需导入 vite 即可合并 vite 配置的功能 + + ```ts + export const mergeViteConfig: ( + defaults: Record, + overrides: Record, + ) => Record + ``` + + ::: warning + + 你不应将 vite 作为依赖,因为你的的用户可能选择其他打包器! + + ::: + + ::: details 示例 + + ```ts + import { mergeViteConfig } from '@vuepress/helper' + + config.viteOptions = mergeViteConfig(config.viteOptions, { + build: { + charset: 'utf8', + }, + }) + ``` + + ::: + +## Webpack 相关 + +- chainWebpack + + 链式修改 webpack 配置. + + ```ts + export const chainWebpack: ( + { app, config }: WebpackCommonOptions, + chainWebpack: ( + config: WebpackChainConfig, + isServer: boolean, + isBuild: boolean, + ) => void, + ) => void + ``` + + ::: details 示例 + + ```ts + import { chainWebpack } from '@vuepress/helper' + + chainWebpack(bundlerOptions, app, (config, isServer, isBuild) => { + // do some customize here + }) + ``` + + ::: + +- configWebpack + + 配置 Webpack + + ```ts + export const configWebpack: ( + bundlerOptions: unknown, + app: App, + configureWebpack: ( + config: WebpackConfiguration, + isServer: boolean, + isBuild: boolean, + ) => void, + ) => void + ``` + + ::: details 实例 + + ```ts + import { configWebpack } from '@vuepress/helper' + + configWebpack(bundlerOptions, app, (config, isServer, isBuild) => { + // do some customize here + }) + ``` + + ::: diff --git a/docs-next/zh/tools/helper/node/page.md b/docs-next/zh/tools/helper/node/page.md new file mode 100644 index 0000000000..e82ad4f4a1 --- /dev/null +++ b/docs-next/zh/tools/helper/node/page.md @@ -0,0 +1,93 @@ +# 页面相关 + +这些函数为你的页面生成常见信息。 + +## getPageExcerpt + +获取页面摘要。 + +```ts +export interface PageExcerptOptions { + /** + * 摘要分隔符 + * + * @default "" + */ + separator?: string + + /** + * 摘要的长度 + * + * @description 摘要的长度会尽可能的接近这个值 + * + * @default 300 + */ + length?: number + + /** + * 被认为是自定义元素的标签 + * + * @description 用于判断一个标签是否是自定义元素,因为在摘要中,所有的未知标签都会被移除。 + */ + isCustomElement?: (tagName: string) => boolean + + /** + * 是否保留页面标题 (第一个 h1) + * + * @default false + */ + keepPageTitle?: boolean + + /** + * 是否保留代码块的标签,诸如行号和高亮行 + * + * @default false + */ + keepFenceDom?: boolean +} + +export const getPageExcerpt: ( + app: App, + page: Page, + options?: PageExcerptOptions, +) => string +``` + +## getPageText + +获取页面纯文本。 + +```ts +export interface PageTextOptions { + /** + * 是否将文字转换成单行内容 + * + * @default false + */ + singleLine?: boolean + + /** + * 文字的长度 + * + * @description 文字的长度会尽可能的接近这个值 + * + * @default 300 + */ + length?: number + + /** + * 需要移除的标签 + * + * @description 默认情况下表格和代码块会被移除 + * + * @default ['table', 'pre'] + */ + removedTags?: string[] +} + +export const getPageText: ( + app: App, + page: Page, + options?: PageTextOptions, +) => string +``` diff --git a/docs-next/zh/tools/helper/shared.md b/docs-next/zh/tools/helper/shared.md new file mode 100644 index 0000000000..9df4dd09df --- /dev/null +++ b/docs-next/zh/tools/helper/shared.md @@ -0,0 +1,190 @@ +# 共享方法 + +以下函数在 Node.js 和客户端上均可用。 + +## 数据相关 + +此方法在 MarkdownIt 插件中很有用。有些时候你可能需要在 Markdown 插件中生成组件,并将复杂的数据写入到组件属性中,一个通常做法是使用 `JSON.stringify` + `encodeURIComponent`,并在客户端 `decodeURIComponent` + `JSON.parse`。但如果内容包含很多特殊字符,转换结果会很长。 + +所以我们提供 `encodeData` 和 `decodeData` 来压缩和编码内容。 + +```ts +export const encodeData: ( + data: string, + level: DeflateOptions['level'] = 6, +) => string + +export const decodeData: (compressed: string) => string +``` + +::: details + +```ts +const content = ` +{ + "type": "bar", + "data": { + "labels": ["Red", "Blue", "Yellow", "Green", "Purple", "Orange"], + "datasets": [ + { + "label": "# of Votes", + "data": [12, 19, 3, 5, 2, 3], + "backgroundColor": [ + "rgba(255, 99, 132, 0.2)", + "rgba(54, 162, 235, 0.2)", + "rgba(255, 206, 86, 0.2)", + "rgba(75, 192, 192, 0.2)", + "rgba(153, 102, 255, 0.2)", + "rgba(255, 159, 64, 0.2)" + ], + "borderColor": [ + "rgba(255, 99, 132, 1)", + "rgba(54, 162, 235, 1)", + "rgba(255, 206, 86, 1)", + "rgba(75, 192, 192, 1)", + "rgba(153, 102, 255, 1)", + "rgba(255, 159, 64, 1)" + ], + "borderWidth": 1 + } + ] + }, + "options": { + "scales": { + "y": { + "beginAtZero": true + } + } + } +} +` + +const prop = encodeData(content) // "eJyNUsFOwzAMve8rrHABKZqWlg5WxAE4cARxAMHEIV1NmQhNlaaCCe3fcdKtW0sLWGpjxy/v+UV512mlcIyfhTa2hHP4GgHYVYExsEQaxqlMpZWxbwAomaAqY5izO0wZB3apKnTrIyqlP1x2bRBzl9xWplC+eWNkniF7dmw1X4nWsfgaNtwNP2kfgH6Be22x9CPUUQ8yFwEHMeMQcog4UBFuiF0kcvGWGV3l6ZVW2uw0XDCTJfIwiOjYjAhESIcn4+BoT2MLio6pP6V+EBJ6AOSZgsmUwyl9A6ATwoiZn3lYTkTkRkycnuP8TU9ENPqUxuuA9i9BmxTNPy9A/G2/F9I23wtpW++FdIwPKzW2W5Afph+WqX2NQWz313XicT7XhV3qnB5f/ejKhVTYVACrXUqUmC3zC/uERsdgTYUdVr/Qb302+gZxe7S/" + +decodeData(prop) // will be the original content + +// if you use `encodeURIComponent`, it will be much longer +encodeURIComponent(content) // '%0A%7B%0A%20%20%22type%22%3A%20%22bar%22%2C%0A%20%20%22data%22%3A%20%7B%0A%20%20%20%20%22labels%22%3A%20%5B%22Red%22%2C%20%22Blue%22%2C%20%22Yellow%22%2C%20%22Green%22%2C%20%22Purple%22%2C%20%22Orange%22%5D%2C%0A%20%20%20%20%22datasets%22%3A%20%5B%0A%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%22label%22%3A%20%22%23%20of%20Votes%22%2C%0A%20%20%20%20%20%20%20%20%22data%22%3A%20%5B12%2C%2019%2C%203%2C%205%2C%202%2C%203%5D%2C%0A%20%20%20%20%20%20%20%20%22backgroundColor%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%22rgba(255%2C%2099%2C%20132%2C%200.2)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(54%2C%20162%2C%20235%2C%200.2)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(255%2C%20206%2C%2086%2C%200.2)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(75%2C%20192%2C%20192%2C%200.2)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(153%2C%20102%2C%20255%2C%200.2)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(255%2C%20159%2C%2064%2C%200.2)%22%0A%20%20%20%20%20%20%20%20%5D%2C%0A%20%20%20%20%20%20%20%20%22borderColor%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%22rgba(255%2C%2099%2C%20132%2C%201)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(54%2C%20162%2C%20235%2C%201)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(255%2C%20206%2C%2086%2C%201)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(75%2C%20192%2C%20192%2C%201)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(153%2C%20102%2C%20255%2C%201)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%22rgba(255%2C%20159%2C%2064%2C%201)%22%0A%20%20%20%20%20%20%20%20%5D%2C%0A%20%20%20%20%20%20%20%20%22borderWidth%22%3A%201%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%5D%0A%20%20%7D%2C%0A%20%20%22options%22%3A%20%7B%0A%20%20%20%20%22scales%22%3A%20%7B%0A%20%20%20%20%20%20%22y%22%3A%20%7B%0A%20%20%20%20%20%20%20%20%22beginAtZero%22%3A%20true%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20%7D%0A%7D%0A' +``` + +::: + +## 类型助手 + +- `isDef(x)`: 判断 x 是否定义。 +- `isBoolean(x)`: 判断 x 是否为布尔值。 +- `isString(x)`: 判断 x 是否为字符串。 +- `isNumber(x)`: 判断 x 是否为数字。 +- `isPlainObject(x)`: 判断值是否为纯对象。 +- `isArray(x)`: 判断 x 是否为数组 +- `isFunction(x)`: 判断 x 是否为函数。 +- `isRegExp(x)`: 判断 x 是否为正则表达式 + +## 字符串相关 + +- `startsWith(a, b)`: 判断字符串 a 是否以指定字符串 b 开头 +- `endsWith(a, b)`: 判断字符串 a 是否以指定字符串 b 结尾 + +当 a 不是字符串时返回 `false` + +## 对象相关 + +- `keys(x)`: 以数组形式返回对象 x 的键 +- `values(x)`: 以数组形式返回对象 x 的值 +- `entries(x)`: 将对象 x 转换为键值对数组。 +- `fromEntries(x)`: 将键值对数组 x 转换为对象。 +- `deepAssign(x, y, ...)`: `Object.assign` 的深度版本。 + + ::: details 示例 + + ```ts + // or @vuepress/helper/client + import { deepAssign } from '@vuepress/helper' + + const defaultOptions = { + optionA: { + optionA1: 'defaultOptionA1', + optionA2: 'defaultOptionA2', + optionA3: 'defaultOptionA3', + }, + optionB: true, + optionC: 'optionC', + } + + const userOptions = { + optionA: { + optionA1: 'optionA1', + optionA2: 'optionA2', + }, + optionB: false, + } + + deepAssign(defaultOptions, userOptions) + // { + // optionA: { + // optionA1: "optionA1", + // optionA2: "optionA2", + // optionA3: "defaultOptionA3", + // }, + // optionB: false, + // optionC: "optionC", + // } + ``` + + ::: + +## 日期相关 + +- `getDate(x)`: 将输入 x 转换为日期,可以支持 Date,时间戳,日期字符串。日期字符串的支持度以环境的 `Date.parse` 支持度为准。当不能转换为日期时返回 `null` + + ::: details 示例 + + ```ts + getDate('2021-01-01') // a Date object represents 2021-01-01 + getDate(1609459200000) // a Date object represents 2021-01-01 + getDate('2021-01-01T00:00:00.000Z') // a Date object represents 2021-01-01 + getDate('2021/01/01') // a Date object represents 2021-01-01 (might be null in some browsers) + getDate('invalid date') // null + getDate(undefined) // null + getDate(-32) // null + ``` + + ::: + +- `dateSorter`: 将可转换为日期的值从新到旧排序,不能转换为日期的值会在最后。 + + ::: details 示例 + + ```ts + const arr = [ + '2020-01-01', + 1609459200000, + '2022-01-01T00:00:00.000Z', + '2023/01/01', + 'invalid date', + undefined, + -32, + ] + + arr.sort(dateSorter) + // [ + // '2023/01/01', + // '2022-01-01T00:00:00.000Z', + // 1609459200000, + // '2020-01-01', + // 'invalid date', + // undefined, + // -32, + // ] + ``` + +## 链接相关 + +- `isLinkHttp(x)`: x 是否是有效的 HTTP URL。 +- `isLinkWithProtocol(x)`: x 是否是有效的带有协议的 URL。 +- `isLinkExternal(x)`: x 是否是有效的外部 URL。 +- `isLinkAbsolute(x)`: x 是否是有效的绝对 URL。 +- `ensureEndingSlash(x)`: 确保 x 以斜杠结尾。 +- `ensureLeadingSlash(x)`: 确保 x 以斜杠开头。 +- `removeEndingSlash(x)`: 确保 x 不以斜杠结尾。 +- `removeLeadingSlash(x)`: 确保 x 不以斜杠开头。 diff --git a/docs-next/zh/tools/helper/style.md b/docs-next/zh/tools/helper/style.md new file mode 100644 index 0000000000..9521f6db96 --- /dev/null +++ b/docs-next/zh/tools/helper/style.md @@ -0,0 +1,7 @@ +# 样式 + +提供了如下样式文件。 + +## 规范化 + +`@vuepress/helper/normalize.css` 是一个 CSS 文件,用于规范化浏览器的默认样式。推荐在社区主题中引入它。 diff --git a/docs/.vuepress/configs/navbar/en.ts b/docs/.vuepress/configs/navbar/en.ts index 37e4ea548f..4b41b107d9 100644 --- a/docs/.vuepress/configs/navbar/en.ts +++ b/docs/.vuepress/configs/navbar/en.ts @@ -1,4 +1,4 @@ -import type { NavbarOptions } from '@vuepress/theme-default' +import type { NavbarOptions } from '@vuepress/theme-classic' export const navbarEn: NavbarOptions = [ { diff --git a/docs/.vuepress/configs/navbar/zh.ts b/docs/.vuepress/configs/navbar/zh.ts index d7f10106e6..69ad7c7569 100644 --- a/docs/.vuepress/configs/navbar/zh.ts +++ b/docs/.vuepress/configs/navbar/zh.ts @@ -1,4 +1,4 @@ -import type { NavbarOptions } from '@vuepress/theme-default' +import type { NavbarOptions } from '@vuepress/theme-classic' export const navbarZh: NavbarOptions = [ { diff --git a/docs/.vuepress/configs/sidebar/en.ts b/docs/.vuepress/configs/sidebar/en.ts index ade79805b0..8df773f07e 100644 --- a/docs/.vuepress/configs/sidebar/en.ts +++ b/docs/.vuepress/configs/sidebar/en.ts @@ -1,4 +1,4 @@ -import type { SidebarOptions } from '@vuepress/theme-default' +import type { SidebarOptions } from '@vuepress/theme-classic' export const sidebarEn: SidebarOptions = { '/plugins/': [ diff --git a/docs/.vuepress/configs/sidebar/zh.ts b/docs/.vuepress/configs/sidebar/zh.ts index 01bfd6e4e0..595f62c705 100644 --- a/docs/.vuepress/configs/sidebar/zh.ts +++ b/docs/.vuepress/configs/sidebar/zh.ts @@ -1,4 +1,4 @@ -import type { SidebarOptions } from '@vuepress/theme-default' +import type { SidebarOptions } from '@vuepress/theme-classic' export const sidebarZh: SidebarOptions = { '/zh/plugins/': [ diff --git a/docs/.vuepress/layouts/CommentPage.vue b/docs/.vuepress/layouts/CommentPage.vue index 439f3254f1..bfc2238653 100644 --- a/docs/.vuepress/layouts/CommentPage.vue +++ b/docs/.vuepress/layouts/CommentPage.vue @@ -1,5 +1,5 @@ + + + + diff --git a/themes/theme-next/src/client/components/VPBadge.vue b/themes/theme-next/src/client/components/VPBadge.vue new file mode 100644 index 0000000000..0d94d7c957 --- /dev/null +++ b/themes/theme-next/src/client/components/VPBadge.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPButton.vue b/themes/theme-next/src/client/components/VPButton.vue new file mode 100644 index 0000000000..59d86fccb2 --- /dev/null +++ b/themes/theme-next/src/client/components/VPButton.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPCarbonAds.vue b/themes/theme-next/src/client/components/VPCarbonAds.vue new file mode 100644 index 0000000000..ee74dbfc3f --- /dev/null +++ b/themes/theme-next/src/client/components/VPCarbonAds.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPContent.vue b/themes/theme-next/src/client/components/VPContent.vue new file mode 100644 index 0000000000..4e5920e0fa --- /dev/null +++ b/themes/theme-next/src/client/components/VPContent.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPDoc.vue b/themes/theme-next/src/client/components/VPDoc.vue new file mode 100644 index 0000000000..f4e54124ce --- /dev/null +++ b/themes/theme-next/src/client/components/VPDoc.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPDocAside.vue b/themes/theme-next/src/client/components/VPDocAside.vue new file mode 100644 index 0000000000..8a62831c87 --- /dev/null +++ b/themes/theme-next/src/client/components/VPDocAside.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPDocAsideCarbonAds.vue b/themes/theme-next/src/client/components/VPDocAsideCarbonAds.vue new file mode 100644 index 0000000000..4639bbe169 --- /dev/null +++ b/themes/theme-next/src/client/components/VPDocAsideCarbonAds.vue @@ -0,0 +1,24 @@ + + + diff --git a/themes/theme-next/src/client/components/VPDocAsideOutline.vue b/themes/theme-next/src/client/components/VPDocAsideOutline.vue new file mode 100644 index 0000000000..b8cf35b820 --- /dev/null +++ b/themes/theme-next/src/client/components/VPDocAsideOutline.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPDocAsideSponsors.vue b/themes/theme-next/src/client/components/VPDocAsideSponsors.vue new file mode 100644 index 0000000000..1abc433241 --- /dev/null +++ b/themes/theme-next/src/client/components/VPDocAsideSponsors.vue @@ -0,0 +1,25 @@ + + + diff --git a/themes/theme-next/src/client/components/VPDocFooter.vue b/themes/theme-next/src/client/components/VPDocFooter.vue new file mode 100644 index 0000000000..bc0deee1fc --- /dev/null +++ b/themes/theme-next/src/client/components/VPDocFooter.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPDocFooterContributors.vue b/themes/theme-next/src/client/components/VPDocFooterContributors.vue new file mode 100644 index 0000000000..648d0a0c5c --- /dev/null +++ b/themes/theme-next/src/client/components/VPDocFooterContributors.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPDocFooterLastUpdated.vue b/themes/theme-next/src/client/components/VPDocFooterLastUpdated.vue new file mode 100644 index 0000000000..8aa2458e4c --- /dev/null +++ b/themes/theme-next/src/client/components/VPDocFooterLastUpdated.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPDocOutlineItem.vue b/themes/theme-next/src/client/components/VPDocOutlineItem.vue new file mode 100644 index 0000000000..2437d3a6e8 --- /dev/null +++ b/themes/theme-next/src/client/components/VPDocOutlineItem.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPFeature.vue b/themes/theme-next/src/client/components/VPFeature.vue new file mode 100644 index 0000000000..1c1d029112 --- /dev/null +++ b/themes/theme-next/src/client/components/VPFeature.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPFeatures.vue b/themes/theme-next/src/client/components/VPFeatures.vue new file mode 100644 index 0000000000..e0dd93b493 --- /dev/null +++ b/themes/theme-next/src/client/components/VPFeatures.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPFlyout.vue b/themes/theme-next/src/client/components/VPFlyout.vue new file mode 100644 index 0000000000..1884c86e07 --- /dev/null +++ b/themes/theme-next/src/client/components/VPFlyout.vue @@ -0,0 +1,162 @@ + + + + + + diff --git a/themes/theme-next/src/client/components/VPFooter.vue b/themes/theme-next/src/client/components/VPFooter.vue new file mode 100644 index 0000000000..5ad8bddd26 --- /dev/null +++ b/themes/theme-next/src/client/components/VPFooter.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPHero.vue b/themes/theme-next/src/client/components/VPHero.vue new file mode 100644 index 0000000000..0b4c1e16a9 --- /dev/null +++ b/themes/theme-next/src/client/components/VPHero.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPHome.vue b/themes/theme-next/src/client/components/VPHome.vue new file mode 100644 index 0000000000..3e93ee649f --- /dev/null +++ b/themes/theme-next/src/client/components/VPHome.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPHomeContent.vue b/themes/theme-next/src/client/components/VPHomeContent.vue new file mode 100644 index 0000000000..30b4b82933 --- /dev/null +++ b/themes/theme-next/src/client/components/VPHomeContent.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPHomeFeatures.vue b/themes/theme-next/src/client/components/VPHomeFeatures.vue new file mode 100644 index 0000000000..0319d80f04 --- /dev/null +++ b/themes/theme-next/src/client/components/VPHomeFeatures.vue @@ -0,0 +1,15 @@ + + + diff --git a/themes/theme-next/src/client/components/VPHomeHero.vue b/themes/theme-next/src/client/components/VPHomeHero.vue new file mode 100644 index 0000000000..972d53f6f1 --- /dev/null +++ b/themes/theme-next/src/client/components/VPHomeHero.vue @@ -0,0 +1,41 @@ + + + diff --git a/themes/theme-next/src/client/components/VPHomeSponsors.vue b/themes/theme-next/src/client/components/VPHomeSponsors.vue new file mode 100644 index 0000000000..a49d4f90f7 --- /dev/null +++ b/themes/theme-next/src/client/components/VPHomeSponsors.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPImage.vue b/themes/theme-next/src/client/components/VPImage.vue new file mode 100644 index 0000000000..a8e8c26384 --- /dev/null +++ b/themes/theme-next/src/client/components/VPImage.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPLink.vue b/themes/theme-next/src/client/components/VPLink.vue new file mode 100644 index 0000000000..6128c8733c --- /dev/null +++ b/themes/theme-next/src/client/components/VPLink.vue @@ -0,0 +1,71 @@ + + + diff --git a/themes/theme-next/src/client/components/VPLocalNav.vue b/themes/theme-next/src/client/components/VPLocalNav.vue new file mode 100644 index 0000000000..c23810211f --- /dev/null +++ b/themes/theme-next/src/client/components/VPLocalNav.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPLocalNavOutlineDropdown.vue b/themes/theme-next/src/client/components/VPLocalNavOutlineDropdown.vue new file mode 100644 index 0000000000..dc4e77e8a9 --- /dev/null +++ b/themes/theme-next/src/client/components/VPLocalNavOutlineDropdown.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPMarkdownContent.ts b/themes/theme-next/src/client/components/VPMarkdownContent.ts new file mode 100644 index 0000000000..840398026d --- /dev/null +++ b/themes/theme-next/src/client/components/VPMarkdownContent.ts @@ -0,0 +1,40 @@ +import { computed, defineAsyncComponent, defineComponent, h } from 'vue' +import { resolveRoute, usePageComponent } from 'vuepress/client' +import { runCallbacks } from '../composables/index.js' + +/** + * Markdown rendered content + */ +export const Content = defineComponent({ + name: 'VPMarkdownContent', + + props: { + path: { + type: String, + required: false, + default: '', + }, + }, + + setup(props) { + const pageComponent = usePageComponent() + const ContentComponent = computed(() => { + if (!props.path) return pageComponent.value + const route = resolveRoute(props.path) + return defineAsyncComponent(() => route.loader().then(({ comp }) => comp)) + }) + + return () => + h(ContentComponent.value, { + onVnodeMounted: () => { + runCallbacks({ mounted: true }) + }, + onVnodeUpdated: () => { + runCallbacks({ updated: true }) + }, + onVnodeBeforeUnmount: () => { + runCallbacks({ beforeUnmount: true }) + }, + }) + }, +}) diff --git a/themes/theme-next/src/client/components/VPMenu.vue b/themes/theme-next/src/client/components/VPMenu.vue new file mode 100644 index 0000000000..3e6b00d178 --- /dev/null +++ b/themes/theme-next/src/client/components/VPMenu.vue @@ -0,0 +1,85 @@ + + + + + + diff --git a/themes/theme-next/src/client/components/VPMenuGroup.vue b/themes/theme-next/src/client/components/VPMenuGroup.vue new file mode 100644 index 0000000000..d1f0f1c1db --- /dev/null +++ b/themes/theme-next/src/client/components/VPMenuGroup.vue @@ -0,0 +1,57 @@ + + + + + + diff --git a/themes/theme-next/src/client/components/VPMenuLink.vue b/themes/theme-next/src/client/components/VPMenuLink.vue new file mode 100644 index 0000000000..40d3394e84 --- /dev/null +++ b/themes/theme-next/src/client/components/VPMenuLink.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPNav.vue b/themes/theme-next/src/client/components/VPNav.vue new file mode 100644 index 0000000000..f0a4603cd2 --- /dev/null +++ b/themes/theme-next/src/client/components/VPNav.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPNavBar.vue b/themes/theme-next/src/client/components/VPNavBar.vue new file mode 100644 index 0000000000..2f163efa5d --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavBar.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPNavBarAppearance.vue b/themes/theme-next/src/client/components/VPNavBarAppearance.vue new file mode 100644 index 0000000000..acd3332263 --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavBarAppearance.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPNavBarExtra.vue b/themes/theme-next/src/client/components/VPNavBarExtra.vue new file mode 100644 index 0000000000..8a5799c123 --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavBarExtra.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPNavBarHamburger.vue b/themes/theme-next/src/client/components/VPNavBarHamburger.vue new file mode 100644 index 0000000000..6800acd993 --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavBarHamburger.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPNavBarMenu.vue b/themes/theme-next/src/client/components/VPNavBarMenu.vue new file mode 100644 index 0000000000..d67122cd44 --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavBarMenu.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPNavBarMenuGroup.vue b/themes/theme-next/src/client/components/VPNavBarMenuGroup.vue new file mode 100644 index 0000000000..2d1e521976 --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavBarMenuGroup.vue @@ -0,0 +1,49 @@ + + + diff --git a/themes/theme-next/src/client/components/VPNavBarMenuLink.vue b/themes/theme-next/src/client/components/VPNavBarMenuLink.vue new file mode 100644 index 0000000000..ec7ed0721e --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavBarMenuLink.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPNavBarSearch.vue b/themes/theme-next/src/client/components/VPNavBarSearch.vue new file mode 100644 index 0000000000..b3d6fd1ce2 --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavBarSearch.vue @@ -0,0 +1,25 @@ + + + diff --git a/themes/theme-next/src/client/components/VPNavBarSocialLinks.vue b/themes/theme-next/src/client/components/VPNavBarSocialLinks.vue new file mode 100644 index 0000000000..d62642dfd2 --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavBarSocialLinks.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPNavBarTitle.vue b/themes/theme-next/src/client/components/VPNavBarTitle.vue new file mode 100644 index 0000000000..609998919c --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavBarTitle.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPNavBarTranslations.vue b/themes/theme-next/src/client/components/VPNavBarTranslations.vue new file mode 100644 index 0000000000..9398f50d37 --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavBarTranslations.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPNavScreen.vue b/themes/theme-next/src/client/components/VPNavScreen.vue new file mode 100644 index 0000000000..68338a9303 --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavScreen.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPNavScreenAppearance.vue b/themes/theme-next/src/client/components/VPNavScreenAppearance.vue new file mode 100644 index 0000000000..33eb0ac54a --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavScreenAppearance.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPNavScreenMenu.vue b/themes/theme-next/src/client/components/VPNavScreenMenu.vue new file mode 100644 index 0000000000..9171a656ec --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavScreenMenu.vue @@ -0,0 +1,20 @@ + + + diff --git a/themes/theme-next/src/client/components/VPNavScreenMenuGroup.vue b/themes/theme-next/src/client/components/VPNavScreenMenuGroup.vue new file mode 100644 index 0000000000..a3d1205e0f --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavScreenMenuGroup.vue @@ -0,0 +1,120 @@ + + + + + + diff --git a/themes/theme-next/src/client/components/VPNavScreenMenuGroupLink.vue b/themes/theme-next/src/client/components/VPNavScreenMenuGroupLink.vue new file mode 100644 index 0000000000..480990237a --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavScreenMenuGroupLink.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPNavScreenMenuGroupSection.vue b/themes/theme-next/src/client/components/VPNavScreenMenuGroupSection.vue new file mode 100644 index 0000000000..4701c03826 --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavScreenMenuGroupSection.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPNavScreenMenuLink.vue b/themes/theme-next/src/client/components/VPNavScreenMenuLink.vue new file mode 100644 index 0000000000..8fa5e4fab4 --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavScreenMenuLink.vue @@ -0,0 +1,49 @@ + + + + + + diff --git a/themes/theme-next/src/client/components/VPNavScreenSocialLinks.vue b/themes/theme-next/src/client/components/VPNavScreenSocialLinks.vue new file mode 100644 index 0000000000..a3d654fd59 --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavScreenSocialLinks.vue @@ -0,0 +1,14 @@ + + + diff --git a/themes/theme-next/src/client/components/VPNavScreenTranslations.vue b/themes/theme-next/src/client/components/VPNavScreenTranslations.vue new file mode 100644 index 0000000000..41e1e2b5d6 --- /dev/null +++ b/themes/theme-next/src/client/components/VPNavScreenTranslations.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPPage.vue b/themes/theme-next/src/client/components/VPPage.vue new file mode 100644 index 0000000000..9058dd4c2f --- /dev/null +++ b/themes/theme-next/src/client/components/VPPage.vue @@ -0,0 +1,7 @@ + diff --git a/themes/theme-next/src/client/components/VPSidebar.vue b/themes/theme-next/src/client/components/VPSidebar.vue new file mode 100644 index 0000000000..6fd0cf8eea --- /dev/null +++ b/themes/theme-next/src/client/components/VPSidebar.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPSidebarItem.vue b/themes/theme-next/src/client/components/VPSidebarItem.vue new file mode 100644 index 0000000000..c691e1f7cc --- /dev/null +++ b/themes/theme-next/src/client/components/VPSidebarItem.vue @@ -0,0 +1,266 @@ + + + + + + diff --git a/themes/theme-next/src/client/components/VPSkipLink.vue b/themes/theme-next/src/client/components/VPSkipLink.vue new file mode 100644 index 0000000000..422cfe4df1 --- /dev/null +++ b/themes/theme-next/src/client/components/VPSkipLink.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPSocialLink.vue b/themes/theme-next/src/client/components/VPSocialLink.vue new file mode 100644 index 0000000000..e0f63cca32 --- /dev/null +++ b/themes/theme-next/src/client/components/VPSocialLink.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPSocialLinks.vue b/themes/theme-next/src/client/components/VPSocialLinks.vue new file mode 100644 index 0000000000..b5c17380eb --- /dev/null +++ b/themes/theme-next/src/client/components/VPSocialLinks.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPSponsors.vue b/themes/theme-next/src/client/components/VPSponsors.vue new file mode 100644 index 0000000000..a5c23ed857 --- /dev/null +++ b/themes/theme-next/src/client/components/VPSponsors.vue @@ -0,0 +1,55 @@ + + + diff --git a/themes/theme-next/src/client/components/VPSponsorsGrid.vue b/themes/theme-next/src/client/components/VPSponsorsGrid.vue new file mode 100644 index 0000000000..d4ae6a458f --- /dev/null +++ b/themes/theme-next/src/client/components/VPSponsorsGrid.vue @@ -0,0 +1,51 @@ + + + diff --git a/themes/theme-next/src/client/components/VPSwitch.vue b/themes/theme-next/src/client/components/VPSwitch.vue new file mode 100644 index 0000000000..a7521897e5 --- /dev/null +++ b/themes/theme-next/src/client/components/VPSwitch.vue @@ -0,0 +1,76 @@ + + + diff --git a/themes/theme-next/src/client/components/VPSwitchAppearance.vue b/themes/theme-next/src/client/components/VPSwitchAppearance.vue new file mode 100644 index 0000000000..698737af7a --- /dev/null +++ b/themes/theme-next/src/client/components/VPSwitchAppearance.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPTeamMembers.vue b/themes/theme-next/src/client/components/VPTeamMembers.vue new file mode 100644 index 0000000000..700e03fa3b --- /dev/null +++ b/themes/theme-next/src/client/components/VPTeamMembers.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPTeamMembersItem.vue b/themes/theme-next/src/client/components/VPTeamMembersItem.vue new file mode 100644 index 0000000000..edcae08b97 --- /dev/null +++ b/themes/theme-next/src/client/components/VPTeamMembersItem.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/themes/theme-next/src/client/components/VPTeamPage.vue b/themes/theme-next/src/client/components/VPTeamPage.vue new file mode 100644 index 0000000000..e588c001f5 --- /dev/null +++ b/themes/theme-next/src/client/components/VPTeamPage.vue @@ -0,0 +1,58 @@ + + + diff --git a/themes/theme-next/src/client/components/VPTeamPageSection.vue b/themes/theme-next/src/client/components/VPTeamPageSection.vue new file mode 100644 index 0000000000..06e16fe5d1 --- /dev/null +++ b/themes/theme-next/src/client/components/VPTeamPageSection.vue @@ -0,0 +1,88 @@ + + + diff --git a/themes/theme-next/src/client/components/VPTeamPageTitle.vue b/themes/theme-next/src/client/components/VPTeamPageTitle.vue new file mode 100644 index 0000000000..bc361e562a --- /dev/null +++ b/themes/theme-next/src/client/components/VPTeamPageTitle.vue @@ -0,0 +1,65 @@ + + + diff --git a/themes/theme-next/src/client/composables/aside.ts b/themes/theme-next/src/client/composables/aside.ts new file mode 100644 index 0000000000..d0c6e18206 --- /dev/null +++ b/themes/theme-next/src/client/composables/aside.ts @@ -0,0 +1,24 @@ +import { useMediaQuery } from '@vueuse/core' +import type { ComputedRef } from 'vue' +import { computed } from 'vue' +import { useSidebar } from './sidebar.js' + +export const useAside = (): { + isAsideEnabled: ComputedRef +} => { + const { hasSidebar } = useSidebar() + const is960 = useMediaQuery('(min-width: 960px)') + const is1280 = useMediaQuery('(min-width: 1280px)') + + const isAsideEnabled = computed(() => { + if (!is1280.value && !is960.value) { + return false + } + + return hasSidebar.value ? is1280.value : is960.value + }) + + return { + isAsideEnabled, + } +} diff --git a/themes/theme-next/src/client/composables/content-update.ts b/themes/theme-next/src/client/composables/content-update.ts new file mode 100644 index 0000000000..eb64606b27 --- /dev/null +++ b/themes/theme-next/src/client/composables/content-update.ts @@ -0,0 +1,25 @@ +import { onUnmounted } from 'vue' + +export interface ContentUpdated { + mounted?: boolean + updated?: boolean + beforeUnmount?: boolean +} + +let contentUpdatedCallbacks: ((lifeCircleType: ContentUpdated) => unknown)[] = + [] + +/** + * Register callback that is called every time the markdown content is updated + * in the DOM. + */ +export const onContentUpdated = (fn: () => unknown): void => { + contentUpdatedCallbacks.push(fn) + onUnmounted(() => { + contentUpdatedCallbacks = contentUpdatedCallbacks.filter((f) => f !== fn) + }) +} + +export const runCallbacks = (lifeCircleType: ContentUpdated): void => { + contentUpdatedCallbacks.forEach((fn) => fn(lifeCircleType)) +} diff --git a/themes/theme-next/src/client/composables/dark-mode.ts b/themes/theme-next/src/client/composables/dark-mode.ts new file mode 100644 index 0000000000..bcd285c819 --- /dev/null +++ b/themes/theme-next/src/client/composables/dark-mode.ts @@ -0,0 +1,43 @@ +import { useDark } from '@vueuse/core' +import type { InjectionKey, Ref } from 'vue' +import { inject, provide, ref } from 'vue' +import { useThemeData } from './theme-data.js' + +type DarkModeRef = Ref + +export const darkModeSymbol: InjectionKey = Symbol( + __VUEPRESS_DEV__ ? 'darkMode' : '', +) + +export const setupDarkMode = (): void => { + const themeLocale = useThemeData() + + const { appearance } = themeLocale.value + const isDark = + appearance === 'force-dark' + ? ref(true) + : appearance + ? useDark({ + storageKey: 'vuepress-color-scheme', + attribute: 'data-theme', + valueLight: 'light', + valueDark: 'dark', + initialValue: () => + typeof appearance === 'string' ? appearance : 'auto', + ...(typeof appearance === 'object' ? appearance : {}), + }) + : ref(false) + + provide(darkModeSymbol, isDark) +} + +/** + * Inject dark mode global computed + */ +export const useDarkMode = (): DarkModeRef => { + const isDarkMode = inject(darkModeSymbol) + if (!isDarkMode) { + throw new Error('useDarkMode() is called without provider.') + } + return isDarkMode +} diff --git a/themes/theme-next/src/client/composables/data.ts b/themes/theme-next/src/client/composables/data.ts new file mode 100644 index 0000000000..b07d6cd111 --- /dev/null +++ b/themes/theme-next/src/client/composables/data.ts @@ -0,0 +1,21 @@ +import type { ThemeLocaleDataRef } from '@vuepress/plugin-theme-data/client' +import type { PageDataRef, PageFrontmatterRef } from 'vuepress/client' +import { usePageData, usePageFrontmatter } from 'vuepress/client' +import type { + DefaultThemeData, + DefaultThemeNormalPageFrontmatter, + DefaultThemePageData, +} from '../../shared/index.js' +import { useThemeLocaleData } from './theme-data.js' + +export const useData = (): { + page: PageDataRef + frontmatter: PageFrontmatterRef + theme: ThemeLocaleDataRef +} => { + const page = usePageData() + const frontmatter = usePageFrontmatter() + const theme = useThemeLocaleData() + + return { page, frontmatter, theme } +} diff --git a/themes/theme-next/src/client/composables/flyout.ts b/themes/theme-next/src/client/composables/flyout.ts new file mode 100644 index 0000000000..d51c155ffb --- /dev/null +++ b/themes/theme-next/src/client/composables/flyout.ts @@ -0,0 +1,60 @@ +import type { Ref } from 'vue' +import { onUnmounted, readonly, ref, watch } from 'vue' +import { inBrowser } from '../utils/index.js' + +interface UseFlyoutOptions { + el: Ref + onFocus?: () => void + onBlur?: () => void +} + +export const focusedElement = ref() + +let active = false +let listeners = 0 + +const handleFocusIn = (): void => { + focusedElement.value = document.activeElement as HTMLElement +} + +const activateFocusTracking = (): void => { + document.addEventListener('focusin', handleFocusIn) + active = true + focusedElement.value = document.activeElement as HTMLElement +} + +const deactivateFocusTracking = (): void => { + document.removeEventListener('focusin', handleFocusIn) +} + +export const useFlyout = ( + options: UseFlyoutOptions, +): Readonly> => { + const focus = ref(false) + + if (inBrowser) { + if (!active) activateFocusTracking() + + listeners++ + + const unwatch = watch(focusedElement, (el) => { + if (el === options.el.value || options.el.value?.contains(el!)) { + focus.value = true + options.onFocus?.() + } else { + focus.value = false + options.onBlur?.() + } + }) + + onUnmounted(() => { + unwatch() + + listeners-- + + if (!listeners) deactivateFocusTracking() + }) + } + + return readonly(focus) +} diff --git a/themes/theme-next/src/client/composables/index.ts b/themes/theme-next/src/client/composables/index.ts new file mode 100644 index 0000000000..4588f7325c --- /dev/null +++ b/themes/theme-next/src/client/composables/index.ts @@ -0,0 +1,18 @@ +export * from './data.js' +export * from './theme-data.js' + +export * from './dark-mode.js' + +export * from './scroll-promise.js' +export * from './content-update.js' + +export * from './aside.js' +export * from './sidebar.js' +export * from './flyout.js' +export * from './nav.js' +export * from './langs.js' + +export * from './prev-next.js' +export * from './last-updated.js' + +export * from './sponsor-grid.js' diff --git a/themes/theme-next/src/client/composables/langs.ts b/themes/theme-next/src/client/composables/langs.ts new file mode 100644 index 0000000000..e94d2d3bd6 --- /dev/null +++ b/themes/theme-next/src/client/composables/langs.ts @@ -0,0 +1,46 @@ +import type { ComputedRef, Ref } from 'vue' +import { computed } from 'vue' +import { useRoute, useRouteLocale, useSiteData } from 'vuepress/client' +import { useThemeData } from './theme-data.js' + +export const useLangs = ({ removeCurrent = true } = {}): { + currentLang: Ref<{ label: string; link: string }> + localeLinks: ComputedRef<{ text: string; link: string }[]> +} => { + const site = useSiteData() + const theme = useThemeData() + const routeLocale = useRouteLocale() + const route = useRoute() + + const currentLang = computed(() => { + const link = routeLocale.value + return { + label: + theme.value.locales?.[link]?.selectLanguageName || + site.value.locales[link].lang || + '', + link, + } + }) + + const localeLinks = computed(() => + Object.keys(site.value.locales).flatMap((localePath) => { + const locale = theme.value.locales?.[localePath] + return removeCurrent && + currentLang.value.label === locale?.selectLanguageName + ? [] + : { + text: + locale?.selectLanguageName || + site.value.locales[localePath].lang || + '', + link: + route.path.replace(routeLocale.value, localePath) + route.hash, + } + }), + ) + return { + currentLang, + localeLinks, + } +} diff --git a/themes/theme-next/src/client/composables/last-updated.ts b/themes/theme-next/src/client/composables/last-updated.ts new file mode 100644 index 0000000000..83a4b3e43e --- /dev/null +++ b/themes/theme-next/src/client/composables/last-updated.ts @@ -0,0 +1,55 @@ +import type { ComputedRef, Ref } from 'vue' +import { computed, onMounted, ref, watchEffect } from 'vue' +import { usePageLang } from 'vuepress/client' +import { useData } from './data.js' + +export const useLastUpdated = (): { + datetime: Ref + lastUpdatedText: ComputedRef + isoDatetime: ComputedRef +} => { + const { theme, page, frontmatter } = useData() + const lang = usePageLang() + + const date = computed(() => + page.value.git?.updatedTime ? new Date(page.value.git.updatedTime) : null, + ) + const isoDatetime = computed(() => date.value?.toISOString()) + + const datetime = ref('') + + const lastUpdatedText = computed(() => { + if (theme.value.lastUpdated === false) return + return theme.value.lastUpdatedText || 'Last updated' + }) + + // set time on mounted hook to avoid hydration mismatch due to + // potential differences in timezones of the server and clients + onMounted(() => { + watchEffect(() => { + if ( + frontmatter.value.lastUpdated === false || + theme.value.lastUpdated === false + ) + return + + datetime.value = date.value + ? new Intl.DateTimeFormat( + theme.value.lastUpdatedFormatOptions?.forceLocale + ? lang.value + : undefined, + theme.value.lastUpdatedFormatOptions ?? { + dateStyle: 'short', + timeStyle: 'short', + }, + ).format(date.value) + : '' + }) + }) + + return { + isoDatetime, + datetime, + lastUpdatedText, + } +} diff --git a/themes/theme-next/src/client/composables/local-nav.ts b/themes/theme-next/src/client/composables/local-nav.ts new file mode 100644 index 0000000000..a44a07c3f1 --- /dev/null +++ b/themes/theme-next/src/client/composables/local-nav.ts @@ -0,0 +1,40 @@ +import type { ComputedRef, ShallowRef } from 'vue' +import { computed, shallowRef } from 'vue' +import { onContentUpdated } from '../composables/content-update.js' +import type { MenuItem } from '../composables/outline.js' +import { getHeaders } from '../composables/outline.js' +import { useData } from './data.js' + +/** + * ReturnType of `useLocalNav`. + */ +export interface DocLocalNav { + /** + * The outline headers of the current page. + */ + headers: ShallowRef + + /** + * Whether the current page has a local nav. Local nav is shown when the + * "outline" is present in the page. However, note that the actual + * local nav visibility depends on the screen width as well. + */ + hasLocalNav: ComputedRef +} + +export const useLocalNav = (): DocLocalNav => { + const { theme, frontmatter } = useData() + + const headers = shallowRef([]) + + const hasLocalNav = computed(() => headers.value.length > 0) + + onContentUpdated(() => { + headers.value = getHeaders(frontmatter.value.outline ?? theme.value.outline) + }) + + return { + headers, + hasLocalNav, + } +} diff --git a/themes/theme-next/src/client/composables/nav.ts b/themes/theme-next/src/client/composables/nav.ts new file mode 100644 index 0000000000..64abebd45c --- /dev/null +++ b/themes/theme-next/src/client/composables/nav.ts @@ -0,0 +1,88 @@ +import type { Ref } from 'vue' +import { computed, ref, watch } from 'vue' +import { useRoute } from 'vuepress/client' +import type { NavItem } from '../../shared/index.js' +import type { + ResolvedNavItem, + ResolvedNavItemWithLink, +} from '../../shared/resolved/navbar.js' +import { getNavLink, normalizeLink } from '../utils/index.js' +import { useData } from './data.js' + +const resolveNavbar = (navbar: NavItem[], _prefix = ''): ResolvedNavItem[] => { + const resolved: ResolvedNavItem[] = [] + navbar.forEach((item) => { + if (typeof item === 'string') { + resolved.push(getNavLink(normalizeLink(_prefix, item))) + } else { + // eslint-disable-next-line @typescript-eslint/no-deprecated + const { items, children, prefix, ...args } = item + const list = items?.length ? items : children + const res = { ...args } as ResolvedNavItem + if ('link' in res) { + res.link = normalizeLink(_prefix, res.link) + } + if (list?.length) { + res.items = resolveNavbar( + list, + normalizeLink(_prefix, prefix), + ) as ResolvedNavItemWithLink[] + } + resolved.push(res) + } + }) + return resolved +} + +export const useNavbarData = (): Ref => { + const { theme } = useData() + + return computed(() => resolveNavbar(theme.value.navbar ?? [])) +} + +export interface UseNavReturn { + isScreenOpen: Ref + openScreen: () => void + closeScreen: () => void + toggleScreen: () => void +} + +export const useNav = (): UseNavReturn => { + const isScreenOpen = ref(false) + + /** + * Close screen when the user resizes the window wider than tablet size. + */ + const closeScreenOnTabletWindow = (): void => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + if (window.outerWidth >= 768) closeScreen() + } + + const openScreen = (): void => { + isScreenOpen.value = true + window.addEventListener('resize', closeScreenOnTabletWindow) + } + + const closeScreen = (): void => { + isScreenOpen.value = false + window.removeEventListener('resize', closeScreenOnTabletWindow) + } + + const toggleScreen = (): void => { + if (isScreenOpen.value) { + closeScreen() + } else { + openScreen() + } + } + + const route = useRoute() + watch(() => route.path, closeScreen) + + return { + isScreenOpen, + openScreen, + closeScreen, + toggleScreen, + } +} diff --git a/themes/theme-next/src/client/composables/outline.ts b/themes/theme-next/src/client/composables/outline.ts new file mode 100644 index 0000000000..d99c3359db --- /dev/null +++ b/themes/theme-next/src/client/composables/outline.ts @@ -0,0 +1,266 @@ +/** + * Question: Why not use page.headers ? + * Answer: In the new theme, explicit header levels can be configured through the outline, + * but VuePress directly globally configures them in Markdown, which leads to conflicts. + * To achieve page-level configuration, the solution of parsing the DOM was chosen. + */ +import type { Ref } from 'vue' +import { onMounted, onUnmounted, onUpdated } from 'vue' +import type { DefaultThemeLocaleData } from '../../shared/index.js' +import { getScrollOffset, throttleAndDebounce } from '../utils/index.js' +import { useAside } from './aside.js' +import { useData } from './data.js' + +export interface Header { + /** + * The level of the header + * + * `1` to `6` for `

` to `

` + */ + level: number + /** + * The title of the header + */ + title: string + /** + * The slug of the header + * + * Typically the `id` attr of the header anchor + */ + slug: string + /** + * Link of the header + * + * Typically using `#${slug}` as the anchor hash + */ + link: string + /** + * The children of the header + */ + children: Header[] +} + +// cached list of anchor elements from resolveHeaders +const resolvedHeaders: { element: HTMLHeadElement; link: string }[] = [] + +export type MenuItem = Omit & { + element: HTMLHeadElement + children?: MenuItem[] +} + +const serializeHeader = (h: Element): string => { + // title + const anchor = h.firstChild + const el = anchor?.firstChild + let ret = '' + for (const node of Array.from(el?.childNodes ?? [])) { + if (node.nodeType === 1) { + if ( + (node as Element).classList.contains('vp-badge') || + (node as Element).classList.contains('ignore-header') + ) { + continue + } + ret += node.textContent || '' + } else if (node.nodeType === 3) { + ret += node.textContent || '' + } + } + // maybe `` or more + let next = anchor?.nextSibling + while (next) { + if (next.nodeType === 1 || next.nodeType === 3) { + ret += next.textContent || '' + } + next = next.nextSibling + } + return ret.trim() +} + +export const resolveTitle = (theme: DefaultThemeLocaleData): string => + theme.outlineTitle || 'On this page' + +export const resolveHeaders = ( + headers: MenuItem[], + range?: DefaultThemeLocaleData['outline'], +): MenuItem[] => { + if (range === false) { + return [] + } + + const levelsRange = range || 2 + + const [high, low]: [number, number] = + typeof levelsRange === 'number' + ? [levelsRange, levelsRange] + : levelsRange === 'deep' + ? [2, 6] + : levelsRange + + // eslint-disable-next-line no-param-reassign + headers = headers.filter((h) => h.level >= high && h.level <= low) + // clear previous caches + resolvedHeaders.length = 0 + // update global header list for active link rendering + for (const { element, link } of headers) { + resolvedHeaders.push({ element, link }) + } + + const ret: MenuItem[] = [] + + // eslint-disable-next-line no-restricted-syntax + outer: for (let i = 0; i < headers.length; i++) { + const cur = headers[i] + if (i === 0) { + ret.push(cur) + } else { + for (let j = i - 1; j >= 0; j--) { + const prev = headers[j] + if (prev.level < cur.level) { + if (!prev.children) prev.children = [] + prev.children.push(cur) + + continue outer + } + } + ret.push(cur) + } + } + + return ret +} + +export const getHeaders = ( + range: DefaultThemeLocaleData['outline'], +): MenuItem[] => { + const headers = Array.from( + document.querySelectorAll('.vp-doc-container :where(h1,h2,h3,h4,h5,h6)'), + ) + .filter((el) => el.id && el.hasChildNodes()) + .map((el) => { + const level = Number(el.tagName[1]) + return { + element: el as HTMLHeadElement, + title: serializeHeader(el), + link: `#${el.id}`, + level, + } + }) + return resolveHeaders(headers, range) +} + +const getAbsoluteTop = (element: HTMLElement | null): number => { + let offsetTop = 0 + let el = element + while (el !== document.body) { + if (el === null) { + // child element is: + // - not attached to the DOM (display: none) + // - set to fixed position (not scrollable) + // - body or html element (null offsetParent) + return NaN + } + offsetTop += el.offsetTop + el = el.offsetParent as HTMLElement + } + return offsetTop +} + +export const useActiveAnchor = ( + container: Ref, + marker: Ref, +): void => { + const { isAsideEnabled } = useAside() + const { theme } = useData() + + let prevActiveLink: HTMLAnchorElement | null = null + + const activateLink = (hash: string | null): void => { + if (prevActiveLink) { + prevActiveLink.classList.remove('active') + } + + if (hash == null) { + prevActiveLink = null + } else { + prevActiveLink = container.value!.querySelector( + `a[href="${decodeURIComponent(hash)}"]`, + ) + } + + const activeLink = prevActiveLink + + if (activeLink) { + activeLink.classList.add('active') + marker.value!.style.top = `${activeLink.offsetTop + 39}px` + marker.value!.style.opacity = '1' + } else { + marker.value!.style.top = '33px' + marker.value!.style.opacity = '0' + } + } + + const setActiveLink = (): void => { + if (!isAsideEnabled.value) { + return + } + + const { scrollY } = window + const { innerHeight } = window + const { offsetHeight } = document.body + const isBottom = Math.abs(scrollY + innerHeight - offsetHeight) < 1 + + // resolvedHeaders may be repositioned, hidden or fix positioned + const headers = resolvedHeaders + .map(({ element, link }) => ({ + link, + top: getAbsoluteTop(element), + })) + .filter(({ top }) => !Number.isNaN(top)) + .sort((a, b) => a.top - b.top) + + // no headers available for active link + if (!headers.length) { + activateLink(null) + return + } + + // page top + if (scrollY < 1) { + activateLink(null) + return + } + + // page bottom - highlight last link + if (isBottom) { + activateLink(headers[headers.length - 1].link) + return + } + + // find the last header above the top of viewport + let activeLink: string | null = null + for (const { link, top } of headers) { + if (top > scrollY + getScrollOffset(theme.value.scrollOffset) + 4) { + break + } + activeLink = link + } + activateLink(activeLink) + } + + const onScroll = throttleAndDebounce(setActiveLink, 100) + + onMounted(() => { + requestAnimationFrame(setActiveLink) + window.addEventListener('scroll', onScroll) + }) + + onUpdated(() => { + // sidebar update means a route change + activateLink(window.location.hash) + }) + + onUnmounted(() => { + window.removeEventListener('scroll', onScroll) + }) +} diff --git a/themes/theme-next/src/client/composables/prev-next.ts b/themes/theme-next/src/client/composables/prev-next.ts new file mode 100644 index 0000000000..7bf6aed48a --- /dev/null +++ b/themes/theme-next/src/client/composables/prev-next.ts @@ -0,0 +1,82 @@ +import type { ComputedRef } from 'vue' +import { computed } from 'vue' +import { resolveRouteFullPath } from 'vuepress/client' +import type { NavItemWithLink } from '../../shared/index.js' +import { isActive } from '../utils/index.js' +import { useData } from './data.js' +import { getFlatSideBarLinks, useSidebarData } from './sidebar.js' + +export interface PrevNext { + prev?: Partial + next?: Partial +} + +const uniqBy = (array: T[], keyFn: (item: T) => unknown): T[] => { + const seen = new Set() + return array.filter((item) => { + const k = keyFn(item) + return seen.has(k) ? false : seen.add(k) + }) +} + +export const usePrevNext = (): ComputedRef => { + const { theme, page, frontmatter } = useData() + const sidebar = useSidebarData() + + return computed(() => { + const links = getFlatSideBarLinks(sidebar.value) + + // ignore inner-page links with hashes + const candidates = uniqBy(links, (link) => link.link.replace(/[?#].*$/, '')) + + const index = candidates.findIndex((link) => + isActive(page.value.path, resolveRouteFullPath(link.link)), + ) + + const hidePrev = + (theme.value.docFooter?.prev === false && !frontmatter.value.prev) || + frontmatter.value.prev === false + + const hideNext = + (theme.value.docFooter?.next === false && !frontmatter.value.next) || + frontmatter.value.next === false + + return { + prev: hidePrev + ? undefined + : { + text: + (typeof frontmatter.value.prev === 'string' + ? frontmatter.value.prev + : typeof frontmatter.value.prev === 'object' + ? frontmatter.value.prev.text + : undefined) ?? + candidates[index - 1]?.docFooterText ?? + candidates[index - 1]?.text, + link: + (typeof frontmatter.value.prev === 'object' + ? frontmatter.value.prev.link + : undefined) ?? candidates[index - 1]?.link, + }, + next: hideNext + ? undefined + : { + text: + (typeof frontmatter.value.next === 'string' + ? frontmatter.value.next + : typeof frontmatter.value.next === 'object' + ? frontmatter.value.next.text + : undefined) ?? + candidates[index + 1]?.docFooterText ?? + candidates[index + 1]?.text, + link: + (typeof frontmatter.value.next === 'object' + ? frontmatter.value.next.link + : undefined) ?? candidates[index + 1]?.link, + }, + } as { + prev?: { text?: string; link?: string } + next?: { text?: string; link?: string } + } + }) +} diff --git a/themes/theme-next/src/client/composables/scroll-promise.ts b/themes/theme-next/src/client/composables/scroll-promise.ts new file mode 100644 index 0000000000..40fba4b1e5 --- /dev/null +++ b/themes/theme-next/src/client/composables/scroll-promise.ts @@ -0,0 +1,24 @@ +export interface ScrollPromise { + wait(): Promise | null + pending: () => void + resolve: () => void +} + +let promise: Promise | null = null +let promiseResolve: (() => void) | null = null + +const scrollPromise: ScrollPromise = { + wait: () => promise, + pending: () => { + promise = new Promise((resolve) => { + promiseResolve = resolve + }) + }, + resolve: () => { + promiseResolve?.() + promise = null + promiseResolve = null + }, +} + +export const useScrollPromise = (): ScrollPromise => scrollPromise diff --git a/themes/theme-next/src/client/composables/sidebar.ts b/themes/theme-next/src/client/composables/sidebar.ts new file mode 100644 index 0000000000..0749ce2d8f --- /dev/null +++ b/themes/theme-next/src/client/composables/sidebar.ts @@ -0,0 +1,419 @@ +import { sidebarData as structureSidebarDataRaw } from '@internal/sidebar' +import { + ensureLeadingSlash, + isArray, + isPlainObject, + isString, +} from '@vuepress/helper/client' +import { useMediaQuery } from '@vueuse/core' +import type { ComputedRef, InjectionKey, Ref } from 'vue' +import { + computed, + inject, + onMounted, + onUnmounted, + provide, + ref, + watch, + watchEffect, + watchPostEffect, +} from 'vue' +import { resolveRouteFullPath, useRoute, useRouteLocale } from 'vuepress/client' +import type { Sidebar, SidebarItem } from '../../shared/index.js' +import type { ResolvedSidebarItem } from '../../shared/resolved/sidebar.js' +import { + getNavLink, + isActive, + normalizeLink, + normalizePrefix, +} from '../utils/index.js' +import { useData } from './data.js' + +export type StructureSidebarDataRef = Ref> + +const structureSidebarData: StructureSidebarDataRef = ref( + structureSidebarDataRaw, +) + +const sidebarSymbol: InjectionKey> = Symbol( + __VUEPRESS_DEV__ ? 'sidebar' : '', +) + +export const setupSidebarData = (): void => { + const { theme, page, frontmatter } = useData() + const routeLocale = useRouteLocale() + + const hasSidebar = computed( + () => + frontmatter.value.sidebar !== false && + frontmatter.value.layout !== 'home' && + frontmatter.value.pageLayout !== 'home', + ) + + const sidebar = computed(() => + hasSidebar.value + ? // eslint-disable-next-line @typescript-eslint/no-use-before-define + getSidebar(theme.value.sidebar, page.value.path, routeLocale.value) + : [], + ) + + provide(sidebarSymbol, sidebar) +} + +export const useSidebarData = (): Ref => { + const sidebarData = inject(sidebarSymbol) + if (!sidebarData) { + throw new Error('useSidebarData() is called without provider.') + } + return sidebarData +} + +/** + * Check if the given sidebar item contains any active link. + */ +export const hasActiveLink = ( + path: string, + items: ResolvedSidebarItem | ResolvedSidebarItem[], +): boolean => { + if (Array.isArray(items)) { + return items.some((item) => hasActiveLink(path, item)) + } + + return isActive( + path, + items.link ? resolveRouteFullPath(items.link) : undefined, + ) + ? true + : items.items + ? hasActiveLink(path, items.items) + : false +} + +if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot)) { + __VUE_HMR_RUNTIME__.updateSidebarData = ( + data: Record, + ) => { + structureSidebarData.value = data + } +} + +export interface SidebarLink { + text: string + link: string + docFooterText?: string +} + +export interface SidebarControl { + collapsed: Ref + collapsible: ComputedRef + isLink: ComputedRef + isActiveLink: Ref + hasActiveLink: ComputedRef + hasChildren: ComputedRef + toggle: () => void +} + +export interface UseSidebarReturn { + isOpen: Ref + sidebar: Ref + sidebarGroups: Ref + hasSidebar: ComputedRef + hasAside: ComputedRef + leftAside: ComputedRef + isSidebarEnabled: ComputedRef + open: () => void + close: () => void + toggle: () => void +} + +const containsActiveLink = hasActiveLink + +export const useSidebar = (): UseSidebarReturn => { + const { theme, frontmatter } = useData() + const is960 = useMediaQuery('(min-width: 960px)') + + const isOpen = ref(false) + + const sidebar = useSidebarData() + + const hasSidebar = computed( + () => + frontmatter.value.sidebar !== false && + sidebar.value.length > 0 && + frontmatter.value.pageLayout !== 'home', + ) + + const hasAside = computed(() => { + if (frontmatter.value.pageLayout === 'home' || frontmatter.value.home) + return false + if (frontmatter.value.aside != null) return !!frontmatter.value.aside + return theme.value.aside !== false + }) + + const leftAside = computed(() => { + if (hasAside.value) + return frontmatter.value.aside == null + ? theme.value.aside === 'left' + : frontmatter.value.aside === 'left' + return false + }) + + const isSidebarEnabled = computed(() => hasSidebar.value && is960.value) + + const sidebarGroups = computed(() => + // eslint-disable-next-line @typescript-eslint/no-use-before-define + hasSidebar.value ? getSidebarGroups(sidebar.value) : [], + ) + + const open = (): void => { + isOpen.value = true + } + + const close = (): void => { + isOpen.value = false + } + + const toggle = (): void => { + if (isOpen.value) { + close() + } else { + open() + } + } + + return { + isOpen, + sidebar, + sidebarGroups, + hasSidebar, + hasAside, + leftAside, + isSidebarEnabled, + open, + close, + toggle, + } +} + +/** + * a11y: cache the element that opened the Sidebar (the menu button) then + * focus that button again when Menu is closed with Escape key. + */ +export const useCloseSidebarOnEscape = ( + isOpen: Ref, + close: () => void, +): void => { + let triggerElement: HTMLButtonElement | undefined + + watchEffect(() => { + triggerElement = isOpen.value + ? (document.activeElement as HTMLButtonElement) + : undefined + }) + + const onEscape = (e: KeyboardEvent): void => { + if (e.key === 'Escape' && isOpen.value) { + close() + triggerElement?.focus() + } + } + + onMounted(() => { + window.addEventListener('keyup', onEscape) + }) + + onUnmounted(() => { + window.removeEventListener('keyup', onEscape) + }) +} + +export const useSidebarControl = ( + item: ComputedRef, +): SidebarControl => { + const { page } = useData() + const route = useRoute() + + const collapsed = ref(false) + + const collapsible = computed(() => item.value.collapsed != null) + + const isLink = computed(() => !!item.value.link) + + const isActiveLink = ref(false) + const updateIsActiveLink = (): void => { + isActiveLink.value = isActive( + page.value.path, + item.value.link ? resolveRouteFullPath(item.value.link) : undefined, + ) + } + + watch([page, item, () => route.hash], updateIsActiveLink) + onMounted(updateIsActiveLink) + + // eslint-disable-next-line @typescript-eslint/no-shadow + const hasActiveLink = computed(() => { + if (isActiveLink.value) { + return true + } + + return item.value.items + ? containsActiveLink(page.value.path, item.value.items) + : false + }) + + const hasChildren = computed(() => !!item.value.items?.length) + + watchEffect(() => { + collapsed.value = !!(collapsible.value && item.value.collapsed) + }) + + watchPostEffect(() => { + if (isActiveLink.value || hasActiveLink.value) collapsed.value = false + }) + + const toggle = (): void => { + if (collapsible.value) { + collapsed.value = !collapsed.value + } + } + + return { + collapsed, + collapsible, + isLink, + isActiveLink, + hasActiveLink, + hasChildren, + toggle, + } +} + +const resolveSidebarItems = ( + sidebarItems: (SidebarItem | string)[], + _prefix = '', +): ResolvedSidebarItem[] => { + const resolved: ResolvedSidebarItem[] = [] + sidebarItems.forEach((item) => { + if (isString(item)) { + resolved.push(getNavLink(normalizeLink(_prefix, item))) + } else { + const { link, items, prefix, ...args } = item + const navLink = { ...args } as ResolvedSidebarItem + if (link) { + navLink.link = normalizeLink(_prefix, link) + } + const nextPrefix = normalizePrefix(_prefix, prefix) + if (items === 'structure') { + navLink.items = structureSidebarData.value[nextPrefix] + } else { + navLink.items = items?.length + ? resolveSidebarItems(items, nextPrefix) + : undefined + } + resolved.push(navLink) + } + }) + return resolved +} + +/** + * Get the `Sidebar` from sidebar option. This method will ensure to get correct + * sidebar config from `MultiSideBarConfig` with various path combinations such + * as matching `guide/` and `/guide/`. If no matching config was found, it will + * return empty array. + */ +export const getSidebar = ( + _sidebar: Sidebar | undefined, + routePath: string, + routeLocal: string, +): ResolvedSidebarItem[] => { + if (_sidebar === 'structure') { + return resolveSidebarItems(structureSidebarData.value[routeLocal]) + } + if (isArray(_sidebar)) { + return resolveSidebarItems(_sidebar, routeLocal) + } + if (isPlainObject(_sidebar)) { + const dir = + Object.keys(_sidebar) + .sort((a, b) => b.split('/').length - a.split('/').length) + // eslint-disable-next-line @typescript-eslint/no-shadow + .find((dir) => + // make sure the multi sidebar key starts with slash too + routePath.startsWith(ensureLeadingSlash(dir)), + ) || '' + const sidebar = dir ? _sidebar[dir] : undefined + + if (sidebar === 'structure') { + return resolveSidebarItems( + dir ? structureSidebarData.value[dir] : [], + routeLocal, + ) + } + if (isArray(sidebar)) { + return resolveSidebarItems(sidebar, dir) + } + if (isPlainObject(sidebar)) { + const prefix = normalizePrefix(dir, sidebar.prefix) + return resolveSidebarItems( + sidebar.items === 'structure' + ? structureSidebarData.value[prefix] + : sidebar.items, + prefix, + ) + } + } + return [] +} + +/** + * Get or generate sidebar group from the given sidebar items. + */ +export const getSidebarGroups = ( + sidebar: ResolvedSidebarItem[], +): ResolvedSidebarItem[] => { + const groups: ResolvedSidebarItem[] = [] + + let lastGroupIndex = 0 + + for (const item of sidebar) { + if (item.items) { + lastGroupIndex = groups.push(item) + continue + } + + if (!groups[lastGroupIndex]) { + groups.push({ items: [] }) + } + + groups[lastGroupIndex].items!.push(item) + } + + return groups +} + +export const getFlatSideBarLinks = ( + sidebar: ResolvedSidebarItem[], +): SidebarLink[] => { + const links: SidebarLink[] = [] + + const recursivelyExtractLinks = (items: ResolvedSidebarItem[]): void => { + for (const item of items) { + if (item.text && item.link) { + links.push({ + text: item.text, + link: item.link, + docFooterText: item.docFooterText, + }) + } + + if (item.items) { + recursivelyExtractLinks(item.items) + } + } + } + + recursivelyExtractLinks(sidebar) + + return links +} diff --git a/themes/theme-next/src/client/composables/sponsor-grid.ts b/themes/theme-next/src/client/composables/sponsor-grid.ts new file mode 100644 index 0000000000..c2c2913a34 --- /dev/null +++ b/themes/theme-next/src/client/composables/sponsor-grid.ts @@ -0,0 +1,139 @@ +import type { Ref } from 'vue' +import { onMounted, onUnmounted } from 'vue' +import { throttleAndDebounce } from '../utils/index.js' + +export type GridSetting = Record + +export type GridSize = 'big' | 'medium' | 'mini' | 'small' | 'xmini' + +export interface UseSponsorsGridOptions { + el: Ref + size?: GridSize +} + +/** + * Defines grid configuration for each sponsor size in tuple. + * + * [Screen width, Column size] + * + * It sets grid size on matching screen size. For example, `[768, 5]` will + * set 5 columns when screen size is bigger or equal to 768px. + * + * Column will set only when item size is bigger than the column size. For + * example, even we define 5 columns, if we only have 1 sponsor yet, we would + * like to show it in 1 column to make it stand out. + */ +const GridSettings: GridSetting = { + xmini: [[0, 2]], + mini: [], + small: [ + [920, 6], + [768, 5], + [640, 4], + [480, 3], + [0, 2], + ], + medium: [ + [960, 5], + [832, 4], + [640, 3], + [480, 2], + ], + big: [ + [832, 3], + [640, 2], + ], +} + +const setGridData = (el: HTMLElement, value: number): void => { + el.dataset.vpGrid = String(value) +} + +const setGrid = (el: HTMLElement, size: GridSize, items: number): number => { + const settings = GridSettings[size] + const screen = window.innerWidth + + let grid = 1 + + settings.some(([breakpoint, value]) => { + if (screen >= breakpoint) { + grid = items < value ? items : value + return true + } + return false + }) + + setGridData(el, grid) + + return grid +} + +const addSlots = (el: HTMLElement, count: number): void => { + for (let i = 0; i < count; i++) { + const slot = document.createElement('div') + + slot.classList.add('vp-sponsor-grid-item', 'empty') + + el.append(slot) + } +} + +const removeSlots = (el: HTMLElement, count: number): void => { + for (let i = 0; i < count; i++) { + el.removeChild(el.lastElementChild!) + } +} + +const neutralizeSlots = (el: HTMLElement, count: number): void => { + if (count === 0) { + return + } + + if (count > 0) { + addSlots(el, count) + } else { + removeSlots(el, count * -1) + } +} + +const manageSlots = ( + el: HTMLElement, + grid: number, + tsize: number, + asize: number, +): void => { + const diff = tsize - asize + const rem = asize % grid + const drem = rem === 0 ? rem : grid - rem + + neutralizeSlots(el, drem - diff) +} + +const adjustSlots = (el: HTMLElement, size: GridSize): void => { + const tsize = el.children.length + const asize = el.querySelectorAll('.vp-sponsor-grid-item:not(.empty)').length + + const grid = setGrid(el, size, asize) + + manageSlots(el, grid, tsize, asize) +} + +export const useSponsorsGrid = ({ + el, + size = 'medium', +}: UseSponsorsGridOptions): void => { + const manage = (): void => { + adjustSlots(el.value!, size) + } + + const onResize = throttleAndDebounce(manage, 100) + + onMounted(() => { + manage() + window.addEventListener('resize', onResize) + }) + + onUnmounted(() => { + window.removeEventListener('resize', onResize) + }) +} diff --git a/themes/theme-next/src/client/composables/theme-data.ts b/themes/theme-next/src/client/composables/theme-data.ts new file mode 100644 index 0000000000..082f5e2762 --- /dev/null +++ b/themes/theme-next/src/client/composables/theme-data.ts @@ -0,0 +1,15 @@ +import type { + ThemeDataRef, + ThemeLocaleDataRef, +} from '@vuepress/plugin-theme-data/client' +import { + useThemeData as _useThemeData, + useThemeLocaleData as _useThemeLocaleData, +} from '@vuepress/plugin-theme-data/client' +import type { DefaultThemeData } from '../../shared/index.js' + +export const useThemeData = (): ThemeDataRef => + _useThemeData() + +export const useThemeLocaleData = (): ThemeLocaleDataRef => + _useThemeLocaleData() diff --git a/themes/theme-next/src/client/config.ts b/themes/theme-next/src/client/config.ts new file mode 100644 index 0000000000..6a027aa3f0 --- /dev/null +++ b/themes/theme-next/src/client/config.ts @@ -0,0 +1,52 @@ +import './styles/index.css' + +import { hasGlobalComponent } from '@vuepress/helper/client' +import { h } from 'vue' +import { defineClientConfig } from 'vuepress/client' +import Badge from './components/VPBadge.vue' +import { Content } from './components/VPMarkdownContent.js' +import { + setupDarkMode, + setupSidebarData, + useScrollPromise, +} from './composables/index.js' +import Layout from './layouts/Layout.vue' +import NotFound from './layouts/NotFound.vue' + +export default defineClientConfig({ + enhance({ app, router }) { + // Warning: provide onContentUpdated hook ⚠️⚠️⚠️ + // Maybe a better way to do it, Maybe rewrite it or remove it + delete app._context.components.Content + + app.component('Content', Content) + if (!hasGlobalComponent('Badge')) app.component('Badge', Badge) + + // compat with @vuepress/plugin-docsearch and @vuepress/plugin-search + app.component('NavbarSearch', () => { + const SearchComponent = + app.component('Docsearch') ?? app.component('SearchBox') + if (SearchComponent) { + return h(SearchComponent) + } + return null + }) + + // handle scrollBehavior with transition + const scrollBehavior = router.options.scrollBehavior! + router.options.scrollBehavior = async (...args) => { + await useScrollPromise().wait() + return scrollBehavior(...args) + } + }, + + setup() { + setupDarkMode() + setupSidebarData() + }, + + layouts: { + Layout, + NotFound, + }, +}) diff --git a/themes/theme-next/src/client/fonts/inter-italic-cyrillic-ext.woff2 b/themes/theme-next/src/client/fonts/inter-italic-cyrillic-ext.woff2 new file mode 100644 index 0000000000..b6b603d596 Binary files /dev/null and b/themes/theme-next/src/client/fonts/inter-italic-cyrillic-ext.woff2 differ diff --git a/themes/theme-next/src/client/fonts/inter-italic-cyrillic.woff2 b/themes/theme-next/src/client/fonts/inter-italic-cyrillic.woff2 new file mode 100644 index 0000000000..def40a4f65 Binary files /dev/null and b/themes/theme-next/src/client/fonts/inter-italic-cyrillic.woff2 differ diff --git a/themes/theme-next/src/client/fonts/inter-italic-greek-ext.woff2 b/themes/theme-next/src/client/fonts/inter-italic-greek-ext.woff2 new file mode 100644 index 0000000000..e070c3d309 Binary files /dev/null and b/themes/theme-next/src/client/fonts/inter-italic-greek-ext.woff2 differ diff --git a/themes/theme-next/src/client/fonts/inter-italic-greek.woff2 b/themes/theme-next/src/client/fonts/inter-italic-greek.woff2 new file mode 100644 index 0000000000..a3c16ca40b Binary files /dev/null and b/themes/theme-next/src/client/fonts/inter-italic-greek.woff2 differ diff --git a/themes/theme-next/src/client/fonts/inter-italic-latin-ext.woff2 b/themes/theme-next/src/client/fonts/inter-italic-latin-ext.woff2 new file mode 100644 index 0000000000..2210a899ed Binary files /dev/null and b/themes/theme-next/src/client/fonts/inter-italic-latin-ext.woff2 differ diff --git a/themes/theme-next/src/client/fonts/inter-italic-latin.woff2 b/themes/theme-next/src/client/fonts/inter-italic-latin.woff2 new file mode 100644 index 0000000000..790d62dc7b Binary files /dev/null and b/themes/theme-next/src/client/fonts/inter-italic-latin.woff2 differ diff --git a/themes/theme-next/src/client/fonts/inter-italic-vietnamese.woff2 b/themes/theme-next/src/client/fonts/inter-italic-vietnamese.woff2 new file mode 100644 index 0000000000..1eec0775a6 Binary files /dev/null and b/themes/theme-next/src/client/fonts/inter-italic-vietnamese.woff2 differ diff --git a/themes/theme-next/src/client/fonts/inter-roman-cyrillic-ext.woff2 b/themes/theme-next/src/client/fonts/inter-roman-cyrillic-ext.woff2 new file mode 100644 index 0000000000..2cfe61536e Binary files /dev/null and b/themes/theme-next/src/client/fonts/inter-roman-cyrillic-ext.woff2 differ diff --git a/themes/theme-next/src/client/fonts/inter-roman-cyrillic.woff2 b/themes/theme-next/src/client/fonts/inter-roman-cyrillic.woff2 new file mode 100644 index 0000000000..e3886dd141 Binary files /dev/null and b/themes/theme-next/src/client/fonts/inter-roman-cyrillic.woff2 differ diff --git a/themes/theme-next/src/client/fonts/inter-roman-greek-ext.woff2 b/themes/theme-next/src/client/fonts/inter-roman-greek-ext.woff2 new file mode 100644 index 0000000000..36d67487dc Binary files /dev/null and b/themes/theme-next/src/client/fonts/inter-roman-greek-ext.woff2 differ diff --git a/themes/theme-next/src/client/fonts/inter-roman-greek.woff2 b/themes/theme-next/src/client/fonts/inter-roman-greek.woff2 new file mode 100644 index 0000000000..2bed1e85e8 Binary files /dev/null and b/themes/theme-next/src/client/fonts/inter-roman-greek.woff2 differ diff --git a/themes/theme-next/src/client/fonts/inter-roman-latin-ext.woff2 b/themes/theme-next/src/client/fonts/inter-roman-latin-ext.woff2 new file mode 100644 index 0000000000..9a8d1e2b5e Binary files /dev/null and b/themes/theme-next/src/client/fonts/inter-roman-latin-ext.woff2 differ diff --git a/themes/theme-next/src/client/fonts/inter-roman-latin.woff2 b/themes/theme-next/src/client/fonts/inter-roman-latin.woff2 new file mode 100644 index 0000000000..07d3c53aef Binary files /dev/null and b/themes/theme-next/src/client/fonts/inter-roman-latin.woff2 differ diff --git a/themes/theme-next/src/client/fonts/inter-roman-vietnamese.woff2 b/themes/theme-next/src/client/fonts/inter-roman-vietnamese.woff2 new file mode 100644 index 0000000000..57bdc22ae8 Binary files /dev/null and b/themes/theme-next/src/client/fonts/inter-roman-vietnamese.woff2 differ diff --git a/themes/theme-next/src/client/index.ts b/themes/theme-next/src/client/index.ts new file mode 100644 index 0000000000..9af62cdade --- /dev/null +++ b/themes/theme-next/src/client/index.ts @@ -0,0 +1,22 @@ +export * from './composables/index.js' +export * from './utils/index.js' +export type * from '../shared/index.js' + +export { default as Layout } from './layouts/Layout.vue' +export { default as NotFound } from './layouts/NotFound.vue' + +export { default as VPBadge } from './components/VPBadge.vue' +export { default as VPImage } from './components/VPImage.vue' +export { default as VPButton } from './components/VPButton.vue' + +export { default as VPHomeHero } from './components/VPHomeHero.vue' +export { default as VPHomeFeatures } from './components/VPHomeFeatures.vue' + +export { default as VPHomeSponsors } from './components/VPHomeSponsors.vue' +export { default as VPSponsors } from './components/VPSponsors.vue' +export { default as VPDocAsideSponsors } from './components/VPDocAsideSponsors.vue' + +export { default as VPTeamPage } from './components/VPTeamPage.vue' +export { default as VPTeamPageTitle } from './components/VPTeamPageTitle.vue' +export { default as VPTeamPageSection } from './components/VPTeamPageSection.vue' +export { default as VPTeamMembers } from './components/VPTeamMembers.vue' diff --git a/themes/theme-next/src/client/layouts/Layout.vue b/themes/theme-next/src/client/layouts/Layout.vue new file mode 100644 index 0000000000..e3a6d59183 --- /dev/null +++ b/themes/theme-next/src/client/layouts/Layout.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/themes/theme-next/src/client/layouts/NotFound.vue b/themes/theme-next/src/client/layouts/NotFound.vue new file mode 100644 index 0000000000..bcbdd5bf35 --- /dev/null +++ b/themes/theme-next/src/client/layouts/NotFound.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/themes/theme-next/src/client/shim.d.ts b/themes/theme-next/src/client/shim.d.ts new file mode 100644 index 0000000000..6ddd0d58c2 --- /dev/null +++ b/themes/theme-next/src/client/shim.d.ts @@ -0,0 +1,12 @@ +declare module '*.vue' { + import type { ComponentOptions } from 'vue' + + const comp: ComponentOptions + export default comp +} + +declare module '@internal/sidebar' { + import type { ResolvedSidebarItem } from '../shared/resolved/sidebar.js' + + export const sidebarData: Record +} diff --git a/themes/theme-next/src/client/styles/base.css b/themes/theme-next/src/client/styles/base.css new file mode 100644 index 0000000000..f0e232aaec --- /dev/null +++ b/themes/theme-next/src/client/styles/base.css @@ -0,0 +1,273 @@ +@media (prefers-reduced-motion: reduce) { + *, + ::before, + ::after { + background-attachment: initial !important; + + scroll-behavior: auto !important; + + transition-delay: 0s !important; + transition-duration: 0s !important; + + animation-duration: 1ms !important; + animation-delay: -1ms !important; + animation-iteration-count: 1 !important; + } +} + +*, +::before, +::after { + box-sizing: border-box; +} + +html { + font-size: 16px; + line-height: 1.4; + scroll-padding-top: 130px; + text-size-adjust: 100%; +} + +@media (min-width: 960px) { + html { + scroll-padding-top: 150px; + } +} + +@media (min-width: 1280px) { + html { + scroll-padding-top: 104px; + } +} + +html[data-theme='dark'] { + color-scheme: dark; +} + +body { + width: 100%; + min-width: 320px; + min-height: 100vh; + margin: 0; + + background-color: var(--vp-c-bg); + color: var(--vp-c-text); + + font-weight: 400; + font-size: 16px; + font-family: var(--vp-font-family-base); + font-synthesis: style; + line-height: 24px; + + text-rendering: optimizelegibility; + + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +main { + display: block; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + margin: 0; + font-weight: 400; + font-size: 16px; + line-height: 24px; +} + +p { + margin: 0; +} + +strong, +b { + font-weight: 600; +} + +/** + * Avoid 300ms click delay on touch devices that support the `touch-action` + * CSS property. + * + * In particular, unlike most other browsers, IE11+Edge on Windows 10 on + * touch devices and IE Mobile 10-11 DON'T remove the click delay when + * `` is present. + * However, they DO support removing the click delay via + * `touch-action: manipulation`. + * + * See: + * - http://v4-alpha.getbootstrap.com/content/reboot/#click-delay-optimization-for-touch + * - http://caniuse.com/#feat=css-touch-action + * - http://patrickhlauke.github.io/touch/tests/results/#suppressing-300ms-delay + */ +a, +area, +button, +[role='button'], +input, +label, +select, +summary, +textarea { + touch-action: manipulation; +} + +a { + color: inherit; + text-decoration: inherit; +} + +ol, +ul { + margin: 0; + padding: 0; + list-style: none; +} + +blockquote { + margin: 0; +} + +pre, +code, +kbd, +samp { + font-family: var(--vp-font-family-mono); +} + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; +} + +figure { + margin: 0; +} + +img, +video { + max-width: 100%; + height: auto; +} + +button, +input, +optgroup, +select, +textarea { + padding: 0; + border: 0; + color: inherit; + line-height: inherit; +} + +button { + padding: 0; + background-color: transparent; + background-image: none; + font-family: inherit; +} + +button:enabled, +[role='button']:enabled { + cursor: pointer; +} + +button:focus, +button:focus-visible { + outline: 1px dotted; + outline: 4px auto -webkit-focus-ring-color; +} + +button:focus:not(:focus-visible) { + outline: none !important; +} + +input:focus, +textarea:focus, +select:focus { + outline: none; +} + +table { + border-collapse: collapse; +} + +input { + background-color: transparent; +} + +input:input-placeholder, +textarea:input-placeholder { + color: var(--vp-c-text-subtle); +} + +input::input-placeholder, +textarea::input-placeholder { + color: var(--vp-c-text-subtle); +} + +input::placeholder, +textarea::placeholder { + color: var(--vp-c-text-subtle); +} + +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + margin: 0; + appearance: none; +} + +input[type='number'] { + appearance: textfield; +} + +textarea { + resize: vertical; +} + +select { + appearance: none; +} + +fieldset { + margin: 0; + padding: 0; +} + +h1, +h2, +h3, +h4, +h5, +h6, +li, +p { + overflow-wrap: break-word; +} + +vite-error-overlay { + z-index: 9999; +} + +mjx-container { + display: inline-block; + vertical-align: middle; + margin: auto 2px; +} + +mjx-container > svg { + display: inline-block; + margin: auto; +} diff --git a/themes/theme-next/src/client/styles/compat.css b/themes/theme-next/src/client/styles/compat.css new file mode 100644 index 0000000000..444ca84690 --- /dev/null +++ b/themes/theme-next/src/client/styles/compat.css @@ -0,0 +1,281 @@ +/* + ** The style here is designed to accommodate the differences in plugins between new and old themes. + */ + +/** + * plugin-docsearch + * ----------------------------------------------------------------- */ +.DocSearch { + --docsearch-primary-color: var(--vp-c-accent); + --docsearch-highlight-color: var(--docsearch-primary-color); + --docsearch-text-color: var(--vp-c-text); + --docsearch-muted-color: var(--vp-c-text-mute); + --docsearch-searchbox-shadow: none; + --docsearch-searchbox-background: var(--vp-c-default-soft); + --docsearch-searchbox-focus-background: var(--vp-c-default-3); + --docsearch-key-gradient: transparent; + --docsearch-key-shadow: none; + --docsearch-modal-background: var(--vp-c-bg-soft); + --docsearch-footer-background: var(--vp-c-bg); +} + +[data-theme='dark'] .DocSearch { + --docsearch-modal-shadow: none; + --docsearch-footer-shadow: none; + --docsearch-logo-color: var(--vp-c-text-mute); + --docsearch-hit-background: var(--vp-c-default-soft); + --docsearch-hit-color: var(--vp-c-text-mute); + --docsearch-hit-shadow: none; +} + +.vp-navbar-search .DocSearch-Button { + display: flex; + align-items: center; + justify-content: center !important; + + width: 32px; + height: 32px; + margin: 0; + padding: 0; + + background: var(--docsearch-searchbox-background); + + transition: + border-color 0.5s ease, + background 0.5s ease; +} + +.vp-navbar-search .DocSearch-Button:hover { + background: var(--docsearch-searchbox-focus-background); +} + +.vp-navbar-search .DocSearch-Button:focus { + outline: 1px dotted; + outline: 5px auto -webkit-focus-ring-color; +} + +.vp-navbar-search .DocSearch-Button:focus:not(:focus-visible) { + outline: none !important; +} + +.vp-navbar-search #docsearch-container { + min-width: 32px; +} + +.DocSearch-Button .DocSearch-Button-Container { + display: flex; + align-items: center; +} + +.DocSearch-Button .DocSearch-Search-Icon { + position: relative; + + width: 16px; + height: 16px; + + color: var(--vp-c-text); + + transition: color 0.5s ease; + + fill: currentcolor; +} + +.DocSearch-Button:hover .DocSearch-Search-Icon { + color: var(--vp-c-text); +} + +.DocSearch-Button .DocSearch-Button-Placeholder { + display: none; + + margin-top: 2px; + padding: 0 16px 0 0; + + color: var(--vp-c-text-mute); + + font-weight: 500; + font-size: 13px; + + transition: color 0.5s ease; +} + +.DocSearch-Button:hover .DocSearch-Button-Placeholder { + color: var(--vp-c-text); +} + +.DocSearch-Button .DocSearch-Button-Keys { + display: none; + min-width: auto; + + /* rtl:ignore */ + direction: ltr; +} + +.DocSearch-Button .DocSearch-Button-Key { + display: block; + + width: auto; + + /* rtl:end:ignore */ + min-width: 0; + height: 22px; + margin: 2px 0 0; + padding-left: 6px; + border: 1px solid var(--vp-c-divider); + + /* rtl:begin:ignore */ + border-right: none; + border-radius: 4px 0 0 4px; + + font-weight: 500; + font-size: 12px; + font-family: var(--vp-font-family-base); + line-height: 22px; + + transition: + color 0.5s ease, + border-color 0.5s ease; +} + +.DocSearch-Button .DocSearch-Button-Key + .DocSearch-Button-Key { + padding-right: 6px; + padding-left: 2px; + + /* rtl:begin:ignore */ + border-right: 1px solid var(--vp-c-divider); + border-left: none; + border-radius: 0 4px 4px 0; + + /* rtl:end:ignore */ +} + +.DocSearch-Button .DocSearch-Button-Key:first-child { + color: transparent; + font-size: 1px; + letter-spacing: -12px; +} + +.DocSearch-Button .DocSearch-Button-Key:first-child::after { + content: 'Ctrl'; + color: var(--docsearch-muted-color); + font-size: 12px; + letter-spacing: normal; +} + +.mac .DocSearch-Button .DocSearch-Button-Key:first-child::after { + content: '\2318'; +} + +.DocSearch-Button .DocSearch-Button-Key:first-child > * { + display: none; +} + +[data-theme='dark'] .DocSearch-Footer { + border-top: 1px solid var(--vp-c-divider); +} + +.DocSearch-Form { + border: 1px solid var(--vp-c-accent); + background-color: var(--vp-c-white); +} + +[data-theme='dark'] .DocSearch-Form { + background-color: var(--vp-c-bg-soft); +} + +@media (min-width: 768px) { + .vp-navbar-search .DocSearch-Button { + justify-content: flex-start; + + width: 100%; + height: 40px; + padding: 0 10px 0 12px; + border: 1px solid transparent; + border-radius: 8px; + + background-color: var(--vp-c-bg-alt); + } + + .vp-navbar-search .DocSearch-Button:hover { + border-color: var(--vp-c-accent); + background: var(--docsearch-searchbox-focus-background); + } +} + +@media (min-width: 768px) { + .DocSearch-Button .DocSearch-Search-Icon { + top: 1px; + + width: 14px; + height: 14px; + margin-right: 8px; + + color: var(--vp-c-text-mute); + } +} + +@media (min-width: 768px) { + .DocSearch-Button .DocSearch-Button-Placeholder { + display: inline-block; + } +} + +@media (min-width: 768px) { + .DocSearch-Button .DocSearch-Button-Keys { + display: flex; + align-items: center; + } +} + +/** + * plugin-search + * ----------------------------------------------------------------- */ + +.vp-navbar-search { + --search-bg-color: var(--vp-c-bg); + --search-accent-color: var(--vp-c-accent); + --search-text-color: var(--vp-c-text); + --search-border-color: var(--vp-c-divider); + --search-item-text-color: var(--vp-c-text-mute); + --search-item-focus-bg-color: var(--vp-c-bg-soft); +} + +.vp-navbar-search .search-box input { + padding: 0 0.3rem 0 1.655rem; + background-position: 0.5rem 0.4rem; +} + +.vp-navbar-search .search-box .suggestions { + top: 40px; + right: -16px; + z-index: 10; + + padding: 16px 12px; + border-radius: 12px; + + background-color: var(--vp-c-bg); + box-shadow: var(--vp-shadow-3); +} + +@media (min-width: 768px) { + .vp-navbar-search .search-box .suggestions { + right: unset; + left: -16px; + } +} + +/** + * plugin-markdown-tab + * ----------------------------------------------------------------- */ +.vp-tabs .vp-code-tabs .vp-code-tabs-nav, +.vp-tabs div[class*='language-'] { + margin-right: 0; + margin-left: 0; +} + +.vp-tabs .vp-tab > *:nth-child(2) { + margin-top: 0; +} + +.vp-tabs .vp-tab > *:last-child { + margin-bottom: 0; +} diff --git a/themes/theme-next/src/client/styles/components/custom-block.css b/themes/theme-next/src/client/styles/components/custom-block.css new file mode 100644 index 0000000000..3c97445019 --- /dev/null +++ b/themes/theme-next/src/client/styles/components/custom-block.css @@ -0,0 +1,240 @@ +.hint-container { + padding: 16px 16px 8px; + border: 1px solid transparent; + border-radius: 8px; + + color: var(--vp-c-text-mute); + + font-size: var(--vp-custom-block-font-size); + line-height: 24px; +} + +.hint-container.info { + border-color: var(--vp-custom-block-info-border); + background-color: var(--vp-custom-block-info-bg); + color: var(--vp-custom-block-info-text); +} + +.hint-container.info .hint-container-title::before { + color: var(--vp-custom-block-info-icon-color); +} + +.hint-container.info a, +.hint-container.info code { + color: var(--vp-c-accent); +} + +.hint-container.info a:hover, +.hint-container.info a:hover > code { + color: var(--vp-c-accent-hover); +} + +.hint-container.info code { + background-color: var(--vp-custom-block-info-code-bg); +} + +.hint-container.note { + border-color: var(--vp-custom-block-note-border); + background-color: var(--vp-custom-block-note-bg); + color: var(--vp-custom-block-note-text); +} + +.hint-container.note .hint-container-title::before { + color: var(--vp-custom-block-note-icon-color); +} + +.hint-container.note a, +.hint-container.note code { + color: var(--vp-c-accent); +} + +.hint-container.note a:hover, +.hint-container.note a:hover > code { + color: var(--vp-c-accent-hover); +} + +.hint-container.note code { + background-color: var(--vp-custom-block-note-code-bg); +} + +.hint-container.tip { + border-color: var(--vp-custom-block-tip-border); + background-color: var(--vp-custom-block-tip-bg); + color: var(--vp-custom-block-tip-text); +} + +.hint-container.tip .hint-container-title::before { + color: var(--vp-custom-block-tip-icon-color); +} + +.hint-container.tip a, +.hint-container.tip code { + color: var(--vp-c-tip-1); +} + +.hint-container.tip a:hover, +.hint-container.tip a:hover > code { + color: var(--vp-c-tip-2); +} + +.hint-container.tip code { + background-color: var(--vp-custom-block-tip-code-bg); +} + +.hint-container.important { + border-color: var(--vp-custom-block-important-border); + background-color: var(--vp-custom-block-important-bg); + color: var(--vp-custom-block-important-text); +} + +.hint-container.important .hint-container-title::before { + color: var(--vp-custom-block-important-icon-color); +} + +.hint-container.important a, +.hint-container.important code { + color: var(--vp-c-important-1); +} + +.hint-container.important a:hover, +.hint-container.important a:hover > code { + color: var(--vp-c-important-2); +} + +.hint-container.important code { + background-color: var(--vp-custom-block-important-code-bg); +} + +.hint-container.warning { + border-color: var(--vp-custom-block-warning-border); + background-color: var(--vp-custom-block-warning-bg); + color: var(--vp-custom-block-warning-text); +} + +.hint-container.warning .hint-container-title::before { + color: var(--vp-custom-block-warning-icon-color); +} + +.hint-container.warning a, +.hint-container.warning code { + color: var(--vp-c-warning-1); +} + +.hint-container.warning a:hover, +.hint-container.warning a:hover > code { + color: var(--vp-c-warning-2); +} + +.hint-container.warning code { + background-color: var(--vp-custom-block-warning-code-bg); +} + +.hint-container.danger { + border-color: var(--vp-custom-block-danger-border); + background-color: var(--vp-custom-block-danger-bg); + color: var(--vp-custom-block-danger-text); +} + +.hint-container.danger .hint-container-title::before { + color: var(--vp-custom-block-danger-icon-color); +} + +.hint-container.danger a, +.hint-container.danger code { + color: var(--vp-c-danger-1); +} + +.hint-container.danger a:hover, +.hint-container.danger a:hover > code { + color: var(--vp-c-danger-2); +} + +.hint-container.danger code { + background-color: var(--vp-custom-block-danger-code-bg); +} + +.hint-container.caution { + border-color: var(--vp-custom-block-caution-border); + background-color: var(--vp-custom-block-caution-bg); + color: var(--vp-custom-block-caution-text); +} + +.hint-container.caution .hint-container-title::before { + color: var(--vp-custom-block-caution-icon-color); +} + +.hint-container.caution a, +.hint-container.caution code { + color: var(--vp-c-caution-1); +} + +.hint-container.caution a:hover, +.hint-container.caution a:hover > code { + color: var(--vp-c-caution-2); +} + +.hint-container.caution code { + background-color: var(--vp-custom-block-caution-code-bg); +} + +.hint-container.details { + border-color: var(--vp-custom-block-details-border); + background-color: var(--vp-custom-block-details-bg); + color: var(--vp-custom-block-details-text); +} + +.hint-container.details a { + color: var(--vp-c-accent); +} + +.hint-container.details a:hover, +.hint-container.details a:hover > code { + color: var(--vp-c-accent-hover); +} + +.hint-container.details code { + background-color: var(--vp-custom-block-details-code-bg); +} + +.hint-container p + p { + margin: 8px 0; +} + +.hint-container.details summary { + font-weight: 700; + cursor: pointer; + user-select: none; +} + +.hint-container.details summary + p { + margin: 8px 0; +} + +.hint-container a { + color: inherit; + + font-weight: 600; + text-decoration: underline; + + transition: opacity 0.25s; + + text-underline-offset: 2px; +} + +.hint-container a:hover { + opacity: 0.75; +} + +.hint-container code { + font-size: var(--vp-custom-block-code-font-size); +} + +.hint-container th, +.hint-container blockquote > p { + color: inherit; + font-size: var(--vp-custom-block-font-size); +} + +.hint-container > .hint-container-title { + font-weight: 600; +} diff --git a/themes/theme-next/src/client/styles/components/vp-code-tabs.css b/themes/theme-next/src/client/styles/components/vp-code-tabs.css new file mode 100644 index 0000000000..0eb0e50dac --- /dev/null +++ b/themes/theme-next/src/client/styles/components/vp-code-tabs.css @@ -0,0 +1,92 @@ +.vp-code-tabs { + margin-top: 16px; +} + +.vp-code-tabs .vp-code-tabs-nav { + position: relative; + + display: flex; + + overflow: auto hidden; + + margin-right: -24px; + margin-bottom: -16px; + margin-left: -24px; + padding: 0 12px; + + background-color: var(--vp-code-tab-bg); + box-shadow: inset 0 -1px var(--vp-code-tab-divider); +} + +@media (min-width: 640px) { + .vp-code-tabs .vp-code-tabs-nav { + margin-right: 0; + margin-left: 0; + border-radius: 8px 8px 0 0; + } +} + +.vp-code-tabs .vp-code-tabs-nav .vp-code-tab-nav { + position: relative; + + display: inline-block; + + padding: 0 12px; + border-bottom: 1px solid transparent; + + background: none; + color: var(--vp-code-tab-text-color); + + font-weight: 500; + font-size: 14px; + line-height: 48px; + white-space: nowrap; + + cursor: pointer; + + transition: color 0.25s; +} + +.vp-code-tabs .vp-code-tabs-nav .vp-code-tab-nav::after { + content: ''; + + position: absolute; + right: 8px; + bottom: -1px; + left: 8px; + z-index: 1; + + width: unset; + height: 2px; + border-radius: 2px; + + background: none; + background-color: transparent; + + transition: background-color 0.25s; +} + +.vp-code-tabs .vp-code-tab-nav:hover { + color: var(--vp-code-tab-hover-text-color); +} + +.vp-code-tabs .vp-code-tab-nav.active { + color: var(--vp-code-tab-active-text-color); +} + +.vp-code-tabs .vp-code-tab-nav.active::after { + background-color: var(--vp-code-tab-active-bar-color); +} + +.vp-code-tabs .vp-code-tab-nav::before { + display: none; +} + +.vp-block { + padding: 20px 24px; +} + +.vp-doc .vp-code-tabs [class*='language-'] { + border-top-left-radius: 0; + border-top-right-radius: 0; +} diff --git a/themes/theme-next/src/client/styles/components/vp-code.css b/themes/theme-next/src/client/styles/components/vp-code.css new file mode 100644 index 0000000000..86c77a7981 --- /dev/null +++ b/themes/theme-next/src/client/styles/components/vp-code.css @@ -0,0 +1,355 @@ +/* stylelint-disable selector-max-compound-selectors */ +/* stylelint-disable rule-empty-line-before */ + +[data-theme='dark'] .vp-code span { + color: var(--shiki-dark, inherit); +} + +html:not([data-theme='dark']) .vp-code span { + color: var(--shiki-light, inherit); +} + +/** + * Code + * -------------------------------------------------------------------------- */ + +/* inline code */ +.vp-doc :not(pre, h1, h2, h3, h4, h5, h6) > code { + color: var(--vp-code-color); + font-size: var(--vp-code-font-size); +} + +.vp-doc :not(pre) > code { + padding: 3px 6px; + border-radius: 4px; + background-color: var(--vp-code-bg); + transition: + color 0.25s, + background-color 0.5s; +} + +.vp-doc a > code { + color: var(--vp-code-link-color); +} + +.vp-doc a:hover > code { + color: var(--vp-code-link-hover-color); +} + +.vp-doc h1 > code, +.vp-doc h2 > code, +.vp-doc h3 > code { + font-size: 0.9em; +} + +.vp-doc div[class*='language-'], +.vp-block { + position: relative; + + overflow-x: auto; + + margin: 16px -24px; + border-radius: 0; + + background-color: var(--vp-code-block-bg); + + transition: background-color 0.5s; +} + +@media (min-width: 640px) { + .vp-doc div[class*='language-'], + .vp-block { + margin: 16px 0; + border-radius: 8px; + } +} + +@media (max-width: 639px) { + .vp-doc li div[class*='language-'] { + border-radius: 8px 0 0 8px; + } +} + +.vp-doc div[class*='language-'] + div[class*='language-'], +.vp-doc div[class$='-api'] + div[class*='language-'], +.vp-doc div[class*='language-'] + div[class$='-api'] > div[class*='language-'] { + margin-top: -8px; +} + +.vp-doc [class*='language-'] pre, +.vp-doc [class*='language-'] code { + background-color: transparent !important; + + /* rtl:ignore */ + text-align: left; + + /* rtl:ignore */ + direction: ltr; + white-space: pre; + word-spacing: normal; + word-wrap: normal; + word-break: normal; + tab-size: 4; + hyphens: none; +} + +.vp-doc [class*='language-'] pre { + position: relative; + z-index: 1; + + overflow-x: auto; + + margin: 0; + padding: 20px 0; + border-radius: 0; + + background: transparent; + + font-size: inherit; + font-family: inherit; + line-height: inherit; +} + +.vp-doc div[class*='language-'].line-numbers-mode pre { + margin-left: 0; +} + +.vp-doc [class*='language-'] code { + display: block; + + width: fit-content; + min-width: 100%; + padding: 0 24px !important; + + color: var(--vp-code-block-color); + + font-size: var(--vp-code-font-size); + line-height: var(--vp-code-line-height); + + transition: color 0.5s; +} + +.vp-doc [class*='language-'] code .line.highlighted { + display: inline-block; + + width: calc(100% + 2 * 24px); + margin: 0 -24px; + padding: 0 24px; + + background-color: var(--vp-code-line-highlight-color); + + transition: background-color 0.5s; +} + +.vp-doc [class*='language-'] code .line.highlighted.error { + background-color: var(--vp-code-line-error-color); +} + +.vp-doc [class*='language-'] code .line.highlighted.warning { + background-color: var(--vp-code-line-warning-color); +} + +.vp-doc [class*='language-'] code .line.diff { + display: inline-block; + + width: calc(100% + 2 * 24px); + margin: 0 -24px; + padding: 0 24px; + + transition: background-color 0.5s; +} + +.vp-doc [class*='language-'] code .line.diff::before { + position: absolute; + left: 10px; +} + +.vp-doc [class*='language-'] .has-focused-lines .line:not(.has-focus) { + opacity: 0.7; + filter: blur(0.095rem); + transition: + filter 0.35s, + opacity 0.35s; +} + +.vp-doc [class*='language-']:hover .has-focused-lines .line:not(.has-focus) { + opacity: 1; + filter: blur(0); +} + +.vp-doc [class*='language-'] code .line.diff.remove { + background-color: var(--vp-code-line-diff-remove-color); + opacity: 0.7; +} + +.vp-doc [class*='language-'] code .line.diff.remove::before { + content: '-'; + color: var(--vp-code-line-diff-remove-symbol-color); +} + +.vp-doc [class*='language-'] code .line.diff.add { + background-color: var(--vp-code-line-diff-add-color); +} + +.vp-doc [class*='language-'] code .line.diff.add::before { + content: '+'; + color: var(--vp-code-line-diff-add-symbol-color); +} + +.vp-doc div[class*='language-'].line-numbers-mode { + /* rtl:ignore */ + padding-left: 32px; +} + +.vp-doc div[class*='language-'].line-numbers-mode .line-numbers { + counter-reset: line-number; + + position: absolute; + top: 0; + bottom: 0; + + /* rtl:ignore */ + left: 0; + z-index: 3; + + width: 32px; + padding-top: 20px; + + /* rtl:ignore */ + border-right: 1px solid var(--vp-code-block-divider-color); + + color: var(--vp-code-line-number-color); + + font-size: var(--vp-code-font-size); + font-family: var(--vp-font-family-mono); + line-height: var(--vp-code-line-height); + text-align: center; + + transition: + border-color 0.5s, + color 0.5s; +} + +.vp-doc div[class*='language-'].line-numbers-mode .line-numbers .line-number { + position: relative; + z-index: 3; + font-family: var(--vp-font-family-mono); + user-select: none; +} + +.vp-doc + div[class*='language-'].line-numbers-mode + .line-numbers + .line-number::before { + content: counter(line-number); + counter-increment: line-number; +} + +.vp-doc div[class*='language-'].line-numbers-mode::after { + content: none; +} + +.vp-doc div[class*='language-']:not(.line-numbers-mode) .line-numbers { + display: none; +} + +.vp-doc div[class*='language'] > .vp-copy-code-button { + top: 12px; + + /* rtl:ignore */ + right: 12px; + + border: 1px solid var(--vp-code-copy-code-border-color); + border-radius: 4px; + + background-color: var(--vp-code-block-bg); + + font-size: 20px; + + transition: + background-color 0.25s, + border-color 0.25s, + opacity 0.25s; +} + +.vp-doc div[class*='language'] > .vp-copy-code-button::before { + width: 100%; + height: 100%; + color: var(--vp-code-block-color); + mask-image: var(--vp-icon-copy); +} + +.vp-doc div[class*='language'] > .vp-copy-code-button:hover, +.vp-doc div[class*='language'] > .vp-copy-code-button.copied { + border-color: var(--vp-code-copy-code-hover-border-color); + background-color: var(--vp-code-copy-code-hover-bg); +} + +.vp-doc [class*='language-'] > button.vp-copy-code-button.copied, +.vp-doc [class*='language-'] > button.vp-copy-code-button:hover.copied { + /* rtl:ignore */ + border-radius: 0 4px 4px 0; +} + +.vp-doc [class*='language-'] > button.vp-copy-code-button.copied::before, +.vp-doc [class*='language-'] > button.vp-copy-code-button:hover.copied::before { + mask-image: var(--vp-icon-copied); +} + +.vp-doc [class*='language-'] > button.vp-copy-code-button.copied::after, +.vp-doc [class*='language-'] > button.vp-copy-code-button:hover.copied::after { + top: -1px; + right: unset; + + display: flex; + align-items: center; + justify-content: center; + + width: fit-content; + height: 40px; + padding: 0 10px; + border: 1px solid var(--vp-code-copy-code-hover-border-color); + + /* rtl:ignore */ + border-right: 0; + border-radius: 4px 0 0 4px; + + background-color: var(--vp-code-copy-code-hover-bg); + color: var(--vp-code-copy-code-active-text); + + font-weight: 500; + font-size: 12px; + text-align: center; + white-space: nowrap; + + /* rtl:ignore */ + transform: translateX(calc(-100% - 1px)); +} + +.vp-doc [class*='language-']::before { + content: attr(data-title); + + position: absolute; + top: 5px; + right: 1em; + z-index: 3; + + color: var(--vp-code-line-number-color); + + font-size: 0.75rem; + + transition: color var(--t-color); +} + +.vp-doc + [class*='language-']:hover + > button.vp-copy-code-button + + .vp-doc + [class*='language-']::before, +.vp-doc + [class*='language-'] + > button.vp-copy-code-button:focus + + .vp-doc + [class*='language-']::before { + opacity: 0; +} diff --git a/themes/theme-next/src/client/styles/components/vp-doc.css b/themes/theme-next/src/client/styles/components/vp-doc.css new file mode 100644 index 0000000000..9c8a4744b3 --- /dev/null +++ b/themes/theme-next/src/client/styles/components/vp-doc.css @@ -0,0 +1,307 @@ +/* stylelint-disable rule-empty-line-before */ +/* stylelint-disable selector-max-compound-selectors */ + +/** + * Headings + * -------------------------------------------------------------------------- */ + +.vp-doc h1, +.vp-doc h2, +.vp-doc h3, +.vp-doc h4, +.vp-doc h5, +.vp-doc h6 { + position: relative; + outline: none; + font-weight: 600; + overflow-wrap: break-word; +} + +.vp-doc h1 { + font-size: 28px; + line-height: 40px; + letter-spacing: -0.02em; +} + +.vp-doc h2 { + margin: 48px 0 16px; + padding-top: 24px; + border-top: 1px solid var(--vp-c-divider); + + font-size: 24px; + line-height: 32px; + letter-spacing: -0.02em; +} + +.vp-doc h3 { + margin: 32px 0 0; + font-size: 20px; + line-height: 28px; + letter-spacing: -0.01em; +} + +.vp-doc .header-anchor { + position: relative; + color: inherit; + text-decoration: none; +} + +.vp-doc .header-anchor::before { + content: var(--vp-header-anchor-symbol); + + position: absolute; + left: -0.75em; + + color: var(--vp-c-accent); + + font-size: 0.8em; + + opacity: 0; + + transition: color 0.25s ease; +} + +.vp-doc .header-anchor:hover { + color: inherit; +} + +.vp-doc .header-anchor:hover::before { + opacity: 1; +} + +.vp-doc .header-anchor:focus-visible { + outline: none; +} + +.vp-doc .header-anchor:focus-visible::before { + content: var(--vp-header-anchor-symbol); + + position: absolute; + left: -0.75em; + + color: var(--vp-c-accent); + outline: auto; +} + +@media (min-width: 768px) { + .vp-doc h1 { + font-size: 32px; + line-height: 40px; + letter-spacing: -0.02em; + } +} + +/** + * Paragraph and inline elements + * -------------------------------------------------------------------------- */ + +.vp-doc p, +.vp-doc summary { + margin: 16px 0; +} + +.vp-doc p { + line-height: 28px; +} + +.vp-doc blockquote { + margin: 16px 0; + padding-left: 16px; + border-left: 2px solid var(--vp-c-divider); + transition: border-color 0.5s; +} + +.vp-doc blockquote > p { + margin: 0; + color: var(--vp-c-text-mute); + font-size: 16px; + transition: color 0.5s; +} + +.vp-doc a { + color: var(--vp-c-accent); + + font-weight: 500; + text-decoration: underline; + + transition: + color 0.25s, + opacity 0.25s; + + text-underline-offset: 2px; +} + +.vp-doc a:hover { + color: var(--vp-c-accent-hover); +} + +.vp-doc strong { + font-weight: 600; +} + +.vp-doc a > img { + display: inline-block; +} + +/** + * Lists + * -------------------------------------------------------------------------- */ + +.vp-doc ul, +.vp-doc ol { + margin: 16px 0; + padding-left: 1.25rem; +} + +.vp-doc ul { + list-style: disc; +} + +.vp-doc ol { + list-style: decimal; +} + +.vp-doc li + li { + margin-top: 8px; +} + +.vp-doc li > ol, +.vp-doc li > ul { + margin: 8px 0 0; +} + +/** + * Table + * -------------------------------------------------------------------------- */ + +.vp-doc table { + display: block; + overflow-x: auto; + margin: 20px 0; + border-collapse: collapse; +} + +.vp-doc tr { + border-top: 1px solid var(--vp-c-divider); + background-color: var(--vp-c-bg); + transition: background-color 0.5s; +} + +.vp-doc tr:nth-child(2n) { + background-color: var(--vp-c-bg-soft); +} + +.vp-doc th, +.vp-doc td { + padding: 8px 16px; + border: 1px solid var(--vp-c-divider); +} + +.vp-doc th { + background-color: var(--vp-c-bg-soft); + color: var(--vp-c-text-mute); + + font-weight: 600; + font-size: 14px; + text-align: left; +} + +.vp-doc td { + font-size: 14px; +} + +/** + * Decorational elements + * -------------------------------------------------------------------------- */ + +.vp-doc hr { + margin: 16px 0; + border: none; + border-top: 1px solid var(--vp-c-divider); +} + +/** + * Custom Block + * -------------------------------------------------------------------------- */ + +.vp-doc .custom-block { + margin: 16px 0; +} + +.vp-doc .custom-block p { + margin: 8px 0; + line-height: 24px; +} + +.vp-doc .custom-block p:first-child { + margin: 0; +} + +.vp-doc .custom-block div[class*='language-'] { + margin: 8px 0; + border-radius: 8px; +} + +.vp-doc .custom-block div[class*='language-'] code { + background-color: transparent; + font-weight: 400; +} + +.vp-doc .custom-block .vp-code-group .tabs { + margin: 0; + border-radius: 8px 8px 0 0; +} + +/** + * Component: Team + * -------------------------------------------------------------------------- */ + +.vp-doc .vp-team-members { + margin-top: 24px; +} + +.vp-doc .vp-team-members.small.count-1 .container { + max-width: calc((100% - 24px) / 2) !important; + margin: 0 !important; +} + +.vp-doc .vp-team-members.small.count-2 .container, +.vp-doc .vp-team-members.small.count-3 .container { + max-width: 100% !important; +} + +.vp-doc .vp-team-members.medium.count-1 .container { + max-width: calc((100% - 24px) / 2) !important; + margin: 0 !important; +} + +/** + * External links + * -------------------------------------------------------------------------- */ + +/* prettier-ignore */ +:is(.vp-external-link-icon, .vp-doc a[href*='://'], .vp-doc a[target='_blank']):not(.no-icon)::after { + --icon: url("data:image/svg+xml, %3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' %3E%3Cpath d='M0 0h24v24H0V0z' fill='none' /%3E%3Cpath d='M9 5v2h6.59L4 18.59 5.41 20 17 8.41V15h2V5H9z' /%3E%3C/svg%3E"); + display: inline-block; + flex-shrink: 0; + + width: 11px; + height: 11px; + margin-top: -1px; + margin-left: 4px; + + background: currentcolor; + color: var(--vp-c-text-subtle); + + mask-image: var(--icon); +} + +.vp-external-link-icon::after { + content: ''; +} + +/* prettier-ignore */ +.external-link-icon-enabled :is(.vp-doc a[href*='://'], .vp-doc a[target='_blank'])::after { + content: ''; + color: currentcolor; +} diff --git a/themes/theme-next/src/client/styles/components/vp-sponsor.css b/themes/theme-next/src/client/styles/components/vp-sponsor.css new file mode 100644 index 0000000000..a92e39eaa5 --- /dev/null +++ b/themes/theme-next/src/client/styles/components/vp-sponsor.css @@ -0,0 +1,162 @@ +/** + * VPSponsors styles are defined as global because a new class gets + * allied in onMounted` hook and we can't use scoped style. + */ +.vp-sponsor { + overflow: hidden; + border-radius: 16px; +} + +.vp-sponsor.aside { + border-radius: 12px; +} + +.vp-sponsor-section + .vp-sponsor-section { + margin-top: 4px; +} + +.vp-sponsor-tier { + width: 100%; + margin: 0 0 4px !important; + + background-color: var(--vp-c-bg-soft); + color: var(--vp-c-text-mute); + + font-weight: 600; + line-height: 24px; + letter-spacing: 1px !important; + text-align: center; +} + +.vp-sponsor.normal .vp-sponsor-tier { + padding: 13px 0 11px; + font-size: 14px; +} + +.vp-sponsor.aside .vp-sponsor-tier { + padding: 9px 0 7px; + font-size: 12px; +} + +.vp-sponsor-grid + .vp-sponsor-tier { + margin-top: 4px; +} + +.vp-sponsor-grid { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.vp-sponsor-grid.xmini .vp-sponsor-grid-link { + height: 64px; +} + +.vp-sponsor-grid.xmini .vp-sponsor-grid-image { + max-width: 64px; + max-height: 22px; +} + +.vp-sponsor-grid.mini .vp-sponsor-grid-link { + height: 72px; +} + +.vp-sponsor-grid.mini .vp-sponsor-grid-image { + max-width: 96px; + max-height: 24px; +} + +.vp-sponsor-grid.small .vp-sponsor-grid-link { + height: 96px; +} + +.vp-sponsor-grid.small .vp-sponsor-grid-image { + max-width: 96px; + max-height: 24px; +} + +.vp-sponsor-grid.medium .vp-sponsor-grid-link { + height: 112px; +} + +.vp-sponsor-grid.medium .vp-sponsor-grid-image { + max-width: 120px; + max-height: 36px; +} + +.vp-sponsor-grid.big .vp-sponsor-grid-link { + height: 184px; +} + +.vp-sponsor-grid.big .vp-sponsor-grid-image { + max-width: 192px; + max-height: 56px; +} + +.vp-sponsor-grid[data-vp-grid='2'] .vp-sponsor-grid-item { + width: calc((100% - 4px) / 2); +} + +.vp-sponsor-grid[data-vp-grid='3'] .vp-sponsor-grid-item { + width: calc((100% - 4px * 2) / 3); +} + +.vp-sponsor-grid[data-vp-grid='4'] .vp-sponsor-grid-item { + width: calc((100% - 4px * 3) / 4); +} + +.vp-sponsor-grid[data-vp-grid='5'] .vp-sponsor-grid-item { + width: calc((100% - 4px * 4) / 5); +} + +.vp-sponsor-grid[data-vp-grid='6'] .vp-sponsor-grid-item { + width: calc((100% - 4px * 5) / 6); +} + +.vp-sponsor-grid-item { + flex-shrink: 0; + width: 100%; + background-color: var(--vp-c-bg-soft); + transition: background-color 0.25s; +} + +.vp-sponsor-grid-item:hover { + background-color: var(--vp-c-default-soft); +} + +.vp-sponsor-grid-item:hover .vp-sponsor-grid-image { + filter: grayscale(0) invert(0); +} + +.vp-sponsor-grid-item.empty:hover { + background-color: var(--vp-c-bg-soft); +} + +[data-theme='dark'] .vp-sponsor-grid-item:hover { + background-color: var(--vp-c-white); +} + +[data-theme='dark'] .vp-sponsor-grid-item.empty:hover { + background-color: var(--vp-c-bg-soft); +} + +.vp-sponsor-grid-link { + display: flex; +} + +.vp-sponsor-grid-box { + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +.vp-sponsor-grid-image { + max-width: 100%; + filter: grayscale(1); + transition: filter 0.25s; +} + +[data-theme='dark'] .vp-sponsor-grid-image { + filter: grayscale(1) invert(1); +} diff --git a/themes/theme-next/src/client/styles/fonts.css b/themes/theme-next/src/client/styles/fonts.css new file mode 100644 index 0000000000..262fb19cef --- /dev/null +++ b/themes/theme-next/src/client/styles/fonts.css @@ -0,0 +1,189 @@ +@font-face { + font-weight: 100 900; + font-style: normal; + font-family: Inter; + src: url('../fonts/inter-roman-cyrillic-ext.woff2') format('woff2'); + + font-display: swap; + unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, + U+FE2E-FE2F; +} + +@font-face { + font-weight: 100 900; + font-style: normal; + font-family: Inter; + src: url('../fonts/inter-roman-cyrillic.woff2') format('woff2'); + + font-display: swap; + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +@font-face { + font-weight: 100 900; + font-style: normal; + font-family: Inter; + src: url('../fonts/inter-roman-greek-ext.woff2') format('woff2'); + + font-display: swap; + unicode-range: U+1F00-1FFF; +} + +@font-face { + font-weight: 100 900; + font-style: normal; + font-family: Inter; + src: url('../fonts/inter-roman-greek.woff2') format('woff2'); + + font-display: swap; + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, + U+03A3-03FF; +} + +@font-face { + font-weight: 100 900; + font-style: normal; + font-family: Inter; + src: url('../fonts/inter-roman-vietnamese.woff2') format('woff2'); + + font-display: swap; + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, + U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, + U+1EA0-1EF9, U+20AB; +} + +@font-face { + font-weight: 100 900; + font-style: normal; + font-family: Inter; + src: url('../fonts/inter-roman-latin-ext.woff2') format('woff2'); + + font-display: swap; + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, + U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-weight: 100 900; + font-style: normal; + font-family: Inter; + src: url('../fonts/inter-roman-latin.woff2') format('woff2'); + + font-display: swap; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, + U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-weight: 100 900; + font-style: italic; + font-family: Inter; + src: url('../fonts/inter-italic-cyrillic-ext.woff2') format('woff2'); + + font-display: swap; + unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, + U+FE2E-FE2F; +} + +@font-face { + font-weight: 100 900; + font-style: italic; + font-family: Inter; + src: url('../fonts/inter-italic-cyrillic.woff2') format('woff2'); + + font-display: swap; + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +@font-face { + font-weight: 100 900; + font-style: italic; + font-family: Inter; + src: url('../fonts/inter-italic-greek-ext.woff2') format('woff2'); + + font-display: swap; + unicode-range: U+1F00-1FFF; +} + +@font-face { + font-weight: 100 900; + font-style: italic; + font-family: Inter; + src: url('../fonts/inter-italic-greek.woff2') format('woff2'); + + font-display: swap; + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, + U+03A3-03FF; +} + +@font-face { + font-weight: 100 900; + font-style: italic; + font-family: Inter; + src: url('../fonts/inter-italic-vietnamese.woff2') format('woff2'); + + font-display: swap; + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, + U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, + U+1EA0-1EF9, U+20AB; +} + +@font-face { + font-weight: 100 900; + font-style: italic; + font-family: Inter; + src: url('../fonts/inter-italic-latin-ext.woff2') format('woff2'); + + font-display: swap; + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, + U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-weight: 100 900; + font-style: italic; + font-family: Inter; + src: url('../fonts/inter-italic-latin.woff2') format('woff2'); + + font-display: swap; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, + U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-weight: 400; + font-family: 'Punctuation SC'; + src: local('PingFang SC Regular'), local('Noto Sans CJK SC'), + local('Microsoft YaHei'); + unicode-range: U+201C, U+201D, U+2018, U+2019, U+2E3A, U+2014, U+2013, U+2026, + U+00B7, U+007E, U+002F; +} + +@font-face { + font-weight: 500; + font-family: 'Punctuation SC'; + src: local('PingFang SC Medium'), local('Noto Sans CJK SC'), + local('Microsoft YaHei'); + unicode-range: U+201C, U+201D, U+2018, U+2019, U+2E3A, U+2014, U+2013, U+2026, + U+00B7, U+007E, U+002F; +} + +@font-face { + font-weight: 600; + font-family: 'Punctuation SC'; + src: local('PingFang SC Semibold'), local('Noto Sans CJK SC Bold'), + local('Microsoft YaHei Bold'); + unicode-range: U+201C, U+201D, U+2018, U+2019, U+2E3A, U+2014, U+2013, U+2026, + U+00B7, U+007E, U+002F; +} + +@font-face { + font-weight: 700; + font-family: 'Punctuation SC'; + src: local('PingFang SC Semibold'), local('Noto Sans CJK SC Bold'), + local('Microsoft YaHei Bold'); + unicode-range: U+201C, U+201D, U+2018, U+2019, U+2E3A, U+2014, U+2013, U+2026, + U+00B7, U+007E, U+002F; +} diff --git a/themes/theme-next/src/client/styles/icons.css b/themes/theme-next/src/client/styles/icons.css new file mode 100644 index 0000000000..8614c13408 --- /dev/null +++ b/themes/theme-next/src/client/styles/icons.css @@ -0,0 +1,154 @@ +[class^='vpi-'], +[class*=' vpi-'], +.vp-icon { + width: 1em; + height: 1em; +} + +[class^='vpi-'].bg, +[class*=' vpi-'].bg, +.vp-icon.bg { + background-color: transparent; + background-size: 100% 100%; +} + +[class^='vpi-']:not(.bg), +[class*=' vpi-']:not(.bg), +.vp-icon:not(.bg) { + background-color: currentcolor; + color: inherit; + mask: var(--icon) no-repeat; + mask-size: 100% 100%; +} + +/* internal icons - used under ISC from https://lucide.dev/ */ +.vpi-align-left { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath d='M21 6H3M15 12H3M17 18H3'/%3E%3C/svg%3E"); +} + +.vpi-arrow-right, +.vpi-arrow-down, +.vpi-arrow-left, +.vpi-arrow-up { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14M12 5l7 7-7 7'/%3E%3C/svg%3E"); +} + +.vpi-chevron-right, +.vpi-chevron-down, +.vpi-chevron-left, +.vpi-chevron-up { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E"); +} + +.vpi-chevron-down, +.vpi-arrow-down { + transform: rotate(90deg); +} + +.vpi-chevron-left, +.vpi-arrow-left { + transform: rotate(180deg); +} + +.vpi-chevron-up, +.vpi-arrow-up { + transform: rotate(-90deg); +} + +.vpi-square-pen { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath d='M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7'/%3E%3Cpath d='M18.375 2.625a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4Z'/%3E%3C/svg%3E"); +} + +.vpi-plus { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14M12 5v14'/%3E%3C/svg%3E"); +} + +.vpi-sun { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41'/%3E%3C/svg%3E"); +} + +.vpi-moon { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath d='M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z'/%3E%3C/svg%3E"); +} + +.vpi-more-horizontal { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='1'/%3E%3Ccircle cx='19' cy='12' r='1'/%3E%3Ccircle cx='5' cy='12' r='1'/%3E%3C/svg%3E"); +} + +.vpi-languages { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath d='m5 8 6 6M4 14l6-6 2-3M2 5h12M7 2h1M22 22l-5-10-5 10M14 18h6'/%3E%3C/svg%3E"); +} + +.vpi-heart { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath d='M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z'/%3E%3C/svg%3E"); +} + +.vpi-search { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E"); +} + +.vpi-layout-list { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Crect width='7' height='7' x='3' y='3' rx='1'/%3E%3Crect width='7' height='7' x='3' y='14' rx='1'/%3E%3Cpath d='M14 4h7M14 9h7M14 15h7M14 20h7'/%3E%3C/svg%3E"); +} + +.vpi-delete { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath d='M20 5H9l-7 7 7 7h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2ZM18 9l-6 6M12 9l6 6'/%3E%3C/svg%3E"); +} + +.vpi-corner-down-left { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath d='m9 10-5 5 5 5'/%3E%3Cpath d='M20 4v7a4 4 0 0 1-4 4H4'/%3E%3C/svg%3E"); +} + +.vpi-back-to-top { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='4' d='M24.008 14.1V42M12 26l12-12l12 12M12 6h24' /%3E%3C/svg%3E"); +} + +:root { + /* clipboard */ + --vp-icon-copy: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='rgba(128,128,128,1)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Crect width='8' height='4' x='8' y='2' rx='1' ry='1'/%3E%3Cpath d='M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2'/%3E%3C/svg%3E"); + + /* clipboard-copy */ + --vp-icon-copied: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='rgba(128,128,128,1)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'%3E%3Crect width='8' height='4' x='8' y='2' rx='1' ry='1'/%3E%3Cpath d='M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2'/%3E%3Cpath d='m9 14 2 2 4-4'/%3E%3C/svg%3E"); +} + +/* social icons - used under CC0 1.0 from https://simpleicons.org/ */ +.vpi-social-discord { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418Z'/%3E%3C/svg%3E"); +} + +.vpi-social-facebook { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z'/%3E%3C/svg%3E"); +} + +.vpi-social-github { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E"); +} + +.vpi-social-instagram { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M7.03.084c-1.277.06-2.149.264-2.91.563a5.874 5.874 0 0 0-2.124 1.388 5.878 5.878 0 0 0-1.38 2.127C.321 4.926.12 5.8.064 7.076.008 8.354-.005 8.764.001 12.023c.007 3.259.021 3.667.083 4.947.061 1.277.264 2.149.563 2.911.308.789.72 1.457 1.388 2.123a5.872 5.872 0 0 0 2.129 1.38c.763.295 1.636.496 2.913.552 1.278.056 1.689.069 4.947.063 3.257-.007 3.668-.021 4.947-.082 1.28-.06 2.147-.265 2.91-.563a5.881 5.881 0 0 0 2.123-1.388 5.881 5.881 0 0 0 1.38-2.129c.295-.763.496-1.636.551-2.912.056-1.28.07-1.69.063-4.948-.006-3.258-.02-3.667-.081-4.947-.06-1.28-.264-2.148-.564-2.911a5.892 5.892 0 0 0-1.387-2.123 5.857 5.857 0 0 0-2.128-1.38C19.074.322 18.202.12 16.924.066 15.647.009 15.236-.006 11.977 0 8.718.008 8.31.021 7.03.084m.14 21.693c-1.17-.05-1.805-.245-2.228-.408a3.736 3.736 0 0 1-1.382-.895 3.695 3.695 0 0 1-.9-1.378c-.165-.423-.363-1.058-.417-2.228-.06-1.264-.072-1.644-.08-4.848-.006-3.204.006-3.583.061-4.848.05-1.169.246-1.805.408-2.228.216-.561.477-.96.895-1.382a3.705 3.705 0 0 1 1.379-.9c.423-.165 1.057-.361 2.227-.417 1.265-.06 1.644-.072 4.848-.08 3.203-.006 3.583.006 4.85.062 1.168.05 1.804.244 2.227.408.56.216.96.475 1.382.895.421.42.681.817.9 1.378.165.422.362 1.056.417 2.227.06 1.265.074 1.645.08 4.848.005 3.203-.006 3.583-.061 4.848-.051 1.17-.245 1.805-.408 2.23-.216.56-.477.96-.896 1.38a3.705 3.705 0 0 1-1.378.9c-.422.165-1.058.362-2.226.418-1.266.06-1.645.072-4.85.079-3.204.007-3.582-.006-4.848-.06m9.783-16.192a1.44 1.44 0 1 0 1.437-1.442 1.44 1.44 0 0 0-1.437 1.442M5.839 12.012a6.161 6.161 0 1 0 12.323-.024 6.162 6.162 0 0 0-12.323.024M8 12.008A4 4 0 1 1 12.008 16 4 4 0 0 1 8 12.008'/%3E%3C/svg%3E"); +} + +.vpi-social-linkedin { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z'/%3E%3C/svg%3E"); +} + +.vpi-social-mastodon { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z'/%3E%3C/svg%3E"); +} + +.vpi-social-npm { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M1.763 0C.786 0 0 .786 0 1.763v20.474C0 23.214.786 24 1.763 24h20.474c.977 0 1.763-.786 1.763-1.763V1.763C24 .786 23.214 0 22.237 0zM5.13 5.323l13.837.019-.009 13.836h-3.464l.01-10.382h-3.456L12.04 19.17H5.113z'/%3E%3C/svg%3E"); +} + +.vpi-social-slack { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z'/%3E%3C/svg%3E"); +} + +.vpi-social-twitter, +.vpi-social-x { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z'/%3E%3C/svg%3E"); +} + +.vpi-social-youtube { + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z'/%3E%3C/svg%3E"); +} diff --git a/themes/theme-next/src/client/styles/index.css b/themes/theme-next/src/client/styles/index.css new file mode 100644 index 0000000000..a26f9e9eb5 --- /dev/null +++ b/themes/theme-next/src/client/styles/index.css @@ -0,0 +1,11 @@ +@import url('./vars.css'); +@import url('./fonts.css'); +@import url('./base.css'); +@import url('./icons.css'); +@import url('./utils.css'); +@import url('./components/custom-block.css'); +@import url('./components/vp-code.css'); +@import url('./components/vp-code-tabs.css'); +@import url('./components/vp-doc.css'); +@import url('./components/vp-sponsor.css'); +@import url('./compat.css'); diff --git a/themes/theme-next/src/client/styles/utils.css b/themes/theme-next/src/client/styles/utils.css new file mode 100644 index 0000000000..8b2e80cd7a --- /dev/null +++ b/themes/theme-next/src/client/styles/utils.css @@ -0,0 +1,12 @@ +.visually-hidden { + position: absolute; + + overflow: hidden; + clip-path: inset(50%); + clip: rect(0 0 0 0); + + width: 1px; + height: 1px; + + white-space: nowrap; +} diff --git a/themes/theme-next/src/client/styles/vars.css b/themes/theme-next/src/client/styles/vars.css new file mode 100644 index 0000000000..a1ea41970e --- /dev/null +++ b/themes/theme-next/src/client/styles/vars.css @@ -0,0 +1,603 @@ +/** + * Colors: Solid + * -------------------------------------------------------------------------- */ + +:root { + --vp-c-white: #fff; + --vp-c-black: #000; + + --vp-c-neutral: var(--vp-c-black); + --vp-c-neutral-inverse: var(--vp-c-white); +} + +[data-theme='dark'] { + --vp-c-neutral: var(--vp-c-white); + --vp-c-neutral-inverse: var(--vp-c-black); +} + +/** + * Colors: Palette + * + * The primitive colors used for accent colors. These colors are referenced + * by functional colors such as "Text", "Background", or "Brand". + * + * Each colors have exact same color scale system with 3 levels of solid + * colors with different brightness, and 1 soft color. + * + * - `XXX-1`: The most solid color used mainly for colored text. It must + * satisfy the contrast ratio against when used on top of `XXX-soft`. + * + * - `XXX-2`: The color used mainly for hover state of the button. + * + * - `XXX-3`: The color for solid background, such as bg color of the button. + * It must satisfy the contrast ratio with pure white (#ffffff) text on + * top of it. + * + * - `XXX-soft`: The color used for subtle background such as custom container + * or badges. It must satisfy the contrast ratio when putting `XXX-1` colors + * on top of it. + * + * The soft color must be semi transparent alpha channel. This is crucial + * because it allows adding multiple "soft" colors on top of each other + * to create a accent, such as when having inline code block inside + * custom containers. + * -------------------------------------------------------------------------- */ + +:root { + --vp-c-gray-1: #dddde3; + --vp-c-gray-2: #e4e4e9; + --vp-c-gray-3: #ebebef; + --vp-c-gray-soft: rgb(142 150 170 / 14%); + + --vp-c-indigo-1: #3451b2; + --vp-c-indigo-2: #3a5ccc; + --vp-c-indigo-3: #5672cd; + --vp-c-indigo-soft: rgb(100 108 255 / 14%); + + --vp-c-purple-1: #6f42c1; + --vp-c-purple-2: #7e4cc9; + --vp-c-purple-3: #8e5cd9; + --vp-c-purple-soft: rgb(159 122 234 / 14%); + + --vp-c-green-1: #18794e; + --vp-c-green-2: #299764; + --vp-c-green-3: #30a46c; + --vp-c-green-soft: rgb(16 185 129 / 14%); + + --vp-c-yellow-1: #915930; + --vp-c-yellow-2: #946300; + --vp-c-yellow-3: #9f6a00; + --vp-c-yellow-soft: rgb(234 179 8 / 14%); + + --vp-c-red-1: #b8272c; + --vp-c-red-2: #d5393e; + --vp-c-red-3: #e0575b; + --vp-c-red-soft: rgb(244 63 94 / 14%); + + --vp-c-sponsor: #db2777; +} + +[data-theme='dark'] { + --vp-c-gray-1: #515c67; + --vp-c-gray-2: #414853; + --vp-c-gray-3: #32363f; + --vp-c-gray-soft: rgb(101 117 133 / 16%); + + --vp-c-indigo-1: #a8b1ff; + --vp-c-indigo-2: #5c73e7; + --vp-c-indigo-3: #3e63dd; + --vp-c-indigo-soft: rgb(100 108 255 / 16%); + + --vp-c-purple-1: #c8abfa; + --vp-c-purple-2: #a879e6; + --vp-c-purple-3: #8e5cd9; + --vp-c-purple-soft: rgb(159 122 234 / 16%); + + --vp-c-green-1: #3dd68c; + --vp-c-green-2: #30a46c; + --vp-c-green-3: #298459; + --vp-c-green-soft: rgb(16 185 129 / 16%); + + --vp-c-yellow-1: #f9b44e; + --vp-c-yellow-2: #da8b17; + --vp-c-yellow-3: #a46a0a; + --vp-c-yellow-soft: rgb(234 179 8 / 16%); + + --vp-c-red-1: #f66f81; + --vp-c-red-2: #f14158; + --vp-c-red-3: #b62a3c; + --vp-c-red-soft: rgb(244 63 94 / 16%); +} + +/** + * Colors: Background + * + * - `bg`: The bg color used for main screen. + * + * - `bg-alt`: The alternative bg color used in places such as "sidebar", + * or "code block". + * + * - `bg-elv`: The elevated bg color. This is used at parts where it "floats", + * such as "dialog". + * + * - `bg-soft`: The bg color to slightly distinguish some components from + * the page. Used for things like "carbon ads" or "table". + * -------------------------------------------------------------------------- */ + +:root { + --vp-c-bg: #fff; + --vp-c-bg-alt: #f6f6f7; + --vp-c-bg-elv: #fff; + --vp-c-bg-soft: #f6f6f7; +} + +[data-theme='dark'] { + --vp-c-bg: #1b1b1f; + --vp-c-bg-alt: #161618; + --vp-c-bg-elv: #202127; + --vp-c-bg-soft: #202127; +} + +/** + * Colors: Borders + * + * - `divider`: This is used for separators. This is used to divide sections + * within the same components, such as having separator on "h2" heading. + * + * - `border`: This is designed for borders on interactive components. + * For example this should be used for a button outline. + * + * - `gutter`: This is used to divide components in the page. For example + * the header and the lest of the page. + * -------------------------------------------------------------------------- */ + +:root { + --vp-c-border: #c2c2c4; + --vp-c-border-hard: #b2b2b4; + --vp-c-divider: #e2e2e3; + --vp-c-gutter: #e2e2e3; +} + +[data-theme='dark'] { + --vp-c-border: #3c3f44; + --vp-c-divider: #2e2e32; + --vp-c-gutter: #000; +} + +/** + * Colors: Text + * + * - `text`: Used for primary text. + * + * - `text-mute`: Used for muted texts, such as "inactive menu" or "info texts". + * + * - `text-subtle`: Used for subtle texts, such as "placeholders" or "caret icon". + * -------------------------------------------------------------------------- */ + +:root { + --vp-c-text: rgb(60 60 67); + --vp-c-text-mute: rgb(60 60 67 / 78%); + --vp-c-text-subtle: rgb(60 60 67 / 56%); +} + +[data-theme='dark'] { + --vp-c-text: rgb(255 255 245 / 86%); + --vp-c-text-mute: rgb(235 235 245 / 60%); + --vp-c-text-subtle: rgb(235 235 245 / 38%); +} + +/** + * Colors: Function + * + * - `default`: The color used purely for subtle indication without any + * special meanings attached to it such as bg color for menu hover state. + * + * - `brand`: Used for primary brand colors, such as link text, button with + * brand theme, etc. + * + * - `tip`: Used to indicate useful information. The default theme uses the + * brand color for this by default. + * + * - `warning`: Used to indicate warning to the users. Used in custom + * container, badges, etc. + * + * - `danger`: Used to show error, or dangerous message to the users. Used + * in custom container, badges, etc. + * + * To understand the scaling system, refer to "Colors: Palette" section. + * -------------------------------------------------------------------------- */ + +:root { + --vp-c-default-1: var(--vp-c-gray-1); + --vp-c-default-2: var(--vp-c-gray-2); + --vp-c-default-3: var(--vp-c-gray-3); + --vp-c-default-soft: var(--vp-c-gray-soft); + + --vp-c-accent: var(--vp-c-indigo-1); + --vp-c-accent-hover: var(--vp-c-indigo-2); + --vp-c-accent-bg: var(--vp-c-indigo-3); + --vp-c-accent-text: var(--vp-c-indigo-1); + --vp-c-accent-soft: var(--vp-c-indigo-soft); + + --vp-c-tip-1: var(--vp-c-accent); + --vp-c-tip-2: var(--vp-c-accent-hover); + --vp-c-tip-3: var(--vp-c-accent-bg); + --vp-c-tip-soft: var(--vp-c-accent-soft); + + --vp-c-note-1: var(--vp-c-accent); + --vp-c-note-2: var(--vp-c-accent-hover); + --vp-c-note-3: var(--vp-c-accent-bg); + --vp-c-note-soft: var(--vp-c-accent-soft); + + --vp-c-success-1: var(--vp-c-green-1); + --vp-c-success-2: var(--vp-c-green-2); + --vp-c-success-3: var(--vp-c-green-3); + --vp-c-success-soft: var(--vp-c-green-soft); + + --vp-c-important-1: var(--vp-c-purple-1); + --vp-c-important-2: var(--vp-c-purple-2); + --vp-c-important-3: var(--vp-c-purple-3); + --vp-c-important-soft: var(--vp-c-purple-soft); + + --vp-c-warning-1: var(--vp-c-yellow-1); + --vp-c-warning-2: var(--vp-c-yellow-2); + --vp-c-warning-3: var(--vp-c-yellow-3); + --vp-c-warning-soft: var(--vp-c-yellow-soft); + + --vp-c-danger-1: var(--vp-c-red-1); + --vp-c-danger-2: var(--vp-c-red-2); + --vp-c-danger-3: var(--vp-c-red-3); + --vp-c-danger-soft: var(--vp-c-red-soft); + + --vp-c-caution-1: var(--vp-c-red-1); + --vp-c-caution-2: var(--vp-c-red-2); + --vp-c-caution-3: var(--vp-c-red-3); + --vp-c-caution-soft: var(--vp-c-red-soft); +} + +/** + * Typography + * -------------------------------------------------------------------------- */ + +:root { + --vp-font-family-base: 'Inter', ui-sans-serif, system-ui, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --vp-font-family-mono: ui-monospace, 'Menlo', 'Monaco', 'Consolas', + 'Liberation Mono', 'Courier New', monospace; + font-optical-sizing: auto; +} + +:root:where(:lang(zh)) { + --vp-font-family-base: 'Punctuation SC', 'Inter', ui-sans-serif, system-ui, + sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; +} + +/** + * Shadows + * -------------------------------------------------------------------------- */ + +:root { + --vp-shadow-1: 0 1px 2px rgb(0 0 0 / 4%), 0 1px 2px rgb(0 0 0 / 6%); + --vp-shadow-2: 0 3px 12px rgb(0 0 0 / 7%), 0 1px 4px rgb(0 0 0 / 7%); + --vp-shadow-3: 0 12px 32px rgb(0 0 0 / 10%), 0 2px 6px rgb(0 0 0 / 8%); + --vp-shadow-4: 0 14px 44px rgb(0 0 0 / 12%), 0 3px 9px rgb(0 0 0 / 12%); + --vp-shadow-5: 0 18px 56px rgb(0 0 0 / 16%), 0 4px 12px rgb(0 0 0 / 16%); + + --vp-shadow: var(--vp-shadow-1); + --vp-shadow-hard: var(--vp-shadow-3); +} + +/** + * Z-indexes + * -------------------------------------------------------------------------- */ + +:root { + --vp-z-index-footer: 10; + --vp-z-index-local-nav: 20; + --vp-z-index-nav: 30; + --vp-z-index-layout-top: 40; + --vp-z-index-backdrop: 50; + --vp-z-index-sidebar: 60; +} + +@media (min-width: 960px) { + :root { + --vp-z-index-sidebar: 25; + } +} + +/** + * Transitions + * -------------------------------------------------------------------------- */ + +:root { + --vp-t-color: ease 0.3s; + --vp-t-transform: ease 0.3s; +} + +/** + * Layouts + * -------------------------------------------------------------------------- */ + +:root { + --vp-layout-max-width: 1440px; +} + +/** + * Controls + * -------------------------------------------------------------------------- */ +:root { + --vp-c-control: var(--vp-c-default-3); + --vp-c-control-hover: var(--vp-c-default-2); + --vp-c-control-active: var(--vp-c-default-1); + --vp-c-control-disabled: var(--vp-c-default-soft); +} + +/** + * Component: Header Anchor + * -------------------------------------------------------------------------- */ + +:root { + --vp-header-anchor-symbol: '#'; +} + +/** + * Component: Tabs + * -------------------------------------------------------------------------- */ +:root { + --tab-c-bg-nav: var(--vp-c-gray-soft); +} + +/** + * Component: Code + * -------------------------------------------------------------------------- */ + +:root { + --vp-code-line-height: 1.7; + --vp-code-font-size: 0.875em; + --vp-code-color: var(--vp-c-accent); + --vp-code-link-color: var(--vp-c-accent); + --vp-code-link-hover-color: var(--vp-c-accent-hover); + --vp-code-bg: var(--vp-c-default-soft); + + --vp-code-block-color: var(--vp-c-text-mute); + --vp-code-block-bg: var(--vp-c-bg-alt); + --vp-code-block-divider-color: var(--vp-c-gutter); + + --vp-code-lang-color: var(--vp-c-text-subtle); + + --vp-code-line-highlight-color: var(--vp-c-default-soft); + --vp-code-line-number-color: var(--vp-c-text-subtle); + + --vp-code-line-diff-add-color: var(--vp-c-success-soft); + --vp-code-line-diff-add-symbol-color: var(--vp-c-success-1); + + --vp-code-line-diff-remove-color: var(--vp-c-danger-soft); + --vp-code-line-diff-remove-symbol-color: var(--vp-c-danger-1); + + --vp-code-line-warning-color: var(--vp-c-warning-soft); + --vp-code-line-error-color: var(--vp-c-danger-soft); + + --vp-code-copy-code-border-color: var(--vp-c-divider); + --vp-code-copy-code-bg: var(--vp-c-bg-soft); + --vp-code-copy-code-hover-border-color: var(--vp-c-divider); + --vp-code-copy-code-hover-bg: var(--vp-c-bg); + --vp-code-copy-code-active-text: var(--vp-c-text-mute); + --vp-code-copy-copied-text-content: 'Copied'; + + --vp-code-tab-divider: var(--vp-code-block-divider-color); + --vp-code-tab-text-color: var(--vp-c-text-mute); + --vp-code-tab-bg: var(--vp-code-block-bg); + --vp-code-tab-hover-text-color: var(--vp-c-text); + --vp-code-tab-active-text-color: var(--vp-c-text); + --vp-code-tab-active-bar-color: var(--vp-c-accent); +} + +/** + * Component: Button + * -------------------------------------------------------------------------- */ + +:root { + --vp-button-brand-border: transparent; + --vp-button-brand-text: var(--vp-c-white); + --vp-button-brand-bg: var(--vp-c-accent-bg); + --vp-button-brand-hover-border: transparent; + --vp-button-brand-hover-text: var(--vp-c-white); + --vp-button-brand-hover-bg: var(--vp-c-accent-hover); + --vp-button-brand-active-border: transparent; + --vp-button-brand-active-text: var(--vp-c-white); + --vp-button-brand-active-bg: var(--vp-c-accent); + + --vp-button-alt-border: transparent; + --vp-button-alt-text: var(--vp-c-text); + --vp-button-alt-bg: var(--vp-c-default-3); + --vp-button-alt-hover-border: transparent; + --vp-button-alt-hover-text: var(--vp-c-text); + --vp-button-alt-hover-bg: var(--vp-c-default-2); + --vp-button-alt-active-border: transparent; + --vp-button-alt-active-text: var(--vp-c-text); + --vp-button-alt-active-bg: var(--vp-c-default-1); + + --vp-button-sponsor-border: var(--vp-c-text-mute); + --vp-button-sponsor-text: var(--vp-c-text-mute); + --vp-button-sponsor-bg: transparent; + --vp-button-sponsor-hover-border: var(--vp-c-sponsor); + --vp-button-sponsor-hover-text: var(--vp-c-sponsor); + --vp-button-sponsor-hover-bg: transparent; + --vp-button-sponsor-active-border: var(--vp-c-sponsor); + --vp-button-sponsor-active-text: var(--vp-c-sponsor); + --vp-button-sponsor-active-bg: transparent; +} + +/** + * Component: Custom Block + * -------------------------------------------------------------------------- */ + +:root { + --vp-custom-block-font-size: 14px; + --vp-custom-block-code-font-size: 13px; + + --vp-custom-block-info-border: transparent; + --vp-custom-block-info-text: var(--vp-c-text); + --vp-custom-block-info-bg: var(--vp-c-default-soft); + --vp-custom-block-info-code-bg: var(--vp-c-default-soft); + --vp-custom-block-info-icon-color: var(--vp-c-text-subtle); + + --vp-custom-block-note-border: transparent; + --vp-custom-block-note-text: var(--vp-c-text); + --vp-custom-block-note-bg: var(--vp-c-default-soft); + --vp-custom-block-note-code-bg: var(--vp-c-default-soft); + --vp-custom-block-note-icon-color: var(--vp-c-text-subtle); + + --vp-custom-block-tip-border: transparent; + --vp-custom-block-tip-text: var(--vp-c-text); + --vp-custom-block-tip-bg: var(--vp-c-tip-soft); + --vp-custom-block-tip-code-bg: var(--vp-c-tip-soft); + --vp-custom-block-tip-icon-color: var(--vp-c-tip-3); + + --vp-custom-block-important-border: transparent; + --vp-custom-block-important-text: var(--vp-c-text); + --vp-custom-block-important-bg: var(--vp-c-important-soft); + --vp-custom-block-important-code-bg: var(--vp-c-important-soft); + --vp-custom-block-important-icon-color: var(--vp-c-important-3); + + --vp-custom-block-warning-border: transparent; + --vp-custom-block-warning-text: var(--vp-c-text); + --vp-custom-block-warning-bg: var(--vp-c-warning-soft); + --vp-custom-block-warning-code-bg: var(--vp-c-warning-soft); + --vp-custom-block-warning-icon-color: var(--vp-c-warning-3); + + --vp-custom-block-danger-border: transparent; + --vp-custom-block-danger-text: var(--vp-c-text); + --vp-custom-block-danger-bg: var(--vp-c-danger-soft); + --vp-custom-block-danger-code-bg: var(--vp-c-danger-soft); + --vp-custom-block-danger-icon-color: var(--vp-c-danger-3); + + --vp-custom-block-caution-border: transparent; + --vp-custom-block-caution-text: var(--vp-c-text); + --vp-custom-block-caution-bg: var(--vp-c-caution-soft); + --vp-custom-block-caution-code-bg: var(--vp-c-caution-soft); + --vp-custom-block-caution-icon-color: var(--vp-c-caution-3); + + --vp-custom-block-details-border: var(--vp-custom-block-info-border); + --vp-custom-block-details-text: var(--vp-custom-block-info-text); + --vp-custom-block-details-bg: var(--vp-custom-block-info-bg); + --vp-custom-block-details-code-bg: var(--vp-custom-block-info-code-bg); +} + +/** + * Component: Input + * -------------------------------------------------------------------------- */ + +:root { + --vp-input-border-color: var(--vp-c-border); + --vp-input-bg-color: var(--vp-c-bg-alt); + + --vp-input-switch-bg-color: var(--vp-c-default-soft); +} + +/** + * Component: Nav + * -------------------------------------------------------------------------- */ + +:root { + --vp-nav-height: 64px; + --vp-nav-bg-color: var(--vp-c-bg); + --vp-nav-screen-bg-color: var(--vp-c-bg); + --vp-nav-logo-height: 24px; +} + +.hide-nav { + --vp-nav-height: 0; +} + +.hide-nav .vp-sidebar { + --vp-nav-height: 22px; +} + +/** + * Component: Local Nav + * -------------------------------------------------------------------------- */ + +:root { + --vp-local-nav-bg-color: var(--vp-c-bg); +} + +/** + * Component: Sidebar + * -------------------------------------------------------------------------- */ + +:root { + --vp-sidebar-width: 272px; + --vp-sidebar-bg-color: var(--vp-c-bg-alt); +} + +/** + * Colors Backdrop + * -------------------------------------------------------------------------- */ + +:root { + --vp-backdrop-bg-color: rgb(0 0 0 / 60%); +} + +/** + * Component: Home + * -------------------------------------------------------------------------- */ + +:root { + --vp-home-hero-name-color: var(--vp-c-accent); + --vp-home-hero-name-background: transparent; + + --vp-home-hero-image-background-image: none; + --vp-home-hero-image-filter: none; +} + +/** + * Component: Badge + * -------------------------------------------------------------------------- */ + +:root { + --vp-badge-info-border: transparent; + --vp-badge-info-text: var(--vp-c-text-mute); + --vp-badge-info-bg: var(--vp-c-default-soft); + + --vp-badge-tip-border: transparent; + --vp-badge-tip-text: var(--vp-c-tip-1); + --vp-badge-tip-bg: var(--vp-c-tip-soft); + + --vp-badge-warning-border: transparent; + --vp-badge-warning-text: var(--vp-c-warning-1); + --vp-badge-warning-bg: var(--vp-c-warning-soft); + + --vp-badge-danger-border: transparent; + --vp-badge-danger-text: var(--vp-c-danger-1); + --vp-badge-danger-bg: var(--vp-c-danger-soft); +} + +/** + * Component: Carbon Ads + * -------------------------------------------------------------------------- */ + +:root { + --vp-carbon-ads-text-color: var(--vp-c-text); + --vp-carbon-ads-poweredby-color: var(--vp-c-text-mute); + --vp-carbon-ads-bg-color: var(--vp-c-bg-soft); + --vp-carbon-ads-hover-text-color: var(--vp-c-accent); + --vp-carbon-ads-hover-poweredby-color: var(--vp-c-text); +} + +/** + * plugin-nprogress + * -------------------------------------------------------------------------- */ +#nprogress { + --nprogress-color: var(--vp-c-accent); +} + +/** + * plugin-photo-swipe + * -------------------------------------------------------------------------- */ +:root { + --photo-swipe-bullet: var(--vp-c-bg); + --photo-swipe-bullet-active: var(--vp-c-accent); +} diff --git a/themes/theme-next/src/client/types.ts b/themes/theme-next/src/client/types.ts new file mode 100644 index 0000000000..80c6dc9151 --- /dev/null +++ b/themes/theme-next/src/client/types.ts @@ -0,0 +1,5 @@ +import type { VNode } from 'vue' + +export type Slot

= never extends P + ? () => VNode | VNode[] | string | null + : (props: P) => VNode | VNode[] | string | null diff --git a/themes/theme-next/src/client/utils/constants.ts b/themes/theme-next/src/client/utils/constants.ts new file mode 100644 index 0000000000..7a1d300ff3 --- /dev/null +++ b/themes/theme-next/src/client/utils/constants.ts @@ -0,0 +1,4 @@ +export const HASH_RE = /#.*$/ +export const EXT_RE = /(index|README)?\.(md|html)$/ + +export const inBrowser = typeof document !== 'undefined' diff --git a/themes/theme-next/src/client/utils/getNavLink.ts b/themes/theme-next/src/client/utils/getNavLink.ts new file mode 100644 index 0000000000..27e0371181 --- /dev/null +++ b/themes/theme-next/src/client/utils/getNavLink.ts @@ -0,0 +1,24 @@ +import { + ensureEndingSlash, + ensureLeadingSlash, + isLinkAbsolute, + isLinkWithProtocol, +} from '@vuepress/helper/client' +import { resolveRoute } from 'vuepress/client' +import type { ResolvedNavItemWithLink } from '../../shared/resolved/navbar.js' + +export const getNavLink = (filepath: string): ResolvedNavItemWithLink => { + const { notFound, path, meta } = resolveRoute<{ title?: string }>(filepath) + if (notFound) { + return { text: path, link: path } + } + return { text: meta.title || path, link: path } +} + +export const normalizeLink = (base = '', link = ''): string => + isLinkAbsolute(link) || isLinkWithProtocol(link) + ? link + : ensureLeadingSlash(`${base}/${link}`.replace(/\/+/g, '/')) + +export const normalizePrefix = (base: string, link = ''): string => + ensureEndingSlash(normalizeLink(base, link)) diff --git a/themes/theme-next/src/client/utils/getScrollOffset.ts b/themes/theme-next/src/client/utils/getScrollOffset.ts new file mode 100644 index 0000000000..72139dfea5 --- /dev/null +++ b/themes/theme-next/src/client/utils/getScrollOffset.ts @@ -0,0 +1,36 @@ +import type { DefaultThemeLocaleData } from '../../shared' + +const tryOffsetSelector = (selector: string, padding: number): number => { + const el = document.querySelector(selector) + if (!el) return 0 + const bot = el.getBoundingClientRect().bottom + if (bot < 0) return 0 + return bot + padding +} + +export const getScrollOffset = ( + scrollOffset?: DefaultThemeLocaleData['scrollOffset'], +): number => { + let offset = 0 + let padding = 24 + if (typeof scrollOffset === 'object' && 'padding' in scrollOffset) { + padding = scrollOffset.padding + // eslint-disable-next-line no-param-reassign + scrollOffset = scrollOffset.selector + } + if (typeof scrollOffset === 'number') { + offset = scrollOffset + } else if (typeof scrollOffset === 'string') { + offset = tryOffsetSelector(scrollOffset, padding) + } else if (Array.isArray(scrollOffset)) { + for (const selector of scrollOffset) { + const res = tryOffsetSelector(selector, padding) + if (res) { + offset = res + break + } + } + } + + return offset +} diff --git a/themes/theme-next/src/client/utils/index.ts b/themes/theme-next/src/client/utils/index.ts new file mode 100644 index 0000000000..7eded5a7f1 --- /dev/null +++ b/themes/theme-next/src/client/utils/index.ts @@ -0,0 +1,5 @@ +export * from './constants.js' +export * from './isActive.js' +export * from './throttleAndDebounce.js' +export * from './getScrollOffset.js' +export * from './getNavLink.js' diff --git a/themes/theme-next/src/client/utils/isActive.ts b/themes/theme-next/src/client/utils/isActive.ts new file mode 100644 index 0000000000..f68949eebc --- /dev/null +++ b/themes/theme-next/src/client/utils/isActive.ts @@ -0,0 +1,25 @@ +import { EXT_RE, HASH_RE, inBrowser } from './constants.js' + +export const normalize = (path: string): string => + decodeURI(path).replace(HASH_RE, '').replace(EXT_RE, '') + +export const isActive = ( + currentPath: string, + matchPath?: string, + asRegex = false, +): boolean => { + if (matchPath === undefined) return false + + // eslint-disable-next-line no-param-reassign + currentPath = normalize(`/${currentPath.replace(/^\//, '')}`) + + if (asRegex) return new RegExp(matchPath).test(currentPath) + + if (normalize(matchPath) !== currentPath) return false + + const hashMatch = matchPath.match(HASH_RE) + + if (hashMatch) return (inBrowser ? window.location.hash : '') === hashMatch[0] + + return true +} diff --git a/themes/theme-next/src/client/utils/throttleAndDebounce.ts b/themes/theme-next/src/client/utils/throttleAndDebounce.ts new file mode 100644 index 0000000000..99dd5ec941 --- /dev/null +++ b/themes/theme-next/src/client/utils/throttleAndDebounce.ts @@ -0,0 +1,19 @@ +export const throttleAndDebounce = ( + fn: () => void, + delay: number, +): (() => void) => { + let timeoutId: NodeJS.Timeout | null = null + let called = false + + return () => { + if (timeoutId) clearTimeout(timeoutId) + + if (!called) { + fn() + called = true + setTimeout(() => { + called = false + }, delay) + } else timeoutId = setTimeout(fn, delay) + } +} diff --git a/themes/theme-next/src/node/config/extendsBundlerOptions.ts b/themes/theme-next/src/node/config/extendsBundlerOptions.ts new file mode 100644 index 0000000000..0b1cf3e0f5 --- /dev/null +++ b/themes/theme-next/src/node/config/extendsBundlerOptions.ts @@ -0,0 +1,10 @@ +import { addViteOptimizeDepsExclude } from '@vuepress/helper' +import type { App, BundlerOptions } from 'vuepress' + +export const extendsBundlerOptions = ( + bundlerOptions: BundlerOptions, + app: App, +): void => { + // ensure theme alias is not optimized by Vite + addViteOptimizeDepsExclude(bundlerOptions, app, '@theme') +} diff --git a/themes/theme-next/src/node/config/index.ts b/themes/theme-next/src/node/config/index.ts new file mode 100644 index 0000000000..c5f88a467d --- /dev/null +++ b/themes/theme-next/src/node/config/index.ts @@ -0,0 +1,3 @@ +export * from './resolveThemeData.js' +export * from './extendsBundlerOptions.js' +export * from './templateBuildRenderer.js' diff --git a/themes/theme-next/src/node/config/resolveThemeData.ts b/themes/theme-next/src/node/config/resolveThemeData.ts new file mode 100644 index 0000000000..c7678ec624 --- /dev/null +++ b/themes/theme-next/src/node/config/resolveThemeData.ts @@ -0,0 +1,55 @@ +import { entries, fromEntries, getLocaleConfig } from '@vuepress/helper' +import type { App } from 'vuepress' +import type { + DefaultThemeLocaleData, + DefaultThemeLocaleOptions, +} from '../../shared/index.js' +import { LOCALES_OPTIONS } from '../locales/index.js' +import { THEME_NAME } from '../utils/index.js' + +const EXCLUDE_LIST = ['locales', 'container'] + +const FALLBACK_OPTIONS: DefaultThemeLocaleData = { + appearance: true, + // outline + outline: [2, 3], + aside: true, + scrollOffset: 134, + editLink: true, + lastUpdated: true, + contributors: true, + externalLinkIcon: true, +} + +const resolveOptions = ( + options: DefaultThemeLocaleData, +): DefaultThemeLocaleData => + fromEntries(entries(options).filter(([key]) => !EXCLUDE_LIST.includes(key))) + +export const resolveThemeData = ( + app: App, + options: DefaultThemeLocaleOptions, +): DefaultThemeLocaleOptions => { + const resolved = resolveOptions(options) + + const themeData: DefaultThemeLocaleOptions = { + ...FALLBACK_OPTIONS, + ...resolved, + locales: getLocaleConfig({ + app, + name: THEME_NAME, + default: LOCALES_OPTIONS, + config: fromEntries( + entries({ + '/': {}, + ...options.locales, + }).map(([locale, opt]) => [ + locale, + { ...resolved, ...resolveOptions(opt) }, + ]), + ), + }), + } + + return themeData +} diff --git a/themes/theme-next/src/node/config/templateBuildRenderer.ts b/themes/theme-next/src/node/config/templateBuildRenderer.ts new file mode 100644 index 0000000000..9cea199203 --- /dev/null +++ b/themes/theme-next/src/node/config/templateBuildRenderer.ts @@ -0,0 +1,37 @@ +import type { TemplateRendererContext } from 'vuepress/utils' +import { templateRenderer } from 'vuepress/utils' +import type { DefaultThemeData } from '../../shared/index.js' + +export const templateBuildRenderer = ( + template: string, + context: TemplateRendererContext, + options: DefaultThemeData, +): Promise | string => { + // eslint-disable-next-line no-useless-assignment + let temp = template.replace( + '', + ``, + ) + + if (options.appearance ?? true) { + const appearance = + typeof options.appearance === 'string' ? options.appearance : 'auto' + const script = + appearance === 'force-dark' + ? `document.documentElement.dataset.theme = 'dark'` + : `;(() => { + const preference = localStorage.getItem('vuepress-color-scheme') || '${appearance}' + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + const isDark = !preference || preference === 'auto' ? prefersDark : preference === 'dark' + document.documentElement.dataset.theme = isDark ? 'dark' : 'light' + })();` + temp = template.replace( + '', + ``, + ) + } else { + temp = template.replace('', '') + } + + return templateRenderer(temp, context) +} diff --git a/themes/theme-next/src/node/defaultTheme.ts b/themes/theme-next/src/node/defaultTheme.ts new file mode 100644 index 0000000000..1e66770e71 --- /dev/null +++ b/themes/theme-next/src/node/defaultTheme.ts @@ -0,0 +1,94 @@ +import { extendsEditLinkPage } from '@vuepress/theme-helper' +import { watch } from 'chokidar' +import type { Page, Theme } from 'vuepress/core' +import { fs, getDirname, path } from 'vuepress/utils' +import type { DefaultThemePageData } from '../shared/index.js' +import { extendsBundlerOptions, templateBuildRenderer } from './config/index.js' +import type { DefaultThemeOptions } from './options.js' +import { getPlugins } from './plugins/index.js' +import { prepareSidebarData } from './prepare/index.js' +import { THEME_NAME, logger } from './utils/index.js' + +const __dirname = getDirname(import.meta.url) + +export const defaultTheme = + ({ + hostname, + themePlugins = {}, + sidebarSorter, + ...localeOptions + }: DefaultThemeOptions): Theme => + (app) => { + if (app.env.isDebug) { + logger.info('Plugin Options:', themePlugins) + } + + return { + name: THEME_NAME, + + templateBuild: path.resolve(__dirname, '../../templates/build.html'), + templateBuildRenderer: (bundleOptions, _app) => + templateBuildRenderer(bundleOptions, _app, localeOptions), + + alias: { + // use alias to make all components replaceable + ...Object.fromEntries( + fs + .readdirSync(path.resolve(__dirname, '../client/components')) + .filter((file) => file.endsWith('.vue')) + .map((file) => [ + `@theme/${file}`, + path.resolve(__dirname, '../client/components', file), + ]), + ), + // use alias to make all composables replaceable + ...Object.fromEntries( + fs + .readdirSync(path.resolve(__dirname, '../client/composables')) + .filter((file) => file.endsWith('.js')) + .map((file) => [ + `@theme/${file.substring(0, file.length - 3)}`, + path.resolve(__dirname, '../client/composables', file), + ]), + ), + }, + + clientConfigFile: path.resolve(__dirname, '../client/config.js'), + + plugins: getPlugins(app, { hostname, themePlugins, localeOptions }), + + onPrepared: (_app) => + prepareSidebarData(_app, localeOptions, sidebarSorter), + + onWatched: (_app, watchers) => { + const watcher = watch( + // This ensures the page is generated or updated + 'pages/**/*.vue', + { + cwd: _app.dir.temp(), + ignoreInitial: true, + }, + ) + + watcher.on('add', () => { + void prepareSidebarData(app, localeOptions, sidebarSorter) + }) + watcher.on('change', () => { + void prepareSidebarData(app, localeOptions, sidebarSorter) + }) + watcher.on('unlink', () => { + void prepareSidebarData(app, localeOptions, sidebarSorter) + }) + + watchers.push(watcher) + }, + + extendsPage: (page: Page>) => { + extendsEditLinkPage(page) + // save title into route meta to generate navbar and sidebar + page.routeMeta.title = page.title + }, + + extendsBundlerOptions, + } + } diff --git a/themes/theme-next/src/node/index.ts b/themes/theme-next/src/node/index.ts new file mode 100644 index 0000000000..99de00840a --- /dev/null +++ b/themes/theme-next/src/node/index.ts @@ -0,0 +1,4 @@ +export * from './defaultTheme.js' +export * from './utils/index.js' +export type * from './options.js' +export type * from '../shared/index.js' diff --git a/themes/theme-next/src/node/locales/de.ts b/themes/theme-next/src/node/locales/de.ts new file mode 100644 index 0000000000..b1f1b1ca69 --- /dev/null +++ b/themes/theme-next/src/node/locales/de.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const de: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: 'Aussehen', + lightModeSwitchTitle: 'Zum hellen Thema wechseln', + darkModeSwitchTitle: 'Zum dunklen Thema wechseln', + + selectLanguageText: 'Sprachen', + selectLanguageName: 'Deutsch', + + // nav + returnToTopLabel: 'Zurück nach oben', + sidebarMenuLabel: 'Menü', + outlineTitle: 'Auf dieser Seite', + + // page meta + editLinkText: 'Diese Seite bearbeiten', + lastUpdatedText: 'Zuletzt aktualisiert', + contributorsText: 'Mitwirkende', + docFooter: { + prev: 'Vorherige Seite', + next: 'Nächste Seite', + }, + + // 404 page messages + notFound: { + title: 'SEITE NICHT GEFUNDEN', + quote: + 'Aber wenn du deine Richtung nicht änderst und weiter suchst, könntest du schließlich dort landen, wohin du unterwegs bist.', + linkLabel: 'zur Startseite', + linkText: 'Bring mich nach Hause', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/deAT.ts b/themes/theme-next/src/node/locales/deAT.ts new file mode 100644 index 0000000000..e7866c0dad --- /dev/null +++ b/themes/theme-next/src/node/locales/deAT.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const deAT: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: 'Aussehen', + lightModeSwitchTitle: 'Zum hellen Thema wechseln', + darkModeSwitchTitle: 'Zum dunklen Thema wechseln', + + selectLanguageText: 'Sprachen', + selectLanguageName: 'Deutsch (Österreich)', + + // nav + returnToTopLabel: 'Zurück nach oben', + sidebarMenuLabel: 'Menü', + outlineTitle: 'Auf dieser Seite', + + // page meta + editLinkText: 'Diese Seite bearbeiten', + lastUpdatedText: 'Zuletzt aktualisiert', + contributorsText: 'Mitwirkende', + docFooter: { + prev: 'Vorherige Seite', + next: 'Nächste Seite', + }, + + // 404 page messages + notFound: { + title: 'SEITE NICHT GEFUNDEN', + quote: + 'Aber wenn du deine Richtung nicht änderst und weiter suchst, könntest du schließlich dort landen, wohin du unterwegs bist.', + linkLabel: 'zur Startseite', + linkText: 'Bring mich nach Hause', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/en.ts b/themes/theme-next/src/node/locales/en.ts new file mode 100644 index 0000000000..30d8d3ed5b --- /dev/null +++ b/themes/theme-next/src/node/locales/en.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const en: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: 'Appearance', + lightModeSwitchTitle: 'Switch to light theme', + darkModeSwitchTitle: 'Switch to dark theme', + + selectLanguageText: 'Languages', + selectLanguageName: 'English', + + // nav + returnToTopLabel: 'Return to top', + sidebarMenuLabel: 'Menu', + outlineTitle: 'On This Page', + + // page meta + editLinkText: 'Edit this page', + lastUpdatedText: 'Last Updated', + contributorsText: 'Contributors', + docFooter: { + prev: 'Previous Page', + next: 'Next Page', + }, + + // 404 page messages + notFound: { + title: 'PAGE NOT FOUND', + quote: + "But if you don't change your direction, and if you keep looking, you may end up where you are heading.", + linkLabel: 'go to home', + linkText: 'Take me home', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/es.ts b/themes/theme-next/src/node/locales/es.ts new file mode 100644 index 0000000000..da6476cbd5 --- /dev/null +++ b/themes/theme-next/src/node/locales/es.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const es: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: 'Apariencia', + lightModeSwitchTitle: 'Cambiar a tema claro', + darkModeSwitchTitle: 'Cambiar a tema oscuro', + + selectLanguageText: 'Idiomas', + selectLanguageName: 'Español', + + // nav + returnToTopLabel: 'Volver arriba', + sidebarMenuLabel: 'Menú', + outlineTitle: 'En esta página', + + // page meta + editLinkText: 'Editar esta página', + lastUpdatedText: 'Última actualización', + contributorsText: 'Colaboradores', + docFooter: { + prev: 'Página anterior', + next: 'Página siguiente', + }, + + // 404 page messages + notFound: { + title: 'PÁGINA NO ENCONTRADA', + quote: + 'Pero si no cambias de dirección y sigues buscando, podrías terminar donde te diriges.', + linkLabel: 'ir a la página de inicio', + linkText: 'Llévame a casa', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/fi.ts b/themes/theme-next/src/node/locales/fi.ts new file mode 100644 index 0000000000..cc7cb3f7f0 --- /dev/null +++ b/themes/theme-next/src/node/locales/fi.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const fi: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: 'Ulkoasu', + lightModeSwitchTitle: 'Vaihda vaaleaan teemaan', + darkModeSwitchTitle: 'Vaihda tummaan teemaan', + + selectLanguageText: 'Kielet', + selectLanguageName: 'Suomi', + + // nav + returnToTopLabel: 'Palaa ylös', + sidebarMenuLabel: 'Valikko', + outlineTitle: 'Tällä sivulla', + + // page meta + editLinkText: 'Muokkaa tätä sivua', + lastUpdatedText: 'Viimeksi päivitetty', + contributorsText: 'Osallistujat', + docFooter: { + prev: 'Edellinen sivu', + next: 'Seuraava sivu', + }, + + // 404 page messages + notFound: { + title: 'SIVUA EI LÖYDY', + quote: + 'Mutta jos et vaihda suuntaasi ja jatkat etsimistä, saatat päätyä sinne, minne olet menossa.', + linkLabel: 'mene kotiin', + linkText: 'Vie minut kotiin', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/fr.ts b/themes/theme-next/src/node/locales/fr.ts new file mode 100644 index 0000000000..c3c839263a --- /dev/null +++ b/themes/theme-next/src/node/locales/fr.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const fr: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: 'Apparence', + lightModeSwitchTitle: 'Passer au thème clair', + darkModeSwitchTitle: 'Passer au thème sombre', + + selectLanguageText: 'Langues', + selectLanguageName: 'Français', + + // nav + returnToTopLabel: 'Retour en haut', + sidebarMenuLabel: 'Menu', + outlineTitle: 'Sur cette page', + + // page meta + editLinkText: 'Modifier cette page', + lastUpdatedText: 'Dernière mise à jour', + contributorsText: 'Contributeurs', + docFooter: { + prev: 'Page précédente', + next: 'Page suivante', + }, + + // 404 page messages + notFound: { + title: 'PAGE NON TROUVÉE', + quote: + 'Mais si vous ne changez pas de direction et continuez à chercher, vous pourriez finir là où vous vous dirigez.', + linkLabel: "aller à la page d'accueil", + linkText: 'Ramène-moi à la maison', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/id.ts b/themes/theme-next/src/node/locales/id.ts new file mode 100644 index 0000000000..f4ac3b4950 --- /dev/null +++ b/themes/theme-next/src/node/locales/id.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const id: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: 'Tampilan', + lightModeSwitchTitle: 'Beralih ke tema terang', + darkModeSwitchTitle: 'Beralih ke tema gelap', + + selectLanguageText: 'Bahasa', + selectLanguageName: 'Bahasa Indonesia', + + // nav + returnToTopLabel: 'Kembali ke atas', + sidebarMenuLabel: 'Menu', + outlineTitle: 'Pada Halaman Ini', + + // page meta + editLinkText: 'Edit halaman ini', + lastUpdatedText: 'Terakhir Diperbarui', + contributorsText: 'Kontributor', + docFooter: { + prev: 'Halaman Sebelumnya', + next: 'Halaman Berikutnya', + }, + + // 404 page messages + notFound: { + title: 'HALAMAN TIDAK DITEMUKAN', + quote: + 'Tetapi jika Anda tidak mengubah arah Anda, dan jika Anda terus mencari, Anda mungkin akan berakhir di tempat yang Anda tuju.', + linkLabel: 'ke beranda', + linkText: 'Bawa saya ke beranda', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/index.ts b/themes/theme-next/src/node/locales/index.ts new file mode 100644 index 0000000000..47a3fc0c98 --- /dev/null +++ b/themes/theme-next/src/node/locales/index.ts @@ -0,0 +1,42 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' +import { de } from './de.js' +import { deAT } from './deAT.js' +import { en } from './en.js' +import { es } from './es.js' +import { fi } from './fi.js' +import { fr } from './fr.js' +import { id } from './id.js' +import { ja } from './ja.js' +import { ko } from './ko.js' +import { nl } from './nl.js' +import { pl } from './pl.js' +import { pt } from './pt.js' +import { ru } from './ru.js' +import { sk } from './sk.js' +import { tr } from './tr.js' +import { uk } from './uk.js' +import { vi } from './vi.js' +import { zh } from './zh.js' +import { zhTW } from './zhTW.js' + +export const LOCALES_OPTIONS: Record = { + '/en/': en, // en-US English + '/zh/': zh, // zh-CN 简体中文 + '/zh-tw/': zhTW, // zh-TW 繁體中文 + '/de/': de, // de-DE Deutsch + '/de-at/': deAT, // de-AT Deutsch (Austria) + '/ru/': ru, // ru-RU Русский + '/uk/': uk, // uk-UA Українська + '/vi/': vi, // vi-VN Tiếng Việt + '/pt/': pt, // pt-BR Portugês + '/pl/': pl, // pl-PL Polski + '/fr/': fr, // fr-FR Français + '/es/': es, // es-ES Español + '/sk/': sk, // sk-SK Slovensky + '/ja/': ja, // ja-JP 日本語 + '/tr/': tr, // tr-TR Türkçe + '/ko/': ko, // ko-KR 한국어 + '/fi/': fi, // fi-FI Suomi + '/id/': id, // id-ID Bahasa + '/nl/': nl, // nl-BE Nederlands +} diff --git a/themes/theme-next/src/node/locales/ja.ts b/themes/theme-next/src/node/locales/ja.ts new file mode 100644 index 0000000000..effc6b09b8 --- /dev/null +++ b/themes/theme-next/src/node/locales/ja.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const ja: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: '外観', + lightModeSwitchTitle: 'ライトテーマに切り替え', + darkModeSwitchTitle: 'ダークテーマに切り替え', + + selectLanguageText: '言語', + selectLanguageName: '日本語', + + // nav + returnToTopLabel: 'トップに戻る', + sidebarMenuLabel: 'メニュー', + outlineTitle: 'このページの内容', + + // page meta + editLinkText: 'このページを編集', + lastUpdatedText: '最終更新', + contributorsText: '貢献者', + docFooter: { + prev: '前のページ', + next: '次のページ', + }, + + // 404 page messages + notFound: { + title: 'ページが見つかりません', + quote: + 'しかし、あなたが方向を変えず、探し続けるなら、あなたが向かっている場所にたどり着くかもしれません。', + linkLabel: 'ホームに戻る', + linkText: 'ホームに戻る', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/ko.ts b/themes/theme-next/src/node/locales/ko.ts new file mode 100644 index 0000000000..a43cfd31a0 --- /dev/null +++ b/themes/theme-next/src/node/locales/ko.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const ko: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: '외관', + lightModeSwitchTitle: '밝은 테마로 전환', + darkModeSwitchTitle: '어두운 테마로 전환', + + selectLanguageText: '언어', + selectLanguageName: '한국어', + + // nav + returnToTopLabel: '맨 위로 돌아가기', + sidebarMenuLabel: '메뉴', + outlineTitle: '이 페이지에서', + + // page meta + editLinkText: '이 페이지 편집', + lastUpdatedText: '마지막 업데이트', + contributorsText: '기여자', + docFooter: { + prev: '이전 페이지', + next: '다음 페이지', + }, + + // 404 page messages + notFound: { + title: '페이지를 찾을 수 없음', + quote: + '하지만 방향을 바꾸지 않고 계속 찾는다면, 당신이 향하는 곳에 도달할 수 있을 것입니다.', + linkLabel: '홈으로 가기', + linkText: '홈으로 데려다 주세요', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/nl.ts b/themes/theme-next/src/node/locales/nl.ts new file mode 100644 index 0000000000..f499c8b600 --- /dev/null +++ b/themes/theme-next/src/node/locales/nl.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const nl: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: 'Weergave', + lightModeSwitchTitle: 'Schakel naar licht thema', + darkModeSwitchTitle: 'Schakel naar donker thema', + + selectLanguageText: 'Talen', + selectLanguageName: 'Nederlands', + + // nav + returnToTopLabel: 'Terug naar boven', + sidebarMenuLabel: 'Menu', + outlineTitle: 'Op deze pagina', + + // page meta + editLinkText: 'Bewerk deze pagina', + lastUpdatedText: 'Laatst bijgewerkt', + contributorsText: 'Bijdragers', + docFooter: { + prev: 'Vorige pagina', + next: 'Volgende pagina', + }, + + // 404 page messages + notFound: { + title: 'PAGINA NIET GEVONDEN', + quote: + 'Maar als je je richting niet verandert en blijft zoeken, kan het zijn dat je uitkomt waar je naartoe gaat.', + linkLabel: 'ga naar home', + linkText: 'Breng me naar huis', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/pl.ts b/themes/theme-next/src/node/locales/pl.ts new file mode 100644 index 0000000000..813cf9b791 --- /dev/null +++ b/themes/theme-next/src/node/locales/pl.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const pl: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: 'Wygląd', + lightModeSwitchTitle: 'Przełącz na jasny motyw', + darkModeSwitchTitle: 'Przełącz na ciemny motyw', + + selectLanguageText: 'Języki', + selectLanguageName: 'Polski', + + // nav + returnToTopLabel: 'Wróć na górę', + sidebarMenuLabel: 'Menu', + outlineTitle: 'Na tej stronie', + + // page meta + editLinkText: 'Edytuj tę stronę', + lastUpdatedText: 'Ostatnia aktualizacja', + contributorsText: 'Współtwórcy', + docFooter: { + prev: 'Poprzednia strona', + next: 'Następna strona', + }, + + // 404 page messages + notFound: { + title: 'STRONA NIE ZOSTAŁA ZNALEZIONA', + quote: + 'Ale jeśli nie zmienisz kierunku i będziesz dalej szukać, możesz skończyć tam, gdzie zmierzasz.', + linkLabel: 'przejdź do strony głównej', + linkText: 'Zabierz mnie do domu', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/pt.ts b/themes/theme-next/src/node/locales/pt.ts new file mode 100644 index 0000000000..7659a1cf95 --- /dev/null +++ b/themes/theme-next/src/node/locales/pt.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const pt: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: 'Aparência', + lightModeSwitchTitle: 'Mudar para o tema claro', + darkModeSwitchTitle: 'Mudar para o tema escuro', + + selectLanguageText: 'Idiomas', + selectLanguageName: 'Português (Brasil)', + + // nav + returnToTopLabel: 'Voltar ao topo', + sidebarMenuLabel: 'Menu', + outlineTitle: 'Nesta Página', + + // page meta + editLinkText: 'Editar esta página', + lastUpdatedText: 'Última atualização', + contributorsText: 'Colaboradores', + docFooter: { + prev: 'Página anterior', + next: 'Próxima página', + }, + + // 404 page messages + notFound: { + title: 'PÁGINA NÃO ENCONTRADA', + quote: + 'Mas se você não mudar sua direção e continuar procurando, você pode acabar onde está indo.', + linkLabel: 'ir para a página inicial', + linkText: 'Leve-me para casa', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/ru.ts b/themes/theme-next/src/node/locales/ru.ts new file mode 100644 index 0000000000..720af800f4 --- /dev/null +++ b/themes/theme-next/src/node/locales/ru.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const ru: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: 'Внешний вид', + lightModeSwitchTitle: 'Переключить на светлую тему', + darkModeSwitchTitle: 'Переключить на темную тему', + + selectLanguageText: 'Языки', + selectLanguageName: 'Русский', + + // nav + returnToTopLabel: 'Вернуться наверх', + sidebarMenuLabel: 'Меню', + outlineTitle: 'На этой странице', + + // page meta + editLinkText: 'Редактировать эту страницу', + lastUpdatedText: 'Последнее обновление', + contributorsText: 'Участники', + docFooter: { + prev: 'Предыдущая страница', + next: 'Следующая страница', + }, + + // 404 page messages + notFound: { + title: 'СТРАНИЦА НЕ НАЙДЕНА', + quote: + 'Но если вы не измените свое направление и продолжите искать, вы можете оказаться там, куда направляетесь.', + linkLabel: 'на главную', + linkText: 'Вернуться на главную', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/sk.ts b/themes/theme-next/src/node/locales/sk.ts new file mode 100644 index 0000000000..0a09971991 --- /dev/null +++ b/themes/theme-next/src/node/locales/sk.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const sk: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: 'Vzhľad', + lightModeSwitchTitle: 'Prepnúť na svetlú tému', + darkModeSwitchTitle: 'Prepnúť na tmavú tému', + + selectLanguageText: 'Jazyky', + selectLanguageName: 'Slovenčina', + + // nav + returnToTopLabel: 'Návrat hore', + sidebarMenuLabel: 'Menu', + outlineTitle: 'Na tejto stránke', + + // page meta + editLinkText: 'Upraviť túto stránku', + lastUpdatedText: 'Naposledy aktualizované', + contributorsText: 'Prispievatelia', + docFooter: { + prev: 'Predchádzajúca stránka', + next: 'Ďalšia stránka', + }, + + // 404 page messages + notFound: { + title: 'STRÁNKA NENÁJDENÁ', + quote: + 'Ale ak nezmeníte svoj smer a budete pokračovať v hľadaní, môžete skončiť tam, kam smerujete.', + linkLabel: 'prejsť na domovskú stránku', + linkText: 'Vezmi ma domov', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/tr.ts b/themes/theme-next/src/node/locales/tr.ts new file mode 100644 index 0000000000..4d072fae8b --- /dev/null +++ b/themes/theme-next/src/node/locales/tr.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const tr: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: 'Görünüm', + lightModeSwitchTitle: 'Açık tema olarak değiştir', + darkModeSwitchTitle: 'Koyu tema olarak değiştir', + + selectLanguageText: 'Diller', + selectLanguageName: 'Türkçe', + + // nav + returnToTopLabel: 'Başa dön', + sidebarMenuLabel: 'Menü', + outlineTitle: 'Bu Sayfada', + + // page meta + editLinkText: 'Bu sayfayı düzenle', + lastUpdatedText: 'Son Güncelleme', + contributorsText: 'Katkıda Bulunanlar', + docFooter: { + prev: 'Önceki Sayfa', + next: 'Sonraki Sayfa', + }, + + // 404 page messages + notFound: { + title: 'SAYFA BULUNAMADI', + quote: + 'Ancak yönünü değiştirmezsen ve aramaya devam edersen, gideceğin yere ulaşabilirsin.', + linkLabel: 'ana sayfaya git', + linkText: 'Beni eve götür', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/uk.ts b/themes/theme-next/src/node/locales/uk.ts new file mode 100644 index 0000000000..6da5b0ece2 --- /dev/null +++ b/themes/theme-next/src/node/locales/uk.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const uk: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: 'Вигляд', + lightModeSwitchTitle: 'Перемкнути на світлу тему', + darkModeSwitchTitle: 'Перемкнути на темну тему', + + selectLanguageText: 'Мови', + selectLanguageName: 'Українська', + + // nav + returnToTopLabel: 'Повернутися нагору', + sidebarMenuLabel: 'Меню', + outlineTitle: 'На цій сторінці', + + // page meta + editLinkText: 'Редагувати цю сторінку', + lastUpdatedText: 'Останнє оновлення', + contributorsText: 'Автори', + docFooter: { + prev: 'Попередня сторінка', + next: 'Наступна сторінка', + }, + + // 404 page messages + notFound: { + title: 'СТОРІНКА НЕ ЗНАЙДЕНА', + quote: + 'Але якщо ви не зміните свого напрямку і продовжите шукати, ви можете опинитися там, куди прямуєте.', + linkLabel: 'на головну', + linkText: 'Відвезти додому', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/vi.ts b/themes/theme-next/src/node/locales/vi.ts new file mode 100644 index 0000000000..14a669e41a --- /dev/null +++ b/themes/theme-next/src/node/locales/vi.ts @@ -0,0 +1,35 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const vi: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: 'Giao diện', + lightModeSwitchTitle: 'Chuyển sang chủ đề sáng', + darkModeSwitchTitle: 'Chuyển sang chủ đề tối', + + selectLanguageText: 'Ngôn ngữ', + selectLanguageName: 'Tiếng Việt', + + // nav + returnToTopLabel: 'Trở về đầu trang', + sidebarMenuLabel: 'Menu', + outlineTitle: 'Trên trang này', + + // page meta + editLinkText: 'Chỉnh sửa trang này', + lastUpdatedText: 'Cập nhật lần cuối', + contributorsText: 'Người đóng góp', + docFooter: { + prev: 'Trang trước', + next: 'Trang tiếp theo', + }, + + // 404 page messages + notFound: { + title: 'KHÔNG TÌM THẤY TRANG', + quote: + 'Nhưng nếu bạn không thay đổi hướng đi của mình và tiếp tục tìm kiếm, bạn có thể kết thúc ở nơi bạn đang hướng tới.', + linkLabel: 'về trang chủ', + linkText: 'Đưa tôi về nhà', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/zh.ts b/themes/theme-next/src/node/locales/zh.ts new file mode 100644 index 0000000000..ee0bc47d88 --- /dev/null +++ b/themes/theme-next/src/node/locales/zh.ts @@ -0,0 +1,34 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const zh: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: '外观', + lightModeSwitchTitle: '切换到浅色主题', + darkModeSwitchTitle: '切换到深色主题', + + selectLanguageText: '选择语言', + selectLanguageName: '简体中文', + + // nav + returnToTopLabel: '返回顶部', + sidebarMenuLabel: '目录', + outlineTitle: '此页内容', + + // page meta + editLinkText: '编辑此页', + lastUpdatedText: '最后更新于', + contributorsText: '贡献者', + docFooter: { + prev: '上一页', + next: '下一页', + }, + + // 404 page messages + notFound: { + title: '页面未找到', + quote: '如果你不改变方向,继续寻找,最终可能会到达你正在前往的地方。', + linkLabel: '回到首页', + linkText: '回到首页', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/locales/zhTW.ts b/themes/theme-next/src/node/locales/zhTW.ts new file mode 100644 index 0000000000..791608a266 --- /dev/null +++ b/themes/theme-next/src/node/locales/zhTW.ts @@ -0,0 +1,34 @@ +import type { DefaultThemeLocaleData } from '../../shared/index.js' + +export const zhTW: DefaultThemeLocaleData = { + // appearance + darkModeSwitchLabel: '外觀', + lightModeSwitchTitle: '切換到淺色主題', + darkModeSwitchTitle: '切換到深色主題', + + selectLanguageText: '選擇語言', + selectLanguageName: '簡體中文', + + // nav + returnToTopLabel: '返回頂部', + sidebarMenuLabel: '目錄', + outlineTitle: '此頁內容', + + // page meta + editLinkText: '編輯此頁', + lastUpdatedText: '最後更新於', + contributorsText: '貢獻者', + docFooter: { + prev: '上一頁', + next: '下一頁', + }, + + // 404 page messages + notFound: { + title: '頁面未找到', + quote: '如果你不改變方向,繼續尋找,最終可能會到達你正在前往的地方。', + linkLabel: '回到首頁', + linkText: '回到首頁', + code: '404', + }, +} diff --git a/themes/theme-next/src/node/options.ts b/themes/theme-next/src/node/options.ts new file mode 100644 index 0000000000..ab5397a9e2 --- /dev/null +++ b/themes/theme-next/src/node/options.ts @@ -0,0 +1,91 @@ +import type { CopyCodePluginOptions } from '@vuepress/plugin-copy-code' +import type { LinksCheckPluginOptions } from '@vuepress/plugin-links-check' +import type { MarkdownHintPluginOptions } from '@vuepress/plugin-markdown-hint' +import type { MarkdownTabPluginOptions } from '@vuepress/plugin-markdown-tab' +import type { SeoPluginOptions } from '@vuepress/plugin-seo' +import type { ShikiPluginOptions } from '@vuepress/plugin-shiki' +import type { SitemapPluginOptions } from '@vuepress/plugin-sitemap' +import type { + DefaultThemeLocaleOptions, + SidebarSorter, +} from '../shared/index.js' + +export interface DefaultThemePluginsOptions { + /** + * Enable @vuepress/plugin-active-header-links or not + */ + activeHeaderLinks?: boolean + + /** + * Enable @vuepress/plugin-copy-code or not + */ + copyCode?: CopyCodePluginOptions | boolean + + /** + * Enable @vuepress/plugin-git or not + */ + git?: boolean + + /** + * Enable @vuepress/plugin-markdown-hint or not + */ + hint?: MarkdownHintPluginOptions | boolean + + /** + * Enable @vuepress/plugin-markdown-tab or not + */ + tab?: MarkdownTabPluginOptions | boolean + + /** + * Enable @vuepress/plugin-links-check or not + */ + linksCheck?: LinksCheckPluginOptions | boolean + + /** + * Enable @vuepress/plugin-photo-swipe or not + */ + photoSwipe?: boolean + + /** + * Enable @vuepress/plugin-nprogress or not + */ + nprogress?: boolean + + /** + * Enable @vuepress/plugin-shiki or not + */ + shiki?: ShikiPluginOptions | boolean + + /** + * Enable @vuepress/plugin-seo or not + */ + seo?: Partial | boolean + + /** + * Enable @vuepress/plugin-sitemap or not + */ + sitemap?: Partial | boolean +} + +export interface DefaultThemeOptions extends DefaultThemeLocaleOptions { + /** + * deployed hostname + */ + hostname?: string + + /** + * To avoid confusion with the root `plugins` option, + * we use `themePlugins` + * + * 为避免与根`plugins`选项混淆,我们使用`themePlugins`。 + */ + themePlugins?: DefaultThemePluginsOptions + + /** + * The sidebar sorters. only `'structure'` optional + * + * 侧边栏排序。仅支持 `'structure'` + * + */ + sidebarSorter?: SidebarSorter +} diff --git a/themes/theme-next/src/node/plugins/getPlugins.ts b/themes/theme-next/src/node/plugins/getPlugins.ts new file mode 100644 index 0000000000..8947d26890 --- /dev/null +++ b/themes/theme-next/src/node/plugins/getPlugins.ts @@ -0,0 +1,147 @@ +import { activeHeaderLinksPlugin } from '@vuepress/plugin-active-header-links' +import { copyCodePlugin } from '@vuepress/plugin-copy-code' +import { gitPlugin } from '@vuepress/plugin-git' +import { linksCheckPlugin } from '@vuepress/plugin-links-check' +import { markdownHintPlugin } from '@vuepress/plugin-markdown-hint' +import { markdownTabPlugin } from '@vuepress/plugin-markdown-tab' +import { nprogressPlugin } from '@vuepress/plugin-nprogress' +import { photoSwipePlugin } from '@vuepress/plugin-photo-swipe' +import type { SeoPluginOptions } from '@vuepress/plugin-seo' +import { seoPlugin } from '@vuepress/plugin-seo' +import type { ShikiPluginOptions } from '@vuepress/plugin-shiki' +import { shikiPlugin } from '@vuepress/plugin-shiki' +import type { SitemapPluginOptions } from '@vuepress/plugin-sitemap' +import { sitemapPlugin } from '@vuepress/plugin-sitemap' +import { themeDataPlugin } from '@vuepress/plugin-theme-data' +import type { App, PluginConfig } from 'vuepress/core' +import { isPlainObject } from 'vuepress/shared' +import type { DefaultThemeLocaleOptions } from '../../shared/index.js' +import { resolveThemeData } from '../config/index.js' +import type { DefaultThemePluginsOptions } from '../options.js' + +interface PluginsOptions { + hostname?: string + themePlugins: DefaultThemePluginsOptions + localeOptions: DefaultThemeLocaleOptions +} + +export const getPlugins = ( + app: App, + { hostname, themePlugins, localeOptions }: PluginsOptions, +): PluginConfig => { + const plugins: PluginConfig = [] + const isProd = app.env.isBuild + + if (themePlugins.activeHeaderLinks !== false) { + plugins.push( + activeHeaderLinksPlugin({ + headerLinkSelector: 'a.outline-link', + headerAnchorSelector: '.header-anchor', + // should greater than page transition duration + delay: 300, + }), + ) + } + + if (themePlugins.tab !== false) { + plugins.push( + markdownTabPlugin({ + ...(isPlainObject(themePlugins.tab) + ? themePlugins.tab + : { tabs: true, codeTabs: true }), + }), + ) + } + + if (themePlugins.copyCode !== false) { + plugins.push( + copyCodePlugin({ + selector: '.vp-content div[class*="language-"] pre', + ...(isPlainObject(themePlugins.copyCode) ? themePlugins.copyCode : {}), + }), + ) + } + + if (themePlugins.hint !== false) { + plugins.push( + markdownHintPlugin({ + hint: true, + alert: true, + ...(isPlainObject(themePlugins.hint) ? themePlugins.hint : {}), + }), + ) + } + + if (themePlugins.git !== false) { + plugins.push( + gitPlugin({ + createdTime: false, + updatedTime: localeOptions.lastUpdated !== false, + contributors: localeOptions.contributors !== false, + }), + ) + } + + if (themePlugins.linksCheck !== false) { + plugins.push( + linksCheckPlugin( + isPlainObject(themePlugins.linksCheck) ? themePlugins.linksCheck : {}, + ), + ) + } + + if (themePlugins.photoSwipe !== false) { + plugins.push( + photoSwipePlugin({ + selector: '.vp-content > img, .vp-content :not(a) > img', + // should greater than page transition duration + delay: 300, + }), + ) + } + + if (themePlugins.nprogress !== false) { + plugins.push(nprogressPlugin()) + } + + if (themePlugins.shiki !== false) { + const shikiOptions = isPlainObject(themePlugins.shiki) + ? themePlugins.shiki + : {} + const defaultOptions: ShikiPluginOptions = { + notationDiff: true, + notationErrorLevel: true, + notationFocus: true, + notationHighlight: true, + themes: { light: 'github-light', dark: 'github-dark' }, + } + if ('theme' in shikiOptions) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + delete (defaultOptions as any).themes + } + plugins.push(shikiPlugin({ ...defaultOptions, ...shikiOptions })) + } + + if ((themePlugins.seo ?? isProd) !== false) { + const seoOptions = isPlainObject(themePlugins.seo) ? themePlugins.seo : {} + seoOptions.hostname ||= hostname + if (seoOptions.hostname) + plugins.push(seoPlugin(seoOptions as SeoPluginOptions)) + } + + if ((themePlugins.sitemap ?? isProd) !== false) { + const sitemapOptions = isPlainObject(themePlugins.sitemap) + ? themePlugins.sitemap + : {} + sitemapOptions.hostname ||= hostname + if (sitemapOptions.hostname) + plugins.push(sitemapPlugin(sitemapOptions as SitemapPluginOptions)) + } + + // @vuepress/plugin-theme-data + plugins.push( + themeDataPlugin({ themeData: resolveThemeData(app, localeOptions) }), + ) + + return plugins +} diff --git a/themes/theme-next/src/node/plugins/index.ts b/themes/theme-next/src/node/plugins/index.ts new file mode 100644 index 0000000000..62d0b452eb --- /dev/null +++ b/themes/theme-next/src/node/plugins/index.ts @@ -0,0 +1 @@ +export * from './getPlugins.js' diff --git a/themes/theme-next/src/node/prepare/index.ts b/themes/theme-next/src/node/prepare/index.ts new file mode 100644 index 0000000000..493d6738ad --- /dev/null +++ b/themes/theme-next/src/node/prepare/index.ts @@ -0,0 +1 @@ +export * from './sidebar/index.js' diff --git a/themes/theme-next/src/node/prepare/sidebar/getSidebarInfo.ts b/themes/theme-next/src/node/prepare/sidebar/getSidebarInfo.ts new file mode 100644 index 0000000000..264fc0820a --- /dev/null +++ b/themes/theme-next/src/node/prepare/sidebar/getSidebarInfo.ts @@ -0,0 +1,194 @@ +import { startsWith } from '@vuepress/helper' +import type { Page } from 'vuepress/core' +import { sanitizeFileName } from 'vuepress/utils' +import type { + DefaultThemeNormalPageFrontmatter, + DefaultThemePageData, + SidebarDirInfo, + SidebarFileInfo, + SidebarInfo, + SidebarSorterFunction, +} from '../../../shared/index.js' +import { getTitleFromFilename } from '../../utils/index.js' +import type { StructureInfo } from './getStructureInfo.js' +import { getStructureInfo } from './getStructureInfo.js' + +export interface FileInfo { + type: 'file' + filename: string + path: string +} + +export interface DirInfo { + type: 'dir' + dirname: string + path: string + items: (DirInfo | FileInfo)[] +} + +export interface ThemeSidebarInfoOptions { + pages: Page[] + sorters: SidebarSorterFunction[] + scope: string +} + +/** + * @private + */ +const getSidebarChildrenInfo = ( + { scope, pages, sorters }: ThemeSidebarInfoOptions, + children: StructureInfo[], +): SidebarInfo[] => + children + // eslint-disable-next-line @typescript-eslint/no-use-before-define + .map((item) => getSidebarInfoFromStructure({ pages, scope, sorters }, item)) + .filter((item): item is SidebarInfo => item !== null) + // sort items + .sort((infoA, infoB) => { + for (const sorter of sorters) { + const result = sorter(infoA, infoB) + + if (result !== 0) return result + } + + return 0 + }) + +/** + * @private + */ +const getSidebarInfoFromStructure = ( + { scope, pages, sorters }: ThemeSidebarInfoOptions, + info: StructureInfo, +): SidebarInfo | null => { + // handle file + if (info.type === 'file') { + const page = pages.find( + ({ filePathRelative }) => filePathRelative === `${scope}${info.path}`, + )! as Page + + if (page.frontmatter.index === false) return null + + const fileInfo: SidebarFileInfo = { + type: 'file', + filename: info.filename, + + title: page.frontmatter.title ?? page.title, + order: page.frontmatter.order ?? null, + path: decodeURI(page.path) === page.pathInferred ? null : page.path, + + frontmatter: page.frontmatter, + pageData: page.data, + } + + return fileInfo + } + + // handle dir + + // performance improvements + const relatedPages = pages.filter(({ filePathRelative }) => + startsWith(filePathRelative, `${scope}${info.path}/`), + ) + const READMEFile = info.children.find( + // eslint-disable-next-line @typescript-eslint/no-shadow + (info) => + info.type === 'file' && info.filename.toLowerCase() === 'readme.md', + ) + + if (READMEFile) { + const readmePage = relatedPages.find( + ({ filePathRelative }) => + filePathRelative === `${scope}${READMEFile.path}`, + )! as Page + + // get dir information + const dirOptions = readmePage.frontmatter.dir + + const title = dirOptions?.text ?? readmePage.title + const collapsible = dirOptions?.collapsible ?? true + + if (dirOptions?.index === false) return null + + const dirInfo: SidebarDirInfo = { + type: 'dir', + dirname: info.dirname, + children: getSidebarChildrenInfo( + { pages: relatedPages, scope, sorters }, + dirOptions?.link + ? // filter README.md + info.children.filter( + (item) => + item.type !== 'file' || + item.filename.toLowerCase() !== 'readme.md', + ) + : info.children, + ), + + title, + order: dirOptions?.order ?? null, + // group information + groupInfo: { + ...(collapsible ? { collapsible } : {}), + ...(dirOptions?.link + ? { + link: + readmePage.pathInferred === decodeURI(readmePage.path) + ? `${sanitizeFileName(info.dirname)}/` + : readmePage.path, + } + : {}), + }, + + frontmatter: readmePage.frontmatter, + pageData: readmePage.data, + } + + return dirInfo + } + + const dirInfo: SidebarDirInfo = { + type: 'dir', + dirname: info.dirname, + children: getSidebarChildrenInfo( + { pages: relatedPages, scope, sorters }, + info.children, + ), + + title: getTitleFromFilename(info.dirname), + order: null, + + // group information + groupInfo: { + collapsible: true, + }, + + frontmatter: null, + pageData: null, + } + + return dirInfo +} + +/** + * @private + */ +export const getSidebarInfo = ({ + pages, + sorters, + scope, +}: ThemeSidebarInfoOptions): // base = "" +SidebarInfo[] => + getStructureInfo(pages, scope) + .map((info) => getSidebarInfoFromStructure({ scope, pages, sorters }, info)) + .filter((item): item is SidebarInfo => item !== null) + // sort items + .sort((infoA, infoB) => { + for (const sorter of sorters) { + const result = sorter(infoA, infoB) + + if (result !== 0) return result + } + + return 0 + }) diff --git a/themes/theme-next/src/node/prepare/sidebar/getSidebarSorter.ts b/themes/theme-next/src/node/prepare/sidebar/getSidebarSorter.ts new file mode 100644 index 0000000000..8b4b5e20b5 --- /dev/null +++ b/themes/theme-next/src/node/prepare/sidebar/getSidebarSorter.ts @@ -0,0 +1,153 @@ +import { isArray, isFunction, isString, keys } from '@vuepress/helper' +import type { + SidebarInfo, + SidebarSorter, + SidebarSorterFunction, +} from '../../../shared/index.js' + +export const sidebarReadmeSorter = ( + infoA: SidebarInfo, + infoB: SidebarInfo, +): number => { + if (infoA.type === 'file' && infoA.filename.toLowerCase() === 'readme.md') + return -1 + + if (infoB.type === 'file' && infoB.filename.toLowerCase() === 'readme.md') + return 1 + + return 0 +} + +export const sidebarOrderSorter = ( + infoA: SidebarInfo, + infoB: SidebarInfo, +): number => { + // itemA order is absent + if (infoA.order === null) { + // both item do not have orders + if (infoB.order === null) return 0 + + // itemA order is absent while itemB order is present + return infoB.order + } + + // itemA order is present while itemB order is absent + if (infoB.order === null) return -infoA.order + + // now we are sure both order exist + + // itemA order is positive + if (infoA.order > 0) { + // both order are negative + if (infoB.order > 0) return infoA.order - infoB.order + + // infoA.order is positive while infoB.order is negative + return -1 + } + + // both order are negative + if (infoB.order < 0) return infoA.order - infoB.order + + // infoA.order is negative while infoB.order is positive + return 1 +} + +export const sidebarDateSorter = ( + infoA: SidebarInfo, + infoB: SidebarInfo, +): number => { + if (infoA.frontmatter?.date instanceof Date) { + if (infoB.frontmatter?.date instanceof Date) + return infoA.frontmatter.date.getTime() - infoB.frontmatter.date.getTime() + + return -1 + } + + if (infoB.frontmatter?.date instanceof Date) return 1 + + return 0 +} + +export const sidebarDateDescSorter = ( + infoA: SidebarInfo, + infoB: SidebarInfo, +): number => { + if (infoA.frontmatter?.date instanceof Date) { + if (infoB.frontmatter?.date instanceof Date) + return infoB.frontmatter.date.getTime() - infoA.frontmatter.date.getTime() + + return -1 + } + + if (infoB.frontmatter?.date instanceof Date) return 1 + + return 0 +} + +const getFilename = (info: SidebarInfo): string => + info.type === 'file' ? info.filename.replace(/\.md$/u, '') : info.dirname + +export const sidebarFilenameSorter = ( + infoA: SidebarInfo, + infoB: SidebarInfo, +): number => { + const result = getFilename(infoA).localeCompare( + getFilename(infoB), + undefined, + { + numeric: true, + sensitivity: 'accent', + }, + ) + + if (result !== 0) return result + + if (infoA.type === 'file' && infoB.type === 'dir') return -1 + if (infoA.type === 'dir' && infoB.type === 'file') return 1 + + return 0 +} + +export const sidebarTitleSorter = ( + infoA: SidebarInfo, + infoB: SidebarInfo, +): number => + infoA.title.localeCompare(infoB.title, undefined, { + numeric: true, + }) + +const sortKeyMap: Record = { + 'readme': sidebarReadmeSorter, + 'order': sidebarOrderSorter, + 'date': sidebarDateSorter, + 'date-desc': sidebarDateDescSorter, + 'filename': sidebarFilenameSorter, + 'title': sidebarTitleSorter, +} + +const availableKeywords = keys(sortKeyMap) + +/** @private */ +export const getSidebarSorter = ( + sorter?: SidebarSorter, +): SidebarSorterFunction[] => { + if (isString(sorter) && availableKeywords.includes(sorter)) + return [sortKeyMap[sorter]] + + if (isFunction(sorter)) return [sorter] + + if (isArray(sorter)) { + const result = sorter + .map((item: unknown) => (isString(item) ? sortKeyMap[item] : item)) + .filter((item) => isFunction(item)) + + if (result.length) return result as SidebarSorterFunction[] + } + + return [ + sidebarReadmeSorter, + sidebarOrderSorter, + sidebarTitleSorter, + sidebarFilenameSorter, + ] +} diff --git a/themes/theme-next/src/node/prepare/sidebar/getStructureInfo.ts b/themes/theme-next/src/node/prepare/sidebar/getStructureInfo.ts new file mode 100644 index 0000000000..8aa752ce8f --- /dev/null +++ b/themes/theme-next/src/node/prepare/sidebar/getStructureInfo.ts @@ -0,0 +1,90 @@ +import { startsWith } from '@vuepress/helper' +import type { Page } from 'vuepress/core' +import { path } from 'vuepress/utils' + +export interface FileInfo { + type: 'file' + filename: string + path: string +} + +export interface DirInfo { + type: 'dir' + dirname: string + path: string + children: StructureInfo[] +} + +export type StructureInfo = DirInfo | FileInfo + +/** + * @private + */ +export const getStructureInfo = ( + pages: Page[], + scope: string, +): StructureInfo[] => { + const relatedPages = pages.filter( + ({ filePathRelative, pathLocale }) => + // Generated from file and inside current scope + startsWith(filePathRelative, scope) && + // Filter other locales in root dir + (scope !== '' || pathLocale === '/'), + ) + + const sortedPages = relatedPages + // Sort pages + .sort( + ( + { filePathRelative: filePathRelative1 }, + { filePathRelative: filePathRelative2 }, + ) => + filePathRelative1!.localeCompare(filePathRelative2!, undefined, { + numeric: true, + sensitivity: 'accent', + }), + ) + + const structure: StructureInfo[] = [] + + sortedPages.forEach((page) => { + const relativePath = path.relative(scope, page.filePathRelative!) + const filename = path.basename(relativePath) + + let currentDir = structure + const levels = relativePath.split('/') + + levels.forEach((level, index) => { + // Already gets filename + if (index === levels.length - 1) { + currentDir.push({ type: 'file', filename, path: relativePath }) + } + // Still generating dir + else { + const result = currentDir.find( + (item): item is DirInfo => + item.type === 'dir' && item.dirname === level, + ) + + if (result) { + currentDir = result.children + } + // We shall create this dir + else { + const dirInfo: DirInfo = { + type: 'dir', + dirname: level, + path: levels.slice(0, index + 1).join('/'), + children: [], + } + + currentDir.push(dirInfo) + + currentDir = dirInfo.children + } + } + }) + }) + + return structure +} diff --git a/themes/theme-next/src/node/prepare/sidebar/index.ts b/themes/theme-next/src/node/prepare/sidebar/index.ts new file mode 100644 index 0000000000..89d944731b --- /dev/null +++ b/themes/theme-next/src/node/prepare/sidebar/index.ts @@ -0,0 +1,3 @@ +export * from './prepareSidebarData.js' +export * from './getSidebarInfo.js' +export * from './getSidebarSorter.js' diff --git a/themes/theme-next/src/node/prepare/sidebar/prepareSidebarData.ts b/themes/theme-next/src/node/prepare/sidebar/prepareSidebarData.ts new file mode 100644 index 0000000000..00dd82a695 --- /dev/null +++ b/themes/theme-next/src/node/prepare/sidebar/prepareSidebarData.ts @@ -0,0 +1,170 @@ +import { + ensureEndingSlash, + ensureLeadingSlash, + entries, + isArray, + isPlainObject, + removeLeadingSlash, +} from '@vuepress/helper' +import type { App } from 'vuepress/core' +import type { + DefaultThemeLocaleOptions, + Sidebar, + SidebarInfo, + SidebarItem, + SidebarSorter, + SidebarSorterFunction, +} from '../../../shared/index.js' +import type { ResolvedSidebarItem } from '../../../shared/resolved/sidebar.js' +import { normalizeLink } from '../../utils/index.js' +import { getSidebarInfo } from './getSidebarInfo.js' +import { getSidebarSorter } from './getSidebarSorter.js' + +const HMR_CODE = ` +if (import.meta.webpackHot) { + import.meta.webpackHot.accept() + if (__VUE_HMR_RUNTIME__.updateSidebarData) { + __VUE_HMR_RUNTIME__.updateSidebarData(sidebarData) + } +} + +if (import.meta.hot) { + import.meta.hot.accept(({ sidebarData }) => { + __VUE_HMR_RUNTIME__.updateSidebarData(sidebarData) + }) +} +` + +export const prepareSidebarData = async ( + app: App, + localesOptions: DefaultThemeLocaleOptions, + sorters?: SidebarSorter, +): Promise => { + const locales: Record = {} + + entries(localesOptions.locales ?? {}).forEach(([localePath, locale]) => { + locales[localePath] = locale.sidebar + }) + + if (!locales['/']) { + locales['/'] = localesOptions.sidebar + } + + // eslint-disable-next-line @typescript-eslint/no-use-before-define + const sidebarData = getSidebarData(app, locales, sorters) + + let content = `\ +export const sidebarData = ${JSON.stringify(sidebarData)} +` + + if (app.env.isDev) { + content += HMR_CODE + } + + await app.writeTemp('internal/sidebar.js', content) +} + +export const getSidebarData = ( + app: App, + locales: Record, + sorter?: SidebarSorter, +): Sidebar => { + const structureDir: string[] = [] + + const resolved: Sidebar = {} + const sorters = getSidebarSorter(sorter) + + entries(locales).forEach(([localePath, sidebar]) => { + if (!sidebar) return + + if (isArray(sidebar)) { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + structureDir.push(...findStructureList(sidebar, localePath)) + } else if (isPlainObject(sidebar)) { + entries(sidebar).forEach(([dirname, config]) => { + const prefix = normalizeLink(localePath, dirname) + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + config === 'structure' + ? structureDir.push(prefix) + : isArray(config) + ? // eslint-disable-next-line @typescript-eslint/no-use-before-define + structureDir.push(...findStructureList(config, prefix)) + : config.items === 'structure' + ? structureDir.push(normalizeLink(prefix, config.prefix)) + : structureDir.push( + // eslint-disable-next-line @typescript-eslint/no-use-before-define + ...findStructureList( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + config.items ?? [], + normalizeLink(prefix, config.prefix), + ), + ) + }) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (sidebar === 'structure') { + structureDir.push(localePath) + } + }) + + structureDir.forEach((dirname) => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + resolved[dirname] = getSidebarItemsFromStructure(app, sorters, dirname) + }) + + return resolved +} + +const findStructureList = ( + sidebar: (SidebarItem | string)[], + prefix = '', +): string[] => { + const list: string[] = [] + if (!sidebar.length) return list + + sidebar.forEach((item) => { + if (isPlainObject(item)) { + const nextPrefix = normalizeLink(prefix, item.prefix) + if (item.items === 'structure') { + list.push(nextPrefix) + } else { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + item.items?.length && + list.push(...findStructureList(item.items, nextPrefix)) + } + } + }) + + return list +} + +const getSidebarItemsFromStructure = ( + app: App, + sorters: SidebarSorterFunction[], + dirname: string, +): ResolvedSidebarItem[] => { + const infos = getSidebarInfo({ + pages: app.pages, + sorters, + scope: removeLeadingSlash(ensureEndingSlash(dirname)), + }) + // eslint-disable-next-line @typescript-eslint/no-use-before-define + return getSidebarItemsFromInfos(infos) +} + +const getSidebarItemsFromInfos = ( + infos: SidebarInfo[], +): ResolvedSidebarItem[] => + infos.map((info) => { + if (info.type === 'file') { + return { + link: + info.path || ensureLeadingSlash(info.pageData.filePathRelative || ''), + text: info.title, + } + } + return { + text: info.title, + ...info.groupInfo, + items: getSidebarItemsFromInfos(info.children), + } + }) diff --git a/themes/theme-next/src/node/utils/constants.ts b/themes/theme-next/src/node/utils/constants.ts new file mode 100644 index 0000000000..48d921c812 --- /dev/null +++ b/themes/theme-next/src/node/utils/constants.ts @@ -0,0 +1 @@ +export const THEME_NAME = '@vuepress/theme-default' diff --git a/themes/theme-next/src/node/utils/getTitleFromFilename.ts b/themes/theme-next/src/node/utils/getTitleFromFilename.ts new file mode 100644 index 0000000000..f8b5892a42 --- /dev/null +++ b/themes/theme-next/src/node/utils/getTitleFromFilename.ts @@ -0,0 +1,37 @@ +const EN_PREPOSITION = [ + 'and', + 'or', + 'in', + 'on', + 'with', + 'by', + 'for', + 'at', + 'about', + 'under', + 'of', + 'to', + 'the', + 'into', +] + +export const getTitleFromFilename = (filename: string): string => { + const words = filename + .replace(/[-_]/gu, ' ') + .replace( + /(^|[^A-Z])([A-Z])/gu, + (_all, match1: string, match2: string) => + `${match1} ${match2.toLowerCase()}`, + ) + .replace(/ +/gu, ' ') + .trim() + .split(' ') + + return words + .map((word, index) => + EN_PREPOSITION.includes(word) && index !== 0 + ? word + : word.charAt(0).toUpperCase() + word.slice(1), + ) + .join(' ') +} diff --git a/themes/theme-next/src/node/utils/index.ts b/themes/theme-next/src/node/utils/index.ts new file mode 100644 index 0000000000..ea8033504e --- /dev/null +++ b/themes/theme-next/src/node/utils/index.ts @@ -0,0 +1,4 @@ +export * from './constants.js' +export * from './getTitleFromFilename.js' +export * from './logger.js' +export * from './normalizeLink.js' diff --git a/themes/theme-next/src/node/utils/logger.ts b/themes/theme-next/src/node/utils/logger.ts new file mode 100644 index 0000000000..f2d5b5ce57 --- /dev/null +++ b/themes/theme-next/src/node/utils/logger.ts @@ -0,0 +1,4 @@ +import { Logger } from '@vuepress/helper' +import { THEME_NAME } from './constants.js' + +export const logger = new Logger(THEME_NAME) diff --git a/themes/theme-next/src/node/utils/normalizeLink.ts b/themes/theme-next/src/node/utils/normalizeLink.ts new file mode 100644 index 0000000000..13ca28f2ba --- /dev/null +++ b/themes/theme-next/src/node/utils/normalizeLink.ts @@ -0,0 +1,10 @@ +import { + ensureLeadingSlash, + isLinkAbsolute, + isLinkWithProtocol, +} from '@vuepress/helper' + +export const normalizeLink = (base: string, link = ''): string => + isLinkAbsolute(link) || isLinkWithProtocol(link) + ? link + : ensureLeadingSlash(`${base}/${link}/`.replace(/\/+/g, '/')) diff --git a/themes/theme-next/src/shared/index.ts b/themes/theme-next/src/shared/index.ts new file mode 100644 index 0000000000..b11cb09f8d --- /dev/null +++ b/themes/theme-next/src/shared/index.ts @@ -0,0 +1,5 @@ +export type * from './shared.js' +export type * from './navbar.js' +export type * from './sidebar.js' +export type * from './page.js' +export type * from './locales.js' diff --git a/themes/theme-next/src/shared/locales.ts b/themes/theme-next/src/shared/locales.ts new file mode 100644 index 0000000000..1478f28c93 --- /dev/null +++ b/themes/theme-next/src/shared/locales.ts @@ -0,0 +1,361 @@ +import type { ThemeData } from '@vuepress/plugin-theme-data' +import type { + ContributorThemeData, + EditLinkThemeData, +} from '@vuepress/theme-helper/shared' +import type { UseDarkOptions } from '@vueuse/core' +import type { LocaleData } from 'vuepress/shared' +import type { NavItem } from './navbar.js' +import type { DefaultThemeImage, SocialLink } from './shared.js' +import type { Sidebar } from './sidebar.js' + +export type DefaultThemeLocaleOptions = DefaultThemeData + +export type DefaultThemeData = ThemeData + +export interface DefaultThemeLocaleData + extends LocaleData, + ContributorThemeData, + EditLinkThemeData { + /** + * Custom site title in navbar. If the value is undefined, + * `userConfig.title` will be used. + * + * 自定义导航栏中的站点标题。如果值为 `undefined`,将使用 `userConfig.title` + */ + siteTitle?: string | false + + /** + * The logo file of the site. + * + * 站点 Logo + * + * @example '/logo.svg' + */ + logo?: DefaultThemeImage + + /** + * Overrides the link of the site logo. + * + * 覆盖站点 Logo 的链接 + */ + logoLink?: string | { link?: string; rel?: string; target?: string } + + /** + * Appearance color mode + * + * 是否开启 浅色/深色模式 + * + * @default true + */ + appearance?: + | boolean + | 'dark' + | 'force-dark' + | (Omit< + UseDarkOptions, + 'initialValue' | 'onChanged' | 'storage' | 'storageKey' | 'storageRef' + > & { initialValue?: 'dark' }) + + /** + * The navbar items. + * + * 导航栏 + */ + navbar?: NavItem[] + + /** + * The sidebar items. + * + * 侧边栏 + */ + sidebar?: Sidebar + + /** + * Whether to enable page internal aside in `doc` layout. + * - Set to `false` to prevent rendering of aside container. + * - Set to `true` to render the aside to the right. + * - Set to `left` to render the aside to the left. + * + * 是否在 `doc` 布局中启用页内侧边栏 + * - 将此值设置为 `false` 可禁用 aside 容器。 + * - 将此值设置为 `true` 将在页面右侧渲染。 + * - 将此值设置为 `left` 将在页面左侧渲染。 + * + * @default true + */ + aside?: boolean | 'left' + + /** + * Custom header levels of outline in the aside component. + * + * 自定义侧边栏中的标题层级 + * + * @default [2,3] + */ + outline?: Outline | false + + /** + * The title of the outline + * + * 侧边栏 标题 + * + * @default 'On this page' + */ + outlineTitle?: string + + /** + * The social links to be displayed at the end of the nav bar. Perfect for + * placing links to social services such as GitHub, Twitter, Facebook, etc. + * + * 在导航栏中显示的社交链接, 适合放置与 GitHub, Twitter, Facebook 等社交服务相关的链接 + */ + socialLinks?: SocialLink[] + + /** + * The footer configuration. + * + * 页脚 + */ + footer?: Footer + + /** + * Customize text of 404 page. + * + * 定制404页面的文本。 + */ + notFound?: NotFoundOptions + + /** + * 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 + + /** + * Set options for last updated time formatting. + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat#using_options + * + * @default + * { dateStyle: 'short', timeStyle: 'short' } + */ + lastUpdatedFormatOptions?: Intl.DateTimeFormatOptions & { + forceLocale?: boolean + } + + /** + * Page meta - contributors config + * + * The text to replace the default "Contributors" + * + * 页面元数据 - 贡献者 + * + * 贡献者的文本 + */ + contributorsText?: string + + /** + * Set custom prev/next labels. + * + * 自定义 prev/next + */ + docFooter?: DocFooter + + /** + * The sidebar menu label + * + * 侧边栏 菜单标签 + * + * @default 'Menu' + */ + sidebarMenuLabel?: string + + /** + * Can be used to customize the dark mode switch label. + * This label is only displayed in the mobile view. + * + * 用于自定义深色模式开关标签,该标签仅在移动端视图中显示。 + * + * @default 'Appearance' + */ + darkModeSwitchLabel?: string + + /** + * @default 'Switch to light theme' + */ + lightModeSwitchTitle?: string + + /** + * @default 'Switch to dark theme' + */ + darkModeSwitchTitle?: string + + /** + * Local Nav config + * + * Local Nav 配置 + * + * @default 'Return to top' + */ + returnToTopLabel?: string + + /** + * Navbar language selection config + * + * Text of the language selection dropdown + * + * 导航栏语言选择配置 + * + * 语言选择下拉菜单的文本 + * + * @default 'Languages' + */ + selectLanguageText?: string + + /** + * Navbar language selection config + * + * Language name of current locale + * + * Displayed inside the language selection dropdown + * + * 导航栏语言选择配置 + * + * 当前区域的语言名称 + * + * 显示在语言选择下拉菜单中 + */ + selectLanguageName?: string + + /** + * The carbon ads options. Leave it undefined to disable the ads feature. + * + * Carbon广告选项。将其保留为未定义以禁用广告功能。 + */ + carbonAds?: CarbonAdsOptions + + /** + * Show external link icon in Markdown links. + * + * 在Markdown链接中显示外部链接图标。 + * + * @default true + */ + externalLinkIcon?: boolean + + /** + * Configure the scroll offset when the theme has a sticky header. + * Can be a number or a selector element to get the offset from. + * Can also be an array of selectors in case some elements will be + * invisible due to responsive layout. VuePress will fallback to the next + * selector if a selector fails to match, or the matched element is not + * currently visible in viewport. + * + * 配置主题具有粘性标题时的滚动偏移量。 + * 可以是一个数字或选择器元素以获取偏移量。 + * 也可以是选择器数组,以防某些元素由于响应式布局而不可见。 + * VuePress 将在选择器无法匹配或匹配的元素当前不可见于视口时,回退到下一个选择器。 + */ + scrollOffset?: + | string[] + | number + | string + | { selector: string[] | string; padding: number } +} + +// prev-next ----------------------------------------------------------------- + +export interface DocFooter { + /** + * Custom label for previous page button. Can be set to `false` to disable. + * + * 设置上一页按钮的自定义标签。可以设置为 `false` 来禁用。 + * + * @default 'Previous page' + */ + prev?: boolean | string + + /** + * Custom label for next page button. Can be set to `false` to disable. + * + * 设置下一页按钮的自定义标签。可以设置为 `false` 来禁用。 + * + * @default 'Next page' + */ + next?: boolean | string +} + +// footer -------------------------------------------------------------------- + +export interface Footer { + message?: string + copyright?: string +} + +// outline ------------------------------------------------------------------- + +export type Outline = number | 'deep' | [number, number] + +// carbon ads ---------------------------------------------------------------- + +export interface CarbonAdsOptions { + code: string + placement: string +} + +// not found ----------------------------------------------------------------- + +export interface NotFoundOptions { + /** + * Set custom not found message. + * + * 自定义 页面未找到 消息。 + * + * @default 'PAGE NOT FOUND' + */ + title?: string + + /** + * Set custom not found description. + * + * 自定义 页面的未找到 描述。 + * + * @default "But if you don't change your direction, and if you keep looking, you may end up where you are heading." + */ + quote?: string + + /** + * Set aria label for home link. + * + * @default 'go to home' + */ + linkLabel?: string + + /** + * Set custom home link text. + * + * @default 'Take me home' + */ + linkText?: string + + /** + * @default '404' + */ + code?: string +} diff --git a/themes/theme-next/src/shared/navbar.ts b/themes/theme-next/src/shared/navbar.ts new file mode 100644 index 0000000000..c88eac0ec5 --- /dev/null +++ b/themes/theme-next/src/shared/navbar.ts @@ -0,0 +1,100 @@ +export type NavItem = NavItemWithChildren | NavItemWithLink | string + +export interface NavItemWithLink { + text: string + link: string + + /** + * A Regexp string, matching path will be active + * + * It's expected to be a regex string as RegExp object isn't serializable + * + * 正则表达式字符串,匹配的路径将被激活 + * + * 由于 RegExp 对象不可序列化,因此我们需要将其定义为字符串。 + */ + activeMatch?: string + + rel?: string + target?: string + + /** + * Whether to display external link icon. + * If the navigation link is an external link, the external link icon will be displayed by default. Setting it to `true` will disable the external link icon. + * + * 是否显示外部链接图标,如果导航链接是外部链接,默认将显示外部链接图标,设置为 `true` 可以关闭外部链接图标 + */ + noIcon?: boolean + + prefix?: never + items?: never + /** + * @deprecated Use `items` instead + * + * @deprecated 使用 `items` 替换 + * */ + children?: never +} + +export interface NavItemChildren { + /** + * The text of the dropdown menu + */ + text?: string + + /** + * Link prefix of current group + * + * 当前分组的页面前缀 + */ + prefix?: string + + /** + * The items in the dropdown menu + * + * 导航栏下拉菜单 + */ + items: (NavItemWithLink | string)[] + + /** + * @deprecated Use `items` instead + * + * @deprecated 使用 `items` 替换 + * */ + children?: (NavItemWithLink | string)[] +} + +export interface NavItemWithChildren { + text?: string + /** + * Link prefix of current group + * + * 当前分组的页面前缀 + */ + prefix?: string + + /** + * A Regexp string, matching path will be active + * + * It's expected to be a regex string as RegExp object isn't serializable + * + * 正则表达式字符串,匹配的路径将被激活 + * + * 由于 RegExp 对象不可序列化,因此我们需要将其定义为字符串。 + */ + activeMatch?: string + + /** + * The items in the dropdown menu + * + * 导航栏下拉菜单 + */ + items: (NavItemChildren | NavItemWithLink | string)[] + + /** + * @deprecated Use `items` instead + * + * @deprecated 使用 `items` 替换 + * */ + children?: (NavItemChildren | NavItemWithLink | string)[] +} diff --git a/themes/theme-next/src/shared/page.ts b/themes/theme-next/src/shared/page.ts new file mode 100644 index 0000000000..e31eb4798f --- /dev/null +++ b/themes/theme-next/src/shared/page.ts @@ -0,0 +1,244 @@ +import type { GitData } from '@vuepress/plugin-git' +import type { + ContributorFrontmatter, + EditLinkFrontmatter, +} from '@vuepress/theme-helper/shared' +import type { Outline } from './locales.js' +import type { NavItemWithLink } from './navbar.js' +import type { DefaultThemeImage, Feature, HeroAction } from './shared.js' + +export interface DefaultThemePageData extends Record { + filePathRelative: string | null + git?: GitData +} + +export interface DefaultThemePageFrontmatter extends Record { + /** + * Whether is homepage + * + * 是否是主页 + */ + home?: boolean + /** + * Extra class name for page + * + * 页面额外类名 + */ + pageClass?: string + /** + * Page layout + * + * 页面布局 + * + * @type { 'doc' | 'page' | 'home' } + * @default 'doc' + */ + pageLayout?: string | false + /** + * Whether show navbar + * + * 是否显示导航 + * + * @default true + */ + navbar?: boolean + /** + * Whether show footer + * + * 是否显示 页脚 + * + * @default true + */ + footer?: boolean + /** + * Whether show external link icon + * + * 是否显示外链图标 + * + * @default true + */ + externalLinkIcon?: boolean +} + +export interface DefaultThemeHomePageFrontmatter + extends DefaultThemePageFrontmatter { + /** + * Whether use markdown styles + * + * 是否使用 markdown 样式 + * + * @default true + */ + markdownStyles?: false + + hero?: { + /** + * The string shown top of `text`. Comes with brand color + * and expected to be short, such as product name. + * + * `text` 上方的字符,带有品牌颜色, 尽量简短,例如产品名称 + */ + name?: string + /** + * The main text for the hero section. This will be defined as `h1` tag. + * + * hero 部分的主要文字,被定义为 `h1` 标签 + */ + text?: string + /** + * Tagline displayed below `text`. + * + * `text` 下方的标语 + */ + tagline?: string + /** + * The image is displayed next to the text and tagline area. + * + * text 和 tagline 区域旁的图片 + */ + image?: DefaultThemeImage + } + /** + * Action buttons to display in home hero section. + * + * 主页 hero 部分的操作按钮 + */ + actions?: HeroAction[] + + /** + * you can list any number of features you would like to show right after the Hero section + * + * 在 Hero 部分之后列出任意数量的 Feature + */ + features?: Feature[] +} + +export interface DefaultThemeNormalPageFrontmatter + extends DefaultThemePageFrontmatter, + ContributorFrontmatter, + EditLinkFrontmatter { + /** + * Whether show sidebar + * + * 是否显示侧边栏 + * + * @default true + */ + sidebar?: boolean + /** + * 定义侧边栏组件在 `doc` 布局中的位置 + * + * @default true + */ + aside?: boolean | 'left' + /** + * Whether show last updated time + * + * 是否显示最后更新时间 + * + * @default true + */ + lastUpdated?: boolean + /** + * 大纲中显示的标题级别。 + * + * @default [2,3] + */ + outline?: Outline + /** + * Whether show Prev link, or define the Prev link + * + * 是否显示 Prev 链接,或定义 Prev 链接 + * + * @default true + */ + prev: NavItemWithLink | boolean | string + /** + * Whether show Next link, or define the Next link + * + * 是否显示 Next 链接,或定义 Next 链接 + * + * @default true + */ + next: NavItemWithLink | boolean | string + /** + * Whether index current page + * + * 是否索引此页面 + * + * @default true + */ + index?: boolean + + /** + * Page order in sidebar + * + * 页面在侧边栏的顺序 + * + * @default 0 + */ + order?: number + /** + * Dir config + * + * @description Only available at README.md + * + * 目录配置 + * + * @description 仅在 README.md 中可用 + */ + dir?: { + /** + * Dir title + * + * @default title of README.md + * + * 目录标题 + * + * @default README.md 标题 + */ + text?: string + + /** + * Whether Dir is collapsible + * + * 目录是否可折叠 + * + * @default true + */ + + collapsible?: boolean + + /** + * Whether Dir is clickable + * + * @description Will set group link to link of README.md + * + * 目录是否可点击 + * + * @description 将会将目录分组的链接设置为 README.md 对应的链接 + * + * @default false + */ + + link?: boolean + + /** + * Whether index current dir + * + * 是否索引此目录 + * + * @default true + */ + index?: boolean + + /** + * Dir order in sidebar + * + * 目录在侧边栏中的顺序 + * + * @default 0 + */ + order?: number + } +} diff --git a/themes/theme-next/src/shared/resolved/navbar.ts b/themes/theme-next/src/shared/resolved/navbar.ts new file mode 100644 index 0000000000..7eae2f35f0 --- /dev/null +++ b/themes/theme-next/src/shared/resolved/navbar.ts @@ -0,0 +1,44 @@ +export type ResolvedNavItem = + | ResolvedNavItemWithChildren + | ResolvedNavItemWithLink + +export interface ResolvedNavItemWithLink { + text: string + link: string + items?: never + + /** + * A Regexp string, matching path will be active + * + * It's expected to be a regex string as RegExp object isn't serializable + * + * 正则表达式字符串,匹配的路径将被激活 + * + * 由于 RegExp 对象不可序列化,因此我们需要将其定义为字符串。 + */ + activeMatch?: string + rel?: string + target?: string + noIcon?: boolean +} + +export interface ResolvedNavItemChildren { + text?: string + items: ResolvedNavItemWithLink[] +} + +export interface ResolvedNavItemWithChildren { + text?: string + items: (ResolvedNavItemChildren | ResolvedNavItemWithLink)[] + + /** + * A Regexp string, matching path will be active + * + * It's expected to be a regex string as RegExp object isn't serializable + * + * 正则表达式字符串,匹配的路径将被激活 + * + * 由于 RegExp 对象不可序列化,因此我们需要将其定义为字符串。 + */ + activeMatch?: string +} diff --git a/themes/theme-next/src/shared/resolved/sidebar.ts b/themes/theme-next/src/shared/resolved/sidebar.ts new file mode 100644 index 0000000000..c7a2777faa --- /dev/null +++ b/themes/theme-next/src/shared/resolved/sidebar.ts @@ -0,0 +1,50 @@ +// resolved sidebar ------------------------------------------------------------------- + +export type ResolvedSidebar = ResolvedSidebarItem[] | ResolvedSidebarMulti + +export type ResolvedSidebarMulti = Record< + string, + ResolvedSidebarItem[] | { items: ResolvedSidebarItem[] } +> + +export interface ResolvedSidebarItem { + /** + * The text label of the item. + */ + text?: string + + /** + * The link of the item. + */ + link?: string + + /** + * The children of the item. + */ + items?: ResolvedSidebarItem[] + + /** + * If not specified, group is not collapsible. + * + * If `true`, group is collapsible and collapsed by default + * + * If `false`, group is collapsible but expanded by default + * + * 若未指定,分组不可折叠。 + * + * 若为 `true`,分组可折叠且默认折叠 + * + * 若为 `false`,分组可折叠但默认展开 + */ + collapsed?: boolean + + /** + * Customize text that appears on the footer of previous/next page. + * + * 自定义上一页/下一页页脚显示的文本。 + */ + docFooterText?: string + + rel?: string + target?: string +} diff --git a/themes/theme-next/src/shared/shared.ts b/themes/theme-next/src/shared/shared.ts new file mode 100644 index 0000000000..485cbadf4b --- /dev/null +++ b/themes/theme-next/src/shared/shared.ts @@ -0,0 +1,160 @@ +// image --------------------------------------------------------------------- + +export type DefaultThemeImage = + | string + | { light: string; dark: string; alt?: string; [prop: string]: unknown } + | { src: string; alt?: string; [prop: string]: unknown } + +export type FeatureIcon = + | string + | { + light: string + dark: string + alt?: string + width?: string + height?: string + wrap?: boolean + } + | { + src: string + alt?: string + width?: string + height?: string + wrap?: boolean + } + +// home --------------------------------------------------------------------- + +export interface HeroAction { + /** + * Color theme of the button. Defaults to `brand`. + * + * 按钮的颜色主题,默认为 `brand` + * + * @default 'brand' + */ + theme?: 'alt' | 'brand' + /** + * Label of the button. + * + * 按钮的标签 + */ + text: string + /** + * Destination link of the button. + * + * 按钮的目标链接 + */ + link: string + /** + * Link target attribute. + * + * 链接的 target 属性 + */ + target?: string + /** + * Link rel attribute. + * + * 链接的 rel 属性 + */ + rel?: string +} + +export interface Feature { + /** + * Show icon on each feature box. + * + * 在每个 feature 框中显示图标 + */ + icon?: FeatureIcon + /** + * Title of the feature. + * + * feature 标题 + */ + title: string + /** + * Details of the feature. + * + * feature 的详情 + */ + details: string + /** + * Link when clicked on feature component. The link can be both internal or external. + * + * 点击 feature 组件时的链接,可以是内部链接,也可以是外部链接。 + */ + link?: string + /** + * Link text to be shown inside feature component. Best used with `link` option. + * + * e.g. `Learn more`, `Visit page`, etc. + * + * feature 组件内显示的链接文本,最好与 `link` 选项一起使用。 + * + * 例如 `Learn more`, `Visit page` 等 + */ + linkText?: string + /** + * Link rel attribute for the `link` option. + * + * `link` 选项的链接 rel 属性 + */ + rel?: string + /** + * Link target attribute for the `link` option. + * + * `link` 选项的链接 target 属性 + */ + target?: string +} + +// social link --------------------------------------------------------------- + +export interface SocialLink { + icon: SocialLinkIcon + link: string + ariaLabel?: string +} + +export type SocialLinkIcon = + | 'discord' + | 'facebook' + | 'github' + | 'instagram' + | 'linkedin' + | 'mastodon' + | 'npm' + | 'slack' + | 'twitter' + | 'x' + | 'youtube' + | { svg: string } + +// sponsor ------------------------------------------------------------------- + +export interface Sponsor { + name: string + img: string + url: string +} + +export interface Sponsors { + tier?: string + size?: 'big' | 'medium' | 'mini' | 'small' | 'xmini' + items: Sponsor[] +} + +// team ---------------------------------------------------------------------- + +export interface TeamMember { + avatar: string + name: string + title?: string + org?: string + orgLink?: string + desc?: string + links?: SocialLink[] + sponsor?: string + actionText?: string +} diff --git a/themes/theme-next/src/shared/sidebar.ts b/themes/theme-next/src/shared/sidebar.ts new file mode 100644 index 0000000000..083ef75ada --- /dev/null +++ b/themes/theme-next/src/shared/sidebar.ts @@ -0,0 +1,128 @@ +import type { PageFrontmatter } from 'vuepress/core' +import type { + DefaultThemeNormalPageFrontmatter, + DefaultThemePageData, +} from './page.js' + +export type Sidebar = (SidebarItem | string)[] | SidebarMulti | 'structure' + +export type SidebarMulti = Record< + string, + | (SidebarItem | string)[] + | 'structure' + | { items: (SidebarItem | string)[] | 'structure'; prefix?: string } +> + +export interface SidebarItem { + /** + * The text label of the item. + * + * 项目文本 + */ + text?: string + + /** + * The link of the item. + * + * 项目链接 + */ + link?: string + + /** + * The children of the item. + * + * 子项目列表 + * + */ + items?: (SidebarItem | string)[] | 'structure' + + /** + * The children of the item. + * @deprecated Use `items` instead + * + * 子项目列表 + * @deprecated 使用 `items` 代替 + */ + children?: (SidebarItem | string)[] + + /** + * Whether the current group is collapsible + * - If not specified, group is not collapsible. + * - If `true`, group is collapsible and collapsed by default + * - If `false`, group is collapsible but expanded by default + * + * 当前子项目列表是否可折叠 + * - 如果未指定,分组不可折叠 + * - 如果为 `true`,分组可折叠且默认折叠 + * - 如果为 `false`,分组可折叠且默认展开 + */ + collapsed?: boolean + + /** + * prefix path for the children items. + * + * 子项目的路径前缀 + */ + prefix?: string + + /** + * Customize text that appears on the footer of previous/next page. + * + * 自定义上一页/下一页页脚显示的文本。 + */ + docFooterText?: string + + rel?: string + target?: string +} + +export interface SidebarFileInfo { + type: 'file' + filename: string + + title: string + order: number | null + path?: string | null + + frontmatter: PageFrontmatter + pageData: DefaultThemePageData +} + +export interface SidebarDirInfo { + type: 'dir' + dirname: string + children: SidebarInfo[] + + title: string + order: number | null + + groupInfo: { + icon?: string + collapsible?: boolean + link?: string + } + + frontmatter: PageFrontmatter | null + pageData: DefaultThemePageData | null +} + +export type SidebarInfo = SidebarDirInfo | SidebarFileInfo + +export type SidebarSorterKeyword = + | 'date-desc' + | 'date' + | 'filename' + | 'order' + | 'readme' + | 'title' + +export type SidebarSorterFunction = ( + infoA: SidebarInfo, + infoB: SidebarInfo, +) => number + +export type SidebarSorter = + | SidebarSorterFunction + | SidebarSorterFunction[] + | SidebarSorterKeyword + | SidebarSorterKeyword[] diff --git a/themes/theme-next/templates/build.html b/themes/theme-next/templates/build.html new file mode 100644 index 0000000000..7c3f6bcc9a --- /dev/null +++ b/themes/theme-next/templates/build.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + +

+ + + diff --git a/themes/theme-next/tsconfig.build.json b/themes/theme-next/tsconfig.build.json new file mode 100644 index 0000000000..10f32ebeb2 --- /dev/null +++ b/themes/theme-next/tsconfig.build.json @@ -0,0 +1,43 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "baseUrl": ".", + "paths": { + "@theme/*": ["./src/client/components/*"] + }, + "types": ["vuepress/client-types", "vite/client", "webpack-env"] + }, + "include": ["./src"], + "references": [ + { + "path": "../../plugins/development/plugin-active-header-links/tsconfig.build.json" + }, + { "path": "../../plugins/development/plugin-git/tsconfig.build.json" }, + { + "path": "../../plugins/development/plugin-theme-data/tsconfig.build.json" + }, + + { + "path": "../../plugins/features/plugin-copy-code/tsconfig.build.json" + }, + { + "path": "../../plugins/features/plugin-medium-zoom/tsconfig.build.json" + }, + { + "path": "../../plugins/features/plugin-nprogress/tsconfig.build.json" + }, + + { + "path": "../../plugins/markdown/plugin-links-check/tsconfig.build.json" + }, + { + "path": "../../plugins/markdown/plugin-shiki/tsconfig.build.json" + }, + + { "path": "../../plugins/seo/plugin-seo/tsconfig.build.json" }, + { "path": "../../plugins/seo/plugin-sitemap/tsconfig.build.json" }, + { "path": "../../tools/theme-helper/tsconfig.build.json" } + ] +} diff --git a/tools/helper/package.json b/tools/helper/package.json index ff15317a32..411f3b7a84 100644 --- a/tools/helper/package.json +++ b/tools/helper/package.json @@ -31,7 +31,6 @@ "./noopComponent": "./lib/client/noopComponent.js", "./noopModule": "./lib/client/noopModule.js", "./colors.css": "./lib/client/styles/colors.css", - "./normalize.css": "./lib/client/styles/normalize.css", "./sr-only.css": "./lib/client/styles/sr-only.css", "./package.json": "./package.json" }, diff --git a/tools/helper/tests/__fixtures__/package-manager/config/pnpm/package.json b/tools/helper/tests/__fixtures__/package-manager/config/pnpm/package.json index bb96fa3ed7..8adc04822e 100644 --- a/tools/helper/tests/__fixtures__/package-manager/config/pnpm/package.json +++ b/tools/helper/tests/__fixtures__/package-manager/config/pnpm/package.json @@ -1,4 +1,4 @@ { "name": "test", - "packageManager": "pnpm@9.14.4" + "packageManager": "pnpm@9.15.0" } diff --git a/tools/theme-helper/package.json b/tools/theme-helper/package.json new file mode 100644 index 0000000000..55d2647380 --- /dev/null +++ b/tools/theme-helper/package.json @@ -0,0 +1,57 @@ +{ + "name": "@vuepress/theme-helper", + "version": "2.0.0-rc.61", + "description": "VuePress Theme Helper", + "keywords": [ + "vuepress", + "helper", + "bundler-helper", + "excerpt" + ], + "homepage": "https://ecosystem.vuejs.press/tools/theme-helper/", + "bugs": { + "url": "https://github.com/vuepress/ecosystem/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vuepress/ecosystem.git", + "directory": "tools/theme-helper" + }, + "license": "MIT", + "author": { + "name": "Mr.Hope", + "email": "mister-hope@outlook.com", + "url": "https://mister-hope.com" + }, + "type": "module", + "exports": { + ".": "./lib/node/index.js", + "./client": "./lib/client/index.js", + "./shared": "./lib/shared/index.js", + "./normalize.css": "./lib/client/styles/normalize.css", + "./package.json": "./package.json" + }, + "main": "./lib/node/index.js", + "types": "./lib/node/index.d.ts", + "files": [ + "lib" + ], + "scripts": { + "build": "tsc -b tsconfig.build.json", + "bundle": "rollup -c rollup.config.ts --configPlugin esbuild", + "clean": "rimraf --glob ./lib ./*.tsbuildinfo", + "style": "sass src:lib --embed-sources --style=compressed" + }, + "dependencies": { + "@vuepress/plugin-git": "workspace:*", + "@vuepress/plugin-theme-data": "workspace:*", + "@vueuse/core": "^12.0.0", + "vue": "^3.5.13" + }, + "peerDependencies": { + "vuepress": "2.0.0-rc.18" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/tools/theme-helper/rollup.config.ts b/tools/theme-helper/rollup.config.ts new file mode 100644 index 0000000000..2ef3ff904c --- /dev/null +++ b/tools/theme-helper/rollup.config.ts @@ -0,0 +1,17 @@ +import { rollupBundle } from '../../scripts/rollup.js' + +export default [ + ...rollupBundle('node/index', { + external: [], + }), + ...rollupBundle( + { + base: 'client', + files: ['index'], + }, + { + external: ['@vuepress/plugin-theme-data/client'], + }, + ), + ...rollupBundle('shared/index'), +] diff --git a/tools/theme-helper/src/client/composables/index.ts b/tools/theme-helper/src/client/composables/index.ts new file mode 100644 index 0000000000..3073d0ea08 --- /dev/null +++ b/tools/theme-helper/src/client/composables/index.ts @@ -0,0 +1 @@ +export * from './useContributors.js' diff --git a/tools/theme-helper/src/client/composables/useContributors.ts b/tools/theme-helper/src/client/composables/useContributors.ts new file mode 100644 index 0000000000..88b0adc754 --- /dev/null +++ b/tools/theme-helper/src/client/composables/useContributors.ts @@ -0,0 +1,25 @@ +import type { GitContributor, GitPluginPageData } from '@vuepress/plugin-git' +import type { ComputedRef } from 'vue' +import { computed } from 'vue' +import type { + ContributorFrontmatter, + ContributorThemeData, +} from '../../shared/index.js' +import { useData } from './useData.js' + +export const useContributors = (): ComputedRef => { + const { page, frontmatter, theme } = useData< + ContributorThemeData, + ContributorFrontmatter, + GitPluginPageData + >() + + return computed(() => { + const showContributors = + frontmatter.value.contributors ?? theme.value.contributors ?? true + + if (!showContributors) return null + + return page.value.git.contributors ?? null + }) +} diff --git a/tools/theme-helper/src/client/composables/useData.ts b/tools/theme-helper/src/client/composables/useData.ts new file mode 100644 index 0000000000..4decd9bee7 --- /dev/null +++ b/tools/theme-helper/src/client/composables/useData.ts @@ -0,0 +1,18 @@ +import type { ThemeLocaleDataRef } from '@vuepress/plugin-theme-data/client' +import { useThemeLocaleData } from '@vuepress/plugin-theme-data/client' +import type { PageDataRef, PageFrontmatterRef } from 'vuepress/client' +import { usePageData, usePageFrontmatter } from 'vuepress/client' + +export const useData = (): { + theme: ThemeLocaleDataRef & ThemeLocaleData> + frontmatter: PageFrontmatterRef> + page: PageDataRef> +} => { + const theme = useThemeLocaleData & ThemeLocaleData>() + const frontmatter = usePageFrontmatter< + Frontmatter & Record + >() + const page = usePageData>() + + return { theme, frontmatter, page } +} diff --git a/themes/theme-default/src/client/composables/useEditLink.ts b/tools/theme-helper/src/client/composables/useEditLink.ts similarity index 55% rename from themes/theme-default/src/client/composables/useEditLink.ts rename to tools/theme-helper/src/client/composables/useEditLink.ts index cbca17a0b5..56c89677a6 100644 --- a/themes/theme-default/src/client/composables/useEditLink.ts +++ b/tools/theme-helper/src/client/composables/useEditLink.ts @@ -1,22 +1,25 @@ -import { useThemeLocaleData } from '@theme/useThemeData' +import { resolveEditLink } from '@vuepress/theme-helper/client' import type { ComputedRef } from 'vue' import { computed } from 'vue' import type { AutoLinkConfig } from 'vuepress/client' -import { usePageData, usePageFrontmatter } from 'vuepress/client' import type { - DefaultThemeNormalPageFrontmatter, - DefaultThemePageData, + EditLinkFrontmatter, + EditLinkPageData, + EditLinkThemeData, } from '../../shared/index.js' -import { resolveEditLink } from '../utils/index.js' +import { useData } from './useData.js' export const useEditLink = (): ComputedRef => { - const themeLocale = useThemeLocaleData() - const page = usePageData() - const frontmatter = usePageFrontmatter() + const { page, frontmatter, theme } = useData< + EditLinkThemeData, + EditLinkFrontmatter, + EditLinkPageData + >() return computed(() => { const showEditLink = - frontmatter.value.editLink ?? themeLocale.value.editLink ?? true + frontmatter.value.editLink ?? theme.value.editLink ?? true + if (!showEditLink) { return null } @@ -27,9 +30,11 @@ export const useEditLink = (): ComputedRef => { docsBranch = 'main', docsDir = '', editLinkText, - } = themeLocale.value + } = theme.value - if (!docsRepo) return null + if (!docsRepo) { + return null + } const editLink = resolveEditLink({ docsRepo, @@ -37,7 +42,7 @@ export const useEditLink = (): ComputedRef => { docsDir, filePathRelative: page.value.filePathRelative, editLinkPattern: - frontmatter.value.editLinkPattern ?? themeLocale.value.editLinkPattern, + frontmatter.value.editLinkPattern ?? theme.value.editLinkPattern, }) if (!editLink) return null diff --git a/tools/theme-helper/src/client/index.ts b/tools/theme-helper/src/client/index.ts new file mode 100644 index 0000000000..3b732676ac --- /dev/null +++ b/tools/theme-helper/src/client/index.ts @@ -0,0 +1,3 @@ +export * from './composables/index.js' +export * from './utils/index.js' +export type * from '../shared/index.js' diff --git a/tools/helper/src/client/styles/normalize.scss b/tools/theme-helper/src/client/styles/normalize.scss similarity index 100% rename from tools/helper/src/client/styles/normalize.scss rename to tools/theme-helper/src/client/styles/normalize.scss diff --git a/tools/theme-helper/src/client/utils/index.ts b/tools/theme-helper/src/client/utils/index.ts new file mode 100644 index 0000000000..0d40221937 --- /dev/null +++ b/tools/theme-helper/src/client/utils/index.ts @@ -0,0 +1,2 @@ +export * from './resolveEditLink.js' +export * from './resolveRepoType.js' diff --git a/themes/theme-default/src/client/utils/resolveEditLink.ts b/tools/theme-helper/src/client/utils/resolveEditLink.ts similarity index 89% rename from themes/theme-default/src/client/utils/resolveEditLink.ts rename to tools/theme-helper/src/client/utils/resolveEditLink.ts index e6e43f218c..e2b3fbc766 100644 --- a/themes/theme-default/src/client/utils/resolveEditLink.ts +++ b/tools/theme-helper/src/client/utils/resolveEditLink.ts @@ -6,7 +6,7 @@ import { import type { RepoType } from './resolveRepoType.js' import { resolveRepoType } from './resolveRepoType.js' -export const editLinkPatterns: Record, string> = { +export const EDIT_LINK_PATTENS: Record, string> = { GitHub: ':repo/edit/:branch/:path', GitLab: ':repo/-/edit/:branch/:path', Gitee: ':repo/edit/:branch/:path', @@ -26,11 +26,8 @@ const resolveEditLinkPatterns = ({ } const repoType = resolveRepoType(docsRepo) - if (repoType !== null) { - return editLinkPatterns[repoType] - } - return null + return repoType ? EDIT_LINK_PATTENS[repoType] : null } export const resolveEditLink = ({ diff --git a/themes/theme-default/src/client/utils/resolveRepoType.ts b/tools/theme-helper/src/client/utils/resolveRepoType.ts similarity index 100% rename from themes/theme-default/src/client/utils/resolveRepoType.ts rename to tools/theme-helper/src/client/utils/resolveRepoType.ts diff --git a/tools/theme-helper/src/node/editLink.ts b/tools/theme-helper/src/node/editLink.ts new file mode 100644 index 0000000000..69a3c8005a --- /dev/null +++ b/tools/theme-helper/src/node/editLink.ts @@ -0,0 +1,9 @@ +import type { Page } from 'vuepress' +import type { EditLinkPageData } from '../shared/index.js' + +export const extendsEditLinkPage = ( + page: Page>, +): void => { + // save relative file path into page data to generate edit link + page.data.filePathRelative = page.filePathRelative +} diff --git a/tools/theme-helper/src/node/index.ts b/tools/theme-helper/src/node/index.ts new file mode 100644 index 0000000000..f6463d57f5 --- /dev/null +++ b/tools/theme-helper/src/node/index.ts @@ -0,0 +1,2 @@ +export * from './editLink.js' +export type * from '../shared/index.js' diff --git a/tools/theme-helper/src/shared/contributor.ts b/tools/theme-helper/src/shared/contributor.ts new file mode 100644 index 0000000000..7d96dd6507 --- /dev/null +++ b/tools/theme-helper/src/shared/contributor.ts @@ -0,0 +1,16 @@ +export interface ContributorOptions { + /** + * @kind Page meta / 页面元数据 + * + * Whether to show contributors + * + * 是否显示贡献者 + * + * @default true + */ + contributors?: boolean +} + +export type ContributorFrontmatter = ContributorOptions + +export type ContributorThemeData = ContributorOptions diff --git a/tools/theme-helper/src/shared/editLink.ts b/tools/theme-helper/src/shared/editLink.ts new file mode 100644 index 0000000000..15446ed0a3 --- /dev/null +++ b/tools/theme-helper/src/shared/editLink.ts @@ -0,0 +1,91 @@ +export interface EditLinkOptions { + /** + * Source repo link + * + * 源代码仓库链接 + */ + repo?: string | null + + /** + * @kind Page meta + * + * Whether to show "Edit this page" or not + * + * 是否显示“编辑此页面” + * + * @default true + */ + editLink?: boolean + + /** + * @kind Page meta + * + * The text for edit link + * + * 编辑链接的文本 + * + * @default 'Edit this page' + */ + editLinkText?: string + + /** + * @kind Page meta + * + * Pattern of edit link + * + * 编辑链接的模式 + * + * @description `:repo` {@link docsRepo} | `:branch` {@link docsBranch} | `:path` {@link docsDir} + * @example ':repo/edit/:branch/:path' + */ + editLinkPattern?: string + + /** + * @kind Page meta + * + * Docs repo link + * + * 文档仓库链接 + * + * @default repo + */ + docsRepo?: string + + /** + * @kind Page meta + * + * Docs branch + * + * 文档分支 + * + * @default 'main' + */ + docsBranch?: string + + /** + * @kind Page meta + * + * Docs dir + * + * 文档目录 + * + * @default '' + */ + docsDir?: string +} + +export type EditLinkFrontmatter = Pick< + EditLinkOptions, + 'editLink' | 'editLinkPattern' +> + +export type EditLinkThemeData = EditLinkOptions + +export interface EditLinkPageData { + /** + * Relative path of current page + * + * 当前页面的相对路径 + */ + filePathRelative: string | null +} diff --git a/tools/theme-helper/src/shared/index.ts b/tools/theme-helper/src/shared/index.ts new file mode 100644 index 0000000000..7c0592a1d3 --- /dev/null +++ b/tools/theme-helper/src/shared/index.ts @@ -0,0 +1,2 @@ +export type * from './contributor.js' +export type * from './editLink.js' diff --git a/tools/theme-helper/tsconfig.build.json b/tools/theme-helper/tsconfig.build.json new file mode 100644 index 0000000000..4f60f73883 --- /dev/null +++ b/tools/theme-helper/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "baseUrl": "." + }, + "include": ["./src"] +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 4315e4adbc..8c013a75d7 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -103,11 +103,13 @@ // themes { "path": "./themes/theme-default/tsconfig.build.json" }, + { "path": "./themes/theme-next/tsconfig.build.json" }, // tools { "path": "./tools/create-vuepress/tsconfig.build.json" }, { "path": "./tools/helper/tsconfig.build.json" }, { "path": "./tools/highlighter-helper/tsconfig.build.json" }, + { "path": "./tools/theme-helper/tsconfig.build.json" }, { "path": "./tools/vp-update/tsconfig.build.json" } ], "files": [] diff --git a/tsconfig.json b/tsconfig.json index 1694ff63ad..7a7ccbb738 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,8 +11,8 @@ "./plugins/plugin-theme-data/src/client/themeData.d.ts" ], "@theme/*": [ - "./themes/theme-default/src/client/components/*", - "./themes/theme-default/src/client/composables/*.js" + "./themes/theme-next/src/client/components/*", + "./themes/theme-next/src/client/composables/*" ] }, "types": ["vuepress/client-types", "webpack-env", "vite/client"]