feat: support `<script client>` in mpa mode

pull/395/head
Evan You 3 years ago
parent b94b163a3a
commit e0b69973f8

@ -21,7 +21,7 @@ export async function build(
} }
try { try {
const [clientResult, serverResult, pageToHashMap] = await bundle( const { clientResult, serverResult, pageToHashMap } = await bundle(
siteConfig, siteConfig,
buildOptions buildOptions
) )
@ -36,7 +36,9 @@ export async function build(
(chunk) => chunk.type === 'chunk' && chunk.isEntry (chunk) => chunk.type === 'chunk' && chunk.isEntry
) as OutputChunk) ) as OutputChunk)
const cssChunk = (clientResult || serverResult).output.find( const cssChunk = (
siteConfig.mpa ? serverResult : clientResult
).output.find(
(chunk) => chunk.type === 'asset' && chunk.fileName.endsWith('.css') (chunk) => chunk.type === 'asset' && chunk.fileName.endsWith('.css')
) as OutputAsset ) as OutputAsset

@ -0,0 +1,45 @@
import { build } from 'vite'
import { SiteConfig } from '..'
const virtualEntry = 'client.js'
export async function buildMPAClient(
js: Record<string, string>,
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]
}
}
}
]
})
}

@ -7,6 +7,7 @@ import { SiteConfig } from '../config'
import { RollupOutput } from 'rollup' import { RollupOutput } from 'rollup'
import { build, BuildOptions, UserConfig as ViteUserConfig } from 'vite' import { build, BuildOptions, UserConfig as ViteUserConfig } from 'vite'
import { createVitePressPlugin } from '../plugin' import { createVitePressPlugin } from '../plugin'
import { buildMPAClient } from './buildMPAClient'
export const okMark = '\x1b[32m✓\x1b[0m' export const okMark = '\x1b[32m✓\x1b[0m'
export const failMark = '\x1b[31m✖\x1b[0m' export const failMark = '\x1b[31m✖\x1b[0m'
@ -15,9 +16,14 @@ export const failMark = '\x1b[31m✖\x1b[0m'
export async function bundle( export async function bundle(
config: SiteConfig, config: SiteConfig,
options: BuildOptions options: BuildOptions
): Promise<[RollupOutput, RollupOutput, Record<string, string>]> { ): Promise<{
clientResult: RollupOutput
serverResult: RollupOutput
pageToHashMap: Record<string, string>
}> {
const { root, srcDir } = config const { root, srcDir } = config
const pageToHashMap = Object.create(null) const pageToHashMap = Object.create(null)
const clientJSMap = Object.create(null)
// define custom rollup input // define custom rollup input
// this is a multi-entry build - every page is considered an entry chunk // this is a multi-entry build - every page is considered an entry chunk
@ -39,7 +45,13 @@ export async function bundle(
root: srcDir, root: srcDir,
base: config.site.base, base: config.site.base,
logLevel: 'warn', logLevel: 'warn',
plugins: createVitePressPlugin(root, config, ssr, pageToHashMap), plugins: createVitePressPlugin(
root,
config,
ssr,
pageToHashMap,
clientJSMap
),
// @ts-ignore // @ts-ignore
ssr: { ssr: {
noExternal: ['vitepress'] noExternal: ['vitepress']
@ -112,9 +124,13 @@ export async function bundle(
if (fs.existsSync(publicDir)) { if (fs.existsSync(publicDir)) {
await fs.copy(publicDir, config.outDir) await fs.copy(publicDir, config.outDir)
} }
// build <script client> bundle
if (Object.keys(clientJSMap).length) {
clientResult = (await buildMPAClient(clientJSMap, config)) as RollupOutput
}
} }
return [clientResult, serverResult, pageToHashMap] return { clientResult, serverResult, pageToHashMap }
} }
const adComponentRE = /(?:Carbon|BuySell)Ads/ const adComponentRE = /(?:Carbon|BuySell)Ads/

@ -4,15 +4,16 @@ import { SiteConfig, resolveSiteDataByRoute } from '../config'
import { HeadConfig } from '../shared' import { HeadConfig } from '../shared'
import { normalizePath } from 'vite' import { normalizePath } from 'vite'
import { RollupOutput, OutputChunk, OutputAsset } from 'rollup' import { RollupOutput, OutputChunk, OutputAsset } from 'rollup'
import { slash } from '../utils/slash'
const escape = require('escape-html') const escape = require('escape-html')
export async function renderPage( export async function renderPage(
config: SiteConfig, config: SiteConfig,
page: string, // foo.md page: string, // foo.md
result: RollupOutput, result: RollupOutput | null,
appChunk: OutputChunk, appChunk: OutputChunk | undefined,
cssChunk: OutputAsset, cssChunk: OutputAsset | undefined,
pageToHashMap: Record<string, string>, pageToHashMap: Record<string, string>,
hashMapString: string hashMapString: string
) { ) {
@ -39,9 +40,13 @@ export async function renderPage(
)) ))
const pageData = JSON.parse(__pageData) const pageData = JSON.parse(__pageData)
const preloadLinks = config.mpa const preloadLinks = (
? '' config.mpa
: [ ? appChunk
? [appChunk.fileName]
: []
: result && appChunk
? [
// resolve imports for index.js + page.md.js and inject script tags for // 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 // them as well so we fetch everything as early as possible without having
// to wait for entry chunks to parse // to wait for entry chunks to parse
@ -49,6 +54,8 @@ export async function renderPage(
pageClientJsFileName, pageClientJsFileName,
appChunk.fileName appChunk.fileName
] ]
: []
)
.map((file) => { .map((file) => {
return `<link rel="modulepreload" href="${siteData.base}${file}">` return `<link rel="modulepreload" href="${siteData.base}${file}">`
}) })
@ -69,6 +76,23 @@ export async function renderPage(
...filterOutHeadDescription(pageData.frontmatter.head) ...filterOutHeadDescription(pageData.frontmatter.head)
) )
let inlinedScript = ''
if (config.mpa && result) {
const matchingChunk = result.output.find(
(chunk) =>
chunk.type === 'chunk' &&
chunk.facadeModuleId === slash(path.join(config.srcDir, page))
) as OutputChunk
if (matchingChunk) {
if (!matchingChunk.code.includes('import')) {
inlinedScript = `<script type="module">${matchingChunk.code}</script>`
fs.removeSync(path.resolve(config.outDir, matchingChunk.fileName))
} else {
inlinedScript = `<script type="module" src="${siteData.base}${matchingChunk.fileName}"></script>`
}
}
}
const html = ` const html = `
<!DOCTYPE html> <!DOCTYPE html>
<html lang="${siteData.lang}"> <html lang="${siteData.lang}">
@ -87,10 +111,16 @@ export async function renderPage(
<div id="app">${content}</div> <div id="app">${content}</div>
${ ${
config.mpa config.mpa
? `` ? ''
: `<script>__VP_HASH_MAP__ = JSON.parse(${hashMapString})</script>` + : `<script>__VP_HASH_MAP__ = JSON.parse(${hashMapString})</script>`
`<script type="module" async src="${siteData.base}${appChunk.fileName}"></script>` }
}</body> ${
appChunk
? `<script type="module" async src="${siteData.base}${appChunk.fileName}"></script>`
: ``
}
${inlinedScript}
</body>
</html>`.trim() </html>`.trim()
const htmlFileName = path.join(config.outDir, page.replace(/\.md$/, '.html')) const htmlFileName = path.join(config.outDir, page.replace(/\.md$/, '.html'))
await fs.ensureDir(path.dirname(htmlFileName)) await fs.ensureDir(path.dirname(htmlFileName))

@ -45,6 +45,7 @@ export interface UserConfig<ThemeConfig = any> {
/** /**
* Enable MPA / zero-JS mode * Enable MPA / zero-JS mode
* @experimental
*/ */
mpa?: boolean mpa?: boolean
} }

@ -133,6 +133,7 @@ export function createMarkdownToVueRenderFn(
const scriptRE = /<\/script>/ const scriptRE = /<\/script>/
const scriptSetupRE = /<\s*script[^>]*\bsetup\b[^>]*/ const scriptSetupRE = /<\s*script[^>]*\bsetup\b[^>]*/
const scriptClientRe = /<\s*script[^>]*\bclient\b[^>]*/
const defaultExportRE = /((?:^|\n|;)\s*)export(\s*)default/ const defaultExportRE = /((?:^|\n|;)\s*)export(\s*)default/
const namedDefaultExportRE = /((?:^|\n|;)\s*)export(.+)as(\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) => { 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) { if (existingScriptIndex > -1) {

@ -16,6 +16,11 @@ const staticInjectMarkerRE =
const staticStripRE = /__VP_STATIC_START__.*?__VP_STATIC_END__/g const staticStripRE = /__VP_STATIC_START__.*?__VP_STATIC_END__/g
const staticRestoreRE = /__VP_STATIC_(START|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 = /<script\b[^>]*client\b[^>]*>([^]*?)<\/script>/
const isPageChunk = ( const isPageChunk = (
chunk: OutputAsset | OutputChunk chunk: OutputAsset | OutputChunk
): chunk is OutputChunk & { facadeModuleId: string } => ): chunk is OutputChunk & { facadeModuleId: string } =>
@ -28,7 +33,12 @@ const isPageChunk = (
export function createVitePressPlugin( export function createVitePressPlugin(
root: string, root: string,
{ siteConfig: SiteConfig,
ssr = false,
pageToHashMap?: Record<string, string>,
clientJSMap?: Record<string, string>
): Plugin[] {
const {
srcDir, srcDir,
configPath, configPath,
alias, alias,
@ -37,10 +47,8 @@ export function createVitePressPlugin(
vue: userVuePluginOptions, vue: userVuePluginOptions,
vite: userViteConfig, vite: userViteConfig,
pages pages
}: SiteConfig, } = siteConfig
ssr = false,
pageToHashMap?: Record<string, string>
): Plugin[] {
let markdownToVue: ( let markdownToVue: (
src: string, src: string,
file: string, file: string,
@ -52,6 +60,15 @@ export function createVitePressPlugin(
...userVuePluginOptions ...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 siteData = site
let hasDeadLinks = false let hasDeadLinks = false
let config: ResolvedConfig let config: ResolvedConfig
@ -109,7 +126,9 @@ export function createVitePressPlugin(
}, },
transform(code, id) { 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 // transform .md files into vueSrc so plugin-vue can handle it
const { vueSrc, deadLinks, includes } = markdownToVue( const { vueSrc, deadLinks, includes } = markdownToVue(
code, code,
@ -124,7 +143,7 @@ export function createVitePressPlugin(
this.addWatchFile(i) this.addWatchFile(i)
}) })
} }
return vueSrc return processClientJS(vueSrc, id)
} }
}, },

Loading…
Cancel
Save