feat(search): 站内搜索/RSS/sitemap/Artalk 懒挂载

- astro-pagefind:构建期索引;SearchDialog 首开懒加载 + 200ms 防抖,
  入口=Header 钮 + 键盘 /(P0 键位集补全)
- @astrojs/rss:posts 全量(draft 排除),双语分流留 Phase 3
- @astrojs/sitemap:sitemap-index 产出
- ArtalkComments:IntersectionObserver 进入视口才拉 Artalk.js,
  pageKey=/archives/{slug}/(中英共键,评论不分裂)

验证: pnpm astro build exit 0;dist/pagefind 目录 + rss.xml +
      sitemap-index.xml 三件断言过

Spec: 2026-10-24_add-blog-skeleton
Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-10-25 00:13:29 +08:00
1 parent 945a9ee610
commit 4694428614
9 files changed
+449 -13

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
---
// Artalk 评论挂载(Phase 0 已部署 @ https://wait4x.online/artalk)
// 懒加载:进入视口才拉 Artalk.js,长文页不为其付首屏成本
interface Props {
pageKey: string; // 评论流键:中英双语页共用同一 slug(评论不分裂)
pageTitle: string;
}
const { pageKey, pageTitle } = Astro.props;
---
<div id="artalk-container" data-page-key={pageKey} data-page-title={pageTitle}></div>
<script>
const el = document.getElementById('artalk-container');
if (el) {
const mount = () => {
if (document.getElementById('artalk-lib')) return initArtalk();
const s = document.createElement('script');
s.id = 'artalk-lib';
s.src = 'https://wait4x.online/artalk/dist/Artalk.js';
s.onload = initArtalk;
document.head.appendChild(s);
};
const initArtalk = () => {
const lib = (window as unknown as { Artalk?: { init: (o: object) => void } }).Artalk;
lib?.init({
el: '#artalk-container',
server: 'https://wait4x.online/artalk',
site: 'wait4x',
pageKey: el.dataset.pageKey,
pageTitle: el.dataset.pageTitle,
locale: 'zh-CN',
});
};
// 进入视口 ~200px 前挂载;无 IO 环境直接挂
if ('IntersectionObserver' in window) {
new IntersectionObserver(
(entries, obs) => { if (entries.some((e) => e.isIntersecting)) { obs.disconnect(); mount(); } },
{ rootMargin: '200px' }
).observe(el);
} else mount();
}
</script>
+101
View File
@@ -0,0 +1,101 @@
---
// 站内搜索(Pagefind):首次打开才懒加载索引(spectre 模式,见调研报告 §4c)
// 入口:Header 搜索钮 + 键盘 `/`
---
<dialog id="search-dialog" class="search-dialog">
<input id="search-input" type="search" placeholder="搜索文章…(Esc 关闭)" autocomplete="off" />
<ul id="search-results" class="results" aria-live="polite"></ul>
<p class="search-hint">索引由 Pagefind 构建期生成 · 静态站零后端</p>
</dialog>
<script>
const dlg = document.getElementById('search-dialog') as HTMLDialogElement;
const input = document.getElementById('search-input') as HTMLInputElement;
const list = document.getElementById('search-results')!;
type PagefindLike = {
search: (q: string) => Promise<{ results: { data: () => Promise<{ url: string; excerpt: string; meta: { title: string } }> }[] }>;
};
let pf: PagefindLike | null = null;
let loadStarted = false;
async function ensureIndex() {
if (loadStarted) return;
loadStarted = true;
pf = (await import(/* @vite-ignore */ '/pagefind/pagefind.js')) as unknown as PagefindLike;
}
function open() {
dlg.showModal();
input.value = '';
list.innerHTML = '';
void ensureIndex();
input.focus();
}
document.querySelector('[data-search-trigger]')?.addEventListener('click', open);
document.addEventListener('keydown', (e) => {
const inField = document.activeElement instanceof HTMLElement &&
(document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA');
if (e.key === '/' && !inField) { e.preventDefault(); open(); }
});
let timer: number | undefined;
input.addEventListener('input', () => {
clearTimeout(timer);
timer = window.setTimeout(async () => {
if (!pf) return;
const q = input.value.trim();
if (!q) { list.innerHTML = ''; return; }
const res = await pf.search(q);
list.innerHTML = '';
for (const r of res.results.slice(0, 8)) {
const d = await r.data();
const li = document.createElement('li');
const a = document.createElement('a');
a.href = d.url;
a.className = 'tap';
a.innerHTML = `<strong>${d.meta.title}</strong><span>${d.excerpt}</span>`;
li.appendChild(a);
list.appendChild(li);
}
if (!list.children.length) list.innerHTML = '<li class="empty">无结果</li>';
}, 200);
});
</script>
<style>
.search-dialog {
margin-top: 12vh;
width: min(560px, calc(100vw - 32px));
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg);
color: var(--ink-2);
padding: var(--space-3);
}
.search-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
input[type='search'] {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg-soft);
font-size: 15px;
}
.results { list-style: none; padding: 0; margin: var(--space-2) 0 0; max-height: 50vh; overflow-y: auto; }
.results a {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-2) var(--space-3);
border-radius: 8px;
text-decoration: none;
}
.results a:hover { background: var(--bg-soft); }
.results strong { color: var(--ink-1); font-size: 14px; }
.results span { font-size: 12px; color: var(--ink-3); }
.results a span :global(mark) { background: var(--accent-soft); }
.empty { padding: var(--space-3); font-size: 13px; color: var(--ink-3); }
.search-hint { margin-top: var(--space-2); font-size: 11px; color: var(--ink-4); text-align: center; font-family: var(--font-mono); }
</style>
+2
View File
@@ -12,6 +12,7 @@ import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import ThemeScript from '../components/ThemeScript.astro';
import Keyboard from '../components/geek/Keyboard.astro';
import SearchDialog from '../components/SearchDialog.astro';
import BuildInfo from '../components/geek/BuildInfo.astro';
import { ClientRouter } from 'astro:transitions';
@@ -47,5 +48,6 @@ const { title, description = 'wait4x 的博客:阅读 · 技术 · 摄影', tr
<BuildInfo slot="build-info" />
</Footer>
<Keyboard />
<SearchDialog />
</body>
</html>
+2 -2
View File
@@ -2,6 +2,7 @@
import type { CollectionEntry } from 'astro:content';
import { getCollection, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import ArtalkComments from '../../components/ArtalkComments.astro';
import { trackOf } from '../../content.config';
// URL = /archives/{slug},slug 即文件名,与 Halo 旧结构 1:1(不可改)
@@ -104,8 +105,7 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
)}
</nav>
<!-- T8 填充:在此容器注入 Artalk 脚本,data-page-key 即文章 id -->
<div id="artalk-container" data-page-key={entry.id}></div>
<ArtalkComments pageKey={`/archives/${entry.id}/`} pageTitle={entry.data.title} />
</BaseLayout>
<script>
+21
View File
@@ -0,0 +1,21 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
// RSS:posts 集合全量(draft 排除);Phase 3 起双语各出一路(/rss.xml 与 /en/rss.xml)
export async function GET(context: APIContext) {
const posts = (await getCollection('posts', ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
return rss({
title: 'wait4x',
description: '阅读 · 技术 · 摄影',
site: context.site ?? 'https://wait4x.online',
items: posts.map((p) => ({
title: p.data.title,
pubDate: p.data.pubDate,
description: p.data.tags.join(', '),
link: `/archives/${p.id}/`,
})),
trailingSlash: true,
});
}