diff --git a/docs/.vitepress/config/shared.ts b/docs/.vitepress/config.ts similarity index 73% rename from docs/.vitepress/config/shared.ts rename to docs/.vitepress/config.ts index beca4e2da55e..575d114dd5d9 100644 --- a/docs/.vitepress/config/shared.ts +++ b/docs/.vitepress/config.ts @@ -1,18 +1,18 @@ -import { defineConfig } from 'vitepress' +import { + defineConfig, + resolveSiteDataByRoute, + type HeadConfig +} from 'vitepress' import { groupIconMdPlugin, groupIconVitePlugin, localIconLoader } from 'vitepress-plugin-group-icons' import llmstxt from 'vitepress-plugin-llms' -import { search as esSearch } from './es' -import { search as faSearch } from './fa' -import { search as koSearch } from './ko' -import { search as ptSearch } from './pt' -import { search as ruSearch } from './ru' -import { search as zhSearch } from './zh' -export const shared = defineConfig({ +const prod = !!process.env.NETLIFY + +export default defineConfig({ title: 'VitePress', rewrites: { @@ -78,8 +78,6 @@ export const shared = defineConfig({ ['link', { rel: 'icon', type: 'image/png', href: '/vitepress-logo-mini.png' }], ['meta', { name: 'theme-color', content: '#5f67ee' }], ['meta', { property: 'og:type', content: 'website' }], - ['meta', { property: 'og:locale', content: 'en' }], - ['meta', { property: 'og:title', content: 'VitePress | Vite & Vue Powered Static Site Generator' }], ['meta', { property: 'og:site_name', content: 'VitePress' }], ['meta', { property: 'og:image', content: 'https://vitepress.dev/vitepress-og.jpg' }], ['meta', { property: 'og:url', content: 'https://vitepress.dev/' }], @@ -98,35 +96,53 @@ export const shared = defineConfig({ options: { appId: '8J64VVRP8K', apiKey: '52f578a92b88ad6abde815aae2b0ad7c', - indexName: 'vitepress', - locales: { - ...zhSearch, - ...ptSearch, - ...ruSearch, - ...esSearch, - ...koSearch, - ...faSearch - } + indexName: 'vitepress' } }, carbonAds: { code: 'CEBDT27Y', placement: 'vuejsorg' } }, + + locales: { + root: { label: 'English' }, + zh: { label: '简体中文' }, + pt: { label: 'Português' }, + ru: { label: 'Русский' }, + es: { label: 'Español' }, + ko: { label: '한국어' }, + fa: { label: 'فارسی' } + }, + vite: { plugins: [ groupIconVitePlugin({ customIcon: { vitepress: localIconLoader( import.meta.url, - '../../public/vitepress-logo-mini.svg' + '../public/vitepress-logo-mini.svg' ), firebase: 'logos:firebase' } }), - llmstxt({ - workDir: 'en', - ignoreFiles: ['index.md'] - }) + prod && + llmstxt({ + workDir: 'en', + ignoreFiles: ['index.md'] + }) ] - } + }, + + transformPageData: prod + ? (pageData, ctx) => { + const site = resolveSiteDataByRoute( + ctx.siteConfig.site, + pageData.relativePath + ) + const title = `${pageData.title || site.title} | ${pageData.description || site.description}` + ;((pageData.frontmatter.head ??= []) as HeadConfig[]).push( + ['meta', { property: 'og:locale', content: site.lang }], + ['meta', { property: 'og:title', content: title }] + ) + } + : undefined }) diff --git a/docs/.vitepress/config/index.ts b/docs/.vitepress/config/index.ts deleted file mode 100644 index 08a81fb94f99..000000000000 --- a/docs/.vitepress/config/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { defineConfig } from 'vitepress' -import { shared } from './shared' -import { en } from './en' -import { zh } from './zh' -import { pt } from './pt' -import { ru } from './ru' -import { es } from './es' -import { ko } from './ko' -import { fa } from './fa' - -export default defineConfig({ - ...shared, - locales: { - root: { label: 'English', ...en }, - zh: { label: '简体中文', ...zh }, - pt: { label: 'Português', ...pt }, - ru: { label: 'Русский', ...ru }, - es: { label: 'Español', ...es }, - ko: { label: '한국어', ...ko }, - fa: { label: 'فارسی', ...fa } - } -}) diff --git a/docs/.vitepress/config/en.ts b/docs/config.ts similarity index 97% rename from docs/.vitepress/config/en.ts rename to docs/config.ts index 1f619347738e..a09a3a67c114 100644 --- a/docs/.vitepress/config/en.ts +++ b/docs/config.ts @@ -1,10 +1,10 @@ import { createRequire } from 'module' -import { defineConfig, type DefaultTheme } from 'vitepress' +import { defineAdditionalConfig, type DefaultTheme } from 'vitepress' const require = createRequire(import.meta.url) const pkg = require('vitepress/package.json') -export const en = defineConfig({ +export default defineAdditionalConfig({ lang: 'en-US', description: 'Vite & Vue powered static site generator.', diff --git a/docs/en/index.md b/docs/en/index.md index c91ce9531b59..61a2b003d27d 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -1,9 +1,6 @@ --- layout: home -title: VitePress -titleTemplate: Vite & Vue Powered Static Site Generator - hero: name: VitePress text: Vite & Vue Powered Static Site Generator diff --git a/docs/.vitepress/config/es.ts b/docs/es/config.ts similarity index 92% rename from docs/.vitepress/config/es.ts rename to docs/es/config.ts index d30fc89f89dc..099edba64b68 100644 --- a/docs/.vitepress/config/es.ts +++ b/docs/es/config.ts @@ -1,16 +1,18 @@ import { createRequire } from 'module' -import { defineConfig, type DefaultTheme } from 'vitepress' +import { defineAdditionalConfig, type DefaultTheme } from 'vitepress' const require = createRequire(import.meta.url) const pkg = require('vitepress/package.json') -export const es = defineConfig({ +export default defineAdditionalConfig({ lang: 'es-CO', description: 'Generador de Sitios Estaticos desarrollado con Vite y Vue.', themeConfig: { nav: nav(), + search: { options: searchOptions() }, + sidebar: { '/es/guide/': { base: '/es/guide/', items: sidebarGuide() }, '/es/reference/': { base: '/es/reference/', items: sidebarReference() } @@ -36,11 +38,15 @@ export const es = defineConfig({ }, lastUpdated: { - text: 'Actualizado en', - formatOptions: { - dateStyle: 'short', - timeStyle: 'medium' - } + text: 'Actualizado en' + }, + + notFound: { + title: 'PÁGINA NO ENCONTRADA', + quote: + 'Pero si no cambias de dirección y sigues buscando, podrías terminar donde te diriges.', + linkLabel: 'ir a inicio', + linkText: 'Llévame a casa' }, langMenuLabel: 'Cambiar Idioma', @@ -170,8 +176,8 @@ function sidebarReference(): DefaultTheme.SidebarItem[] { ] } -export const search: DefaultTheme.AlgoliaSearchOptions['locales'] = { - es: { +function searchOptions(): Partial { + return { placeholder: 'Buscar documentos', translations: { button: { diff --git a/docs/es/index.md b/docs/es/index.md index 04a4c68fd42c..24b5034cf485 100644 --- a/docs/es/index.md +++ b/docs/es/index.md @@ -1,9 +1,6 @@ --- layout: home -title: VitePress -titleTemplate: Generador de Sitios Estáticos desarrollado con Vite y Vue - hero: name: VitePress text: Generador de Sitios Estáticos Vite y Vue diff --git a/docs/.vitepress/config/fa.ts b/docs/fa/config.ts similarity index 86% rename from docs/.vitepress/config/fa.ts rename to docs/fa/config.ts index 5c91d0bc720a..d4698db07f0d 100644 --- a/docs/.vitepress/config/fa.ts +++ b/docs/fa/config.ts @@ -1,25 +1,19 @@ import { createRequire } from 'module' -import { defineConfig, type DefaultTheme } from 'vitepress' +import { defineAdditionalConfig, type DefaultTheme } from 'vitepress' const require = createRequire(import.meta.url) const pkg = require('vitepress/package.json') -export const fa = defineConfig({ - title: 'ویت‌پرس', +export default defineAdditionalConfig({ lang: 'fa-IR', - description: 'Vite & Vue powered static site generator.', + description: 'ژنراتور استاتیک وب‌سایت با Vite و Vue', dir: 'rtl', - markdown: { - container: { - tipLabel: 'نکته', - warningLabel: 'هشدار', - dangerLabel: 'خطر', - infoLabel: 'اطلاعات', - detailsLabel: 'جزئیات' - } - }, + themeConfig: { nav: nav(), + + search: { options: searchOptions() }, + sidebar: { '/fa/guide/': { base: '/fa/guide/', items: sidebarGuide() }, '/fa/reference/': { base: '/fa/reference/', items: sidebarReference() } @@ -45,11 +39,15 @@ export const fa = defineConfig({ }, lastUpdated: { - text: 'آخرین به‌روزرسانی‌', - formatOptions: { - dateStyle: 'short', - timeStyle: 'medium' - } + text: 'آخرین به‌روزرسانی‌' + }, + + notFound: { + title: 'صفحه پیدا نشد', + quote: + 'اما اگر جهت خود را تغییر ندهید و همچنان به جستجو ادامه دهید، ممکن است در نهایت به جایی برسید که در حال رفتن به آن هستید.', + linkLabel: 'برو به خانه', + linkText: 'من را به خانه ببر' }, langMenuLabel: 'تغییر زبان', @@ -58,14 +56,6 @@ export const fa = defineConfig({ darkModeSwitchLabel: 'تم تاریک', lightModeSwitchTitle: 'رفتن به حالت روشن', darkModeSwitchTitle: 'رفتن به حالت تاریک', - notFound: { - linkLabel: 'بازگشت به خانه', - linkText: 'بازگشت به خانه', - title: 'صفحه مورد نظر یافت نشد', - code: '۴۰۴', - quote: - 'اما اگر جهت خود را تغییر ندهید و اگر ادامه دهید به دنبال چیزی که دنبال می‌کنید، ممکن است در نهایت به جایی که در حال رفتن به سمتش هستید، برسید.' - }, siteTitle: 'ویت‌پرس' } }) @@ -181,8 +171,8 @@ function sidebarReference(): DefaultTheme.SidebarItem[] { ] } -export const search: DefaultTheme.AlgoliaSearchOptions['locales'] = { - fa: { +function searchOptions(): Partial { + return { placeholder: 'جستجوی مستندات', translations: { button: { diff --git a/docs/fa/index.md b/docs/fa/index.md index cd6552baedb0..8fa79bd31148 100644 --- a/docs/fa/index.md +++ b/docs/fa/index.md @@ -1,9 +1,6 @@ --- layout: home -title: ویت‌پرس -titleTemplate: Vite & Vue Powered Static Site Generator - hero: name: ویت‌پرس text: سازنده سایت‌های ایستا به کمک Vite و Vue diff --git a/docs/.vitepress/config/ko.ts b/docs/ko/config.ts similarity index 92% rename from docs/.vitepress/config/ko.ts rename to docs/ko/config.ts index faebabf44e56..78b5c3f3d416 100644 --- a/docs/.vitepress/config/ko.ts +++ b/docs/ko/config.ts @@ -1,16 +1,18 @@ import { createRequire } from 'module' -import { defineConfig, type DefaultTheme } from 'vitepress' +import { defineAdditionalConfig, type DefaultTheme } from 'vitepress' const require = createRequire(import.meta.url) const pkg = require('vitepress/package.json') -export const ko = defineConfig({ +export default defineAdditionalConfig({ lang: 'ko-KR', description: 'Vite 및 Vue 기반 정적 사이트 생성기.', themeConfig: { nav: nav(), + search: { options: searchOptions() }, + sidebar: { '/ko/guide/': { base: '/ko/guide/', items: sidebarGuide() }, '/ko/reference/': { base: '/ko/reference/', items: sidebarReference() } @@ -39,6 +41,14 @@ export const ko = defineConfig({ text: '업데이트 날짜' }, + notFound: { + title: '페이지를 찾을 수 없습니다', + quote: + '방향을 바꾸지 않고 계속 찾다 보면 결국 당신이 가고 있는 곳에 도달할 수도 있습니다.', + linkLabel: '홈으로 가기', + linkText: '집으로 데려가줘' + }, + langMenuLabel: '언어 변경', returnToTopLabel: '맨 위로 돌아가기', sidebarMenuLabel: '사이드바 메뉴', @@ -208,8 +218,8 @@ function sidebarReference(): DefaultTheme.SidebarItem[] { ] } -export const search: DefaultTheme.AlgoliaSearchOptions['locales'] = { - ko: { +function searchOptions(): Partial { + return { placeholder: '문서 검색', translations: { button: { diff --git a/docs/ko/index.md b/docs/ko/index.md index 3ddaa07dca62..ae7df4c8f271 100644 --- a/docs/ko/index.md +++ b/docs/ko/index.md @@ -1,9 +1,6 @@ --- layout: home -title: VitePress -titleTemplate: Vite & Vue 기반 정적 사이트 생성기 - hero: name: VitePress text: Vite & Vue 기반 정적 사이트 생성기 diff --git a/docs/lunaria.config.json b/docs/lunaria.config.json index 60abdd98a436..4f93f4dca566 100644 --- a/docs/lunaria.config.json +++ b/docs/lunaria.config.json @@ -6,8 +6,8 @@ }, "files": [ { - "location": ".vitepress/config/{en,zh,pt,ru,es,ko,fa}.ts", - "pattern": ".vitepress/config/@lang.ts", + "location": "**/config.ts", + "pattern": "@lang/@path", "type": "universal" }, { diff --git a/docs/.vitepress/config/pt.ts b/docs/pt/config.ts similarity index 92% rename from docs/.vitepress/config/pt.ts rename to docs/pt/config.ts index 12cb52fae539..4926e697ffa0 100644 --- a/docs/.vitepress/config/pt.ts +++ b/docs/pt/config.ts @@ -1,16 +1,18 @@ import { createRequire } from 'module' -import { defineConfig, type DefaultTheme } from 'vitepress' +import { defineAdditionalConfig, type DefaultTheme } from 'vitepress' const require = createRequire(import.meta.url) const pkg = require('vitepress/package.json') -export const pt = defineConfig({ +export default defineAdditionalConfig({ lang: 'pt-BR', description: 'Gerador de Site Estático desenvolvido com Vite e Vue.', themeConfig: { nav: nav(), + search: { options: searchOptions() }, + sidebar: { '/pt/guide/': { base: '/pt/guide/', items: sidebarGuide() }, '/pt/reference/': { base: '/pt/reference/', items: sidebarReference() } @@ -36,11 +38,15 @@ export const pt = defineConfig({ }, lastUpdated: { - text: 'Atualizado em', - formatOptions: { - dateStyle: 'short', - timeStyle: 'medium' - } + text: 'Atualizado em' + }, + + notFound: { + title: 'PÁGINA NÃO ENCONTRADA', + quote: + 'Mas se você não mudar de direção e continuar procurando, pode acabar onde está indo.', + linkLabel: 'ir para a página inicial', + linkText: 'Me leve para casa' }, langMenuLabel: 'Alterar Idioma', @@ -167,8 +173,8 @@ function sidebarReference(): DefaultTheme.SidebarItem[] { ] } -export const search: DefaultTheme.AlgoliaSearchOptions['locales'] = { - pt: { +function searchOptions(): Partial { + return { placeholder: 'Pesquisar documentos', translations: { button: { diff --git a/docs/pt/index.md b/docs/pt/index.md index 71fff26b7cf1..67cab365b054 100644 --- a/docs/pt/index.md +++ b/docs/pt/index.md @@ -1,9 +1,6 @@ --- layout: home -title: VitePress -titleTemplate: Gerador de Site Estático desenvolvido com Vite & Vue - hero: name: VitePress text: Gerador de Site Estático Vite & Vue diff --git a/docs/.vitepress/config/ru.ts b/docs/ru/config.ts similarity index 92% rename from docs/.vitepress/config/ru.ts rename to docs/ru/config.ts index b75b1b77ee74..739cfbf2ae26 100644 --- a/docs/.vitepress/config/ru.ts +++ b/docs/ru/config.ts @@ -1,16 +1,18 @@ import { createRequire } from 'module' -import { defineConfig, type DefaultTheme } from 'vitepress' +import { defineAdditionalConfig, type DefaultTheme } from 'vitepress' const require = createRequire(import.meta.url) const pkg = require('vitepress/package.json') -export const ru = defineConfig({ +export default defineAdditionalConfig({ lang: 'ru-RU', description: 'Генератор статических сайтов на основе Vite и Vue.', themeConfig: { nav: nav(), + search: { options: searchOptions() }, + sidebar: { '/ru/guide/': { base: '/ru/guide/', items: sidebarGuide() }, '/ru/reference/': { base: '/ru/reference/', items: sidebarReference() } @@ -37,6 +39,14 @@ export const ru = defineConfig({ text: 'Обновлено' }, + notFound: { + title: 'СТРАНИЦА НЕ НАЙДЕНА', + quote: + 'Но если ты не изменишь направление и продолжишь искать, ты можешь оказаться там, куда направляешься.', + linkLabel: 'перейти на главную', + linkText: 'Отведи меня домой' + }, + darkModeSwitchLabel: 'Оформление', lightModeSwitchTitle: 'Переключить на светлую тему', darkModeSwitchTitle: 'Переключить на тёмную тему', @@ -163,8 +173,8 @@ function sidebarReference(): DefaultTheme.SidebarItem[] { ] } -export const search: DefaultTheme.AlgoliaSearchOptions['locales'] = { - ru: { +function searchOptions(): Partial { + return { placeholder: 'Поиск в документации', translations: { button: { diff --git a/docs/ru/index.md b/docs/ru/index.md index 77f2072a0135..e5bc7732741c 100644 --- a/docs/ru/index.md +++ b/docs/ru/index.md @@ -1,9 +1,6 @@ --- layout: home -title: VitePress -titleTemplate: Генератор статических сайтов на основе Vite и Vue - hero: name: VitePress text: Генератор статических сайтов на основе Vite и Vue diff --git a/docs/.vitepress/config/zh.ts b/docs/zh/config.ts similarity index 91% rename from docs/.vitepress/config/zh.ts rename to docs/zh/config.ts index e9d5fbcd4f72..22fb8b95121f 100644 --- a/docs/.vitepress/config/zh.ts +++ b/docs/zh/config.ts @@ -1,16 +1,18 @@ import { createRequire } from 'module' -import { defineConfig, type DefaultTheme } from 'vitepress' +import { defineAdditionalConfig, type DefaultTheme } from 'vitepress' const require = createRequire(import.meta.url) const pkg = require('vitepress/package.json') -export const zh = defineConfig({ +export default defineAdditionalConfig({ lang: 'zh-Hans', description: '由 Vite 和 Vue 驱动的静态站点生成器', themeConfig: { nav: nav(), + search: { options: searchOptions() }, + sidebar: { '/zh/guide/': { base: '/zh/guide/', items: sidebarGuide() }, '/zh/reference/': { base: '/zh/reference/', items: sidebarReference() } @@ -36,11 +38,15 @@ export const zh = defineConfig({ }, lastUpdated: { - text: '最后更新于', - formatOptions: { - dateStyle: 'short', - timeStyle: 'medium' - } + text: '最后更新于' + }, + + notFound: { + title: '页面未找到', + quote: + '但如果你不改变方向,并且继续寻找,你可能最终会到达你所前往的地方。', + linkLabel: '前往首页', + linkText: '带我回首页' }, langMenuLabel: '多语言', @@ -160,8 +166,8 @@ function sidebarReference(): DefaultTheme.SidebarItem[] { ] } -export const search: DefaultTheme.AlgoliaSearchOptions['locales'] = { - zh: { +function searchOptions(): Partial { + return { placeholder: '搜索文档', translations: { button: { diff --git a/docs/zh/index.md b/docs/zh/index.md index e07f9136b412..7beeb9988e50 100644 --- a/docs/zh/index.md +++ b/docs/zh/index.md @@ -1,9 +1,6 @@ --- layout: home -title: VitePress -titleTemplate: 由 Vite 和 Vue 驱动的静态站点生成器 - hero: name: VitePress text: 由 Vite 和 Vue 驱动的静态站点生成器 diff --git a/src/node/build/render.ts b/src/node/build/render.ts index 2a2f22862c6e..2b616b2a60b6 100644 --- a/src/node/build/render.ts +++ b/src/node/build/render.ts @@ -33,7 +33,7 @@ export async function renderPage( usedIcons: Set ) { const routePath = `/${page.replace(/\.md$/, '')}` - const siteData = resolveSiteDataByRoute(config.site, routePath) + const siteData = resolveSiteDataByRoute(config.site, page) // render page const context = await render(routePath) diff --git a/src/node/config.ts b/src/node/config.ts index f5e0bb3985b7..959e39bd761e 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -2,6 +2,7 @@ import _debug from 'debug' import fs from 'fs-extra' import path from 'node:path' import c from 'picocolors' +import { glob } from 'tinyglobby' import { createLogger, loadConfigFromFile, @@ -12,10 +13,20 @@ import { import { DEFAULT_THEME_PATH } from './alias' import type { DefaultTheme } from './defaultTheme' import { resolvePages } from './plugins/dynamicRoutesPlugin' -import { APPEARANCE_KEY, slash, type HeadConfig, type SiteData } from './shared' +import { + APPEARANCE_KEY, + VP_SOURCE_KEY, + isObject, + slash, + type AdditionalConfig, + type Awaitable, + type HeadConfig, + type SiteData +} from './shared' import type { RawConfigExports, SiteConfig, UserConfig } from './siteConfig' export { resolvePages } from './plugins/dynamicRoutesPlugin' +export { resolveSiteDataByRoute } from './shared' export * from './siteConfig' const debug = _debug('vitepress:config') @@ -25,21 +36,40 @@ const resolve = (root: string, file: string) => export type UserConfigFn = ( env: ConfigEnv -) => UserConfig | Promise> +) => Awaitable> export type UserConfigExport = - | UserConfig - | Promise> + | Awaitable> | UserConfigFn /** * Type config helper */ -export function defineConfig(config: UserConfig) { +export function defineConfig( + config: UserConfig> +) { + return config +} + +export type AdditionalConfigFn = ( + env: ConfigEnv +) => Awaitable> +export type AdditionalConfigExport = + | Awaitable> + | AdditionalConfigFn + +/** + * Type config helper for additional/locale-specific config + */ +export function defineAdditionalConfig( + config: AdditionalConfig> +) { return config } /** * Type config helper for custom theme config + * + * @deprecated use `defineConfig` instead */ export function defineConfigWithTheme( config: UserConfig @@ -141,6 +171,62 @@ export async function resolveConfig( } const supportedConfigExtensions = ['js', 'ts', 'mjs', 'mts'] +const additionalConfigRE = /(?:^|\/|\\)config\.m?[jt]s$/ +const additionalConfigGlob = `**/config.{js,mjs,ts,mts}` + +export function isAdditionalConfigFile(path: string) { + return additionalConfigRE.test(path) +} + +async function gatherAdditionalConfig( + root: string, + command: 'serve' | 'build', + mode: string, + srcDir: string = '.', + srcExclude: string[] = [] +) { + // + + const candidates = await glob(additionalConfigGlob, { + cwd: path.resolve(root, srcDir), + dot: false, // conveniently ignores .vitepress/* + ignore: ['**/node_modules/**', ...srcExclude], + expandDirectories: false + }) + + const deps: string[][] = [] + + const exports = await Promise.all( + candidates.map(async (file) => { + const id = normalizePath(`/${path.dirname(file)}/`) + + const configExports = await loadConfigFromFile( + { command, mode }, + normalizePath(path.resolve(root, srcDir, file)), + root + ).catch(console.error) // Skip additionalConfig file if it fails to load + + if (!configExports) { + debug(`Failed to load additional config from ${file}`) + return + } + + deps.push( + configExports.dependencies.map((file) => + normalizePath(path.resolve(file)) + ) + ) + + if (mode === 'development') { + ;(configExports.config as any)[VP_SOURCE_KEY] = '/' + slash(file) + } + + return [id, configExports.config as AdditionalConfig] as const + }) + ) + + return [Object.fromEntries(exports.filter((e) => e != null)), deps] as const +} export async function resolveUserConfig( root: string, @@ -170,6 +256,18 @@ 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.srcDir, + userConfig.srcExclude + ) + userConfig.additionalConfig = additionalConfig + configDeps = configDeps.concat(...additionalDeps) + } } debug(`loaded config at ${c.yellow(configPath)}`) } @@ -213,10 +311,6 @@ export function mergeConfig(a: UserConfig, b: UserConfig, isRoot = true) { 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, @@ -241,7 +335,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 748c4da48045..f6c3d3f98ea0 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 49451cbeb0de..82ab3162e6a8 100644 --- a/src/node/siteConfig.ts +++ b/src/node/siteConfig.ts @@ -14,6 +14,10 @@ import type { SSGContext, SiteData } from './shared' +import type { + AdditionalConfigDict, + AdditionalConfigLoader +} from '../../types/shared' export type RawConfigExports = | Awaitable> @@ -187,6 +191,18 @@ export interface UserConfig pageData: PageData, ctx: TransformPageContext ) => Awaitable | { [key: string]: any } | void> + + /** + * Multi-layer configuration overloading. + * Auto-resolves to `docs/.../config.{js,mjs,ts,mts}` when unspecified. + * + * Set to `{}` to opt-out. + * + * @experimental + */ + additionalConfig?: + | AdditionalConfigDict + | AdditionalConfigLoader } export interface SiteConfig diff --git a/src/shared/shared.ts b/src/shared/shared.ts index 0dd35db48376..3caca9a92189 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -1,4 +1,9 @@ -import type { HeadConfig, PageData, SiteData } from '../../types/shared' +import type { + AdditionalConfig, + HeadConfig, + PageData, + SiteData +} from '../../types/shared' export type { Awaitable, @@ -11,12 +16,18 @@ export type { PageData, PageDataPayload, SiteData, - SSGContext + SSGContext, + AdditionalConfig, + AdditionalConfigDict, + AdditionalConfigLoader } from '../../types/shared' export const EXTERNAL_URL_RE = /^(?:[a-z]+:|\/\/)/i export const APPEARANCE_KEY = 'vitepress-theme-appearance' +export const VP_SOURCE_KEY = '[VP_SOURCE]' +const UnpackStackView = Symbol('stack-view:unpack') + const HASH_RE = /#.*$/ const HASH_OR_QUERY_RE = /[?#].*$/ const INDEX_OR_EXT_RE = /(?:(^|\/)index)?\.(?:md|html)$/ @@ -81,7 +92,7 @@ export function getLocaleForPath( (key) => key !== 'root' && !isExternal(key) && - isActive(relativePath, `/${key}/`, true) + isActive(relativePath, `^/${key}/`, true) ) || 'root' ) } @@ -94,22 +105,34 @@ export function resolveSiteDataByRoute( relativePath: string ): SiteData { const localeIndex = getLocaleForPath(siteData, relativePath) + const { label, link, ...localeConfig } = siteData.locales[localeIndex] ?? {} + Object.assign(localeConfig, { localeIndex }) + + const additionalConfigs = resolveAdditionalConfig(siteData, relativePath) + + if (inBrowser && (import.meta as any).env?.DEV) { + ;(localeConfig as any)[VP_SOURCE_KEY] = `locale config (${localeIndex})` + reportConfigLayers(relativePath, [ + ...additionalConfigs, + localeConfig, + siteData + ]) + } - return Object.assign({}, siteData, { - localeIndex, - lang: siteData.locales[localeIndex]?.lang ?? siteData.lang, - dir: siteData.locales[localeIndex]?.dir ?? siteData.dir, - title: siteData.locales[localeIndex]?.title ?? siteData.title, - titleTemplate: - siteData.locales[localeIndex]?.titleTemplate ?? siteData.titleTemplate, - description: - siteData.locales[localeIndex]?.description ?? siteData.description, - head: mergeHead(siteData.head, siteData.locales[localeIndex]?.head ?? []), - themeConfig: { - ...siteData.themeConfig, - ...siteData.locales[localeIndex]?.themeConfig - } - }) + const topLayer = { + head: mergeHead( + siteData.head ?? [], + localeConfig.head ?? [], + ...additionalConfigs.map((data) => data.head ?? []).reverse() + ) + } as SiteData + + return stackView( + topLayer, + ...additionalConfigs, + localeConfig, + siteData + ) } /** @@ -151,18 +174,33 @@ function createTitleTemplate( return ` | ${template}` } -function hasTag(head: HeadConfig[], tag: HeadConfig) { - const [tagType, tagAttrs] = tag - if (tagType !== 'meta') return false - const keyAttr = Object.entries(tagAttrs)[0] // First key - if (keyAttr == null) return false - return head.some( - ([type, attrs]) => type === tagType && attrs[keyAttr[0]] === keyAttr[1] - ) -} +export function mergeHead(...headArrays: HeadConfig[][]): HeadConfig[] { + const merged: HeadConfig[] = [] + const metaKeyMap = new Map() + + for (const current of headArrays) { + for (const tag of current) { + const [type, attrs] = tag + const keyAttr = Object.entries(attrs)[0] + + if (type !== 'meta' || !keyAttr) { + merged.push(tag) + continue + } + + const key = `${keyAttr[0]}=${keyAttr[1]}` + const existingIndex = metaKeyMap.get(key) + + if (existingIndex != null) { + merged[existingIndex] = tag // replace existing tag + } else { + metaKeyMap.set(key, merged.length) + merged.push(tag) + } + } + } -export function mergeHead(prev: HeadConfig[], curr: HeadConfig[]) { - return [...prev.filter((tagAttrs) => !hasTag(curr, tagAttrs)), ...curr] + return merged } // https://github.com/rollup/rollup/blob/fec513270c6ac350072425cc045db367656c623b/src/utils/sanitizeFileName.ts @@ -230,3 +268,87 @@ export function escapeHtml(str: string): string { .replace(/"/g, '"') .replace(/&(?![\w#]+;)/g, '&') } + +function resolveAdditionalConfig( + { additionalConfig }: SiteData, + path: string +): AdditionalConfig[] { + if (additionalConfig === undefined) return [] + if (typeof additionalConfig === 'function') return additionalConfig(path) + + const configs: AdditionalConfig[] = [] + const segments = path.split('/').slice(0, -1) // remove file name + + while (segments.length) { + const key = `/${segments.join('/')}/` + configs.push(additionalConfig[key]) + segments.pop() + } + + configs.push(additionalConfig['/']) + return configs.filter((config) => config !== undefined) +} + +// This helps users to understand which configuration files are active +function reportConfigLayers(path: string, layers: Partial[]) { + 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_KEY] ?? '(Unknown Source)'}` + }) + + console.debug( + [summaryTitle, ''.padEnd(summaryTitle.length, '='), ...summary].join('\n') + ) +} + +/** + * Creates a deep, merged view of multiple objects without mutating originals. + * Returns a readonly proxy behaving like a merged object of the input objects. + * Layers are merged in descending precedence, i.e. earlier layer is on top. + */ +export function stackView(..._layers: Partial[]): T { + const layers = _layers.filter((layer) => isObject(layer)) + if (layers.length <= 1) return _layers[0] as T + + const allKeys = new Set(layers.flatMap((layer) => Reflect.ownKeys(layer))) + const allKeysArray = [...allKeys] + + return new Proxy({} as T, { + // TODO: optimize for performance, this is a hot path + get(_, prop) { + if (prop === UnpackStackView) return layers + return stackView( + ...layers + .map((layer) => layer[prop]) + .filter((v): v is NonNullable => v !== undefined) + ) + }, + set() { + throw new Error('StackView is read-only and cannot be mutated.') + }, + has(_, prop) { + return allKeys.has(prop) + }, + ownKeys() { + return allKeysArray + }, + getOwnPropertyDescriptor(_, prop) { + for (const layer of layers) { + const descriptor = Object.getOwnPropertyDescriptor(layer, prop) + if (descriptor) return descriptor + } + } + }) +} + +stackView.unpack = function (obj: T): T[] | undefined { + return (obj as any)?.[UnpackStackView] +} + +type ObjectType = Record +export function isObject(value: unknown): value is ObjectType { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/types/shared.d.ts b/types/shared.d.ts index 1ebfa9b98d39..445bb82ee733 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -5,6 +5,19 @@ export type { DefaultTheme } from './default-theme.js' export type Awaitable = T | PromiseLike +type DeepPartial = + T extends Record + ? T extends + | Date + | RegExp + | Function + | ReadonlyMap + | ReadonlySet + | ReadonlyArray + ? T + : { [P in keyof T]?: DeepPartial } + : T + export interface PageData { relativePath: string /** @@ -134,6 +147,9 @@ export interface SiteData { router: { prefetchLinks: boolean } + additionalConfig?: + | AdditionalConfigDict + | AdditionalConfigLoader } export type HeadConfig = @@ -158,7 +174,7 @@ export interface LocaleSpecificConfig { titleTemplate?: string | boolean description?: string head?: HeadConfig[] - themeConfig?: ThemeConfig + themeConfig?: DeepPartial } export type LocaleConfig = Record< @@ -166,9 +182,20 @@ export type LocaleConfig = Record< LocaleSpecificConfig & { label: string; link?: string } > +export type AdditionalConfig = + LocaleSpecificConfig + +export type AdditionalConfigDict = Record< + string, + AdditionalConfig +> + +export type AdditionalConfigLoader = ( + relativePath: string +) => AdditionalConfig[] + // Manually declaring all properties as rollup-plugin-dts // is unable to merge augmented module declarations - export interface MarkdownEnv { /** * The raw Markdown content without frontmatter