import fs from 'node:fs' import { cp } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import pMap from 'p-map' import { build, normalizePath, type BuildOptions, type Rolldown, type InlineConfig as ViteInlineConfig } from 'vite' import { APP_PATH } from '../alias' import type { SiteConfig } from '../config' import { createVitePressPlugin, type PageMeta } from '../plugin' import { escapeRegExp, sanitizeFileName, slash } from '../shared' import { task } from '../utils/task' import { buildMPAClient } from './buildMPAClient' // https://github.com/vitejs/vite/blob/a55d0b34400e3360c4100d05e422ae9cf10fa07b/packages/vite/src/node/constants.ts#L50 const CSS_LANGS_RE = /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/ const clientDir = normalizePath( path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../client') ) // these deps are also being used in the client code (outside of the theme) // exclude them from the theme chunk so there is no circular dependency const excludedModules = [ '/@siteData', 'node_modules/@vueuse/core/', 'node_modules/@vueuse/shared/', 'node_modules/vue/', clientDir ] // bundles the VitePress app for both client AND server. export async function bundle( config: SiteConfig, options: BuildOptions, 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 // 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) }) const themeEntryRE = new RegExp( `^${escapeRegExp( path.resolve(config.themeDir, 'index.js').replace(/\\/g, '/') ).slice(0, -2)}m?(j|t)s` ) // resolve options to pass to vite const { rollupOptions, rolldownOptions = rollupOptions, ...restOptions } = 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, pageMetaMap ), ssr: { noExternal: ['vitepress', '@docsearch/css'] }, build: { ...restOptions, emptyOutDir: true, ssr, ssrEmitAssets: config.mpa, minify: ssr ? !!config.mpa : (options.minify ?? !process.env.DEBUG), outDir: ssr ? config.tempDir : 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 }, // important so that each page chunk and the index export things for // each other preserveEntrySignatures: 'allow-extension', output: { sanitizeFileName, ...rolldownOptions?.output, assetFileNames: `${config.assetsDir}/[name].[hash].[ext]`, ...(ssr ? { entryFileNames: '[name].js', chunkFileNames: '[name].[hash].js' } : { entryFileNames: `${config.assetsDir}/[name].[hash].js`, chunkFileNames(chunk) { // avoid ads chunk being intercepted by adblock return /(?:Carbon|BuySell)Ads/.test(chunk.name) ? `${config.assetsDir}/chunks/[hash].js` : `${config.assetsDir}/chunks/[name].[hash].js` }, codeSplitting: { groups: [ { name(id, ctx) { const getModuleInfo = ctx.getModuleInfo.bind(ctx) // avoid emitting multiple files for assets // see: https://github.com/rolldown/rolldown/issues/4246 if (getModuleInfo(id)?.meta['vite:asset']) { return 'assets' } // move known framework code into a stable chunk so that // custom theme changes do not invalidate hash for all pages if ( id.startsWith('\0vite') || id.includes('plugin-vue:export-helper') || (id.includes(`${clientDir}/app`) && id !== `${clientDir}/app/index.js`) || (isEagerChunk(id, getModuleInfo) && /@vue\/(runtime|shared|reactivity)/.test(id)) ) { return 'framework' } if ( (id.startsWith(`${clientDir}/theme-default`) || !excludedModules.some((i) => id.includes(i))) && staticImportedByEntry( id, getModuleInfo, cacheTheme, themeEntryRE ) ) { return 'theme' } } } ] } }) }, checks: { pluginTimings: false, ...rolldownOptions?.checks } } }, configFile: config.vite?.configFile }) let { clientResult, serverResult } = await task( 'building client + server bundles', async () => { const clientResult = config.mpa ? null : ((await build( await resolveViteConfig(false) )) as Rolldown.RolldownOutput) const serverResult = (await build( await resolveViteConfig(true) )) as Rolldown.RolldownOutput return { clientResult, serverResult } } ) 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, async (chunk) => { if (!chunk.fileName.endsWith('.js')) { const tempPath = path.resolve(config.tempDir, chunk.fileName) const outPath = path.resolve(config.outDir, chunk.fileName) await cp(tempPath, outPath) } }, { concurrency: config.buildConcurrency } ) // also copy over public dir const { publicDir } = config if (publicDir && fs.existsSync(publicDir)) { // dereference symlinks like vite's own publicDir copy does, and so that // copying over an existing symlinked file does not fail with EEXIST await cp(publicDir, config.outDir, { recursive: true, dereference: true }) } // build