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