diff --git a/src/node/build/build.ts b/src/node/build/build.ts index 32f40225e142..a8934b1e08fd 100644 --- a/src/node/build/build.ts +++ b/src/node/build/build.ts @@ -21,7 +21,7 @@ export async function build( } try { - const [clientResult, serverResult, pageToHashMap] = await bundle( + const { clientResult, serverResult, pageToHashMap } = await bundle( siteConfig, buildOptions ) @@ -36,7 +36,9 @@ export async function build( (chunk) => chunk.type === 'chunk' && chunk.isEntry ) as OutputChunk) - const cssChunk = (clientResult || serverResult).output.find( + const cssChunk = ( + siteConfig.mpa ? serverResult : clientResult + ).output.find( (chunk) => chunk.type === 'asset' && chunk.fileName.endsWith('.css') ) as OutputAsset diff --git a/src/node/build/buildMPAClient.ts b/src/node/build/buildMPAClient.ts new file mode 100644 index 000000000000..cb0a240092f4 --- /dev/null +++ b/src/node/build/buildMPAClient.ts @@ -0,0 +1,45 @@ +import { build } from 'vite' +import { SiteConfig } from '..' + +const virtualEntry = 'client.js' + +export async function buildMPAClient( + js: Record, + config: SiteConfig +) { + const files = Object.keys(js) + const themeFiles = files.filter((f) => !f.endsWith('.md')) + const pages = files.filter((f) => f.endsWith('.md')) + + return build({ + root: config.srcDir, + base: config.site.base, + logLevel: 'warn', + build: { + emptyOutDir: false, + outDir: config.outDir, + rollupOptions: { + input: [virtualEntry, ...pages] + } + }, + plugins: [ + { + name: 'vitepress-mpa-client', + resolveId(id) { + if (id === virtualEntry) { + return id + } + }, + load(id) { + if (id === virtualEntry) { + return themeFiles + .map((file) => `import ${JSON.stringify(file)}`) + .join('\n') + } else if (id in js) { + return js[id] + } + } + } + ] + }) +} diff --git a/src/node/build/bundle.ts b/src/node/build/bundle.ts index 18cdd40b7bf2..bb7368477643 100644 --- a/src/node/build/bundle.ts +++ b/src/node/build/bundle.ts @@ -7,6 +7,7 @@ import { SiteConfig } from '../config' import { RollupOutput } from 'rollup' import { build, BuildOptions, UserConfig as ViteUserConfig } from 'vite' import { createVitePressPlugin } from '../plugin' +import { buildMPAClient } from './buildMPAClient' export const okMark = '\x1b[32m✓\x1b[0m' export const failMark = '\x1b[31m✖\x1b[0m' @@ -15,9 +16,14 @@ export const failMark = '\x1b[31m✖\x1b[0m' export async function bundle( config: SiteConfig, options: BuildOptions -): Promise<[RollupOutput, RollupOutput, Record]> { +): Promise<{ + clientResult: RollupOutput + serverResult: RollupOutput + pageToHashMap: Record +}> { const { root, srcDir } = config 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 @@ -39,7 +45,13 @@ export async function bundle( root: srcDir, base: config.site.base, logLevel: 'warn', - plugins: createVitePressPlugin(root, config, ssr, pageToHashMap), + plugins: createVitePressPlugin( + root, + config, + ssr, + pageToHashMap, + clientJSMap + ), // @ts-ignore ssr: { noExternal: ['vitepress'] @@ -112,9 +124,13 @@ export async function bundle( if (fs.existsSync(publicDir)) { await fs.copy(publicDir, config.outDir) } + // build ` + fs.removeSync(path.resolve(config.outDir, matchingChunk.fileName)) + } else { + inlinedScript = `` + } + } + } + const html = ` @@ -87,10 +111,16 @@ export async function renderPage(
${content}
${ config.mpa - ? `` - : `` + - `` - } + ? '' + : `` + } + ${ + appChunk + ? `` + : `` + } + ${inlinedScript} + `.trim() const htmlFileName = path.join(config.outDir, page.replace(/\.md$/, '.html')) await fs.ensureDir(path.dirname(htmlFileName)) diff --git a/src/node/config.ts b/src/node/config.ts index 20dac3686e32..a367ac677a50 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -45,6 +45,7 @@ export interface UserConfig { /** * Enable MPA / zero-JS mode + * @experimental */ mpa?: boolean } diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index fc4b36c58dd5..280d4b47c293 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -133,6 +133,7 @@ export function createMarkdownToVueRenderFn( const scriptRE = /<\/script>/ 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/ @@ -142,7 +143,11 @@ function genPageDataCode(tags: string[], data: PageData) { )}` const existingScriptIndex = tags.findIndex((tag) => { - return scriptRE.test(tag) && !scriptSetupRE.test(tag) + return ( + scriptRE.test(tag) && + !scriptSetupRE.test(tag) && + !scriptClientRe.test(tag) + ) }) if (existingScriptIndex > -1) { diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 2da0847f0937..9c8e9f504a83 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -16,6 +16,11 @@ const staticInjectMarkerRE = const staticStripRE = /__VP_STATIC_START__.*?__VP_STATIC_END__/g const staticRestoreRE = /__VP_STATIC_(START|END)__/g +// matches client-side js blocks in MPA mode. +// in the future we may add different execution strategies like visible or +// media queries. +const scriptClientRE = /]*client\b[^>]*>([^]*?)<\/script>/ + const isPageChunk = ( chunk: OutputAsset | OutputChunk ): chunk is OutputChunk & { facadeModuleId: string } => @@ -28,7 +33,12 @@ const isPageChunk = ( export function createVitePressPlugin( root: string, - { + siteConfig: SiteConfig, + ssr = false, + pageToHashMap?: Record, + clientJSMap?: Record +): Plugin[] { + const { srcDir, configPath, alias, @@ -37,10 +47,8 @@ export function createVitePressPlugin( vue: userVuePluginOptions, vite: userViteConfig, pages - }: SiteConfig, - ssr = false, - pageToHashMap?: Record -): Plugin[] { + } = siteConfig + let markdownToVue: ( src: string, file: string, @@ -52,6 +60,15 @@ export function createVitePressPlugin( ...userVuePluginOptions }) + const processClientJS = (code: string, id: string) => { + return scriptClientRE.test(code) + ? code.replace(scriptClientRE, (_, content) => { + if (ssr && clientJSMap) clientJSMap[id] = content + return `\n`.repeat(_.split('\n').length - 1) + }) + : code + } + let siteData = site let hasDeadLinks = false let config: ResolvedConfig @@ -109,7 +126,9 @@ export function createVitePressPlugin( }, transform(code, id) { - if (id.endsWith('.md')) { + if (id.endsWith('.vue')) { + return processClientJS(code, id) + } else if (id.endsWith('.md')) { // transform .md files into vueSrc so plugin-vue can handle it const { vueSrc, deadLinks, includes } = markdownToVue( code, @@ -124,7 +143,7 @@ export function createVitePressPlugin( this.addWatchFile(i) }) } - return vueSrc + return processClientJS(vueSrc, id) } },