diff --git a/e2e/docs/.vuepress/config.ts b/e2e/docs/.vuepress/config.ts
index d670adfb49..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.html') {
+ if (page.routeKey === '/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 df5e94875f..078013d02e 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/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}`)
-}
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/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..74c9217e65 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,
+ current: page.routeKey,
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..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.html',
+ routeKey: '/regular',
pathInferred: '/regular.md',
}),
createPage({
chunkName: 'virtual',
- path: '/virtual.html',
+ routeKey: '/virtual',
pathInferred: null,
}),
]
@@ -39,23 +39,23 @@ 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',
+ 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`,
@@ -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..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.html'): 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([
@@ -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/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..25bea990ab 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,24 @@ 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/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/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 dced59609a..ca5ccb7fc4 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'
@@ -26,19 +26,22 @@ 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
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
- 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 534509baa0..945946f601 100644
--- a/packages/core/src/app/resolveAppMarkdown.ts
+++ b/packages/core/src/app/resolveAppMarkdown.ts
@@ -9,6 +9,15 @@ 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 ??= {}
+ // 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
+ }
+
// 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 7b3cba3234..ca1443ad66 100644
--- a/packages/core/src/app/resolveAppOptions.ts
+++ b/packages/core/src/app/resolveAppOptions.ts
@@ -43,8 +43,11 @@ 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: userRoute = {},
userStyle = null,
plugins = [],
theme,
@@ -75,8 +78,18 @@ export const resolveAppOptions = ({
bundler,
debug,
markdown,
- pagePatterns,
- permalinkPattern,
+ route: {
+ // 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,
theme,
diff --git a/packages/core/src/app/resolveAppPages.ts b/packages/core/src/app/resolveAppPages.ts
index 3b3d1d874d..95102e4384 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.routeKey === '/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/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/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..5aa869db43 100644
--- a/packages/core/src/page/resolvePageHtmlInfo.ts
+++ b/packages/core/src/page/resolvePageHtmlInfo.ts
@@ -5,28 +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.html -> foo.html
+ // /foo -> foo.html
// /foo/ -> foo/index.html
const htmlFilePathRelative = removeLeadingSlash(
- path.endsWith('/')
- ? `${path}index.html`
- : path.endsWith('.html')
- ? path
- : `${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 9215769d8f..7bc23651da 100644
--- a/packages/core/src/page/resolvePagePath.ts
+++ b/packages/core/src/page/resolvePagePath.ts
@@ -1,28 +1,16 @@
-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 || 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('/'))
-}
+ routeKey: string
+ cleanUrl: boolean
+}): string =>
+ cleanUrl || routeKey.endsWith('/') ? routeKey : `${routeKey}.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/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/src/types/app/options.ts b/packages/core/src/types/app/options.ts
index f8e6254c86..262095b878 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
@@ -196,11 +208,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 b5633a23e7..11460d724e 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'),
@@ -49,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 624445eac2..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.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.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.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.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 77b16c4de3..924f6e605e 100644
--- a/packages/core/tests/page/createPage.spec.ts
+++ b/packages/core/tests/page/createPage.spec.ts
@@ -97,10 +97,12 @@ describe('should work without plugins', () => {
// page data
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.html')
+ expect(page.routeKey).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 52984c43f0..2adcb59c55 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: '/',
})
})
@@ -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..843619718a 100644
--- a/packages/core/tests/page/resolvePageHtmlInfo.spec.ts
+++ b/packages/core/tests/page/resolvePageHtmlInfo.spec.ts
@@ -12,22 +12,18 @@ 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'],
]
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 3eeb9efb2e..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.html',
+ '/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.html',
+ '/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/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')
})
})
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 ea75a6bb21..dd6f740d41 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,16 @@ import type { MarkdownEnv } from '../../types.js'
import { resolvePaths } from './resolvePaths.js'
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
+
/**
* Additional attributes for external links
*
@@ -48,6 +62,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 +146,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/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)
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
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)
- })
- })
-})