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 {
async paths() {
return [
{ params: { id: 'foo' }, content: `# Foo` },
{ params: { id: 'bar' }, content: `# Bar` }
]
import { defineRoutes } from 'vitepress'
import paths from './paths'
export default defineRoutes({
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
if (import.meta.hot) {
import.meta.hot.accept('/@siteData', (m) => {
import.meta.hot.accept('@siteData', (m) => {
if (m) {
siteDataRef.value = m.default
}

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

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

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

@ -11,7 +11,7 @@ import fs from 'fs-extra'
import template from 'lodash.template'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { bold, cyan, yellow } from 'picocolors'
import c from 'picocolors'
import { slash } from '../shared'
export enum ScaffoldThemeType {
@ -38,7 +38,7 @@ const getPackageManger = () => {
}
export async function init(root?: string) {
intro(bold(cyan('Welcome to VitePress!')))
intro(c.bold(c.cyan('Welcome to VitePress!')))
const options = await group(
{
@ -232,7 +232,7 @@ export function scaffold({
const gitignorePrefix = root ? `${slash(root)}/.vitepress` : '.vitepress'
if (fs.existsSync('.git')) {
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']
) {
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 pm = getPackageManger()
@ -261,8 +261,8 @@ export function scaffold({
Object.assign(userPkg.scripts || (userPkg.scripts = {}), scripts)
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 {
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 MarkdownRenderer
} from './markdown/markdown'
import { getPageDataTransformer } from './plugins/dynamicRoutesPlugin'
import {
EXTERNAL_URL_RE,
getLocaleForPath,
@ -31,25 +32,62 @@ export interface MarkdownCompileResult {
includes: string[]
}
export function clearCache(file?: string) {
if (!file) {
export function clearCache(id?: string) {
if (!id) {
cache.clear()
return
}
file = JSON.stringify({ file }).slice(1)
cache.find((_, key) => key.endsWith(file!) && cache.delete(key))
id = JSON.stringify({ id }).slice(1)
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(
srcDir: string,
options: MarkdownOptions = {},
pages: string[],
isBuild = false,
base = '/',
includeLastUpdatedData = false,
cleanUrls = false,
siteConfig: SiteConfig | null = null
siteConfig: SiteConfig
) {
const md = await createMarkdownRenderer(
srcDir,
@ -58,32 +96,30 @@ export async function createMarkdownToVueRenderFn(
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 (
src: string,
file: string,
publicDir: string
): 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
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) {
const cached = cache.get(cacheKey)
if (cached) {
@ -205,10 +241,9 @@ export async function createMarkdownToVueRenderFn(
}
}
if (siteConfig?.transformPageData) {
const dataToMerge = await siteConfig.transformPageData(pageData, {
siteConfig
})
for (const fn of transformPageData) {
if (fn) {
const dataToMerge = await fn(pageData, { siteConfig })
if (dataToMerge) {
pageData = {
...pageData,
@ -216,6 +251,7 @@ export async function createMarkdownToVueRenderFn(
}
}
}
}
const vueSrc = [
...injectPageDataCode(
@ -318,10 +354,7 @@ const inferDescription = (frontmatter: Record<string, any>) => {
return (head && getHeadMetaContent(head, 'description')) || ''
}
const getHeadMetaContent = (
head: HeadConfig[],
name: string
): string | undefined => {
const getHeadMetaContent = (head: HeadConfig[], name: string) => {
if (!head || !head.length) {
return undefined
}

@ -3,7 +3,6 @@ import c from 'picocolors'
import {
mergeConfig,
searchForWorkspaceRoot,
type ModuleNode,
type Plugin,
type ResolvedConfig,
type Rollup,
@ -96,7 +95,7 @@ export async function createVitePressPlugin(
// lazy require plugin-vue to respect NODE_ENV in @vue/compiler-x
const vuePlugin = await import('@vitejs/plugin-vue').then((r) =>
r.default({
include: [/\.vue$/, /\.md$/],
include: /\.(?:vue|md)$/,
...userVuePluginOptions,
template: {
...userVuePluginOptions?.template,
@ -130,7 +129,6 @@ export async function createVitePressPlugin(
markdownToVue = await createMarkdownToVueRenderFn(
srcDir,
markdown,
siteConfig.pages,
config.command === 'build',
config.base,
lastUpdated,
@ -197,9 +195,7 @@ export async function createVitePressPlugin(
}
}
data = serializeFunctions(data)
return `${deserializeFunctions};export default deserializeFunctions(JSON.parse(${JSON.stringify(
JSON.stringify(data)
)}))`
return `${deserializeFunctions};export default deserializeFunctions(JSON.parse(${JSON.stringify(JSON.stringify(data))}))`
}
},
@ -208,7 +204,7 @@ export async function createVitePressPlugin(
return processClientJS(code, id)
} else if (id.endsWith('.md')) {
// 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,
id,
config.publicDir
@ -220,6 +216,22 @@ export async function createVitePressPlugin(
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)
}
},
@ -256,9 +268,7 @@ export async function createVitePressPlugin(
if (themeRE.test(file)) {
siteConfig.logger.info(
c.green(
`${path.relative(process.cwd(), _file)} ${
added ? 'created' : 'deleted'
}, restarting server...\n`
`${path.relative(process.cwd(), _file)} ${added ? 'created' : 'deleted'}, restarting server...\n`
),
{ clear: true, timestamp: true }
)
@ -368,15 +378,13 @@ export async function createVitePressPlugin(
}
},
async handleHotUpdate(ctx) {
const { file, read, server } = ctx
async hotUpdate({ file }) {
if (this.environment.name !== 'client') return
if (file === configPath || configDeps.includes(file)) {
siteConfig.logger.info(
c.green(
`${path.relative(
process.cwd(),
file
)} changed, restarting server...\n`
`${path.relative(process.cwd(), file)} changed, restarting server...\n`
),
{ clear: true, timestamp: true }
)
@ -393,47 +401,23 @@ export async function createVitePressPlugin(
await recreateServer?.()
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 = {
name: 'vitepress:hmr-fix',
async handleHotUpdate({ file, server, modules }) {
async hotUpdate({ file, modules }) {
if (this.environment.name !== 'client') return
const importers = [...(importerMap[slash(file)] || [])]
if (importers.length > 0) {
return [
...modules,
...importers.map((id) => {
clearCache(slash(path.relative(srcDir, id)))
return server.moduleGraph.getModuleById(id)
clearCache(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 path from 'node:path'
import c from 'picocolors'
import { isMatch } from 'picomatch'
import { glob } from 'tinyglobby'
import {
loadConfigFromFile,
normalizePath,
type EnvironmentModuleNode,
type Logger,
type Plugin,
type ViteDevServer
type Plugin
} from 'vite'
import type { Awaitable } from '../shared'
import { type SiteConfig, type UserConfig } from '../siteConfig'
import { ModuleGraph } from '../utils/moduleGraph'
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(
srcDir: string,
userConfig: UserConfig,
logger: Logger
) {
): Promise<Pick<SiteConfig, 'pages' | 'dynamicRoutes' | 'rewrites'>> {
// Important: tinyglobby doesn't guarantee order of the returned files.
// We must sort the pages so the input list to rollup is stable across
// 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
// supported in Node 12+, which is required by Vite.
const allMarkdownFiles = (
await glob(['**.md'], {
await glob(['**/*.md'], {
cwd: srcDir,
ignore: [
'**/node_modules/**',
@ -50,67 +103,32 @@ export async function resolvePages(
dynamicRouteFiles,
logger
)
pages.push(...dynamicRoutes.routes.map((r) => r.path))
pages.push(...dynamicRoutes.map((r) => r.path))
const rewrites = resolveRewrites(pages, userConfig.rewrites)
return {
pages,
dynamicRoutes,
rewrites
}
}
interface UserRouteConfig {
params: Record<string, string>
content?: string
}
interface RouteModule {
path: string
config: {
paths:
| UserRouteConfig[]
| (() => UserRouteConfig[] | Promise<UserRouteConfig[]>)
rewrites,
// @ts-expect-error internal flag to reload resolution cache in ../markdownToVue.ts
__dirty: true
}
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 (
config: SiteConfig
): Promise<Plugin> => {
let server: ViteDevServer
return {
name: 'vitepress:dynamic-routes',
configureServer(_server) {
server = _server
},
resolveId(id) {
if (!id.endsWith('.md')) return
const normalizedId = id.startsWith(config.srcDir)
? id
: normalizePath(path.resolve(config.srcDir, id.replace(/^\//, '')))
const matched = config.dynamicRoutes.routes.find(
const matched = config.dynamicRoutes.find(
(r) => r.fullPath === normalizedId
)
if (matched) {
@ -119,11 +137,13 @@ export const dynamicRoutesPlugin = async (
},
load(id) {
const matched = config.dynamicRoutes.routes.find((r) => r.fullPath === id)
const matched = config.dynamicRoutes.find((r) => r.fullPath === id)
if (matched) {
const { route, params, content } = matched
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')
@ -139,39 +159,65 @@ export const dynamicRoutesPlugin = async (
}
// params are injected with special markers and extracted as part of
// __pageData in ../markdownTovue.ts
return `__VP_PARAMS_START${JSON.stringify(
params
)}__VP_PARAMS_END__${baseContent}`
// __pageData in ../markdownToVue.ts
return `__VP_PARAMS_START${JSON.stringify(params)}__VP_PARAMS_END__${baseContent}`
}
},
async handleHotUpdate(ctx) {
routeModuleCache.delete(ctx.file)
const mods = config.dynamicRoutes.fileToModulesMap[ctx.file]
if (mods) {
async hotUpdate({ file, modules: existingMods }) {
if (this.environment.name !== 'client') return
const modules: EnvironmentModuleNode[] = []
const normalizedFile = normalizePath(file)
// Trigger update if a module or its dependencies changed.
for (const id of moduleGraph.delete(normalizedFile)) {
routeModuleCache.delete(id)
const mod = this.environment.moduleGraph.getModuleById(id)
if (mod) {
modules.push(mod)
}
}
// 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
if (!ctx.file.endsWith('.md')) {
Object.assign(
config,
await resolvePages(config.srcDir, config.userConfig, config.logger)
)
}
for (const id of mods) {
ctx.modules.push(server.moduleGraph.getModuleById(id)!)
}
}
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,
routes: string[],
logger: Logger
): Promise<SiteConfig['dynamicRoutes']> {
): Promise<ResolvedRouteConfig[]> {
const pendingResolveRoutes: Promise<ResolvedRouteConfig[]>[] = []
const routeFileToModulesMap: Record<string, Set<string>> = {}
const newModuleGraph = moduleGraph.clone()
for (const route of routes) {
// locate corresponding route paths file
@ -194,37 +240,50 @@ export async function resolveDynamicRoutes(
}
// load the paths loader module
let mod = routeModuleCache.get(pathsFile)
if (!mod) {
let watch: ResolvedRouteModule['watch']
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 {
mod = (await loadConfigFromFile(
mod = await loadConfigFromFile(
{} as any,
pathsFile,
undefined,
'silent'
)) as RouteModule
routeModuleCache.set(pathsFile, mod)
)
} catch (err: any) {
logger.warn(
`${c.yellow(`Failed to load ${pathsFile}:`)}\n${err.message}\n${err.stack}`
)
continue
}
}
// this array represents the virtual modules affected by this route
const matchedModuleIds = (routeFileToModulesMap[
normalizePath(path.resolve(srcDir, route))
] = new Set())
// each dependency (including the loader module itself) also point to the
// same array
for (const dep of mod.dependencies) {
// deps are resolved relative to cwd
routeFileToModulesMap[normalizePath(path.resolve(dep))] = matchedModuleIds
if (!mod) {
logger.warn(
c.yellow(
`Invalid paths file export in ${pathsFile}. ` +
`Missing "default" export.`
)
)
continue
}
const loader = mod!.config.paths
// @ts-ignore
;({ paths: loader, watch, ...extras } = mod.config)
if (!loader) {
logger.warn(
c.yellow(
@ -235,9 +294,41 @@ export async function resolveDynamicRoutes(
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)))
)
}
const resolveRoute = async (): Promise<ResolvedRouteConfig[]> => {
const paths = await (typeof loader === 'function' ? loader() : loader)
return paths.map((userConfig) => {
let pathsData: UserRouteConfig[]
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(
dynamicRouteRE,
(_, key) => userConfig.params[key]
@ -246,15 +337,21 @@ export async function resolveDynamicRoutes(
path: resolvedPath,
fullPath: normalizePath(path.resolve(srcDir, resolvedPath)),
route,
loaderPath,
...userConfig
}
})
routeModuleCache.set(loaderPath, { ...extras, watch, routes, loader })
return routes
}
pendingResolveRoutes.push(resolveRoute())
}
return {
routes: (await Promise.all(pendingResolveRoutes)).flat(),
fileToModulesMap: routeFileToModulesMap
}
const resolvedRoutes = (await Promise.all(pendingResolveRoutes)).flat()
moduleGraph = newModuleGraph
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')) {
await indexFile(file)
debug('🔍️ Updated', file)

@ -1,5 +1,5 @@
import path from 'node:path'
import { isMatch } from 'picomatch'
import path, { dirname, resolve } from 'node:path'
import { glob } from 'tinyglobby'
import {
type EnvironmentModuleNode,
@ -25,10 +25,12 @@ export function defineLoader(loader: LoaderModule) {
return loader
}
// Map from loader module id to its module info
const idToLoaderModulesMap: Record<string, LoaderModule | undefined> =
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
// 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 load: LoaderModule['load']
@ -70,14 +72,18 @@ export const staticDataPlugin: Plugin = {
if (existing) {
;({ watch, load } = existing)
} 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
const res = await loadConfigFromFile({} as any, id.replace(/\?.*$/, ''))
// record deps for hmr
if (server && res) {
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) {
watch = watch.map((p) => {
return p.startsWith('.')
? normalizePath(resolve(base, p))
? normalizePath(path.resolve(base, p))
: normalizePath(p)
})
}
@ -97,9 +103,8 @@ export const staticDataPlugin: Plugin = {
}
// load the data
let watchedFiles
let watchedFiles: string[] = []
if (watch) {
if (typeof watch === 'string') watch = [watch]
watchedFiles = (
await glob(watch, {
ignore: ['**/node_modules/**', '**/dist/**'],
@ -107,41 +112,50 @@ export const staticDataPlugin: Plugin = {
})
).sort()
}
const data = await load(watchedFiles || [])
const data = await load(watchedFiles)
// record loader module for HMR
if (server) {
idToLoaderModulesMap[id] = { watch, load }
}
const result = `export const data = JSON.parse(${JSON.stringify(
JSON.stringify(data)
)})`
const result = `export const data = JSON.parse(${JSON.stringify(JSON.stringify(data))})`
if (_resolve) _resolve(result)
return result
}
},
hotUpdate(ctx) {
const file = ctx.file
hotUpdate({ file, modules: existingMods }) {
if (this.environment.name !== 'client') return
const modules: EnvironmentModuleNode[] = []
// dependency of data loader changed
// (note the dep array includes the loader file itself)
if (file in depToLoaderModuleIdMap) {
const id = depToLoaderModuleIdMap[file]!
const normalizedFile = normalizePath(file)
// Trigger update if a dependency (including transitive ones) changed.
if (normalizedFile in depToLoaderModuleIdsMap) {
for (const id of Array.from(
depToLoaderModuleIdsMap[normalizedFile] || []
)) {
delete idToLoaderModulesMap[id]
modules.push(this.environment.moduleGraph.getModuleById(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) {
const { watch } = idToLoaderModulesMap[id]!
if (watch && isMatch(file, watch)) {
modules.push(this.environment.moduleGraph.getModuleById(id)!)
const loader = idToLoaderModulesMap[id]
if (loader && loader.watch && isMatch(normalizedFile, loader.watch)) {
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 { SitemapItem } from './build/generateSitemap'
import type { MarkdownOptions } from './markdown/markdown'
import type { ResolvedRouteConfig } from './plugins/dynamicRoutesPlugin'
import type {
Awaitable,
HeadConfig,
@ -30,26 +31,6 @@ export interface TransformContext {
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 {
siteConfig: SiteConfig
}
@ -240,10 +221,7 @@ export interface SiteConfig<ThemeConfig = any>
cacheDir: string
tempDir: string
pages: string[]
dynamicRoutes: {
routes: ResolvedRouteConfig[]
fileToModulesMap: Record<string, Set<string>>
}
dynamicRoutes: ResolvedRouteConfig[]
rewrites: {
map: 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'
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>) {
const spinner = ora({ discardStdin: false })

Loading…
Cancel
Save