Skip to content

Commit a62ea6a

Browse files
authored
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.
1 parent d1f2afd commit a62ea6a

File tree

16 files changed

+556
-254
lines changed

16 files changed

+556
-254
lines changed
Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
export default {
2-
async paths() {
3-
return [
4-
{ params: { id: 'foo' }, content: `# Foo` },
5-
{ params: { id: 'bar' }, content: `# Bar` }
6-
]
1+
import { defineRoutes } from 'vitepress'
2+
import paths from './paths'
3+
4+
export default defineRoutes({
5+
async paths(watchedFiles: string[]) {
6+
// console.log('watchedFiles', watchedFiles)
7+
return paths
8+
},
9+
watch: ['**/data-loading/**/*.json'],
10+
async transformPageData(pageData) {
11+
// console.log('transformPageData', pageData.filePath)
12+
pageData.title += ' - transformed'
713
}
8-
}
14+
})

__tests__/e2e/dynamic-routes/paths.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export default [
2+
{ params: { id: 'foo' }, content: `# Foo` },
3+
{ params: { id: 'bar' }, content: `# Bar` }
4+
]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { ModuleGraph } from 'node/utils/moduleGraph'
2+
3+
describe('node/utils/moduleGraph', () => {
4+
let graph: ModuleGraph
5+
6+
beforeEach(() => {
7+
graph = new ModuleGraph()
8+
})
9+
10+
it('should correctly delete a module and its dependents', () => {
11+
graph.add('A', ['B', 'C'])
12+
graph.add('B', ['D'])
13+
graph.add('C', [])
14+
graph.add('D', [])
15+
16+
expect(graph.delete('D')).toEqual(new Set(['D', 'B', 'A']))
17+
})
18+
19+
it('should handle shared dependencies correctly', () => {
20+
graph.add('A', ['B', 'C'])
21+
graph.add('B', ['D'])
22+
graph.add('C', ['D']) // Shared dependency
23+
graph.add('D', [])
24+
25+
expect(graph.delete('D')).toEqual(new Set(['A', 'B', 'C', 'D']))
26+
})
27+
28+
it('merges dependencies correctly', () => {
29+
// Add module A with dependency B
30+
graph.add('A', ['B'])
31+
// Merge new dependency C into module A (B should remain)
32+
graph.add('A', ['C'])
33+
34+
// Deleting B should remove A as well, since A depends on B.
35+
expect(graph.delete('B')).toEqual(new Set(['B', 'A']))
36+
})
37+
38+
it('handles cycles gracefully', () => {
39+
// Create a cycle: A -> B, B -> C, C -> A.
40+
graph.add('A', ['B'])
41+
graph.add('B', ['C'])
42+
graph.add('C', ['A'])
43+
44+
// Deleting any module in the cycle should delete all modules in the cycle.
45+
expect(graph.delete('A')).toEqual(new Set(['A', 'B', 'C']))
46+
})
47+
48+
it('cleans up dependencies when deletion', () => {
49+
// Setup A -> B relationship.
50+
graph.add('A', ['B'])
51+
graph.add('B', [])
52+
53+
// Deleting B should remove both B and A from the graph.
54+
expect(graph.delete('B')).toEqual(new Set(['B', 'A']))
55+
56+
// After deletion, add modules again.
57+
graph.add('C', [])
58+
graph.add('A', ['C']) // Now A depends only on C.
59+
60+
expect(graph.delete('C')).toEqual(new Set(['C', 'A']))
61+
})
62+
63+
it('handles independent modules', () => {
64+
// Modules with no dependencies.
65+
graph.add('X', [])
66+
graph.add('Y', [])
67+
68+
// Deletion of one should only remove that module.
69+
expect(graph.delete('X')).toEqual(new Set(['X']))
70+
expect(graph.delete('Y')).toEqual(new Set(['Y']))
71+
})
72+
})

src/client/app/data.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export const siteDataRef: Ref<SiteData> = shallowRef(
6262

6363
// hmr
6464
if (import.meta.hot) {
65-
import.meta.hot.accept('/@siteData', (m) => {
65+
import.meta.hot.accept('@siteData', (m) => {
6666
if (m) {
6767
siteDataRef.value = m.default
6868
}

src/client/theme-default/components/VPLocalSearchBox.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const searchIndexData = shallowRef(localSearchIndex)
4646
4747
// hmr
4848
if (import.meta.hot) {
49-
import.meta.hot.accept('/@localSearchIndex', (m) => {
49+
import.meta.hot.accept('@localSearchIndex', (m) => {
5050
if (m) {
5151
searchIndexData.value = m.default
5252
}

src/node/config.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,20 +97,12 @@ export async function resolveConfig(
9797
? userThemeDir
9898
: DEFAULT_THEME_PATH
9999

100-
const { pages, dynamicRoutes, rewrites } = await resolvePages(
101-
srcDir,
102-
userConfig,
103-
logger
104-
)
105-
106100
const config: SiteConfig = {
107101
root,
108102
srcDir,
109103
assetsDir,
110104
site,
111105
themeDir,
112-
pages,
113-
dynamicRoutes,
114106
configPath,
115107
configDeps,
116108
outDir,
@@ -135,10 +127,10 @@ export async function resolveConfig(
135127
transformHead: userConfig.transformHead,
136128
transformHtml: userConfig.transformHtml,
137129
transformPageData: userConfig.transformPageData,
138-
rewrites,
139130
userConfig,
140131
sitemap: userConfig.sitemap,
141-
buildConcurrency: userConfig.buildConcurrency ?? 64
132+
buildConcurrency: userConfig.buildConcurrency ?? 64,
133+
...(await resolvePages(srcDir, userConfig, logger))
142134
}
143135

144136
// to be shared with content loaders

src/node/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ export * from './contentLoader'
55
export * from './init/init'
66
export * from './markdown/markdown'
77
export { defineLoader, type LoaderModule } from './plugins/staticDataPlugin'
8+
export {
9+
defineRoutes,
10+
type ResolvedRouteConfig,
11+
type RouteModule
12+
} from './plugins/dynamicRoutesPlugin'
813
export * from './postcss/isolateStyles'
914
export * from './serve/serve'
1015
export * from './server'

src/node/init/init.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import fs from 'fs-extra'
1111
import template from 'lodash.template'
1212
import path from 'node:path'
1313
import { fileURLToPath } from 'node:url'
14-
import { bold, cyan, yellow } from 'picocolors'
14+
import c from 'picocolors'
1515
import { slash } from '../shared'
1616

1717
export enum ScaffoldThemeType {
@@ -38,7 +38,7 @@ const getPackageManger = () => {
3838
}
3939

4040
export async function init(root?: string) {
41-
intro(bold(cyan('Welcome to VitePress!')))
41+
intro(c.bold(c.cyan('Welcome to VitePress!')))
4242

4343
const options = await group(
4444
{
@@ -232,7 +232,7 @@ export function scaffold({
232232
const gitignorePrefix = root ? `${slash(root)}/.vitepress` : '.vitepress'
233233
if (fs.existsSync('.git')) {
234234
tips.push(
235-
`Make sure to add ${cyan(`${gitignorePrefix}/dist`)} and ${cyan(`${gitignorePrefix}/cache`)} to your ${cyan(`.gitignore`)} file.`
235+
`Make sure to add ${c.cyan(`${gitignorePrefix}/dist`)} and ${c.cyan(`${gitignorePrefix}/cache`)} to your ${c.cyan(`.gitignore`)} file.`
236236
)
237237
}
238238

@@ -242,11 +242,11 @@ export function scaffold({
242242
!userPkg.devDependencies?.['vue']
243243
) {
244244
tips.push(
245-
`Since you've chosen to customize the theme, you should also explicitly install ${cyan(`vue`)} as a dev dependency.`
245+
`Since you've chosen to customize the theme, you should also explicitly install ${c.cyan(`vue`)} as a dev dependency.`
246246
)
247247
}
248248

249-
const tip = tips.length ? yellow([`\n\nTips:`, ...tips].join('\n- ')) : ``
249+
const tip = tips.length ? c.yellow([`\n\nTips:`, ...tips].join('\n- ')) : ``
250250
const dir = root ? ' ' + root : ''
251251
const pm = getPackageManger()
252252

@@ -261,8 +261,8 @@ export function scaffold({
261261
Object.assign(userPkg.scripts || (userPkg.scripts = {}), scripts)
262262
fs.writeFileSync(pkgPath, JSON.stringify(userPkg, null, 2))
263263

264-
return `Done! Now run ${cyan(`${pm} run ${prefix}dev`)} and start writing.${tip}`
264+
return `Done! Now run ${c.cyan(`${pm} run ${prefix}dev`)} and start writing.${tip}`
265265
} else {
266-
return `You're all set! Now run ${cyan(`${pm === 'npm' ? 'npx' : pm} vitepress dev${dir}`)} and start writing.${tip}`
266+
return `You're all set! Now run ${c.cyan(`${pm === 'npm' ? 'npx' : pm} vitepress dev${dir}`)} and start writing.${tip}`
267267
}
268268
}

src/node/markdownToVue.ts

Lines changed: 69 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type MarkdownOptions,
1010
type MarkdownRenderer
1111
} from './markdown/markdown'
12+
import { getPageDataTransformer } from './plugins/dynamicRoutesPlugin'
1213
import {
1314
EXTERNAL_URL_RE,
1415
getLocaleForPath,
@@ -31,25 +32,62 @@ export interface MarkdownCompileResult {
3132
includes: string[]
3233
}
3334

34-
export function clearCache(file?: string) {
35-
if (!file) {
35+
export function clearCache(id?: string) {
36+
if (!id) {
3637
cache.clear()
3738
return
3839
}
3940

40-
file = JSON.stringify({ file }).slice(1)
41-
cache.find((_, key) => key.endsWith(file!) && cache.delete(key))
41+
id = JSON.stringify({ id }).slice(1)
42+
cache.find((_, key) => key.endsWith(id!) && cache.delete(key))
43+
}
44+
45+
let __pages: string[] = []
46+
let __dynamicRoutes = new Map<string, [string, string]>()
47+
let __rewrites = new Map<string, string>()
48+
let __ts: number
49+
50+
function getResolutionCache(siteConfig: SiteConfig) {
51+
// @ts-expect-error internal
52+
if (siteConfig.__dirty) {
53+
__pages = siteConfig.pages.map((p) => slash(p.replace(/\.md$/, '')))
54+
55+
__dynamicRoutes = new Map(
56+
siteConfig.dynamicRoutes.map((r) => [
57+
r.fullPath,
58+
[slash(path.join(siteConfig.srcDir, r.route)), r.loaderPath]
59+
])
60+
)
61+
62+
__rewrites = new Map(
63+
Object.entries(siteConfig.rewrites.map).map(([key, value]) => [
64+
slash(path.join(siteConfig.srcDir, key)),
65+
slash(path.join(siteConfig.srcDir, value!))
66+
])
67+
)
68+
69+
__ts = Date.now()
70+
71+
// @ts-expect-error internal
72+
siteConfig.__dirty = false
73+
}
74+
75+
return {
76+
pages: __pages,
77+
dynamicRoutes: __dynamicRoutes,
78+
rewrites: __rewrites,
79+
ts: __ts
80+
}
4281
}
4382

4483
export async function createMarkdownToVueRenderFn(
4584
srcDir: string,
4685
options: MarkdownOptions = {},
47-
pages: string[],
4886
isBuild = false,
4987
base = '/',
5088
includeLastUpdatedData = false,
5189
cleanUrls = false,
52-
siteConfig: SiteConfig | null = null
90+
siteConfig: SiteConfig
5391
) {
5492
const md = await createMarkdownRenderer(
5593
srcDir,
@@ -58,32 +96,30 @@ export async function createMarkdownToVueRenderFn(
5896
siteConfig?.logger
5997
)
6098

61-
pages = pages.map((p) => slash(p.replace(/\.md$/, '')))
62-
63-
const dynamicRoutes = new Map(
64-
siteConfig?.dynamicRoutes?.routes.map((r) => [
65-
r.fullPath,
66-
slash(path.join(srcDir, r.route))
67-
]) || []
68-
)
69-
70-
const rewrites = new Map(
71-
Object.entries(siteConfig?.rewrites.map || {}).map(([key, value]) => [
72-
slash(path.join(srcDir, key)),
73-
slash(path.join(srcDir, value!))
74-
]) || []
75-
)
76-
7799
return async (
78100
src: string,
79101
file: string,
80102
publicDir: string
81103
): Promise<MarkdownCompileResult> => {
82-
const fileOrig = dynamicRoutes.get(file) || file
104+
const { pages, dynamicRoutes, rewrites, ts } =
105+
getResolutionCache(siteConfig)
106+
107+
const dynamicRoute = dynamicRoutes.get(file)
108+
const fileOrig = dynamicRoute?.[0] || file
109+
const transformPageData = [
110+
siteConfig?.transformPageData,
111+
getPageDataTransformer(dynamicRoute?.[1]!)
112+
].filter((fn) => fn != null)
113+
83114
file = rewrites.get(file) || file
84115
const relativePath = slash(path.relative(srcDir, file))
85116

86-
const cacheKey = JSON.stringify({ src, file: relativePath })
117+
const cacheKey = JSON.stringify({
118+
src,
119+
ts,
120+
file: relativePath,
121+
id: fileOrig
122+
})
87123
if (isBuild || options.cache !== false) {
88124
const cached = cache.get(cacheKey)
89125
if (cached) {
@@ -205,14 +241,14 @@ export async function createMarkdownToVueRenderFn(
205241
}
206242
}
207243

208-
if (siteConfig?.transformPageData) {
209-
const dataToMerge = await siteConfig.transformPageData(pageData, {
210-
siteConfig
211-
})
212-
if (dataToMerge) {
213-
pageData = {
214-
...pageData,
215-
...dataToMerge
244+
for (const fn of transformPageData) {
245+
if (fn) {
246+
const dataToMerge = await fn(pageData, { siteConfig })
247+
if (dataToMerge) {
248+
pageData = {
249+
...pageData,
250+
...dataToMerge
251+
}
216252
}
217253
}
218254
}
@@ -318,10 +354,7 @@ const inferDescription = (frontmatter: Record<string, any>) => {
318354
return (head && getHeadMetaContent(head, 'description')) || ''
319355
}
320356

321-
const getHeadMetaContent = (
322-
head: HeadConfig[],
323-
name: string
324-
): string | undefined => {
357+
const getHeadMetaContent = (head: HeadConfig[], name: string) => {
325358
if (!head || !head.length) {
326359
return undefined
327360
}

0 commit comments

Comments
 (0)