diff --git a/packages/bundler-vite/src/build/build.ts b/packages/bundler-vite/src/build/build.ts index 3cc7e9c922..a569950309 100644 --- a/packages/bundler-vite/src/build/build.ts +++ b/packages/bundler-vite/src/build/build.ts @@ -1,3 +1,4 @@ +import type { PageChunkFilesMap } from '@vuepress/bundlerutils' import { createVueServerApp, getSsrTemplate } from '@vuepress/bundlerutils' import type { App, Bundler } from '@vuepress/core' import { colors, debug, fs, withSpinner } from '@vuepress/utils' @@ -7,6 +8,7 @@ import { build as viteBuild } from 'vite' import { resolveViteConfig } from '../resolveViteConfig.js' import type { ViteBundlerOptions } from '../types.js' import { renderPage } from './renderPage.js' +import { resolvePageChunkFiles } from './resolvePageChunkFiles.js' const log = debug('vuepress:bundler-vite/build') @@ -65,6 +67,15 @@ export const build = async ( ) const ssrTemplate = await getSsrTemplate(app) + // build page path -> chunk files map for preload & prefetch + const pageChunkFilesMap: PageChunkFilesMap = new Map() + for (const page of app.pages) { + pageChunkFilesMap.set( + page.path, + resolvePageChunkFiles({ page, output: clientOutput.output }), + ) + } + // pre-render pages to html files for (const page of app.pages) { if (spinner) spinner.text = `Rendering pages ${colors.magenta(page.path)}` @@ -77,6 +88,7 @@ export const build = async ( output: clientOutput.output, outputEntryChunk: clientEntryChunk, outputCssAsset: clientCssAsset, + pageChunkFilesMap, }) } }) diff --git a/packages/bundler-vite/src/build/renderPage.ts b/packages/bundler-vite/src/build/renderPage.ts index 34a6860887..f186caa917 100644 --- a/packages/bundler-vite/src/build/renderPage.ts +++ b/packages/bundler-vite/src/build/renderPage.ts @@ -1,3 +1,4 @@ +import type { PageChunkFilesMap } from '@vuepress/bundlerutils' import { renderPageToString } from '@vuepress/bundlerutils' import type { App, Page } from '@vuepress/core' import { fs, renderHead } from '@vuepress/utils' @@ -20,6 +21,7 @@ export const renderPage = async ({ output, outputEntryChunk, outputCssAsset, + pageChunkFilesMap, }: { app: App page: Page @@ -29,6 +31,7 @@ export const renderPage = async ({ output: RolldownOutput['output'] outputEntryChunk: OutputChunk outputCssAsset: OutputAsset | undefined + pageChunkFilesMap: PageChunkFilesMap }): Promise => { // render current page to string const { ssrContext, ssrString } = await renderPageToString({ @@ -49,11 +52,15 @@ export const renderPage = async ({ app, outputEntryChunk, pageChunkFiles, + page, + pageChunkFilesMap, }), preload: renderPagePreloadLinks({ app, outputEntryChunk, pageChunkFiles, + page, + pageChunkFilesMap, }), scripts: renderPageScripts({ app, outputEntryChunk }), styles: renderPageStyles({ app, outputCssAsset }), diff --git a/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts b/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts index ca7299fc58..07b0288b9c 100644 --- a/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts +++ b/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts @@ -1,4 +1,6 @@ -import type { App } from '@vuepress/core' +import type { PageChunkFilesMap } from '@vuepress/bundlerutils' +import { resolveLinkRoutePath } from '@vuepress/bundlerutils' +import type { App, Page } from '@vuepress/core' import type { OutputChunk } from 'rolldown' /** @@ -8,10 +10,14 @@ export const renderPagePrefetchLinks = ({ app, outputEntryChunk, pageChunkFiles, + page, + pageChunkFilesMap, }: { app: App outputEntryChunk: OutputChunk pageChunkFiles: string[] + page: Page + pageChunkFilesMap: PageChunkFilesMap }): string => { // shouldPrefetch option const { shouldPrefetch } = app.options @@ -21,12 +27,37 @@ export const renderPagePrefetchLinks = ({ return '' } - // dynamic imports excluding current page chunks - const prefetchFiles = outputEntryChunk.dynamicImports.filter( - (item) => !pageChunkFiles.some((file) => file === item), - ) + let candidateFiles: string[] - return prefetchFiles + if (shouldPrefetch === 'as-needed') { + // collect linked page chunk file names + const linkedFileNames = new Set() + for (const link of page.links) { + const routePath = resolveLinkRoutePath(link.absolute, app.options.base) + if (routePath) { + const targetChunks = pageChunkFilesMap.get(routePath) + if (targetChunks) { + for (const file of targetChunks) { + linkedFileNames.add(file) + } + } + } + } + // dynamic imports excluding current page chunks + // filtered to only linked pages' chunk files + candidateFiles = outputEntryChunk.dynamicImports.filter( + (item) => + linkedFileNames.has(item) && + !pageChunkFiles.some((file) => file === item), + ) + } else { + // dynamic imports excluding current page chunks + candidateFiles = outputEntryChunk.dynamicImports.filter( + (item) => !pageChunkFiles.some((file) => file === item), + ) + } + + return candidateFiles .map((item) => { // resolve file type const type = item.endsWith('.js') @@ -36,7 +67,11 @@ export const renderPagePrefetchLinks = ({ : '' // user wants to explicitly control what to prefetch - if (shouldPrefetch !== true && !shouldPrefetch(item, type)) { + if ( + shouldPrefetch !== true && + shouldPrefetch !== 'as-needed' && + !shouldPrefetch(item, type) + ) { return '' } return `` diff --git a/packages/bundler-vite/src/build/renderPagePreloadLinks.ts b/packages/bundler-vite/src/build/renderPagePreloadLinks.ts index 06b481c7fd..6a9740b1d1 100644 --- a/packages/bundler-vite/src/build/renderPagePreloadLinks.ts +++ b/packages/bundler-vite/src/build/renderPagePreloadLinks.ts @@ -1,4 +1,6 @@ -import type { App } from '@vuepress/core' +import type { PageChunkFilesMap } from '@vuepress/bundlerutils' +import { resolveLinkRoutePath } from '@vuepress/bundlerutils' +import type { App, Page } from '@vuepress/core' import type { OutputChunk } from 'rolldown' /** @@ -8,10 +10,14 @@ export const renderPagePreloadLinks = ({ app, outputEntryChunk, pageChunkFiles, + page, + pageChunkFilesMap, }: { app: App outputEntryChunk: OutputChunk pageChunkFiles: string[] + page: Page + pageChunkFilesMap: PageChunkFilesMap }): string => { // shouldPreload option const { shouldPreload } = app.options @@ -30,6 +36,23 @@ export const renderPagePreloadLinks = ({ ]), ) + // when 'as-needed', also add linked pages' chunk files + if (shouldPreload === 'as-needed') { + for (const link of page.links) { + const routePath = resolveLinkRoutePath(link.absolute, app.options.base) + if (routePath) { + const targetChunks = pageChunkFilesMap.get(routePath) + if (targetChunks) { + for (const file of targetChunks) { + if (!preloadFiles.includes(file)) { + preloadFiles.push(file) + } + } + } + } + } + } + return preloadFiles .map((item) => { // resolve file type @@ -45,7 +68,11 @@ export const renderPagePreloadLinks = ({ } // user wants to explicitly control what to preload - if (shouldPreload !== true && !shouldPreload(item, type)) { + if ( + shouldPreload !== true && + shouldPreload !== 'as-needed' && + !shouldPreload(item, type) + ) { return '' } diff --git a/packages/bundler-webpack/src/build/build.ts b/packages/bundler-webpack/src/build/build.ts index 9cb82786e4..254a3dabcc 100644 --- a/packages/bundler-webpack/src/build/build.ts +++ b/packages/bundler-webpack/src/build/build.ts @@ -1,3 +1,4 @@ +import type { PageChunkFilesMap } from '@vuepress/bundlerutils' import { createVueServerApp, getSsrTemplate } from '@vuepress/bundlerutils' import type { App, Bundler } from '@vuepress/core' import { colors, debug, fs, logger, withSpinner } from '@vuepress/utils' @@ -78,6 +79,16 @@ export const build = async ( const { initialFilesMeta, asyncFilesMeta, moduleFilesMetaMap } = resolveClientManifestMeta(clientManifest) + // build page path -> chunk files map for preload & prefetch + const pageChunkFilesMap: PageChunkFilesMap = new Map() + for (const page of app.pages) { + const pageFileNames = clientManifest.chunks[page.chunkName] ?? [] + + if (pageFileNames.length) { + pageChunkFilesMap.set(page.path, pageFileNames) + } + } + // create vue ssr app and get ssr template const { vueApp, vueRouter } = await createVueServerApp( app.dir.temp('.server/app.cjs'), @@ -98,6 +109,7 @@ export const build = async ( initialFilesMeta, asyncFilesMeta, moduleFilesMetaMap, + pageChunkFilesMap, }) } }) diff --git a/packages/bundler-webpack/src/build/createClientPlugin.ts b/packages/bundler-webpack/src/build/createClientPlugin.ts index 8e39036dd0..ef878c273c 100644 --- a/packages/bundler-webpack/src/build/createClientPlugin.ts +++ b/packages/bundler-webpack/src/build/createClientPlugin.ts @@ -90,12 +90,23 @@ export const createClientPlugin = ( if (request) manifestModules[request] = files }) + // build chunk name -> file names mapping + const chunkFiles: Record = {} + chunks.forEach((chunk) => { + if (chunk.names.length && chunk.files.length) { + chunk.names.forEach((name) => { + chunkFiles[name] = [...(chunkFiles[name] ?? []), ...chunk.files] + }) + } + }) + // generate client manifest json file const clientManifest: ClientManifest = { all: allFiles, initial: initialFiles, async: asyncFiles, modules: manifestModules, + chunks: chunkFiles, } const clientManifestJson = JSON.stringify(clientManifest, null, 2) diff --git a/packages/bundler-webpack/src/build/renderPage.ts b/packages/bundler-webpack/src/build/renderPage.ts index 2608faff9e..135029142b 100644 --- a/packages/bundler-webpack/src/build/renderPage.ts +++ b/packages/bundler-webpack/src/build/renderPage.ts @@ -1,4 +1,4 @@ -import type { PageSSRContext } from '@vuepress/bundlerutils' +import type { PageChunkFilesMap, PageSSRContext } from '@vuepress/bundlerutils' import { renderPageToString } from '@vuepress/bundlerutils' import type { App, Page } from '@vuepress/core' import { fs, renderHead } from '@vuepress/utils' @@ -33,6 +33,7 @@ export const renderPage = async ({ initialFilesMeta, asyncFilesMeta, moduleFilesMetaMap, + pageChunkFilesMap, }: { app: App page: Page @@ -42,6 +43,7 @@ export const renderPage = async ({ initialFilesMeta: FileMeta[] asyncFilesMeta: FileMeta[] moduleFilesMetaMap: ModuleFilesMetaMap + pageChunkFilesMap: PageChunkFilesMap }): Promise => { // render current page to string const { ssrContext, ssrString } = @@ -67,11 +69,15 @@ export const renderPage = async ({ app, asyncFilesMeta, pageClientFilesMeta, + page, + pageChunkFilesMap, }), preload: renderPagePreloadLinks({ app, initialFilesMeta, pageClientFilesMeta, + page, + pageChunkFilesMap, }), scripts: renderPageScripts({ app, initialFilesMeta, pageClientFilesMeta }), styles: renderPageStyles({ app, initialFilesMeta, pageClientFilesMeta }), diff --git a/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts b/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts index 810fd115d0..2cf3754bf0 100644 --- a/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts +++ b/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts @@ -1,4 +1,6 @@ -import type { App } from '@vuepress/core' +import type { PageChunkFilesMap } from '@vuepress/bundlerutils' +import { resolveLinkRoutePath } from '@vuepress/bundlerutils' +import type { App, Page } from '@vuepress/core' import type { FileMeta } from './types.js' @@ -9,10 +11,14 @@ export const renderPagePrefetchLinks = ({ app, asyncFilesMeta, pageClientFilesMeta, + page, + pageChunkFilesMap, }: { app: App asyncFilesMeta: FileMeta[] pageClientFilesMeta: FileMeta[] + page: Page + pageChunkFilesMap: PageChunkFilesMap }): string => { // shouldPrefetch option const { shouldPrefetch } = app.options @@ -22,15 +28,44 @@ export const renderPagePrefetchLinks = ({ return '' } - // async files excluding files used by current page should be prefetch - const prefetchFilesMeta = asyncFilesMeta.filter( - ({ file }) => !pageClientFilesMeta.some((f) => f.file === file), - ) + let prefetchFilesMeta: FileMeta[] + + if (shouldPrefetch === 'as-needed') { + // collect linked page chunk file names + const linkedFileNames = new Set() + for (const link of page.links) { + const routePath = resolveLinkRoutePath(link.absolute, app.options.base) + if (routePath) { + const targetChunks = pageChunkFilesMap.get(routePath) + if (targetChunks) { + for (const file of targetChunks) { + linkedFileNames.add(file) + } + } + } + } + // async files excluding files used by current page + // filtered to only linked pages' chunk files + prefetchFilesMeta = asyncFilesMeta.filter( + ({ file }) => + linkedFileNames.has(file) && + !pageClientFilesMeta.some((f) => f.file === file), + ) + } else { + // async files excluding files used by current page should be prefetch + prefetchFilesMeta = asyncFilesMeta.filter( + ({ file }) => !pageClientFilesMeta.some((f) => f.file === file), + ) + } return prefetchFilesMeta .map(({ file, type }) => { // user wants to explicitly control what to prefetch - if (shouldPrefetch !== true && !shouldPrefetch(file, type)) { + if ( + shouldPrefetch !== true && + shouldPrefetch !== 'as-needed' && + !shouldPrefetch(file, type) + ) { return '' } return `` diff --git a/packages/bundler-webpack/src/build/renderPagePreloadLinks.ts b/packages/bundler-webpack/src/build/renderPagePreloadLinks.ts index e3dda92dee..d817209116 100644 --- a/packages/bundler-webpack/src/build/renderPagePreloadLinks.ts +++ b/packages/bundler-webpack/src/build/renderPagePreloadLinks.ts @@ -1,5 +1,8 @@ -import type { App } from '@vuepress/core' +import type { PageChunkFilesMap } from '@vuepress/bundlerutils' +import { resolveLinkRoutePath } from '@vuepress/bundlerutils' +import type { App, Page } from '@vuepress/core' +import { resolveFileMeta } from './resolveFileMeta.js' import type { FileMeta } from './types.js' /** @@ -9,10 +12,14 @@ export const renderPagePreloadLinks = ({ app, initialFilesMeta, pageClientFilesMeta, + page, + pageChunkFilesMap, }: { app: App initialFilesMeta: FileMeta[] pageClientFilesMeta: FileMeta[] + page: Page + pageChunkFilesMap: PageChunkFilesMap }): string => { // shouldPreload option const { shouldPreload } = app.options @@ -25,6 +32,27 @@ export const renderPagePreloadLinks = ({ // initial files and files used by current page should be preload const preloadFilesMeta = [...initialFilesMeta, ...pageClientFilesMeta] + // when 'as-needed', also add linked pages' chunk files + if (shouldPreload === 'as-needed') { + const linkedFileNames = new Set() + for (const link of page.links) { + const routePath = resolveLinkRoutePath(link.absolute, app.options.base) + if (routePath) { + const targetChunks = pageChunkFilesMap.get(routePath) + if (targetChunks) { + for (const file of targetChunks) { + linkedFileNames.add(file) + } + } + } + } + for (const fileName of linkedFileNames) { + if (!preloadFilesMeta.some((f) => f.file === fileName)) { + preloadFilesMeta.push(resolveFileMeta(fileName)) + } + } + } + return preloadFilesMeta .map(({ file, extension, type }) => { // by default, we only preload scripts or css @@ -33,7 +61,11 @@ export const renderPagePreloadLinks = ({ } // user wants to explicitly control what to preload - if (shouldPreload !== true && !shouldPreload(file, type)) { + if ( + shouldPreload !== true && + shouldPreload !== 'as-needed' && + !shouldPreload(file, type) + ) { return '' } diff --git a/packages/bundler-webpack/src/build/types.ts b/packages/bundler-webpack/src/build/types.ts index 5eaf515096..e81feaf5d6 100644 --- a/packages/bundler-webpack/src/build/types.ts +++ b/packages/bundler-webpack/src/build/types.ts @@ -36,4 +36,8 @@ export interface ClientManifest { initial: string[] async: string[] modules: Record + /** + * Chunk name to output file names mapping + */ + chunks: Record } diff --git a/packages/bundlerutils/src/build/index.ts b/packages/bundlerutils/src/build/index.ts index a3e53e9f79..aeb819eb73 100644 --- a/packages/bundlerutils/src/build/index.ts +++ b/packages/bundlerutils/src/build/index.ts @@ -1,3 +1,5 @@ export * from './createVueServerApp' export * from './getSsrTemplate' export * from './renderPageToString' +export * from './resolveLinkRoutePath' +export type * from './types' diff --git a/packages/bundlerutils/src/build/resolveLinkRoutePath.ts b/packages/bundlerutils/src/build/resolveLinkRoutePath.ts new file mode 100644 index 0000000000..446e2f45e1 --- /dev/null +++ b/packages/bundlerutils/src/build/resolveLinkRoutePath.ts @@ -0,0 +1,21 @@ +import { inferRoutePath } from '@vuepress/shared' + +/** + * Normalize a MarkdownLink's absolute path to a route path + * that can be matched against Page.path. + * + * Returns null if the link is unresolvable. + */ +export const resolveLinkRoutePath = ( + absolute: string | null, + base: string, +): string | null => { + if (!absolute) return null + + // Strip base prefix, ensuring leading slash + const pathWithoutBase = absolute.startsWith(base) + ? `/${absolute.slice(base.length)}` + : absolute + + return inferRoutePath(pathWithoutBase) +} diff --git a/packages/bundlerutils/src/build/types.ts b/packages/bundlerutils/src/build/types.ts new file mode 100644 index 0000000000..44abc170fd --- /dev/null +++ b/packages/bundlerutils/src/build/types.ts @@ -0,0 +1,5 @@ +/** + * Map from page route path to its chunk output file names. + * Used by prefetch/preload strategy. + */ +export type PageChunkFilesMap = Map diff --git a/packages/core/src/app/resolveAppOptions.ts b/packages/core/src/app/resolveAppOptions.ts index 12f9ee4bb5..3f0f477ccf 100644 --- a/packages/core/src/app/resolveAppOptions.ts +++ b/packages/core/src/app/resolveAppOptions.ts @@ -33,8 +33,8 @@ export const resolveAppOptions = ({ require.resolve('@vuepress/client/templates/dev.html'), ), // build config - shouldPreload = true, - shouldPrefetch = true, + shouldPreload = 'as-needed', + shouldPrefetch = 'as-needed', templateBuild = path.normalize( require.resolve('@vuepress/client/templates/build.html'), ), diff --git a/packages/core/src/types/app/options.ts b/packages/core/src/types/app/options.ts index 7b47c6bae0..382d4423d4 100644 --- a/packages/core/src/types/app/options.ts +++ b/packages/core/src/types/app/options.ts @@ -151,17 +151,23 @@ export interface AppConfigBuild { * Determine what resource files should be preloaded. Use boolean value to * totally enable / disable. * - * @default true + * @default 'as-needed' */ - shouldPreload?: boolean | ((file: string, type: string) => boolean) + shouldPreload?: + | boolean + | 'as-needed' + | ((file: string, type: string) => boolean) /** * Determine what resource files should be prefetched. Use boolean value to * totally enable / disable. * - * @default true + * @default 'as-needed' */ - shouldPrefetch?: boolean | ((file: string, type: string) => boolean) + shouldPrefetch?: + | boolean + | 'as-needed' + | ((file: string, type: string) => boolean) /** * Specify the path of the HTML template to be used for build diff --git a/packages/core/tests/app/resolveAppOptions.spec.ts b/packages/core/tests/app/resolveAppOptions.spec.ts index ab3033c435..de1e0c25e4 100644 --- a/packages/core/tests/app/resolveAppOptions.spec.ts +++ b/packages/core/tests/app/resolveAppOptions.spec.ts @@ -43,8 +43,8 @@ it('should create app options with default values', () => { require.resolve('@vuepress/client/templates/build.html'), ), templateBuildRenderer: templateRenderer, - shouldPreload: true, - shouldPrefetch: true, + shouldPreload: 'as-needed', + shouldPrefetch: 'as-needed', markdown: {}, plugins: [], })