From cbba7868bf87a658bf418025801002db4bc5be90 Mon Sep 17 00:00:00 2001 From: pengzhanbo Date: Mon, 8 Jul 2024 02:35:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=8D=95=E7=8B=AC?= =?UTF-8?q?=E7=9A=84=E4=B8=BB=E9=A2=98=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- theme/src/node/loadConfig/compiler.ts | 89 +++++++++++ theme/src/node/loadConfig/findConfigPath.ts | 49 ++++++ theme/src/node/loadConfig/index.ts | 8 + theme/src/node/loadConfig/loader.ts | 163 ++++++++++++++++++++ theme/src/node/utils/hash.ts | 6 + theme/src/node/utils/index.ts | 11 ++ theme/src/node/utils/package.ts | 23 +++ theme/src/node/utils/path.ts | 37 +++++ theme/src/node/utils/resolveContent.ts | 31 ++++ theme/src/node/utils/writeTemp.ts | 29 ++++ 10 files changed, 446 insertions(+) create mode 100644 theme/src/node/loadConfig/compiler.ts create mode 100644 theme/src/node/loadConfig/findConfigPath.ts create mode 100644 theme/src/node/loadConfig/index.ts create mode 100644 theme/src/node/loadConfig/loader.ts create mode 100644 theme/src/node/utils/hash.ts create mode 100644 theme/src/node/utils/index.ts create mode 100644 theme/src/node/utils/package.ts create mode 100644 theme/src/node/utils/path.ts create mode 100644 theme/src/node/utils/resolveContent.ts create mode 100644 theme/src/node/utils/writeTemp.ts diff --git a/theme/src/node/loadConfig/compiler.ts b/theme/src/node/loadConfig/compiler.ts new file mode 100644 index 00000000..04d7ce2c --- /dev/null +++ b/theme/src/node/loadConfig/compiler.ts @@ -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 ?? {}), + } +} diff --git a/theme/src/node/loadConfig/findConfigPath.ts b/theme/src/node/loadConfig/findConfigPath.ts new file mode 100644 index 00000000..460fc947 --- /dev/null +++ b/theme/src/node/loadConfig/findConfigPath.ts @@ -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 { + 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 + } +} diff --git a/theme/src/node/loadConfig/index.ts b/theme/src/node/loadConfig/index.ts new file mode 100644 index 00000000..e5cc0fb8 --- /dev/null +++ b/theme/src/node/loadConfig/index.ts @@ -0,0 +1,8 @@ +/** + * 每次修改 主题配置 都会导致 vuepress 服务重启,成本太高了,严重影响了用户体验。 + * 实际上 主题配置 中的大部分 选项 跟 node 的构建过程是无关的,根本无需重启服务。 + * 因此,将 主题配置 抽离到独立的文件中进行配置,避免服务重启,是非常有必要的。 + */ +export * from './findConfigPath.js' +export * from './compiler.js' +export * from './loader.js' diff --git a/theme/src/node/loadConfig/loader.ts b/theme/src/node/loadConfig/loader.ts new file mode 100644 index 00000000..2b3df684 --- /dev/null +++ b/theme/src/node/loadConfig/loader.ts @@ -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 +} + +export interface InitConfigLoaderOptions { + configFile?: string + onChange?: ChangeEvent +} + +export type ChangeEvent = (config: ResolvedConfig) => void | Promise + +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((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) + } +} diff --git a/theme/src/node/utils/hash.ts b/theme/src/node/utils/hash.ts new file mode 100644 index 00000000..8784a3b8 --- /dev/null +++ b/theme/src/node/utils/hash.ts @@ -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) diff --git a/theme/src/node/utils/index.ts b/theme/src/node/utils/index.ts new file mode 100644 index 00000000..35c75a61 --- /dev/null +++ b/theme/src/node/utils/index.ts @@ -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' diff --git a/theme/src/node/utils/package.ts b/theme/src/node/utils/package.ts new file mode 100644 index 00000000..16292696 --- /dev/null +++ b/theme/src/node/utils/package.ts @@ -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 +} diff --git a/theme/src/node/utils/path.ts b/theme/src/node/utils/path.ts new file mode 100644 index 00000000..82525cf3 --- /dev/null +++ b/theme/src/node/utils/path.ts @@ -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}`) +} diff --git a/theme/src/node/utils/resolveContent.ts b/theme/src/node/utils/resolveContent.ts new file mode 100644 index 00000000..9036b1f7 --- /dev/null +++ b/theme/src/node/utils/resolveContent.ts @@ -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 +} diff --git a/theme/src/node/utils/writeTemp.ts b/theme/src/node/utils/writeTemp.ts new file mode 100644 index 00000000..722cc19a --- /dev/null +++ b/theme/src/node/utils/writeTemp.ts @@ -0,0 +1,29 @@ +/** + * 仅内容发生变更时,才写入临时文件 + */ +import type { App } from 'vuepress' +import { hash } from './hash.js' + +export const contentHash: Map = new Map() + +export async function writeTemp( + app: App, + filepath: string, + content: string, +): Promise { + 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) + } +}