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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-
function isSocialMediaUrl(url) {
if (!url) return false;
const socialDomains = [
'github.com', 'gitlab.com', 'bitbucket.org',
'twitter.com', 'x.com', 'mastodon.social', 'mastodon.online',
'linkedin.com', 'facebook.com', 'instagram.com',
'weibo.com', 'zhihu.com', 'bilibili.com',
'youtube.com', 'youtu.be', 'tiktok.com',
'discord.gg', 'telegram.me', 't.me'
];
try {
const domain = new URL(url).hostname.toLowerCase();
return socialDomains.some(socialDomain =>
domain === socialDomain || domain.endsWith('.' + socialDomain)
);
} catch {
return false;
}
}

这个 <link> 呢,放在哪里都可以。像我就偷懒了,直接在我的二级菜单里添加了这个判断条件:

1
2
3
4
5
6
7
8
mixin nav-link(item)
if item.type === 'internal'
// 省略
else if item.type === 'external'
- var relAttr = "noopener noreferrer" + (isSocialMediaUrl(item.path) ? " me" : "")
a.nav-link(href=item.path target="_blank" rel=relAttr)
!= __(`menu.${item.key}`)
+icon-external-link()

我的主题可以手动标记二级菜单里的链接是否是内部还是外部的(其实自动识别更好,但我偷懒了)。假设是外部链接,就让主题判断它是否是社交平台链接。如果是的话,就添加 rel="me" 这个属性。因为二级菜单里的链接大概率都是自己的社交平台链接,所以我认为这么写完全没问题~

这样做的目的是身份验证。现在来到 Webmention.io 里便可以注册该网站、获得你的接收网址:

1
<link rel="webmention" href="https://webmention.io/你的域名/webmention" />

再将其添加网站的 <head> 里:

1
2
if theme.webmention && theme.webmention.domain
link(rel="webmention", href=`https://webmention.io/${theme.webmention.domain}/webmention`)
themes/主题/_config.yml
1
2
webmention:
domain: 你的域名

为了让使用到 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 通知:

  • 基础发送方式
    1. 命令行工具
      • 使用 curl -X POST 向 https://webmention.app/check?url=你的网页地址 发起请求
      • 支持一次性发送单条或多条条目,会自动发现 .h-entry 等 Microformats 标记(见 Microformats)
      • 可通过 GET 请求进行 dry-run 预览,也就是检查将要发送的 Webmention 但不会实际发送
      • 建议在 Webmention.app 内领取令牌并配合使用,避免速率限制。如果不添加令牌的话,每个独立的 URL 每四个小时只能请求一次。用法很简单,只需要在 API 里添加 token 参数
    2. Node.js 本地工具
      • 安装 @remy/webmention 包后,可以使用 webmention 或者简写命令 wm
      • 支持读取多种内容格式,包括 HTML、RSS、Atom
      • 使用方法:npx webmention 你的网站的feed地址 --limit 数字 --send
        • --limit 默认为 10 条。假设设置成 1,就是发送 feed 里最新的文章的 Webmention
        • 默认执行 dry-run,加上 --send 参数才会实际发送 Webmention
    3. Webmention.app 网页
      • 该方案没有在文档中提及。简单来说便是在 Webmention.app 的 Test a URL 页 手动填写网页地址、手动点击 Send All Webmentions 按钮发送
      • 其实就是「命令行工具」方案的网页版,更好看些
  • 自动化发送方案
    1. Netlify 部署通知
      • 步骤
        1. 在 Netlify 项目中访问 Deploys 页
        2. 点击 Notifications 按钮
        3. 在 Deploy notifications 块旁找到 Add notification 选项
        4. 点击后选择 HTTP POST request
        5. Event to listen for 选择 Deploy succeeded
        6. URL to notify 输入该 URL:https://webmention.app/check?token=令牌&limit=限制发送多少条&url=你的网站feed地址
      • 每次部署成功后,Netlify 都会自动向 Webmention.app 发送 POST 请求
      • 其实该方法就是最先说的「命令行工具」方案的进化版,并不意味着必须要使用 Netlify 部署网站才能自动化。你只要能确保每次部署成功后都可以发送一次该 POST 请求即可
    2. IFTTT + RSS Feed
      • 该方案需要付费。我个人不推荐该方案,你可以找找 IFTTT 的免费代替品,或者使用其他方案。和我上面说的一样,这些方案的本质都是向 Webmention.app 发送 POST 请求
    3. IFTTT + 定时触发
      • 同上,不推荐

Microformats

Microformats 是一组基于 HTML 的轻量级语义标记规范,用于在网页中嵌入结构化数据,使人类和机器都能更好地理解和使用网页内容。

它的核心概念是:

  • 不需要额外的语法或嵌套语言,直接在网页中使用标准 HTML 元素和 class 属性
  • 通过添加特定 class 名称来标记文章、人物、事件等信息
  • 搜索引擎、浏览器、社交平台等可以自动识别这些标记并提取结构化数据
  • 标记内容仍然是普通网页的一部分,不影响用户阅读体验

为了让 Webmention 功能更完整地显示我们的内容,Microformats 是十分建议添加的。重点在于第二条:标记文章、人物、事件等信息。

  • 文章:h-entry 用于标记网页内容条目的语义类名

    • 用纯 HTML 作为例子,用法是这样的:

      1
      2
      3
      <div class="h-entry">
      <p>世界你好~</p>
      </div>
    • 要想添加标题、摘要、正文等信息,需要在 h-entry 所属的标签内部添加子标签。这些是广泛使用的属性:

      • p-name:条目标题或名称

        1
        2
        3
        4
        <div class="h-entry">
        <p class="p-name">我是标题</p>
        <p>世界你好~</p>
        </div>
      • p-summary:简短摘要

        1
        2
        3
        4
        5
        <div class="h-entry">
        <p class="p-name">我是标题</p>
        <p class="p-summary">我是摘要</p>
        <p>世界你好~</p>
        </div>
      • e-content:正文内容

        1
        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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
mixin post(item)
.post
article.post-block.h-entry
.post-meta.p-author.h-card
img.author-avatar.u-photo(src=url_for(theme.logo), alt=config.author)
a.author-name.p-name.u-url(href=config.url)= config.author

a.post-permalink.u-url(href=full_url_for(item.path)) 永久链接

if item.excerpt
.p-summary
!= item.excerpt

h1.post-title.p-name
!= item.title
+postInfo(item)

if item.in_reply_to
.post-reply
!= __('reply_to') + ': '
a.u-in-reply-to(href=item.in_reply_to)= item.in_reply_to

.post-content.e-content
!= item.content

此处我直接照搬的主题源码。实际传参用的是 page。

需要注意的是,我没有使用到全部的属性。未来我会慢慢扩展这些内容。

1
2
3
4
5
6
mixin postInfo(item)
.post-info
time.post-date.dt-published(datetime=date_xml(item.date))
!= full_date(item.date, 'l')
if item.from && (is_home() || is_post())
a.post-from(href=item.from target="_blank" title=item.from)!= __('translated')

在文章中的 Front Matter 里添加该属性来指定目标地址:

1
2
3
---
in_reply_to: 目标地址
---

不过我的主题有些特殊:

  1. 导航栏会渲染我的头像作为 logo,我不想要文章又一次显示该头像
  2. 部分内容显示出来会导致整体页面看上去比较臃肿
  3. item.content 包含了 item.excerpt 在内。直接从 item.content 里取摘要会很麻烦,再显示一次 item.excerpt 又很奇怪

解决方案也很简单。既然这部分内容只是给机器看的,我们将其隐藏就好了。直接全部加上 display: none 样式。

进阶玩法

仅发送一部分内容

有时候,你一个文章里只有小部分内容是你想要发送的;有时候,接收方的 Webmention 显示处并没有写字数限制,你的文章会原封不动地全部显示在接收方的网页里。至少我是想要一个更灵活的发送方法,因此我写了个脚本。

效果如下:

1
2
3
4
5
6
7
{% reply 目标地址 %}

这是会被发送给目标地址的内容。目标地址是 xxx。

{% endreply %}

这是不会被发送给目标地址的内容。

首先我们需要注册标签 reply:

reply-tag.js
1
2
3
4
hexo.extend.tag.register('reply', function(args, content) {
const targetUrl = args[0];
// ...
}, {ends: true});

我们先前的主题模板写法,会默认给文章最外部包上 h-entry、发送整个文章。既然有些文章我们只想在内部包含多个小的 h-entry 回复块,我们可以在内存中添加一个属性 entry。

如果该文章我们希望整个打包送走,那就在文章的 Front Matter 里添加:

1
2
3
---
entry: true
---

并在主题模板里添加条件判断:

1
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
mixin post(item)
.post
//- 如果 entry 为 true,包裹整个 article
if item.entry === true
article.post-block.h-entry
.post-meta.p-author.h-card(style="display: none;")
img.author-avatar.u-photo(src=url_for(theme.logo), alt=config.author)
a.author-name.p-name.u-url(href=config.url)= config.author

a.post-permalink.u-url(href=full_url_for(item.path), style="display: none;") 永久链接

if item.excerpt
.p-summary(style="display: none;")
!= item.excerpt

h1.post-title.p-name
!= item.title
+postInfo(item)

if item.in_reply_to
.post-reply
!= __('reply_to') + ': '
a.u-in-reply-to(href=item.in_reply_to)= item.in_reply_to

.post-content.e-content
!= item.content
else
article.post-block
h1.post-title
!= item.title
+postInfo(item)

if item.in_reply_to
.post-reply
!= __('reply_to') + ': '
a.u-in-reply-to(href=item.in_reply_to)= item.in_reply_to

.post-content
!= item.content

由于会使用到 reply 标签的文章都不希望发送整个文章,所以在脚本内我们要将 entry 设置为 false:

reply-tag.js
1
2
3
if (this.page) {
this.page.entry = false;
}

接着验证 URL:

reply-tag.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1. 检查用户是否忘记提供 URL
if (!targetUrl) {
hexo.log.warn('Reply tag: Missing target URL');
return '<div class="reply-error">回复标签缺少目标URL</div>';
}

// 2. 确保是绝对URL
let absoluteUrl;
try {
new URL(targetUrl);
absoluteUrl = targetUrl;
} catch (e) {
try {
// 3. 如果失败,尝试作为相对路径处理
absoluteUrl = hexo.extend.helper.get('full_url_for').call({config: hexo.config}, targetUrl);
} catch (e2) {
// 4. 如果再次失败,则判定为无效 URL 并报错
hexo.log.warn(`Reply tag: Invalid URL - ${targetUrl}`);
return '<div class="reply-error">回复标签包含无效URL</div>';
}
}

渲染 Markdown 内容:

reply-tag.js
1
2
3
4
5
6
7
8
9
10
let renderedContent;
try {
renderedContent = hexo.render.renderSync({
text: content.trim(),
engine: 'markdown'
});
} catch (e) {
hexo.log.warn('Reply tag: Failed to render markdown content');
renderedContent = content; // 降级到纯文本
}

国际化支持:

reply-tag.js
1
2
3
4
5
6
7
8
9
10
11
12
13
// 获取当前语言的“回复”翻译
const currentLang = this.page ? this.page.lang : hexo.config.language;
let replyText = '回复';

// 尝试从主题语言文件获取翻译
try {
const langHelper = hexo.extend.helper.get('__');
if (langHelper) {
replyText = langHelper.call({page: {lang: currentLang}}, 'reply_to') || replyText;
}
} catch (e) {
// 使用默认文本
}

获取并处理作者信息:

reply-tag.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 获取作者信息和网站配置
const config = hexo.config;
const theme = hexo.theme.config;
const authorName = config.author || 'Anonymous';
const siteUrl = config.url || '';
// 简单构造绝对 URL,确保 logo 路径正确
let authorAvatar = '';
if (theme.logo) {
// 如果 logo 已经是绝对 URL,直接使用
if (theme.logo.startsWith('http')) {
authorAvatar = theme.logo;
} else {
// 确保路径以/开头,构造网站根路径
const logoPath = theme.logo.startsWith('/') ? theme.logo : '/' + theme.logo;
authorAvatar = (config.url || '').replace(/\/$/, '') + logoPath;
}
}

最后生成 HTML 结构:

reply-tag.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
return `
<div class="reply-block h-entry">
<!-- 作者信息(h-card) -->
<div class="post-meta p-author h-card" style="display: none">
${authorAvatar ? `<img class="author-avatar u-photo" src="${authorAvatar}" alt="${authorName}">` : ''}
<a class="author-name p-name u-url" href="${siteUrl}">${authorName}</a>
</div>

<!-- 回复内容 -->
<div class="reply-content e-content">
${renderedContent}
</div>

<!-- 回复元信息 - 通过 CSS 在列表页隐藏 -->
<div class="reply-meta">
<span class="reply-label">${replyText}:</span>
<a class="reply-target u-in-reply-to" href="${absoluteUrl}">${absoluteUrl}</a>
</div>
</div>
`;

这里我声明了一些类,需要自定义样式:

  • 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 文件执行网络请求和数据处理。不过纯动态有些劣势:

  1. 对 SEO 不友好。其他人发送的 Webmention 不会被搜索引擎索引
  2. 可能在内容出现时导致页面布局发生跳动
  3. 浏览器需要发起一次额外的网络请求
  4. 如果 Webmention.io 的 API 出现故障或者相应缓慢,就什么都加载不出来了

如果你认为这四点很重要,你可以考虑采用纯静态构建。这需要你在项目根目录的 /scripts/ 目录下创建 Hexo 脚本:

webmention-receiver.js
1
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
hexo.extend.generator.register('webmentions', async function() {
const hexo = this;
const webmentionConfig = hexo.config.webmention;
let allMentions = [];

if (webmentionConfig && webmentionConfig.enable) {
try {
// 拼接 Webmention.io API URL 并且发起请求
const newWebmentions = await fetchWebmentions(hexo.config);
// 更新本地的缓存文件
// 缓存功能非必需,可以无视掉!
allMentions = updateWebmentionsCache(hexo, newWebmentions);
} catch (error) {
hexo.log.error(error.message);
hexo.log.warn('[Webmention] Falling back to local cache due to fetch error.');
allMentions = loadWebmentionsCache(hexo);
}
} else {
allMentions = loadWebmentionsCache(hexo);
}

// 将获取到的数据注入到 Hexo 的本地变量
hexo.locals.set('all_webmentions', allMentions);
if (webmentionConfig && webmentionConfig.debug) {
hexo.log.info(`[Webmention] Injected ${allMentions.length} total mentions into hexo.locals.`);
}

return;
});

我假设了项目配置项里有这些内容:

__config.yml
1
2
3
4
5
webmention:
enable: true
domain: 域名
api_endpoint: https://webmention.io/api/mentions.jf2
token: 令牌

要想要在主题模板里渲染这些变量,有两种方法:

  1. 直接在主题模板里用 site 变量渲染

    关于 Hexo 的局部变量,建议看以下文档:

    总之,脚本里 hexo.locals.get("posts") 等同于模板里用 site.posts。

  2. 写一个辅助函数,在模板中调用、渲染返回的 HTML:

    webmention-receiver.js
    1
    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
    79
    hexo.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 可以自行修改。如果想要直接使用该脚本文件,不要忘了为里面提到的类写样式。

    接下来你要做的就是在模板里找到个顺眼的地方调用该方法:

    1
    2
    if config.webmention && config.webmention.enable
    != render_webmentions()

插件

先前说过了,我有「强迫症」。我将上述的所有功能整合成了多个 Hexo 插件,你只需要安装和配置。详情以仓库里的 README 文件为标准。

测试

Post Link

如果你想要测试,又不想要打扰到其他人,你可以向 这个地址 发送 Webmention。这是我为 Hexo-Theme-Ares 搭建的 Demo 页面。