feat: 添加单独的主题配置文件支持

This commit is contained in:
pengzhanbo 2024-07-08 02:35:47 +08:00
parent 5ae11c766b
commit cbba7868bf
10 changed files with 446 additions and 0 deletions

View 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 ?? {}),
}
}

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

View File

@ -0,0 +1,8 @@
/**
* vuepress
* node
*
*/
export * from './findConfigPath.js'
export * from './compiler.js'
export * from './loader.js'

View 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)
}
}

View 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)

View 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'

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

View 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}`)
}

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

View 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)
}
}