feat: 添加单独的主题配置文件支持
This commit is contained in:
parent
5ae11c766b
commit
cbba7868bf
89
theme/src/node/loadConfig/compiler.ts
Normal file
89
theme/src/node/loadConfig/compiler.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { promises as fsp } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import { importFileDefault } from 'vuepress/utils'
|
||||
import type { ThemeConfig } from '../../shared/theme-data.js'
|
||||
import { hash } from '../utils/index.js'
|
||||
|
||||
export async function compiler(configPath?: string,
|
||||
): Promise<{
|
||||
config: ThemeConfig
|
||||
dependencies: string[]
|
||||
}> {
|
||||
if (!configPath) {
|
||||
return { config: {}, dependencies: [] }
|
||||
}
|
||||
|
||||
const dirnameVarName = '__vite_injected_original_dirname'
|
||||
const filenameVarName = '__vite_injected_original_filename'
|
||||
const importMetaUrlVarName = '__vite_injected_original_import_meta_url'
|
||||
const result = await build({
|
||||
absWorkingDir: process.cwd(),
|
||||
entryPoints: [configPath],
|
||||
outfile: 'out.js',
|
||||
write: false,
|
||||
target: ['node18'],
|
||||
platform: 'node',
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
sourcemap: 'inline',
|
||||
metafile: true,
|
||||
define: {
|
||||
'__dirname': dirnameVarName,
|
||||
'__filename': filenameVarName,
|
||||
'import.meta.url': importMetaUrlVarName,
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: 'externalize-deps',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /.*/ }, ({ path: id }) => {
|
||||
// externalize bare imports
|
||||
if (id[0] !== '.' && !path.isAbsolute(id)) {
|
||||
return { external: true }
|
||||
}
|
||||
return null
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'inject-file-scope-variables',
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /\.[cm]?[jt]s$/ }, async (args) => {
|
||||
const contents = await fsp.readFile(args.path, 'utf8')
|
||||
const injectValues
|
||||
= `const ${dirnameVarName} = ${JSON.stringify(
|
||||
path.dirname(args.path),
|
||||
)};`
|
||||
+ `const ${filenameVarName} = ${JSON.stringify(args.path)};`
|
||||
+ `const ${importMetaUrlVarName} = ${JSON.stringify(
|
||||
pathToFileURL(args.path).href,
|
||||
)};`
|
||||
|
||||
return {
|
||||
loader: args.path.endsWith('ts') ? 'ts' : 'js',
|
||||
contents: injectValues + contents,
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { text } = result.outputFiles[0]
|
||||
const tempFilePath = `${configPath}.${hash(text)}.mjs`
|
||||
let config: ThemeConfig
|
||||
try {
|
||||
await fsp.writeFile(tempFilePath, text)
|
||||
config = await importFileDefault(tempFilePath)
|
||||
}
|
||||
finally {
|
||||
await fsp.rm(tempFilePath)
|
||||
}
|
||||
return {
|
||||
config,
|
||||
dependencies: Object.keys(result.metafile?.inputs ?? {}),
|
||||
}
|
||||
}
|
||||
49
theme/src/node/loadConfig/findConfigPath.ts
Normal file
49
theme/src/node/loadConfig/findConfigPath.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import fs, { constants, promises as fsp } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import type { App } from 'vuepress'
|
||||
import { colors } from 'vuepress/utils'
|
||||
import { logger } from '../utils/index.js'
|
||||
|
||||
const CONFIG_FILE_NAME = 'plume.config'
|
||||
const extensions: string[] = ['ts', 'js', 'mts', 'cts', 'mjs', 'cjs']
|
||||
|
||||
export async function findConfigPath(app: App, configPath?: string): Promise<string | undefined> {
|
||||
const cwd = process.cwd()
|
||||
const source = app.dir.source('.vuepress')
|
||||
|
||||
const paths: string[] = []
|
||||
|
||||
if (configPath) {
|
||||
const path = resolve(cwd, configPath)
|
||||
if (existsSync(path) && (await fsp.stat(path)).isFile()) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
extensions.forEach((ext) => {
|
||||
paths.push(resolve(cwd, `./${configPath}.${ext}`))
|
||||
paths.push(resolve(cwd, `${source}/${CONFIG_FILE_NAME}.${ext}`))
|
||||
paths.push(resolve(cwd, `./.vuepress/${CONFIG_FILE_NAME}.${ext}`))
|
||||
})
|
||||
let current: string | undefined
|
||||
for (const path of paths) {
|
||||
if (existsSync(path) && (await fsp.stat(path)).isFile()) {
|
||||
current = path
|
||||
break
|
||||
}
|
||||
}
|
||||
if (configPath && current) {
|
||||
logger.warn(`Can not find config file: ${colors.gray(configPath)}\nUse config file: ${colors.gray(current)}`)
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function existsSync(fp: string) {
|
||||
try {
|
||||
fs.accessSync(fp, constants.R_OK)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
8
theme/src/node/loadConfig/index.ts
Normal file
8
theme/src/node/loadConfig/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 每次修改 主题配置 都会导致 vuepress 服务重启,成本太高了,严重影响了用户体验。
|
||||
* 实际上 主题配置 中的大部分 选项 跟 node 的构建过程是无关的,根本无需重启服务。
|
||||
* 因此,将 主题配置 抽离到独立的文件中进行配置,避免服务重启,是非常有必要的。
|
||||
*/
|
||||
export * from './findConfigPath.js'
|
||||
export * from './compiler.js'
|
||||
export * from './loader.js'
|
||||
163
theme/src/node/loadConfig/loader.ts
Normal file
163
theme/src/node/loadConfig/loader.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import type { App } from 'vuepress'
|
||||
import type { FSWatcher } from 'chokidar'
|
||||
import { path } from 'vuepress/utils'
|
||||
import { watch } from 'chokidar'
|
||||
import { deepMerge } from '@pengzhanbo/utils'
|
||||
import type { ThemeConfig } from '../../shared/theme-data.js'
|
||||
import type { AutoFrontmatter, PlumeThemeEncrypt, PlumeThemeLocaleOptions } from '../../shared/index.js'
|
||||
import { resolveLocaleOptions } from '../config/resolveLocaleOptions.js'
|
||||
import { findConfigPath } from './findConfigPath.js'
|
||||
import { compiler } from './compiler.js'
|
||||
|
||||
export interface ResolvedConfig {
|
||||
localeOptions: PlumeThemeLocaleOptions
|
||||
encrypt?: PlumeThemeEncrypt
|
||||
autoFrontmatter?: false | Omit<AutoFrontmatter, 'frontmatter'>
|
||||
}
|
||||
|
||||
export interface InitConfigLoaderOptions {
|
||||
configFile?: string
|
||||
onChange?: ChangeEvent
|
||||
}
|
||||
|
||||
export type ChangeEvent = (config: ResolvedConfig) => void | Promise<void>
|
||||
|
||||
export interface Loader {
|
||||
configFile: string | undefined
|
||||
dependencies: string[]
|
||||
load: () => Promise<{ config: ThemeConfig, dependencies: string[] }>
|
||||
loaded: boolean
|
||||
watcher: FSWatcher | null
|
||||
changeEvents: ChangeEvent[]
|
||||
whenLoaded: ChangeEvent[]
|
||||
defaultConfig: ThemeConfig
|
||||
resolvedConfig: ResolvedConfig
|
||||
}
|
||||
|
||||
let loader: Loader | null = null
|
||||
|
||||
export async function initConfigLoader(
|
||||
app: App,
|
||||
defaultConfig: ThemeConfig,
|
||||
{ configFile, onChange }: InitConfigLoaderOptions = {},
|
||||
) {
|
||||
configFile = await findConfigPath(app, configFile)
|
||||
|
||||
const { encrypt, autoFrontmatter, ...localeOptions } = defaultConfig
|
||||
loader = {
|
||||
configFile,
|
||||
dependencies: [],
|
||||
load: () => compiler(configFile),
|
||||
loaded: false,
|
||||
watcher: null,
|
||||
changeEvents: [],
|
||||
whenLoaded: [],
|
||||
defaultConfig,
|
||||
resolvedConfig: {
|
||||
localeOptions: resolveLocaleOptions(app, localeOptions),
|
||||
encrypt,
|
||||
autoFrontmatter,
|
||||
},
|
||||
}
|
||||
|
||||
onChange && loader.changeEvents.push(onChange)
|
||||
|
||||
const { config, dependencies = [] } = await loader.load()
|
||||
loader.loaded = true
|
||||
addDependencies(dependencies)
|
||||
updateResolvedConfig(app, config)
|
||||
runChangeEvents()
|
||||
|
||||
loader.whenLoaded.forEach(fn => fn(loader!.resolvedConfig))
|
||||
loader.whenLoaded = []
|
||||
}
|
||||
|
||||
export function watchConfigFile(app: App, watchers: any[]) {
|
||||
if (!loader || !loader.configFile)
|
||||
return
|
||||
|
||||
const watcher = watch(loader.configFile, {
|
||||
ignoreInitial: true,
|
||||
cwd: path.join(path.dirname(loader.configFile), '../'),
|
||||
})
|
||||
|
||||
addDependencies()
|
||||
|
||||
watcher.on('change', async () => {
|
||||
if (loader) {
|
||||
loader.loaded = false
|
||||
const { config, dependencies = [] } = await loader.load()
|
||||
loader.loaded = true
|
||||
addDependencies(dependencies)
|
||||
updateResolvedConfig(app, config)
|
||||
runChangeEvents()
|
||||
}
|
||||
})
|
||||
|
||||
watcher.on('unlink', async () => {
|
||||
updateResolvedConfig(app)
|
||||
runChangeEvents()
|
||||
})
|
||||
|
||||
loader.watcher = watcher
|
||||
|
||||
watchers.push(watcher)
|
||||
}
|
||||
|
||||
export async function onConfigChange(onChange: ChangeEvent) {
|
||||
if (loader && !loader.changeEvents.includes(onChange)) {
|
||||
loader.changeEvents.push(onChange)
|
||||
loader.loaded && onChange(loader.resolvedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
export function waitForConfigLoaded() {
|
||||
return new Promise<ResolvedConfig>((resolve) => {
|
||||
if (loader?.loaded) {
|
||||
resolve(loader.resolvedConfig)
|
||||
}
|
||||
else {
|
||||
loader?.whenLoaded.push(resolve)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getResolvedThemeConfig() {
|
||||
return loader!.resolvedConfig
|
||||
}
|
||||
|
||||
export function isConfigLoaded() {
|
||||
return loader?.loaded ?? false
|
||||
}
|
||||
|
||||
function updateResolvedConfig(app: App, userConfig: ThemeConfig = {}) {
|
||||
if (loader) {
|
||||
const { encrypt, autoFrontmatter, ...localeOptions } = deepMerge({}, loader.defaultConfig, userConfig)
|
||||
loader.resolvedConfig = {
|
||||
localeOptions: resolveLocaleOptions(app, localeOptions),
|
||||
encrypt,
|
||||
autoFrontmatter,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runChangeEvents() {
|
||||
if (loader) {
|
||||
loader.changeEvents.forEach(fn => fn(loader!.resolvedConfig))
|
||||
}
|
||||
}
|
||||
|
||||
function addDependencies(dependencies?: string[]) {
|
||||
if (!loader)
|
||||
return
|
||||
|
||||
if (dependencies?.length) {
|
||||
const deps = dependencies
|
||||
.filter(dep => !loader!.dependencies.includes(dep) && dep[0] === '.')
|
||||
loader.dependencies.push(...deps)
|
||||
deps.length && loader.watcher?.add(deps)
|
||||
}
|
||||
else {
|
||||
loader.watcher?.add(loader.dependencies)
|
||||
}
|
||||
}
|
||||
6
theme/src/node/utils/hash.ts
Normal file
6
theme/src/node/utils/hash.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { customAlphabet } from 'nanoid'
|
||||
|
||||
export const hash = (content: string) => createHash('md5').update(content).digest('hex')
|
||||
|
||||
export const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 8)
|
||||
11
theme/src/node/utils/index.ts
Normal file
11
theme/src/node/utils/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Logger } from '@vuepress/helper'
|
||||
|
||||
export const THEME_NAME = 'vuepress-theme-plume'
|
||||
|
||||
export const logger = new Logger(THEME_NAME)
|
||||
|
||||
export * from './hash.js'
|
||||
export * from './path.js'
|
||||
export * from './package.js'
|
||||
export * from './resolveContent.js'
|
||||
export * from './writeTemp.js'
|
||||
23
theme/src/node/utils/package.ts
Normal file
23
theme/src/node/utils/package.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import process from 'node:process'
|
||||
import { fs, path } from 'vuepress/utils'
|
||||
import { resolve } from './path.js'
|
||||
|
||||
export function getPackage() {
|
||||
let pkg = {} as any
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8')
|
||||
pkg = JSON.parse(content)
|
||||
}
|
||||
catch { }
|
||||
return pkg
|
||||
}
|
||||
|
||||
export function getThemePackage() {
|
||||
let pkg = {} as any
|
||||
try {
|
||||
const content = fs.readFileSync(resolve('.../../package.json'), 'utf-8')
|
||||
pkg = JSON.parse(content)
|
||||
}
|
||||
catch {}
|
||||
return pkg
|
||||
}
|
||||
37
theme/src/node/utils/path.ts
Normal file
37
theme/src/node/utils/path.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { getDirname, path } from 'vuepress/utils'
|
||||
import { ensureEndingSlash, ensureLeadingSlash, isLinkAbsolute, isLinkWithProtocol } from '@vuepress/helper'
|
||||
|
||||
const __dirname = getDirname(import.meta.url)
|
||||
|
||||
export const resolve = (...args: string[]) => path.resolve(__dirname, '../../', ...args)
|
||||
export const templates = (url: string) => resolve('../templates', url)
|
||||
|
||||
const RE_SLASH = /(\\|\/)+/g
|
||||
export function normalizePath(path: string) {
|
||||
return path.replace(RE_SLASH, '/')
|
||||
}
|
||||
|
||||
export function pathJoin(...args: string[]) {
|
||||
return normalizePath(path.join(...args))
|
||||
}
|
||||
|
||||
export function normalizeLink(base: string, link = ''): string {
|
||||
return isLinkAbsolute(link) || isLinkWithProtocol(link)
|
||||
? link
|
||||
: ensureLeadingSlash(normalizePath(`${base}/${link}/`))
|
||||
}
|
||||
|
||||
const RE_START_END_SLASH = /^\/|\/$/g
|
||||
export function getCurrentDirname(basePath: string | undefined, filepath: string) {
|
||||
const dirList = normalizePath(basePath || path.dirname(filepath))
|
||||
.replace(RE_START_END_SLASH, '')
|
||||
.split('/')
|
||||
return dirList.length > 0 ? dirList[dirList.length - 1] : ''
|
||||
}
|
||||
|
||||
export function withBase(path = '', base = '/'): string {
|
||||
path = ensureEndingSlash(ensureLeadingSlash(path))
|
||||
if (path.startsWith(base))
|
||||
return normalizePath(path)
|
||||
return normalizePath(`${base}${path}`)
|
||||
}
|
||||
31
theme/src/node/utils/resolveContent.ts
Normal file
31
theme/src/node/utils/resolveContent.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import type { App } from 'vuepress'
|
||||
|
||||
export interface ResolveContentOptions {
|
||||
name: string
|
||||
content: any
|
||||
before?: string
|
||||
after?: string
|
||||
}
|
||||
|
||||
export function resolveContent(app: App, { name, content, before, after }: ResolveContentOptions): string {
|
||||
content = `${before ? `${before}\n` : ''}export const ${name} = ${JSON.stringify(content)}${after ? `\n${after}` : ''}`
|
||||
|
||||
if (app.env.isDev) {
|
||||
const func = `update${name[0].toUpperCase()}${name.slice(1)}`
|
||||
content += `\n
|
||||
if (import.meta.webpackHot) {
|
||||
import.meta.webpackHot.accept()
|
||||
if (__VUE_HMR_RUNTIME__.${func}) {
|
||||
__VUE_HMR_RUNTIME__.${func}(${name})
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(({ ${name} }) => {
|
||||
__VUE_HMR_RUNTIME__.${func}(${name})
|
||||
})
|
||||
}
|
||||
`
|
||||
}
|
||||
return content
|
||||
}
|
||||
29
theme/src/node/utils/writeTemp.ts
Normal file
29
theme/src/node/utils/writeTemp.ts
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 仅内容发生变更时,才写入临时文件
|
||||
*/
|
||||
import type { App } from 'vuepress'
|
||||
import { hash } from './hash.js'
|
||||
|
||||
export const contentHash: Map<string, string> = new Map()
|
||||
|
||||
export async function writeTemp(
|
||||
app: App,
|
||||
filepath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const currentHash = hash(content)
|
||||
if (!contentHash.has(filepath) || contentHash.get(filepath) !== currentHash) {
|
||||
contentHash.set(filepath, currentHash)
|
||||
await app.writeTemp(filepath, content)
|
||||
}
|
||||
}
|
||||
|
||||
export function setContentHash(filepath: string, content: string): void {
|
||||
if (content) {
|
||||
const currentHash = hash(content)
|
||||
contentHash.set(filepath, currentHash)
|
||||
}
|
||||
else {
|
||||
contentHash.delete(filepath)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user