diff --git a/theme/src/node/autoFrontmatter/baseFrontmatter.ts b/theme/src/node/autoFrontmatter/baseFrontmatter.ts new file mode 100644 index 000000000..e53c7e996 --- /dev/null +++ b/theme/src/node/autoFrontmatter/baseFrontmatter.ts @@ -0,0 +1,21 @@ +import { format } from 'date-fns' +import type { + AutoFrontmatter, + AutoFrontmatterObject, +} from '../../shared/index.js' + +export function createBaseFrontmatter(options: AutoFrontmatter): AutoFrontmatterObject { + const res: AutoFrontmatterObject = {} + + if (options.createTime !== false) { + res.createTime = (formatTime: string, { createTime }, data) => { + if (formatTime) + return formatTime + if (data.friends || data.pageLayout === 'friends') + return + return format(new Date(createTime), 'yyyy/MM/dd HH:mm:ss') + } + } + + return res +} diff --git a/theme/src/node/autoFrontmatter/generator.ts b/theme/src/node/autoFrontmatter/generator.ts index 860e618fc..68d1f79cd 100644 --- a/theme/src/node/autoFrontmatter/generator.ts +++ b/theme/src/node/autoFrontmatter/generator.ts @@ -3,7 +3,7 @@ import chokidar from 'chokidar' import { createFilter } from 'create-filter' import grayMatter from 'gray-matter' import jsonToYaml from 'json2yaml' -import { fs, hash } from 'vuepress/utils' +import { fs, hash, path } from 'vuepress/utils' import type { App } from 'vuepress' import { getThemeConfig } from '../loadConfig/index.js' import { readMarkdown, readMarkdownList } from './readFile.js' @@ -16,6 +16,8 @@ import type { PlumeThemeLocaleOptions, } from '../../shared/index.js' +const CACHE_FILE = 'markdown/auto-frontmatter.json' + export interface Generate { globFilter: (id?: string) => boolean global: AutoFrontmatterObject @@ -24,6 +26,9 @@ export interface Generate { filter: (id?: string) => boolean frontmatter: AutoFrontmatterObject }[] + cache: Record + checkCache: (id: string) => boolean + updateCache: (app: App) => Promise } let generate: Generate | null = null @@ -53,21 +58,46 @@ export function initAutoFrontmatter( } }) + const cache: Record = {} + + function checkCache(filepath: string): boolean { + const stats = fs.statSync(filepath) + + if (cache[filepath] && cache[filepath] === stats.mtimeMs.toString()) + return false + cache[filepath] = stats.mtimeMs.toString() + return true + } + + async function updateCache(app: App): Promise { + await fs.writeFile(app.dir.cache(CACHE_FILE), JSON.stringify(cache), 'utf-8') + } + generate = { globFilter, global: globalConfig, rules, + cache, + checkCache, + updateCache, } } export async function generateAutoFrontmatter(app: App) { if (!generate) return - const markdownList = await readMarkdownList(app.dir.source(), generate.globFilter) + + const cachePath = app.dir.cache(CACHE_FILE) + if (fs.existsSync(cachePath)) { + generate.cache = JSON.parse(await fs.readFile(cachePath, 'utf-8')) + } + const markdownList = await readMarkdownList(app, generate) await promiseParallel( markdownList.map(file => () => generator(file)), 64, ) + + await generate.updateCache(app) } export async function watchAutoFrontmatter(app: App, watchers: any[]) { @@ -88,6 +118,14 @@ export async function watchAutoFrontmatter(app: App, watchers: any[]) { await generator(file) }) + watcher.on('change', async (relativePath) => { + const enabled = getThemeConfig().autoFrontmatter !== false + if (!generate!.globFilter(relativePath) || !enabled) + return + if (generate!.checkCache(path.join(app.dir.source(), relativePath))) + await generate!.updateCache(app) + }) + watchers.push(watcher) } @@ -103,8 +141,11 @@ async function generator(file: AutoFrontmatterMarkdownFile): Promise { const beforeHash = hash(data) for (const key in formatter) { - const value = await formatter[key](data[key], file, data) - data[key] = value ?? data[key] + const value = (await formatter[key](data[key], file, data)) ?? data[key] + if (typeof value !== 'undefined') + data[key] = value + else + delete data[key] } if (beforeHash === hash(data)) @@ -121,6 +162,7 @@ async function generator(file: AutoFrontmatterMarkdownFile): Promise { const newContent = yaml ? `${yaml}---\n${content}` : content await fs.promises.writeFile(filepath, newContent, 'utf-8') + generate.checkCache(filepath) } catch (e) { console.error(e) diff --git a/theme/src/node/autoFrontmatter/readFile.ts b/theme/src/node/autoFrontmatter/readFile.ts index fd1509889..7ff041798 100644 --- a/theme/src/node/autoFrontmatter/readFile.ts +++ b/theme/src/node/autoFrontmatter/readFile.ts @@ -1,20 +1,27 @@ import fg from 'fast-glob' import { fs, path } from 'vuepress/utils' +import type { App } from 'vuepress' import type { AutoFrontmatterMarkdownFile } from '../../shared/index.js' +import type { Generate } from './generator.js' export async function readMarkdownList( - sourceDir: string, - filter: (id: string) => boolean, + app: App, + { globFilter, checkCache }: Generate, ): Promise { + const source = app.dir.source() const files: string[] = await fg(['**/*.md'], { - cwd: sourceDir, + cwd: source, ignore: ['node_modules', '.vuepress'], }) return await Promise.all( files - .filter(filter) - .map(file => readMarkdown(sourceDir, file)), + .filter((id) => { + if (!globFilter(id)) + return false + return checkCache(path.join(source, id)) + }) + .map(file => readMarkdown(source, file)), ) } diff --git a/theme/src/node/autoFrontmatter/resolveLinkBySidebar.ts b/theme/src/node/autoFrontmatter/resolveLinkBySidebar.ts new file mode 100644 index 000000000..986353232 --- /dev/null +++ b/theme/src/node/autoFrontmatter/resolveLinkBySidebar.ts @@ -0,0 +1,51 @@ +import { pathJoin } from '../utils/index.js' +import type { SidebarItem } from '../../shared/index.js' + +export function resolveLinkBySidebar( + sidebar: 'auto' | (string | SidebarItem)[], + _prefix: string, +) { + 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, pathJoin(_prefix, prefix || dir), res) + } + } + return res +} + +function getSidebarLink(items: 'auto' | (string | SidebarItem)[] | undefined, link: string, text: string, dir = '', res: Record = {}) { + if (items === 'auto') + return + + if (!items) { + res[pathJoin(dir, `${text}.md`)] = link + return + } + + for (const item of items) { + if (typeof item === 'string') { + if (!link) + continue + if (item) { + res[pathJoin(dir, `${item}.md`)] = link + } + else { + res[pathJoin(dir, 'README.md')] = link + res[pathJoin(dir, 'index.md')] = link + res[pathJoin(dir, 'readme.md')] = link + } + res[dir] = link + } + else { + const { prefix, dir: subDir = '', link: subLink = '/', items: subItems, text: subText = '' } = item + getSidebarLink(subItems, pathJoin(link, subLink), subText, pathJoin(prefix || dir, subDir), res) + } + } +} diff --git a/theme/src/node/autoFrontmatter/resolveOptions.ts b/theme/src/node/autoFrontmatter/resolveOptions.ts index 8e5ea82bb..cb3627cb4 100644 --- a/theme/src/node/autoFrontmatter/resolveOptions.ts +++ b/theme/src/node/autoFrontmatter/resolveOptions.ts @@ -1,65 +1,35 @@ import { uniq } from '@pengzhanbo/utils' import { ensureLeadingSlash } from '@vuepress/helper' -import { format } from 'date-fns' -import { removeLeadingSlash, resolveLocalePath } from 'vuepress/shared' +import { resolveLocalePath } from 'vuepress/shared' import { path } from 'vuepress/utils' -import { resolveNotesOptions } from '../config/index.js' -import { - getCurrentDirname, - nanoid, - normalizePath, - pathJoin, - withBase, -} from '../utils/index.js' +import { resolveNotesDirs } from '../config/index.js' +import { getCurrentDirname, nanoid, normalizePath, pathJoin, withBase } from '../utils/index.js' +import { createBaseFrontmatter } from './baseFrontmatter.js' +import { resolveLinkBySidebar } from './resolveLinkBySidebar.js' import type { AutoFrontmatter, AutoFrontmatterArray, - AutoFrontmatterObject, + NoteItem, + NotesOptions, PlumeThemeLocaleOptions, - SidebarItem, } from '../../shared/index.js' export function resolveOptions( localeOptions: PlumeThemeLocaleOptions, options: AutoFrontmatter, ): AutoFrontmatter { - const { article: articlePrefix = '/article/' } = localeOptions + const resolveLocale = (relativeFilepath: string): string => + resolveLocalePath(localeOptions.locales!, ensureLeadingSlash(relativeFilepath)) - const resolveLocale = (relativeFilepath: string) => { - const file = ensureLeadingSlash(relativeFilepath) - - return resolveLocalePath(localeOptions.locales!, file) - } - - const notesList = resolveNotesOptions(localeOptions) - const localesNotesDirs = uniq(notesList - .flatMap(({ notes, dir }) => - notes.map(note => removeLeadingSlash(normalizePath(`${dir}/${note.dir || ''}/`))), - )) - - const baseFrontmatter: AutoFrontmatterObject = {} - - if (options.createTime !== false) { - baseFrontmatter.createTime = (formatTime: string, { createTime }, data) => { - if (formatTime) - return formatTime - if (data.friends || data.pageLayout === 'friends') - return - return format(new Date(createTime), 'yyyy/MM/dd HH:mm:ss') - } - } - - const notesByLocale = (locale: string) => { + const findNotesByLocale = (locale: string): NotesOptions | undefined => { const notes = localeOptions.locales?.[locale]?.notes - if (notes === false) - return undefined - return notes + return notes === false ? undefined : notes } - const findNote = (relativeFilepath: string) => { + const findNote = (relativeFilepath: string): NoteItem | undefined => { const locale = resolveLocale(relativeFilepath) const filepath = ensureLeadingSlash(relativeFilepath) - const notes = notesByLocale(locale) + const notes = findNotesByLocale(locale) if (!notes) return undefined const notesList = notes?.notes || [] @@ -69,217 +39,145 @@ export function resolveOptions( ) } - return { - include: options?.include ?? ['**/*.md'], - exclude: uniq(['.vuepress/**/*', 'node_modules', ...(options?.exclude ?? [])]), - - frontmatter: [ - localesNotesDirs.length - ? { - // note 首页链接 - include: localesNotesDirs.map(dir => pathJoin(dir, '/{readme,README,index}.md')), - frontmatter: { - ...options.title !== false - ? { - title(title: string, { relativePath }) { - if (title) - return title - const note = findNote(relativePath) - if (note?.text) - return note.text - return getCurrentDirname('', relativePath) || '' - }, - } as AutoFrontmatterObject - : undefined, - ...baseFrontmatter, - ...options.permalink !== false - ? { - permalink(permalink: string, { relativePath }, data: any) { - if (permalink) - return permalink - if (data.friends) - return - const locale = resolveLocale(relativePath) - - const prefix = notesByLocale(locale)?.link || '' - const note = findNote(relativePath) - return pathJoin( - prefix.startsWith(locale) ? '/' : locale, - prefix, - note?.link || getCurrentDirname(note?.dir, relativePath), - '/', - ) - }, - } as AutoFrontmatterObject - : undefined, - }, - } - : '', - localesNotesDirs.length - ? { - include: localesNotesDirs.map(dir => `${dir}**/**.md`), - frontmatter: { - ...options.title !== false - ? { - title(title: string, { relativePath }) { - if (title) - return title - - const note = findNote(relativePath) - let basename = path.basename(relativePath, '.md') - if (note?.sidebar === 'auto') - basename = basename.replace(/^\d+\./, '') - - return basename - }, - } as AutoFrontmatterObject - : undefined, - ...baseFrontmatter, - ...options.permalink !== false - ? { - permalink(permalink: string, { relativePath }, data: any) { - if (permalink) - return permalink - if (data.friends) - return - const locale = resolveLocale(relativePath) - const notes = notesByLocale(locale) - const note = findNote(relativePath) - const prefix = notes?.link || '' - const args: string[] = [ - prefix.startsWith(locale) ? '/' : locale, - prefix, - note?.link || '', - ] - const sidebar = note?.sidebar - - if (note && sidebar && sidebar !== 'auto') { - const res = resolveLinkBySidebar(sidebar, pathJoin(notes?.dir || '', note.dir || '')) - const file = ensureLeadingSlash(relativePath) - if (res[file]) { - args.push(res[file]) - } - else if (res[path.dirname(file)]) { - args.push(res[path.dirname(file)]) - } - } - - return pathJoin(...args, nanoid(), '/') - }, - } as AutoFrontmatterObject - : undefined, - }, - } - : '', - { - include: '**/{readme,README,index}.md', - frontmatter: {}, + const baseFrontmatter = createBaseFrontmatter(options) + const localesNotesDirs = resolveNotesDirs(localeOptions) + const configs: AutoFrontmatterArray = [] + + if (localesNotesDirs.length) { + // note 首页 + configs.push({ + include: localesNotesDirs.map(dir => pathJoin(dir, '/{readme,README,index}.md')), + frontmatter: { + title(title: string, { relativePath }) { + if (title) + return title + if (options.title === false) + return + return findNote(relativePath)?.text || getCurrentDirname('', relativePath) + }, + ...baseFrontmatter, + permalink(permalink: string, { relativePath }, data: any) { + if (permalink) + return permalink + if (options.permalink === false || data.friends || data.pageLayout === 'friends') + return + + const locale = resolveLocale(relativePath) + const prefix = findNotesByLocale(locale)?.link || '' + const note = findNote(relativePath) + return pathJoin( + prefix.startsWith(locale) ? '/' : locale, + prefix, + note?.link || getCurrentDirname(note?.dir, relativePath), + '/', + ) + }, }, - localeOptions.blog !== false - ? { - include: localeOptions.blog?.include ?? ['**/*.md'], - frontmatter: { - ...options.title !== false - ? { - title(title: string, { relativePath }) { - if (title) - return title - const basename = path.basename(relativePath || '', '.md') - return basename - }, - } as AutoFrontmatterObject - : undefined, - ...baseFrontmatter, - ...options.permalink !== false - ? { - permalink(permalink: string, { relativePath }) { - if (permalink) - return permalink - const locale = resolveLocale(relativePath) - const prefix = withBase(articlePrefix, locale) - - return normalizePath(`${prefix}/${nanoid()}/`) - }, - } as AutoFrontmatterObject - : undefined, - }, + }) + // note page + configs.push({ + include: localesNotesDirs.map(dir => `${dir}**/**.md`), + frontmatter: { + title(title: string, { relativePath }) { + if (title) + return title + if (options.title === false) + return + return path.basename(relativePath, '.md').replace(/^\d+\./, '') + }, + ...baseFrontmatter, + permalink(permalink: string, { relativePath }, data) { + if (permalink) + return permalink + if (options.permalink === false) + return + if (data.friends || data.pageLayout === 'friends') + return + const locale = resolveLocale(relativePath) + const notes = findNotesByLocale(locale) + const note = findNote(relativePath) + const prefix = notes?.link || '' + const args: string[] = [ + prefix.startsWith(locale) ? '/' : locale, + prefix, + note?.link || '', + ] + const sidebar = note?.sidebar + + if (note && sidebar && sidebar !== 'auto') { + const res = resolveLinkBySidebar(sidebar, pathJoin(notes?.dir || '', note.dir || '')) + const file = ensureLeadingSlash(relativePath) + if (res[file]) { + args.push(res[file]) + } + else if (res[path.dirname(file)]) { + args.push(res[path.dirname(file)]) + } } - : '', - { - include: '*', - frontmatter: { - ...options.title !== false - ? { - title(title: string, { relativePath }) { - if (title) - return title - const basename = path.basename(relativePath || '', '.md') - return basename - }, - } as AutoFrontmatterObject - : undefined, - ...baseFrontmatter, - ...options.permalink !== false - ? { - permalink(permalink: string, { relativePath }) { - if (permalink) - return permalink - return ensureLeadingSlash(normalizePath(relativePath.replace(/\.md$/, '/'))) - }, - } as AutoFrontmatterObject - : undefined, + return pathJoin(...args, nanoid(), '/') }, }, - ].filter(Boolean) as AutoFrontmatterArray, - } -} - -function resolveLinkBySidebar( - sidebar: 'auto' | (string | SidebarItem)[], - _prefix: string, -) { - 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, pathJoin(_prefix, prefix || dir), res) - } + // 未知 readme 不处理 + configs.push({ + include: '**/{readme,README,index}.md', + frontmatter: {}, + }) + + if (localeOptions.blog !== false) { + // 博客文章 + configs.push({ + include: localeOptions.blog?.include ?? ['**/*.md'], + frontmatter: { + title(title: string, { relativePath }) { + if (title) + return title + if (options.title === false) + return + return path.basename(relativePath || '', '.md') + }, + ...baseFrontmatter, + permalink(permalink: string, { relativePath }) { + if (permalink) + return permalink + if (options.permalink === false) + return + const locale = resolveLocale(relativePath) + const prefix = withBase(localeOptions.article || '/article/', locale) + + return normalizePath(`${prefix}/${nanoid()}/`) + }, + }, + }) } - return res -} - -function getSidebarLink(items: 'auto' | (string | SidebarItem)[] | undefined, link: string, text: string, dir = '', res: Record = {}) { - if (items === 'auto') - return - if (!items) { - res[pathJoin(dir, `${text}.md`)] = link - return - } + // 其他 + configs.push({ + include: '*', + frontmatter: { + title(title: string, { relativePath }) { + if (title) + return title + if (options.title === false) + return + return path.basename(relativePath || '', '.md') + }, + ...baseFrontmatter, + permalink(permalink: string, { relativePath }) { + if (permalink) + return permalink + if (options.permalink === false) + return + return ensureLeadingSlash(normalizePath(relativePath.replace(/\.md$/, '/'))) + }, + }, + }) - for (const item of items) { - if (typeof item === 'string') { - if (!link) - continue - if (item) { - res[pathJoin(dir, `${item}.md`)] = link - } - else { - res[pathJoin(dir, 'README.md')] = link - res[pathJoin(dir, 'index.md')] = link - res[pathJoin(dir, 'readme.md')] = link - } - res[dir] = link - } - else { - const { prefix, dir: subDir = '', link: subLink = '/', items: subItems, text: subText = '' } = item - getSidebarLink(subItems, pathJoin(link, subLink), subText, pathJoin(prefix || dir, subDir), res) - } + return { + include: options?.include ?? ['**/*.md'], + exclude: uniq(['.vuepress/**/*', 'node_modules', ...(options?.exclude ?? [])]), + frontmatter: configs, } } diff --git a/theme/src/node/config/resolveNotesOptions.ts b/theme/src/node/config/resolveNotesOptions.ts index 0438f7b19..c2c05f7ba 100644 --- a/theme/src/node/config/resolveNotesOptions.ts +++ b/theme/src/node/config/resolveNotesOptions.ts @@ -1,6 +1,6 @@ import { uniq } from '@pengzhanbo/utils' -import { entries } from '@vuepress/helper' -import { withBase } from '../utils/index.js' +import { entries, removeLeadingSlash } from '@vuepress/helper' +import { normalizePath, withBase } from '../utils/index.js' import type { NotesOptions, PlumeThemeLocaleOptions } from '../../shared/index.js' export function resolveNotesLinkList(localeOptions: PlumeThemeLocaleOptions) { @@ -34,3 +34,11 @@ export function resolveNotesOptions(localeOptions: PlumeThemeLocaleOptions): Not return notesOptionsList } + +export function resolveNotesDirs(localeOptions: PlumeThemeLocaleOptions): string[] { + const notesList = resolveNotesOptions(localeOptions) + return uniq(notesList + .flatMap(({ notes, dir }) => + notes.map(note => removeLeadingSlash(normalizePath(`${dir}/${note.dir || ''}/`))), + )) +}