Webmention 是 IndieWeb 的一个核心的开放标准,允许网站之间像社交平台一样互动:评论、点赞、转发等。重点是它去中心化、跨站点实现这些功能。

要知道,我的博客是用「老掉牙」的 Hexo 构建的。Hexo 的插件列表里,可是没有任何一款插件是和 Webmention 相关的。假设我想要在自己的网站里添加 Webmention 的发送和接收功能,只能自己写。更悲伤的是,我的「强迫症」要求我在自己开发的主题里也添加上即拿即用的 Webmention 功能。

和过去一样,我会将 Hexo 项目根目录的 _config.yml 称呼为 Hexo 配置项、将 themes/主题名/ 目录下的 _config.yml 称呼为主题配置项。

我用的是 Markdown + Pug。如果你用的是其他引擎,请自行改动代码,逻辑应当都是相同的。

该文章默认你是 Hexo 插件 & 主题开发者!如果你不是、又或者说只是想要即拿即用的方案,见 这里

接收

要想接收 Webmention,非常简单。你只需要在网站里添加这样的标签:

html
1
<link rel="me" href="https://github.com/yourusername" />

这不需要是 GitHub 链接。实际上大多数社交平台的链接都可以,像 GitLab 啦、推特啦、长毛象啦…… 我写了一个辅助函数来检测一个链接是否是社交平台的链接:

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

plaintext
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 里便可以注册该网站、获得你的接收网址:

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

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

plaintext
1
2
if theme.webmention && theme.webmention.domain
link(rel="webmention", href=`https://webmention.io/${theme.webmention.domain}/webmention`)
themes/主题/_config.ymlyaml
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 POSThttps://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 作为例子,用法是这样的:

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

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

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

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

        html
        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-cardh-adrh-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-contentp-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 类。

我个人的写法是这样的:

plaintext
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

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

plaintext
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 里添加该属性来指定目标地址:

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

不过我的主题有些特殊:

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

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

进阶玩法

仅发送一部分内容

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

效果如下:

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

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

{% endreply %}

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

首先我们需要注册标签 reply

reply-tag.JSjavascript
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 里添加:

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

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

plaintext
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.JSjavascript
1
2
3
if (this.page) {
this.page.entry = false;
}

接着验证 URL:

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

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

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

插件

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

测试

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