同步 Blog 主题变更:Webmention、互动区与样式修复

This commit is contained in:
RiseForever
2026-08-30 20:57:39 +08:00
parent b70ed6051a
commit d6cd9ea4c5
23 changed files with 1534 additions and 38 deletions
+2
View File
@@ -6,3 +6,5 @@ dist/
.env.* .env.*
!.env.example !.env.example
pnpm-debug.log* pnpm-debug.log*
.obsidian/
.pnpm-store/
+17 -1
View File
@@ -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`。两者使用相同的字段结构:
+15
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
webmention.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:8080
}
+79
View File
@@ -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 不同的目标;建议发送时使用不带锚点的文章永久链接。
+46
View File
@@ -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:
+13
View File
@@ -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;
}
}
+249
View File
@@ -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>
+148 -2
View File
@@ -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>
+12 -2
View File
@@ -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);
+8 -1
View File
@@ -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} />
+3
View File
@@ -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();
}); });
+6 -7
View File
@@ -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>
+844
View File
@@ -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>
+1
View File
@@ -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)
}); });
+6 -6
View File
@@ -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 = () => {
+4
View File
@@ -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;
}
+10 -5
View File
@@ -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>
+31 -13
View File
@@ -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>
+6
View File
@@ -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
View File
@@ -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 {
+6
View File
@@ -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));
+7
View File
@@ -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;