1528 字
8 分钟
Fuwari 功能增强 Episode1

前言#

对Fuwari的做了一些增强,考虑到后续可能还会有更多的修改,所以标题后加了个EP1。 本次增强的功能为图片标题(Figure Caption)与邮箱保护(Email Protection),看过我之前的Fuwari修改的文章的读者,应该知道我的修改风格,尽可能做少的改动,尽可能不破坏原有代码,本次改动同样如此。

NOTE

本文方法适用于 Astro

图片标题#

图片标题基于HTML原生元素 figure,并基于Astro的rehype扩展功能实现(关于Astro的remark和rehype扩展,有时间我会写一篇文章)。由于比较简单,就不过多解释了。创建文件src/plugins/rehype-figure.mjs然后修改astro配置即可。

src/plugins/rehype-figure.mjs
import { visit } from 'unist-util-visit';
import { h } from 'hastscript';
/**
* 将带有 alt 文本的图片转换为包含 figcaption 的 figure 元素的 rehype 插件
*
* @returns {Function} A transformer function for the rehype plugin
*/
export default function rehypeFigure() {
34 collapsed lines
return (tree) => {
visit(tree, 'element', (node, index, parent) => {
// 只处理 img 元素
if (node.tagName !== 'img') {
return;
}
// 获取 alt 属性
const alt = node.properties?.alt;
// 如果没有 alt 属性或 alt 为空字符串,则保持原样
if (!alt || alt.trim() === '') {
return;
}
// 创建 figure 元素,包含原始的 img 和居中的 figcaption
const figure = h('figure', [
// 复制原始的 img 节点,但移除 alt 属性避免重复显示
h('img', {
...node.properties,
alt: '' // 清空 alt 属性,因为现在有 figcaption 了
}),
h('figcaption', alt)
]);
// 居中显示
const centerFigure = h("center", figure);
// 替换当前的 img 节点为 figure 节点
if (parent && typeof index === 'number') {
parent.children[index] = centerFigure;
}
});
};
}
astro.config.mjs
...
import rehypeFigure from "./src/plugins/rehype-figure.js";
...
markdown: {
...
rehypePlugins: [
rehypeFigure,
...

邮箱保护#

Fuwari 中,所有的邮箱地址都会直接呈现在HTML网页中。这么做并没有什么问题,但我是魔怔人,担心邮箱地址泄露或是被爬虫机器人爬到,之后频繁给我发spam。因此我需要一个方法来保护我的邮箱地址,要求让自动化爬虫程序无法直接爬到邮箱地址,又能让正常用户方便使用。

保护邮箱地址的方法有很多,比如使用图片、使用CSS、使用Javascript、使用Challenge质询等。最终我选择了使用Javascript来保护邮件,我认为这是最方便用户的方法,不会对现有的用户操作造成任何修改。

首先Fuwari Profile组件的修改,这部分没有办法以扩展的方式实现,不得不修改原有代码。这段代码有两条路径,所以看起来修改比较多。

src/components/widget/Profile.astro
24 collapsed lines
---
import { Icon } from "astro-icon/components";
import { profileConfig } from "../../config";
import { url } from "../../utils/url-utils";
import ImageWrapper from "../misc/ImageWrapper.astro";
const config = profileConfig;
---
<div class="card-base p-3">
<a aria-label="Go to About Page" href={url('/about/')}
class="group block relative mx-auto mt-1 lg:mx-0 lg:mt-0 mb-3
max-w-[12rem] lg:max-w-none overflow-hidden rounded-xl active:scale-95">
<div class="absolute transition pointer-events-none group-hover:bg-black/30 group-active:bg-black/50
w-full h-full z-50 flex items-center justify-center">
<Icon name="fa6-regular:address-card"
class="transition opacity-0 scale-90 group-hover:scale-100 group-hover:opacity-100 text-white text-5xl">
</Icon>
</div>
<ImageWrapper src={config.avatar || ""} alt="Profile Image of the Author" class="mx-auto lg:w-full h-full lg:mt-0 "></ImageWrapper>
</a>
<div class="px-2">
<div class="font-bold text-xl text-center mb-1 dark:text-neutral-50 transition">{config.name}</div>
<div class="h-1 w-5 bg-[var(--primary)] mx-auto rounded-full mb-2 transition"></div>
<div class="text-center text-neutral-400 mb-2.5 transition">{config.bio}</div>
<div class="flex gap-2 justify-center mb-1">
{config.links.length > 1 && config.links.map(item =>
{
if (item.url.startsWith("mailto:")) {
const encodedEmail = btoa(item.url.replace("mailto:", ""));
return <a rel="me" aria-label={item.name} href="#" data-encoded-email={encodedEmail} onclick={`
(function() {
const encodedEmail = this.getAttribute('data-encoded-email');
const decodedEmail = atob(encodedEmail);
this.href = 'mailto:' + decodedEmail;
this.removeAttribute('data-encoded-email');
this.removeAttribute('onclick');
this.click();
return false;
}).call(this);
`.replace(/\s+/g, " ").trim()
},
class="btn-regular rounded-lg h-10 w-10 active:scale-90">
<Icon name={item.icon} class="text-[1.5rem]"></Icon>
</a>
} else {
return <a rel="me" aria-label={item.name} href={item.url} target="_blank" class="btn-regular rounded-lg h-10 w-10 active:scale-90">
<Icon name={item.icon} class="text-[1.5rem]"></Icon>
</a>
}
}
)}
{config.links.length == 1 && (function(item){
if (item.url.startsWith("mailto:")) {
const encodedEmail = btoa(item.url.replace("mailto:", ""));
return <a rel="me" aria-label={item.name} href="#" data-encoded-email={encodedEmail} onclick={`
(function() {
const encodedEmail = this.getAttribute('data-encoded-email');
const decodedEmail = atob(encodedEmail);
this.href = 'mailto:' + decodedEmail;
this.removeAttribute('data-encoded-email');
this.removeAttribute('onclick');
this.click();
return false;
}).call(this);
`.replace(/\s+/g, " ").trim()
},
class="btn-regular rounded-lg h-10 gap-2 px-3 font-bold active:scale-95">
<Icon name={item.icon} class="text-[1.5rem]"></Icon>
{item.name}
</a>
} else {
return <a rel="me" aria-label={item.name} href={item.url} target="_blank"
class="btn-regular rounded-lg h-10 gap-2 px-3 font-bold active:scale-95">
<Icon name={item.icon} class="text-[1.5rem]"></Icon>
{item.name}
</a>
}
})(config.links[0])}
</div>
</div>
</div>

然后是全文邮箱地址保护,在Astro中,博客的内容基本是Markdown或MDX文件,可以借助rehype扩展插件实现所有文章和页面的邮箱保护。

src/plugins/rehype-email-protection.mjs
import { h } from "hastscript";
import { visit } from "unist-util-visit";
/**
* 加密 mailto 链接以保护邮箱地址免受爬虫抓取的 rehype 插件
*
* @param {Object} options - 插件选项
* @param {string} [options.method='base64'] - 编码方式: 'base64' or 'rot13'
* @returns {Function} A transformer function for the rehype plugin
*/
export default function rehypeEmailProtection(options = {}) {
114 collapsed lines
const { method = "base64" } = options;
// Base64 编码函数
const base64Encode = (str) => {
return btoa(str);
};
// ROT13 编码函数
const rot13Encode = (str) => {
return str.replace(/[a-zA-Z]/g, (char) => {
const start = char <= "Z" ? 65 : 97;
return String.fromCharCode(
((char.charCodeAt(0) - start + 13) % 26) + start,
);
});
};
// 根据选择的方法进行编码
const encode = (str) => {
return method === "rot13" ? rot13Encode(str) : base64Encode(str);
};
// 生成解码 JavaScript 代码
const generateDecodeScript = () => {
if (method === "rot13") {
return `
function decodeRot13(str) {
return str.replace(/[a-zA-Z]/g, function(char) {
const start = char <= 'Z' ? 65 : 97;
return String.fromCharCode(((char.charCodeAt(0) - start + 13) % 26) + start);
});
}
const decodedEmail = decodeRot13(encodedEmail);
`;
}
return `
const decodedEmail = atob(encodedEmail);
`;
};
return (tree) => {
let hasEmailLinks = false;
visit(tree, "element", (node, index, parent) => {
// 只处理 a 元素
if (node.tagName !== "a") {
return;
}
// 检查是否是 mailto 链接
const href = node.properties?.href;
if (!href || !href.startsWith("mailto:")) {
return;
}
hasEmailLinks = true;
// 提取邮箱地址
const email = href.replace("mailto:", "");
const encodedEmail = encode(email);
// 创建加密的链接元素(移除原始的 href 属性,避免重复定义)
const { href: originalHref, ...otherProperties } = node.properties;
const protectedLink = h(
"a",
{
...otherProperties,
href: "#",
"data-encoded-email": encodedEmail,
onclick: `
(function() {
const encodedEmail = this.getAttribute('data-encoded-email');
${generateDecodeScript()}
this.href = 'mailto:' + decodedEmail;
this.removeAttribute('data-encoded-email');
this.removeAttribute('onclick');
this.click();
return false;
}).call(this);
`
.replace(/\s+/g, " ")
.trim(),
},
node.children,
);
// 替换当前的 a 节点
if (parent && typeof index === "number") {
parent.children[index] = protectedLink;
}
});
// 如果页面中有邮箱链接,添加样式
if (hasEmailLinks) {
visit(tree, "element", (node) => {
if (node.tagName === "head") {
const style = h(
"style",
`
a[data-encoded-email] {
cursor: pointer;
text-decoration: underline;
color: inherit;
}
a[data-encoded-email]:hover {
text-decoration: underline;
}
`.trim(),
);
node.children.push(style);
}
});
}
};
}
astro.config.mjs
...
import rehypeEmailProtection from "./src/plugins/rehype-email-protection.mjs";
...
markdown: {
...
rehypePlugins: [
[rehypeEmailProtection, { method: "base64" }], // 邮箱保护插件,支持 'base64' 或 'rot13'
...

成果展示#

markdown表示

[邮箱地址](mailto:example@mail.com)

渲染结果

邮箱地址

<a href="#" data-encoded-email="ZXhhbXBsZUBtYWlsLmNvbQ==" onclick="(function() { const encodedEmail = this.getAttribute('data-encoded-email'); const decodedEmail = atob(encodedEmail); this.href = 'mailto:' + decodedEmail; this.removeAttribute('data-encoded-email'); this.removeAttribute('onclick'); this.click(); return false; }).call(this);">邮箱地址</a>

Figure

![有标题的图片](https://lain.bgm.tv/pic/cover/l/65/cc/1020_L3HDr.jpg)

有标题的图片
有标题的图片

<!-- 无标题的图片 -->
![](https://i.waifu.pics/Owt_E3B.jpg)

Fuwari 功能增强 Episode1
https://kasuha.com/posts/fuwari-enhance-ep1/
作者
霞葉
发布于
2025-05-24
许可协议
CC BY-NC-SA 4.0
评论加载中...