From b3692e93746e202d4a4fc4c56aaf7660964cc2e8 Mon Sep 17 00:00:00 2001 From: "Mr.Hope" Date: Mon, 18 May 2026 21:17:53 +0800 Subject: [PATCH 1/5] feat: add route option (close #1505) --- e2e/docs/.vuepress/config.ts | 2 +- e2e/docs/router/navigate-by-link.md | 24 +- e2e/docs/router/navigate-by-router.md | 70 ++++-- e2e/tests/router/navigate-by-link.spec.ts | 130 ++++++++++- e2e/tests/router/navigate-by-router.spec.ts | 96 ++++++-- .../src/plugins/vuepressConfigPlugin.ts | 1 + .../src/config/handlePluginDefine.ts | 1 + .../cli/src/commands/dev/watchPageFiles.ts | 2 +- packages/client/src/router/index.ts | 2 +- packages/client/src/router/resolveRoute.ts | 16 +- ...eRoutePath.ts => resolveRouteCleanPath.ts} | 6 +- .../client/src/router/resolveRouteFullPath.ts | 12 +- packages/client/types.d.ts | 1 + .../core/src/app/prepare/prepareRoutes.ts | 4 +- packages/core/src/app/resolveAppMarkdown.ts | 7 + packages/core/src/app/resolveAppOptions.ts | 21 +- packages/core/src/app/resolveAppPages.ts | 6 +- packages/core/src/page/inferPagePath.ts | 6 +- packages/core/src/page/resolvePageHtmlInfo.ts | 8 +- packages/core/src/page/resolvePagePath.ts | 11 +- .../core/src/page/resolvePagePermalink.ts | 6 +- packages/core/src/types/app/options.ts | 54 +++-- .../core/tests/app/resolveAppOptions.spec.ts | 7 +- .../core/tests/app/resolveAppPages.spec.ts | 12 +- .../core/tests/page/inferPagePath.spec.ts | 10 +- .../core/tests/page/resolvePagePath.spec.ts | 4 +- .../src/plugins/linksPlugin/linksPlugin.ts | 25 ++- packages/shared/src/utils/routes/index.ts | 5 +- .../shared/src/utils/routes/inferRouteKey.ts | 26 +++ .../shared/src/utils/routes/inferRoutePath.ts | 26 --- ...alizeRoutePath.ts => normalizeRouteKey.ts} | 10 +- .../utils/routes/resolveRoutePathWithExt.ts | 2 + ...outePath.spec.ts => inferRouteKey.spec.ts} | 48 ++-- .../tests/routes/normalizeRouteKey.spec.ts | 209 ++++++++++++++++++ .../tests/routes/normalizeRoutePath.spec.ts | 207 ----------------- 35 files changed, 693 insertions(+), 384 deletions(-) rename packages/client/src/router/{resolveRoutePath.ts => resolveRouteCleanPath.ts} (85%) create mode 100644 packages/shared/src/utils/routes/inferRouteKey.ts delete mode 100644 packages/shared/src/utils/routes/inferRoutePath.ts rename packages/shared/src/utils/routes/{normalizeRoutePath.ts => normalizeRouteKey.ts} (51%) create mode 100644 packages/shared/src/utils/routes/resolveRoutePathWithExt.ts rename packages/shared/tests/routes/{inferRoutePath.spec.ts => inferRouteKey.spec.ts} (50%) create mode 100644 packages/shared/tests/routes/normalizeRouteKey.spec.ts delete mode 100644 packages/shared/tests/routes/normalizeRoutePath.spec.ts diff --git a/e2e/docs/.vuepress/config.ts b/e2e/docs/.vuepress/config.ts index 4e9fada490..39237568a9 100644 --- a/e2e/docs/.vuepress/config.ts +++ b/e2e/docs/.vuepress/config.ts @@ -69,7 +69,7 @@ export default defineUserConfig({ theme: e2eTheme(), extendsPage: (page) => { - if (page.path === '/page-data/route-meta.html') { + if (page.path === '/page-data/route-meta') { page.routeMeta = { a: 1, b: 2, diff --git a/e2e/docs/router/navigate-by-link.md b/e2e/docs/router/navigate-by-link.md index 58200b2295..46e973d218 100644 --- a/e2e/docs/router/navigate-by-link.md +++ b/e2e/docs/router/navigate-by-link.md @@ -14,7 +14,16 @@ Home Home 404 -404 +404 + +## HTML Clean Links + +Home +404 +Home +Home +404 +404 ## Markdown Links with html paths @@ -22,7 +31,18 @@ - [404](/404.html) - [Home with query](/?home=true) - [Home with query and hash](/?home=true#home) -- [404 with hash](/404.html#404) +- [404 with hash](/404.html#_404) - [404 with complex hash](/404.html#/404?lang=en) > Non-recommended usage. HTML paths could not be prepended with `base` correctly. + +## Markdown Clean Links + +- [Home](/) +- [404](/404) +- [Home with query](/?home=true) +- [Home with query and hash](/?home=true#home) +- [404 with hash](/404#_404) +- [404 with complex hash](/404#/404?lang=en) + +> Non-recommended usage. HTML paths could not be prepended with `base` correctly. diff --git a/e2e/docs/router/navigate-by-router.md b/e2e/docs/router/navigate-by-router.md index 5fe23bcb7a..47e83b6010 100644 --- a/e2e/docs/router/navigate-by-router.md +++ b/e2e/docs/router/navigate-by-router.md @@ -1,37 +1,71 @@ - - +
+ + + + + + +
- - - - +
+ + + + + + +
diff --git a/e2e/tests/router/navigate-by-link.spec.ts b/e2e/tests/router/navigate-by-link.spec.ts index de0c00b6cd..5bcb16cb44 100644 --- a/e2e/tests/router/navigate-by-link.spec.ts +++ b/e2e/tests/router/navigate-by-link.spec.ts @@ -74,14 +74,54 @@ test.describe('html links', () => { }) test('should preserve hash', async ({ page }) => { - await page.locator('#html-links + p > a').nth(4).click() + await page.locator(selector).nth(4).click() await expect(page).toHaveURL(`${BASE}404.html#_404`) await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') }) - test('should preserve hash and query', async ({ page }) => { + test('should preserve complex hash', async ({ page }) => { await page.locator('#html-links + p > a').nth(5).click() - await expect(page).toHaveURL(`${BASE}404.html#_404?notFound=true`) + await expect(page).toHaveURL(`${BASE}404.html#/404?lang=en`) + await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') + }) +}) + +test.describe('html clean links', () => { + const selector = '#html-clean-links + p > a' + + test('should navigate to home correctly', async ({ page }) => { + await page.locator(selector).nth(0).click() + await expect(page).toHaveURL(BASE) + await expect(page.locator('#home-h2')).toHaveText('Home H2') + }) + + test('should navigate to 404 page correctly', async ({ page }) => { + await page.locator(selector).nth(1).click() + await expect(page).toHaveURL(`${BASE}404.html`) + await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') + }) + + test('should preserve query', async ({ page }) => { + await page.locator(selector).nth(2).click() + await expect(page).toHaveURL(`${BASE}?home=true`) + await expect(page.locator('#home-h2')).toHaveText('Home H2') + }) + + test('should preserve query and hash', async ({ page }) => { + await page.locator(selector).nth(3).click() + await expect(page).toHaveURL(`${BASE}?home=true#home`) + await expect(page.locator('#home-h2')).toHaveText('Home H2') + }) + + test('should preserve hash', async ({ page }) => { + await page.locator(selector).nth(4).click() + await expect(page).toHaveURL(`${BASE}404.html#_404`) + await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') + }) + + test('should preserve complex hash', async ({ page }) => { + await page.locator('#html-links + p > a').nth(5).click() + await expect(page).toHaveURL(`${BASE}404.html#/404?lang=en`) await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') }) }) @@ -149,7 +189,7 @@ test.describe('markdown links with html paths', () => { await expect(page).toHaveURL(`${BASE}404.html#_404`) await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') } else { - await expect(locator).toHaveAttribute('href', '/404.html#404') + await expect(locator).toHaveAttribute('href', '/404.html#_404') await expect(locator).toHaveAttribute('target', '_blank') } }) @@ -167,3 +207,85 @@ test.describe('markdown links with html paths', () => { } }) }) + +test.describe('markdown clean links', () => { + const selector = '#markdown-clean-links + ul > li > a' + + test('should navigate to home correctly', async ({ page }) => { + const locator = page.locator(selector).nth(0) + + if (BASE === '/') { + await locator.click() + await expect(page).toHaveURL('/') + await expect(page.locator('#home-h2')).toHaveText('Home H2') + } else { + await expect(locator).toHaveAttribute('href', '/') + await expect(locator).toHaveAttribute('target', '_blank') + } + }) + + test('should navigate to 404 page correctly', async ({ page }) => { + const locator = page.locator(selector).nth(1) + + if (BASE === '/') { + await locator.click() + await expect(page).toHaveURL(`${BASE}404.html`) + await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') + } else { + await expect(locator).toHaveAttribute('href', '/404') + await expect(locator).toHaveAttribute('target', '_blank') + } + }) + + test('should preserve query', async ({ page }) => { + const locator = page.locator(selector).nth(2) + + if (BASE === '/') { + await locator.click() + await expect(page).toHaveURL(`${BASE}?home=true`) + await expect(page.locator('#home-h2')).toHaveText('Home H2') + } else { + await expect(locator).toHaveAttribute('href', '/?home=true') + await expect(locator).toHaveAttribute('target', '_blank') + } + }) + + test('should preserve query and hash', async ({ page }) => { + const locator = page.locator(selector).nth(3) + + if (BASE === '/') { + await locator.click() + await expect(page).toHaveURL(`${BASE}?home=true#home`) + await expect(page.locator('#home-h2')).toHaveText('Home H2') + } else { + await expect(locator).toHaveAttribute('href', '/?home=true#home') + await expect(locator).toHaveAttribute('target', '_blank') + } + }) + + test('should preserve hash', async ({ page }) => { + const locator = page.locator(selector).nth(4) + + if (BASE === '/') { + await locator.click() + await expect(page).toHaveURL(`${BASE}404.html#_404`) + await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') + } else { + await expect(locator).toHaveAttribute('href', '/404#_404') + await expect(locator).toHaveAttribute('target', '_blank') + } + }) + + test('should preserve complex hash', async ({ page }) => { + const locator = page.locator(selector).nth(5) + + if (BASE === '/') { + await locator.click() + await expect(page).toHaveURL(`${BASE}404.html#/404?lang=en`) + await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') + } else { + await expect(locator).toHaveAttribute('href', '/404#/404?lang=en') + await expect(locator).toHaveAttribute('target', '_blank') + } + }) +}) diff --git a/e2e/tests/router/navigate-by-router.spec.ts b/e2e/tests/router/navigate-by-router.spec.ts index c8dff05ebc..9b19cf225a 100644 --- a/e2e/tests/router/navigate-by-router.spec.ts +++ b/e2e/tests/router/navigate-by-router.spec.ts @@ -6,38 +6,86 @@ test.beforeEach(async ({ page }) => { await page.goto('router/navigate-by-router.html') }) -test('should navigate to home correctly', async ({ page }) => { - await page.locator('#home').click() - await expect(page).toHaveURL(BASE) - await expect(page.locator('#home-h2')).toHaveText('Home H2') +test.describe('should navigate to home correctly', () => { + test('full', async ({ page }) => { + await page.locator('#full .home').click() + await expect(page).toHaveURL(BASE) + await expect(page.locator('#home-h2')).toHaveText('Home H2') + }) + + test('clean', async ({ page }) => { + await page.locator('#clean .home').click() + await expect(page).toHaveURL(BASE) + await expect(page.locator('#home-h2')).toHaveText('Home H2') + }) }) -test('should navigate to 404 page correctly', async ({ page }) => { - await page.locator('#not-found').click() - await expect(page).toHaveURL(`${BASE}404.html`) - await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') +test.describe('should navigate to 404 page correctly', () => { + test('full', async ({ page }) => { + await page.locator('#full .not-found').click() + await expect(page).toHaveURL(`${BASE}404.html`) + await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') + }) + + test('clean', async ({ page }) => { + await page.locator('#clean .not-found').click() + await expect(page).toHaveURL(`${BASE}404.html`) + await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') + }) }) -test('should preserve query', async ({ page }) => { - await page.locator('#home-with-query').click() - await expect(page).toHaveURL(`${BASE}?home=true`) - await expect(page.locator('#home-h2')).toHaveText('Home H2') +test.describe('should preserve query', () => { + test('full', async ({ page }) => { + await page.locator('#full .home-with-query').click() + await expect(page).toHaveURL(`${BASE}?home=true`) + await expect(page.locator('#home-h2')).toHaveText('Home H2') + }) + + test('clean', async ({ page }) => { + await page.locator('#clean .home-with-query').click() + await expect(page).toHaveURL(`${BASE}?home=true`) + await expect(page.locator('#home-h2')).toHaveText('Home H2') + }) }) -test('should preserve query and hash', async ({ page }) => { - await page.locator('#home-with-query-and-hash').click() - await expect(page).toHaveURL(`${BASE}?home=true#home`) - await expect(page.locator('#home-h2')).toHaveText('Home H2') +test.describe('should preserve query and hash', () => { + test('full', async ({ page }) => { + await page.locator('#full .home-with-query-and-hash').click() + await expect(page).toHaveURL(`${BASE}?home=true#home`) + await expect(page.locator('#home-h2')).toHaveText('Home H2') + }) + + test('clean', async ({ page }) => { + await page.locator('#clean .home-with-query-and-hash').click() + await expect(page).toHaveURL(`${BASE}?home=true#home`) + await expect(page.locator('#home-h2')).toHaveText('Home H2') + }) }) -test('should preserve hash', async ({ page }) => { - await page.locator('#not-found-with-hash').click() - await expect(page).toHaveURL(`${BASE}404.html#_404`) - await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') +test.describe('should preserve hash', () => { + test('full', async ({ page }) => { + await page.locator('#full .not-found-with-hash').click() + await expect(page).toHaveURL(`${BASE}404.html#_404`) + await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') + }) + + test('clean', async ({ page }) => { + await page.locator('#clean .not-found-with-hash').click() + await expect(page).toHaveURL(`${BASE}404.html#_404`) + await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') + }) }) -test('should preserve complex hash', async ({ page }) => { - await page.locator('#not-found-with-complex-hash').click() - await expect(page).toHaveURL(`${BASE}404.html#/404?lang=en`) - await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') +test.describe('should preserve complex hash', () => { + test('full', async ({ page }) => { + await page.locator('#full .not-found-with-complex-hash').click() + await expect(page).toHaveURL(`${BASE}404.html#/404?lang=en`) + await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') + }) + + test('clean', async ({ page }) => { + await page.locator('#clean .not-found-with-complex-hash').click() + await expect(page).toHaveURL(`${BASE}404.html#/404?lang=en`) + await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2') + }) }) diff --git a/packages/bundler-vite/src/plugins/vuepressConfigPlugin.ts b/packages/bundler-vite/src/plugins/vuepressConfigPlugin.ts index e12ac90922..8e3c4e7d47 100644 --- a/packages/bundler-vite/src/plugins/vuepressConfigPlugin.ts +++ b/packages/bundler-vite/src/plugins/vuepressConfigPlugin.ts @@ -54,6 +54,7 @@ const resolveDefine = async ({ const define: UserConfig['define'] = { __VUEPRESS_VERSION__: JSON.stringify(app.version), __VUEPRESS_BASE__: JSON.stringify(app.options.base), + __VUEPRESS_CLEAN_URL__: JSON.stringify(app.options.route.cleanUrl), __VUEPRESS_DEV__: JSON.stringify(!isBuild), __VUEPRESS_SSR__: JSON.stringify(isServer), // @see http://link.vuejs.org/feature-flags diff --git a/packages/bundler-webpack/src/config/handlePluginDefine.ts b/packages/bundler-webpack/src/config/handlePluginDefine.ts index 17fbb25615..42d863f6f9 100644 --- a/packages/bundler-webpack/src/config/handlePluginDefine.ts +++ b/packages/bundler-webpack/src/config/handlePluginDefine.ts @@ -21,6 +21,7 @@ export const handlePluginDefine = async ({ { __VUEPRESS_VERSION__: JSON.stringify(app.version), __VUEPRESS_BASE__: JSON.stringify(app.options.base), + __VUEPRESS_CLEAN_URL__: JSON.stringify(app.options.route.cleanUrl), __VUEPRESS_DEV__: JSON.stringify(!isBuild), __VUEPRESS_SSR__: JSON.stringify(isServer), // @see http://link.vuejs.org/feature-flags diff --git a/packages/cli/src/commands/dev/watchPageFiles.ts b/packages/cli/src/commands/dev/watchPageFiles.ts index c796e38c94..356af479ac 100644 --- a/packages/cli/src/commands/dev/watchPageFiles.ts +++ b/packages/cli/src/commands/dev/watchPageFiles.ts @@ -149,7 +149,7 @@ export const watchPageFiles = ( // watch page files const { matchPatterns, ignorePatterns } = processPagePatterns( - app.options.pagePatterns, + app.options.route.pagePatterns, ) const sourceDir = app.dir.source() const tempDir = app.dir.temp() diff --git a/packages/client/src/router/index.ts b/packages/client/src/router/index.ts index 206f6f2f2b..6f6161ad4a 100644 --- a/packages/client/src/router/index.ts +++ b/packages/client/src/router/index.ts @@ -2,5 +2,5 @@ export type { Router, RouteLocationNormalizedLoaded } from 'vue-router' export { useRoute, useRouter } from 'vue-router' export * from './resolveRoute.js' +export * from './resolveRouteCleanPath.js' export * from './resolveRouteFullPath.js' -export * from './resolveRoutePath.js' diff --git a/packages/client/src/router/resolveRoute.ts b/packages/client/src/router/resolveRoute.ts index 14f047bb04..b283ffcfce 100644 --- a/packages/client/src/router/resolveRoute.ts +++ b/packages/client/src/router/resolveRoute.ts @@ -1,8 +1,8 @@ -import { splitPath } from '@vuepress/shared' +import { resolveRoutePathWithExt, splitPath } from '@vuepress/shared' import { routes } from '../internal/routes.js' import type { Route, RouteMeta } from '../types/index.js' -import { resolveRoutePath } from './resolveRoutePath.js' +import { resolveRouteCleanPath } from './resolveRouteCleanPath.js' export interface ResolvedRoute< T extends RouteMeta = RouteMeta, @@ -22,21 +22,23 @@ export const resolveRoute = ( const { pathname, hashAndQueries } = splitPath(path) // resolve the route path - const routePath = resolveRoutePath(pathname, currentPath) - const routeFullPath = routePath + hashAndQueries + const cleanRoutePath = resolveRouteCleanPath(pathname, currentPath) + const routeFullPath = __VUEPRESS_CLEAN_URL__ + ? cleanRoutePath + : resolveRoutePathWithExt(cleanRoutePath) + hashAndQueries // the route not found // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- unsafe indexed access - if (!routes.value[routePath]) { + if (!routes.value[cleanRoutePath]) { return { - ...routes.value['/404.html'], + ...routes.value['/404'], path: routeFullPath, notFound: true, } as ResolvedRoute } return { - ...routes.value[routePath], + ...routes.value[cleanRoutePath], path: routeFullPath, notFound: false, } as ResolvedRoute diff --git a/packages/client/src/router/resolveRoutePath.ts b/packages/client/src/router/resolveRouteCleanPath.ts similarity index 85% rename from packages/client/src/router/resolveRoutePath.ts rename to packages/client/src/router/resolveRouteCleanPath.ts index 430f8f123f..69165eefb1 100644 --- a/packages/client/src/router/resolveRoutePath.ts +++ b/packages/client/src/router/resolveRouteCleanPath.ts @@ -1,16 +1,16 @@ -import { normalizeRoutePath } from '@vuepress/shared' +import { normalizeRouteKey } from '@vuepress/shared' import { redirects, routes } from '../internal/routes.js' /** * Resolve route path with given raw path */ -export const resolveRoutePath = ( +export const resolveRouteCleanPath = ( pathname: string, currentPath?: string, ): string => { // normalized path - const normalizedRoutePath = normalizeRoutePath(pathname, currentPath) + const normalizedRoutePath = normalizeRouteKey(pathname, currentPath) // check if the normalized path is in routes // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- unsafe indexed access diff --git a/packages/client/src/router/resolveRouteFullPath.ts b/packages/client/src/router/resolveRouteFullPath.ts index c756fe5070..c3fd1c2dc2 100644 --- a/packages/client/src/router/resolveRouteFullPath.ts +++ b/packages/client/src/router/resolveRouteFullPath.ts @@ -1,6 +1,6 @@ -import { splitPath } from '@vuepress/shared' +import { resolveRoutePathWithExt, splitPath } from '@vuepress/shared' -import { resolveRoutePath } from './resolveRoutePath.js' +import { resolveRouteCleanPath } from './resolveRouteCleanPath.js' /** * Resolve route full path with given raw path @@ -10,5 +10,11 @@ export const resolveRouteFullPath = ( currentPath?: string, ): string => { const { pathname, hashAndQueries } = splitPath(path) - return resolveRoutePath(pathname, currentPath) + hashAndQueries + const routeCleanPath = resolveRouteCleanPath(pathname, currentPath) + + return ( + (__VUEPRESS_CLEAN_URL__ + ? routeCleanPath + : resolveRoutePathWithExt(routeCleanPath)) + hashAndQueries + ) } diff --git a/packages/client/types.d.ts b/packages/client/types.d.ts index 032e27fcfd..89087a11fb 100644 --- a/packages/client/types.d.ts +++ b/packages/client/types.d.ts @@ -2,6 +2,7 @@ declare const __VUEPRESS_VERSION__: string declare const __VUEPRESS_BASE__: string declare const __VUEPRESS_DEV__: boolean +declare const __VUEPRESS_CLEAN_URL__: boolean declare const __VUEPRESS_SSR__: boolean declare const __VUE_HMR_RUNTIME__: Record declare const __VUE_PROD_DEVTOOLS__: boolean diff --git a/packages/core/src/app/prepare/prepareRoutes.ts b/packages/core/src/app/prepare/prepareRoutes.ts index 7f40f68faf..784ff9839e 100644 --- a/packages/core/src/app/prepare/prepareRoutes.ts +++ b/packages/core/src/app/prepare/prepareRoutes.ts @@ -1,4 +1,4 @@ -import { normalizeRoutePath } from '@vuepress/shared' +import { normalizeRouteKey } from '@vuepress/shared' import type { App, Page } from '../../types/index.js' @@ -31,7 +31,7 @@ const resolvePageRedirects = ({ path, pathInferred }: Page): string[] => { // redirect from inferred path, notice that the inferred path is not uri-encoded if (pathInferred !== null) { - const normalizedPathInferred = normalizeRoutePath(pathInferred) + const normalizedPathInferred = normalizeRouteKey(pathInferred) const encodedPathInferred = encodeURI(normalizedPathInferred) // add redirect to the set when the redirect could not be normalized & encoded to the page path diff --git a/packages/core/src/app/resolveAppMarkdown.ts b/packages/core/src/app/resolveAppMarkdown.ts index f0446b8239..a6193a00bf 100644 --- a/packages/core/src/app/resolveAppMarkdown.ts +++ b/packages/core/src/app/resolveAppMarkdown.ts @@ -9,6 +9,13 @@ import type { App } from '../types/index.js' * @internal */ export const resolveAppMarkdown = async (app: App): Promise => { + // links plugin is not disabled + if (app.options.markdown.links !== false) { + app.options.markdown.links ??= {} + // set the cleanUrl option + app.options.markdown.links.cleanUrl = app.options.route.cleanUrl + } + // plugin hook: extendsMarkdownOptions await app.pluginApi.hooks.extendsMarkdownOptions.process( app.options.markdown, diff --git a/packages/core/src/app/resolveAppOptions.ts b/packages/core/src/app/resolveAppOptions.ts index 12f9ee4bb5..dcd607577a 100644 --- a/packages/core/src/app/resolveAppOptions.ts +++ b/packages/core/src/app/resolveAppOptions.ts @@ -43,8 +43,18 @@ export const resolveAppOptions = ({ bundler, debug = false, markdown = {}, - pagePatterns = ['**/*.md', '!.vuepress'], - permalinkPattern = null, + // eslint-disable-next-line @typescript-eslint/no-deprecated + pagePatterns: _pagePatterns, + // eslint-disable-next-line @typescript-eslint/no-deprecated + permalinkPattern: _permalinkPattern, + route: { + cleanUrl = false, + pagePatterns = ['**/*.md', '!.vuepress', '!node_modules'], + permalinkPattern = null, + } = { + pagePatterns: _pagePatterns, + permalinkPattern: _permalinkPattern, + }, userStyle = null, plugins = [], theme, @@ -75,8 +85,11 @@ export const resolveAppOptions = ({ bundler, debug, markdown, - pagePatterns, - permalinkPattern, + route: { + cleanUrl, + pagePatterns, + permalinkPattern, + }, userStyle, plugins, theme, diff --git a/packages/core/src/app/resolveAppPages.ts b/packages/core/src/app/resolveAppPages.ts index 3b3d1d874d..112b898ceb 100644 --- a/packages/core/src/app/resolveAppPages.ts +++ b/packages/core/src/app/resolveAppPages.ts @@ -17,7 +17,7 @@ export const resolveAppPages = async ( log('resolveAppPages start') // resolve page absolute file paths according to the page patterns - const pageFilePaths = await tinyglobby.glob(app.options.pagePatterns, { + const pageFilePaths = await tinyglobby.glob(app.options.route.pagePatterns, { absolute: true, cwd: app.dir.source(), ignore: DEFAULT_IGNORE_PATTERNS, @@ -35,7 +35,7 @@ export const resolveAppPages = async ( pagesMap[page.filePath] = page } // if there is a 404 page, set the default layout to NotFound - if (page.path === '/404.html') { + if (page.path === '/404') { page.frontmatter.layout ??= 'NotFound' hasNotFoundPage = true } @@ -47,7 +47,7 @@ export const resolveAppPages = async ( if (!hasNotFoundPage) { pages.push( await createPage(app, { - path: '/404.html', + path: '/404', frontmatter: { layout: 'NotFound' }, content: '404 Not Found', }), diff --git a/packages/core/src/page/inferPagePath.ts b/packages/core/src/page/inferPagePath.ts index 7ae91269f0..5bb889ccf3 100644 --- a/packages/core/src/page/inferPagePath.ts +++ b/packages/core/src/page/inferPagePath.ts @@ -1,6 +1,6 @@ import { ensureLeadingSlash, - inferRoutePath, + inferRouteKey, resolveLocalePath, } from '@vuepress/shared' @@ -29,7 +29,7 @@ export const inferPagePath = ({ return { pathInferred: filePathRelative - ? inferRoutePath(ensureLeadingSlash(filePathRelative)) + ? inferRouteKey(ensureLeadingSlash(filePathRelative)) : null, pathLocale, } @@ -44,7 +44,7 @@ export const inferPagePath = ({ // infer page route path from file path // foo/bar.md -> /foo/bar.html - const pathInferred = inferRoutePath(ensureLeadingSlash(filePathRelative)) + const pathInferred = inferRouteKey(ensureLeadingSlash(filePathRelative)) // resolve page locale path const pathLocale = resolveLocalePath(app.siteData.locales, pathInferred) diff --git a/packages/core/src/page/resolvePageHtmlInfo.ts b/packages/core/src/page/resolvePageHtmlInfo.ts index ba855382af..7794e543f2 100644 --- a/packages/core/src/page/resolvePageHtmlInfo.ts +++ b/packages/core/src/page/resolvePageHtmlInfo.ts @@ -19,14 +19,10 @@ export const resolvePageHtmlInfo = ({ } => { const path = decodeURI(pagePath) - // /foo.html -> foo.html + // /foo -> foo.html // /foo/ -> foo/index.html const htmlFilePathRelative = removeLeadingSlash( - path.endsWith('/') - ? `${path}index.html` - : path.endsWith('.html') - ? path - : `${path}.html`, + path.endsWith('/') ? `${path}index.html` : `${path}.html`, ) const htmlFilePath = app.dir.dest(htmlFilePathRelative) diff --git a/packages/core/src/page/resolvePagePath.ts b/packages/core/src/page/resolvePagePath.ts index 9215769d8f..340f3310b4 100644 --- a/packages/core/src/page/resolvePagePath.ts +++ b/packages/core/src/page/resolvePagePath.ts @@ -1,3 +1,4 @@ +import { inferRouteKey } from '@vuepress/shared' import { logger, sanitizeFileName } from '@vuepress/utils' import type { PageOptions } from '../types/index.js' @@ -16,7 +17,9 @@ export const resolvePagePath = ({ pathInferred: string | null options: PageOptions }): string => { - const pagePath = options.path || permalink || pathInferred + const pagePath = options.path + ? inferRouteKey(options.path) + : permalink || pathInferred if (!pagePath) { throw logger.createError( @@ -24,5 +27,9 @@ export const resolvePagePath = ({ ) } - return encodeURI(pagePath.split('/').map(sanitizeFileName).join('/')) + return ( + encodeURI(pagePath.split('/').map(sanitizeFileName).join('/')) + // get clean format + .replace(/\.html$/, '') + ) } diff --git a/packages/core/src/page/resolvePagePermalink.ts b/packages/core/src/page/resolvePagePermalink.ts index e46fba6bbc..2828d5e6cd 100644 --- a/packages/core/src/page/resolvePagePermalink.ts +++ b/packages/core/src/page/resolvePagePermalink.ts @@ -1,4 +1,4 @@ -import { ensureLeadingSlash, isString } from '@vuepress/shared' +import { ensureLeadingSlash, inferRouteKey, isString } from '@vuepress/shared' import { path } from '@vuepress/utils' import type { App, PageFrontmatter } from '../types/index.js' @@ -37,7 +37,7 @@ export const resolvePagePermalink = ({ } const permalinkPattern = - frontmatter.permalinkPattern || app.options.permalinkPattern + frontmatter.permalinkPattern || app.options.route.permalinkPattern if (!isString(permalinkPattern)) { return null @@ -55,5 +55,5 @@ export const resolvePagePermalink = ({ .replace(/:raw/, pathInferred?.replace(/^\//, '') ?? ''), ) - return ensureLeadingSlash(link) + return inferRouteKey(ensureLeadingSlash(link)) } diff --git a/packages/core/src/types/app/options.ts b/packages/core/src/types/app/options.ts index 7b47c6bae0..567ec5711b 100644 --- a/packages/core/src/types/app/options.ts +++ b/packages/core/src/types/app/options.ts @@ -6,6 +6,27 @@ import type { Bundler } from '../bundler.js' import type { PluginConfig } from '../plugin.js' import type { Theme } from '../theme.js' +export interface RouteOptions { + /** + * Whether to use "clean url" + */ + cleanUrl?: boolean + + /** + * Patterns to match the markdown files as pages + * + * @default ['**\/*.md', '!.vuepress'] + */ + pagePatterns?: string[] + + /** + * Pattern to generate permalink for pages + * + * @default null + */ + permalinkPattern?: string | null +} + /** * Vuepress app common config that shared between dev and build */ @@ -70,18 +91,9 @@ export interface AppConfigCommon extends Partial { markdown?: MarkdownOptions /** - * Patterns to match the markdown files as pages - * - * @default ['**\/*.md', '!.vuepress'] + * Vuepress route options */ - pagePatterns?: string[] - - /** - * Pattern to generate permalink for pages - * - * @default null - */ - permalinkPattern?: string | null + route?: RouteOptions /** * Allow specifying user styles, which will be injected into client at the bottom @@ -183,11 +195,27 @@ export interface AppConfigBuild { * * It would be provided by user, typically via a config file. */ -export type AppConfig = AppConfigBuild & AppConfigCommon & AppConfigDev +export type AppConfig = AppConfigBuild & + AppConfigCommon & + AppConfigDev & { + /** + * @deprecated use `route.pagePatterns` instead + */ + pagePatterns?: string[] + + /** + * @deprecated use `route.permalinkPattern` instead + */ + permalinkPattern?: string | null + } /** * Vuepress app options that resolved from user config. * * It fills all optional fields with a default value. */ -export type AppOptions = Required +export type AppOptions = Required< + AppConfigBuild & AppConfigCommon & AppConfigDev +> & { + route: Required +} diff --git a/packages/core/tests/app/resolveAppOptions.spec.ts b/packages/core/tests/app/resolveAppOptions.spec.ts index ab3033c435..dd28e651ba 100644 --- a/packages/core/tests/app/resolveAppOptions.spec.ts +++ b/packages/core/tests/app/resolveAppOptions.spec.ts @@ -33,8 +33,11 @@ it('should create app options with default values', () => { host: '0.0.0.0', port: 8080, open: false, - pagePatterns: ['**/*.md', '!.vuepress'], - permalinkPattern: null, + route: { + cleanUrl: false, + pagePatterns: ['**/*.md', '!.vuepress'], + permalinkPattern: null, + }, userStyle: null, templateDev: path.normalize( require.resolve('@vuepress/client/templates/dev.html'), diff --git a/packages/core/tests/app/resolveAppPages.spec.ts b/packages/core/tests/app/resolveAppPages.spec.ts index 624445eac2..d33b813233 100644 --- a/packages/core/tests/app/resolveAppPages.spec.ts +++ b/packages/core/tests/app/resolveAppPages.spec.ts @@ -14,9 +14,9 @@ it('should create two pages with default 404 page', async () => { app.markdown = createMarkdown() const { pages } = await resolveAppPages(app) - const fooPage = pages.find((page) => page.path === '/foo.html') - const barPage = pages.find((page) => page.path === '/bar.html') - const notFoundPage = pages.find((page) => page.path === '/404.html') + const fooPage = pages.find((page) => page.path === '/foo') + const barPage = pages.find((page) => page.path === '/bar') + const notFoundPage = pages.find((page) => page.path === '/404') expect(pages).toHaveLength(3) expect(fooPage?.filePathRelative).toEqual('foo.md') @@ -34,9 +34,9 @@ it('should create two pages with custom 404 page', async () => { app.markdown = createMarkdown() const { pages } = await resolveAppPages(app) - const fooPage = pages.find((page) => page.path === '/foo.html') - const barPage = pages.find((page) => page.path === '/bar.html') - const notFoundPage = pages.find((page) => page.path === '/404.html') + const fooPage = pages.find((page) => page.path === '/foo') + const barPage = pages.find((page) => page.path === '/bar') + const notFoundPage = pages.find((page) => page.path === '/404') expect(pages).toHaveLength(3) expect(fooPage?.filePathRelative).toEqual('foo.md') diff --git a/packages/core/tests/page/inferPagePath.spec.ts b/packages/core/tests/page/inferPagePath.spec.ts index 52984c43f0..530175a320 100644 --- a/packages/core/tests/page/inferPagePath.spec.ts +++ b/packages/core/tests/page/inferPagePath.spec.ts @@ -32,7 +32,7 @@ const TEST_CASES: [string, ReturnType][] = [ [ 'foo.md', { - pathInferred: '/foo.html', + pathInferred: '/foo', pathLocale: '/', }, ], @@ -46,7 +46,7 @@ const TEST_CASES: [string, ReturnType][] = [ [ 'en/foo.md', { - pathInferred: '/en/foo.html', + pathInferred: '/en/foo', pathLocale: '/en/', }, ], @@ -60,7 +60,7 @@ const TEST_CASES: [string, ReturnType][] = [ [ 'zh/foo.md', { - pathInferred: '/zh/foo.html', + pathInferred: '/zh/foo', pathLocale: '/zh/', }, ], @@ -74,7 +74,7 @@ const TEST_CASES: [string, ReturnType][] = [ [ '中文/foo.md', { - pathInferred: '/中文/foo.html', + pathInferred: '/中文/foo', pathLocale: '/中文/', }, ], @@ -102,7 +102,7 @@ it('should use `/` as the default locale path', () => { options: {}, }), ).toEqual({ - pathInferred: '/en/foo/bar.html', + pathInferred: '/en/foo/bar', pathLocale: '/', }) }) diff --git a/packages/core/tests/page/resolvePagePath.spec.ts b/packages/core/tests/page/resolvePagePath.spec.ts index 3eeb9efb2e..5b4493edc2 100644 --- a/packages/core/tests/page/resolvePagePath.spec.ts +++ b/packages/core/tests/page/resolvePagePath.spec.ts @@ -41,7 +41,7 @@ const TEST_CASES: [ }, }, ], - '/options.html', + '/options', ], // use permalink [ @@ -93,7 +93,7 @@ const TEST_CASES: [ options: {}, }, ], - '/inferred.html', + '/inferred', ], ] diff --git a/packages/markdown/src/plugins/linksPlugin/linksPlugin.ts b/packages/markdown/src/plugins/linksPlugin/linksPlugin.ts index ea75a6bb21..963b00005f 100644 --- a/packages/markdown/src/plugins/linksPlugin/linksPlugin.ts +++ b/packages/markdown/src/plugins/linksPlugin/linksPlugin.ts @@ -1,4 +1,8 @@ -import { inferRoutePath, isLinkExternal } from '@vuepress/shared' +import { + inferRouteKey, + isLinkExternal, + resolveRoutePathWithExt, +} from '@vuepress/shared' import type { PluginWithOptions } from 'markdown-it' import type Token from 'markdown-it/lib/token.mjs' @@ -6,6 +10,13 @@ import type { MarkdownEnv } from '../../types.js' import { resolvePaths } from './resolvePaths.js' export interface LinksPluginOptions { + /** + * Whether use "clean url" + * + * @default false + */ + cleanUrl?: boolean + /** * Additional attributes for external links * @@ -48,6 +59,7 @@ export const linksPlugin: PluginWithOptions = ( const internalTag = options.internalTag || 'RouteLink' const isExternal = options.isExternal ?? ((href, env) => isLinkExternal(href, env.base)) + const cleanUrl = options.cleanUrl ?? false // attrs that going to be added to external links const externalAttrs = { @@ -131,19 +143,22 @@ export const linksPlugin: PluginWithOptions = ( // normalize markdown file path to route path // we are removing the `base` from absolute path because it should not be // passed to `` or `` - const normalizedPath = inferRoutePath( + const routeKey = inferRouteKey( absolutePath ? absolutePath.replace(new RegExp(`^${base}`), '/') : relativePath, ) + // replace the original href link with the normalized path - hrefAttr[1] = `${normalizedPath}${rawHashAndQueries}` + hrefAttr[1] = `${cleanUrl ? routeKey : resolveRoutePathWithExt(routeKey)}${rawHashAndQueries}` // set `hasOpenInternalLink` to modify the ending tag hasOpenInternalLink = true } else { - const normalizedPath = inferRoutePath(absolutePath ?? relativePath) + // ext is added here + const routeKey = inferRouteKey(absolutePath ?? relativePath) + // replace the original href link with the normalized path - hrefAttr[1] = `${normalizedPath}${rawHashAndQueries}` + hrefAttr[1] = `${cleanUrl ? routeKey : resolveRoutePathWithExt(routeKey)}${rawHashAndQueries}` } // extract internal links for file / page existence check diff --git a/packages/shared/src/utils/routes/index.ts b/packages/shared/src/utils/routes/index.ts index 5e67f4b001..a60f733e51 100644 --- a/packages/shared/src/utils/routes/index.ts +++ b/packages/shared/src/utils/routes/index.ts @@ -1,5 +1,6 @@ -export * from './inferRoutePath' -export * from './normalizeRoutePath.js' +export * from './inferRouteKey' +export * from './normalizeRouteKey.js' +export * from './resolveRoutePathWithExt.js' export * from './resolveLocalePath.js' export * from './resolveRoutePathFromUrl.js' export * from './splitPath.js' diff --git a/packages/shared/src/utils/routes/inferRouteKey.ts b/packages/shared/src/utils/routes/inferRouteKey.ts new file mode 100644 index 0000000000..7bd25cee1f --- /dev/null +++ b/packages/shared/src/utils/routes/inferRouteKey.ts @@ -0,0 +1,26 @@ +/** + * Infer route key of the given raw path + */ +export const inferRouteKey = (rawPath: string): string => { + // if the raw path is empty or ends with `/`, return as is + if (!rawPath || rawPath.endsWith('/')) return rawPath + + // convert README.md to index.html + let routePath = rawPath.replace(/(^|\/)README.md$/i, '$1index') + + // convert /foo/bar.md to /foo/bar + if (routePath.endsWith('.md')) { + routePath = routePath.substring(0, routePath.length - 3) + } + // convert /foo/bar.html to /foo/bar + else if (routePath.endsWith('.html')) { + routePath = routePath.substring(0, routePath.length - 5) + } + + // convert /foo/index to /foo/ + if (routePath.endsWith('/index')) { + routePath = routePath.substring(0, routePath.length - 5) + } + + return routePath +} diff --git a/packages/shared/src/utils/routes/inferRoutePath.ts b/packages/shared/src/utils/routes/inferRoutePath.ts deleted file mode 100644 index 803cef50bf..0000000000 --- a/packages/shared/src/utils/routes/inferRoutePath.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Infer route path of the given raw path - */ -export const inferRoutePath = (rawPath: string): string => { - // if the raw path is empty or ends with `/`, return as is - if (!rawPath || rawPath.endsWith('/')) return rawPath - - // convert README.md to index.html - let routePath = rawPath.replace(/(^|\/)README.md$/i, '$1index.html') - - // convert /foo/bar.md to /foo/bar.html - if (routePath.endsWith('.md')) { - routePath = `${routePath.substring(0, routePath.length - 3)}.html` - } - // convert /foo/bar to /foo/bar.html - else if (!routePath.endsWith('.html')) { - routePath = `${routePath}.html` - } - - // convert /foo/index.html to /foo/ - if (routePath.endsWith('/index.html')) { - routePath = routePath.substring(0, routePath.length - 10) - } - - return routePath -} diff --git a/packages/shared/src/utils/routes/normalizeRoutePath.ts b/packages/shared/src/utils/routes/normalizeRouteKey.ts similarity index 51% rename from packages/shared/src/utils/routes/normalizeRoutePath.ts rename to packages/shared/src/utils/routes/normalizeRouteKey.ts index 6c73ef172a..ffb7ac819d 100644 --- a/packages/shared/src/utils/routes/normalizeRoutePath.ts +++ b/packages/shared/src/utils/routes/normalizeRouteKey.ts @@ -1,11 +1,11 @@ -import { inferRoutePath } from './inferRoutePath.js' +import { inferRouteKey } from './inferRouteKey.js' const FAKE_HOST = 'http://.' /** - * Normalize the given pathname path to the final route path + * Normalize the given pathname path to the final route key */ -export const normalizeRoutePath = ( +export const normalizeRouteKey = ( pathname: string, current?: string, ): string => { @@ -13,8 +13,8 @@ export const normalizeRoutePath = ( // the relative path should be resolved against the current path const loc = current.slice(0, current.lastIndexOf('/')) - return inferRoutePath(new URL(`${loc}/${pathname}`, FAKE_HOST).pathname) + return inferRouteKey(new URL(`${loc}/${pathname}`, FAKE_HOST).pathname) } - return inferRoutePath(pathname) + return inferRouteKey(pathname) } diff --git a/packages/shared/src/utils/routes/resolveRoutePathWithExt.ts b/packages/shared/src/utils/routes/resolveRoutePathWithExt.ts new file mode 100644 index 0000000000..cd48fb29b1 --- /dev/null +++ b/packages/shared/src/utils/routes/resolveRoutePathWithExt.ts @@ -0,0 +1,2 @@ +export const resolveRoutePathWithExt = (routePath: string): string => + routePath.endsWith('/') ? routePath : `${routePath}.html` diff --git a/packages/shared/tests/routes/inferRoutePath.spec.ts b/packages/shared/tests/routes/inferRouteKey.spec.ts similarity index 50% rename from packages/shared/tests/routes/inferRoutePath.spec.ts rename to packages/shared/tests/routes/inferRouteKey.spec.ts index b63b0ebc3e..e45ee96b5e 100644 --- a/packages/shared/tests/routes/inferRoutePath.spec.ts +++ b/packages/shared/tests/routes/inferRouteKey.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { inferRoutePath } from '../../src/index.js' +import { inferRouteKey } from '../../src/index.js' const TEST_CASES = [ // absolute index @@ -16,19 +16,19 @@ const TEST_CASES = [ ['/foo/index.md', '/foo/'], ['/foo/index.html', '/foo/'], ['/foo/index', '/foo/'], - ['README.md', 'index.html'], - ['readme.md', 'index.html'], - ['index.md', 'index.html'], - ['index.html', 'index.html'], - ['index', 'index.html'], + ['README.md', 'index'], + ['readme.md', 'index'], + ['index.md', 'index'], + ['index.html', 'index'], + ['index', 'index'], // absolute non-index - ['/foo', '/foo.html'], - ['/foo.md', '/foo.html'], - ['/foo.html', '/foo.html'], - ['/foo/bar', '/foo/bar.html'], - ['/foo/bar.md', '/foo/bar.html'], - ['/foo/bar.html', '/foo/bar.html'], + ['/foo', '/foo'], + ['/foo.md', '/foo'], + ['/foo.html', '/foo'], + ['/foo/bar', '/foo/bar'], + ['/foo/bar.md', '/foo/bar'], + ['/foo/bar.html', '/foo/bar'], // relative index without current ['foo/', 'foo/'], @@ -39,25 +39,25 @@ const TEST_CASES = [ ['foo/index', 'foo/'], // relative non index without current - ['foo', 'foo.html'], - ['foo.md', 'foo.html'], - ['foo.html', 'foo.html'], - ['foo/bar', 'foo/bar.html'], - ['foo/bar.md', 'foo/bar.html'], - ['foo/bar.html', 'foo/bar.html'], + ['foo', 'foo'], + ['foo.md', 'foo'], + ['foo.html', 'foo'], + ['foo/bar', 'foo/bar'], + ['foo/bar.md', 'foo/bar'], + ['foo/bar.html', 'foo/bar'], // unexpected corner cases ['', ''], - ['.md', '.html'], - ['foo/.md', 'foo/.html'], - ['/.md', '/.html'], - ['/foo/.md', '/foo/.html'], + ['.md', ''], + ['foo/.md', 'foo/'], + ['/.md', '/'], + ['/foo/.md', '/foo/'], ] -describe('should normalize clean paths correctly', () => { +describe('should normalize route keys correctly', () => { TEST_CASES.forEach(([path, expected]) => { it(`"${path}" -> "${expected}"`, () => { - expect(inferRoutePath(path)).toBe(expected) + expect(inferRouteKey(path)).toBe(expected) }) }) }) diff --git a/packages/shared/tests/routes/normalizeRouteKey.spec.ts b/packages/shared/tests/routes/normalizeRouteKey.spec.ts new file mode 100644 index 0000000000..90b6ce646b --- /dev/null +++ b/packages/shared/tests/routes/normalizeRouteKey.spec.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest' + +import { normalizeRouteKey } from '../../src/index.js' + +type TestCase = [[path: string, current?: string], expected: string] + +const TEST_CASES: TestCase[] = [ + // absolute index + [['/'], '/'], + [['/README.md'], '/'], + [['/readme.md'], '/'], + [['/index.md'], '/'], + [['/index.html'], '/'], + [['/index'], '/'], + [['/foo/'], '/foo/'], + [['/foo/README.md'], '/foo/'], + [['/foo/readme.md'], '/foo/'], + [['/foo/index.md'], '/foo/'], + [['/foo/index.html'], '/foo/'], + [['/foo/index'], '/foo/'], + [['README.md'], 'index'], + [['readme.md'], 'index'], + [['index.md'], 'index'], + [['index.html'], 'index'], + [['index'], 'index'], + + // absolute non-index + [['/foo'], '/foo'], + [['/foo.md'], '/foo'], + [['/foo.html'], '/foo'], + [['/foo/bar'], '/foo/bar'], + [['/foo/bar.md'], '/foo/bar'], + [['/foo/bar.html'], '/foo/bar'], + + // relative index without current + [['foo/'], 'foo/'], + [['foo/README.md'], 'foo/'], + [['foo/readme.md'], 'foo/'], + [['foo/index.md'], 'foo/'], + [['foo/index.html'], 'foo/'], + [['foo/index'], 'foo/'], + + // relative non index without current + [['foo'], 'foo'], + [['foo.md'], 'foo'], + [['foo.html'], 'foo'], + [['foo/bar'], 'foo/bar'], + [['foo/bar.md'], 'foo/bar'], + [['foo/bar.html'], 'foo/bar'], + + // relative non index with current + [['foo', '/'], '/foo'], + [['foo', '/a.html'], '/foo'], + [['foo', '/index.html'], '/foo'], + [['foo', '/a/'], '/a/foo'], + [['foo', '/a/index.html'], '/a/foo'], + [['foo', '/a/b.html'], '/a/foo'], + [['foo.md', '/'], '/foo'], + [['foo.md', '/a.html'], '/foo'], + [['foo.md', '/index.html'], '/foo'], + [['foo.md', '/a/'], '/a/foo'], + [['foo.md', '/a/index.html'], '/a/foo'], + [['foo.md', '/a/b.html'], '/a/foo'], + [['foo.html', '/'], '/foo'], + [['foo.html', '/a.html'], '/foo'], + [['foo.html', '/index.html'], '/foo'], + [['foo.html', '/a/'], '/a/foo'], + [['foo.html', '/a/index.html'], '/a/foo'], + [['foo.html', '/a/b.html'], '/a/foo'], + [['foo/bar', '/'], '/foo/bar'], + [['foo/bar', '/a.html'], '/foo/bar'], + [['foo/bar', '/index.html'], '/foo/bar'], + [['foo/bar', '/a/'], '/a/foo/bar'], + [['foo/bar', '/a/index.html'], '/a/foo/bar'], + [['foo/bar', '/a/b.html'], '/a/foo/bar'], + [['foo/bar.md', '/'], '/foo/bar'], + [['foo/bar.md', '/a.html'], '/foo/bar'], + [['foo/bar.md', '/index.html'], '/foo/bar'], + [['foo/bar.md', '/a/'], '/a/foo/bar'], + [['foo/bar.md', '/a/index.html'], '/a/foo/bar'], + [['foo/bar.md', '/a/b.html'], '/a/foo/bar'], + [['foo/bar.html', '/'], '/foo/bar'], + [['foo/bar.html', '/a.html'], '/foo/bar'], + [['foo/bar.html', '/index.html'], '/foo/bar'], + [['foo/bar.html', '/a/'], '/a/foo/bar'], + [['foo/bar.html', '/a/index.html'], '/a/foo/bar'], + [['foo/bar.html', '/a/b.html'], '/a/foo/bar'], + [['./foo', '/'], '/foo'], + [['./foo', '/a.html'], '/foo'], + [['./foo', '/index.html'], '/foo'], + [['./foo', '/a/'], '/a/foo'], + [['./foo', '/a/index.html'], '/a/foo'], + [['./foo', '/a/b.html'], '/a/foo'], + [['./foo.md', '/'], '/foo'], + [['./foo.md', '/a.html'], '/foo'], + [['./foo.md', '/index.html'], '/foo'], + [['./foo.md', '/a/'], '/a/foo'], + [['./foo.md', '/a/index.html'], '/a/foo'], + [['./foo.md', '/a/b.html'], '/a/foo'], + [['./foo.html', '/'], '/foo'], + [['./foo.html', '/a.html'], '/foo'], + [['./foo.html', '/index.html'], '/foo'], + [['./foo.html', '/a/'], '/a/foo'], + [['./foo.html', '/a/index.html'], '/a/foo'], + [['./foo.html', '/a/b.html'], '/a/foo'], + [['./foo/bar', '/'], '/foo/bar'], + [['./foo/bar', '/a.html'], '/foo/bar'], + [['./foo/bar', '/index.html'], '/foo/bar'], + [['./foo/bar', '/a/'], '/a/foo/bar'], + [['./foo/bar', '/a/index.html'], '/a/foo/bar'], + [['./foo/bar', '/a/b.html'], '/a/foo/bar'], + [['./foo/bar.md', '/'], '/foo/bar'], + [['./foo/bar.md', '/a.html'], '/foo/bar'], + [['./foo/bar.md', '/index.html'], '/foo/bar'], + [['./foo/bar.md', '/a/'], '/a/foo/bar'], + [['./foo/bar.md', '/a/index.html'], '/a/foo/bar'], + [['./foo/bar.md', '/a/b.html'], '/a/foo/bar'], + [['./foo/bar.html', '/'], '/foo/bar'], + [['./foo/bar.html', '/a.html'], '/foo/bar'], + [['./foo/bar.html', '/index.html'], '/foo/bar'], + [['./foo/bar.html', '/a/'], '/a/foo/bar'], + [['./foo/bar.html', '/a/index.html'], '/a/foo/bar'], + [['./foo/bar.html', '/a/b.html'], '/a/foo/bar'], + [['../foo', '/a/'], '/foo'], + [['../foo', '/a/index.html'], '/foo'], + [['../foo', '/a/b.html'], '/foo'], + [['../foo.md', '/a/'], '/foo'], + [['../foo.md', '/a/index.html'], '/foo'], + [['../foo.md', '/a/b.html'], '/foo'], + [['../foo.html', '/a/'], '/foo'], + [['../foo.html', '/a/index.html'], '/foo'], + [['../foo.html', '/a/b.html'], '/foo'], + [['../foo/bar', '/a/'], '/foo/bar'], + [['../foo/bar', '/a/index.html'], '/foo/bar'], + [['../foo/bar', '/a/b.html'], '/foo/bar'], + [['../foo/bar.md', '/a/'], '/foo/bar'], + [['../foo/bar.md', '/a/index.html'], '/foo/bar'], + [['../foo/bar.md', '/a/b.html'], '/foo/bar'], + [['../foo/bar.html', '/a/'], '/foo/bar'], + [['../foo/bar.html', '/a/index.html'], '/foo/bar'], + [['../foo/bar.html', '/a/b.html'], '/foo/bar'], + + // absolute non index with current + [['/foo', '/'], '/foo'], + [['/foo', '/a.html'], '/foo'], + [['/foo', '/index.html'], '/foo'], + [['/foo', '/a/'], '/foo'], + [['/foo', '/a/index.html'], '/foo'], + [['/foo', '/a/b.html'], '/foo'], + [['/foo.md', '/'], '/foo'], + [['/foo.md', '/a.html'], '/foo'], + [['/foo.md', '/index.html'], '/foo'], + [['/foo.md', '/a/'], '/foo'], + [['/foo.md', '/a/index.html'], '/foo'], + [['/foo.md', '/a/b.html'], '/foo'], + [['/foo.html', '/'], '/foo'], + [['/foo.html', '/a.html'], '/foo'], + [['/foo.html', '/index.html'], '/foo'], + [['/foo.html', '/a/'], '/foo'], + [['/foo.html', '/a/index.html'], '/foo'], + [['/foo.html', '/a/b.html'], '/foo'], + [['/foo/bar', '/'], '/foo/bar'], + [['/foo/bar', '/a.html'], '/foo/bar'], + [['/foo/bar', '/index.html'], '/foo/bar'], + [['/foo/bar', '/a/'], '/foo/bar'], + [['/foo/bar', '/a/index.html'], '/foo/bar'], + [['/foo/bar', '/a/b.html'], '/foo/bar'], + [['/foo/bar.md', '/'], '/foo/bar'], + [['/foo/bar.md', '/a.html'], '/foo/bar'], + [['/foo/bar.md', '/index.html'], '/foo/bar'], + [['/foo/bar.md', '/a/'], '/foo/bar'], + [['/foo/bar.md', '/a/index.html'], '/foo/bar'], + [['/foo/bar.md', '/a/b.html'], '/foo/bar'], + [['/foo/bar.html', '/'], '/foo/bar'], + [['/foo/bar.html', '/a.html'], '/foo/bar'], + [['/foo/bar.html', '/index.html'], '/foo/bar'], + [['/foo/bar.html', '/a/'], '/foo/bar'], + [['/foo/bar.html', '/a/index.html'], '/foo/bar'], + [['/foo/bar.html', '/a/b.html'], '/foo/bar'], + + // empty + [[''], ''], + + // unexpected corner cases + [['.md'], ''], + [['foo/.md'], 'foo/'], + [['/.md'], '/'], + [['/foo/.md'], '/foo/'], + [['.md', '/a/'], '/a/'], + [['foo/.md', '/a/'], '/a/foo/'], + [['/.md', '/a/'], '/'], + [['/foo/.md', '/a/'], '/foo/'], + [['.md', '/a/index.html'], '/a/'], + [['foo/.md', '/a/index.html'], '/a/foo/'], + [['/.md', '/a/index.html'], '/'], + [['/foo/.md', '/a/index.html'], '/foo/'], + [['.md', '/a/b.html'], '/a/'], + [['foo/.md', '/a/b.html'], '/a/foo/'], + [['/.md', '/a/b.html'], '/'], + [['/foo/.md', '/a/b.html'], '/foo/'], +] + +describe('should normalize route keys correctly', () => { + TEST_CASES.forEach(([[path, current], expected]) => { + it(`${current ? `"${current}"-` : ''}"${path}" -> "${expected}"`, () => { + expect(normalizeRouteKey(path, current)).toBe(expected) + }) + }) +}) diff --git a/packages/shared/tests/routes/normalizeRoutePath.spec.ts b/packages/shared/tests/routes/normalizeRoutePath.spec.ts deleted file mode 100644 index 0816852635..0000000000 --- a/packages/shared/tests/routes/normalizeRoutePath.spec.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { normalizeRoutePath } from '../../src/index.js' - -const TEST_CASES = [ - // absolute index - [['/'], '/'], - [['/README.md'], '/'], - [['/readme.md'], '/'], - [['/index.md'], '/'], - [['/index.html'], '/'], - [['/index'], '/'], - [['/foo/'], '/foo/'], - [['/foo/README.md'], '/foo/'], - [['/foo/readme.md'], '/foo/'], - [['/foo/index.md'], '/foo/'], - [['/foo/index.html'], '/foo/'], - [['/foo/index'], '/foo/'], - [['README.md'], 'index.html'], - [['readme.md'], 'index.html'], - [['index.md'], 'index.html'], - [['index.html'], 'index.html'], - [['index'], 'index.html'], - - // absolute non-index - [['/foo'], '/foo.html'], - [['/foo.md'], '/foo.html'], - [['/foo.html'], '/foo.html'], - [['/foo/bar'], '/foo/bar.html'], - [['/foo/bar.md'], '/foo/bar.html'], - [['/foo/bar.html'], '/foo/bar.html'], - - // relative index without current - [['foo/'], 'foo/'], - [['foo/README.md'], 'foo/'], - [['foo/readme.md'], 'foo/'], - [['foo/index.md'], 'foo/'], - [['foo/index.html'], 'foo/'], - [['foo/index'], 'foo/'], - - // relative non index without current - [['foo'], 'foo.html'], - [['foo.md'], 'foo.html'], - [['foo.html'], 'foo.html'], - [['foo/bar'], 'foo/bar.html'], - [['foo/bar.md'], 'foo/bar.html'], - [['foo/bar.html'], 'foo/bar.html'], - - // relative non index with current - [['foo', '/'], '/foo.html'], - [['foo', '/a.html'], '/foo.html'], - [['foo', '/index.html'], '/foo.html'], - [['foo', '/a/'], '/a/foo.html'], - [['foo', '/a/index.html'], '/a/foo.html'], - [['foo', '/a/b.html'], '/a/foo.html'], - [['foo.md', '/'], '/foo.html'], - [['foo.md', '/a.html'], '/foo.html'], - [['foo.md', '/index.html'], '/foo.html'], - [['foo.md', '/a/'], '/a/foo.html'], - [['foo.md', '/a/index.html'], '/a/foo.html'], - [['foo.md', '/a/b.html'], '/a/foo.html'], - [['foo.html', '/'], '/foo.html'], - [['foo.html', '/a.html'], '/foo.html'], - [['foo.html', '/index.html'], '/foo.html'], - [['foo.html', '/a/'], '/a/foo.html'], - [['foo.html', '/a/index.html'], '/a/foo.html'], - [['foo.html', '/a/b.html'], '/a/foo.html'], - [['foo/bar', '/'], '/foo/bar.html'], - [['foo/bar', '/a.html'], '/foo/bar.html'], - [['foo/bar', '/index.html'], '/foo/bar.html'], - [['foo/bar', '/a/'], '/a/foo/bar.html'], - [['foo/bar', '/a/index.html'], '/a/foo/bar.html'], - [['foo/bar', '/a/b.html'], '/a/foo/bar.html'], - [['foo/bar.md', '/'], '/foo/bar.html'], - [['foo/bar.md', '/a.html'], '/foo/bar.html'], - [['foo/bar.md', '/index.html'], '/foo/bar.html'], - [['foo/bar.md', '/a/'], '/a/foo/bar.html'], - [['foo/bar.md', '/a/index.html'], '/a/foo/bar.html'], - [['foo/bar.md', '/a/b.html'], '/a/foo/bar.html'], - [['foo/bar.html', '/'], '/foo/bar.html'], - [['foo/bar.html', '/a.html'], '/foo/bar.html'], - [['foo/bar.html', '/index.html'], '/foo/bar.html'], - [['foo/bar.html', '/a/'], '/a/foo/bar.html'], - [['foo/bar.html', '/a/index.html'], '/a/foo/bar.html'], - [['foo/bar.html', '/a/b.html'], '/a/foo/bar.html'], - [['./foo', '/'], '/foo.html'], - [['./foo', '/a.html'], '/foo.html'], - [['./foo', '/index.html'], '/foo.html'], - [['./foo', '/a/'], '/a/foo.html'], - [['./foo', '/a/index.html'], '/a/foo.html'], - [['./foo', '/a/b.html'], '/a/foo.html'], - [['./foo.md', '/'], '/foo.html'], - [['./foo.md', '/a.html'], '/foo.html'], - [['./foo.md', '/index.html'], '/foo.html'], - [['./foo.md', '/a/'], '/a/foo.html'], - [['./foo.md', '/a/index.html'], '/a/foo.html'], - [['./foo.md', '/a/b.html'], '/a/foo.html'], - [['./foo.html', '/'], '/foo.html'], - [['./foo.html', '/a.html'], '/foo.html'], - [['./foo.html', '/index.html'], '/foo.html'], - [['./foo.html', '/a/'], '/a/foo.html'], - [['./foo.html', '/a/index.html'], '/a/foo.html'], - [['./foo.html', '/a/b.html'], '/a/foo.html'], - [['./foo/bar', '/'], '/foo/bar.html'], - [['./foo/bar', '/a.html'], '/foo/bar.html'], - [['./foo/bar', '/index.html'], '/foo/bar.html'], - [['./foo/bar', '/a/'], '/a/foo/bar.html'], - [['./foo/bar', '/a/index.html'], '/a/foo/bar.html'], - [['./foo/bar', '/a/b.html'], '/a/foo/bar.html'], - [['./foo/bar.md', '/'], '/foo/bar.html'], - [['./foo/bar.md', '/a.html'], '/foo/bar.html'], - [['./foo/bar.md', '/index.html'], '/foo/bar.html'], - [['./foo/bar.md', '/a/'], '/a/foo/bar.html'], - [['./foo/bar.md', '/a/index.html'], '/a/foo/bar.html'], - [['./foo/bar.md', '/a/b.html'], '/a/foo/bar.html'], - [['./foo/bar.html', '/'], '/foo/bar.html'], - [['./foo/bar.html', '/a.html'], '/foo/bar.html'], - [['./foo/bar.html', '/index.html'], '/foo/bar.html'], - [['./foo/bar.html', '/a/'], '/a/foo/bar.html'], - [['./foo/bar.html', '/a/index.html'], '/a/foo/bar.html'], - [['./foo/bar.html', '/a/b.html'], '/a/foo/bar.html'], - [['../foo', '/a/'], '/foo.html'], - [['../foo', '/a/index.html'], '/foo.html'], - [['../foo', '/a/b.html'], '/foo.html'], - [['../foo.md', '/a/'], '/foo.html'], - [['../foo.md', '/a/index.html'], '/foo.html'], - [['../foo.md', '/a/b.html'], '/foo.html'], - [['../foo.html', '/a/'], '/foo.html'], - [['../foo.html', '/a/index.html'], '/foo.html'], - [['../foo.html', '/a/b.html'], '/foo.html'], - [['../foo/bar', '/a/'], '/foo/bar.html'], - [['../foo/bar', '/a/index.html'], '/foo/bar.html'], - [['../foo/bar', '/a/b.html'], '/foo/bar.html'], - [['../foo/bar.md', '/a/'], '/foo/bar.html'], - [['../foo/bar.md', '/a/index.html'], '/foo/bar.html'], - [['../foo/bar.md', '/a/b.html'], '/foo/bar.html'], - [['../foo/bar.html', '/a/'], '/foo/bar.html'], - [['../foo/bar.html', '/a/index.html'], '/foo/bar.html'], - [['../foo/bar.html', '/a/b.html'], '/foo/bar.html'], - - // absolute non index with current - [['/foo', '/'], '/foo.html'], - [['/foo', '/a.html'], '/foo.html'], - [['/foo', '/index.html'], '/foo.html'], - [['/foo', '/a/'], '/foo.html'], - [['/foo', '/a/index.html'], '/foo.html'], - [['/foo', '/a/b.html'], '/foo.html'], - [['/foo.md', '/'], '/foo.html'], - [['/foo.md', '/a.html'], '/foo.html'], - [['/foo.md', '/index.html'], '/foo.html'], - [['/foo.md', '/a/'], '/foo.html'], - [['/foo.md', '/a/index.html'], '/foo.html'], - [['/foo.md', '/a/b.html'], '/foo.html'], - [['/foo.html', '/'], '/foo.html'], - [['/foo.html', '/a.html'], '/foo.html'], - [['/foo.html', '/index.html'], '/foo.html'], - [['/foo.html', '/a/'], '/foo.html'], - [['/foo.html', '/a/index.html'], '/foo.html'], - [['/foo.html', '/a/b.html'], '/foo.html'], - [['/foo/bar', '/'], '/foo/bar.html'], - [['/foo/bar', '/a.html'], '/foo/bar.html'], - [['/foo/bar', '/index.html'], '/foo/bar.html'], - [['/foo/bar', '/a/'], '/foo/bar.html'], - [['/foo/bar', '/a/index.html'], '/foo/bar.html'], - [['/foo/bar', '/a/b.html'], '/foo/bar.html'], - [['/foo/bar.md', '/'], '/foo/bar.html'], - [['/foo/bar.md', '/a.html'], '/foo/bar.html'], - [['/foo/bar.md', '/index.html'], '/foo/bar.html'], - [['/foo/bar.md', '/a/'], '/foo/bar.html'], - [['/foo/bar.md', '/a/index.html'], '/foo/bar.html'], - [['/foo/bar.md', '/a/b.html'], '/foo/bar.html'], - [['/foo/bar.html', '/'], '/foo/bar.html'], - [['/foo/bar.html', '/a.html'], '/foo/bar.html'], - [['/foo/bar.html', '/index.html'], '/foo/bar.html'], - [['/foo/bar.html', '/a/'], '/foo/bar.html'], - [['/foo/bar.html', '/a/index.html'], '/foo/bar.html'], - [['/foo/bar.html', '/a/b.html'], '/foo/bar.html'], - - // only hash and query - [[''], ''], - - // unexpected corner cases - [['.md'], '.html'], - [['foo/.md'], 'foo/.html'], - [['/.md'], '/.html'], - [['/foo/.md'], '/foo/.html'], - [['.md', '/a/'], '/a/.html'], - [['foo/.md', '/a/'], '/a/foo/.html'], - [['/.md', '/a/'], '/.html'], - [['/foo/.md', '/a/'], '/foo/.html'], - [['.md', '/a/index.html'], '/a/.html'], - [['foo/.md', '/a/index.html'], '/a/foo/.html'], - [['/.md', '/a/index.html'], '/.html'], - [['/foo/.md', '/a/index.html'], '/foo/.html'], - [['.md', '/a/b.html'], '/a/.html'], - [['foo/.md', '/a/b.html'], '/a/foo/.html'], - [['/.md', '/a/b.html'], '/.html'], - [['/foo/.md', '/a/b.html'], '/foo/.html'], -] as const - -describe('should normalize clean paths correctly', () => { - TEST_CASES.forEach(([[path, current], expected]) => { - it(`${current ? `"${current}"-` : ''}"${path}" -> "${expected}"`, () => { - expect(normalizeRoutePath(path, current)).toBe(expected) - }) - }) -}) From 74096fae8527b5d5ce1775825ac5bed2cfa5fef5 Mon Sep 17 00:00:00 2001 From: "Mr.Hope" Date: Mon, 18 May 2026 21:22:09 +0800 Subject: [PATCH 2/5] test: update unit tests --- packages/core/src/app/resolveAppOptions.ts | 2 +- packages/core/tests/page/createPage.spec.ts | 4 ++-- packages/core/tests/page/inferPagePath.spec.ts | 4 ++-- packages/core/tests/page/resolvePageHtmlInfo.spec.ts | 4 ---- packages/core/tests/page/resolvePagePermalink.spec.ts | 4 ++-- 5 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/core/src/app/resolveAppOptions.ts b/packages/core/src/app/resolveAppOptions.ts index dcd607577a..578067aa8e 100644 --- a/packages/core/src/app/resolveAppOptions.ts +++ b/packages/core/src/app/resolveAppOptions.ts @@ -49,7 +49,7 @@ export const resolveAppOptions = ({ permalinkPattern: _permalinkPattern, route: { cleanUrl = false, - pagePatterns = ['**/*.md', '!.vuepress', '!node_modules'], + pagePatterns = ['**/*.md', '!.vuepress'], permalinkPattern = null, } = { pagePatterns: _pagePatterns, diff --git a/packages/core/tests/page/createPage.spec.ts b/packages/core/tests/page/createPage.spec.ts index 77b16c4de3..a929f058e4 100644 --- a/packages/core/tests/page/createPage.spec.ts +++ b/packages/core/tests/page/createPage.spec.ts @@ -96,11 +96,11 @@ describe('should work without plugins', () => { }) // page data - expect(page.data.path).toBe('/zh/test.html') + expect(page.data.path).toBe('/zh/test') expect(page.data.lang).toBe('zh-CN') // base fields - expect(page.path).toBe('/zh/test.html') + expect(page.path).toBe('/zh/test') expect(page.lang).toBe('zh-CN') // file info diff --git a/packages/core/tests/page/inferPagePath.spec.ts b/packages/core/tests/page/inferPagePath.spec.ts index 530175a320..2adcb59c55 100644 --- a/packages/core/tests/page/inferPagePath.spec.ts +++ b/packages/core/tests/page/inferPagePath.spec.ts @@ -128,7 +128,7 @@ it('should respect options.path', () => { options: { path: '/custom/path.html' }, }), ).toEqual({ - pathInferred: '/foo/bar.html', + pathInferred: '/foo/bar', pathLocale: '/', }) @@ -139,7 +139,7 @@ it('should respect options.path', () => { options: { path: '/zh/custom/path.html' }, }), ).toEqual({ - pathInferred: '/zh/foo/bar.html', + pathInferred: '/zh/foo/bar', pathLocale: '/zh/', }) }) diff --git a/packages/core/tests/page/resolvePageHtmlInfo.spec.ts b/packages/core/tests/page/resolvePageHtmlInfo.spec.ts index 1d07c0bbc8..2fbcf79893 100644 --- a/packages/core/tests/page/resolvePageHtmlInfo.spec.ts +++ b/packages/core/tests/page/resolvePageHtmlInfo.spec.ts @@ -12,11 +12,7 @@ const app = createBaseApp({ const TEST_CASES: [string, string][] = [ ['/foo', 'foo.html'], - ['/foo.html', 'foo.html'], - ['/foo/bar.html', 'foo/bar.html'], ['/foo/bar', 'foo/bar.html'], - ['/foo/index.html', 'foo/index.html'], - ['/foo/bar/index.html', 'foo/bar/index.html'], ['/foo/', 'foo/index.html'], ['/foo/bar/', 'foo/bar/index.html'], ] diff --git a/packages/core/tests/page/resolvePagePermalink.spec.ts b/packages/core/tests/page/resolvePagePermalink.spec.ts index 78c5ea2dbf..9f355cc6b4 100644 --- a/packages/core/tests/page/resolvePagePermalink.spec.ts +++ b/packages/core/tests/page/resolvePagePermalink.spec.ts @@ -166,11 +166,11 @@ describe('permalink pattern', () => { }, slug: 'foo-bar', date: '2020-10-07', - pathInferred: '/raw.html', + pathInferred: '/raw', pathLocale: '/en/', }) - expect(resolved).toBe('/en/2020/10/07/foo-bar/raw.html') + expect(resolved).toBe('/en/2020/10/07/foo-bar/raw') }) }) From cf57a08aff8394b1a304dd5aba77b00773a24b32 Mon Sep 17 00:00:00 2001 From: "Mr.Hope" Date: Mon, 18 May 2026 21:26:25 +0800 Subject: [PATCH 3/5] chore: remove webpack middleware to fix broken clean urls --- .../src/dev/createDevServerConfig.ts | 10 ------- packages/bundler-webpack/src/dev/dev.ts | 2 +- .../src/dev/trailingSlashMiddleware.ts | 26 ------------------- 3 files changed, 1 insertion(+), 37 deletions(-) delete mode 100644 packages/bundler-webpack/src/dev/trailingSlashMiddleware.ts diff --git a/packages/bundler-webpack/src/dev/createDevServerConfig.ts b/packages/bundler-webpack/src/dev/createDevServerConfig.ts index cb2e19a941..9326f3bdb8 100644 --- a/packages/bundler-webpack/src/dev/createDevServerConfig.ts +++ b/packages/bundler-webpack/src/dev/createDevServerConfig.ts @@ -4,12 +4,8 @@ import type { App } from '@vuepress/core' import { path } from '@vuepress/utils' import type WebpackDevServer from 'webpack-dev-server' -import type { WebpackBundlerOptions } from '../types.js' -import { trailingSlashMiddleware } from './trailingSlashMiddleware.js' - export const createDevServerConfig = ( app: App, - options: WebpackBundlerOptions, ): WebpackDevServer.Configuration => ({ allowedHosts: 'all', compress: true, @@ -27,12 +23,6 @@ export const createDevServerConfig = ( }, host: app.options.host, hot: true, - setupMiddlewares: (middlewares, devServer) => { - devServer.app?.use(trailingSlashMiddleware) - return ( - options.devServerSetupMiddlewares?.(middlewares, devServer) ?? middlewares - ) - }, open: app.options.open, port: app.options.port, static: { diff --git a/packages/bundler-webpack/src/dev/dev.ts b/packages/bundler-webpack/src/dev/dev.ts index fc157fc51e..57b43c147e 100644 --- a/packages/bundler-webpack/src/dev/dev.ts +++ b/packages/bundler-webpack/src/dev/dev.ts @@ -30,7 +30,7 @@ export const dev = async ( const compiler = webpack(webpackConfig) // create webpack-dev-server - const serverConfig = createDevServerConfig(app, options) + const serverConfig = createDevServerConfig(app) const server = new WebpackDevServer(serverConfig, compiler) const [, close] = await Promise.all([ diff --git a/packages/bundler-webpack/src/dev/trailingSlashMiddleware.ts b/packages/bundler-webpack/src/dev/trailingSlashMiddleware.ts deleted file mode 100644 index 34d34e0717..0000000000 --- a/packages/bundler-webpack/src/dev/trailingSlashMiddleware.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { RequestHandler } from 'express' - -/** - * A middleware to add trailing slash to the url - * - * It will redirect '/foo' to '/foo/' with 302 - */ -export const trailingSlashMiddleware: RequestHandler = (req, res, next) => { - if ( - // only add trailing slash in GET and HEAD requests - !['GET', 'HEAD'].includes(req.method) || - // if the last section of the path has a dot, we think it has extension - // and should not add trailing slash - req.path.split('/').pop()?.includes('.') || - // if the path already has trailing slash - req.path.endsWith('/') - ) { - next() - return - } - - // add trailing slash and retain query - // notice that we should not use 301 in dev-server - const query = req.url.slice(req.path.length) - res.redirect(302, `${req.path}/${query}`) -} From d8b8d39260948614f63f844627e042e49bfe36cd Mon Sep 17 00:00:00 2001 From: "Mr.Hope" Date: Mon, 7 Sep 2026 13:36:38 +0800 Subject: [PATCH 4/5] chore: tweaks --- packages/bundlerutils/src/build/index.ts | 2 +- ...inkRoutePath.ts => resolveLinkRouteKey.ts} | 10 +- .../src/build/resolveLinkedPageChunkFiles.ts | 8 +- .../build/createPageChunkFilesMap.spec.ts | 14 +-- ...th.spec.ts => resolveLinkRouteKey.spec.ts} | 21 ++-- .../build/resolveLinkedPageChunkFiles.spec.ts | 6 +- packages/client/src/router/resolveRoute.ts | 7 +- .../tests/plugins/linksPlugin.spec.ts | 106 ++++++++++++++++++ 8 files changed, 138 insertions(+), 36 deletions(-) rename packages/bundlerutils/src/build/{resolveLinkRoutePath.ts => resolveLinkRouteKey.ts} (63%) rename packages/bundlerutils/tests/build/{resolveLinkRoutePath.spec.ts => resolveLinkRouteKey.spec.ts} (72%) diff --git a/packages/bundlerutils/src/build/index.ts b/packages/bundlerutils/src/build/index.ts index 18abd8db83..aad8d61af4 100644 --- a/packages/bundlerutils/src/build/index.ts +++ b/packages/bundlerutils/src/build/index.ts @@ -2,6 +2,6 @@ export * from './createPageChunkFilesMap' export * from './createVueServerApp' export * from './getSsrTemplate' export * from './renderPageToString' -export * from './resolveLinkRoutePath' +export * from './resolveLinkRouteKey' export * from './resolveLinkedPageChunkFiles' export type * from './types' diff --git a/packages/bundlerutils/src/build/resolveLinkRoutePath.ts b/packages/bundlerutils/src/build/resolveLinkRouteKey.ts similarity index 63% rename from packages/bundlerutils/src/build/resolveLinkRoutePath.ts rename to packages/bundlerutils/src/build/resolveLinkRouteKey.ts index 42cbe1a9e8..c910dc7217 100644 --- a/packages/bundlerutils/src/build/resolveLinkRoutePath.ts +++ b/packages/bundlerutils/src/build/resolveLinkRouteKey.ts @@ -1,13 +1,13 @@ import type { MarkdownLink } from '@vuepress/markdown' -import { inferRoutePath, normalizeRoutePath } from '@vuepress/shared' +import { inferRouteKey, normalizeRouteKey } from '@vuepress/shared' /** - * Normalize a MarkdownLink to a route path + * Normalize a MarkdownLink to a route key * that can be matched against Page.path. * * Returns null if the link is unresolvable. */ -export const resolveLinkRoutePath = ({ +export const resolveLinkRouteKey = ({ base, current, link, @@ -17,7 +17,7 @@ export const resolveLinkRoutePath = ({ link: MarkdownLink }): string | null => { if (!link.absolute) { - return link.relative ? normalizeRoutePath(link.relative, current) : null + return link.relative ? normalizeRouteKey(link.relative, current) : null } // Strip base prefix, ensuring leading slash @@ -25,5 +25,5 @@ export const resolveLinkRoutePath = ({ ? `/${link.absolute.slice(base.length)}` : link.absolute - return inferRoutePath(pathWithoutBase) + return inferRouteKey(pathWithoutBase) } diff --git a/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts b/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts index 1d209c1b4d..7f7d50fe0c 100644 --- a/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts +++ b/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts @@ -1,6 +1,6 @@ import type { Page } from '@vuepress/core' -import { resolveLinkRoutePath } from './resolveLinkRoutePath.js' +import { resolveLinkRouteKey } from './resolveLinkRouteKey.js' import type { PageChunkFilesMap } from './types.js' /** @@ -18,13 +18,13 @@ export const resolveLinkedPageChunkFiles = ({ const linkedPageChunkFiles = new Set() for (const link of page.links) { - const routePath = resolveLinkRoutePath({ + const routeKey = resolveLinkRouteKey({ base, current: page.path, link, }) - if (routePath) { - const targetChunks = pageChunkFilesMap.get(routePath) + if (routeKey) { + const targetChunks = pageChunkFilesMap.get(routeKey) if (targetChunks) { for (const file of targetChunks) { linkedPageChunkFiles.add(file) diff --git a/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts b/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts index 9d561838b9..cf6a236386 100644 --- a/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts +++ b/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts @@ -21,12 +21,12 @@ test('should map final paths and inferred route aliases to page chunks', () => { }), createPage({ chunkName: 'regular', - path: '/regular.html', + path: '/regular', pathInferred: '/regular.md', }), createPage({ chunkName: 'virtual', - path: '/virtual.html', + path: '/virtual', pathInferred: null, }), ] @@ -39,18 +39,18 @@ test('should map final paths and inferred route aliases to page chunks', () => { ).toEqual( new Map([ ['/permalink/', ['permalink.js']], - ['/posts/foo.html', ['permalink.js']], + ['/posts/foo', ['permalink.js']], ['/custom/', ['custom-path.js']], ['/%E4%B8%AD%E6%96%87/', ['custom-path.js']], - ['/regular.html', ['regular.js']], - ['/virtual.html', ['virtual.js']], + ['/regular', ['regular.js']], + ['/virtual', ['virtual.js']], ]), ) }) const exactPage = createPage({ chunkName: 'exact', - path: '/legacy.html', + path: '/legacy', pathInferred: null, }) const redirectingPage = createPage({ @@ -73,7 +73,7 @@ test.for([ createPageChunkFilesMap({ pages, resolvePageChunkFiles, - }).get('/legacy.html'), + }).get('/legacy'), ).toEqual(['exact.js']) expect(resolvePageChunkFiles).toHaveBeenCalledTimes(pages.length) }, diff --git a/packages/bundlerutils/tests/build/resolveLinkRoutePath.spec.ts b/packages/bundlerutils/tests/build/resolveLinkRouteKey.spec.ts similarity index 72% rename from packages/bundlerutils/tests/build/resolveLinkRoutePath.spec.ts rename to packages/bundlerutils/tests/build/resolveLinkRouteKey.spec.ts index 4e1970f8c7..d1b81dd646 100644 --- a/packages/bundlerutils/tests/build/resolveLinkRoutePath.spec.ts +++ b/packages/bundlerutils/tests/build/resolveLinkRouteKey.spec.ts @@ -1,23 +1,18 @@ import type { MarkdownLink } from '@vuepress/markdown' import { expect, test } from 'vitest' -import { resolveLinkRoutePath } from '../../src/index.js' +import { resolveLinkRouteKey } from '../../src/index.js' const TEST_CASES = [ [{ absolute: null, relative: '' }, '/', '/', null], - [{ absolute: '/foo.md', relative: 'foo.md' }, '/', '/', '/foo.html'], + [{ absolute: '/foo.md', relative: 'foo.md' }, '/', '/', '/foo'], [ { absolute: '/foo/README.md', relative: 'foo/README.md' }, '/', '/', '/foo/', ], - [ - { absolute: '/base/foo.md', relative: 'foo.md' }, - '/base/', - '/', - '/foo.html', - ], + [{ absolute: '/base/foo.md', relative: 'foo.md' }, '/base/', '/', '/foo'], [ { absolute: '/base/foo/index.html', relative: 'foo/index.html' }, '/base/', @@ -28,20 +23,20 @@ const TEST_CASES = [ { absolute: '/base/base/foo.md', relative: 'base/foo.md' }, '/base/', '/', - '/base/foo.html', + '/base/foo', ], - [{ absolute: '/foo.md', relative: 'foo.md' }, '/base/', '/', '/foo.html'], + [{ absolute: '/foo.md', relative: 'foo.md' }, '/base/', '/', '/foo'], [ { absolute: null, relative: 'foo.md' }, '/base/', '/virtual/page.html', - '/virtual/foo.html', + '/virtual/foo', ], [ { absolute: null, relative: '../foo.md' }, '/', '/virtual/page.html', - '/foo.html', + '/foo', ], [ { absolute: null, relative: 'foo/README.md' }, @@ -54,6 +49,6 @@ const TEST_CASES = [ 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) + expect(resolveLinkRouteKey({ base, current, link })).toBe(expected) }, ) diff --git a/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts b/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts index dfee3b1f6b..30c4ffe880 100644 --- a/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts +++ b/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts @@ -6,7 +6,7 @@ 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 => +const createPage = (links: Page['links'], pagePath = '/current'): Page => ({ links, path: pagePath }) as Page test('should resolve and dedupe linked page chunk files', () => { @@ -28,7 +28,7 @@ test('should resolve and dedupe linked page chunk files', () => { }, ]) const pageChunkFilesMap: PageChunkFilesMap = new Map([ - ['/guide/foo.html', ['foo.js', 'shared.js']], + ['/guide/foo', ['foo.js', 'shared.js']], ['/guide/bar/', ['bar.js', 'shared.js']], ]) @@ -54,7 +54,7 @@ test('should resolve relative links from virtual pages against the current route content: ['[foo](./foo.md)', '[bar](../bar/README.md)'].join('\n\n'), }) const pageChunkFilesMap: PageChunkFilesMap = new Map([ - ['/guide/foo.html', ['foo.js', 'shared.js']], + ['/guide/foo', ['foo.js', 'shared.js']], ['/bar/', ['bar.js', 'shared.js']], ]) diff --git a/packages/client/src/router/resolveRoute.ts b/packages/client/src/router/resolveRoute.ts index b283ffcfce..25bea990ab 100644 --- a/packages/client/src/router/resolveRoute.ts +++ b/packages/client/src/router/resolveRoute.ts @@ -23,9 +23,10 @@ export const resolveRoute = ( // resolve the route path const cleanRoutePath = resolveRouteCleanPath(pathname, currentPath) - const routeFullPath = __VUEPRESS_CLEAN_URL__ - ? cleanRoutePath - : resolveRoutePathWithExt(cleanRoutePath) + hashAndQueries + const routeFullPath = + (__VUEPRESS_CLEAN_URL__ + ? cleanRoutePath + : resolveRoutePathWithExt(cleanRoutePath)) + hashAndQueries // the route not found // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- unsafe indexed access diff --git a/packages/markdown/tests/plugins/linksPlugin.spec.ts b/packages/markdown/tests/plugins/linksPlugin.spec.ts index 1ad7972faa..af1f81374c 100644 --- a/packages/markdown/tests/plugins/linksPlugin.spec.ts +++ b/packages/markdown/tests/plugins/linksPlugin.spec.ts @@ -732,6 +732,112 @@ describe('internal links', () => { }) }) +describe('internal links with clean url', () => { + const source = [ + '[a](foo.md)', + '[b](foo/bar.md)', + '[c](foo/README.md)', + '[d](../bar.md)', + '[e](index.md)', + ].join('\n\n') + + const expectLinks = [ + { + raw: 'foo.md', + relative: 'foo.md', + absolute: null, + }, + { + raw: 'foo/bar.md', + relative: 'foo/bar.md', + absolute: null, + }, + { + raw: 'foo/README.md', + relative: 'foo/README.md', + absolute: null, + }, + { + raw: '../bar.md', + relative: '../bar.md', + absolute: null, + }, + { + raw: 'index.md', + relative: 'index.md', + absolute: null, + }, + ] + + it('should drop `.html` suffix with cleanUrl', () => { + const md = MarkdownIt({ html: true }).use(linksPlugin, { + cleanUrl: true, + }) + const env: MarkdownEnv = {} + + const rendered = md.render(source, env) + + expect(rendered).toEqual( + [ + 'a', + 'b', + 'c', + 'd', + 'e', + ] + .map((a) => `

${a}

`) + .join('\n') + '\n', + ) + + expect(env.links).toEqual(expectLinks) + }) + + it('should keep `.html` suffix without cleanUrl', () => { + const md = MarkdownIt({ html: true }).use(linksPlugin) + const env: MarkdownEnv = {} + + const rendered = md.render(source, env) + + expect(rendered).toEqual( + [ + 'a', + 'b', + 'c', + 'd', + 'e', + ] + .map((a) => `

${a}

`) + .join('\n') + '\n', + ) + + expect(env.links).toEqual(expectLinks) + }) + + it('should preserve hash and queries with cleanUrl', () => { + const sourceWithHashAndQuery = [ + '[hash](foo.md#hash)', + '[query](foo.md?a=1&b=2)', + '[hash-and-query](foo.md?a=1&b=2#hash)', + ].join('\n\n') + const md = MarkdownIt({ html: true }).use(linksPlugin, { + cleanUrl: true, + }) + const env: MarkdownEnv = {} + + const rendered = md.render(sourceWithHashAndQuery, env) + + expect(rendered).toEqual( + [ + 'hash', + 'query', + 'hash-and-query', + ] + .map((a) => `

${a}

`) + .join('\n') + '\n', + ) + }) +}) + describe('empty links', () => { it('should render correctly', () => { const md = MarkdownIt({ html: true }).use(linksPlugin) From 98a2e3c0aab6ba39234ad2e71ae1ededa1d3ebc0 Mon Sep 17 00:00:00 2001 From: "Mr.Hope" Date: Mon, 7 Sep 2026 14:16:45 +0800 Subject: [PATCH 5/5] feat: tweaks --- e2e/docs/.vuepress/config.ts | 2 +- .../src/build/createPageChunkFilesMap.ts | 8 +- .../src/build/resolveLinkedPageChunkFiles.ts | 2 +- .../build/createPageChunkFilesMap.spec.ts | 18 +-- .../build/resolveLinkedPageChunkFiles.spec.ts | 4 +- packages/client/src/setupGlobalComputed.ts | 8 +- .../core/src/app/prepare/prepareRoutes.ts | 20 ++- packages/core/src/app/resolveAppMarkdown.ts | 4 +- packages/core/src/app/resolveAppOptions.ts | 22 ++-- packages/core/src/app/resolveAppPages.ts | 2 +- packages/core/src/page/createPage.ts | 15 ++- packages/core/src/page/index.ts | 1 + packages/core/src/page/resolvePageHtmlInfo.ts | 11 +- packages/core/src/page/resolvePagePath.ts | 39 ++---- packages/core/src/page/resolvePageRouteKey.ts | 38 ++++++ .../core/tests/app/resolveAppOptions.spec.ts | 43 ++++++ .../core/tests/app/resolveAppPages.spec.ts | 12 +- packages/core/tests/page/createPage.spec.ts | 6 +- .../tests/page/resolvePageHtmlInfo.spec.ts | 6 +- .../core/tests/page/resolvePagePath.spec.ts | 97 +++----------- .../tests/page/resolvePageRouteKey.spec.ts | 122 ++++++++++++++++++ .../src/plugins/linksPlugin/linksPlugin.ts | 3 + packages/shared/src/types/page.ts | 24 +++- 23 files changed, 332 insertions(+), 175 deletions(-) create mode 100644 packages/core/src/page/resolvePageRouteKey.ts create mode 100644 packages/core/tests/page/resolvePageRouteKey.spec.ts diff --git a/e2e/docs/.vuepress/config.ts b/e2e/docs/.vuepress/config.ts index 3c748286ef..eef27d66b4 100644 --- a/e2e/docs/.vuepress/config.ts +++ b/e2e/docs/.vuepress/config.ts @@ -69,7 +69,7 @@ export default defineUserConfig({ theme: e2eTheme(), extendsPage: (page) => { - if (page.path === '/page-data/route-meta') { + if (page.routeKey === '/page-data/route-meta') { page.routeMeta = { a: 1, b: 2, diff --git a/packages/bundlerutils/src/build/createPageChunkFilesMap.ts b/packages/bundlerutils/src/build/createPageChunkFilesMap.ts index 299076a263..9f5002c5b6 100644 --- a/packages/bundlerutils/src/build/createPageChunkFilesMap.ts +++ b/packages/bundlerutils/src/build/createPageChunkFilesMap.ts @@ -14,15 +14,15 @@ export const createPageChunkFilesMap = ({ resolvePageChunkFiles: (page: Page) => string[] }): PageChunkFilesMap => { const pageChunkFilesMap: PageChunkFilesMap = new Map() - const pagePaths = new Set(pages.map((page) => page.path)) + const pageRouteKeys = new Set(pages.map((page) => page.routeKey)) for (const page of pages) { const pageChunkFiles = resolvePageChunkFiles(page) - pageChunkFilesMap.set(page.path, pageChunkFiles) + pageChunkFilesMap.set(page.routeKey, pageChunkFiles) for (const redirect of resolvePageRedirects(page)) { - // Exact page paths take precedence over redirects in the client router. - if (!pagePaths.has(redirect)) { + // Exact page route keys take precedence over redirects in the client router. + if (!pageRouteKeys.has(redirect)) { pageChunkFilesMap.set(redirect, pageChunkFiles) } } diff --git a/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts b/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts index 7f7d50fe0c..74c9217e65 100644 --- a/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts +++ b/packages/bundlerutils/src/build/resolveLinkedPageChunkFiles.ts @@ -20,7 +20,7 @@ export const resolveLinkedPageChunkFiles = ({ for (const link of page.links) { const routeKey = resolveLinkRouteKey({ base, - current: page.path, + current: page.routeKey, link, }) if (routeKey) { diff --git a/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts b/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts index cf6a236386..9e71b259b7 100644 --- a/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts +++ b/packages/bundlerutils/tests/build/createPageChunkFilesMap.spec.ts @@ -4,29 +4,29 @@ import { expect, test, vi } from 'vitest' import { createPageChunkFilesMap } from '../../src/index.js' const createPage = ( - page: Pick, + page: Pick, ): Page => page as Page -test('should map final paths and inferred route aliases to page chunks', () => { +test('should map final route keys and inferred route aliases to page chunks', () => { const pages = [ createPage({ chunkName: 'permalink', - path: '/permalink/', + routeKey: '/permalink/', pathInferred: '/posts/foo.md', }), createPage({ chunkName: 'custom-path', - path: '/custom/', + routeKey: '/custom/', pathInferred: '/中文/README.md', }), createPage({ chunkName: 'regular', - path: '/regular', + routeKey: '/regular', pathInferred: '/regular.md', }), createPage({ chunkName: 'virtual', - path: '/virtual', + routeKey: '/virtual', pathInferred: null, }), ] @@ -50,12 +50,12 @@ test('should map final paths and inferred route aliases to page chunks', () => { const exactPage = createPage({ chunkName: 'exact', - path: '/legacy', + routeKey: '/legacy', pathInferred: null, }) const redirectingPage = createPage({ chunkName: 'permalink', - path: '/permalink/', + routeKey: '/permalink/', pathInferred: '/legacy.md', }) @@ -63,7 +63,7 @@ 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', + 'should prioritize an exact page route key when it appears $name its redirect alias', ({ pages }) => { const resolvePageChunkFiles = vi.fn((page: Page) => [ `${page.chunkName}.js`, diff --git a/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts b/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts index 30c4ffe880..6c84a776b4 100644 --- a/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts +++ b/packages/bundlerutils/tests/build/resolveLinkedPageChunkFiles.spec.ts @@ -6,8 +6,8 @@ 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'): Page => - ({ links, path: pagePath }) as Page +const createPage = (links: Page['links'], routeKey = '/current'): Page => + ({ links, routeKey }) as Page test('should resolve and dedupe linked page chunk files', () => { const page = createPage([ diff --git a/packages/client/src/setupGlobalComputed.ts b/packages/client/src/setupGlobalComputed.ts index 4d7cf6520d..1b136a7306 100644 --- a/packages/client/src/setupGlobalComputed.ts +++ b/packages/client/src/setupGlobalComputed.ts @@ -47,16 +47,16 @@ export const setupGlobalComputed = ( // handle page data HMR if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot)) { __VUE_HMR_RUNTIME__.updatePageData = async (newPageData: PageData) => { - const oldPageChunk = await routes.value[newPageData.path].loader() + const oldPageChunk = await routes.value[newPageData.routeKey].loader() const newPageChunk: PageChunk = { default: oldPageChunk.default, _pageData: newPageData, } - routes.value[newPageData.path].loader = async () => + routes.value[newPageData.routeKey].loader = async () => Promise.resolve(newPageChunk) if ( - newPageData.path === - router.currentRoute.value.meta._pageChunk?._pageData.path + newPageData.routeKey === + router.currentRoute.value.meta._pageChunk?._pageData.routeKey ) { pageChunk.value = newPageChunk } diff --git a/packages/core/src/app/prepare/prepareRoutes.ts b/packages/core/src/app/prepare/prepareRoutes.ts index 492838ba68..ca5ccb7fc4 100644 --- a/packages/core/src/app/prepare/prepareRoutes.ts +++ b/packages/core/src/app/prepare/prepareRoutes.ts @@ -26,10 +26,10 @@ if (import.meta.hot) { * @internal */ export const resolvePageRedirects = ({ - path, + routeKey, pathInferred, }: Page): string[] => { - // paths that should redirect to this page, use set to dedupe + // route keys that should redirect to this page, use set to dedupe const redirectsSet = new Set() // redirect from inferred path, notice that the inferred path is not uri-encoded @@ -37,8 +37,11 @@ export const resolvePageRedirects = ({ const normalizedPathInferred = normalizeRouteKey(pathInferred) const encodedPathInferred = encodeURI(normalizedPathInferred) - // add redirect to the set when the redirect could not be normalized & encoded to the page path - if (normalizedPathInferred !== path && encodedPathInferred !== path) { + // add redirect to the set when the redirect could not be normalized & encoded to the page route key + if ( + normalizedPathInferred !== routeKey && + encodedPathInferred !== routeKey + ) { redirectsSet.add(encodedPathInferred) } } @@ -56,7 +59,10 @@ export const ${REDIRECTS_VAR_NAME} = JSON.parse(${JSON.stringify( JSON.stringify( Object.fromEntries( app.pages.flatMap((page) => - resolvePageRedirects(page).map((redirect) => [redirect, page.path]), + resolvePageRedirects(page).map((redirect) => [ + redirect, + page.routeKey, + ]), ), ), ), @@ -65,8 +71,8 @@ export const ${REDIRECTS_VAR_NAME} = JSON.parse(${JSON.stringify( export const ${ROUTES_VAR_NAME} = Object.fromEntries([ ${app.pages .map( - ({ chunkFilePath, chunkName, path, routeMeta }) => - ` [${JSON.stringify(path)}, { loader: () => import(${chunkName ? `/* webpackChunkName: "${chunkName}" */` : ''}${JSON.stringify(chunkFilePath)}), meta: ${JSON.stringify(routeMeta)} }],`, + ({ chunkFilePath, chunkName, routeKey, routeMeta }) => + ` [${JSON.stringify(routeKey)}, { loader: () => import(${chunkName ? `/* webpackChunkName: "${chunkName}" */` : ''}${JSON.stringify(chunkFilePath)}), meta: ${JSON.stringify(routeMeta)} }],`, ) .join('\n')} ]); diff --git a/packages/core/src/app/resolveAppMarkdown.ts b/packages/core/src/app/resolveAppMarkdown.ts index b3460b5957..945946f601 100644 --- a/packages/core/src/app/resolveAppMarkdown.ts +++ b/packages/core/src/app/resolveAppMarkdown.ts @@ -12,7 +12,9 @@ export const resolveAppMarkdown = async (app: App): Promise => { // links plugin is not disabled if (app.options.markdown.links !== false) { app.options.markdown.links ??= {} - // set the cleanUrl option + // inject the global `route.cleanUrl` option into the links plugin + // `markdown.links.cleanUrl` is an internal option and should not be + // configured by users, `route.cleanUrl` is the single source of truth app.options.markdown.links.cleanUrl = app.options.route.cleanUrl } diff --git a/packages/core/src/app/resolveAppOptions.ts b/packages/core/src/app/resolveAppOptions.ts index 1468c164f9..ca1443ad66 100644 --- a/packages/core/src/app/resolveAppOptions.ts +++ b/packages/core/src/app/resolveAppOptions.ts @@ -47,14 +47,7 @@ export const resolveAppOptions = ({ pagePatterns: _pagePatterns, // eslint-disable-next-line @typescript-eslint/no-deprecated permalinkPattern: _permalinkPattern, - route: { - cleanUrl = false, - pagePatterns = ['**/*.md', '!.vuepress'], - permalinkPattern = null, - } = { - pagePatterns: _pagePatterns, - permalinkPattern: _permalinkPattern, - }, + route: userRoute = {}, userStyle = null, plugins = [], theme, @@ -86,9 +79,16 @@ export const resolveAppOptions = ({ debug, markdown, route: { - cleanUrl, - pagePatterns, - permalinkPattern, + // each `route` field falls back to the deprecated top-level config first, + // then to the built-in default, so that a partial `route` object does not + // silently drop the legacy `pagePatterns` / `permalinkPattern` + cleanUrl: userRoute.cleanUrl ?? false, + pagePatterns: userRoute.pagePatterns ?? + _pagePatterns ?? ['**/*.md', '!.vuepress'], + permalinkPattern: + userRoute.permalinkPattern === undefined + ? (_permalinkPattern ?? null) + : userRoute.permalinkPattern, }, userStyle, plugins, diff --git a/packages/core/src/app/resolveAppPages.ts b/packages/core/src/app/resolveAppPages.ts index 112b898ceb..95102e4384 100644 --- a/packages/core/src/app/resolveAppPages.ts +++ b/packages/core/src/app/resolveAppPages.ts @@ -35,7 +35,7 @@ export const resolveAppPages = async ( pagesMap[page.filePath] = page } // if there is a 404 page, set the default layout to NotFound - if (page.path === '/404') { + if (page.routeKey === '/404') { page.frontmatter.layout ??= 'NotFound' hasNotFoundPage = true } diff --git a/packages/core/src/page/createPage.ts b/packages/core/src/page/createPage.ts index bcb1b9b12c..2349742577 100644 --- a/packages/core/src/page/createPage.ts +++ b/packages/core/src/page/createPage.ts @@ -9,6 +9,7 @@ import { resolvePageHtmlInfo } from './resolvePageHtmlInfo.js' import { resolvePageLang } from './resolvePageLang.js' import { resolvePagePath } from './resolvePagePath.js' import { resolvePagePermalink } from './resolvePagePermalink.js' +import { resolvePageRouteKey } from './resolvePageRouteKey.js' import { resolvePageRouteMeta } from './resolvePageRouteMeta.js' import { resolvePageSlug } from './resolvePageSlug.js' @@ -78,13 +79,19 @@ export const createPage = async ( pathLocale, }) - // resolve page path - const path = resolvePagePath({ permalink, pathInferred, options }) + // resolve the canonical route key of the page + const routeKey = resolvePageRouteKey({ permalink, pathInferred, options }) + + // resolve the actual route path of the page in the current mode + const path = resolvePagePath({ + routeKey, + cleanUrl: app.options.route.cleanUrl, + }) // resolve page rendered html file path const { htmlFilePath, htmlFilePathRelative } = resolvePageHtmlInfo({ app, - path, + routeKey, }) const { chunkFilePath, chunkFilePathRelative, chunkName } = @@ -99,6 +106,7 @@ export const createPage = async ( // page data data: { path, + routeKey, title, lang, frontmatter, @@ -106,6 +114,7 @@ export const createPage = async ( // base fields path, + routeKey, title, lang, frontmatter, diff --git a/packages/core/src/page/index.ts b/packages/core/src/page/index.ts index e6bc9edd07..a7813312e4 100644 --- a/packages/core/src/page/index.ts +++ b/packages/core/src/page/index.ts @@ -10,5 +10,6 @@ export * from './resolvePageHtmlInfo.js' export * from './resolvePageLang.js' export * from './resolvePagePath.js' export * from './resolvePagePermalink.js' +export * from './resolvePageRouteKey.js' export * from './resolvePageRouteMeta.js' export * from './resolvePageSlug.js' diff --git a/packages/core/src/page/resolvePageHtmlInfo.ts b/packages/core/src/page/resolvePageHtmlInfo.ts index 7794e543f2..5aa869db43 100644 --- a/packages/core/src/page/resolvePageHtmlInfo.ts +++ b/packages/core/src/page/resolvePageHtmlInfo.ts @@ -5,24 +5,27 @@ import type { App } from '../types/index.js' /** * Resolve page rendered html file path * + * The html file path is derived from the canonical route key of the page, + * so that it stays the same no matter `cleanUrl` is enabled or not. + * * @internal */ export const resolvePageHtmlInfo = ({ app, - path: pagePath, + routeKey, }: { app: App - path: string + routeKey: string }): { htmlFilePath: string htmlFilePathRelative: string } => { - const path = decodeURI(pagePath) + const routePath = decodeURI(routeKey) // /foo -> foo.html // /foo/ -> foo/index.html const htmlFilePathRelative = removeLeadingSlash( - path.endsWith('/') ? `${path}index.html` : `${path}.html`, + routePath.endsWith('/') ? `${routePath}index.html` : `${routePath}.html`, ) const htmlFilePath = app.dir.dest(htmlFilePathRelative) diff --git a/packages/core/src/page/resolvePagePath.ts b/packages/core/src/page/resolvePagePath.ts index 340f3310b4..7bc23651da 100644 --- a/packages/core/src/page/resolvePagePath.ts +++ b/packages/core/src/page/resolvePagePath.ts @@ -1,35 +1,16 @@ -import { inferRouteKey } from '@vuepress/shared' -import { logger, sanitizeFileName } from '@vuepress/utils' - -import type { PageOptions } from '../types/index.js' - /** - * Resolve the final route path of a page + * Resolve the actual route path of a page in the current mode + * + * It is the canonical route key with the `.html` suffix appended, unless + * `cleanUrl` is enabled or the route key already ends with a trailing slash. * * @internal */ export const resolvePagePath = ({ - permalink, - pathInferred, - options, + routeKey, + cleanUrl, }: { - permalink: string | null - pathInferred: string | null - options: PageOptions -}): string => { - const pagePath = options.path - ? inferRouteKey(options.path) - : permalink || pathInferred - - if (!pagePath) { - throw logger.createError( - `page path is empty, page options: ${JSON.stringify(options, null, 2)}`, - ) - } - - return ( - encodeURI(pagePath.split('/').map(sanitizeFileName).join('/')) - // get clean format - .replace(/\.html$/, '') - ) -} + routeKey: string + cleanUrl: boolean +}): string => + cleanUrl || routeKey.endsWith('/') ? routeKey : `${routeKey}.html` diff --git a/packages/core/src/page/resolvePageRouteKey.ts b/packages/core/src/page/resolvePageRouteKey.ts new file mode 100644 index 0000000000..f13844ee82 --- /dev/null +++ b/packages/core/src/page/resolvePageRouteKey.ts @@ -0,0 +1,38 @@ +import { inferRouteKey } from '@vuepress/shared' +import { logger, sanitizeFileName } from '@vuepress/utils' + +import type { PageOptions } from '../types/index.js' + +/** + * Resolve the canonical route key of a page + * + * The route key is always in the "clean" format, i.e. without the trailing + * `.html` suffix, and is used as the canonical identity of the page. + * + * @internal + */ +export const resolvePageRouteKey = ({ + permalink, + pathInferred, + options, +}: { + permalink: string | null + pathInferred: string | null + options: PageOptions +}): string => { + const routeKey = options.path + ? inferRouteKey(options.path) + : permalink || pathInferred + + if (!routeKey) { + throw logger.createError( + `page path is empty, page options: ${JSON.stringify(options, null, 2)}`, + ) + } + + return ( + encodeURI(routeKey.split('/').map(sanitizeFileName).join('/')) + // get clean format + .replace(/\.html$/, '') + ) +} diff --git a/packages/core/tests/app/resolveAppOptions.spec.ts b/packages/core/tests/app/resolveAppOptions.spec.ts index e83d930f69..11460d724e 100644 --- a/packages/core/tests/app/resolveAppOptions.spec.ts +++ b/packages/core/tests/app/resolveAppOptions.spec.ts @@ -52,3 +52,46 @@ it('should create app options with default values', () => { plugins: [], }) }) + +it('should fall back to deprecated top-level config for unset `route` fields', () => { + const source = '/foo' + + expect( + resolveAppOptions({ + source, + theme: { name: 'theme' }, + bundler: { name: 'bundler', type: 'vite' } as Bundler, + pagePatterns: ['foo/**/*.md'], + permalinkPattern: '/:slug', + route: { + cleanUrl: true, + }, + }).route, + ).toEqual({ + cleanUrl: true, + pagePatterns: ['foo/**/*.md'], + permalinkPattern: '/:slug', + }) +}) + +it('should let `route` fields override the deprecated top-level config', () => { + const source = '/foo' + + expect( + resolveAppOptions({ + source, + theme: { name: 'theme' }, + bundler: { name: 'bundler', type: 'vite' } as Bundler, + pagePatterns: ['foo/**/*.md'], + permalinkPattern: '/:slug', + route: { + pagePatterns: ['bar/**/*.md'], + permalinkPattern: null, + }, + }).route, + ).toEqual({ + cleanUrl: false, + pagePatterns: ['bar/**/*.md'], + permalinkPattern: null, + }) +}) diff --git a/packages/core/tests/app/resolveAppPages.spec.ts b/packages/core/tests/app/resolveAppPages.spec.ts index d33b813233..1f2c8d717a 100644 --- a/packages/core/tests/app/resolveAppPages.spec.ts +++ b/packages/core/tests/app/resolveAppPages.spec.ts @@ -14,9 +14,9 @@ it('should create two pages with default 404 page', async () => { app.markdown = createMarkdown() const { pages } = await resolveAppPages(app) - const fooPage = pages.find((page) => page.path === '/foo') - const barPage = pages.find((page) => page.path === '/bar') - const notFoundPage = pages.find((page) => page.path === '/404') + const fooPage = pages.find((page) => page.routeKey === '/foo') + const barPage = pages.find((page) => page.routeKey === '/bar') + const notFoundPage = pages.find((page) => page.routeKey === '/404') expect(pages).toHaveLength(3) expect(fooPage?.filePathRelative).toEqual('foo.md') @@ -34,9 +34,9 @@ it('should create two pages with custom 404 page', async () => { app.markdown = createMarkdown() const { pages } = await resolveAppPages(app) - const fooPage = pages.find((page) => page.path === '/foo') - const barPage = pages.find((page) => page.path === '/bar') - const notFoundPage = pages.find((page) => page.path === '/404') + const fooPage = pages.find((page) => page.routeKey === '/foo') + const barPage = pages.find((page) => page.routeKey === '/bar') + const notFoundPage = pages.find((page) => page.routeKey === '/404') expect(pages).toHaveLength(3) expect(fooPage?.filePathRelative).toEqual('foo.md') diff --git a/packages/core/tests/page/createPage.spec.ts b/packages/core/tests/page/createPage.spec.ts index a929f058e4..924f6e605e 100644 --- a/packages/core/tests/page/createPage.spec.ts +++ b/packages/core/tests/page/createPage.spec.ts @@ -96,11 +96,13 @@ describe('should work without plugins', () => { }) // page data - expect(page.data.path).toBe('/zh/test') + expect(page.data.path).toBe('/zh/test.html') + expect(page.data.routeKey).toBe('/zh/test') expect(page.data.lang).toBe('zh-CN') // base fields - expect(page.path).toBe('/zh/test') + expect(page.path).toBe('/zh/test.html') + expect(page.routeKey).toBe('/zh/test') expect(page.lang).toBe('zh-CN') // file info diff --git a/packages/core/tests/page/resolvePageHtmlInfo.spec.ts b/packages/core/tests/page/resolvePageHtmlInfo.spec.ts index 2fbcf79893..843619718a 100644 --- a/packages/core/tests/page/resolvePageHtmlInfo.spec.ts +++ b/packages/core/tests/page/resolvePageHtmlInfo.spec.ts @@ -18,12 +18,12 @@ const TEST_CASES: [string, string][] = [ ] describe('should resolve page html file path correctly', () => { - TEST_CASES.forEach(([source, expected]) => { - it(JSON.stringify(source), () => { + TEST_CASES.forEach(([routeKey, expected]) => { + it(JSON.stringify(routeKey), () => { expect( resolvePageHtmlInfo({ app, - path: source, + routeKey, }), ).toEqual({ htmlFilePath: app.dir.dest(expected), diff --git a/packages/core/tests/page/resolvePagePath.spec.ts b/packages/core/tests/page/resolvePagePath.spec.ts index 5b4493edc2..2fce38220f 100644 --- a/packages/core/tests/page/resolvePagePath.spec.ts +++ b/packages/core/tests/page/resolvePagePath.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { resolvePagePath } from '../../src/index.js' @@ -6,94 +6,43 @@ const TEST_CASES: [ Parameters, ReturnType, ][] = [ - // use options.path + // clean url disabled: append `.html` unless the route key ends with a slash [ [ { - permalink: '/permalink', - pathInferred: '/inferred', - options: { - path: '/options', - }, + routeKey: '/foo', + cleanUrl: false, }, ], - '/options', + '/foo.html', ], [ [ { - permalink: '/permalink/', - pathInferred: '/inferred/', - options: { - path: '/options/', - }, + routeKey: '/foo/bar/', + cleanUrl: false, }, ], - '/options/', + '/foo/bar/', ], + // clean url enabled: keep the clean route key [ [ { - permalink: '/permalink.html', - pathInferred: '/inferred.html', - options: { - path: '/options.html', - }, + routeKey: '/foo', + cleanUrl: true, }, ], - '/options', + '/foo', ], - // use permalink [ [ { - permalink: '/permalink', - pathInferred: '/inferred', - options: {}, + routeKey: '/foo/', + cleanUrl: true, }, ], - '/permalink', - ], - [ - [ - { - permalink: '/permalink/', - pathInferred: '/inferred/', - options: {}, - }, - ], - '/permalink/', - ], - // user pathInferred - [ - [ - { - permalink: null, - pathInferred: '/inferred', - options: {}, - }, - ], - '/inferred', - ], - [ - [ - { - permalink: null, - pathInferred: '/inferred/', - options: {}, - }, - ], - '/inferred/', - ], - [ - [ - { - permalink: null, - pathInferred: '/inferred.html', - options: {}, - }, - ], - '/inferred', + '/foo/', ], ] @@ -104,19 +53,3 @@ describe('should resolve page path correctly', () => { }) }) }) - -it('should throw an error', () => { - const consoleError = console.error - console.error = vi.fn() - - expect(() => - resolvePagePath({ - permalink: null, - pathInferred: null, - options: {}, - }), - ).toThrow() - expect(console.error).toHaveBeenCalled() - - console.error = consoleError -}) diff --git a/packages/core/tests/page/resolvePageRouteKey.spec.ts b/packages/core/tests/page/resolvePageRouteKey.spec.ts new file mode 100644 index 0000000000..ca8c13d177 --- /dev/null +++ b/packages/core/tests/page/resolvePageRouteKey.spec.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from 'vitest' + +import { resolvePageRouteKey } from '../../src/index.js' + +const TEST_CASES: [ + Parameters, + ReturnType, +][] = [ + // use options.path + [ + [ + { + permalink: '/permalink', + pathInferred: '/inferred', + options: { + path: '/options', + }, + }, + ], + '/options', + ], + [ + [ + { + permalink: '/permalink/', + pathInferred: '/inferred/', + options: { + path: '/options/', + }, + }, + ], + '/options/', + ], + [ + [ + { + permalink: '/permalink.html', + pathInferred: '/inferred.html', + options: { + path: '/options.html', + }, + }, + ], + '/options', + ], + // use permalink + [ + [ + { + permalink: '/permalink', + pathInferred: '/inferred', + options: {}, + }, + ], + '/permalink', + ], + [ + [ + { + permalink: '/permalink/', + pathInferred: '/inferred/', + options: {}, + }, + ], + '/permalink/', + ], + // user pathInferred + [ + [ + { + permalink: null, + pathInferred: '/inferred', + options: {}, + }, + ], + '/inferred', + ], + [ + [ + { + permalink: null, + pathInferred: '/inferred/', + options: {}, + }, + ], + '/inferred/', + ], + [ + [ + { + permalink: null, + pathInferred: '/inferred.html', + options: {}, + }, + ], + '/inferred', + ], +] + +describe('should resolve page route key correctly', () => { + TEST_CASES.forEach(([input, expected]) => { + it(`input: ${JSON.stringify(input)}`, () => { + expect(resolvePageRouteKey(...input)).toEqual(expected) + }) + }) +}) + +it('should throw an error', () => { + const consoleError = console.error + console.error = vi.fn() + + expect(() => + resolvePageRouteKey({ + permalink: null, + pathInferred: null, + options: {}, + }), + ).toThrow() + expect(console.error).toHaveBeenCalled() + + console.error = consoleError +}) diff --git a/packages/markdown/src/plugins/linksPlugin/linksPlugin.ts b/packages/markdown/src/plugins/linksPlugin/linksPlugin.ts index 963b00005f..dd6f740d41 100644 --- a/packages/markdown/src/plugins/linksPlugin/linksPlugin.ts +++ b/packages/markdown/src/plugins/linksPlugin/linksPlugin.ts @@ -13,6 +13,9 @@ export interface LinksPluginOptions { /** * Whether use "clean url" * + * @internal this is injected by `@vuepress/core` from the global + * `route.cleanUrl` config. Users should not configure it directly. + * * @default false */ cleanUrl?: boolean diff --git a/packages/shared/src/types/page.ts b/packages/shared/src/types/page.ts index 65bf43181d..b984d1fe77 100644 --- a/packages/shared/src/types/page.ts +++ b/packages/shared/src/types/page.ts @@ -12,14 +12,28 @@ export interface PageBase< >, > { /** - * Route path of the page + * Canonical route key of the page * - * Firstly inferred from the file path + * It is always in the "clean" format, i.e. without the trailing `.html` + * suffix. It is used as the key of the client route map and as the + * canonical identity of the page across routes and redirects. * - * Might be overridden by permalink + * @example '/guide' + * @example '/2020/02/02/hello-world' + * @example '/guide/' + */ + routeKey: string + + /** + * Path of the page in the current mode + * + * It is the actual route path that should be used to link the page. It + * equals the `routeKey` when `route.cleanUrl` is enabled, or the `routeKey` + * plus the `.html` suffix otherwise (the default behavior). * - * @example '/guide/index.html' - * @example '/2020/02/02/hello-world.html' + * @example '/guide.html' + * @example '/guide' + * @example '/guide/' */ path: string