diff --git a/.gitignore b/.gitignore index 5a7f121..309047e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ dist/ .env.* !.env.example pnpm-debug.log* +.obsidian/ +.pnpm-store/ \ No newline at end of file diff --git a/NOTICE.md b/NOTICE.md index 62d4da4..16178ad 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -2,7 +2,7 @@ Mirages 是一个基于 Astro 的静态博客主题,用于发布文章和独立页面。旧版 PHP 文件位于本项目之外,保持不变。 -本文档说明 Astro 主题的常用配置、Twikoo 评论和文章 Frontmatter。 +本文档说明 Astro 主题的常用配置、Twikoo 评论、WebMention 和文章 Frontmatter。 ## 运行项目 @@ -117,6 +117,22 @@ pnpm build 如果页面显示“评论暂时不可用”,依次检查云函数地址是否能从浏览器直接访问、CORS 配置、Twikoo 环境变量和 `envId` 是否包含多余路径或空格。 +## WebMention 配置 + +WebMention 与 Twikoo 并行工作:Twikoo 接收本站评论,WebMention 接收其他网站对文章的回复、提及、点赞和转发。配置入口位于 `src/site.config.ts`: + +```ts +webmentions: { + enabled: true, + endpoint: 'https://webmention.example.com', + form: true +}, +``` + +启用后,全站 `` 会声明 `${endpoint}/receive`,文章页从 `${endpoint}/get` 读取当前 canonical URL 的已批准 WebMention。`form` 控制是否在文章底部显示手动发送表单。 + +仓库在 `deploy/webmention/` 提供了基于 webmentiond、SQLite 和 Docker Compose 的自托管配置。服务器部署、SMTP 登录、反向代理及验证步骤见该目录的 `README.md`。 + ## 文章 Frontmatter 文章位于 `src/content/posts`,独立页面位于 `src/content/pages`。两者使用相同的字段结构: diff --git a/deploy/webmention/.env.example b/deploy/webmention/.env.example new file mode 100644 index 0000000..f7a735b --- /dev/null +++ b/deploy/webmention/.env.example @@ -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 diff --git a/deploy/webmention/Caddyfile.example b/deploy/webmention/Caddyfile.example new file mode 100644 index 0000000..2038fa9 --- /dev/null +++ b/deploy/webmention/Caddyfile.example @@ -0,0 +1,4 @@ +webmention.example.com { + encode zstd gzip + reverse_proxy 127.0.0.1:8080 +} diff --git a/deploy/webmention/README.md b/deploy/webmention/README.md new file mode 100644 index 0000000..5364e9b --- /dev/null +++ b/deploy/webmention/README.md @@ -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. 接口与站点集成 + +博客构建结果会在 `` 中自动声明: + +```html + +``` + +文章页通过以下接口读取当前 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 不同的目标;建议发送时使用不带锚点的文章永久链接。 diff --git a/deploy/webmention/compose.yaml b/deploy/webmention/compose.yaml new file mode 100644 index 0000000..92a8566 --- /dev/null +++ b/deploy/webmention/compose.yaml @@ -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: diff --git a/deploy/webmention/nginx.conf.example b/deploy/webmention/nginx.conf.example new file mode 100644 index 0000000..2db0636 --- /dev/null +++ b/deploy/webmention/nginx.conf.example @@ -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; + } +} diff --git a/src/components/EngagementTabs.astro b/src/components/EngagementTabs.astro new file mode 100644 index 0000000..4feec8f --- /dev/null +++ b/src/components/EngagementTabs.astro @@ -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) &&
+ {showTabs ?
+ + +
:

{showWebmention ? 'Webmention' : '评论'}

} + + {showWebmention && } + + {showComments &&
+ +
} +
} + + + + diff --git a/src/components/ImageLightbox.astro b/src/components/ImageLightbox.astro index 4ff3457..acd9e72 100644 --- a/src/components/ImageLightbox.astro +++ b/src/components/ImageLightbox.astro @@ -24,13 +24,80 @@ const items = images.map((item) => ({ ...getSize(item), 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 }) => { const lightbox = new PhotoSwipeLightbox({ 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(); return lightbox; @@ -85,4 +152,83 @@ diff --git a/src/components/Navbar.astro b/src/components/Navbar.astro index 905465e..7cfac21 100644 --- a/src/components/Navbar.astro +++ b/src/components/Navbar.astro @@ -78,8 +78,8 @@ const { overlay = false } = Astro.props; {sidebarToolbarItems.map((item) => (
{item.type === 'settings' && } - {item.type === 'link' && {item.name}} - {item.type === 'rss' && } + {item.type === 'link' && {item.name}} + {item.type === 'rss' && }
))} @@ -176,6 +176,16 @@ const { overlay = false } = Astro.props; 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 { border-color: color-mix(in srgb, var(--color-text) 50%, transparent); background: color-mix(in srgb, var(--color-bg) 92%, transparent); diff --git a/src/components/SeoHead.astro b/src/components/SeoHead.astro index bbba082..fd42eed 100644 --- a/src/components/SeoHead.astro +++ b/src/components/SeoHead.astro @@ -1,5 +1,6 @@ --- import { siteConfig } from '@/site.config'; +import { getWebmentionEndpoint } from '@/lib/webmentions'; interface Props { title?: string; @@ -7,6 +8,7 @@ interface Props { image?: string; type?: 'website' | 'article'; noindex?: boolean; + webmention?: boolean; } const { @@ -14,16 +16,21 @@ const { description = siteConfig.site.description, image, type = 'website', - noindex = false + noindex = false, + webmention = false } = Astro.props; const pageTitle = title ? `${title} - ${siteConfig.site.title}` : siteConfig.site.title; const canonical = new URL(Astro.url.pathname, siteConfig.site.url); const imageUrl = image ? new URL(image, siteConfig.site.url) : undefined; +const webmentionEndpoint = webmention && siteConfig.webmentions.enabled && siteConfig.webmentions.endpoint.trim() + ? getWebmentionEndpoint(siteConfig.webmentions.endpoint, 'receive') + : undefined; --- {pageTitle} +{webmentionEndpoint && } diff --git a/src/components/TwikooClient.astro b/src/components/TwikooClient.astro index 2159a87..7d14cea 100644 --- a/src/components/TwikooClient.astro +++ b/src/components/TwikooClient.astro @@ -39,6 +39,8 @@ const initCurrentComments = () => { const root = document.querySelector('#twikoo-comments'); if (!(root instanceof HTMLElement) || root.dataset.twikooBound) return; + const panel = root.closest('[data-engagement-panel]'); + if (panel instanceof HTMLElement && panel.hidden) return; let config; try { config = JSON.parse(root.dataset.twikooConfig ?? '{}'); @@ -62,6 +64,7 @@ initCurrentComments(); document.addEventListener('astro:page-load', initCurrentComments); + document.addEventListener('engagement:tab-change', initCurrentComments); document.addEventListener('astro:before-swap', () => { document.querySelector('#twikoo-comments')?.replaceChildren(); }); diff --git a/src/components/TwikooComments.astro b/src/components/TwikooComments.astro index 8a58b9f..1e8e98f 100644 --- a/src/components/TwikooComments.astro +++ b/src/components/TwikooComments.astro @@ -12,12 +12,12 @@ const config = comments.provider === 'twikoo' && comments.envId.trim() ? { } : null; --- {enabled && config &&
-

评论

+

评论

} {enabled && !config &&
-

评论

+

评论

Twikoo 尚未配置

} diff --git a/src/components/Webmentions.astro b/src/components/Webmentions.astro new file mode 100644 index 0000000..1eb8bf5 --- /dev/null +++ b/src/components/Webmentions.astro @@ -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 &&
+

Webmention

+ +
+ + Loading... +
+ + + + + + + {config.form &&
+ +
+ + + +
+

+
} +
} + + + + diff --git a/src/content.config.ts b/src/content.config.ts index 5402772..142f9fd 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -20,6 +20,7 @@ const baseSchema = z.object({ z.object({ enabled: z.boolean().default(true), position: z.enum(['left', 'right']).default('right') }) ]).default(true), comments: z.boolean().default(false), + webmention: z.boolean().default(false), math: z.boolean().default(false), mermaid: z.boolean().default(false) }); diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 17615bf..cd5b9a9 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -10,8 +10,8 @@ import NavigationProgress from '@/components/NavigationProgress.astro'; import { ClientRouter } from 'astro:transitions'; import '../styles/global.css'; -interface Props { title?: string; description?: string; image?: string; type?: 'website' | 'article'; noindex?: boolean; math?: boolean; navbarOverlay?: boolean; hasMasthead?: boolean; } -const { title, description, image, type, noindex, math, navbarOverlay = false, hasMasthead = Astro.slots.has('masthead') } = Astro.props; +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, 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 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;});`; @@ -28,7 +28,7 @@ const { title, description, image, type, noindex, math, navbarOverlay = false, h {siteConfig.comments.provider === 'twikoo' && } - + {siteConfig.pjax.enabled && } {math && } @@ -52,12 +52,12 @@ const { title, description, image, type, noindex, math, navbarOverlay = false, h const applyPanguSpacing = () => { 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(el); - }); + pangu.spacingNode(document.body); }; + pangu.taskScheduler.config.enabled = false; applyPanguSpacing(); + pangu.autoSpacingPage({ pageDelayMs: 0, nodeDelayMs: 0, nodeMaxWaitMs: 100 }); document.addEventListener('astro:page-load', applyPanguSpacing); const initCodeLineNumbers = () => { diff --git a/src/lib/webmentions.ts b/src/lib/webmentions.ts new file mode 100644 index 0000000..f5d67fb --- /dev/null +++ b/src/lib/webmentions.ts @@ -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; +} diff --git a/src/pages/posts/[...slug].astro b/src/pages/posts/[...slug].astro index 72a1a35..461bd29 100644 --- a/src/pages/posts/[...slug].astro +++ b/src/pages/posts/[...slug].astro @@ -4,7 +4,7 @@ import BaseLayout from '@/layouts/BaseLayout.astro'; import Masthead from '@/components/Masthead.astro'; import { getContentCacheKey, getExcerpt, getPostCardImage, getPostPath, getPostTaxonomy, getPublicPosts, slugify } from '@/lib/content'; import { siteConfig } from '@/site.config'; -import TwikooComments from '@/components/TwikooComments.astro'; +import EngagementTabs from '@/components/EngagementTabs.astro'; import TableOfContents from '@/components/TableOfContents.astro'; import Mermaid from '@/components/Mermaid.astro'; import ImageLightbox from '@/components/ImageLightbox.astro'; @@ -29,7 +29,7 @@ const date = { }; const dateText = `${date.year} 年 ${date.month} 月 ${date.day} 日`; --- - + diff --git a/src/pages/search/index.astro b/src/pages/search/index.astro index 3667e65..f91614d 100644 --- a/src/pages/search/index.astro +++ b/src/pages/search/index.astro @@ -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 PagefindModule { options: (options: { excerptLength: number }) => Promise; search: (query: string) => Promise<{ results: PagefindResult[] }>; } - const root = document.querySelector('[data-search-page]'); - const results = root?.querySelector('[data-search-page-results]'); - const queryLabel = root?.querySelector('[data-search-query]'); - const input = root?.querySelector('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 cardBackground = (url: string) => { let hash = 0; @@ -42,14 +33,39 @@ const query = Astro.url.searchParams.get('q')?.trim() ?? ''; return cardBackgrounds[hash % cardBackgrounds.length]; }; + let pagefindPromise: Promise | 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 () => { - if (!query || !results) return; + const root = document.querySelector('[data-search-page]'); + const results = root?.querySelector('[data-search-page-results]'); + if (!root || !results) return; + const queryLabel = root.querySelector('[data-search-query]'); + const input = root.querySelector('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 = '

请输入查询词开始搜索。

'; + return; + } + results.innerHTML = '

正在搜索...

'; if (import.meta.env.DEV) { results.innerHTML = '

搜索功能需在构建并预览后使用。

'; return; } try { - 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 }); + const pagefind = await loadPagefind(); const search = await pagefind.search(query); + if (token !== runToken) return; results.replaceChildren(); if (!search.results.length) { results.innerHTML = '

没有找到相关内容。

'; @@ -57,6 +73,7 @@ const query = Astro.url.searchParams.get('q')?.trim() ?? ''; } for (const result of search.results) { const data = await result.data(); + if (token !== runToken) return; const article = document.createElement('article'); article.className = 'py-[0.9375rem] md:py-[0.9375rem]'; const link = document.createElement('a'); @@ -107,4 +124,5 @@ const query = Astro.url.searchParams.get('q')?.trim() ?? ''; } catch { results.innerHTML = '

搜索暂时不可用。

'; } }; run(); + document.addEventListener('astro:page-load', run); diff --git a/src/site.config.ts b/src/site.config.ts index e50dd96..159694a 100644 --- a/src/site.config.ts +++ b/src/site.config.ts @@ -58,6 +58,12 @@ export const siteConfig: SiteConfig = { region: 'cn', lang: 'zh-CN' }, + // WebMention 采用 webmentiond 自托管,请将 endpoint 换成你的接收端地址 + webmentions: { + enabled: true, + endpoint: 'https://webmention.example.com', + form: true + }, // 搜索服务默认使用 PageFind,无需理会 search: { provider: 'pagefind', placeholder: '搜索文章' }, // 这是顶栏右上角及侧栏底部的工具按钮,可自定义。下方示例已涵盖了站内功能、RSS 复制、外链跳转等场景。 diff --git a/src/styles/content.css b/src/styles/content.css index cab4417..0c71812 100644 --- a/src/styles/content.css +++ b/src/styles/content.css @@ -49,6 +49,22 @@ 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) { margin-block: 1.15em; } @@ -88,7 +104,7 @@ border-left: 3px solid var(--color-accent); color: var(--color-muted); font-family: var(--reading-font-family); - font-size: 1.08em; + font-size: 1em; } .prose :not(pre) > code { diff --git a/src/styles/shortcodes.css b/src/styles/shortcodes.css index 896edf4..9d81ac4 100644 --- a/src/styles/shortcodes.css +++ b/src/styles/shortcodes.css @@ -274,6 +274,12 @@ color: var(--color-text); 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 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/src/types/config.ts b/src/types/config.ts index 9d767c7..3e26160 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -15,6 +15,12 @@ type SearchConfig = | { provider: 'none' } | { provider: 'pagefind'; placeholder: string }; +export interface WebmentionConfig { + enabled: boolean; + endpoint: string; + form?: boolean; +} + export type ToolbarIcon = 'search' | 'rss' | 'settings' | 'link' | 'tram-front'; export type ToolbarItem = @@ -66,6 +72,7 @@ export interface SiteConfig { links: LinkItem[]; }; comments: CommentsConfig; + webmentions: WebmentionConfig; search: SearchConfig; pjax: { enabled: boolean;