import { isBooleanAttr } from '@vue/shared' import { mkdir, realpath, rm, writeFile } from 'node:fs/promises' import path from 'node:path' import { minify, normalizePath, type Rolldown } from 'vite' import { version } from '../../../package.json' import type { SiteConfig } from '../config' import { EXTERNAL_URL_RE, createTitle, escapeHtml, mergeHead, notFoundPageData, resolveSiteDataByRoute, sanitizeFileName, type HeadConfig, type PageData, type SSGContext } from '../shared' import { nativeImport } from '../utils/nativeImport' export interface PageChunkInfo { fileName: string code: string } export interface RenderMetadata { appChunk?: { fileName: string; imports: string[] } cssChunk?: { fileName: string } assets: string[] isDefaultTheme: boolean pageImports: Map pageChunks: Map } export interface SerializedRenderMetadata extends Omit< RenderMetadata, 'pageImports' | 'pageChunks' > { pageImports: [string, string[]][] pageChunks: [string, PageChunkInfo][] } /** * The output of Vue SSR before VitePress runs user hooks and writes HTML. * * Keeping this boundary free of SiteConfig allows lightweight render workers * to return their result to the coordinator, where closure-bearing build hooks * can run without resolving the user's config again. */ export interface RenderedPage { page: string pageData: PageData hasCustom404: boolean context: SSGContext } export type SerializedSSGContext = Omit & { vpSocialIcons: string[] } export interface SerializedRenderedPage extends Omit { context: SerializedSSGContext } export function createRenderMetadata( config: SiteConfig, clientResult: Rolldown.RolldownOutput | null | undefined, serverResult: Rolldown.RolldownOutput | null | undefined ): RenderMetadata { const clientOutput = clientResult?.output ?? [] const assetOutput = (config.mpa ? serverResult : clientResult)?.output ?? [] const cssChunk = assetOutput.find( (chunk): chunk is Rolldown.OutputAsset => chunk.type === 'asset' && chunk.fileName.endsWith('.css') ) const assets = assetOutput .filter( (chunk): chunk is Rolldown.OutputAsset => chunk.type === 'asset' && !chunk.fileName.endsWith('.css') ) .map((asset) => config.site.base + asset.fileName) 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 pageImports = new Map() const pageChunks = new Map() let appChunk: RenderMetadata['appChunk'] for (const chunk of clientOutput) { if (chunk.type !== 'chunk') continue if (!appChunk && chunk.isEntry && chunk.facadeModuleId?.endsWith('.js')) { appChunk = { fileName: chunk.fileName, imports: [...chunk.imports] } } if (!chunk.isEntry || !chunk.facadeModuleId?.endsWith('.md')) { continue } const facadeModuleId = normalizePath(chunk.facadeModuleId) if (config.mpa) { pageChunks.set(facadeModuleId, { fileName: chunk.fileName, code: chunk.code }) } else { pageImports.set(facadeModuleId, [...chunk.imports]) } } return { appChunk, cssChunk: cssChunk ? { fileName: cssChunk.fileName } : undefined, assets, isDefaultTheme, pageImports, pageChunks } } export function serializeRenderMetadata( metadata: RenderMetadata ): SerializedRenderMetadata { return { ...metadata, pageImports: [...metadata.pageImports], pageChunks: [...metadata.pageChunks] } } export function deserializeRenderMetadata( metadata: SerializedRenderMetadata ): RenderMetadata { return { ...metadata, pageImports: new Map(metadata.pageImports), pageChunks: new Map(metadata.pageChunks) } } export async function renderPage( render: (path: string) => Promise, config: SiteConfig, page: string, // foo.md renderMetadata: RenderMetadata, pageToHashMap: Record, metadataScript: { html: string; inHead: boolean }, additionalHeadTags: HeadConfig[], usedIcons: Set, serverTempDir = config.tempDir ) { const pageName = sanitizeFileName(page.replace(/\//g, '_')) const renderedPage = await renderPageToResult( render, page, path.join(serverTempDir, pageName + '.js') ) await finalizeRenderedPage( renderedPage, config, renderMetadata, pageToHashMap, metadataScript, additionalHeadTags, usedIcons ) } /** * Render a page and load its page data without invoking user build hooks or * writing to the final output directory. */ export async function renderPageToResult( render: (path: string) => Promise, page: string, pageModulePath: string ): Promise { const routePath = `/${page.replace(/\.md$/, '')}` const context = await render(routePath) let pageData: PageData let hasCustom404 = true try { const { __pageData } = await nativeImport(pageModulePath) pageData = __pageData } catch (e) { if (page === '404.md') { hasCustom404 = false pageData = notFoundPageData } else { throw e } } return { page, pageData, hasCustom404, context } } /** Convert Set-backed SSR state into a transport-friendly representation. */ export function serializeRenderedPage( renderedPage: RenderedPage ): SerializedRenderedPage { return { ...renderedPage, context: { ...renderedPage.context, vpSocialIcons: [...renderedPage.context.vpSocialIcons].sort() } } } /** Restore the SSR context shape expected by postRender and the finalizer. */ export function deserializeRenderedPage( renderedPage: SerializedRenderedPage ): RenderedPage { return { ...renderedPage, context: { ...renderedPage.context, content: renderedPage.context.content, vpSocialIcons: new Set(renderedPage.context.vpSocialIcons) } } } /** * Run coordinator-owned hooks and emit one final HTML page. */ export async function finalizeRenderedPage( renderedPage: RenderedPage, config: SiteConfig, renderMetadata: RenderMetadata, pageToHashMap: Record, metadataScript: { html: string; inHead: boolean }, additionalHeadTags: HeadConfig[], usedIcons: Set ) { const { page, pageData, hasCustom404 } = renderedPage const context = (await config.postRender?.(renderedPage.context)) ?? renderedPage.context const { content, teleports, vpSocialIcons } = context const { appChunk, cssChunk, assets, pageImports, pageChunks } = renderMetadata vpSocialIcons.forEach((icon) => usedIcons.add(icon)) const pageName = sanitizeFileName(page.replace(/\//g, '_')) // 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 = `${config.assetsDir}/${pageName}.${pageHash}.lean.js` const siteData = resolveSiteDataByRoute(config.site, page, pageData.filePath) const title: string = createTitle(siteData, pageData) const description: string = pageData.description || siteData.description const stylesheetLink = cssChunk ? `` : '' let preloadLinks = config.mpa || (!hasCustom404 && page === '404.md') ? [] : 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 ...(await resolvePageImports( config, page, pageImports, appChunk )), pageClientJsFileName ]) ] : [] let prefetchLinks: string[] = [] const { shouldPreload } = config if (shouldPreload) { prefetchLinks = preloadLinks.filter((link) => !shouldPreload(link, page)) preloadLinks = preloadLinks.filter((link) => shouldPreload(link, page)) } const toHeadTags = (files: string[], rel: string): HeadConfig[] => files.map((file) => [ 'link', { rel, // don't add base to external urls href: (EXTERNAL_URL_RE.test(file) ? '' : siteData.base) + file } ]) const preloadHeadTags = toHeadTags(preloadLinks, 'modulepreload') const prefetchHeadTags = toHeadTags(prefetchLinks, 'prefetch') const headBeforeTransform = [ ...additionalHeadTags, ...preloadHeadTags, ...prefetchHeadTags, ...mergeHead( siteData.head, filterOutHeadDescription(pageData.frontmatter.head) ) ] const head = mergeHead( headBeforeTransform, (await config.transformHead?.({ page, siteConfig: config, siteData, pageData, title, description, head: headBeforeTransform, content, assets })) || [] ) let inlinedScript = '' if (config.mpa) { const matchingChunk = pageChunks.get( normalizePath(path.join(config.srcDir, page)) ) if (matchingChunk) { if (!matchingChunk.code.includes('import')) { inlinedScript = `` await rm(path.resolve(config.outDir, matchingChunk.fileName), { force: true }) } else { inlinedScript = `` } } } const dir = pageData.frontmatter.dir || siteData.dir || 'ltr' const html = ` ${ isMetaViewportOverridden(head) ? '' : '' } ${escapeHtml(title)} ${ isDescriptionOverridden(head) ? '' : `` } ${stylesheetLink} ${metadataScript.inHead ? metadataScript.html : ''} ${ appChunk ? `` : '' } ${await renderHead(head)} ${teleports?.body || ''}
${page === '404.md' ? '' : content}
${metadataScript.inHead ? '' : metadataScript.html} ${inlinedScript} ` const htmlFileName = path.join(config.outDir, page.replace(/\.md$/, '.html')) await mkdir(path.dirname(htmlFileName), { recursive: true }) const transformedHtml = await config.transformHtml?.(html, htmlFileName, { page, siteConfig: config, siteData, pageData, title, description, head, content, assets }) await writeFile(htmlFileName, transformedHtml || html) } async function resolvePageImports( config: SiteConfig, page: string, pageImports: Map, appChunk: { fileName: string; imports: string[] } ) { page = config.rewrites.inv[page] || page // find the page's js chunk and inject script tags for its imports so that // they start fetching as early as possible let srcPath = path.resolve(config.srcDir, page) try { if (!config.vite?.resolve?.preserveSymlinks) { srcPath = await realpath(srcPath) } } catch (e) { // if the page is a virtual page generated by a dynamic route this would // fail, which is expected } srcPath = normalizePath(srcPath) const imports = pageImports.get(srcPath) || [] return [ ...appChunk.imports, // ...appChunk.dynamicImports, ...imports // ...pageChunk.dynamicImports ] } async function renderHead(head: HeadConfig[]): Promise { const tags = await 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 minify('inline-script.js', innerHTML)).code } return `${openTag}${innerHTML}` } else { return openTag } }) ) return tags.join('\n ') } function renderAttrs(attrs: Record): string { return Object.keys(attrs) .map((key) => { if (isBooleanAttr(key)) return ` ${key}` return ` ${key}="${escapeHtml(attrs[key] as string)}"` }) .join('') } function filterOutHeadDescription(head: HeadConfig[] = []) { return head.filter(([type, attrs]) => { return !(type === 'meta' && attrs?.name === 'description') }) } function isDescriptionOverridden(head: HeadConfig[] = []) { return head.some(([type, attrs]) => { return type === 'meta' && attrs?.name === 'description' }) } function isMetaViewportOverridden(head: HeadConfig[] = []) { return head.some(([type, attrs]) => { return type === 'meta' && attrs?.name === 'viewport' }) }