diff --git a/e2e/docs/.vuepress/theme/client/layouts/NotFound.vue b/e2e/docs/.vuepress/theme/client/layouts/NotFound.vue
index 75e27d4c0e..1e98d1e27f 100644
--- a/e2e/docs/.vuepress/theme/client/layouts/NotFound.vue
+++ b/e2e/docs/.vuepress/theme/client/layouts/NotFound.vue
@@ -1,3 +1,4 @@
404 Not Found
+
diff --git a/e2e/docs/404.md b/e2e/docs/404.md
index fac3cec274..937c74d960 100644
--- a/e2e/docs/404.md
+++ b/e2e/docs/404.md
@@ -2,3 +2,5 @@
routeMeta:
foo: bar
---
+
+## NotFound H2
diff --git a/e2e/docs/README.md b/e2e/docs/README.md
index 257cc5642c..eb63f0ccbf 100644
--- a/e2e/docs/README.md
+++ b/e2e/docs/README.md
@@ -1 +1,3 @@
foo
+
+## Home H2
diff --git a/e2e/docs/router/navigate-by-link.md b/e2e/docs/router/navigate-by-link.md
new file mode 100644
index 0000000000..e496113275
--- /dev/null
+++ b/e2e/docs/router/navigate-by-link.md
@@ -0,0 +1,28 @@
+## Markdown Links
+
+- [Home](/README.md)
+- [404](/404.md)
+- [Home with query](/README.md?home=true)
+- [Home with query and hash](/README.md?home=true#home)
+- [404 with hash](/404.md#404)
+- [404 with hash and query](/404.md#404?notFound=true)
+
+## HTML Links
+
+Home
+404
+Home
+Home
+404
+404
+
+## Markdown Links with html paths
+
+- [Home](/)
+- [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 and query](/404.html#404?notFound=true)
+
+> 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
new file mode 100644
index 0000000000..b28397dbf7
--- /dev/null
+++ b/e2e/docs/router/navigate-by-router.md
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
diff --git a/e2e/docs/router/navigation.md b/e2e/docs/router/navigation.md
deleted file mode 100644
index 624df7c8f1..0000000000
--- a/e2e/docs/router/navigation.md
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
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/navigate-by-link.spec.ts b/e2e/tests/router/navigate-by-link.spec.ts
new file mode 100644
index 0000000000..4b7560a732
--- /dev/null
+++ b/e2e/tests/router/navigate-by-link.spec.ts
@@ -0,0 +1,98 @@
+import { expect, test } from '@playwright/test'
+import { BASE } from '../../utils/env'
+
+test.beforeEach(async ({ page }) => {
+ await page.goto('router/navigate-by-link.html')
+})
+
+test.describe('markdown links', () => {
+ test('should navigate to home correctly', async ({ page }) => {
+ await page.locator('#markdown-links + ul > li > a').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('#markdown-links + ul > li > a').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('#markdown-links + ul > li > a').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('#markdown-links + ul > li > a').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('#markdown-links + ul > li > a').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 }) => {
+ await page.locator('#markdown-links + ul > li > a').nth(5).click()
+ await expect(page).toHaveURL(`${BASE}404.html#404?notFound=true`)
+ await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2')
+ })
+})
+
+test.describe('html links', () => {
+ test('should navigate to home correctly', async ({ page }) => {
+ await page.locator('#html-links + p > a').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('#html-links + p > a').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('#html-links + p > a').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('#html-links + p > a').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('#html-links + p > a').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 }) => {
+ await page.locator('#html-links + p > a').nth(5).click()
+ await expect(page).toHaveURL(`${BASE}404.html#404?notFound=true`)
+ await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2')
+ })
+})
+
+test.describe('markdown links with html paths', () => {
+ test('should navigate to home correctly', async ({ page }) => {
+ const locator = page
+ .locator('#markdown-links-with-html-paths + ul > li > a')
+ .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')
+ }
+ })
+})
diff --git a/e2e/tests/router/navigate-by-router.spec.ts b/e2e/tests/router/navigate-by-router.spec.ts
new file mode 100644
index 0000000000..327c53be57
--- /dev/null
+++ b/e2e/tests/router/navigate-by-router.spec.ts
@@ -0,0 +1,42 @@
+import { expect, test } from '@playwright/test'
+import { BASE } from '../../utils/env'
+
+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('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('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('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('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('should preserve hash and query', async ({ page }) => {
+ await page.locator('#not-found-with-hash-and-query').click()
+ await expect(page).toHaveURL(`${BASE}404.html#404?notFound=true`)
+ await expect(page.locator('#notfound-h2')).toHaveText('NotFound H2')
+})
diff --git a/e2e/tests/router/navigation.spec.ts b/e2e/tests/router/navigation.spec.ts
deleted file mode 100644
index 76573acf6c..0000000000
--- a/e2e/tests/router/navigation.spec.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { expect, test } from '@playwright/test'
-import { BASE } from '../../utils/env'
-
-test('should preserve query', async ({ page }) => {
- await page.goto('router/navigation.html')
-
- await page.locator('#home').click()
-
- await expect(page).toHaveURL(`${BASE}?home=true`)
-})
-
-test('should preserve hash', async ({ page }) => {
- await page.goto('router/navigation.html')
-
- await page.locator('#not-found').click()
-
- await expect(page).toHaveURL(`${BASE}404.html#404`)
-})
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..c8a7f4b8a8 100644
--- a/packages/client/src/router/resolveRoute.ts
+++ b/packages/client/src/router/resolveRoute.ts
@@ -1,3 +1,4 @@
+import { splitPath } 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 } = splitPath(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..694386d74b
--- /dev/null
+++ b/packages/client/src/router/resolveRouteFullPath.ts
@@ -0,0 +1,13 @@
+import { splitPath } 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 } = splitPath(path)
+ return resolveRoutePath(pathname, currentPath) + hashAndQueries
+}
diff --git a/packages/client/src/router/resolveRoutePath.ts b/packages/client/src/router/resolveRoutePath.ts
index f5aece8f9b..4f815e4c17 100644
--- a/packages/client/src/router/resolveRoutePath.ts
+++ b/packages/client/src/router/resolveRoutePath.ts
@@ -5,21 +5,30 @@ import { redirects, routes } from '../internal/routes.js'
* Resolve route path with given raw path
*/
export const resolveRoutePath = (
- path: string,
+ pathname: string,
currentPath?: string,
): string => {
// normalized path
- const normalizedPath = normalizeRoutePath(path, currentPath)
- if (routes.value[normalizedPath]) return normalizedPath
+ const normalizedRoutePath = normalizeRoutePath(pathname, currentPath)
- // 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..5e67f4b001 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 './splitPath.js'
diff --git a/packages/shared/src/utils/routes/normalizeRoutePath.ts b/packages/shared/src/utils/routes/normalizeRoutePath.ts
index 334fc308c1..6c73ef172a 100644
--- a/packages/shared/src/utils/routes/normalizeRoutePath.ts
+++ b/packages/shared/src/utils/routes/normalizeRoutePath.ts
@@ -3,19 +3,18 @@ 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, current?: string): string => {
- if (!path.startsWith('/') && current) {
+export const normalizeRoutePath = (
+ pathname: string,
+ current?: string,
+): string => {
+ 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) + search + hash
+ return inferRoutePath(new URL(`${loc}/${pathname}`, FAKE_HOST).pathname)
}
- const [pathname, ...queryAndHash] = path.split(/(\?|#)/)
-
- return inferRoutePath(pathname) + queryAndHash.join('')
+ return inferRoutePath(pathname)
}
diff --git a/packages/shared/src/utils/routes/splitPath.ts b/packages/shared/src/utils/routes/splitPath.ts
new file mode 100644
index 0000000000..2aa3906dc6
--- /dev/null
+++ b/packages/shared/src/utils/routes/splitPath.ts
@@ -0,0 +1,17 @@
+const SPLIT_CHAR_REGEXP = /(#|\?)/
+
+/**
+ * Split a path into pathname and hashAndQueries
+ */
+export const splitPath = (
+ path: string,
+): {
+ pathname: string
+ hashAndQueries: string
+} => {
+ const [pathname, ...hashAndQueries] = path.split(SPLIT_CHAR_REGEXP)
+ return {
+ pathname,
+ hashAndQueries: hashAndQueries.join(''),
+ }
+}
diff --git a/packages/shared/tests/routes/normalizeRoutePath.spec.ts b/packages/shared/tests/routes/normalizeRoutePath.spec.ts
index d18a00b035..1732c6d2f6 100644
--- a/packages/shared/tests/routes/normalizeRoutePath.spec.ts
+++ b/packages/shared/tests/routes/normalizeRoutePath.spec.ts
@@ -204,42 +204,3 @@ describe('should normalize clean paths correctly', () => {
}),
)
})
-
-describe('should normalize paths with query correctly', () => {
- testCases
- .map(([[path, current], expected]) => [
- [`${path}?foo=bar`, current],
- `${expected}?foo=bar`,
- ])
- .forEach(([[path, current], expected]) =>
- it(`${current ? `"${current}"-` : ''}"${path}" -> "${expected}"`, () => {
- expect(normalizeRoutePath(path, current)).toBe(expected)
- }),
- )
-})
-
-describe('should normalize paths with hash correctly', () => {
- testCases
- .map(([[path, current], expected]) => [
- [`${path}#foobar`, current],
- `${expected}#foobar`,
- ])
- .map(([[path, current], expected]) =>
- it(`${current ? `"${current}"-` : ''}"${path}" -> "${expected}"`, () => {
- expect(normalizeRoutePath(path, current)).toBe(expected)
- }),
- )
-})
-
-describe('should normalize paths with query and hash correctly', () => {
- testCases
- .map(([[path, current], expected]) => [
- [`${path}?foo=1&bar=2#foobar`, current],
- `${expected}?foo=1&bar=2#foobar`,
- ])
- .map(([[path, current], expected]) =>
- it(`${current ? `"${current}"-` : ''}"${path}" -> "${expected}"`, () => {
- expect(normalizeRoutePath(path, current)).toBe(expected)
- }),
- )
-})
diff --git a/packages/shared/tests/routes/splitPath.spec.ts b/packages/shared/tests/routes/splitPath.spec.ts
new file mode 100644
index 0000000000..6dfd2452fb
--- /dev/null
+++ b/packages/shared/tests/routes/splitPath.spec.ts
@@ -0,0 +1,22 @@
+import { expect, it } from 'vitest'
+import { splitPath } from '../../src/index.js'
+
+const testCases: [string, ReturnType][] = [
+ ['/a/b/c/', { pathname: '/a/b/c/', hashAndQueries: '' }],
+ ['/a/b/c/?a=1', { pathname: '/a/b/c/', hashAndQueries: '?a=1' }],
+ ['/a/b/c/#b', { pathname: '/a/b/c/', hashAndQueries: '#b' }],
+ ['/a/b/c/?a=1#b', { pathname: '/a/b/c/', hashAndQueries: '?a=1#b' }],
+ ['a/index.html', { pathname: 'a/index.html', hashAndQueries: '' }],
+ ['/a/index.html?a=1', { pathname: '/a/index.html', hashAndQueries: '?a=1' }],
+ ['/a/index.html#a', { pathname: '/a/index.html', hashAndQueries: '#a' }],
+ [
+ '/a/index.html?a=1#b',
+ { pathname: '/a/index.html', hashAndQueries: '?a=1#b' },
+ ],
+]
+
+testCases.forEach(([source, expected]) => {
+ it(`${source} -> ${expected}`, () => {
+ expect(splitPath(source)).toEqual(expected)
+ })
+})