feat(theme): improve markdown link behavior (#619)

This commit is contained in:
pengzhanbo 2025-06-19 00:12:04 +08:00 committed by GitHub
parent 953277c77d
commit a5c874cdcf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 119 additions and 10 deletions

View File

@ -0,0 +1,29 @@
import MarkdownIt from 'markdown-it'
import { describe, expect, it } from 'vitest'
import { linksPlugin } from '../src/node/enhance/links.js'
describe('linksPlugin', () => {
const md = new MarkdownIt({
html: true,
}).use(linksPlugin)
it('should work with external link', () => {
expect(md.render('[link](https://github.com)')).toContain('<a')
expect(md.render('[link](https://github.com)')).toContain('href="https://github.com"')
expect(md.render('[link](https://github.com)')).toContain('target="_blank"')
expect(md.render('[link](https://github.com)')).toContain('rel="noopener noreferrer"')
})
it('should work with hash link', () => {
expect(md.render('[link](#anchor)')).toContain('<a')
expect(md.render('[link](#anchor)')).toContain('href="#anchor"')
expect(md.render('[link](#anchor)')).not.toContain('target="_blank"')
expect(md.render('[link](#anchor)')).not.toContain('rel="noopener noreferrer"')
})
it('should work with internal link', () => {
expect(md.render('[link](/path)')).toContain('<VPLink')
expect(md.render('[link](/path)')).toContain('href="/path"')
expect(md.render('[link](/path)')).toContain('</VPLink>')
})
})

View File

@ -0,0 +1,60 @@
import type Token from 'markdown-it/lib/token.mjs'
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
import { isLinkWithProtocol } from 'vuepress/shared'
export function linksPlugin(md: Markdown): void {
// attrs that going to be added to external links
const externalAttrs = {
target: '_blank',
rel: 'noopener noreferrer',
}
let hasOpenInternalLink = false
const internalTag = 'VPLink'
function handleLinkOpen(tokens: Token[], idx: number) {
hasOpenInternalLink = false
const token = tokens[idx]
// get `href` attr index
const hrefIndex = token.attrIndex('href')
// if `href` attr does not exist, skip
/* istanbul ignore if -- @preserve */
if (hrefIndex < 0) {
return
}
// if `href` attr exists, `token.attrs` is not `null`
const hrefAttr = token.attrs![hrefIndex]
const hrefLink: string = hrefAttr[1]
if (isLinkWithProtocol(hrefLink)) {
// set `externalAttrs` to current token
Object.entries(externalAttrs).forEach(([key, val]) => {
token.attrSet(key, val)
})
return
}
if (hrefLink[0] === '#')
return
// convert starting tag of internal link
hasOpenInternalLink = true
token.tag = internalTag
}
md.renderer.rules.link_open = (tokens, idx, opts, env: MarkdownEnv, self) => {
handleLinkOpen(tokens, idx)
return self.renderToken(tokens, idx, opts)
}
md.renderer.rules.link_close = (tokens, idx, opts, _env, self) => {
// convert ending tag of internal link
if (hasOpenInternalLink) {
hasOpenInternalLink = false
tokens[idx].tag = internalTag
}
return self.renderToken(tokens, idx, opts)
}
}

View File

@ -8,6 +8,7 @@ import { demoPlugin, demoWatcher, extendsPageWithDemo, waitDemoRender } from './
import { embedSyntaxPlugin } from './embed/index.js'
import { docsTitlePlugin } from './enhance/docsTitle.js'
import { imageSizePlugin } from './enhance/imageSize.js'
import { linksPlugin } from './enhance/links.js'
import { iconPlugin } from './icon/index.js'
import { inlineSyntaxPlugin } from './inline/index.js'
import { prepareConfigFile } from './prepareConfigFile.js'
@ -44,6 +45,7 @@ export function markdownPowerPlugin(
},
extendsMarkdown: async (md, app) => {
linksPlugin(md)
docsTitlePlugin(md)
embedSyntaxPlugin(md, options)
inlineSyntaxPlugin(md, options)

View File

@ -26,7 +26,7 @@ const component = computed(() => {
return props.tag || props.href ? 'a' : 'button'
})
const { link, isExternal } = useLink(toRef(props, 'href'), toRef(props, 'target'))
const { link, isExternal, isExternalProtocol } = useLink(toRef(props, 'href'), toRef(props, 'target'))
function linkTo(e: Event) {
if (!isExternal.value && link.value?.[0] !== '#') {
@ -42,7 +42,7 @@ function linkTo(e: Event) {
:is="component"
class="vp-button"
:class="[size, theme]"
:href=" link ? link[0] === '#' || isExternal ? link : withBase(link) : undefined"
:href=" link ? link[0] === '#' || isExternalProtocol ? link : withBase(link) : undefined"
:target="target ?? (isExternal ? '_blank' : undefined)"
:rel="rel ?? (isExternal ? 'noreferrer' : undefined)"
@click="linkTo($event)"

View File

@ -16,7 +16,7 @@ const router = useRouter()
const tag = computed(() => props.tag ?? (props.href ? 'a' : 'span'))
const { link, isExternal } = useLink(toRef(props, 'href'), toRef(props, 'target'))
const { link, isExternal, isExternalProtocol } = useLink(toRef(props, 'href'), toRef(props, 'target'))
function linkTo(e: Event) {
if (!isExternal.value && link.value) {
@ -28,8 +28,8 @@ function linkTo(e: Event) {
<template>
<Component
:is="tag" class="vp-link no-icon" :class="{ link }"
:href="link ? isExternal ? link : withBase(link) : undefined"
:is="tag" class="vp-link" :class="{ link, 'no-icon': noIcon }"
:href="link ? isExternalProtocol ? link : withBase(link) : undefined"
:target="target ?? (isExternal ? '_blank' : undefined)"
:rel="rel ?? (isExternal ? 'noreferrer' : undefined)"
@click="linkTo($event)"
@ -37,12 +37,12 @@ function linkTo(e: Event) {
<slot>
{{ text || href }}
</slot>
<span v-if="isExternal && !noIcon" class="vpi-external-link icon" />
<span v-if="isExternal && !noIcon" class="vpi-external-link" />
</Component>
</template>
<style scoped>
.icon {
<style>
.vp-link .vpi-external-link {
width: 11px;
height: 11px;
margin-top: -1px;

View File

@ -1,11 +1,19 @@
import type { ComputedRef, MaybeRefOrGetter } from 'vue'
import { isLinkExternal } from '@vuepress/helper/client'
import { isLinkExternal, isLinkWithProtocol } from '@vuepress/helper/client'
import { computed, toValue } from 'vue'
import { resolveRouteFullPath, useRoute } from 'vuepress/client'
import { useData } from './data.js'
interface UseLinkResult {
/**
*
*/
isExternal: ComputedRef<boolean>
/**
*
* target="_blank"
*/
isExternalProtocol: ComputedRef<boolean>
link: ComputedRef<string | undefined>
}
@ -46,5 +54,12 @@ export function useLink(
return path
})
return { isExternal, link }
const isExternalProtocol = computed(() => {
if (!link.value || link.value[0] === '#')
return false
return isLinkWithProtocol(link.value)
})
return { isExternal, isExternalProtocol, link }
}

View File

@ -8,6 +8,7 @@ import VPLinkCard from '@theme/global/VPLinkCard.vue'
import VPHomeBox from '@theme/Home/VPHomeBox.vue'
import VPButton from '@theme/VPButton.vue'
import VPIcon from '@theme/VPIcon.vue'
import VPLink from '@theme/VPLink.vue'
import { hasGlobalComponent } from '@vuepress/helper/client'
import { h, resolveComponent } from 'vue'
@ -30,6 +31,8 @@ export function globalComponents(app: App): void {
app.component('VPCardMasonry', VPCardMasonry)
app.component('CardMasonry', VPCardMasonry)
app.component('VPLink', VPLink)
app.component('Icon', VPIcon)
app.component('VPIcon', VPIcon)