feat: build concurrency + ssr batching

pull/5349/head
Calum H. (IMB11) 13 hours ago
parent fe6c1ff2e9
commit 60add88c16

@ -1,5 +1,10 @@
import path from 'node:path'
import { defineConfig, type DefaultTheme } from 'vitepress'
let renderCapturedMarkdown: (() => Promise<string>) | undefined
const batchHeadHookPages = new Set<string>()
let batchHeadHookSequence = 0
const nav: DefaultTheme.Config['nav'] = [
{
text: 'Home',
@ -154,8 +159,15 @@ const sidebar: DefaultTheme.Config['sidebar'] = {
export default defineConfig({
title: 'Example',
description: 'An example app using VitePress.',
ssrBuildBatchSize: process.env.VITE_TEST_SSR_BATCH ? 10 : undefined,
ssrBuildWorkerConcurrency: process.env.VITE_TEST_SSR_BATCH ? 2 : undefined,
markdown: {
image: { lazyLoad: true }
shikiCacheKey: 'user-configured-shiki-cache-key',
image: { lazyLoad: true },
config(md) {
renderCapturedMarkdown = () =>
md.renderAsync('```ts\nconst batch = true\n```')
}
},
themeConfig: {
nav,
@ -181,11 +193,110 @@ export default defineConfig({
}
},
vite: {
build: {
// Exercises the batch-only post-config guard that prevents every SSR
// worker from copying the public directory into disposable output.
copyPublicDir: true
},
plugins: [
{
name: 'test:ssr-batch-public-copy',
config() {
if (process.env.VITE_TEST_SSR_BATCH) {
return {
publicDir: 'batch-public',
resolve: {
alias: {
'/vitepress.png': path.resolve(
import.meta.dirname,
'../public/vitepress.png'
)
}
},
environments: {
ssr: { build: { copyPublicDir: true } }
}
}
}
},
configResolved(config) {
if (
process.env.VITE_TEST_SSR_BATCH &&
config.build.ssr &&
(config.build.copyPublicDir !== false ||
config.environments.ssr?.build.copyPublicDir !== false)
) {
throw new Error('SSR batch worker would copy the public directory')
}
}
}
],
server: {
watch: {
usePolling: true,
interval: 100
}
}
},
buildEnd(siteConfig) {
if (
process.env.VITE_TEST_SSR_BATCH &&
siteConfig.publicDir !== path.resolve(siteConfig.srcDir, 'batch-public')
) {
throw new Error('Resolved publicDir was not restored in the coordinator')
}
if (
process.env.VITE_TEST_SSR_BATCH &&
(!batchHeadHookPages.has('ssr-static.md') ||
!batchHeadHookPages.has('dynamic-routes/foo.md'))
) {
throw new Error(
'Coordinator-owned build hook state was not preserved across SSR workers'
)
}
},
transformHead(context) {
if (!process.env.VITE_TEST_SSR_BATCH) return
if (
context.siteConfig.markdown?.shikiCacheKey !==
'user-configured-shiki-cache-key'
) {
throw new Error(
'SSR batching exposed its internal Shiki cache key to render hooks'
)
}
batchHeadHookPages.add(context.page)
return [
[
'meta',
{
name: 'ssr-batch-hook-state',
content: `${++batchHeadHookSequence}:${context.pageData.relativePath}`
}
]
]
},
transformHtml(code, _id, context) {
if (!process.env.VITE_TEST_SSR_BATCH) return
if (!batchHeadHookPages.has(context.page)) {
throw new Error(
'transformHtml ran without coordinator transformHead state'
)
}
return code.replace(
'</body>',
`<span hidden data-ssr-batch-transform="${context.page}"></span>\n </body>`
)
},
async postRender(context) {
if (process.env.VITE_TEST_SSR_BATCH) {
if (!renderCapturedMarkdown) {
throw new Error('Markdown renderer was not captured during SSR setup')
}
await renderCapturedMarkdown()
}
return context
}
})

@ -0,0 +1 @@
copied once by the client build

@ -3,6 +3,7 @@ describe('dynamic routes', () => {
await goto('/dynamic-routes/foo')
expect(await page.textContent('h1')).toMatch('Foo')
expect(await page.textContent('pre.params')).toMatch('"id": "foo"')
expect(await page.title()).toBe('Foo - transformed | Example')
await goto('/dynamic-routes/bar')
expect(await page.textContent('h1')).toMatch('Bar')

@ -83,6 +83,29 @@ describe('local search', () => {
).toBe(0)
})
test.runIf(process.env.VITE_TEST_SSR_BATCH)(
'indexes static-page HTML produced by the artifact pipeline',
async () => {
await page.locator('.VPNavBarSearchButton').click()
const input = await page.waitForSelector('input#localsearch-input')
await input.type('Static HTML marker')
await page.waitForFunction(() =>
[
...document.querySelectorAll('#localsearch-list li[role=option]')
].some((option) => option.textContent?.includes('Static batching page'))
)
expect(
await page
.locator('#localsearch-list li[role=option]')
.filter({ hasText: 'Static batching page' })
.count()
).toBeGreaterThan(0)
}
)
test('uses the same desktop breakpoint as the nav bar', async () => {
try {
for (const { width, isDesktop } of [

@ -80,6 +80,9 @@ test('resolved config-file hooks preserve legacy physical Markdown SSR semantics
expect(html).toContain(
'<p data-resolved-transform-mode="server">environment-sensitive Markdown transform</p>'
)
expect(html).toContain(
'<p data-resolved-plugin-context="build:false">production plugin context</p>'
)
expect(html).not.toContain('data-resolved-transform-mode="client"')
})

@ -0,0 +1,14 @@
---
title: Scoped batching page
description: A page that must preserve its physical Markdown module identity.
---
# Scoped batching page
<div class="scoped-batch-marker">Scoped module identity</div>
<style scoped>
.scoped-batch-marker {
color: rgb(1, 2, 3);
}
</style>

@ -0,0 +1,18 @@
---
title: Static batching page
description: A page eligible for the direct static SSR path.
---
# Static batching page
This content is rendered without evaluating a per-page SSR module.
<p data-static-batch-marker="preserved">Static HTML marker</p>
## Static presentational markup
<span class="VPBadge warning">static badge</span>
<!-- static comment preserved -->
<img data-static-public-asset src="/batch-public.txt" alt="Static public asset">

@ -38,7 +38,8 @@ export default defineConfig({
filter: { id: artifactSafetyPageRE },
handler(code, _id, options) {
const mode = options?.ssr ? 'server' : 'client'
return `${code}\n<p data-resolved-transform-mode="${mode}">environment-sensitive Markdown transform</p>`
const pluginContext = `${this.environment.mode}:${this.meta.watchMode}`
return `${code}\n<p data-resolved-transform-mode="${mode}">environment-sensitive Markdown transform</p>\n<p data-resolved-plugin-context="${pluginContext}">production plugin context</p>`
}
}
}

@ -21,7 +21,28 @@ export async function setup() {
process.env['PORT'] = port.toString()
if (process.env['VITE_TEST_BUILD']) {
await build(root)
if (process.env.VITE_TEST_SSR_BATCH) {
let afterConfigResolveCalls = 0
await build(root, {
onAfterConfigResolve(siteConfig) {
afterConfigResolveCalls++
siteConfig.site.head.push([
'meta',
{
name: 'ssr-batch-after-config-resolve',
content: 'coordinator mutation retained'
}
])
}
})
if (afterConfigResolveCalls !== 1) {
throw new Error(
`Expected one coordinator config hook call, received ${afterConfigResolveCalls}`
)
}
} else {
await build(root)
}
server = (await serve({ root, port })).server
} else {
server = await createServer(root, { port })
@ -30,7 +51,8 @@ export async function setup() {
}
export async function teardown() {
await browserServer.close()
await browserServer?.close()
if (!server) return
if ('ws' in server) {
await server.close()
} else {

@ -0,0 +1,437 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import {
captureClientAssetUrls,
collectSsrRuntimeBridges,
createSsrRuntimeBridgePlugin,
createSsrRuntimeInput
} from 'node/build/bundle'
import type { SiteConfig } from 'node/config'
import {
build as viteBuild,
normalizePath,
type Plugin,
type ResolvedConfig,
type Rolldown
} from 'vite'
function assetCaptureTransform(assetMap: Record<string, string>) {
const plugin = captureClientAssetUrls(
{ site: { base: '/' } } as SiteConfig,
assetMap
)
const transform = plugin.transform as {
handler(code: string, id: string): void
}
return transform.handler
}
test('captures inlined assets without treating raw or arbitrary root strings as URLs', () => {
const assetMap: Record<string, string> = Object.create(null)
const transform = assetCaptureTransform(assetMap)
transform('export default "data:image/png;base64,cGl4ZWw="', '/logo.png')
transform('export default "data:not-an-asset"', '/message.txt?raw')
transform('export default "/arbitrary-string"', '/message.txt?custom')
expect(assetMap['/logo.png']).toBe('data:image/png;base64,cGl4ZWw=')
expect(assetMap['/message.txt?raw']).toBeUndefined()
expect(assetMap['/message.txt?custom']).toBeUndefined()
})
test('rejects runtime renderBuiltUrl expressions for batched SSR assets', () => {
const assetMap: Record<string, string> = Object.create(null)
const plugin = captureClientAssetUrls(
{ site: { base: '/' } } as SiteConfig,
assetMap
)
const configResolved = plugin.configResolved as (
config: ResolvedConfig
) => void
configResolved({
experimental: {
renderBuiltUrl() {
return { runtime: 'globalThis.__assetUrl' }
}
}
} as ResolvedConfig)
const transform = plugin.transform as {
handler(code: string, id: string): void
}
const assetId = '/logo.svg?url'
transform.handler('export default "__VITE_ASSET__logo__"', assetId)
const generateBundle = plugin.generateBundle as (
this: Rolldown.PluginContext,
options: Rolldown.NormalizedOutputOptions,
bundle: Rolldown.OutputBundle
) => void
expect(() =>
generateBundle.call(
{
getFileName() {
return 'assets/logo.svg'
}
} as unknown as Rolldown.PluginContext,
{} as Rolldown.NormalizedOutputOptions,
{
'page.js': {
type: 'chunk',
moduleIds: [assetId],
fileName: 'page.js'
}
} as Rolldown.OutputBundle
)
).toThrow(
'ssrBuildBatchSize cannot materialize the runtime renderBuiltUrl expression for assets/logo.svg. Return a URL string for SSR assets instead.'
)
})
function invokeModuleParsed(
plugin: Plugin,
moduleInfo: Pick<Rolldown.ModuleInfo, 'id' | 'isEntry'> &
Partial<Rolldown.ModuleInfo>,
emitFile: (file: Rolldown.EmittedFile) => string
) {
const handler = plugin.moduleParsed as (
this: Rolldown.PluginContext,
moduleInfo: Rolldown.ModuleInfo
) => void
handler.call(
{ emitFile } as unknown as Rolldown.PluginContext,
{
importers: [],
dynamicImporters: [],
importedIds: [],
dynamicallyImportedIds: [],
...moduleInfo
} as Rolldown.ModuleInfo
)
}
async function invokeBuildStart(plugin: Plugin, resolvedId: string) {
const handler = plugin.buildStart as (
this: Rolldown.PluginContext
) => Promise<void>
const resolve = vi.fn(
async () => ({ id: resolvedId, external: false }) as Rolldown.ResolvedId
)
await handler.call({
resolve,
error(message: string | Rolldown.RollupError): never {
throw new Error(typeof message === 'string' ? message : message.message)
}
} as unknown as Rolldown.PluginContext)
return resolve
}
test('declares only runtime roots instead of every file in a custom theme', () => {
const bridgeModuleIds = new Set<string>()
const input = createSsrRuntimeInput(
{
themeDir: path.join(process.cwd(), 'site/.vitepress/theme')
} as SiteConfig,
bridgeModuleIds
)
expect(input).toMatchObject({
app: expect.any(String),
vitepress: expect.any(String),
theme: expect.any(String),
'site-theme': '@theme/index'
})
expect(Object.keys(input)).toEqual([
'app',
'vitepress',
'theme',
'site-theme'
])
expect([...bridgeModuleIds].sort()).toEqual(
[normalizePath(input.vitepress), normalizePath(input.theme)].sort()
)
})
test('emits bounded facades for all site-local and virtual theme dependencies', async () => {
const themeDir = path.join(process.cwd(), 'site/.vitepress/theme')
const indexId = normalizePath(path.join(themeDir, 'index.ts'))
const componentId = normalizePath(
path.join(themeDir, 'components/Widget.vue')
)
const componentScriptId = `${componentId}?vue&type=script&lang.ts`
const componentStyleId = `${componentId}?vue&type=style&index=0&lang.css`
const sharedId = normalizePath(path.join(themeDir, '../../shared/state.ts'))
const virtualId = '\0test:theme-singleton'
const virtualAssetId = '\0test:theme-logo.svg'
const customAssetId = '\0test:custom-asset'
const dependencyId = normalizePath(
path.join(process.cwd(), 'node_modules/example/index.js')
)
const nativeId = 'node:crypto'
const bridgeModuleIds = new Set<string>()
const plugin = createSsrRuntimeBridgePlugin({ themeDir }, bridgeModuleIds)
const emitFile = vi.fn((_file: Rolldown.EmittedFile) => 'bridge')
const resolve = await invokeBuildStart(plugin, indexId)
// A source may be parsed first through another runtime entry. The final
// bridge set must not depend on Rolldown's traversal order.
invokeModuleParsed(plugin, { id: sharedId, isEntry: false }, emitFile)
invokeModuleParsed(plugin, { id: virtualId, isEntry: false }, emitFile)
invokeModuleParsed(
plugin,
{
id: indexId,
isEntry: true,
importedIds: [
componentId,
virtualId,
virtualAssetId,
customAssetId,
dependencyId,
nativeId
]
},
emitFile
)
invokeModuleParsed(
plugin,
{
id: componentId,
isEntry: false,
importedIds: [componentScriptId, componentStyleId]
},
emitFile
)
invokeModuleParsed(plugin, { id: componentId, isEntry: false }, emitFile)
invokeModuleParsed(
plugin,
{
id: componentScriptId,
isEntry: false,
importedIds: [sharedId]
},
emitFile
)
invokeModuleParsed(plugin, { id: componentStyleId, isEntry: false }, emitFile)
invokeModuleParsed(plugin, { id: virtualAssetId, isEntry: false }, emitFile)
invokeModuleParsed(
plugin,
{
id: customAssetId,
isEntry: false,
meta: { 'vite:asset': true }
},
emitFile
)
invokeModuleParsed(plugin, { id: dependencyId, isEntry: false }, emitFile)
invokeModuleParsed(plugin, { id: nativeId, isEntry: false }, emitFile)
invokeModuleParsed(
plugin,
{
id: normalizePath(path.join(themeDir, 'ambient.d.ts')),
isEntry: false
},
emitFile
)
invokeModuleParsed(
plugin,
{ id: `${componentId}?vue&type=style`, isEntry: false },
emitFile
)
invokeModuleParsed(
plugin,
{
id: normalizePath(path.join(themeDir, '../theme-story/Story.ts')),
isEntry: false
},
emitFile
)
expect(resolve).toHaveBeenCalledWith('@theme/index', undefined, {
isEntry: true
})
expect([...bridgeModuleIds].sort()).toEqual(
[indexId, componentId, sharedId, virtualId].sort()
)
expect(emitFile).toHaveBeenCalledTimes(3)
for (const id of [componentId, sharedId, virtualId]) {
expect(emitFile).toHaveBeenCalledWith({
type: 'chunk',
id,
name: expect.stringMatching(/^site-runtime-[a-f\d]{16}$/),
preserveSignature: 'strict'
})
}
})
test('runtime facades preserve local and virtual singleton identity', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'vitepress-runtime-bridge-'))
const themeDir = path.join(root, '.vitepress/theme')
const dependencyDir = path.join(root, 'node_modules/runtime-dependency')
const outDir = path.join(root, 'out')
const appId = path.join(root, 'app.js')
const themeId = path.join(themeDir, 'index.js')
const sharedId = path.join(root, 'shared.js')
const virtualId = '\0test:virtual-singleton'
try {
await Promise.all([
mkdir(themeDir, { recursive: true }),
mkdir(dependencyDir, { recursive: true })
])
await Promise.all([
writeFile(appId, `export * from '@theme/index'`),
writeFile(
themeId,
[
`export { localSingleton } from '../../shared.js'`,
`export { virtualSingleton } from 'virtual:singleton'`,
`export { dependencySingleton } from 'runtime-dependency'`,
`export { types as nativeTypes } from 'node:util'`
].join('\n')
),
writeFile(sharedId, `export const localSingleton = { local: true }`),
writeFile(
path.join(dependencyDir, 'package.json'),
JSON.stringify({ type: 'module', exports: './index.js' })
),
writeFile(
path.join(dependencyDir, 'index.js'),
`export const dependencySingleton = { dependency: true }`
)
])
const bridgeModuleIds = new Set<string>()
const result = (await viteBuild({
root,
configFile: false,
logLevel: 'silent',
resolve: {
alias: { '@theme/index': themeId }
},
plugins: [
{
name: 'test:virtual-singleton',
resolveId(id) {
if (id === 'virtual:singleton') return virtualId
},
load(id) {
if (id === virtualId) {
return `export const virtualSingleton = { virtual: true }`
}
}
},
createSsrRuntimeBridgePlugin({ themeDir }, bridgeModuleIds)
],
build: {
ssr: true,
outDir,
minify: false,
rolldownOptions: {
input: { app: appId, 'site-theme': '@theme/index' },
preserveEntrySignatures: 'strict',
output: {
entryFileNames: '[name].mjs',
chunkFileNames: 'chunks/[name]-[hash].mjs'
}
}
}
})) as Rolldown.RolldownOutput
const normalizedThemeId = [...bridgeModuleIds].find((id) =>
id.endsWith('/.vitepress/theme/index.js')
)
const normalizedSharedId = [...bridgeModuleIds].find((id) =>
id.endsWith('/shared.js')
)
expect(normalizedThemeId).toBeDefined()
expect(normalizedSharedId).toBeDefined()
expect([...bridgeModuleIds].sort()).toEqual(
[normalizedThemeId!, normalizedSharedId!, virtualId].sort()
)
const bridges = collectSsrRuntimeBridges(result, outDir, bridgeModuleIds)
const appChunk = result.output.find(
(output): output is Rolldown.OutputChunk =>
output.type === 'chunk' && output.isEntry && output.name === 'app'
)
expect(appChunk).toBeDefined()
const externalImports = result.output.flatMap((output) =>
output.type === 'chunk' ? output.imports : []
)
expect(externalImports).toContain('runtime-dependency')
expect(externalImports).toContain('node:util')
const runtime = await import(
pathToFileURL(path.resolve(outDir, appChunk!.fileName)).href
)
const localBridge = await import(
pathToFileURL(bridges[normalizedSharedId!]).href
)
const virtualBridge = await import(pathToFileURL(bridges[virtualId]).href)
expect(runtime.localSingleton).toBe(localBridge.localSingleton)
expect(runtime.virtualSingleton).toBe(virtualBridge.virtualSingleton)
expect(runtime.dependencySingleton.dependency).toBe(true)
expect(runtime.nativeTypes.isNativeError).toBeTypeOf('function')
} finally {
await rm(root, { recursive: true, force: true })
}
})
test('collects only recorded runtime facades and rejects missing ones', () => {
const outDir = path.join(process.cwd(), '.temp/runtime')
const vitepressId = normalizePath(path.join(process.cwd(), 'client/index.js'))
const themeId = normalizePath(path.join(process.cwd(), 'theme/index.ts'))
const unrelatedId = normalizePath(
path.join(process.cwd(), 'theme/Widget.story.ts')
)
const result = {
output: [
{
type: 'chunk',
isEntry: true,
name: 'vitepress',
facadeModuleId: vitepressId,
fileName: 'vitepress.js'
},
{
type: 'chunk',
isEntry: true,
name: 'site-theme',
facadeModuleId: themeId,
fileName: 'site-theme.js'
},
{
type: 'chunk',
isEntry: true,
name: 'unrelated',
facadeModuleId: unrelatedId,
fileName: 'unrelated.js'
}
]
} as unknown as Rolldown.RolldownOutput
const bridges = collectSsrRuntimeBridges(
result,
outDir,
new Set([vitepressId, themeId])
)
expect(bridges).toEqual({
[vitepressId]: path.resolve(outDir, 'vitepress.js'),
[themeId]: path.resolve(outDir, 'site-theme.js')
})
expect(bridges[unrelatedId]).toBeUndefined()
const missingId = normalizePath(path.join(process.cwd(), 'theme/missing.ts'))
expect(() =>
collectSsrRuntimeBridges(
result,
outDir,
new Set([vitepressId, themeId, missingId])
)
).toThrow(missingId)
})

@ -0,0 +1,94 @@
import { resolvePageArtifactCachePolicy } from 'node/build/build'
import type { SiteConfig } from 'node/config'
function createSiteConfig(
markdown: SiteConfig['markdown'],
overrides: Partial<SiteConfig> = {}
): SiteConfig {
return {
cacheDir: '/persistent-cache',
publicDir: '/site/public',
cleanUrls: false,
lastUpdated: false,
ignoreDeadLinks: false,
markdown,
site: {
base: '/',
locales: {},
themeConfig: {}
},
...overrides
} as SiteConfig
}
describe('page artifact cache policy', () => {
test('persists declarative artifact configuration', () => {
const config = createSiteConfig({
lineNumbers: true,
image: { lazyLoad: true },
languageAlias: { shell: 'bash' }
})
expect(resolvePageArtifactCachePolicy(config, '/build-a')).toEqual({
persistent: true,
root: '/persistent-cache'
})
})
test('uses an isolated per-build store when cache is disabled', () => {
const config = createSiteConfig({
cache: false,
cacheKey: 'ignored-while-disabled'
})
const first = resolvePageArtifactCachePolicy(config, '/build-a')
const second = resolvePageArtifactCachePolicy(config, '/build-b')
expect(first).toEqual({
persistent: false,
root: '/build-a/page-artifact-cache'
})
expect(second).toEqual({
persistent: false,
root: '/build-b/page-artifact-cache'
})
expect(first.root).not.toBe(second.root)
})
test('does not persist opaque hook closures without an explicit key', () => {
const markdownHook = createSiteConfig({ config() {} })
const viteHook = createSiteConfig(undefined, {
vite: { plugins: [{ name: 'opaque', transform() {} }] }
})
expect(
resolvePageArtifactCachePolicy(markdownHook, '/build').persistent
).toBe(false)
expect(resolvePageArtifactCachePolicy(viteHook, '/build').persistent).toBe(
false
)
})
test('an explicit whole-page key opts opaque hooks into persistence', () => {
const config = createSiteConfig(
{ cacheKey: 'external-state-v2', config() {} },
{
transformPageData() {},
vite: { plugins: [{ name: 'opaque', transform() {} }] }
}
)
expect(resolvePageArtifactCachePolicy(config, '/build')).toEqual({
persistent: true,
root: '/persistent-cache'
})
})
test('rejects an empty explicit key', () => {
const config = createSiteConfig({ cacheKey: ' ' })
expect(() => resolvePageArtifactCachePolicy(config, '/build')).toThrow(
'markdown.cacheKey must be a non-empty string.'
)
})
})

@ -0,0 +1,139 @@
import type { SiteConfig } from 'node/config'
import {
createRenderMetadata,
deserializeRenderMetadata,
deserializeRenderedPage,
serializeRenderMetadata,
serializeRenderedPage
} from 'node/build/render'
import type { Rolldown } from 'vite'
const chunk = (values: Partial<Rolldown.OutputChunk>): Rolldown.OutputChunk =>
({
type: 'chunk',
fileName: '',
name: '',
code: '',
imports: [],
moduleIds: [],
isEntry: false,
...values
}) as Rolldown.OutputChunk
const asset = (fileName: string): Rolldown.OutputAsset =>
({
type: 'asset',
fileName,
names: [],
originalFileNames: [],
source: ''
}) as Rolldown.OutputAsset
test('retains only compact client metadata and round-trips maps', () => {
const pagePath = '/site/guide.md'
const clientResult = {
output: [
chunk({
fileName: 'assets/app.123.js',
facadeModuleId: '/vitepress/app/index.js',
imports: ['assets/framework.js'],
isEntry: true,
code: 'large app code that must not be retained'
}),
chunk({
fileName: 'assets/guide.123.js',
facadeModuleId: pagePath,
imports: ['assets/theme.js'],
isEntry: true,
code: 'large page code that must not be retained'
}),
chunk({
name: 'theme',
moduleIds: ['/vitepress/client/theme-default/index.js']
}),
asset('assets/style.123.css'),
asset('assets/logo.123.svg')
]
} as Rolldown.RolldownOutput
const config = {
mpa: false,
site: { base: '/docs/' }
} as SiteConfig
const metadata = createRenderMetadata(config, clientResult, null)
const serialized = serializeRenderMetadata(metadata)
const restored = deserializeRenderMetadata(serialized)
expect(restored.appChunk).toEqual({
fileName: 'assets/app.123.js',
imports: ['assets/framework.js']
})
expect(restored.cssChunk).toEqual({ fileName: 'assets/style.123.css' })
expect(restored.assets).toEqual(['/docs/assets/logo.123.svg'])
expect(restored.isDefaultTheme).toBe(true)
expect(restored.pageImports.get(pagePath)).toEqual(['assets/theme.js'])
expect(JSON.stringify(serialized)).not.toContain('large page code')
expect(JSON.stringify(serialized)).not.toContain('large app code')
})
test('retains inlineable page chunks for normal MPA rendering', () => {
const pagePath = '/site/index.md'
const clientResult = {
output: [
chunk({
fileName: 'assets/index.js',
facadeModuleId: pagePath,
isEntry: true,
code: 'console.log("client")'
})
]
} as Rolldown.RolldownOutput
const serverResult = {
output: [asset('assets/mpa.css')]
} as Rolldown.RolldownOutput
const config = { mpa: true, site: { base: '/' } } as SiteConfig
const metadata = createRenderMetadata(config, clientResult, serverResult)
expect(metadata.pageChunks.get(pagePath)).toEqual({
fileName: 'assets/index.js',
code: 'console.log("client")'
})
expect(metadata.cssChunk).toEqual({ fileName: 'assets/mpa.css' })
})
test('round-trips worker render results with sorted Set-backed state', () => {
const renderedPage = {
page: 'guide.md',
pageData: {
title: 'Guide',
description: '',
frontmatter: {},
headers: [],
relativePath: 'guide.md',
filePath: 'guide.md'
},
hasCustom404: true,
context: {
content: '<main>Guide</main>',
teleports: { body: '<div>teleported</div>' },
vpSocialIcons: new Set(['z-icon', 'a-icon'])
}
}
const serialized = serializeRenderedPage(renderedPage)
expect(serialized.context.vpSocialIcons).toEqual(['a-icon', 'z-icon'])
const restored = deserializeRenderedPage(serialized)
expect(restored).toMatchObject({
page: renderedPage.page,
pageData: renderedPage.pageData,
hasCustom404: true,
context: {
content: '<main>Guide</main>',
teleports: { body: '<div>teleported</div>' }
}
})
expect(restored.context.vpSocialIcons).toBeInstanceOf(Set)
expect([...restored.context.vpSocialIcons]).toEqual(['a-icon', 'z-icon'])
})

@ -0,0 +1,237 @@
import {
adaptSsrBatchPagePlugins,
createSsrBatchPlan,
createWorkerExecArgv,
validateBuildConcurrency,
validateSsrBatchPageOutputHooks,
validateSsrBuildBatchSize,
validateSsrBuildWorkerConcurrency
} from 'node/build/ssrBatchUtils'
import type { Plugin, Rolldown } from 'vite'
describe('SSR batch planning', () => {
test('adapts frozen user plugins without changing internal plugins', () => {
const userPlugin = Object.freeze({
name: 'frozen-user-plugin',
transform(this: any) {
return `${this.environment.mode}:${this.meta.watchMode}:${typeof this.setAssetSource}`
}
}) as Plugin
const internalPlugin = Object.freeze({
name: 'vite:internal-test',
transform() {}
}) as Plugin
const [adaptedUser, adaptedInternal] = adaptSsrBatchPagePlugins([
userPlugin,
internalPlugin
])
expect(adaptedUser).not.toBe(userPlugin)
expect(adaptedInternal).toBe(internalPlugin)
const transform = adaptedUser.transform
const handler =
typeof transform === 'function' ? transform : transform?.handler
expect(
handler?.call(
{
environment: { mode: 'dev' },
meta: { watchMode: true }
},
'',
'/page.js',
{ moduleType: 'js', ssr: true }
)
).toBe('build:false:undefined')
})
test('requires positive global build concurrency', () => {
expect(validateBuildConcurrency(1)).toBe(1)
expect(validateBuildConcurrency(64)).toBe(64)
for (const value of [undefined, 0, -1, 1.5, Number.NaN, Infinity, '2']) {
expect(() => validateBuildConcurrency(value)).toThrow(
'buildConcurrency must be a positive integer.'
)
}
})
test('requires a positive integer when configured', () => {
expect(validateSsrBuildBatchSize(undefined)).toBeUndefined()
expect(validateSsrBuildBatchSize(64)).toBe(64)
for (const value of [0, -1, 1.5, Number.NaN, Infinity, '2', null]) {
expect(() => validateSsrBuildBatchSize(value)).toThrow(
'ssrBuildBatchSize must be a positive integer.'
)
}
})
test('requires positive render-worker concurrency', () => {
expect(validateSsrBuildWorkerConcurrency(1)).toBe(1)
expect(validateSsrBuildWorkerConcurrency(4)).toBe(4)
for (const value of [undefined, 0, -1, 1.5, Number.NaN, '2', null]) {
expect(() => validateSsrBuildWorkerConcurrency(value)).toThrow(
'ssrBuildWorkerConcurrency must be a positive integer.'
)
}
})
test('partitions the render queue without reordering pages', () => {
const pages = ['a.md', 'b.md', 'c.md', 'd.md', 'e.md']
const batches = createSsrBatchPlan(pages, 2)
expect(batches.flatMap((batch) => batch.pages)).toEqual([
'404.md',
...pages
])
expect(batches.map((batch) => batch.offset)).toEqual([0, 2, 4])
expect(batches.every((batch) => batch.pages.length <= 2)).toBe(true)
})
test('supports a synthetic 404-only batch', () => {
expect(createSsrBatchPlan([], 10)).toEqual([
{ offset: 0, pages: ['404.md'] }
])
})
test('does not schedule a custom 404 page twice', () => {
const batches = createSsrBatchPlan(['guide.md', '404.md'], 2)
expect(batches.flatMap((batch) => batch.pages)).toEqual([
'404.md',
'guide.md'
])
})
test('accepts transform and teardown hooks plus Vite internal bundle hooks', async () => {
const plugins = [
{
name: 'fabric-docs:transform-files',
transform(code: string) {
return code
},
buildEnd() {},
closeBundle() {}
},
{
name: 'vite:css-post',
renderChunk() {},
augmentChunkHash() {}
},
{
name: 'vitepress',
renderStart() {},
generateBundle() {}
}
] as Plugin[]
await expect(
validateSsrBatchPageOutputHooks(plugins, undefined)
).resolves.toBeUndefined()
})
test('rejects user bundle-graph and output hooks with plugin and hook names', async () => {
const plugins = [
{
name: 'custom-page-renderer',
moduleParsed() {},
renderChunk: { handler() {} },
augmentChunkHash() {
return 'custom'
}
},
{
name: 'page-manifest',
resolveDynamicImport() {
return null
},
generateBundle() {}
}
] as Plugin[]
await expect(
validateSsrBatchPageOutputHooks(plugins, undefined)
).rejects.toThrow(
[
'SSR batching cannot preserve Rolldown bundle hooks for unbundled SSR page modules:',
' - plugin "custom-page-renderer": moduleParsed, augmentChunkHash, renderChunk',
' - plugin "page-manifest": resolveDynamicImport, generateBundle',
'Disable ssrBuildBatchSize'
].join('\n')
)
})
test('rejects async nested Rolldown output plugins and output addons', async () => {
const outputPlugin = Promise.resolve({
name: 'server-page-assets',
writeBundle() {}
})
const output = {
banner: '/* server page */',
plugins: [false, [outputPlugin]]
} as Rolldown.OutputOptions
await expect(validateSsrBatchPageOutputHooks([], output)).rejects.toThrow(
[
' - output options "output": banner',
' - output plugin "server-page-assets": writeBundle'
].join('\n')
)
})
})
const inspectorOverrides = [
'--no-inspect',
'--no-inspect-brk',
'--no-inspect-wait'
].filter((flag) => process.allowedNodeEnvironmentFlags.has(flag))
test('worker exec arguments omit inspector flags and override NODE_OPTIONS', () => {
expect(
createWorkerExecArgv([
'--enable-source-maps',
'--inspect',
'--inspect-brk=127.0.0.1:9230',
'--inspect-port',
'9231',
'--loader',
'tsx',
'--inspect-publish-uid',
'stderr,http',
'--conditions=development'
])
).toEqual([
'--enable-source-maps',
'--loader',
'tsx',
'--conditions=development',
...inspectorOverrides
])
})
test('worker exec arguments omit parent entrypoint modes', () => {
expect(
createWorkerExecArgv([
'--enable-source-maps',
'--',
'-e',
'build()',
'--input-type',
'module',
'--test',
'--watch-path',
'src',
'--test-coverage-include',
'src/**/*.ts',
'--watch-kill-signal=SIGTERM',
'-pe',
'process.version',
'--conditions=development'
])
).toEqual([
'--enable-source-maps',
'--conditions=development',
...inspectorOverrides
])
})

File diff suppressed because it is too large Load Diff

@ -0,0 +1,22 @@
import { serializeSsrRenderWorkerResult } from 'node/build/ssrWorkerProtocol'
describe('SSR render-worker result protocol', () => {
test('explains the batching constraint for custom non-transferable context', () => {
expect(() =>
serializeSsrRenderWorkerResult({
pages: [
{
page: 'guide.md',
pageData: {} as any,
hasCustom404: true,
context: {
content: '<main>Guide</main>',
vpSocialIcons: [],
customCallback: () => undefined
} as any
}
]
})
).toThrow(/SSGContext must be structured-cloneable/)
})
})

@ -0,0 +1,57 @@
import type { MarkdownOptions } from 'node/markdown/markdown'
import { highlight } from 'node/markdown/plugins/highlight'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
describe('persistent Shiki highlight cache', () => {
let root: string | undefined
afterEach(async () => {
if (root) {
await rm(root, { recursive: true, force: true })
root = undefined
}
})
test('reuses highlighted HTML without initializing a second renderer', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-shiki-cache-'))
let setupCalls = 0
let failIfInitialized = false
const options: MarkdownOptions = {
shikiCacheKey: 'persistent-cache-test-v1',
async shikiSetup() {
setupCalls++
if (failIfInitialized) {
throw new Error('the persistent cache was not used')
}
}
}
const logger = { warn: vi.fn() }
const [firstHighlight, disposeFirst] = await highlight(
'github-light',
options,
logger,
root
)
const first = await firstHighlight('const persistent = true', 'js', '{1}')
disposeFirst()
failIfInitialized = true
const [secondHighlight, disposeSecond] = await highlight(
'github-light',
options,
logger,
root
)
const second = await secondHighlight('const persistent = true', 'js', '{1}')
disposeSecond()
expect(second).toBe(first)
expect(second).toContain('const')
expect(setupCalls).toBe(1)
expect(logger.warn).not.toHaveBeenCalled()
})
})

@ -1,6 +1,12 @@
import { resolveConfig } from 'node/config'
import { createMarkdownToVueRenderFn } from 'node/markdownToVue'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import {
canCompileSsrPageArtifact,
createMarkdownToVueRenderFn,
createStaticPageVueSource,
prepareStaticHtmlForSsr
} from 'node/markdownToVue'
import { PageArtifactStore } from 'node/pageArtifacts'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
@ -152,4 +158,641 @@ describe('node/markdownToVue', () => {
expect(result.pageData.relativePath).toBe('index.md')
})
test('refreshes cached Vue page data after hooks without mutating the base artifact', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-page-data-'))
const file = path.join(root, 'index.md')
const src = '---\nnested:\n value: 1\n---\n# Original\n'
await writeFile(file, src)
const siteConfig = await resolveConfig(root, 'build', 'production')
siteConfig.transformPageData = vi.fn(async (pageData) => {
;(pageData.frontmatter.nested as { value: number }).value = 2
return {
title: 'Current build title',
relativePath: 'current-build.md'
}
})
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false },
'/',
false,
false,
siteConfig,
false,
true,
true
)
const base = await render(src, file)
expect(siteConfig.transformPageData).not.toHaveBeenCalled()
const finalized = await render.finalize(base, file)
expect(siteConfig.transformPageData).toHaveBeenCalledTimes(1)
expect(base.pageData.title).toBe('Original')
expect(base.pageData.relativePath).toBe('index.md')
expect(base.pageData.frontmatter.nested).toEqual({ value: 1 })
expect(finalized.pageData.title).toBe('Current build title')
expect(finalized.pageData.relativePath).toBe('current-build.md')
expect(finalized.pageData.frontmatter.nested).toEqual({ value: 2 })
expect(readEmbeddedPageData(finalized.vueSrc)).toEqual(finalized.pageData)
expect(finalized.vueSrc).toContain(
'export default {name:"current-build.md"}'
)
})
test('runs site and dynamic-route page-data hooks once on cold and warm artifacts', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-dynamic-page-data-'))
const stateKey = `__vitepress_page_data_${Date.now()}_${Math.random()}`
const state = {
build: 'cold',
siteCalls: 0,
dynamicCalls: 0,
publishedWasDate: [] as boolean[]
}
;(globalThis as Record<string, unknown>)[stateKey] = state
try {
await writeFile(
path.join(root, '[id].md'),
'---\npublished: 2025-01-02\n---\n# Original\n'
)
await writeFile(
path.join(root, '[id].paths.mts'),
`const state = globalThis[${JSON.stringify(stateKey)}]
export default {
paths: [{ params: { id: 'one' } }],
transformPageData(pageData) {
state.dynamicCalls++
return { title: pageData.title + ':' + state.build + ':dynamic' }
}
}
`
)
const siteConfig = await resolveConfig(root, 'build', 'production')
siteConfig.transformPageData = async (pageData) => {
state.siteCalls++
state.publishedWasDate.push(
pageData.frontmatter.published instanceof Date
)
return {
title: `${pageData.title}:${state.build}:site`,
relativePath: `${state.build}.md`
}
}
const route = siteConfig.dynamicRoutes[0]
const source =
'__VP_PARAMS_START{"id":"one"}__VP_PARAMS_END__---\npublished: 2025-01-02\n---\n# Original\n'
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false },
'/',
false,
false,
siteConfig,
false,
true,
true
)
const coldCompile = vi.fn(() => render(source, route.fullPath))
const cold = new PageArtifactStore(siteConfig.cacheDir, {
namespace: 'dynamic-page-data'
})
const coldArtifact = await cold.getOrCreate(
route.path,
source,
coldCompile,
(artifact) => render.finalize(artifact, route.fullPath)
)
await cold.getOrCreate(route.path, source, coldCompile, (artifact) =>
render.finalize(artifact, route.fullPath)
)
await cold.flush()
expect(coldCompile).toHaveBeenCalledTimes(1)
expect(state.siteCalls).toBe(1)
expect(state.dynamicCalls).toBe(1)
expect(coldArtifact.pageData.title).toBe(
'Original:cold:site:cold:dynamic'
)
expect(coldArtifact.pageData.relativePath).toBe('cold.md')
expect(readEmbeddedPageData(coldArtifact.vueSrc)).toMatchObject({
title: coldArtifact.pageData.title,
relativePath: coldArtifact.pageData.relativePath,
frontmatter: { published: '2025-01-02T00:00:00.000Z' }
})
expect(coldArtifact.vueSrc).toContain('export default {name:"cold.md"}')
state.build = 'warm'
const warmCompile = vi.fn(() => render(source, route.fullPath))
const warm = new PageArtifactStore(siteConfig.cacheDir, {
namespace: 'dynamic-page-data'
})
const warmArtifact = await warm.getOrCreate(
route.path,
source,
warmCompile,
(artifact) => render.finalize(artifact, route.fullPath)
)
await warm.getOrCreate(route.path, source, warmCompile, (artifact) =>
render.finalize(artifact, route.fullPath)
)
expect(warmCompile).not.toHaveBeenCalled()
expect(state.siteCalls).toBe(2)
expect(state.dynamicCalls).toBe(2)
expect(state.publishedWasDate).toEqual([true, true])
expect(warmArtifact.pageData.title).toBe(
'Original:warm:site:warm:dynamic'
)
expect(warmArtifact.pageData.relativePath).toBe('warm.md')
expect(readEmbeddedPageData(warmArtifact.vueSrc)).toMatchObject({
title: warmArtifact.pageData.title,
relativePath: warmArtifact.pageData.relativePath,
frontmatter: { published: '2025-01-02T00:00:00.000Z' }
})
expect(warmArtifact.vueSrc).toContain('export default {name:"warm.md"}')
expect((await warm.getCurrent(route.path))?.pageData).toEqual(
warmArtifact.pageData
)
} finally {
delete (globalThis as Record<string, unknown>)[stateKey]
}
})
test('marks only conservatively static Markdown for the direct SSR path', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-static-page-'))
const cases = [
{
name: 'plain',
source: '# Static page\n\nPlain **Markdown** and [a link](/home).',
expected: true
},
{
name: 'component',
source: '# Component\n\n<ClientOnly>client content</ClientOnly>',
expected: false
},
{
name: 'interpolation',
source: '# Interpolation\n\n<span>{{ count }}</span>',
expected: false
},
{
name: 'directive',
source: '# Directive\n\n<button @click="run">Run</button>',
expected: false
},
{
name: 'relative-asset',
source: '# Asset\n\n![local asset](./asset.png)',
expected: false
},
{
name: 'absolute-asset',
source: '# Asset\n\n![root asset](/asset.png)',
expected: true
},
{
name: 'v-pre-relative-asset',
source:
'# Asset\n\n<pre v-pre><img src="./asset.png" alt="local"></pre>',
expected: false
},
{
name: 'hash-asset',
source: '# Asset\n\n<img src="#asset" alt="hash import">',
expected: false
},
{
name: 'mailto-asset',
source: '# Asset\n\n<img src="mailto:image@example.com" alt="scheme">',
expected: false
},
{
name: 'duplicate-asset',
source:
'# Asset\n\n<img src="./asset.png" src="https://example.com/safe.png">',
expected: false
},
{
name: 'external-asset',
source: '# Asset\n\n![external asset](https://example.com/asset.png)',
expected: true
},
{
name: 'ordinary-link',
source: '# Link\n\n[relative page](./other-page.md)',
expected: true
},
{
name: 'svg-asset',
source:
'# SVG\n\n<svg><use xlink:href="./icons.svg#check"></use></svg>',
expected: false
},
{
name: 'object-asset',
source: '# Object\n\n<object data="./document.pdf"></object>',
expected: false
},
{
name: 'link-asset',
source: '# Link asset\n\n<link href="./theme.css">',
expected: false
},
{
name: 'srcset-asset',
source:
'# Sources\n\n<img srcset="https://example.com/a.png 1x, ./b.png 2x">',
expected: false
},
{
name: 'meta-asset',
source:
'# Metadata\n\n<meta property="og:image" content="./social.png">',
expected: false
},
{
name: 'non-asset-meta',
source:
'# Metadata\n\n<meta name="description" content="./plain-text">',
expected: true
},
{
name: 'boolean-ref',
source: '# Reserved property\n\n<div ref>content</div>',
expected: false
},
{
name: 'textarea-value',
source: '# Textarea\n\n<textarea value="content"></textarea>',
expected: false
},
{
name: 'highlighted-code',
source: '# Code\n\n```js\nconsole.log("render")\n```',
expected: true
},
{
name: 'default-theme-badge',
source: '# Badge <Badge type="warning">1.2.3</Badge>',
expected: true
},
{
name: 'html-comment',
source: '# Comment\n\n<!-- preserved -->\n\nContent',
expected: true
},
{
name: 'sfc-block',
source:
'# SFC\n\n<script setup>const value = 1</script>\n\n<style>h1 { color: red }</style>',
expected: false
},
{
name: 'scoped-style',
source: '# Scoped style\n\n<style scoped>h1 { color: red }</style>',
expected: false
},
{
name: 'css-module',
source: '# CSS module\n\n<style module>.title { color: red }</style>',
expected: false
}
] as const
await mkdir(path.join(root, 'public'), { recursive: true })
await Promise.all([
writeFile(path.join(root, 'public', 'asset.png'), 'asset'),
...cases.map(({ name, source }) =>
writeFile(path.join(root!, `${name}.md`), source)
)
])
const siteConfig = await resolveConfig(root, 'build', 'production')
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false },
'/',
false,
false,
siteConfig
)
for (const { name, source, expected } of cases) {
const result = await render(source, path.join(root, `${name}.md`))
expect(result.html).not.toBe('')
expect(result.staticPage, name).toBe(expected ? true : undefined)
if (name === 'plain') {
expect(result.staticHtml).toBeUndefined()
}
if (name === 'highlighted-code') {
expect(result.html).toContain('v-pre')
expect(result.staticHtml).toBeUndefined()
expect(prepareStaticHtmlForSsr(result.html)).not.toContain('v-pre')
}
if (name === 'default-theme-badge') {
expect(result.staticHtml).toContain(
'<span class="VPBadge warning"><!--[-->1.2.3<!--]--></span>'
)
const clientSource = createStaticPageVueSource(result)
expect(clientSource).toContain(
"import { createStaticVNode } from 'vue'"
)
expect(clientSource).toContain('VPBadge warning')
}
if (
name === 'sfc-block' ||
name === 'scoped-style' ||
name === 'css-module'
) {
expect(result.requiresSourceModuleIdentity).toBe(true)
expect(
canCompileSsrPageArtifact(
siteConfig,
path.join(root, `${name}.md`),
result
)
).toBe(false)
}
}
})
test('does not fold Badge markup from a custom theme', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-custom-badge-'))
await mkdir(path.join(root, '.vitepress/theme'), { recursive: true })
const file = path.join(root, 'index.md')
const source = '# Custom Badge <Badge type="warning">custom</Badge>'
await writeFile(file, source)
const siteConfig = await resolveConfig(root, 'build', 'production')
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false },
'/',
false,
false,
siteConfig
)
const result = await render(source, file)
expect(result.staticPage).toBeUndefined()
expect(result.html).toContain('<Badge type="warning">custom</Badge>')
})
test('does not bypass resolved renderBuiltUrl for public assets', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-built-url-static-'))
await mkdir(path.join(root, 'public'), { recursive: true })
const file = path.join(root, 'index.md')
const source = '# Asset\n\n![asset](/asset.png)'
await Promise.all([
writeFile(file, source),
writeFile(path.join(root, 'public', 'asset.png'), 'asset')
])
const siteConfig = await resolveConfig(root, 'build', 'production')
siteConfig.vite = {
experimental: {
renderBuiltUrl(filename) {
return `/cdn/${filename}`
}
}
}
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false },
'/',
false,
false,
siteConfig
)
const result = await render(source, file)
expect(result.staticPage).toBeUndefined()
expect(result.requiresSourceModuleIdentity).toBeUndefined()
})
test('reuses only explicitly environment-invariant Markdown pre transforms', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-static-transform-'))
const file = path.join(root, 'index.md')
const source = '# Source transform\n'
await writeFile(file, source)
const siteConfig = await resolveConfig(root, 'build', 'production')
siteConfig.vite = {
plugins: [
{
name: 'source-only-markdown',
enforce: 'pre',
api: { vitepress: { ssrArtifactSafe: true } },
transform: {
filter: { id: /[.]md$/ },
handler(code) {
return `${code}\ntransformed`
}
}
}
]
}
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false },
'/',
false,
false,
siteConfig
)
expect(canCompileSsrPageArtifact(siteConfig, file)).toBe(true)
const result = await render(source, file)
expect(result.staticPage).toBe(true)
})
test('keeps SSR-sensitive source transforms on the physical path', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-ssr-transform-'))
const file = path.join(root, 'index.md')
const source = '# Environment-sensitive transform\n'
await writeFile(file, source)
const siteConfig = await resolveConfig(root, 'build', 'production')
siteConfig.vite = {
plugins: [
{
name: 'ssr-sensitive-markdown',
enforce: 'pre',
transform: {
filter: { id: /[.]md$/ },
handler(code, _id, options) {
return `${code}\n${options?.ssr ? 'server' : 'client'}`
}
}
}
]
}
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false },
'/',
false,
false,
siteConfig
)
expect(canCompileSsrPageArtifact(siteConfig, file)).toBe(false)
const result = await render(source, file)
expect(result.staticPage).toBeUndefined()
expect(result.requiresSourceModuleIdentity).toBe(true)
expect(canCompileSsrPageArtifact(siteConfig, file, result)).toBe(false)
})
test('keeps applicable resolve and load hooks on the physical non-static path', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-module-hooks-'))
const file = path.join(root, 'index.md')
const source = '# Module hooks\n'
await writeFile(file, source)
const siteConfig = await resolveConfig(root, 'build', 'production')
const plugins = [
{
name: 'resolve-page-dependency',
resolveId: {
filter: { id: /[.]json$/ },
handler() {
return null
}
}
},
{
name: 'load-artifact-vue',
load: {
filter: { id: /[.]__vitepress_ssr[.]vue$/ },
handler() {
return null
}
}
}
]
for (const plugin of plugins) {
siteConfig.vite = { plugins: [plugin] }
expect(canCompileSsrPageArtifact(siteConfig, file)).toBe(false)
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false },
'/',
false,
false,
siteConfig
)
const result = await render(source, file)
expect(result.staticPage).toBe(true)
expect(result.requiresSourceModuleIdentity).toBe(true)
}
})
test('treats filtered load hooks as potentially applicable to page dependencies', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-filtered-hooks-'))
const file = path.join(root, 'index.md')
await writeFile(file, '# Filtered hooks\n')
const siteConfig = await resolveConfig(root, 'build', 'production')
siteConfig.vite = {
plugins: [
{
name: 'json-only-load',
load: {
filter: { id: /[.]json$/ },
handler() {
return null
}
}
}
]
}
expect(canCompileSsrPageArtifact(siteConfig, file)).toBe(false)
siteConfig.vite = {
plugins: [
{
name: 'serve-only-load',
apply: 'serve',
load() {
return null
}
}
]
}
expect(canCompileSsrPageArtifact(siteConfig, file)).toBe(true)
})
test('treats promised plugins as opaque for artifact module identities', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-promised-hooks-'))
const file = path.join(root, 'index.md')
await writeFile(file, '# Promised hooks\n')
const siteConfig = await resolveConfig(root, 'build', 'production')
siteConfig.vite = {
plugins: [
Promise.resolve({
name: 'promised-load-hook',
load() {
return null
}
})
]
}
expect(canCompileSsrPageArtifact(siteConfig, file)).toBe(false)
})
test('keeps normal Markdown transforms on the physical compiled path', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-static-transform-'))
const file = path.join(root, 'index.md')
const source = '# Generated-SFC transform\n'
await writeFile(file, source)
const siteConfig = await resolveConfig(root, 'build', 'production')
siteConfig.vite = {
plugins: [
{
name: 'generated-sfc-markdown',
transform: {
filter: { id: /[.]md$/ },
handler(code) {
return `${code}\ntransformed`
}
}
}
]
}
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false },
'/',
false,
false,
siteConfig
)
const result = await render(source, file)
expect(result.staticPage).toBeUndefined()
})
})
function readEmbeddedPageData(vueSrc: string) {
const encoded = vueSrc.match(
/export const __pageData = JSON\.parse\(("(?:[^"\\]|\\.)*")\)/
)?.[1]
expect(encoded).toBeTruthy()
return JSON.parse(JSON.parse(encoded!))
}

@ -0,0 +1,264 @@
import { PageArtifactStore } from 'node/pageArtifacts'
import type { MarkdownCompileResult } from 'node/markdownToVue'
import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
describe('PageArtifactStore', () => {
let root: string | undefined
afterEach(async () => {
if (root) {
await rm(root, { recursive: true, force: true })
root = undefined
}
})
async function createRoot() {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-page-artifacts-'))
return root
}
test('persists artifacts and non-JSON page-data values across store instances', async () => {
const cacheDir = await createRoot()
const store = new PageArtifactStore(cacheDir, {
namespace: 'site-config-v1'
})
const published = new Date('2025-01-02T03:04:05.000Z')
const artifact = createArtifact({
pageData: {
...createArtifact().pageData,
frontmatter: { published, optional: undefined }
}
})
await store.put('docs/page.md', '# Page', artifact)
await store.flush()
const restored = new PageArtifactStore(cacheDir, {
namespace: 'site-config-v1'
})
const restoredArtifact = await restored.get('./docs/page.md', '# Page')
expect(restoredArtifact).toEqual(artifact)
expect(restoredArtifact?.pageData.frontmatter.published).toBeInstanceOf(
Date
)
expect(
Object.hasOwn(restoredArtifact?.pageData.frontmatter ?? {}, 'optional')
).toBe(true)
})
test('invalidates on source, namespace, and include dependency changes', async () => {
const cacheDir = await createRoot()
const include = path.join(cacheDir, 'shared.md')
await writeFile(include, 'first include')
const store = new PageArtifactStore(cacheDir, { namespace: 'routes-v1' })
await store.put(
'page.md',
'# Page',
createArtifact({ includes: [include] })
)
await store.flush()
await expect(
new PageArtifactStore(cacheDir, { namespace: 'routes-v1' }).get(
'page.md',
'# Changed'
)
).resolves.toBeUndefined()
await expect(
new PageArtifactStore(cacheDir, { namespace: 'routes-v2' }).get(
'page.md',
'# Page'
)
).resolves.toBeUndefined()
await writeFile(include, 'changed include')
await expect(
new PageArtifactStore(cacheDir, { namespace: 'routes-v1' }).get(
'page.md',
'# Page'
)
).resolves.toBeUndefined()
})
test('deduplicates concurrent compilation and identical CAS objects', async () => {
const cacheDir = await createRoot()
const store = new PageArtifactStore(cacheDir, { namespace: 'dedup' })
const artifact = createArtifact()
const compile = vi.fn(async () => {
await Promise.resolve()
return artifact
})
const results = await Promise.all(
Array.from({ length: 4 }, () =>
store.getOrCreate('page.md', '# Page', compile)
)
)
expect(compile).toHaveBeenCalledTimes(1)
expect(results).toEqual([artifact, artifact, artifact, artifact])
// The page key belongs to the manifest, not to the immutable object. Two
// entries with byte-identical output therefore share one object file.
await store.put('alias.md', '# Alias', artifact)
await store.flush()
const objectsDir = path.join(
cacheDir,
'vitepress-page-artifacts',
'objects'
)
const shards = await readdir(objectsDir)
const objectFiles = (
await Promise.all(
shards.map((shard) => readdir(path.join(objectsDir, shard)))
)
).flat()
expect(objectFiles).toHaveLength(1)
})
test('shares content bodies across route-specific page overlays', async () => {
const cacheDir = await createRoot()
const store = new PageArtifactStore(cacheDir, {
namespace: 'cross-version-body'
})
const artifact = createArtifact()
await store.put('1.0/page.md', '# Page', artifact)
await store.put('2.0/page.md', '# Page', {
...artifact,
pageData: {
...artifact.pageData,
relativePath: '2.0/page.md',
filePath: '2.0/page.md'
}
})
await store.flush()
const artifactRoot = path.join(cacheDir, 'vitepress-page-artifacts')
expect(await countShardedFiles(path.join(artifactRoot, 'objects'))).toBe(2)
expect(await countShardedFiles(path.join(artifactRoot, 'bodies'))).toBe(1)
})
test('retains physical-module eligibility in compact page metadata', async () => {
const cacheDir = await createRoot()
const store = new PageArtifactStore(cacheDir, {
namespace: 'source-module-identity'
})
const artifact = createArtifact({
staticPage: undefined,
requiresSourceModuleIdentity: true
})
await store.put('styled.md', '# Styled', artifact)
await expect(store.getCurrentMetadata('styled.md')).resolves.toEqual({
staticPage: false,
requiresSourceModuleIdentity: true
})
})
test('persists pre-hook artifacts and finalizes them once per build', async () => {
const cacheDir = await createRoot()
const compile = vi.fn(async () => createArtifact())
const coldFinalize = vi.fn(async (artifact: MarkdownCompileResult) => ({
...artifact,
pageData: { ...artifact.pageData, title: 'cold build' }
}))
const cold = new PageArtifactStore(cacheDir, {
namespace: 'page-data-hooks'
})
const [coldFirst, coldSecond] = await Promise.all([
cold.getOrCreate('page.md', '# Page', compile, coldFinalize),
cold.getOrCreate('page.md', '# Page', compile, coldFinalize)
])
expect(compile).toHaveBeenCalledTimes(1)
expect(coldFinalize).toHaveBeenCalledTimes(1)
expect(coldFirst.pageData.title).toBe('cold build')
expect(coldSecond.pageData.title).toBe('cold build')
expect((await cold.getCurrent('page.md'))?.pageData.title).toBe(
'cold build'
)
await cold.flush()
const warmCompile = vi.fn(async () => {
throw new Error('Markdown must not run on a warm artifact hit')
})
const warmFinalize = vi.fn(async (artifact: MarkdownCompileResult) => ({
...artifact,
pageData: {
...artifact.pageData,
title: `${artifact.pageData.title}:warm build`
}
}))
const warm = new PageArtifactStore(cacheDir, {
namespace: 'page-data-hooks'
})
const warmFirst = await warm.getOrCreate(
'page.md',
'# Page',
warmCompile,
warmFinalize
)
const warmSecond = await warm.getOrCreate(
'page.md',
'# Page',
warmCompile,
warmFinalize
)
expect(warmCompile).not.toHaveBeenCalled()
expect(warmFinalize).toHaveBeenCalledTimes(1)
// The warm hook starts from the persistent pre-hook page data. It must not
// receive the previous build's transformed result.
expect(warmFirst.pageData.title).toBe('Page:warm build')
expect(warmSecond.pageData.title).toBe('Page:warm build')
expect((await warm.getCurrent('page.md'))?.pageData.title).toBe(
'Page:warm build'
)
})
test('turns a read-only cache miss into a coordinator error', async () => {
const cacheDir = await createRoot()
const store = new PageArtifactStore(cacheDir, {
namespace: 'render-worker',
readOnly: true
})
await expect(store.getOrCreate('missing.md', '# Missing')).rejects.toThrow(
'The coordinator must compile page artifacts before starting render workers.'
)
})
})
async function countShardedFiles(root: string): Promise<number> {
const shards = await readdir(root)
return (
await Promise.all(shards.map((shard) => readdir(path.join(root, shard))))
).flat().length
}
function createArtifact(
overrides: Partial<MarkdownCompileResult> = {}
): MarkdownCompileResult {
return {
vueSrc: '<template><div><h1>Page</h1></div></template>',
html: '<h1>Page</h1>',
pageData: {
title: 'Page',
description: '',
frontmatter: {},
headers: [],
relativePath: 'page.md',
filePath: 'page.md'
},
deadLinks: [],
includes: [],
staticPage: true,
...overrides
}
}

@ -0,0 +1,140 @@
import { resolveConfig } from 'node/config'
import { PageArtifactStore } from 'node/pageArtifacts'
import { createVitePressPlugin } from 'node/plugin'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { normalizePath, type Plugin } from 'vite'
describe('node/plugin coordinator client', () => {
let root: string | undefined
afterEach(async () => {
if (root) {
await rm(root, { recursive: true, force: true })
root = undefined
}
})
test('initializes Markdown and preloads resolved pages through the client graph', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-client-preload-'))
await Promise.all([
writeFile(path.join(root, 'one.md'), '# One\n'),
writeFile(path.join(root, 'two.md'), '# Two\n')
])
const siteConfig = await resolveConfig(root, 'build', 'production')
const configureMarkdown = vi.fn()
const userPostBuildStart = vi.fn()
const userPlugin: Plugin = {
name: 'test:user-post-build-start',
enforce: 'post',
buildStart: {
order: 'post',
handler: userPostBuildStart
}
}
siteConfig.markdown = { cache: false, config: configureMarkdown }
siteConfig.vite = { plugins: [userPlugin] }
siteConfig.buildConcurrency = 1
const store = new PageArtifactStore(siteConfig.cacheDir, {
namespace: 'client-preload'
})
const plugins = await createVitePressPlugin(
siteConfig,
false,
undefined,
undefined,
undefined,
undefined,
{
coordinatorClient: true,
pageArtifactStore: store,
skipGitScan: true
}
)
const vitePressPlugin = plugins[0] as Plugin
const configResolved = getHookHandler(vitePressPlugin.configResolved)
await configResolved.call(undefined, {
base: '/',
command: 'build',
publicDir: siteConfig.publicDir
} as any)
// This lifecycle still runs on an all-warm build while the highlighter
// remains lazy until a Markdown cache miss actually needs it.
expect(configureMarkdown).toHaveBeenCalledTimes(1)
const preloadPlugin = plugins.at(-1) as Plugin
expect(preloadPlugin.name).toBe('vitepress:coordinator-page-preload')
expect(preloadPlugin.enforce).toBe('post')
expect(plugins.indexOf(userPlugin)).toBeLessThan(
plugins.indexOf(preloadPlugin)
)
const buildStart = preloadPlugin.buildStart as unknown as {
order: string
sequential: boolean
handler: (...args: any[]) => Promise<void>
}
expect(buildStart.order).toBe('post')
expect(buildStart.sequential).toBe(true)
let activeLoads = 0
let peakLoads = 0
const resolve = vi.fn(async (id: string) => ({ id }))
const load = vi.fn(
async (_options: { id: string; resolveDependencies: boolean }) => {
expect(userPostBuildStart).toHaveBeenCalledTimes(1)
activeLoads++
peakLoads = Math.max(peakLoads, activeLoads)
await Promise.resolve()
activeLoads--
return {} as any
}
)
await getHookHandler(userPlugin.buildStart).call({})
await buildStart.handler.call({ resolve, load })
const expectedIds = siteConfig.pages.map((page) =>
normalizePath(path.resolve(siteConfig.srcDir, page))
)
expect(resolve.mock.calls.map(([id]) => id)).toEqual(expectedIds)
expect(load.mock.calls.map(([options]) => options)).toEqual(
expectedIds.map((id) => ({ id, resolveDependencies: true }))
)
expect(peakLoads).toBe(1)
})
test('does not let an isolated SSR phase replace the client public directory', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-ssr-public-dir-'))
await writeFile(path.join(root, 'index.md'), '# Page\n')
const siteConfig = await resolveConfig(root, 'build', 'production')
const clientPublicDir = siteConfig.publicDir
const plugins = await createVitePressPlugin(
siteConfig,
true,
undefined,
undefined,
undefined,
undefined,
{ isSsrBatch: true, skipGitScan: true }
)
const vitePressPlugin = plugins[0] as Plugin
const configResolved = getHookHandler(vitePressPlugin.configResolved)
await configResolved.call(undefined, {
base: '/',
command: 'build',
publicDir: path.join(root, 'runtime-public')
} as any)
expect(siteConfig.publicDir).toBe(clientPublicDir)
})
})
function getHookHandler<T extends (...args: any[]) => any>(
hook: T | { handler: T } | undefined
): T {
if (!hook) throw new Error('Expected plugin hook.')
return typeof hook === 'function' ? hook : hook.handler
}

@ -1,6 +1,10 @@
import MiniSearch from 'minisearch'
import type { MarkdownItAsync } from 'markdown-it-async'
import { resolveConfig } from 'node/config'
import type { MarkdownCompileResult } from 'node/markdownToVue'
import { PageArtifactStore } from 'node/pageArtifacts'
import { localSearchPlugin } from 'node/plugins/localSearchPlugin'
import type { MarkdownEnv } from 'node/shared'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
@ -101,6 +105,61 @@ describe('node/plugins/localSearchPlugin', () => {
])
expect(zhIndex.search('rootonlytoken')).toEqual([])
})
test('runs custom _render hooks again on a warm artifact build', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-local-search-hook-'))
const source = '# Search hook\n\nsearchhooktoken\n'
await writeFile(path.join(root, 'index.md'), source)
const siteConfig = await resolveConfig(root, 'build', 'production')
const renderHook = vi.fn(
async (src: string, env: MarkdownEnv, md: MarkdownItAsync) =>
md.renderAsync(src, env)
)
siteConfig.site.themeConfig = {
search: { provider: 'local', options: { _render: renderHook } }
}
const artifact: MarkdownCompileResult = {
vueSrc: '<template><div><h1>Search hook</h1></div></template>',
html: '<h1 id="search-hook">Search hook<a href="#search-hook"></a></h1>',
pageData: {
title: 'Search hook',
description: '',
frontmatter: {},
headers: [],
relativePath: 'index.md',
filePath: 'index.md'
},
deadLinks: [],
includes: []
}
const coldStore = new PageArtifactStore(siteConfig.cacheDir, {
namespace: 'local-search-hook'
})
await coldStore.put('index.md', source, artifact)
await coldStore.flush()
const coldPlugin = await localSearchPlugin(siteConfig, false, coldStore)
;(coldPlugin.configResolved as any)?.call(
{},
{ publicDir: siteConfig.publicDir }
)
await (coldPlugin.load as any).handler.call({}, '/@localSearchIndex')
const warmStore = new PageArtifactStore(siteConfig.cacheDir, {
namespace: 'local-search-hook'
})
await warmStore.get('index.md', source)
const warmPlugin = await localSearchPlugin(siteConfig, false, warmStore)
;(warmPlugin.configResolved as any)?.call(
{},
{ publicDir: siteConfig.publicDir }
)
await (warmPlugin.load as any).handler.call({}, '/@localSearchIndex')
expect(renderHook).toHaveBeenCalledTimes(2)
})
})
function loadIndex(serializedModule: string) {

@ -0,0 +1,176 @@
import { createVueDescriptorMemoryPlugin } from 'node/plugins/vueDescriptorMemory'
import type { Plugin } from 'vite'
describe('node/plugins/vueDescriptorMemory', () => {
test('compacts tracked compiler results without mutating the shared compiler', () => {
const { compiler, parseCacheClear } = createCompiler()
const vuePlugin = createVuePlugin(compiler)
const plugin = createVueDescriptorMemoryPlugin(vuePlugin)
const originalParse = compiler.parse
const originalCompileScript = compiler.compileScript
getBuildStart(plugin).call({})
const facade = (vuePlugin as any).api.options.compiler
expect(facade).not.toBe(compiler)
expect(compiler.parse).toBe(originalParse)
expect(compiler.compileScript).toBe(originalCompileScript)
const parsed = facade.parse('<template>large source</template>', {
filename: '/one.md'
})
const script = facade.compileScript(parsed.descriptor)
expect(plugin.api.retainedFiles).toBe(1)
getHook(plugin.buildEnd).call({}, undefined)
expect(plugin.api.retainedFiles).toBe(0)
expect(parsed.descriptor.source).toBe('')
expect(parsed.descriptor.template.content).toBe('')
expect(parsed.descriptor.template.ast).toBeUndefined()
expect(script.content).toBe('')
expect(script.scriptAst).toBeUndefined()
expect(parseCacheClear).toHaveBeenCalledTimes(1)
expect((vuePlugin as any).api.options.compiler).toBe(facade)
getHook(plugin.closeBundle).call({})
expect((vuePlugin as any).api.options.compiler).toBe(compiler)
expect(compiler.parse).toBe(originalParse)
expect(compiler.compileScript).toBe(originalCompileScript)
})
test('isolates concurrent plugin instances that share compiler-sfc', () => {
const { compiler } = createCompiler()
const vueA = createVuePlugin(compiler)
const vueB = createVuePlugin(compiler)
const pluginA = createVueDescriptorMemoryPlugin(vueA)
const pluginB = createVueDescriptorMemoryPlugin(vueB)
getBuildStart(pluginA).call({})
getBuildStart(pluginB).call({})
const facadeA = (vueA as any).api.options.compiler
const facadeB = (vueB as any).api.options.compiler
expect(facadeA).not.toBe(facadeB)
expect(compiler.parse).not.toBe(facadeA.parse)
expect(compiler.parse).not.toBe(facadeB.parse)
const descriptorA = facadeA.parse('source a', {
filename: '/shared.md'
}).descriptor
const descriptorB = facadeB.parse('source b', {
filename: '/shared.md'
}).descriptor
pluginA.api.release(['/shared.md'])
expect(descriptorA.source).toBe('')
expect(descriptorB.source).toBe('source b')
getHook(pluginA.closeBundle).call({})
expect((vueA as any).api.options.compiler).toBe(compiler)
expect((vueB as any).api.options.compiler).toBe(facadeB)
getHook(pluginB.closeBundle).call({})
expect((vueB as any).api.options.compiler).toBe(compiler)
})
test('leases compiler-sfc parse-cache results across concurrent builds', () => {
const { compiler } = createCompiler()
const sharedDescriptor = compiler.parse('shared source', {
filename: '/shared.md'
}).descriptor
compiler.parse.mockReturnValue({ descriptor: sharedDescriptor })
const vueA = createVuePlugin(compiler)
const vueB = createVuePlugin(compiler)
const pluginA = createVueDescriptorMemoryPlugin(vueA)
const pluginB = createVueDescriptorMemoryPlugin(vueB)
getBuildStart(pluginA).call({})
getBuildStart(pluginB).call({})
;(vueA as any).api.options.compiler.parse('shared source', {
filename: '/shared.md'
})
;(vueB as any).api.options.compiler.parse('shared source', {
filename: '/shared.md'
})
pluginA.api.release(['/shared.md'])
expect(sharedDescriptor.source).toBe('shared source')
pluginB.api.release(['/shared.md'])
expect(sharedDescriptor.source).toBe('')
getHook(pluginA.closeBundle).call({})
getHook(pluginB.closeBundle).call({})
})
test('restores its compiler facade when graph construction fails', () => {
const { compiler } = createCompiler()
const vuePlugin = createVuePlugin(compiler)
const plugin = createVueDescriptorMemoryPlugin(vuePlugin)
getBuildStart(plugin).call({})
expect((vuePlugin as any).api.options.compiler).not.toBe(compiler)
getHook(plugin.buildEnd).call({}, new Error('build failed'))
expect((vuePlugin as any).api.options.compiler).toBe(compiler)
})
})
function createCompiler() {
const parseCacheClear = vi.fn()
const compiler = {
parse: vi.fn((source: string, options: { filename: string }) => ({
descriptor: {
filename: options.filename,
source,
template: {
content: source,
ast: { source },
map: { source },
loc: { source }
},
script: null,
scriptSetup: null,
styles: [],
customBlocks: []
}
})),
compileScript: vi.fn((descriptor: { filename: string }) => ({
content: `compiled ${descriptor.filename}`,
scriptAst: { filename: descriptor.filename },
scriptSetupAst: { filename: descriptor.filename },
deps: [descriptor.filename],
imports: { value: true },
bindings: { value: true }
})),
parseCache: { clear: parseCacheClear }
}
return { compiler, parseCacheClear }
}
function createVuePlugin(
compiler: ReturnType<typeof createCompiler>['compiler']
): Plugin {
return {
name: 'vite:vue',
api: {
options: { compiler }
}
} as Plugin
}
function getBuildStart(plugin: Plugin): (...args: any[]) => any {
const hook = plugin.buildStart
if (!hook || typeof hook === 'function') {
throw new Error('Expected an object buildStart hook.')
}
return hook.handler
}
function getHook<T extends (...args: any[]) => any>(
hook: T | { handler: T } | undefined
): T {
if (!hook) throw new Error('Expected plugin hook.')
return typeof hook === 'function' ? hook : hook.handler
}

@ -0,0 +1,43 @@
import { execFile } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'
import {
cacheAllGitTimestamps,
getGitTimestamp
} from 'node/utils/getGitTimestamp'
const execFileAsync = promisify(execFile)
test('development cache misses are retried after a file is committed', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'vitepress-git-'))
const page = path.join(root, 'page.md')
try {
await execFileAsync('git', ['init'], { cwd: root })
await writeFile(page, '# Page')
await cacheAllGitTimestamps(root)
expect(await getGitTimestamp(page)).toBe(0)
await execFileAsync('git', ['add', 'page.md'], { cwd: root })
await execFileAsync(
'git',
[
'-c',
'user.name=VitePress Test',
'-c',
'user.email=vitepress@example.com',
'commit',
'-m',
'add page'
],
{ cwd: root }
)
expect(await getGitTimestamp(page)).toBeGreaterThan(0)
} finally {
await rm(root, { recursive: true, force: true })
}
})

@ -510,6 +510,60 @@ export default {
When set to `true`, the production app will be built in [MPA Mode](../guide/mpa-mode). MPA mode ships 0kb JavaScript by default, at the cost of disabling client-side navigation and requires explicit opt-in for interactivity.
### buildConcurrency
- Type: `number`
- Default: `64`
The maximum number of pages VitePress renders or finalizes concurrently during a build. Lower values reduce peak memory usage at the cost of build time. The value must be a positive integer.
When [SSR batching](#ssrbuildbatchsize) is enabled, this remains a global page-work budget. The coordinator divides it across the active workers and uses the same per-worker share while finalizing their results. With two active workers and `buildConcurrency: 64`, for example, each worker renders at most 32 pages concurrently. The effective per-worker limit is approximately:
```text
min(ssrBuildBatchSize, floor(buildConcurrency / activeWorkers))
```
This prevents page-level concurrency from multiplying merely because multiple workers are enabled; each worker's copy of the shared runtime still adds memory independently.
### ssrBuildBatchSize <Badge type="warning" text="experimental" />
- Type: `number`
- Default: `undefined`
Sets the maximum number of pages that one server-side rendering (SSR) worker processes. The value must be a positive integer.
Use this option to reduce memory use when you build a large site. A smaller batch uses less memory but can increase build time.
VitePress compiles the site once and then renders the pages in batches. Each batch runs in a new worker process.
```ts
export default {
ssrBuildBatchSize: 64
}
```
This option does not work with [`mpa`](#mpa). If you use custom Vite plugins, VitePress reports an error when a hook does not support batching.
### ssrBuildWorkerConcurrency <Badge type="warning" text="experimental" />
- Type: `number`
- Default: `1`
Sets the maximum number of SSR workers that run at the same time. This option applies only when you set [`ssrBuildBatchSize`](#ssrbuildbatchsize).
The value must be a positive integer. More workers can reduce build time, but each worker uses more memory.
```ts
export default {
ssrBuildBatchSize: 64,
ssrBuildWorkerConcurrency: 2
}
```
Start with `1`. Increase the value only if the build system has sufficient memory.
The [`buildConcurrency`](#buildconcurrency) option still limits the total number of pages that VitePress renders at the same time.
## Theming
### appearance

@ -67,7 +67,7 @@
"test:types": "tsc -p __tests__/unit && tsc -p __tests__/e2e && tsc -p __tests__/init && vue-tsc -p docs",
"test:unit": "vitest run -r __tests__/unit",
"test:unit:watch": "vitest -r __tests__/unit",
"test:e2e": "pnpm test:e2e-dev && pnpm test:e2e-build",
"test:e2e": "pnpm test:e2e-dev && pnpm test:e2e-build && pnpm test:e2e-ssr-batch",
"test:e2e:site:dev": "pnpm -F=tests-e2e site:dev",
"test:e2e:site:build": "pnpm -F=tests-e2e site:build",
"test:e2e:site:preview": "pnpm -F=tests-e2e site:preview",
@ -75,6 +75,7 @@
"test:e2e-dev:watch": "pnpm -F=tests-e2e watch",
"test:e2e-build": "VITE_TEST_BUILD=1 pnpm test:e2e-dev",
"test:e2e-build:watch": "VITE_TEST_BUILD=1 pnpm test:e2e-dev:watch",
"test:e2e-ssr-batch": "VITE_TEST_BUILD=1 VITE_TEST_SSR_BATCH=1 pnpm -F=tests-e2e exec vitest run ssr-batching.test.ts local-search/local-search.test.ts",
"test:init": "pnpm -F=tests-init test",
"test:init:watch": "pnpm -F=tests-init watch",
"docs": "pnpm --stream '/^(docs:)?dev$/'",

@ -39,7 +39,11 @@ const plugins = [
]
const esmBuild: RollupOptions = {
input: ['src/node/index.ts', 'src/node/cli.ts'],
input: [
'src/node/index.ts',
'src/node/cli.ts',
'src/node/build/ssrWorker.ts'
],
output: {
format: 'esm',
entryFileNames: `[name].js`,

@ -63,10 +63,12 @@ const VitePressApp = defineComponent({
}
})
export async function createApp() {
export type PageModuleLoader = Parameters<typeof createRouter>[0]
export async function createApp(loadPageModule?: PageModuleLoader) {
;(globalThis as any).__VITEPRESS__ = true
const router = newRouter()
const router = newRouter(loadPageModule)
const app = newApp()
@ -117,7 +119,11 @@ function newApp(): App {
: createClientApp(VitePressApp)
}
function newRouter(): Router {
function newRouter(loadPageModule?: PageModuleLoader): Router {
if (loadPageModule) {
return createRouter(loadPageModule, Theme.NotFound)
}
let isInitialPageLoad = inBrowser
return createRouter((path) => {

@ -1,12 +1,2 @@
// entry for SSR
import { renderToString } from 'vue/server-renderer'
import type { SSGContext } from '../shared'
import { createApp } from './index'
export async function render(path: string) {
const { app, router } = await createApp()
await router.go(path)
const ctx: SSGContext = { content: '', vpSocialIcons: new Set<string>() }
ctx.content = await renderToString(app, ctx)
return ctx
}
// legacy full-bundle SSR entry
export { render } from './ssrRuntime'

@ -0,0 +1,65 @@
import { createStaticVNode, defineComponent } from 'vue'
import { renderToString } from 'vue/server-renderer'
import type { PageData, SSGContext } from '../shared'
import { createApp, type PageModuleLoader } from './index'
type PageModule = Exclude<Awaited<ReturnType<PageModuleLoader>>, null>
export interface StaticPagePayload {
html: string
pageData: PageData
}
export type SsrPagePayload = PageModule | StaticPagePayload | null
function isStaticPagePayload(
payload: Exclude<SsrPagePayload, null>
): payload is StaticPagePayload {
return !('default' in payload)
}
function createStaticPageModule(payload: StaticPagePayload): PageModule {
const component = defineComponent({
name: payload.pageData.relativePath,
setup() {
return () => createStaticVNode(`<div>${payload.html}</div>`, 1)
}
})
return {
default: component,
__pageData: payload.pageData
}
}
async function renderWithLoader(
path: string,
loadPageModule?: PageModuleLoader
) {
const { app, router } = await createApp(loadPageModule)
await router.go(path)
const ctx: SSGContext = { content: '', vpSocialIcons: new Set<string>() }
ctx.content = await renderToString(app, ctx)
return ctx
}
/**
* Render through the production route loader. This keeps the legacy full SSR
* bundle entry working while the artifact renderer uses `renderPage` below.
*/
export function render(path: string) {
return renderWithLoader(path)
}
/**
* Render an already-loaded page module, or a conservative static Markdown
* payload, through the shared application and theme runtime.
*/
export function renderPage(path: string, payload: SsrPagePayload) {
const pageModule =
payload && isStaticPagePayload(payload)
? createStaticPageModule(payload)
: payload
return renderWithLoader(path, () => pageModule)
}

File diff suppressed because it is too large Load Diff

@ -1,3 +1,4 @@
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import { cp } from 'node:fs/promises'
import path from 'node:path'
@ -7,12 +8,18 @@ import {
build,
normalizePath,
type BuildOptions,
type Plugin,
type RenderBuiltAssetUrl,
type Rolldown,
type InlineConfig as ViteInlineConfig
} from 'vite'
import { APP_PATH } from '../alias'
import { APP_PATH, DEFAULT_THEME_PATH, DIST_CLIENT_PATH } from '../alias'
import type { SiteConfig } from '../config'
import { createVitePressPlugin, type PageMeta } from '../plugin'
import {
createVitePressPlugin,
type PageMeta,
type VitePressPluginOptions
} from '../plugin'
import { escapeRegExp, sanitizeFileName, slash } from '../shared'
import { buildMPAClient } from './buildMPAClient'
@ -37,75 +44,529 @@ const excludedModules = [
const cache = new Map<string, boolean>()
const cacheTheme = new Map<string, boolean>()
// bundles the VitePress app for both client AND server.
export async function bundle(
export type ClientAssetMap = Record<string, string>
export type SsrRuntimeBridgeMap = Record<string, string>
const builtAssetRE = /__VITE_ASSET__([\w$]+)__(?:\$_(.*?)__)?/
const publicAssetRE = /__VITE_PUBLIC_ASSET__[a-z\d]{8}__/
const rawQueryRE = /(?:\?|&)raw(?:&|$)/
const declarationSourceRE = /\.d\.[cm]?ts$/
const dependencyPathRE = /(?:^|\/)node_modules(?:\/|$)/
const knownAssetSourceRE =
/\.(?:apng|bmp|png|jpe?g|jfif|pjpeg|pjp|gif|svg|ico|webp|avif|cur|jxl|mp4|webm|ogg|mp3|wav|flac|aac|opus|mov|m4a|vtt|woff2?|eot|ttf|otf|webmanifest|pdf|txt)(?:$|[?#])/i
const nonRuntimeModuleQueryRE =
/(?:\?|&)(?:raw|url|inline|worker|sharedworker|init|direct)(?:[=&]|$)|\?vue&type=(?:style|custom)(?:&|$)/
function encodeURIPath(uri: string): string {
if (uri.startsWith('data:')) return uri
const postfixIndex = uri.search(/[?#]/)
const filePath = postfixIndex < 0 ? uri : uri.slice(0, postfixIndex)
const postfix = postfixIndex < 0 ? '' : uri.slice(postfixIndex)
return encodeURI(filePath) + postfix
}
function removeUrlQuery(url: string): string {
return url.replace(/(\?|&)url(?:&|$)/, '$1').replace(/[?&]$/, '')
}
/** @internal Exported for focused pipeline tests. */
export function captureClientAssetUrls(
config: SiteConfig,
options: BuildOptions,
assetMap: ClientAssetMap
): Plugin {
const pending = new Map<
string,
| { type: 'asset'; referenceId: string; postfix: string }
| { type: 'public'; url: string }
>()
let renderBuiltUrl: RenderBuiltAssetUrl | undefined
const resolveBuiltUrl = (
filename: string,
type: 'asset' | 'public',
hostId: string
) => {
const custom = renderBuiltUrl?.(filename, {
type,
hostId,
hostType: 'js',
// These URLs are consumed while executing SSR, but must point at files
// emitted by the client build.
ssr: true
})
if (typeof custom === 'string') {
if (custom) return custom
} else if (custom?.runtime) {
throw new Error(
`ssrBuildBatchSize cannot materialize the runtime renderBuiltUrl expression for ${filename}. Return a URL string for SSR assets instead.`
)
}
return slash(`${config.site.base}${filename.replace(/^\/+/, '')}`)
}
return {
name: 'vitepress:ssr-client-asset-map',
enforce: 'post',
configResolved(resolved) {
renderBuiltUrl = resolved.experimental.renderBuiltUrl
},
transform: {
// Rolldown applies this filter natively, avoiding a JS hook call for the
// app, every Markdown page module, and every Vue component.
filter: {
id: {
exclude:
/\.(?:[cm]?[jt]s|vue)(?:$|\?(?!url(?:&|#|$))(?![^#]*&url(?:&|#|$)))/
}
},
handler(code, id) {
const match = /^export default ("(?:[^"\\]|\\.)*")\s*;?\s*$/.exec(code)
if (!match) return
const value = JSON.parse(match[1]) as string
const asset = builtAssetRE.exec(value)
if (asset) {
pending.set(normalizePath(id), {
type: 'asset',
referenceId: asset[1],
// Vite stores the query/hash postfix verbatim. Decoding it changes
// URL semantics (and can throw for a literal `%`), so preserve it.
postfix: asset[2] || ''
})
} else if (publicAssetRE.test(value)) {
// A non-bundled SSR environment would otherwise produce a dev URL
// for public files. Mirror Vite's final client URL, including `base`,
// and remove only the asset-plugin's `?url` control query.
pending.set(normalizePath(id), {
type: 'public',
url: removeUrlQuery(id)
})
} else if (value.startsWith('data:') && !rawQueryRE.test(id)) {
assetMap[normalizePath(id)] = value
}
}
},
generateBundle(_options, bundle) {
const moduleHosts = new Map<string, string>()
for (const output of Object.values(bundle)) {
if (output.type !== 'chunk') continue
for (const moduleId of output.moduleIds) {
const normalizedId = normalizePath(moduleId)
if (!moduleHosts.has(normalizedId)) {
moduleHosts.set(normalizedId, output.fileName)
}
}
}
for (const [id, asset] of pending) {
const hostId = moduleHosts.get(id) ?? id
const filename =
asset.type === 'asset'
? `${this.getFileName(asset.referenceId)}${asset.postfix}`
: asset.url.replace(/^\/+/, '')
assetMap[id] = encodeURIPath(
resolveBuiltUrl(filename, asset.type, hostId)
)
}
}
}
}
function useClientAssetUrlsForSsr(assetMap: ClientAssetMap): Plugin {
return {
name: 'vitepress:ssr-client-asset-urls',
enforce: 'pre',
load(id) {
// `load` receives the fully resolved ID, which is also what the client
// capture pass records. Intercepting here avoids resolving every normal
// runtime import twice and still lets earlier custom loaders win.
const assetUrl = assetMap[normalizePath(id)]
if (assetUrl === undefined) return
return {
code: `export default ${JSON.stringify(assetUrl)}`,
moduleType: 'js'
}
}
}
}
/** @internal Exported for focused pipeline tests. */
export function createSsrRuntimeInput(
config: SiteConfig,
bridgeModuleIds: Set<string> = new Set()
) {
const input: Record<string, string> = {
app: path.resolve(APP_PATH, 'ssrRuntime.js'),
// These bridge entries share their chunks with the app entry. Page
// artifact runners can externalize VitePress imports to them without
// creating a second set of injection symbols or Vue app state.
vitepress: path.resolve(DIST_CLIENT_PATH, 'index.js'),
theme: path.resolve(DEFAULT_THEME_PATH, 'index.js')
}
bridgeModuleIds.add(normalizePath(input.vitepress))
bridgeModuleIds.add(normalizePath(input.theme))
if (normalizePath(config.themeDir) === normalizePath(DEFAULT_THEME_PATH)) {
return input
}
// Let Vite resolve the real theme entry, including plugin-provided or
// non-standard extensions. Other bridge facades are emitted only after
// Rolldown proves that the source is reachable from this runtime graph.
input['site-theme'] = '@theme/index'
return input
}
function isSsrRuntimeGraphSource(
id: string,
moduleInfo?: Pick<Rolldown.ModuleInfo, 'meta'>
): boolean {
const sourceId = id.replace(/[?#].*$/, '')
if (
moduleInfo?.meta?.['vite:asset'] ||
id.includes('#') ||
nonRuntimeModuleQueryRE.test(id) ||
declarationSourceRE.test(sourceId) ||
CSS_LANGS_RE.test(id) ||
knownAssetSourceRE.test(id)
) {
return false
}
if (id.startsWith('\0')) {
// Virtual JavaScript modules have no useful extension to inspect. Known
// style/asset queries were rejected above; declarations and Vite's own
// implementation modules are not source identities a page can import.
return (
!id.startsWith('\0vite/') &&
!id.startsWith('\0vite:') &&
!id.startsWith('\0plugin-vue:')
)
}
// Bare and native modules must retain Vite's normal SSR externalization.
// Package files are excluded even when a plugin forces a dependency into
// the runtime bundle; bridging dependencies would create an entry per
// package implementation module and defeat Node's package semantics.
if (!path.isAbsolute(id) || dependencyPathRE.test(id)) return false
// VitePress already exposes strict entries for its public client and
// default-theme roots. Descendant implementation files are package code,
// not site-local singleton identities.
if (
id === normalizePath(DIST_CLIENT_PATH) ||
id.startsWith(`${normalizePath(DIST_CLIENT_PATH)}/`)
) {
return false
}
// Reaching moduleParsed proves that a loader turned this local source into
// JavaScript. Do not require a conventional extension: user plugins can
// provide importable singleton modules from extensionless/custom files.
return true
}
function isSsrRuntimeBridgeSource(
id: string,
moduleInfo: Pick<Rolldown.ModuleInfo, 'meta'>
): boolean {
return (
isSsrRuntimeGraphSource(id, moduleInfo) &&
(id.startsWith('\0') || !id.includes('?'))
)
}
/** @internal Exported for focused pipeline tests. */
export function createSsrRuntimeBridgePlugin(
_config: Pick<SiteConfig, 'themeDir'>,
bridgeModuleIds: Set<string>
): Plugin {
const emitted = new Set<string>()
const reachableFromTheme = new Set<string>()
const parsedModules = new Map<string, Rolldown.ModuleInfo>()
let themeEntryId: string | undefined
const emitBridge = (
context: Rolldown.PluginContext,
moduleInfo: Rolldown.ModuleInfo
) => {
const id = normalizePath(moduleInfo.id)
if (!isSsrRuntimeBridgeSource(id, moduleInfo)) return
bridgeModuleIds.add(id)
if (moduleInfo.isEntry || emitted.has(id)) return
emitted.add(id)
context.emitFile({
type: 'chunk',
id: moduleInfo.id,
name: `site-runtime-${createHash('sha256').update(id).digest('hex').slice(0, 16)}`,
// Every source identity needs its own importable facade. Rolldown
// shares the implementation chunk with app.js rather than evaluating
// the module a second time.
preserveSignature: 'strict'
})
}
const visitThemeGraph = (
context: Rolldown.PluginContext,
startingId: string
) => {
const pending = [startingId]
while (pending.length) {
const rawId = pending.pop()!
const id = normalizePath(rawId)
if (reachableFromTheme.has(id)) continue
reachableFromTheme.add(id)
const moduleInfo = parsedModules.get(id)
if (!moduleInfo) continue
emitBridge(context, moduleInfo)
if (id !== themeEntryId && !isSsrRuntimeGraphSource(id, moduleInfo)) {
continue
}
pending.push(
...moduleInfo.importedIds,
...moduleInfo.dynamicallyImportedIds
)
}
}
return {
name: 'vitepress:ssr-runtime-theme-bridges',
resolveId(id) {
// A virtual module's owner commonly resolves only its public spelling
// (for example `virtual:state` -> `\0plugin:state`). Emitted chunk
// entries are resolved again from their canonical ID, so preserve that
// already-resolved identity for the facade without asking the owner to
// support a second resolve shape.
if (id.startsWith('\0') && emitted.has(normalizePath(id))) return id
},
async buildStart() {
// Record the resolved identity of the declared site-theme entry as well
// as file-backed descendants discovered below. This preserves custom
// resolver support when `@theme/index` maps to a virtual module or a
// source outside the conventional theme directory.
const resolved = await this.resolve('@theme/index', undefined, {
isEntry: true
})
if (!resolved || resolved.external) {
this.error(
'Unable to resolve the custom theme entry for the shared SSR runtime.'
)
}
const id = normalizePath(resolved.id)
themeEntryId = id
bridgeModuleIds.add(id)
visitThemeGraph(this, resolved.id)
},
moduleParsed(moduleInfo) {
const id = normalizePath(moduleInfo.id)
if (id !== themeEntryId && !isSsrRuntimeGraphSource(id, moduleInfo)) {
return
}
parsedModules.set(id, moduleInfo)
if (
reachableFromTheme.has(id) ||
moduleInfo.importers.some((importer) =>
reachableFromTheme.has(normalizePath(importer))
) ||
moduleInfo.dynamicImporters.some((importer) =>
reachableFromTheme.has(normalizePath(importer))
)
) {
// A module can be parsed through another runtime entry before its
// custom-theme importer is discovered. Walking the now-known forward
// graph makes the result independent of module traversal order.
reachableFromTheme.delete(id)
visitThemeGraph(this, moduleInfo.id)
}
}
}
}
/** @internal Exported for focused pipeline tests. */
export function collectSsrRuntimeBridges(
result: Rolldown.RolldownOutput,
outDir: string,
bridgeModuleIds: ReadonlySet<string>
): SsrRuntimeBridgeMap {
const bridges = Object.create(null) as SsrRuntimeBridgeMap
for (const output of result.output) {
if (output.type !== 'chunk' || !output.isEntry || !output.facadeModuleId) {
continue
}
const moduleId = normalizePath(output.facadeModuleId)
if (!bridgeModuleIds.has(moduleId)) continue
bridges[moduleId] = path.resolve(outDir, output.fileName)
}
const missing = [...bridgeModuleIds]
.filter((moduleId) => bridges[moduleId] === undefined)
.sort()
if (missing.length) {
throw new Error(
`The shared SSR runtime did not emit an entry facade for:\n${missing.map((id) => ` ${id}`).join('\n')}\nPage modules cannot safely reuse this runtime without those bridges.`
)
}
return bridges
}
const disableIsolatedSsrPublicCopyPlugin: Plugin = {
name: 'vitepress:isolated-ssr-public-copy',
enforce: 'post',
config: {
order: 'post',
handler(config) {
// The VitePress config hook merges the user's Vite config after the
// inline build config. Reassert this invariant after every user hook so
// disposable SSR output directories never receive the public tree.
const build = (config.build ??= {})
build.copyPublicDir = false
if (config.environments?.ssr?.build) {
config.environments.ssr.build.copyPublicDir = false
}
}
}
}
export type BundleTarget =
| {
mode: 'full'
vitePressPluginOptions?: VitePressPluginOptions
}
| {
mode: 'client'
vitePressPluginOptions?: VitePressPluginOptions
}
| {
mode: 'ssr-runtime'
outDir: string
clientAssetMap: ClientAssetMap
}
export interface ViteBuildConfigOptions {
ssr: boolean
pages?: string[]
outDir?: string
isolatedSsr?: boolean
runtime?: boolean
pageToHashMap?: Record<string, string>
clientJSMap?: Record<string, string>
pageMetaMap?: Record<string, PageMeta>
): Promise<{
clientResult: Rolldown.RolldownOutput | null
serverResult: Rolldown.RolldownOutput
pageToHashMap: Record<string, string>
}> {
const pageToHashMap = Object.create(null) as Record<string, string>
const clientJSMap = Object.create(null) as Record<string, string>
clientAssetMap?: ClientAssetMap
/** @internal Runtime source identities that require emitted entry facades. */
ssrRuntimeBridgeModuleIds?: Set<string>
vitePressPluginOptions?: VitePressPluginOptions
}
// define custom rolldown input
// this is a multi-entry build - every page is considered an entry chunk
// the loading is done via filename conversion rules so that the
// metadata doesn't need to be included in the main chunk.
const input: Record<string, string> = {}
config.pages.forEach((file) => {
// page filename conversion
// foo/bar.md -> foo_bar.md
const alias = config.rewrites.map[file] || file
input[slash(alias).replace(/\//g, '_')] = path.resolve(config.srcDir, file)
})
/**
* Create the Vite config used by the client, legacy SSR, shared SSR runtime,
* and page-artifact compiler. Keeping this in one place prevents those build
* paths from drifting in aliases, defines, and user plugin behavior.
*/
export async function createViteBuildConfig(
config: SiteConfig,
buildOptions: BuildOptions,
target: ViteBuildConfigOptions
): Promise<ViteInlineConfig> {
const {
ssr,
pages = config.pages,
outDir = config.tempDir,
isolatedSsr = false,
runtime = false,
pageToHashMap = Object.create(null) as Record<string, string>,
clientJSMap = Object.create(null) as Record<string, string>,
pageMetaMap,
clientAssetMap,
ssrRuntimeBridgeModuleIds = new Set<string>(),
vitePressPluginOptions
} = target
if (runtime && !ssr) {
throw new Error('The shared SSR runtime must use an SSR Vite config.')
}
const createPageInput = () => {
const input: Record<string, string> = {}
pages.forEach((file) => {
// page filename conversion: foo/bar.md -> foo_bar.md
const alias = config.rewrites.map[file] || file
input[slash(alias).replace(/\//g, '_')] = path.resolve(
config.srcDir,
file
)
})
return input
}
const themeEntryRE = new RegExp(
`^${escapeRegExp(slash(path.resolve(config.themeDir, 'index.js'))).slice(0, -2)}m?(j|t)s`
)
// resolve options to pass to vite
const {
rollupOptions,
rolldownOptions = rollupOptions,
...restOptions
} = options
} = buildOptions
const input = runtime
? createSsrRuntimeInput(config, ssrRuntimeBridgeModuleIds)
: {
app: path.resolve(APP_PATH, ssr ? 'ssr.js' : 'index.js'),
...createPageInput()
}
const resolveViteConfig = async (
ssr: boolean
): Promise<ViteInlineConfig> => ({
return {
root: config.srcDir,
cacheDir: config.cacheDir,
base: config.site.base,
logLevel: config.vite?.logLevel ?? 'warn',
plugins: await createVitePressPlugin(
config,
ssr,
pageToHashMap,
clientJSMap,
pageMetaMap
),
plugins: [
...(await createVitePressPlugin(
config,
ssr,
pageToHashMap,
clientJSMap,
pageMetaMap,
undefined,
{ ...vitePressPluginOptions, isSsrBatch: isolatedSsr }
)),
...(!ssr && clientAssetMap
? [captureClientAssetUrls(config, clientAssetMap)]
: []),
...(ssr && runtime && clientAssetMap
? [useClientAssetUrlsForSsr(clientAssetMap)]
: []),
...(ssr &&
runtime &&
normalizePath(config.themeDir) !== normalizePath(DEFAULT_THEME_PATH)
? [createSsrRuntimeBridgePlugin(config, ssrRuntimeBridgeModuleIds)]
: []),
...(isolatedSsr ? [disableIsolatedSsrPublicCopyPlugin] : [])
],
ssr: { noExternal: ['vitepress', '@docsearch/css'] },
build: {
...restOptions,
emptyOutDir: true,
copyPublicDir: isolatedSsr ? false : restOptions.copyPublicDir,
ssr,
ssrEmitAssets: config.mpa,
minify: ssr ? !!config.mpa : (options.minify ?? !process.env.DEBUG),
outDir: ssr ? config.tempDir : config.outDir,
minify: ssr
? runtime
? (buildOptions.minify ?? (process.env.DEBUG ? false : 'oxc'))
: !!config.mpa
: (buildOptions.minify ?? !process.env.DEBUG),
outDir: ssr ? outDir : config.outDir,
cssCodeSplit: false,
rolldownOptions: {
...rolldownOptions,
input: {
// use different entry based on ssr or not
app: path.resolve(APP_PATH, ssr ? 'ssr.js' : 'index.js'),
...input
},
input,
// important so that each page chunk and the index export things for
// each other
preserveEntrySignatures: 'allow-extension',
preserveEntrySignatures: runtime ? 'strict' : 'allow-extension',
output: {
sanitizeFileName,
...rolldownOptions?.output,
@ -132,20 +593,96 @@ export async function bundle(
}
},
configFile: config.vite?.configFile
})
}
}
// bundles the VitePress app for the client, server, or both.
export async function bundle(
config: SiteConfig,
options: BuildOptions,
pageMetaMap?: Record<string, PageMeta>,
target: BundleTarget = { mode: 'full' }
): Promise<{
clientResult: Rolldown.RolldownOutput | null
serverResult: Rolldown.RolldownOutput | null
pageToHashMap: Record<string, string>
clientAssetMap: ClientAssetMap
ssrRuntimeBridgeMap: SsrRuntimeBridgeMap
}> {
const pageToHashMap = Object.create(null) as Record<string, string>
const clientJSMap = Object.create(null) as Record<string, string>
const clientAssetMap = Object.create(null) as ClientAssetMap
let ssrRuntimeBridgeMap = Object.create(null) as SsrRuntimeBridgeMap
let clientResult: Rolldown.RolldownOutput | null = null
let serverResult: Rolldown.RolldownOutput | null = null
if (target.mode === 'ssr-runtime') {
if (config.mpa) {
throw new Error('The shared SSR runtime is not compatible with MPA mode.')
}
const ssrRuntimeBridgeModuleIds = new Set<string>()
serverResult = (await build(
await createViteBuildConfig(config, options, {
ssr: true,
pages: [],
outDir: target.outDir,
isolatedSsr: true,
runtime: true,
pageToHashMap,
clientJSMap,
pageMetaMap,
clientAssetMap: target.clientAssetMap,
ssrRuntimeBridgeModuleIds
})
)) as Rolldown.RolldownOutput
ssrRuntimeBridgeMap = collectSsrRuntimeBridges(
serverResult,
target.outDir,
ssrRuntimeBridgeModuleIds
)
return {
clientResult,
serverResult,
pageToHashMap,
clientAssetMap,
ssrRuntimeBridgeMap
}
}
let clientResult = config.mpa
? null
: ((await build(await resolveViteConfig(false))) as Rolldown.RolldownOutput)
const serverResult = (await build(
await resolveViteConfig(true)
)) as Rolldown.RolldownOutput
if (!config.mpa) {
clientResult = (await build(
await createViteBuildConfig(config, options, {
ssr: false,
pageToHashMap,
clientJSMap,
pageMetaMap,
clientAssetMap: target.mode === 'client' ? clientAssetMap : undefined,
vitePressPluginOptions:
target.mode === 'client' || target.mode === 'full'
? target.vitePressPluginOptions
: undefined
})
)) as Rolldown.RolldownOutput
}
if (target.mode === 'full') {
serverResult = (await build(
await createViteBuildConfig(config, options, {
ssr: true,
pageToHashMap,
clientJSMap,
pageMetaMap,
vitePressPluginOptions: target.vitePressPluginOptions
})
)) as Rolldown.RolldownOutput
}
if (config.mpa) {
// in MPA mode, we need to copy over the non-js asset files from the
// server build since there is no client-side build.
await pMap(
serverResult.output,
serverResult!.output,
async (chunk) => {
if (!chunk.fileName.endsWith('.js')) {
const tempPath = path.resolve(config.tempDir, chunk.fileName)
@ -178,7 +715,13 @@ export async function bundle(
sortedPageToHashMap[key] = pageToHashMap[key]
})
return { clientResult, serverResult, pageToHashMap: sortedPageToHashMap }
return {
clientResult,
serverResult,
pageToHashMap: sortedPageToHashMap,
clientAssetMap,
ssrRuntimeBridgeMap
}
}
function chunkName(

@ -12,52 +12,184 @@ import {
notFoundPageData,
resolveSiteDataByRoute,
sanitizeFileName,
slash,
type HeadConfig,
type PageData,
type SSGContext
} from '../shared'
import { nativeImport } from '../utils/nativeImport'
export interface PageChunkInfo {
fileName: string
code: string
}
export interface RenderMetadata {
appChunk?: { fileName: string; imports: string[] }
cssChunk?: { fileName: string }
assets: string[]
isDefaultTheme: boolean
pageImports: Map<string, string[]>
pageChunks: Map<string, PageChunkInfo>
}
export interface SerializedRenderMetadata extends Omit<
RenderMetadata,
'pageImports' | 'pageChunks'
> {
pageImports: [string, string[]][]
pageChunks: [string, PageChunkInfo][]
}
/**
* The output of Vue SSR before VitePress runs user hooks and writes HTML.
*
* Keeping this boundary free of SiteConfig allows lightweight render workers
* to return their result to the coordinator, where closure-bearing build hooks
* can run without resolving the user's config again.
*/
export interface RenderedPage {
page: string
pageData: PageData
hasCustom404: boolean
context: SSGContext
}
export type SerializedSSGContext = Omit<SSGContext, 'vpSocialIcons'> & {
vpSocialIcons: string[]
}
export interface SerializedRenderedPage extends Omit<RenderedPage, 'context'> {
context: SerializedSSGContext
}
export function createRenderMetadata(
config: SiteConfig,
clientResult: Rolldown.RolldownOutput | null | undefined,
serverResult: Rolldown.RolldownOutput | null | undefined
): RenderMetadata {
const clientOutput = clientResult?.output ?? []
const assetOutput = (config.mpa ? serverResult : clientResult)?.output ?? []
const cssChunk = assetOutput.find(
(chunk): chunk is Rolldown.OutputAsset =>
chunk.type === 'asset' && chunk.fileName.endsWith('.css')
)
const assets = assetOutput
.filter(
(chunk): chunk is Rolldown.OutputAsset =>
chunk.type === 'asset' && !chunk.fileName.endsWith('.css')
)
.map((asset) => config.site.base + asset.fileName)
const isDefaultTheme = clientOutput.some(
(chunk): chunk is Rolldown.OutputChunk =>
chunk.type === 'chunk' &&
chunk.name === 'theme' &&
chunk.moduleIds.some((id) => id.includes('client/theme-default'))
)
const pageImports = new Map<string, string[]>()
const pageChunks = new Map<string, PageChunkInfo>()
let appChunk: RenderMetadata['appChunk']
for (const chunk of clientOutput) {
if (chunk.type !== 'chunk') continue
if (!appChunk && chunk.isEntry && chunk.facadeModuleId?.endsWith('.js')) {
appChunk = { fileName: chunk.fileName, imports: [...chunk.imports] }
}
if (!chunk.isEntry || !chunk.facadeModuleId?.endsWith('.md')) {
continue
}
const facadeModuleId = normalizePath(chunk.facadeModuleId)
if (config.mpa) {
pageChunks.set(facadeModuleId, {
fileName: chunk.fileName,
code: chunk.code
})
} else {
pageImports.set(facadeModuleId, [...chunk.imports])
}
}
return {
appChunk,
cssChunk: cssChunk ? { fileName: cssChunk.fileName } : undefined,
assets,
isDefaultTheme,
pageImports,
pageChunks
}
}
export function serializeRenderMetadata(
metadata: RenderMetadata
): SerializedRenderMetadata {
return {
...metadata,
pageImports: [...metadata.pageImports],
pageChunks: [...metadata.pageChunks]
}
}
export function deserializeRenderMetadata(
metadata: SerializedRenderMetadata
): RenderMetadata {
return {
...metadata,
pageImports: new Map(metadata.pageImports),
pageChunks: new Map(metadata.pageChunks)
}
}
export async function renderPage(
render: (path: string) => Promise<SSGContext>,
config: SiteConfig,
page: string, // foo.md
result: Rolldown.RolldownOutput | null | undefined,
appChunk: Rolldown.OutputChunk | null | undefined,
cssChunk: Rolldown.OutputAsset | null | undefined,
assets: string[],
renderMetadata: RenderMetadata,
pageToHashMap: Record<string, string>,
metadataScript: { html: string; inHead: boolean },
additionalHeadTags: HeadConfig[],
usedIcons: Set<string>
usedIcons: Set<string>,
serverTempDir = config.tempDir
) {
const routePath = `/${page.replace(/\.md$/, '')}`
// render page
const context = await render(routePath)
const { content, teleports, vpSocialIcons } =
(await config.postRender?.(context)) ?? context
const pageName = sanitizeFileName(page.replace(/\//g, '_'))
const renderedPage = await renderPageToResult(
render,
page,
path.join(serverTempDir, pageName + '.js')
)
// add used social icons to the set
vpSocialIcons.forEach((icon) => usedIcons.add(icon))
await finalizeRenderedPage(
renderedPage,
config,
renderMetadata,
pageToHashMap,
metadataScript,
additionalHeadTags,
usedIcons
)
}
const pageName = sanitizeFileName(page.replace(/\//g, '_'))
// server build doesn't need hash
const pageServerJsFileName = pageName + '.js'
// for any initial page load, we only need the lean version of the page js
// since the static content is already on the page!
const pageHash = pageToHashMap[pageName.toLowerCase()]
const pageClientJsFileName = `${config.assetsDir}/${pageName}.${pageHash}.lean.js`
/**
* Render a page and load its page data without invoking user build hooks or
* writing to the final output directory.
*/
export async function renderPageToResult(
render: (path: string) => Promise<SSGContext>,
page: string,
pageModulePath: string
): Promise<RenderedPage> {
const routePath = `/${page.replace(/\.md$/, '')}`
const context = await render(routePath)
let pageData: PageData
let hasCustom404 = true
try {
// resolve page data so we can render head tags
const { __pageData } = await nativeImport(
path.join(config.tempDir, pageServerJsFileName)
)
const { __pageData } = await nativeImport(pageModulePath)
pageData = __pageData
} catch (e) {
if (page === '404.md') {
@ -68,6 +200,62 @@ export async function renderPage(
}
}
return { page, pageData, hasCustom404, context }
}
/** Convert Set-backed SSR state into a transport-friendly representation. */
export function serializeRenderedPage(
renderedPage: RenderedPage
): SerializedRenderedPage {
return {
...renderedPage,
context: {
...renderedPage.context,
vpSocialIcons: [...renderedPage.context.vpSocialIcons].sort()
}
}
}
/** Restore the SSR context shape expected by postRender and the finalizer. */
export function deserializeRenderedPage(
renderedPage: SerializedRenderedPage
): RenderedPage {
return {
...renderedPage,
context: {
...renderedPage.context,
content: renderedPage.context.content,
vpSocialIcons: new Set(renderedPage.context.vpSocialIcons)
}
}
}
/**
* Run coordinator-owned hooks and emit one final HTML page.
*/
export async function finalizeRenderedPage(
renderedPage: RenderedPage,
config: SiteConfig,
renderMetadata: RenderMetadata,
pageToHashMap: Record<string, string>,
metadataScript: { html: string; inHead: boolean },
additionalHeadTags: HeadConfig[],
usedIcons: Set<string>
) {
const { page, pageData, hasCustom404 } = renderedPage
const context =
(await config.postRender?.(renderedPage.context)) ?? renderedPage.context
const { content, teleports, vpSocialIcons } = context
const { appChunk, cssChunk, assets, pageImports, pageChunks } = renderMetadata
vpSocialIcons.forEach((icon) => usedIcons.add(icon))
const pageName = sanitizeFileName(page.replace(/\//g, '_'))
// for any initial page load, we only need the lean version of the page js
// since the static content is already on the page!
const pageHash = pageToHashMap[pageName.toLowerCase()]
const pageClientJsFileName = `${config.assetsDir}/${pageName}.${pageHash}.lean.js`
const siteData = resolveSiteDataByRoute(config.site, page, pageData.filePath)
const title: string = createTitle(siteData, pageData)
@ -79,13 +267,18 @@ export async function renderPage(
let preloadLinks =
config.mpa || (!hasCustom404 && page === '404.md')
? []
: result && appChunk
: appChunk
? [
...new Set([
// resolve imports for index.js + page.md.js and inject script tags
// for them as well so we fetch everything as early as possible
// without having to wait for entry chunks to parse
...(await resolvePageImports(config, page, result, appChunk)),
...(await resolvePageImports(
config,
page,
pageImports,
appChunk
)),
pageClientJsFileName
])
]
@ -138,11 +331,9 @@ export async function renderPage(
)
let inlinedScript = ''
if (config.mpa && result) {
const matchingChunk = result.output.find(
(chunk): chunk is Rolldown.OutputChunk =>
chunk.type === 'chunk' &&
chunk.facadeModuleId === slash(path.join(config.srcDir, page))
if (config.mpa) {
const matchingChunk = pageChunks.get(
normalizePath(path.join(config.srcDir, page))
)
if (matchingChunk) {
if (!matchingChunk.code.includes('import')) {
@ -210,8 +401,8 @@ export async function renderPage(
async function resolvePageImports(
config: SiteConfig,
page: string,
result: Rolldown.RolldownOutput,
appChunk: Rolldown.OutputChunk
pageImports: Map<string, string[]>,
appChunk: { fileName: string; imports: string[] }
) {
page = config.rewrites.inv[page] || page
// find the page's js chunk and inject script tags for its imports so that
@ -226,14 +417,11 @@ async function resolvePageImports(
// fail, which is expected
}
srcPath = normalizePath(srcPath)
const pageChunk = result.output.find(
(chunk): chunk is Rolldown.OutputChunk =>
chunk.type === 'chunk' && chunk.facadeModuleId === srcPath
)
const imports = pageImports.get(srcPath) || []
return [
...appChunk.imports,
// ...appChunk.dynamicImports,
...(pageChunk?.imports || [])
...imports
// ...pageChunk.dynamicImports
]
}

@ -0,0 +1,190 @@
import { createHash } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import pMap from 'p-map'
import type { SiteConfig } from '../config'
import { disposeMdItInstance } from '../markdown/markdown'
import { clearCache } from '../markdownToVue'
import { slash, type HeadConfig, type SSGContext } from '../shared'
import { deserializeFunctions, serializeFunctions } from '../utils/fnSerialize'
import { nativeImport } from '../utils/nativeImport'
import { createRenderMetadata, renderPage, type RenderMetadata } from './render'
export function disposeBuildCaches(): void {
clearBuildCaches()
disposeMdItInstance()
}
export function clearBuildCaches(): void {
clearCache()
}
function createAdditionalHeadTags(
renderMetadata: RenderMetadata
): HeadConfig[] {
const additionalHeadTags: HeadConfig[] = []
if (renderMetadata.isDefaultTheme) {
const fontURL = renderMetadata.assets.find((file) =>
/inter-roman-latin\.[\w-]+\.woff2/.test(file)
)
if (fontURL) {
additionalHeadTags.push([
'link',
{
rel: 'preload',
href: fontURL,
as: 'font',
type: 'font/woff2',
crossorigin: ''
}
])
}
}
return additionalHeadTags
}
async function generateMetadataScript(
pageToHashMap: Record<string, string>,
config: SiteConfig
): Promise<{ html: string; inHead: boolean }> {
if (config.mpa) {
return { html: '', inHead: false }
}
// We embed the hash map and site config strings into each page directly
// so that it doesn't alter the main chunk's hash on every build.
// It's also embedded as a string and JSON.parsed from the client because
// it's faster than embedding as JS object literal.
const hashMapString = JSON.stringify(JSON.stringify(pageToHashMap))
const siteDataString = JSON.stringify(
JSON.stringify(serializeFunctions({ ...config.site, head: [] }))
)
const metadataContent = `window.__VP_HASH_MAP__=JSON.parse(${hashMapString});${
siteDataString.includes('_vp-fn_')
? `${deserializeFunctions};window.__VP_SITE_DATA__=deserializeFunctions(JSON.parse(${siteDataString}));`
: `window.__VP_SITE_DATA__=JSON.parse(${siteDataString});`
}`
const metadataFile = path.join(
config.assetsDir,
'chunks',
`metadata.${createHash('sha256')
.update(metadataContent)
.digest('hex')
.slice(0, 8)}.js`
)
const resolvedMetadataFile = path.join(config.outDir, metadataFile)
const metadataFileURL = slash(`${config.site.base}${metadataFile}`)
await mkdir(path.dirname(resolvedMetadataFile), { recursive: true })
await writeFile(resolvedMetadataFile, metadataContent)
return {
html: `<script type="module" src="${metadataFileURL}"></script>`,
inHead: true
}
}
export async function prepareRenderInputs(
siteConfig: SiteConfig,
clientResult: Parameters<typeof createRenderMetadata>[1],
serverResult: Parameters<typeof createRenderMetadata>[2],
pageToHashMap: Record<string, string>
) {
const renderMetadata = createRenderMetadata(
siteConfig,
clientResult,
serverResult
)
return {
renderMetadata,
additionalHeadTags: createAdditionalHeadTags(renderMetadata),
metadataScript: await generateMetadataScript(pageToHashMap, siteConfig)
}
}
export async function getRenderer(tempDir: string) {
const { render } = await nativeImport(path.join(tempDir, 'app.js'))
return render as (path: string) => Promise<SSGContext>
}
export async function renderPages(
render: (path: string) => Promise<SSGContext>,
siteConfig: SiteConfig,
serverTempDir: string,
pages: string[],
renderMetadata: RenderMetadata,
pageToHashMap: Record<string, string>,
metadataScript: { html: string; inHead: boolean },
additionalHeadTags: HeadConfig[],
usedIcons: Set<string>
): Promise<void> {
await pMap(
pages,
async (page) => {
await renderPage(
render,
siteConfig,
siteConfig.rewrites.map[page] || page,
renderMetadata,
pageToHashMap,
metadataScript,
additionalHeadTags,
usedIcons,
serverTempDir
)
},
{ concurrency: siteConfig.buildConcurrency }
)
}
export function createRouteDigest(siteConfig: SiteConfig): string {
const hash = createHash('sha256')
const add = (value: string | undefined) => {
if (value === undefined) {
hash.update('-1:')
} else {
hash.update(`${Buffer.byteLength(value)}:`)
hash.update(value)
}
}
const sortEntries = (entries: [string, string | undefined][]) =>
entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
hash.update('pages:')
siteConfig.pages.forEach(add)
hash.update(`dynamicRoutes:${siteConfig.dynamicRoutes.length}:`)
for (const route of siteConfig.dynamicRoutes) {
add(route.route)
add(route.path)
add(route.fullPath)
add(route.loaderPath)
const params = sortEntries(Object.entries(route.params))
hash.update(`params:${params.length}:`)
for (const [key, value] of params) {
add(key)
add(value)
}
add(route.content)
}
hash.update('rewrites.map:')
for (const [key, value] of sortEntries(
Object.entries(siteConfig.rewrites.map)
)) {
add(key)
add(value)
}
hash.update('rewrites.inv:')
for (const [key, value] of sortEntries(
Object.entries(siteConfig.rewrites.inv)
)) {
add(key)
add(value)
}
return hash.digest('hex')
}

@ -0,0 +1,412 @@
import type { Plugin, Rolldown } from 'vite'
export interface SsrBatchPlan {
offset: number
pages: string[]
}
const SSR_PAGE_UNBUNDLED_HOOKS = [
'moduleParsed',
'resolveDynamicImport',
'augmentChunkHash',
'outputOptions',
'renderChunk',
'renderStart',
'renderError',
'writeBundle',
'generateBundle',
'banner',
'footer',
'intro',
'outro'
] as const
const SSR_PAGE_OUTPUT_ADDONS = ['banner', 'footer', 'intro', 'outro'] as const
const SSR_PAGE_CONTEXT_HOOKS = [
'options',
'buildStart',
'resolveId',
'load',
'transform',
'buildEnd',
'closeBundle'
] as const
const SSR_PAGE_UNAVAILABLE_CONTEXT_METHODS = new Set<PropertyKey>([
'emitFile',
'getFileName',
'getModuleIds'
])
const buildModeEnvironmentFacades = new WeakMap<object, object>()
type SsrPageUnbundledHook = (typeof SSR_PAGE_UNBUNDLED_HOOKS)[number]
type PageUnbundledPlugin = {
name?: string
} & Partial<Record<SsrPageUnbundledHook, unknown>>
interface PageUnbundledHookViolation {
hooks: SsrPageUnbundledHook[]
name: string
source: 'plugin' | 'output plugin' | 'output options'
}
function isViteInternalPlugin(name: string): boolean {
return (
name === 'alias' ||
name === 'vitepress' ||
name.startsWith('vite:') ||
name.startsWith('vitepress:') ||
name.startsWith('builtin:') ||
name.startsWith('native:')
)
}
function unbundledHooks(plugin: PageUnbundledPlugin): SsrPageUnbundledHook[] {
return SSR_PAGE_UNBUNDLED_HOOKS.filter((hook) => plugin[hook] != null)
}
function unsupportedPageContextMethod(
pluginName: string,
method: PropertyKey
): never {
throw new Error(
`Vite plugin ${JSON.stringify(pluginName)} called this.${String(method)}() while compiling unbundled SSR page modules. ` +
'SSR batching has no Rolldown output or complete bundle graph for this context method. Exclude the plugin from the unbundled SSR environment or disable ssrBuildBatchSize.'
)
}
function createBuildModeEnvironment(environment: object): object {
const existing = buildModeEnvironmentFacades.get(environment)
if (existing) return existing
const facade = new Proxy(environment, {
get(target, property) {
if (property === 'mode') return 'build'
const value = Reflect.get(target, property, target)
return typeof value === 'function' ? value.bind(target) : value
},
set(target, property, value) {
if (property === 'mode') return value === 'build'
return Reflect.set(target, property, value, target)
}
})
buildModeEnvironmentFacades.set(environment, facade)
return facade
}
function createBuildModePluginContext(
context: unknown,
pluginName: string
): unknown {
if (!context || typeof context !== 'object') return context
const target = context as Record<PropertyKey, unknown>
const environment = target.environment
const buildEnvironment =
environment && typeof environment === 'object'
? createBuildModeEnvironment(environment)
: environment
const meta = target.meta
const buildMeta =
meta && typeof meta === 'object'
? { ...(meta as object), watchMode: false }
: meta
return new Proxy(target, {
get(target, property) {
if (property === 'environment') return buildEnvironment
if (property === 'meta') return buildMeta
// Rolldown's production context does not expose these serve-only APIs.
// Hide Vite's runnable-environment additions so feature detection is
// identical to a production plugin context.
if (property === 'setAssetSource' || property === 'getWatchFiles') {
return undefined
}
if (
SSR_PAGE_UNAVAILABLE_CONTEXT_METHODS.has(property) &&
property in target
) {
return () => unsupportedPageContextMethod(pluginName, property)
}
if (
property === 'getCombinedSourcemap' &&
typeof target._getCombinedSourcemap === 'function'
) {
return target._getCombinedSourcemap.bind(target)
}
const value = Reflect.get(target, property, target)
return typeof value === 'function' ? value.bind(target) : value
}
})
}
function adaptPluginHookContext(hook: unknown, pluginName: string): unknown {
const handler =
typeof hook === 'function'
? hook
: hook && typeof hook === 'object'
? (hook as { handler?: unknown }).handler
: undefined
if (typeof handler !== 'function') return hook
const wrapped = function (this: unknown, ...args: unknown[]) {
return Reflect.apply(
handler,
createBuildModePluginContext(this, pluginName),
args
)
}
return typeof hook === 'function'
? wrapped
: { ...(hook as object), handler: wrapped }
}
/**
* A RunnableDevEnvironment supplies the transform/module graph needed by the
* offline compiler, but its raw user contexts identify as dev/watch mode and
* silently ignore Rolldown-only methods. Present production semantics to user
* hooks and fail unsupported output/whole-graph access explicitly. Vite's own
* plugins retain their native runnable-environment contexts.
*
* @internal Exported for focused pipeline tests.
*/
export function adaptSsrBatchPagePlugins(plugins: readonly Plugin[]): Plugin[] {
return plugins.map((plugin) => {
if (isViteInternalPlugin(plugin.name)) return plugin
// A Proxy cannot legally return a wrapped hook for a frozen plugin's
// non-configurable property. Copy resolved values onto a loose facade so
// frozen and class-based plugin objects remain supported.
const adapted = Object.create(Object.getPrototypeOf(plugin)) as Plugin &
Record<PropertyKey, unknown>
for (const property of Reflect.ownKeys(plugin)) {
const descriptor = Object.getOwnPropertyDescriptor(plugin, property)!
Object.defineProperty(adapted, property, {
configurable: true,
enumerable: descriptor.enumerable,
value: Reflect.get(plugin, property, plugin),
writable: true
})
}
for (const hook of SSR_PAGE_CONTEXT_HOOKS) {
const value = Reflect.get(plugin, hook, plugin)
if (value == null) continue
Object.defineProperty(adapted, hook, {
configurable: true,
enumerable: true,
value: adaptPluginHookContext(value, plugin.name || '<anonymous>'),
writable: true
})
}
return adapted
})
}
async function collectOutputPlugins(
option: Rolldown.OutputOptions['plugins'],
collected: PageUnbundledPlugin[]
): Promise<void> {
const resolved = await option
if (Array.isArray(resolved)) {
for (const nested of resolved) {
await collectOutputPlugins(nested, collected)
}
} else if (resolved && typeof resolved === 'object') {
collected.push(resolved as PageUnbundledPlugin)
}
}
/**
* Batched page modules are transformed in an unbundled Vite environment, so
* Rolldown's bundle-graph and output-generation phases never run for them.
* Reject user hooks that would otherwise observe or mutate the legacy SSR
* page bundle. `buildEnd` is deliberately supported: the unbundled plugin
* container runs it during compiler teardown.
*
* @internal Exported for focused pipeline tests.
*/
export async function validateSsrBatchPageOutputHooks(
plugins: readonly Plugin[],
output: Rolldown.OutputOptions | Rolldown.OutputOptions[] | undefined
): Promise<void> {
const violations: PageUnbundledHookViolation[] = []
for (const plugin of plugins) {
if (isViteInternalPlugin(plugin.name)) continue
const hooks = unbundledHooks(plugin)
if (hooks.length) {
violations.push({ hooks, name: plugin.name, source: 'plugin' })
}
}
const outputs = output ? (Array.isArray(output) ? output : [output]) : []
for (const [index, options] of outputs.entries()) {
const optionHooks = SSR_PAGE_OUTPUT_ADDONS.filter(
(hook) => options[hook] != null
)
if (optionHooks.length) {
violations.push({
hooks: optionHooks,
name: outputs.length === 1 ? 'output' : `output[${index}]`,
source: 'output options'
})
}
const outputPlugins: PageUnbundledPlugin[] = []
await collectOutputPlugins(options.plugins, outputPlugins)
for (const plugin of outputPlugins) {
const hooks = unbundledHooks(plugin)
if (hooks.length) {
violations.push({
hooks,
name: plugin.name || '<anonymous>',
source: 'output plugin'
})
}
}
}
if (!violations.length) return
const details = violations
.map(
({ hooks, name, source }) =>
` - ${source} ${JSON.stringify(name)}: ${hooks.join(', ')}`
)
.join('\n')
throw new Error(
`SSR batching cannot preserve Rolldown bundle hooks for unbundled SSR page modules:\n${details}\nDisable ssrBuildBatchSize. If these hooks are bundled-only, register the plugin in vite.plugins and exclude it from the unbundled SSR environment with applyToEnvironment.`
)
}
export function validateBuildConcurrency(value: unknown): number {
if (!Number.isInteger(value) || (value as number) < 1) {
throw new Error('buildConcurrency must be a positive integer.')
}
return value as number
}
export function validateSsrBuildBatchSize(value: unknown): number | undefined {
if (value === undefined) return
if (!Number.isInteger(value) || (value as number) < 1) {
throw new Error('ssrBuildBatchSize must be a positive integer.')
}
return value as number
}
export function validateSsrBuildWorkerConcurrency(value: unknown): number {
if (!Number.isInteger(value) || (value as number) < 1) {
throw new Error('ssrBuildWorkerConcurrency must be a positive integer.')
}
return value as number
}
export function createSsrBatchPlan(
sitePages: string[],
batchSize: number
): SsrBatchPlan[] {
batchSize = validateSsrBuildBatchSize(batchSize)!
const renderQueue = [
'404.md',
...sitePages.filter((page) => page !== '404.md')
]
const batches: SsrBatchPlan[] = []
for (let offset = 0; offset < renderQueue.length; offset += batchSize) {
batches.push({
offset,
pages: renderQueue.slice(offset, offset + batchSize)
})
}
return batches
}
const WORKER_ENTRYPOINT_FLAGS = new Set([
'-c',
'--check',
'-i',
'--interactive',
'--test',
'--watch',
'--watch-preserve-output'
])
const WORKER_ENTRYPOINT_FLAGS_WITH_VALUES = new Set([
'-e',
'--eval',
'-p',
'--print',
'--input-type',
'--run',
'--watch-path',
'--test-concurrency',
'--test-isolation',
'--test-name-pattern',
'--test-reporter',
'--test-reporter-destination',
'--test-shard',
'--test-timeout'
])
export function createWorkerExecArgv(execArgv: string[]): string[] {
const result: string[] = []
for (let index = 0; index < execArgv.length; index++) {
const argument = execArgv[index]
if (argument === '--') continue
const shortEntrypointFlags = /^-([ceip]+)$/.exec(argument)?.[1]
if (shortEntrypointFlags) {
if (/[ep]/.test(shortEntrypointFlags) && index + 1 < execArgv.length) {
index++
}
continue
}
if (WORKER_ENTRYPOINT_FLAGS.has(argument)) continue
const valueFlag = [...WORKER_ENTRYPOINT_FLAGS_WITH_VALUES].find(
(flag) => argument === flag || argument.startsWith(`${flag}=`)
)
if (valueFlag) {
if (argument === valueFlag && index + 1 < execArgv.length) index++
continue
}
if (
argument.startsWith('--test-') ||
argument.startsWith('--experimental-test-') ||
argument.startsWith('--watch-')
) {
if (
!argument.includes('=') &&
index + 1 < execArgv.length &&
!execArgv[index + 1].startsWith('-')
) {
index++
}
continue
}
if (!argument.startsWith('--inspect')) result.push(argument)
if (
(argument === '--inspect-port' || argument === '--inspect-publish-uid') &&
index + 1 < execArgv.length
) {
index++
}
}
// Inspector flags can also arrive through NODE_OPTIONS, which is inherited
// by workers. Explicit command-line negatives take precedence there.
for (const flag of [
'--no-inspect',
'--no-inspect-brk',
'--no-inspect-wait'
]) {
if (process.allowedNodeEnvironmentFlags.has(flag)) result.push(flag)
}
return result
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,171 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import type { FetchResult } from 'vite/module-runner'
export const SSR_MODULE_ARTIFACT_VERSION = 2
export type MaterializedSsrModuleResult = Exclude<FetchResult, { cache: true }>
export interface StoredSsrModuleArtifact {
version: typeof SSR_MODULE_ARTIFACT_VERSION
result: MaterializedSsrModuleResult
dependencies: string[]
hasUnknownDynamicImports: boolean
}
export interface StoredSsrModuleRequest {
version: typeof SSR_MODULE_ARTIFACT_VERSION
key: string
artifact: string
}
export interface SsrModuleStoreSnapshot {
version: typeof SSR_MODULE_ARTIFACT_VERSION
requests: [key: string, artifact: string][]
}
export function hashSsrModuleValue(value: string | Buffer): string {
return createHash('sha256').update(value).digest('hex')
}
export function ssrModuleCacheFile(root: string, hash: string): string {
return path.join(root, hash.slice(0, 2), `${hash.slice(2)}.json`)
}
export function createSsrModuleRequestKey(
id: string,
importer: string | undefined
): string {
// Plugin resolveId hooks and final asset resolvers may use the importer even
// when `id` is already an absolute path or file URL. Keep that identity in
// the request key; equal transformed results still deduplicate in the
// content-addressed module store after resolution.
return JSON.stringify([SSR_MODULE_ARTIFACT_VERSION, id, importer ?? null])
}
export async function readStoredSsrModuleRequest(
storeRoot: string,
id: string,
importer: string | undefined
): Promise<StoredSsrModuleArtifact | undefined> {
return readStoredSsrModuleRequestByKey(
storeRoot,
createSsrModuleRequestKey(id, importer)
)
}
export async function readStoredSsrModuleRequestByKey(
storeRoot: string,
key: string
): Promise<StoredSsrModuleArtifact | undefined> {
const requestHash = hashSsrModuleValue(key)
try {
const request = JSON.parse(
await readFile(
ssrModuleCacheFile(path.join(storeRoot, 'requests'), requestHash),
'utf8'
)
) as StoredSsrModuleRequest
if (
request.version !== SSR_MODULE_ARTIFACT_VERSION ||
request.key !== key
) {
return
}
return readStoredSsrModuleArtifact(storeRoot, request.artifact)
} catch (error) {
if (
error instanceof SyntaxError ||
(error as NodeJS.ErrnoException).code === 'ENOENT'
) {
return
}
throw error
}
}
export async function readStoredSsrModuleArtifact(
storeRoot: string,
artifactHash: string
): Promise<StoredSsrModuleArtifact | undefined> {
try {
const artifact = JSON.parse(
await readFile(
ssrModuleCacheFile(path.join(storeRoot, 'modules'), artifactHash),
'utf8'
)
) as StoredSsrModuleArtifact
if (artifact.version !== SSR_MODULE_ARTIFACT_VERSION) return
return artifact
} catch (error) {
if (
error instanceof SyntaxError ||
(error as NodeJS.ErrnoException).code === 'ENOENT'
) {
return
}
throw error
}
}
export class SsrModuleArtifactReader {
readonly #snapshot: Promise<Map<string, string> | undefined>
readonly #snapshotIsAuthoritative: boolean
readonly #artifacts = new Map<
string,
Promise<StoredSsrModuleArtifact | undefined>
>()
constructor(
private readonly storeRoot: string,
snapshotPath?: string
) {
this.#snapshotIsAuthoritative = snapshotPath !== undefined
this.#snapshot = this.#readSnapshot(
snapshotPath ?? path.join(this.storeRoot, 'snapshot.json')
)
}
async read(
id: string,
importer: string | undefined
): Promise<StoredSsrModuleArtifact | undefined> {
const key = createSsrModuleRequestKey(id, importer)
const artifactHash = (await this.#snapshot)?.get(key)
if (!artifactHash) {
// A batch snapshot is a complete capability list for that worker. Do
// not let pointer files from a broader store silently escape the slice.
if (this.#snapshotIsAuthoritative) return
return readStoredSsrModuleRequestByKey(this.storeRoot, key)
}
let artifact = this.#artifacts.get(artifactHash)
if (!artifact) {
artifact = readStoredSsrModuleArtifact(this.storeRoot, artifactHash)
this.#artifacts.set(artifactHash, artifact)
}
return artifact
}
async #readSnapshot(
snapshotPath: string
): Promise<Map<string, string> | undefined> {
try {
const snapshot = JSON.parse(
await readFile(snapshotPath, 'utf8')
) as SsrModuleStoreSnapshot
if (snapshot.version !== SSR_MODULE_ARTIFACT_VERSION) return
return new Map(snapshot.requests)
} catch (error) {
if (
error instanceof SyntaxError ||
(error as NodeJS.ErrnoException).code === 'ENOENT'
) {
return
}
throw error
}
}
}

@ -0,0 +1,73 @@
import type { ModuleRunnerTransport } from 'vite/module-runner'
import { SsrModuleArtifactReader } from './ssrModuleStore'
import type { SerializedSsrBuiltin } from './ssrWorkerProtocol'
interface ViteInvokePayload {
type: 'custom'
event: 'vite:invoke'
data: {
name: 'fetchModule' | 'getBuiltins'
data: unknown[]
}
}
/** Read-only transport for a page graph compiled before workers start. */
export class SsrModuleArtifactTransport implements ModuleRunnerTransport {
readonly #reader: SsrModuleArtifactReader
constructor(
private readonly moduleStorePath: string,
private readonly builtins: SerializedSsrBuiltin[],
moduleSnapshotPath?: string
) {
this.#reader = new SsrModuleArtifactReader(
moduleStorePath,
moduleSnapshotPath
)
}
async invoke(payload: unknown): Promise<{ result: unknown }> {
if (!isViteInvokePayload(payload)) {
throw new Error('SSR worker received an invalid Vite invoke payload.')
}
if (payload.data.name === 'getBuiltins') {
return { result: this.builtins }
}
const [id, rawImporter, rawOptions] = payload.data.data
if (typeof id !== 'string') {
throw new TypeError('SSR module artifact request id must be a string.')
}
const importer = typeof rawImporter === 'string' ? rawImporter : undefined
const options =
rawOptions && typeof rawOptions === 'object'
? (rawOptions as { cached?: boolean })
: undefined
if (options?.cached) {
return { result: { cache: true } }
}
const artifact = await this.#reader.read(id, importer)
if (!artifact) {
throw new Error(
`Missing precompiled SSR module ${JSON.stringify(id)}` +
(importer ? ` imported by ${JSON.stringify(importer)}` : '') +
`. The coordinator did not materialize the complete page graph at ${this.moduleStorePath}.`
)
}
return { result: artifact.result }
}
}
function isViteInvokePayload(value: unknown): value is ViteInvokePayload {
if (!value || typeof value !== 'object') return false
const payload = value as Partial<ViteInvokePayload>
const data = payload.data as Partial<ViteInvokePayload['data']> | undefined
return (
payload.type === 'custom' &&
payload.event === 'vite:invoke' &&
!!data &&
(data.name === 'fetchModule' || data.name === 'getBuiltins') &&
Array.isArray(data.data)
)
}

@ -0,0 +1,165 @@
import { readFile, writeFile } from 'node:fs/promises'
import pMap from 'p-map'
import { createNodeImportMeta, ModuleRunner } from 'vite/module-runner'
import { notFoundPageData, type PageData, type SSGContext } from '../shared'
import { nativeImport } from '../utils/nativeImport'
import type { SerializedRenderedPage, SerializedSSGContext } from './render'
import {
serializeSsrRenderWorkerResult,
type SsrRenderWorkerDescriptor,
type SsrRenderWorkerResult
} from './ssrWorkerProtocol'
import { SsrModuleArtifactTransport } from './ssrModuleTransport'
interface RuntimeModule {
renderPage(path: string, pageModule: unknown): Promise<SSGContext>
}
interface LoadedPageModule {
default?: unknown
__pageData?: PageData
}
function serializeContext(context: SSGContext): SerializedSSGContext {
const serialized = {
...context,
vpSocialIcons: [...context.vpSocialIcons].sort()
} as SerializedSSGContext & {
__teleportBuffers?: unknown
__watcherHandles?: unknown
}
// Vue leaves these private render-time structures on SSRContext after it
// has produced the public `teleports` output. Watcher handles contain
// functions and therefore cannot cross the V8 serialization boundary; the
// teleport buffers are redundant once renderToString has resolved them.
delete serialized.__teleportBuffers
delete serialized.__watcherHandles
return serialized
}
function validatePageModule(
pageModule: LoadedPageModule,
moduleId: string
): asserts pageModule is LoadedPageModule & {
default: NonNullable<unknown>
__pageData: PageData
} {
if (!pageModule.default) {
throw new Error(
`SSR page module ${moduleId} has no default component export.`
)
}
if (!pageModule.__pageData) {
throw new Error(`SSR page module ${moduleId} did not export __pageData.`)
}
}
async function renderBatch(
descriptor: SsrRenderWorkerDescriptor
): Promise<SsrRenderWorkerResult> {
const runtime = (await nativeImport(descriptor.runtimePath)) as RuntimeModule
if (typeof runtime.renderPage !== 'function') {
throw new Error(
`Shared SSR runtime at ${descriptor.runtimePath} does not export renderPage().`
)
}
const needsModuleRunner = descriptor.pages.some(
(page) => page.moduleId && !page.staticPage
)
const runner = needsModuleRunner
? new ModuleRunner({
transport: new SsrModuleArtifactTransport(
descriptor.moduleStorePath,
descriptor.builtins,
descriptor.moduleSnapshotPath
),
hmr: false,
createImportMeta: createNodeImportMeta,
sourcemapInterceptor: false
})
: undefined
try {
const pages = await pMap(
descriptor.pages,
async (page): Promise<SerializedRenderedPage> => {
try {
const pageModule = page.staticPage
? null
: page.moduleId
? ((await runner!.import(page.moduleId)) as LoadedPageModule)
: null
if (pageModule && page.moduleId) {
validatePageModule(pageModule, page.moduleId)
}
const payload = page.staticPage ?? pageModule
const context = await runtime.renderPage(page.routePath, payload)
const pageData =
page.staticPage?.pageData ??
pageModule?.__pageData ??
(page.page === '404.md' ? notFoundPageData : undefined)
if (!pageData) {
throw new Error(
`SSR page ${page.page} has neither a static payload nor a page-data module.`
)
}
return {
page: page.page,
pageData,
hasCustom404: page.page !== '404.md' || payload !== null,
context: serializeContext(context)
}
} catch (error) {
const detail =
error instanceof Error
? (error.stack ?? error.message)
: String(error)
throw new Error(
`Failed to render ${page.page} in SSR worker: ${detail}`,
{ cause: error }
)
}
},
{
concurrency: descriptor.renderConcurrency,
// Do not close the shared ModuleRunner while another mapper is still
// importing or rendering. p-map reports an AggregateError after the
// bounded batch has settled when any page fails.
stopOnError: false
}
)
return { pages }
} finally {
await runner?.close()
}
}
async function main(): Promise<void> {
const descriptorPath = process.argv[2]
if (!descriptorPath) {
throw new Error('Missing SSR worker descriptor path.')
}
const descriptor = JSON.parse(
await readFile(descriptorPath, 'utf8')
) as SsrRenderWorkerDescriptor
if (descriptor.type !== 'ssr-render') {
throw new Error('Unknown SSR worker descriptor type.')
}
const result = await renderBatch(descriptor)
await writeFile(
descriptor.resultPath,
serializeSsrRenderWorkerResult(result),
{ mode: 0o600 }
)
}
main().catch((error) => {
console.error(error instanceof Error ? error : new Error(String(error)))
process.exitCode = 1
})

@ -0,0 +1,54 @@
import { serialize } from 'node:v8'
import type { PageData } from '../shared'
import type { SerializedRenderedPage } from './render'
export type SerializedSsrBuiltin =
| { type: 'string'; value: string }
| { type: 'RegExp'; source: string; flags: string }
export interface SsrStaticPagePayload {
html: string
pageData: PageData
}
/**
* Everything a disposable worker needs for one route. `page` is the rewritten
* output page while `moduleId` points at the original source module.
*/
export interface SsrRenderWorkerPage {
page: string
routePath: string
moduleId: string | null
staticPage?: SsrStaticPagePayload
}
/** JSON descriptor written by the build coordinator. */
export interface SsrRenderWorkerDescriptor {
type: 'ssr-render'
runtimePath: string
moduleStorePath: string
/** Request-key slice containing only modules reachable by this batch. */
moduleSnapshotPath: string
builtins: SerializedSsrBuiltin[]
resultPath: string
renderConcurrency: number
pages: SsrRenderWorkerPage[]
}
/** V8-serialized worker output. */
export interface SsrRenderWorkerResult {
pages: SerializedRenderedPage[]
}
export function serializeSsrRenderWorkerResult(
result: SsrRenderWorkerResult
): Buffer {
try {
return serialize(result)
} catch (error) {
throw new Error(
'Unable to transfer the SSR render result to the build coordinator. Custom values added to SSGContext must be structured-cloneable when ssrBuildBatchSize is enabled; functions, symbols, WeakMaps, and proxies cannot cross the render-worker boundary.',
{ cause: error }
)
}
}

@ -1,14 +1,9 @@
import minimist from 'minimist'
import c from 'picocolors'
import { createLogger, version as viteVersion, type Logger } from 'vite'
import {
build,
createServer,
disposeMdItInstance,
resolveConfig,
serve
} from '.'
import { createServer, disposeMdItInstance, resolveConfig, serve } from '.'
import { version } from '../../package.json'
import { buildFromCli } from './build/build'
import { init } from './init/init'
import { clearCache } from './markdownToVue'
import { bindShortcuts } from './shortcuts'
@ -81,7 +76,7 @@ if (!command || command === 'dev') {
init(argv.root)
} else {
if (command === 'build') {
build(root, {
buildFromCli(root, {
...argv,
onAfterConfigResolve(siteConfig) {
logVersion(siteConfig.logger)

@ -172,7 +172,9 @@ export async function resolveConfig(
transformPageData: userConfig.transformPageData,
userConfig,
sitemap: userConfig.sitemap,
buildConcurrency: userConfig.buildConcurrency ?? 64
buildConcurrency: userConfig.buildConcurrency ?? 64,
ssrBuildBatchSize: userConfig.ssrBuildBatchSize,
ssrBuildWorkerConcurrency: userConfig.ssrBuildWorkerConcurrency ?? 1
}
// to be shared with content loaders

@ -11,6 +11,13 @@ import type { Awaitable, MarkdownEnv } from './shared'
declare module '../../types/default-theme.js' {
namespace DefaultTheme {
interface LocalSearchOptions {
/**
* Transforms the already-rendered page HTML before indexing (node only).
* This avoids a second Markdown/Shiki pass and is preferred over
* `_render` when the customization only filters or edits HTML.
* Return an empty string to skip indexing.
*/
_transformHtml?: (html: string, env: MarkdownEnv) => Awaitable<string>
/**
* Allows transformation of content before indexing (node only)
* Return empty string to skip indexing

@ -1,5 +1,5 @@
export { loadEnv, type Plugin } from 'vite'
export * from './build/build'
export { build } from './build/build'
export * from './config'
export * from './contentLoader'
export type { DefaultTheme } from './defaultTheme'
@ -14,7 +14,7 @@ export { defineLoader, type LoaderModule } from './plugins/staticDataPlugin'
export * from './postcss/isolateStyles'
export * from './serve/serve'
export * from './server'
export * from './utils/getGitTimestamp'
export { cacheAllGitTimestamps, getGitTimestamp } from './utils/getGitTimestamp'
// shared types
export type {

@ -88,9 +88,27 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions {
*/
config?: (md: MarkdownRenderer) => Awaitable<void>
/**
* Disable cache (experimental)
* Disable compiled Markdown caching (experimental).
*
* In a batched production build this also disables reuse of compiled page
* artifacts across builds. Artifacts are still shared by the client build,
* SSR compiler and render coordinator within the current build.
*/
cache?: boolean
/**
* A whole-page Markdown artifact cache fingerprint (experimental).
*
* Set this when Markdown output can depend on state that VitePress cannot
* inspect, such as environment variables or values captured by Markdown,
* Vite or page-data hook closures. Change the key whenever any such state
* can change the compiled HTML, Vue source or page data. Supplying a key is
* an explicit opt-in to persistent cross-build page-artifact reuse when
* opaque callbacks are present.
*
* This is broader than `shikiCacheKey`, which fingerprints only syntax
* highlighting. `cache: false` takes precedence over this option.
*/
cacheKey?: string
/**
* HTML attributes applied to external links.
* @default { target: '_blank', rel: 'noreferrer' }
@ -170,6 +188,11 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions {
* Configure the Shiki instance.
*/
shikiSetup?: (shiki: Highlighter) => void | Promise<void>
/**
* Extra persistent-highlight cache fingerprint for configuration captured by
* opaque transformer or `shikiSetup` closures.
*/
shikiCacheKey?: string
/* ==================== Code Blocks ==================== */
@ -350,7 +373,8 @@ export async function createMarkdownRenderer(
options: MarkdownOptions = {},
base = '/',
logger: Pick<Logger, 'warn'> = console,
publicDir?: string
publicDir?: string,
cacheDir?: string
): Promise<MarkdownRenderer> {
if (md) return md
@ -364,7 +388,7 @@ export async function createMarkdownRenderer(
const [highlight, dispose] = options.highlight
? [options.highlight, () => {}]
: await createHighlighter(theme, options, logger)
: await createHighlighter(theme, options, logger, cacheDir)
_disposeHighlighter = dispose

@ -5,15 +5,23 @@ import {
transformerNotationFocus,
transformerNotationHighlight
} from '@shikijs/transformers'
import { LRUCache } from 'lru-cache'
import { customAlphabet } from 'nanoid'
import { createHash, randomUUID } from 'node:crypto'
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
import c from 'picocolors'
import type { BundledLanguage, ShikiTransformer } from 'shiki'
import { createHighlighter, guessEmbeddedLanguages, isSpecialLang } from 'shiki'
import { version as shikiVersion } from 'shiki/package.json'
import type { Logger } from 'vite'
import { version as vitepressVersion } from '../../../../package.json'
import { isShell } from '../../shared'
import type { MarkdownOptions, ThemeOptions } from '../markdown'
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10)
const HIGHLIGHT_CACHE_SCHEMA_VERSION = 2
const HIGHLIGHT_MEMORY_CACHE_SIZE = 16 * 1024 * 1024
/**
* Prevents the leading '$' symbol etc from being selectable/copyable. Also
@ -52,7 +60,8 @@ function transformerDisableShellSymbolSelect(): ShikiTransformer {
export async function highlight(
theme: ThemeOptions,
options: MarkdownOptions,
logger: Pick<Logger, 'warn'> = console
logger: Pick<Logger, 'warn'> = console,
cacheDir?: string
): Promise<
[(str: string, lang: string, attrs: string) => Promise<string>, () => void]
> {
@ -66,34 +75,103 @@ export async function highlight(
.map(([k, v]) => [k.toLowerCase(), v])
)
const highlighter = await createHighlighter({
themes:
typeof theme === 'object' && 'light' in theme && 'dark' in theme
? [theme.light, theme.dark]
: [theme],
langs: [...(options.languages || []), ...Object.values(langAlias)],
langAlias
})
let runtimePromise:
| Promise<{
highlighter: Awaited<ReturnType<typeof createHighlighter>>
transformers: ShikiTransformer[]
}>
| undefined
const getRuntime = () =>
(runtimePromise ??= (async () => {
const highlighter = await createHighlighter({
themes:
typeof theme === 'object' && 'light' in theme && 'dark' in theme
? [theme.light, theme.dark]
: [theme],
langs: [...(options.languages || []), ...Object.values(langAlias)],
langAlias
})
await options?.shikiSetup?.(highlighter)
const transformers: ShikiTransformer[] = [
transformerMetaHighlight(),
transformerNotationDiff(),
transformerNotationFocus({
classActiveLine: 'has-focus',
classActivePre: 'has-focused-lines'
}),
transformerNotationHighlight(),
transformerNotationErrorLevel(),
transformerDisableShellSymbolSelect(),
{
name: 'vitepress:add-dir',
pre(node) {
node.properties.dir = 'ltr'
}
}
]
await options?.shikiSetup?.(highlighter)
const transformers: ShikiTransformer[] = [
transformerMetaHighlight(),
transformerNotationDiff(),
transformerNotationFocus({
classActiveLine: 'has-focus',
classActivePre: 'has-focused-lines'
}),
transformerNotationHighlight(),
transformerNotationErrorLevel(),
transformerDisableShellSymbolSelect(),
{
name: 'vitepress:add-dir',
pre(node) {
node.properties.dir = 'ltr'
}
}
]
return { highlighter, transformers }
})())
const colorReplacements = {
'github-light': {
'#959da5': '#6c676f',
'#28a745': '#0e790b',
'#b08800': '#846312',
'#e36209': '#c13617',
'#3192aa': '#05728b',
'#d73a49': '#c62739',
'#22863a': '#11782a',
'#6a737d': '#62687b',
'#1b7c83': '#06747a',
'#0366d6': '#0663d0',
'#cb2431': '#c82430'
},
'github-dark': {
'#586069': '#5b93a3',
'#6a737d': '#818e99',
'#ea4a5a': '#ef5564',
'#2188ff': '#268bf9'
},
...options.colorReplacements
}
const cacheNamespace = hash(
stableSerialize({
schemaVersion: HIGHLIGHT_CACHE_SCHEMA_VERSION,
vitepressVersion,
shikiVersion,
theme,
languages: options.languages,
langAlias,
defaultLang,
transformerFactories: [
transformerMetaHighlight,
transformerNotationDiff,
transformerNotationFocus,
transformerNotationHighlight,
transformerNotationErrorLevel,
transformerDisableShellSymbolSelect,
'vitepress:add-dir',
'vitepress:v-pre',
'vitepress:empty-line'
],
userTransformers,
colorReplacements,
shikiSetup: options.shikiSetup,
shikiCacheKey: options.shikiCacheKey
})
)
const cacheRoot = cacheDir
? path.join(cacheDir, 'vitepress-shiki', cacheNamespace)
: undefined
const memoryCache = new LRUCache<string, string>({
maxSize: HIGHLIGHT_MEMORY_CACHE_SIZE,
sizeCalculation: (value) => Buffer.byteLength(value)
})
const pending = new Map<string, Promise<string>>()
// keep in sync with ./preWrapper.ts#extractLang
const langRE = /^[a-zA-Z0-9-_]+/
@ -114,112 +192,226 @@ export async function highlight(
const vPre = !vueRE.test(lang)
if (!vPre) lang = lang.slice(0, -4)
try {
// https://github.com/shikijs/shiki/issues/952
if (
!isSpecialLang(lang) &&
!highlighter.getLoadedLanguages().includes(lang)
) {
await highlighter.loadLanguage(lang as any)
str = str.trimEnd()
const cacheKey = hashParts([
cacheNamespace,
str,
lang,
attrs,
vPre ? 'v-pre' : 'vue'
])
const cached = memoryCache.get(cacheKey)
if (cached != null) return cached
const existing = pending.get(cacheKey)
if (existing) return existing
const operation = (async () => {
if (cacheRoot) {
const cached = await readCachedHighlight(cacheRoot, cacheKey)
if (cached != null) return cached
}
} catch {
logger.warn(
c.yellow(
`\nThe language '${lang}' is not loaded, falling back to '${defaultLang}' for syntax highlighting.`
)
)
lang = defaultLang
}
const mustaches = new Map<string, string>()
const { highlighter, transformers } = await getRuntime()
const removeMustache = (s: string) => {
if (vPre) return s
return s.replace(/\{\{.*?\}\}/g, (match) => {
let marker = mustaches.get(match)
if (!marker) {
marker = nanoid()
mustaches.set(match, marker)
try {
// https://github.com/shikijs/shiki/issues/952
if (
!isSpecialLang(lang) &&
!highlighter.getLoadedLanguages().includes(lang)
) {
await highlighter.loadLanguage(lang as any)
}
return marker
})
}
} catch {
logger.warn(
c.yellow(
`\nThe language '${lang}' is not loaded, falling back to '${defaultLang}' for syntax highlighting.`
)
)
lang = defaultLang
}
const restoreMustache = (s: string) => {
mustaches.forEach((marker, match) => {
s = s.replaceAll(marker, match)
})
return s
}
const mustaches = new Map<string, string>()
str = removeMustache(str).trimEnd()
const removeMustache = (s: string) => {
if (vPre) return s
return s.replace(/\{\{.*?\}\}/g, (match) => {
let marker = mustaches.get(match)
if (!marker) {
marker = nanoid()
mustaches.set(match, marker)
}
return marker
})
}
const embeddedLang = guessEmbeddedLanguages(str, lang, highlighter)
await highlighter.loadLanguage(...(embeddedLang as BundledLanguage[]))
const restoreMustache = (s: string) => {
mustaches.forEach((marker, match) => {
s = s.replaceAll(marker, match)
})
return s
}
const highlighted = highlighter.codeToHtml(str, {
lang,
transformers: [
...transformers,
{
name: 'vitepress:v-pre',
pre(node) {
if (vPre) node.properties['v-pre'] = ''
}
},
{
name: 'vitepress:empty-line',
code(hast) {
hast.children.forEach((span) => {
if (
span.type === 'element' &&
span.tagName === 'span' &&
Array.isArray(span.properties.class) &&
span.properties.class.includes('line') &&
span.children.length === 0
) {
span.children.push({
type: 'element',
tagName: 'wbr',
properties: {},
children: []
})
}
})
}
},
...userTransformers
],
meta: { __raw: attrs },
...(typeof theme === 'object' && 'light' in theme && 'dark' in theme
? { themes: theme, defaultColor: false }
: { theme }),
colorReplacements: {
'github-light': {
'#959da5': '#6c676f',
'#28a745': '#0e790b',
'#b08800': '#846312',
'#e36209': '#c13617',
'#3192aa': '#05728b',
'#d73a49': '#c62739',
'#22863a': '#11782a',
'#6a737d': '#62687b',
'#1b7c83': '#06747a',
'#0366d6': '#0663d0',
'#cb2431': '#c82430'
},
'github-dark': {
'#586069': '#5b93a3',
'#6a737d': '#818e99',
'#ea4a5a': '#ef5564',
'#2188ff': '#268bf9'
},
...options.colorReplacements
str = removeMustache(str)
const embeddedLang = guessEmbeddedLanguages(str, lang, highlighter)
await highlighter.loadLanguage(...(embeddedLang as BundledLanguage[]))
const highlighted = highlighter.codeToHtml(str, {
lang,
transformers: [
...transformers,
{
name: 'vitepress:v-pre',
pre(node) {
if (vPre) node.properties['v-pre'] = ''
}
},
{
name: 'vitepress:empty-line',
code(hast) {
hast.children.forEach((span) => {
if (
span.type === 'element' &&
span.tagName === 'span' &&
Array.isArray(span.properties.class) &&
span.properties.class.includes('line') &&
span.children.length === 0
) {
span.children.push({
type: 'element',
tagName: 'wbr',
properties: {},
children: []
})
}
})
}
},
...userTransformers
],
meta: { __raw: attrs },
...(typeof theme === 'object' && 'light' in theme && 'dark' in theme
? { themes: theme, defaultColor: false }
: { theme }),
colorReplacements
})
const result = restoreMustache(highlighted)
if (cacheRoot) {
await writeCachedHighlight(cacheRoot, cacheKey, result)
}
})
return result
})()
return restoreMustache(highlighted)
pending.set(cacheKey, operation)
try {
const result = await operation
if (Buffer.byteLength(result) <= HIGHLIGHT_MEMORY_CACHE_SIZE) {
memoryCache.set(cacheKey, result)
}
return result
} finally {
pending.delete(cacheKey)
}
},
highlighter.dispose
() => {
runtimePromise
?.then(({ highlighter }) => highlighter.dispose())
.catch(() => {})
}
]
}
async function readCachedHighlight(
root: string,
cacheKey: string
): Promise<string | undefined> {
try {
return await readFile(getCacheFile(root, cacheKey), 'utf8')
} catch {
return
}
}
async function writeCachedHighlight(
root: string,
cacheKey: string,
html: string
): Promise<void> {
const file = getCacheFile(root, cacheKey)
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`
try {
await mkdir(path.dirname(file), { recursive: true })
await writeFile(temporary, html, { mode: 0o600 })
await rename(temporary, file)
} catch {
// Highlight caching is an optimization; read-only or partially cleared
// cache directories must not make the documentation build fail.
} finally {
await unlink(temporary).catch(() => {})
}
}
function getCacheFile(root: string, cacheKey: string): string {
return path.join(root, cacheKey.slice(0, 2), `${cacheKey}.html`)
}
function hash(value: string): string {
return createHash('sha256').update(value).digest('hex')
}
function hashParts(parts: string[]): string {
const digest = createHash('sha256')
for (const part of parts) {
digest.update(`${Buffer.byteLength(part)}:`)
digest.update(part)
}
return digest.digest('hex')
}
function stableSerialize(value: unknown, seen = new Set<object>()): string {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
const valueType = typeof value
if (valueType === 'string') return JSON.stringify(value)
if (valueType === 'number' || valueType === 'boolean') return String(value)
if (valueType === 'bigint') return `${value}n`
if (valueType === 'symbol') return String(value)
if (valueType === 'function') return `function:${String(value)}`
const object = value as object
if (seen.has(object)) return '[Circular]'
seen.add(object)
try {
if (object instanceof RegExp) return `regexp:${String(object)}`
if (object instanceof Date) return `date:${object.toISOString()}`
if (Array.isArray(object)) {
return `[${object.map((item) => stableSerialize(item, seen)).join(',')}]`
}
if (object instanceof Map) {
const entries = [...object].map(
([key, item]) =>
`${stableSerialize(key, seen)}:${stableSerialize(item, seen)}`
)
return `map:{${entries.sort().join(',')}}`
}
if (object instanceof Set) {
return `set:[${[...object]
.map((item) => stableSerialize(item, seen))
.sort()
.join(',')}]`
}
const record = object as Record<string, unknown>
const constructorName = object.constructor?.name || 'Object'
return `${constructorName}:{${Object.keys(record)
.sort()
.map(
(key) => `${JSON.stringify(key)}:${stableSerialize(record[key], seen)}`
)
.join(',')}}`
} finally {
seen.delete(object)
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,508 @@
import { createHash, randomUUID } from 'node:crypto'
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { deserialize, serialize } from 'node:v8'
import type { MarkdownCompileResult } from './markdownToVue'
import { slash } from './shared'
const PAGE_ARTIFACT_SCHEMA_VERSION = 8
export interface PageArtifactManifestEntry {
inputHash: string
objectHash: string
dependencies: { file: string; hash: string }[]
metadata: PageArtifactMetadata
}
export interface PageArtifactMetadata {
staticPage: boolean
requiresSourceModuleIdentity: boolean
}
interface PageArtifactManifest {
schemaVersion: number
namespace: string
entries: Record<string, PageArtifactManifestEntry>
}
type StoredArtifactOverlay = Omit<
MarkdownCompileResult,
'html' | 'staticHtml' | 'vueSrc'
>
type StoredVueSource = { source: string } | { prefix: string; suffix: string }
interface StoredPageArtifact {
schemaVersion: number
artifact: StoredArtifactOverlay
htmlHash: string
staticHtmlHash?: string
vueSource: StoredVueSource
}
export type PageArtifactFinalizer = (
artifact: MarkdownCompileResult
) => Promise<MarkdownCompileResult>
interface CurrentPageArtifact {
inputHash: string
objectHash: string
finalized: boolean
}
export interface PageArtifactStoreOptions {
/**
* A fingerprint for everything that can affect Markdown output. The build
* coordinator should include the VitePress version, resolved route digest,
* config dependencies, base, Markdown options and relevant environment data.
*/
namespace: string
/** Prevent creation of missing artifacts. Useful in render-only workers. */
readOnly?: boolean
}
/**
* A small content-addressed store for the expensive Markdown -> Vue boundary.
*
* The manifest only retains hashes and dependency paths. Rendered HTML bodies
* and route-specific overlays are written immediately to separate CAS objects,
* so compiling a large site does not retain every page in memory and exactly
* identical bodies can share storage even when their overlays differ.
*/
export class PageArtifactStore {
readonly root: string
readonly namespace: string
readonly readOnly: boolean
readonly #manifestPath: string
readonly #objectsDir: string
readonly #bodiesDir: string
readonly #entries = new Map<string, PageArtifactManifestEntry>()
// Only CAS coordinates are retained. Finalized Vue/HTML strings are read
// back on demand so a streaming build never accumulates every page in RAM.
readonly #current = new Map<string, CurrentPageArtifact>()
readonly #pending = new Map<string, Promise<MarkdownCompileResult>>()
readonly #dependencyHashes = new Map<string, Promise<string>>()
#loaded: Promise<void> | undefined
#dirty = false
constructor(root: string, options: PageArtifactStoreOptions) {
this.namespace = options.namespace
this.readOnly = options.readOnly ?? false
const namespaceHash = hash(options.namespace).slice(0, 20)
this.root = path.resolve(root, 'vitepress-page-artifacts')
this.#manifestPath = path.join(
this.root,
'manifests',
`${namespaceHash}.json`
)
this.#objectsDir = path.join(this.root, 'objects')
this.#bodiesDir = path.join(this.root, 'bodies')
}
/**
* Reads an artifact only when both its transformed Markdown input and every
* include/snippet dependency still match the manifest.
*/
async get(
page: string,
transformedSource: string
): Promise<MarkdownCompileResult | undefined> {
await this.#load()
page = normalizePageKey(page)
const inputHash = this.createInputHash(page, transformedSource)
const stored = await this.#getValidatedStored(page, inputHash)
if (!stored) return
this.#current.set(page, {
inputHash,
objectHash: stored.objectHash,
finalized: false
})
return stored.artifact
}
/**
* Returns an artifact already validated or created during this build. This is
* intended for coordinator-owned consumers such as local search generation.
*/
async getCurrent(page: string): Promise<MarkdownCompileResult | undefined> {
await this.#load()
page = normalizePageKey(page)
const current = this.#current.get(page)
return current ? this.#readObject(current.objectHash) : undefined
}
/** Read small routing metadata without parsing the full artifact object. */
async getCurrentMetadata(
page: string
): Promise<PageArtifactMetadata | undefined> {
await this.#load()
page = normalizePageKey(page)
if (!this.#current.has(page)) return
const metadata = this.#entries.get(page)?.metadata
return metadata ? { ...metadata } : undefined
}
async put(
page: string,
transformedSource: string,
artifact: MarkdownCompileResult
): Promise<void> {
if (this.readOnly) {
throw new Error(
`Cannot create page artifact for ${page} in read-only mode.`
)
}
await this.#load()
page = normalizePageKey(page)
await this.#putBase(page, transformedSource, artifact, true)
}
/**
* Deduplicates concurrent requests for the same page/input. A read-only
* consumer can omit `compile` to turn a cache miss into an actionable error.
*/
async getOrCreate(
page: string,
transformedSource: string,
compile?: () => Promise<MarkdownCompileResult>,
finalize?: PageArtifactFinalizer
): Promise<MarkdownCompileResult> {
page = normalizePageKey(page)
const operationKey = this.createInputHash(page, transformedSource)
const pending = this.#pending.get(operationKey)
if (pending) return pending
const operation = (async () => {
await this.#load()
const current = this.#current.get(page)
if (
current?.inputHash === operationKey &&
(!finalize || current.finalized)
) {
const currentArtifact = await this.#readObject(current.objectHash)
if (currentArtifact) return currentArtifact
}
let baseStored = await this.#getValidatedStored(page, operationKey)
if (!baseStored) {
if (!compile) {
throw new Error(
`Missing or stale Markdown artifact for ${page}. ` +
'The coordinator must compile page artifacts before starting render workers.'
)
}
const artifact = await compile()
const objectHash = await this.#putBase(
page,
transformedSource,
artifact,
false
)
baseStored = { objectHash, artifact }
}
const artifact = finalize
? await finalize(baseStored.artifact)
: baseStored.artifact
let objectHash = baseStored.objectHash
if (finalize) {
if (artifact === baseStored.artifact) {
objectHash = baseStored.objectHash
} else if (this.readOnly) {
objectHash = this.#hashStoredArtifact(artifact)
if (!(await this.#readStoredObject(objectHash))) {
throw new Error(
`Missing finalized Markdown artifact for ${page}. ` +
'The coordinator must finalize page data before starting read-only consumers.'
)
}
} else {
objectHash = await this.#writeStoredObject(artifact)
}
}
this.#current.set(page, {
inputHash: operationKey,
objectHash,
finalized: !!finalize
})
return artifact
})()
this.#pending.set(operationKey, operation)
try {
return await operation
} finally {
this.#pending.delete(operationKey)
}
}
createInputHash(page: string, transformedSource: string): string {
return hashParts([
`vitepress-page-artifact-v${PAGE_ARTIFACT_SCHEMA_VERSION}`,
this.namespace,
normalizePageKey(page),
transformedSource
])
}
/** Flushes the small sorted manifest after a streaming compilation pass. */
async flush(): Promise<void> {
await this.#load()
if (this.readOnly || !this.#dirty) return
const entries = Object.fromEntries(
[...this.#entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
)
const manifest: PageArtifactManifest = {
schemaVersion: PAGE_ARTIFACT_SCHEMA_VERSION,
namespace: this.namespace,
entries
}
await mkdir(path.dirname(this.#manifestPath), { recursive: true })
await atomicWrite(this.#manifestPath, JSON.stringify(manifest))
this.#dirty = false
}
async #load(): Promise<void> {
if (this.#loaded) return this.#loaded
this.#loaded = (async () => {
try {
const manifest = JSON.parse(
await readFile(this.#manifestPath, 'utf8')
) as PageArtifactManifest
if (
manifest.schemaVersion !== PAGE_ARTIFACT_SCHEMA_VERSION ||
manifest.namespace !== this.namespace
) {
return
}
for (const [page, entry] of Object.entries(manifest.entries)) {
this.#entries.set(page, entry)
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
// A partial/corrupt cache must never make a build fail. Treat it as a
// miss; a successful build will atomically replace the manifest.
return
}
}
})()
return this.#loaded
}
async #readObject(
objectHash: string
): Promise<MarkdownCompileResult | undefined> {
return this.#readStoredObject(objectHash)
}
async #readStoredObject(
objectHash: string
): Promise<MarkdownCompileResult | undefined> {
try {
const stored = deserialize(
await readFile(this.#objectPath(objectHash))
) as StoredPageArtifact
if (stored.schemaVersion !== PAGE_ARTIFACT_SCHEMA_VERSION) return
const [html, staticHtml] = await Promise.all([
readFile(this.#bodyPath(stored.htmlHash), 'utf8'),
stored.staticHtmlHash
? readFile(this.#bodyPath(stored.staticHtmlHash), 'utf8')
: undefined
])
const vueSrc =
'source' in stored.vueSource
? stored.vueSource.source
: `${stored.vueSource.prefix}${html}${stored.vueSource.suffix}`
return {
...stored.artifact,
html,
vueSrc,
...(staticHtml === undefined ? {} : { staticHtml })
}
} catch {
return
}
}
async #getValidatedStored(
page: string,
inputHash: string
): Promise<
{ objectHash: string; artifact: MarkdownCompileResult } | undefined
> {
const entry = this.#entries.get(page)
if (!entry || entry.inputHash !== inputHash) return
if (!(await this.#dependenciesMatch(entry.dependencies))) return
const artifact = await this.#readStoredObject(entry.objectHash)
return artifact ? { objectHash: entry.objectHash, artifact } : undefined
}
async #putBase(
page: string,
transformedSource: string,
artifact: MarkdownCompileResult,
markCurrent: boolean
): Promise<string> {
const objectHash = await this.#writeStoredObject(artifact)
const inputHash = this.createInputHash(page, transformedSource)
const dependencies = await this.#hashDependencies(artifact.includes)
this.#entries.set(page, {
inputHash,
objectHash,
dependencies,
metadata: {
staticPage: artifact.staticPage === true,
requiresSourceModuleIdentity:
artifact.requiresSourceModuleIdentity === true
}
})
if (markCurrent) {
this.#current.set(page, {
inputHash,
objectHash,
finalized: false
})
}
this.#dirty = true
return objectHash
}
async #writeStoredObject(artifact: MarkdownCompileResult): Promise<string> {
const stored = this.#createStoredArtifact(artifact)
await Promise.all([
this.#writeBody(stored.htmlHash, artifact.html),
stored.staticHtmlHash && artifact.staticHtml !== undefined
? this.#writeBody(stored.staticHtmlHash, artifact.staticHtml)
: undefined
])
const source = serialize(stored)
const objectHash = hash(source)
const objectPath = this.#objectPath(objectHash)
await mkdir(path.dirname(objectPath), { recursive: true })
await atomicWriteIfChanged(objectPath, source)
return objectHash
}
#hashStoredArtifact(artifact: MarkdownCompileResult): string {
return hash(serialize(this.#createStoredArtifact(artifact)))
}
#createStoredArtifact(artifact: MarkdownCompileResult): StoredPageArtifact {
const { html, staticHtml, vueSrc, ...overlay } = artifact
return {
schemaVersion: PAGE_ARTIFACT_SCHEMA_VERSION,
artifact: overlay,
htmlHash: hash(html),
...(staticHtml === undefined ? {} : { staticHtmlHash: hash(staticHtml) }),
vueSource: compactVueSource(vueSrc, html)
}
}
async #writeBody(bodyHash: string, body: string): Promise<void> {
const bodyPath = this.#bodyPath(bodyHash)
await mkdir(path.dirname(bodyPath), { recursive: true })
await atomicWriteIfChanged(bodyPath, Buffer.from(body))
}
#objectPath(objectHash: string): string {
return path.join(
this.#objectsDir,
objectHash.slice(0, 2),
`${objectHash}.bin`
)
}
#bodyPath(bodyHash: string): string {
return path.join(this.#bodiesDir, bodyHash.slice(0, 2), `${bodyHash}.html`)
}
async #hashDependencies(
files: string[]
): Promise<PageArtifactManifestEntry['dependencies']> {
const uniqueFiles = [...new Set(files.map((file) => slash(file)))].sort()
return Promise.all(
uniqueFiles.map(async (file) => ({
file,
hash: await this.#hashDependency(file)
}))
)
}
async #dependenciesMatch(
dependencies: PageArtifactManifestEntry['dependencies']
): Promise<boolean> {
const matches = await Promise.all(
dependencies.map(
async ({ file, hash: expected }) =>
(await this.#hashDependency(file)) === expected
)
)
return matches.every(Boolean)
}
#hashDependency(file: string): Promise<string> {
let pending = this.#dependencyHashes.get(file)
if (!pending) {
pending = readFile(file).then(hash, () => '<missing>')
this.#dependencyHashes.set(file, pending)
}
return pending
}
}
function normalizePageKey(page: string): string {
return slash(page).replace(/^\.\//, '')
}
function compactVueSource(vueSrc: string, html: string): StoredVueSource {
const template = `<template><div>${html}</div></template>`
const index = vueSrc.indexOf(template)
if (index < 0) return { source: vueSrc }
const bodyOffset = index + '<template><div>'.length
return {
prefix: vueSrc.slice(0, bodyOffset),
suffix: vueSrc.slice(bodyOffset + html.length)
}
}
function hash(value: string | Uint8Array): string {
return createHash('sha256').update(value).digest('hex')
}
function hashParts(parts: string[]): string {
const digest = createHash('sha256')
for (const part of parts) {
digest.update(`${Buffer.byteLength(part)}:`)
digest.update(part)
}
return digest.digest('hex')
}
async function atomicWriteIfChanged(file: string, content: Uint8Array) {
try {
if (Buffer.compare(await readFile(file), content) === 0) return
} catch {}
await atomicWrite(file, content)
}
async function atomicWrite(file: string, content: string | Uint8Array) {
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`
await writeFile(temporary, content, { mode: 0o600 })
try {
await rename(temporary, file)
} finally {
await unlink(temporary).catch(() => {})
}
}

@ -1,5 +1,6 @@
import { exactRegex } from '@rolldown/pluginutils'
import path from 'node:path'
import pMap from 'p-map'
import c from 'picocolors'
import {
mergeConfig,
@ -23,16 +24,20 @@ import { isAdditionalConfigFile, resolvePages, type SiteConfig } from './config'
import {
clearCache,
createMarkdownToVueRenderFn,
createStaticPageVueSource,
resolveDeadLinks,
type MarkdownCompileResult
} from './markdownToVue'
import type { PageArtifactStore } from './pageArtifacts'
import { dynamicRoutesPlugin } from './plugins/dynamicRoutesPlugin'
import { localSearchPlugin } from './plugins/localSearchPlugin'
import { createVueDescriptorMemoryPlugin } from './plugins/vueDescriptorMemory'
import { rewritesPlugin } from './plugins/rewritesPlugin'
import { staticDataPlugin } from './plugins/staticDataPlugin'
import { webFontsPlugin } from './plugins/webFontsPlugin'
import { slash, type PageDataPayload } from './shared'
import { deserializeFunctions, serializeFunctions } from './utils/fnSerialize'
import { cacheAllGitTimestamps } from './utils/getGitTimestamp'
import { cacheAllGitTimestamps, getGitTimestamp } from './utils/getGitTimestamp'
declare module 'vite' {
interface UserConfig {
@ -43,6 +48,7 @@ declare module 'vite' {
const themeRE = /(?:^|\/)\.vitepress\/theme\/index\.(m|c)?(j|t)s$/
const startsWithThemeRE = /^@theme(?:\/|$)/
const docsearchRE = /\bdocsearch\b/ // narrow it if any issue arises
const ssrPageArtifactRE = /\.__vitepress_ssr\.vue$/
const hashRE = /\.([-\w]+)\.js$/
const staticInjectMarkerRE = /\bcreateStaticVNode\((?:(".*")|('.*')), (\d+)\)/g
@ -70,14 +76,61 @@ export interface PageMeta {
lastUpdated?: number
}
export interface VitePressPluginOptions {
isSsrBatch?: boolean
/** Compile/store Markdown only, without running Vue's SFC compiler. */
artifactOnly?: boolean
/** Shared Markdown artifacts produced by the build coordinator. */
pageArtifactStore?: PageArtifactStore
/**
* Make the coordinator's client graph own the artifact-seeding pass. Every
* page is transformed into that graph before virtual consumers such as local
* search load, and the normal client entries then reuse the same modules.
*/
coordinatorClient?: boolean
/**
* Generated `.vue` module id -> rewritten page artifact. These modules let
* the isolated SSR compiler consume the coordinator's post-Markdown SFC
* without re-running source-only Markdown transforms.
*/
ssrPageArtifacts?: ReadonlyMap<string, string>
/**
* Skip the normal git-history prewarm. A coordinator that owns the build can
* use this after calling `cacheAllGitTimestamps` once for all consumers.
*/
skipGitScan?: boolean
}
export async function createVitePressPlugin(
siteConfig: SiteConfig,
ssr = false,
pageToHashMap?: Record<string, string>,
clientJSMap?: Record<string, string>,
pageMetaMap?: Record<string, PageMeta>,
restartServer?: () => Promise<void>
restartServer?: () => Promise<void>,
options: VitePressPluginOptions = {}
) {
const {
isSsrBatch = false,
artifactOnly = false,
pageArtifactStore,
coordinatorClient = false,
ssrPageArtifacts,
skipGitScan = false
} = options
if (artifactOnly && !pageArtifactStore) {
throw new Error('artifactOnly requires a pageArtifactStore.')
}
if (coordinatorClient && (!pageArtifactStore || ssr || artifactOnly)) {
throw new Error(
'coordinatorClient requires a non-SSR client build with a pageArtifactStore.'
)
}
if (ssrPageArtifacts && (!pageArtifactStore || !ssr || artifactOnly)) {
throw new Error(
'ssrPageArtifacts requires an SSR compiler with a pageArtifactStore.'
)
}
const {
srcDir,
configPath,
@ -93,12 +146,17 @@ export async function createVitePressPlugin(
let markdownToVue: Awaited<ReturnType<typeof createMarkdownToVueRenderFn>>
// lazy require plugin-vue to respect NODE_ENV in @vue/compiler-x
const vuePlugin = await import('@vitejs/plugin-vue').then((r) =>
r.default({
include: /\.(?:vue|md)$/,
...userVuePluginOptions
})
)
const vuePlugin = artifactOnly
? undefined
: await import('@vitejs/plugin-vue').then((r) =>
r.default({
include: /\.(?:vue|md)$/,
...userVuePluginOptions
})
)
const vueDescriptorMemoryPlugin = vuePlugin
? createVueDescriptorMemoryPlugin(vuePlugin)
: undefined
const processClientJS = (code: string, id: string) => {
return scriptClientRE.test(code)
@ -113,24 +171,48 @@ export async function createVitePressPlugin(
let allDeadLinks: MarkdownCompileResult['deadLinks'] = []
let config: ResolvedConfig
let importerMap: Record<string, Set<string> | undefined> = {}
const dynamicRouteSources = new Map(
siteConfig.dynamicRoutes.map((route) => [
normalizePath(route.fullPath),
normalizePath(path.resolve(srcDir, route.route))
])
)
const vitePressPlugin: Plugin = {
name: 'vitepress',
async configResolved(resolvedConfig) {
config = resolvedConfig
// sync with the actual resolved publicDir (can be customized via
// vite config, or altered by other vite plugins)
siteConfig.publicDir = config.publicDir
// The browser build owns the copied public tree. Later isolated SSR
// environments may resolve different Vite settings, but must not mutate
// the coordinator's client-resolved path after its cache namespace and
// asset map have been established.
if (!isSsrBatch) siteConfig.publicDir = config.publicDir
// pre-resolve git timestamps
if (lastUpdated) await cacheAllGitTimestamps(srcDir)
if (lastUpdated && !isSsrBatch && !skipGitScan) {
await cacheAllGitTimestamps(
srcDir,
['*.md'],
config.command === 'build'
)
}
markdownToVue = await createMarkdownToVueRenderFn(
srcDir,
markdown ?? {},
config.base,
lastUpdated ?? false,
cleanUrls ?? false,
siteConfig
siteConfig,
artifactOnly || coordinatorClient,
!isSsrBatch,
!!pageArtifactStore,
[
config.plugins,
config.environments?.client?.plugins,
config.build?.rolldownOptions?.plugins,
config.environments?.client?.build.rolldownOptions.plugins
],
config.experimental?.renderBuiltUrl
)
},
@ -174,8 +256,13 @@ export async function createVitePressPlugin(
},
resolveId: {
filter: { id: [exactRegex(SITE_DATA_ID), startsWithThemeRE] },
filter: {
id: [exactRegex(SITE_DATA_ID), startsWithThemeRE, ssrPageArtifactRE]
},
handler(id, importer, resolveOptions) {
if (ssrPageArtifactRE.test(id)) {
return ssrPageArtifacts?.has(id) ? id : undefined
}
if (id === SITE_DATA_ID) {
return SITE_DATA_REQUEST_PATH
}
@ -188,8 +275,22 @@ export async function createVitePressPlugin(
},
load: {
filter: { id: exactRegex(SITE_DATA_REQUEST_PATH) },
handler() {
filter: {
id: [exactRegex(SITE_DATA_REQUEST_PATH), ssrPageArtifactRE]
},
async handler(id) {
const artifactPage = ssrPageArtifacts?.get(id)
if (ssrPageArtifactRE.test(id)) {
if (!artifactPage) return
const artifact = await pageArtifactStore!.getCurrent(artifactPage)
if (!artifact) {
throw new Error(
`Missing coordinator Markdown artifact for SSR page ${artifactPage}.`
)
}
return artifact.vueSrc
}
let data = siteData
// head info is not needed by the client in production build
if (config.command === 'build') {
@ -213,16 +314,43 @@ export async function createVitePressPlugin(
}
if (id.endsWith('.md')) {
// transform .md files into vueSrc so plugin-vue can handle it
const { vueSrc, deadLinks, includes, pageData } = await markdownToVue(
code,
id
)
const sourcePath = slash(path.relative(srcDir, id))
const artifactPage = siteConfig.rewrites.map[sourcePath] || sourcePath
let artifactInput = code
if (pageArtifactStore && lastUpdated) {
const timestampSource =
dynamicRouteSources.get(normalizePath(id)) || id
artifactInput += `\0vitepress:last-updated:${await getGitTimestamp(timestampSource)}`
}
const artifact = pageArtifactStore
? await pageArtifactStore.getOrCreate(
artifactPage,
artifactInput,
pageArtifactStore.readOnly
? undefined
: () => markdownToVue(code, id),
(artifact) => markdownToVue.finalize(artifact, id)
)
: await markdownToVue(code, id)
const {
vueSrc,
deadLinks,
linkCandidates,
linkContext,
includes,
pageData
} = artifact
const currentDeadLinks = isSsrBatch
? []
: linkCandidates && linkContext
? resolveDeadLinks(linkCandidates, linkContext, siteConfig)
: deadLinks
if (pageMetaMap) {
pageMetaMap[pageData.relativePath] = {
lastUpdated: pageData.lastUpdated
}
}
allDeadLinks.push(...deadLinks)
allDeadLinks.push(...currentDeadLinks)
if (includes.length) {
includes.forEach((i) => {
;(importerMap[slash(i)] ??= new Set()).add(slash(id))
@ -233,7 +361,7 @@ export async function createVitePressPlugin(
this.environment.mode === 'dev' &&
this.environment.name === 'client'
) {
logDeadLinks(deadLinks, siteConfig.logger, true)
logDeadLinks(currentDeadLinks, siteConfig.logger, true)
const payload: PageDataPayload = {
path: `/${pageData.relativePath}`,
pageData
@ -245,7 +373,17 @@ export async function createVitePressPlugin(
data: payload
})
}
return processClientJS(vueSrc, id)
// An artifact-only coordinator environment needs Vite to execute all
// enforce-pre source transforms and this Markdown transform. Keeping
// Vue out of that pass avoids compiling throwaway client/SSR JS.
return artifactOnly
? 'export default {}'
: processClientJS(
artifact.staticPage
? createStaticPageVueSource(artifact)
: vueSrc,
id
)
}
if (docsearchRE.test(normalizePath(id))) {
return code
@ -305,6 +443,7 @@ export async function createVitePressPlugin(
},
renderChunk(code, chunk) {
if (artifactOnly) return null
if (!ssr && isPageChunk(chunk)) {
// For each page chunk, inject marker for start/end of static strings.
// we do this here because in generateBundle the chunks would have been
@ -325,6 +464,7 @@ export async function createVitePressPlugin(
generateBundle: {
order: ssr ? null : 'post',
handler(_options, bundle) {
if (artifactOnly) return
if (ssr) {
this.emitFile({
type: 'asset',
@ -358,6 +498,10 @@ export async function createVitePressPlugin(
}
},
async closeBundle() {
await pageArtifactStore?.flush()
},
async hotUpdate({ file, type }) {
if (this.environment.name !== 'client') return
const relativePath = path.posix.relative(srcDir, file)
@ -396,6 +540,53 @@ export async function createVitePressPlugin(
}
}
// This must be the final user-visible buildStart hook. Loading modules from
// an earlier hook would run user transforms before a later user buildStart
// had initialized their state, unlike Rollup's normal module-load ordering.
const coordinatorPreloadPlugin: Plugin | undefined = coordinatorClient
? {
name: 'vitepress:coordinator-page-preload',
enforce: 'post',
buildStart: {
order: 'post',
sequential: true,
async handler() {
const concurrency = Math.max(
1,
Math.min(siteConfig.buildConcurrency, siteConfig.pages.length)
)
await pMap(
siteConfig.pages,
async (page) => {
const pageId = normalizePath(path.resolve(srcDir, page))
const resolved = await this.resolve(pageId, undefined, {
isEntry: true
})
if (!resolved || resolved.external) {
throw new Error(
`Unable to preload VitePress page entry ${page}.`
)
}
try {
await this.load({
id: resolved.id,
// Wait for plugin-vue's template/script/style submodules.
// The declared entry will reuse this same transformed graph,
// so its heavyweight SFC descriptor can then be compacted
// while later pages are still streaming through the build.
resolveDependencies: true
})
} finally {
vueDescriptorMemoryPlugin?.api.release([pageId])
}
},
{ concurrency }
)
}
}
}
: undefined
const hmrFix: Plugin = {
name: 'vitepress:hmr-fix',
async hotUpdate({ file, type, modules: existingMods }) {
@ -429,16 +620,27 @@ export async function createVitePressPlugin(
}
}
if (artifactOnly) {
return [
vitePressPlugin,
// User enforce-pre transforms are part of the Markdown artifact input.
...(userViteConfig?.plugins || []),
await dynamicRoutesPlugin(siteConfig)
]
}
return [
vitePressPlugin,
rewritesPlugin(siteConfig),
vuePlugin,
...(vuePlugin ? [vuePlugin] : []),
hmrFix,
webFontsPlugin(siteConfig.useWebFonts),
...(userViteConfig?.plugins || []),
await localSearchPlugin(siteConfig),
await localSearchPlugin(siteConfig, isSsrBatch, pageArtifactStore),
staticDataPlugin,
await dynamicRoutesPlugin(siteConfig)
await dynamicRoutesPlugin(siteConfig),
...(vueDescriptorMemoryPlugin ? [vueDescriptorMemoryPlugin] : []),
...(coordinatorPreloadPlugin ? [coordinatorPreloadPlugin] : [])
]
}

@ -6,6 +6,8 @@ import type { Plugin, ViteDevServer } from 'vite'
import type { SiteConfig } from '../config'
import type { DefaultTheme } from '../defaultTheme'
import { createMarkdownRenderer } from '../markdown/markdown'
import type { MarkdownCompileResult } from '../markdownToVue'
import type { PageArtifactStore } from '../pageArtifacts'
import { getLocaleForPath, slash, type MarkdownEnv } from '../shared'
import { readFile } from '../utils/fs'
import { processIncludes } from '../utils/processIncludes'
@ -26,9 +28,11 @@ interface IndexObject {
}
export async function localSearchPlugin(
siteConfig: SiteConfig<DefaultTheme.Config>
siteConfig: SiteConfig<DefaultTheme.Config>,
stubIndex = false,
pageArtifactStore?: PageArtifactStore
): Promise<Plugin> {
if (siteConfig.site.themeConfig?.search?.provider !== 'local') {
if (stubIndex || siteConfig.site.themeConfig?.search?.provider !== 'local') {
return {
name: 'vitepress:local-search',
resolveId: {
@ -46,24 +50,43 @@ export async function localSearchPlugin(
}
}
// created in configResolved to use the resolved publicDir
let md: Awaited<ReturnType<typeof createMarkdownRenderer>>
// Lazily created only when an artifact cannot satisfy search indexing. This
// lets a fully seeded production build avoid initializing Markdown/Shiki.
let publicDir = siteConfig.publicDir
let mdPromise: ReturnType<typeof createMarkdownRenderer> | undefined
const getMarkdownRenderer = () =>
(mdPromise ??= createMarkdownRenderer(
siteConfig.srcDir,
siteConfig.markdown,
siteConfig.site.base,
siteConfig.logger,
publicDir,
siteConfig.cacheDir
))
const options = siteConfig.site.themeConfig.search.options || {}
async function render(file: string) {
async function render(file: string, artifact?: MarkdownCompileResult) {
const { srcDir, cleanUrls = false } = siteConfig
const md = await getMarkdownRenderer()
const relativePath = slash(path.relative(srcDir, file))
const env: MarkdownEnv = { path: file, relativePath, cleanUrls }
const raw = await readFile(file).catch((e) => {
if (e.code === 'ENOENT') {
debug(`File not found: ${file}`)
return ''
}
throw e
})
const src = await processIncludes(md, srcDir, raw, file, [], cleanUrls)
if (options._render) {
let src: string
try {
const raw = await readFile(file)
src = await processIncludes(md, srcDir, raw, file, [], cleanUrls)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
// Dynamic routes have no physical output file. Their seeded artifact is
// the only source available; physical pages retain the historical raw
// read/include semantics and avoid applying enforce-pre transforms twice.
src = artifact?.markdownSource ?? ''
if (!src) debug(`File not found: ${file}`)
}
if (options._transformHtml) {
const html = await md.renderAsync(src, env)
return options._transformHtml(html, env)
} else if (options._render) {
return options._render(src, env, md)
} else {
const html = await md.renderAsync(src, env)
@ -126,6 +149,7 @@ export async function localSearchPlugin(
async function indexFile(page: string) {
const file = path.join(siteConfig.srcDir, page)
const artifactPage = siteConfig.rewrites.map[page] || page
// get file metadata
const fileId = getDocId(file)
const locale = getLocaleForPath(
@ -134,7 +158,28 @@ export async function localSearchPlugin(
)
const index = getIndexByLocale(locale)
// retrieve file and split into "sections"
const html = await render(file)
const artifact = await pageArtifactStore?.getCurrent(artifactPage)
let html: string | undefined
if (artifact) {
if (options._transformHtml) {
html = await options._transformHtml(artifact.html, {
...(artifact.markdownEnv ?? {
path: file,
relativePath: artifact.pageData.relativePath,
cleanUrls: siteConfig.cleanUrls ?? false,
frontmatter: artifact.pageData.frontmatter
})
})
} else if (!options._render) {
html =
artifact.pageData.frontmatter.search === false ? '' : artifact.html
}
}
if (html == null) {
html = await render(file, artifact)
}
if (!html) return
const sections =
// user provided generator
@ -167,14 +212,8 @@ export async function localSearchPlugin(
return {
name: 'vitepress:local-search',
async configResolved(config) {
md = await createMarkdownRenderer(
siteConfig.srcDir,
siteConfig.markdown,
siteConfig.site.base,
siteConfig.logger,
config.publicDir
)
configResolved(config) {
publicDir = config.publicDir
},
config() {

@ -0,0 +1,265 @@
import { normalizePath, type Plugin, type ResolvedConfig } from 'vite'
export const VUE_DESCRIPTOR_MEMORY_PLUGIN = 'vitepress:vue-descriptor-memory'
interface MutableSfcBlock {
content?: string
map?: unknown
ast?: unknown
loc?: { source?: string }
}
interface MutableSfcDescriptor {
filename?: string
source?: string
template?: MutableSfcBlock | null
script?: MutableSfcBlock | null
scriptSetup?: MutableSfcBlock | null
styles?: MutableSfcBlock[]
customBlocks?: MutableSfcBlock[]
}
interface MutableCompileScriptResult {
content?: string
map?: unknown
scriptAst?: unknown
scriptSetupAst?: unknown
deps?: string[]
imports?: Record<string, unknown>
bindings?: Record<string, unknown>
}
interface MutableVueCompiler {
parse: (...args: any[]) => { descriptor?: MutableSfcDescriptor }
compileScript?: (
descriptor: MutableSfcDescriptor,
...args: any[]
) => MutableCompileScriptResult
parseCache?: { clear?: () => void }
}
interface MutableVuePluginOptions {
compiler?: MutableVueCompiler
[key: string]: unknown
}
interface MutableVuePluginApi {
options: MutableVuePluginOptions
}
interface VuePluginWithCompiler extends Plugin {
api?: MutableVuePluginApi
}
export interface VueDescriptorMemoryApi {
release(files?: Iterable<string>): void
readonly retainedFiles: number
}
const descriptorLeases = new WeakMap<MutableSfcDescriptor, number>()
const scriptLeases = new WeakMap<MutableCompileScriptResult, number>()
function retainTracked<T extends object>(
values: Set<T>,
value: T,
leases: WeakMap<T, number>
): void {
if (values.has(value)) return
values.add(value)
leases.set(value, (leases.get(value) ?? 0) + 1)
}
function releaseTracked<T extends object>(
values: Set<T> | undefined,
leases: WeakMap<T, number>,
compact: (value: T) => void
): void {
if (!values) return
for (const value of values) {
const count = leases.get(value) ?? 1
if (count <= 1) {
leases.delete(value)
compact(value)
} else {
leases.set(value, count - 1)
}
}
}
function compactBlock(block: MutableSfcBlock | null | undefined): void {
if (!block) return
block.content = ''
block.map = undefined
block.ast = undefined
if (block.loc) block.loc.source = ''
}
function compactDescriptor(descriptor: MutableSfcDescriptor): void {
descriptor.source = ''
compactBlock(descriptor.template)
compactBlock(descriptor.script)
compactBlock(descriptor.scriptSetup)
descriptor.styles?.forEach(compactBlock)
descriptor.customBlocks?.forEach(compactBlock)
}
function compactScript(result: MutableCompileScriptResult): void {
result.content = ''
result.map = undefined
result.scriptAst = undefined
result.scriptSetupAst = undefined
result.deps = []
result.imports = {}
result.bindings = {}
}
/**
* @vitejs/plugin-vue keeps production SFC descriptors in process-global maps.
* On documentation sites this otherwise retains every Markdown page's full
* source, block locations and template AST after its compiled module has been
* emitted. Track the same descriptor objects through the public compiler
* option and compact them only after the owning build/batch has completed.
*/
export function createVueDescriptorMemoryPlugin(
vuePlugin: Plugin
): Plugin & { api: VueDescriptorMemoryApi } {
const descriptors = new Map<string, Set<MutableSfcDescriptor>>()
const scripts = new Map<string, Set<MutableCompileScriptResult>>()
let vueApi: MutableVuePluginApi | undefined
let sourceCompiler: MutableVueCompiler | undefined
let compilerFacade: MutableVueCompiler | undefined
let originalParse: MutableVueCompiler['parse'] | undefined
let originalCompileScript: MutableVueCompiler['compileScript'] | undefined
const release: VueDescriptorMemoryApi['release'] = (files) => {
const selected = files
? new Set([...files].map((file) => normalizePath(file)))
: new Set([...descriptors.keys(), ...scripts.keys()])
for (const file of selected) {
releaseTracked(descriptors.get(file), descriptorLeases, compactDescriptor)
releaseTracked(scripts.get(file), scriptLeases, compactScript)
descriptors.delete(file)
scripts.delete(file)
}
sourceCompiler?.parseCache?.clear?.()
}
const dispose = () => {
release()
if (
vueApi &&
sourceCompiler &&
compilerFacade &&
vueApi.options.compiler === compilerFacade
) {
vueApi.options = {
...vueApi.options,
compiler: sourceCompiler
}
}
vueApi = undefined
sourceCompiler = undefined
compilerFacade = undefined
originalParse = undefined
originalCompileScript = undefined
}
const api: VueDescriptorMemoryApi = {
release,
get retainedFiles() {
return new Set([...descriptors.keys(), ...scripts.keys()]).size
}
}
const memoryPlugin: Plugin & { api: VueDescriptorMemoryApi } = {
name: VUE_DESCRIPTOR_MEMORY_PLUGIN,
api,
buildStart: {
order: 'post',
sequential: true,
handler() {
const api = (vuePlugin as VuePluginWithCompiler).api
const resolvedCompiler = api?.options.compiler
if (!api || !resolvedCompiler || resolvedCompiler === compilerFacade) {
return
}
dispose()
vueApi = api
sourceCompiler = resolvedCompiler
originalParse = resolvedCompiler.parse
// plugin-vue resolves @vue/compiler-sfc to a process-global module
// namespace. Never patch that namespace directly: concurrent Vite
// builds can use it at the same time. Give only this plugin instance
// an isolated facade and replace it through plugin-vue's public API.
const facade = Object.create(resolvedCompiler) as MutableVueCompiler
facade.parse = function (...args) {
const result = originalParse!.apply(sourceCompiler, args)
const descriptor = result?.descriptor
if (descriptor?.filename) {
const filename = normalizePath(descriptor.filename)
let retained = descriptors.get(filename)
if (!retained) descriptors.set(filename, (retained = new Set()))
retainTracked(retained, descriptor, descriptorLeases)
}
return result
}
if (resolvedCompiler.compileScript) {
originalCompileScript = resolvedCompiler.compileScript
facade.compileScript = function (descriptor, ...args) {
const result = originalCompileScript!.call(
sourceCompiler,
descriptor,
...args
)
if (descriptor.filename) {
const filename = normalizePath(descriptor.filename)
let retained = scripts.get(filename)
if (!retained) scripts.set(filename, (retained = new Set()))
retainTracked(retained, result, scriptLeases)
}
return result
}
}
compilerFacade = facade
api.options = {
...api.options,
compiler: facade
}
}
},
buildEnd: {
order: 'post',
sequential: true,
handler(error) {
release()
// Rollup does not guarantee closeBundle after graph-construction
// failure, so restore this plugin instance's facade here on errors.
if (error) dispose()
}
},
closeBundle: {
order: 'post',
sequential: true,
handler() {
dispose()
}
}
}
return memoryPlugin
}
export function getVueDescriptorMemoryApi(
config: ResolvedConfig
): VueDescriptorMemoryApi | undefined {
return (
config.plugins.find(
(plugin) => plugin.name === VUE_DESCRIPTOR_MEMORY_PLUGIN
) as (Plugin & { api?: VueDescriptorMemoryApi }) | undefined
)?.api
}

@ -222,6 +222,26 @@ export interface UserConfig<
* @default 64
*/
buildConcurrency?: number
/**
* Maximum number of pages loaded by each disposable SSR render worker.
* Markdown, the browser bundle, and the shared SSR application/theme runtime
* are compiled once by the coordinator; render workers never run Vite.
*
* Smaller values release Node's page-module cache more often. Unlike legacy
* SSR bundling, reducing this value does not rebuild the application, theme,
* or Markdown pages for every batch.
* @experimental
*/
ssrBuildBatchSize?: number
/**
* Maximum number of lightweight SSR render workers to run concurrently.
* Workers reuse the coordinator's transformed page artifacts and never run
* Vite or resolve the site configuration themselves.
*
* @experimental
* @default 1
*/
ssrBuildWorkerConcurrency?: number
/**
* Source-to-destination page path mappings, or a function returning
* the destination path for a source path. Used to serve pages at
@ -384,4 +404,9 @@ export interface SiteConfig<ThemeConfig = any> extends Pick<
* Number of pages rendered concurrently during the build.
*/
buildConcurrency: number
/**
* Maximum number of pages loaded by each SSR render worker.
*/
ssrBuildBatchSize?: number
ssrBuildWorkerConcurrency: number
}

@ -8,6 +8,7 @@ import { slash } from '../shared'
const debug = createDebug('vitepress:git')
const cache = new Map<string, number>()
const authoritativeRoots = new Set<string>()
const RS = 0x1e
const NUL = 0x00
@ -106,11 +107,25 @@ class GitLogParser extends Transform {
export async function cacheAllGitTimestamps(
root: string,
pathspec: string[] = ['*.md']
pathspec: string[] = ['*.md'],
cacheMissing = false
): Promise<void> {
const cp = sync('git', ['rev-parse', '--show-toplevel'], { cwd: root })
if (cp.error) throw cp.error
const gitRoot = cp.stdout.toString('utf8').trim()
if (cp.status !== 0) {
throw new Error(
`git rev-parse failed (${cp.signal ? `signal ${cp.signal}` : `exit ${cp.status}`}): ${cp.stderr.toString('utf8').trim()}`
)
}
const gitRoot = slash(path.resolve(cp.stdout.toString('utf8').trim()))
const head = sync('git', ['rev-parse', '--verify', 'HEAD^{commit}'], {
cwd: root
})
if (head.error) throw head.error
if (head.status !== 0) {
publishGitCache(gitRoot, new Map(), cacheMissing)
return
}
const args = [
'log',
@ -121,27 +136,66 @@ export async function cacheAllGitTimestamps(
...pathspec
]
cache.clear()
const child = spawn('git', args, { cwd: root })
const records = child.stdout.pipe(new GitLogParser())
child.on('error', (err) => records.destroy(err))
let stderr = ''
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk) => {
stderr += chunk
})
const close = once(child, 'close') as Promise<
[code: number | null, signal: NodeJS.Signals | null]
>
const nextCache = new Map<string, number>()
for await (const rec of records as AsyncIterable<GitLogRecord>) {
for (const file of rec.files) {
const slashed = slash(path.resolve(gitRoot, file))
if (!cache.has(slashed)) cache.set(slashed, rec.ts)
if (!nextCache.has(slashed)) nextCache.set(slashed, rec.ts)
}
}
const [code, signal] = await close
if (code !== 0) {
throw new Error(
`git log failed (${signal ? `signal ${signal}` : `exit ${code}`}): ${stderr.trim()}`
)
}
// Publish a complete repository snapshot atomically. Other repository
// caches stay valid for concurrent/sequential multi-site builds.
publishGitCache(gitRoot, nextCache, cacheMissing)
}
export async function getGitTimestamp(file: string): Promise<number> {
const cached = cache.get(file)
if (cached) return cached
const normalizedFile = slash(path.resolve(file))
const cached = cache.get(normalizedFile)
if (cached !== undefined) return cached
// A production pre-scan is authoritative for the repository history. Files
// missing from it are untracked (or have no commits), so spawning one git
// process per miss only repeats work and is especially costly for generated
// dynamic routes.
if (
[...authoritativeRoots].some((root) => isWithinRoot(normalizedFile, root))
) {
cache.set(normalizedFile, 0)
return 0
}
// most likely will never happen except for recently added files in dev
debug(`[cache miss] ${file}`)
if (!fs.existsSync(file)) return 0
if (!fs.existsSync(file)) {
return 0
}
const head = sync('git', ['rev-parse', '--verify', 'HEAD^{commit}'], {
cwd: path.dirname(file)
})
if (head.error) throw head.error
if (head.status !== 0) return 0
const child = spawn(
'git',
@ -151,11 +205,38 @@ export async function getGitTimestamp(file: string): Promise<number> {
let output = ''
child.stdout.on('data', (d) => (output += String(d)))
await once(child, 'close')
const [code, signal] = (await once(child, 'close')) as [
number | null,
NodeJS.Signals | null
]
if (code !== 0) {
throw new Error(
`git log failed for ${file} (${signal ? `signal ${signal}` : `exit ${code}`}).`
)
}
const ts = Number.parseInt(output.trim(), 10) * 1000
if (!(ts > 0)) return 0
if (!(ts > 0)) {
return 0
}
cache.set(file, ts)
cache.set(normalizedFile, ts)
return ts
}
function isWithinRoot(file: string, root: string): boolean {
return file === root || file.startsWith(`${root}/`)
}
function publishGitCache(
gitRoot: string,
nextCache: ReadonlyMap<string, number>,
cacheMissing: boolean
): void {
for (const file of cache.keys()) {
if (isWithinRoot(file, gitRoot)) cache.delete(file)
}
for (const [file, timestamp] of nextCache) cache.set(file, timestamp)
if (cacheMissing) authoritativeRoots.add(gitRoot)
else authoritativeRoots.delete(gitRoot)
}

Loading…
Cancel
Save