同步 Blog 主题变更:Webmention、互动区与样式修复
This commit is contained in:
@@ -6,3 +6,5 @@ dist/
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
.obsidian/
|
||||||
|
.pnpm-store/
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Mirages 是一个基于 Astro 的静态博客主题,用于发布文章和独立页面。旧版 PHP 文件位于本项目之外,保持不变。
|
Mirages 是一个基于 Astro 的静态博客主题,用于发布文章和独立页面。旧版 PHP 文件位于本项目之外,保持不变。
|
||||||
|
|
||||||
本文档说明 Astro 主题的常用配置、Twikoo 评论和文章 Frontmatter。
|
本文档说明 Astro 主题的常用配置、Twikoo 评论、WebMention 和文章 Frontmatter。
|
||||||
|
|
||||||
## 运行项目
|
## 运行项目
|
||||||
|
|
||||||
@@ -117,6 +117,22 @@ pnpm build
|
|||||||
|
|
||||||
如果页面显示“评论暂时不可用”,依次检查云函数地址是否能从浏览器直接访问、CORS 配置、Twikoo 环境变量和 `envId` 是否包含多余路径或空格。
|
如果页面显示“评论暂时不可用”,依次检查云函数地址是否能从浏览器直接访问、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
|
## 文章 Frontmatter
|
||||||
|
|
||||||
文章位于 `src/content/posts`,独立页面位于 `src/content/pages`。两者使用相同的字段结构:
|
文章位于 `src/content/posts`,独立页面位于 `src/content/pages`。两者使用相同的字段结构:
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
WEBMENTION_PORT=8080
|
||||||
|
WEBMENTION_PUBLIC_URL=https://webmention.example.com
|
||||||
|
WEBMENTION_ALLOWED_TARGET_DOMAINS=www.example.com,example.com
|
||||||
|
WEBMENTION_ALLOWED_ORIGIN=https://www.example.com
|
||||||
|
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.example.com {
|
||||||
|
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.example.com` 解析到运行 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.example.com/ui/
|
||||||
|
```
|
||||||
|
|
||||||
|
输入 `WEBMENTION_ADMIN_EMAILS` 中的邮箱,通过邮件链接登录。收到的 WebMention 验证成功后仍需在这里批准,批准后才会出现在博客文章底部。
|
||||||
|
|
||||||
|
## 4. 接口与站点集成
|
||||||
|
|
||||||
|
博客构建结果会在 `<head>` 中自动声明:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="webmention" href="https://webmention.example.com/receive">
|
||||||
|
```
|
||||||
|
|
||||||
|
文章页通过以下接口读取当前 canonical URL 已批准的项目:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET https://webmention.example.com/get?target=https://www.example.com/posts/example/
|
||||||
|
```
|
||||||
|
|
||||||
|
文章底部也提供发送表单,POST `source` 和 `target` 到 `/receive`。`WEBMENTION_ALLOWED_ORIGIN` 必须与博客的浏览器 Origin 完全一致,否则读取与发送都会被 CORS 拦截。
|
||||||
|
|
||||||
|
## 5. 验证
|
||||||
|
|
||||||
|
完成接收端和博客部署后:
|
||||||
|
|
||||||
|
1. 查看任一文章源码,确认存在 `rel="webmention"`。
|
||||||
|
2. 打开 `https://webmention.example.com/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.example.com;
|
||||||
|
|
||||||
|
# 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
---
|
||||||
|
import TwikooComments from '@/components/TwikooComments.astro';
|
||||||
|
import Webmentions from '@/components/Webmentions.astro';
|
||||||
|
import { siteConfig } from '@/site.config';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
commentsEnabled: boolean;
|
||||||
|
webmentionEnabled: boolean;
|
||||||
|
path: string;
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { commentsEnabled, webmentionEnabled, path, target } = Astro.props;
|
||||||
|
const showWebmention = webmentionEnabled && siteConfig.webmentions.enabled && Boolean(siteConfig.webmentions.endpoint.trim());
|
||||||
|
const commentsConfigured = siteConfig.comments.provider === 'twikoo';
|
||||||
|
const showComments = commentsEnabled && commentsConfigured;
|
||||||
|
const showTabs = showWebmention && showComments;
|
||||||
|
---
|
||||||
|
|
||||||
|
{(showWebmention || showComments) && <section
|
||||||
|
class="engagement-shell"
|
||||||
|
aria-label="文章互动"
|
||||||
|
data-engagement-tabs={showTabs ? '' : undefined}
|
||||||
|
data-pagefind-ignore="all"
|
||||||
|
>
|
||||||
|
{showTabs ? <div class="engagement-tablist" role="tablist" aria-label="文章互动方式">
|
||||||
|
<button
|
||||||
|
class="engagement-tab"
|
||||||
|
id="engagement-tab-comments"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="engagement-panel-comments"
|
||||||
|
aria-selected="true"
|
||||||
|
data-engagement-tab="comments"
|
||||||
|
>评论</button>
|
||||||
|
<button
|
||||||
|
class="engagement-tab"
|
||||||
|
id="engagement-tab-webmentions"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="engagement-panel-webmentions"
|
||||||
|
aria-selected="false"
|
||||||
|
tabindex={-1}
|
||||||
|
data-engagement-tab="webmentions"
|
||||||
|
>Webmentions</button>
|
||||||
|
</div> : <h2 class="engagement-title">{showWebmention ? 'Webmention' : '评论'}</h2>}
|
||||||
|
|
||||||
|
{showWebmention && <div
|
||||||
|
class="engagement-panel"
|
||||||
|
id={showTabs ? 'engagement-panel-webmentions' : undefined}
|
||||||
|
role={showTabs ? 'tabpanel' : undefined}
|
||||||
|
aria-labelledby={showTabs ? 'engagement-tab-webmentions' : undefined}
|
||||||
|
data-engagement-panel={showTabs ? 'webmentions' : undefined}
|
||||||
|
hidden={showTabs}
|
||||||
|
>
|
||||||
|
<Webmentions target={target} />
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{showComments && <div
|
||||||
|
class="engagement-panel"
|
||||||
|
id={showTabs ? 'engagement-panel-comments' : undefined}
|
||||||
|
role={showTabs ? 'tabpanel' : undefined}
|
||||||
|
aria-labelledby={showTabs ? 'engagement-tab-comments' : undefined}
|
||||||
|
data-engagement-panel={showTabs ? 'comments' : undefined}
|
||||||
|
>
|
||||||
|
<TwikooComments enabled={commentsEnabled} path={path} />
|
||||||
|
</div>}
|
||||||
|
</section>}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.engagement-shell {
|
||||||
|
width: min(100%, var(--article-content-width));
|
||||||
|
margin: 4.375rem auto 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--reading-font-family);
|
||||||
|
font-size: .94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-tablist {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
min-height: 3.25rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-title {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1.5rem 0 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--reading-font-family);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-tab {
|
||||||
|
position: relative;
|
||||||
|
align-self: stretch;
|
||||||
|
padding: 0 .125rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-tab::after {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
content: '';
|
||||||
|
opacity: 0;
|
||||||
|
transform: scaleX(.5);
|
||||||
|
transition: opacity 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-tab:is(:hover, [aria-selected='true']) {
|
||||||
|
color: var(--color-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-tab[aria-selected='true']::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-tab:focus-visible {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
outline: 1px solid var(--color-accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-panel {
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-panel[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 37.5rem) {
|
||||||
|
.engagement-shell {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 3.25rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-tablist {
|
||||||
|
min-height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-title {
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
font-size: 1.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.engagement-panel {
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
type EngagementTabName = 'webmentions' | 'comments';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__eidolonEngagementTabsBound?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabNameFromHash = (): EngagementTabName | null => {
|
||||||
|
if (window.location.hash === '#comments') return 'comments';
|
||||||
|
if (window.location.hash === '#webmentions') return 'webmentions';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activateEngagementTab = (
|
||||||
|
root: HTMLElement,
|
||||||
|
name: EngagementTabName,
|
||||||
|
focus = false
|
||||||
|
) => {
|
||||||
|
const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-engagement-tab]'));
|
||||||
|
const panels = Array.from(root.querySelectorAll<HTMLElement>('[data-engagement-panel]'));
|
||||||
|
const activeTab = tabs.find((tab) => tab.dataset.engagementTab === name);
|
||||||
|
if (!activeTab) return;
|
||||||
|
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const active = tab === activeTab;
|
||||||
|
tab.setAttribute('aria-selected', String(active));
|
||||||
|
tab.tabIndex = active ? 0 : -1;
|
||||||
|
}
|
||||||
|
for (const panel of panels) {
|
||||||
|
panel.hidden = panel.dataset.engagementPanel !== name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focus) activeTab.focus();
|
||||||
|
root.dispatchEvent(new CustomEvent('engagement:tab-change', {
|
||||||
|
bubbles: true,
|
||||||
|
detail: { tab: name }
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindEngagementTabs = () => {
|
||||||
|
const root = document.querySelector<HTMLElement>('[data-engagement-tabs]');
|
||||||
|
if (!root || root.dataset.engagementTabsBound) return;
|
||||||
|
root.dataset.engagementTabsBound = 'true';
|
||||||
|
|
||||||
|
const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-engagement-tab]'));
|
||||||
|
const defaultTab = tabs.find((tab) => tab.dataset.engagementTab === 'comments')?.dataset.engagementTab;
|
||||||
|
const initialTab = tabNameFromHash() ?? (defaultTab as EngagementTabName | undefined);
|
||||||
|
if (initialTab) activateEngagementTab(root, initialTab);
|
||||||
|
|
||||||
|
for (const tab of tabs) {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
activateEngagementTab(root, tab.dataset.engagementTab as EngagementTabName);
|
||||||
|
});
|
||||||
|
tab.addEventListener('keydown', (event) => {
|
||||||
|
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const currentIndex = tabs.indexOf(tab);
|
||||||
|
const nextIndex = event.key === 'Home'
|
||||||
|
? 0
|
||||||
|
: event.key === 'End'
|
||||||
|
? tabs.length - 1
|
||||||
|
: (currentIndex + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
|
||||||
|
const nextName = tabs[nextIndex]?.dataset.engagementTab as EngagementTabName | undefined;
|
||||||
|
if (nextName) activateEngagementTab(root, nextName, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!window.__eidolonEngagementTabsBound) {
|
||||||
|
window.__eidolonEngagementTabsBound = true;
|
||||||
|
document.addEventListener('astro:page-load', bindEngagementTabs);
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
const root = document.querySelector<HTMLElement>('[data-engagement-tabs]');
|
||||||
|
const tab = tabNameFromHash();
|
||||||
|
if (root && tab) activateEngagementTab(root, tab);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bindEngagementTabs();
|
||||||
|
</script>
|
||||||
@@ -24,13 +24,80 @@
|
|||||||
const items = images.map((item) => ({
|
const items = images.map((item) => ({
|
||||||
...getSize(item),
|
...getSize(item),
|
||||||
src: item.dataset.originalSrc || item.currentSrc || item.src,
|
src: item.dataset.originalSrc || item.currentSrc || item.src,
|
||||||
alt: item.alt
|
msrc: item.currentSrc || item.src,
|
||||||
|
alt: item.alt,
|
||||||
|
element: item
|
||||||
}));
|
}));
|
||||||
|
|
||||||
lightboxPromise ??= import('photoswipe/lightbox').then(({ default: PhotoSwipeLightbox }) => {
|
lightboxPromise ??= import('photoswipe/lightbox').then(({ default: PhotoSwipeLightbox }) => {
|
||||||
const lightbox = new PhotoSwipeLightbox({
|
const lightbox = new PhotoSwipeLightbox({
|
||||||
pswpModule: () => import('photoswipe'),
|
pswpModule: () => import('photoswipe'),
|
||||||
bgClickAction: 'close'
|
bgClickAction: 'close',
|
||||||
|
wheelToZoom: true,
|
||||||
|
allowPanToNext: true,
|
||||||
|
pinchToClose: true,
|
||||||
|
showHideAnimationType: 'zoom',
|
||||||
|
showAnimationDuration: 360,
|
||||||
|
hideAnimationDuration: 300,
|
||||||
|
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||||
|
paddingFn: (viewportSize) => ({
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: viewportSize.x < 640 ? 78 : 92,
|
||||||
|
left: 16
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
lightbox.on('uiRegister', () => {
|
||||||
|
lightbox.pswp?.ui?.registerElement({
|
||||||
|
name: 'thumbnails',
|
||||||
|
className: 'pswp__thumbnails',
|
||||||
|
appendTo: 'root',
|
||||||
|
order: 20,
|
||||||
|
onInit: (element, pswp) => {
|
||||||
|
element.setAttribute('role', 'toolbar');
|
||||||
|
element.setAttribute('aria-label', '图片预览');
|
||||||
|
|
||||||
|
const buttons = Array.from({ length: pswp.getNumItems() }, (_, itemIndex) => {
|
||||||
|
const item = pswp.getItemData(itemIndex);
|
||||||
|
const button = document.createElement('button');
|
||||||
|
const thumbnail = document.createElement('img');
|
||||||
|
const label = item.alt ? `查看图片:${item.alt}` : `查看第 ${itemIndex + 1} 张图片`;
|
||||||
|
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'pswp__thumbnail';
|
||||||
|
button.title = label;
|
||||||
|
button.setAttribute('aria-label', label);
|
||||||
|
button.addEventListener('click', () => pswp.goTo(itemIndex));
|
||||||
|
|
||||||
|
thumbnail.src = item.msrc || item.src || '';
|
||||||
|
thumbnail.alt = '';
|
||||||
|
thumbnail.draggable = false;
|
||||||
|
button.append(thumbnail);
|
||||||
|
element.append(button);
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCurrentThumbnail = () => {
|
||||||
|
buttons.forEach((button, itemIndex) => {
|
||||||
|
const isCurrent = itemIndex === pswp.currIndex;
|
||||||
|
button.classList.toggle('is-current', isCurrent);
|
||||||
|
if (isCurrent) {
|
||||||
|
button.setAttribute('aria-current', 'true');
|
||||||
|
element.scrollTo({
|
||||||
|
behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
|
||||||
|
left: button.offsetLeft - (element.clientWidth - button.clientWidth) / 2
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
button.removeAttribute('aria-current');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
pswp.on('change', updateCurrentThumbnail);
|
||||||
|
updateCurrentThumbnail();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
lightbox.init();
|
lightbox.init();
|
||||||
return lightbox;
|
return lightbox;
|
||||||
@@ -85,4 +152,83 @@
|
|||||||
<style is:global>
|
<style is:global>
|
||||||
.prose img.lightbox-image { cursor: zoom-in; }
|
.prose img.lightbox-image { cursor: zoom-in; }
|
||||||
.prose img.lightbox-image:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 4px; }
|
.prose img.lightbox-image:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 4px; }
|
||||||
|
|
||||||
|
.pswp__thumbnails {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
right: 64px;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 64px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
width: max-content;
|
||||||
|
max-width: calc(100% - 128px);
|
||||||
|
height: 64px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
scrollbar-width: none;
|
||||||
|
touch-action: pan-x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pswp__thumbnails::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.pswp__thumbnail {
|
||||||
|
flex: 0 0 64px;
|
||||||
|
width: 64px;
|
||||||
|
height: 52px;
|
||||||
|
padding: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgb(20 20 20 / 72%);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: .58;
|
||||||
|
transition: border-color 160ms ease, opacity 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pswp__thumbnail:hover { opacity: .88; }
|
||||||
|
|
||||||
|
.pswp__thumbnail.is-current {
|
||||||
|
border-color: #fff;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pswp__thumbnail:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pswp__thumbnail img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.pswp__thumbnails {
|
||||||
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
left: 8px;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: calc(100% - 16px);
|
||||||
|
height: 56px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pswp__thumbnail {
|
||||||
|
flex-basis: 54px;
|
||||||
|
width: 54px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pswp__thumbnail { transition: none; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ const { overlay = false } = Astro.props;
|
|||||||
{sidebarToolbarItems.map((item) => (
|
{sidebarToolbarItems.map((item) => (
|
||||||
<div class="static flex items-center" data-toolbar-item={item.type} data-toolbar-name={item.name}>
|
<div class="static flex items-center" data-toolbar-item={item.type} data-toolbar-name={item.name}>
|
||||||
{item.type === 'settings' && <ThemeSwitcher placement="up" />}
|
{item.type === 'settings' && <ThemeSwitcher placement="up" />}
|
||||||
{item.type === 'link' && <a class="grid size-[2.5rem] shrink-0 place-items-center text-foreground no-underline hover:bg-raised" href={item.href} target={item.external ? '_blank' : undefined} rel={item.external ? 'noreferrer' : undefined} aria-label={item.name} title={item.name}><ToolbarIcon icon={item.icon} /><span class="sr-only">{item.name}</span></a>}
|
{item.type === 'link' && <a class="grid size-[2.5rem] shrink-0 place-items-center rounded-[4px] text-foreground no-underline transition-colors duration-150 hover:bg-mirages-accent/10 hover:text-mirages-accent" href={item.href} target={item.external ? '_blank' : undefined} rel={item.external ? 'noreferrer' : undefined} aria-label={item.name} title={item.name}><ToolbarIcon icon={item.icon} /><span class="sr-only">{item.name}</span></a>}
|
||||||
{item.type === 'rss' && <button class="relative grid size-[2.5rem] shrink-0 cursor-pointer place-items-center border-0 bg-transparent p-0 text-foreground hover:bg-raised" type="button" aria-label={item.name} title={item.name} data-rss-copy data-rss-url={item.href}><span class="grid place-items-center leading-none" data-rss-default-icon><ToolbarIcon icon={item.icon} /></span><Check class="hidden" aria-hidden="true" size="1em" data-rss-success-icon /><span class="pointer-events-none absolute bottom-[calc(100%+0.5rem)] left-1/2 hidden -translate-x-1/2 whitespace-nowrap rounded-[4px] bg-foreground px-2 py-1 text-xs text-page shadow-mirages" role="status" data-rss-feedback>已复制</span><span class="sr-only">{item.name}</span></button>}
|
{item.type === 'rss' && <button class="relative grid size-[2.5rem] shrink-0 cursor-pointer place-items-center rounded-[4px] border-0 bg-transparent p-0 text-foreground transition-colors duration-150 hover:bg-mirages-accent/10 hover:text-mirages-accent" type="button" aria-label={item.name} title={item.name} data-rss-copy data-rss-url={item.href}><span class="grid place-items-center leading-none" data-rss-default-icon><ToolbarIcon icon={item.icon} /></span><Check class="hidden" aria-hidden="true" size="1em" data-rss-success-icon /><span class="pointer-events-none absolute bottom-[calc(100%+0.5rem)] left-1/2 hidden -translate-x-1/2 whitespace-nowrap rounded-[4px] bg-foreground px-2 py-1 text-xs text-page shadow-mirages" role="status" data-rss-feedback>已复制</span><span class="sr-only">{item.name}</span></button>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -176,6 +176,16 @@ const { overlay = false } = Astro.props;
|
|||||||
background: color-mix(in srgb, currentColor 16%, transparent);
|
background: color-mix(in srgb, currentColor 16%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-sidebar-toolbar] [data-toolbar-item] > details > summary {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar-toolbar] [data-toolbar-item] > details > summary:is(:hover, :focus-visible) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.navbar-over-banner [data-navbar-tools] .navbar-search-container > .search-form {
|
.navbar-over-banner [data-navbar-tools] .navbar-search-container > .search-form {
|
||||||
border-color: color-mix(in srgb, var(--color-text) 50%, transparent);
|
border-color: color-mix(in srgb, var(--color-text) 50%, transparent);
|
||||||
background: color-mix(in srgb, var(--color-bg) 92%, transparent);
|
background: color-mix(in srgb, var(--color-bg) 92%, transparent);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { siteConfig } from '@/site.config';
|
import { siteConfig } from '@/site.config';
|
||||||
|
import { getWebmentionEndpoint } from '@/lib/webmentions';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -7,6 +8,7 @@ interface Props {
|
|||||||
image?: string;
|
image?: string;
|
||||||
type?: 'website' | 'article';
|
type?: 'website' | 'article';
|
||||||
noindex?: boolean;
|
noindex?: boolean;
|
||||||
|
webmention?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -14,16 +16,21 @@ const {
|
|||||||
description = siteConfig.site.description,
|
description = siteConfig.site.description,
|
||||||
image,
|
image,
|
||||||
type = 'website',
|
type = 'website',
|
||||||
noindex = false
|
noindex = false,
|
||||||
|
webmention = false
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
const pageTitle = title ? `${title} - ${siteConfig.site.title}` : siteConfig.site.title;
|
const pageTitle = title ? `${title} - ${siteConfig.site.title}` : siteConfig.site.title;
|
||||||
const canonical = new URL(Astro.url.pathname, siteConfig.site.url);
|
const canonical = new URL(Astro.url.pathname, siteConfig.site.url);
|
||||||
const imageUrl = image ? new URL(image, siteConfig.site.url) : undefined;
|
const imageUrl = image ? new URL(image, siteConfig.site.url) : undefined;
|
||||||
|
const webmentionEndpoint = webmention && siteConfig.webmentions.enabled && siteConfig.webmentions.endpoint.trim()
|
||||||
|
? getWebmentionEndpoint(siteConfig.webmentions.endpoint, 'receive')
|
||||||
|
: undefined;
|
||||||
---
|
---
|
||||||
|
|
||||||
<title>{pageTitle}</title>
|
<title>{pageTitle}</title>
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<link rel="canonical" href={canonical} />
|
<link rel="canonical" href={canonical} />
|
||||||
|
{webmentionEndpoint && <link rel="webmention" href={webmentionEndpoint} />}
|
||||||
<meta property="og:type" content={type} />
|
<meta property="og:type" content={type} />
|
||||||
<meta property="og:title" content={pageTitle} />
|
<meta property="og:title" content={pageTitle} />
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
|
|||||||
@@ -39,6 +39,8 @@
|
|||||||
const initCurrentComments = () => {
|
const initCurrentComments = () => {
|
||||||
const root = document.querySelector('#twikoo-comments');
|
const root = document.querySelector('#twikoo-comments');
|
||||||
if (!(root instanceof HTMLElement) || root.dataset.twikooBound) return;
|
if (!(root instanceof HTMLElement) || root.dataset.twikooBound) return;
|
||||||
|
const panel = root.closest('[data-engagement-panel]');
|
||||||
|
if (panel instanceof HTMLElement && panel.hidden) return;
|
||||||
let config;
|
let config;
|
||||||
try {
|
try {
|
||||||
config = JSON.parse(root.dataset.twikooConfig ?? '{}');
|
config = JSON.parse(root.dataset.twikooConfig ?? '{}');
|
||||||
@@ -62,6 +64,7 @@
|
|||||||
|
|
||||||
initCurrentComments();
|
initCurrentComments();
|
||||||
document.addEventListener('astro:page-load', initCurrentComments);
|
document.addEventListener('astro:page-load', initCurrentComments);
|
||||||
|
document.addEventListener('engagement:tab-change', initCurrentComments);
|
||||||
document.addEventListener('astro:before-swap', () => {
|
document.addEventListener('astro:before-swap', () => {
|
||||||
document.querySelector('#twikoo-comments')?.replaceChildren();
|
document.querySelector('#twikoo-comments')?.replaceChildren();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ const config = comments.provider === 'twikoo' && comments.envId.trim() ? {
|
|||||||
} : null;
|
} : null;
|
||||||
---
|
---
|
||||||
{enabled && config && <section class="comments-shell" id="comments" aria-labelledby="comments-title">
|
{enabled && config && <section class="comments-shell" id="comments" aria-labelledby="comments-title">
|
||||||
<h2 id="comments-title">评论</h2>
|
<h2 class="sr-only" id="comments-title">评论</h2>
|
||||||
<div id="twikoo-comments" data-twikoo-config={JSON.stringify(config)} data-twikoo-state="idle"></div>
|
<div id="twikoo-comments" data-twikoo-config={JSON.stringify(config)} data-twikoo-state="idle"></div>
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
{enabled && !config && <section class="comments-shell comments-preview" id="comments" aria-labelledby="comments-title" data-pagefind-ignore="all">
|
{enabled && !config && <section class="comments-shell comments-preview" id="comments" aria-labelledby="comments-title" data-pagefind-ignore="all">
|
||||||
<h2 id="comments-title">评论</h2>
|
<h2 class="sr-only" id="comments-title">评论</h2>
|
||||||
<div class="twikoo-preview" aria-label="Twikoo 评论预览">
|
<div class="twikoo-preview" aria-label="Twikoo 评论预览">
|
||||||
<p class="twikoo-preview-status">Twikoo 尚未配置</p>
|
<p class="twikoo-preview-status">Twikoo 尚未配置</p>
|
||||||
<div class="twikoo-preview-fields" aria-hidden="true">
|
<div class="twikoo-preview-fields" aria-hidden="true">
|
||||||
@@ -30,9 +30,8 @@ const config = comments.provider === 'twikoo' && comments.envId.trim() ? {
|
|||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
<style is:global>
|
<style is:global>
|
||||||
#comments { width: min(100%, var(--article-content-width)); margin: 4.375rem auto 0; }
|
#comments { width: 100%; margin: 0; font-family: var(--reading-font-family); }
|
||||||
#comments > 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; }
|
#twikoo-comments, #comments > #twikoo { color: var(--color-text); font-family: var(--reading-font-family); font-size: .94rem; }
|
||||||
#twikoo-comments, #comments > #twikoo { color: var(--color-text); font-size: .94rem; }
|
|
||||||
#twikoo-comments .tk-comments-title, #comments > #twikoo .tk-comments-title { margin: 0 0 18px; color: var(--color-text); font-family: var(--font-serif); font-size: 1.1rem; font-weight: 600; }
|
#twikoo-comments .tk-comments-title, #comments > #twikoo .tk-comments-title { margin: 0 0 18px; color: var(--color-text); font-family: var(--font-serif); font-size: 1.1rem; font-weight: 600; }
|
||||||
#twikoo-comments .tk-comment, #comments > #twikoo .tk-comment { padding: 1.375rem 0; border-bottom: 1px solid var(--color-border); }
|
#twikoo-comments .tk-comment, #comments > #twikoo .tk-comment { padding: 1.375rem 0; border-bottom: 1px solid var(--color-border); }
|
||||||
#twikoo-comments .tk-content, #twikoo-comments .tk-comment-content, #comments > #twikoo .tk-content, #comments > #twikoo .tk-comment-content { margin-top: .5rem; color: var(--color-text); line-height: 1.75; overflow-wrap: anywhere; }
|
#twikoo-comments .tk-content, #twikoo-comments .tk-comment-content, #comments > #twikoo .tk-content, #comments > #twikoo .tk-comment-content { margin-top: .5rem; color: var(--color-text); line-height: 1.75; overflow-wrap: anywhere; }
|
||||||
@@ -94,7 +93,7 @@ const config = comments.provider === 'twikoo' && comments.envId.trim() ? {
|
|||||||
#comments > #twikoo .tk-submit-action-icon { color: var(--color-muted) !important; }
|
#comments > #twikoo .tk-submit-action-icon { color: var(--color-muted) !important; }
|
||||||
#comments > #twikoo .OwO-logo { color: var(--color-muted) !important; }
|
#comments > #twikoo .OwO-logo { color: var(--color-muted) !important; }
|
||||||
#comments > #twikoo .OwO-body { border: 1px solid var(--color-border) !important; border-radius: var(--radius-sm) !important; background: var(--color-surface) !important; color: var(--color-text) !important; box-shadow: var(--shadow-soft) !important; }
|
#comments > #twikoo .OwO-body { border: 1px solid var(--color-border) !important; border-radius: var(--radius-sm) !important; background: var(--color-surface) !important; color: var(--color-text) !important; box-shadow: var(--shadow-soft) !important; }
|
||||||
#comments > #twikoo .tk-send { min-width: 7rem !important; height: 2.25rem !important; border: 0 !important; border-radius: var(--radius-sm) !important; background: var(--color-accent) !important; color: var(--color-accent-contrast) !important; font-weight: 500 !important; }
|
#comments > #twikoo .tk-send { min-width: 7rem !important; height: 2.25rem !important; border: 0 !important; border-radius: var(--radius-sm) !important; background: var(--color-accent) !important; color: var(--color-accent-contrast) !important; font-family: var(--reading-font-family) !important; font-size: .875rem !important; font-weight: 500 !important; }
|
||||||
#comments > #twikoo .tk-send.is-disabled { opacity: .55 !important; }
|
#comments > #twikoo .tk-send.is-disabled { opacity: .55 !important; }
|
||||||
#comments > #twikoo .tk-preview { border: 1px solid var(--color-border) !important; border-radius: var(--radius-sm) !important; background: transparent !important; color: var(--color-muted) !important; }
|
#comments > #twikoo .tk-preview { border: 1px solid var(--color-border) !important; border-radius: var(--radius-sm) !important; background: transparent !important; color: var(--color-muted) !important; }
|
||||||
#comments > #twikoo .tk-comments-container { margin-top: 3rem !important; }
|
#comments > #twikoo .tk-comments-container { margin-top: 3rem !important; }
|
||||||
@@ -169,5 +168,5 @@ const config = comments.provider === 'twikoo' && comments.envId.trim() ? {
|
|||||||
#comments > #twikoo .tk-replies .tk-comment > .tk-avatar { flex-basis: 1.75rem !important; width: 1.75rem !important; height: 1.75rem !important; margin-right: .5rem !important; }
|
#comments > #twikoo .tk-replies .tk-comment > .tk-avatar { flex-basis: 1.75rem !important; width: 1.75rem !important; height: 1.75rem !important; margin-right: .5rem !important; }
|
||||||
#comments > #twikoo .OwO .OwO-body { right: 0 !important; left: auto !important; width: min(18rem, 100vw - 2rem) !important; }
|
#comments > #twikoo .OwO .OwO-body { right: 0 !important; left: auto !important; width: min(18rem, 100vw - 2rem) !important; }
|
||||||
}
|
}
|
||||||
@media (max-width: 37.5rem) { #comments { margin-top: 3.25rem; } #twikoo-comments .tk-avatar, #twikoo-comments .tk-comment-avatar { width: 1.875rem; height: 1.875rem; } }
|
@media (max-width: 37.5rem) { #twikoo-comments .tk-avatar, #twikoo-comments .tk-comment-avatar { width: 1.875rem; height: 1.875rem; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,844 @@
|
|||||||
|
---
|
||||||
|
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-site-origin={new URL(siteConfig.site.url).origin}
|
||||||
|
data-pagefind-ignore="all"
|
||||||
|
>
|
||||||
|
<h2 class="sr-only" id="webmentions-title">Webmention</h2>
|
||||||
|
|
||||||
|
<div class="webmentions-loading" data-webmentions-loading role="status" aria-label="正在读取 Webmention">
|
||||||
|
<svg class="webmentions-loading-spinner" viewBox="25 25 50 50" aria-hidden="true">
|
||||||
|
<circle class="webmentions-loading-path" cx="50" cy="50" r="20" fill="none"></circle>
|
||||||
|
</svg>
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p class="webmentions-status" data-webmentions-status role="status" hidden></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">发送</button>
|
||||||
|
</div>
|
||||||
|
<p class="webmention-form-status" data-webmention-form-status role="status" aria-live="polite"></p>
|
||||||
|
</form>}
|
||||||
|
</section>}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.webmentions-shell {
|
||||||
|
font-family: var(--reading-font-family);
|
||||||
|
font-size: .94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webmentions-status,
|
||||||
|
.webmention-form-status {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: .86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webmentions-loading {
|
||||||
|
display: flex;
|
||||||
|
min-height: 5rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: .625rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: .86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webmentions-loading[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webmentions-loading-spinner {
|
||||||
|
width: 2.625rem;
|
||||||
|
height: 2.625rem;
|
||||||
|
animation: webmentions-loading-rotate 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webmentions-loading-path {
|
||||||
|
animation: webmentions-loading-dash 1.5s ease-in-out infinite;
|
||||||
|
stroke: var(--color-accent);
|
||||||
|
stroke-dasharray: 90, 150;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webmentions-status {
|
||||||
|
margin: 0;
|
||||||
|
padding-block: .25rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes webmentions-loading-rotate {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes webmentions-loading-dash {
|
||||||
|
0% { stroke-dasharray: 1, 200; stroke-dashoffset: 0; }
|
||||||
|
50% { stroke-dasharray: 90, 150; stroke-dashoffset: -40px; }
|
||||||
|
100% { stroke-dasharray: 90, 150; stroke-dashoffset: -120px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.webmentions-loading-spinner,
|
||||||
|
.webmentions-loading-path {
|
||||||
|
animation-duration: 3s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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.125rem;
|
||||||
|
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-site-icon) {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.webmention-site-icon img),
|
||||||
|
:global(.webmention-icon-fallback) {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.webmention-site-icon img) {
|
||||||
|
z-index: 1;
|
||||||
|
padding: .3rem;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--color-surface);
|
||||||
|
object-fit: contain;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.webmention-site-icon[data-icon-loaded='true'] img) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.webmention-icon-fallback) {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
: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: .8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.webmention-host) {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: .75rem;
|
||||||
|
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.125rem;
|
||||||
|
padding: .438rem .525rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .8125rem;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
min-width: 7rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 0 .875rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-accent-contrast);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webmention-form-row {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webmention-form button {
|
||||||
|
align-self: flex-end;
|
||||||
|
min-width: 5.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
type WebmentionRecord = {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
hostname: string;
|
||||||
|
createdAt: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
author: string;
|
||||||
|
siteName: string;
|
||||||
|
favicons: string[];
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WebmentionClientState = {
|
||||||
|
bound?: boolean;
|
||||||
|
controller?: AbortController;
|
||||||
|
metadataCache?: Map<string, Promise<SourceMetadata | null>>;
|
||||||
|
observer?: IntersectionObserver;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SourceMetadata = {
|
||||||
|
title: string;
|
||||||
|
siteName: string;
|
||||||
|
favicon: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__miragesWebmentions?: WebmentionClientState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = window.__miragesWebmentions ??= {};
|
||||||
|
const metadataCache = state.metadataCache ??= new Map<string, Promise<SourceMetadata | null>>();
|
||||||
|
|
||||||
|
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 safeAsset = (value: string, base: URL) => {
|
||||||
|
if (!value) return '';
|
||||||
|
try {
|
||||||
|
const url = new URL(value, base);
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 objectValue = (value: unknown) => (
|
||||||
|
value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
const author = plainText(getString(record, 'author_name'));
|
||||||
|
const siteName = plainText(getString(record, 'site_name'))
|
||||||
|
|| plainText(getString(record, 'site_title'))
|
||||||
|
|| author
|
||||||
|
|| source.hostname;
|
||||||
|
const favicon = safeAsset(
|
||||||
|
getString(record, 'favicon') || getString(record, 'site_icon') || getString(record, 'icon'),
|
||||||
|
source
|
||||||
|
);
|
||||||
|
const favicons = Array.from(new Set([
|
||||||
|
favicon,
|
||||||
|
`https://favicon.im/${encodeURIComponent(source.hostname)}?larger=true`
|
||||||
|
].filter(Boolean)));
|
||||||
|
return {
|
||||||
|
id: getString(record, 'id'),
|
||||||
|
source: source.href,
|
||||||
|
hostname: source.hostname,
|
||||||
|
createdAt: getString(record, 'created_at'),
|
||||||
|
title: plainText(getString(record, 'title')),
|
||||||
|
content: plainText(getString(record, 'content')),
|
||||||
|
author: author || source.hostname,
|
||||||
|
siteName,
|
||||||
|
favicons,
|
||||||
|
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 displayTitle = (mention: WebmentionRecord) => mention.title || mention.siteName || mention.hostname;
|
||||||
|
|
||||||
|
const sourceSlug = (source: URL) => {
|
||||||
|
const segments = source.pathname.split('/').filter(Boolean);
|
||||||
|
const segment = segments.at(-1) ?? '';
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(segment).replace(/\.(?:html?|php)$/i, '');
|
||||||
|
} catch {
|
||||||
|
return segment.replace(/\.(?:html?|php)$/i, '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSlugTitle = (title: string, source: URL) => {
|
||||||
|
const normalized = (value: string) => value
|
||||||
|
.trim()
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.replace(/[\s_-]+/g, '-');
|
||||||
|
return Boolean(title) && normalized(title) === normalized(sourceSlug(source));
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSourceDocument = (html: string, base: URL): SourceMetadata => {
|
||||||
|
const sourceDocument = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const metaContent = (selector: string) => (
|
||||||
|
sourceDocument.querySelector<HTMLMetaElement>(selector)?.content ?? ''
|
||||||
|
);
|
||||||
|
const faviconHref = sourceDocument.querySelector<HTMLLinkElement>(
|
||||||
|
'link[rel~="icon"], link[rel="shortcut icon"]'
|
||||||
|
)?.getAttribute('href') ?? '';
|
||||||
|
return {
|
||||||
|
title: plainText(metaContent('meta[property="og:title"]') || sourceDocument.title),
|
||||||
|
siteName: plainText(metaContent('meta[property="og:site_name"]')),
|
||||||
|
favicon: safeAsset(faviconHref, base)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSourceDocument = async (mention: WebmentionRecord, requestUrl = mention.source) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), 8000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(requestUrl, {
|
||||||
|
headers: { Accept: 'text/html,application/xhtml+xml' },
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
if (!contentType.toLowerCase().includes('text/html')) return null;
|
||||||
|
return parseSourceDocument(await response.text(), new URL(mention.source));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMicrolinkMetadata = async (mention: WebmentionRecord) => {
|
||||||
|
const source = new URL(mention.source);
|
||||||
|
const url = new URL('https://api.microlink.io/');
|
||||||
|
url.searchParams.set('url', mention.source);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), 8000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const payload = objectValue(await response.json());
|
||||||
|
const data = objectValue(payload?.data);
|
||||||
|
if (!data) return null;
|
||||||
|
const title = plainText(typeof data.title === 'string' ? data.title : '');
|
||||||
|
return {
|
||||||
|
title: isSlugTitle(title, source) ? '' : title,
|
||||||
|
siteName: plainText(typeof data.publisher === 'string' ? data.publisher : ''),
|
||||||
|
favicon: ''
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceMetadata = (mention: WebmentionRecord, siteOrigin: string) => {
|
||||||
|
const sourceOrigin = new URL(mention.source).origin;
|
||||||
|
const canFetchDirectly = sourceOrigin === window.location.origin || sourceOrigin === siteOrigin;
|
||||||
|
const cacheKey = `${canFetchDirectly ? 'direct' : 'external'}:${mention.source}`;
|
||||||
|
const cached = metadataCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const directUrl = sourceOrigin === siteOrigin
|
||||||
|
? new URL(`${new URL(mention.source).pathname}${new URL(mention.source).search}`, window.location.origin).href
|
||||||
|
: mention.source;
|
||||||
|
const request = canFetchDirectly
|
||||||
|
? fetchSourceDocument(mention, directUrl).then((metadata) => metadata ?? fetchMicrolinkMetadata(mention))
|
||||||
|
: fetchMicrolinkMetadata(mention);
|
||||||
|
metadataCache.set(cacheKey, request);
|
||||||
|
return request;
|
||||||
|
};
|
||||||
|
|
||||||
|
const enrichMention = async (mention: WebmentionRecord, siteOrigin: string) => {
|
||||||
|
const metadata = await sourceMetadata(mention, siteOrigin);
|
||||||
|
if (!metadata) return mention;
|
||||||
|
return {
|
||||||
|
...mention,
|
||||||
|
title: mention.title || metadata.title,
|
||||||
|
siteName: metadata.siteName || mention.siteName,
|
||||||
|
favicons: Array.from(new Set([metadata.favicon, ...mention.favicons].filter(Boolean)))
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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 createSiteIcon = (mention: WebmentionRecord, className: string) => {
|
||||||
|
const wrapper = document.createElement('span');
|
||||||
|
wrapper.className = className;
|
||||||
|
wrapper.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
const fallback = document.createElement('span');
|
||||||
|
fallback.className = 'webmention-icon-fallback';
|
||||||
|
fallback.textContent = initialFor(mention.siteName || mention.hostname);
|
||||||
|
|
||||||
|
const image = document.createElement('img');
|
||||||
|
image.alt = '';
|
||||||
|
image.loading = 'lazy';
|
||||||
|
image.decoding = 'async';
|
||||||
|
image.referrerPolicy = 'no-referrer';
|
||||||
|
image.addEventListener('load', () => {
|
||||||
|
wrapper.dataset.iconLoaded = 'true';
|
||||||
|
});
|
||||||
|
let faviconIndex = 0;
|
||||||
|
image.addEventListener('error', () => {
|
||||||
|
faviconIndex += 1;
|
||||||
|
const nextFavicon = mention.favicons[faviconIndex];
|
||||||
|
if (nextFavicon) {
|
||||||
|
image.src = nextFavicon;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
image.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.append(fallback);
|
||||||
|
const firstFavicon = mention.favicons[0];
|
||||||
|
if (firstFavicon) {
|
||||||
|
image.src = firstFavicon;
|
||||||
|
wrapper.append(image);
|
||||||
|
}
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderReaction = (mention: WebmentionRecord) => {
|
||||||
|
const link = sourceLink(mention, 'webmention-reaction');
|
||||||
|
const title = displayTitle(mention);
|
||||||
|
link.append(createSiteIcon(mention, 'webmention-site-icon'));
|
||||||
|
link.title = `${title} (${mention.hostname})`;
|
||||||
|
link.setAttribute('aria-label', `${title}:${kindLabel(kindOf(mention))}`);
|
||||||
|
return link;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDiscussion = (mention: WebmentionRecord) => {
|
||||||
|
const kind = kindOf(mention);
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.className = 'webmention-item';
|
||||||
|
|
||||||
|
const avatar = createSiteIcon(mention, 'webmention-avatar webmention-site-icon');
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'webmention-meta';
|
||||||
|
|
||||||
|
const author = sourceLink(mention, 'webmention-author');
|
||||||
|
author.textContent = displayTitle(mention);
|
||||||
|
meta.append(author);
|
||||||
|
|
||||||
|
if (displayTitle(mention) !== mention.hostname) {
|
||||||
|
const host = document.createElement('span');
|
||||||
|
host.className = 'webmention-host';
|
||||||
|
host.textContent = mention.hostname;
|
||||||
|
meta.append(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
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 loading = root.querySelector<HTMLElement>('[data-webmentions-loading]');
|
||||||
|
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 (!loading || !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;
|
||||||
|
loading.hidden = true;
|
||||||
|
status.textContent = mentions.length ? '' : '暂时还没有站外回应。';
|
||||||
|
status.hidden = mentions.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchWebmentions = (root: HTMLElement) => {
|
||||||
|
if (root.dataset.webmentionsLoaded) return;
|
||||||
|
root.dataset.webmentionsLoaded = 'true';
|
||||||
|
const loading = root.querySelector<HTMLElement>('[data-webmentions-loading]');
|
||||||
|
const status = root.querySelector<HTMLElement>('[data-webmentions-status]');
|
||||||
|
const getEndpoint = root.dataset.getEndpoint;
|
||||||
|
const target = root.dataset.target;
|
||||||
|
const siteOrigin = root.dataset.siteOrigin;
|
||||||
|
if (!loading || !status || !getEndpoint || !target || !siteOrigin) 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);
|
||||||
|
if (!mentions.length) return;
|
||||||
|
void Promise.all(mentions.map((mention) => enrichMention(mention, siteOrigin))).then((enriched) => {
|
||||||
|
if (root.isConnected) renderMentions(root, enriched);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (controller.signal.aborted || !root.isConnected) return;
|
||||||
|
loading.hidden = true;
|
||||||
|
status.textContent = '站外回应暂时无法读取。';
|
||||||
|
status.hidden = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCurrentWebmentions = () => {
|
||||||
|
const root = document.querySelector<HTMLElement>('[data-webmentions-root]');
|
||||||
|
if (!root || root.dataset.webmentionsBound) return;
|
||||||
|
root.dataset.webmentionsBound = 'true';
|
||||||
|
|
||||||
|
state.observer?.disconnect();
|
||||||
|
if (!('IntersectionObserver' in window)) {
|
||||||
|
fetchWebmentions(root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.observer = new IntersectionObserver((entries, observer) => {
|
||||||
|
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||||
|
observer.disconnect();
|
||||||
|
fetchWebmentions(root);
|
||||||
|
}, { rootMargin: '400px 0px' });
|
||||||
|
state.observer.observe(root);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = '正在发送...';
|
||||||
|
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
for (const [name, value] of new FormData(form)) {
|
||||||
|
if (typeof value === 'string') body.append(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
// webmentiond currently requires this Content-Type without a charset suffix.
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
}).then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = plainText(await response.text());
|
||||||
|
throw new Error(message || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
status.textContent = '已收到,验证通过并经审核后会显示在这里。';
|
||||||
|
const source = form.elements.namedItem('source');
|
||||||
|
if (source instanceof HTMLInputElement) source.value = '';
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.message : '';
|
||||||
|
status.textContent = message ? `发送失败:${message}` : '发送失败,请稍后重试。';
|
||||||
|
}).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.observer?.disconnect();
|
||||||
|
state.controller?.abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCurrentWebmentions();
|
||||||
|
</script>
|
||||||
@@ -20,6 +20,7 @@ const baseSchema = z.object({
|
|||||||
z.object({ enabled: z.boolean().default(true), position: z.enum(['left', 'right']).default('right') })
|
z.object({ enabled: z.boolean().default(true), position: z.enum(['left', 'right']).default('right') })
|
||||||
]).default(true),
|
]).default(true),
|
||||||
comments: z.boolean().default(false),
|
comments: z.boolean().default(false),
|
||||||
|
webmention: z.boolean().default(false),
|
||||||
math: z.boolean().default(false),
|
math: z.boolean().default(false),
|
||||||
mermaid: z.boolean().default(false)
|
mermaid: z.boolean().default(false)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import NavigationProgress from '@/components/NavigationProgress.astro';
|
|||||||
import { ClientRouter } from 'astro:transitions';
|
import { ClientRouter } from 'astro:transitions';
|
||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
|
|
||||||
interface Props { title?: string; description?: string; image?: string; type?: 'website' | 'article'; noindex?: boolean; math?: boolean; navbarOverlay?: boolean; hasMasthead?: boolean; }
|
interface Props { title?: string; description?: string; image?: string; type?: 'website' | 'article'; noindex?: boolean; math?: boolean; webmention?: boolean; navbarOverlay?: boolean; hasMasthead?: boolean; }
|
||||||
const { title, description, image, type, noindex, math, navbarOverlay = false, hasMasthead = Astro.slots.has('masthead') } = Astro.props;
|
const { title, description, image, type, noindex, math, webmention = false, navbarOverlay = false, hasMasthead = Astro.slots.has('masthead') } = Astro.props;
|
||||||
const appearanceScript = `(function(){var r=document.documentElement,t='mirages-theme',s='mirages-font-scale',f='mirages-font-family',themes=['auto','light','sunset','dark'],fonts=['serif','sans'],stored=Number(localStorage.getItem(s)),scale=stored>=.8&&stored<=1.5?stored*100:stored>=80&&stored<=150?stored:100,theme=localStorage.getItem(t),font=localStorage.getItem(f);scale=Math.round(scale/5)*5;r.dataset.theme=themes.indexOf(theme)>-1?theme:'${siteConfig.appearance.defaultTheme}';r.dataset.font=fonts.indexOf(font)>-1?font:'sans';r.dataset.platform=/Win/.test(navigator.userAgentData?.platform||navigator.platform)?'windows':'other';r.style.fontSize=scale+'%'})();`;
|
const appearanceScript = `(function(){var r=document.documentElement,t='mirages-theme',s='mirages-font-scale',f='mirages-font-family',themes=['auto','light','sunset','dark'],fonts=['serif','sans'],stored=Number(localStorage.getItem(s)),scale=stored>=.8&&stored<=1.5?stored*100:stored>=80&&stored<=150?stored:100,theme=localStorage.getItem(t),font=localStorage.getItem(f);scale=Math.round(scale/5)*5;r.dataset.theme=themes.indexOf(theme)>-1?theme:'${siteConfig.appearance.defaultTheme}';r.dataset.font=fonts.indexOf(font)>-1?font:'sans';r.dataset.platform=/Win/.test(navigator.userAgentData?.platform||navigator.platform)?'windows':'other';r.style.fontSize=scale+'%'})();`;
|
||||||
const tabsScript = `if(!window.__tabsInit){window.__tabsInit=true;document.addEventListener('click',function(event){var button=event.target.closest('[data-shortcode-tab]');if(!button)return;var group=button.closest('[data-shortcode-tabs]');var id=button.dataset.shortcodeTab;group.querySelectorAll('[data-shortcode-tab=\"'+id+'\"]').forEach(function(tab){var active=tab===button;tab.setAttribute('aria-selected',String(active));tab.tabIndex=active?0:-1;var panel=group.querySelector('#'+tab.getAttribute('aria-controls'));if(panel)panel.hidden=!active;});});}`;
|
const tabsScript = `if(!window.__tabsInit){window.__tabsInit=true;document.addEventListener('click',function(event){var button=event.target.closest('[data-shortcode-tab]');if(!button)return;var group=button.closest('[data-shortcode-tabs]');var id=button.dataset.shortcodeTab;group.querySelectorAll('[data-shortcode-tab=\"'+id+'\"]').forEach(function(tab){var active=tab===button;tab.setAttribute('aria-selected',String(active));tab.tabIndex=active?0:-1;var panel=group.querySelector('#'+tab.getAttribute('aria-controls'));if(panel)panel.hidden=!active;});});}`;
|
||||||
const collapseScript = `function initCollapse(){document.querySelectorAll('.shortcode-collapse').forEach(function(details){details.dataset.collapseReady='';details.classList.toggle('is-expanded',details.open);var body=details.querySelector(':scope>.shortcode-body');if(body instanceof HTMLElement&&details.open)body.style.height='auto';});}initCollapse();document.addEventListener('astro:page-load',initCollapse);document.addEventListener('click',function(event){var summary=event.target.closest('.shortcode-collapse>summary');if(!summary)return;var details=summary.parentElement;if(!(details instanceof HTMLDetailsElement))return;event.preventDefault();var body=details.querySelector(':scope>.shortcode-body');if(!(body instanceof HTMLElement))return;var expand=!details.classList.contains('is-expanded');details.classList.toggle('is-expanded',expand);if(matchMedia('(prefers-reduced-motion: reduce)').matches){details.open=expand;body.style.height=expand?'auto':'';delete details.dataset.animating;return;}var height=body.getBoundingClientRect().height;details.open=true;body.style.height=height+'px';void body.offsetHeight;details.dataset.animating='true';body.style.height=(expand?body.scrollHeight:0)+'px';});document.addEventListener('transitionend',function(event){var body=event.target;if(!(body instanceof HTMLElement)||!body.classList.contains('shortcode-body')||event.propertyName!=='height')return;var details=body.parentElement;if(!(details instanceof HTMLDetailsElement))return;var expanded=details.classList.contains('is-expanded');details.open=expanded;body.style.height=expanded?'auto':'';delete details.dataset.animating;});`;
|
const collapseScript = `function initCollapse(){document.querySelectorAll('.shortcode-collapse').forEach(function(details){details.dataset.collapseReady='';details.classList.toggle('is-expanded',details.open);var body=details.querySelector(':scope>.shortcode-body');if(body instanceof HTMLElement&&details.open)body.style.height='auto';});}initCollapse();document.addEventListener('astro:page-load',initCollapse);document.addEventListener('click',function(event){var summary=event.target.closest('.shortcode-collapse>summary');if(!summary)return;var details=summary.parentElement;if(!(details instanceof HTMLDetailsElement))return;event.preventDefault();var body=details.querySelector(':scope>.shortcode-body');if(!(body instanceof HTMLElement))return;var expand=!details.classList.contains('is-expanded');details.classList.toggle('is-expanded',expand);if(matchMedia('(prefers-reduced-motion: reduce)').matches){details.open=expand;body.style.height=expand?'auto':'';delete details.dataset.animating;return;}var height=body.getBoundingClientRect().height;details.open=true;body.style.height=height+'px';void body.offsetHeight;details.dataset.animating='true';body.style.height=(expand?body.scrollHeight:0)+'px';});document.addEventListener('transitionend',function(event){var body=event.target;if(!(body instanceof HTMLElement)||!body.classList.contains('shortcode-body')||event.propertyName!=='height')return;var details=body.parentElement;if(!(details instanceof HTMLDetailsElement))return;var expanded=details.classList.contains('is-expanded');details.open=expanded;body.style.height=expanded?'auto':'';delete details.dataset.animating;});`;
|
||||||
@@ -28,7 +28,7 @@ const { title, description, image, type, noindex, math, navbarOverlay = false, h
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;500;600;700&display=swap" />
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;500;600;700&display=swap" />
|
||||||
{siteConfig.comments.provider === 'twikoo' && <link rel="stylesheet" href="https://registry.npmmirror.com/twikoo/1.7.15/files/dist/twikoo.css" data-twikoo-styles transition:persist="twikoo-styles" />}
|
{siteConfig.comments.provider === 'twikoo' && <link rel="stylesheet" href="https://registry.npmmirror.com/twikoo/1.7.15/files/dist/twikoo.css" data-twikoo-styles transition:persist="twikoo-styles" />}
|
||||||
<SeoHead title={title} description={description} image={image} type={type} noindex={noindex} />
|
<SeoHead title={title} description={description} image={image} type={type} noindex={noindex} webmention={webmention} />
|
||||||
{siteConfig.pjax.enabled && <ClientRouter />}
|
{siteConfig.pjax.enabled && <ClientRouter />}
|
||||||
{math && <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.47/dist/katex.min.css" />}
|
{math && <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.47/dist/katex.min.css" />}
|
||||||
<script is:inline set:html={appearanceScript}></script>
|
<script is:inline set:html={appearanceScript}></script>
|
||||||
@@ -52,12 +52,12 @@ const { title, description, image, type, noindex, math, navbarOverlay = false, h
|
|||||||
|
|
||||||
const applyPanguSpacing = () => {
|
const applyPanguSpacing = () => {
|
||||||
document.querySelectorAll('pre, code, .line').forEach((el) => el.classList.add('no-pangu-spacing'));
|
document.querySelectorAll('pre, code, .line').forEach((el) => el.classList.add('no-pangu-spacing'));
|
||||||
document.querySelectorAll('.prose p, .prose li, .prose blockquote, .prose h1, .prose h2, .prose h3, .prose h4').forEach((el) => {
|
pangu.spacingNode(document.body);
|
||||||
pangu.spacingNode(el);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pangu.taskScheduler.config.enabled = false;
|
||||||
applyPanguSpacing();
|
applyPanguSpacing();
|
||||||
|
pangu.autoSpacingPage({ pageDelayMs: 0, nodeDelayMs: 0, nodeMaxWaitMs: 100 });
|
||||||
document.addEventListener('astro:page-load', applyPanguSpacing);
|
document.addEventListener('astro:page-load', applyPanguSpacing);
|
||||||
|
|
||||||
const initCodeLineNumbers = () => {
|
const initCodeLineNumbers = () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import BaseLayout from '@/layouts/BaseLayout.astro';
|
|||||||
import Masthead from '@/components/Masthead.astro';
|
import Masthead from '@/components/Masthead.astro';
|
||||||
import { getContentCacheKey, getExcerpt, getPostCardImage, getPostPath, getPostTaxonomy, getPublicPosts, slugify } from '@/lib/content';
|
import { getContentCacheKey, getExcerpt, getPostCardImage, getPostPath, getPostTaxonomy, getPublicPosts, slugify } from '@/lib/content';
|
||||||
import { siteConfig } from '@/site.config';
|
import { siteConfig } from '@/site.config';
|
||||||
import TwikooComments from '@/components/TwikooComments.astro';
|
import EngagementTabs from '@/components/EngagementTabs.astro';
|
||||||
import TableOfContents from '@/components/TableOfContents.astro';
|
import TableOfContents from '@/components/TableOfContents.astro';
|
||||||
import Mermaid from '@/components/Mermaid.astro';
|
import Mermaid from '@/components/Mermaid.astro';
|
||||||
import ImageLightbox from '@/components/ImageLightbox.astro';
|
import ImageLightbox from '@/components/ImageLightbox.astro';
|
||||||
@@ -29,7 +29,7 @@ const date = {
|
|||||||
};
|
};
|
||||||
const dateText = `${date.year} 年 ${date.month} 月 ${date.day} 日`;
|
const dateText = `${date.year} 年 ${date.month} 月 ${date.day} 日`;
|
||||||
---
|
---
|
||||||
<BaseLayout title={post.data.title} description={getExcerpt(post)} image={cover} type="article" math={post.data.math} navbarOverlay={Boolean(cover)}>
|
<BaseLayout title={post.data.title} description={getExcerpt(post)} image={cover} type="article" math={post.data.math} webmention={post.data.webmention} navbarOverlay={Boolean(cover)}>
|
||||||
<Fragment slot="masthead"><Masthead article={{ title: post.data.title, date, categories: taxonomy.categories, image: cover, textTone: post.data.textTone }} /></Fragment>
|
<Fragment slot="masthead"><Masthead article={{ title: post.data.title, date, categories: taxonomy.categories, image: cover, textTone: post.data.textTone }} /></Fragment>
|
||||||
<article class:list={['article-shell', 'relative pt-8 pb-14 md:pt-10 md:pb-20', { 'has-toc': toc.enabled, [`toc-${toc.position}`]: toc.enabled }]}>
|
<article class:list={['article-shell', 'relative pt-8 pb-14 md:pt-10 md:pb-20', { 'has-toc': toc.enabled, [`toc-${toc.position}`]: toc.enabled }]}>
|
||||||
<div class="sr-only" aria-hidden="true">
|
<div class="sr-only" aria-hidden="true">
|
||||||
@@ -45,10 +45,15 @@ const dateText = `${date.year} 年 ${date.month} 月 ${date.day} 日`;
|
|||||||
{tags.map((tag) => <a class="rounded-[3px] bg-[var(--color-post-tag-bg)] px-2.5 py-1 no-underline" href={`/tags/${slugify(tag)}/`}>#{tag}</a>)}
|
{tags.map((tag) => <a class="rounded-[3px] bg-[var(--color-post-tag-bg)] px-2.5 py-1 no-underline" href={`/tags/${slugify(tag)}/`}>#{tag}</a>)}
|
||||||
</div>}
|
</div>}
|
||||||
<nav class="mt-10 grid grid-cols-1 overflow-hidden rounded-[4px] border border-line bg-raised sm:grid-cols-2" aria-label="相邻文章" data-pagefind-ignore="all">
|
<nav class="mt-10 grid grid-cols-1 overflow-hidden rounded-[4px] border border-line bg-raised sm:grid-cols-2" aria-label="相邻文章" data-pagefind-ignore="all">
|
||||||
{newer ? <a class="grid min-h-24 content-center p-4 no-underline sm:border-r sm:border-line" 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 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>}
|
{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>}
|
||||||
{older ? <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(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 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>}
|
{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>
|
</nav>
|
||||||
<TwikooComments enabled={post.data.comments} path={Astro.url.pathname} />
|
<EngagementTabs
|
||||||
|
commentsEnabled={post.data.comments}
|
||||||
|
webmentionEnabled={post.data.webmention}
|
||||||
|
path={Astro.url.pathname}
|
||||||
|
target={new URL(Astro.url.pathname, siteConfig.site.url).href}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
<Mermaid enabled={post.data.mermaid} />
|
<Mermaid enabled={post.data.mermaid} />
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -26,15 +26,6 @@ const query = Astro.url.searchParams.get('q')?.trim() ?? '';
|
|||||||
interface PagefindResult { data: () => Promise<{ url: string; meta?: { title?: string; date?: string; category?: string; categories?: string; tags?: string; cover?: string } }>; }
|
interface PagefindResult { data: () => Promise<{ url: string; meta?: { title?: string; date?: string; category?: string; categories?: string; tags?: string; cover?: string } }>; }
|
||||||
interface PagefindModule { options: (options: { excerptLength: number }) => Promise<void>; search: (query: string) => Promise<{ results: PagefindResult[] }>; }
|
interface PagefindModule { options: (options: { excerptLength: number }) => Promise<void>; search: (query: string) => Promise<{ results: PagefindResult[] }>; }
|
||||||
|
|
||||||
const root = document.querySelector<HTMLElement>('[data-search-page]');
|
|
||||||
const results = root?.querySelector<HTMLElement>('[data-search-page-results]');
|
|
||||||
const queryLabel = root?.querySelector<HTMLElement>('[data-search-query]');
|
|
||||||
const input = root?.querySelector<HTMLInputElement>('input[name="q"]');
|
|
||||||
const query = new URLSearchParams(window.location.search).get('q')?.trim() ?? '';
|
|
||||||
if (query) {
|
|
||||||
if (queryLabel) queryLabel.textContent = `查询词:${query}`;
|
|
||||||
if (input) input.value = query;
|
|
||||||
}
|
|
||||||
const cardBackgrounds = ['#547a82', '#806b76', '#657657', '#876d56', '#5f718d', '#796f54'];
|
const cardBackgrounds = ['#547a82', '#806b76', '#657657', '#876d56', '#5f718d', '#796f54'];
|
||||||
const cardBackground = (url: string) => {
|
const cardBackground = (url: string) => {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
@@ -42,14 +33,39 @@ const query = Astro.url.searchParams.get('q')?.trim() ?? '';
|
|||||||
return cardBackgrounds[hash % cardBackgrounds.length];
|
return cardBackgrounds[hash % cardBackgrounds.length];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let pagefindPromise: Promise<PagefindModule> | undefined;
|
||||||
|
const loadPagefind = () => {
|
||||||
|
pagefindPromise ??= (async () => {
|
||||||
|
const pagefindUrl = new URL(`${import.meta.env.BASE_URL}pagefind/pagefind.js`, document.baseURI).href;
|
||||||
|
const pagefind = await import(/* @vite-ignore */ pagefindUrl) as unknown as PagefindModule;
|
||||||
|
await pagefind.options({ excerptLength: 0 });
|
||||||
|
return pagefind;
|
||||||
|
})();
|
||||||
|
return pagefindPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Client-side navigation reuses this module, so every run reads the page and query afresh. */
|
||||||
|
let runToken = 0;
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
if (!query || !results) return;
|
const root = document.querySelector<HTMLElement>('[data-search-page]');
|
||||||
|
const results = root?.querySelector<HTMLElement>('[data-search-page-results]');
|
||||||
|
if (!root || !results) return;
|
||||||
|
const queryLabel = root.querySelector<HTMLElement>('[data-search-query]');
|
||||||
|
const input = root.querySelector<HTMLInputElement>('input[name="q"]');
|
||||||
|
const query = new URLSearchParams(window.location.search).get('q')?.trim() ?? '';
|
||||||
|
const token = ++runToken;
|
||||||
|
if (queryLabel) queryLabel.textContent = query ? `查询词:${query}` : '请输入查询词开始搜索。';
|
||||||
|
if (input && input.value.trim() !== query) input.value = query;
|
||||||
|
if (!query) {
|
||||||
|
results.innerHTML = '<p class="px-3 py-4 text-sm text-subtle">请输入查询词开始搜索。</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
results.innerHTML = '<p class="px-3 py-4 text-sm text-subtle">正在搜索...</p>';
|
||||||
if (import.meta.env.DEV) { results.innerHTML = '<p class="px-3 py-4 text-sm text-subtle">搜索功能需在构建并预览后使用。</p>'; return; }
|
if (import.meta.env.DEV) { results.innerHTML = '<p class="px-3 py-4 text-sm text-subtle">搜索功能需在构建并预览后使用。</p>'; return; }
|
||||||
try {
|
try {
|
||||||
const pagefindUrl = new URL(`${import.meta.env.BASE_URL}pagefind/pagefind.js`, document.baseURI).href;
|
const pagefind = await loadPagefind();
|
||||||
const pagefind = await import(/* @vite-ignore */ pagefindUrl) as unknown as PagefindModule;
|
|
||||||
await pagefind.options({ excerptLength: 0 });
|
|
||||||
const search = await pagefind.search(query);
|
const search = await pagefind.search(query);
|
||||||
|
if (token !== runToken) return;
|
||||||
results.replaceChildren();
|
results.replaceChildren();
|
||||||
if (!search.results.length) {
|
if (!search.results.length) {
|
||||||
results.innerHTML = '<p class="px-3 py-4 text-sm text-subtle">没有找到相关内容。</p>';
|
results.innerHTML = '<p class="px-3 py-4 text-sm text-subtle">没有找到相关内容。</p>';
|
||||||
@@ -57,6 +73,7 @@ const query = Astro.url.searchParams.get('q')?.trim() ?? '';
|
|||||||
}
|
}
|
||||||
for (const result of search.results) {
|
for (const result of search.results) {
|
||||||
const data = await result.data();
|
const data = await result.data();
|
||||||
|
if (token !== runToken) return;
|
||||||
const article = document.createElement('article');
|
const article = document.createElement('article');
|
||||||
article.className = 'py-[0.9375rem] md:py-[0.9375rem]';
|
article.className = 'py-[0.9375rem] md:py-[0.9375rem]';
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
@@ -107,4 +124,5 @@ const query = Astro.url.searchParams.get('q')?.trim() ?? '';
|
|||||||
} catch { results.innerHTML = '<p class="px-3 py-4 text-sm text-subtle">搜索暂时不可用。</p>'; }
|
} catch { results.innerHTML = '<p class="px-3 py-4 text-sm text-subtle">搜索暂时不可用。</p>'; }
|
||||||
};
|
};
|
||||||
run();
|
run();
|
||||||
|
document.addEventListener('astro:page-load', run);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ export const siteConfig: SiteConfig = {
|
|||||||
region: 'cn',
|
region: 'cn',
|
||||||
lang: 'zh-CN'
|
lang: 'zh-CN'
|
||||||
},
|
},
|
||||||
|
// WebMention 采用 webmentiond 自托管,请将 endpoint 换成你的接收端地址
|
||||||
|
webmentions: {
|
||||||
|
enabled: true,
|
||||||
|
endpoint: 'https://webmention.example.com',
|
||||||
|
form: true
|
||||||
|
},
|
||||||
// 搜索服务默认使用 PageFind,无需理会
|
// 搜索服务默认使用 PageFind,无需理会
|
||||||
search: { provider: 'pagefind', placeholder: '搜索文章' },
|
search: { provider: 'pagefind', placeholder: '搜索文章' },
|
||||||
// 这是顶栏右上角及侧栏底部的工具按钮,可自定义。下方示例已涵盖了站内功能、RSS 复制、外链跳转等场景。
|
// 这是顶栏右上角及侧栏底部的工具按钮,可自定义。下方示例已涵盖了站内功能、RSS 复制、外链跳转等场景。
|
||||||
|
|||||||
+17
-1
@@ -49,6 +49,22 @@
|
|||||||
font-size: 1.65rem;
|
font-size: 1.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose :where(h2, h3) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
column-gap: 0.6em;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :where(h2, h3)::before {
|
||||||
|
width: 4px;
|
||||||
|
height: 1.05em;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
transform: translateY(0.08em);
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
.prose :where(p, ul, ol, blockquote, pre, table) {
|
.prose :where(p, ul, ol, blockquote, pre, table) {
|
||||||
margin-block: 1.15em;
|
margin-block: 1.15em;
|
||||||
}
|
}
|
||||||
@@ -88,7 +104,7 @@
|
|||||||
border-left: 3px solid var(--color-accent);
|
border-left: 3px solid var(--color-accent);
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
font-family: var(--reading-font-family);
|
font-family: var(--reading-font-family);
|
||||||
font-size: 1.08em;
|
font-size: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose :not(pre) > code {
|
.prose :not(pre) > code {
|
||||||
|
|||||||
@@ -274,6 +274,12 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font: 600 1rem/1.35 var(--reading-font-family);
|
font: 600 1rem/1.35 var(--reading-font-family);
|
||||||
}
|
}
|
||||||
|
.prose .shortcode-tools-title {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.prose .shortcode-tools-title::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
.shortcode-tool-list {
|
.shortcode-tool-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ type SearchConfig =
|
|||||||
| { provider: 'none' }
|
| { provider: 'none' }
|
||||||
| { provider: 'pagefind'; placeholder: string };
|
| { provider: 'pagefind'; placeholder: string };
|
||||||
|
|
||||||
|
export interface WebmentionConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
endpoint: string;
|
||||||
|
form?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type ToolbarIcon = 'search' | 'rss' | 'settings' | 'link' | 'tram-front';
|
export type ToolbarIcon = 'search' | 'rss' | 'settings' | 'link' | 'tram-front';
|
||||||
|
|
||||||
export type ToolbarItem =
|
export type ToolbarItem =
|
||||||
@@ -66,6 +72,7 @@ export interface SiteConfig {
|
|||||||
links: LinkItem[];
|
links: LinkItem[];
|
||||||
};
|
};
|
||||||
comments: CommentsConfig;
|
comments: CommentsConfig;
|
||||||
|
webmentions: WebmentionConfig;
|
||||||
search: SearchConfig;
|
search: SearchConfig;
|
||||||
pjax: {
|
pjax: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user