perf(theme): optimize the startup time of auto-frontmatter (#185)

This commit is contained in:
pengzhanbo 2024-09-17 00:30:59 +08:00 committed by GitHub
parent 4907bf4b29
commit 5b5409d2ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 290 additions and 263 deletions

View File

@ -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
}

View File

@ -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<string, string>
checkCache: (id: string) => boolean
updateCache: (app: App) => Promise<void>
}
let generate: Generate | null = null
@ -53,21 +58,46 @@ export function initAutoFrontmatter(
}
})
const cache: Record<string, string> = {}
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<void> {
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<void> {
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<void> {
const newContent = yaml ? `${yaml}---\n${content}` : content
await fs.promises.writeFile(filepath, newContent, 'utf-8')
generate.checkCache(filepath)
}
catch (e) {
console.error(e)

View File

@ -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<AutoFrontmatterMarkdownFile[]> {
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)),
)
}

View File

@ -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<string, string> = {}
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<string, string> = {}) {
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)
}
}
}

View File

@ -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(
)
}
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),
'/',
)
},
},
})
// 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)])
}
}
return pathJoin(...args, nanoid(), '/')
},
},
})
}
// 未知 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()}/`)
},
},
})
}
// 其他
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$/, '/')))
},
},
})
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: {},
},
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,
},
}
: '',
{
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,
},
},
].filter(Boolean) as AutoFrontmatterArray,
}
}
function resolveLinkBySidebar(
sidebar: 'auto' | (string | SidebarItem)[],
_prefix: string,
) {
const res: Record<string, string> = {}
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<string, string> = {}) {
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)
}
frontmatter: configs,
}
}

View File

@ -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 || ''}/`))),
))
}