mirror of https://github.com/vuejs/vitepress
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
244 lines
7.2 KiB
244 lines
7.2 KiB
import { getIconsCSS } from '@iconify/utils'
|
|
import { createHash } from 'node:crypto'
|
|
import fs from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
import path from 'node:path'
|
|
import pMap from 'p-map'
|
|
import { packageDirectorySync } from 'package-directory'
|
|
import type { BuildOptions, Rolldown } from 'vite'
|
|
import { resolveConfig, type SiteConfig } from '../config'
|
|
import { clearCache } from '../markdownToVue'
|
|
import { slash, type Awaitable, type HeadConfig } from '../shared'
|
|
import { deserializeFunctions, serializeFunctions } from '../utils/fnSerialize'
|
|
import { nativeImport } from '../utils/nativeImport'
|
|
import { task } from '../utils/task'
|
|
import { bundle } from './bundle'
|
|
import { generateSitemap } from './generateSitemap'
|
|
import { renderPage } from './render'
|
|
|
|
const require = createRequire(import.meta.url)
|
|
|
|
export async function build(
|
|
root?: string,
|
|
buildOptions: BuildOptions & {
|
|
base?: string
|
|
mpa?: string
|
|
onAfterConfigResolve?: (siteConfig: SiteConfig) => Awaitable<void>
|
|
} = {}
|
|
) {
|
|
const start = Date.now()
|
|
|
|
process.env.NODE_ENV = 'production'
|
|
const siteConfig = await resolveConfig(root, 'build', 'production')
|
|
|
|
await buildOptions.onAfterConfigResolve?.(siteConfig)
|
|
delete buildOptions.onAfterConfigResolve
|
|
|
|
const unlinkVue = linkVue()
|
|
|
|
if (buildOptions.base) {
|
|
siteConfig.site.base = buildOptions.base
|
|
delete buildOptions.base
|
|
}
|
|
|
|
if (buildOptions.mpa) {
|
|
siteConfig.mpa = true
|
|
delete buildOptions.mpa
|
|
}
|
|
|
|
if (buildOptions.outDir) {
|
|
siteConfig.outDir = path.resolve(process.cwd(), buildOptions.outDir)
|
|
delete buildOptions.outDir
|
|
}
|
|
|
|
try {
|
|
const { clientResult, serverResult, pageToHashMap } = await bundle(
|
|
siteConfig,
|
|
buildOptions
|
|
)
|
|
|
|
if (process.env.BUNDLE_ONLY) {
|
|
return
|
|
}
|
|
|
|
const entryPath = path.join(siteConfig.tempDir, 'app.js')
|
|
const { render } = await nativeImport(entryPath)
|
|
|
|
await task('rendering pages', async () => {
|
|
const clientOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] =
|
|
clientResult?.output || []
|
|
|
|
const appChunk = clientOutput.find(
|
|
(chunk): chunk is Rolldown.OutputChunk =>
|
|
chunk.type === 'chunk' &&
|
|
chunk.isEntry &&
|
|
!!chunk.facadeModuleId?.endsWith('.js')
|
|
)
|
|
|
|
const isDefaultTheme = clientOutput.some(
|
|
(chunk): chunk is Rolldown.OutputChunk =>
|
|
chunk.type === 'chunk' &&
|
|
chunk.name === 'theme' &&
|
|
chunk.moduleIds.some((id) => id.includes('client/theme-default'))
|
|
)
|
|
|
|
// ----
|
|
|
|
const resultOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] =
|
|
(siteConfig.mpa ? serverResult : clientResult)?.output || []
|
|
|
|
const cssChunk = resultOutput.find(
|
|
(chunk): chunk is Rolldown.OutputAsset =>
|
|
chunk.type === 'asset' && chunk.fileName.endsWith('.css')
|
|
)
|
|
|
|
// prettier-ignore
|
|
const assets = resultOutput.filter(
|
|
(chunk): chunk is Rolldown.OutputAsset =>
|
|
chunk.type === 'asset' && !chunk.fileName.endsWith('.css')
|
|
).map((asset) => siteConfig.site.base + asset.fileName)
|
|
|
|
// ----
|
|
|
|
const additionalHeadTags: HeadConfig[] = []
|
|
const metadataScript = generateMetadataScript(pageToHashMap, siteConfig)
|
|
|
|
if (isDefaultTheme) {
|
|
const fontURL = assets.find((file) =>
|
|
/inter-roman-latin\.[\w-]+\.woff2/.test(file)
|
|
)
|
|
if (fontURL) {
|
|
additionalHeadTags.push([
|
|
'link',
|
|
{
|
|
rel: 'preload',
|
|
href: fontURL,
|
|
as: 'font',
|
|
type: 'font/woff2',
|
|
crossorigin: ''
|
|
}
|
|
])
|
|
}
|
|
}
|
|
|
|
const usedIcons = new Set<string>()
|
|
|
|
await pMap(
|
|
['404.md', ...siteConfig.pages],
|
|
async (page) => {
|
|
await renderPage(
|
|
render,
|
|
siteConfig,
|
|
siteConfig.rewrites.map[page] || page,
|
|
clientResult,
|
|
appChunk,
|
|
cssChunk,
|
|
assets,
|
|
pageToHashMap,
|
|
metadataScript,
|
|
additionalHeadTags,
|
|
usedIcons
|
|
)
|
|
},
|
|
{ concurrency: siteConfig.buildConcurrency }
|
|
)
|
|
|
|
const icons = require('@iconify-json/simple-icons/icons.json')
|
|
const iconsCss = getIconsCSS(icons, Array.from(usedIcons).sort(), {
|
|
iconSelector: '.vpi-social-{name}',
|
|
commonSelector: '.vpi-social',
|
|
varName: 'icon',
|
|
format: process.env.DEBUG ? 'expanded' : 'compressed',
|
|
mode: 'mask'
|
|
}).replace(/[^]*?}\n*/, '')
|
|
|
|
fs.writeFileSync(path.join(siteConfig.outDir, 'vp-icons.css'), iconsCss)
|
|
})
|
|
|
|
// emit page hash map for the case where a user session is open
|
|
// when the site got redeployed (which invalidates current hash map)
|
|
fs.writeFileSync(
|
|
path.join(siteConfig.outDir, 'hashmap.json'),
|
|
JSON.stringify(pageToHashMap)
|
|
)
|
|
} finally {
|
|
unlinkVue()
|
|
if (!process.env.DEBUG) {
|
|
fs.rmSync(siteConfig.tempDir, {
|
|
recursive: true,
|
|
force: true,
|
|
maxRetries: 10
|
|
})
|
|
}
|
|
}
|
|
|
|
await generateSitemap(siteConfig)
|
|
await siteConfig.buildEnd?.(siteConfig)
|
|
clearCache()
|
|
|
|
siteConfig.logger.info(
|
|
`build complete in ${((Date.now() - start) / 1000).toFixed(2)}s.`
|
|
)
|
|
}
|
|
|
|
function linkVue() {
|
|
const root = packageDirectorySync()
|
|
if (root) {
|
|
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.mkdirSync(path.dirname(dest), { recursive: true })
|
|
fs.symlinkSync(src, dest, 'junction')
|
|
return () => {
|
|
fs.unlinkSync(dest)
|
|
}
|
|
}
|
|
}
|
|
return () => {}
|
|
}
|
|
|
|
function generateMetadataScript(
|
|
pageToHashMap: Record<string, string>,
|
|
config: SiteConfig
|
|
) {
|
|
if (config.mpa) {
|
|
return { html: '', inHead: false }
|
|
}
|
|
|
|
// We embed the hash map and site config strings into each page directly
|
|
// so that it doesn't alter the main chunk's hash on every build.
|
|
// It's also embedded as a string and JSON.parsed from the client because
|
|
// it's faster than embedding as JS object literal.
|
|
const hashMapString = JSON.stringify(JSON.stringify(pageToHashMap))
|
|
const siteDataString = JSON.stringify(
|
|
JSON.stringify(serializeFunctions({ ...config.site, head: [] }))
|
|
)
|
|
|
|
const metadataContent = `window.__VP_HASH_MAP__=JSON.parse(${hashMapString});${
|
|
siteDataString.includes('_vp-fn_')
|
|
? `${deserializeFunctions};window.__VP_SITE_DATA__=deserializeFunctions(JSON.parse(${siteDataString}));`
|
|
: `window.__VP_SITE_DATA__=JSON.parse(${siteDataString});`
|
|
}`
|
|
|
|
const metadataFile = path.join(
|
|
config.assetsDir,
|
|
'chunks',
|
|
`metadata.${createHash('sha256')
|
|
.update(metadataContent)
|
|
.digest('hex')
|
|
.slice(0, 8)}.js`
|
|
)
|
|
|
|
const resolvedMetadataFile = path.join(config.outDir, metadataFile)
|
|
const metadataFileURL = slash(`${config.site.base}${metadataFile}`)
|
|
|
|
fs.mkdirSync(path.dirname(resolvedMetadataFile), { recursive: true })
|
|
fs.writeFileSync(resolvedMetadataFile, metadataContent)
|
|
|
|
return {
|
|
html: `<script type="module" src="${metadataFileURL}"></script>`,
|
|
inHead: true
|
|
}
|
|
}
|