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配置即可。
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; } }); };}...import rehypeFigure from "./src/plugins/rehype-figure.js";...markdown: { ... rehypePlugins: [ rehypeFigure, ...邮箱保护
Fuwari 中,所有的邮箱地址都会直接呈现在HTML网页中。这么做并没有什么问题,但我是魔怔人,担心邮箱地址泄露或是被爬虫机器人爬到,之后频繁给我发spam。因此我需要一个方法来保护我的邮箱地址,要求让自动化爬虫程序无法直接爬到邮箱地址,又能让正常用户方便使用。
保护邮箱地址的方法有很多,比如使用图片、使用CSS、使用Javascript、使用Challenge质询等。最终我选择了使用Javascript来保护邮件,我认为这是最方便用户的方法,不会对现有的用户操作造成任何修改。
首先Fuwari Profile组件的修改,这部分没有办法以扩展的方式实现,不得不修改原有代码。这段代码有两条路径,所以看起来修改比较多。
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扩展插件实现所有文章和页面的邮箱保护。
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); } }); } };}...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

<!-- 无标题的图片 -->
Fuwari 功能增强 Episode1
https://kasuha.com/posts/fuwari-enhance-ep1/