From a3d8e225b96b8950d72ba6bb1a43cfecd926afd5 Mon Sep 17 00:00:00 2001 From: pengzhanbo Date: Mon, 19 Jan 2026 21:43:41 +0800 Subject: [PATCH] feat(theme): add filepath permalink support for auto frontmatter, #815 (#822) --- docs/.vuepress/collections/en/theme-guide.ts | 1 + docs/.vuepress/collections/zh/theme-guide.ts | 1 + docs/config/theme.md | 5 +- docs/en/config/theme.md | 5 +- docs/en/guide/quick-start/auto-frontmatter.md | 334 ++++++++++++++++++ docs/guide/quick-start/auto-frontmatter.md | 331 +++++++++++++++++ theme/package.json | 6 + theme/src/node/autoFrontmatter/helper.ts | 48 +++ .../autoFrontmatter/resolveLinkBySidebar.ts | 48 +++ theme/src/node/autoFrontmatter/rules.ts | 142 +++----- theme/src/node/utils/index.ts | 1 + theme/src/node/utils/pinyin.ts | 18 + theme/src/shared/features/autoFrontmatter.ts | 23 +- 13 files changed, 865 insertions(+), 98 deletions(-) create mode 100644 docs/en/guide/quick-start/auto-frontmatter.md create mode 100644 docs/guide/quick-start/auto-frontmatter.md create mode 100644 theme/src/node/autoFrontmatter/helper.ts create mode 100644 theme/src/node/autoFrontmatter/resolveLinkBySidebar.ts create mode 100644 theme/src/node/utils/pinyin.ts diff --git a/docs/.vuepress/collections/en/theme-guide.ts b/docs/.vuepress/collections/en/theme-guide.ts index cf95075e..d33acca9 100644 --- a/docs/.vuepress/collections/en/theme-guide.ts +++ b/docs/.vuepress/collections/en/theme-guide.ts @@ -23,6 +23,7 @@ export const themeGuide: ThemeCollectionItem = defineCollection({ }, 'sidebar', 'write', + 'auto-frontmatter', 'locales', 'deployment', 'optimize-build', diff --git a/docs/.vuepress/collections/zh/theme-guide.ts b/docs/.vuepress/collections/zh/theme-guide.ts index cd25af79..f0ee78fb 100644 --- a/docs/.vuepress/collections/zh/theme-guide.ts +++ b/docs/.vuepress/collections/zh/theme-guide.ts @@ -23,6 +23,7 @@ export const themeGuide: ThemeCollectionItem = defineCollection({ }, 'sidebar', 'write', + 'auto-frontmatter', 'locales', 'deployment', 'optimize-build', diff --git a/docs/config/theme.md b/docs/config/theme.md index 621fc48c..77febecf 100644 --- a/docs/config/theme.md +++ b/docs/config/theme.md @@ -190,8 +190,11 @@ export default defineThemeConfig({ * 是否自动生成 permalink * * @default true + * - true: 自动生成 permalink + * - false: 不生成 permalink + * - 'filepath': 根据文件路径生成 permalink */ - permalink?: boolean + permalink?: boolean | 'filepath' /** * 是否自动生成 createTime diff --git a/docs/en/config/theme.md b/docs/en/config/theme.md index 7f6e9265..7db8104c 100644 --- a/docs/en/config/theme.md +++ b/docs/en/config/theme.md @@ -190,8 +190,11 @@ export default defineThemeConfig({ * Whether to automatically generate permalink * * @default true + * - true: auto generate permalink + * - false: do not generate permalink + * - 'filepath': generate permalink based on file path */ - permalink?: boolean + permalink?: boolean | 'filepath' /** * Whether to automatically generate createTime diff --git a/docs/en/guide/quick-start/auto-frontmatter.md b/docs/en/guide/quick-start/auto-frontmatter.md new file mode 100644 index 00000000..59392227 --- /dev/null +++ b/docs/en/guide/quick-start/auto-frontmatter.md @@ -0,0 +1,334 @@ +--- +title: frontmatter +icon: material-symbols:markdown-outline-rounded +createTime: 2026/01/15 15:03:10 +permalink: /en/guide/auto-frontmatter/ +--- + +## Auto-generating Frontmatter + +This feature automatically generates frontmatter for each Markdown file. + +::: details What is Frontmatter? +Frontmatter is a metadata block written in YAML format at the very beginning of a Markdown file. +You can think of it as the "ID card" or "configuration manual" of the Markdown file. +It won't be rendered into the web page content directly, but is used to configure relevant parameters for the file. + +Frontmatter is wrapped using three dashes (`---`) and located at the very start of the file: + +```md +--- +title: Post Title +createTime: 2026/01/15 15:03:10 +--- + +Here is the Markdown content... +``` + +::: + +The current theme supports auto-generated frontmatter including: + +- `title`: Article title, generated based on the file name +- `createTime`: Article creation time, generated based on the file creation time +- `permalink`: Article permalink + - Uses `nanoid` to generate an 8-character random string by default + - Can be set to `filepath` to generate based on the file path + +## Configuration + +### Global Configuration + +::: code-tabs#config + +@tab .vuepress/config.ts + +```ts twoslash +import { defineUserConfig } from 'vuepress' +import { plumeTheme } from 'vuepress-theme-plume' + +export default defineUserConfig({ + theme: plumeTheme({ + // autoFrontmatter: true, // Theme built-in configuration + autoFrontmatter: { + title: true, // Auto-generate title + createTime: true, // Auto-generate creation time + permalink: true, // Auto-generate permalink + } + }) +}) +``` + +@tab .vuepress/plume.config.ts + +```ts twoslash +import { defineThemeConfig } from 'vuepress-theme-plume' + +export default defineThemeConfig({ + // autoFrontmatter: true, // Theme built-in configuration + autoFrontmatter: { + title: true, // Auto-generate title + createTime: true, // Auto-generate creation time + permalink: true, // Auto-generate permalink + } +}) +``` + +::: + +### Collection Configuration + +You can configure autoFrontmatter separately for each collection. + +::: code-tabs#config + +@tab .vuepress/config.ts + +```ts twoslash +import { defineUserConfig } from 'vuepress' +import { plumeTheme } from 'vuepress-theme-plume' + +export default defineUserConfig({ + theme: plumeTheme({ + collections: [ + { + type: 'doc', + dir: 'guide', + title: '指南', + // autoFrontmatter: true, // Theme built-in configuration + autoFrontmatter: { + title: true, // Auto-generate title + createTime: true, // Auto-generate creation time + permalink: true, // Auto-generate permalink + } + } + ] + }) +}) +``` + +@tab .vuepress/plume.config.ts + +```ts twoslash +import { defineThemeConfig } from 'vuepress-theme-plume' + +export default defineThemeConfig({ + collections: [ + { + type: 'doc', + dir: 'guide', + title: '指南', + // autoFrontmatter: true, // Theme built-in configuration + autoFrontmatter: { + title: true, // Auto-generate title + createTime: true, // Auto-generate creation time + permalink: true, // Auto-generate permalink + } + } + ] +}) +``` + +::: + +### Custom Processing Logic + +Use `transform(data, context, locale)` to configure custom processing logic. +`data` is the frontmatter data, `context` is the file context, and `locale` is the current language path. + +- `transform()` can also be an async function, returning a Promise. +- `transform()` is applicable in both global configuration and collection configuration. + +::: code-tabs#config + +@tab .vuepress/config.ts + +```ts +import { defineUserConfig } from 'vuepress' +import { plumeTheme } from 'vuepress-theme-plume' + +export default defineUserConfig({ + theme: plumeTheme({ + autoFrontmatter: { + title: true, // Auto-generate title + createTime: true, // Auto-generate creation time + permalink: true, // Auto-generate permalink + transform: (data, context, locale) => { // Custom transform + // context.filePath // File absolute path + // context.relativePath // File relative path, relative to source directory + // context.content // Markdown content + + data.foo ??= 'foo' + return data + } + } + }) +}) +``` + +@tab .vuepress/plume.config.ts + +```ts +import { defineThemeConfig } from 'vuepress-theme-plume' + +export default defineThemeConfig({ + autoFrontmatter: { + title: true, // Auto-generate title + createTime: true, // Auto-generate creation time + permalink: true, // Auto-generate permalink + transform: (data, context, locale) => { // Custom transform + // context.filePath // File absolute path + // context.relativePath // File relative path, relative to source directory + // context.content // Markdown content + + data.foo ??= 'foo' + return data + } + } +}) +``` + +::: + +### Interface + +```ts +interface AutoFrontmatterContext { + /** + * File absolute path + */ + filepath: string + /** + * File relative path + */ + relativePath: string + /** + * File markdown content + */ + content: string +} + +interface AutoFrontmatterOptions { + /** + * Whether to auto-generate permalink + * + * - `false`: Do not auto-generate permalink + * - `true`: Auto-generate permalink, using nanoid to generate an 8-digit random string + * - `filepath`: Generate permalink based on file path + * + * @default true + */ + permalink?: boolean | 'filepath' + /** + * Whether to auto-generate createTime + * + * Reads the file creation time by default. `createTime` is more precise to the second than VuePress's default `date` time. + */ + createTime?: boolean + /** + * Whether to auto-generate title + * + * Reads the file name as the title by default. + */ + title?: boolean + + /** + * Custom frontmatter generation function + * + * - You should add new fields directly to `data` + * - If a completely new `data` object is returned, it will overwrite the previous frontmatter + * @param data Existing frontmatter on the page + * @param context Context information of the current page + * @param locale Current language path + * @returns Returns the processed frontmatter + */ + transform?: (data: AutoFrontmatterData, context: AutoFrontmatterContext, locale: string) => AutoFrontmatterData | Promise +} +``` + +## Permalink + +The theme uses `nanoid` to generate an 8-character random string as the file's permalink by default. + +You can also configure `permalink` as `'filepath'` to generate the permalink based on the file path. +Please note, if your file path contains Chinese characters, +the theme recommends installing `pinyin-pro` in your project to support converting Chinese to pinyin. + +::: npm-to + +```sh +npm i pinyin-pro +``` + +::: + +::: code-tabs#config + +@tab .vuepress/config.ts + +```ts twoslash +import { defineUserConfig } from 'vuepress' +import { plumeTheme } from 'vuepress-theme-plume' + +export default defineUserConfig({ + theme: plumeTheme({ + autoFrontmatter: { + permalink: 'filepath', // [!code hl] + } + }) +}) +``` + +@tab .vuepress/plume.config.ts + +```ts twoslash +import { defineThemeConfig } from 'vuepress-theme-plume' + +export default defineThemeConfig({ + autoFrontmatter: { + permalink: 'filepath', // [!code hl] + } +}) +``` + +::: + +Example: + +::: code-tree + +```md title="docs/blog/服务.md" +--- +title: 服务 +permalink: /blog/wu-fu/ +--- +``` + +```md title="docs/blog/都城.md" +--- +title: 都城 +permalink: /blog/dou-cheng/ +--- +``` + +::: + +You probably noticed that in the example, the permalink for the `都城.md` file is `/blog/dou-cheng/`, +which is incorrect. This is because `pinyin-pro`'s default dictionary cannot accurately identify polyphonic +characters. If you need a more precise conversion result,you can manually install `@pinyin-pro/data`, +and the theme will automatically load this dictionary to improve accuracy. + +::: npm-to + +```sh +npm i @pinyin-pro/data +``` + +::: + +```md title="docs/blog/都城.md" +--- +title: 都城 +permalink: /blog/du-cheng/ +--- +``` diff --git a/docs/guide/quick-start/auto-frontmatter.md b/docs/guide/quick-start/auto-frontmatter.md new file mode 100644 index 00000000..36a2a61d --- /dev/null +++ b/docs/guide/quick-start/auto-frontmatter.md @@ -0,0 +1,331 @@ +--- +title: frontmatter +icon: material-symbols:markdown-outline-rounded +createTime: 2026/01/15 15:03:10 +permalink: /guide/auto-frontmatter/ +--- + +## 自动生成 frontmatter + +主题 自动为每个 Markdown 文件生成 frontmatter。 + +::: details 什么是 frontmatter ? +Frontmatter(前言)是在 Markdown 文件最开头部分使用 YAML 格式编写的元数据区块。 +你可以把它想象成 Markdown 文件的“身份证”或“配置说明书”,它不会被直接渲染成网页内容,而是用于配置该文件的相关参数。 + +Frontmatter 使用三个连字符(---)包裹,位于文件的最开头: + +```md +--- +title: Post Title +createTime: 2026/01/15 15:03:10 +--- + +这里是 Markdown 正文内容... +``` + +::: + +当前主题支持自动生成的 frontmatter 包括: + +- `title`: 文章标题,根据文件名生成 +- `createTime`: 文章创建时间,根据文件创建时间生成 +- `permalink`: 文章链接 + - 默认使用 `nanoid` 生成 8 位随机字符串 + - 可以设置为 `filepath` 根据文件路径生成 + +## 配置 + +### 全局配置 + +::: code-tabs#config + +@tab .vuepress/config.ts + +```ts twoslash +import { defineUserConfig } from 'vuepress' +import { plumeTheme } from 'vuepress-theme-plume' + +export default defineUserConfig({ + theme: plumeTheme({ + // autoFrontmatter: true, // 主题内置配置 + autoFrontmatter: { + title: true, // 自动生成标题 + createTime: true, // 自动生成创建时间 + permalink: true, // 自动生成永久链接 + } + }) +}) +``` + +@tab .vuepress/plume.config.ts + +```ts twoslash +import { defineThemeConfig } from 'vuepress-theme-plume' + +export default defineThemeConfig({ + // autoFrontmatter: true, // 主题内置配置 + autoFrontmatter: { + title: true, // 自动生成标题 + createTime: true, // 自动生成创建时间 + permalink: true, // 自动生成永久链接 + } +}) +``` + +::: + +### 集合配置 + +可以给每一个集合单独配置 autoFrontmatter + +::: code-tabs#config + +@tab .vuepress/config.ts + +```ts twoslash +import { defineUserConfig } from 'vuepress' +import { plumeTheme } from 'vuepress-theme-plume' + +export default defineUserConfig({ + theme: plumeTheme({ + collections: [ + { + type: 'doc', + dir: 'guide', + title: '指南', + // autoFrontmatter: true, // 主题内置配置 + autoFrontmatter: { + title: true, // 自动生成标题 + createTime: true, // 自动生成创建时间 + permalink: true, // 自动生成永久链接 + } + } + ] + }) +}) +``` + +@tab .vuepress/plume.config.ts + +```ts twoslash +import { defineThemeConfig } from 'vuepress-theme-plume' + +export default defineThemeConfig({ + collections: [ + { + type: 'doc', + dir: 'guide', + title: '指南', + // autoFrontmatter: true, // 主题内置配置 + autoFrontmatter: { + title: true, // 自动生成标题 + createTime: true, // 自动生成创建时间 + permalink: true, // 自动生成永久链接 + } + } + ] +}) +``` + +::: + +### 自定义处理逻辑 + +使用 `transform(data, context, locale)` 配置自定义处理逻辑,`data` 为 frontmatter 数据,`context` 为文件上下文,`locale` 为当前语言路径。 + +- `transform()` 也可以是异步函数,返回 Promise。 +- `transform()` 适用于 全局配置 和 集合配置 中。 + +::: code-tabs#config + +@tab .vuepress/config.ts + +```ts +import { defineUserConfig } from 'vuepress' +import { plumeTheme } from 'vuepress-theme-plume' + +export default defineUserConfig({ + theme: plumeTheme({ + autoFrontmatter: { + title: true, // 自动生成标题 + createTime: true, // 自动生成创建时间 + permalink: true, // 自动生成永久链接 + transform: (data, context, locale) => { // 自定义转换 + // context.filePath // 文件绝对路径 + // context.relativePath // 文件相对路径,相对于源目录 + // context.content // markdown 正文内容 + + data.foo ??= 'foo' + return data + } + } + }) +}) +``` + +@tab .vuepress/plume.config.ts + +```ts +import { defineThemeConfig } from 'vuepress-theme-plume' + +export default defineThemeConfig({ + autoFrontmatter: { + title: true, // 自动生成标题 + createTime: true, // 自动生成创建时间 + permalink: true, // 自动生成永久链接 + transform: (data, context, locale) => { // 自定义转换 + // context.filePath // 文件绝对路径 + // context.relativePath // 文件相对路径,相对于源目录 + // context.content // markdown 正文内容 + + data.foo ??= 'foo' + return data + } + } +}) +``` + +::: + +### Interface + +```ts +interface AutoFrontmatterContext { + /** + * 文件绝对路径 + */ + filepath: string + /** + * 文件相对路径 + */ + relativePath: string + /** + * 文件 markdown 内容 + */ + content: string +} + +interface AutoFrontmatterOptions { + /** + * 是否自动生成 permalink + * + * - `false`: 不自动生成 permalink + * - `true`: 自动生成 permalink ,使用 nanoid 生成 8 位数随机字符串 + * - `filepath`: 根据文件路径生成 permalink + * + * @default true + */ + permalink?: boolean | 'filepath' + /** + * 是否自动生成 createTime + * + * 默认读取 文件创建时间,`createTime` 比 vuepress 默认的 `date` 时间更精准到秒 + */ + createTime?: boolean + /** + * 是否自动生成 title + * + * 默认读取文件名作为标题 + */ + title?: boolean + + /** + * 自定义 frontmatter 生成函数 + * + * - 你应该直接将新字段添加到 `data` 中 + * - 如果返回全新的 `data` 对象,会覆盖之前的 frontmatter + * @param data 页面已存在的 frontmatter + * @param context 当前页面的上下文信息 + * @param locale 当前语言路径 + * @returns 返回处理后的 frontmatter + */ + transform?: (data: AutoFrontmatterData, context: AutoFrontmatterContext, locale: string) => AutoFrontmatterData | Promise +} +``` + +## 永久链接 permalink + +主题默认使用 `nanoid` 生成一个 8 位的随机字符串作为文件的永久链接。 + +还可以将 `permalink` 配置为 `'filepath'` ,根据文件路径生成永久链接。 +请注意,如果你的文件路径中,包含中文,主题建议在你的项目中安装 `pinyin-pro` , +以支持将中文转换为拼音。 + +::: npm-to + +```sh +npm i pinyin-pro +``` + +::: + +::: code-tabs#config + +@tab .vuepress/config.ts + +```ts twoslash +import { defineUserConfig } from 'vuepress' +import { plumeTheme } from 'vuepress-theme-plume' + +export default defineUserConfig({ + theme: plumeTheme({ + autoFrontmatter: { + permalink: 'filepath', // [!code hl] + } + }) +}) +``` + +@tab .vuepress/plume.config.ts + +```ts twoslash +import { defineThemeConfig } from 'vuepress-theme-plume' + +export default defineThemeConfig({ + autoFrontmatter: { + permalink: 'filepath', // [!code hl] + } +}) +``` + +::: + +示例: + +::: code-tree + +```md title="docs/blog/服务.md" +--- +title: 服务 +permalink: /blog/wu-fu/ +--- +``` + +```md title="docs/blog/都城.md" +--- +title: 都城 +permalink: /blog/dou-cheng/ +--- +``` + +::: + +你应该已经发现 示例中的 `都城.md` 文件的 permalink 为 `/blog/dou-cheng/` ,这显然是错误的,这是因为 `pinyin-pro` +默认的词库对于多音字并不能精确的识别,如果你需要更为精确的转换结果,可以手动安装 `@pinyin-pro/data`, +主题为自动加载该词库,以提高精度。 + +::: npm-to + +```sh +npm i @pinyin-pro/data +``` + +::: + +```md title="docs/blog/都城.md" +--- +title: 都城 +permalink: /blog/du-cheng/ +--- +``` diff --git a/theme/package.json b/theme/package.json index ca87772c..dcdbd1c8 100644 --- a/theme/package.json +++ b/theme/package.json @@ -71,6 +71,7 @@ "@vuepress/shiki-twoslash": "catalog:vuepress", "gsap": "catalog:peer", "ogl": "catalog:peer", + "pinyin-pro": "catalog:peer", "postprocessing": "catalog:peer", "swiper": "catalog:peer", "three": "catalog:peer", @@ -92,6 +93,9 @@ "ogl": { "optional": true }, + "pinyin-pro": { + "optional": true + }, "postprocessing": { "optional": true }, @@ -146,8 +150,10 @@ }, "devDependencies": { "@iconify/json": "catalog:peer", + "@pinyin-pro/data": "catalog:peer", "gsap": "catalog:peer", "ogl": "catalog:peer", + "pinyin-pro": "catalog:peer", "postprocessing": "catalog:peer", "swiper": "catalog:peer", "three": "catalog:peer", diff --git a/theme/src/node/autoFrontmatter/helper.ts b/theme/src/node/autoFrontmatter/helper.ts new file mode 100644 index 00000000..7d4e01b0 --- /dev/null +++ b/theme/src/node/autoFrontmatter/helper.ts @@ -0,0 +1,48 @@ +import { kebabCase } from '@pengzhanbo/utils' +import dayjs from 'dayjs' +import { ensureLeadingSlash, removeLeadingSlash } from 'vuepress/shared' +import { fs, path } from 'vuepress/utils' +import { getPinyin, hasPinyin } from '../utils/index.js' + +export const EXCLUDE = ['!**/.vuepress/', '!**/node_modules/'] + +const NUMBER_RE = /^\d+\./ + +export function isReadme(filepath: string): boolean { + return filepath.endsWith('README.md') || filepath.endsWith('index.md') || filepath.endsWith('readme.md') +} + +export function normalizeTitle(title: string): string { + return title.replace(NUMBER_RE, '').trim() +} + +export function getFileCreateTime(filepath: string): string { + const stats = fs.statSync(filepath) + const time = stats.birthtime.getFullYear() !== 1970 ? stats.birthtime : stats.atime + return dayjs(new Date(time)).format('YYYY/MM/DD HH:mm:ss') +} + +export function getCurrentName(filepath: string): string { + if (isReadme(filepath)) + return normalizeTitle(path.dirname(filepath).slice(-1).split('/').pop() || 'Home') + + return normalizeTitle(path.basename(filepath, '.md')) +} + +export async function getPermalinkByFilepath(filepath: string, base = '/'): Promise { + const relative = removeLeadingSlash(ensureLeadingSlash(filepath).replace(ensureLeadingSlash(base), '')) + const dirs = path.dirname(relative).split('/').map(normalizeTitle) + const basename = normalizeTitle(path.basename(relative, '.md')) + if (hasPinyin) { + const pinyin = await getPinyin() + return path.join( + ...dirs.map(dir => slugify(pinyin?.(dir, { toneType: 'none', nonZh: 'consecutive' }) || dir)), + slugify(pinyin?.(basename, { toneType: 'none', nonZh: 'consecutive' }) || basename), + ) + } + return path.join(...dirs.map(slugify), slugify(basename)) +} + +function slugify(str: string): string { + return kebabCase(str.trim()) +} diff --git a/theme/src/node/autoFrontmatter/resolveLinkBySidebar.ts b/theme/src/node/autoFrontmatter/resolveLinkBySidebar.ts new file mode 100644 index 00000000..6f4c7390 --- /dev/null +++ b/theme/src/node/autoFrontmatter/resolveLinkBySidebar.ts @@ -0,0 +1,48 @@ +import type { ThemeSidebarItem } from '../../shared/index.js' +import { ensureEndingSlash } from 'vuepress/shared' +import { path } from 'vuepress/utils' + +export function resolveLinkBySidebar( + sidebar: 'auto' | (string | ThemeSidebarItem)[], + _prefix: string, +): Record { + const res: Record = {} + + if (sidebar === 'auto') { + return res + } + + for (const item of sidebar) { + if (typeof item !== 'string') { + const { prefix, dir = '', link = '/', items, text = '' } = item + getSidebarLink(items, link, text, path.join(_prefix, prefix || dir), res) + } + } + return res +} + +function getSidebarLink(items: 'auto' | (string | ThemeSidebarItem)[] | undefined, link: string, text: string, dir = '', res: Record = {}) { + if (items === 'auto') + return + + if (!items) { + res[ensureEndingSlash(dir)] = link + return + } + + for (const item of items) { + if (typeof item === 'string') { + res[ensureEndingSlash(dir)] = link + } + else { + const { prefix = '', dir: subDir = '', link: subLink = '/', items: subItems, text: subText = '' } = item + getSidebarLink( + subItems, + path.join(link, subLink), + subText, + path.join(prefix[0] === '/' ? prefix : `/${dir}/${prefix || subDir}`), + res, + ) + } + } +} diff --git a/theme/src/node/autoFrontmatter/rules.ts b/theme/src/node/autoFrontmatter/rules.ts index bfdee48f..e07d0427 100644 --- a/theme/src/node/autoFrontmatter/rules.ts +++ b/theme/src/node/autoFrontmatter/rules.ts @@ -1,13 +1,24 @@ -import type { AutoFrontmatterContext, AutoFrontmatterData, AutoFrontmatterOptions, AutoFrontmatterRule, ThemeDocCollection, ThemeSidebarItem } from '../../shared/index.js' +import type { + AutoFrontmatterContext, + AutoFrontmatterData, + AutoFrontmatterOptions, + AutoFrontmatterRule, + ThemeDocCollection, +} from '../../shared/index.js' import type { ThemePostCollection } from '../../shared/index.js' import { hasOwn, toArray } from '@pengzhanbo/utils' -import dayjs from 'dayjs' -import { ensureLeadingSlash, removeLeadingSlash } from 'vuepress/shared' -import { fs, path } from 'vuepress/utils' +import { ensureEndingSlash, ensureLeadingSlash, removeLeadingSlash } from 'vuepress/shared' +import { path } from 'vuepress/utils' import { getThemeConfig } from '../loadConfig/index.js' import { nanoid } from '../utils/index.js' - -const EXCLUDE = ['!**/.vuepress/', '!**/node_modules/'] +import { + EXCLUDE, + getCurrentName, + getFileCreateTime, + getPermalinkByFilepath, + isReadme, +} from './helper.js' +import { resolveLinkBySidebar } from './resolveLinkBySidebar.js' const rules: AutoFrontmatterRule[] = [] @@ -100,7 +111,14 @@ async function generateWithPost( } if (ep && !hasOwn(data, 'permalink')) { - data.permalink = path.join(locale, collection.linkPrefix || collection.link || collection.dir, nanoid(), '/') + data.permalink = path.join( + locale, + collection.linkPrefix || collection.link || collection.dir, + ep === 'filepath' + ? await getPermalinkByFilepath(context.relativePath, path.join(locale, collection.dir)) + : nanoid(), + '/', + ) } data = await transform?.(data, context, locale) ?? data @@ -136,12 +154,32 @@ async function generateWithDoc( } else if (collection.sidebar && collection.sidebar !== 'auto') { const res = resolveLinkBySidebar(collection.sidebar, ensureLeadingSlash(collection.dir)) - const file = ensureLeadingSlash(context.relativePath) - const link = res[file] || res[path.dirname(file)] || '' - data.permalink = path.join(locale, collection.linkPrefix, link, isReadme(context.relativePath) ? '' : nanoid(8), '/') + const file = path.dirname(context.relativePath) + const link = res[ensureLeadingSlash(ensureEndingSlash(file))] || '/' + data.permalink = path.join( + locale, + collection.linkPrefix, + link, + isReadme(context.relativePath) + ? '' + : ep === 'filepath' + ? await getPermalinkByFilepath( + link === '/' ? context.relativePath : path.basename(context.relativePath), + link === '/' ? path.join(locale, collection.dir) : '', + ) + : nanoid(8), + '/', + ) } else { - data.permalink = path.join(locale, collection.linkPrefix, nanoid(8), '/') + data.permalink = path.join( + locale, + collection.linkPrefix, + ep === 'filepath' + ? await getPermalinkByFilepath(context.relativePath, path.join(locale, collection.dir)) + : nanoid(8), + '/', + ) } } @@ -175,86 +213,14 @@ async function generateWithRemain( } if (ep && !hasOwn(data, 'permalink') && !isRoot) { - data.permalink = path.join(locale, nanoid(8), '/') + data.permalink = path.join( + locale, + ep === 'filepath' ? await getPermalinkByFilepath(context.relativePath, locale) : nanoid(8), + '/', + ) } data = await transform?.(data, context, locale) ?? data return data } - -function isReadme(filepath: string): boolean { - return filepath.endsWith('README.md') || filepath.endsWith('index.md') || filepath.endsWith('readme.md') -} - -function normalizeTitle(title: string): string { - return title.replace(/^\d+\./, '').trim() -} - -function getFileCreateTime(filepath: string): string { - const stats = fs.statSync(filepath) - const time = stats.birthtime.getFullYear() !== 1970 ? stats.birthtime : stats.atime - return dayjs(new Date(time)).format('YYYY/MM/DD HH:mm:ss') -} - -function getCurrentName(filepath: string): string { - if (isReadme(filepath)) - return normalizeTitle(path.dirname(filepath).slice(-1).split('/').pop() || 'Home') - - return normalizeTitle(path.basename(filepath, '.md')) -} - -export function resolveLinkBySidebar( - sidebar: 'auto' | (string | ThemeSidebarItem)[], - _prefix: string, -): Record { - const res: Record = {} - - if (sidebar === 'auto') { - return res - } - - for (const item of sidebar) { - if (typeof item !== 'string') { - const { prefix, dir = '', link = '/', items, text = '' } = item - getSidebarLink(items, link, text, path.join(_prefix, prefix || dir), res) - } - } - return res -} - -function getSidebarLink(items: 'auto' | (string | ThemeSidebarItem)[] | undefined, link: string, text: string, dir = '', res: Record = {}) { - if (items === 'auto') - return - - if (!items) { - res[path.join(dir, `${text}.md`)] = link - return - } - - for (const item of items) { - if (typeof item === 'string') { - if (!link) - continue - if (item) { - res[path.join(dir, `${item}.md`)] = link - } - else { - res[path.join(dir, 'README.md')] = link - res[path.join(dir, 'index.md')] = link - res[path.join(dir, 'readme.md')] = link - } - res[dir] = link - } - else { - const { prefix, dir: subDir = '', link: subLink = '/', items: subItems, text: subText = '' } = item - getSidebarLink( - subItems, - path.join(link, subLink), - subText, - path.join(prefix?.[0] === '/' ? prefix : `/${dir}/${prefix}`, subDir), - res, - ) - } - } -} diff --git a/theme/src/node/utils/index.ts b/theme/src/node/utils/index.ts index 3620374f..cfd9ab74 100644 --- a/theme/src/node/utils/index.ts +++ b/theme/src/node/utils/index.ts @@ -7,6 +7,7 @@ export * from './interopDefault.js' export * from './logger.js' export * from './package.js' export * from './path.js' +export * from './pinyin.js' export * from './resolveContent.js' export * from './translate.js' export * from './writeTemp.js' diff --git a/theme/src/node/utils/pinyin.ts b/theme/src/node/utils/pinyin.ts new file mode 100644 index 00000000..8448c551 --- /dev/null +++ b/theme/src/node/utils/pinyin.ts @@ -0,0 +1,18 @@ +import { isPackageExists } from 'local-pkg' +import { interopDefault } from './interopDefault' + +let _pinyin: typeof import('pinyin-pro').pinyin | null = null + +export const hasPinyin = isPackageExists('pinyin-pro') +const hasPinyinData = isPackageExists('@pinyin-pro/data') + +export async function getPinyin() { + if (hasPinyin && !_pinyin) { + const { pinyin, addDict } = (await import('pinyin-pro')) + _pinyin = pinyin + if (hasPinyinData) { + addDict(await interopDefault(import('@pinyin-pro/data/complete'))) + } + } + return _pinyin +} diff --git a/theme/src/shared/features/autoFrontmatter.ts b/theme/src/shared/features/autoFrontmatter.ts index 049411d7..1ba3c40d 100644 --- a/theme/src/shared/features/autoFrontmatter.ts +++ b/theme/src/shared/features/autoFrontmatter.ts @@ -1,4 +1,9 @@ -export type AutoFrontmatterData = Record +import type { LiteralUnion } from '@pengzhanbo/utils' + +export type AutoFrontmatterData = Record< + LiteralUnion<'title' | 'createTime' | 'permalink'>, + any +> /** * The context of the markdown file @@ -76,9 +81,16 @@ export interface AutoFrontmatterOptions { /** * 是否自动生成 permalink * + * - `false`: 不自动生成 permalink + * - `true`: 自动生成 permalink ,使用 nanoid 生成 8 位数随机字符串 + * - `filepath`: 根据文件路径生成 permalink + * + * 对于 `filepath`,如果文件路径中包含中文,可以手动安装 `pinyin-pro` , + * 主题内部会加载 `pinyin-pro` 进行中文拼音转换 + * * @default true */ - permalink?: boolean + permalink?: boolean | 'filepath' /** * 是否自动生成 createTime * @@ -112,10 +124,5 @@ export interface AutoFrontmatterOptions { * } * ``` */ - transform?: < - D extends AutoFrontmatterData = AutoFrontmatterData, - >(data: D, - context: AutoFrontmatterContext, - locale: string, - ) => D | Promise + transform?: (data: AutoFrontmatterData, context: AutoFrontmatterContext, locale: string) => AutoFrontmatterData | Promise }