Skip to content

Commit 5a4700a

Browse files
committed
feat(client): add AutoLink component
1 parent ab3d6e6 commit 5a4700a

File tree

4 files changed

+330
-0
lines changed

4 files changed

+330
-0
lines changed

e2e/docs/components/auto-link.md

Lines changed: 61 additions & 0 deletions
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+
57+
'tel:+1234567890',
58+
]
59+
60+
const externalLinksConfig = externalLinks.map((link) => ({ link, text: 'text' }))
61+
</script>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
for (const el of await page
10+
.locator('.e2e-theme-content #route-link a')
11+
.all()) {
12+
await expect(el).toHaveAttribute('class', /route-link/)
13+
}
14+
})
15+
16+
test('should render external-link correctly', async ({ page }) => {
17+
for (const el of await page
18+
.locator('.e2e-theme-content #external-link a')
19+
.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(await locator.nth(0)).toHaveText('text1')
28+
await expect(await locator.nth(0)).toHaveAttribute('href', BASE)
29+
await expect(await locator.nth(0)).toHaveAttribute('aria-label', 'label')
30+
31+
await expect(await locator.nth(1)).toHaveText('text2')
32+
await expect(await locator.nth(1)).toHaveAttribute(
33+
'href',
34+
'https://example.com/test/',
35+
)
36+
await expect(await locator.nth(1)).toHaveAttribute('target', '_blank')
37+
await expect(await locator.nth(1)).toHaveAttribute(
38+
'rel',
39+
'noopener noreferrer',
40+
)
41+
})
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
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 AutoLinkConfig {
9+
/**
10+
* Text of item
11+
*
12+
* 项目文字
13+
*/
14+
text: string
15+
16+
/**
17+
* Aria label of item
18+
*
19+
* 项目无障碍标签
20+
*/
21+
ariaLabel?: string
22+
23+
/**
24+
* Link of item
25+
*
26+
* 当前页面链接
27+
*/
28+
link: string
29+
30+
/**
31+
* Rel of `<a>` tag
32+
*
33+
* `<a>` 标签的 `rel` 属性
34+
*/
35+
rel?: string
36+
37+
/**
38+
* Target of `<a>` tag
39+
*
40+
* `<a>` 标签的 `target` 属性
41+
*/
42+
target?: string
43+
44+
/**
45+
* Regexp mode to be active
46+
*
47+
* 匹配激活的正则表达式
48+
*/
49+
activeMatch?: string
50+
}
51+
52+
export const AutoLink = defineComponent({
53+
name: 'AutoLink',
54+
55+
props: {
56+
/**
57+
* Text of item
58+
*
59+
* 项目文字
60+
*/
61+
text: {
62+
type: String,
63+
required: true,
64+
},
65+
66+
/**
67+
* Link of item
68+
*
69+
* 当前页面链接
70+
*/
71+
link: {
72+
type: String,
73+
required: true,
74+
},
75+
76+
/**
77+
* Aria label of item
78+
*
79+
* 项目无障碍标签
80+
*/
81+
ariaLabel: {
82+
type: String,
83+
default: '',
84+
},
85+
86+
/**
87+
* Rel of `<a>` tag
88+
*
89+
* `<a>` 标签的 `rel` 属性
90+
*/
91+
rel: {
92+
type: String,
93+
default: '',
94+
},
95+
96+
/**
97+
* Target of `<a>` tag
98+
*
99+
* `<a>` 标签的 `target` 属性
100+
*/
101+
target: {
102+
type: String,
103+
default: '',
104+
},
105+
106+
/**
107+
* Whether it's active only when exact match
108+
*
109+
* 是否当恰好匹配时激活
110+
*/
111+
exact: Boolean,
112+
113+
/**
114+
* Regexp mode to be active
115+
*
116+
* @description has higher priority than exact
117+
*
118+
* 匹配激活的正则表达式
119+
*
120+
* @description 比 exact 的优先级更高
121+
*/
122+
activeMatch: {
123+
type: [String, RegExp],
124+
default: '',
125+
},
126+
},
127+
128+
slots: Object as SlotsType<{
129+
default?: () => VNode[] | VNode
130+
before?: () => VNode[] | VNode | null
131+
after?: () => VNode[] | VNode | null
132+
}>,
133+
134+
setup(props, { slots }) {
135+
const route = useRoute()
136+
const siteData = useSiteData()
137+
138+
// If the link has non-http protocol
139+
const withProtocol = computed(() => isLinkWithProtocol(props.link))
140+
141+
// Resolve the `target` attr
142+
const linkTarget = computed(
143+
() => props.target || (withProtocol.value ? '_blank' : undefined),
144+
)
145+
146+
// If the `target` attr is "_blank"
147+
const isBlankTarget = computed(() => linkTarget.value === '_blank')
148+
149+
// Whether the link is internal
150+
const isInternal = computed(
151+
() => !withProtocol.value && !isBlankTarget.value,
152+
)
153+
154+
// Resolve the `rel` attr
155+
const linkRel = computed(
156+
() => props.rel || (isBlankTarget.value ? 'noopener noreferrer' : null),
157+
)
158+
159+
// Resolve the `aria-label` attr
160+
const linkAriaLabel = computed(() => props.ariaLabel ?? props.text)
161+
162+
// Should be active when current route is a subpath of this link
163+
const shouldBeActiveInSubpath = computed(() => {
164+
// Should not be active in `exact` mode
165+
if (props.exact) return false
166+
167+
const localePaths = Object.keys(siteData.value.locales)
168+
169+
return localePaths.length
170+
? // Check all the locales
171+
localePaths.every((key) => key !== props.link)
172+
: // Check root
173+
props.link !== '/'
174+
})
175+
176+
// If this link is active
177+
const isActive = computed(() => {
178+
if (!isInternal.value) return false
179+
180+
if (props.activeMatch)
181+
return (
182+
props.activeMatch instanceof RegExp
183+
? props.activeMatch
184+
: new RegExp(props.activeMatch, 'u')
185+
).test(route.path)
186+
187+
// If this link is active in subpath
188+
if (shouldBeActiveInSubpath.value)
189+
return route.path.startsWith(props.link)
190+
191+
return route.path === props.link
192+
})
193+
194+
return (): VNode => {
195+
const { before, after, default: defaultSlot } = slots
196+
197+
const content = defaultSlot?.() || [
198+
before ? before() : null,
199+
props.text,
200+
after?.(),
201+
]
202+
203+
return isInternal.value
204+
? h(
205+
RouteLink,
206+
{
207+
'class': 'auto-link',
208+
'to': props.link,
209+
'active': isActive.value,
210+
'aria-label': linkAriaLabel.value,
211+
},
212+
() => content,
213+
)
214+
: h(
215+
'a',
216+
{
217+
'class': 'auto-link external-link',
218+
'href': props.link,
219+
'rel': linkRel.value,
220+
'target': linkTarget.value,
221+
'aria-label': linkAriaLabel.value,
222+
},
223+
content,
224+
)
225+
}
226+
},
227+
})
Lines changed: 1 addition & 0 deletions
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)