|
| 1 | +--- |
| 2 | +/** |
| 3 | + * 外部链接组件,自动添加图标和属性 |
| 4 | + * |
| 5 | + * 使用示例: |
| 6 | + * <ExternalLink href="https://google.com">Google</ExternalLink> |
| 7 | + */ |
| 8 | +
|
| 9 | +import { Icons } from 'astro-pure/libs' |
| 10 | +import config from 'virtual:config' |
| 11 | +
|
| 12 | +interface Props { |
| 13 | + href: string |
| 14 | + target?: string |
| 15 | + rel?: string | string[] |
| 16 | + class?: string |
| 17 | + customIcons?: Record<string, string> |
| 18 | + iconSize?: number |
| 19 | +} |
| 20 | +
|
| 21 | +const { |
| 22 | + href, |
| 23 | + target = '_blank', |
| 24 | + rel = ['nofollow', 'noopener', 'noreferrer'], |
| 25 | + class: className = '', |
| 26 | + customIcons, |
| 27 | + iconSize = 16 |
| 28 | +} = Astro.props |
| 29 | +
|
| 30 | +// 获取全局配置 |
| 31 | +const globalCustomIcons = config.content?.externalLinks?.customIcons || {} |
| 32 | +const mergedCustomIcons = { ...globalCustomIcons, ...customIcons } |
| 33 | +const relString = Array.isArray(rel) ? rel.join(' ') : rel |
| 34 | +
|
| 35 | +// 检查是否为绝对 URL |
| 36 | +function isAbsoluteUrl(url: string): boolean { |
| 37 | + if (typeof url !== 'string') return false |
| 38 | + if (/^[a-zA-Z]:\\/.test(url)) return false |
| 39 | + return /^[a-zA-Z][a-zA-Z\d+\-.]*?:/.test(url) |
| 40 | +} |
| 41 | +
|
| 42 | +// 获取 hostname |
| 43 | +function getHostname(href: string): string | null { |
| 44 | + try { |
| 45 | + const url = href.startsWith('//') ? `http:${href}` : href |
| 46 | + return new URL(url).hostname |
| 47 | + } catch { |
| 48 | + return null |
| 49 | + } |
| 50 | +} |
| 51 | +
|
| 52 | +// 判断是否为外部链接 |
| 53 | +const protocolRelative = href.startsWith('//') |
| 54 | +const isAbsolute = isAbsoluteUrl(href) |
| 55 | +let hostname: string | null = null |
| 56 | +let iconSrc: string | null = null |
| 57 | +
|
| 58 | +if (protocolRelative || isAbsolute) { |
| 59 | + hostname = getHostname(href) |
| 60 | + if (hostname) { |
| 61 | + // 处理自定义图标 |
| 62 | + const customIconKey = mergedCustomIcons[hostname] |
| 63 | + if (customIconKey) { |
| 64 | + const iconSvg = Icons[customIconKey as keyof typeof Icons] |
| 65 | + if (iconSvg) { |
| 66 | + let svgString = iconSvg |
| 67 | + if (!svgString.trim().startsWith('<svg')) { |
| 68 | + svgString = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">${svgString}</svg>` |
| 69 | + } |
| 70 | + iconSrc = `data:image/svg+xml;base64,${Buffer.from(svgString).toString('base64')}` |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | + // 如果没有自定义图标,使用 favicon 服务 |
| 75 | + if (!iconSrc) { |
| 76 | + iconSrc = `https://favicon.im/${hostname}` |
| 77 | + } |
| 78 | + } |
| 79 | +} |
| 80 | +
|
| 81 | +// 如果是外部链接,添加图标和属性 |
| 82 | +const isExternal = !!(protocolRelative || (isAbsolute && hostname)) |
| 83 | +--- |
| 84 | + |
| 85 | +{isExternal ? ( |
| 86 | + <a |
| 87 | + href={href} |
| 88 | + target={target} |
| 89 | + rel={relString} |
| 90 | + class:list={['external-link', 'not-prose', className]} |
| 91 | + data-external-link-processed="true" |
| 92 | + > |
| 93 | + {iconSrc ? ( |
| 94 | + <img |
| 95 | + src={iconSrc} |
| 96 | + alt="" |
| 97 | + width={iconSize} |
| 98 | + height={iconSize} |
| 99 | + class="external-link-icon" |
| 100 | + onerror="this.style.display='none'; this.nextElementSibling.style.display='inline-block'" |
| 101 | + /> |
| 102 | + ) : null} |
| 103 | + <span class="external-link-icon-emoji" style={iconSrc ? 'display:none' : 'display:inline-block'} aria-hidden="true">🌐</span> |
| 104 | + <slot /> |
| 105 | + </a> |
| 106 | +) : ( |
| 107 | + <a href={href} class={className}> |
| 108 | + <slot /> |
| 109 | + </a> |
| 110 | +)} |
0 commit comments