polen
Version:
A framework for delightful GraphQL developer portals
262 lines (217 loc) • 8 kB
text/typescript
import type { Config } from '#api/config/index'
import { Content } from '#api/content/$'
import { polenVirtual } from '#api/vite/vi'
import type { Vite } from '#dep/vite/index'
import { reportDiagnostics } from '#lib/file-router/diagnostic-reporter'
import { FileRouter } from '#lib/file-router/index'
import { debugPolen } from '#singletons/debug'
import { superjson } from '#singletons/superjson'
import mdx from '@mdx-js/rollup'
import { Arr, Cache, Path, Str } from '@wollybeard/kit'
import { recmaCodeHike, remarkCodeHike } from 'codehike/mdx'
import remarkFrontmatter from 'remark-frontmatter'
import remarkGfm from 'remark-gfm'
import { viProjectData } from './core.js'
const debug = debugPolen.sub(`vite-pages`)
export const viProjectRoutes = polenVirtual([`project`, `routes.jsx`], { allowPluginProcessing: true })
export const viProjectPagesCatalog = polenVirtual([`project`, `data`, `pages-catalog.jsonsuper`], {
allowPluginProcessing: true,
})
export interface Options {
config: Config.Config
}
export interface ProjectPagesCatalog {
sidebarIndex: Content.SidebarIndex
pages: Content.Page[]
}
/**
* Pages plugin with tree support
*/
export const Pages = ({
config,
}: Options): Vite.Plugin[] => {
const scanPages = Cache.memoize(debug.trace(async function scanPages() {
const result = await Content.scan({
dir: config.paths.project.absolute.pages,
glob: `**/*.{md,mdx}`,
})
return result
}))
const invalidateVirtualModules = (server: Vite.ViteDevServer) => {
const routesModule = server.moduleGraph.getModuleById(viProjectRoutes.id)
if (routesModule) {
server.moduleGraph.invalidateModule(routesModule)
debug(`Invalidated routes virtual module`)
}
const catalogModule = server.moduleGraph.getModuleById(viProjectPagesCatalog.id)
if (catalogModule) {
server.moduleGraph.invalidateModule(catalogModule)
debug(`Invalidated pages catalog virtual module`)
}
const projectDataModule = server.moduleGraph.getModuleById(viProjectData.id)
if (projectDataModule) {
server.moduleGraph.invalidateModule(projectDataModule)
debug(`Invalidated project data virtual module`)
}
}
const generateRoutesModule = (pages: Content.Page[]): string => {
const $ = {
routes: `routes`,
}
const s = Str.Builder()
s`export const ${$.routes} = []`
// Generate imports and route objects
for (const { route, metadata } of pages) {
const filePathExp = Path.format(route.file.path.absolute)
const pathExp = FileRouter.routeToPathExpression(route)
const $$ = {
...$,
Component: Str.Case.camel(`page ` + Str.titlizeSlug(pathExp)),
}
s`
import ${$$.Component} from '${filePathExp}'
${$$.routes}.push({
path: '${pathExp}',
Component: ${$$.Component},
metadata: ${JSON.stringify(metadata)}
})
`
}
return s.render()
}
const chConfig = {
components: { code: 'CodeBlock' },
syntaxHighlighting: {
theme: `github-light`,
},
}
return [
// Plugin 1: MDX Processing
{
enforce: `pre`,
...mdx({
jsxImportSource: `polen/react`,
providerImportSource: `polen/mdx`,
remarkPlugins: [
// Parse frontmatter blocks so they're removed from content
remarkFrontmatter,
remarkGfm,
[remarkCodeHike, chConfig],
],
recmaPlugins: [
[recmaCodeHike, chConfig],
],
rehypePlugins: [],
}),
},
// Plugin 2: Pages Management
{
name: `polen:pages`,
// Dev server configuration
configureServer(server) {
// Add pages directory to watcher
debug(`configureServer: watch pages directory`, config.paths.project.absolute.pages)
server.watcher.add(config.paths.project.absolute.pages)
// Handle file additions and deletions
const handleFileStructureChange = async (file: string, event: `add` | `unlink`) => {
if (!Content.isPageFile(file, config.paths.project.absolute.pages)) return
debug(`Page file ${event === `add` ? `added` : `deleted`}:`, file)
// Clear cache and rescan
scanPages.clear()
const newScanResult = await scanPages()
// Invalidate virtual modules
invalidateVirtualModules(server)
// Report any diagnostics
reportDiagnostics(newScanResult.diagnostics)
// Trigger full reload to ensure routes are updated
server.ws.send({ type: `full-reload` })
}
server.watcher.on(`add`, (file) => handleFileStructureChange(file, `add`))
server.watcher.on(`unlink`, (file) => handleFileStructureChange(file, `unlink`))
},
// Hot update handling for existing files
async handleHotUpdate({ file, server, modules }) {
debug(`handleHotUpdate`, file)
if (!Content.isPageFile(file, config.paths.project.absolute.pages)) return
debug(`Page file changed:`, file)
// Get current pages before clearing cache
const oldPages = await scanPages()
// Clear cache and rescan
scanPages.clear()
const newScanResult = await scanPages()
// Check if the visible pages list changed. This can happen when:
// - A page's frontmatter `hidden` field changes (true <-> false)
// - A page's frontmatter affects its route (though we don't support this yet)
// If only the content changed (not frontmatter), we can use fast HMR.
const pageStructureChanged = !oldPages || !Arr.equalShallowly(
oldPages.list.map(p => Path.format(p.route.file.path.absolute)),
newScanResult.list.map(p => Path.format(p.route.file.path.absolute)),
)
if (!pageStructureChanged) {
debug(`Page content changed, allowing HMR`)
// Let default HMR handle the MDX file change
return modules
}
//
// ━━ Manual Invalidation
//
debug(`Page structure changed, triggering full reload`)
// Invalidate virtual modules and trigger reload
invalidateVirtualModules(server)
reportDiagnostics(newScanResult.diagnostics)
server.ws.send({ type: `full-reload` })
return []
},
resolveId(id) {
if (id === viProjectPagesCatalog.id) {
return viProjectPagesCatalog.resolved
}
},
load: {
async handler(id) {
if (id !== viProjectPagesCatalog.resolved) return
debug(`hook load`)
const scanResult = await scanPages()
reportDiagnostics(scanResult.diagnostics)
debug(`found visible`, { count: scanResult.list.length })
//
// ━━ Build Sidebar
//
const sidebarIndex = Content.buildSidebarIndex(scanResult)
//
// ━━ Put It All together
//
const projectPagesCatalog: ProjectPagesCatalog = {
sidebarIndex,
pages: scanResult.list,
}
// Return just the JSON string - let the JSON plugin handle the transformation
return superjson.stringify(projectPagesCatalog)
},
},
},
// Plugin 3: Virtual Module for React Router Routes
{
name: `polen:routes`,
resolveId(id) {
if (id === viProjectRoutes.id) {
return viProjectRoutes.resolved
}
},
load: {
handler: async (id) => {
if (id !== viProjectRoutes.resolved) return
debug(`Loading viProjectRoutes virtual module`)
const scanResult = await scanPages()
reportDiagnostics(scanResult.diagnostics)
const code = generateRoutesModule(scanResult.list)
// Generate the module code
return {
code,
moduleType: `js`,
}
},
},
},
]
}