CitrusGrid 有两套[同名文件 + 契约]的可插拔机制,加功能不需要改框架代码:

插件目录配置项运行时内置实现
src/comments/siteConfig.commentScriptsrc/comments/client.tsutterances.ts
src/stats/siteConfig.statsScriptsrc/stats/client.tsvisitor-stats.ts、random-visitor.ts

另有右下角音乐播放器(MusicPlayer.astro),它不是插件但也在本文档说明。

仓库内已有两份详细契约文档,本文是「二次开发视角」的整合版(含参数表与改造清单):

  • src/comments/使用规则.md
  • src/stats/使用规则.md

1. 插件加载机制(评论 / 统计共用的模式)

ts
12345678910111213141516171819
// 以评论为例
// 评论(排除掉运行时自身 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 + 停用,不影响页面其他部分
⚠️ 停用插件仍会产出 chunkimport.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 启用与停用

ts
123
// 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❌() => voidSwup 换页、旧 DOM 销毁前清理(定时器/监听器),多数适配器无需实现

错误处理约定:用 throw 表达失败,不要返回 null/false。

无默认导出,全部使用命名导出。

2.3 运行时参数(client.ts 顶部常量)

常量值作用
LOAD_MARGIN'300px 0px'IntersectionObserver 的 rootMargin:评论区距视口 300px 时开始加载适配器
SKELETON_TIMEOUT8000(ms)骨架兜底超时:适配器已挂载但迟迟渲染不出内容时强制撤骨架

2.4 挂载点与状态机

Comments.astro 渲染的结构:

html
1234
<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 对象(这是唯一需要改的地方):

字段类型默认值说明
repostring''必填。接收评论的公开 GitHub 仓库,格式 '用户名/仓库名',需在该仓库安装 utterances App。留空 → mountComment 抛错 → 评论区隐藏
issueTermstring'pathname'评论与页面的映射方式:pathname(推荐)/ url / title / og:title / 自定义 issue 编号
labelstring''可选,给自动创建的 Issue 打标签(标签需已存在于仓库)。留空不打
lightThemestring'github-light'亮色主题,可选值见 utteranc.es
darkThemestring'github-dark'暗色主题

实现要点:

ts
1234567891011121314151617181920
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

ts
123456789101112131415161718192021222324252627282930313233343536373839
// 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 启用与停用

ts
1234
// src/site.config.ts
statsScript: 'visitor-stats',   // 正式:需自建统计服务
statsScript: 'random-visitor',  // 测试:随机数字(出厂默认,上线前请改掉)
statsScript: '',                // 停用

只在生产环境生效

BaseLayout.astro 的条件:

astro
123
data-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_GAP150(ms)相邻请求最小间隔(串行队列限速)
缓存 keystats: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() 的逻辑:

ts
123456789101112
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_DELAY300 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_TTL5 * 60_000插件自身的 localStorage 缓存 TTL
TOTAL_CACHE_KEY'visitor-stats:total'缓存 key(localStorage,跨标签页共享)

消费的服务端接口:

接口方法响应
/log?path=…GET埋点上报(keepalive: true)
/totalGET{ 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#六的完整骨架。最小示例:

ts
12345678910111213141516171819202122
// 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.astroconst 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 开关与配置

ts
12345
// src/site.config.ts
musicPlayer: {
  enabled: true,           // false 时整个播放器不渲染
  playlist: [] as MusicTrack[],
},

4.2 播放列表的两种来源

优先级条件行为
1playlist.length > 0使用显式列表(自定义标题、封面、顺序)
2playlist 为空构建期扫描 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')(按中文文件名排序)
titlepath.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_LIMIT2 * 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 换页后表现正常