在 Hexo 项目中添加 Webmention
Webmention 是 IndieWeb 的一个核心的开放标准,允许网站之间像社交平台一样互动:评论、点赞、转发等。重点是它去中心化、跨站点实现这些功能。
要知道,我的博客是用「老掉牙」的 Hexo 构建的。Hexo 的插件列表里,可是没有任何一款插件是和 Webmention 相关的。假设我想要在自己的网站里添加 Webmention 的发送和接收功能,只能自己写。更悲伤的是,我的「强迫症」要求我在自己开发的主题里也添加上即拿即用的 Webmention 功能。
和过去一样,我会将 Hexo 项目根目录的 _config.yml
称呼为 Hexo 配置项、将 themes/主题名/
目录下的 _config.yml
称呼为主题配置项。
我用的是 Markdown + Pug。如果你用的是其他引擎,请自行改动代码,逻辑应当都是相同的。
该文章默认你是 Hexo 插件 & 主题开发者!如果你不是、又或者说只是想要即拿即用的方案,见 这里。
接收
要想接收 Webmention,非常简单。你只需要在网站里添加这样的标签:
1 | <link rel="me" href="https://github.com/yourusername" /> |
这不需要是 GitHub 链接。实际上大多数社交平台的链接都可以,像 GitLab 啦、推特啦、长毛象啦…… 我写了一个辅助函数来检测一个链接是否是社交平台的链接:
1 | - |
这个 <link>
呢,放在哪里都可以。像我就偷懒了,直接在我的二级菜单里添加了这个判断条件:
1 | mixin nav-link(item) |
我的主题可以手动标记二级菜单里的链接是否是内部还是外部的(其实自动识别更好,但我偷懒了)。假设是外部链接,就让主题判断它是否是社交平台链接。如果是的话,就添加 rel="me"
这个属性。因为二级菜单里的链接大概率都是自己的社交平台链接,所以我认为这么写完全没问题~
这样做的目的是身份验证。现在来到 Webmention.io 里便可以注册该网站、获得你的接收网址:
1 | <link rel="webmention" href="https://webmention.io/你的域名/webmention" /> |
再将其添加网站的 <head>
里:
1 | if theme.webmention && theme.webmention.domain |
1 | webmention: |
为了让使用到 Hexo-Theme-Ares 的用户也可以立即使用上 Webmention,我写成了需要在主题配置项里配置、才能自动添加接收链接的逻辑。
收到的所有 Webmention 都可以在 Webmention.io 的仪表盘 里查看。
发送
我参考的是 Webmention.app 文档 里的做法。
我建议还在开发该功能的朋友们,先使用手动发送。待功能完善后,再进行自动化。
问的话,只能说都是泪。我在功能还未完善时就采用了 Netlify 自动请求 Webhook 的方案。结果向接收方发送了数个不完善的 Webmention。这些 Webmention 还无法撤回,只能等待对方自行移除。不是什么大问题,但心里会想着给对方添了麻烦、实在是抱歉。
再就是手动发送时请一定要注意自己网页的 URL 规范。例如我现在这个文章,你可以用 /posts/1de
访问,也可以用 /posts/1de.html
访问。看似是一样的,但是对于 Webmention.io 而言它们是完全不同的源地址。
假设你先用了不带 .html
的 URL 作为源地址发送,之后又用了带 .html
的 URL 作为源地址发送。即时它们内容一样、目标一样,服务器也会创建两条独立的记录。
想知道我是如何知道的吗?就算你问我这个问题,我也只能看着接收方数个重复的 Webmention 记录、笑笑不说话了。
我个人建议用后者发送,因为是 Permalink。
Webmention.app 提供了数个发送的方法,其核心都是通过检查指定 URL 的内容并自动发现其中的链接目标,然后向这些目标发送 Webmention 通知:
- 基础发送方式
- 命令行工具
- 使用
curl -X POST
向https://webmention.app/check?url=你的网页地址
发起请求 - 支持一次性发送单条或多条条目,会自动发现
.h-entry
等 Microformats 标记(见 Microformats) - 可通过 GET 请求进行 dry-run 预览,也就是检查将要发送的 Webmention 但不会实际发送
- 建议在 Webmention.app 内领取令牌并配合使用,避免速率限制。如果不添加令牌的话,每个独立的 URL 每四个小时只能请求一次。用法很简单,只需要在 API 里添加
token
参数
- 使用
- Node.JS 本地工具
- 安装
@remy/webmention
包后,可以使用webmention
或者简写命令wm
- 支持读取多种内容格式,包括 HTML、RSS、Atom
- 使用方法:
npx webmention 你的网站的feed地址 --limit 数字 --send
--limit
默认为 10 条。假设设置成 1,就是发送 feed 里最新的文章的 Webmention- 默认执行 dry-run,加上
--send
参数才会实际发送 Webmention
- 安装
- Webmention.app 网页
- 该方案没有在文档中提及。简单来说便是在 Webmention.app 的 Test a URL 页 手动填写网页地址、手动点击 Send All Webmentions 按钮发送
- 其实就是「命令行工具」方案的网页版,更好看些
- 命令行工具
- 自动化发送方案
- Netlify 部署通知
- 步骤
- 在 Netlify 项目中访问 Deploys 页
- 点击 Notifications 按钮
- 在 Deploy notifications 块旁找到 Add notification 选项
- 点击后选择 HTTP POST request
- Event to listen for 选择 Deploy succeeded
- URL to notify 输入该 URL:
https://webmention.app/check?token=令牌&limit=限制发送多少条&url=你的网站feed地址
- 每次部署成功后,Netlify 都会自动向 Webmention.app 发送 POST 请求
- 其实该方法就是最先说的「命令行工具」方案的进化版,并不意味着必须要使用 Netlify 部署网站才能自动化。你只要能确保每次部署成功后都可以发送一次该 POST 请求即可
- 步骤
- IFTTT + RSS Feed
- 该方案需要付费。我个人不推荐该方案,你可以找找 IFTTT 的免费代替品,或者使用其他方案。和我上面说的一样,这些方案的本质都是向 Webmention.app 发送 POST 请求
- IFTTT + 定时触发
- 同上,不推荐
- Netlify 部署通知
Microformats
Microformats 是一组基于 HTML 的轻量级语义标记规范,用于在网页中嵌入结构化数据,使人类和机器都能更好地理解和使用网页内容。
它的核心概念是:
- 不需要额外的语法或嵌套语言,直接在网页中使用标准 HTML 元素和
class
属性 - 通过添加特定
class
名称来标记文章、人物、事件等信息 - 搜索引擎、浏览器、社交平台等可以自动识别这些标记并提取结构化数据
- 标记内容仍然是普通网页的一部分,不影响用户阅读体验
为了让 Webmention 功能更完整地显示我们的内容,Microformats 是十分建议添加的。重点在于第二条:标记文章、人物、事件等信息。
-
文章:
h-entry
用于标记网页内容条目的语义类名-
用纯 HTML 作为例子,用法是这样的:
html1
2
3<div class="h-entry">
<p>世界你好~</p>
</div> -
要想添加标题、摘要、正文等信息,需要在
h-entry
所属的标签内部添加子标签。这些是广泛使用的属性:-
p-name
:条目标题或名称html1
2
3
4<div class="h-entry">
<p class="p-name">我是标题</p>
<p>世界你好~</p>
</div> -
p-summary
:简短摘要html1
2
3
4
5<div class="h-entry">
<p class="p-name">我是标题</p>
<p class="p-summary">我是摘要</p>
<p>世界你好~</p>
</div> -
e-content
:正文内容html1
2
3
4
5<div class="h-entry">
<p class="p-name">我是标题</p>
<p class="p-summary">我是摘要</p>
<p class="e-content">世界你好~</p>
</div> -
dt-published
:发布时间(建议使用<time datetime>
)就不举例了,总之和上面的例子一样,都是标签里加类名。
-
dt-updated
:更新时间 -
p-author
:分类或标签 -
u-url
:条目的永久链接 -
u-uid
:唯一标识符,通常与u-url
相同 -
p-location
:发布地点(可嵌套h-card
、h-adr
、h-geo
) -
u-syndication
:条目的分发副本链接 -
u-in-reply-to
:所回应的原始条目链接(可嵌套h-cite
) -
p-rsvp
:RSVP 状态-
RSVP 指的是法语短语「Répondez s'il vous plaît」的缩写,意思是「请回复」。在活动邀请中,它用于请求受邀者告知是否会出席。虽然来源是法语,但这个缩写已经被英语和其他语言广泛采用,尤其在正式或半正式的场合中
-
在 Microformats2 的语义标记中,
p-rsvp
用来表示你对某个活动(h-event
)的回应状态。支持的值包括:yes
:会参加no
:不会参加maybe
:尚未决定interested
:感兴趣但未承诺参加
-
-
u-like-of
:所点赞的条目链接 -
u-repost-of
:所转发的条目链接
-
-
虽然没有强制要求,但一个典型的
h-entry
应至少包含:u-url
dt-published
e-content
或p-summary
p-name
p-author
:稍后在h-card
提到
-
-
人物:
h-card
用于在网页中嵌入人物或组织的结构化信息- 和
h-entry
一样的用法。核心属性是这些,它们都是可选的,并且支持多值:p-name
:姓名或组织名称u-url
:主页或代表链接u-photo
:头像或代表图像u-email
:邮箱地址p-org
:所属组织p-job-title
:职位名称p-note
:附加说明p-category
:标签或分类dt-bday
:生日日期p-tel
:电话号码p-adr
:地址信息,可嵌套h-adr
p-geo
/u-geo
:地理位置,可嵌套h-adr
h-card
不强制放入h-entry
内,但我 推荐 嵌套使用。这样有助于 Webmention 接收方或 Microformats 解析器识别作者是谁,并展示头像、名称、主页等信息
- 和
现在我们回到 Hexo。我建议在渲染文章的 <article>
标签里添加 h-entry
类。
我个人的写法是这样的:
1 | mixin post(item) |
此处我直接照搬的主题源码。实际传参用的是 page
。
需要注意的是,我没有使用到全部的属性。未来我会慢慢扩展这些内容。
1 | mixin postInfo(item) |
在文章中的 Front Matter 里添加该属性来指定目标地址:
1 | --- |
不过我的主题有些特殊:
- 导航栏会渲染我的头像作为 logo,我不想要文章又一次显示该头像
- 部分内容显示出来会导致整体页面看上去比较臃肿
item.content
包含了item.excerpt
在内。直接从item.content
里取摘要会很麻烦,再显示一次item.excerpt
又很奇怪
解决方案也很简单。既然这部分内容只是给机器看的,我们将其隐藏就好了。直接全部加上 display: none
样式。
进阶玩法
仅发送一部分内容
有时候,你一个文章里只有小部分内容是你想要发送的;有时候,接收方的 Webmention 显示处并没有写字数限制,你的文章会原封不动地全部显示在接收方的网页里。至少我是想要一个更灵活的发送方法,因此我写了个脚本。
效果如下:
1 | {% reply 目标地址 %} |
首先我们需要注册标签 reply
:
1 | hexo.extend.tag.register('reply', function(args, content) { |
我们先前的主题模板写法,会默认给文章最外部包上 h-entry
、发送整个文章。既然有些文章我们只想在内部包含多个小的 h-entry
回复块,我们可以在内存中添加一个属性 entry
。
如果该文章我们希望整个打包送走,那就在文章的 Front Matter 里添加:
1 | --- |
并在主题模板里添加条件判断:
1 | mixin post(item) |
由于会使用到 reply
标签的文章都不希望发送整个文章,所以在脚本内我们要将 entry
设置为 false
:
1 | if (this.page) { |
接着验证 URL:
1 | // 1. 检查用户是否忘记提供 URL |
渲染 Markdown 内容:
1 | let renderedContent; |
国际化支持:
1 | // 获取当前语言的“回复”翻译 |
获取并处理作者信息:
1 | // 获取作者信息和网站配置 |
最后生成 HTML 结构:
1 | return ` |
这里我声明了一些类,需要自定义样式:
reply-block
post-meta
author-avatar
author-name
reply-content
reply-meta
reply-label
reply-target
不用这些样式也可以。我个人更希望回复块和普通文本们可以区分开来。
和先前做的一样,我也为 h-card
标签添加了 display: none
样式。这个样式可以根据你们的需求、选择保留或者删除。
显示
要想在博客内显示其他人向我们发送的 Webmention,就需要用到 Webmention.io 提供的 Mentions Feed API。Webmention.io 会提供给我们一个 API 密钥,只需要将其添加在以下任意一个 URL 中即可:
https://webmention.io/api/mentions.html?token=API密钥
https://webmention.io/api/mentions.atom?token=API密钥
https://webmention.io/api/mentions.jf2?token=API密钥
这些链接会根据自己的格式,显示网站所有的 Webmention。但要是用在我们的博客网站上的话,就遭殃了:读者只想阅读你的一篇文章,但你的网站已经获取了全部一千篇文章的 Webmention。请不要这么做。
正确做法是使用 url
参数,指定获取哪个文章的 Webmention。我建议使用 jf2
来获取数据,它是纯粹的、为机器而生的数据格式:
https://webmention.io/api/mentions.jf2?url=文章URL
如果你认为数据获取可以在客户端上进行、希望 Webmention 可以实时显示,你完全可以在主题的 /source/js/
目录下添加一个 JavaScript 文件执行网络请求和数据处理。不过纯动态有些劣势:
- 对 SEO 不友好。其他人发送的 Webmention 不会被搜索引擎索引
- 可能在内容出现时导致页面布局发生跳动
- 浏览器需要发起一次额外的网络请求
- 如果 Webmention.io 的 API 出现故障或者相应缓慢,就什么都加载不出来了
如果你认为这四点很重要,你可以考虑采用纯静态构建。这需要你在项目根目录的 /scripts/
目录下创建 Hexo 脚本:
1 | hexo.extend.generator.register('webmentions', async function() { |
我假设了项目配置项里有这些内容:
1 | webmention: |
要想要在主题模板里渲染这些变量,有两种方法:
-
直接在主题模板里用
site
变量渲染关于 Hexo 的局部变量,建议看以下文档:
- 自定义 - 变量 - 《Hexo 博客框架文档》 - 书栈网・BookStack:列出了模板中所有可用的变量
- 局部变量 | Hexo:官方文档。主要是说明如何在 JavaScript 里操作
hexo.locals
总之,脚本里
hexo.locals.get("posts")
等同于模板里用site.posts
。 -
写一个辅助函数,在模板中调用、渲染返回的 HTML:
webmention-receiver.JSjavascript1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79hexo.extend.helper.register('render_webmentions', function(pageUrl) {
const webmentionConfig = this.config.webmention;
const mode = webmentionConfig && webmentionConfig.mode ? webmentionConfig.mode : 'static';
// 从 hexo.locals 获取 webmention 数据
const allMentions = hexo.locals.get('all_webmentions') || [];
// 文字截断函数
const truncateContent = (content, maxLength = 200) => {
if (!content) return '';
// 如果是HTML内容,先提取纯文本
const textContent = content.replace(/<[^>]*>/g, '');
if (textContent.length <= maxLength) {
return content;
}
// 截断文本并添加省略号
const truncatedText = textContent.substring(0, maxLength).trim();
// 如果原内容是HTML,包装成段落;否则直接返回
return content.includes('<') ? `<p>${truncatedText}…</p>` : `${truncatedText}…`;
};
if (!pageUrl) {
// 尝试多种方式获取当前页面 URL
pageUrl = this.page.path || this.page.url || this.url;
}
// 确保 pageUrl 有值
if (!pageUrl) {
return '<div class="webmention-section webmention-empty"><span>无法获取页面URL</span></div>';
}
const fullUrl = new URL(pageUrl, this.config.url).href;
const mentionsForThisPage = allMentions.filter(mention => {
// 简化 URL 匹配逻辑,使用字符串比较而非 URL 构造
const targetUrl = mention.target;
// 同时尝试完整 URL 和相对路径匹配
return targetUrl === fullUrl ||
targetUrl.endsWith(pageUrl) ||
fullUrl.endsWith(mention.target.split('/').pop()) ||
mention.target.includes(pageUrl.replace(/^\//, ''));
});
let html = `<div class="webmention-section" data-page-url="${pageUrl}" data-full-url="${fullUrl}" data-mode="${mode}">
<h3 class="webmention-title">Webmentions (<span class="webmention-count">${mentionsForThisPage.length}</span>)</h3>
<div class="webmention-list">`;
mentionsForThisPage.forEach(mention => {
const authorHtml = mention.author.url
? `<a class="webmention-author-name" href="${mention.author.url}" target="_blank" rel="noopener ugc">${mention.author.name}</a>`
: `<span class="webmention-author-name">${mention.author.name}</span>`;
const photoHtml = mention.author.photo
? `<img class="webmention-author-photo" src="${mention.author.photo}" alt="${mention.author.name}" loading="lazy">`
: '';
html += `
<div class="webmention-item" id="webmention-${mention.id}" data-webmention-id="${mention.id}">
<div class="webmention-author">
${photoHtml}
${authorHtml}
<span class="webmention-date">${new Date(mention.published || mention.received).toLocaleDateString('zh-CN')}</span>
</div>
<div class="webmention-content">
${truncateContent(mention.content.html || mention.content.text)}
</div>
<div class="webmention-meta">
<a class="webmention-source" href="${mention.source}" target="_blank" rel="noopener ugc">查看原文</a>
</div>
</div>
`;
});
html += '</div></div>';
return html;
});实际返回的 HTML 可以自行修改。如果想要直接使用该脚本文件,不要忘了为里面提到的类写样式。
接下来你要做的就是在模板里找到个顺眼的地方调用该方法:
plaintext1
2if config.webmention && config.webmention.enable
!= render_webmentions()
插件
先前说过了,我有「强迫症」。我将上述的所有功能整合成了多个 Hexo 插件,你只需要安装和配置。详情以仓库里的 README 文件为标准。