diff --git a/__tests__/e2e/.vitepress/config.ts b/__tests__/e2e/.vitepress/config.ts index 7ec72c30..2e50e4c4 100644 --- a/__tests__/e2e/.vitepress/config.ts +++ b/__tests__/e2e/.vitepress/config.ts @@ -1,5 +1,10 @@ +import path from 'node:path' import { defineConfig, type DefaultTheme } from 'vitepress' +let renderCapturedMarkdown: (() => Promise) | undefined +const batchHeadHookPages = new Set() +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( + '', + `\n ` + ) + }, + 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 } }) diff --git a/__tests__/e2e/batch-public/batch-public.txt b/__tests__/e2e/batch-public/batch-public.txt new file mode 100644 index 00000000..a12dd8ad --- /dev/null +++ b/__tests__/e2e/batch-public/batch-public.txt @@ -0,0 +1 @@ +copied once by the client build diff --git a/__tests__/e2e/dynamic-routes/dynamic-routes.test.ts b/__tests__/e2e/dynamic-routes/dynamic-routes.test.ts index 5829ae89..d206b88d 100644 --- a/__tests__/e2e/dynamic-routes/dynamic-routes.test.ts +++ b/__tests__/e2e/dynamic-routes/dynamic-routes.test.ts @@ -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') diff --git a/__tests__/e2e/local-search/local-search.test.ts b/__tests__/e2e/local-search/local-search.test.ts index 07ec1926..741436c4 100644 --- a/__tests__/e2e/local-search/local-search.test.ts +++ b/__tests__/e2e/local-search/local-search.test.ts @@ -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 [ diff --git a/__tests__/e2e/ssr-batching.test.ts b/__tests__/e2e/ssr-batching.test.ts index 30ade433..7b48e4aa 100644 --- a/__tests__/e2e/ssr-batching.test.ts +++ b/__tests__/e2e/ssr-batching.test.ts @@ -80,6 +80,9 @@ test('resolved config-file hooks preserve legacy physical Markdown SSR semantics expect(html).toContain( '

environment-sensitive Markdown transform

' ) + expect(html).toContain( + '

production plugin context

' + ) expect(html).not.toContain('data-resolved-transform-mode="client"') }) diff --git a/__tests__/e2e/ssr-scoped.md b/__tests__/e2e/ssr-scoped.md new file mode 100644 index 00000000..597d6e75 --- /dev/null +++ b/__tests__/e2e/ssr-scoped.md @@ -0,0 +1,14 @@ +--- +title: Scoped batching page +description: A page that must preserve its physical Markdown module identity. +--- + +# Scoped batching page + +
Scoped module identity
+ + diff --git a/__tests__/e2e/ssr-static.md b/__tests__/e2e/ssr-static.md new file mode 100644 index 00000000..f8708a0a --- /dev/null +++ b/__tests__/e2e/ssr-static.md @@ -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. + +

Static HTML marker

+ +## Static presentational markup + +static badge + + + +Static public asset diff --git a/__tests__/e2e/vite.config.ts b/__tests__/e2e/vite.config.ts index 9d27b581..426a4c17 100644 --- a/__tests__/e2e/vite.config.ts +++ b/__tests__/e2e/vite.config.ts @@ -38,7 +38,8 @@ export default defineConfig({ filter: { id: artifactSafetyPageRE }, handler(code, _id, options) { const mode = options?.ssr ? 'server' : 'client' - return `${code}\n

environment-sensitive Markdown transform

` + const pluginContext = `${this.environment.mode}:${this.meta.watchMode}` + return `${code}\n

environment-sensitive Markdown transform

\n

production plugin context

` } } } diff --git a/__tests__/e2e/vitestGlobalSetup.ts b/__tests__/e2e/vitestGlobalSetup.ts index 74596801..ea13d593 100644 --- a/__tests__/e2e/vitestGlobalSetup.ts +++ b/__tests__/e2e/vitestGlobalSetup.ts @@ -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 { diff --git a/__tests__/unit/node/build/bundle.test.ts b/__tests__/unit/node/build/bundle.test.ts new file mode 100644 index 00000000..3a76b704 --- /dev/null +++ b/__tests__/unit/node/build/bundle.test.ts @@ -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) { + 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 = 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 = 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 & + Partial, + 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 + 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() + 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() + 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() + 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) +}) diff --git a/__tests__/unit/node/build/pageArtifactCache.test.ts b/__tests__/unit/node/build/pageArtifactCache.test.ts new file mode 100644 index 00000000..5f921817 --- /dev/null +++ b/__tests__/unit/node/build/pageArtifactCache.test.ts @@ -0,0 +1,94 @@ +import { resolvePageArtifactCachePolicy } from 'node/build/build' +import type { SiteConfig } from 'node/config' + +function createSiteConfig( + markdown: SiteConfig['markdown'], + overrides: Partial = {} +): 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.' + ) + }) +}) diff --git a/__tests__/unit/node/build/render.test.ts b/__tests__/unit/node/build/render.test.ts new file mode 100644 index 00000000..e634939a --- /dev/null +++ b/__tests__/unit/node/build/render.test.ts @@ -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 => + ({ + 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: '
Guide
', + teleports: { body: '
teleported
' }, + 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: '
Guide
', + teleports: { body: '
teleported
' } + } + }) + expect(restored.context.vpSocialIcons).toBeInstanceOf(Set) + expect([...restored.context.vpSocialIcons]).toEqual(['a-icon', 'z-icon']) +}) diff --git a/__tests__/unit/node/build/ssrBatchUtils.test.ts b/__tests__/unit/node/build/ssrBatchUtils.test.ts new file mode 100644 index 00000000..b56f645d --- /dev/null +++ b/__tests__/unit/node/build/ssrBatchUtils.test.ts @@ -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 + ]) +}) diff --git a/__tests__/unit/node/build/ssrModuleCompiler.test.ts b/__tests__/unit/node/build/ssrModuleCompiler.test.ts new file mode 100644 index 00000000..9134568c --- /dev/null +++ b/__tests__/unit/node/build/ssrModuleCompiler.test.ts @@ -0,0 +1,1031 @@ +import { + createSsrModuleCompiler, + type SsrModuleCompiler +} from 'node/build/ssrModuleCompiler' +import { SsrModuleArtifactTransport } from 'node/build/ssrModuleTransport' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { perEnvironmentState, type Plugin } from 'vite' +import { createNodeImportMeta, ModuleRunner } from 'vite/module-runner' + +describe('SsrModuleCompiler', () => { + let root: string | undefined + const compilers = new Set() + + afterEach(async () => { + await Promise.all([...compilers].map((compiler) => compiler.close())) + compilers.clear() + if (root) { + await rm(root, { recursive: true, force: true }) + root = undefined + } + }) + + async function createFixture() { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-ssr-modules-')) + await writeFile( + path.join(root, 'package.json'), + JSON.stringify({ type: 'module' }) + ) + return { + root, + artifactDir: path.join(root, '.artifacts') + } + } + + test('materializes final asset URLs and externalizes shared runtime bridges', async () => { + const fixture = await createFixture() + const bridge = path.join(fixture.root, 'runtime-bridge.mjs') + await writeFile(bridge, 'export const shared = true') + + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:resolve-runtime-bridge', + resolveId(id) { + if (id === 'virtual:runtime') return '\0test:runtime' + if (id === 'virtual:runtime?raw') return '\0test:runtime?raw' + }, + load(id) { + if (id === '\0test:runtime?raw') { + return 'export default "raw-runtime-source"' + } + } + } + ] + }, + fixture.artifactDir, + { + runtimeBridges: new Map([['\0test:runtime', bridge]]), + resolveAsset: new Map([ + ['virtual:logo', '/assets/logo.content-hash.svg'] + ]) + } + ) + compilers.add(compiler) + await compiler.init() + + await expect(compiler.handleFetch(['virtual:runtime'])).resolves.toEqual({ + externalize: pathToFileURL(bridge).href, + type: 'module' + }) + + const queriedRuntime = await compiler.handleFetch(['virtual:runtime?raw']) + expect('externalize' in queriedRuntime).toBe(false) + expect('code' in queriedRuntime).toBe(true) + if ('code' in queriedRuntime) { + expect(queriedRuntime.code).toContain('raw-runtime-source') + } + + const asset = await compiler.handleFetch(['virtual:logo']) + expect('cache' in asset).toBe(false) + expect('code' in asset).toBe(true) + if ('code' in asset) { + expect(asset.code).toContain('/assets/logo.content-hash.svg') + expect(asset.code).not.toContain('/@fs/') + expect(asset.invalidate).toBe(false) + } + }) + + test('runs SSR plugin buildStart hooks before transforming modules', async () => { + const fixture = await createFixture() + let initialized = false + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:ssr-build-start', + buildStart() { + initialized = true + }, + resolveId(id) { + if (id === 'virtual:after-build-start') { + return '\0test:after-build-start' + } + }, + load(id) { + if (id !== '\0test:after-build-start') return + if (!initialized) throw new Error('buildStart did not run') + return 'export const initialized = true' + } + } + ] + }, + fixture.artifactDir, + { persistArtifacts: false } + ) + compilers.add(compiler) + await compiler.init() + + const result = await compiler.precompile('virtual:after-build-start') + expect(initialized).toBe(true) + expect('code' in result && result.code).toContain('initialized') + }) + + test('presents production non-watch semantics to user plugin hooks', async () => { + const fixture = await createFixture() + const observations: { + hook: string + mode: string + watchMode: boolean + }[] = [] + const environments = new Set() + const environmentState = perEnvironmentState(() => ({ + hooks: [] as string[] + })) + let sharedEnvironmentState: { hooks: string[] } | undefined + let sawBuildContextSurface = false + let readModuleMeta = false + const observe = ( + hook: string, + context: { + environment: { mode: string } + meta: { watchMode: boolean } + } + ) => { + const state = environmentState(context as never) + sharedEnvironmentState ||= state + expect(state).toBe(sharedEnvironmentState) + state.hooks.push(hook) + environments.add(context.environment) + observations.push({ + hook, + mode: context.environment.mode, + watchMode: context.meta.watchMode + }) + } + let readCombinedSourcemap = false + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:ssr-build-context', + options(options) { + observe('options', this) + expect(this.emitFile).toBeUndefined() + expect(this.getFileName).toBeUndefined() + expect(this.getModuleInfo).toBeUndefined() + expect(this.getModuleIds).toBeUndefined() + return options + }, + buildStart() { + observe('buildStart', this) + }, + resolveId(id) { + if (id !== 'virtual:build-context') return + observe('resolveId', this) + return '\0test:build-context' + }, + load(id) { + if (id !== '\0test:build-context') return + observe('load', this) + return 'export const context = true' + }, + transform(code, id) { + if (id !== '\0test:build-context') return + observe('transform', this) + sawBuildContextSurface = + typeof this.emitFile === 'function' && + typeof this.getFileName === 'function' && + typeof this.getModuleInfo === 'function' && + typeof this.getModuleIds === 'function' && + this.setAssetSource === undefined && + this.getWatchFiles === undefined + readModuleMeta = this.getModuleInfo(id)?.meta != null + this.getCombinedSourcemap() + readCombinedSourcemap = true + return code + }, + buildEnd() { + observe('buildEnd', this) + }, + closeBundle() { + observe('closeBundle', this) + } + } + ] + }, + fixture.artifactDir, + { persistArtifacts: false } + ) + compilers.add(compiler) + await compiler.init() + await compiler.precompile('virtual:build-context') + await compiler.close() + + expect(readCombinedSourcemap).toBe(true) + expect(readModuleMeta).toBe(true) + expect(sawBuildContextSurface).toBe(true) + expect(environments.size).toBe(1) + expect(observations.map(({ hook }) => hook)).toEqual([ + 'options', + 'buildStart', + 'resolveId', + 'load', + 'transform', + 'buildEnd', + 'closeBundle' + ]) + expect( + observations.every( + ({ mode, watchMode }) => mode === 'build' && watchMode === false + ) + ).toBe(true) + expect(sharedEnvironmentState?.hooks).toEqual( + observations.map(({ hook }) => hook) + ) + }) + + test('rejects Rolldown-only context methods instead of ignoring them', async () => { + const fixture = await createFixture() + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:ssr-emit-file', + resolveId(id) { + if (id === 'virtual:emit-file') return '\0test:emit-file' + }, + load(id) { + if (id !== '\0test:emit-file') return + this.emitFile({ + type: 'asset', + name: 'server-only.txt', + source: 'server-only' + }) + return 'export const emitted = true' + } + } + ] + }, + fixture.artifactDir, + { persistArtifacts: false } + ) + compilers.add(compiler) + await compiler.init() + + await expect(compiler.precompile('virtual:emit-file')).rejects.toThrow( + 'plugin "test:ssr-emit-file" called this.emitFile()' + ) + }) + + test('runs supported plugin teardown hooks when closing', async () => { + const fixture = await createFixture() + const lifecycle: string[] = [] + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:ssr-teardown-lifecycle', + buildEnd() { + lifecycle.push('buildEnd') + }, + closeBundle() { + lifecycle.push('closeBundle') + } + } + ] + }, + fixture.artifactDir, + { persistArtifacts: false } + ) + compilers.add(compiler) + await compiler.init() + + await compiler.close() + expect(lifecycle).toEqual(['buildEnd', 'closeBundle']) + }) + + test('propagates plugin teardown errors after closing the environment', async () => { + const fixture = await createFixture() + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:ssr-teardown-error', + closeBundle() { + throw new Error('SSR teardown failed') + } + } + ] + }, + fixture.artifactDir, + { persistArtifacts: false } + ) + compilers.add(compiler) + await compiler.init() + + const environment = ( + compiler as unknown as { + environment: { close: () => Promise } + } + ).environment + const closeEnvironment = vi.spyOn(environment, 'close') + + await expect(compiler.close()).rejects.toThrow('SSR teardown failed') + expect(closeEnvironment).toHaveBeenCalledOnce() + }) + + test('rejects output hooks before starting the unbundled page environment', async () => { + const fixture = await createFixture() + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:ssr-page-output', + renderChunk(code) { + return code + } + } + ] + }, + fixture.artifactDir, + { persistArtifacts: false } + ) + compilers.add(compiler) + + await expect(compiler.init()).rejects.toThrow( + 'plugin "test:ssr-page-output": renderChunk' + ) + }) + + test('rejects output hooks from build.rolldownOptions.plugins once', async () => { + const fixture = await createFixture() + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + build: { + rolldownOptions: { + plugins: [ + { + name: 'test:rolldown-page-output', + augmentChunkHash() { + return 'page-output' + } + } + ] + } + } + }, + fixture.artifactDir, + { persistArtifacts: false } + ) + compilers.add(compiler) + + const error = await compiler.init().catch((error: unknown) => error) + expect(error).toBeInstanceOf(Error) + const message = (error as Error).message + expect(message).toContain( + 'plugin "test:rolldown-page-output": augmentChunkHash' + ) + expect(message.match(/test:rolldown-page-output/g)).toHaveLength(1) + }) + + test('allows bundled-only output hooks excluded from the page environment', async () => { + const fixture = await createFixture() + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:bundled-page-output', + applyToEnvironment(environment) { + return environment.config.isBundled + }, + renderChunk(code) { + return code + } + } + ] + }, + fixture.artifactDir, + { persistArtifacts: false } + ) + compilers.add(compiler) + + await expect(compiler.init()).resolves.toBeUndefined() + }) + + test('deduplicates transforms and serves materialized CAS output to a new compiler', async () => { + const fixture = await createFixture() + const virtualId = '\0test:ssr-page' + let loadCalls = 0 + const sourcePlugin: Plugin = { + name: 'test:ssr-page-source', + resolveId(id) { + if (id === 'virtual:ssr-page') return virtualId + }, + load(id) { + if (id === virtualId) { + loadCalls++ + return 'export const page = "materialized"' + } + } + } + + const firstCompiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [sourcePlugin] + }, + fixture.artifactDir + ) + compilers.add(firstCompiler) + await firstCompiler.init() + + const [first, concurrent] = await Promise.all([ + firstCompiler.precompile('virtual:ssr-page'), + firstCompiler.precompile('virtual:ssr-page') + ]) + expect(concurrent).toEqual(first) + expect(loadCalls).toBe(1) + expect('cache' in first).toBe(false) + if ('code' in first) { + expect(first.code).toContain('materialized') + expect(first.invalidate).toBe(false) + } + + await firstCompiler.close() + compilers.delete(firstCompiler) + + const rejectUncachedLoad: Plugin = { + name: 'test:reject-uncached-ssr-page', + resolveId(id) { + if (id === 'virtual:ssr-page') return virtualId + }, + load(id) { + if (id === virtualId) { + throw new Error('expected transformed module to come from the CAS') + } + } + } + const secondCompiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [rejectUncachedLoad] + }, + fixture.artifactDir + ) + compilers.add(secondCompiler) + await secondCompiler.init() + + await expect( + secondCompiler.precompile('virtual:ssr-page') + ).resolves.toEqual(first) + }) + + test('does not persist entries and scopes dependency reuse by importer', async () => { + const fixture = await createFixture() + const entryId = '\0test:one-shot-entry' + const dependencyId = path.join(fixture.root, 'shared-dependency.js') + const transformedDependencyId = '\0test:shared-dependency' + + let entryLoads = 0 + let dependencyLoads = 0 + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:persistence-boundary', + resolveId(id) { + if (id === 'virtual:one-shot-entry') return entryId + if (id === dependencyId) return transformedDependencyId + }, + load(id) { + if (id === entryId) { + entryLoads++ + return 'export const entry = true' + } + if (id === transformedDependencyId) { + dependencyLoads++ + return 'export const dependency = true' + } + } + } + ] + }, + fixture.artifactDir, + { persistEntries: false } + ) + compilers.add(compiler) + await compiler.init() + + await compiler.precompile('virtual:one-shot-entry') + await compiler.precompile('virtual:one-shot-entry') + expect(entryLoads).toBe(2) + + const entryGraph = ( + compiler as unknown as { + environment: { + moduleGraph: { + getModuleById: (id: string) => unknown + urlToModuleMap: Map + } + } + } + ).environment.moduleGraph + expect(entryGraph.getModuleById(entryId)).toBeUndefined() + expect( + [...entryGraph.urlToModuleMap.values()].some( + (module) => module.id === entryId + ) + ).toBe(false) + + await compiler.handleFetch([dependencyId, '/first-page.md']) + await compiler.handleFetch([dependencyId, '/first-page.md']) + expect(dependencyLoads).toBe(1) + await compiler.handleFetch([dependencyId, '/second-page.md']) + expect(dependencyLoads).toBe(2) + }) + + test('removes absolute one-shot entries across ModuleRunner id spellings', async () => { + const fixture = await createFixture() + const entryFile = path.join(fixture.root, 'absolute-entry.js') + await writeFile(entryFile, 'export const absoluteEntry = true') + + const compiler = createSsrModuleCompiler( + { root: fixture.root, logLevel: 'silent' }, + fixture.artifactDir, + { persistEntries: false } + ) + compilers.add(compiler) + await compiler.init() + + const result = await compiler.precompile(entryFile) + expect('code' in result && result.code).toContain('absoluteEntry') + + const graph = ( + compiler as unknown as { + environment: { + moduleGraph: { + idToModuleMap: Map + urlToModuleMap: Map + } + } + } + ).environment.moduleGraph + expect( + [...graph.idToModuleMap.values(), ...graph.urlToModuleMap.values()].some( + (module) => module.id?.replace(/[?#].*$/, '') === entryFile + ) + ).toBe(false) + }) + + test('keeps importer-aware resolution and assets distinct for absolute requests', async () => { + const fixture = await createFixture() + const firstBridge = path.join(fixture.root, 'runtime-one.mjs') + const secondBridge = path.join(fixture.root, 'runtime-two.mjs') + const absoluteId = path.join(fixture.root, 'shared-runtime.js') + const windowsAbsoluteId = 'C:/docs/shared-runtime.js' + const fileUrlId = pathToFileURL( + path.join(fixture.root, 'file-url-runtime.js') + ).href + const ids = [absoluteId, windowsAbsoluteId, fileUrlId] + const importerOne = '/first-page.md' + const importerTwo = '/second-page.md' + const resolvedIds = new Map( + ids.map((id, index) => [ + id, + [`\0test:runtime-${index}-one`, `\0test:runtime-${index}-two`] + ]) + ) + const replacements = new Map() + for (const [one, two] of resolvedIds.values()) { + replacements.set(one, firstBridge) + replacements.set(two, secondBridge) + } + const absoluteAsset = path.join(fixture.root, 'logo.svg') + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:runtime-replacement-ids', + resolveId(id, importer) { + const resolved = resolvedIds.get(id) + if (resolved) { + return importer === importerOne ? resolved[0] : resolved[1] + } + } + } + ] + }, + fixture.artifactDir, + { + runtimeBridges: replacements, + resolveAsset(id, importer) { + if (id !== absoluteAsset && id !== fileUrlId) return + return importer === importerOne + ? '/assets/logo-one.svg' + : '/assets/logo-two.svg' + } + } + ) + compilers.add(compiler) + await compiler.init() + + for (const id of [absoluteId, windowsAbsoluteId]) { + await expect(compiler.handleFetch([id, importerOne])).resolves.toEqual({ + externalize: pathToFileURL(firstBridge).href, + type: 'module' + }) + await expect(compiler.handleFetch([id, importerTwo])).resolves.toEqual({ + externalize: pathToFileURL(secondBridge).href, + type: 'module' + }) + } + + const firstAsset = await compiler.handleFetch([absoluteAsset, importerOne]) + const secondAsset = await compiler.handleFetch([absoluteAsset, importerTwo]) + expect('code' in firstAsset && firstAsset.code).toContain( + '/assets/logo-one.svg' + ) + expect('code' in secondAsset && secondAsset.code).toContain( + '/assets/logo-two.svg' + ) + + const firstFileUrlAsset = await compiler.handleFetch([ + fileUrlId, + importerOne + ]) + const secondFileUrlAsset = await compiler.handleFetch([ + fileUrlId, + importerTwo + ]) + expect('code' in firstFileUrlAsset && firstFileUrlAsset.code).toContain( + '/assets/logo-one.svg' + ) + expect('code' in secondFileUrlAsset && secondFileUrlAsset.code).toContain( + '/assets/logo-two.svg' + ) + }) + + test('omits inline sourcemaps and releases transforms without invalidating importers', async () => { + const fixture = await createFixture() + const virtualId = '\0test:mapped-module' + const source = 'export const mapped = true' + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:mapped-module', + resolveId(id) { + if (id === 'virtual:mapped-module') return virtualId + }, + load(id) { + if (id === virtualId) return source + }, + transform(code, id) { + if (id !== virtualId) return + return { + code, + map: { + version: 3, + names: [], + sources: ['mapped-source.ts'], + sourcesContent: [source], + mappings: 'AAAA' + } + } + } + } + ] + }, + fixture.artifactDir, + { persistArtifacts: false } + ) + compilers.add(compiler) + await compiler.init() + + const graph = ( + compiler as unknown as { + environment: { + moduleGraph: { + invalidateModule: (...args: unknown[]) => void + updateModuleTransformResult: (...args: unknown[]) => void + } + } + } + ).environment.moduleGraph + const invalidate = vi.spyOn(graph, 'invalidateModule') + const release = vi.spyOn(graph, 'updateModuleTransformResult') + + const result = await compiler.precompile('virtual:mapped-module') + expect('code' in result).toBe(true) + if ('code' in result) { + expect(result.code).not.toContain('sourceMappingURL=data:') + expect(result.code).not.toContain('sourceMappingSource=vite-generated') + expect(result.invalidate).toBe(false) + } + expect(invalidate).not.toHaveBeenCalled() + expect(release).toHaveBeenCalledWith( + expect.objectContaining({ id: virtualId }), + null + ) + }) + + test('waits for accepted fetches before closing the environment', async () => { + const fixture = await createFixture() + const virtualId = '\0test:slow-module' + let finishLoad!: (source: string) => void + let markLoadStarted!: () => void + const loadStarted = new Promise((resolve) => { + markLoadStarted = resolve + }) + const compiler = createSsrModuleCompiler( + { + root: fixture.root, + logLevel: 'silent', + plugins: [ + { + name: 'test:slow-module', + resolveId(id) { + if (id === 'virtual:slow-module') return virtualId + }, + load(id) { + if (id !== virtualId) return + markLoadStarted() + return new Promise((resolve) => { + finishLoad = resolve + }) + } + } + ] + }, + fixture.artifactDir + ) + compilers.add(compiler) + await compiler.init() + + const fetch = compiler.precompile('virtual:slow-module') + await loadStarted + const closing = compiler.close() + await expect(compiler.precompile('virtual:slow-module')).rejects.toThrow( + 'SSR module compiler is closing' + ) + + finishLoad('export const slow = true') + await expect(fetch).resolves.toEqual( + expect.objectContaining({ invalidate: false }) + ) + await closing + }) + + test('materializes page graphs for an offline ModuleRunner', async () => { + const fixture = await createFixture() + const entry = path.join(fixture.root, 'page.js') + const dependency = path.join(fixture.root, 'dependency.js') + const dynamicDependency = path.join(fixture.root, 'dynamic.js') + await Promise.all([ + writeFile( + entry, + [ + "import { dependency } from './dependency.js'", + 'export const value = `page:${dependency}`', + "export const loadDynamic = () => import('./dynamic.js')" + ].join('\n') + ), + writeFile(dependency, 'export const dependency = "shared"'), + writeFile(dynamicDependency, 'export const dynamic = "loaded"') + ]) + + const compiler = createSsrModuleCompiler( + { root: fixture.root, logLevel: 'silent' }, + fixture.artifactDir, + { persistEntries: true, releaseEntries: true } + ) + compilers.add(compiler) + await compiler.init() + + const materialized = await compiler.materializeGraphs([entry], 2) + expect(materialized.entries).toBe(1) + expect(materialized.requests).toBe(3) + const builtins = compiler.getBuiltins() + await compiler.close() + compilers.delete(compiler) + + const runner = new ModuleRunner({ + transport: new SsrModuleArtifactTransport(fixture.artifactDir, builtins), + hmr: false, + createImportMeta: createNodeImportMeta, + sourcemapInterceptor: false + }) + try { + const page = (await runner.import(entry)) as { + value: string + loadDynamic: () => Promise<{ dynamic: string }> + } + expect(page.value).toBe('page:shared') + await expect(page.loadDynamic()).resolves.toMatchObject({ + dynamic: 'loaded' + }) + } finally { + await runner.close() + } + }) + + test('uses ModuleRunner file identity for query-module dependencies', async () => { + const fixture = await createFixture() + const entry = path.join(fixture.root, 'page.js') + const script = path.join(fixture.root, 'script.js') + const dependency = path.join(fixture.root, 'dependency.js') + await Promise.all([ + writeFile( + entry, + "import { value } from './script.js?part'\nexport { value }" + ), + writeFile( + script, + "import { dependency } from './dependency.js'\nexport const value = `query:${dependency}`" + ), + writeFile(dependency, 'export const dependency = "shared"') + ]) + + const compiler = createSsrModuleCompiler( + { root: fixture.root, logLevel: 'silent' }, + fixture.artifactDir, + { persistEntries: true, releaseEntries: true } + ) + compilers.add(compiler) + await compiler.init() + await compiler.materializeGraphs([entry]) + + const builtins = compiler.getBuiltins() + await compiler.close() + compilers.delete(compiler) + + const runner = new ModuleRunner({ + transport: new SsrModuleArtifactTransport(fixture.artifactDir, builtins), + hmr: false, + createImportMeta: createNodeImportMeta, + sourcemapInterceptor: false + }) + try { + await expect(runner.import(entry)).resolves.toMatchObject({ + value: 'query:shared' + }) + } finally { + await runner.close() + } + }) + + test('publishes only the module closure reachable by one worker batch', async () => { + const fixture = await createFixture() + const pageA = path.join(fixture.root, 'page-a.js') + const pageB = path.join(fixture.root, 'page-b.js') + const shared = path.join(fixture.root, 'shared.js') + const dynamicA = path.join(fixture.root, 'dynamic-a.js') + const onlyB = path.join(fixture.root, 'only-b.js') + await Promise.all([ + writeFile( + pageA, + [ + "import { shared } from './shared.js'", + 'export const value = `a:${shared}`', + "export const loadDynamic = () => import('./dynamic-a.js')" + ].join('\n') + ), + writeFile( + pageB, + [ + "import { shared } from './shared.js'", + "import { onlyB } from './only-b.js'", + 'export const value = `b:${shared}:${onlyB}`' + ].join('\n') + ), + writeFile(shared, 'export const shared = "shared"'), + writeFile(dynamicA, 'export const dynamicA = "dynamic-a"'), + writeFile(onlyB, 'export const onlyB = "only-b"') + ]) + + const compiler = createSsrModuleCompiler( + { root: fixture.root, logLevel: 'silent' }, + fixture.artifactDir, + { + persistEntries: true, + releaseEntries: true, + publishFullSnapshot: false + } + ) + compilers.add(compiler) + await compiler.init() + const materialized = await compiler.materializeGraphs([pageA, pageB], 2) + + const batchSnapshot = path.join( + fixture.artifactDir, + 'snapshots', + 'page-a.json' + ) + const requestCount = await compiler.writeSnapshotForEntries( + [pageA], + batchSnapshot + ) + const slicedSnapshot = JSON.parse( + await readFile(batchSnapshot, 'utf8') + ) as { requests: [string, string][] } + expect(slicedSnapshot.requests).toHaveLength(requestCount) + expect(requestCount).toBeLessThan(materialized.requests) + await expect( + readFile(path.join(fixture.artifactDir, 'snapshot.json'), 'utf8') + ).rejects.toMatchObject({ code: 'ENOENT' }) + + const builtins = compiler.getBuiltins() + await compiler.close() + compilers.delete(compiler) + + const runner = new ModuleRunner({ + transport: new SsrModuleArtifactTransport( + fixture.artifactDir, + builtins, + batchSnapshot + ), + hmr: false, + createImportMeta: createNodeImportMeta, + sourcemapInterceptor: false + }) + try { + const page = (await runner.import(pageA)) as { + value: string + loadDynamic: () => Promise<{ dynamicA: string }> + } + expect(page.value).toBe('a:shared') + await expect(page.loadDynamic()).resolves.toMatchObject({ + dynamicA: 'dynamic-a' + }) + + // Pointer files for page B exist in the shared store, but an explicit + // batch snapshot is authoritative and cannot escape its closure. + await expect(runner.import(pageB)).rejects.toThrow( + /Missing precompiled SSR module/ + ) + } finally { + await runner.close() + } + }) + + test('rejects runtime-computed imports before offline rendering', async () => { + const fixture = await createFixture() + const entry = path.join(fixture.root, 'computed.js') + await writeFile( + entry, + [ + "const target = './dependency.js'", + 'export const load = () => import(target)' + ].join('\n') + ) + + const compiler = createSsrModuleCompiler( + { root: fixture.root, logLevel: 'silent' }, + fixture.artifactDir, + { persistEntries: true, releaseEntries: true } + ) + compilers.add(compiler) + await compiler.init() + + await expect(compiler.materializeGraphs([entry])).rejects.toThrow( + /runtime-computed import/ + ) + }) + + test('reports a missing offline module with its importer', async () => { + const fixture = await createFixture() + const transport = new SsrModuleArtifactTransport(fixture.artifactDir, []) + + await expect( + transport.invoke({ + type: 'custom', + event: 'vite:invoke', + data: { + name: 'fetchModule', + data: ['./missing.js', '/page.js', {}] + } + }) + ).rejects.toThrow(/\.\/missing\.js.*\/page\.js/) + }) +}) diff --git a/__tests__/unit/node/build/ssrWorkerProtocol.test.ts b/__tests__/unit/node/build/ssrWorkerProtocol.test.ts new file mode 100644 index 00000000..44058088 --- /dev/null +++ b/__tests__/unit/node/build/ssrWorkerProtocol.test.ts @@ -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: '
Guide
', + vpSocialIcons: [], + customCallback: () => undefined + } as any + } + ] + }) + ).toThrow(/SSGContext must be structured-cloneable/) + }) +}) diff --git a/__tests__/unit/node/markdown/highlight.test.ts b/__tests__/unit/node/markdown/highlight.test.ts new file mode 100644 index 00000000..41832eb1 --- /dev/null +++ b/__tests__/unit/node/markdown/highlight.test.ts @@ -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() + }) +}) diff --git a/__tests__/unit/node/markdownToVue.test.ts b/__tests__/unit/node/markdownToVue.test.ts index 1d675f31..c7a6b141 100644 --- a/__tests__/unit/node/markdownToVue.test.ts +++ b/__tests__/unit/node/markdownToVue.test.ts @@ -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)[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)[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\nclient content', + expected: false + }, + { + name: 'interpolation', + source: '# Interpolation\n\n{{ count }}', + expected: false + }, + { + name: 'directive', + source: '# Directive\n\n', + 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
local
', + expected: false + }, + { + name: 'hash-asset', + source: '# Asset\n\nhash import', + expected: false + }, + { + name: 'mailto-asset', + source: '# Asset\n\nscheme', + expected: false + }, + { + name: 'duplicate-asset', + source: + '# Asset\n\n', + 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', + expected: false + }, + { + name: 'object-asset', + source: '# Object\n\n', + expected: false + }, + { + name: 'link-asset', + source: '# Link asset\n\n', + expected: false + }, + { + name: 'srcset-asset', + source: + '# Sources\n\n', + expected: false + }, + { + name: 'meta-asset', + source: + '# Metadata\n\n', + expected: false + }, + { + name: 'non-asset-meta', + source: + '# Metadata\n\n', + expected: true + }, + { + name: 'boolean-ref', + source: '# Reserved property\n\n
content
', + expected: false + }, + { + name: 'textarea-value', + source: '# Textarea\n\n', + expected: false + }, + { + name: 'highlighted-code', + source: '# Code\n\n```js\nconsole.log("render")\n```', + expected: true + }, + { + name: 'default-theme-badge', + source: '# Badge 1.2.3', + expected: true + }, + { + name: 'html-comment', + source: '# Comment\n\n\n\nContent', + expected: true + }, + { + name: 'sfc-block', + source: + '# SFC\n\n\n\n', + expected: false + }, + { + name: 'scoped-style', + source: '# Scoped style\n\n', + expected: false + }, + { + name: 'css-module', + source: '# CSS module\n\n', + 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( + '1.2.3' + ) + 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 custom' + 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('custom') + }) + + 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!)) +} diff --git a/__tests__/unit/node/pageArtifacts.test.ts b/__tests__/unit/node/pageArtifacts.test.ts new file mode 100644 index 00000000..859605b7 --- /dev/null +++ b/__tests__/unit/node/pageArtifacts.test.ts @@ -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 { + const shards = await readdir(root) + return ( + await Promise.all(shards.map((shard) => readdir(path.join(root, shard)))) + ).flat().length +} + +function createArtifact( + overrides: Partial = {} +): MarkdownCompileResult { + return { + vueSrc: '', + html: '

Page

', + pageData: { + title: 'Page', + description: '', + frontmatter: {}, + headers: [], + relativePath: 'page.md', + filePath: 'page.md' + }, + deadLinks: [], + includes: [], + staticPage: true, + ...overrides + } +} diff --git a/__tests__/unit/node/plugin.test.ts b/__tests__/unit/node/plugin.test.ts new file mode 100644 index 00000000..66a1e341 --- /dev/null +++ b/__tests__/unit/node/plugin.test.ts @@ -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 + } + 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 any>( + hook: T | { handler: T } | undefined +): T { + if (!hook) throw new Error('Expected plugin hook.') + return typeof hook === 'function' ? hook : hook.handler +} diff --git a/__tests__/unit/node/plugins/localSearchPlugin.test.ts b/__tests__/unit/node/plugins/localSearchPlugin.test.ts index 44e85696..72a99e01 100644 --- a/__tests__/unit/node/plugins/localSearchPlugin.test.ts +++ b/__tests__/unit/node/plugins/localSearchPlugin.test.ts @@ -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: '', + html: '

Search hook

', + 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) { diff --git a/__tests__/unit/node/plugins/vueDescriptorMemory.test.ts b/__tests__/unit/node/plugins/vueDescriptorMemory.test.ts new file mode 100644 index 00000000..65be4a70 --- /dev/null +++ b/__tests__/unit/node/plugins/vueDescriptorMemory.test.ts @@ -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('', { + 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['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 any>( + hook: T | { handler: T } | undefined +): T { + if (!hook) throw new Error('Expected plugin hook.') + return typeof hook === 'function' ? hook : hook.handler +} diff --git a/__tests__/unit/node/utils/getGitTimestamp.test.ts b/__tests__/unit/node/utils/getGitTimestamp.test.ts new file mode 100644 index 00000000..d548f4a4 --- /dev/null +++ b/__tests__/unit/node/utils/getGitTimestamp.test.ts @@ -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 }) + } +}) diff --git a/docs/en/reference/site-config.md b/docs/en/reference/site-config.md index a9d9c4f1..6dbe26b3 100644 --- a/docs/en/reference/site-config.md +++ b/docs/en/reference/site-config.md @@ -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 + +- 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 + +- 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 diff --git a/package.json b/package.json index b3e32e5f..84ebdd7d 100644 --- a/package.json +++ b/package.json @@ -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$/'", diff --git a/rollup.config.ts b/rollup.config.ts index 7f040f6f..482eeda6 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -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`, diff --git a/src/client/app/index.ts b/src/client/app/index.ts index 178c98b1..4222ce9f 100644 --- a/src/client/app/index.ts +++ b/src/client/app/index.ts @@ -63,10 +63,12 @@ const VitePressApp = defineComponent({ } }) -export async function createApp() { +export type PageModuleLoader = Parameters[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) => { diff --git a/src/client/app/ssr.ts b/src/client/app/ssr.ts index 338d4aad..4bf7c1d1 100644 --- a/src/client/app/ssr.ts +++ b/src/client/app/ssr.ts @@ -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() } - ctx.content = await renderToString(app, ctx) - return ctx -} +// legacy full-bundle SSR entry +export { render } from './ssrRuntime' diff --git a/src/client/app/ssrRuntime.ts b/src/client/app/ssrRuntime.ts new file mode 100644 index 00000000..a9f4aa86 --- /dev/null +++ b/src/client/app/ssrRuntime.ts @@ -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>, null> + +export interface StaticPagePayload { + html: string + pageData: PageData +} + +export type SsrPagePayload = PageModule | StaticPagePayload | null + +function isStaticPagePayload( + payload: Exclude +): payload is StaticPagePayload { + return !('default' in payload) +} + +function createStaticPageModule(payload: StaticPagePayload): PageModule { + const component = defineComponent({ + name: payload.pageData.relativePath, + setup() { + return () => createStaticVNode(`
${payload.html}
`, 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() } + 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) +} diff --git a/src/node/build/build.ts b/src/node/build/build.ts index 771d8bd4..3b2f89cd 100644 --- a/src/node/build/build.ts +++ b/src/node/build/build.ts @@ -1,32 +1,200 @@ import { getIconsCSS } from '@iconify/utils' +import { spawn } from 'node:child_process' import { createHash } from 'node:crypto' import fs from 'node:fs' -import { mkdir, rm, symlink, unlink, writeFile } from 'node:fs/promises' +import { + mkdir, + mkdtemp, + readFile, + rm, + symlink, + unlink, + writeFile +} from 'node:fs/promises' import { createRequire } from 'node:module' import path from 'node:path' -import pMap from 'p-map' +import { fileURLToPath } from 'node:url' +import { deserialize } from 'node:v8' import { packageDirectory } from 'package-directory' -import type { BuildOptions, Rolldown } from 'vite' +import pMap from 'p-map' +import type { BuildOptions } from 'vite' +import { version } from '../../../package.json' import { resolveConfig, type SiteConfig } from '../config' -import { clearCache } from '../markdownToVue' +import { + canCompileSsrPageArtifact, + canReuseSsrPageArtifactWithPlugins, + createSsrPageArtifactModuleId, + prepareStaticHtmlForSsr +} from '../markdownToVue' +import { PageArtifactStore } from '../pageArtifacts' import type { PageMeta } from '../plugin' -import { slash, type Awaitable, type HeadConfig } from '../shared' -import { deserializeFunctions, serializeFunctions } from '../utils/fnSerialize' -import { nativeImport } from '../utils/nativeImport' +import type { Awaitable } from '../shared' +import { cacheAllGitTimestamps } from '../utils/getGitTimestamp' import { task } from '../utils/task' -import { bundle } from './bundle' +import { bundle, createViteBuildConfig } from './bundle' import { generateSitemap } from './generateSitemap' -import { renderPage } from './render' +import { deserializeRenderedPage, finalizeRenderedPage } from './render' +import { + clearBuildCaches, + createRouteDigest, + disposeBuildCaches, + getRenderer, + prepareRenderInputs, + renderPages +} from './ssrBatch' +import { + createSsrBatchPlan, + createWorkerExecArgv, + validateBuildConcurrency, + validateSsrBuildBatchSize, + validateSsrBuildWorkerConcurrency +} from './ssrBatchUtils' +import { createSsrModuleCompiler } from './ssrModuleCompiler' +import { + type SsrRenderWorkerDescriptor, + type SsrRenderWorkerPage, + type SsrRenderWorkerResult +} from './ssrWorkerProtocol' const require = createRequire(import.meta.url) +function collectGarbageAtPhaseBoundary(): void { + ;(globalThis as typeof globalThis & { gc?: () => void }).gc?.() +} + +async function dispatchRenderWorker( + descriptor: SsrRenderWorkerDescriptor, + descriptorPath: string +): Promise { + await writeFile(descriptorPath, JSON.stringify(descriptor), { mode: 0o600 }) + const workerEntry = fileURLToPath(new URL('./ssrWorker.js', import.meta.url)) + + await new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [...createWorkerExecArgv(process.execArgv), workerEntry, descriptorPath], + { + cwd: process.cwd(), + stdio: 'inherit' + } + ) + let settled = false + + const terminateWorker = () => { + if (child.exitCode === null && child.signalCode === null) child.kill() + } + const finish = (error?: unknown) => { + if (settled) return + settled = true + process.removeListener('exit', terminateWorker) + if (error) terminateWorker() + if (error) reject(error) + else resolve() + } + + process.once('exit', terminateWorker) + child.once('error', finish) + child.once('exit', (code, signal) => { + if (settled) return + if (code === 0) { + finish() + } else { + finish( + new Error( + `SSR render worker failed (${signal ? `signal ${signal}` : `exit ${code}`}).` + ) + ) + } + }) + }) +} + +async function forEachConcurrent( + values: readonly T[], + concurrency: number, + iteratee: (value: T) => Promise +): Promise { + let nextIndex = 0 + let failed = false + let firstError: unknown + const runners = Array.from( + { length: Math.min(concurrency, values.length) }, + async () => { + while (!failed) { + const index = nextIndex++ + if (index >= values.length) return + try { + await iteratee(values[index]) + } catch (error) { + if (!failed) { + failed = true + firstError = error + } + } + } + } + ) + + await Promise.all(runners) + if (failed) throw firstError +} + +function validateWorkerResult( + value: unknown, + expectedPages: readonly SsrRenderWorkerPage[] +): asserts value is SsrRenderWorkerResult { + if (!value || typeof value !== 'object') { + throw new Error('SSR render worker returned a non-object result.') + } + const pages = (value as { pages?: unknown }).pages + if (!Array.isArray(pages)) { + throw new Error('SSR render worker result is missing its pages array.') + } + if (pages.length !== expectedPages.length) { + throw new Error( + `SSR render worker returned ${pages.length} pages; expected ${expectedPages.length}.` + ) + } + + for (let index = 0; index < pages.length; index++) { + const rendered = pages[index] + const expected = expectedPages[index].page + if ( + !rendered || + typeof rendered !== 'object' || + (rendered as { page?: unknown }).page !== expected + ) { + throw new Error( + `SSR render worker result ${index} does not match expected page ${expected}.` + ) + } + } +} + +type VitePressBuildOptions = BuildOptions & { + base?: string + mpa?: string + onAfterConfigResolve?: (siteConfig: SiteConfig) => Awaitable +} + export async function build( root?: string, - buildOptions: BuildOptions & { - base?: string - mpa?: string - onAfterConfigResolve?: (siteConfig: SiteConfig) => Awaitable - } = {} + buildOptions: VitePressBuildOptions = {} +) { + return buildInternal(root, buildOptions) +} + +/** @internal */ +export async function buildFromCli( + root?: string, + buildOptions: VitePressBuildOptions = {} +) { + return buildInternal(root, buildOptions) +} + +async function buildInternal( + root: string | undefined, + buildOptions: VitePressBuildOptions ) { const start = performance.now() @@ -36,8 +204,6 @@ export async function build( await buildOptions.onAfterConfigResolve?.(siteConfig) delete buildOptions.onAfterConfigResolve - const unlinkVue = await linkVue() - if (buildOptions.base) { siteConfig.site.base = buildOptions.base delete buildOptions.base @@ -53,208 +219,701 @@ export async function build( delete buildOptions.outDir } + validateBuildConcurrency(siteConfig.buildConcurrency) + const batchSize = validateSsrBuildBatchSize(siteConfig.ssrBuildBatchSize) + const workerConcurrency = validateSsrBuildWorkerConcurrency( + siteConfig.ssrBuildWorkerConcurrency + ) + + if (siteConfig.mpa && batchSize) { + throw new Error('ssrBuildBatchSize is not compatible with MPA mode.') + } + + if (process.env.BUNDLE_ONLY && batchSize) { + throw new Error( + 'BUNDLE_ONLY is not compatible with ssrBuildBatchSize because the shared runtime and page artifacts are consumed by the batched renderer.' + ) + } + + if (siteConfig.lastUpdated) { + await task('loading last-updated data', () => + cacheAllGitTimestamps(siteConfig.srcDir, ['*.md'], true) + ) + } + + const unlinkVue = await linkVue() const pageMetaMap = Object.create(null) as Record try { - const out = await task( - 'building client + server bundles', - bundle.bind(null, siteConfig, buildOptions, pageMetaMap) - ) + try { + const usedIcons = new Set() + let pageToHashMap: Record + + if (batchSize) { + pageToHashMap = await buildWithBatchedSsr( + siteConfig, + buildOptions, + pageMetaMap, + usedIcons, + batchSize, + workerConcurrency + ) + } else { + let { + clientResult, + serverResult, + pageToHashMap: pageHashes + } = await task('building client + server bundles', () => + bundle(siteConfig, buildOptions, pageMetaMap, { + mode: 'full', + vitePressPluginOptions: { skipGitScan: true } + }) + ) + pageToHashMap = pageHashes + + if (process.env.BUNDLE_ONLY) return + + const { renderMetadata, additionalHeadTags, metadataScript } = + await prepareRenderInputs( + siteConfig, + clientResult, + serverResult, + pageToHashMap + ) + + clientResult = null + serverResult = null + clearBuildCaches() + const render = await getRenderer(siteConfig.tempDir) + + await task('rendering pages', () => + renderPages( + render, + siteConfig, + siteConfig.tempDir, + ['404.md', ...siteConfig.pages], + renderMetadata, + pageToHashMap, + metadataScript, + additionalHeadTags, + usedIcons + ) + ) + } + + const icons = require('@iconify-json/simple-icons/icons.json') + const iconsCss = getIconsCSS(icons, Array.from(usedIcons).sort(), { + iconSelector: '.vpi-social-{name}', + commonSelector: '.vpi-social', + varName: 'icon', + format: process.env.DEBUG ? 'expanded' : 'compressed', + mode: 'mask' + }).replace(/[^]*?}\n*/, '') + + await writeFile(path.join(siteConfig.outDir, 'vp-icons.css'), iconsCss) + + // Emit the page hash map for sessions that span a redeployment. + await writeFile( + path.join(siteConfig.outDir, 'hashmap.json'), + JSON.stringify(pageToHashMap) + ) + } finally { + await unlinkVue() + if (!process.env.DEBUG) { + await rm(siteConfig.tempDir, { + recursive: true, + force: true, + maxRetries: 10 + }) + } + } - if (process.env.BUNDLE_ONLY) { - return + if (siteConfig.sitemap?.hostname) { + await task('generating sitemap', () => + generateSitemap(siteConfig, pageMetaMap) + ) } - await task('rendering pages', render.bind(null, siteConfig, out)) + await siteConfig.buildEnd?.(siteConfig) + + siteConfig.logger.info( + `build complete in ${((performance.now() - start) / 1000).toFixed(2)}s.` + ) } finally { - await unlinkVue() - if (!process.env.DEBUG) { - await rm(siteConfig.tempDir, { - recursive: true, - force: true, - maxRetries: 10 - }) - } + disposeBuildCaches() } +} + +async function buildWithBatchedSsr( + siteConfig: SiteConfig, + buildOptions: BuildOptions, + pageMetaMap: Record, + usedIcons: Set, + batchSize: number, + workerConcurrency: number +): Promise> { + await mkdir(siteConfig.tempDir, { recursive: true }) + const coordinatorDir = await mkdtemp( + path.join(siteConfig.tempDir, 'ssr-coordinator-') + ) + const routeDigest = createRouteDigest(siteConfig) + const configDependencyDigest = await createConfigDependencyDigest(siteConfig) + const configuredMarkdown = siteConfig.markdown + const configuredShikiCacheKey = siteConfig.markdown?.shikiCacheKey + let pageArtifactStore!: PageArtifactStore + let clientBuild: Awaited> | undefined - if (siteConfig.sitemap?.hostname) { - await task( - 'generating sitemap', - generateSitemap.bind(null, siteConfig, pageMetaMap) + try { + // The derived key belongs to the coordinator's immutable Markdown cache, + // not to the public SiteConfig observed by render and build hooks. + siteConfig.markdown = { + ...siteConfig.markdown, + shikiCacheKey: createHash('sha256') + .update('vitepress-shiki-config-v1') + .update('\0') + .update(configDependencyDigest) + .update('\0') + .update(configuredShikiCacheKey ?? '') + .digest('hex') + } + const namespace = createPageArtifactNamespace( + siteConfig, + routeDigest, + configDependencyDigest + ) + const pageArtifactCache = resolvePageArtifactCachePolicy( + siteConfig, + coordinatorDir ) + pageArtifactStore = new PageArtifactStore(pageArtifactCache.root, { + namespace + }) + + clientBuild = await task( + 'compiling Markdown and building client bundle', + () => + bundle(siteConfig, buildOptions, pageMetaMap, { + mode: 'client', + vitePressPluginOptions: { + coordinatorClient: true, + pageArtifactStore, + skipGitScan: true + } + }) + ) + await pageArtifactStore.flush() + } finally { + siteConfig.markdown = configuredMarkdown } - await siteConfig.buildEnd?.(siteConfig) - clearCache() - - siteConfig.logger.info( - `build complete in ${((performance.now() - start) / 1000).toFixed(2)}s.` + if (!clientBuild) { + throw new Error('The coordinator client build did not produce a result.') + } + let { clientResult, pageToHashMap, clientAssetMap } = clientBuild + + const { renderMetadata, additionalHeadTags, metadataScript } = + await prepareRenderInputs(siteConfig, clientResult, null, pageToHashMap) + // `clientResult` owns the complete Rolldown output graph. Dropping only the + // destructured local leaves the same object reachable through `clientBuild` + // for the entire SSR phase, which can retain several GiB on large sites. + clientBuild.clientResult = null + clientBuild.serverResult = null + clientBuild = undefined + clientResult = null + disposeBuildCaches() + collectGarbageAtPhaseBoundary() + + const runtimeDir = path.join(coordinatorDir, 'runtime') + let runtimeBuild: Awaited> | undefined = await task( + 'building shared SSR runtime', + () => + bundle(siteConfig, buildOptions, undefined, { + mode: 'ssr-runtime', + outDir: runtimeDir, + clientAssetMap + }) ) -} + const { ssrRuntimeBridgeMap } = runtimeBuild + runtimeBuild.clientResult = null + runtimeBuild.serverResult = null + runtimeBuild = undefined + disposeBuildCaches() + collectGarbageAtPhaseBoundary() + + type RenderPagePlan = Omit & { + isStatic: boolean + } + const renderPagesBySource = new Map() + const ssrPageArtifacts = new Map() + const sourcePages = new Set(siteConfig.pages) + const renderQueue = [ + '404.md', + ...siteConfig.pages.filter((page) => page !== '404.md') + ] + for (const sourcePage of renderQueue) { + const page = siteConfig.rewrites.map[sourcePage] || sourcePage + const hasSource = sourcePages.has(sourcePage) + const artifactMetadata = hasSource + ? await pageArtifactStore.getCurrentMetadata(page) + : undefined + if (hasSource && !artifactMetadata) { + throw new Error( + `Missing client-compiled Markdown artifact for ${sourcePage}.` + ) + } -async function linkVue() { - const root = await packageDirectory() - if (root) { - const dest = path.resolve(root, 'node_modules/vue') - // if user did not install vue by themselves, link VitePress' version - if (!fs.existsSync(dest)) { - const src = path.dirname(createRequire(import.meta.url).resolve('vue')) - await mkdir(path.dirname(dest), { recursive: true }) - await symlink(src, dest, 'junction') - return () => unlink(dest) + const sourceModuleId = path.resolve(siteConfig.srcDir, sourcePage) + const artifactModuleId = createSsrPageArtifactModuleId(sourceModuleId) + const canUseArtifactModule = + hasSource && + !artifactMetadata?.staticPage && + canCompileSsrPageArtifact(siteConfig, sourceModuleId, artifactMetadata) + if (canUseArtifactModule) { + ssrPageArtifacts.set(artifactModuleId, page) } + + renderPagesBySource.set(sourcePage, { + page, + routePath: `/${page.replace(/\.md$/, '')}`, + moduleId: + hasSource && !artifactMetadata?.staticPage + ? canUseArtifactModule + ? artifactModuleId + : sourceModuleId + : null, + isStatic: !!artifactMetadata?.staticPage + }) } - return async () => {} -} -async function render( - siteConfig: SiteConfig, - { - clientResult, - serverResult, - pageToHashMap - }: Awaited> -): Promise { - const entryPath = path.join(siteConfig.tempDir, 'app.js') - const { render } = await nativeImport(entryPath) + const ssrConfig = await createViteBuildConfig(siteConfig, buildOptions, { + ssr: true, + pages: [], + outDir: path.join(coordinatorDir, 'page-compiler'), + isolatedSsr: true, + vitePressPluginOptions: { + pageArtifactStore, + ssrPageArtifacts, + skipGitScan: true + } + }) + const moduleStorePath = path.join(coordinatorDir, 'ssr-modules') + const batches = createSsrBatchPlan(siteConfig.pages, batchSize) + const batchModuleSnapshots = new Map() + const compiler = createSsrModuleCompiler(ssrConfig, moduleStorePath, { + persistEntries: true, + releaseEntries: true, + snapshotOnly: true, + publishFullSnapshot: false, + runtimeBridges: new Map(Object.entries(ssrRuntimeBridgeMap)), + resolveAsset: clientAssetMap + }) + + let builtins: ReturnType = [] + try { + await compiler.init() + + // Client and SSR environments may receive different plugin instances from + // `vite.config.*` or `applyToEnvironment`. Finalize the optimization only + // after the actual unbundled SSR environment is resolved. Any hook that can + // observe the physical Markdown/module identity demotes the page to that + // path; the store still reuses the client Markdown result when the SSR + // source transform proves byte-identical at runtime. + const resolvedSsrConfig = compiler.resolvedConfig.environments.ssr + const resolvedSsrArtifactPlugins = [ + resolvedSsrConfig.plugins, + resolvedSsrConfig.build.rolldownOptions.plugins + ] + for (const [sourcePage, plan] of renderPagesBySource) { + if (!plan.isStatic && !plan.moduleId?.endsWith('.__vitepress_ssr.vue')) { + continue + } + + const sourceModuleId = path.resolve(siteConfig.srcDir, sourcePage) + const canReuseArtifactModule = canReuseSsrPageArtifactWithPlugins( + resolvedSsrArtifactPlugins, + sourceModuleId + ) + const needsCompiledAssetUrls = + plan.isStatic && + compiler.resolvedConfig.experimental.renderBuiltUrl != null + if (canReuseArtifactModule && !needsCompiledAssetUrls) { + continue + } + + ssrPageArtifacts.delete(createSsrPageArtifactModuleId(sourceModuleId)) + plan.moduleId = sourceModuleId + plan.isStatic = false + } - const clientOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] = - clientResult?.output || [] + const dynamicPageModules = [ + ...new Set( + [...renderPagesBySource.values()] + .map((page) => page.moduleId) + .filter((moduleId): moduleId is string => !!moduleId) + ) + ] + await task(`compiling ${dynamicPageModules.length} SSR page modules`, () => + compiler.materializeGraphs( + dynamicPageModules, + siteConfig.buildConcurrency + ) + ) - const appChunk = clientOutput.find( - (chunk): chunk is Rolldown.OutputChunk => - chunk.type === 'chunk' && - chunk.isEntry && - !!chunk.facadeModuleId?.endsWith('.js') - ) + // Workers read an immutable request-key capability list instead of the + // full-site manifest. Transformed module bodies remain deduplicated in the + // shared CAS and are pulled lazily as ModuleRunner reaches them. + for (const { offset, pages } of batches) { + const entries = [ + ...new Set( + pages + .map((page) => renderPagesBySource.get(page)?.moduleId) + .filter((moduleId): moduleId is string => !!moduleId) + ) + ] + const snapshotPath = path.join( + moduleStorePath, + 'snapshots', + `${offset}.json` + ) + await compiler.writeSnapshotForEntries(entries, snapshotPath) + batchModuleSnapshots.set(offset, snapshotPath) + } + builtins = compiler.getBuiltins() + + if (process.env.VITEPRESS_SSR_MEMORY_STATS) { + const memory = process.memoryUsage() + siteConfig.logger.info( + `[ssr-compiler-memory] ${JSON.stringify({ + rssMiB: Math.round(memory.rss / 1024 / 1024), + heapUsedMiB: Math.round(memory.heapUsed / 1024 / 1024), + externalMiB: Math.round(memory.external / 1024 / 1024), + ...compiler.getMemoryStats() + })}` + ) + } + } finally { + await compiler.close() + } + disposeBuildCaches() + collectGarbageAtPhaseBoundary() - 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 activeWorkers = Math.min( + workerConcurrency, + siteConfig.buildConcurrency, + batches.length + ) + const perWorkerRenderConcurrency = Math.max( + 1, + Math.floor(siteConfig.buildConcurrency / activeWorkers) ) + await task( + `rendering pages across ${batches.length} lightweight workers`, + () => + forEachConcurrent(batches, activeWorkers, async ({ offset, pages }) => { + const resultPath = path.join(coordinatorDir, `result-${offset}.bin`) + const descriptorPath = path.join( + coordinatorDir, + `worker-${offset}.json` + ) + const moduleSnapshotPath = batchModuleSnapshots.get(offset) + if (!moduleSnapshotPath) { + throw new Error( + `Missing SSR module snapshot for render batch ${offset}.` + ) + } + const descriptor: SsrRenderWorkerDescriptor = { + type: 'ssr-render', + runtimePath: path.join(runtimeDir, 'app.js'), + moduleStorePath, + moduleSnapshotPath, + builtins, + resultPath, + renderConcurrency: Math.min(perWorkerRenderConcurrency, pages.length), + pages: await pMap( + pages, + async (sourcePage): Promise => { + const plan = renderPagesBySource.get(sourcePage) + if (!plan) { + throw new Error(`Missing render descriptor for ${sourcePage}.`) + } + const { isStatic, ...descriptor } = plan + if (!isStatic) return descriptor + + const artifact = await pageArtifactStore.getCurrent( + descriptor.page + ) + if (!artifact?.staticPage) { + throw new Error( + `Missing static Markdown artifact for ${sourcePage}.` + ) + } + return { + ...descriptor, + staticPage: { + html: + artifact.staticHtml ?? + prepareStaticHtmlForSsr(artifact.html), + pageData: artifact.pageData + } + } + }, + { concurrency: perWorkerRenderConcurrency } + ) + } - // ---- + try { + await dispatchRenderWorker(descriptor, descriptorPath) + const workerResult: unknown = deserialize(await readFile(resultPath)) + validateWorkerResult(workerResult, descriptor.pages) + + await pMap( + workerResult.pages, + (page) => + finalizeRenderedPage( + deserializeRenderedPage(page), + siteConfig, + renderMetadata, + pageToHashMap, + metadataScript, + additionalHeadTags, + usedIcons + ), + { + concurrency: perWorkerRenderConcurrency, + stopOnError: false + } + ) + } finally { + if (!process.env.DEBUG) { + await Promise.all([ + unlink(descriptorPath).catch(() => {}), + unlink(resultPath).catch(() => {}), + unlink(moduleSnapshotPath).catch(() => {}) + ]) + } + } - const resultOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] = - (siteConfig.mpa ? serverResult : clientResult)?.output || [] + collectGarbageAtPhaseBoundary() + if (process.env.VITEPRESS_SSR_MEMORY_STATS) { + const memory = process.memoryUsage() + siteConfig.logger.info( + `[ssr-memory] ${JSON.stringify({ + offset, + rssMiB: Math.round(memory.rss / 1024 / 1024), + heapUsedMiB: Math.round(memory.heapUsed / 1024 / 1024), + externalMiB: Math.round(memory.external / 1024 / 1024) + })}` + ) + } + }) + ) - const cssChunk = resultOutput.find( - (chunk): chunk is Rolldown.OutputAsset => - chunk.type === 'asset' && chunk.fileName.endsWith('.css') + return pageToHashMap +} + +function createPageArtifactNamespace( + siteConfig: SiteConfig, + routeDigest: string, + configDependencyDigest: string +): string { + const digest = createHash('sha256') + addDigestPart(digest, 'schema', 'vitepress-page-pipeline-v1') + addDigestPart(digest, 'vitepress', version) + addDigestPart(digest, 'routes', routeDigest) + addDigestPart( + digest, + 'config', + stableSerialize(createPageArtifactConfigFingerprint(siteConfig)) ) + addDigestPart(digest, 'configDependencies', configDependencyDigest) - // prettier-ignore - const assets = resultOutput.filter( - (chunk): chunk is Rolldown.OutputAsset => - chunk.type === 'asset' && !chunk.fileName.endsWith('.css') - ).map((asset) => siteConfig.site.base + asset.fileName) + return digest.digest('hex') +} - // ---- +function createPageArtifactConfigFingerprint(siteConfig: SiteConfig) { + return { + base: siteConfig.site.base, + locales: siteConfig.site.locales, + publicDir: siteConfig.publicDir, + cleanUrls: siteConfig.cleanUrls, + lastUpdated: siteConfig.lastUpdated, + ignoreDeadLinks: siteConfig.ignoreDeadLinks, + markdown: siteConfig.markdown, + transformPageData: siteConfig.transformPageData, + localSearchOptions: + siteConfig.site.themeConfig?.search?.provider === 'local' + ? siteConfig.site.themeConfig.search.options + : undefined, + vue: siteConfig.vue, + vite: siteConfig.vite + } +} - const additionalHeadTags: HeadConfig[] = [] - const metadataScript = await generateMetadataScript(pageToHashMap, siteConfig) +/** @internal */ +export function resolvePageArtifactCachePolicy( + siteConfig: SiteConfig, + coordinatorDir: string +): { persistent: boolean; root: string } { + const cacheKey = siteConfig.markdown?.cacheKey + if ( + cacheKey !== undefined && + (typeof cacheKey !== 'string' || cacheKey.trim().length === 0) + ) { + throw new Error('markdown.cacheKey must be a non-empty string.') + } - if (isDefaultTheme) { - const fontURL = 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: '' - } - ]) - } + const persistent = + siteConfig.markdown?.cache !== false && + (cacheKey !== undefined || + isDeclarativeCacheInput(createPageArtifactConfigFingerprint(siteConfig))) + + return { + persistent, + root: persistent + ? siteConfig.cacheDir + : path.join(coordinatorDir, 'page-artifact-cache') } +} - const usedIcons = new Set() - - await pMap( - ['404.md', ...siteConfig.pages], - async (page) => { - await renderPage( - render, - siteConfig, - siteConfig.rewrites.map[page] || page, - clientResult, - appChunk, - cssChunk, - assets, - pageToHashMap, - metadataScript, - additionalHeadTags, - usedIcons +function isDeclarativeCacheInput( + value: unknown, + ancestors = new Set() +): boolean { + if ( + value == null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' + ) { + return true + } + if (typeof value !== 'object') return false + if (value instanceof RegExp || value instanceof Date) return true + if (ancestors.has(value)) return false + + ancestors.add(value) + try { + if (Array.isArray(value)) { + return value.every((item) => isDeclarativeCacheInput(item, ancestors)) + } + if (value instanceof Map) { + return [...value].every( + ([key, item]) => + isDeclarativeCacheInput(key, ancestors) && + isDeclarativeCacheInput(item, ancestors) ) - }, - { concurrency: siteConfig.buildConcurrency } - ) + } + if (value instanceof Set) { + return [...value].every((item) => + isDeclarativeCacheInput(item, ancestors) + ) + } - const icons = require('@iconify-json/simple-icons/icons.json') - const iconsCss = getIconsCSS(icons, Array.from(usedIcons).sort(), { - iconSelector: '.vpi-social-{name}', - commonSelector: '.vpi-social', - varName: 'icon', - format: process.env.DEBUG ? 'expanded' : 'compressed', - mode: 'mask' - }).replace(/[^]*?}\n*/, '') - - await writeFile(path.join(siteConfig.outDir, 'vp-icons.css'), iconsCss) - - // emit page hash map for the case where a user session is open - // when the site got redeployed (which invalidates current hash map) - await writeFile( - path.join(siteConfig.outDir, 'hashmap.json'), - JSON.stringify(pageToHashMap) - ) + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) return false + return Object.values(value).every((item) => + isDeclarativeCacheInput(item, ancestors) + ) + } finally { + ancestors.delete(value) + } } -async function generateMetadataScript( - pageToHashMap: Record, - config: SiteConfig -): Promise<{ html: string; inHead: boolean }> { - if (config.mpa) { - return { html: '', inHead: false } +async function createConfigDependencyDigest( + siteConfig: SiteConfig +): Promise { + const digest = createHash('sha256') + const configFiles = [ + ...new Set( + [ + siteConfig.configPath, + ...siteConfig.configDeps, + ...siteConfig.dynamicRoutes.map((route) => route.loaderPath) + ].filter((file): file is string => !!file) + ) + ].sort() + for (const file of configFiles) { + try { + addDigestPart(digest, file, await readFile(file, 'utf8')) + } catch (error) { + addDigestPart( + digest, + file, + `<${(error as NodeJS.ErrnoException).code || 'unreadable'}>` + ) + } } + return digest.digest('hex') +} - // 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: [] })) - ) +function addDigestPart( + digest: ReturnType, + key: string, + value: string +) { + digest.update(`${Buffer.byteLength(key)}:${key}`) + digest.update(`${Buffer.byteLength(value)}:${value}`) +} - 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` - ) +function stableSerialize(value: unknown, seen = new Set()): string { + if (value === null) return 'null' + if (value === undefined) return 'undefined' + if (typeof value === 'string') return JSON.stringify(value) + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + if (typeof value === 'bigint') return `${value}n` + if (typeof value === 'symbol') return String(value) + if (typeof value === 'function') return `function:${String(value)}` - const resolvedMetadataFile = path.join(config.outDir, metadataFile) - const metadataFileURL = slash(`${config.site.base}${metadataFile}`) + if (seen.has(value)) return '[Circular]' + seen.add(value) + try { + if (value instanceof RegExp) return `regexp:${String(value)}` + if (value instanceof Date) return `date:${value.toISOString()}` + if (Array.isArray(value)) { + return `[${value.map((item) => stableSerialize(item, seen)).join(',')}]` + } + if (value instanceof Map) { + return `map:{${[...value] + .map( + ([key, item]) => + `${stableSerialize(key, seen)}:${stableSerialize(item, seen)}` + ) + .sort() + .join(',')}}` + } + if (value instanceof Set) { + return `set:[${[...value] + .map((item) => stableSerialize(item, seen)) + .sort() + .join(',')}]` + } - await mkdir(path.dirname(resolvedMetadataFile), { recursive: true }) - await writeFile(resolvedMetadataFile, metadataContent) + const record = value as Record + return `${value.constructor?.name || 'Object'}:{${Object.keys(record) + .sort() + .map( + (key) => `${JSON.stringify(key)}:${stableSerialize(record[key], seen)}` + ) + .join(',')}}` + } finally { + seen.delete(value) + } +} - return { - html: ``, - inHead: true +async function linkVue() { + const root = await packageDirectory() + if (root) { + const dest = path.resolve(root, 'node_modules/vue') + // If the user did not install Vue, link VitePress' copy. + if (!fs.existsSync(dest)) { + const src = path.dirname(createRequire(import.meta.url).resolve('vue')) + await mkdir(path.dirname(dest), { recursive: true }) + await symlink(src, dest, 'junction') + return () => unlink(dest) + } } + return async () => {} } diff --git a/src/node/build/bundle.ts b/src/node/build/bundle.ts index 409c5c28..9a48487c 100644 --- a/src/node/build/bundle.ts +++ b/src/node/build/bundle.ts @@ -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() const cacheTheme = new Map() -// bundles the VitePress app for both client AND server. -export async function bundle( +export type ClientAssetMap = Record +export type SsrRuntimeBridgeMap = Record + +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() + 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 = new Set() +) { + const input: Record = { + 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 +): 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 +): boolean { + return ( + isSsrRuntimeGraphSource(id, moduleInfo) && + (id.startsWith('\0') || !id.includes('?')) + ) +} + +/** @internal Exported for focused pipeline tests. */ +export function createSsrRuntimeBridgePlugin( + _config: Pick, + bridgeModuleIds: Set +): Plugin { + const emitted = new Set() + const reachableFromTheme = new Set() + const parsedModules = new Map() + 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 +): 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 + clientJSMap?: Record pageMetaMap?: Record -): Promise<{ - clientResult: Rolldown.RolldownOutput | null - serverResult: Rolldown.RolldownOutput - pageToHashMap: Record -}> { - const pageToHashMap = Object.create(null) as Record - const clientJSMap = Object.create(null) as Record + clientAssetMap?: ClientAssetMap + /** @internal Runtime source identities that require emitted entry facades. */ + ssrRuntimeBridgeModuleIds?: Set + 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 = {} - 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 { + const { + ssr, + pages = config.pages, + outDir = config.tempDir, + isolatedSsr = false, + runtime = false, + pageToHashMap = Object.create(null) as Record, + clientJSMap = Object.create(null) as Record, + pageMetaMap, + clientAssetMap, + ssrRuntimeBridgeModuleIds = new Set(), + vitePressPluginOptions + } = target + + if (runtime && !ssr) { + throw new Error('The shared SSR runtime must use an SSR Vite config.') + } + + const createPageInput = () => { + const input: Record = {} + 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 => ({ + 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, + target: BundleTarget = { mode: 'full' } +): Promise<{ + clientResult: Rolldown.RolldownOutput | null + serverResult: Rolldown.RolldownOutput | null + pageToHashMap: Record + clientAssetMap: ClientAssetMap + ssrRuntimeBridgeMap: SsrRuntimeBridgeMap +}> { + const pageToHashMap = Object.create(null) as Record + const clientJSMap = Object.create(null) as Record + 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() + 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( diff --git a/src/node/build/render.ts b/src/node/build/render.ts index d91a8675..ca92a7e1 100644 --- a/src/node/build/render.ts +++ b/src/node/build/render.ts @@ -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 + pageChunks: Map +} + +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 & { + vpSocialIcons: string[] +} + +export interface SerializedRenderedPage extends Omit { + 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() + const pageChunks = new Map() + 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, 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, metadataScript: { html: string; inHead: boolean }, additionalHeadTags: HeadConfig[], - usedIcons: Set + usedIcons: Set, + 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, + page: string, + pageModulePath: string +): Promise { + 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, + metadataScript: { html: string; inHead: boolean }, + additionalHeadTags: HeadConfig[], + usedIcons: Set +) { + 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, + 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 ] } diff --git a/src/node/build/ssrBatch.ts b/src/node/build/ssrBatch.ts new file mode 100644 index 00000000..9fec042d --- /dev/null +++ b/src/node/build/ssrBatch.ts @@ -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, + 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: ``, + inHead: true + } +} + +export async function prepareRenderInputs( + siteConfig: SiteConfig, + clientResult: Parameters[1], + serverResult: Parameters[2], + pageToHashMap: Record +) { + 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 +} + +export async function renderPages( + render: (path: string) => Promise, + siteConfig: SiteConfig, + serverTempDir: string, + pages: string[], + renderMetadata: RenderMetadata, + pageToHashMap: Record, + metadataScript: { html: string; inHead: boolean }, + additionalHeadTags: HeadConfig[], + usedIcons: Set +): Promise { + 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') +} diff --git a/src/node/build/ssrBatchUtils.ts b/src/node/build/ssrBatchUtils.ts new file mode 100644 index 00000000..4d470f20 --- /dev/null +++ b/src/node/build/ssrBatchUtils.ts @@ -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([ + 'emitFile', + 'getFileName', + 'getModuleIds' +]) +const buildModeEnvironmentFacades = new WeakMap() + +type SsrPageUnbundledHook = (typeof SSR_PAGE_UNBUNDLED_HOOKS)[number] +type PageUnbundledPlugin = { + name?: string +} & Partial> + +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 + 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 + 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 || ''), + writable: true + }) + } + return adapted + }) +} + +async function collectOutputPlugins( + option: Rolldown.OutputOptions['plugins'], + collected: PageUnbundledPlugin[] +): Promise { + 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 { + 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 || '', + 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 +} diff --git a/src/node/build/ssrModuleCompiler.ts b/src/node/build/ssrModuleCompiler.ts new file mode 100644 index 00000000..bf431d77 --- /dev/null +++ b/src/node/build/ssrModuleCompiler.ts @@ -0,0 +1,1064 @@ +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { + createRunnableDevEnvironment, + mergeConfig, + moduleRunnerTransform, + normalizePath, + resolveConfig, + type EnvironmentModuleGraph, + type EnvironmentModuleNode, + type InlineConfig, + type Plugin, + type ResolvedConfig, + type RunnableDevEnvironment +} from 'vite' +import { + ESModulesEvaluator, + type FetchFunctionOptions, + type FetchResult +} from 'vite/module-runner' +import type { Awaitable } from '../shared' +import { + getVueDescriptorMemoryApi, + type VueDescriptorMemoryApi +} from '../plugins/vueDescriptorMemory' +import { + createSsrModuleRequestKey, + hashSsrModuleValue, + ssrModuleCacheFile, + SSR_MODULE_ARTIFACT_VERSION, + type MaterializedSsrModuleResult, + type SsrModuleStoreSnapshot, + type StoredSsrModuleArtifact, + type StoredSsrModuleRequest +} from './ssrModuleStore' +import { + adaptSsrBatchPagePlugins, + validateSsrBatchPageOutputHooks +} from './ssrBatchUtils' +import type { SerializedSsrBuiltin } from './ssrWorkerProtocol' + +export type { SerializedSsrBuiltin } from './ssrWorkerProtocol' + +type MaterializedFetchResult = MaterializedSsrModuleResult + +type InternalEnvironmentModuleGraph = EnvironmentModuleGraph & { + _unresolvedUrlToModuleMap?: Map< + string, + EnvironmentModuleNode | Promise + > + _hasResolveFailedErrorModules?: Set +} + +export type SsrModuleFetchArgs = [ + id: string, + importer?: string | null, + options?: FetchFunctionOptions | null +] + +export type SsrModuleReplacementMap = + ReadonlyMap | Readonly> + +export type SsrAssetResolver = ( + id: string, + importer: string | undefined, + resolvedId: string | undefined +) => Awaitable + +export interface SsrModuleCompilerOptions { + /** + * Persist materialized module-runner results in the request/module CAS. + * Disable this for artifact-only seed passes whose transformed JavaScript is + * never evaluated. Pending-request deduplication and Vite graph release stay + * active either way. + * @default true + */ + persistArtifacts?: boolean + /** + * Persist entry-module results whose ModuleRunner request has no importer. + * Page entries are normally consumed exactly once, so batched builds can + * disable this without affecting dependency reuse between workers. + * @default true + */ + persistEntries?: boolean + /** + * Remove one-shot entry nodes after their transformed output is persisted. + * This is independent from persistence so an offline worker can read the + * entry while the coordinator still keeps a bounded Vite graph. + * @default `persistEntries === false` + */ + releaseEntries?: boolean + /** Write one request manifest instead of thousands of pointer files. */ + snapshotOnly?: boolean + /** + * Publish the full-site request manifest after materialization. A caller + * that publishes entry-scoped snapshots can disable this duplicate index. + * @default true + */ + publishFullSnapshot?: boolean + /** + * Map the source identities of VitePress and its theme to native ESM bridge + * entries emitted with the shared runtime bundle. + */ + runtimeBridges?: SsrModuleReplacementMap + /** + * Resolve an asset request to its final URL in the client build. Non-bundled + * Vite environments otherwise emit development-only `/@fs/` URLs. + */ + resolveAsset?: SsrModuleReplacementMap | SsrAssetResolver +} + +export interface SsrModuleCompilerMemoryStats { + idModules: number + urlModules: number + fileEntries: number + markdownModules: number + transformedModules: number + importerEdges: number + cachedRequests: number + pendingRequests: number + vueDescriptors: number +} + +export interface SsrMaterializedGraph { + entries: number + requests: number + descriptorFiles: string[] +} + +interface SsrModuleRequestMetadata { + dependencies: string[] + hasUnknownDynamicImports: boolean + /** Importer identity ModuleRunner sends while evaluating this result. */ + dependencyImporter?: string +} + +function getDependencyImporter( + result: MaterializedFetchResult +): string | undefined { + if (!('id' in result)) return + // ModuleRunner deliberately prefers `file` over the resolved module id. + // Query-bearing Vue/Markdown submodules therefore import their children as + // the clean physical file, while virtual modules fall back to `id`. + return result.file || result.id +} + +function normalizeMaterializedResult( + result: MaterializedFetchResult +): MaterializedFetchResult { + // This compiler has no watcher or HMR source invalidations. Replaying Vite's + // initial `invalidate: true` can clear an already evaluated singleton when + // the same resolved module is reached through another URL spelling. + return 'invalidate' in result && result.invalidate !== false + ? { ...result, invalidate: false } + : result +} + +function isErrnoException(value: unknown): value is NodeJS.ErrnoException { + return value instanceof Error && 'code' in value +} + +function mapValue( + map: SsrModuleReplacementMap | undefined, + ids: Iterable +): string | undefined { + if (!map) return + const readonlyMap = map as ReadonlyMap + const record = map as Readonly> + for (const id of ids) { + const value = + typeof readonlyMap.get === 'function' + ? readonlyMap.get(id) + : Object.prototype.hasOwnProperty.call(record, id) + ? record[id] + : undefined + if (value !== undefined) return value + } +} + +function idCandidates( + includeCleanId: boolean, + ...values: (string | undefined)[] +): Set { + const candidates = new Set() + const add = (value: string) => { + candidates.add(value) + candidates.add(normalizePath(value)) + if (includeCleanId) { + const clean = value.replace(/[?#].*$/, '') + candidates.add(clean) + candidates.add(normalizePath(clean)) + } + } + + for (const value of values) { + if (!value) continue + add(value) + + if (value.startsWith('file://')) { + try { + const url = new URL(value) + add(`${fileURLToPath(url)}${url.search}${url.hash}`) + } catch {} + } else if (value.startsWith('/@fs/')) { + try { + add(decodeURI(value.slice('/@fs/'.length))) + } catch {} + } + } + + return candidates +} + +function asExternalUrl(value: string): string { + return path.isAbsolute(value) ? pathToFileURL(value).href : value +} + +function asPhysicalFile(value: string | null | undefined): string | undefined { + if (!value) return + try { + if (value.startsWith('file://')) { + value = fileURLToPath(new URL(value)) + } else if (value.startsWith('/@fs/')) { + value = decodeURI(value.slice('/@fs/'.length)) + } + } catch { + return + } + + value = value.replace(/[?#].*$/, '') + return path.isAbsolute(value) && !value.startsWith('/@') + ? normalizePath(value) + : undefined +} + +function asPhysicalSfcFile( + value: string | null | undefined +): string | undefined { + const file = asPhysicalFile(value) + return file && /\.(?:md|vue)$/.test(file) ? file : undefined +} + +function hasUnknownDynamicImports( + code: string, + knownDynamicImports: number +): boolean { + const transformedImportCount = + code.match(/\b__vite_ssr_dynamic_import__\s*\(/g)?.length ?? 0 + return transformedImportCount > knownDynamicImports +} + +function deleteGraphEntriesByValue(map: Map, value: V): void { + for (const [key, candidate] of map) { + if (candidate === value) map.delete(key) + } +} + +/** + * Vite does not currently expose a public removal API for an immutable dev + * environment's module graph. Page entries are one-shot roots, so detach them + * explicitly after their transformed code has crossed the worker boundary. + * Shared dependencies remain in the graph and in the request CAS. + */ +function removeOneShotEntry( + graph: EnvironmentModuleGraph, + module: EnvironmentModuleNode, + requestId: string, + settled = false +): void { + // A Markdown file can be both a requested page root and a dependency of a + // different page. Never remove a node that still has a live importer; only + // its heavyweight transform result may be discarded in that case. + if (!settled && module.importers.size > 0) { + graph.updateModuleTransformResult(module, null) + return + } + + for (const dependency of module.importedModules) { + dependency.importers.delete(module) + } + for (const importer of module.importers) { + importer.importedModules.delete(module) + } + module.importedModules.clear() + module.importers.clear() + module.acceptedHmrDeps.clear() + module.importedBindings = null + + graph.updateModuleTransformResult(module, null) + deleteGraphEntriesByValue(graph.urlToModuleMap, module) + deleteGraphEntriesByValue(graph.idToModuleMap, module) + deleteGraphEntriesByValue(graph.etagToModuleMap, module) + + if (module.file) { + const fileModules = graph.fileToModulesMap.get(module.file) + fileModules?.delete(module) + if (fileModules?.size === 0) graph.fileToModulesMap.delete(module.file) + } + + const internal = graph as InternalEnvironmentModuleGraph + const unresolved = internal._unresolvedUrlToModuleMap + if (unresolved) { + const requestCandidates = idCandidates( + true, + requestId, + module.id ?? undefined, + module.url + ) + for (const [url, candidate] of unresolved) { + if (candidate === module || requestCandidates.has(url)) { + unresolved.delete(url) + } + } + } + internal._hasResolveFailedErrorModules?.delete(module) +} + +/** + * Coordinator-owned Vite transform environment for SSR page modules. + * + * Workers never load Vite or contact this object. Immutable results are + * streamed into a content-addressed store before offline rendering begins. + */ +export class SsrModuleCompiler { + private readonly requestsDir: string + private readonly modulesDir: string + private readonly requestArtifacts = new Map() + private readonly requestManifest = new Map() + private readonly requestMetadata = new Map() + private readonly pendingRequests = new Map< + string, + Promise + >() + private readonly runnerStartOffset = new ESModulesEvaluator().startOffset + private environment: RunnableDevEnvironment | undefined + private config: ResolvedConfig | undefined + private closePromise: Promise | undefined + private vueDescriptorMemory: VueDescriptorMemoryApi | undefined + private writeId = 0 + + constructor( + private readonly inlineConfig: InlineConfig, + private readonly artifactDir: string, + private readonly options: SsrModuleCompilerOptions = {} + ) { + this.requestsDir = path.join(artifactDir, 'requests') + this.modulesDir = path.join(artifactDir, 'modules') + } + + async init(): Promise { + if (this.environment) return + + if (this.options.persistArtifacts !== false) { + await Promise.all([ + mkdir(this.requestsDir, { recursive: true }), + mkdir(this.modulesDir, { recursive: true }) + ]) + } + + const inlineConfig = mergeConfig(this.inlineConfig, { + server: { + // This coordinator-owned SSR environment is a complete immutable + // compilation phase, not an auxiliary dev environment. Run every + // plugin's lifecycle hooks (notably plugin-vue's compiler setup). + perEnvironmentStartEndDuringDev: true + }, + environments: { + ssr: { + consumer: 'server', + isBundled: false, + dev: { moduleRunnerTransform: true } + } + } + } satisfies InlineConfig) + + const config = await resolveConfig( + inlineConfig, + 'build', + 'production', + 'production', + false, + (resolved) => { + const ssr = resolved.environments.ssr + if (!ssr) { + throw new Error( + 'The SSR module compiler requires an SSR environment.' + ) + } + ssr.consumer = 'server' + ssr.isBundled = false + ssr.dev.moduleRunnerTransform = true + } + ) + const ssr = config.environments.ssr + await validateSsrBatchPageOutputHooks( + ssr.plugins, + ssr.build.rolldownOptions.output + ) + const ssrPlugins = ssr.plugins as Plugin[] + ssrPlugins.splice( + 0, + ssrPlugins.length, + ...adaptSsrBatchPagePlugins(ssrPlugins) + ) + + const environment = createRunnableDevEnvironment('ssr', config, { + hot: false, + remoteRunner: { inlineSourceMap: false } + }) + + try { + await environment.init() + // This immutable compiler has no watcher. Vite's runnable environment + // defaults the shared plugin meta to watch mode even though closeBundle + // is final here; switch only that lifecycle flag so internal adapters + // (notably esbuild-compatible plugins) release their resources on close. + const pluginContainer = environment.pluginContainer as unknown as { + minimalContext: { meta: { watchMode: boolean } } + } + pluginContainer.minimalContext.meta.watchMode = false + } catch (error) { + await environment.close().catch(() => {}) + throw error + } + + this.config = config + this.environment = environment + this.vueDescriptorMemory = getVueDescriptorMemoryApi(config) + } + + /** Transform an entry, optionally persist it, then release Vite's code. */ + precompile(id: string): Promise { + return this.handleFetch([ + id, + undefined, + { startOffset: this.runnerStartOffset } + ]) + } + + /** + * Materialize complete statically discoverable page graphs into the disk CAS. + * Each wave releases its Vue descriptors before the next begins, so the Vite + * compiler is a bounded materialization phase and can close before rendering. + */ + async materializeGraphs( + entries: readonly string[], + concurrency = 1 + ): Promise { + if (this.options.persistArtifacts === false) { + throw new Error('Offline SSR graphs require persisted module artifacts.') + } + if (!Number.isInteger(concurrency) || concurrency < 1) { + throw new Error('SSR graph materialization concurrency must be positive.') + } + + const visited = new Set() + const allDescriptorFiles = new Set() + let requests = 0 + + const visit = async ( + id: string, + importer: string | undefined, + waveDescriptorFiles: Set, + waveGraphFiles: Set + ): Promise => { + const key = createSsrModuleRequestKey(id, importer) + if (visited.has(key)) return + visited.add(key) + + const result = + importer === undefined + ? await this.precompile(id) + : await this.handleFetch([ + id, + importer, + { startOffset: this.runnerStartOffset } + ]) + requests++ + + for (const value of [ + id, + 'id' in result ? result.id : undefined, + 'file' in result ? result.file : undefined + ]) { + const graphFile = asPhysicalFile(value) + if (graphFile) waveGraphFiles.add(graphFile) + const descriptorFile = asPhysicalSfcFile(value) + if (descriptorFile) { + waveDescriptorFiles.add(descriptorFile) + allDescriptorFiles.add(descriptorFile) + } + } + + const metadata = this.requestMetadata.get(key) + if (metadata?.hasUnknownDynamicImports) { + throw new Error( + `SSR page graph contains a runtime-computed import in ${id}. ` + + 'Batched rendering requires every Vite-transformed dependency to be statically discoverable.' + ) + } + if (!metadata?.dependencyImporter) return + + for (const dependency of metadata.dependencies) { + await visit( + dependency, + metadata.dependencyImporter, + waveDescriptorFiles, + waveGraphFiles + ) + } + } + + for (let offset = 0; offset < entries.length; offset += concurrency) { + const waveDescriptorFiles = new Set() + const waveGraphFiles = new Set() + let outcomes: PromiseSettledResult[] + try { + outcomes = await Promise.allSettled( + entries + .slice(offset, offset + concurrency) + .map((entry) => + visit(entry, undefined, waveDescriptorFiles, waveGraphFiles) + ) + ) + } finally { + this.releasePageDescriptors(waveDescriptorFiles) + this.releasePageGraph(waveGraphFiles) + } + + const failed = outcomes!.find( + (outcome): outcome is PromiseRejectedResult => + outcome.status === 'rejected' + ) + if (failed) { + throw failed.reason + } + } + + if (this.options.publishFullSnapshot !== false) { + await this.writeFullSnapshot() + } + + return { + entries: entries.length, + requests, + descriptorFiles: [...allDescriptorFiles].sort() + } + } + + /** + * Publish the complete request closure for a bounded set of entry modules. + * The snapshot contains only request keys and CAS hashes; transformed code + * remains deduplicated in the shared module store and is loaded lazily by a + * disposable worker. + */ + async writeSnapshotForEntries( + entries: readonly string[], + snapshotPath: string + ): Promise { + this.requireEnvironment() + + const reachable = new Set() + const pending = entries.map((entry) => + createSsrModuleRequestKey(entry, undefined) + ) + while (pending.length > 0) { + const key = pending.pop()! + if (reachable.has(key)) continue + + const artifactHash = this.requestManifest.get(key) + const metadata = this.requestMetadata.get(key) + if (!artifactHash || !metadata) { + const [, id, importer] = JSON.parse(key) as [ + number, + string, + string | null + ] + throw new Error( + `SSR module ${JSON.stringify(id)}` + + (importer ? ` imported by ${JSON.stringify(importer)}` : '') + + ' was not materialized before publishing its worker snapshot.' + ) + } + + reachable.add(key) + if (!metadata.dependencyImporter) continue + for (const dependency of metadata.dependencies) { + pending.push( + createSsrModuleRequestKey(dependency, metadata.dependencyImporter) + ) + } + } + + const requests = [...reachable] + .map((key): [string, string] => [key, this.requestManifest.get(key)!]) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + await this.writeSnapshot(snapshotPath, requests) + return requests.length + } + + /** Handle the argument tuple sent by Vite's ModuleRunner transport. */ + async handleFetch(args: SsrModuleFetchArgs): Promise { + if (this.closePromise) { + throw new Error('SSR module compiler is closing.') + } + + const [id, rawImporter, rawOptions] = args + if (typeof id !== 'string') { + throw new TypeError('SSR module fetch id must be a string.') + } + const importer = typeof rawImporter === 'string' ? rawImporter : undefined + const fetchOptions = rawOptions ?? undefined + + // Render builds are immutable: there is no watcher and every first fetch + // completes before it is returned to a worker. When ModuleRunner says it + // already has a module, acknowledge that cache instead of replaying the + // materialized result (whose initial `invalidate` flag would force + // evaluation again and can break singleton and circular-module semantics). + if (fetchOptions?.cached) return { cache: true } + + const key = createSsrModuleRequestKey(id, importer) + const persistRequest = + this.options.persistArtifacts !== false && + (importer !== undefined || this.options.persistEntries !== false) + + const pending = this.pendingRequests.get(key) + if (pending) return pending + + // Register before the first cache I/O await so close() can reliably wait + // for every accepted fetch, including a request currently reading the CAS. + const request = (async () => { + if (persistRequest) { + const existing = await this.readRequest(key) + if (existing) return normalizeMaterializedResult(existing) + } + return this.fetchAndPersist( + key, + id, + importer, + fetchOptions, + persistRequest + ) + })().finally(() => { + this.pendingRequests.delete(key) + }) + this.pendingRequests.set(key, request) + return request + } + + getBuiltins(): SerializedSsrBuiltin[] { + const environment = this.requireEnvironment() + return environment.config.resolve.builtins.map( + (builtin) => + typeof builtin === 'string' + ? { type: 'string', value: builtin } + : { + type: 'RegExp', + source: builtin.source, + flags: builtin.flags + } + ) + } + + get resolvedConfig(): ResolvedConfig { + this.requireEnvironment() + return this.config! + } + + /** @internal Diagnostic counters for large-site memory investigations. */ + getMemoryStats(): SsrModuleCompilerMemoryStats { + const graph = this.requireEnvironment().moduleGraph + let markdownModules = 0 + let transformedModules = 0 + let importerEdges = 0 + for (const module of graph.idToModuleMap.values()) { + if (module.id?.replace(/[?#].*$/, '').endsWith('.md')) markdownModules++ + if (module.transformResult) transformedModules++ + importerEdges += module.importers.size + } + return { + idModules: graph.idToModuleMap.size, + urlModules: graph.urlToModuleMap.size, + fileEntries: graph.fileToModulesMap.size, + markdownModules, + transformedModules, + importerEdges, + cachedRequests: this.requestArtifacts.size, + pendingRequests: this.pendingRequests.size, + vueDescriptors: this.vueDescriptorMemory?.retainedFiles ?? 0 + } + } + + /** Release page-local Vue compiler state after its worker has exited. */ + releasePageDescriptors(files: Iterable): void { + this.vueDescriptorMemory?.release(files) + } + + /** Remove settled page/query nodes after a materialization wave completes. */ + releasePageGraph(files: Iterable): void { + const graph = this.requireEnvironment().moduleGraph + for (const file of files) { + const normalizedFile = normalizePath(file) + const modules = graph.fileToModulesMap.get(normalizedFile) + if (!modules) continue + for (const module of [...modules]) { + removeOneShotEntry(graph, module, module.url, true) + } + } + } + + async close(): Promise { + if (this.closePromise) return this.closePromise + + const environment = this.environment + if (!environment) return + + const closePromise = (async () => { + // A failed materialization can still leave an accepted transform or CAS + // write settling. Keep the environment alive until all of them finish. + await Promise.allSettled([...this.pendingRequests.values()]) + this.environment = undefined + this.config = undefined + this.vueDescriptorMemory = undefined + this.pendingRequests.clear() + this.requestArtifacts.clear() + this.requestManifest.clear() + this.requestMetadata.clear() + + // DevEnvironment.close() intentionally uses Promise.allSettled and + // therefore hides buildEnd/closeBundle failures from its plugin + // container. Close that container explicitly first so batching reports + // teardown failures, then always close the whole environment to release + // the optimizer, hot channel, pending requests, and runner as well. + const closeErrors: unknown[] = [] + try { + await environment.pluginContainer.close() + } catch (error) { + closeErrors.push(error) + } + try { + await environment.close() + } catch (error) { + closeErrors.push(error) + } + + if (closeErrors.length === 1) throw closeErrors[0] + if (closeErrors.length > 1) { + throw new AggregateError( + closeErrors, + 'Failed to close the SSR module compiler cleanly.' + ) + } + })() + this.closePromise = closePromise + + try { + await closePromise + } finally { + if (this.closePromise === closePromise) this.closePromise = undefined + } + } + + private requireEnvironment(): RunnableDevEnvironment { + if (!this.environment) { + throw new Error('SSR module compiler has not been initialized.') + } + return this.environment + } + + private async fetchAndPersist( + key: string, + id: string, + importer: string | undefined, + options: FetchFunctionOptions | undefined, + persistRequest: boolean + ): Promise { + const environment = this.requireEnvironment() + const needsResolution = + this.options.runtimeBridges !== undefined || + this.options.resolveAsset !== undefined + const resolved = needsResolution + ? await environment.pluginContainer.resolveId(id, importer) + : null + // Query-bearing requests have distinct module semantics (`?raw`, `?url`, + // Vue submodules, and user plugin queries). Never clean-match one of them + // to a runtime facade for the underlying source module. + const runtimeCandidates = idCandidates(false, id, resolved?.id) + const assetCandidates = idCandidates(false, id, resolved?.id) + + const runtimeBridge = mapValue( + this.options.runtimeBridges, + runtimeCandidates + ) + let result: FetchResult + + if (runtimeBridge !== undefined) { + result = { + externalize: asExternalUrl(runtimeBridge), + type: 'module' + } + } else { + const assetUrl = await this.resolveAsset( + id, + importer, + resolved?.id, + assetCandidates + ) + if (assetUrl !== undefined) { + result = await this.createAssetModule(id, assetUrl) + } else { + result = await environment.fetchModule(id, importer, options) + // A newly started worker can never use a coordinator-only cache marker. + // Retry materialized so the result can be persisted and transferred. + if ('cache' in result) { + result = await environment.fetchModule(id, importer, { + ...options, + cached: false + }) + } + } + } + + if ('cache' in result) { + throw new Error(`Vite returned an unresolved cache marker for ${id}.`) + } + + const materialized = normalizeMaterializedResult(result) + const metadata = this.captureRequestMetadata(materialized, id) + this.requestMetadata.set(key, metadata) + try { + if (persistRequest) { + await this.persistRequest(key, materialized, metadata) + } + return materialized + } finally { + // Always release transformed source, including when a cache write fails + // or persistence is intentionally disabled for a one-shot entry. + this.releaseTransform( + materialized, + id, + importer === undefined && + (this.options.releaseEntries ?? this.options.persistEntries === false) + ) + } + } + + private async resolveAsset( + id: string, + importer: string | undefined, + resolvedId: string | undefined, + candidates: Set + ): Promise { + const resolver = this.options.resolveAsset + if (!resolver) return + return typeof resolver === 'function' + ? resolver(id, importer, resolvedId) + : mapValue(resolver, candidates) + } + + private async createAssetModule( + requestId: string, + assetUrl: string + ): Promise { + const id = `\0vitepress:ssr-asset:${hashSsrModuleValue(`${requestId}\0${assetUrl}`)}` + const source = `export default ${JSON.stringify(assetUrl)}` + const transformed = await moduleRunnerTransform(source, null, id, source) + if (!transformed) { + throw new Error(`Unable to create SSR asset module for ${requestId}.`) + } + return { + code: transformed.code, + file: null, + id, + url: requestId, + invalidate: false + } + } + + private releaseTransform( + result: MaterializedFetchResult, + requestId: string, + removeEntry: boolean + ): void { + if (!('id' in result)) return + const graph = this.requireEnvironment().moduleGraph + const module = this.findGraphModule(result, requestId) + if (module) { + if (removeEntry) { + removeOneShotEntry(graph, module, requestId) + } else { + graph.updateModuleTransformResult(module, null) + } + } + } + + private captureRequestMetadata( + result: MaterializedFetchResult, + requestId: string + ): SsrModuleRequestMetadata { + if (!('id' in result)) { + return { dependencies: [], hasUnknownDynamicImports: false } + } + + const transformed = this.findGraphModule(result, requestId)?.transformResult + const staticDependencies = transformed?.deps ?? [] + const dynamicDependencies = transformed?.dynamicDeps ?? [] + return { + dependencies: [ + ...new Set([...staticDependencies, ...dynamicDependencies]) + ], + hasUnknownDynamicImports: hasUnknownDynamicImports( + result.code, + dynamicDependencies.length + ), + dependencyImporter: getDependencyImporter(result) + } + } + + private findGraphModule( + result: MaterializedFetchResult, + requestId: string + ): EnvironmentModuleNode | undefined { + if (!('id' in result)) return + const graph = this.requireEnvironment().moduleGraph + const candidates = idCandidates(true, result.id, requestId) + for (const candidate of candidates) { + const module = + graph.getModuleById(candidate) ?? graph.urlToModuleMap.get(candidate) + if (module) return module + if (path.isAbsolute(candidate)) { + const fileUrlModule = graph.getModuleById(pathToFileURL(candidate).href) + if (fileUrlModule) return fileUrlModule + } + } + } + + private async readRequest( + key: string + ): Promise { + const requestHash = hashSsrModuleValue(key) + let artifactHash = + this.requestManifest.get(key) ?? this.requestArtifacts.get(requestHash) + + if (!artifactHash) { + try { + const stored = JSON.parse( + await readFile( + ssrModuleCacheFile(this.requestsDir, requestHash), + 'utf8' + ) + ) as StoredSsrModuleRequest + if ( + stored.version !== SSR_MODULE_ARTIFACT_VERSION || + stored.key !== key + ) { + return + } + artifactHash = stored.artifact + this.requestArtifacts.set(requestHash, artifactHash) + this.requestManifest.set(key, artifactHash) + } catch (error) { + if (isErrnoException(error) && error.code === 'ENOENT') return + if (error instanceof SyntaxError) return + throw error + } + } + + try { + const artifact = JSON.parse( + await readFile( + ssrModuleCacheFile(this.modulesDir, artifactHash), + 'utf8' + ) + ) as StoredSsrModuleArtifact + if (artifact.version !== SSR_MODULE_ARTIFACT_VERSION) return + this.requestMetadata.set(key, { + dependencies: artifact.dependencies, + hasUnknownDynamicImports: artifact.hasUnknownDynamicImports, + dependencyImporter: getDependencyImporter(artifact.result) + }) + return artifact.result + } catch (error) { + if (isErrnoException(error) && error.code === 'ENOENT') return + if (error instanceof SyntaxError) return + throw error + } + } + + private async persistRequest( + key: string, + result: MaterializedFetchResult, + metadata: SsrModuleRequestMetadata + ): Promise { + const artifact: StoredSsrModuleArtifact = { + version: SSR_MODULE_ARTIFACT_VERSION, + result, + dependencies: metadata.dependencies, + hasUnknownDynamicImports: metadata.hasUnknownDynamicImports + } + const artifactJson = JSON.stringify(artifact) + const artifactHash = hashSsrModuleValue(artifactJson) + const artifactPath = ssrModuleCacheFile(this.modulesDir, artifactHash) + await mkdir(path.dirname(artifactPath), { recursive: true }) + try { + await writeFile(artifactPath, artifactJson, { flag: 'wx' }) + } catch (error) { + if (!isErrnoException(error) || error.code !== 'EEXIST') throw error + } + + const requestHash = hashSsrModuleValue(key) + const requestPath = ssrModuleCacheFile(this.requestsDir, requestHash) + const storedRequest: StoredSsrModuleRequest = { + version: SSR_MODULE_ARTIFACT_VERSION, + key, + artifact: artifactHash + } + if (!this.options.snapshotOnly) { + await mkdir(path.dirname(requestPath), { recursive: true }) + await this.writeAtomically(requestPath, JSON.stringify(storedRequest)) + } + this.requestArtifacts.set(requestHash, artifactHash) + this.requestManifest.set(key, artifactHash) + } + + private async writeFullSnapshot(): Promise { + await this.writeSnapshot( + path.join(this.artifactDir, 'snapshot.json'), + [...this.requestManifest].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + ) + } + + private async writeSnapshot( + snapshotPath: string, + requests: [key: string, artifact: string][] + ): Promise { + const snapshot: SsrModuleStoreSnapshot = { + version: SSR_MODULE_ARTIFACT_VERSION, + requests + } + await mkdir(path.dirname(snapshotPath), { recursive: true }) + await this.writeAtomically(snapshotPath, JSON.stringify(snapshot)) + } + + private async writeAtomically(file: string, contents: string): Promise { + const temporary = `${file}.${process.pid}.${this.writeId++}.tmp` + await writeFile(temporary, contents) + try { + await rename(temporary, file) + } finally { + await unlink(temporary).catch((error) => { + if (!isErrnoException(error) || error.code !== 'ENOENT') throw error + }) + } + } +} + +export function createSsrModuleCompiler( + inlineConfig: InlineConfig, + artifactDir: string, + options?: SsrModuleCompilerOptions +): SsrModuleCompiler { + return new SsrModuleCompiler(inlineConfig, artifactDir, options) +} diff --git a/src/node/build/ssrModuleStore.ts b/src/node/build/ssrModuleStore.ts new file mode 100644 index 00000000..0138678f --- /dev/null +++ b/src/node/build/ssrModuleStore.ts @@ -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 + +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 { + return readStoredSsrModuleRequestByKey( + storeRoot, + createSsrModuleRequestKey(id, importer) + ) +} + +export async function readStoredSsrModuleRequestByKey( + storeRoot: string, + key: string +): Promise { + 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 { + 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 | undefined> + readonly #snapshotIsAuthoritative: boolean + readonly #artifacts = new Map< + string, + Promise + >() + + 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 { + 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 | 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 + } + } +} diff --git a/src/node/build/ssrModuleTransport.ts b/src/node/build/ssrModuleTransport.ts new file mode 100644 index 00000000..09f2bf23 --- /dev/null +++ b/src/node/build/ssrModuleTransport.ts @@ -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 + const data = payload.data as Partial | undefined + return ( + payload.type === 'custom' && + payload.event === 'vite:invoke' && + !!data && + (data.name === 'fetchModule' || data.name === 'getBuiltins') && + Array.isArray(data.data) + ) +} diff --git a/src/node/build/ssrWorker.ts b/src/node/build/ssrWorker.ts new file mode 100644 index 00000000..7a271f71 --- /dev/null +++ b/src/node/build/ssrWorker.ts @@ -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 +} + +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 + __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 { + 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 => { + 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 { + 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 +}) diff --git a/src/node/build/ssrWorkerProtocol.ts b/src/node/build/ssrWorkerProtocol.ts new file mode 100644 index 00000000..aacd92c6 --- /dev/null +++ b/src/node/build/ssrWorkerProtocol.ts @@ -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 } + ) + } +} diff --git a/src/node/cli.ts b/src/node/cli.ts index 2e12748c..1ff9e5af 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -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) diff --git a/src/node/config.ts b/src/node/config.ts index 982016a6..4a433a43 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -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 diff --git a/src/node/defaultTheme.ts b/src/node/defaultTheme.ts index 7b50e1b6..8f92080e 100644 --- a/src/node/defaultTheme.ts +++ b/src/node/defaultTheme.ts @@ -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 /** * Allows transformation of content before indexing (node only) * Return empty string to skip indexing diff --git a/src/node/index.ts b/src/node/index.ts index a86f49b2..2db313eb 100644 --- a/src/node/index.ts +++ b/src/node/index.ts @@ -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 { diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index 7ffe1e6d..eb5a8704 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -88,9 +88,27 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions { */ config?: (md: MarkdownRenderer) => Awaitable /** - * 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 + /** + * 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 = console, - publicDir?: string + publicDir?: string, + cacheDir?: string ): Promise { 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 diff --git a/src/node/markdown/plugins/highlight.ts b/src/node/markdown/plugins/highlight.ts index d6cf82d7..95897dc6 100644 --- a/src/node/markdown/plugins/highlight.ts +++ b/src/node/markdown/plugins/highlight.ts @@ -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 = console + logger: Pick = console, + cacheDir?: string ): Promise< [(str: string, lang: string, attrs: string) => Promise, () => 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> + 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({ + maxSize: HIGHLIGHT_MEMORY_CACHE_SIZE, + sizeCalculation: (value) => Buffer.byteLength(value) + }) + const pending = new Map>() // 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() + 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() - 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 { + try { + return await readFile(getCacheFile(root, cacheKey), 'utf8') + } catch { + return + } +} + +async function writeCachedHighlight( + root: string, + cacheKey: string, + html: string +): Promise { + 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()): 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 + 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) + } +} diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index a31b60c7..d997f6cf 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -1,8 +1,11 @@ import { resolveTitleFromToken } from '@mdit-vue/shared' +import { isHTMLTag, isMathMLTag, isSVGTag } from '@vue/shared' import { LRUCache } from 'lru-cache' import fs from 'node:fs' import path from 'node:path' import { createDebug } from 'obug' +import { createFilter, type Plugin } from 'vite' +import { DEFAULT_THEME_PATH } from './alias' import type { SiteConfig } from './config' import { createMarkdownRenderer, @@ -24,7 +27,24 @@ import { getGitTimestamp } from './utils/getGitTimestamp' import { processIncludes } from './utils/processIncludes' const debug = createDebug('vitepress:md') -const cache = new LRUCache({ max: 1024 }) +const MARKDOWN_CACHE_MAX_BYTES = 32 * 1024 * 1024 +const cache = new LRUCache({ + maxSize: MARKDOWN_CACHE_MAX_BYTES, + sizeCalculation(result) { + return ( + Buffer.byteLength(result.vueSrc) + + Buffer.byteLength(result.html) + + (result.markdownSource ? Buffer.byteLength(result.markdownSource) : 0) + + // Keep the cache bounded even for pages with unusually large page data + // or link/header arrays without serializing those objects a second time. + 1024 + ) + } +}) +const deadLinkPageCache = new WeakMap< + SiteConfig, + { source: string[]; pages: Set } +>() const scriptRE = /<\/script>/ const scriptLangTsRE = /<\s*script[^>]*\blang=['"]ts['"][^>]*/ @@ -32,17 +52,69 @@ const scriptSetupRE = /<\s*script[^>]*\bsetup\b[^>]*/ const scriptClientRE = /<\s*script[^>]*\bclient\b[^>]*/ const defaultExportRE = /((?:^|\n|;)\s*)export(\s*)default/ const namedDefaultExportRE = /((?:^|\n|;)\s*)export(.+)as(\s*)default/ +const SSR_PAGE_ARTIFACT_SUFFIX = '.__vitepress_ssr.vue' -let __pages: string[] = [] let __dynamicRoutes = new Map() let __rewrites = new Map() let __ts: number export interface MarkdownCompileResult { vueSrc: string + /** Rendered Markdown body before Vue's SFC compiler processes it. */ + html: string + /** Markdown after include/snippet expansion, reusable by local search. */ + markdownSource?: string + /** + * The documented Markdown environment produced by the primary render. This + * is deliberately a plain snapshot rather than the renderer-owned object so + * plugin-private/cyclic state cannot leak into the persistent artifact. + */ + markdownEnv?: MarkdownEnv pageData: PageData deadLinks: { url: string; file: string; line?: number }[] + /** Raw link facts used to revalidate routes/public files every build. */ + linkCandidates?: PageLinkCandidate[] + linkContext?: PageLinkContext includes: string[] + /** + * Present when the page can be inserted without evaluating a page Vue + * module. `staticHtml` is omitted when `html` is already SSR-ready. + */ + staticPage?: true + /** SSR-ready static HTML with compile-time-only `v-pre` markers removed. */ + staticHtml?: string + /** + * Internal marker used when refreshing a cached module after page-data hooks. + * User-authored default exports must never be rewritten as component names. + */ + generatedPageComponentName?: true + /** + * User-authored SFC blocks can make plugin-vue's filename-derived component + * id and Vite's importer identity observable. Compile these pages through the + * physical Markdown id so client and SSR transforms see the same filename. + */ + requiresSourceModuleIdentity?: true +} + +export interface PageLinkCandidate { + url: string + line?: number +} + +export interface PageLinkContext { + /** Original source/template path used in diagnostics. */ + file: string + /** Rewritten absolute page path used to resolve relative links. */ + pagePath: string +} + +export interface MarkdownToVueRenderFn { + (src: string, file: string): Promise + /** Apply site and dynamic-route page-data hooks without rerendering Markdown. */ + finalize( + artifact: MarkdownCompileResult, + file: string + ): Promise } export function clearCache(relativePath?: string) { @@ -62,8 +134,6 @@ function normalizeDriveLetter(file: string) { function getResolutionCache(siteConfig: SiteConfig) { // @ts-expect-error internal if (siteConfig.__dirty) { - __pages = siteConfig.pages.map((p) => slash(p.replace(/\.md$/, ''))) - __dynamicRoutes = new Map( siteConfig.dynamicRoutes.map((r) => [ r.fullPath, @@ -85,7 +155,6 @@ function getResolutionCache(siteConfig: SiteConfig) { } return { - pages: __pages, dynamicRoutes: __dynamicRoutes, rewrites: __rewrites, ts: __ts @@ -98,31 +167,99 @@ export async function createMarkdownToVueRenderFn( base: string, includeLastUpdatedData: boolean, cleanUrls: boolean, - siteConfig: SiteConfig + siteConfig: SiteConfig, + initializeRenderer = false, + validateLinks = true, + deferPageDataTransforms = false, + artifactPlugins: unknown = siteConfig.vite?.plugins, + renderBuiltUrl: unknown = siteConfig.vite?.experimental?.renderBuiltUrl ) { - const md = await createMarkdownRenderer( - srcDir, - mergeMarkdownLocales(options, siteConfig?.site.locales), - base, - siteConfig?.logger, - siteConfig?.publicDir - ) + const localSearchOptions = ( + siteConfig.site.themeConfig as + | { + search?: { + options?: { _render?: unknown; _transformHtml?: unknown } + } + } + | undefined + )?.search?.options + const captureSearchEnv = + typeof localSearchOptions?._transformHtml === 'function' + const captureSearchSource = + !captureSearchEnv && typeof localSearchOptions?._render === 'function' + let mdPromise: Promise | undefined + const getMarkdownRenderer = () => + (mdPromise ??= createMarkdownRenderer( + srcDir, + mergeMarkdownLocales(options, siteConfig?.site.locales), + base, + siteConfig?.logger, + siteConfig?.publicDir, + siteConfig?.cacheDir + )) - return async (src: string, file: string): Promise => { - const { pages, dynamicRoutes, rewrites, ts } = - getResolutionCache(siteConfig) + // The artifact seed owns the once-per-build lifecycle of renderer setup + // hooks, even when every page is a persistent-cache hit. Later client/runtime + // environments stay completely lazy and therefore cannot repeat setup. + if (initializeRenderer && (options.preConfig || options.config)) { + await getMarkdownRenderer() + } + const finalize = async ( + artifact: MarkdownCompileResult, + file: string + ): Promise => { + artifact = applyArtifactEnvironmentSafety( + artifact, + file, + artifactPlugins, + renderBuiltUrl + ) + const { dynamicRoutes } = getResolutionCache(siteConfig) const dynamicRoute = dynamicRoutes.get(file) - const fileOrig = dynamicRoute?.[0] || file - const transformPageData = [ - siteConfig?.transformPageData, + const transforms = [ + siteConfig.transformPageData, getPageDataTransformer(dynamicRoute?.[1]!) ].filter((fn) => fn != null) + if (transforms.length === 0) return artifact + + // Hooks are allowed to mutate their argument. Keep the persistent pre-hook + // artifact immutable so every build starts from the same Markdown result. + let pageData = clonePageData(artifact.pageData) + for (const transform of transforms) { + if (transform) { + const dataToMerge = await transform(pageData, { siteConfig }) + if (dataToMerge) pageData = { ...pageData, ...dataToMerge } + } + } + + return { + ...artifact, + vueSrc: refreshPageDataCode(artifact, pageData), + pageData + } + } + + const render = (async ( + src: string, + file: string + ): Promise => { + const { dynamicRoutes, rewrites, ts } = getResolutionCache(siteConfig) + + const routeFile = file + const dynamicRoute = dynamicRoutes.get(file) + const fileOrig = dynamicRoute?.[0] || file + file = rewrites.get(normalizeDriveLetter(file)) || file const relativePath = slash(path.relative(srcDir, file)) - const cacheKey = JSON.stringify({ src, ts, relativePath }) + const cacheKey = JSON.stringify({ + src, + ts, + deferPageDataTransforms, + relativePath + }) if (options.cache !== false) { const cached = cache.get(cacheKey) if (cached) { @@ -131,6 +268,8 @@ export async function createMarkdownToVueRenderFn( } } + const md = await getMarkdownRenderer() + const start = Date.now() // resolve params for dynamic routes @@ -172,78 +311,21 @@ export async function createMarkdownToVueRenderFn( content && src.endsWith(content) ? src.slice(0, -content.length) : '' ) - // validate data.links - const deadLinks: MarkdownCompileResult['deadLinks'] = [] - const recordDeadLink = (url: string, line?: number) => { - deadLinks.push( - line == null ? { url, file: fileOrig } : { url, file: fileOrig, line } - ) + const linkCandidates: PageLinkCandidate[] = links.map((url, index) => ({ + url, + ...(linkLines[index] == null + ? {} + : { line: linkLines[index] + contentLineOffset }) + })) + const linkContext: PageLinkContext = { + file: fileOrig, + pagePath: file } + const deadLinks = validateLinks + ? resolveDeadLinks(linkCandidates, linkContext, siteConfig) + : [] - function shouldIgnoreDeadLink(url: string) { - if (!siteConfig?.ignoreDeadLinks) { - return false - } - if (siteConfig.ignoreDeadLinks === true) { - return true - } - if (siteConfig.ignoreDeadLinks === 'localhostLinks') { - return url.replace(EXTERNAL_URL_RE, '').startsWith('//localhost') - } - - return siteConfig.ignoreDeadLinks.some((ignore) => { - if (typeof ignore === 'string') return url === ignore - if (ignore instanceof RegExp) return ignore.test(url) - if (typeof ignore === 'function') return ignore(url, fileOrig) - return false - }) - } - - if (links && siteConfig?.ignoreDeadLinks !== true) { - const dir = path.dirname(file) - for (const [index, rawUrl] of links.entries()) { - let url = rawUrl - const line = - linkLines[index] == null - ? undefined - : linkLines[index] + contentLineOffset - const { pathname } = new URL(url, 'http://a.com') - if (!treatAsHtml(pathname)) continue - - url = url.replace(/[?#].*$/, '').replace(/\.(html|md)$/, '') - if (url.endsWith('/')) url += `index` - - let resolved = decodeURIComponent( - slash( - url.startsWith('/') - ? url.slice(1) - : path.relative(srcDir, path.resolve(dir, url)) - ) - ) - const rewriteSource = siteConfig?.rewrites.inv[resolved + '.md'] - if (rewriteSource) resolved = rewriteSource.slice(0, -3) - - // a link to the pre-rewrite path of a rewritten page 404s in the - // built site even though the page itself exists - const rewritten = rewriteSource - ? undefined - : siteConfig?.rewrites.map[resolved + '.md'] - - if ( - (!pages.includes(resolved) || - (rewritten != null && rewritten !== resolved + '.md')) && - !( - siteConfig?.publicDir && - fs.existsSync(path.join(siteConfig.publicDir, `${resolved}.html`)) - ) && - !shouldIgnoreDeadLink(url) - ) { - recordDeadLink(url, line) - } - } - } - - let pageData: PageData = { + const pageData: PageData = { title: inferTitle(md, frontmatter, title), titleTemplate: frontmatter.titleTemplate as any, description: inferDescription(frontmatter), @@ -262,35 +344,791 @@ export async function createMarkdownToVueRenderFn( } } - for (const fn of transformPageData) { - if (fn) { - const dataToMerge = await fn(pageData, { siteConfig }) - if (dataToMerge) pageData = { ...pageData, ...dataToMerge } - } - } - + const injectedPageData = injectPageDataCode( + sfcBlocks?.scripts.map((item) => item.content) ?? [], + pageData + ) const vueSrc = [ - ...injectPageDataCode( - sfcBlocks?.scripts.map((item) => item.content) ?? [], - pageData - ), + ...injectedPageData.tags, ``, ...(sfcBlocks?.styles.map((item) => item.content) ?? []), ...(sfcBlocks?.customBlocks.map((item) => item.content) ?? []) ].join('\n') + const unsafeArtifactModuleSemantics = hasUnsafeArtifactModuleSemantics( + artifactPlugins, + fileOrig + ) + const unsafeStaticPluginSemantics = hasUnsafeStaticPluginSemantics( + artifactPlugins, + fileOrig + ) + const staticHtml = + unsafeStaticPluginSemantics || renderBuiltUrl != null + ? undefined + : createStaticHtml(html, sfcBlocks, siteConfig) + const requiresSourceModuleIdentity = !!( + sfcBlocks?.scripts?.length || + sfcBlocks?.styles?.length || + sfcBlocks?.customBlocks?.length || + unsafeArtifactModuleSemantics + ) debug(`[render] ${file} in ${Date.now() - start}ms.`) - const result = { vueSrc, pageData, deadLinks, includes } - if (options.cache !== false) cache.set(cacheKey, result) - return result + const result: MarkdownCompileResult = { + vueSrc, + html, + ...(captureSearchSource && dynamicRoute ? { markdownSource: src } : {}), + ...(captureSearchEnv ? { markdownEnv: snapshotMarkdownEnv(env) } : {}), + pageData, + deadLinks, + linkCandidates, + linkContext, + includes, + ...(injectedPageData.generatedComponentName + ? { generatedPageComponentName: true as const } + : {}), + ...(requiresSourceModuleIdentity + ? { requiresSourceModuleIdentity: true as const } + : {}), + ...(staticHtml == null + ? {} + : { + staticPage: true as const, + ...(staticHtml === prepareStaticHtmlForSsr(html) + ? {} + : { staticHtml }) + }) + } + const finalized = deferPageDataTransforms + ? result + : await finalize(result, routeFile) + if (options.cache !== false) cache.set(cacheKey, finalized) + return finalized + }) as MarkdownToVueRenderFn + + render.finalize = finalize + return render +} + +/** Revalidate cached link facts against current routes, rewrites and public files. */ +export function resolveDeadLinks( + candidates: PageLinkCandidate[], + context: PageLinkContext, + siteConfig: SiteConfig +): MarkdownCompileResult['deadLinks'] { + if (siteConfig.ignoreDeadLinks === true) return [] + + let pageCache = deadLinkPageCache.get(siteConfig) + if (!pageCache || pageCache.source !== siteConfig.pages) { + pageCache = { + source: siteConfig.pages, + pages: new Set( + siteConfig.pages.map((page) => slash(page.replace(/\.md$/, ''))) + ) + } + deadLinkPageCache.set(siteConfig, pageCache) } + const { pages } = pageCache + const deadLinks: MarkdownCompileResult['deadLinks'] = [] + + const shouldIgnore = (url: string, file: string) => { + const { ignoreDeadLinks } = siteConfig + if (!ignoreDeadLinks) return false + if (ignoreDeadLinks === 'localhostLinks') { + return url.replace(EXTERNAL_URL_RE, '').startsWith('//localhost') + } + if (!Array.isArray(ignoreDeadLinks)) return false + + return ignoreDeadLinks.some((ignore) => { + if (typeof ignore === 'string') return url === ignore + if (ignore instanceof RegExp) { + ignore.lastIndex = 0 + return ignore.test(url) + } + return typeof ignore === 'function' && ignore(url, file) + }) + } + + for (const candidate of candidates) { + let url = candidate.url + const { pathname } = new URL(url, 'http://a.com') + if (!treatAsHtml(pathname)) continue + + url = url.replace(/[?#].*$/, '').replace(/\.(html|md)$/, '') + if (url.endsWith('/')) url += 'index' + + const dir = path.dirname(context.pagePath) + let resolved = decodeURIComponent( + slash( + url.startsWith('/') + ? url.slice(1) + : path.relative(siteConfig.srcDir, path.resolve(dir, url)) + ) + ) + const rewriteSource = siteConfig.rewrites.inv[resolved + '.md'] + if (rewriteSource) resolved = rewriteSource.slice(0, -3) + + // A link to the pre-rewrite path of a rewritten page still 404s. + const rewritten = rewriteSource + ? undefined + : siteConfig.rewrites.map[resolved + '.md'] + const publicHtml = siteConfig.publicDir + ? path.join(siteConfig.publicDir, `${resolved}.html`) + : undefined + + if ( + (!pages.has(resolved) || + (rewritten != null && rewritten !== resolved + '.md')) && + !(publicHtml && fs.existsSync(publicHtml)) && + !shouldIgnore(url, context.file) + ) { + deadLinks.push( + candidate.line == null + ? { url, file: context.file } + : { url, file: context.file, line: candidate.line } + ) + } + } + + return deadLinks +} + +function snapshotMarkdownEnv(env: MarkdownEnv): MarkdownEnv { + const { + content, + excerpt, + frontmatter, + headers, + sfcBlocks, + title, + path, + relativePath, + cleanUrls, + links, + linkLines, + includes, + realPath, + localeIndex + } = env + + return { + content, + excerpt, + frontmatter, + headers, + sfcBlocks, + title, + path, + relativePath, + cleanUrls, + links, + linkLines, + includes, + realPath, + localeIndex + } +} + +const STATIC_HTML_META_ASSET_NAMES = new Set([ + 'msapplication-tileimage', + 'msapplication-square70x70logo', + 'msapplication-square150x150logo', + 'msapplication-wide310x150logo', + 'msapplication-square310x310logo', + 'msapplication-config', + 'twitter:image' +]) +const STATIC_HTML_META_ASSET_PROPERTIES = new Set([ + 'og:image', + 'og:image:url', + 'og:image:secure_url', + 'og:audio', + 'og:audio:secure_url', + 'og:video', + 'og:video:secure_url' +]) +const STATIC_HTML_ASSET_SOURCES: Record< + string, + { src?: readonly string[]; srcset?: readonly string[] } +> = { + audio: { src: ['src'] }, + embed: { src: ['src'] }, + img: { src: ['src'], srcset: ['srcset'] }, + image: { src: ['href', 'xlink:href'] }, + input: { src: ['src'] }, + link: { src: ['href'], srcset: ['imagesrcset'] }, + object: { src: ['data'] }, + source: { src: ['src'], srcset: ['srcset'] }, + track: { src: ['src'] }, + use: { src: ['href', 'xlink:href'] }, + video: { src: ['src', 'poster'] }, + meta: { src: ['content'] } +} +const STATIC_BADGE_TYPES = new Set(['info', 'tip', 'warning', 'danger']) + +// Deliberately conservative. A false negative only uses the normal Vue SSR +// path; a false positive could change output or hydration semantics. +function createStaticHtml( + html: string, + sfcBlocks: + | { + scripts?: unknown[] + styles?: unknown[] + customBlocks?: unknown[] + } + | undefined, + siteConfig: SiteConfig +): string | undefined { + // Resolved Vite hooks are screened by the caller. Vue compiler options can + // also change the generated template, so keep their proof local here. + if (hasUnsafeVueTransforms(siteConfig.vue)) { + return + } + + if ( + sfcBlocks?.scripts?.length || + sfcBlocks?.styles?.length || + sfcBlocks?.customBlocks?.length + ) { + return + } + + const expandedHtml = expandStaticDefaultThemeBadges(html, siteConfig) + if (expandedHtml == null) return + + // Shiki marks non-Vue code blocks with v-pre so arbitrary code text is not + // parsed as Vue. The compiler removes that boundary attribute and otherwise + // emits the subtree verbatim. Mask those known-safe subtrees while checking + // the rest of the document, then remove the compile-time marker from the + // direct SSR payload to match Vue's output exactly. + const inspectedHtml = expandedHtml.replace( + /]*\bv-pre(?:\s|=|>))[^>]*>[^]*?<\/pre>/gi, + '
'
+  )
+
+  // Interpolations and Vue directive shorthand outside known Shiki v-pre
+  // blocks are excluded. False negatives stay on the compiled-module path.
+  if (/\{\{[^]*?\}\}/.test(inspectedHtml)) return
+  if (/<[A-Za-z][^>]*\s(?:v-|[.:@#])[^>]*>/.test(inspectedHtml)) return
+  // Vue treats these tags/attributes as VNode control flow rather than plain
+  // HTML. Keeping the fast path conservative avoids changing SSR output for
+  // hand-written HTML embedded in Markdown.
+  if (/<\/?(?:slot|template)\b/i.test(inspectedHtml)) return
+  if (/<[A-Za-z][^>]*\s(?:key|ref|is|slot)(?=\s|=|\/?>)/i.test(inspectedHtml)) {
+    return
+  }
+  if (/]*\svalue(?=\s|=|\/?>)/i.test(inspectedHtml)) return
+
+  // The direct-static path skips Vite/Vue's asset URL transform. Mirror
+  // Vite's default HTML asset-source matrix and reject every URL that is not
+  // intrinsically final. Build-time plugin-vue uses includeAbsolute, so root
+  // URLs are unsafe too: Vite may hash a public asset or prefix a non-root base.
+  if (hasRewritableStaticAssetUrl(expandedHtml, siteConfig)) return
+
+  const tagRE = /<\/?([A-Za-z][\w.-]*)\b/g
+  for (const match of inspectedHtml.matchAll(tagRE)) {
+    const tag = match[1]
+    if (!isHTMLTag(tag) && !isSVGTag(tag) && !isMathMLTag(tag)) return
+  }
+
+  return prepareStaticHtmlForSsr(expandedHtml)
+}
+
+/**
+ * Fold the default theme's presentational Badge component into its exact Vue
+ * SSR markup. Slot fragment comments are intentional hydration boundaries.
+ * Any non-literal prop or nested markup leaves the page on the compiled path.
+ */
+function expandStaticDefaultThemeBadges(
+  html: string,
+  siteConfig: SiteConfig
+): string | undefined {
+  if (!/<\/?Badge\b/.test(html)) return html
+
+  // A custom theme may register Badge with different markup or behavior. Only
+  // fold the component when its implementation is the built-in default theme
+  // that this exact SSR markup belongs to.
+  if (
+    slash(path.resolve(siteConfig.themeDir)) !==
+    slash(path.resolve(DEFAULT_THEME_PATH))
+  ) {
+    return
+  }
+
+  let unsupported = false
+  const expanded = html.replace(
+    /]*)>([^<]*)<\/Badge>/g,
+    (source, rawAttributes: string, text: string) => {
+      const attributes = new Map()
+      let consumed = ''
+      for (const match of rawAttributes.matchAll(
+        /\s+([A-Za-z][\w-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g
+      )) {
+        consumed += match[0]
+        const name = match[1]
+        if (attributes.has(name)) {
+          unsupported = true
+          return source
+        }
+        attributes.set(name, match[2] ?? match[3] ?? '')
+      }
+      if (
+        consumed !== rawAttributes ||
+        [...attributes].some(([name]) => name !== 'type')
+      ) {
+        unsupported = true
+        return source
+      }
+
+      const type = attributes.get('type') || 'tip'
+      if (!STATIC_BADGE_TYPES.has(type)) {
+        unsupported = true
+        return source
+      }
+      return `${text}`
+    }
+  )
+  if (unsupported || /<\/?Badge\b/.test(expanded)) return
+  return expanded
+}
+
+/** @internal Prepare an eligible artifact only while materializing its batch. */
+export function prepareStaticHtmlForSsr(html: string): string {
+  const withoutVPre = html.replace(
+    /(]*?)\s+v-pre(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi,
+    '$1'
+  )
+  return normalizeStaticHtmlWhitespace(withoutVPre)
+}
+
+/**
+ * Client entry for a direct-static page. Keeping the body as one static vnode
+ * avoids running Vue's template compiler across content that the coordinator
+ * has already proven to contain no runtime behavior.
+ */
+export function createStaticPageVueSource(
+  artifact: MarkdownCompileResult
+): string {
+  if (!artifact.staticPage) {
+    throw new Error('Cannot create a static page module for a dynamic page.')
+  }
+  const html = artifact.staticHtml ?? prepareStaticHtmlForSsr(artifact.html)
+  return ``
+}
+
+function normalizeStaticHtmlWhitespace(html: string): string {
+  const protectedContents: string[] = []
+  const masked = html.replace(
+    /(<(pre|textarea)\b[^>]*>)([^]*?)(<\/\2>)/gi,
+    (_match, open: string, _tag: string, content: string, close: string) => {
+      const index = protectedContents.push(content) - 1
+      return `${open}\uE000${index}\uE001${close}`
+    }
+  )
+
+  // Vue's default template whitespace mode removes newline-only text nodes
+  // between elements. Raw Markdown HTML retains those newlines, which shifts
+  // hydration's child cursor and can make a lean client page patch the wrong
+  // element. Preserve whitespace-sensitive element contents, but mirror the
+  // compiled template at ordinary block boundaries.
+  return masked
+    .replace(/>\s*\n\s*<')
+    .replace(
+      /\uE000(\d+)\uE001/g,
+      (_match, index: string) => protectedContents[Number(index)]
+    )
+    .trim()
+}
+
+function hasRewritableStaticAssetUrl(
+  html: string,
+  siteConfig: SiteConfig
+): boolean {
+  for (const match of html.matchAll(
+    /<([A-Za-z][\w.-]*)\b((?:"[^"]*"|'[^']*'|[^'">])*)>/g
+  )) {
+    const tag = match[1].toLowerCase()
+    const source = STATIC_HTML_ASSET_SOURCES[tag]
+    if (!source) continue
+
+    const attributes = new Map()
+    for (const attribute of match[2].matchAll(
+      /\s+([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g
+    )) {
+      const name = attribute[1].toLowerCase()
+      const values = attributes.get(name) ?? []
+      values.push(attribute[2] ?? attribute[3] ?? attribute[4] ?? '')
+      attributes.set(name, values)
+    }
+
+    if (tag === 'meta' && !isAssetMetaTag(attributes)) continue
+    for (const name of source.src ?? []) {
+      const values = attributes.get(name) ?? []
+      if (
+        values.some(
+          (value) => value && !isFinalStaticAssetUrl(value, siteConfig)
+        )
+      ) {
+        return true
+      }
+    }
+    for (const name of source.srcset ?? []) {
+      const values = attributes.get(name) ?? []
+      if (
+        values.some((value) => value && !isFinalStaticSrcset(value, siteConfig))
+      ) {
+        return true
+      }
+    }
+  }
+  return false
+}
+
+function isAssetMetaTag(attributes: Map): boolean {
+  if (
+    attributes
+      .get('name')
+      ?.some((name) =>
+        STATIC_HTML_META_ASSET_NAMES.has(name.trim().toLowerCase())
+      )
+  ) {
+    return true
+  }
+  return !!attributes
+    .get('property')
+    ?.some((property) =>
+      STATIC_HTML_META_ASSET_PROPERTIES.has(property.trim().toLowerCase())
+    )
+}
+
+function isFinalStaticSrcset(value: string, siteConfig: SiteConfig): boolean {
+  return value.split(',').every((candidate) => {
+    const url = candidate.trim().split(/\s+/, 1)[0]
+    return !url || isFinalStaticAssetUrl(url, siteConfig, false)
+  })
+}
+
+function isFinalStaticAssetUrl(
+  value: string,
+  siteConfig: SiteConfig,
+  allowBareHash = true
+): boolean {
+  const url = value.trim()
+  return (
+    !url ||
+    (allowBareHash && url === '#') ||
+    /^data:/i.test(url) ||
+    /^(?:https?:)?\/\//.test(url) ||
+    isRootPublicAsset(url, siteConfig)
+  )
+}
+
+function isRootPublicAsset(url: string, siteConfig: SiteConfig): boolean {
+  if (
+    !url.startsWith('/') ||
+    url.startsWith('//') ||
+    siteConfig.site.base !== '/' ||
+    !siteConfig.publicDir
+  ) {
+    return false
+  }
+
+  let pathname: string
+  try {
+    pathname = decodeURIComponent(url.replace(/[?#].*$/, ''))
+  } catch {
+    return false
+  }
+  const publicDir = path.resolve(siteConfig.publicDir)
+  const file = path.resolve(publicDir, `.${pathname}`)
+  return (
+    (file === publicDir || file.startsWith(`${publicDir}${path.sep}`)) &&
+    fs.existsSync(file)
+  )
+}
+
+/**
+ * Use a generated `.vue` identity beside the physical Markdown source. The
+ * sibling location preserves relative asset resolution while preventing
+ * Markdown-only pre transforms from running a second time over generated SFC
+ * source in the coordinator's SSR compiler.
+ */
+export function createSsrPageArtifactModuleId(sourceFile: string): string {
+  return `${slash(path.resolve(sourceFile))}${SSR_PAGE_ARTIFACT_SUFFIX}`
+}
+
+/**
+ * Whether the coordinator can compile the stored post-Markdown SFC directly.
+ *
+ * User transforms that apply to `.md` can branch on the SSR environment even
+ * when they are filtered and enforce-pre, so they require the physical path
+ * unless they explicitly opt into the artifact-safety contract. Resolve/load
+ * hooks must likewise be unable to observe the substitute module id.
+ */
+export function canCompileSsrPageArtifact(
+  siteConfig: SiteConfig,
+  sourceFile: string,
+  artifact?: { requiresSourceModuleIdentity?: boolean }
+): boolean {
+  if (artifact) return !artifact.requiresSourceModuleIdentity
+  return !hasUnsafeArtifactModuleSemantics(siteConfig.vite?.plugins, sourceFile)
+}
+
+type ArtifactAwarePlugin = Plugin<{
+  vitepress?: { ssrArtifactSafe?: boolean }
+}>
+
+function isVitePressInternalPlugin(plugin: ArtifactAwarePlugin): boolean {
+  const name = plugin.name || ''
+  return (
+    name === 'alias' ||
+    name === 'vitepress' ||
+    name.startsWith('vite:') ||
+    name.startsWith('vitepress:') ||
+    name.startsWith('builtin:') ||
+    name.startsWith('native:')
+  )
+}
+
+function isExplicitlyArtifactSafe(plugin: ArtifactAwarePlugin): boolean {
+  return plugin.api?.vitepress?.ssrArtifactSafe === true
+}
+
+function isInactiveBuildPlugin(plugin: ArtifactAwarePlugin): boolean {
+  return plugin.apply === 'serve'
+}
+
+function applyArtifactEnvironmentSafety(
+  artifact: MarkdownCompileResult,
+  sourceFile: string,
+  plugins: unknown,
+  renderBuiltUrl: unknown
+): MarkdownCompileResult {
+  const unsafeArtifactModuleSemantics = hasUnsafeArtifactModuleSemantics(
+    plugins,
+    sourceFile
+  )
+  const unsafeStaticSemantics =
+    hasUnsafeStaticPluginSemantics(plugins, sourceFile) ||
+    renderBuiltUrl != null
+  if (!unsafeArtifactModuleSemantics && !unsafeStaticSemantics) return artifact
+  if (
+    (!unsafeArtifactModuleSemantics || artifact.requiresSourceModuleIdentity) &&
+    (!unsafeStaticSemantics || !artifact.staticPage)
+  ) {
+    return artifact
+  }
+
+  const safeArtifact = {
+    ...artifact,
+    ...(unsafeArtifactModuleSemantics
+      ? { requiresSourceModuleIdentity: true as const }
+      : {})
+  }
+  if (unsafeStaticSemantics) {
+    delete safeArtifact.staticPage
+    delete safeArtifact.staticHtml
+  }
+  return safeArtifact
+}
+
+function hasUnsafeArtifactModuleSemantics(
+  plugins: unknown,
+  sourceFile: string
+): boolean {
+  return (
+    hasUnsafeViteTransforms(plugins, sourceFile) ||
+    hasUnsafeSsrArtifactModuleHooks(plugins)
+  )
+}
+
+function hasUnsafeStaticPluginSemantics(
+  plugins: unknown,
+  sourceFile: string
+): boolean {
+  return (
+    hasUnsafeViteTransforms(plugins, sourceFile) ||
+    hasUnsafeStaticPageModuleHooks(plugins, sourceFile)
+  )
+}
+
+/**
+ * Whether a resolved Vite environment can consume the coordinator's client
+ * Markdown artifact without observing a different physical module pipeline.
+ * The batched coordinator evaluates this once for the client environment and
+ * again after the real unbundled SSR environment has resolved, because
+ * `applyToEnvironment` may return different plugin objects for each one.
+ *
+ * @internal
+ */
+export function canReuseSsrPageArtifactWithPlugins(
+  plugins: unknown,
+  sourceFile: string
+): boolean {
+  return !hasUnsafeArtifactModuleSemantics(plugins, sourceFile)
+}
+
+function hookAppliesToId(hook: unknown, id: string): boolean {
+  if (typeof hook === 'function') return true
+  if (!hook || typeof hook !== 'object') return false
+
+  const filteredHook = hook as {
+    filter?: { id?: unknown }
+    handler?: unknown
+  }
+  if (typeof filteredHook.handler !== 'function') return false
+  if (!filteredHook.filter?.id) return true
+  try {
+    return createFilter(filteredHook.filter.id as never)(id)
+  } catch {
+    return true
+  }
+}
+
+function hasUnsafeStaticPageModuleHooks(
+  value: unknown,
+  sourceFile: string
+): boolean {
+  if (!value) return false
+  if (Array.isArray(value)) {
+    return value.some((plugin) =>
+      hasUnsafeStaticPageModuleHooks(plugin, sourceFile)
+    )
+  }
+  if (typeof value !== 'object') return false
+
+  const plugin = value as ArtifactAwarePlugin & { then?: unknown }
+  if (typeof plugin.then === 'function') return true
+  if (
+    isVitePressInternalPlugin(plugin) ||
+    isExplicitlyArtifactSafe(plugin) ||
+    isInactiveBuildPlugin(plugin)
+  ) {
+    return false
+  }
+
+  return (
+    hookAppliesToId(plugin.resolveId, sourceFile) ||
+    hookAppliesToId(plugin.load, sourceFile)
+  )
+}
+
+function hasUnsafeSsrArtifactModuleHooks(value: unknown): boolean {
+  if (!value) return false
+  if (Array.isArray(value)) {
+    return value.some(hasUnsafeSsrArtifactModuleHooks)
+  }
+  if (typeof value !== 'object') return false
+
+  const plugin = value as {
+    api?: unknown
+    apply?: unknown
+    load?: unknown
+    name?: unknown
+    resolveId?: unknown
+    then?: unknown
+  }
+  // Plugin promises are resolved by Vite later. Until then, neither their
+  // hooks nor their filters can be proven insensitive to the synthetic id.
+  if (typeof plugin.then === 'function') return true
+  const resolvedPlugin = plugin as ArtifactAwarePlugin
+  if (
+    isVitePressInternalPlugin(resolvedPlugin) ||
+    isExplicitlyArtifactSafe(resolvedPlugin) ||
+    isInactiveBuildPlugin(resolvedPlugin)
+  ) {
+    return false
+  }
+
+  // resolveId filters select the import source, not its importer, and load
+  // hooks can apply to dependencies of the page. Without compiling the graph,
+  // any such hook can observe a synthetic page id directly or as an importer.
+  return plugin.resolveId != null || plugin.load != null
+}
+
+function hasUnsafeViteTransforms(value: unknown, sourceFile: string): boolean {
+  if (!value) return false
+  if (Array.isArray(value)) {
+    return value.some((plugin) => hasUnsafeViteTransforms(plugin, sourceFile))
+  }
+  if (typeof value !== 'object') return false
+
+  const plugin = value as {
+    api?: unknown
+    apply?: unknown
+    enforce?: unknown
+    name?: unknown
+    transform?: unknown
+    then?: unknown
+  }
+  // Vite accepts promised plugin options. Their eventual transform ordering is
+  // opaque here, so use the compiled path.
+  if (typeof plugin.then === 'function') return true
+  const resolvedPlugin = plugin as ArtifactAwarePlugin
+  if (
+    isVitePressInternalPlugin(resolvedPlugin) ||
+    isExplicitlyArtifactSafe(resolvedPlugin) ||
+    isInactiveBuildPlugin(resolvedPlugin) ||
+    plugin.transform == null
+  ) {
+    return false
+  }
+
+  if (typeof plugin.apply === 'function') return true
+  if (typeof plugin.transform === 'function') return true
+
+  const transform = plugin.transform as {
+    filter?: { id?: unknown }
+    handler?: unknown
+  }
+  if (typeof transform.handler !== 'function' || !transform.filter?.id) {
+    return true
+  }
+
+  try {
+    const filter = createFilter(transform.filter.id as never)
+    const appliesToSource = filter(sourceFile)
+    const appliesToArtifact = filter(createSsrPageArtifactModuleId(sourceFile))
+    if (!appliesToSource && !appliesToArtifact) return false
+
+    // Even an enforce-pre, source-only transform can inspect the SSR flag or
+    // `this.environment`. Reusing its client result would then differ from the
+    // legacy server build. Such a transform must explicitly promise that its
+    // Markdown result and observable side effects are environment-invariant.
+    return true
+  } catch {
+    return true
+  }
+}
+
+function hasUnsafeVueTransforms(value: SiteConfig['vue']): boolean {
+  if (!value || Object.keys(value).length === 0) return false
+  const keys = Object.keys(value)
+  if (keys.some((key) => key !== 'template')) return true
+
+  const template = value.template
+  if (!template) return false
+  if (Object.keys(template).some((key) => key !== 'compilerOptions')) {
+    return true
+  }
+
+  const compilerOptions = template.compilerOptions
+  return !!(
+    compilerOptions &&
+    Object.keys(compilerOptions).some((key) => key !== 'isCustomElement')
+  )
 }
 
 function injectPageDataCode(tags: string[], data: PageData) {
-  const code = `\nexport const __pageData = JSON.parse(${JSON.stringify(
-    JSON.stringify(data)
-  )})`
+  const code = createPageDataExportCode(data)
+  let generatedComponentName = false
 
   const existingScriptIndex = tags.findIndex((tag) => {
     return (
@@ -308,25 +1146,105 @@ function injectPageDataCode(tags: string[], data: PageData) {
     // if it doesn't have export default it will error out on build
     const hasDefaultExport =
       defaultExportRE.test(tagSrc) || namedDefaultExportRE.test(tagSrc)
+    if (!hasDefaultExport) generatedComponentName = true
     tags[existingScriptIndex] = tagSrc.replace(
       scriptRE,
       code +
         (hasDefaultExport
           ? ``
-          : `\nexport default {name:${JSON.stringify(data.relativePath)}}`) +
+          : createPageComponentDefault(data.relativePath)) +
         ``
     )
   } else {
+    generatedComponentName = true
     tags.unshift(
       ``
+      }>${code}${createPageComponentDefault(data.relativePath)}`
+    )
+  }
+
+  return { tags, generatedComponentName }
+}
+
+function refreshPageDataCode(
+  artifact: MarkdownCompileResult,
+  pageData: PageData
+): string {
+  const previousCode = createPageDataExportCode(artifact.pageData)
+  const nextCode = createPageDataExportCode(pageData)
+  if (!artifact.vueSrc.includes(previousCode)) {
+    throw new Error(
+      `Unable to refresh cached page data for ${artifact.pageData.relativePath}.`
+    )
+  }
+
+  let vueSrc = artifact.vueSrc.replace(previousCode, nextCode)
+  if (
+    artifact.generatedPageComponentName &&
+    artifact.pageData.relativePath !== pageData.relativePath
+  ) {
+    const previousDefault = createPageComponentDefault(
+      artifact.pageData.relativePath
+    )
+    if (!vueSrc.includes(previousDefault)) {
+      throw new Error(
+        `Unable to refresh cached component name for ${artifact.pageData.relativePath}.`
+      )
+    }
+    vueSrc = vueSrc.replace(
+      previousDefault,
+      createPageComponentDefault(pageData.relativePath)
     )
   }
+  return vueSrc
+}
+
+function createPageDataExportCode(data: PageData): string {
+  return `\nexport const __pageData = JSON.parse(${JSON.stringify(
+    JSON.stringify(data)
+  )})`
+}
+
+function createPageComponentDefault(relativePath: string): string {
+  return `\nexport default {name:${JSON.stringify(relativePath)}}`
+}
+
+function clonePageData(data: PageData): PageData {
+  try {
+    return structuredClone(data)
+  } catch {
+    return clonePageDataValue(data, new Map())
+  }
+}
+
+function clonePageDataValue(value: T, seen: Map): T {
+  if (value == null || typeof value !== 'object') return value
+  if (value instanceof Date) return new Date(value) as unknown as T
+  if (value instanceof RegExp) return new RegExp(value) as unknown as T
+
+  const existing = seen.get(value)
+  if (existing) return existing as T
 
-  return tags
+  if (Array.isArray(value)) {
+    const result: unknown[] = []
+    seen.set(value, result)
+    for (const item of value) result.push(clonePageDataValue(item, seen))
+    return result as unknown as T
+  }
+
+  const result = Object.create(Object.getPrototypeOf(value)) as Record<
+    PropertyKey,
+    unknown
+  >
+  seen.set(value, result)
+  for (const key of Reflect.ownKeys(value)) {
+    result[key] = clonePageDataValue(
+      (value as Record)[key],
+      seen
+    )
+  }
+  return result as unknown as T
 }
 
 const inferTitle = (
diff --git a/src/node/pageArtifacts.ts b/src/node/pageArtifacts.ts
new file mode 100644
index 00000000..58f95d86
--- /dev/null
+++ b/src/node/pageArtifacts.ts
@@ -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
+}
+
+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
+
+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()
+  // 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()
+  readonly #pending = new Map>()
+  readonly #dependencyHashes = new Map>()
+  #loaded: Promise | 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 {
+    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 {
+    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 {
+    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 {
+    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,
+    finalize?: PageArtifactFinalizer
+  ): Promise {
+    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 {
+    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 {
+    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 {
+    return this.#readStoredObject(objectHash)
+  }
+
+  async #readStoredObject(
+    objectHash: string
+  ): Promise {
+    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 {
+    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 {
+    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 {
+    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 {
+    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 {
+    const matches = await Promise.all(
+      dependencies.map(
+        async ({ file, hash: expected }) =>
+          (await this.#hashDependency(file)) === expected
+      )
+    )
+    return matches.every(Boolean)
+  }
+
+  #hashDependency(file: string): Promise {
+    let pending = this.#dependencyHashes.get(file)
+    if (!pending) {
+      pending = readFile(file).then(hash, () => '')
+      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 = ``
+  const index = vueSrc.indexOf(template)
+  if (index < 0) return { source: vueSrc }
+
+  const bodyOffset = index + '