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>
456 lines (432 loc) • 15.7 kB
text/typescript
import * as path from 'path'
import type { PluggableList, Pluggable } from 'unified'
import type {
Plugin,
IndexHtmlTransformContext,
PluginOption,
Rollup,
} from 'vite'
import type { staticSiteGenerationConfig } from './types'
import {
DefaultPageStrategy,
defaultFileHandler,
} from './page-strategy/DefaultPageStrategy'
import {
renderPageList,
renderPageListInSSR,
renderOnePageData,
renderAllPagesOutlines,
} from './page-strategy/pageUtils'
import { PageStrategy } from './page-strategy'
import { resolveTheme } from './virtual-module-plugins/theme'
import {
DemoModuleManager,
DemoMdxPlugin,
} from './virtual-module-plugins/demo-modules'
import {
TsInfoModuleManager,
TsInfoMdxPlugin,
} from './virtual-module-plugins/ts-info-module'
import { injectHTMLTag } from './utils/injectHTMLTag'
import { VirtualModulesManager } from './utils/virtual-module'
import { FileTextMdxPlugin } from './utils/mdx-plugin-file-text'
import {
OutlineInfoModuleManager,
OUTLINE_INFO_MODULE_ID_PREFIX,
} from './virtual-module-plugins/outline-info-module'
/**
* This is a public API that users use in their index.html.
* Changing this would introduce breaking change for users.
*/
const appEntryId = '/@pages-infra/main.js'
/**
* This is a private prefix and users should not use them directly
*/
const modulePrefix = '/@react-pages/'
const pagesModuleId = modulePrefix + 'pages'
const themeModuleId = modulePrefix + 'theme'
const ssrDataModuleId = modulePrefix + 'ssrData'
const allOutlineDataModuleId = modulePrefix + 'allPagesOutlines'
const tsInfoQueryReg = /\?tsInfo=(.*)$/
export interface PluginConfig {
pagesDir?: string
pageStrategy?: PageStrategy
useHashRouter?: boolean
staticSiteGeneration?: staticSiteGenerationConfig
/** user can add/remove remark plugins passed to mdx */
modifyRemarkPlugins?: ModifyNamedUnifiedPlugins
/** user can add/remove rehype plugins passed to mdx */
modifyRehypePlugins?: ModifyNamedUnifiedPlugins
}
export type NamedUnifiedPlugin = {
/** use name so that modifier can recognize a plugin */
name: string
createPlugin: () => Pluggable | Promise<Pluggable>
}
export type ModifyNamedUnifiedPlugins = (
original: NamedUnifiedPlugin[]
) => NamedUnifiedPlugin[]
function pluginFactory(opts: PluginConfig = {}): Plugin {
const { useHashRouter = false, staticSiteGeneration } = opts
let isBuild: boolean
let pagesDir: string
let pageStrategy: PageStrategy
/** used as data source for PageStrategy and other dynamic-modules */
const virtualModulesManager = new VirtualModulesManager()
const demoModuleManager = new DemoModuleManager()
const tsInfoModuleManager = new TsInfoModuleManager()
const outlineInfoModuleManager = new OutlineInfoModuleManager()
return {
name: 'vite-plugin-react-pages',
enforce: 'pre',
config: (config, env) => ({
optimizeDeps: {
include: [
'react',
// fix https://github.com/vitejs/vite-plugin-react-pages/issues/132#issuecomment-1536515395
'react/jsx-runtime',
'react-dom',
'react-dom/client',
'react-router-dom',
'@mdx-js/react',
],
exclude: ['vite-plugin-react-pages'],
},
define: {
__HASH_ROUTER__: !!useHashRouter,
'process.env.VITE_PAGES_IS_SSR': config.build?.ssr
? JSON.stringify('true')
: JSON.stringify('false'),
},
build: {
rollupOptions: {
output: {
manualChunks: undefined,
// local rollup's types may not be compatible with vite's rollup types
plugins: [outputPluginDisableJekyll() as any],
},
},
},
}),
async configResolved({ root, plugins, logger, command }) {
isBuild = command === 'build'
pagesDir = opts.pagesDir ?? path.resolve(root, 'pages')
if (opts.pageStrategy) {
pageStrategy = opts.pageStrategy
} else {
pageStrategy = new DefaultPageStrategy()
}
const mdxPlugin = plugins.find(
(plugin) => plugin.name === 'vite-plugin-mdx'
)
if (mdxPlugin) {
throw new Error(
'You should not use vite-plugin-mdx with vite-plugin-react-pages. vite-pages v5 has buildin plugin for mdx.'
)
}
},
configureServer({ watcher, moduleGraph }) {
const reloadVirtualModule = (moduleId: string) => {
const module = moduleGraph.getModuleById(moduleId)
if (module) {
moduleGraph.invalidateModule(module)
watcher.emit('change', moduleId)
}
}
pageStrategy
.on('page-list', () => reloadVirtualModule(pagesModuleId))
.on('page', (pageIds: string[]) => {
pageIds.forEach((pageId) => {
reloadVirtualModule(pagesModuleId + pageId)
})
})
demoModuleManager.onUpdate(reloadVirtualModule)
tsInfoModuleManager.onUpdate(reloadVirtualModule)
outlineInfoModuleManager.onUpdate(reloadVirtualModule)
},
buildStart() {
// buildStart may be called multiple times
// if the port has already been taken and vite retry with another port
// pageStrategy.start can't be put in configResolved
// because vite's resolveConfig will call configResolved without calling close hook
pageStrategy.start(pagesDir, virtualModulesManager)
},
async resolveId(id, importer) {
if (id === appEntryId) return id
if (id.startsWith(modulePrefix)) return id
if (id.endsWith('?demo')) {
const bareImport = id.slice(0, 0 - '?demo'.length)
const resolved = await this.resolve(bareImport, importer)
if (!resolved || resolved.external)
throw new Error(`can not resolve demo: ${id}. importer: ${importer}`)
return demoModuleManager.registerProxyModule(resolved.id)
}
if (id.endsWith('?outlineInfo')) {
const bareImport = id.slice(0, 0 - '?outlineInfo'.length)
const resolved = await this.resolve(bareImport, importer)
if (!resolved || resolved.external)
throw new Error(
`can not resolve outlineInfo: ${id}. importer: ${importer}`
)
return outlineInfoModuleManager.registerProxyModule(resolved.id)
}
const matchTsInfo = id.match(tsInfoQueryReg)
if (matchTsInfo) {
const bareImport = id.replace(tsInfoQueryReg, '')
const resolved = await this.resolve(bareImport, importer)
if (!resolved || resolved.external)
throw new Error(
`can not resolve tsInfo: ${id}. importer: ${importer}`
)
const exportName = matchTsInfo[1]
return tsInfoModuleManager.registerProxyModule(resolved.id, exportName)
}
return undefined
},
async load(id) {
// vite will resolve it with v=${versionHash} query
// so that this import can be cached
if (id === appEntryId)
return `import "vite-plugin-react-pages/dist/client-bundles/entries/csr.mjs";`
// page list
if (id === pagesModuleId) {
return renderPageList(await pageStrategy.getPages(), isBuild)
}
// one page data
if (id.startsWith(pagesModuleId + '/')) {
let pageId = id.slice(pagesModuleId.length)
if (pageId === '/index__') pageId = '/'
const page = await pageStrategy.getPage(pageId)
if (!page) {
throw Error(`Page not found: ${pageId}`)
}
return renderOnePageData(page.data)
}
if (id === themeModuleId) {
return `export { default } from "${await resolveTheme(pagesDir)}";`
}
if (id === ssrDataModuleId) {
return renderPageListInSSR(await pageStrategy.getPages())
}
if (demoModuleManager.isProxyModuleId(id)) {
return demoModuleManager.loadProxyModule(id)
}
if (outlineInfoModuleManager.isProxyModuleId(id)) {
return outlineInfoModuleManager.loadProxyModule(id)
}
if (id === allOutlineDataModuleId) {
return renderAllPagesOutlines(await pageStrategy.getPages())
}
if (tsInfoModuleManager.isProxyModuleId(id)) {
return tsInfoModuleManager.loadProxyModule(id)
}
},
closeBundle() {
virtualModulesManager.close()
demoModuleManager.close()
tsInfoModuleManager.close()
outlineInfoModuleManager.close()
},
transformIndexHtml(html, ctx) {
return moveScriptTagToBodyEnd(html, ctx)
},
// Read by the cli script to get staticSiteGeneration config
// @ts-expect-error
vitePagesStaticSiteGeneration: staticSiteGeneration,
}
}
export type {
Theme,
LoadState,
PagesLoaded,
PagesStaticData,
TsInfo,
TsPropertyOrMethodInfo,
} from '../../clientTypes'
export type { FileHandler } from './page-strategy/types.doc'
export { extractStaticData, File } from './utils/virtual-module'
export { PageStrategy }
export { DefaultPageStrategy, defaultFileHandler }
/**
* vite put script before style, which cause style problem for antd
* so we move the script tag to the end of the body
* https://github.com/vitejs/vite/blob/4112c5d103673b83c50d446096086617dfaac5a3/packages/vite/src/node/plugins/html.ts#L352
*/
function moveScriptTagToBodyEnd(
html: string,
ctx: IndexHtmlTransformContext
): string | undefined {
if (ctx.chunk) {
const reg = new RegExp(
`<script\\s[^>]*?${ctx.chunk.fileName}[^<]*?<\\/script>`
)
const match = html.match(reg)
if (match) {
const script = match[0]
html = html.replace(script, '')
return injectHTMLTag(html, script)
}
}
}
export default async function setupPlugins(
vpConfig: PluginConfig = {}
): Promise<PluginOption[]> {
// use dynamic import so that it supports node commonjs
const mdx = await import('@mdx-js/rollup')
const mdxPlugin = mdx.default({
remarkPlugins: await getRemarkPlugins(vpConfig.modifyRemarkPlugins),
rehypePlugins: await getRehypePlugins(vpConfig.modifyRehypePlugins),
// treat .md as mdx
mdExtensions: [],
mdxExtensions: ['.md', '.mdx'],
providerImportSource: '@mdx-js/react',
})
return [
{
...mdxPlugin,
enforce: 'pre',
},
createMdxTransformPlugin(),
pluginFactory(vpConfig),
]
}
function getRemarkPlugins(
modifyPlugins?: ModifyNamedUnifiedPlugins
): Promise<PluggableList> {
const originalPlugins: NamedUnifiedPlugin[] = [
{
name: 'remark-frontmatter',
// use dynamic import so that it works in node commonjs
// use lazy-eval function so that we don't import/create a plugin until we actually need it
// (it may be removed by modifyPlugins so we can avoid calling it)
createPlugin: () => import('remark-frontmatter').then((m) => m.default),
},
{
name: 'remark-gfm',
createPlugin: () => import('remark-gfm').then((m) => m.default),
},
{
name: 'remark-mdx-images',
createPlugin: () => import('remark-mdx-images').then((m) => m.default),
},
{
name: 'DemoMdxPlugin',
createPlugin: () => DemoMdxPlugin,
},
{
name: 'TsInfoMdxPlugin',
createPlugin: () => TsInfoMdxPlugin,
},
{
name: 'FileTextMdxPlugin',
createPlugin: () => FileTextMdxPlugin,
},
]
return createFinalPlugins(originalPlugins, modifyPlugins)
}
function getRehypePlugins(
modifyPlugins?: ModifyNamedUnifiedPlugins
): Promise<PluggableList> {
const originalPlugins: NamedUnifiedPlugin[] = [
{
name: 'rehype-slug',
// use dynamic import so that it works in node commonjs
// use lazy-eval function so that we don't import/create a plugin until we actually need it
// (it may be removed by modifyPlugins so we can avoid calling it)
createPlugin: () => import('rehype-slug').then((m) => m.default),
},
]
return createFinalPlugins(originalPlugins, modifyPlugins)
}
function createFinalPlugins(
originalPlugins: NamedUnifiedPlugin[],
modifyPlugins: ModifyNamedUnifiedPlugins | undefined
) {
const finalPlugins = (() => {
if (typeof modifyPlugins === 'function') {
const res = modifyPlugins(originalPlugins)
if (Array.isArray(res)) return res
}
return originalPlugins
})()
return Promise.all(finalPlugins.map(({ createPlugin }) => createPlugin()))
}
/**
* use @vitejs/plugin-react to transform the output of @mdx-js/rollup,
* adding react-refresh hmr ability to .md and .mdx files
* workaround this issue: https://github.com/vitejs/vite-plugin-react/issues/38
*/
function createMdxTransformPlugin(): Plugin {
let vitePluginReactTrasnform: Plugin['transform'] | undefined
const PLUGIN_NAME = 'vite-pages:mdx-fast-refresh'
return {
name: PLUGIN_NAME,
apply: 'serve',
configResolved: ({ plugins }) => {
// find this plugin to call it's transform function:
// https://github.com/vitejs/vite-plugin-react/blob/b647e74c38565696bd6fb931b8bd9ac7f3bebe88/packages/plugin-react/src/index.ts#L206
// or https://github.com/vitejs/vite-plugin-react-swc/blob/95e991914322e7b011d1c8d18d501b9eee21adaa/src/index.ts#L111
vitePluginReactTrasnform = plugins.find(
(p) =>
(p.name === 'vite:react-babel' &&
typeof p.transform === 'function') ||
(p.name === 'vite:react-swc' && typeof p.transform === 'function')
)?.transform
if (!vitePluginReactTrasnform) {
throw new Error(
`Can't find an instance of @vitejs/plugin-react or @vitejs/plugin-react-swc. You should apply either of these plugins to make mdx work.`
)
}
const reactSwcPluginIndex = plugins.findIndex(
(p) => p.name === 'vite:react-swc' && typeof p.transform === 'function'
)
const thisPluginIndex = plugins.findIndex((p) => p.name === PLUGIN_NAME)
if (
reactSwcPluginIndex !== -1 &&
thisPluginIndex !== -1 &&
reactSwcPluginIndex > thisPluginIndex
) {
throw new Error(
'[vite-plugin-react-pages]: @vitejs/plugin-react-swc should be placed before this plugin'
)
}
},
transform: (code, id, options) => {
const [filepath, ...qs] = id.split('?')
if (
filepath.match(/\.mdx?$/) &&
!id.startsWith(OUTLINE_INFO_MODULE_ID_PREFIX)
) {
const refreshContentRE = /\$Refresh(?:Reg|Sig)\$\(/
if (code.includes('/@react-refresh') && refreshContentRE.test(code)) {
// the mdx output has already been transformed by @vitejs/plugin-react-swc
// https://github.com/vitejs/vite-plugin-react-swc/blob/95e991914322e7b011d1c8d18d501b9eee21adaa/src/index.ts#L199C3-L199C3
// don't transform it again
return
}
// turn file path like `/path/to/md-file$.md` into `/path/to/md-file$.jsx`
// make vite-plugin-react transform "the output of @mdx-js/rollup" like a jsx file
// https://github.com/vitejs/vite-plugin-react/blob/caa9b5330092c70288fcb94ceb96ca42438df2a2/packages/plugin-react/src/index.ts#L170
const newFilePath = filepath.replace(/\.mdx?$/, '.jsx')
const newId = [newFilePath, ...qs].join('?')
return (vitePluginReactTrasnform as any)(code, newId, options)
}
},
}
}
/**
* Some chunk filenames may start with `_`, which will be treated as special resource by github pages. So we need to disable jekyll of github pages.
* https://github.blog/2009-12-29-bypassing-jekyll-on-github-pages/
*/
function outputPluginDisableJekyll(): Rollup.OutputPlugin {
return {
name: 'vite-pages-disable-jekyll',
generateBundle() {
this.emitFile({
type: 'asset',
fileName: '.nojekyll',
source: '',
})
},
}
}