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.
vitepress/src/node/build/render.ts

256 lines
7.2 KiB

import fs from 'fs-extra'
import path from 'path'
import { pathToFileURL } from 'url'
import escape from 'escape-html'
import { normalizePath, transformWithEsbuild } from 'vite'
import { RollupOutput, OutputChunk, OutputAsset } from 'rollup'
import {
HeadConfig,
PageData,
createTitle,
notFoundPageData,
mergeHead,
EXTERNAL_URL_RE,
sanitizeFileName
} from '../shared'
import { slash } from '../utils/slash'
import { SiteConfig, resolveSiteDataByRoute } from '../config'
export async function renderPage(
render: (path: string) => Promise<string>,
config: SiteConfig,
page: string, // foo.md
result: RollupOutput | null,
appChunk: OutputChunk | undefined,
cssChunk: OutputAsset | undefined,
pageToHashMap: Record<string, string>,
hashMapString: string
) {
const routePath = `/${page.replace(/\.md$/, '')}`
const siteData = resolveSiteDataByRoute(config.site, routePath)
// render page
const content = await render(routePath)
const pageName = sanitizeFileName(page.replace(/\//g, '_'))
// server build doesn't need hash
const pageServerJsFileName = pageName + '.js'
// 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.toLowerCase()]
const pageClientJsFileName = `assets/${pageName}.${pageHash}.lean.js`
let pageData: PageData
let hasCustom404 = true
try {
// resolve page data so we can render head tags
const { __pageData } = await import(
pathToFileURL(path.join(config.tempDir, pageServerJsFileName)).toString()
)
pageData = __pageData
} catch (e) {
if (page === '404.md') {
hasCustom404 = false
pageData = notFoundPageData
} else {
throw e
}
}
let preloadLinks =
config.mpa || (!hasCustom404 && page === '404.md')
? appChunk
? [appChunk.fileName]
: []
: result && appChunk
? [
...new Set([
// 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 to wait for entry chunks to parse
...resolvePageImports(config, page, result, appChunk),
pageClientJsFileName,
appChunk.fileName
])
]
: []
let prefetchLinks: string[] = []
const { shouldPreload } = config
if (shouldPreload) {
prefetchLinks = preloadLinks.filter((link) => !shouldPreload(link, page))
preloadLinks = preloadLinks.filter((link) => shouldPreload(link, page))
}
const preloadLinksString = preloadLinks
.map((file) => {
return `<link rel="modulepreload" href="${
EXTERNAL_URL_RE.test(file) ? '' : siteData.base // don't add base to external urls
}${file}">`
})
.join('\n ')
const prefetchLinkString = prefetchLinks
.map((file) => {
return `<link rel="prefetch" href="${
EXTERNAL_URL_RE.test(file) ? '' : siteData.base // don't add base to external urls
}${file}">`
})
.join('\n ')
const stylesheetLink = cssChunk
? `<link rel="stylesheet" href="${siteData.base}${cssChunk.fileName}">`
: ''
const title: string = createTitle(siteData, pageData)
const description: string = pageData.description || siteData.description
const headBeforeTransform = mergeHead(
siteData.head,
filterOutHeadDescription(pageData.frontmatter.head)
)
const head = mergeHead(
headBeforeTransform,
(await config.transformHead?.({
siteConfig: config,
siteData,
pageData,
title,
description,
head: headBeforeTransform,
content
})) || []
)
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 = `
<!DOCTYPE html>
<html lang="${siteData.lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title}</title>
<meta name="description" content="${description}">
${stylesheetLink}
${preloadLinksString}
${prefetchLinkString}
${await renderHead(head)}
</head>
<body>
<div id="app">${content}</div>
${
config.mpa
? ''
: `<script>__VP_HASH_MAP__ = JSON.parse(${hashMapString})</script>`
}
${
appChunk
? `<script type="module" async src="${siteData.base}${appChunk.fileName}"></script>`
: ``
}
${inlinedScript}
</body>
</html>`.trim()
const createSubDirectory =
config.cleanUrls === 'with-subfolders' &&
!/(^|\/)(index|404).md$/.test(page)
const htmlFileName = path.join(
config.outDir,
page.replace(/\.md$/, createSubDirectory ? '/index.html' : '.html')
)
await fs.ensureDir(path.dirname(htmlFileName))
const transformedHtml = await config.transformHtml?.(html, htmlFileName, {
siteConfig: config,
siteData,
pageData,
title,
description,
head,
content
})
await fs.writeFile(htmlFileName, transformedHtml || html)
}
function resolvePageImports(
config: SiteConfig,
page: string,
result: RollupOutput,
appChunk: OutputChunk
) {
// find the page's js chunk and inject script tags for its imports so that
// they start fetching as early as possible
const srcPath = normalizePath(
fs.realpathSync(path.resolve(config.srcDir, page))
)
const pageChunk = result.output.find(
(chunk) => chunk.type === 'chunk' && chunk.facadeModuleId === srcPath
) as OutputChunk
return [
...appChunk.imports,
...appChunk.dynamicImports,
...pageChunk.imports,
...pageChunk.dynamicImports
]
}
function renderHead(head: HeadConfig[]): Promise<string> {
return Promise.all(
head.map(async ([tag, attrs = {}, innerHTML = '']) => {
const openTag = `<${tag}${renderAttrs(attrs)}>`
if (tag !== 'link' && tag !== 'meta') {
if (
tag === 'script' &&
(attrs.type === undefined || attrs.type.includes('javascript'))
) {
innerHTML = (
await transformWithEsbuild(innerHTML, 'inline-script.js', {
minify: true
})
).code.trim()
}
return `${openTag}${innerHTML}</${tag}>`
} else {
return openTag
}
})
).then((tags) => tags.join('\n '))
}
function renderAttrs(attrs: Record<string, string>): string {
return Object.keys(attrs)
.map((key) => {
return ` ${key}="${escape(attrs[key])}"`
})
.join('')
}
function isMetaDescription(headConfig: HeadConfig) {
const [type, attrs] = headConfig
return type === 'meta' && attrs?.name === 'description'
}
function filterOutHeadDescription(head: HeadConfig[] | undefined) {
return head ? head.filter((h) => !isMetaDescription(h)) : []
}