1303 字
7 min
Fuwari链接卡片v2.0

话说在前#

说不了一点,这里涉及的代码包含了99%的Claude以及我1%的思考

具体实现#

  1. 创建组件 新建一个src/plugins/rehype-component-link-card.mjs组件
rehype-component-link-card.mjs
/// <reference types="mdast" />
import { h } from "hastscript";
// 常量定义
const CONSTANTS = {
FAVICON_API: 'https://www.google.com/s2/favicons',
FAVICON_SIZE: 32,
ID_PREFIX: 'LC',
LOADING_TITLE: 'Loading...',
LOADING_DESC: 'Loading description...',
};
// 错误信息
const ERRORS = {
INVALID_DIRECTIVE: 'Invalid directive. ("link-card" directive must be leaf type "::link-card{url="https://example.com"}")',
INVALID_URL: 'Invalid URL. ("url" attribute must be a valid HTTP/HTTPS URL)',
};
/**
* 生成唯一的卡片ID
* 使用时间戳和随机数确保唯一性
*/
function generateCardId() {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).slice(2, 8);
return `${CONSTANTS.ID_PREFIX}${timestamp}${random}`;
}
/**
* 安全地提取域名
*/
function extractDomain(url) {
try {
return new URL(url).hostname;
} catch {
return 'unknown';
}
}
/**
* 验证URL是否有效
*/
function isValidUrl(url) {
if (!url) return false;
try {
const urlObj = new URL(url);
return ['http:', 'https:'].includes(urlObj.protocol);
} catch {
return false;
}
}
/**
* 转义字符串用于安全插入到JavaScript中
*/
function escapeForScript(str) {
return JSON.stringify(str);
}
/**
* 生成获取元数据的脚本
* 使用IIFE避免全局污染,使用JSON.stringify防止XSS
*/
function generateMetadataScript(cardId, url, domain) {
return `
(function() {
'use strict';
try {
const cardElement = document.getElementById('${cardId}-card');
const titleElement = document.getElementById('${cardId}-title');
const descElement = document.getElementById('${cardId}-description');
if (!cardElement || !titleElement || !descElement) {
console.warn('[LINK-CARD] Elements not found for ${cardId}');
return;
}
// 只有在没有自定义内容时才设置默认值
if (!titleElement.dataset.hasCustomTitle) {
titleElement.textContent = ${escapeForScript(domain)};
}
if (!descElement.dataset.hasCustomDesc) {
descElement.textContent = ${escapeForScript(`Visit ${domain}`)};
}
cardElement.classList.remove("fetch-waiting");
console.log("[LINK-CARD] Loaded card for:", ${escapeForScript(url)}, "|", "${cardId}");
} catch (err) {
console.error("[LINK-CARD] Error loading card:", err);
const cardEl = document.getElementById('${cardId}-card');
if (cardEl) {
cardEl.classList.add("fetch-error");
cardEl.classList.remove("fetch-waiting");
}
}
})();
`;
}
/**
* Creates a Link Card component for third-party links.
* @param {Object} properties - The properties for the link card
* @param {string} properties.url - The URL to link to (required)
* @param {string} [properties.title] - Custom title for the card
* @param {string} [properties.description] - Custom description
* @param {string} [properties.image] - Custom image URL
* @param {string} [properties.icon] - Custom favicon URL
* @param {Array} children - Should be empty for leaf directive
* @returns {Object} HAST element representing the link card
*/
export function LinkCardComponent(properties = {}, children = []) {
// 验证:确保是叶子指令(没有子元素)
if (Array.isArray(children) && children.length !== 0) {
return h("div", { class: "hidden" }, ERRORS.INVALID_DIRECTIVE);
}
// 验证URL
if (!isValidUrl(properties.url)) {
return h("div", { class: "hidden" }, ERRORS.INVALID_URL);
}
const url = properties.url;
const domain = extractDomain(url);
const cardId = generateCardId();
// 解构自定义属性,提供默认值
const {
title: customTitle = null,
description: customDescription = null,
image: customImage = null,
icon: customIcon = null
} = properties;
// 判断是否需要获取元数据
const needsFetch = !customTitle || !customDescription;
// 构建favicon URL,使用自定义图标或Google的favicon服务
const iconUrl = customIcon ||
`${CONSTANTS.FAVICON_API}?domain=${encodeURIComponent(domain)}&sz=${CONSTANTS.FAVICON_SIZE}`;
// 创建favicon元素
const nFavicon = h(`div#${cardId}-favicon`, {
class: "lc-favicon",
style: `background-image: url(${iconUrl})`,
// 添加错误处理:如果图标加载失败,使用默认背景色
onerror: "this.style.backgroundImage='none'; this.style.backgroundColor='#f0f0f0';"
});
// 创建标题栏
const nTitle = h("div", { class: "lc-titlebar" }, [
h("div", { class: "lc-titlebar-left" }, [
h("div", { class: "lc-site" }, [
nFavicon,
h("div", { class: "lc-domain" }, domain),
]),
]),
h("div", { class: "lc-external-icon" }),
]);
// 创建卡片标题
const nCardTitle = h(
`div#${cardId}-title`,
{
class: "lc-card-title",
...(customTitle && { 'data-has-custom-title': 'true' })
},
customTitle || CONSTANTS.LOADING_TITLE
);
// 创建描述
const nDescription = h(
`div#${cardId}-description`,
{
class: "lc-description",
...(customDescription && { 'data-has-custom-desc': 'true' })
},
customDescription || CONSTANTS.LOADING_DESC
);
// 构建卡片内容数组
const cardContent = [nTitle, nCardTitle, nDescription];
// 如果有自定义图片,添加图片元素
if (customImage) {
const nImage = h(
`div#${cardId}-image`,
{ class: "lc-image" },
h("img", {
src: customImage,
alt: customTitle || "Link preview",
loading: "lazy", // 添加懒加载
onerror: "this.style.display='none';" // 图片加载失败时隐藏
})
);
cardContent.push(nImage);
}
// 如果需要获取元数据,添加脚本
if (needsFetch) {
const nScript = h(
`script#${cardId}-script`,
{
type: "text/javascript",
defer: true
},
generateMetadataScript(cardId, url, domain)
);
cardContent.push(nScript);
}
// 创建并返回链接卡片
return h(
`a#${cardId}-card`,
{
class: needsFetch ? "card-link fetch-waiting no-styling" : "card-link no-styling",
href: url,
target: "_blank",
rel: "noopener noreferrer", // 安全性:防止新页面访问 window.opener
'data-url': url,
'aria-label': `Link to ${domain}`, // 无障碍:添加屏幕阅读器标签
title: customTitle || `Visit ${domain}` // 添加悬停提示
},
cardContent
);
}
// 导出默认函数,保持向后兼容
export default LinkCardComponent;
  1. 新建样式 我这里选择新建src/styles/link-card.styl,当然也可以选择在src/styles/markdown-extend.styl中添加以下样式
// Link Card Styles
a.card-link
display: block
text-decoration: none
border: 1px solid var(--line-divider)
border-radius: 8px
padding: 16px
margin: 16px 0
background-color: var(--card-bg)
transition: all 0.2s ease
position: relative
overflow: hidden
color: inherit
&:hover
background-color: var(--btn-regular-bg-hover)
.lc-titlebar
.lc-external-icon
opacity: 1
&:active
transform: translateY(0)
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1)
.lc-titlebar
display: flex
justify-content: space-between
align-items: center
margin-bottom: 8px
.lc-titlebar-left
display: flex
align-items: center
.lc-site
display: flex
align-items: center
gap: 8px
.lc-favicon
width: 16px
height: 16px
background-size: contain
background-repeat: no-repeat
background-position: center
flex-shrink: 0
.lc-domain
font-size: 14px
color: var(--text-color-secondary)
font-weight: 500
.lc-external-icon
width: 16px
height: 16px
opacity: 0.6
transition: opacity 0.2s ease
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15,3 21,3 21,9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>')
background-size: contain
background-repeat: no-repeat
background-position: center
.lc-card-title
font-size: 18px
font-weight: 600
color: var(--text-color-primary)
margin-bottom: 8px
line-height: 1.3
.lc-description
font-size: 14px
color: var(--text-color-secondary)
line-height: 1.4
margin-bottom: 12px
.lc-image
margin-top: 12px
img
width: 100%
max-height: 200px
object-fit: cover
border-radius: 4px
&.fetch-waiting
.lc-card-title, .lc-description
animation: pulse 1.5s ease-in-out infinite
&.fetch-error
border-color: var(--error-color)
background-color: var(--error-bg)
@keyframes pulse
0%, 100%
opacity: 1
50%
opacity: 0.5

这里参考鱼的样式

然后在src/styles/markdown-extend.styl中导入

markdown-extend.styl
@import 'link-card.styl' // 在文件末尾添加就行
  1. 配置Astro 在astro.config.mjs文件中导入组件并注册
astro.config.mjs
// 添加导入
import { LinkCardComponent } from "./src/plugins/rehype-component-link-card.mjs";
// 在 rehypeComponents 配置中添加
rehypeComponents,
{
components: {
"link-card": LinkCardComponent, // 添加这一行
// ... 其他组件
},
},

使用方法#

fuwari.oh1.top
Loading...
Loading description...
// 基本用法
::link-card{url="https://example.com"}
// 自定义标题和描述
::link-card{url="https://github.com" title="GitHub" description="代码托管平台"}
// 带自定义图片
::link-card{url="https://vercel.com" title="Vercel" description="部署平台" image="https://example.com/image.png"}
// 自定义图标
::link-card{url="https://github.com" title="GitHub" description="代码托管平台" icon="https://github.com/favicon.ico"}
NOTE
  • 确保URL以http://https://开头
  • 自定义图片建议使用合适的尺寸和格式 '''

The end Ciallo~

Fuwari链接卡片v2.0
https://fuwari.oh1.top/posts/fuwari/link-card/
作者
yCENzh
发布于
2025-08-23
License
CC BY-NC-SA 4.0