import type { Options as VuePluginOptions } from '@vitejs/plugin-vue' import _debug from 'debug' import fg from 'fast-glob' import fs from 'fs-extra' import path from 'path' import c from 'picocolors' import { createLogger, loadConfigFromFile, mergeConfig as mergeViteConfig, normalizePath, type Logger, type UserConfig as ViteConfig } from 'vite' import { DEFAULT_THEME_PATH } from './alias' import type { MarkdownOptions } from './markdown/markdown' import { dynamicRouteRE, resolveDynamicRoutes, type ResolvedRouteConfig } from './plugins/dynamicRoutesPlugin' import { resolveRewrites } from './plugins/rewritesPlugin' import { APPEARANCE_KEY, type Awaitable, type DefaultTheme, type HeadConfig, type LocaleConfig, type LocaleSpecificConfig, type PageData, type SiteData, type SSGContext } from './shared' const debug = _debug('vitepress:config') export interface UserConfig extends LocaleSpecificConfig { extends?: RawConfigExports base?: string srcDir?: string srcExclude?: string[] outDir?: string cacheDir?: string shouldPreload?: (link: string, page: string) => boolean locales?: LocaleConfig appearance?: boolean | 'dark' lastUpdated?: boolean /** * MarkdownIt options */ markdown?: MarkdownOptions /** * Options to pass on to `@vitejs/plugin-vue` */ vue?: VuePluginOptions /** * Vite config */ vite?: ViteConfig /** * Configure the scroll offset when the theme has a sticky header. * Can be a number or a selector element to get the offset from. * Can also be an array of selectors in case some elements will be * invisible due to responsive layout. VitePress will fallback to the next * selector if a selector fails to match, or the matched element is not * currently visible in viewport. */ scrollOffset?: number | string | string[] /** * Enable MPA / zero-JS mode. * @experimental */ mpa?: boolean /** * Don't fail builds due to dead links. * * @default false */ ignoreDeadLinks?: | boolean | 'localhostLinks' | (string | RegExp | ((link: string) => boolean))[] /** * Don't force `.html` on URLs. * * @default false */ cleanUrls?: boolean /** * Use web fonts instead of emitting font files to dist. * The used theme should import a file named `fonts.(s)css` for this to work. * If you are a theme author, to support this, place your web font import * between `webfont-marker-begin` and `webfont-marker-end` comments. * * @default true in webcontainers, else false */ useWebFonts?: boolean /** * @experimental * * source -> destination */ rewrites?: Record /** * Build end hook: called when SSG finish. * @param siteConfig The resolved configuration. */ buildEnd?: (siteConfig: SiteConfig) => Awaitable /** * Render end hook: called when SSR rendering is done. */ postRender?: (context: SSGContext) => Awaitable /** * Head transform hook: runs before writing HTML to dist. * * This build hook will allow you to modify the head adding new entries that cannot be statically added. */ transformHead?: (context: TransformContext) => Awaitable /** * HTML transform hook: runs before writing HTML to dist. */ transformHtml?: ( code: string, id: string, ctx: TransformContext ) => Awaitable /** * PageData transform hook: runs when rendering markdown to vue */ transformPageData?: ( pageData: PageData, ctx: TransformPageContext ) => Awaitable | { [key: string]: any } | void> } export interface TransformPageContext { siteConfig: SiteConfig } export interface TransformContext { page: string siteConfig: SiteConfig siteData: SiteData pageData: PageData title: string description: string head: HeadConfig[] content: string assets: string[] } export type RawConfigExports = | Awaitable> | (() => Awaitable>) export interface SiteConfig extends Pick< UserConfig, | 'markdown' | 'vue' | 'vite' | 'shouldPreload' | 'mpa' | 'lastUpdated' | 'ignoreDeadLinks' | 'cleanUrls' | 'useWebFonts' | 'postRender' | 'buildEnd' | 'transformHead' | 'transformHtml' | 'transformPageData' > { root: string srcDir: string site: SiteData configPath: string | undefined configDeps: string[] themeDir: string outDir: string cacheDir: string tempDir: string pages: string[] dynamicRoutes: { routes: ResolvedRouteConfig[] fileToModulesMap: Record> } rewrites: { map: Record inv: Record } logger: Logger userConfig: UserConfig } const resolve = (root: string, file: string) => normalizePath(path.resolve(root, `.vitepress`, file)) /** * Type config helper */ export function defineConfig(config: UserConfig) { return config } /** * Type config helper for custom theme config */ export function defineConfigWithTheme( config: UserConfig ) { return config } export async function resolveConfig( root: string = process.cwd(), command: 'serve' | 'build' = 'serve', mode = 'development' ): Promise { // normalize root into absolute path root = normalizePath(path.resolve(root)) const [userConfig, configPath, configDeps] = await resolveUserConfig( root, command, mode ) const logger = userConfig.vite?.customLogger ?? createLogger(userConfig.vite?.logLevel, { prefix: '[vitepress]', allowClearScreen: userConfig.vite?.clearScreen }) const site = await resolveSiteData(root, userConfig) const srcDir = normalizePath(path.resolve(root, userConfig.srcDir || '.')) const outDir = userConfig.outDir ? normalizePath(path.resolve(root, userConfig.outDir)) : resolve(root, 'dist') const cacheDir = userConfig.cacheDir ? normalizePath(path.resolve(root, userConfig.cacheDir)) : resolve(root, 'cache') // resolve theme path const userThemeDir = resolve(root, 'theme') const themeDir = (await fs.pathExists(userThemeDir)) ? userThemeDir : DEFAULT_THEME_PATH const { pages, dynamicRoutes, rewrites } = await resolvePages( srcDir, userConfig ) const config: SiteConfig = { root, srcDir, site, themeDir, pages, dynamicRoutes, configPath, configDeps, outDir, cacheDir, logger, tempDir: resolve(root, '.temp'), markdown: userConfig.markdown, lastUpdated: userConfig.lastUpdated, vue: userConfig.vue, vite: userConfig.vite, shouldPreload: userConfig.shouldPreload, mpa: !!userConfig.mpa, ignoreDeadLinks: userConfig.ignoreDeadLinks, cleanUrls: !!userConfig.cleanUrls, useWebFonts: userConfig.useWebFonts ?? typeof process.versions.webcontainer === 'string', postRender: userConfig.postRender, buildEnd: userConfig.buildEnd, transformHead: userConfig.transformHead, transformHtml: userConfig.transformHtml, transformPageData: userConfig.transformPageData, rewrites, userConfig } // to be shared with content loaders // @ts-ignore global.VITEPRESS_CONFIG = config return config } const supportedConfigExtensions = ['js', 'ts', 'cjs', 'mjs', 'cts', 'mts'] export async function resolveUserConfig( root: string, command: 'serve' | 'build', mode: string ): Promise<[UserConfig, string | undefined, string[]]> { // load user config const configPath = supportedConfigExtensions .flatMap((ext) => [ resolve(root, `config/index.${ext}`), resolve(root, `config.${ext}`) ]) .find(fs.pathExistsSync) let userConfig: RawConfigExports = {} let configDeps: string[] = [] if (!configPath) { debug(`no config file found.`) } else { const configExports = await loadConfigFromFile( { command, mode }, configPath, root ) if (configExports) { userConfig = configExports.config configDeps = configExports.dependencies.map((file) => normalizePath(path.resolve(file)) ) } debug(`loaded config at ${c.yellow(configPath)}`) } return [await resolveConfigExtends(userConfig), configPath, configDeps] } async function resolveConfigExtends( config: RawConfigExports ): Promise { const resolved = await (typeof config === 'function' ? config() : config) if (resolved.extends) { const base = await resolveConfigExtends(resolved.extends) return mergeConfig(base, resolved) } return resolved } function mergeConfig(a: UserConfig, b: UserConfig, isRoot = true) { const merged: Record = { ...a } for (const key in b) { const value = b[key as keyof UserConfig] if (value == null) { continue } const existing = merged[key] if (Array.isArray(existing) && Array.isArray(value)) { merged[key] = [...existing, ...value] continue } if (isObject(existing) && isObject(value)) { if (isRoot && key === 'vite') { merged[key] = mergeViteConfig(existing, value) } else { merged[key] = mergeConfig(existing, value, false) } continue } merged[key] = value } return merged } function isObject(value: unknown): value is Record { return Object.prototype.toString.call(value) === '[object Object]' } export async function resolveSiteData( root: string, userConfig?: UserConfig, command: 'serve' | 'build' = 'serve', mode = 'development' ): Promise { userConfig = userConfig || (await resolveUserConfig(root, command, mode))[0] return { lang: userConfig.lang || 'en-US', dir: userConfig.dir || 'ltr', title: userConfig.title || 'VitePress', titleTemplate: userConfig.titleTemplate, description: userConfig.description || 'A VitePress site', base: userConfig.base ? userConfig.base.replace(/([^/])$/, '$1/') : '/', head: resolveSiteDataHead(userConfig), appearance: userConfig.appearance ?? true, themeConfig: userConfig.themeConfig || {}, locales: userConfig.locales || {}, scrollOffset: userConfig.scrollOffset || 90, cleanUrls: !!userConfig.cleanUrls } } function resolveSiteDataHead(userConfig?: UserConfig): HeadConfig[] { const head = userConfig?.head ?? [] // add inline script to apply dark mode, if user enables the feature. // this is required to prevent "flash" on initial page load. if (userConfig?.appearance ?? true) { // if appearance mode set to light or dark, default to the defined mode // in case the user didn't specify a preference - otherwise, default to auto const fallbackPreference = userConfig?.appearance !== true ? userConfig?.appearance ?? '' : 'auto' head.push([ 'script', { id: 'check-dark-light' }, ` ;(() => { const preference = localStorage.getItem('${APPEARANCE_KEY}') || '${fallbackPreference}' const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches if (!preference || preference === 'auto' ? prefersDark : preference === 'dark') { document.documentElement.classList.add('dark') } })() ` ]) } return head } export async function resolvePages(srcDir: string, userConfig: UserConfig) { // Important: fast-glob doesn't guarantee order of the returned files. // We must sort the pages so the input list to rollup is stable across // builds - otherwise different input order could result in different exports // order in shared chunks which in turns invalidates the hash of every chunk! // JavaScript built-in sort() is mandated to be stable as of ES2019 and // supported in Node 12+, which is required by Vite. const allMarkdownFiles = ( await fg(['**.md'], { cwd: srcDir, ignore: ['**/node_modules', ...(userConfig.srcExclude || [])] }) ).sort() const pages = allMarkdownFiles.filter((p) => !dynamicRouteRE.test(p)) const dynamicRouteFiles = allMarkdownFiles.filter((p) => dynamicRouteRE.test(p) ) const dynamicRoutes = await resolveDynamicRoutes(srcDir, dynamicRouteFiles) pages.push(...dynamicRoutes.routes.map((r) => r.path)) const rewrites = resolveRewrites(pages, userConfig.rewrites) return { pages, dynamicRoutes, rewrites } }