feat: support distributed config files (#4660)

---------

Co-authored-by: Divyansh Singh <40380293+brc-dd@users.noreply.github.com>
pull/4696/head
Yuxuan Zhang 5 months ago committed by GitHub
parent 0b70397197
commit c5e2e4db81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

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

@ -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.',

@ -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

@ -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<DefaultTheme.AlgoliaSearchOptions> {
return {
placeholder: 'Buscar documentos',
translations: {
button: {

@ -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

@ -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<DefaultTheme.AlgoliaSearchOptions> {
return {
placeholder: 'جستجوی مستندات',
translations: {
button: {

@ -1,9 +1,6 @@
---
layout: home
title: ویت‌پرس
titleTemplate: Vite & Vue Powered Static Site Generator
hero:
name: ویت‌پرس
text: سازنده سایت‌های ایستا به کمک Vite و Vue

@ -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<DefaultTheme.AlgoliaSearchOptions> {
return {
placeholder: '문서 검색',
translations: {
button: {

@ -1,9 +1,6 @@
---
layout: home
title: VitePress
titleTemplate: Vite & Vue 기반 정적 사이트 생성기
hero:
name: VitePress
text: Vite & Vue 기반 정적 사이트 생성기

@ -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"
},
{

@ -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<DefaultTheme.AlgoliaSearchOptions> {
return {
placeholder: 'Pesquisar documentos',
translations: {
button: {

@ -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

@ -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<DefaultTheme.AlgoliaSearchOptions> {
return {
placeholder: 'Поиск в документации',
translations: {
button: {

@ -1,9 +1,6 @@
---
layout: home
title: VitePress
titleTemplate: Генератор статических сайтов на основе Vite и Vue
hero:
name: VitePress
text: Генератор статических сайтов на основе Vite и Vue

@ -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<DefaultTheme.AlgoliaSearchOptions> {
return {
placeholder: '搜索文档',
translations: {
button: {

@ -1,9 +1,6 @@
---
layout: home
title: VitePress
titleTemplate: 由 Vite 和 Vue 驱动的静态站点生成器
hero:
name: VitePress
text: 由 Vite 和 Vue 驱动的静态站点生成器

@ -33,7 +33,7 @@ export async function renderPage(
usedIcons: Set<string>
) {
const routePath = `/${page.replace(/\.md$/, '')}`
const siteData = resolveSiteDataByRoute(config.site, routePath)
const siteData = resolveSiteDataByRoute(config.site, page)
// render page
const context = await render(routePath)

@ -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<ThemeConfig> = (
env: ConfigEnv
) => UserConfig<ThemeConfig> | Promise<UserConfig<ThemeConfig>>
) => Awaitable<UserConfig<ThemeConfig>>
export type UserConfigExport<ThemeConfig> =
| UserConfig<ThemeConfig>
| Promise<UserConfig<ThemeConfig>>
| Awaitable<UserConfig<ThemeConfig>>
| UserConfigFn<ThemeConfig>
/**
* Type config helper
*/
export function defineConfig(config: UserConfig<DefaultTheme.Config>) {
export function defineConfig<ThemeConfig = DefaultTheme.Config>(
config: UserConfig<NoInfer<ThemeConfig>>
) {
return config
}
export type AdditionalConfigFn<ThemeConfig> = (
env: ConfigEnv
) => Awaitable<AdditionalConfig<ThemeConfig>>
export type AdditionalConfigExport<ThemeConfig> =
| Awaitable<AdditionalConfig<ThemeConfig>>
| AdditionalConfigFn<ThemeConfig>
/**
* Type config helper for additional/locale-specific config
*/
export function defineAdditionalConfig<ThemeConfig = DefaultTheme.Config>(
config: AdditionalConfig<NoInfer<ThemeConfig>>
) {
return config
}
/**
* Type config helper for custom theme config
*
* @deprecated use `defineConfig` instead
*/
export function defineConfigWithTheme<ThemeConfig>(
config: UserConfig<ThemeConfig>
@ -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<string, any> {
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
}
}

@ -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`

@ -14,6 +14,10 @@ import type {
SSGContext,
SiteData
} from './shared'
import type {
AdditionalConfigDict,
AdditionalConfigLoader
} from '../../types/shared'
export type RawConfigExports<ThemeConfig = any> =
| Awaitable<UserConfig<ThemeConfig>>
@ -187,6 +191,18 @@ export interface UserConfig<ThemeConfig = any>
pageData: PageData,
ctx: TransformPageContext
) => Awaitable<Partial<PageData> | { [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<ThemeConfig>
| AdditionalConfigLoader<ThemeConfig>
}
export interface SiteConfig<ThemeConfig = any>

@ -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<SiteData>(
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<string, number>()
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, '&quot;')
.replace(/&(?![\w#]+;)/g, '&amp;')
}
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<SiteData>[]) {
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<T extends ObjectType>(..._layers: Partial<T>[]): 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<T[string | symbol]> => 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 <T>(obj: T): T[] | undefined {
return (obj as any)?.[UnpackStackView]
}
type ObjectType = Record<PropertyKey, any>
export function isObject(value: unknown): value is ObjectType {
return Object.prototype.toString.call(value) === '[object Object]'
}

31
types/shared.d.ts vendored

@ -5,6 +5,19 @@ export type { DefaultTheme } from './default-theme.js'
export type Awaitable<T> = T | PromiseLike<T>
type DeepPartial<T> =
T extends Record<string, any>
? T extends
| Date
| RegExp
| Function
| ReadonlyMap<any, any>
| ReadonlySet<any>
| ReadonlyArray<any>
? T
: { [P in keyof T]?: DeepPartial<T[P]> }
: T
export interface PageData {
relativePath: string
/**
@ -134,6 +147,9 @@ export interface SiteData<ThemeConfig = any> {
router: {
prefetchLinks: boolean
}
additionalConfig?:
| AdditionalConfigDict<ThemeConfig>
| AdditionalConfigLoader<ThemeConfig>
}
export type HeadConfig =
@ -158,7 +174,7 @@ export interface LocaleSpecificConfig<ThemeConfig = any> {
titleTemplate?: string | boolean
description?: string
head?: HeadConfig[]
themeConfig?: ThemeConfig
themeConfig?: DeepPartial<ThemeConfig>
}
export type LocaleConfig<ThemeConfig = any> = Record<
@ -166,9 +182,20 @@ export type LocaleConfig<ThemeConfig = any> = Record<
LocaleSpecificConfig<ThemeConfig> & { label: string; link?: string }
>
export type AdditionalConfig<ThemeConfig = any> =
LocaleSpecificConfig<ThemeConfig>
export type AdditionalConfigDict<ThemeConfig = any> = Record<
string,
AdditionalConfig<ThemeConfig>
>
export type AdditionalConfigLoader<ThemeConfig = any> = (
relativePath: string
) => AdditionalConfig<ThemeConfig>[]
// Manually declaring all properties as rollup-plugin-dts
// is unable to merge augmented module declarations
export interface MarkdownEnv {
/**
* The raw Markdown content without frontmatter

Loading…
Cancel
Save