Apply Webmention Function
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
Mirages 是一个基于 Astro 的静态博客主题,用于发布文章和独立页面。旧版 PHP 文件位于本项目之外,保持不变。
|
||||
|
||||
本文档说明 Astro 主题的常用配置、Twikoo 评论和文章 Frontmatter。
|
||||
本文档说明 Astro 主题的常用配置、Twikoo 评论、WebMention 和文章 Frontmatter。
|
||||
|
||||
## 运行项目
|
||||
|
||||
@@ -117,6 +117,22 @@ pnpm build
|
||||
|
||||
如果页面显示“评论暂时不可用”,依次检查云函数地址是否能从浏览器直接访问、CORS 配置、Twikoo 环境变量和 `envId` 是否包含多余路径或空格。
|
||||
|
||||
## WebMention 配置
|
||||
|
||||
WebMention 与 Twikoo 并行工作:Twikoo 接收本站评论,WebMention 接收其他网站对文章的回复、提及、点赞和转发。配置入口位于 `src/site.config.ts`:
|
||||
|
||||
```ts
|
||||
webmentions: {
|
||||
enabled: true,
|
||||
endpoint: 'https://webmention.example.com',
|
||||
form: true
|
||||
},
|
||||
```
|
||||
|
||||
启用后,全站 `<head>` 会声明 `${endpoint}/receive`,文章页从 `${endpoint}/get` 读取当前 canonical URL 的已批准 WebMention。`form` 控制是否在文章底部显示手动发送表单。
|
||||
|
||||
仓库在 `deploy/webmention/` 提供了基于 webmentiond、SQLite 和 Docker Compose 的自托管配置。服务器部署、SMTP 登录、反向代理及验证步骤见该目录的 `README.md`。
|
||||
|
||||
## 文章 Frontmatter
|
||||
|
||||
文章位于 `src/content/posts`,独立页面位于 `src/content/pages`。两者使用相同的字段结构:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
WEBMENTION_PORT=8080
|
||||
WEBMENTION_PUBLIC_URL=https://webmention.luming.cool
|
||||
WEBMENTION_ALLOWED_TARGET_DOMAINS=www.luming.cool,luming.cool
|
||||
WEBMENTION_ALLOWED_ORIGIN=https://www.luming.cool
|
||||
WEBMENTION_ADMIN_EMAILS=you@example.com
|
||||
|
||||
SERVER_AUTH_JWT_SECRET=replace-with-a-long-random-secret
|
||||
|
||||
MAIL_HOST=smtp.example.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USER=you@example.com
|
||||
MAIL_PASSWORD=replace-with-your-smtp-password
|
||||
MAIL_FROM=you@example.com
|
||||
MAIL_USE_STARTTLS=true
|
||||
MAIL_NO_TLS=false
|
||||
@@ -0,0 +1,4 @@
|
||||
webmention.luming.cool {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# WebMention 接收端
|
||||
|
||||
本站使用 [webmentiond](https://github.com/zerok/webmentiond) 作为自托管接收端。它负责异步验证来源页面、将结果存入 SQLite,并提供审核后台与公开查询 API。Twikoo 继续作为站内评论系统,两者互不替代。
|
||||
|
||||
## 1. 准备域名
|
||||
|
||||
将 `webmention.luming.cool` 解析到运行 Docker 的服务器。如果要换成其他域名,同时修改:
|
||||
|
||||
- `src/site.config.ts` 中的 `webmentions.endpoint`
|
||||
- `.env` 中的 `WEBMENTION_PUBLIC_URL`
|
||||
- Caddy 或 Nginx 的域名
|
||||
|
||||
接收端必须使用公网可访问的 HTTPS 地址。
|
||||
|
||||
## 2. 配置并启动
|
||||
|
||||
在服务器上进入本目录,复制示例环境变量并填写真实值:
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Compose 会先用一次性 `webmention-init` 容器将数据卷所有者设为 webmentiond 使用的 UID 1500,然后再启动接收端。若曾使用旧版 Compose,并在日志中看到 `unable to open database file`,可在部署目录执行:
|
||||
|
||||
```sh
|
||||
docker compose stop webmentiond
|
||||
docker compose run --rm --no-deps --user 0 --entrypoint /bin/sh webmentiond -c 'chown -R 1500:1500 /data'
|
||||
docker compose up -d webmentiond
|
||||
```
|
||||
|
||||
必须修改以下值:
|
||||
|
||||
- `WEBMENTION_ADMIN_EMAILS`:允许登录审核后台的邮箱,可用逗号分隔多个地址。
|
||||
- `SERVER_AUTH_JWT_SECRET`:长随机字符串,可用 `openssl rand -hex 32` 生成。
|
||||
- `MAIL_*`:SMTP 地址、账号、密码和发件人。后台使用邮件链接登录,因此 SMTP 不能省略。
|
||||
|
||||
SQLite 数据保存在 Docker 卷 `webmention-data`。更新容器不会删除它,仍建议定期备份该卷。
|
||||
|
||||
## 3. 配置反向代理
|
||||
|
||||
任选 `Caddyfile.example` 或 `nginx.conf.example` 作为反向代理参考。容器只监听服务器本机的 `127.0.0.1:8080`,TLS 由反向代理处理。
|
||||
|
||||
部署完成后访问:
|
||||
|
||||
```text
|
||||
https://webmention.luming.cool/ui/
|
||||
```
|
||||
|
||||
输入 `WEBMENTION_ADMIN_EMAILS` 中的邮箱,通过邮件链接登录。收到的 WebMention 验证成功后仍需在这里批准,批准后才会出现在博客文章底部。
|
||||
|
||||
## 4. 接口与站点集成
|
||||
|
||||
博客构建结果会在 `<head>` 中自动声明:
|
||||
|
||||
```html
|
||||
<link rel="webmention" href="https://webmention.luming.cool/receive">
|
||||
```
|
||||
|
||||
文章页通过以下接口读取当前 canonical URL 已批准的项目:
|
||||
|
||||
```text
|
||||
GET https://webmention.luming.cool/get?target=https://www.luming.cool/posts/example/
|
||||
```
|
||||
|
||||
文章底部也提供发送表单,POST `source` 和 `target` 到 `/receive`。`WEBMENTION_ALLOWED_ORIGIN` 必须与博客的浏览器 Origin 完全一致,否则读取与发送都会被 CORS 拦截。
|
||||
|
||||
## 5. 验证
|
||||
|
||||
完成接收端和博客部署后:
|
||||
|
||||
1. 查看任一文章源码,确认存在 `rel="webmention"`。
|
||||
2. 打开 `https://webmention.luming.cool/ui/`,确认可以收到登录邮件。
|
||||
3. 使用 [webmention.rocks](https://webmention.rocks/) 检查接收端标准兼容性。
|
||||
4. 从一个确实包含本站文章链接的公开页面发送 WebMention。
|
||||
5. 在管理后台批准后,刷新对应文章,确认它出现在“站外回应”。
|
||||
|
||||
`webmentiond` 按完整 target URL 精确查询。发送方若将带 `#fragment` 的链接作为 target,它会被视为与文章 canonical URL 不同的目标;建议发送时使用不带锚点的文章永久链接。
|
||||
@@ -0,0 +1,46 @@
|
||||
services:
|
||||
webmention-init:
|
||||
image: zerok/webmentiond:latest
|
||||
user: "0:0"
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
command:
|
||||
- chown -R 1500:1500 /data
|
||||
volumes:
|
||||
- webmention-data:/data
|
||||
restart: "no"
|
||||
|
||||
webmentiond:
|
||||
image: zerok/webmentiond:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
webmention-init:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "127.0.0.1:${WEBMENTION_PORT:-8080}:8080"
|
||||
environment:
|
||||
MAIL_HOST: ${MAIL_HOST}
|
||||
MAIL_PORT: ${MAIL_PORT}
|
||||
MAIL_USER: ${MAIL_USER}
|
||||
MAIL_PASSWORD: ${MAIL_PASSWORD}
|
||||
MAIL_FROM: ${MAIL_FROM}
|
||||
MAIL_USE_STARTTLS: ${MAIL_USE_STARTTLS:-true}
|
||||
MAIL_NO_TLS: ${MAIL_NO_TLS:-false}
|
||||
SERVER_AUTH_JWT_SECRET: ${SERVER_AUTH_JWT_SECRET}
|
||||
volumes:
|
||||
- webmention-data:/data
|
||||
command:
|
||||
- --addr
|
||||
- 0.0.0.0:8080
|
||||
- --public-url
|
||||
- ${WEBMENTION_PUBLIC_URL}
|
||||
- --allowed-target-domains
|
||||
- ${WEBMENTION_ALLOWED_TARGET_DOMAINS}
|
||||
- --allowed-origin
|
||||
- ${WEBMENTION_ALLOWED_ORIGIN}
|
||||
- --auth-admin-emails
|
||||
- ${WEBMENTION_ADMIN_EMAILS}
|
||||
|
||||
volumes:
|
||||
webmention-data:
|
||||
@@ -0,0 +1,13 @@
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name webmention.luming.cool;
|
||||
|
||||
# Keep the existing certificate directives from your server configuration.
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import { siteConfig } from '@/site.config';
|
||||
import { getWebmentionEndpoint } from '@/lib/webmentions';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
@@ -19,11 +20,15 @@ const {
|
||||
const pageTitle = title ? `${title} - ${siteConfig.site.title}` : siteConfig.site.title;
|
||||
const canonical = new URL(Astro.url.pathname, siteConfig.site.url);
|
||||
const imageUrl = image ? new URL(image, siteConfig.site.url) : undefined;
|
||||
const webmentionEndpoint = siteConfig.webmentions.enabled && siteConfig.webmentions.endpoint.trim()
|
||||
? getWebmentionEndpoint(siteConfig.webmentions.endpoint, 'receive')
|
||||
: undefined;
|
||||
---
|
||||
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
{webmentionEndpoint && <link rel="webmention" href={webmentionEndpoint} />}
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
---
|
||||
import { Send } from '@lucide/astro';
|
||||
import { getWebmentionEndpoint } from '@/lib/webmentions';
|
||||
import { siteConfig } from '@/site.config';
|
||||
|
||||
interface Props {
|
||||
target?: string;
|
||||
}
|
||||
|
||||
const { target = new URL(Astro.url.pathname, siteConfig.site.url).href } = Astro.props;
|
||||
const config = siteConfig.webmentions;
|
||||
const endpoint = config.endpoint.trim();
|
||||
const receiveEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'receive') : '';
|
||||
const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
|
||||
---
|
||||
|
||||
{config.enabled && endpoint && <section
|
||||
class="webmentions-shell"
|
||||
id="webmentions"
|
||||
aria-labelledby="webmentions-title"
|
||||
data-webmentions-root
|
||||
data-get-endpoint={getEndpoint}
|
||||
data-receive-endpoint={receiveEndpoint}
|
||||
data-target={target}
|
||||
data-pagefind-ignore="all"
|
||||
>
|
||||
<h2 id="webmentions-title">WebMention</h2>
|
||||
|
||||
<p class="webmentions-status" data-webmentions-status role="status">正在读取站外回应...</p>
|
||||
|
||||
<div class="webmentions-reactions" data-webmentions-reactions hidden>
|
||||
<div data-webmentions-reaction-group="like" hidden>
|
||||
<h3><span data-webmentions-like-count>0</span> 人喜欢</h3>
|
||||
<div class="webmentions-reaction-list" data-webmentions-like-list></div>
|
||||
</div>
|
||||
<div data-webmentions-reaction-group="repost" hidden>
|
||||
<h3><span data-webmentions-repost-count>0</span> 次转发</h3>
|
||||
<div class="webmentions-reaction-list" data-webmentions-repost-list></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="webmentions-discussion" data-webmentions-discussion hidden>
|
||||
<h3><span data-webmentions-discussion-count>0</span> 条回应与提及</h3>
|
||||
<ol data-webmentions-list></ol>
|
||||
</div>
|
||||
|
||||
{config.form && <form class="webmention-form" action={receiveEndpoint} method="post" data-webmention-form>
|
||||
<label for="webmention-source">发送 WebMention</label>
|
||||
<div class="webmention-form-row">
|
||||
<input id="webmention-source" name="source" type="url" inputmode="url" autocomplete="url" placeholder="https://example.com/your-post" required />
|
||||
<input name="target" type="hidden" value={target} />
|
||||
<button type="submit">
|
||||
<Send size={16} stroke-width={1.8} aria-hidden="true" />
|
||||
<span>发送</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="webmention-form-status" data-webmention-form-status role="status" aria-live="polite"></p>
|
||||
</form>}
|
||||
</section>}
|
||||
|
||||
<style>
|
||||
.webmentions-shell {
|
||||
width: min(100%, var(--article-content-width));
|
||||
margin: 4.375rem auto 0;
|
||||
}
|
||||
|
||||
.webmentions-shell > h2 {
|
||||
margin: 0 0 1.5rem;
|
||||
padding-top: 1.375rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
color: var(--color-accent-strong);
|
||||
font-family: var(--reading-font-family);
|
||||
font-size: 1.5625rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.webmentions-status,
|
||||
.webmention-form-status {
|
||||
color: var(--color-muted);
|
||||
font-size: .86rem;
|
||||
}
|
||||
|
||||
.webmentions-status {
|
||||
margin: 0;
|
||||
padding-block: .25rem 1rem;
|
||||
}
|
||||
|
||||
.webmentions-reactions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem 2.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.webmentions-reactions h3,
|
||||
.webmentions-discussion h3 {
|
||||
margin: 0 0 .75rem;
|
||||
color: var(--color-text);
|
||||
font-family: var(--reading-font-family);
|
||||
font-size: .9375rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.webmentions-reaction-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .45rem;
|
||||
}
|
||||
|
||||
:global(.webmention-reaction) {
|
||||
display: grid;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 50%;
|
||||
background: var(--color-code);
|
||||
color: var(--color-muted);
|
||||
font-family: var(--mirages-font-ui);
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:global(.webmention-reaction:hover) {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
.webmentions-discussion ol {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
:global(.webmention-item) {
|
||||
display: grid;
|
||||
grid-template-columns: 2.25rem minmax(0, 1fr);
|
||||
gap: .75rem;
|
||||
padding: 1.25rem 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
:global(.webmention-avatar) {
|
||||
display: grid;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-code);
|
||||
color: var(--color-muted);
|
||||
font-family: var(--mirages-font-ui);
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.webmention-meta) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: .25rem .65rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.webmention-author) {
|
||||
color: var(--color-text);
|
||||
font-size: .875rem;
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:global(.webmention-date),
|
||||
:global(.webmention-kind) {
|
||||
color: var(--color-muted);
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
:global(.webmention-content) {
|
||||
margin: .5rem 0 0;
|
||||
color: var(--color-text);
|
||||
font-size: .9375rem;
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
:global(.webmention-source-link) {
|
||||
display: inline-block;
|
||||
margin-top: .45rem;
|
||||
color: var(--color-accent);
|
||||
font-size: .78rem;
|
||||
}
|
||||
|
||||
.webmention-form {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.webmention-form > label {
|
||||
display: block;
|
||||
margin-bottom: .5rem;
|
||||
color: var(--color-muted);
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.webmention-form-row {
|
||||
display: flex;
|
||||
gap: .625rem;
|
||||
}
|
||||
|
||||
.webmention-form input[type='url'] {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
height: 2.5rem;
|
||||
padding: .5rem .75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.webmention-form input[type='url']:focus {
|
||||
border-color: var(--color-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.webmention-form button {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .4rem;
|
||||
min-height: 2.5rem;
|
||||
padding: .45rem .85rem;
|
||||
border: 1px solid var(--color-accent-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-contrast);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.webmention-form button:disabled {
|
||||
cursor: wait;
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
.webmention-form-status {
|
||||
min-height: 1.5em;
|
||||
margin: .4rem 0 0;
|
||||
}
|
||||
|
||||
@media (max-width: 37.5rem) {
|
||||
.webmentions-shell {
|
||||
width: 100%;
|
||||
margin-top: 3.25rem;
|
||||
}
|
||||
|
||||
.webmention-form-row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.webmention-form button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
type WebmentionRecord = {
|
||||
id: string;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
title: string;
|
||||
content: string;
|
||||
author: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type WebmentionClientState = {
|
||||
bound?: boolean;
|
||||
controller?: AbortController;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__miragesWebmentions?: WebmentionClientState;
|
||||
}
|
||||
}
|
||||
|
||||
const state = window.__miragesWebmentions ??= {};
|
||||
|
||||
const getString = (record: Record<string, unknown>, key: string) => {
|
||||
const value = record[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
};
|
||||
|
||||
const safeSource = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const plainText = (value: string) => {
|
||||
if (!value) return '';
|
||||
const parsed = new DOMParser().parseFromString(value, 'text/html');
|
||||
return (parsed.body.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 1200);
|
||||
};
|
||||
|
||||
const normalizeMention = (value: unknown): WebmentionRecord | null => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const source = safeSource(getString(record, 'source'));
|
||||
if (!source) return null;
|
||||
return {
|
||||
id: getString(record, 'id'),
|
||||
source: source.href,
|
||||
createdAt: getString(record, 'created_at'),
|
||||
title: plainText(getString(record, 'title')),
|
||||
content: plainText(getString(record, 'content')),
|
||||
author: plainText(getString(record, 'author_name')) || source.hostname,
|
||||
type: getString(record, 'type').toLowerCase()
|
||||
};
|
||||
};
|
||||
|
||||
const kindOf = (mention: WebmentionRecord) => {
|
||||
if (mention.type.includes('like')) return 'like';
|
||||
if (mention.type.includes('repost') || mention.type.includes('share')) return 'repost';
|
||||
if (mention.type.includes('reply')) return 'reply';
|
||||
return 'mention';
|
||||
};
|
||||
|
||||
const kindLabel = (kind: ReturnType<typeof kindOf>) => ({
|
||||
like: '喜欢',
|
||||
repost: '转发',
|
||||
reply: '回应',
|
||||
mention: '提及'
|
||||
})[kind];
|
||||
|
||||
const initialFor = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? '@';
|
||||
|
||||
const sourceLink = (mention: WebmentionRecord, className: string) => {
|
||||
const link = document.createElement('a');
|
||||
link.className = className;
|
||||
link.href = mention.source;
|
||||
link.target = '_blank';
|
||||
link.rel = 'nofollow ugc noopener noreferrer';
|
||||
return link;
|
||||
};
|
||||
|
||||
const renderReaction = (mention: WebmentionRecord) => {
|
||||
const link = sourceLink(mention, 'webmention-reaction');
|
||||
link.textContent = initialFor(mention.author);
|
||||
link.title = mention.author;
|
||||
link.setAttribute('aria-label', `${mention.author}:${kindLabel(kindOf(mention))}`);
|
||||
return link;
|
||||
};
|
||||
|
||||
const renderDiscussion = (mention: WebmentionRecord) => {
|
||||
const kind = kindOf(mention);
|
||||
const item = document.createElement('li');
|
||||
item.className = 'webmention-item';
|
||||
|
||||
const avatar = document.createElement('span');
|
||||
avatar.className = 'webmention-avatar';
|
||||
avatar.textContent = initialFor(mention.author);
|
||||
avatar.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const body = document.createElement('div');
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'webmention-meta';
|
||||
|
||||
const author = sourceLink(mention, 'webmention-author');
|
||||
author.textContent = mention.author;
|
||||
meta.append(author);
|
||||
|
||||
if (mention.createdAt) {
|
||||
const date = new Date(mention.createdAt);
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
const time = document.createElement('time');
|
||||
time.className = 'webmention-date';
|
||||
time.dateTime = date.toISOString();
|
||||
time.textContent = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium' }).format(date);
|
||||
meta.append(time);
|
||||
}
|
||||
}
|
||||
|
||||
const type = document.createElement('span');
|
||||
type.className = 'webmention-kind';
|
||||
type.textContent = kindLabel(kind);
|
||||
meta.append(type);
|
||||
body.append(meta);
|
||||
|
||||
const text = mention.content || mention.title;
|
||||
if (text) {
|
||||
const content = document.createElement('p');
|
||||
content.className = 'webmention-content';
|
||||
content.textContent = text;
|
||||
body.append(content);
|
||||
}
|
||||
|
||||
const source = sourceLink(mention, 'webmention-source-link');
|
||||
source.textContent = '查看来源';
|
||||
body.append(source);
|
||||
item.append(avatar, body);
|
||||
return item;
|
||||
};
|
||||
|
||||
const renderMentions = (root: HTMLElement, mentions: WebmentionRecord[]) => {
|
||||
const status = root.querySelector<HTMLElement>('[data-webmentions-status]');
|
||||
const likeGroup = root.querySelector<HTMLElement>('[data-webmentions-reaction-group="like"]');
|
||||
const repostGroup = root.querySelector<HTMLElement>('[data-webmentions-reaction-group="repost"]');
|
||||
const reactions = root.querySelector<HTMLElement>('[data-webmentions-reactions]');
|
||||
const discussion = root.querySelector<HTMLElement>('[data-webmentions-discussion]');
|
||||
const likeList = root.querySelector<HTMLElement>('[data-webmentions-like-list]');
|
||||
const repostList = root.querySelector<HTMLElement>('[data-webmentions-repost-list]');
|
||||
const list = root.querySelector<HTMLOListElement>('[data-webmentions-list]');
|
||||
if (!status || !likeGroup || !repostGroup || !reactions || !discussion || !likeList || !repostList || !list) return;
|
||||
|
||||
const likes = mentions.filter((mention) => kindOf(mention) === 'like');
|
||||
const reposts = mentions.filter((mention) => kindOf(mention) === 'repost');
|
||||
const discussions = mentions.filter((mention) => !['like', 'repost'].includes(kindOf(mention)));
|
||||
|
||||
likeList.replaceChildren(...likes.map(renderReaction));
|
||||
repostList.replaceChildren(...reposts.map(renderReaction));
|
||||
list.replaceChildren(...discussions.map(renderDiscussion));
|
||||
|
||||
const likeCount = root.querySelector<HTMLElement>('[data-webmentions-like-count]');
|
||||
const repostCount = root.querySelector<HTMLElement>('[data-webmentions-repost-count]');
|
||||
const discussionCount = root.querySelector<HTMLElement>('[data-webmentions-discussion-count]');
|
||||
if (likeCount) likeCount.textContent = String(likes.length);
|
||||
if (repostCount) repostCount.textContent = String(reposts.length);
|
||||
if (discussionCount) discussionCount.textContent = String(discussions.length);
|
||||
|
||||
likeGroup.hidden = likes.length === 0;
|
||||
repostGroup.hidden = reposts.length === 0;
|
||||
reactions.hidden = likes.length + reposts.length === 0;
|
||||
discussion.hidden = discussions.length === 0;
|
||||
status.textContent = mentions.length ? '' : '暂时还没有站外回应。';
|
||||
status.hidden = mentions.length > 0;
|
||||
};
|
||||
|
||||
const loadCurrentWebmentions = () => {
|
||||
const root = document.querySelector<HTMLElement>('[data-webmentions-root]');
|
||||
if (!root || root.dataset.webmentionsBound) return;
|
||||
root.dataset.webmentionsBound = 'true';
|
||||
const status = root.querySelector<HTMLElement>('[data-webmentions-status]');
|
||||
const getEndpoint = root.dataset.getEndpoint;
|
||||
const target = root.dataset.target;
|
||||
if (!status || !getEndpoint || !target) return;
|
||||
|
||||
state.controller?.abort();
|
||||
const controller = new AbortController();
|
||||
state.controller = controller;
|
||||
const url = new URL(getEndpoint);
|
||||
url.searchParams.set('target', target);
|
||||
|
||||
fetch(url, { headers: { Accept: 'application/json' }, signal: controller.signal })
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
})
|
||||
.then((data: unknown) => {
|
||||
if (!root.isConnected) return;
|
||||
const mentions = Array.isArray(data)
|
||||
? data.map(normalizeMention).filter((mention): mention is WebmentionRecord => mention !== null)
|
||||
: [];
|
||||
renderMentions(root, mentions);
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted || !root.isConnected) return;
|
||||
status.textContent = '站外回应暂时无法读取。';
|
||||
});
|
||||
};
|
||||
|
||||
const submitWebmention = (form: HTMLFormElement) => {
|
||||
const status = form.querySelector<HTMLElement>('[data-webmention-form-status]');
|
||||
const button = form.querySelector<HTMLButtonElement>('button[type="submit"]');
|
||||
if (!status || !button) return;
|
||||
button.disabled = true;
|
||||
status.textContent = '正在发送...';
|
||||
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
body: new FormData(form),
|
||||
headers: { Accept: 'application/json' }
|
||||
}).then((response) => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
status.textContent = '已收到,验证通过并经审核后会显示在这里。';
|
||||
const source = form.elements.namedItem('source');
|
||||
if (source instanceof HTMLInputElement) source.value = '';
|
||||
}).catch(() => {
|
||||
status.textContent = '发送失败,请稍后重试。';
|
||||
}).finally(() => {
|
||||
button.disabled = false;
|
||||
});
|
||||
};
|
||||
|
||||
if (!state.bound) {
|
||||
state.bound = true;
|
||||
document.addEventListener('submit', (event) => {
|
||||
const form = event.target;
|
||||
if (!(form instanceof HTMLFormElement) || !form.matches('[data-webmention-form]')) return;
|
||||
event.preventDefault();
|
||||
submitWebmention(form);
|
||||
});
|
||||
document.addEventListener('astro:page-load', loadCurrentWebmentions);
|
||||
document.addEventListener('astro:before-swap', () => state.controller?.abort());
|
||||
}
|
||||
|
||||
loadCurrentWebmentions();
|
||||
</script>
|
||||
@@ -0,0 +1,4 @@
|
||||
export function getWebmentionEndpoint(base: string, route: 'get' | 'receive'): string {
|
||||
const endpoint = new URL(base.endsWith('/') ? base : `${base}/`);
|
||||
return new URL(route, endpoint).href;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import TwikooComments from '@/components/TwikooComments.astro';
|
||||
import TableOfContents from '@/components/TableOfContents.astro';
|
||||
import Mermaid from '@/components/Mermaid.astro';
|
||||
import ImageLightbox from '@/components/ImageLightbox.astro';
|
||||
import Webmentions from '@/components/Webmentions.astro';
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getPublicPosts();
|
||||
return posts.map((post, index) => ({
|
||||
@@ -48,6 +49,7 @@ const dateText = `${date.year} 年 ${date.month} 月 ${date.day} 日`;
|
||||
{older ? <a class="grid min-h-24 content-center p-4 no-underline sm:border-r sm:border-line" href={getPostPath(older)}><span class="text-xs text-subtle">上一篇</span><strong class="mt-1 font-sans text-base font-normal">{older.data.title}</strong></a> : <span class="grid min-h-24 content-center p-4 sm:border-r sm:border-line"><span class="text-xs text-subtle">上一篇</span><strong class="mt-1 font-sans text-base font-normal">没有更多了</strong></span>}
|
||||
{newer ? <a class="grid min-h-24 content-center border-t border-line p-4 no-underline sm:border-t-0 sm:text-right" href={getPostPath(newer)}><span class="text-xs text-subtle">下一篇</span><strong class="mt-1 font-sans text-base font-normal">{newer.data.title}</strong></a> : <span class="grid min-h-24 content-center border-t border-line p-4 sm:border-t-0 sm:text-right"><span class="text-xs text-subtle">下一篇</span><strong class="mt-1 font-sans text-base font-normal">没有更多了</strong></span>}
|
||||
</nav>
|
||||
<Webmentions target={new URL(Astro.url.pathname, siteConfig.site.url).href} />
|
||||
<TwikooComments enabled={post.data.comments} path={Astro.url.pathname} />
|
||||
</article>
|
||||
<Mermaid enabled={post.data.mermaid} />
|
||||
|
||||
@@ -62,6 +62,11 @@ export const siteConfig: SiteConfig = {
|
||||
region: 'cn',
|
||||
lang: 'zh-CN'
|
||||
},
|
||||
webmentions: {
|
||||
enabled: true,
|
||||
endpoint: 'https://webmention.luming.cool',
|
||||
form: true
|
||||
},
|
||||
search: { provider: 'pagefind', placeholder: '搜索文章' },
|
||||
toolbarItems: [
|
||||
{ type: 'search', icon: 'search', name: '搜索文章' },
|
||||
|
||||
@@ -15,6 +15,12 @@ type SearchConfig =
|
||||
| { provider: 'none' }
|
||||
| { provider: 'pagefind'; placeholder: string };
|
||||
|
||||
export interface WebmentionConfig {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
form?: boolean;
|
||||
}
|
||||
|
||||
export type ToolbarIcon = 'search' | 'rss' | 'settings' | 'link' | 'tram-front';
|
||||
|
||||
export type ToolbarItem =
|
||||
@@ -66,6 +72,7 @@ export interface SiteConfig {
|
||||
links: LinkItem[];
|
||||
};
|
||||
comments: CommentsConfig;
|
||||
webmentions: WebmentionConfig;
|
||||
search: SearchConfig;
|
||||
pjax: {
|
||||
enabled: boolean;
|
||||
|
||||
Reference in New Issue
Block a user