Skip to content

Commit

Permalink
feat(client): add AutoLink component
Browse files Browse the repository at this point in the history
  • Loading branch information
Mister-Hope committed Apr 17, 2024
1 parent ab3d6e6 commit 5a4700a
Show file tree
Hide file tree
Showing 4 changed files with 330 additions and 0 deletions.
61 changes: 61 additions & 0 deletions e2e/docs/components/auto-link.md
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:example@example.com',
'tel:+1234567890',
]

const externalLinksConfig = externalLinks.map((link) => ({ link, text: 'text' }))
</script>
41 changes: 41 additions & 0 deletions e2e/tests/components/auto-link.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
)
})
227 changes: 227 additions & 0 deletions packages/client/src/components/AutoLink.ts
Original file line number Diff line number Diff line change
@@ -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 `<a>` tag
*
* `<a>` 标签的 `rel` 属性
*/
rel?: string

/**
* Target of `<a>` tag
*
* `<a>` 标签的 `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 `<a>` tag
*
* `<a>` 标签的 `rel` 属性
*/
rel: {
type: String,
default: '',
},

/**
* Target of `<a>` tag
*
* `<a>` 标签的 `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,
)
}
},
})
1 change: 1 addition & 0 deletions packages/client/src/components/index.ts
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'

0 comments on commit 5a4700a

Please sign in to comment.