2302 字
12 分钟Add commentMore actions
Fuwari添加链接卡片

前言#

在日常博客推文当中,不仅仅是 GitHub 存储库,还有很多的外部链接会常常引用。在Fuwari中,原生使用了 GithubCardComponent 这个组件来调用 Github 存储库,这是远远不够的,所以我参考了 @Hasenpfote 佬的 PR 来添加链接卡片来引用外部链接

示例#

在 Markdown 的段落中仅包含一个“裸链接”(没有描述性文字的链接)或类似内容
它将自动转换为链接卡片
**外部链接**
https://astro.build/
<https://music.163.com/>
**内部链接**
[/posts/fuwari/](/posts/fuwari/)
**国际化域名 (Internationalized Domain Name)**
https://はじめよう.みんな/
NOTE

卡片显示后,更改主题颜色或启用夜间模式卡片也会随之改变嗷

添加链接卡片#

安装依赖#

我们需要安装 @fastify/deepmerge open-graph-scraper punycode unified 依赖 仅需在项目根目录执行 pnpm add 相关依赖即可

Terminal window
pnpm add @fastify/deepmerge open-graph-scraper punycode
pnpm add -D unified @types/punycode #-D 安装开发依赖

添加配置#

astro.config.mjs
import fuwariLinkCard from "./src/plugins/fuwari-link-card.ts"
integrations: [
...
fuwariLinkCard({
internalLink: { enabled: true },
}),
...
]

主要配置说明#

名称 (Name)类型 (Type)默认值 (Default)描述 (Description)
devModebooleanimport.meta.env.DEV启用或禁用开发模式。默认情况下,它会根据 Astro 的环境自动判断(即在 astro dev 时为 trueastro build 时为 false)。
excludedUrlsArray<string | RegExp>[]一个由字符串或正则表达式组成的数组,用于排除特定的 URL 不进行链接卡片处理。此选项也有助于防止与其他处理链接的插件发生冲突。
linkAttributesObject{ target: ”,
rel: ”,
}
为外部链接设置 targetrel 属性。您可以将它们留空(如默认值所示),将这些属性的处理权交给其他插件(例如,一个自动为所有外链添加 target="_blank" 的插件)。
rewriteRulesArray<Object>[]重写从链接中获取的特定元数据属性,例如标题(title)和描述(description)。这在您需要修正或自定义抓取到的信息时非常有用。
basestring’/‘指定与您 Astro 配置中相同的 base 路径。详情请参考 Astro 的官方文档。当此工具作为 Astro 集成(Integration)使用时,如果未指定此选项,它将被自动检测并设置。
defaultThumbnailstring当链接的元数据中不包含预览图(og:image)时,使用的默认缩略图路径。该路径应相对于 public 目录。例如,如果图片位于 public/images/default-thumbnail.jpg,则此项应设置为 'images/default-thumbnail.jpg'
internalLinkObject{ enabled: false,
site: ” }
启用对您站点内部链接的处理。需要设置 enabled: true 并提供 site 的 URL。

添加插件#

在对应的文件夹下添加文件以及相关代码

src/plugins/fuwari-link-card.ts
import type { AstroIntegration } from 'astro'
import remarkLinkCard from './remark-link-card.ts'
import type { UserOptions } from './remark-link-card.ts'
const fuwariLinkCard = (options: UserOptions = {}): AstroIntegration => {
const integration: AstroIntegration = {
hooks: {
'astro:config:setup': ({ config, updateConfig }) => {
options.base = options.base ?? config.base
if (options.internalLink?.enabled) {
options.internalLink.site = options.internalLink.site ?? config.site
}
// If the remark-sectionize plugin exists, insert the new plugin before it to avoid conflicts.
const pluginName = 'plugin' // remark-sectionize
const index = config.markdown.remarkPlugins.findIndex(element => {
if (typeof element === 'function') return element.name === pluginName
if (Array.isArray(element) && typeof element[0] === 'function')
return element[0].name === pluginName
return false
})
if (index !== -1) {
config.markdown.remarkPlugins.splice(index, 0, [
remarkLinkCard,
options,
])
} else {
updateConfig({
markdown: { remarkPlugins: [[remarkLinkCard, options]] },
})
}
},
},
name: 'fuwari-link-card',
}
return integration
}
export default fuwariLinkCard
export type { UserOptions }
src\plugins\remark-link-card.ts
import { createHash } from 'node:crypto'
import * as fs from 'node:fs/promises'
import path from 'node:path'
import deepmerge from '@fastify/deepmerge'
import { h } from 'hastscript'
import type { Link, Paragraph, Root, Text } from 'mdast'
477 collapsed lines
import ogs from 'open-graph-scraper'
import type { ErrorResult, OgObject } from 'open-graph-scraper/types'
import punycode from 'punycode/'
import type { Plugin, Transformer } from 'unified'
import { visit } from 'unist-util-visit'
import type { Visitor } from 'unist-util-visit'
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>
}
: T
type ObtainKeys<T, V> = keyof {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
[K in keyof T as T[K] extends V ? K : never]: any
}
interface LinkAttributes {
target: string
rel: string
}
interface RewriteStep {
key: string
pattern: RegExp
replacement: string
}
interface RewriteRule {
url: RegExp
rewriteSteps: RewriteStep[]
}
interface InternalLink {
enabled: boolean
site: string
}
interface Cache {
enabled: boolean
outDir: string
cacheDir: string
maxFileSize: number
maxCacheSize: number
userAgent: string
}
interface Options {
devMode: boolean
excludedUrls: (string | RegExp)[]
linkAttributes: LinkAttributes
rewriteRules: RewriteRule[]
base: string
defaultThumbnail: string
internalLink: InternalLink
cache: Cache
}
type UserOptions = DeepPartial<Options>
interface BareLink {
url: URL
isInternal: boolean
}
interface Data {
url: string
domainName: string
title: string
description: string
date: string
faviconSrc: string
thumbnailSrc: string
thumbnailAlt: string
hasThumbnail: boolean
isInternalLink: boolean
}
const defaultOptions: Options = {
devMode: import.meta.env.DEV,
excludedUrls: [],
linkAttributes: { target: '', rel: '' },
rewriteRules: [],
base: '/',
defaultThumbnail: '',
internalLink: { enabled: false, site: '' },
cache: {
enabled: false,
outDir: './dist/',
cacheDir: './link-card/',
maxFileSize: 0,
maxCacheSize: 0,
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36',
},
}
const mimeExtensions: Record<string, string> = {
'image/apng': '.apng',
'image/avif': '.avif',
'image/gif': '.gif',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/svg+xml': '.svg',
'image/webp': '.webp',
'image/bmp': '.bmp',
'image/x-icon': '.ico',
'image/tiff': '.tif',
}
const remarkLinkCard: Plugin<[], Root> = (options?: UserOptions) => {
const mergedOptions = deepmerge()(defaultOptions, options ?? {}) as Options
const transformer: Transformer<Root> = async tree => {
const tasks: Array<() => Promise<void>> = []
const visitor: Visitor<Paragraph> = (paragraphNode, index, parent) => {
if (
index === undefined ||
parent === undefined ||
parent.type !== 'root' ||
paragraphNode.children.length !== 1 ||
paragraphNode.data !== undefined
)
return
visit(paragraphNode, 'link', linkNode => {
const bareLink = getBareLink(linkNode, mergedOptions)
if (!bareLink || isExcludedUrl(bareLink.url.href, mergedOptions)) return
const url = linkNode.url
tasks.push(async () => {
const data = await getData(bareLink, mergedOptions)
if (!data) return
for (const rewriteRule of mergedOptions.rewriteRules) {
if (!rewriteRule.url.test(url)) continue
for (const rewriteStep of rewriteRule.rewriteSteps) {
if (!Object.hasOwn(data, rewriteStep.key)) continue
const key = rewriteStep.key as ObtainKeys<Data, string>
data[key] = data[key].replace(
rewriteStep.pattern,
rewriteStep.replacement,
)
}
}
const newNode = generateNode(data, mergedOptions)
parent.children.splice(index, 1, newNode)
})
})
}
visit(tree, 'paragraph', visitor)
try {
await Promise.all(tasks.map(t => t()))
} catch (error) {
console.error(`[remark-link-card] Error: ${error}`)
throw error
}
}
return transformer
}
const isAbsoluteUrl = (url: string): boolean => {
return /^[a-z][a-z\d+\-.]*:/i.test(url)
}
const isValidUrl = (url: string): boolean => {
return /^https?:\/\/(?:[-.\w]+)(?:\/[^\s]*)?$/.test(url)
}
const getBareLink = (linkNode: Link, options: Options): BareLink | null => {
if (
linkNode.children.length !== 1 ||
linkNode.children[0].type !== 'text' ||
linkNode.children[0].value !== linkNode.url ||
/\s/.test(linkNode.url) // When the URL contains multiple URLs
)
return null
const url = linkNode.url
const isInternal = !isAbsoluteUrl(url)
const base =
options.internalLink.enabled && isInternal
? options.internalLink.site
: undefined
let parsedUrl: URL
try {
parsedUrl = new URL(url, base)
} catch {
return null
}
if (!isValidUrl(parsedUrl.href)) return null
return { url: parsedUrl, isInternal: isInternal }
}
const isExcludedUrl = (url: string, options: Options): boolean => {
for (const excludedUrl of options.excludedUrls) {
if (typeof excludedUrl === 'string') {
if (url.includes(excludedUrl)) return true
} else if (excludedUrl instanceof RegExp) {
if (excludedUrl.test(url)) return true
}
}
return false
}
const fetchMetadata = async (
url: string,
options: Options,
): Promise<OgObject | null> => {
try {
const data = await ogs({
url: url,
fetchOptions: { headers: { 'user-agent': options.cache.userAgent } },
timeout: 10000,
})
if (data.error) return null
return data.result
} catch (error) {
const errorResult = error as ErrorResult
console.warn(
`[remark-link-card] Warning: Failed to fetch Open Graph data for ${errorResult.result.requestUrl} due to ${errorResult.result.error}.`,
)
return null
}
}
const getDirSize = async (dirPath: string): Promise<number> => {
const files = await fs.readdir(dirPath, { withFileTypes: true })
const promises = files.map(async file => {
const filePath = path.join(dirPath, file.name)
if (file.isDirectory()) return await getDirSize(filePath)
const { size } = await fs.stat(filePath)
return size
})
const sizes = await Promise.all(promises)
return sizes.reduce((accumulator, size) => accumulator + size, 0)
}
const ensureDirectoryExists = async (dirPath: string): Promise<void> => {
try {
await fs.access(dirPath)
} catch {
await fs.mkdir(dirPath, { recursive: true })
}
}
const existsCache = async (
cachePath: string,
cacheName: string,
): Promise<string | undefined> => {
const cachedFileNames = await fs.readdir(cachePath)
return cachedFileNames.find(cachedFileName =>
cachedFileName.startsWith(cacheName),
)
}
const IsCacheable = async (
fileSize: number,
cachePath: string,
maxFileSize: number,
maxCacheSize: number,
): Promise<boolean> => {
const exceedsMaxFileSize = maxFileSize > 0 && fileSize > maxFileSize
const exceedsMaxCacheSize =
maxCacheSize > 0 && (await getDirSize(cachePath)) + fileSize > maxCacheSize
return !(exceedsMaxFileSize || exceedsMaxCacheSize)
}
const downloadImage = async (
url: string,
cachePath: string,
options: Options,
): Promise<string | null> => {
try {
await ensureDirectoryExists(cachePath)
const stem = createHash('sha256').update(decodeURI(url)).digest('hex')
const cachedFileName = await existsCache(cachePath, stem)
if (cachedFileName) return cachedFileName
const response = await fetch(url, {
headers: { 'User-Agent': options.cache.userAgent },
signal: AbortSignal.timeout(10000),
})
const contentType = response.headers.get('Content-Type') || ''
const extension = mimeExtensions[contentType] ?? ''
if (extension === '') return null
const arrayBuffer = await response.arrayBuffer()
if (
!(await IsCacheable(
arrayBuffer.byteLength,
cachePath,
options.cache.maxFileSize,
options.cache.maxCacheSize,
))
)
return null
const fileName = `${stem}${extension}`
const filePath = path.join(cachePath, fileName)
const buffer = new Uint8Array(arrayBuffer)
await fs.writeFile(filePath, buffer)
return fileName
} catch (error) {
console.error(`[remark-link-card] Error: ${error}`)
return null
}
}
const getImageUrl = async (url: string, options: Options): Promise<string> => {
let imageUrl = url
if (!options.cache.enabled) return imageUrl
const cachePath = path.join(options.cache.outDir, options.cache.cacheDir)
const fileName = await downloadImage(imageUrl, cachePath, options)
if (fileName) {
const regex = /^(public|\.\/public)\/?$/
const cachaDir =
options.devMode && !regex.test(options.cache.outDir)
? cachePath
: options.cache.cacheDir
imageUrl = path.join(options.base, cachaDir, fileName)
imageUrl = imageUrl.replaceAll(path.sep, path.posix.sep)
}
return imageUrl
}
const getFaviconUrl = async (
url: string,
options: Options,
): Promise<string> => {
const faviconUrl = `https://www.google.com/s2/favicons?domain=${url}`
return await getImageUrl(faviconUrl, options)
}
const getData = async (
bareLink: BareLink,
options: Options,
): Promise<Data | null> => {
const metadata = await fetchMetadata(bareLink.url.href, options)
if (!metadata) return null
const url = bareLink.isInternal
? bareLink.url.href.replace(bareLink.url.origin, '')
: bareLink.url.href
const result: Data = {
url: url,
domainName: bareLink.url.hostname,
title: '',
description: '',
date: '',
faviconSrc: '',
thumbnailSrc: '',
thumbnailAlt: 'thumbnail',
hasThumbnail: false,
isInternalLink: bareLink.isInternal,
}
result.title = metadata.ogTitle ?? ''
result.description = metadata.ogDescription ?? ''
const date = metadata.ogDate ? new Date(metadata.ogDate) : null
if (date && !Number.isNaN(date)) {
result.date = `${date.toISOString().split('.')[0]}Z`
}
result.faviconSrc = await getFaviconUrl(bareLink.url.hostname, options)
if (metadata.ogImage?.[0]?.url) {
result.thumbnailSrc = await getImageUrl(metadata.ogImage[0].url, options)
result.hasThumbnail = true
} else if (options.defaultThumbnail) {
result.thumbnailSrc = path.join(options.base, options.defaultThumbnail)
result.thumbnailSrc = result.thumbnailSrc.replaceAll(
path.sep,
path.posix.sep,
)
}
if (metadata.ogImage?.[0]?.alt) {
result.thumbnailAlt = metadata.ogImage[0].alt
}
return result
}
const generateNode = (data: Data, options: Options): Text => {
const target = options.linkAttributes.target
const rel = options.linkAttributes.rel
return {
type: 'text',
value: '',
data: {
hName: 'div',
hProperties: { class: 'link-card__container' },
hChildren: [
h(
'a',
{
class: 'link-card',
href: data.url,
...(!data.isInternalLink && {
...(target && { target }),
...(rel && { rel }),
}),
},
[
h('div', { class: 'link-card__info' }, [
h('div', { class: 'link-card__title' }, [data.title]),
h('div', { class: 'link-card__description' }, [data.description]),
h('div', { class: 'link-card__metadata' }, [
h('div', { class: 'link-card__domain' }, [
h(
'img',
{
class: 'link-card__favicon',
src: data.faviconSrc,
alt: 'favicon',
},
[],
),
h('span', { class: 'link-card__domain-name' }, [
punycode.toUnicode(data.domainName),
]),
]),
...(data.date
? [h('span', { class: 'link-card__date' }, [data.date])]
: []),
]),
]),
...(data.thumbnailSrc
? [
h(
'div',
{
class:
`link-card__thumbnail ${data.hasThumbnail ? '' : 'link-card__thumbnail--default'}`.trim(),
},
[
h(
'img',
{
class: 'link-card__image',
src: data.thumbnailSrc,
alt: data.thumbnailAlt,
},
[],
),
],
),
]
: []),
],
),
],
},
}
}
export default remarkLinkCard
export type { UserOptions }

修改样式#

src/styles/link-card.css
.link-card__container {
@apply mb-4;
}
.link-card {
@apply flex min-h-32 overflow-hidden rounded-xl transition-colors !no-underline !m-0 !p-0
bg-[var(--card-bg)] hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)]
ring-1 ring-[var(--btn-plain-bg-hover)] active:ring-[var(--btn-plain-bg-active)];
}
.link-card__info {
@apply w-3/4 flex-1 pl-6 pr-4 py-4 flex flex-col gap-y-2 relative;
}
.link-card__title {
@apply text-black/90 dark:text-white/90
text-base font-semibold leading-6 line-clamp-2;
}
.link-card__title::before {
@apply content-[''] block w-1 h-4 rounded-md bg-[var(--primary)] absolute left-[10px] top-[19px];
}
.link-card:hover .link-card__title,
.link-card:hover .link-card__description,
.link-card:hover .link-card__domain-name,
.link-card:hover .link-card__date {
color: var(--primary);
}
.dark .link-card:hover .link-card__title,
.dark .link-card:hover .link-card__description,
.dark .link-card:hover .link-card__domain-name,
.dark .link-card:hover .link-card__date {
color: var(--primary);
}
.link-card__description {
@apply text-black/75 dark:text-white/75 mt-1 text-sm line-clamp-2;
}
.link-card__metadata {
@apply flex flex-wrap items-center justify-between mt-auto;
}
.link-card__domain {
@apply flex;
}
.link-card__favicon {
@apply !my-0 mr-1 h-4 w-4 pointer-events-none;
}
.link-card__domain-name {
@apply text-black/50 dark:text-white/50 text-xs;
}
.link-card__date {
@apply text-black/50 dark:text-white/50 text-xs;
}
.link-card__thumbnail {
@apply w-1/4 md:max-w-64 pr-6 pt-4;
}
.link-card__image {
@apply h-24 w-48 !my-0 object-contain md:object-cover !rounded-none pointer-events-none;
}
.link-card__thumbnail--default > .link-card__image {
@apply object-contain;
}
Fuwari添加链接卡片
https://p1ume.vercel.app/posts/fuwari/link-card/
作者
p1ume
发布于
2025-06-17
许可协议
CC BY-NC-SA 4.0