vite-plugin-react-pages
Version:
<p> <a href="https://www.npmjs.com/package/vite-plugin-react-pages" target="_blank" rel="noopener"><img src="https://img.shields.io/npm/v/vite-plugin-react-pages.svg" alt="npm package" /></a> </p>
257 lines (232 loc) • 8.45 kB
text/typescript
import { build as viteBuild } from 'vite'
import type { ResolvedConfig, Rollup } from 'vite'
import { minify } from 'html-minifier-terser'
import * as path from 'path'
import fs from 'fs-extra'
import { pathToFileURL } from 'node:url'
import { CLIENT_PATH } from '../constants'
import type { SSRPlugin } from '../../../clientTypes'
import type { staticSiteGenerationConfig } from '../types'
type RollupOutput = Rollup.RollupOutput
const minifyOptions = {
keepClosingSlash: true,
removeRedundantAttributes: true,
removeStyleLinkTypeAttributes: true,
useShortDoctype: true,
minifyCSS: true,
}
export async function ssrBuild(
viteConfig: ResolvedConfig,
argv: any,
ssrConfig?: staticSiteGenerationConfig
) {
// ssr build should not use hash router
// if (viteOptions?.define?.['__HASH_ROUTER__'])
// viteOptions!.define!['__HASH_ROUTER__'] = false
const root = viteConfig.root
let outDir = viteConfig.build?.outDir ?? 'dist'
outDir = path.resolve(root, outDir)
await fs.emptyDir(outDir)
const ssrOutDir = path.join(outDir, 'ssr-tmp')
const clientOutDir = path.join(outDir, 'client-tmp')
console.log('\n\npreparing vite pages ssr bundle...')
const ssrOutput = await viteBuild({
root,
configFile: viteConfig.configFile,
// mode: "development",
build: {
ssr: true,
cssCodeSplit: false,
rollupOptions: {
input: path.join(CLIENT_PATH, 'entries', 'ssg-server.mjs'),
// preserveEntrySignatures: 'allow-extension',
output: {
entryFileNames: '[name].mjs',
chunkFileNames: '[name]-[hash].mjs',
},
onwarn(warning, defaultHandler) {
// suppress warning like: /@react-pages/pages/guide/react/getting-started is dynamically imported by /@react-pages/pages but also statically imported by /@react-pages/ssrData, dynamic import will not move module into another chunk.
if (
warning.plugin === 'vite:reporter' &&
warning.message.includes('/@react-pages/ssrData') &&
warning.message.includes(
'dynamic import will not move module into another chunk'
)
)
return
defaultHandler(warning)
},
},
outDir: ssrOutDir,
minify: false,
},
ssr: {
// `vite-pages-theme-doc/dist/index.js` have `import './index.css'`
// so it needs to be bundled by vite before executed by node.js.
// This is coupled to theme-doc,
// but we don't want to ask users to put this in their vite config.
// So let's put it here :)
noExternal: ['vite-pages-theme-doc'],
},
})
console.log('\n\nrendering html...')
const ssrPluginPromises: Promise<SSRPlugin>[] = []
;(global as any)['register_vite_pages_ssr_plugin'] = (
importSSRPlugin: () => Promise<SSRPlugin>
) => {
ssrPluginPromises.push(importSSRPlugin())
}
process.env.VITE_PAGES_IS_SSR = 'true'
const { renderToString, ssrData } = await import(
pathToFileURL(path.join(ssrOutDir, 'ssg-server.mjs')).toString()
)
const ssrPlugins = await Promise.all(ssrPluginPromises)
ssrPlugins.forEach((plugin, index) => {
// validate ssr plugins
if (!plugin?.id) {
console.error('invalid ssr plugins:', ssrPlugins)
throw new Error('invalid ssr plugin: no plugin id')
}
const idx = ssrPlugins.findIndex((p) => p.id === plugin.id)
if (idx !== index) {
console.error('invalid ssr plugins:', ssrPlugins)
throw new Error(`duplicate ssr plugin: ${plugin.id}`)
}
})
const pagePaths = Object.keys(ssrData)
console.log('\n\npreparing vite pages client bundle...')
const _clientResult = await viteBuild({
root,
configFile: viteConfig.configFile,
build: {
cssCodeSplit: false,
rollupOptions: {
input: path.join(CLIENT_PATH, 'entries', 'ssg-client.mjs'),
preserveEntrySignatures: 'allow-extension',
},
assetsDir: 'assets',
outDir: clientOutDir,
},
})
let clientResult: RollupOutput
if (Array.isArray(_clientResult)) {
if (_clientResult.length !== 1)
throw new Error(`expect viteBuild to have only one BuildResult`)
clientResult = _clientResult[0]
} else {
clientResult = _clientResult as RollupOutput
}
const entryChunk = (() => {
const _entryChunks = clientResult.output.filter((chunkOrAsset) => {
return chunkOrAsset.type === 'chunk' && chunkOrAsset.isEntry
})
if (_entryChunks.length !== 1) {
throw new Error(`Expect one entryChunk. Got ${_entryChunks.length}.`)
}
return _entryChunks[0]
})()
const cssChunks = clientResult.output.filter((chunk) => {
return chunk.type === 'asset' && chunk.fileName.endsWith('.css')
})
const basePath = viteConfig.base ?? '/'
const htmlCode = await fs.readFile(path.join(root, 'index.html'), 'utf-8')
const RootElementInjectPoint = '<div id="root"></div>'
if (!htmlCode.includes(RootElementInjectPoint)) {
throw new Error(
`Your index.html should contain the RootElementInjectPoint: "${RootElementInjectPoint}" (it must appear exactly as-is)`
)
}
const EntryModuleInjectPoint =
'<script type="module" src="/@pages-infra/main.js"></script>'
if (!htmlCode.includes(EntryModuleInjectPoint)) {
throw new Error(
`Your index.html should contain EntryModuleInjectPoint: "${EntryModuleInjectPoint}" (it must appear exactly as-is)`
)
}
const CSSInjectPoint = '</head>'
if (!htmlCode.includes(CSSInjectPoint)) {
throw new Error(
`Your index.html should contain CSSInjectPoint: "${CSSInjectPoint}" (it must appear exactly as-is)`
)
}
await Promise.all(
pagePaths.map(async (pagePath) => {
// currently not support pages with path params
// .e.g /users/:userId
if (pagePath.match(/\/:\w/)) return
const html = await renderHTML(pagePath)
// TODO: injectPreload
// preload data module for this page
// html = injectPreload(html, "path/to/page/data")
const writePath = path.join(
clientOutDir,
pagePath.replace(/^\//, ''),
'index.html'
)
await fs.ensureDir(path.dirname(writePath))
await fs.writeFile(writePath, html)
if (pagePath !== '/') {
// should write to both /pagePath/index.html and /pagePath.html
const writePath2 = path.join(
clientOutDir,
pagePath.replace(/^\//, '') + '.html'
)
await fs.ensureDir(path.dirname(writePath2))
await fs.writeFile(writePath2, html)
}
})
)
const html404Path = path.join(clientOutDir, '404.html')
// pass in a pagePath that won't match any defined page
// so the render result will be 404 page
const html404 = await renderHTML('/internal-404-page')
await fs.writeFile(html404Path, html404)
// move 404 page to `/` if `/` doesn't exists
if (!pagePaths.includes('/')) {
await fs.copy(html404Path, path.join(clientOutDir, 'index.html'))
}
await fs.copy(clientOutDir, outDir)
await fs.remove(clientOutDir)
await fs.remove(ssrOutDir)
console.log('vite pages ssr build finished successfully.')
return
async function renderHTML(pagePath: string) {
const { contentText, styleText } = renderToString(pagePath, ssrPlugins)
const ssrInfo = {
routePath: pagePath,
}
let html = htmlCode.replace(
RootElementInjectPoint,
// let client know the current ssr page
`<script>window._vitePagesSSR=${JSON.stringify(ssrInfo)};</script>
<div id="root">${contentText}</div>`
)
const cssInject = cssChunks.map((cssChunk) => {
return `<link rel="stylesheet" href="${basePath}${cssChunk.fileName}" />`
})
cssInject.push(styleText)
html = html.replace(
CSSInjectPoint,
`${cssInject.join('\n')}
${CSSInjectPoint}`
)
html = html.replace(
EntryModuleInjectPoint,
`<script type="module" src="${basePath}${entryChunk.fileName}"></script>`
)
const minifyHtml = argv?.minifyHtml ?? ssrConfig?.minifyHtml ?? true
if (minifyHtml) {
const minifiedHtml = await minify(html, minifyOptions)
return minifiedHtml
}
return html
}
}
const injectPreload = (html: string, filePath: string) => {
const tag = `<link rel="modulepreload" href="${filePath}" />`
if (/<\/head>/.test(html)) {
return html.replace(/<\/head>/, `${tag}\n</head>`)
} else {
return tag + '\n' + html
}
}