diff --git a/src/client/app/data.ts b/src/client/app/data.ts index d9b80ce0..e342a6cf 100644 --- a/src/client/app/data.ts +++ b/src/client/app/data.ts @@ -19,7 +19,7 @@ import { type SiteData } from '../shared' import type { Route } from './router' -import { dirname, stackView } from './utils' +import { stackView } from './utils' export const dataSymbol: InjectionKey = Symbol() @@ -70,49 +70,45 @@ if (import.meta.hot) { }) } -// hierarchical config pre-loading -const extraConfig: Record = Object.fromEntries( - Object.entries( - import.meta.glob('/**/config.([cm]?js|ts|json)', { - eager: true +function debugConfigLayers(path: string, layers: SiteData[]): SiteData[] { + // debug info + if (inBrowser && import.meta.env.DEV) { + const summaryTitle = `Config Layers for ${path}:` + const summary = layers.map((c, i, arr) => { + const n = i + 1 + if (n === arr.length) return `${n}. .vitepress/config (root)` + return `${n}. ${(c as any)?.VP_SOURCE ?? '(Unknown Source)'}` }) - ).map(([path, module]) => [ - dirname(path), - { __module__: path, ...((module as any)?.default ?? module) } - ]) -) + console.debug( + [summaryTitle, ''.padEnd(summaryTitle.length, '='), ...summary].join('\n') + ) + } + return layers +} -function getExtraConfigs(path: string): SiteData[] { +function getConfigLayers(root: SiteData, path: string): SiteData[] { if (!path.startsWith('/')) path = `/${path}` + const additionalConfig = root.additionalConfig + if (additionalConfig === undefined) return [root] + else if (typeof additionalConfig === 'function') + return [...(additionalConfig(path) as SiteData[]), root] const configs: SiteData[] = [] const segments = path.split('/').slice(1, -1) while (segments.length) { const key = `/${segments.join('/')}/` - if (key in extraConfig) configs.push(extraConfig[key]) + if (key in additionalConfig) configs.push(additionalConfig[key] as SiteData) segments.pop() } - // debug info - if (inBrowser) { - const summaryTitle = `Config Layers for ${path}:` - const summary = configs.map( - (c, i) => ` ${i + 1}. ${(c as any).__module__}` - ) - summary.push(` ${summary.length + 1}. .vitepress/config (root)`) - console.debug( - [summaryTitle, ''.padEnd(summaryTitle.length, '='), ...summary].join('\n') - ) - } - return configs + return [...configs, root] } // per-app data export function initData(route: Route): VitePressData { const site = computed(() => { - const data = resolveSiteDataByRoute( - siteDataRef.value, - route.data.relativePath - ) - return stackView(...getExtraConfigs(route.data.relativePath), data) + ;(window as any).siteData = siteDataRef.value + const path = route.data.relativePath + const data = resolveSiteDataByRoute(siteDataRef.value, path) + return stackView(...debugConfigLayers(path, getConfigLayers(data, path))) }) const appearance = site.value.appearance // fine with reactivity being lost here, config change triggers a restart diff --git a/src/client/app/utils.ts b/src/client/app/utils.ts index 3190cd0a..b701c08c 100644 --- a/src/client/app/utils.ts +++ b/src/client/app/utils.ts @@ -141,12 +141,6 @@ function tryOffsetSelector(selector: string, padding: number): number { return bot + padding } -export function dirname(path: string) { - const segments = path.split('/') - segments[segments.length - 1] = '' - return segments.join('/') -} - const unpackStackView = Symbol('unpackStackView') function isStackable(obj: any) { diff --git a/src/node/config.ts b/src/node/config.ts index f5e0bb39..a2634034 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -14,6 +14,11 @@ import type { DefaultTheme } from './defaultTheme' import { resolvePages } from './plugins/dynamicRoutesPlugin' import { APPEARANCE_KEY, slash, type HeadConfig, type SiteData } from './shared' import type { RawConfigExports, SiteConfig, UserConfig } from './siteConfig' +import type { + AdditionalConfigDict, + AdditionalConfigEntry +} from '../../types/shared' +import { glob } from 'tinyglobby' export { resolvePages } from './plugins/dynamicRoutesPlugin' export * from './siteConfig' @@ -140,7 +145,65 @@ export async function resolveConfig( return config } -const supportedConfigExtensions = ['js', 'ts', 'mjs', 'mts'] +export const supportedConfigExtensions = ['js', 'ts', 'mjs', 'mts'] + +export function isAdditionalConfigFile(path: string) { + const filename_to_check = path.split('/').pop() ?? '' + for (const filename of supportedConfigExtensions.map((e) => `config.${e}`)) { + if (filename_to_check === filename) { + return true + } + } + return false +} + +/** + * Make sure the path ends with a slash. + * If path points to a file, remove the filename component. + * @param path + * @returns + */ +function dirname(path: string) { + const segments = path.split('/') + segments[segments.length - 1] = '' + return segments.join('/') +} + +async function gatherAdditionalConfig( + root: string, + command: 'serve' | 'build', + mode: string +): Promise<[AdditionalConfigDict, string[][]]> { + const pattern = `**/config.{${supportedConfigExtensions.join(',')}}` + const candidates = await glob(pattern, { + cwd: root, + dot: false, // conveniently ignores .vitepress/* + ignore: ['**/node_modules/**', '**/.git/**'] + }) + const deps: string[][] = [] + const exports = await Promise.all( + candidates.map(async (file) => { + const id = '/' + dirname(slash(file)) + const configExports = await loadConfigFromFile( + { command, mode }, + normalizePath(path.resolve(root, file)), + root + ).catch(console.error) // Skip additionalConfig file if it fails to load + if (!configExports) { + debug(`Failed to load additional config from ${file}`) + return [id, undefined] + } + deps.push( + configExports.dependencies.map((file) => + normalizePath(path.resolve(file)) + ) + ) + if (mode === 'development') (configExports.config as any).VP_SOURCE = file + return [id, configExports.config as AdditionalConfigEntry] + }) + ) + return [Object.fromEntries(exports.filter(([id, config]) => config)), deps] +} export async function resolveUserConfig( root: string, @@ -170,6 +233,16 @@ export async function resolveUserConfig( configDeps = configExports.dependencies.map((file) => normalizePath(path.resolve(file)) ) + // Auto-generate additional config if user leaves it unspecified + if (userConfig.additionalConfig === undefined) { + const [additionalConfig, additionalDeps] = await gatherAdditionalConfig( + root, + command, + mode + ) + userConfig.additionalConfig = additionalConfig + configDeps = configDeps.concat(...additionalDeps) + } } debug(`loaded config at ${c.yellow(configPath)}`) } @@ -241,7 +314,8 @@ export async function resolveSiteData( locales: userConfig.locales || {}, scrollOffset: userConfig.scrollOffset ?? 134, cleanUrls: !!userConfig.cleanUrls, - contentProps: userConfig.contentProps + contentProps: userConfig.contentProps, + additionalConfig: userConfig.additionalConfig } } diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 748c4da4..f6c3d3f9 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -15,7 +15,12 @@ import { SITE_DATA_REQUEST_PATH, resolveAliases } from './alias' -import { resolvePages, resolveUserConfig, type SiteConfig } from './config' +import { + resolvePages, + resolveUserConfig, + isAdditionalConfigFile, + type SiteConfig +} from './config' import { disposeMdItInstance } from './markdown/markdown' import { clearCache, @@ -383,7 +388,11 @@ export async function createVitePressPlugin( async hotUpdate({ file }) { if (this.environment.name !== 'client') return - if (file === configPath || configDeps.includes(file)) { + if ( + file === configPath || + configDeps.includes(file) || + isAdditionalConfigFile(file) + ) { siteConfig.logger.info( c.green( `${path.relative(process.cwd(), file)} changed, restarting server...\n` diff --git a/src/node/siteConfig.ts b/src/node/siteConfig.ts index 49451cbe..c591a956 100644 --- a/src/node/siteConfig.ts +++ b/src/node/siteConfig.ts @@ -14,6 +14,7 @@ import type { SSGContext, SiteData } from './shared' +import type { AdditionalConfig } from '../../types/shared' export type RawConfigExports = | Awaitable> @@ -187,6 +188,13 @@ export interface UserConfig pageData: PageData, ctx: TransformPageContext ) => Awaitable | { [key: string]: any } | void> + + /** + * @experimental + * Multi-layer configuration overloading. + * Auto-resolves to docs/.../config.(ts|js|json) when unspecified. + */ + additionalConfig?: AdditionalConfig } export interface SiteConfig @@ -209,6 +217,7 @@ export interface SiteConfig | 'transformHtml' | 'transformPageData' | 'sitemap' + | 'additionalConfig' > { root: string srcDir: string diff --git a/types/shared.d.ts b/types/shared.d.ts index 1ebfa9b9..62a618c2 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -134,6 +134,7 @@ export interface SiteData { router: { prefetchLinks: boolean } + additionalConfig?: AdditionalConfig } export type HeadConfig = @@ -161,6 +162,27 @@ export interface LocaleSpecificConfig { themeConfig?: ThemeConfig } +export interface AdditionalConfigEntry + extends LocaleSpecificConfig { + /** + * Source of current config entry, only available in development mode + */ + src?: string +} + +export type AdditionalConfigDict = Record< + string, + AdditionalConfigEntry +> + +export type AdditionalConfigLoader = ( + path: string +) => AdditionalConfigEntry[] + +export type AdditionalConfig = + | AdditionalConfigDict + | AdditionalConfigLoader + export type LocaleConfig = Record< string, LocaleSpecificConfig & { label: string; link?: string }