Skip to content

Commit 8eb722f

Browse files
Mister-Hopemeteorlxy
andauthoredMay 14, 2024··
feat(client): add AutoLink component (#1546)
Co-authored-by: Xinyu Liu <meteor.lxy@foxmail.com>
1 parent ef172d4 commit 8eb722f

File tree

4 files changed

+306
-0
lines changed

4 files changed

+306
-0
lines changed
 

‎e2e/docs/components/auto-link.md

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# AutoLink
2+
3+
<div id="route-link">
4+
<AutoLink v-for="item in routeLinksConfig" v-bind="item" />
5+
</div>
6+
7+
<div id="external-link">
8+
<AutoLink v-for="item in externalLinksConfig" v-bind="item" />
9+
</div>
10+
11+
<div id="config">
12+
<AutoLink v-bind="{ text: 'text1', link: '/', ariaLabel: 'label' }" />
13+
<AutoLink v-bind="{ text: 'text2', link: 'https://example.com/test/' }" />
14+
</div>
15+
16+
<script setup lang="ts">
17+
import { AutoLink } from 'vuepress/client'
18+
19+
const routeLinks = [
20+
'/',
21+
'/README.md',
22+
'/index.html',
23+
'/non-existent',
24+
'/non-existent.md',
25+
'/non-existent.html',
26+
'/routes/non-ascii-paths/中文目录名/中文文件名',
27+
'/routes/non-ascii-paths/中文目录名/中文文件名.md',
28+
'/routes/non-ascii-paths/中文目录名/中文文件名.html',
29+
'/README.md#hash',
30+
'/README.md?query',
31+
'/README.md?query#hash',
32+
'/#hash',
33+
'/?query',
34+
'/?query#hash',
35+
'#hash',
36+
'?query',
37+
'?query#hash',
38+
'route-link',
39+
'route-link.md',
40+
'route-link.html',
41+
'not-existent',
42+
'not-existent.md',
43+
'not-existent.html',
44+
'../',
45+
'../README.md',
46+
'../404.md',
47+
'../404.html',
48+
]
49+
50+
const routeLinksConfig = routeLinks.map((link) => ({ link, text: 'text' }))
51+
52+
const externalLinks = [
53+
'//example.com',
54+
'http://example.com',
55+
'https://example.com',
56+
'mailto:example@example.com',
57+
'tel:+1234567890',
58+
]
59+
60+
const externalLinksConfig = externalLinks.map((link) => ({ link, text: 'text' }))
61+
</script>
+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { expect, test } from '@playwright/test'
2+
import { BASE } from '../../utils/env'
3+
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto('components/auto-link.html')
6+
})
7+
8+
test('should render route-link correctly', async ({ page }) => {
9+
const locator = page.locator('.e2e-theme-content #route-link a')
10+
11+
for (const el of await locator.all()) {
12+
await expect(el).toHaveAttribute('class', /route-link/)
13+
}
14+
})
15+
16+
test('should render external-link correctly', async ({ page }) => {
17+
const locator = page.locator('.e2e-theme-content #external-link a')
18+
19+
for (const el of await locator.all()) {
20+
await expect(el).toHaveAttribute('class', /external-link/)
21+
}
22+
})
23+
24+
test('should render config correctly', async ({ page }) => {
25+
const locator = page.locator('.e2e-theme-content #config a')
26+
27+
await expect(locator.nth(0)).toHaveText('text1')
28+
await expect(locator.nth(0)).toHaveAttribute('href', BASE)
29+
await expect(locator.nth(0)).toHaveAttribute('aria-label', 'label')
30+
31+
await expect(locator.nth(1)).toHaveText('text2')
32+
await expect(locator.nth(1)).toHaveAttribute(
33+
'href',
34+
'https://example.com/test/',
35+
)
36+
await expect(locator.nth(1)).toHaveAttribute('target', '_blank')
37+
await expect(locator.nth(1)).toHaveAttribute('rel', 'noopener noreferrer')
38+
})
+206
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { isLinkWithProtocol } from '@vuepress/shared'
2+
import type { SlotsType, VNode } from 'vue'
3+
import { computed, defineComponent, h } from 'vue'
4+
import { useRoute } from 'vue-router'
5+
import { useSiteData } from '../composables/index.js'
6+
import { RouteLink } from './RouteLink.js'
7+
8+
export interface AutoLinkProps {
9+
/**
10+
* Pattern to determine if the link should be active, which has higher priority than `exact`
11+
*/
12+
activeMatch?: string | RegExp
13+
14+
/**
15+
* The `aria-label` attribute
16+
*/
17+
ariaLabel?: string
18+
19+
/**
20+
* Whether the link should be active only if the url is an exact match
21+
*/
22+
exact?: boolean
23+
24+
/**
25+
* URL of the auto link
26+
*/
27+
link: string
28+
29+
/**
30+
* The `rel` attribute
31+
*/
32+
rel?: string
33+
34+
/**
35+
* The `target` attribute
36+
*/
37+
target?: string
38+
39+
/**
40+
* Text of the auto link
41+
*/
42+
text: string
43+
}
44+
45+
/**
46+
* Component to render a link automatically according to the link type
47+
*
48+
* - If the link is internal, it will be rendered as a `<RouteLink>`
49+
* - If the link is external, it will be rendered as a normal `<a>` tag
50+
*/
51+
export const AutoLink = defineComponent({
52+
name: 'AutoLink',
53+
54+
props: {
55+
/**
56+
* Pattern to determine if the link should be active, which has higher priority than `exact`
57+
*/
58+
activeMatch: {
59+
type: [String, RegExp],
60+
default: '',
61+
},
62+
63+
/**
64+
* The `aria-label` attribute
65+
*/
66+
ariaLabel: {
67+
type: String,
68+
default: '',
69+
},
70+
71+
/**
72+
* Whether the link should be active only if the url is an exact match
73+
*/
74+
exact: Boolean,
75+
76+
/**
77+
* URL of the auto link
78+
*/
79+
link: {
80+
type: String,
81+
required: true,
82+
},
83+
84+
/**
85+
* The `rel` attribute
86+
*/
87+
rel: {
88+
type: String,
89+
default: '',
90+
},
91+
92+
/**
93+
* The `target` attribute
94+
*/
95+
target: {
96+
type: String,
97+
default: '',
98+
},
99+
100+
/**
101+
* Text of the auto link
102+
*/
103+
text: {
104+
type: String,
105+
required: true,
106+
},
107+
},
108+
109+
slots: Object as SlotsType<{
110+
default?: () => VNode[] | VNode
111+
before?: () => VNode[] | VNode | null
112+
after?: () => VNode[] | VNode | null
113+
}>,
114+
115+
setup(props, { slots }) {
116+
const route = useRoute()
117+
const siteData = useSiteData()
118+
119+
// If the link has non-http protocol
120+
const withProtocol = computed(() => isLinkWithProtocol(props.link))
121+
122+
// Resolve the `target` attr
123+
const linkTarget = computed(
124+
() => props.target || (withProtocol.value ? '_blank' : undefined),
125+
)
126+
127+
// If the `target` attr is "_blank"
128+
const isBlankTarget = computed(() => linkTarget.value === '_blank')
129+
130+
// Whether the link is internal
131+
const isInternal = computed(
132+
() => !withProtocol.value && !isBlankTarget.value,
133+
)
134+
135+
// Resolve the `rel` attr
136+
const linkRel = computed(
137+
() => props.rel || (isBlankTarget.value ? 'noopener noreferrer' : null),
138+
)
139+
140+
// Resolve the `aria-label` attr
141+
const linkAriaLabel = computed(() => props.ariaLabel ?? props.text)
142+
143+
// Should be active when current route is a subpath of this link
144+
const shouldBeActiveInSubpath = computed(() => {
145+
// Should not be active in `exact` mode
146+
if (props.exact) return false
147+
148+
const localePaths = Object.keys(siteData.value.locales)
149+
150+
return localePaths.length
151+
? // Check all the locales
152+
localePaths.every((key) => key !== props.link)
153+
: // Check root
154+
props.link !== '/'
155+
})
156+
157+
// If this link is active
158+
const isActive = computed(() => {
159+
if (!isInternal.value) return false
160+
161+
if (props.activeMatch) {
162+
return (
163+
props.activeMatch instanceof RegExp
164+
? props.activeMatch
165+
: new RegExp(props.activeMatch, 'u')
166+
).test(route.path)
167+
}
168+
169+
// If this link is active in subpath
170+
if (shouldBeActiveInSubpath.value) {
171+
return route.path.startsWith(props.link)
172+
}
173+
174+
return route.path === props.link
175+
})
176+
177+
return () => {
178+
const { before, after, default: defaultSlot } = slots
179+
180+
const content = defaultSlot?.() || [before?.(), props.text, after?.()]
181+
182+
return isInternal.value
183+
? h(
184+
RouteLink,
185+
{
186+
'class': 'auto-link',
187+
'to': props.link,
188+
'active': isActive.value,
189+
'aria-label': linkAriaLabel.value,
190+
},
191+
() => content,
192+
)
193+
: h(
194+
'a',
195+
{
196+
'class': 'auto-link external-link',
197+
'href': props.link,
198+
'aria-label': linkAriaLabel.value,
199+
'rel': linkRel.value,
200+
'target': linkTarget.value,
201+
},
202+
content,
203+
)
204+
}
205+
},
206+
})
+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './AutoLink.js'
12
export * from './ClientOnly.js'
23
export * from './Content.js'
34
export * from './RouteLink.js'

0 commit comments

Comments
 (0)
Please sign in to comment.