diff --git a/e2e/docs/.vuepress/config.ts b/e2e/docs/.vuepress/config.ts index 4e9fada490..d670adfb49 100644 --- a/e2e/docs/.vuepress/config.ts +++ b/e2e/docs/.vuepress/config.ts @@ -2,7 +2,7 @@ import process from 'node:process' import { viteBundler } from '@vuepress/bundler-vite' import { webpackBundler } from '@vuepress/bundler-webpack' -import { defineUserConfig } from 'vuepress' +import { createPage, defineUserConfig } from 'vuepress' import { path } from 'vuepress/utils' import { fooPlugin } from './plugins/foo/fooPlugin.js' @@ -78,6 +78,21 @@ export default defineUserConfig({ } }, + onInitialized: async (app) => { + app.pages.push( + await createPage(app, { + path: '/resource-hints/virtual-source.html', + content: [ + '# Virtual Resource Hints Source', + '[Linked page without a permalink](./linked.md)', + '[Linked page with a permalink](./linked-permalink.md)', + '[Linked page whose exact path collides with a redirect](./redirect-collision.md)', + '[Linked page with extracted CSS](../styles/css-container.md)', + ].join('\n\n'), + }), + ) + }, + plugins: [fooPlugin], // The alias entries are intentionally ordered by key length to ensure diff --git a/e2e/docs/resource-hints/linked-permalink.md b/e2e/docs/resource-hints/linked-permalink.md new file mode 100644 index 0000000000..2ab4081f65 --- /dev/null +++ b/e2e/docs/resource-hints/linked-permalink.md @@ -0,0 +1,5 @@ +--- +permalink: /resource-hints/linked-permalink/ +--- + +# Resource Hints Linked Permalink Page diff --git a/e2e/docs/resource-hints/linked.md b/e2e/docs/resource-hints/linked.md new file mode 100644 index 0000000000..3f50f6b0d2 --- /dev/null +++ b/e2e/docs/resource-hints/linked.md @@ -0,0 +1 @@ +# Resource Hints Linked Page diff --git a/e2e/docs/resource-hints/redirect-collision-exact.md b/e2e/docs/resource-hints/redirect-collision-exact.md new file mode 100644 index 0000000000..aa485dfc86 --- /dev/null +++ b/e2e/docs/resource-hints/redirect-collision-exact.md @@ -0,0 +1,5 @@ +--- +permalink: /resource-hints/redirect-collision.html +--- + +# Exact Resource Hints Page diff --git a/e2e/docs/resource-hints/redirect-collision.md b/e2e/docs/resource-hints/redirect-collision.md new file mode 100644 index 0000000000..3331c7baef --- /dev/null +++ b/e2e/docs/resource-hints/redirect-collision.md @@ -0,0 +1,5 @@ +--- +permalink: /resource-hints/redirect-collision-permalink/ +--- + +# Resource Hints Redirect Collision diff --git a/e2e/docs/resource-hints/source.md b/e2e/docs/resource-hints/source.md new file mode 100644 index 0000000000..843faae915 --- /dev/null +++ b/e2e/docs/resource-hints/source.md @@ -0,0 +1,9 @@ +# Resource Hints Source + +[Linked page without a permalink](./linked.md) + +[Linked page with a permalink](./linked-permalink.md) + +[Linked page whose exact path collides with a redirect](./redirect-collision.md) + +[Linked page with extracted CSS](../styles/css-container.md) diff --git a/e2e/docs/resource-hints/unlinked.md b/e2e/docs/resource-hints/unlinked.md new file mode 100644 index 0000000000..1636a558dd --- /dev/null +++ b/e2e/docs/resource-hints/unlinked.md @@ -0,0 +1 @@ +# Resource Hints Unlinked Page diff --git a/e2e/tests/resource-hints.spec.ts b/e2e/tests/resource-hints.spec.ts new file mode 100644 index 0000000000..7ba0d1f5cb --- /dev/null +++ b/e2e/tests/resource-hints.spec.ts @@ -0,0 +1,216 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' + +import { BUNDLER, IS_PROD } from '../utils/env' + +interface PageResourceInfo { + prefetch: string[] + preload: string[] + resources: string[] +} + +const collectPageResourceInfo = async ( + page: Page, + path: string, +): Promise => { + await page.goto(path) + await page.waitForLoadState('networkidle') + + const links = await page + .locator('head link[rel][href]') + .evaluateAll((elements) => + elements.map((element) => ({ + href: element.getAttribute('href') ?? '', + rel: element.getAttribute('rel') ?? '', + })), + ) + const resources = await page.evaluate(() => + performance + .getEntriesByType('resource') + .map(({ name }) => new URL(name).pathname), + ) + + return { + prefetch: links + .filter(({ rel }) => rel === 'prefetch') + .map(({ href }) => href), + preload: links + .filter(({ rel }) => rel === 'preload' || rel === 'modulepreload') + .map(({ href }) => href), + resources: resources.filter((file) => /\.(?:css|js)$/.test(file)), + } +} + +const difference = (values: string[], otherValues: string[]): string[] => + values.filter((value) => !otherValues.includes(value)) + +const EXTRACTED_CSS_PAGE_PATH = 'styles/css-container.html' + +const LINKED_PAGE_CASES = [ + { + name: 'without a permalink', + path: 'resource-hints/linked.html', + }, + { + name: 'with a permalink', + path: 'resource-hints/linked-permalink/', + }, + { + name: 'with an exact path that collides with a redirect', + path: 'resource-hints/redirect-collision.html', + }, + { + name: 'with extracted CSS', + path: EXTRACTED_CSS_PAGE_PATH, + }, +] as const + +if (IS_PROD) { + test.describe('default resource hints', () => { + let allLinkedPageFiles: string[] + let linkedPageFiles: Map + let sourceInfo: PageResourceInfo + let sourcePageFiles: string[] + let unlinkedPageFiles: string[] + let virtualSourceInfo: PageResourceInfo + let virtualSourcePageFiles: string[] + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + + const linkedInfos = new Map() + for (const linkedPageCase of LINKED_PAGE_CASES) { + linkedInfos.set( + linkedPageCase.path, + await collectPageResourceInfo(page, linkedPageCase.path), + ) + } + const unlinkedInfo = await collectPageResourceInfo( + page, + 'resource-hints/unlinked.html', + ) + sourceInfo = await collectPageResourceInfo( + page, + 'resource-hints/source.html', + ) + virtualSourceInfo = await collectPageResourceInfo( + page, + 'resource-hints/virtual-source.html', + ) + + const allLinkedPageResources = [ + ...new Set( + [...linkedInfos.values()].flatMap(({ resources }) => resources), + ), + ] + linkedPageFiles = new Map( + [...linkedInfos].map(([path, { resources }]) => [ + path, + difference(resources, unlinkedInfo.resources), + ]), + ) + allLinkedPageFiles = difference( + allLinkedPageResources, + unlinkedInfo.resources, + ) + unlinkedPageFiles = difference( + unlinkedInfo.resources, + allLinkedPageResources, + ) + sourcePageFiles = sourceInfo.resources.filter( + (file) => + !allLinkedPageResources.includes(file) && + !unlinkedInfo.resources.includes(file), + ) + virtualSourcePageFiles = virtualSourceInfo.resources.filter( + (file) => + !allLinkedPageResources.includes(file) && + !unlinkedInfo.resources.includes(file), + ) + + await page.close() + }) + + LINKED_PAGE_CASES.forEach((linkedPageCase) => { + test(`should not preload linked page files ${linkedPageCase.name}`, () => { + const pageFiles = linkedPageFiles.get(linkedPageCase.path) ?? [] + + expect(pageFiles.length).toBeGreaterThan(0) + pageFiles.forEach((file) => { + expect(sourceInfo.preload).not.toContain(file) + }) + }) + + test(`should prefetch linked page files ${linkedPageCase.name}`, () => { + const pageFiles = linkedPageFiles.get(linkedPageCase.path) ?? [] + + expect(pageFiles.length).toBeGreaterThan(0) + pageFiles.forEach((file) => { + expect(sourceInfo.prefetch).toContain(file) + }) + }) + }) + + if (BUNDLER === 'webpack') { + test('should prefetch extracted CSS required by a linked page', () => { + const cssFiles = ( + linkedPageFiles.get(EXTRACTED_CSS_PAGE_PATH) ?? [] + ).filter((file) => file.endsWith('.css')) + + expect(cssFiles.length).toBeGreaterThan(0) + cssFiles.forEach((file) => { + expect(sourceInfo.prefetch).toContain(file) + }) + }) + } + + test('should not preload unlinked page files', () => { + expect(unlinkedPageFiles.length).toBeGreaterThan(0) + + unlinkedPageFiles.forEach((file) => { + expect(sourceInfo.preload).not.toContain(file) + }) + }) + + test('should only prefetch linked page files', () => { + expect(sourcePageFiles.length).toBeGreaterThan(0) + expect(sourceInfo.prefetch.length).toBeGreaterThan(0) + + sourcePageFiles.forEach((file) => { + expect(sourceInfo.prefetch).not.toContain(file) + }) + unlinkedPageFiles.forEach((file) => { + expect(sourceInfo.prefetch).not.toContain(file) + }) + sourceInfo.prefetch.forEach((file) => { + expect(allLinkedPageFiles).toContain(file) + }) + }) + + test('should not preload linked page files from a virtual page', () => { + expect(allLinkedPageFiles.length).toBeGreaterThan(0) + + allLinkedPageFiles.forEach((file) => { + expect(virtualSourceInfo.preload).not.toContain(file) + }) + }) + + test('should only prefetch linked page files from a virtual page', () => { + expect(virtualSourcePageFiles.length).toBeGreaterThan(0) + expect(virtualSourceInfo.prefetch.length).toBeGreaterThan(0) + + allLinkedPageFiles.forEach((file) => { + expect(virtualSourceInfo.prefetch).toContain(file) + }) + virtualSourcePageFiles.forEach((file) => { + expect(virtualSourceInfo.prefetch).not.toContain(file) + }) + unlinkedPageFiles.forEach((file) => { + expect(virtualSourceInfo.prefetch).not.toContain(file) + }) + virtualSourceInfo.prefetch.forEach((file) => { + expect(allLinkedPageFiles).toContain(file) + }) + }) + }) +} diff --git a/packages/bundler-vite/src/build/build.ts b/packages/bundler-vite/src/build/build.ts index 3cc7e9c922..aa9a40a82d 100644 --- a/packages/bundler-vite/src/build/build.ts +++ b/packages/bundler-vite/src/build/build.ts @@ -1,4 +1,8 @@ -import { createVueServerApp, getSsrTemplate } from '@vuepress/bundlerutils' +import { + createPageChunkFilesMap, + createVueServerApp, + getSsrTemplate, +} from '@vuepress/bundlerutils' import type { App, Bundler } from '@vuepress/core' import { colors, debug, fs, withSpinner } from '@vuepress/utils' import type { OutputAsset, OutputChunk, RolldownOutput } from 'rolldown' @@ -7,6 +11,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 +70,13 @@ export const build = async ( ) const ssrTemplate = await getSsrTemplate(app) + // build page path -> chunk files map for preload & prefetch + const pageChunkFilesMap = createPageChunkFilesMap({ + pages: app.pages, + resolvePageChunkFiles: (page) => + 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 +89,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..599b7292fa 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({ @@ -48,7 +51,9 @@ export const renderPage = async ({ prefetch: renderPagePrefetchLinks({ app, outputEntryChunk, + page, pageChunkFiles, + pageChunkFilesMap, }), preload: renderPagePreloadLinks({ app, diff --git a/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts b/packages/bundler-vite/src/build/renderPagePrefetchLinks.ts index ca7299fc58..b339f0a60c 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 { resolveLinkedPageChunkFiles } from '@vuepress/bundlerutils' +import type { App, Page } from '@vuepress/core' import type { OutputChunk } from 'rolldown' /** @@ -7,11 +9,15 @@ import type { OutputChunk } from 'rolldown' export const renderPagePrefetchLinks = ({ app, outputEntryChunk, + page, pageChunkFiles, + pageChunkFilesMap, }: { app: App outputEntryChunk: OutputChunk + page: Page pageChunkFiles: string[] + pageChunkFilesMap: PageChunkFilesMap }): string => { // shouldPrefetch option const { shouldPrefetch } = app.options @@ -21,12 +27,30 @@ 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') { + const linkedPageChunkFiles = resolveLinkedPageChunkFiles({ + base: app.options.base, + page, + pageChunkFilesMap, + }) + + // dynamic imports excluding current page chunks + // filtered to only linked pages' chunk files + candidateFiles = outputEntryChunk.dynamicImports.filter( + (item) => + linkedPageChunkFiles.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 +60,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-webpack/src/build/build.ts b/packages/bundler-webpack/src/build/build.ts index 9cb82786e4..081403ee0d 100644 --- a/packages/bundler-webpack/src/build/build.ts +++ b/packages/bundler-webpack/src/build/build.ts @@ -1,4 +1,8 @@ -import { createVueServerApp, getSsrTemplate } from '@vuepress/bundlerutils' +import { + createPageChunkFilesMap, + createVueServerApp, + getSsrTemplate, +} from '@vuepress/bundlerutils' import type { App, Bundler } from '@vuepress/core' import { colors, debug, fs, logger, withSpinner } from '@vuepress/utils' import type { MultiConfiguration } from 'webpack' @@ -78,6 +82,13 @@ export const build = async ( const { initialFilesMeta, asyncFilesMeta, moduleFilesMetaMap } = resolveClientManifestMeta(clientManifest) + // build page path -> chunk files map for preload & prefetch + const pageChunkFilesMap = createPageChunkFilesMap({ + pages: app.pages, + resolvePageChunkFiles: (page) => + clientManifest.chunks[page.chunkName] ?? [], + }) + // 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..a5d5cb41f9 100644 --- a/packages/bundler-webpack/src/build/createClientPlugin.ts +++ b/packages/bundler-webpack/src/build/createClientPlugin.ts @@ -90,12 +90,21 @@ export const createClientPlugin = ( if (request) manifestModules[request] = files }) + // build chunk group name -> file names mapping + const chunkFiles = Object.fromEntries( + Array.from(compilation.namedChunkGroups, ([name, chunkGroup]) => [ + name, + chunkGroup.getFiles(), + ]), + ) + // 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..f5413d031e 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 } = @@ -66,7 +68,9 @@ export const renderPage = async ({ prefetch: renderPagePrefetchLinks({ app, asyncFilesMeta, + page, pageClientFilesMeta, + pageChunkFilesMap, }), preload: renderPagePreloadLinks({ app, diff --git a/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts b/packages/bundler-webpack/src/build/renderPagePrefetchLinks.ts index 810fd115d0..6172d9197c 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 { resolveLinkedPageChunkFiles } from '@vuepress/bundlerutils' +import type { App, Page } from '@vuepress/core' import type { FileMeta } from './types.js' @@ -8,11 +10,15 @@ import type { FileMeta } from './types.js' export const renderPagePrefetchLinks = ({ app, asyncFilesMeta, + page, pageClientFilesMeta, + pageChunkFilesMap, }: { app: App asyncFilesMeta: FileMeta[] + page: Page pageClientFilesMeta: FileMeta[] + pageChunkFilesMap: PageChunkFilesMap }): string => { // shouldPrefetch option const { shouldPrefetch } = app.options @@ -22,15 +28,37 @@ 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') { + const linkedPageChunkFiles = resolveLinkedPageChunkFiles({ + base: app.options.base, + page, + pageChunkFilesMap, + }) + + // async files excluding files used by current page + // filtered to only linked pages' chunk files + prefetchFilesMeta = asyncFilesMeta.filter( + ({ file }) => + linkedPageChunkFiles.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/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/package.json b/packages/bundlerutils/package.json index 6f9459015f..26f168244e 100644 --- a/packages/bundlerutils/package.json +++ b/packages/bundlerutils/package.json @@ -38,6 +38,7 @@ "dependencies": { "@vuepress/client": "workspace:*", "@vuepress/core": "workspace:*", + "@vuepress/markdown": "workspace:*", "@vuepress/shared": "workspace:*", "@vuepress/utils": "workspace:*", "vue": "catalog:vue", diff --git a/packages/bundlerutils/src/build/createPageChunkFilesMap.ts b/packages/bundlerutils/src/build/createPageChunkFilesMap.ts new file mode 100644 index 0000000000..299076a263 --- /dev/null +++ b/packages/bundlerutils/src/build/createPageChunkFilesMap.ts @@ -0,0 +1,32 @@ +import type { Page } from '@vuepress/core' +import { resolvePageRedirects } from '@vuepress/core' + +import type { PageChunkFilesMap } from './types.js' + +/** + * Create a map from page route paths to their chunk output file names. + */ +export const createPageChunkFilesMap = ({ + pages, + resolvePageChunkFiles, +}: { + pages: Page[] + resolvePageChunkFiles: (page: Page) => string[] +}): PageChunkFilesMap => { + const pageChunkFilesMap: PageChunkFilesMap = new Map() + const pagePaths = new Set(pages.map((page) => page.path)) + + for (const page of pages) { + const pageChunkFiles = resolvePageChunkFiles(page) + + pageChunkFilesMap.set(page.path, pageChunkFiles) + for (const redirect of resolvePageRedirects(page)) { + // Exact page paths take precedence over redirects in the client router. + if (!pagePaths.has(redirect)) { + pageChunkFilesMap.set(redirect, pageChunkFiles) + } + } + } + + return pageChunkFilesMap +} diff --git a/packages/bundlerutils/src/build/index.ts b/packages/bundlerutils/src/build/index.ts index a3e53e9f79..18abd8db83 100644 --- a/packages/bundlerutils/src/build/index.ts +++ b/packages/bundlerutils/src/build/index.ts @@ -1,3 +1,7 @@ +export * from './createPageChunkFilesMap' export * from './createVueServerApp' export * from './getSsrTemplate' export * from './renderPageToString' +export * from './resolveLinkRoutePath' +export * from './resolveLinkedPageChunkFiles' +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..42cbe1a9e8 --- /dev/null +++ b/packages/bundlerutils/src/build/resolveLinkRoutePath.ts @@ -0,0 +1,29 @@ +import type { MarkdownLink } from '@vuepress/markdown' +import { inferRoutePath, normalizeRoutePath } from '@vuepress/shared' + +/** + * Normalize a MarkdownLink to a route path + * that can be matched against Page.path. + * + * Returns null if the link is unresolvable. + */ +export const resolveLinkRoutePath = ({ + base, + current, + link, +}: { + base: string + current: string + link: MarkdownLink +}): string | null => { + if (!link.absolute) { + return link.relative ? normalizeRoutePath(link.relative, current) : null + } + + // Strip base prefix, ensuring leading slash + const pathWithoutBase = link.absolute.startsWith(base) + ? `/${link.absolute.slice(base.length)}` + : link.absolute + + return inferRoutePath(pathWithoutBase) +} diff --git a/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts b/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts new file mode 100644 index 0000000000..1d209c1b4d --- /dev/null +++ b/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts @@ -0,0 +1,37 @@ +import type { Page } from '@vuepress/core' + +import { resolveLinkRoutePath } from './resolveLinkRoutePath.js' +import type { PageChunkFilesMap } from './types.js' + +/** + * Resolve the chunk output file names of pages linked from the current page. + */ +export const resolveLinkedPageChunkFiles = ({ + base, + page, + pageChunkFilesMap, +}: { + base: string + page: Page + pageChunkFilesMap: PageChunkFilesMap +}): Set => { + const linkedPageChunkFiles = new Set() + + for (const link of page.links) { + const routePath = resolveLinkRoutePath({ + base, + current: page.path, + link, + }) + if (routePath) { + const targetChunks = pageChunkFilesMap.get(routePath) + if (targetChunks) { + for (const file of targetChunks) { + linkedPageChunkFiles.add(file) + } + } + } + } + + return linkedPageChunkFiles +} 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/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts b/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts new file mode 100644 index 0000000000..9d561838b9 --- /dev/null +++ b/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts @@ -0,0 +1,80 @@ +import type { Page } from '@vuepress/core' +import { expect, test, vi } from 'vitest' + +import { createPageChunkFilesMap } from '../../src/index.js' + +const createPage = ( + page: Pick, +): Page => page as Page + +test('should map final paths and inferred route aliases to page chunks', () => { + const pages = [ + createPage({ + chunkName: 'permalink', + path: '/permalink/', + pathInferred: '/posts/foo.md', + }), + createPage({ + chunkName: 'custom-path', + path: '/custom/', + pathInferred: '/中文/README.md', + }), + createPage({ + chunkName: 'regular', + path: '/regular.html', + pathInferred: '/regular.md', + }), + createPage({ + chunkName: 'virtual', + path: '/virtual.html', + pathInferred: null, + }), + ] + + expect( + createPageChunkFilesMap({ + pages, + resolvePageChunkFiles: (page) => [`${page.chunkName}.js`], + }), + ).toEqual( + new Map([ + ['/permalink/', ['permalink.js']], + ['/posts/foo.html', ['permalink.js']], + ['/custom/', ['custom-path.js']], + ['/%E4%B8%AD%E6%96%87/', ['custom-path.js']], + ['/regular.html', ['regular.js']], + ['/virtual.html', ['virtual.js']], + ]), + ) +}) + +const exactPage = createPage({ + chunkName: 'exact', + path: '/legacy.html', + pathInferred: null, +}) +const redirectingPage = createPage({ + chunkName: 'permalink', + path: '/permalink/', + pathInferred: '/legacy.md', +}) + +test.for([ + { name: 'before', pages: [exactPage, redirectingPage] }, + { name: 'after', pages: [redirectingPage, exactPage] }, +])( + 'should prioritize an exact page path when it appears $name its redirect alias', + ({ pages }) => { + const resolvePageChunkFiles = vi.fn((page: Page) => [ + `${page.chunkName}.js`, + ]) + + expect( + createPageChunkFilesMap({ + pages, + resolvePageChunkFiles, + }).get('/legacy.html'), + ).toEqual(['exact.js']) + expect(resolvePageChunkFiles).toHaveBeenCalledTimes(pages.length) + }, +) diff --git a/packages/bundlerutils/tests/build/resolveLinkRoutePath.spec.ts b/packages/bundlerutils/tests/build/resolveLinkRoutePath.spec.ts new file mode 100644 index 0000000000..4e1970f8c7 --- /dev/null +++ b/packages/bundlerutils/tests/build/resolveLinkRoutePath.spec.ts @@ -0,0 +1,59 @@ +import type { MarkdownLink } from '@vuepress/markdown' +import { expect, test } from 'vitest' + +import { resolveLinkRoutePath } from '../../src/index.js' + +const TEST_CASES = [ + [{ absolute: null, relative: '' }, '/', '/', null], + [{ absolute: '/foo.md', relative: 'foo.md' }, '/', '/', '/foo.html'], + [ + { absolute: '/foo/README.md', relative: 'foo/README.md' }, + '/', + '/', + '/foo/', + ], + [ + { absolute: '/base/foo.md', relative: 'foo.md' }, + '/base/', + '/', + '/foo.html', + ], + [ + { absolute: '/base/foo/index.html', relative: 'foo/index.html' }, + '/base/', + '/', + '/foo/', + ], + [ + { absolute: '/base/base/foo.md', relative: 'base/foo.md' }, + '/base/', + '/', + '/base/foo.html', + ], + [{ absolute: '/foo.md', relative: 'foo.md' }, '/base/', '/', '/foo.html'], + [ + { absolute: null, relative: 'foo.md' }, + '/base/', + '/virtual/page.html', + '/virtual/foo.html', + ], + [ + { absolute: null, relative: '../foo.md' }, + '/', + '/virtual/page.html', + '/foo.html', + ], + [ + { absolute: null, relative: 'foo/README.md' }, + '/', + '/virtual/', + '/virtual/foo/', + ], +] as [MarkdownLink, string, string, string | null][] + +test.for(TEST_CASES)( + 'should resolve $0 with base $1 and current route $2 to $3', + ([link, base, current, expected]) => { + expect(resolveLinkRoutePath({ base, current, link })).toBe(expected) + }, +) diff --git a/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts b/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts new file mode 100644 index 0000000000..dfee3b1f6b --- /dev/null +++ b/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts @@ -0,0 +1,103 @@ +import type { Bundler, Page } from '@vuepress/core' +import { createBaseApp, createPage as createVuePressPage } from '@vuepress/core' +import { path } from '@vuepress/utils' +import { expect, test } from 'vitest' + +import type { PageChunkFilesMap } from '../../src/index.js' +import { resolveLinkedPageChunkFiles } from '../../src/index.js' + +const createPage = (links: Page['links'], pagePath = '/current.html'): Page => + ({ links, path: pagePath }) as Page + +test('should resolve and dedupe linked page chunk files', () => { + const page = createPage([ + { + raw: './foo.md', + relative: 'guide/foo.md', + absolute: '/base/guide/foo.md', + }, + { + raw: './bar/README.md', + relative: 'guide/bar/README.md', + absolute: '/base/guide/bar/README.md', + }, + { + raw: './foo.md', + relative: 'guide/foo.md', + absolute: '/base/guide/foo.md', + }, + ]) + const pageChunkFilesMap: PageChunkFilesMap = new Map([ + ['/guide/foo.html', ['foo.js', 'shared.js']], + ['/guide/bar/', ['bar.js', 'shared.js']], + ]) + + expect( + resolveLinkedPageChunkFiles({ + base: '/base/', + page, + pageChunkFilesMap, + }), + ).toEqual(new Set(['foo.js', 'shared.js', 'bar.js'])) +}) + +test('should resolve relative links from virtual pages against the current route', async () => { + const app = createBaseApp({ + source: path.resolve(__dirname, 'fake-source'), + theme: { name: 'test' }, + bundler: {} as Bundler, + }) + await app.init() + + const page = await createVuePressPage(app, { + path: '/guide/virtual.html', + content: ['[foo](./foo.md)', '[bar](../bar/README.md)'].join('\n\n'), + }) + const pageChunkFilesMap: PageChunkFilesMap = new Map([ + ['/guide/foo.html', ['foo.js', 'shared.js']], + ['/bar/', ['bar.js', 'shared.js']], + ]) + + expect(page.links).toEqual([ + { + raw: './foo.md', + relative: 'foo.md', + absolute: null, + }, + { + raw: '../bar/README.md', + relative: '../bar/README.md', + absolute: null, + }, + ]) + expect( + resolveLinkedPageChunkFiles({ + base: '/', + page, + pageChunkFilesMap, + }), + ).toEqual(new Set(['foo.js', 'shared.js', 'bar.js'])) +}) + +test('should ignore unresolvable links and links without mapped chunks', () => { + const page = createPage([ + { + raw: '', + relative: '', + absolute: null, + }, + { + raw: './missing.md', + relative: 'missing.md', + absolute: '/missing.md', + }, + ]) + + expect( + resolveLinkedPageChunkFiles({ + base: '/', + page, + pageChunkFilesMap: new Map(), + }), + ).toEqual(new Set()) +}) diff --git a/packages/core/src/app/prepare/prepareRoutes.ts b/packages/core/src/app/prepare/prepareRoutes.ts index 7f40f68faf..dced59609a 100644 --- a/packages/core/src/app/prepare/prepareRoutes.ts +++ b/packages/core/src/app/prepare/prepareRoutes.ts @@ -25,7 +25,10 @@ if (import.meta.hot) { * * @internal */ -const resolvePageRedirects = ({ path, pathInferred }: Page): string[] => { +export const resolvePageRedirects = ({ + path, + pathInferred, +}: Page): string[] => { // paths that should redirect to this page, use set to dedupe const redirectsSet = new Set() diff --git a/packages/core/src/app/resolveAppOptions.ts b/packages/core/src/app/resolveAppOptions.ts index 12f9ee4bb5..7b3cba3234 100644 --- a/packages/core/src/app/resolveAppOptions.ts +++ b/packages/core/src/app/resolveAppOptions.ts @@ -34,7 +34,7 @@ export const resolveAppOptions = ({ ), // build config shouldPreload = true, - shouldPrefetch = true, + 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..f8e6254c86 100644 --- a/packages/core/src/types/app/options.ts +++ b/packages/core/src/types/app/options.ts @@ -148,20 +148,33 @@ export interface AppConfigDev { */ export interface AppConfigBuild { /** - * Determine what resource files should be preloaded. Use boolean value to - * totally enable / disable. + * Determine what resource files should be preloaded. + * + * - `false` disables preloading. + * - `true` preloads script and style resource files used by the current page. + * - A function preloads a candidate file when it returns `true`. It receives + * the output file name and inferred resource type. * * @default true */ shouldPreload?: boolean | ((file: string, type: string) => boolean) /** - * Determine what resource files should be prefetched. Use boolean value to - * totally enable / disable. + * Determine what resource files should be prefetched. * - * @default true + * - `false` disables prefetching. + * - `true` prefetches all async resource files not used by the current page. + * - `'as-needed'` prefetches async resource files only for pages linked from + * the current page's Markdown content. + * - A function prefetches a candidate file when it returns `true`. It receives + * the output file name and inferred resource type. + * + * @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 edb1f186b2..b5633a23e7 100644 --- a/packages/core/tests/app/resolveAppOptions.spec.ts +++ b/packages/core/tests/app/resolveAppOptions.spec.ts @@ -44,7 +44,7 @@ it('should create app options with default values', () => { ), templateBuildRenderer: templateRenderer, shouldPreload: true, - shouldPrefetch: true, + shouldPrefetch: 'as-needed', markdown: {}, plugins: [], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67fb8722d9..b987601e86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -287,6 +287,9 @@ importers: '@vuepress/core': specifier: workspace:* version: link:../core + '@vuepress/markdown': + specifier: workspace:* + version: link:../markdown '@vuepress/shared': specifier: workspace:* version: link:../shared