import ora from 'ora' import path from 'path' import fs from 'fs-extra' import { build, type BuildOptions, type UserConfig as ViteUserConfig } from 'vite' import type { GetModuleInfo, RollupOutput } from 'rollup' import type { SiteConfig } from '../config' import { APP_PATH } from '../alias' import { createVitePressPlugin } from '../plugin' import { sanitizeFileName, slash } from '../shared' import { buildMPAClient } from './buildMPAClient' import { fileURLToPath } from 'url' import { normalizePath } from 'vite' export const okMark = '\x1b[32m✓\x1b[0m' export const failMark = '\x1b[31m✖\x1b[0m' // A list of default theme components that should only be loaded on demand. const lazyDefaultThemeComponentsRE = /VP(HomeSponsors|DocAsideSponsors|TeamPage|TeamMembers|LocalSearchBox|AlgoliaSearchBox|CarbonAds|DocAsideCarbonAds)/ const clientDir = normalizePath( path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../client') ) // bundles the VitePress app for both client AND server. export async function bundle( config: SiteConfig, options: BuildOptions ): Promise<{ clientResult: RollupOutput serverResult: RollupOutput pageToHashMap: Record }> { const pageToHashMap = Object.create(null) const clientJSMap = Object.create(null) // define custom rollup 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) }) // resolve options to pass to vite const { rollupOptions } = options const resolveViteConfig = async (ssr: boolean): Promise => ({ root: config.srcDir, cacheDir: config.cacheDir, base: config.site.base, logLevel: config.vite?.logLevel ?? 'warn', plugins: await createVitePressPlugin( config, ssr, pageToHashMap, clientJSMap ), ssr: { noExternal: ['vitepress', '@docsearch/css'] }, build: { ...options, emptyOutDir: true, ssr, // minify with esbuild in MPA mode (for CSS) minify: ssr ? config.mpa ? 'esbuild' : false : typeof options.minify === 'boolean' ? options.minify : !process.env.DEBUG, outDir: ssr ? config.tempDir : options.outDir || config.outDir, cssCodeSplit: false, rollupOptions: { ...rollupOptions, input: { ...input, // use different entry based on ssr or not app: path.resolve(APP_PATH, ssr ? 'ssr.js' : 'index.js') }, // important so that each page chunk and the index export things for each // other preserveEntrySignatures: 'allow-extension', output: { sanitizeFileName, ...rollupOptions?.output, assetFileNames: 'assets/[name].[hash].[ext]', ...(ssr ? { entryFileNames: '[name].js', chunkFileNames: '[name].[hash].js' } : { entryFileNames: 'assets/[name].[hash].js', chunkFileNames(chunk) { // avoid ads chunk being intercepted by adblock return /(?:Carbon|BuySell)Ads/.test(chunk.name) ? 'assets/chunks/ui-custom.[hash].js' : 'assets/chunks/[name].[hash].js' }, manualChunks(id, ctx) { if (lazyDefaultThemeComponentsRE.test(id)) { return } if (id.startsWith(`${clientDir}/theme-default`)) { return 'theme' } // move known framework code into a stable chunk so that // custom theme changes do not invalidate hash for all pages if (id.startsWith('\0vite')) { return 'framework' } if (id.includes('plugin-vue:export-helper')) { return 'framework' } if ( id.includes(`${clientDir}/app`) && id !== `${clientDir}/app/index.js` ) { return 'framework' } if ( isEagerChunk(id, ctx.getModuleInfo) && /@vue\/(runtime|shared|reactivity)/.test(id) ) { return 'framework' } } }) } } } }) let clientResult: RollupOutput let serverResult: RollupOutput const spinner = ora() spinner.start('building client + server bundles...') try { ;[clientResult, serverResult] = await (Promise.all([ config.mpa ? null : build(await resolveViteConfig(false)), build(await resolveViteConfig(true)) ]) as Promise<[RollupOutput, RollupOutput]>) } catch (e) { spinner.stopAndPersist({ symbol: failMark }) throw e } spinner.stopAndPersist({ symbol: okMark }) 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 Promise.all( serverResult.output.map(async (chunk) => { if (!chunk.fileName.endsWith('.js')) { const tempPath = path.resolve(config.tempDir, chunk.fileName) const outPath = path.resolve(config.outDir, chunk.fileName) await fs.copy(tempPath, outPath) } }) ) // also copy over public dir const publicDir = path.resolve(config.srcDir, 'public') if (fs.existsSync(publicDir)) { await fs.copy(publicDir, config.outDir) } // build