Metadata-Version: 2.4
Name: mkdocs-webmention
Version: 0.1.0
Summary: 在 MkDocs 站点上接收并展示 Webmention
Project-URL: Homepage, https://github.com/liWanr/mkdocs-webmention
Project-URL: Repository, https://github.com/liWanr/mkdocs-webmention
Project-URL: Issues, https://github.com/liWanr/mkdocs-webmention/issues
Author-email: liWanr <itsWanr@iCloud.com>
License-Expression: MIT
License-File: LICENSE
Keywords: indieweb,microformats,mkdocs,mkdocs-plugin,webmention
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Plugins
Classifier: Framework :: MkDocs
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Documentation
Classifier: Topic :: Software Development :: Documentation
Classifier: Topic :: Text Processing :: Markup :: HTML
Requires-Python: >=3.9
Requires-Dist: mkdocs>=1.5
Provides-Extra: dev
Requires-Dist: mkdocs-material>=9; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Description-Content-Type: text/markdown

# mkdocs-webmention

在 MkDocs 站点上**接收**并**展示** [Webmention](https://www.w3.org/TR/webmention/)：
别人在自己的博客上提到你的文章时，把这些回应显示在文章底部。

配合 [Brid.gy](https://brid.gy) 使用时，Mastodon / Bluesky 上对你文章的回复、转发、
点赞也会汇聚过来。

```
别人的文章 ──POST──► webmention.io ──抓取核对──► 收件箱
                                                │
你的页面 ◄──浏览器现场拉取（构建期零网络请求）─────────┘
```

## 它做什么、不做什么

| | |
|---|---|
| ✅ 往每个页面注入 `<link rel="webmention">` | 别人才知道该往哪发通知 |
| ✅ 在浏览器里动态拉取并渲染回应 | 构建期零网络请求，数据永远最新，改了不用重新部署 |
| ✅ 提供手动提交表单 | 大多数博客不会自动发送，这是它们唯一的通知途径 |
| ✅ 生成回复语境标记 | front matter 写一行，对方站点就把你这篇算作"回复"而不是普通"提及" |
| ❌ 不接收 | 静态站点没有服务端，收件箱交给 [webmention.io](https://webmention.io) |
| ❌ 不发送 | 你链接到别人时不会自动通知对方 |

**数据不在你的仓库里。** 构建产物里只有一个空的 `<div class="wm">`，回应全在
webmention.io 的数据库中，访客打开页面时现场拉取。好处是内容变了不用重新部署，
代价是这个服务挂了你的回应就没了 —— 想要备份的话，用你账号的 token 定期拉一份
`?domain=` 全站查询存起来。

## 快速开始

1. 用你的域名在 [webmention.io](https://webmention.io) 注册。它走 IndieAuth 登录，
   要求你的首页有一个指向 GitHub 之类身份提供方的 `rel="me"` 链接，并且对方主页
   也链回你的域名（双向验证）。

   ```html
   <a href="https://github.com/yourname" rel="me">GitHub</a>
   ```

2. 安装：

   ```bash
   pip install mkdocs-webmention
   ```

3. 在 `mkdocs.yml` 里启用。**`site_url` 必填** —— 插件靠它推算每个页面的 target，
   收件箱域名也从它的主机名推出来：

   ```yaml
   site_url: https://example.com/

   plugins:
     - webmention: {}
   ```

就这样。回应区会自动追加到每篇文章末尾。

## 配置

六个配置项，全部有默认值。下面写的都是默认值：

```yaml
plugins:
  - webmention:
      show: [replies, mentions, likes, reposts, bookmarks]   # 显示哪些类型
      facepile: [likes, reposts, bookmarks]                  # 哪些折叠成头像排
      content: text        # 回复正文：text（安全）| html（白名单清洗）
      lazy: true           # 滚到才请求，兼作隐私缓解
      exclude: []          # 哪些页面不显示
      i18n: {}             # 逐条改文案
```

**`facepile` 必须是 `show` 的子集**，多出来的会被静默剔除 —— 点赞转发没有正文，
逐条列出来是浪费空间，所以折叠成一排头像。

**`exclude` 只影响显示。** 被排除的页面 `<head>` 里照样有 `<link rel="webmention">`，
别人还是能提及它。不想显示 ≠ 不想被提及。三种写法：

```yaml
exclude:
  - index.md        # 单个文件
  - about/          # 整个目录，含子目录
  - drafts/*.md     # glob（注意 fnmatch 的 * 会跨越 /）
```

**`lazy` 有个例外**：容器如果在未激活的选项卡面板里，它没有布局盒子，
`IntersectionObserver` 永远不会触发。插件检测到这种情况会放弃 lazy 直接拉取 ——
宁可多一个请求，也不要静默失效。

### 没有的配置项

没有 `enabled`、`endpoint`、`domain`、`auto_inject`、`form`、`show_empty`、
`turnstile_sitekey`。它们要么关掉之后功能自相矛盾（藏起提交表单就没人能提交，
没人提交就永远是空的），要么根本没有第二个值可选，要么本来就不起作用
（见[关于人机验证](#关于人机验证)）。

上限、截断长度、排序方向同样写死在代码里：最多 100 条、正文超过 500 字截断并附
"查看原文"、头像排最多 12 个、最新在前。

容器摆在哪也不用配，见[自己摆放容器](#自己摆放容器)。

### 回应类型

类型名同时接受两种写法：直白的复数别名，和 webmention.io API 里的
[microformats2](https://microformats.org/wiki/h-entry) 原值。排查问题时后者能直接
和 API 响应的 `wm-property` 字段对上。

| 别名 | API 原值 | 含义 |
|---|---|---|
| `replies` | `in-reply-to` | 回复，相当于评论 |
| `mentions` | `mention-of` | 正文里顺带链接了你 |
| `likes` | `like-of` | 点赞 |
| `reposts` | `repost-of` | 转发 |
| `bookmarks` | `bookmark-of` | 收藏 |

类型由**对方页面上那个链接的 class** 决定，不是你能控制的：`u-in-reply-to` 记成回复，
`u-like-of` 记成点赞，没有任何 `u-*` class 的普通链接记成提及。

### 单页控制

```yaml
---
webmention: false   # 本页不显示回应，压过 exclude
---
```

## 回复语境：让对方把你这篇算作"回复"

上面讲的都是**收**。这一节相反：你写了一篇文章去回应别人，希望它显示在**对方**
页面的"回复"里，而不是躺在"提及"堆里。

决定权在对方的解析器手上，它看的是你页面上那个链接的 microformats2 class。
front matter 里声明一行就够了：

```yaml
---
reply: https://friend.example/posts/webmention-is-nice/
---
```

插件会在标题下方生成这样一块 —— 既给读者看（这篇在回应谁），也给解析器看。
藏起来的链接对读者没价值，也容易被当成作弊：

```html
<div class="wm-context h-entry">
  <data class="p-name" value="一篇回复"></data>
  <data class="u-url" value="https://example.com/posts/reply/"></data>
  <p class="wm-context__item">
    <span class="wm-context__label">回复</span>
    <a class="u-in-reply-to wm-context__link"
       href="https://friend.example/posts/webmention-is-nice/">friend.example/posts/webmention-is-nice</a>
  </p>
</div>
```

四种关系，值可以是单个网址或列表：

| front matter | 生成的 class | 中 / 英标签 |
|---|---|---|
| `reply` | `u-in-reply-to` | 回复 / In reply to |
| `like` | `u-like-of` | 喜欢 / Likes |
| `repost` | `u-repost-of` | 转发 / Reposted |
| `bookmark` | `u-bookmark-of` | 收藏 / Bookmarked |

标签文案走 `i18n:` 里的 `context_*` 键。

**只认这四个词，别名一个都不认** —— `comments: true` 是 Material 里极常见的
front matter，把 `comment` 也收进来的话，别人开个评论就成了声明回复对象。

**也没有 `mention`** —— 那不是来源侧的属性。对方对一个没有任何 `u-*` class 的普通
链接本来就会记成 `mention-of`，所以"提及"直接在正文里写链接即可。

几点说明：

- **`u-in-reply-to` 必须待在 `h-entry` 里才算数。** 光有 class 而没有 h-entry 包着，
  对方解析出来还是一条普通 mention —— 这块整体就是一个 h-entry，正是为此。
- **只是生成标记，不发送。** 对方站点得知道你写了这篇才会去抓：用他们页面上的
  提交表单把你的地址贴过去，或者等他们的爬虫发现。
- **必须是 `http(s)://` 开头的绝对地址。** 相对路径在对方那边的解析基准是他们的
  域名，指过去就错了；非 http(s) 的值会被跳过并告警（`--strict` 下构建失败）。
- **和 `exclude` 无关。** 一篇不显示回应的文章照样可以是对别人的回复。
- **对方显示的是标题 + 链接，没有正文摘要。** `h-entry` 只包这一小块，不包正文 ——
  包正文要在外面套一层 `div`，而 Material 有 `.md-content__inner > .tabbed-set`
  这类直接子元素选择器（你自己的样式表里往往也有），套一层就全失效了。想要摘要，
  在主题模板里给正文元素加 `class="e-content"`，并去掉这里的 `h-entry`。

## 手动提交表单

大多数博客不会自动发送 Webmention，这个表单是它们唯一的通知途径 —— 访客在自己
站点写了回应后，把链接贴进来即可。**常驻，没有开关。**

没写协议会自动补 `https://`（从地址栏复制过来经常就丢了），补齐后的地址写回输入框，
让人看见实际提交的是什么。已经带了别的协议的（`mailto:`、`javascript:`）才会提示 ——
那种没有正确答案可以替用户猜。

### 从提交到显示

```
T+0     POST 到 webmention.io，带 source（对方的页面）和 target（你的文章）
T+0.2s  201 {"status":"queued","location":…}
        ↑ 只代表收下了，还没验证。数据库里什么都没有。
T+?     webmention.io 出队，用服务器去抓 source
        ├─ 抓不到，或者页面里没有指向 target 的链接  → 丢弃
        └─ 找到了 → 解析 microformats2 定类型、取作者和正文 → 落库
T+?     访客下次打开页面 → 浏览器拉取 → 显示
```

**全程没有人参与。** 没有审核、没有通知、没有待批准列表 —— 你既不会收到提醒，
也没有地方去点"通过"。

201 之后的结果只有响应里那个 `location` 能看到，所以它会渲染成"查看处理状态"
链接。不给出这个链接的话，"验证通过后会显示在这里"就是一句无法核实的空话。

提交成功后会丢掉本页的 sessionStorage 缓存。核对要花点时间，这条现在还查不到；
但等它落库之后用户多半会回到同一个标签页刷新，缓存里那份 5 分钟的旧数据会让他
以为提交失败了。

顺带一提，**来源页面删掉之后那条回应不会自动消失**。W3C 规范把删除的责任压在发送
方（源返回 `410 Gone` 并重新通知），接收方不会自己回头去爬。把同一个链接再提交
一次可以触发重新核对。

### 技术细节

请求刻意构造成 CORS simple request（`URLSearchParams` 作为 body，不带任何自定义
请求头），因为 webmention.io 的 `OPTIONS` 返回 404，一旦触发预检请求就必然失败。

### 关于人机验证

**没做，因为做了也没用。** Webmention 端点按协议就是公开的，机器人绕开表单直接
POST 即可，在表单上装样子拦不住任何人。

真正的防线在 webmention.io 那边：它收到后会去抓取来源页面，确认里面确实链接到
本文才收下。灌水过不了这一关 —— 你没法凭空提交一条评论，必须先在自己的地盘上
写点东西并且真的链过来。

想要额外防护只能自建端点（比如 Cloudflare Worker）校验 token 后再转发。

## 自己摆放容器

容器默认追加到正文末尾。想自己决定位置（典型用途是把回应区和评论系统放进同一个
Tab 组件里），直接在模板里用下面的变量即可 —— **不需要任何配置**。插件发现模板
已经摆了一个，会自动撤掉正文末尾那个。

| 变量 | 说明 |
|---|---|
| `webmention_container` | 完整的容器标签，已转义，直接输出即可 |
| `webmention_targets` | 只要属性值，自定义标记时用 |
| `webmention_enabled` | 本页是否显示，配合 `{% if %}` |
| `webmention_i18n` | 解析后的文案字典，模板自己的 UI（如 Tab 标签）用；放进 HTML 记得过 `\|e` |

### 事件

容器内容每次变化都会在 `document` 上冒泡一个事件：

```js
document.addEventListener("webmention:loaded", function (e) {
  e.detail.state     // "loading" | "ready" | "error"
  e.detail.total     // 3
  e.detail.counts    // { "in-reply-to": 2, "like-of": 1 }
  e.detail.rendered  // 容器里到底有没有东西
})
```

判断要不要显示自己的入口时用 `rendered` 而不是 `total`：零回应时容器里仍然有提交
表单和"还没有人回应"的文案，拿 `total > 0` 去推就会把它们一起藏掉 —— 没人能提交，
也就永远是空的。

**一次装载至少广播两次**（开始拉取一次，有结果一次），所以处理函数每次都要把显示
和隐藏两个方向都写一遍，不要在 `rendered` 为假时提前 `return`。失败时 `rendered`
同样是真 —— 容器里有错误提示和重试按钮，藏起来的话那个按钮谁也点不到。

同一份结论也写在容器的 `data-wm-state` / `data-wm-rendered` / `data-wm-total` 上。
宿主脚本和插件脚本的先后顺序没有保证（主题把 `extra_javascript` 放哪、有没有开
instant navigation 都会变），监听器挂晚了错过事件时，直接读这几个属性补上：

```js
var wm = document.querySelector(".wm[data-wm-state]")
if (wm) sync({ rendered: wm.dataset.wmRendered === "1", total: +wm.dataset.wmTotal })
```

## 主题适配与无障碍

样式全部走 Material 的 CSS 自定义属性（`--md-default-fg-color` 等），明暗主题切换
自动跟随；换成别的主题时回退到自带配色，并按 `prefers-color-scheme` 分明暗两套。

所有选择器都带 `.wm ` 前缀。这不是写法偏好 —— Material 的 `.md-typeset h2`、
`.md-typeset ul li` 特异度是 (0,0,1,n)，`extra_css` 虽然加载在后，但只有特异度相同
时源码顺序才起作用，不加前缀标题字重和列表间距会被静默覆盖。`tests/test_assets.py`
里有检查盯着这条。

无障碍方面：回应区是带 `aria-labelledby` 的 `section`（会出现在地标列表里）；日期
链接带说明性的 `aria-label`；头像旁重复的作者链接对读屏隐藏且不进 tab 顺序；加载
期间容器标记 `aria-busy`；键盘焦点有独立的 `:focus-visible` 样式。另外适配了
`prefers-reduced-motion`、Windows 高对比度模式和打印样式。

## 隐私说明

回应数据由**访客的浏览器**直接向 webmention.io 请求，因此访客 IP 会暴露给该服务。
默认开启的 `lazy` 会把请求推迟到访客真的滚动到回应区域才发出。

头像的 `photo` 地址由 webmention.io 提供，多数情况下指向它自己的域名；出现指向第
三方的地址时，`<img>` 上的 `referrerpolicy="no-referrer"` 至少不会泄露来源页面。

回复正文里的图片一律不渲染（会变成追踪像素）。`content: html` 模式下的正文经
DOMParser + 标签白名单清洗 —— 上游做过一轮清洗，但把上游的清洗当作可信保证是错的。
链接强制带 `rel="nofollow ugc noopener noreferrer"`。

前端运行时除清洗器内部的 `DOMParser` 外不使用任何 `innerHTML`，所有文本一律经
`textContent` 写入，因此不存在转义遗漏导致的 XSS。测试里有检查盯着这条。

## 已知限制

- **一页最多显示 100 条回应**，没有分页。超出的部分不会显示，也没有提示。
- **数据托管在 webmention.io。** 服务不可用时回应区显示错误态和重试按钮。
- **target 按字符串精确匹配。** 别人链接时写成 `www.` 或 `http://`，webmention.io
  收得下，但插件按你的规范 URL 去查就找不到。

## 开发

```bash
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
.venv/bin/pytest                        # Python 侧：构建产物注入是否正确
npm install && npm test                 # 前端侧：清洗、渲染、拉取、提交链路
cd demo && ../.venv/bin/mkdocs serve    # 本地预览示例站
```

`demo/` 是一个最小站点，`tests/test_build.py` 会真的构建它并检查产物。

## 许可

MIT
