diff --git a/__tests__/e2e/dynamic-routes/[id].paths.ts b/__tests__/e2e/dynamic-routes/[id].paths.ts index 3eca4d91..12a8bc32 100644 --- a/__tests__/e2e/dynamic-routes/[id].paths.ts +++ b/__tests__/e2e/dynamic-routes/[id].paths.ts @@ -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' } -} +}) diff --git a/__tests__/e2e/dynamic-routes/paths.ts b/__tests__/e2e/dynamic-routes/paths.ts new file mode 100644 index 00000000..5adb0ed4 --- /dev/null +++ b/__tests__/e2e/dynamic-routes/paths.ts @@ -0,0 +1,4 @@ +export default [ + { params: { id: 'foo' }, content: `# Foo` }, + { params: { id: 'bar' }, content: `# Bar` } +] diff --git a/__tests__/unit/node/utils/moduleGraph.test.ts b/__tests__/unit/node/utils/moduleGraph.test.ts new file mode 100644 index 00000000..9b3db9e5 --- /dev/null +++ b/__tests__/unit/node/utils/moduleGraph.test.ts @@ -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'])) + }) +}) diff --git a/src/client/app/data.ts b/src/client/app/data.ts index c99de52f..16288e87 100644 --- a/src/client/app/data.ts +++ b/src/client/app/data.ts @@ -62,7 +62,7 @@ export const siteDataRef: Ref = shallowRef( // hmr if (import.meta.hot) { - import.meta.hot.accept('/@siteData', (m) => { + import.meta.hot.accept('@siteData', (m) => { if (m) { siteDataRef.value = m.default } diff --git a/src/client/theme-default/components/VPLocalSearchBox.vue b/src/client/theme-default/components/VPLocalSearchBox.vue index 951a7536..b348de54 100644 --- a/src/client/theme-default/components/VPLocalSearchBox.vue +++ b/src/client/theme-default/components/VPLocalSearchBox.vue @@ -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 } diff --git a/src/node/config.ts b/src/node/config.ts index 5f1698fe..75740b80 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -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 diff --git a/src/node/index.ts b/src/node/index.ts index 63ee79f8..5f3980be 100644 --- a/src/node/index.ts +++ b/src/node/index.ts @@ -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' diff --git a/src/node/init/init.ts b/src/node/init/init.ts index 6ccfdf09..8a6836a1 100644 --- a/src/node/init/init.ts +++ b/src/node/init/init.ts @@ -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}` } } diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index a4f8a20d..d4576cda 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -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() +let __rewrites = new Map() +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 => { - 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,14 +241,14 @@ export async function createMarkdownToVueRenderFn( } } - if (siteConfig?.transformPageData) { - const dataToMerge = await siteConfig.transformPageData(pageData, { - siteConfig - }) - if (dataToMerge) { - pageData = { - ...pageData, - ...dataToMerge + for (const fn of transformPageData) { + if (fn) { + const dataToMerge = await fn(pageData, { siteConfig }) + if (dataToMerge) { + pageData = { + ...pageData, + ...dataToMerge + } } } } @@ -318,10 +354,7 @@ const inferDescription = (frontmatter: Record) => { 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 } diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 078fd2a6..35905912 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -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) } } } diff --git a/src/node/plugins/dynamicRoutesPlugin.ts b/src/node/plugins/dynamicRoutesPlugin.ts index 11f4b64e..a480d898 100644 --- a/src/node/plugins/dynamicRoutesPlugin.ts +++ b/src/node/plugins/dynamicRoutesPlugin.ts @@ -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 + 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) + 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() +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> { // 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 + rewrites, + // @ts-expect-error internal flag to reload resolution cache in ../markdownToVue.ts + __dirty: true } } -interface UserRouteConfig { - params: Record - content?: string -} - -interface RouteModule { - path: string - config: { - paths: - | UserRouteConfig[] - | (() => UserRouteConfig[] | Promise) - } - dependencies: string[] -} - -const routeModuleCache = new Map() - -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 => { - 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) { - // 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) - ) + 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) } - 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, routes: string[], logger: Logger -): Promise { +): Promise { const pendingResolveRoutes: Promise[] = [] - const routeFileToModulesMap: Record> = {} + const newModuleGraph = moduleGraph.clone() for (const route of routes) { // locate corresponding route paths file @@ -194,50 +240,95 @@ 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 + + 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()) + if (!mod) { + logger.warn( + c.yellow( + `Invalid paths file export in ${pathsFile}. ` + + `Missing "default" export.` + ) + ) + continue + } - // 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 - } + // @ts-ignore + ;({ paths: loader, watch, ...extras } = mod.config) - const loader = mod!.config.paths - if (!loader) { - logger.warn( - c.yellow( - `Invalid paths file export in ${pathsFile}. ` + - `Missing "paths" property from default export.` + if (!loader) { + logger.warn( + c.yellow( + `Invalid paths file export in ${pathsFile}. ` + + `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 => { - 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 } diff --git a/src/node/plugins/localSearchPlugin.ts b/src/node/plugins/localSearchPlugin.ts index bffe74e8..debc024c 100644 --- a/src/node/plugins/localSearchPlugin.ts +++ b/src/node/plugins/localSearchPlugin.ts @@ -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) diff --git a/src/node/plugins/staticDataPlugin.ts b/src/node/plugins/staticDataPlugin.ts index 6e420ec2..8521603d 100644 --- a/src/node/plugins/staticDataPlugin.ts +++ b/src/node/plugins/staticDataPlugin.ts @@ -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 = Object.create(null) -const depToLoaderModuleIdMap: Record = Object.create(null) +// Map from dependency file to a set of loader module ids +const depToLoaderModuleIdsMap: Record> = 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]! - delete idToLoaderModulesMap[id] - modules.push(this.environment.moduleGraph.getModuleById(id)!) + 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] + 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 } } diff --git a/src/node/siteConfig.ts b/src/node/siteConfig.ts index ebd99096..49451cbe 100644 --- a/src/node/siteConfig.ts +++ b/src/node/siteConfig.ts @@ -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 - 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 cacheDir: string tempDir: string pages: string[] - dynamicRoutes: { - routes: ResolvedRouteConfig[] - fileToModulesMap: Record> - } + dynamicRoutes: ResolvedRouteConfig[] rewrites: { map: Record inv: Record diff --git a/src/node/utils/moduleGraph.ts b/src/node/utils/moduleGraph.ts new file mode 100644 index 00000000..61e5eecf --- /dev/null +++ b/src/node/utils/moduleGraph.ts @@ -0,0 +1,115 @@ +export class ModuleGraph { + // Each module is tracked with its dependencies and dependents. + private nodes: Map< + string, + { dependencies: Set; dependents: Set } + > = 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 { + const deleted = new Set() + 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 + } +} diff --git a/src/node/utils/task.ts b/src/node/utils/task.ts index 1d8a3322..8fd169bf 100644 --- a/src/node/utils/task.ts +++ b/src/node/utils/task.ts @@ -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) { const spinner = ora({ discardStdin: false })