Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(client): add AutoLink component #1546

Merged
merged 26 commits into from
May 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
62cec2e
feat(shared): support relative links in normalizeRoutePath
Mister-Hope Apr 16, 2024
73be4bb
feat(client): support relative link in resolveRoute and resolveRoutePath
Mister-Hope Apr 16, 2024
c12435a
feat(client): support relative link in RouteLink
Mister-Hope Apr 16, 2024
d94d9c5
chore: split function
Mister-Hope Apr 17, 2024
7374e36
test: add unit tests
Mister-Hope Apr 17, 2024
b69d3b3
Merge branch 'main' into normalizeRoutePath
Mister-Hope Apr 17, 2024
ab3d6e6
Merge branch 'normalizeRoutePath' into route-link
Mister-Hope Apr 17, 2024
5a4700a
feat(client): add AutoLink component
Mister-Hope Apr 17, 2024
7297db5
perf: tweaks
Mister-Hope Apr 17, 2024
3ffa61f
Update index.ts
meteorlxy Apr 17, 2024
77b2a76
Merge branch 'main' into normalizeRoutePath
Mister-Hope Apr 17, 2024
f3690b8
Merge branch 'main' into route-link
Mister-Hope Apr 17, 2024
6967766
Update inferRoutePath.ts
meteorlxy Apr 17, 2024
cddd691
test: update test
Mister-Hope Apr 17, 2024
0f82b53
Merge branch 'normalizeRoutePath' into route-link
Mister-Hope Apr 17, 2024
923619c
Merge branch 'route-link' into auto-link
Mister-Hope Apr 17, 2024
f3b3fb0
Merge branch 'main' into route-link
Mister-Hope May 13, 2024
c4eff84
Merge branch 'route-link' into auto-link
Mister-Hope May 13, 2024
1ed8b68
Merge branch 'main' into auto-link
Mister-Hope May 13, 2024
ff460a2
feat: updates
meteorlxy May 14, 2024
d1c881e
test(e2e): fix locator usage
meteorlxy May 14, 2024
eed348d
chore: tweaks
meteorlxy May 14, 2024
601d6f4
feat: add AutoLinkProps type
meteorlxy May 14, 2024
11a5661
Merge remote-tracking branch 'origin/main' into auto-link
meteorlxy May 14, 2024
3ba98e0
chore: tweaks
meteorlxy May 14, 2024
0d4d658
chore: tweaks
meteorlxy May 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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>
38 changes: 38 additions & 0 deletions e2e/tests/components/auto-link.spec.ts
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')
})
206 changes: 206 additions & 0 deletions packages/client/src/components/AutoLink.ts
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,
)
}
},
})
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'