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 @@ 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) + }) +})