diff --git a/e2e/docs/router/resolve-route-query-hash.md b/e2e/docs/router/resolve-route-query-hash.md new file mode 100644 index 0000000000..a21266220f --- /dev/null +++ b/e2e/docs/router/resolve-route-query-hash.md @@ -0,0 +1,12 @@ +# Resolve Route FullPath + +## Includes Query And Hash + +- Search Query: {{ JSON.stringify(resolveRoute('/?query=1')) }} +- Hash: {{ JSON.stringify(resolveRoute('/#hash')) }} +- Search Query And Hash: {{ JSON.stringify(resolveRoute('/?query=1#hash')) }} +- Permalink And Search Query: {{ JSON.stringify(resolveRoute('/routes/permalinks/ascii-non-ascii.md?query=1')) }} + + diff --git a/e2e/tests/router/resolve-route-query-hash.spec.ts b/e2e/tests/router/resolve-route-query-hash.spec.ts new file mode 100644 index 0000000000..d7554c6425 --- /dev/null +++ b/e2e/tests/router/resolve-route-query-hash.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test' + +const testCases = [ + { + path: '/?query=1', + notFound: false, + }, + { + path: '/#hash', + notFound: false, + }, + { + path: '/?query=1#hash', + notFound: false, + }, + { + path: encodeURI('/永久链接-ascii-中文/?query=1'), + notFound: false, + }, +] + +test('should resolve routes when including both the query and hash', async ({ + page, +}) => { + const listItemsLocator = await page + .locator('.e2e-theme-content #includes-query-and-hash + ul > li') + .all() + + for (const [index, li] of listItemsLocator.entries()) { + const textContent = await li.textContent() + const resolvedRoute = JSON.parse(/: (\{.*\})\s*$/.exec(textContent!)![1]) + expect(resolvedRoute.path).toEqual(testCases[index].path) + expect(resolvedRoute.notFound).toEqual(testCases[index].notFound) + } +}) diff --git a/packages/client/src/components/RouteLink.ts b/packages/client/src/components/RouteLink.ts index 99c4875533..1b1e62b157 100644 --- a/packages/client/src/components/RouteLink.ts +++ b/packages/client/src/components/RouteLink.ts @@ -1,7 +1,7 @@ import { computed, defineComponent, h } from 'vue' import type { SlotsType, VNode } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { resolveRoutePath } from '../router/index.js' +import { resolveRouteFullPath } from '../router/index.js' /** * Forked from https://github.com/vuejs/router/blob/941b2131e80550009e5221d4db9f366b1fea3fd5/packages/router/src/RouterLink.ts#L293 @@ -91,7 +91,7 @@ export const RouteLink = defineComponent({ const path = computed(() => props.to.startsWith('#') || props.to.startsWith('?') ? props.to - : `${__VUEPRESS_BASE__}${resolveRoutePath(props.to, route.path).substring(1)}`, + : `${__VUEPRESS_BASE__}${resolveRouteFullPath(props.to, route.path).substring(1)}`, ) return () => diff --git a/packages/client/src/router/index.ts b/packages/client/src/router/index.ts index dae3d58e2c..206f6f2f2b 100644 --- a/packages/client/src/router/index.ts +++ b/packages/client/src/router/index.ts @@ -2,4 +2,5 @@ export type { Router, RouteLocationNormalizedLoaded } from 'vue-router' export { useRoute, useRouter } from 'vue-router' export * from './resolveRoute.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 e4345c9a9b..8cacd9aac5 100644 --- a/packages/client/src/router/resolveRoute.ts +++ b/packages/client/src/router/resolveRoute.ts @@ -1,3 +1,4 @@ +import { resolveRoutePathInfo } from '@vuepress/shared' import { routes } from '../internal/routes.js' import type { Route, RouteMeta } from '../types/index.js' import { resolveRoutePath } from './resolveRoutePath.js' @@ -15,15 +16,25 @@ export const resolveRoute = ( path: string, currentPath?: string, ): ResolvedRoute => { - const routePath = resolveRoutePath(path, currentPath) - const route = routes.value[routePath] ?? { - ...routes.value['/404.html'], - notFound: true, + // get only the pathname from the path + const [pathname, hashAndQueries] = resolveRoutePathInfo(path) + + // resolve the route path + const routePath = resolveRoutePath(pathname, currentPath) + const routeFullPath = routePath + hashAndQueries + + // the route not found + if (!routes.value[routePath]) { + return { + ...routes.value['/404.html'], + path: routeFullPath, + notFound: true, + } as ResolvedRoute } return { - path: routePath, + ...routes.value[routePath], + path: routeFullPath, notFound: false, - ...route, } as ResolvedRoute } diff --git a/packages/client/src/router/resolveRouteFullPath.ts b/packages/client/src/router/resolveRouteFullPath.ts new file mode 100644 index 0000000000..a07441899a --- /dev/null +++ b/packages/client/src/router/resolveRouteFullPath.ts @@ -0,0 +1,14 @@ +import { resolveRoutePathInfo } from '@vuepress/shared' +import { resolveRoutePath } from './resolveRoutePath.js' + +/** + * Resolve route full path with given raw path + */ +export const resolveRouteFullPath = ( + path: string, + currentPath?: string, +): string => { + const [pathname, hashAndQueries] = resolveRoutePathInfo(path) + + return resolveRoutePath(pathname, currentPath) + hashAndQueries +} diff --git a/packages/client/src/router/resolveRoutePath.ts b/packages/client/src/router/resolveRoutePath.ts index 47b2be32ca..6005543285 100644 --- a/packages/client/src/router/resolveRoutePath.ts +++ b/packages/client/src/router/resolveRoutePath.ts @@ -7,25 +7,34 @@ declare const __VUEPRESS_CLEAN_URL__: boolean * Resolve route path with given raw path */ export const resolveRoutePath = ( - path: string, + pathname: string, currentPath?: string, ): string => { // normalized path - const normalizedPath = normalizeRoutePath( - path, + const normalizedRoutePath = normalizeRoutePath( + pathname, currentPath, __VUEPRESS_CLEAN_URL__, ) - if (routes.value[normalizedPath]) return normalizedPath - // encoded path - const encodedPath = encodeURI(normalizedPath) - if (routes.value[encodedPath]) return encodedPath + // check if the normalized path is in routes + if (routes.value[normalizedRoutePath]) return normalizedRoutePath - // redirected path or fallback to the normalized path - return ( - redirects.value[normalizedPath] || - redirects.value[encodedPath] || - normalizedPath - ) + // check encoded path + const encodedRoutePath = encodeURI(normalizedRoutePath) + + if (routes.value[encodedRoutePath]) { + return encodedRoutePath + } + + // check redirected path with normalized path and encoded path + const redirectedRoutePath = + redirects.value[normalizedRoutePath] || redirects.value[encodedRoutePath] + + if (redirectedRoutePath) { + return redirectedRoutePath + } + + // default to normalized route path + return normalizedRoutePath } diff --git a/packages/shared/src/utils/routes/index.ts b/packages/shared/src/utils/routes/index.ts index a83b4caa70..4ec0e91b53 100644 --- a/packages/shared/src/utils/routes/index.ts +++ b/packages/shared/src/utils/routes/index.ts @@ -2,3 +2,4 @@ export * from './inferRoutePath' export * from './normalizeRoutePath.js' export * from './resolveLocalePath.js' export * from './resolveRoutePathFromUrl.js' +export * from './resolveRoutePathInfo.js' diff --git a/packages/shared/src/utils/routes/normalizeRoutePath.ts b/packages/shared/src/utils/routes/normalizeRoutePath.ts index da8bb2ed7d..c92c2b301a 100644 --- a/packages/shared/src/utils/routes/normalizeRoutePath.ts +++ b/packages/shared/src/utils/routes/normalizeRoutePath.ts @@ -3,23 +3,22 @@ import { inferRoutePath } from './inferRoutePath.js' const FAKE_HOST = 'http://.' /** - * Normalize the given path to the final route path + * Normalize the given pathname path to the final route path */ export const normalizeRoutePath = ( - path: string, + pathname: string, current?: string, cleanUrl = false, ): string => { - if (!path.startsWith('/') && current) { + if (!pathname.startsWith('/') && current) { // the relative path should be resolved against the current path const loc = current.slice(0, current.lastIndexOf('/')) - const { pathname, search, hash } = new URL(`${loc}/${path}`, FAKE_HOST) - - return inferRoutePath(pathname, cleanUrl) + search + hash + return inferRoutePath( + new URL(`${loc}/${pathname}`, FAKE_HOST).pathname, + cleanUrl, + ) } - const [pathname, ...queryAndHash] = path.split(/(\?|#)/) - - return inferRoutePath(pathname, cleanUrl) + queryAndHash.join('') + return inferRoutePath(pathname, cleanUrl) } diff --git a/packages/shared/src/utils/routes/resolveRoutePathInfo.ts b/packages/shared/src/utils/routes/resolveRoutePathInfo.ts new file mode 100644 index 0000000000..65b0b68bad --- /dev/null +++ b/packages/shared/src/utils/routes/resolveRoutePathInfo.ts @@ -0,0 +1,12 @@ +const SPLIT_CHAR_REGEXP = /(#|\?)/ + +/** + * Extract pathname / hash and queries from a full route path + */ +export const resolveRoutePathInfo = ( + path: string, +): [pathname: string, hashAndQueries: string] => { + const [pathname, ...hashAndQueries] = path.split(SPLIT_CHAR_REGEXP) + + return [pathname, hashAndQueries.join('')] +} diff --git a/packages/shared/tests/routes/normalizeRoutePath.spec.ts b/packages/shared/tests/routes/normalizeRoutePath.spec.ts index 5d01992112..2b475760a7 100644 --- a/packages/shared/tests/routes/normalizeRoutePath.spec.ts +++ b/packages/shared/tests/routes/normalizeRoutePath.spec.ts @@ -198,6 +198,13 @@ describe('default', () => { [['/foo/.md', '/a/b.html'], '/foo/.html'], ] + describe('should normalize clean paths correctly', () => { + testCases.forEach(([[path, current], expected]) => + it(`${current ? `"${current}"-` : ''}"${path}" -> "${expected}"`, () => { + expect(normalizeRoutePath(path, current)).toBe(expected) + }), + ) + }) describe('should normalize clean paths correctly', () => { testCases.forEach(([[path, current], expected]) => it(`${current ? `"${current}"-` : ''}"${path}" -> "${expected}"`, () => { diff --git a/packages/shared/tests/routes/resolveRoutePathInfo.spec.ts b/packages/shared/tests/routes/resolveRoutePathInfo.spec.ts new file mode 100644 index 0000000000..5577bd8c09 --- /dev/null +++ b/packages/shared/tests/routes/resolveRoutePathInfo.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { resolveRoutePathInfo } from '../../src/index.js' + +const testCases: [string, [string, string]][] = [ + ['/a/b/c/', ['/a/b/c/', '']], + ['/a/b/c/?a=1', ['/a/b/c/', '?a=1']], + ['/a/b/c/#b', ['/a/b/c/', '#b']], + ['/a/b/c/?a=1#b', ['/a/b/c/', '?a=1#b']], + ['a/index.html', ['a/index.html', '']], + ['/a/index.html?a=1', ['/a/index.html', '?a=1']], + ['/a/index.html#a', ['/a/index.html', '#a']], + ['/a/index.html?a=1#b', ['/a/index.html', '?a=1#b']], +] + +describe('should resolve route path info correctly', () => { + testCases.forEach(([source, expected]) => { + it(`${source} -> ${expected}`, () => { + expect(resolveRoutePathInfo(source)).toEqual(expected) + }) + }) +})