diff --git a/e2e/docs/components/auto-link.md b/e2e/docs/components/auto-link.md
new file mode 100644
index 0000000000..08cdbc9e86
--- /dev/null
+++ b/e2e/docs/components/auto-link.md
@@ -0,0 +1,61 @@
+# AutoLink
+
+
+
+
+
+
+
+
diff --git a/e2e/docs/components/route-link.md b/e2e/docs/components/route-link.md
index ec2656aa50..48dd4e0010 100644
--- a/e2e/docs/components/route-link.md
+++ b/e2e/docs/components/route-link.md
@@ -28,13 +28,19 @@
- text
- text
+- text
+- text
- text
- text
+- text
+- text
### Class
- text
- text
+- text
+- text
### Attrs
@@ -42,11 +48,17 @@
- text
- text
- text
+- text
+- text
+- text
+- text
### Slots
- text
- texttext2
+- text
+- texttext2
### Hash and query
@@ -56,9 +68,24 @@
- text
- text
- text
+- text
+- text
+- text
+- text
+- text
+- text
- text
- text
- text
- text
- text
- text
+
+### Relative
+
+- text
+- text
+- text
+- text
+- text
+- text
diff --git a/e2e/tests/components/auto-link.spec.ts b/e2e/tests/components/auto-link.spec.ts
new file mode 100644
index 0000000000..653df632e9
--- /dev/null
+++ b/e2e/tests/components/auto-link.spec.ts
@@ -0,0 +1,41 @@
+import { expect, test } from '@playwright/test'
+import { BASE } from '../../utils/env'
+
+test.beforeEach(async ({ page }) => {
+ await page.goto('components/auto-link.html')
+})
+
+test('should render route-link correctly', async ({ page }) => {
+ for (const el of await page
+ .locator('.e2e-theme-content #route-link a')
+ .all()) {
+ await expect(el).toHaveAttribute('class', /route-link/)
+ }
+})
+
+test('should render external-link correctly', async ({ page }) => {
+ for (const el of await page
+ .locator('.e2e-theme-content #external-link a')
+ .all()) {
+ await expect(el).toHaveAttribute('class', /external-link/)
+ }
+})
+
+test('should render config correctly', async ({ page }) => {
+ const locator = page.locator('.e2e-theme-content #config a')
+
+ await expect(await locator.nth(0)).toHaveText('text1')
+ await expect(await locator.nth(0)).toHaveAttribute('href', BASE)
+ await expect(await locator.nth(0)).toHaveAttribute('aria-label', 'label')
+
+ await expect(await locator.nth(1)).toHaveText('text2')
+ await expect(await locator.nth(1)).toHaveAttribute(
+ 'href',
+ 'https://example.com/test/',
+ )
+ await expect(await locator.nth(1)).toHaveAttribute('target', '_blank')
+ await expect(await locator.nth(1)).toHaveAttribute(
+ 'rel',
+ 'noopener noreferrer',
+ )
+})
diff --git a/e2e/tests/components/route-link.spec.ts b/e2e/tests/components/route-link.spec.ts
index c72e932ff2..e555744cac 100644
--- a/e2e/tests/components/route-link.spec.ts
+++ b/e2e/tests/components/route-link.spec.ts
@@ -38,6 +38,10 @@ test('should render active status correctly', async ({ page }) => {
const CONFIGS = [
'route-link route-link-active',
'route-link route-link-active',
+ 'route-link route-link-active',
+ 'route-link route-link-active',
+ 'route-link',
+ 'route-link',
'route-link',
'route-link',
]
@@ -53,6 +57,8 @@ test('should render class correctly', async ({ page }) => {
const CONFIGS = [
'route-link custom-class',
'route-link route-link-active custom-class',
+ 'route-link custom-class',
+ 'route-link route-link-active custom-class',
]
for (const [index, className] of CONFIGS.entries()) {
@@ -80,6 +86,22 @@ test('should render attributes correctly', async ({ page }) => {
attrName: 'aria-label',
attrValue: 'test',
},
+ {
+ attrName: 'title',
+ attrValue: 'Title',
+ },
+ {
+ attrName: 'target',
+ attrValue: '_blank',
+ },
+ {
+ attrName: 'rel',
+ attrValue: 'noopener',
+ },
+ {
+ attrName: 'aria-label',
+ attrValue: 'test',
+ },
]
for (const [index, { attrName, attrValue }] of CONFIGS.entries()) {
@@ -99,6 +121,14 @@ test('should render slots correctly', async ({ page }) => {
spansCount: 2,
spansText: ['text', 'text2'],
},
+ {
+ spansCount: 1,
+ spansText: ['text'],
+ },
+ {
+ spansCount: 2,
+ spansText: ['text', 'text2'],
+ },
]
for (const [index, { spansCount, spansText }] of CONFIGS.entries()) {
const children = await page
@@ -114,6 +144,12 @@ test('should render slots correctly', async ({ page }) => {
test('should render hash and query correctly', async ({ page }) => {
const CONFIGS = [
+ `${BASE}#hash`,
+ `${BASE}?query`,
+ `${BASE}?query#hash`,
+ `${BASE}?query=1#hash`,
+ `${BASE}?query=1&query=2#hash`,
+ `${BASE}#hash?query=1&query=2`,
`${BASE}#hash`,
`${BASE}?query`,
`${BASE}?query#hash`,
@@ -134,3 +170,20 @@ test('should render hash and query correctly', async ({ page }) => {
).toHaveAttribute('href', href)
}
})
+
+test('should render relative links correctly', async ({ page }) => {
+ const CONFIGS = [
+ BASE,
+ `${BASE}404.html`,
+ `${BASE}components/not-exist.html`,
+ BASE,
+ `${BASE}404.html`,
+ `${BASE}components/not-exist.html`,
+ ]
+
+ for (const [index, href] of CONFIGS.entries()) {
+ await expect(
+ page.locator('.e2e-theme-content #relative + ul > li a').nth(index),
+ ).toHaveAttribute('href', href)
+ }
+})
diff --git a/packages/client/src/components/AutoLink.ts b/packages/client/src/components/AutoLink.ts
new file mode 100644
index 0000000000..bfe9cd7d39
--- /dev/null
+++ b/packages/client/src/components/AutoLink.ts
@@ -0,0 +1,227 @@
+import { isLinkWithProtocol } from '@vuepress/shared'
+import type { SlotsType, VNode } from 'vue'
+import { computed, defineComponent, h } from 'vue'
+import { useRoute } from 'vue-router'
+import { useSiteData } from '../composables/index.js'
+import { RouteLink } from './RouteLink.js'
+
+export interface AutoLinkConfig {
+ /**
+ * Text of item
+ *
+ * 项目文字
+ */
+ text: string
+
+ /**
+ * Aria label of item
+ *
+ * 项目无障碍标签
+ */
+ ariaLabel?: string
+
+ /**
+ * Link of item
+ *
+ * 当前页面链接
+ */
+ link: string
+
+ /**
+ * Rel of `` tag
+ *
+ * `` 标签的 `rel` 属性
+ */
+ rel?: string
+
+ /**
+ * Target of `` tag
+ *
+ * `` 标签的 `target` 属性
+ */
+ target?: string
+
+ /**
+ * Regexp mode to be active
+ *
+ * 匹配激活的正则表达式
+ */
+ activeMatch?: string
+}
+
+export const AutoLink = defineComponent({
+ name: 'AutoLink',
+
+ props: {
+ /**
+ * Text of item
+ *
+ * 项目文字
+ */
+ text: {
+ type: String,
+ required: true,
+ },
+
+ /**
+ * Link of item
+ *
+ * 当前页面链接
+ */
+ link: {
+ type: String,
+ required: true,
+ },
+
+ /**
+ * Aria label of item
+ *
+ * 项目无障碍标签
+ */
+ ariaLabel: {
+ type: String,
+ default: '',
+ },
+
+ /**
+ * Rel of `` tag
+ *
+ * `` 标签的 `rel` 属性
+ */
+ rel: {
+ type: String,
+ default: '',
+ },
+
+ /**
+ * Target of `` tag
+ *
+ * `` 标签的 `target` 属性
+ */
+ target: {
+ type: String,
+ default: '',
+ },
+
+ /**
+ * Whether it's active only when exact match
+ *
+ * 是否当恰好匹配时激活
+ */
+ exact: Boolean,
+
+ /**
+ * Regexp mode to be active
+ *
+ * @description has higher priority than exact
+ *
+ * 匹配激活的正则表达式
+ *
+ * @description 比 exact 的优先级更高
+ */
+ activeMatch: {
+ type: [String, RegExp],
+ default: '',
+ },
+ },
+
+ slots: Object as SlotsType<{
+ default?: () => VNode[] | VNode
+ before?: () => VNode[] | VNode | null
+ after?: () => VNode[] | VNode | null
+ }>,
+
+ setup(props, { slots }) {
+ const route = useRoute()
+ const siteData = useSiteData()
+
+ // If the link has non-http protocol
+ const withProtocol = computed(() => isLinkWithProtocol(props.link))
+
+ // Resolve the `target` attr
+ const linkTarget = computed(
+ () => props.target || (withProtocol.value ? '_blank' : undefined),
+ )
+
+ // If the `target` attr is "_blank"
+ const isBlankTarget = computed(() => linkTarget.value === '_blank')
+
+ // Whether the link is internal
+ const isInternal = computed(
+ () => !withProtocol.value && !isBlankTarget.value,
+ )
+
+ // Resolve the `rel` attr
+ const linkRel = computed(
+ () => props.rel || (isBlankTarget.value ? 'noopener noreferrer' : null),
+ )
+
+ // Resolve the `aria-label` attr
+ const linkAriaLabel = computed(() => props.ariaLabel ?? props.text)
+
+ // Should be active when current route is a subpath of this link
+ const shouldBeActiveInSubpath = computed(() => {
+ // Should not be active in `exact` mode
+ if (props.exact) return false
+
+ const localePaths = Object.keys(siteData.value.locales)
+
+ return localePaths.length
+ ? // Check all the locales
+ localePaths.every((key) => key !== props.link)
+ : // Check root
+ props.link !== '/'
+ })
+
+ // If this link is active
+ const isActive = computed(() => {
+ if (!isInternal.value) return false
+
+ if (props.activeMatch)
+ return (
+ props.activeMatch instanceof RegExp
+ ? props.activeMatch
+ : new RegExp(props.activeMatch, 'u')
+ ).test(route.path)
+
+ // If this link is active in subpath
+ if (shouldBeActiveInSubpath.value)
+ return route.path.startsWith(props.link)
+
+ return route.path === props.link
+ })
+
+ return (): VNode => {
+ const { before, after, default: defaultSlot } = slots
+
+ const content = defaultSlot?.() || [
+ before ? before() : null,
+ props.text,
+ after?.(),
+ ]
+
+ return isInternal.value
+ ? h(
+ RouteLink,
+ {
+ 'class': 'auto-link',
+ 'to': props.link,
+ 'active': isActive.value,
+ 'aria-label': linkAriaLabel.value,
+ },
+ () => content,
+ )
+ : h(
+ 'a',
+ {
+ 'class': 'auto-link external-link',
+ 'href': props.link,
+ 'rel': linkRel.value,
+ 'target': linkTarget.value,
+ 'aria-label': linkAriaLabel.value,
+ },
+ content,
+ )
+ }
+ },
+})
diff --git a/packages/client/src/components/RouteLink.ts b/packages/client/src/components/RouteLink.ts
index a246bba061..be3dccd24c 100644
--- a/packages/client/src/components/RouteLink.ts
+++ b/packages/client/src/components/RouteLink.ts
@@ -1,8 +1,8 @@
-import { h } from 'vue'
-import type { FunctionalComponent, HTMLAttributes, VNode } from 'vue'
-import { useRouter } from 'vue-router'
+import { removeLeadingSlash } from '@vuepress/shared'
+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 { withBase } from '../utils/index.js'
/**
* Forked from https://github.com/vuejs/router/blob/941b2131e80550009e5221d4db9f366b1fea3fd5/packages/router/src/RouterLink.ts#L293
@@ -23,7 +23,7 @@ const guardEvent = (event: MouseEvent): boolean | void => {
return true
}
-export interface RouteLinkProps extends HTMLAttributes {
+export interface RouteLinkProps {
/**
* Whether the link is active to have an active class
*
@@ -53,42 +53,61 @@ export interface RouteLinkProps extends HTMLAttributes {
*
* It's recommended to use `RouteLink` in VuePress.
*/
-export const RouteLink: FunctionalComponent<
- RouteLinkProps,
- Record,
- {
- default: () => string | VNode | (string | VNode)[]
- }
-> = (
- { active = false, activeClass = 'route-link-active', to, ...attrs },
- { slots },
-) => {
- const router = useRouter()
- const resolvedPath = resolveRoutePath(to)
+export const RouteLink = defineComponent({
+ name: 'RouteLink',
- const path =
- // only anchor or query
- resolvedPath.startsWith('#') || resolvedPath.startsWith('?')
- ? resolvedPath
- : withBase(resolvedPath)
+ props: {
+ /**
+ * The route path to link to
+ */
+ to: {
+ type: String,
+ required: true,
+ },
- return h(
- 'a',
- {
- ...attrs,
- class: ['route-link', { [activeClass]: active }],
- href: path,
- onClick: (event: MouseEvent = {} as MouseEvent) => {
- guardEvent(event) ? router.push(to).catch() : Promise.resolve()
- },
+ /**
+ * Whether the link is active to have an active class
+ *
+ * Notice that the active status is not automatically determined according to the current route.
+ */
+ active: Boolean,
+
+ /**
+ * The class to add when the link is active
+ */
+ activeClass: {
+ type: String,
+ default: 'route-link-active',
},
- slots.default?.(),
- )
-}
+ },
-RouteLink.displayName = 'RouteLink'
-RouteLink.props = {
- active: Boolean,
- activeClass: String,
- to: String,
-}
+ slots: Object as SlotsType<{
+ default: () => string | VNode | (string | VNode)[]
+ }>,
+
+ setup(props, { slots }) {
+ const router = useRouter()
+ const route = useRoute()
+
+ const path = computed(() =>
+ props.to.startsWith('#') || props.to.startsWith('?')
+ ? props.to
+ : `${__VUEPRESS_BASE__}${removeLeadingSlash(resolveRoutePath(props.to, route.path))}`,
+ )
+
+ return () =>
+ h(
+ 'a',
+ {
+ class: ['route-link', { [props.activeClass]: props.active }],
+ href: path.value,
+ onClick: (event: MouseEvent = {} as MouseEvent) => {
+ if (guardEvent(event)) {
+ router.push(props.to).catch()
+ }
+ },
+ },
+ slots.default?.(),
+ )
+ },
+})
diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts
index f4bc111a81..72d5746a89 100644
--- a/packages/client/src/components/index.ts
+++ b/packages/client/src/components/index.ts
@@ -1,3 +1,4 @@
+export * from './AutoLink.js'
export * from './ClientOnly.js'
export * from './Content.js'
export * from './RouteLink.js'
diff --git a/packages/client/src/router/resolveRoute.ts b/packages/client/src/router/resolveRoute.ts
index ebfe486855..3f4a3ca541 100644
--- a/packages/client/src/router/resolveRoute.ts
+++ b/packages/client/src/router/resolveRoute.ts
@@ -13,8 +13,9 @@ export interface ResolvedRoute
*/
export const resolveRoute = (
path: string,
+ current?: string,
): ResolvedRoute => {
- const routePath = resolveRoutePath(path)
+ const routePath = resolveRoutePath(path, current)
const route = routes.value[routePath] ?? {
...routes.value['/404.html'],
notFound: true,
diff --git a/packages/client/src/router/resolveRoutePath.ts b/packages/client/src/router/resolveRoutePath.ts
index b820df8778..2a9f2d2ef8 100644
--- a/packages/client/src/router/resolveRoutePath.ts
+++ b/packages/client/src/router/resolveRoutePath.ts
@@ -4,9 +4,9 @@ import { redirects, routes } from '../internal/routes.js'
/**
* Resolve route path with given raw path
*/
-export const resolveRoutePath = (path: string): string => {
+export const resolveRoutePath = (path: string, current?: string): string => {
// normalized path
- const normalizedPath = normalizeRoutePath(path)
+ const normalizedPath = normalizeRoutePath(path, current)
if (routes.value[normalizedPath]) return normalizedPath
// encoded path
diff --git a/packages/markdown/src/plugins/linksPlugin/linksPlugin.ts b/packages/markdown/src/plugins/linksPlugin/linksPlugin.ts
index 482161b5a6..0c7ca1638c 100644
--- a/packages/markdown/src/plugins/linksPlugin/linksPlugin.ts
+++ b/packages/markdown/src/plugins/linksPlugin/linksPlugin.ts
@@ -1,4 +1,4 @@
-import { isLinkExternal, normalizeRoutePath } from '@vuepress/shared'
+import { inferRoutePath, isLinkExternal } from '@vuepress/shared'
import type { PluginWithOptions } from 'markdown-it'
import type Token from 'markdown-it/lib/token.mjs'
import type { MarkdownEnv } from '../../types.js'
@@ -67,7 +67,7 @@ export const linksPlugin: PluginWithOptions = (
// if `href` attr exists, `token.attrs` is not `null`
const hrefAttr = token.attrs![hrefIndex]
- const hrefLink = hrefAttr[1]
+ const hrefLink: string = hrefAttr[1]
// get `base` and `filePathRelative` from `env`
const { base = '/', filePathRelative = null } = env
@@ -83,7 +83,7 @@ export const linksPlugin: PluginWithOptions = (
// check if a link is an internal link
const internalLinkMatch = hrefLink.match(
- /^((?:.*)(?:\/|\.md|\.html))(#.*)?$/,
+ /^([^#?]*?(?:\/|\.md|\.html))([#?].*)?$/,
)
if (!internalLinkMatch) {
@@ -97,7 +97,7 @@ export const linksPlugin: PluginWithOptions = (
// notice that the path and hash are encoded by markdown-it
const rawPath = internalLinkMatch[1]
- const rawHash = internalLinkMatch[2] || ''
+ const rawHashAndQueries = internalLinkMatch[2] || ''
// resolve relative and absolute path
const { relativePath, absolutePath } = resolvePaths(
@@ -114,16 +114,19 @@ 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 = normalizeRoutePath(
- absolutePath.replace(new RegExp(`^${base}`), '/'),
+ const normalizedPath = inferRoutePath(
+ absolutePath
+ ? absolutePath.replace(new RegExp(`^${base}`), '/')
+ : relativePath,
)
// replace the original href link with the normalized path
- hrefAttr[1] = `${normalizedPath}${rawHash}`
+ hrefAttr[1] = `${normalizedPath}${rawHashAndQueries}`
// set `hasOpenInternalLink` to modify the ending tag
hasOpenInternalLink = true
} else {
- const normalizedPath = normalizeRoutePath(absolutePath)
- hrefAttr[1] = `${normalizedPath}${rawHash}`
+ const normalizedPath = inferRoutePath(absolutePath ?? relativePath)
+ // replace the original href link with the normalized path
+ hrefAttr[1] = `${normalizedPath}${rawHashAndQueries}`
}
// extract internal links for file / page existence check
diff --git a/packages/markdown/src/plugins/linksPlugin/resolvePaths.ts b/packages/markdown/src/plugins/linksPlugin/resolvePaths.ts
index a7e316a449..f24e41fd59 100644
--- a/packages/markdown/src/plugins/linksPlugin/resolvePaths.ts
+++ b/packages/markdown/src/plugins/linksPlugin/resolvePaths.ts
@@ -9,7 +9,7 @@ export const resolvePaths = (
base: string,
filePathRelative: string | null,
): {
- absolutePath: string
+ absolutePath: string | null
relativePath: string
} => {
let absolutePath: string
@@ -48,7 +48,7 @@ export const resolvePaths = (
// remove leading './'
relativePath = rawPath.replace(/^(?:\.\/)?(.*)$/, '$1')
// just take relative link as absolute link
- absolutePath = relativePath
+ absolutePath = null
}
}
diff --git a/packages/markdown/src/types.ts b/packages/markdown/src/types.ts
index deda82b063..bc123f70b6 100644
--- a/packages/markdown/src/types.ts
+++ b/packages/markdown/src/types.ts
@@ -48,7 +48,7 @@ export type MarkdownHeader = PageHeader
export interface MarkdownLink {
raw: string
relative: string
- absolute: string
+ absolute: string | null
}
/**
diff --git a/packages/markdown/tests/plugins/linksPlugin.spec.ts b/packages/markdown/tests/plugins/linksPlugin.spec.ts
index 10dde854fd..74b36a0d36 100644
--- a/packages/markdown/tests/plugins/linksPlugin.spec.ts
+++ b/packages/markdown/tests/plugins/linksPlugin.spec.ts
@@ -195,92 +195,92 @@ describe('@vuepress/markdown > plugins > linksPlugin', () => {
{
raw: 'foo.md',
relative: 'foo.md',
- absolute: 'foo.md',
+ absolute: null,
},
{
raw: 'foo.md#hash',
relative: 'foo.md',
- absolute: 'foo.md',
+ absolute: null,
},
{
raw: './foo.md',
relative: 'foo.md',
- absolute: 'foo.md',
+ absolute: null,
},
{
raw: '../bar.md',
relative: '../bar.md',
- absolute: '../bar.md',
+ absolute: null,
},
{
raw: '../bar.md#hash',
relative: '../bar.md',
- absolute: '../bar.md',
+ absolute: null,
},
{
raw: './../bar.md',
relative: '../bar.md',
- absolute: '../bar.md',
+ absolute: null,
},
{
raw: 'foo/bar.md',
relative: 'foo/bar.md',
- absolute: 'foo/bar.md',
+ absolute: null,
},
{
raw: 'foo/bar.md#hash',
relative: 'foo/bar.md',
- absolute: 'foo/bar.md',
+ absolute: null,
},
{
raw: '../foo/bar.md',
relative: '../foo/bar.md',
- absolute: '../foo/bar.md',
+ absolute: null,
},
{
raw: '../foo/bar.md#hash',
relative: '../foo/bar.md',
- absolute: '../foo/bar.md',
+ absolute: null,
},
{
raw: 'index.md',
relative: 'index.md',
- absolute: 'index.md',
+ absolute: null,
},
{
raw: 'index.md#hash',
relative: 'index.md',
- absolute: 'index.md',
+ absolute: null,
},
{
raw: './index.md',
relative: 'index.md',
- absolute: 'index.md',
+ absolute: null,
},
{
raw: '../index.md',
relative: '../index.md',
- absolute: '../index.md',
+ absolute: null,
},
{
raw: '../foo/bar/index.md',
relative: '../foo/bar/index.md',
- absolute: '../foo/bar/index.md',
+ absolute: null,
},
{
raw: 'readme.md',
relative: 'readme.md',
- absolute: 'readme.md',
+ absolute: null,
},
{
raw: '../readme.md#hash',
relative: '../readme.md',
- absolute: '../readme.md',
+ absolute: null,
},
{
raw: '../foo/bar/readme.md',
relative: '../foo/bar/readme.md',
- absolute: '../foo/bar/readme.md',
+ absolute: null,
},
])
})
@@ -320,92 +320,92 @@ describe('@vuepress/markdown > plugins > linksPlugin', () => {
{
raw: 'foo.md',
relative: 'foo.md',
- absolute: 'foo.md',
+ absolute: null,
},
{
raw: 'foo.md#hash',
relative: 'foo.md',
- absolute: 'foo.md',
+ absolute: null,
},
{
raw: './foo.md',
relative: 'foo.md',
- absolute: 'foo.md',
+ absolute: null,
},
{
raw: '../bar.md',
relative: '../bar.md',
- absolute: '../bar.md',
+ absolute: null,
},
{
raw: '../bar.md#hash',
relative: '../bar.md',
- absolute: '../bar.md',
+ absolute: null,
},
{
raw: './../bar.md',
relative: '../bar.md',
- absolute: '../bar.md',
+ absolute: null,
},
{
raw: 'foo/bar.md',
relative: 'foo/bar.md',
- absolute: 'foo/bar.md',
+ absolute: null,
},
{
raw: 'foo/bar.md#hash',
relative: 'foo/bar.md',
- absolute: 'foo/bar.md',
+ absolute: null,
},
{
raw: '../foo/bar.md',
relative: '../foo/bar.md',
- absolute: '../foo/bar.md',
+ absolute: null,
},
{
raw: '../foo/bar.md#hash',
relative: '../foo/bar.md',
- absolute: '../foo/bar.md',
+ absolute: null,
},
{
raw: 'index.md',
relative: 'index.md',
- absolute: 'index.md',
+ absolute: null,
},
{
raw: 'index.md#hash',
relative: 'index.md',
- absolute: 'index.md',
+ absolute: null,
},
{
raw: './index.md',
relative: 'index.md',
- absolute: 'index.md',
+ absolute: null,
},
{
raw: '../index.md',
relative: '../index.md',
- absolute: '../index.md',
+ absolute: null,
},
{
raw: '../foo/bar/index.md',
relative: '../foo/bar/index.md',
- absolute: '../foo/bar/index.md',
+ absolute: null,
},
{
raw: 'readme.md',
relative: 'readme.md',
- absolute: 'readme.md',
+ absolute: null,
},
{
raw: '../readme.md#hash',
relative: '../readme.md',
- absolute: '../readme.md',
+ absolute: null,
},
{
raw: '../foo/bar/readme.md',
relative: '../foo/bar/readme.md',
- absolute: '../foo/bar/readme.md',
+ absolute: null,
},
])
})
diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts
index a14fe7c3d9..885236d8b1 100644
--- a/packages/shared/src/utils/index.ts
+++ b/packages/shared/src/utils/index.ts
@@ -6,6 +6,7 @@ export * from './isLinkExternal.js'
export * from './isLinkHttp.js'
export * from './isLinkWithProtocol.js'
export * from './isPlainObject.js'
+export * from './inferRoutePath.js'
export * from './normalizeRoutePath.js'
export * from './omit.js'
export * from './removeEndingSlash.js'
diff --git a/packages/shared/src/utils/inferRoutePath.ts b/packages/shared/src/utils/inferRoutePath.ts
new file mode 100644
index 0000000000..96b36e28de
--- /dev/null
+++ b/packages/shared/src/utils/inferRoutePath.ts
@@ -0,0 +1,23 @@
+export const inferRoutePath = (path: string): string => {
+ // if the pathname is empty or ends with `/`, return as is
+ if (!path || path.endsWith('/')) return path
+
+ // convert README.md to index.html
+ let routePath = path.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/normalizeRoutePath.ts b/packages/shared/src/utils/normalizeRoutePath.ts
index db2e72837d..334fc308c1 100644
--- a/packages/shared/src/utils/normalizeRoutePath.ts
+++ b/packages/shared/src/utils/normalizeRoutePath.ts
@@ -1,30 +1,21 @@
+import { inferRoutePath } from './inferRoutePath.js'
+
+const FAKE_HOST = 'http://.'
+
/**
* Normalize the given path to the final route path
*/
-export const normalizeRoutePath = (path: string): string => {
- // split pathname and query/hash
- const [pathname, ...queryAndHash] = path.split(/(\?|#)/)
-
- // if the pathname is empty or ends with `/`, return as is
- if (!pathname || pathname.endsWith('/')) return path
+export const normalizeRoutePath = (path: string, current?: string): string => {
+ if (!path.startsWith('/') && current) {
+ // the relative path should be resolved against the current path
+ const loc = current.slice(0, current.lastIndexOf('/'))
- // convert README.md to index.html
- let routePath = pathname.replace(/(^|\/)README.md$/i, '$1index.html')
+ const { pathname, search, hash } = new URL(`${loc}/${path}`, FAKE_HOST)
- // 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'
+ return inferRoutePath(pathname) + search + hash
}
- // convert /foo/index.html to /foo/
- if (routePath.endsWith('/index.html')) {
- routePath = routePath.substring(0, routePath.length - 10)
- }
+ const [pathname, ...queryAndHash] = path.split(/(\?|#)/)
- // add query and hash back
- return routePath + queryAndHash.join('')
+ return inferRoutePath(pathname) + queryAndHash.join('')
}
diff --git a/packages/shared/tests/inferRoutePath.spec.ts b/packages/shared/tests/inferRoutePath.spec.ts
new file mode 100644
index 0000000000..36bd910b96
--- /dev/null
+++ b/packages/shared/tests/inferRoutePath.spec.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest'
+import { inferRoutePath } from '../src/index.js'
+
+const testCases = [
+ // 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'],
+
+ // unexpected corner cases
+ ['', ''],
+ ['.md', '.html'],
+ ['foo/.md', 'foo/.html'],
+ ['/.md', '/.html'],
+ ['/foo/.md', '/foo/.html'],
+]
+
+describe('should normalize clean paths correctly', () => {
+ testCases.forEach(([path, expected]) =>
+ it(`"${path}" -> "${expected}"`, () => {
+ expect(inferRoutePath(path)).toBe(expected)
+ }),
+ )
+})
diff --git a/packages/shared/tests/normalizeRoutePath.spec.ts b/packages/shared/tests/normalizeRoutePath.spec.ts
index e19287705d..fc4301cc81 100644
--- a/packages/shared/tests/normalizeRoutePath.spec.ts
+++ b/packages/shared/tests/normalizeRoutePath.spec.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { normalizeRoutePath } from '../src/index.js'
const testCases = [
- // index
+ // absolute index
['/', '/'],
['/README.md', '/'],
['/readme.md', '/'],
@@ -15,26 +15,29 @@ const testCases = [
['/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'],
- ['foo/', 'foo/'],
- ['foo/README.md', 'foo/'],
- ['foo/readme.md', 'foo/'],
- ['foo/index.md', 'foo/'],
- ['foo/index.html', 'foo/'],
- ['foo/index', 'foo/'],
- // non-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'],
+
+ // 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'],
@@ -42,28 +45,138 @@ const testCases = [
['foo/bar.md', 'foo/bar.html'],
['foo/bar.html', 'foo/bar.html'],
- // hash and query
- ['/foo#bar', '/foo.html#bar'],
- ['/foo.md#bar', '/foo.html#bar'],
- ['/foo.html#bar', '/foo.html#bar'],
- ['/foo?bar=baz', '/foo.html?bar=baz'],
- ['/foo.md?bar=baz', '/foo.html?bar=baz'],
- ['/foo.html?bar=baz', '/foo.html?bar=baz'],
- ['/foo?bar=baz#qux', '/foo.html?bar=baz#qux'],
- ['/foo.md?bar=baz#qux', '/foo.html?bar=baz#qux'],
- ['/foo.html?bar=baz#qux', '/foo.html?bar=baz#qux'],
- ['foo#bar', 'foo.html#bar'],
- ['foo.md#bar', 'foo.html#bar'],
- ['foo.html#bar', 'foo.html#bar'],
- ['foo?bar=baz', 'foo.html?bar=baz'],
- ['foo.md?bar=baz', 'foo.html?bar=baz'],
- ['foo.html?bar=baz', 'foo.html?bar=baz'],
- ['foo?bar=baz#qux', 'foo.html?bar=baz#qux'],
- ['foo.md?bar=baz#qux', 'foo.html?bar=baz#qux'],
- ['foo.html?bar=baz#qux', 'foo.html?bar=baz#qux'],
- ['#bar', '#bar'],
- ['?bar=baz', '?bar=baz'],
- ['?bar=baz#qux', '?bar=baz#qux'],
+ // relative non index with current
+ ['foo', '/foo.html', '/'],
+ ['foo', '/foo.html', '/a.html'],
+ ['foo', '/foo.html', '/index.html'],
+ ['foo', '/a/foo.html', '/a/'],
+ ['foo', '/a/foo.html', '/a/index.html'],
+ ['foo', '/a/foo.html', '/a/b.html'],
+ ['foo.md', '/foo.html', '/'],
+ ['foo.md', '/foo.html', '/a.html'],
+ ['foo.md', '/foo.html', '/index.html'],
+ ['foo.md', '/a/foo.html', '/a/'],
+ ['foo.md', '/a/foo.html', '/a/index.html'],
+ ['foo.md', '/a/foo.html', '/a/b.html'],
+ ['foo.html', '/foo.html', '/'],
+ ['foo.html', '/foo.html', '/a.html'],
+ ['foo.html', '/foo.html', '/index.html'],
+ ['foo.html', '/a/foo.html', '/a/'],
+ ['foo.html', '/a/foo.html', '/a/index.html'],
+ ['foo.html', '/a/foo.html', '/a/b.html'],
+ ['foo/bar', '/foo/bar.html', '/'],
+ ['foo/bar', '/foo/bar.html', '/a.html'],
+ ['foo/bar', '/foo/bar.html', '/index.html'],
+ ['foo/bar', '/a/foo/bar.html', '/a/'],
+ ['foo/bar', '/a/foo/bar.html', '/a/index.html'],
+ ['foo/bar', '/a/foo/bar.html', '/a/b.html'],
+ ['foo/bar.md', '/foo/bar.html', '/'],
+ ['foo/bar.md', '/foo/bar.html', '/a.html'],
+ ['foo/bar.md', '/foo/bar.html', '/index.html'],
+ ['foo/bar.md', '/a/foo/bar.html', '/a/'],
+ ['foo/bar.md', '/a/foo/bar.html', '/a/index.html'],
+ ['foo/bar.md', '/a/foo/bar.html', '/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', '/a/foo/bar.html', '/a/'],
+ ['foo/bar.html', '/a/foo/bar.html', '/a/index.html'],
+ ['foo/bar.html', '/a/foo/bar.html', '/a/b.html'],
+ ['./foo', '/foo.html', '/'],
+ ['./foo', '/foo.html', '/a.html'],
+ ['./foo', '/foo.html', '/index.html'],
+ ['./foo', '/a/foo.html', '/a/'],
+ ['./foo', '/a/foo.html', '/a/index.html'],
+ ['./foo', '/a/foo.html', '/a/b.html'],
+ ['./foo.md', '/foo.html', '/'],
+ ['./foo.md', '/foo.html', '/a.html'],
+ ['./foo.md', '/foo.html', '/index.html'],
+ ['./foo.md', '/a/foo.html', '/a/'],
+ ['./foo.md', '/a/foo.html', '/a/index.html'],
+ ['./foo.md', '/a/foo.html', '/a/b.html'],
+ ['./foo.html', '/foo.html', '/'],
+ ['./foo.html', '/foo.html', '/a.html'],
+ ['./foo.html', '/foo.html', '/index.html'],
+ ['./foo.html', '/a/foo.html', '/a/'],
+ ['./foo.html', '/a/foo.html', '/a/index.html'],
+ ['./foo.html', '/a/foo.html', '/a/b.html'],
+ ['./foo/bar', '/foo/bar.html', '/'],
+ ['./foo/bar', '/foo/bar.html', '/a.html'],
+ ['./foo/bar', '/foo/bar.html', '/index.html'],
+ ['./foo/bar', '/a/foo/bar.html', '/a/'],
+ ['./foo/bar', '/a/foo/bar.html', '/a/index.html'],
+ ['./foo/bar', '/a/foo/bar.html', '/a/b.html'],
+ ['./foo/bar.md', '/foo/bar.html', '/'],
+ ['./foo/bar.md', '/foo/bar.html', '/a.html'],
+ ['./foo/bar.md', '/foo/bar.html', '/index.html'],
+ ['./foo/bar.md', '/a/foo/bar.html', '/a/'],
+ ['./foo/bar.md', '/a/foo/bar.html', '/a/index.html'],
+ ['./foo/bar.md', '/a/foo/bar.html', '/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', '/a/foo/bar.html', '/a/'],
+ ['./foo/bar.html', '/a/foo/bar.html', '/a/index.html'],
+ ['./foo/bar.html', '/a/foo/bar.html', '/a/b.html'],
+ ['../foo', '/foo.html', '/a/'],
+ ['../foo', '/foo.html', '/a/index.html'],
+ ['../foo', '/foo.html', '/a/b.html'],
+ ['../foo.md', '/foo.html', '/a/'],
+ ['../foo.md', '/foo.html', '/a/index.html'],
+ ['../foo.md', '/foo.html', '/a/b.html'],
+ ['../foo.html', '/foo.html', '/a/'],
+ ['../foo.html', '/foo.html', '/a/index.html'],
+ ['../foo.html', '/foo.html', '/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.md', '/foo/bar.html', '/a/'],
+ ['../foo/bar.md', '/foo/bar.html', '/a/index.html'],
+ ['../foo/bar.md', '/foo/bar.html', '/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'],
+
+ // absolute non index with current
+ ['/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.md', '/foo.html', '/'],
+ ['/foo.md', '/foo.html', '/a.html'],
+ ['/foo.md', '/foo.html', '/index.html'],
+ ['/foo.md', '/foo.html', '/a/'],
+ ['/foo.md', '/foo.html', '/a/index.html'],
+ ['/foo.md', '/foo.html', '/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/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.md', '/foo/bar.html', '/'],
+ ['/foo/bar.md', '/foo/bar.html', '/a.html'],
+ ['/foo/bar.md', '/foo/bar.html', '/index.html'],
+ ['/foo/bar.md', '/foo/bar.html', '/a/'],
+ ['/foo/bar.md', '/foo/bar.html', '/a/index.html'],
+ ['/foo/bar.md', '/foo/bar.html', '/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'],
+
+ // only hash and query
+ ['', ''],
// unexpected corner cases
['.md', '.html'],
@@ -72,39 +185,52 @@ const testCases = [
['/foo/.md', '/foo/.html'],
]
-describe('should normalize clean paths correctly', () =>
- testCases.forEach(([path, expected]) =>
- it(`"${path}" -> "${expected}"`, () => {
- expect(normalizeRoutePath(path)).toBe(expected)
+describe('should normalize clean paths correctly', () => {
+ testCases.forEach(([path, expected, current]) =>
+ it(`${current ? `"${current}"-` : ''}"${path}" -> "${expected}"`, () => {
+ expect(normalizeRoutePath(path, current)).toBe(expected)
}),
- ))
+ )
+})
-describe('should normalize paths with query correctly', () =>
+describe('should normalize paths with query correctly', () => {
testCases
- .map(([path, expected]) => [`${path}?foo=bar`, `${expected}?foo=bar`])
- .forEach(([path, expected]) =>
- it(`"${path}" -> "${expected}"`, () => {
- expect(normalizeRoutePath(path)).toBe(expected)
+ .map(([path, expected, current]) => [
+ `${path}?foo=bar`,
+ `${expected}?foo=bar`,
+ current,
+ ])
+ .forEach(([path, expected, current]) =>
+ it(`${current ? `"${current}"-` : ''}"${path}" -> "${expected}"`, () => {
+ expect(normalizeRoutePath(path, current)).toBe(expected)
}),
- ))
+ )
+})
-describe('should normalize paths with hash correctly', () =>
+describe('should normalize paths with hash correctly', () => {
testCases
- .map(([path, expected]) => [`${path}#foobar`, `${expected}#foobar`])
- .forEach(([path, expected]) =>
- it(`"${path}" -> "${expected}"`, () => {
- expect(normalizeRoutePath(path)).toBe(expected)
+ .map(([path, expected, current]) => [
+ `${path}#foobar`,
+ `${expected}#foobar`,
+ current,
+ ])
+ .forEach(([path, expected, current]) =>
+ it(`${current ? `"${current}"-` : ''}"${path}" -> "${expected}"`, () => {
+ expect(normalizeRoutePath(path, current)).toBe(expected)
}),
- ))
+ )
+})
-describe('should normalize paths with query and hash correctly', () =>
+describe('should normalize paths with query and hash correctly', () => {
testCases
- .map(([path, expected]) => [
+ .map(([path, expected, current]) => [
`${path}?foo=1&bar=2#foobar`,
`${expected}?foo=1&bar=2#foobar`,
+ current,
])
- .forEach(([path, expected]) =>
- it(`"${path}" -> "${expected}"`, () => {
- expect(normalizeRoutePath(path)).toBe(expected)
+ .forEach(([path, expected, current]) =>
+ it(`${current ? `"${current}"-` : ''}"${path}" -> "${expected}"`, () => {
+ expect(normalizeRoutePath(path, current)).toBe(expected)
}),
- ))
+ )
+})