diff --git a/src/client/app/ssr.ts b/src/client/app/ssr.ts new file mode 100644 index 000000000000..9a85b3dab250 --- /dev/null +++ b/src/client/app/ssr.ts @@ -0,0 +1,9 @@ +// entry for SSR +import { createApp } from './index.js' +import { renderToString } from 'vue/server-renderer' + +export async function render(path: string) { + const { app, router } = createApp() + await router.go(path) + return renderToString(app) +} diff --git a/src/node/alias.ts b/src/node/alias.ts index d90399ff65c4..cda18f4af8c1 100644 --- a/src/node/alias.ts +++ b/src/node/alias.ts @@ -2,6 +2,7 @@ import { createRequire } from 'module' import { resolve, join } from 'path' import { fileURLToPath } from 'url' import { Alias, AliasOptions } from 'vite' +import { SiteConfig } from './config' const require = createRequire(import.meta.url) const PKG_ROOT = resolve(fileURLToPath(import.meta.url), '../..') @@ -19,21 +20,15 @@ export const SITE_DATA_REQUEST_PATH = '/' + SITE_DATA_ID const vueRuntimePath = 'vue/dist/vue.runtime.esm-bundler.js' -export function resolveAliases(root: string, themeDir: string): AliasOptions { +export function resolveAliases( + { root, themeDir }: SiteConfig, + ssr: boolean +): AliasOptions { const paths: Record = { '@theme': themeDir, [SITE_DATA_ID]: SITE_DATA_REQUEST_PATH } - // prioritize vue installed in project root and fallback to - // vue that comes with vitepress itself. - let vuePath - try { - vuePath = require.resolve(vueRuntimePath, { paths: [root] }) - } catch (e) { - vuePath = require.resolve(vueRuntimePath) - } - const aliases: Alias[] = [ ...Object.keys(paths).map((p) => ({ find: p, @@ -46,14 +41,25 @@ export function resolveAliases(root: string, themeDir: string): AliasOptions { { find: /^vitepress\/theme$/, replacement: join(DIST_CLIENT_PATH, '/theme-default/index.js') - }, - // make sure it always use the same vue dependency that comes - // with vitepress itself - { - find: /^vue$/, - replacement: vuePath } ] + if (!ssr) { + // Prioritize vue installed in project root and fallback to + // vue that comes with vitepress itself. + // Only do this when not running SSR build, since `vue` needs to be + // externalized during SSR + let vuePath + try { + vuePath = require.resolve(vueRuntimePath, { paths: [root] }) + } catch (e) { + vuePath = require.resolve(vueRuntimePath) + } + aliases.push({ + find: /^vue$/, + replacement: vuePath + }) + } + return aliases } diff --git a/src/node/build/build.ts b/src/node/build/build.ts index fb09c71728a2..1f1933235654 100644 --- a/src/node/build/build.ts +++ b/src/node/build/build.ts @@ -6,15 +6,18 @@ import { OutputChunk, OutputAsset } from 'rollup' import { resolveConfig } from '../config' import { renderPage } from './render' import { bundle, okMark, failMark } from './bundle' +import { createRequire } from 'module' +import { pathToFileURL } from 'url' export async function build( - root: string, + root?: string, buildOptions: BuildOptions & { base?: string; mpa?: string } = {} ) { const start = Date.now() process.env.NODE_ENV = 'production' const siteConfig = await resolveConfig(root, 'build', 'production') + const unlinkVue = linkVue(siteConfig.root) if (buildOptions.base) { siteConfig.site.base = buildOptions.base @@ -32,6 +35,9 @@ export async function build( buildOptions ) + const entryPath = path.join(siteConfig.tempDir, 'app.js') + const { render } = await import(pathToFileURL(entryPath).toString()) + const spinner = ora() spinner.start('rendering pages...') @@ -58,6 +64,7 @@ export async function build( for (const page of pages) { await renderPage( + render, siteConfig, page, clientResult, @@ -84,6 +91,7 @@ export async function build( pageToHashMap ) } finally { + unlinkVue() if (!process.env.DEBUG) fs.rmSync(siteConfig.tempDir, { recursive: true, force: true }) } @@ -92,3 +100,16 @@ export async function build( console.log(`build complete in ${((Date.now() - start) / 1000).toFixed(2)}s.`) } + +function linkVue(root: string) { + 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')) + fs.ensureSymlinkSync(src, dest) + return () => { + fs.unlinkSync(dest) + } + } + return () => {} +} diff --git a/src/node/build/bundle.ts b/src/node/build/bundle.ts index c8e21b947478..a3765d409669 100644 --- a/src/node/build/bundle.ts +++ b/src/node/build/bundle.ts @@ -28,9 +28,7 @@ export async function bundle( // 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 = { - app: path.resolve(APP_PATH, 'index.js') - } + const input: Record = {} config.pages.forEach((file) => { // page filename conversion // foo/bar.md -> foo_bar.md @@ -40,66 +38,70 @@ export async function bundle( // resolve options to pass to vite const { rollupOptions } = options - const resolveViteConfig = async (ssr: boolean): Promise => ({ - root: config.srcDir, - base: config.site.base, - logLevel: 'warn', - plugins: await createVitePressPlugin( - config, - ssr, - pageToHashMap, - clientJSMap - ), - ssr: { - noExternal: ['vitepress', '@docsearch/css'] - }, - build: { - ...options, - emptyOutDir: true, - ssr, - outDir: ssr ? config.tempDir : config.outDir, - cssCodeSplit: false, - rollupOptions: { - ...rollupOptions, - input, - // important so that each page chunk and the index export things for each - // other - preserveEntrySignatures: 'allow-extension', - output: { - ...rollupOptions?.output, - ...(ssr - ? { - entryFileNames: `[name].js`, - chunkFileNames: `[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) { - // move known framework code into a stable chunk so that - // custom theme changes do not invalidate hash for all pages - if (id.includes('plugin-vue:export-helper')) { - return 'framework' - } - if ( - isEagerChunk(id, ctx) && - (/@vue\/(runtime|shared|reactivity)/.test(id) || - /vitepress\/dist\/client/.test(id)) - ) { - return 'framework' - } - } - }) - } + const resolveViteConfig = async (ssr: boolean): Promise => { + // use different entry based on ssr or not + input['app'] = path.resolve(APP_PATH, ssr ? 'ssr.js' : 'index.js') + return { + root: config.srcDir, + base: config.site.base, + logLevel: 'warn', + plugins: await createVitePressPlugin( + config, + ssr, + pageToHashMap, + clientJSMap + ), + ssr: { + noExternal: ['vitepress', '@docsearch/css'] }, - // minify with esbuild in MPA mode (for CSS) - minify: ssr ? (config.mpa ? 'esbuild' : false) : !process.env.DEBUG + build: { + ...options, + emptyOutDir: true, + ssr, + outDir: ssr ? config.tempDir : config.outDir, + cssCodeSplit: false, + rollupOptions: { + ...rollupOptions, + input, + // important so that each page chunk and the index export things for each + // other + preserveEntrySignatures: 'allow-extension', + output: { + ...rollupOptions?.output, + ...(ssr + ? { + entryFileNames: `[name].js`, + chunkFileNames: `[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) { + // move known framework code into a stable chunk so that + // custom theme changes do not invalidate hash for all pages + if (id.includes('plugin-vue:export-helper')) { + return 'framework' + } + if ( + isEagerChunk(id, ctx) && + (/@vue\/(runtime|shared|reactivity)/.test(id) || + /vitepress\/dist\/client/.test(id)) + ) { + return 'framework' + } + } + }) + } + }, + // minify with esbuild in MPA mode (for CSS) + minify: ssr ? (config.mpa ? 'esbuild' : false) : !process.env.DEBUG + } } - }) + } let clientResult: RollupOutput let serverResult: RollupOutput diff --git a/src/node/build/render.ts b/src/node/build/render.ts index 9dbd685f6461..55e1adf3772e 100644 --- a/src/node/build/render.ts +++ b/src/node/build/render.ts @@ -1,4 +1,3 @@ -import { createRequire } from 'module' import fs from 'fs-extra' import path from 'path' import { pathToFileURL } from 'url' @@ -16,9 +15,8 @@ import { import { slash } from '../utils/slash' import { SiteConfig, resolveSiteDataByRoute } from '../config' -const require = createRequire(import.meta.url) - export async function renderPage( + render: (path: string) => Promise, config: SiteConfig, page: string, // foo.md result: RollupOutput | null, @@ -27,28 +25,11 @@ export async function renderPage( pageToHashMap: Record, hashMapString: string ) { - const entryPath = path.join(config.tempDir, 'app.js') - const { createApp } = await import(pathToFileURL(entryPath).toString()) - const { app, router } = createApp() const routePath = `/${page.replace(/\.md$/, '')}` const siteData = resolveSiteDataByRoute(config.site, routePath) - await router.go(routePath) - - // lazy require server-renderer for production build - // prioritize project root over vitepress' own dep - let rendererPath - try { - rendererPath = require.resolve('vue/server-renderer', { - paths: [config.root] - }) - } catch (e) { - rendererPath = require.resolve('vue/server-renderer') - } // render page - const content = await import(pathToFileURL(rendererPath).toString()).then( - (r) => r.renderToString(app) - ) + const content = await render(routePath) const pageName = page.replace(/\//g, '_') // server build doesn't need hash diff --git a/src/node/config.ts b/src/node/config.ts index 29b79ba24548..6206b1fd7b0b 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -4,7 +4,6 @@ import c from 'picocolors' import fg from 'fast-glob' import { normalizePath, - AliasOptions, UserConfig as ViteConfig, mergeConfig as mergeViteConfig, loadConfigFromFile @@ -20,7 +19,7 @@ import { CleanUrlsMode, PageData } from './shared' -import { resolveAliases, DEFAULT_THEME_PATH } from './alias' +import { DEFAULT_THEME_PATH } from './alias' import { MarkdownOptions } from './markdown/markdown' import _debug from 'debug' @@ -138,7 +137,6 @@ export interface SiteConfig themeDir: string outDir: string tempDir: string - alias: AliasOptions pages: string[] } @@ -208,7 +206,6 @@ export async function resolveConfig( tempDir: resolve(root, '.temp'), markdown: userConfig.markdown, lastUpdated: userConfig.lastUpdated, - alias: resolveAliases(root, themeDir), vue: userConfig.vue, vite: userConfig.vite, shouldPreload: userConfig.shouldPreload, diff --git a/src/node/plugin.ts b/src/node/plugin.ts index de85b1359a10..dab227f4efd9 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -3,7 +3,12 @@ import c from 'picocolors' import { defineConfig, mergeConfig, Plugin, ResolvedConfig } from 'vite' import { SiteConfig } from './config' import { createMarkdownToVueRenderFn, clearCache } from './markdownToVue' -import { DIST_CLIENT_PATH, APP_PATH, SITE_DATA_REQUEST_PATH } from './alias' +import { + DIST_CLIENT_PATH, + APP_PATH, + SITE_DATA_REQUEST_PATH, + resolveAliases +} from './alias' import { slash } from './utils/slash' import { OutputAsset, OutputChunk } from 'rollup' import { staticDataPlugin } from './staticDataPlugin' @@ -41,7 +46,6 @@ export async function createVitePressPlugin( srcDir, configPath, configDeps, - alias, markdown, site, vue: userVuePluginOptions, @@ -95,7 +99,7 @@ export async function createVitePressPlugin( config() { const baseConfig = defineConfig({ resolve: { - alias + alias: resolveAliases(siteConfig, ssr) }, define: { __ALGOLIA__: !!site.themeConfig.algolia,