diff --git a/src/client/app/utils.ts b/src/client/app/utils.ts index 0509668a..0eee7e11 100644 --- a/src/client/app/utils.ts +++ b/src/client/app/utils.ts @@ -29,7 +29,7 @@ export function pathToFile(path: string): string { // client production build needs to account for page hash, which is // injected directly in the page's html const pageHash = __VP_HASH_MAP__[pagePath] - pagePath = `${base}_assets/${pagePath}.${pageHash}.js` + pagePath = `${base}assets/${pagePath}.${pageHash}.js` } else { // ssr build uses much simpler name mapping pagePath = `./${pagePath.slice(1).replace(/\//g, '_')}.md.js` diff --git a/src/node/resolver.ts b/src/node/alias.ts similarity index 100% rename from src/node/resolver.ts rename to src/node/alias.ts diff --git a/src/node/build/build.ts b/src/node/build/build.ts index 9265456a..a1f64f7a 100644 --- a/src/node/build/build.ts +++ b/src/node/build/build.ts @@ -1,24 +1,16 @@ import fs from 'fs-extra' import { bundle, okMark, failMark } from './bundle' -import { BuildOptions as ViteBuildOptions } from 'vite' +import { BuildOptions } from 'vite' import { resolveConfig } from '../config' import { renderPage } from './render' import { OutputChunk, OutputAsset } from 'rollup' import ora from 'ora' -export type BuildOptions = Pick< - Partial, - | 'root' - | 'rollupInputOptions' - | 'rollupOutputOptions' - | 'rollupPluginVueOptions' -> - -export async function build(buildOptions: BuildOptions = {}) { +export async function build(root: string, buildOptions: BuildOptions = {}) { const start = Date.now() process.env.NODE_ENV = 'production' - const siteConfig = await resolveConfig(buildOptions.root) + const siteConfig = await resolveConfig(root) try { const [clientResult, , pageToHashMap] = await bundle( @@ -30,12 +22,11 @@ export async function build(buildOptions: BuildOptions = {}) { spinner.start('rendering pages...') try { - const appChunk = clientResult.assets.find( - (chunk) => - chunk.type === 'chunk' && chunk.fileName.match(/^app\.\w+\.js$/) + const appChunk = clientResult.output.find( + (chunk) => chunk.type === 'chunk' && chunk.isEntry && chunk ) as OutputChunk - const cssChunk = clientResult.assets.find( + const cssChunk = clientResult.output.find( (chunk) => chunk.type === 'asset' && chunk.fileName.endsWith('.css') ) as OutputAsset diff --git a/src/node/build/bundle.ts b/src/node/build/bundle.ts index 7aca513a..84a62d09 100644 --- a/src/node/build/bundle.ts +++ b/src/node/build/bundle.ts @@ -1,109 +1,23 @@ +import ora from 'ora' import path from 'path' import slash from 'slash' -import fs from 'fs-extra' -import { APP_PATH, createResolver, SITE_DATA_REQUEST_PATH } from '../resolver' -import { BuildOptions } from './build' -import { resolveUserConfig, SiteConfig } from '../config' -import { Plugin, OutputAsset, OutputChunk } from 'rollup' -import { createMarkdownToVueRenderFn } from '../markdownToVue' -import { build, BuildOptions as ViteBuildOptions } from 'vite' -import ora from 'ora' +import { APP_PATH } from '../alias' +import { SiteConfig } from '../config' +import { RollupOutput, ExternalOption } from 'rollup' +import { build, BuildOptions, UserConfig as ViteUserConfig } from 'vite' +import { createVitePressPlugin } from '../plugin' export const okMark = '\x1b[32m✓\x1b[0m' export const failMark = '\x1b[31m✖\x1b[0m' -const hashRE = /\.(\w+)\.js$/ -const staticInjectMarkerRE = /\b(const _hoisted_\d+ = \/\*#__PURE__\*\/createStaticVNode)\("(.*)", (\d+)\)/g -const staticStripRE = /__VP_STATIC_START__.*?__VP_STATIC_END__/g -const staticRestoreRE = /__VP_STATIC_(START|END)__/g - -const isPageChunk = ( - chunk: OutputAsset | OutputChunk -): chunk is OutputChunk & { facadeModuleId: string } => - !!( - chunk.type === 'chunk' && - chunk.isEntry && - chunk.facadeModuleId && - chunk.facadeModuleId.endsWith('.md') - ) - // bundles the VitePress app for both client AND server. export async function bundle( config: SiteConfig, options: BuildOptions -): Promise<[BuildResult, BuildResult, Record]> { +): Promise<[RollupOutput, RollupOutput, Record]> { const root = config.root - const userConfig = await resolveUserConfig(root) - const resolver = createResolver(config.themeDir, userConfig) - const markdownToVue = createMarkdownToVueRenderFn(root, userConfig.markdown) - - let isClientBuild = true const pageToHashMap = Object.create(null) - const VitePressPlugin: Plugin = { - name: 'vitepress', - resolveId(id) { - if (id === SITE_DATA_REQUEST_PATH) { - return id - } - }, - - async load(id) { - if (id === SITE_DATA_REQUEST_PATH) { - return `export default ${JSON.stringify(JSON.stringify(config.site))}` - } - // compile md into vue src - if (id.endsWith('.md')) { - const content = await fs.readFile(id, 'utf-8') - const { vueSrc } = markdownToVue(content, id) - return vueSrc - } - }, - - renderChunk(code, chunk) { - if (isClientBuild && isPageChunk(chunk as OutputChunk)) { - // For each page chunk, inject marker for start/end of static strings. - // we do this here because in generateBundle the chunks would have been - // minified and we won't be able to safely locate the strings. - // Using a regexp relies on specific output from Vue compiler core, - // which is a reasonable trade-off considering the massive perf win over - // a full AST parse. - code = code.replace( - staticInjectMarkerRE, - '$1("__VP_STATIC_START__$2__VP_STATIC_END__", $3)' - ) - return code - } - return null - }, - - generateBundle(_options, bundle) { - if (!isClientBuild) { - return - } - - // for each .md entry chunk, adjust its name to its correct path. - for (const name in bundle) { - const chunk = bundle[name] - if (isPageChunk(chunk)) { - // record page -> hash relations - const hash = chunk.fileName.match(hashRE)![1] - const pageName = chunk.fileName.replace(hashRE, '') - pageToHashMap[pageName] = hash - - // inject another chunk with the content stripped - bundle[name + '-lean'] = { - ...chunk, - fileName: chunk.fileName.replace(/\.js$/, '.lean.js'), - code: chunk.code.replace(staticStripRE, ``) - } - // remove static markers from original code - chunk.code = chunk.code.replace(staticRestoreRE, '') - } - } - } - } - // 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 @@ -118,49 +32,57 @@ export async function bundle( }) // resolve options to pass to vite - const { rollupInputOptions = {}, rollupOutputOptions = {} } = options - const viteOptions: Partial = { - ...options, - base: config.site.base, - resolvers: [resolver], - outDir: config.outDir, - // let rollup-plugin-vue compile .md files as well - rollupPluginVueOptions: { - include: /\.(vue|md)$/ - }, - rollupInputOptions: { - ...rollupInputOptions, - input, - // important so that each page chunk and the index export things for each - // other - preserveEntrySignatures: 'allow-extension', - plugins: [VitePressPlugin, ...(rollupInputOptions.plugins || [])] - }, - rollupOutputOptions: { - ...rollupOutputOptions, - chunkFileNames(chunk): string { - if (/runtime-dom/.test(chunk.name)) { - return `framework.[hash].js` + const { rollupOptions } = options + + const resolveViteConfig = (ssr: boolean): ViteUserConfig => ({ + logLevel: 'warn', + plugins: createVitePressPlugin(root, config, ssr, pageToHashMap), + build: { + ...options, + base: config.site.base, + outDir: ssr ? config.tempDir : config.outDir, + cssCodeSplit: !ssr, + rollupOptions: { + ...rollupOptions, + input, + external: ssr + ? resolveExternal(rollupOptions?.external) + : rollupOptions?.external, + // important so that each page chunk and the index export things for each + // other + preserveEntrySignatures: 'allow-extension', + output: { + ...rollupOptions?.output, + ...(ssr + ? { + format: 'cjs', + exports: 'named', + entryFileNames: '[name].js' + } + : { + chunkFileNames(chunk): string { + if (!chunk.isEntry && /runtime/.test(chunk.name)) { + return `assets/framework.[hash].js` + } + return `assets/[name].[hash].js` + } + }) } - return `[name].[hash].js` - } - }, - silent: !process.env.DEBUG, - minify: !process.env.DEBUG - } + }, + minify: false //ssr ? false : !process.env.DEBUG + } + }) - let clientResult, serverResult + let clientResult: RollupOutput + let serverResult: RollupOutput const spinner = ora() spinner.start('building client + server bundles...') try { - ;[clientResult, serverResult] = await Promise.all([ - build(viteOptions), - ssrBuild({ - ...viteOptions, - outDir: config.tempDir - }) - ]) + ;[clientResult, serverResult] = await (Promise.all([ + build(resolveViteConfig(false)), + build(resolveViteConfig(true)) + ]) as Promise<[RollupOutput, RollupOutput]>) } catch (e) { spinner.stopAndPersist({ symbol: failMark @@ -171,5 +93,26 @@ export async function bundle( symbol: okMark }) - return [clientResult[0], serverResult[0], pageToHashMap] + return [clientResult, serverResult, pageToHashMap] +} + +function resolveExternal( + userExternal: ExternalOption | undefined +): ExternalOption { + const required = ['vue', /^@vue\//] + if (!userExternal) { + return required + } + if (Array.isArray(userExternal)) { + return [...required, ...userExternal] + } else if (typeof userExternal === 'function') { + return (src, importer, isResolved) => { + if (src === 'vue' || /^@vue\//.test(src)) { + return true + } + return userExternal(src, importer, isResolved) + } + } else { + return [...required, userExternal] + } } diff --git a/src/node/build/render.ts b/src/node/build/render.ts index 3883e38f..8ec778bb 100644 --- a/src/node/build/render.ts +++ b/src/node/build/render.ts @@ -2,21 +2,20 @@ import path from 'path' import fs from 'fs-extra' import { SiteConfig, resolveSiteDataByRoute } from '../config' import { HeadConfig } from '../../../types/shared' -import { BuildResult } from 'vite' -import { OutputChunk, OutputAsset } from 'rollup' +import { RollupOutput, OutputChunk, OutputAsset } from 'rollup' const escape = require('escape-html') export async function renderPage( config: SiteConfig, page: string, // foo.md - result: BuildResult, + result: RollupOutput, appChunk: OutputChunk, cssChunk: OutputAsset, pageToHashMap: Record, hashMapString: string ) { - const { createApp } = require(path.join(config.tempDir, `_assets/app.js`)) + const { createApp } = require(path.join(config.tempDir, `app.js`)) const { app, router } = createApp() const routePath = `/${page.replace(/\.md$/, '')}` const siteData = resolveSiteDataByRoute(config.site, routePath) @@ -30,17 +29,15 @@ export async function renderPage( // 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] - const pageClientJsFileName = pageName + `.` + pageHash + '.lean.js' + const pageClientJsFileName = `assets/${pageName}.${pageHash}.lean.js` // resolve page data so we can render head tags const { __pageData } = require(path.join( config.tempDir, - `_assets`, pageServerJsFileName )) const pageData = JSON.parse(__pageData) - const assetPath = `${siteData.base}_assets/` const preloadLinks = [ // 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 @@ -50,7 +47,7 @@ export async function renderPage( appChunk.fileName ] .map((file) => { - return `` + return `` }) .join('\n ') @@ -64,7 +61,7 @@ export async function renderPage( ${pageData.title ? pageData.title + ` | ` : ``}${siteData.title} - + ${preloadLinks} ${renderHead(siteData.head)} ${renderHead(pageData.frontmatter.head)} @@ -72,7 +69,9 @@ export async function renderPage(
${content}
- + `.trim() const htmlFileName = path.join(config.outDir, page.replace(/\.md$/, '.html')) @@ -83,14 +82,14 @@ export async function renderPage( function resolvePageImports( config: SiteConfig, page: string, - result: BuildResult, + result: RollupOutput, indexChunk: OutputChunk ) { // find the page's js chunk and inject script tags for its imports so that // they are start fetching as early as possible const srcPath = path.resolve(config.root, page) - const pageChunk = result.assets.find( + const pageChunk = result.output.find( (chunk) => chunk.type === 'chunk' && chunk.facadeModuleId === srcPath ) as OutputChunk return Array.from(new Set([...indexChunk.imports, ...pageChunk.imports])) diff --git a/src/node/cli.ts b/src/node/cli.ts index eef10966..63001af9 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -21,7 +21,7 @@ if (!command || command === 'dev') { process.exit(1) }) } else if (command === 'build') { - build(argv).catch((err) => { + build(root, argv).catch((err) => { console.error(chalk.red(`build error:\n`), err) process.exit(1) }) diff --git a/src/node/config.ts b/src/node/config.ts index 72193c9b..da078a20 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -2,7 +2,7 @@ import path from 'path' import fs from 'fs-extra' import chalk from 'chalk' import globby from 'globby' -import { APP_PATH, createAlias, DEFAULT_THEME_PATH } from './resolver' +import { createAlias, APP_PATH, DEFAULT_THEME_PATH } from './alias' import { SiteData, HeadConfig, LocaleConfig } from '../../types/shared' import { MarkdownOptions } from './markdown/markdown' import { AliasOptions } from 'vite' diff --git a/src/node/plugin.ts b/src/node/plugin.ts index d38deb66..7bf4d5df 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -2,18 +2,37 @@ import path from 'path' import { Plugin } from 'vite' import { SiteConfig, resolveSiteData } from './config' import { createMarkdownToVueRenderFn } from './markdownToVue' -import { APP_PATH, SITE_DATA_REQUEST_PATH } from './resolver' +import { APP_PATH, SITE_DATA_REQUEST_PATH } from './alias' import createVuePlugin from '@vitejs/plugin-vue' import slash from 'slash' +import { OutputAsset, OutputChunk } from 'rollup' + +const hashRE = /\.(\w+)\.js$/ +const staticInjectMarkerRE = /\b(const _hoisted_\d+ = \/\*#__PURE__\*\/createStaticVNode)\("(.*)", (\d+)\)/g +const staticStripRE = /__VP_STATIC_START__.*?__VP_STATIC_END__/g +const staticRestoreRE = /__VP_STATIC_(START|END)__/g + +const isPageChunk = ( + chunk: OutputAsset | OutputChunk +): chunk is OutputChunk & { facadeModuleId: string } => + !!( + chunk.type === 'chunk' && + chunk.isEntry && + chunk.facadeModuleId && + chunk.facadeModuleId.endsWith('.md') + ) export function createVitePressPlugin( root: string, - { configPath, aliases, markdown, site: initialSiteData }: SiteConfig + { configPath, aliases, markdown, site: initialSiteData }: SiteConfig, + ssr = false, + pageToHashMap?: Record ): Plugin[] { const markdownToVue = createMarkdownToVueRenderFn(root, markdown) const vuePlugin = createVuePlugin({ - include: [/\.vue$/, /\.md$/] + include: [/\.vue$/, /\.md$/], + ssr }) let siteData = initialSiteData @@ -51,6 +70,7 @@ export function createVitePressPlugin( // serve our index.html after vite history fallback const indexPath = `/@fs/${path.join(APP_PATH, 'index.html')}` return () => { + // @ts-ignore server.app.use((req, _, next) => { if (req.url!.endsWith('.html')) { req.url = indexPath @@ -60,6 +80,55 @@ export function createVitePressPlugin( } }, + renderChunk(code, chunk) { + if (!ssr && isPageChunk(chunk as OutputChunk)) { + // For each page chunk, inject marker for start/end of static strings. + // we do this here because in generateBundle the chunks would have been + // minified and we won't be able to safely locate the strings. + // Using a regexp relies on specific output from Vue compiler core, + // which is a reasonable trade-off considering the massive perf win over + // a full AST parse. + code = code.replace( + staticInjectMarkerRE, + '$1("__VP_STATIC_START__$2__VP_STATIC_END__", $3)' + ) + return code + } + return null + }, + + generateBundle(_options, bundle) { + if (ssr) { + // ssr build: + // delete all asset chunks + for (const name in bundle) { + if (bundle[name].type === 'asset') { + delete bundle[name] + } + } + } else { + // client build: + // for each .md entry chunk, adjust its name to its correct path. + for (const name in bundle) { + const chunk = bundle[name] + if (isPageChunk(chunk)) { + // record page -> hash relations + const hash = chunk.fileName.match(hashRE)![1] + pageToHashMap![chunk.name] = hash + + // inject another chunk with the content stripped + bundle[name + '-lean'] = { + ...chunk, + fileName: chunk.fileName.replace(/\.js$/, '.lean.js'), + code: chunk.code.replace(staticStripRE, ``) + } + // remove static markers from original code + chunk.code = chunk.code.replace(staticRestoreRE, '') + } + } + } + }, + async handleHotUpdate(file, mods, read, server) { // handle config hmr if (file === configPath) {