feat: dynamic routes plugin overhaul (#4525)

BREAKING CHANGES: Internals are modified a bit to better support vite 6 and handle HMR more correctly. For most users this won't need any change on their side.
pull/4600/head
Divyansh Singh 7 months ago committed by GitHub
parent d1f2afdf0f
commit a62ea6a832
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,8 +1,14 @@
export default { import { defineRoutes } from 'vitepress'
async paths() { import paths from './paths'
return [
{ params: { id: 'foo' }, content: `# Foo` }, export default defineRoutes({
{ params: { id: 'bar' }, content: `# Bar` } async paths(watchedFiles: string[]) {
] // console.log('watchedFiles', watchedFiles)
return paths
},
watch: ['**/data-loading/**/*.json'],
async transformPageData(pageData) {
// console.log('transformPageData', pageData.filePath)
pageData.title += ' - transformed'
} }
} })

@ -0,0 +1,4 @@
export default [
{ params: { id: 'foo' }, content: `# Foo` },
{ params: { id: 'bar' }, content: `# Bar` }
]

@ -0,0 +1,72 @@
import { ModuleGraph } from 'node/utils/moduleGraph'
describe('node/utils/moduleGraph', () => {
let graph: ModuleGraph
beforeEach(() => {
graph = new ModuleGraph()
})
it('should correctly delete a module and its dependents', () => {
graph.add('A', ['B', 'C'])
graph.add('B', ['D'])
graph.add('C', [])
graph.add('D', [])
expect(graph.delete('D')).toEqual(new Set(['D', 'B', 'A']))
})
it('should handle shared dependencies correctly', () => {
graph.add('A', ['B', 'C'])
graph.add('B', ['D'])
graph.add('C', ['D']) // Shared dependency
graph.add('D', [])
expect(graph.delete('D')).toEqual(new Set(['A', 'B', 'C', 'D']))
})
it('merges dependencies correctly', () => {
// Add module A with dependency B
graph.add('A', ['B'])
// Merge new dependency C into module A (B should remain)
graph.add('A', ['C'])
// Deleting B should remove A as well, since A depends on B.
expect(graph.delete('B')).toEqual(new Set(['B', 'A']))
})
it('handles cycles gracefully', () => {
// Create a cycle: A -> B, B -> C, C -> A.
graph.add('A', ['B'])
graph.add('B', ['C'])
graph.add('C', ['A'])
// Deleting any module in the cycle should delete all modules in the cycle.
expect(graph.delete('A')).toEqual(new Set(['A', 'B', 'C']))
})
it('cleans up dependencies when deletion', () => {
// Setup A -> B relationship.
graph.add('A', ['B'])
graph.add('B', [])
// Deleting B should remove both B and A from the graph.
expect(graph.delete('B')).toEqual(new Set(['B', 'A']))
// After deletion, add modules again.
graph.add('C', [])
graph.add('A', ['C']) // Now A depends only on C.
expect(graph.delete('C')).toEqual(new Set(['C', 'A']))
})
it('handles independent modules', () => {
// Modules with no dependencies.
graph.add('X', [])
graph.add('Y', [])
// Deletion of one should only remove that module.
expect(graph.delete('X')).toEqual(new Set(['X']))
expect(graph.delete('Y')).toEqual(new Set(['Y']))
})
})

@ -62,7 +62,7 @@ export const siteDataRef: Ref<SiteData> = shallowRef(
// hmr // hmr
if (import.meta.hot) { if (import.meta.hot) {
import.meta.hot.accept('/@siteData', (m) => { import.meta.hot.accept('@siteData', (m) => {
if (m) { if (m) {
siteDataRef.value = m.default siteDataRef.value = m.default
} }

@ -46,7 +46,7 @@ const searchIndexData = shallowRef(localSearchIndex)
// hmr // hmr
if (import.meta.hot) { if (import.meta.hot) {
import.meta.hot.accept('/@localSearchIndex', (m) => { import.meta.hot.accept('@localSearchIndex', (m) => {
if (m) { if (m) {
searchIndexData.value = m.default searchIndexData.value = m.default
} }

@ -97,20 +97,12 @@ export async function resolveConfig(
? userThemeDir ? userThemeDir
: DEFAULT_THEME_PATH : DEFAULT_THEME_PATH
const { pages, dynamicRoutes, rewrites } = await resolvePages(
srcDir,
userConfig,
logger
)
const config: SiteConfig = { const config: SiteConfig = {
root, root,
srcDir, srcDir,
assetsDir, assetsDir,
site, site,
themeDir, themeDir,
pages,
dynamicRoutes,
configPath, configPath,
configDeps, configDeps,
outDir, outDir,
@ -135,10 +127,10 @@ export async function resolveConfig(
transformHead: userConfig.transformHead, transformHead: userConfig.transformHead,
transformHtml: userConfig.transformHtml, transformHtml: userConfig.transformHtml,
transformPageData: userConfig.transformPageData, transformPageData: userConfig.transformPageData,
rewrites,
userConfig, userConfig,
sitemap: userConfig.sitemap, sitemap: userConfig.sitemap,
buildConcurrency: userConfig.buildConcurrency ?? 64 buildConcurrency: userConfig.buildConcurrency ?? 64,
...(await resolvePages(srcDir, userConfig, logger))
} }
// to be shared with content loaders // to be shared with content loaders

@ -5,6 +5,11 @@ export * from './contentLoader'
export * from './init/init' export * from './init/init'
export * from './markdown/markdown' export * from './markdown/markdown'
export { defineLoader, type LoaderModule } from './plugins/staticDataPlugin' export { defineLoader, type LoaderModule } from './plugins/staticDataPlugin'
export {
defineRoutes,
type ResolvedRouteConfig,
type RouteModule
} from './plugins/dynamicRoutesPlugin'
export * from './postcss/isolateStyles' export * from './postcss/isolateStyles'
export * from './serve/serve' export * from './serve/serve'
export * from './server' export * from './server'

@ -11,7 +11,7 @@ import fs from 'fs-extra'
import template from 'lodash.template' import template from 'lodash.template'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { bold, cyan, yellow } from 'picocolors' import c from 'picocolors'
import { slash } from '../shared' import { slash } from '../shared'
export enum ScaffoldThemeType { export enum ScaffoldThemeType {
@ -38,7 +38,7 @@ const getPackageManger = () => {
} }
export async function init(root?: string) { export async function init(root?: string) {
intro(bold(cyan('Welcome to VitePress!'))) intro(c.bold(c.cyan('Welcome to VitePress!')))
const options = await group( const options = await group(
{ {
@ -232,7 +232,7 @@ export function scaffold({
const gitignorePrefix = root ? `${slash(root)}/.vitepress` : '.vitepress' const gitignorePrefix = root ? `${slash(root)}/.vitepress` : '.vitepress'
if (fs.existsSync('.git')) { if (fs.existsSync('.git')) {
tips.push( tips.push(
`Make sure to add ${cyan(`${gitignorePrefix}/dist`)} and ${cyan(`${gitignorePrefix}/cache`)} to your ${cyan(`.gitignore`)} file.` `Make sure to add ${c.cyan(`${gitignorePrefix}/dist`)} and ${c.cyan(`${gitignorePrefix}/cache`)} to your ${c.cyan(`.gitignore`)} file.`
) )
} }
@ -242,11 +242,11 @@ export function scaffold({
!userPkg.devDependencies?.['vue'] !userPkg.devDependencies?.['vue']
) { ) {
tips.push( tips.push(
`Since you've chosen to customize the theme, you should also explicitly install ${cyan(`vue`)} as a dev dependency.` `Since you've chosen to customize the theme, you should also explicitly install ${c.cyan(`vue`)} as a dev dependency.`
) )
} }
const tip = tips.length ? yellow([`\n\nTips:`, ...tips].join('\n- ')) : `` const tip = tips.length ? c.yellow([`\n\nTips:`, ...tips].join('\n- ')) : ``
const dir = root ? ' ' + root : '' const dir = root ? ' ' + root : ''
const pm = getPackageManger() const pm = getPackageManger()
@ -261,8 +261,8 @@ export function scaffold({
Object.assign(userPkg.scripts || (userPkg.scripts = {}), scripts) Object.assign(userPkg.scripts || (userPkg.scripts = {}), scripts)
fs.writeFileSync(pkgPath, JSON.stringify(userPkg, null, 2)) fs.writeFileSync(pkgPath, JSON.stringify(userPkg, null, 2))
return `Done! Now run ${cyan(`${pm} run ${prefix}dev`)} and start writing.${tip}` return `Done! Now run ${c.cyan(`${pm} run ${prefix}dev`)} and start writing.${tip}`
} else { } else {
return `You're all set! Now run ${cyan(`${pm === 'npm' ? 'npx' : pm} vitepress dev${dir}`)} and start writing.${tip}` return `You're all set! Now run ${c.cyan(`${pm === 'npm' ? 'npx' : pm} vitepress dev${dir}`)} and start writing.${tip}`
} }
} }

@ -9,6 +9,7 @@ import {
type MarkdownOptions, type MarkdownOptions,
type MarkdownRenderer type MarkdownRenderer
} from './markdown/markdown' } from './markdown/markdown'
import { getPageDataTransformer } from './plugins/dynamicRoutesPlugin'
import { import {
EXTERNAL_URL_RE, EXTERNAL_URL_RE,
getLocaleForPath, getLocaleForPath,
@ -31,25 +32,62 @@ export interface MarkdownCompileResult {
includes: string[] includes: string[]
} }
export function clearCache(file?: string) { export function clearCache(id?: string) {
if (!file) { if (!id) {
cache.clear() cache.clear()
return return
} }
file = JSON.stringify({ file }).slice(1) id = JSON.stringify({ id }).slice(1)
cache.find((_, key) => key.endsWith(file!) && cache.delete(key)) cache.find((_, key) => key.endsWith(id!) && cache.delete(key))
}
let __pages: string[] = []
let __dynamicRoutes = new Map<string, [string, string]>()
let __rewrites = new Map<string, string>()
let __ts: number
function getResolutionCache(siteConfig: SiteConfig) {
// @ts-expect-error internal
if (siteConfig.__dirty) {
__pages = siteConfig.pages.map((p) => slash(p.replace(/\.md$/, '')))
__dynamicRoutes = new Map(
siteConfig.dynamicRoutes.map((r) => [
r.fullPath,
[slash(path.join(siteConfig.srcDir, r.route)), r.loaderPath]
])
)
__rewrites = new Map(
Object.entries(siteConfig.rewrites.map).map(([key, value]) => [
slash(path.join(siteConfig.srcDir, key)),
slash(path.join(siteConfig.srcDir, value!))
])
)
__ts = Date.now()
// @ts-expect-error internal
siteConfig.__dirty = false
}
return {
pages: __pages,
dynamicRoutes: __dynamicRoutes,
rewrites: __rewrites,
ts: __ts
}
} }
export async function createMarkdownToVueRenderFn( export async function createMarkdownToVueRenderFn(
srcDir: string, srcDir: string,
options: MarkdownOptions = {}, options: MarkdownOptions = {},
pages: string[],
isBuild = false, isBuild = false,
base = '/', base = '/',
includeLastUpdatedData = false, includeLastUpdatedData = false,
cleanUrls = false, cleanUrls = false,
siteConfig: SiteConfig | null = null siteConfig: SiteConfig
) { ) {
const md = await createMarkdownRenderer( const md = await createMarkdownRenderer(
srcDir, srcDir,
@ -58,32 +96,30 @@ export async function createMarkdownToVueRenderFn(
siteConfig?.logger siteConfig?.logger
) )
pages = pages.map((p) => slash(p.replace(/\.md$/, '')))
const dynamicRoutes = new Map(
siteConfig?.dynamicRoutes?.routes.map((r) => [
r.fullPath,
slash(path.join(srcDir, r.route))
]) || []
)
const rewrites = new Map(
Object.entries(siteConfig?.rewrites.map || {}).map(([key, value]) => [
slash(path.join(srcDir, key)),
slash(path.join(srcDir, value!))
]) || []
)
return async ( return async (
src: string, src: string,
file: string, file: string,
publicDir: string publicDir: string
): Promise<MarkdownCompileResult> => { ): Promise<MarkdownCompileResult> => {
const fileOrig = dynamicRoutes.get(file) || file const { pages, dynamicRoutes, rewrites, ts } =
getResolutionCache(siteConfig)
const dynamicRoute = dynamicRoutes.get(file)
const fileOrig = dynamicRoute?.[0] || file
const transformPageData = [
siteConfig?.transformPageData,
getPageDataTransformer(dynamicRoute?.[1]!)
].filter((fn) => fn != null)
file = rewrites.get(file) || file file = rewrites.get(file) || file
const relativePath = slash(path.relative(srcDir, file)) const relativePath = slash(path.relative(srcDir, file))
const cacheKey = JSON.stringify({ src, file: relativePath }) const cacheKey = JSON.stringify({
src,
ts,
file: relativePath,
id: fileOrig
})
if (isBuild || options.cache !== false) { if (isBuild || options.cache !== false) {
const cached = cache.get(cacheKey) const cached = cache.get(cacheKey)
if (cached) { if (cached) {
@ -205,14 +241,14 @@ export async function createMarkdownToVueRenderFn(
} }
} }
if (siteConfig?.transformPageData) { for (const fn of transformPageData) {
const dataToMerge = await siteConfig.transformPageData(pageData, { if (fn) {
siteConfig const dataToMerge = await fn(pageData, { siteConfig })
}) if (dataToMerge) {
if (dataToMerge) { pageData = {
pageData = { ...pageData,
...pageData, ...dataToMerge
...dataToMerge }
} }
} }
} }
@ -318,10 +354,7 @@ const inferDescription = (frontmatter: Record<string, any>) => {
return (head && getHeadMetaContent(head, 'description')) || '' return (head && getHeadMetaContent(head, 'description')) || ''
} }
const getHeadMetaContent = ( const getHeadMetaContent = (head: HeadConfig[], name: string) => {
head: HeadConfig[],
name: string
): string | undefined => {
if (!head || !head.length) { if (!head || !head.length) {
return undefined return undefined
} }

@ -3,7 +3,6 @@ import c from 'picocolors'
import { import {
mergeConfig, mergeConfig,
searchForWorkspaceRoot, searchForWorkspaceRoot,
type ModuleNode,
type Plugin, type Plugin,
type ResolvedConfig, type ResolvedConfig,
type Rollup, type Rollup,
@ -96,7 +95,7 @@ export async function createVitePressPlugin(
// lazy require plugin-vue to respect NODE_ENV in @vue/compiler-x // lazy require plugin-vue to respect NODE_ENV in @vue/compiler-x
const vuePlugin = await import('@vitejs/plugin-vue').then((r) => const vuePlugin = await import('@vitejs/plugin-vue').then((r) =>
r.default({ r.default({
include: [/\.vue$/, /\.md$/], include: /\.(?:vue|md)$/,
...userVuePluginOptions, ...userVuePluginOptions,
template: { template: {
...userVuePluginOptions?.template, ...userVuePluginOptions?.template,
@ -130,7 +129,6 @@ export async function createVitePressPlugin(
markdownToVue = await createMarkdownToVueRenderFn( markdownToVue = await createMarkdownToVueRenderFn(
srcDir, srcDir,
markdown, markdown,
siteConfig.pages,
config.command === 'build', config.command === 'build',
config.base, config.base,
lastUpdated, lastUpdated,
@ -197,9 +195,7 @@ export async function createVitePressPlugin(
} }
} }
data = serializeFunctions(data) data = serializeFunctions(data)
return `${deserializeFunctions};export default deserializeFunctions(JSON.parse(${JSON.stringify( return `${deserializeFunctions};export default deserializeFunctions(JSON.parse(${JSON.stringify(JSON.stringify(data))}))`
JSON.stringify(data)
)}))`
} }
}, },
@ -208,7 +204,7 @@ export async function createVitePressPlugin(
return processClientJS(code, id) return processClientJS(code, id)
} else if (id.endsWith('.md')) { } else if (id.endsWith('.md')) {
// transform .md files into vueSrc so plugin-vue can handle it // transform .md files into vueSrc so plugin-vue can handle it
const { vueSrc, deadLinks, includes } = await markdownToVue( const { vueSrc, deadLinks, includes, pageData } = await markdownToVue(
code, code,
id, id,
config.publicDir config.publicDir
@ -220,6 +216,22 @@ export async function createVitePressPlugin(
this.addWatchFile(i) this.addWatchFile(i)
}) })
} }
if (
this.environment.mode === 'dev' &&
this.environment.name === 'client'
) {
const relativePath = path.posix.relative(srcDir, id)
const payload: PageDataPayload = {
path: `/${siteConfig.rewrites.map[relativePath] || relativePath}`,
pageData
}
// notify the client to update page data
this.environment.hot.send({
type: 'custom',
event: 'vitepress:pageData',
data: payload
})
}
return processClientJS(vueSrc, id) return processClientJS(vueSrc, id)
} }
}, },
@ -256,9 +268,7 @@ export async function createVitePressPlugin(
if (themeRE.test(file)) { if (themeRE.test(file)) {
siteConfig.logger.info( siteConfig.logger.info(
c.green( c.green(
`${path.relative(process.cwd(), _file)} ${ `${path.relative(process.cwd(), _file)} ${added ? 'created' : 'deleted'}, restarting server...\n`
added ? 'created' : 'deleted'
}, restarting server...\n`
), ),
{ clear: true, timestamp: true } { clear: true, timestamp: true }
) )
@ -368,15 +378,13 @@ export async function createVitePressPlugin(
} }
}, },
async handleHotUpdate(ctx) { async hotUpdate({ file }) {
const { file, read, server } = ctx if (this.environment.name !== 'client') return
if (file === configPath || configDeps.includes(file)) { if (file === configPath || configDeps.includes(file)) {
siteConfig.logger.info( siteConfig.logger.info(
c.green( c.green(
`${path.relative( `${path.relative(process.cwd(), file)} changed, restarting server...\n`
process.cwd(),
file
)} changed, restarting server...\n`
), ),
{ clear: true, timestamp: true } { clear: true, timestamp: true }
) )
@ -393,47 +401,23 @@ export async function createVitePressPlugin(
await recreateServer?.() await recreateServer?.()
return return
} }
// hot reload .md files as .vue files
if (file.endsWith('.md')) {
const content = await read()
const { pageData, vueSrc } = await markdownToVue(
content,
file,
config.publicDir
)
const relativePath = slash(path.relative(srcDir, file))
const payload: PageDataPayload = {
path: `/${siteConfig.rewrites.map[relativePath] || relativePath}`,
pageData
}
// notify the client to update page data
server.ws.send({
type: 'custom',
event: 'vitepress:pageData',
data: payload
})
// overwrite src so vue plugin can handle the HMR
ctx.read = () => vueSrc
}
} }
} }
const hmrFix: Plugin = { const hmrFix: Plugin = {
name: 'vitepress:hmr-fix', name: 'vitepress:hmr-fix',
async handleHotUpdate({ file, server, modules }) { async hotUpdate({ file, modules }) {
if (this.environment.name !== 'client') return
const importers = [...(importerMap[slash(file)] || [])] const importers = [...(importerMap[slash(file)] || [])]
if (importers.length > 0) { if (importers.length > 0) {
return [ return [
...modules, ...modules,
...importers.map((id) => { ...importers.map((id) => {
clearCache(slash(path.relative(srcDir, id))) clearCache(id)
return server.moduleGraph.getModuleById(id) return this.environment.moduleGraph.getModuleById(id)
}) })
].filter(Boolean) as ModuleNode[] ].filter((mod) => mod !== undefined)
} }
} }
} }

@ -1,24 +1,77 @@
import fs from 'fs-extra' import fs from 'fs-extra'
import path from 'node:path' import path from 'node:path'
import c from 'picocolors' import c from 'picocolors'
import { isMatch } from 'picomatch'
import { glob } from 'tinyglobby' import { glob } from 'tinyglobby'
import { import {
loadConfigFromFile, loadConfigFromFile,
normalizePath, normalizePath,
type EnvironmentModuleNode,
type Logger, type Logger,
type Plugin, type Plugin
type ViteDevServer
} from 'vite' } from 'vite'
import type { Awaitable } from '../shared'
import { type SiteConfig, type UserConfig } from '../siteConfig' import { type SiteConfig, type UserConfig } from '../siteConfig'
import { ModuleGraph } from '../utils/moduleGraph'
import { resolveRewrites } from './rewritesPlugin' import { resolveRewrites } from './rewritesPlugin'
export const dynamicRouteRE = /\[(\w+?)\]/g interface UserRouteConfig {
params: Record<string, string>
content?: string
}
export type ResolvedRouteConfig = UserRouteConfig & {
/**
* the raw route (relative to src root), e.g. foo/[bar].md
*/
route: string
/**
* the actual path with params resolved (relative to src root), e.g. foo/1.md
*/
path: string
/**
* absolute fs path
*/
fullPath: string
/**
* the path to the paths loader module
*/
loaderPath: string
}
export interface RouteModule {
watch?: string[] | string
paths:
| UserRouteConfig[]
| ((watchedFiles: string[]) => Awaitable<UserRouteConfig[]>)
transformPageData?: UserConfig['transformPageData']
}
interface ResolvedRouteModule {
watch: string[] | undefined
routes: ResolvedRouteConfig[] | undefined
loader: RouteModule['paths']
transformPageData?: RouteModule['transformPageData']
}
const dynamicRouteRE = /\[(\w+?)\]/g
const pathLoaderRE = /\.paths\.m?[jt]s$/
const routeModuleCache = new Map<string, ResolvedRouteModule>()
let moduleGraph = new ModuleGraph()
/**
* Helper for defining routes with type inference
*/
export function defineRoutes(loader: RouteModule) {
return loader
}
export async function resolvePages( export async function resolvePages(
srcDir: string, srcDir: string,
userConfig: UserConfig, userConfig: UserConfig,
logger: Logger logger: Logger
) { ): Promise<Pick<SiteConfig, 'pages' | 'dynamicRoutes' | 'rewrites'>> {
// Important: tinyglobby doesn't guarantee order of the returned files. // Important: tinyglobby doesn't guarantee order of the returned files.
// We must sort the pages so the input list to rollup is stable across // We must sort the pages so the input list to rollup is stable across
// builds - otherwise different input order could result in different exports // builds - otherwise different input order could result in different exports
@ -26,7 +79,7 @@ export async function resolvePages(
// JavaScript built-in sort() is mandated to be stable as of ES2019 and // JavaScript built-in sort() is mandated to be stable as of ES2019 and
// supported in Node 12+, which is required by Vite. // supported in Node 12+, which is required by Vite.
const allMarkdownFiles = ( const allMarkdownFiles = (
await glob(['**.md'], { await glob(['**/*.md'], {
cwd: srcDir, cwd: srcDir,
ignore: [ ignore: [
'**/node_modules/**', '**/node_modules/**',
@ -50,67 +103,32 @@ export async function resolvePages(
dynamicRouteFiles, dynamicRouteFiles,
logger logger
) )
pages.push(...dynamicRoutes.routes.map((r) => r.path))
pages.push(...dynamicRoutes.map((r) => r.path))
const rewrites = resolveRewrites(pages, userConfig.rewrites) const rewrites = resolveRewrites(pages, userConfig.rewrites)
return { return {
pages, pages,
dynamicRoutes, dynamicRoutes,
rewrites rewrites,
// @ts-expect-error internal flag to reload resolution cache in ../markdownToVue.ts
__dirty: true
} }
} }
interface UserRouteConfig {
params: Record<string, string>
content?: string
}
interface RouteModule {
path: string
config: {
paths:
| UserRouteConfig[]
| (() => UserRouteConfig[] | Promise<UserRouteConfig[]>)
}
dependencies: string[]
}
const routeModuleCache = new Map<string, RouteModule>()
export type ResolvedRouteConfig = UserRouteConfig & {
/**
* the raw route (relative to src root), e.g. foo/[bar].md
*/
route: string
/**
* the actual path with params resolved (relative to src root), e.g. foo/1.md
*/
path: string
/**
* absolute fs path
*/
fullPath: string
}
export const dynamicRoutesPlugin = async ( export const dynamicRoutesPlugin = async (
config: SiteConfig config: SiteConfig
): Promise<Plugin> => { ): Promise<Plugin> => {
let server: ViteDevServer
return { return {
name: 'vitepress:dynamic-routes', name: 'vitepress:dynamic-routes',
configureServer(_server) {
server = _server
},
resolveId(id) { resolveId(id) {
if (!id.endsWith('.md')) return if (!id.endsWith('.md')) return
const normalizedId = id.startsWith(config.srcDir) const normalizedId = id.startsWith(config.srcDir)
? id ? id
: normalizePath(path.resolve(config.srcDir, id.replace(/^\//, ''))) : normalizePath(path.resolve(config.srcDir, id.replace(/^\//, '')))
const matched = config.dynamicRoutes.routes.find( const matched = config.dynamicRoutes.find(
(r) => r.fullPath === normalizedId (r) => r.fullPath === normalizedId
) )
if (matched) { if (matched) {
@ -119,11 +137,13 @@ export const dynamicRoutesPlugin = async (
}, },
load(id) { load(id) {
const matched = config.dynamicRoutes.routes.find((r) => r.fullPath === id) const matched = config.dynamicRoutes.find((r) => r.fullPath === id)
if (matched) { if (matched) {
const { route, params, content } = matched const { route, params, content } = matched
const routeFile = normalizePath(path.resolve(config.srcDir, route)) const routeFile = normalizePath(path.resolve(config.srcDir, route))
config.dynamicRoutes.fileToModulesMap[routeFile].add(id)
moduleGraph.add(id, [routeFile])
moduleGraph.add(routeFile, [matched.loaderPath])
let baseContent = fs.readFileSync(routeFile, 'utf-8') let baseContent = fs.readFileSync(routeFile, 'utf-8')
@ -139,39 +159,65 @@ export const dynamicRoutesPlugin = async (
} }
// params are injected with special markers and extracted as part of // params are injected with special markers and extracted as part of
// __pageData in ../markdownTovue.ts // __pageData in ../markdownToVue.ts
return `__VP_PARAMS_START${JSON.stringify( return `__VP_PARAMS_START${JSON.stringify(params)}__VP_PARAMS_END__${baseContent}`
params
)}__VP_PARAMS_END__${baseContent}`
} }
}, },
async handleHotUpdate(ctx) { async hotUpdate({ file, modules: existingMods }) {
routeModuleCache.delete(ctx.file) if (this.environment.name !== 'client') return
const mods = config.dynamicRoutes.fileToModulesMap[ctx.file]
if (mods) { const modules: EnvironmentModuleNode[] = []
// path loader module or deps updated, reset loaded routes const normalizedFile = normalizePath(file)
if (!ctx.file.endsWith('.md')) {
Object.assign( // Trigger update if a module or its dependencies changed.
config, for (const id of moduleGraph.delete(normalizedFile)) {
await resolvePages(config.srcDir, config.userConfig, config.logger) routeModuleCache.delete(id)
) const mod = this.environment.moduleGraph.getModuleById(id)
if (mod) {
modules.push(mod)
} }
for (const id of mods) { }
ctx.modules.push(server.moduleGraph.getModuleById(id)!)
// Also check if the file matches any custom watch patterns.
let watchedFileChanged = false
for (const [, route] of routeModuleCache) {
if (route.watch && isMatch(normalizedFile, route.watch)) {
route.routes = undefined
watchedFileChanged = true
} }
} }
if (
(modules.length && !normalizedFile.endsWith('.md')) ||
watchedFileChanged ||
pathLoaderRE.test(normalizedFile)
) {
// path loader module or deps updated, reset loaded routes
Object.assign(
config,
await resolvePages(config.srcDir, config.userConfig, config.logger)
)
}
return modules.length ? [...existingMods, ...modules] : undefined
} }
} }
} }
export async function resolveDynamicRoutes( export function getPageDataTransformer(
loaderPath: string
): UserConfig['transformPageData'] | undefined {
return routeModuleCache.get(loaderPath)?.transformPageData
}
async function resolveDynamicRoutes(
srcDir: string, srcDir: string,
routes: string[], routes: string[],
logger: Logger logger: Logger
): Promise<SiteConfig['dynamicRoutes']> { ): Promise<ResolvedRouteConfig[]> {
const pendingResolveRoutes: Promise<ResolvedRouteConfig[]>[] = [] const pendingResolveRoutes: Promise<ResolvedRouteConfig[]>[] = []
const routeFileToModulesMap: Record<string, Set<string>> = {} const newModuleGraph = moduleGraph.clone()
for (const route of routes) { for (const route of routes) {
// locate corresponding route paths file // locate corresponding route paths file
@ -194,50 +240,95 @@ export async function resolveDynamicRoutes(
} }
// load the paths loader module // load the paths loader module
let mod = routeModuleCache.get(pathsFile) let watch: ResolvedRouteModule['watch']
if (!mod) { let loader: ResolvedRouteModule['loader']
let extras: Partial<ResolvedRouteModule>
const loaderPath = normalizePath(pathsFile)
const existing = routeModuleCache.get(loaderPath)
if (existing) {
// use cached routes if not invalidated by hmr
if (existing.routes) {
pendingResolveRoutes.push(Promise.resolve(existing.routes))
continue
}
;({ watch, loader, ...extras } = existing)
} else {
let mod
try { try {
mod = (await loadConfigFromFile( mod = await loadConfigFromFile(
{} as any, {} as any,
pathsFile, pathsFile,
undefined, undefined,
'silent' 'silent'
)) as RouteModule )
routeModuleCache.set(pathsFile, mod)
} catch (err: any) { } catch (err: any) {
logger.warn( logger.warn(
`${c.yellow(`Failed to load ${pathsFile}:`)}\n${err.message}\n${err.stack}` `${c.yellow(`Failed to load ${pathsFile}:`)}\n${err.message}\n${err.stack}`
) )
continue continue
} }
}
// this array represents the virtual modules affected by this route if (!mod) {
const matchedModuleIds = (routeFileToModulesMap[ logger.warn(
normalizePath(path.resolve(srcDir, route)) c.yellow(
] = new Set()) `Invalid paths file export in ${pathsFile}. ` +
`Missing "default" export.`
)
)
continue
}
// each dependency (including the loader module itself) also point to the // @ts-ignore
// same array ;({ paths: loader, watch, ...extras } = mod.config)
for (const dep of mod.dependencies) {
// deps are resolved relative to cwd
routeFileToModulesMap[normalizePath(path.resolve(dep))] = matchedModuleIds
}
const loader = mod!.config.paths if (!loader) {
if (!loader) { logger.warn(
logger.warn( c.yellow(
c.yellow( `Invalid paths file export in ${pathsFile}. ` +
`Invalid paths file export in ${pathsFile}. ` + `Missing "paths" property from default export.`
`Missing "paths" property from default export.` )
)
continue
}
watch = typeof watch === 'string' ? [watch] : watch
if (watch) {
watch = watch.map((p) =>
p.startsWith('.')
? normalizePath(path.resolve(path.dirname(pathsFile), p))
: normalizePath(p)
) )
}
// record deps for hmr
newModuleGraph.add(
loaderPath,
mod.dependencies.map((p) => normalizePath(path.resolve(p)))
) )
continue
} }
const resolveRoute = async (): Promise<ResolvedRouteConfig[]> => { const resolveRoute = async (): Promise<ResolvedRouteConfig[]> => {
const paths = await (typeof loader === 'function' ? loader() : loader) let pathsData: UserRouteConfig[]
return paths.map((userConfig) => {
if (typeof loader === 'function') {
let watchedFiles: string[] = []
if (watch) {
watchedFiles = (
await glob(watch, {
ignore: ['**/node_modules/**', '**/dist/**'],
expandDirectories: false
})
).sort()
}
pathsData = await loader(watchedFiles)
} else {
pathsData = loader
}
const routes = pathsData.map((userConfig) => {
const resolvedPath = route.replace( const resolvedPath = route.replace(
dynamicRouteRE, dynamicRouteRE,
(_, key) => userConfig.params[key] (_, key) => userConfig.params[key]
@ -246,15 +337,21 @@ export async function resolveDynamicRoutes(
path: resolvedPath, path: resolvedPath,
fullPath: normalizePath(path.resolve(srcDir, resolvedPath)), fullPath: normalizePath(path.resolve(srcDir, resolvedPath)),
route, route,
loaderPath,
...userConfig ...userConfig
} }
}) })
routeModuleCache.set(loaderPath, { ...extras, watch, routes, loader })
return routes
} }
pendingResolveRoutes.push(resolveRoute()) pendingResolveRoutes.push(resolveRoute())
} }
return { const resolvedRoutes = (await Promise.all(pendingResolveRoutes)).flat()
routes: (await Promise.all(pendingResolveRoutes)).flat(), moduleGraph = newModuleGraph
fileToModulesMap: routeFileToModulesMap
} return resolvedRoutes
} }

@ -198,7 +198,9 @@ export async function localSearchPlugin(
} }
}, },
async handleHotUpdate({ file }) { async hotUpdate({ file }) {
if (this.environment.name !== 'client') return
if (file.endsWith('.md')) { if (file.endsWith('.md')) {
await indexFile(file) await indexFile(file)
debug('🔍️ Updated', file) debug('🔍️ Updated', file)

@ -1,5 +1,5 @@
import path from 'node:path'
import { isMatch } from 'picomatch' import { isMatch } from 'picomatch'
import path, { dirname, resolve } from 'node:path'
import { glob } from 'tinyglobby' import { glob } from 'tinyglobby'
import { import {
type EnvironmentModuleNode, type EnvironmentModuleNode,
@ -25,10 +25,12 @@ export function defineLoader(loader: LoaderModule) {
return loader return loader
} }
// Map from loader module id to its module info
const idToLoaderModulesMap: Record<string, LoaderModule | undefined> = const idToLoaderModulesMap: Record<string, LoaderModule | undefined> =
Object.create(null) Object.create(null)
const depToLoaderModuleIdMap: Record<string, string> = Object.create(null) // Map from dependency file to a set of loader module ids
const depToLoaderModuleIdsMap: Record<string, Set<string>> = Object.create(null)
// During build, the load hook will be called on the same file twice // During build, the load hook will be called on the same file twice
// once for client and once for server build. Not only is this wasteful, it // once for client and once for server build. Not only is this wasteful, it
@ -62,7 +64,7 @@ export const staticDataPlugin: Plugin = {
}) })
} }
const base = dirname(id) const base = path.dirname(id)
let watch: LoaderModule['watch'] let watch: LoaderModule['watch']
let load: LoaderModule['load'] let load: LoaderModule['load']
@ -70,14 +72,18 @@ export const staticDataPlugin: Plugin = {
if (existing) { if (existing) {
;({ watch, load } = existing) ;({ watch, load } = existing)
} else { } else {
// use vite's load config util as a away to load Node.js file with // use vite's load config util as a way to load Node.js file with
// TS & native ESM support // TS & native ESM support
const res = await loadConfigFromFile({} as any, id.replace(/\?.*$/, '')) const res = await loadConfigFromFile({} as any, id.replace(/\?.*$/, ''))
// record deps for hmr // record deps for hmr
if (server && res) { if (server && res) {
for (const dep of res.dependencies) { for (const dep of res.dependencies) {
depToLoaderModuleIdMap[normalizePath(path.resolve(dep))] = id const depPath = normalizePath(path.resolve(dep))
if (!depToLoaderModuleIdsMap[depPath]) {
depToLoaderModuleIdsMap[depPath] = new Set()
}
depToLoaderModuleIdsMap[depPath].add(id)
} }
} }
@ -89,7 +95,7 @@ export const staticDataPlugin: Plugin = {
if (watch) { if (watch) {
watch = watch.map((p) => { watch = watch.map((p) => {
return p.startsWith('.') return p.startsWith('.')
? normalizePath(resolve(base, p)) ? normalizePath(path.resolve(base, p))
: normalizePath(p) : normalizePath(p)
}) })
} }
@ -97,9 +103,8 @@ export const staticDataPlugin: Plugin = {
} }
// load the data // load the data
let watchedFiles let watchedFiles: string[] = []
if (watch) { if (watch) {
if (typeof watch === 'string') watch = [watch]
watchedFiles = ( watchedFiles = (
await glob(watch, { await glob(watch, {
ignore: ['**/node_modules/**', '**/dist/**'], ignore: ['**/node_modules/**', '**/dist/**'],
@ -107,41 +112,50 @@ export const staticDataPlugin: Plugin = {
}) })
).sort() ).sort()
} }
const data = await load(watchedFiles || []) const data = await load(watchedFiles)
// record loader module for HMR // record loader module for HMR
if (server) { if (server) {
idToLoaderModulesMap[id] = { watch, load } idToLoaderModulesMap[id] = { watch, load }
} }
const result = `export const data = JSON.parse(${JSON.stringify( const result = `export const data = JSON.parse(${JSON.stringify(JSON.stringify(data))})`
JSON.stringify(data)
)})`
if (_resolve) _resolve(result) if (_resolve) _resolve(result)
return result return result
} }
}, },
hotUpdate(ctx) { hotUpdate({ file, modules: existingMods }) {
const file = ctx.file if (this.environment.name !== 'client') return
const modules: EnvironmentModuleNode[] = [] const modules: EnvironmentModuleNode[] = []
// dependency of data loader changed const normalizedFile = normalizePath(file)
// (note the dep array includes the loader file itself)
if (file in depToLoaderModuleIdMap) { // Trigger update if a dependency (including transitive ones) changed.
const id = depToLoaderModuleIdMap[file]! if (normalizedFile in depToLoaderModuleIdsMap) {
delete idToLoaderModulesMap[id] for (const id of Array.from(
modules.push(this.environment.moduleGraph.getModuleById(id)!) depToLoaderModuleIdsMap[normalizedFile] || []
)) {
delete idToLoaderModulesMap[id]
const mod = this.environment.moduleGraph.getModuleById(id)
if (mod) {
modules.push(mod)
}
}
} }
// Also check if the file matches any custom watch patterns.
for (const id in idToLoaderModulesMap) { for (const id in idToLoaderModulesMap) {
const { watch } = idToLoaderModulesMap[id]! const loader = idToLoaderModulesMap[id]
if (watch && isMatch(file, watch)) { if (loader && loader.watch && isMatch(normalizedFile, loader.watch)) {
modules.push(this.environment.moduleGraph.getModuleById(id)!) const mod = this.environment.moduleGraph.getModuleById(id)
if (mod && !modules.includes(mod)) {
modules.push(mod)
}
} }
} }
return modules.length > 0 ? [...ctx.modules, ...modules] : undefined return modules.length ? [...existingMods, ...modules] : undefined
} }
} }

@ -4,6 +4,7 @@ import type { SitemapStreamOptions } from 'sitemap'
import type { Logger, UserConfig as ViteConfig } from 'vite' import type { Logger, UserConfig as ViteConfig } from 'vite'
import type { SitemapItem } from './build/generateSitemap' import type { SitemapItem } from './build/generateSitemap'
import type { MarkdownOptions } from './markdown/markdown' import type { MarkdownOptions } from './markdown/markdown'
import type { ResolvedRouteConfig } from './plugins/dynamicRoutesPlugin'
import type { import type {
Awaitable, Awaitable,
HeadConfig, HeadConfig,
@ -30,26 +31,6 @@ export interface TransformContext {
assets: string[] assets: string[]
} }
interface UserRouteConfig {
params: Record<string, string>
content?: string
}
export type ResolvedRouteConfig = UserRouteConfig & {
/**
* the raw route (relative to src root), e.g. foo/[bar].md
*/
route: string
/**
* the actual path with params resolved (relative to src root), e.g. foo/1.md
*/
path: string
/**
* absolute fs path
*/
fullPath: string
}
export interface TransformPageContext { export interface TransformPageContext {
siteConfig: SiteConfig siteConfig: SiteConfig
} }
@ -240,10 +221,7 @@ export interface SiteConfig<ThemeConfig = any>
cacheDir: string cacheDir: string
tempDir: string tempDir: string
pages: string[] pages: string[]
dynamicRoutes: { dynamicRoutes: ResolvedRouteConfig[]
routes: ResolvedRouteConfig[]
fileToModulesMap: Record<string, Set<string>>
}
rewrites: { rewrites: {
map: Record<string, string | undefined> map: Record<string, string | undefined>
inv: Record<string, string | undefined> inv: Record<string, string | undefined>

@ -0,0 +1,115 @@
export class ModuleGraph {
// Each module is tracked with its dependencies and dependents.
private nodes: Map<
string,
{ dependencies: Set<string>; dependents: Set<string> }
> = new Map()
/**
* Adds or updates a module by merging the provided dependencies
* with any existing ones.
*
* For every new dependency, the module is added to that dependency's
* 'dependents' set.
*
* @param module - The module to add or update.
* @param dependencies - Array of module names that the module depends on.
*/
add(module: string, dependencies: string[]): void {
// Ensure the module exists in the graph.
if (!this.nodes.has(module)) {
this.nodes.set(module, {
dependencies: new Set(),
dependents: new Set()
})
}
const moduleNode = this.nodes.get(module)!
// Merge the new dependencies with any that already exist.
for (const dep of dependencies) {
if (!moduleNode.dependencies.has(dep) && dep !== module) {
moduleNode.dependencies.add(dep)
// Ensure the dependency exists in the graph.
if (!this.nodes.has(dep)) {
this.nodes.set(dep, {
dependencies: new Set(),
dependents: new Set()
})
}
// Add the module as a dependent of the dependency.
this.nodes.get(dep)!.dependents.add(module)
}
}
}
/**
* Deletes a module and all modules that (transitively) depend on it.
*
* This method performs a depth-first search from the target module,
* collects all affected modules, and then removes them from the graph,
* cleaning up their references from other nodes.
*
* @param module - The module to delete.
* @returns A Set containing the deleted module and all modules that depend on it.
*/
delete(module: string): Set<string> {
const deleted = new Set<string>()
const stack: string[] = [module]
// Traverse the reverse dependency graph (using dependents).
while (stack.length) {
const current = stack.pop()!
if (!deleted.has(current)) {
deleted.add(current)
const node = this.nodes.get(current)
if (node) {
for (const dependent of node.dependents) {
stack.push(dependent)
}
}
}
}
// Remove deleted nodes from the graph.
// For each deleted node, also remove it from its dependencies' dependents.
for (const mod of deleted) {
const node = this.nodes.get(mod)
if (node) {
for (const dep of node.dependencies) {
const depNode = this.nodes.get(dep)
if (depNode) {
depNode.dependents.delete(mod)
}
}
}
this.nodes.delete(mod)
}
return deleted
}
/**
* Clears all modules from the graph.
*/
clear(): void {
this.nodes.clear()
}
/**
* Creates a deep clone of the ModuleGraph instance.
* This is useful for preserving the state of the graph
* before making modifications.
*
* @returns A new ModuleGraph instance with the same state as the original.
*/
clone(): ModuleGraph {
const clone = new ModuleGraph()
for (const [module, { dependencies, dependents }] of this.nodes) {
clone.nodes.set(module, {
dependencies: new Set(dependencies),
dependents: new Set(dependents)
})
}
return clone
}
}

@ -1,7 +1,7 @@
import ora from 'ora' import ora from 'ora'
export const okMark = '\x1b[32m✓\x1b[0m' export const okMark = '\x1b[32m✓\x1b[0m'
export const failMark = '\x1b[31m\x1b[0m' export const failMark = '\x1b[31m\x1b[0m'
export async function task(taskName: string, task: () => Promise<void>) { export async function task(taskName: string, task: () => Promise<void>) {
const spinner = ora({ discardStdin: false }) const spinner = ora({ discardStdin: false })

Loading…
Cancel
Save