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
相关依赖即可
pnpm add @fastify/deepmerge open-graph-scraper punycodepnpm add -D unified @types/punycode #-D 安装开发依赖
添加配置
import fuwariLinkCard from "./src/plugins/fuwari-link-card.ts"
integrations: [ ...
fuwariLinkCard({ internalLink: { enabled: true }, }), ... ]
主要配置说明
名称 (Name) | 类型 (Type) | 默认值 (Default) | 描述 (Description) |
---|---|---|---|
devMode | boolean | import.meta.env.DEV | 启用或禁用开发模式。默认情况下,它会根据 Astro 的环境自动判断(即在 astro dev 时为 true ,astro build 时为 false )。 |
excludedUrls | Array<string | RegExp> | [] | 一个由字符串或正则表达式组成的数组,用于排除特定的 URL 不进行链接卡片处理。此选项也有助于防止与其他处理链接的插件发生冲突。 |
linkAttributes | Object | { target: ”, rel: ”, } | 为外部链接设置 target 和 rel 属性。您可以将它们留空(如默认值所示),将这些属性的处理权交给其他插件(例如,一个自动为所有外链添加 target="_blank" 的插件)。 |
rewriteRules | Array<Object> | [] | 重写从链接中获取的特定元数据属性,例如标题(title)和描述(description)。这在您需要修正或自定义抓取到的信息时非常有用。 |
base | string | ’/‘ | 指定与您 Astro 配置中相同的 base 路径。详情请参考 Astro 的官方文档。当此工具作为 Astro 集成(Integration)使用时,如果未指定此选项,它将被自动检测并设置。 |
defaultThumbnail | string | ” | 当链接的元数据中不包含预览图(og:image )时,使用的默认缩略图路径。该路径应相对于 public 目录。例如,如果图片位于 public/images/default-thumbnail.jpg ,则此项应设置为 'images/default-thumbnail.jpg' 。 |
internalLink | Object | { enabled: false, site: ” } | 启用对您站点内部链接的处理。需要设置 enabled: true 并提供 site 的 URL。 |
添加插件
在对应的文件夹下添加文件以及相关代码
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 fuwariLinkCardexport type { UserOptions }
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 remarkLinkCardexport type { UserOptions }
修改样式
.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/