-
Notifications
You must be signed in to change notification settings - Fork 920
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(client): add AutoLink component (#1546)
Co-authored-by: Xinyu Liu <[email protected]>
- Loading branch information
1 parent
ef172d4
commit 8eb722f
Showing
4 changed files
with
306 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
# AutoLink | ||
|
||
<div id="route-link"> | ||
<AutoLink v-for="item in routeLinksConfig" v-bind="item" /> | ||
</div> | ||
|
||
<div id="external-link"> | ||
<AutoLink v-for="item in externalLinksConfig" v-bind="item" /> | ||
</div> | ||
|
||
<div id="config"> | ||
<AutoLink v-bind="{ text: 'text1', link: '/', ariaLabel: 'label' }" /> | ||
<AutoLink v-bind="{ text: 'text2', link: 'https://example.com/test/' }" /> | ||
</div> | ||
|
||
<script setup lang="ts"> | ||
import { AutoLink } from 'vuepress/client' | ||
|
||
const routeLinks = [ | ||
'/', | ||
'/README.md', | ||
'/index.html', | ||
'/non-existent', | ||
'/non-existent.md', | ||
'/non-existent.html', | ||
'/routes/non-ascii-paths/中文目录名/中文文件名', | ||
'/routes/non-ascii-paths/中文目录名/中文文件名.md', | ||
'/routes/non-ascii-paths/中文目录名/中文文件名.html', | ||
'/README.md#hash', | ||
'/README.md?query', | ||
'/README.md?query#hash', | ||
'/#hash', | ||
'/?query', | ||
'/?query#hash', | ||
'#hash', | ||
'?query', | ||
'?query#hash', | ||
'route-link', | ||
'route-link.md', | ||
'route-link.html', | ||
'not-existent', | ||
'not-existent.md', | ||
'not-existent.html', | ||
'../', | ||
'../README.md', | ||
'../404.md', | ||
'../404.html', | ||
] | ||
|
||
const routeLinksConfig = routeLinks.map((link) => ({ link, text: 'text' })) | ||
|
||
const externalLinks = [ | ||
'//example.com', | ||
'http://example.com', | ||
'https://example.com', | ||
'mailto:[email protected]', | ||
'tel:+1234567890', | ||
] | ||
|
||
const externalLinksConfig = externalLinks.map((link) => ({ link, text: 'text' })) | ||
</script> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
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 }) => { | ||
const locator = page.locator('.e2e-theme-content #route-link a') | ||
|
||
for (const el of await locator.all()) { | ||
await expect(el).toHaveAttribute('class', /route-link/) | ||
} | ||
}) | ||
|
||
test('should render external-link correctly', async ({ page }) => { | ||
const locator = page.locator('.e2e-theme-content #external-link a') | ||
|
||
for (const el of await locator.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(locator.nth(0)).toHaveText('text1') | ||
await expect(locator.nth(0)).toHaveAttribute('href', BASE) | ||
await expect(locator.nth(0)).toHaveAttribute('aria-label', 'label') | ||
|
||
await expect(locator.nth(1)).toHaveText('text2') | ||
await expect(locator.nth(1)).toHaveAttribute( | ||
'href', | ||
'https://example.com/test/', | ||
) | ||
await expect(locator.nth(1)).toHaveAttribute('target', '_blank') | ||
await expect(locator.nth(1)).toHaveAttribute('rel', 'noopener noreferrer') | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,206 @@ | ||
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 AutoLinkProps { | ||
/** | ||
* Pattern to determine if the link should be active, which has higher priority than `exact` | ||
*/ | ||
activeMatch?: string | RegExp | ||
|
||
/** | ||
* The `aria-label` attribute | ||
*/ | ||
ariaLabel?: string | ||
|
||
/** | ||
* Whether the link should be active only if the url is an exact match | ||
*/ | ||
exact?: boolean | ||
|
||
/** | ||
* URL of the auto link | ||
*/ | ||
link: string | ||
|
||
/** | ||
* The `rel` attribute | ||
*/ | ||
rel?: string | ||
|
||
/** | ||
* The `target` attribute | ||
*/ | ||
target?: string | ||
|
||
/** | ||
* Text of the auto link | ||
*/ | ||
text: string | ||
} | ||
|
||
/** | ||
* Component to render a link automatically according to the link type | ||
* | ||
* - If the link is internal, it will be rendered as a `<RouteLink>` | ||
* - If the link is external, it will be rendered as a normal `<a>` tag | ||
*/ | ||
export const AutoLink = defineComponent({ | ||
name: 'AutoLink', | ||
|
||
props: { | ||
/** | ||
* Pattern to determine if the link should be active, which has higher priority than `exact` | ||
*/ | ||
activeMatch: { | ||
type: [String, RegExp], | ||
default: '', | ||
}, | ||
|
||
/** | ||
* The `aria-label` attribute | ||
*/ | ||
ariaLabel: { | ||
type: String, | ||
default: '', | ||
}, | ||
|
||
/** | ||
* Whether the link should be active only if the url is an exact match | ||
*/ | ||
exact: Boolean, | ||
|
||
/** | ||
* URL of the auto link | ||
*/ | ||
link: { | ||
type: String, | ||
required: true, | ||
}, | ||
|
||
/** | ||
* The `rel` attribute | ||
*/ | ||
rel: { | ||
type: String, | ||
default: '', | ||
}, | ||
|
||
/** | ||
* The `target` attribute | ||
*/ | ||
target: { | ||
type: String, | ||
default: '', | ||
}, | ||
|
||
/** | ||
* Text of the auto link | ||
*/ | ||
text: { | ||
type: String, | ||
required: true, | ||
}, | ||
}, | ||
|
||
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 () => { | ||
const { before, after, default: defaultSlot } = slots | ||
|
||
const content = defaultSlot?.() || [before?.(), 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, | ||
'aria-label': linkAriaLabel.value, | ||
'rel': linkRel.value, | ||
'target': linkTarget.value, | ||
}, | ||
content, | ||
) | ||
} | ||
}, | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
export * from './AutoLink.js' | ||
export * from './ClientOnly.js' | ||
export * from './Content.js' | ||
export * from './RouteLink.js' |