CitrusGrid 有两套[同名文件 + 契约]的可插拔机制,加功能不需要改框架代码:
| 插件目录 | 配置项 | 运行时 | 内置实现 |
|---|---|---|---|
src/comments/ | siteConfig.commentScript | src/comments/client.ts | utterances.ts |
src/stats/ | siteConfig.statsScript | src/stats/client.ts | visitor-stats.ts、random-visitor.ts |
另有右下角音乐播放器(MusicPlayer.astro),它不是插件但也在本文档说明。
仓库内已有两份详细契约文档,本文是「二次开发视角」的整合版(含参数表与改造清单):
src/comments/使用规则.mdsrc/stats/使用规则.md
1. 插件加载机制(评论 / 统计共用的模式)
// 以评论为例
// 评论(排除掉运行时自身 client.ts)
const adapterModules = import.meta.glob<CommentAdapter>(['./*.ts', '!./client.ts'])
// 统计(未排除 client.ts,写成 statsScript: 'client' 会加载到运行时自身,故不要这么做)
// const pluginModules = import.meta.glob<StatsPlugin>('./*.ts')
const adapterCache = new Map<string, Promise<CommentAdapter | null>>()
function loadAdapter(name: string) {
let cached = adapterCache.get(name)
if (!cached) {
cached = (async () => {
const importer = adapterModules[`./${name}.ts`]
if (!importer) { console.warn(`未找到 …${name}.ts,已停用`); return null }
…
})()
adapterCache.set(name, cached)
}
return cached
}
| 机制 | 说明 |
|---|---|
| 解析方式 | import.meta.glob 相对路径 glob,key 形如 ./utterances.ts |
| 安全性 | 名字对不上即取不到 → 天然免疫路径穿越(配置项不能指向目录外的文件) |
| 代码拆分 | 每个插件是独立 chunk,只加载被指名的那一个 |
| 会话级缓存 | 同一插件只加载一次,Swup 换页复用(Map 缓存 Promise) |
| 失败降级 | 找不到 / 无有效导出 / 抛错 → console.warn + 停用,不影响页面其他部分 |
| ⚠️ 停用插件仍会产出 chunk | import.meta.glob 在 buildStart 期静态展开,Rollup 会为每个匹配到的插件文件产出独立 chunk 到 dist/_astro/——即使它没被 site.config.ts 指名。区别只在运行时永远不请求它。实测:commentScript: ''、statsScript: 'random-visitor' 时 dist/_astro/ 里仍有 utterances.*.js 与 visitor-stats.*.js |
插件导出的一切都会进浏览器包
尽量不要在插件文件里嵌入密钥、Token。评论系统所需的通常都是公开值;
visitor-stats.ts里的STATS_API_KEY注释也明确说明「不用担心泄露,worker 增加域名识别,仅在当前域名下有效」。
2. 评论插件
2.1 启用与停用
// src/site.config.ts
commentScript: 'utterances', // 启用 src/comments/utterances.ts
commentScript: '', // 停用(默认)
2.2 适配器接口契约(CommentAdapter)
源码定义在 src/comments/client.ts 第 22–29 行:
| 导出 | 必选 | 签名 | 说明 |
|---|---|---|---|
mountComment | ✅ | (container: HTMLElement) => void | Promise<void> | 把评论渲染进容器。配置缺失请直接 throw,运行时会隐藏整个评论区 |
onThemeChange | ❌ | (dark: boolean) => void | 主题切换回调,由运行时监听全局 theme-change 转发 |
unmountComment | ❌ | () => void | Swup 换页、旧 DOM 销毁前清理(定时器/监听器),多数适配器无需实现 |
错误处理约定:用 throw 表达失败,不要返回 null/false。
无默认导出,全部使用命名导出。
2.3 运行时参数(client.ts 顶部常量)
| 常量 | 值 | 作用 |
|---|---|---|
LOAD_MARGIN | '300px 0px' | IntersectionObserver 的 rootMargin:评论区距视口 300px 时开始加载适配器 |
SKELETON_TIMEOUT | 8000(ms) | 骨架兜底超时:适配器已挂载但迟迟渲染不出内容时强制撤骨架 |
2.4 挂载点与状态机
Comments.astro 渲染的结构:
<section class="comment-section is-loading" data-comment-script="utterances" aria-label="Comments">
<div class="comment-skeleton" aria-hidden="true"><span/><span/><span/></div>
<div class="comment-mount"></div> <!-- ← 适配器唯一允许写入的容器 -->
</section>
| class | 何时添加 | 样式效果(comments.css) |
|---|---|---|
is-loading | 只在服务端渲染时由模板写上(CSS 里没有对应规则,代码也从不摘除它) | 骨架是 comments.css 的默认显示状态,不依赖这个类 |
is-loaded | .comment-mount 出现首个非 <script> 子节点 | 隐藏骨架 |
is-error | 适配器加载/挂载失败 | 整个评论区 display: none |
<script>被特意排除:utterances 会先注入client.js引导脚本,iframe 随后才由该脚本创建。若把 script 当作「渲染完成」,骨架会过早消失。
2.5 内置实现:utterances.ts 配置参数
文件顶部 config 对象(这是唯一需要改的地方):
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
repo | string | '' | 必填。接收评论的公开 GitHub 仓库,格式 '用户名/仓库名',需在该仓库安装 utterances App。留空 → mountComment 抛错 → 评论区隐藏 |
issueTerm | string | 'pathname' | 评论与页面的映射方式:pathname(推荐)/ url / title / og:title / 自定义 issue 编号 |
label | string | '' | 可选,给自动创建的 Issue 打标签(标签需已存在于仓库)。留空不打 |
lightTheme | string | 'github-light' | 亮色主题,可选值见 utteranc.es |
darkTheme | string | 'github-dark' | 暗色主题 |
实现要点:
const UTTERANCES_ORIGIN = 'https://utteranc.es'
export function mountComment(container: HTMLElement): void {
if (!config.repo) throw new Error('[comments] utterances.repo 未配置,请编辑 src/comments/utterances.ts 填写 repo')
const script = document.createElement('script')
script.src = `${UTTERANCES_ORIGIN}/client.js`
script.async = true
script.crossOrigin = 'anonymous'
script.setAttribute('repo', config.repo)
script.setAttribute('issue-term', config.issueTerm)
if (config.label) script.setAttribute('label', config.label)
script.setAttribute('theme', utterancesTheme(isDark()))
container.appendChild(script)
}
export function onThemeChange(dark: boolean): void {
document.querySelectorAll<HTMLIFrameElement>('.utterances iframe').forEach((iframe) => {
iframe.contentWindow?.postMessage({ type: 'set-theme', theme: utterancesTheme(dark) }, UTTERANCES_ORIGIN)
})
}
2.6 接入新评论系统(以 Waline 为例)
第 1 步:新建 src/comments/waline.ts
// src/comments/waline.ts —— site.config.ts 填 commentScript: 'waline' 即启用
// ========== 配置区 ==========
const config = {
serverURL: '', // 必填:Waline 服务端地址,如 https://waline.example.com
lang: 'zh-CN',
dark: false, // 是否使用内置暗色
meta: ['nick', 'mail', 'link'],
requiredMeta: ['nick'],
}
// ============================
let instance: { update?: (opts: object) => void } | null = null
export async function mountComment(container: HTMLElement): Promise<void> {
if (!config.serverURL) throw new Error('[comments] waline.serverURL 未配置')
// 动态注入一个带 ESM 的初始化脚本
await new Promise<void>((resolve, reject) => {
const s = document.createElement('script')
s.type = 'module'
s.src = `${config.serverURL}/client.js`
s.onload = () => resolve()
s.onerror = () => reject(new Error('[comments] Waline 客户端加载失败'))
container.appendChild(s)
const init = document.createElement('script')
init.type = 'module'
init.textContent = `
import { init } from '${config.serverURL}/client.js'
window.__waline = init({ el: '.comment-mount', serverURL: '${config.serverURL}', lang: '${config.lang}', dark: ${document.documentElement.classList.contains('dark')} })
`
container.appendChild(init)
})
}
export function onThemeChange(dark: boolean): void {
// Waline 的 dark 选项在 init 时固定,需要重新 init 或改 CSS 变量;
// 最简单的方式是让 Waline 自己跟随 prefers-color-scheme,或在这里切换 .dark 类
document.querySelector('.wl-panel')?.classList.toggle('wl-dark', dark)
}
第 2 步:site.config.ts 填 commentScript: 'waline'
第 3 步:检查评论区的 CSS(可选)
comments.css 只定义了骨架与状态类,不约束插件内容的样式。若需要与主题一致的排版,建议在 src/styles/ 新增 waline.css 并在适配器里 import '@/styles/waline.css'(适配器的 CSS 会被打包进它的 chunk)。
2.7 已接入的页面
| 页面 | 位置 |
|---|---|
| 文章页 | PostPage.astro 的 <Comments />(在上一篇/下一篇导航之后) |
| 友链页 | FriendsPage.astro 末尾的 <Comments /> |
给其他页面加评论
直接在页面组件里
<Comments />即可(例如 07 文档里的留言板示例)。
3. 统计插件
3.1 启用与停用
// src/site.config.ts
statsScript: 'visitor-stats', // 正式:需自建统计服务
statsScript: 'random-visitor', // 测试:随机数字(出厂默认,上线前请改掉)
statsScript: '', // 停用
只在生产环境生效
BaseLayout.astro的条件:astro123data-stats-script={(siteConfig.statsScript && import.meta.env.PROD ? siteConfig.statsScript : undefined)} … {siteConfig.statsScript && import.meta.env.PROD && <script>import '@/stats/client'</script>}注意两件事的开关不同:
项 控制条件 挂载点( -占位与「次 / 人」单位)是否渲染只看 Boolean(siteConfig.statsScript),与 PROD 无关客户端脚本是否注入、 <html data-stats-script>是否设置siteConfig.statsScript && import.meta.env.PROD所以
pnpm dev下挂载点照常渲染(只要statsScript非空,默认的'random-visitor'就满足),但脚本不注入,数字永远停在-,也不会发出任何统计请求。要看到真实数字必须pnpm build && pnpm preview。只有把
statsScript留空('')时,挂载点才完全不渲染。与 04-站点配置.md#2.9 的描述一致。
3.2 插件接口契约(StatsPlugin)
三个函数全部可选,但至少要导出一个(否则客户端警告并停用):
| 导出 | 签名 | 作用 |
|---|---|---|
trackVisit | (path?: string) => void | 每次页面加载调用一次的上报。fire-and-forget:内部必须吞掉所有错误,不得抛异常 |
getSiteTotal | () => Promise<{ siteTotal: number; siteUnique: number }> | 全站累计 PV/UV |
getArticleStats | (path: string) => Promise<{ articleTotal: number; articleUnique: number; siteTotal?: number; siteUnique?: number }> | 单篇累计;返回值可顺带带上全站数据以省掉一次请求 |
约定:
| 项 | 要求 |
|---|---|
path 格式 | 规范路径,如 /posts/hello-world(固定以 /posts/ 开头,与语言前缀无关) |
| 失败表达 | throw(Promise reject),不要返回 -1/null |
| 运行环境 | 浏览器,可用 fetch,不要用 Node API |
| 无默认导出 | 全部命名导出 |
3.3 运行时参数(client.ts 顶部常量)
| 常量 | 值 | 作用 |
|---|---|---|
CACHE_TTL | 生产 5 * 60_000(5 分钟)/ dev 60_000(1 分钟) | sessionStorage 缓存有效期;成功与失败都缓存(失败为负缓存,TTL 内不重试) |
REQUEST_GAP | 150(ms) | 相邻请求最小间隔(串行队列限速) |
| 缓存 key | stats:site、stats:art:{path} | sessionStorage 前缀 stats: |
三层节约请求的设计:
| 层 | 机制 |
|---|---|
| 1. 缓存 | sessionStorage + TTL;失败也缓存 |
| 2. 复用 | getArticleStats 返回的 siteTotal/siteUnique 直接写进 stats:site 缓存,省掉一次请求 |
| 3. 限速 | enqueue() 串行队列,相邻请求间隔 ≥150ms |
3.4 挂载点协议
| 挂载点 | 位置 | 填充内容 |
|---|---|---|
data-stats="site-pv" / "site-uv" | ProfileCard.astro | 全站累计 PV / UV(仅 statsScript 非空时渲染) |
data-stats="article-pv" / "article-uv",且祖先元素带 data-stats-path="/posts/xxx" | PostCard.astro 徽标、PostPage.astro 头部 | 该文章累计 PV / UV |
客户端 collect() 的逻辑:
document.querySelectorAll('[data-stats]').forEach((el) => {
const path = el.closest('[data-stats-path]')?.dataset.statsPath // ← 用最近祖先取 path
switch (el.dataset.stats) {
case 'site-pv': site.pv.push(el); break
case 'site-uv': site.uv.push(el); break
case 'article-pv':
case 'article-uv': {
if (!path) return // ← 无 path 则忽略
…按 path 分组
}
}
})
| 行为 | 说明 |
|---|---|
| 初始值 | 服务端渲染为 - |
| 成功 | 用 toLocaleString(document.documentElement.lang) 本地化数字 |
| 失败 | 显式写回 - |
自己写挂载点时:
data-stats-path必须在祖先元素上(closest()向上查找)。写成兄弟节点不会被识别。
3.5 内置实现:random-visitor.ts(测试用)
| 常量 | 默认值 | 作用 |
|---|---|---|
FAKE_DELAY | 300 ms | 人为延迟,便于观察「-」到数字的过渡 |
SITE_PV_DIGITS | [4, 6] | 随机位数范围 |
SITE_UV_DIGITS | [3, 5] | — |
ARTICLE_PV_DIGITS | [1, 4] | — |
ARTICLE_UV_DIGITS | [1, 4] | — |
trackVisit 是空函数。数字在 TTL 内保持不变,清 sessionStorage 或等 TTL 过期才会变。
3.6 内置实现:visitor-stats.ts(正式)
需要自行部署 visitor-stats 的 Cloudflare Worker 服务。
| 配置项 | 默认值 | 说明 |
|---|---|---|
STATS_BASE | ''(空 → 同域相对路径) | 统计服务基址,如 https://stats.example.com |
STATS_API_KEY | '' | 仅 /stats 实时接口需要;注释说明 worker 有域名识别,泄露风险低 |
TOTAL_CACHE_TTL | 5 * 60_000 | 插件自身的 localStorage 缓存 TTL |
TOTAL_CACHE_KEY | 'visitor-stats:total' | 缓存 key(localStorage,跨标签页共享) |
消费的服务端接口:
| 接口 | 方法 | 响应 |
|---|---|---|
/log?path=… | GET | 埋点上报(keepalive: true) |
/total | GET | { siteTotal, siteUnique, siteLastUpdated, articles: [{ path, articleTotal, articleUnique, articleLastUpdated }] } |
/stats?period=today&path=… | GET | 实时数据,需 Authorization: Bearer <STATS_API_KEY> |
关键设计:
| 设计 | 说明 |
|---|---|
只走 /total | 一次请求拿全站 + 全部文章,避免逐篇请求造成 429 |
| 模块级 in-flight 去重 | totalPromise ??= …,失败时清空以允许重试 |
| localStorage 缓存 | 跨标签页共享;换页/预取/新开标签 0 请求 |
normalizePath() | 去掉尾斜杠(根路径除外)。原因:托管平台会把 /posts/x 重定向为 /posts/x/,location.pathname 带斜杠而 data-stats-path 不带,不归一化会统计失配 |
| 未收录文章 | 返回 0 而不是报错(新发布文章还没有埋点数据) |
3.7 接入自建统计服务
见 src/stats/使用规则.md#六的完整骨架。最小示例:
// src/stats/my-stats.ts —— site.config.ts 填 statsScript: 'my-stats' 即启用
export function trackVisit(path: string = location.pathname) {
fetch(`https://api.example.com/log?path=${encodeURIComponent(path)}`).catch(() => {})
}
export function getSiteTotal() {
return fetch('https://api.example.com/site')
.then((r) => r.json())
.then((d) => ({ siteTotal: d.views, siteUnique: d.visitors }))
}
export function getArticleStats(path: string) {
return fetch(`https://api.example.com/page?path=${encodeURIComponent(path)}`)
.then((r) => r.json())
.then((d) => ({
articleTotal: d.views,
articleUnique: d.visitors,
siteTotal: d.siteViews, // 可选,能带就带(省一次请求)
siteUnique: d.siteVisitors,
}))
}
建议:服务端接口加短缓存(如 Cache-Control: public, max-age=60),与客户端 TTL 形成双层减压。
3.8 显示位置的开关
统计挂载点由 Boolean(siteConfig.statsScript) 控制:
| 组件 | 变量 | 影响 |
|---|---|---|
ProfileCard.astro | const statsEnabled = Boolean(siteConfig.statsScript) | 控制名片上的全站 PV/UV 两项 |
PostCard.astro | 同上 | 控制卡片右下角的浏览/访客徽标 |
PostPage.astro | 同上 | 控制文章头部「阅读时间」之后的 PV/UV |
只想显示全站统计、不显示文章统计:
保留
statsScript,然后从PostCard.astro与PostPage.astro中删掉statsEnabled &&那几段 JSX 即可(注意data-stats-path容器也要一起删)。
4. 音乐播放器
4.1 开关与配置
// src/site.config.ts
musicPlayer: {
enabled: true, // false 时整个播放器不渲染
playlist: [] as MusicTrack[],
},
4.2 播放列表的两种来源
| 优先级 | 条件 | 行为 |
|---|---|---|
| 1 | playlist.length > 0 | 使用显式列表(自定义标题、封面、顺序) |
| 2 | playlist 为空 | 构建期扫描 public/music/(discoverPlaylist()) |
自动扫描参数(MusicPlayer.astro 源码第 28–45 行)
| 项 | 值 |
|---|---|
AUDIO_EXTS | .flac .mp3 .m4a .ogg .wav .opus .aac .webm |
| 目录 | path.resolve(process.cwd(), 'public', 'music')(不存在则返回 [],不报错) |
| 排序 | a.localeCompare(b, 'zh')(按中文文件名排序) |
title | path.basename(f, path.extname(f)) |
src | /music/${encodeURIComponent(f)} |
cover | 不设置 → 客户端从音频元数据提取 |
4.3 MusicTrack 字段
| 字段 | 必填 | 说明 |
|---|---|---|
title | ✅ | 播放列表显示名 |
src | ✅ | public/ 下的静态路径(以 / 开头)或完整 http(s) URL |
cover | ❌ | 封面 URL;留空时尝试从音频元数据提取,提取失败则显示音符图标 |
4.4 封面自动提取(src/lib/audioCover.ts)
| 项 | 值 |
|---|---|
FETCH_LIMIT | 2 * 1024 * 1024(2MB) |
| 请求方式 | fetch(url, { cache: 'no-store', headers: { Range: 'bytes=0-2097151' } }) |
| FLAC | 解析 fLaC 魔数后的元数据块,找 type 6(PICTURE) |
| MP3 | 解析 ID3 标签,找 APIC 帧(支持 enc 0/1/2/3) |
| 结果 | URL.createObjectURL(blob),模块级 Map 缓存(同一 src 只解析一次) |
| 失败 | 返回 null → 显示音符图标(不报错) |
cache: 'no-store'是刻意为之。源码注释:该 fetch 只读取文件头字节提取封面,若走 HTTP 缓存会与
<audio>媒体栈共用同一缓存条目,偶发 Chromium 的net::ERR_CACHE_OPERATION_NOT_SUPPORTED。不要「优化」掉这个选项。
4.5 可调参数速查
| 想改什么 | 位置 |
|---|---|
| 支持更多音频格式 | MusicPlayer.astro 的 AUDIO_EXTS |
| 扫描目录 | discoverPlaylist() 里的 path.resolve(process.cwd(), 'public', 'music') |
| 排序规则 | discoverPlaylist() 的 .sort(...) |
| 默认音量 | MusicPlayer.astro 脚本中的音量初始化 |
| 播放器外观 | src/styles/music-player.css |
| 界面文案 | src/i18n/*.json 的 player 块 |
| 播放器位置 | .music-fab 由 reposition() 固定在右下底部槽位(桌面 32 / 移动 64),菜单触发器随之挪到音乐按钮正上方 |
5. 插件开发自检清单
- 文件名即配置值(
commentScript: 'waline'↔src/comments/waline.ts) - 只使用命名导出,没有
export default - 必选函数缺失时直接 throw,不用返回值表达失败
- 只使用浏览器 API(不要
node:fs等) - 没有把密钥写进会被打包的代码
- 主题相关:
mountComment里按当前document.documentElement.classList.contains('dark')初始化,并导出onThemeChange - 有全局定时器/监听器时导出
unmountComment清理 - 统计插件的
trackVisit内部静默失败(.catch(() => {})) - 统计插件的
getArticleStats尽量顺带返回siteTotal/siteUnique - 统计插件对 path 做尾斜杠归一化
-
pnpm build && pnpm preview下验证(dev 环境统计不注入) - 切换明暗主题、Swup 换页后表现正常