日历、目录与站点统计小部件
Mizuki 侧边栏中的三个信息型小部件——文章日历(calendar)、卡片式目录(card-toc)与站点统计(site-stats)——它们共同承担"让读者快速了解站点内容规模与结构"的职责。其中站点统计完全在构建期计算,日历小部件采用 Astro 静态外壳 + Svelte 交互岛的混合架构。
Purpose and Scope
本页覆盖 src/components/widgets/ 目录下三个小部件子模块的架构、数据来源与实现细节:
- calendar/:文章日历小部件,包含 Astro 外壳、Svelte 交互岛、7 个子组件、状态钩子、工具函数与类型定义,以及配套的数据端点
src/pages/api/calendar-data.json.ts。 - card-toc/:卡片式文章目录(Table of Contents)小部件。
- site-stats/:站点统计小部件(文章数、分类数、标签数、总字数、运行天数、最后更新)。
以下相邻主题有意留给兄弟页面,本页不展开:
- 公告板小部件(announcement/)与分类小部件(categories/)——见各自的小部件页面。
- 个人资料小部件(profile/)中的 Umami 访问量统计(
fetchSiteStats/window.oddmisc.getSiteStats)——那是运行时访问统计,与本文的构建期内容统计是两套不同机制。 WidgetLayout等通用布局组件的完整设计——本文仅在"如何被小部件消费"的层面引用它。
Overview
Mizuki 是基于 Astro 的博客主题,侧边栏小部件位于 src/components/widgets/ 下,每个小部件一个子目录,并通过 index.ts 对外导出。三个小部件的工作模式各不相同:
| 小部件 | 目录 | 渲染模式 | 数据来源 |
|---|---|---|---|
| 站点统计 | site-stats/ | 纯静态(Astro frontmatter 构建期计算) | 内容集合(getSortedPosts 等) |
| 文章日历 | calendar/ | Astro 外壳 + Svelte 客户端交互岛 | /api/calendar-data.json 端点 |
| 卡片目录 | card-toc/ | Astro 静态渲染 | 当前文章标题结构 |
关键概念:
- 构建期统计 vs 客户端动态统计:站点统计里"文章数/分类数/标签数/总字数"在构建时算死并直接输出到 HTML;而"运行天数/最后更新距今天数"因随时间流逝而变化,构建期占位、客户端脚本更新(见下文
dynamic: true标记)。 - CJK 混合字数统计:中文按字符计数、西文按空白分词计数,且统计前先剔除代码块,保证字数反映"正文体量"而非"代码体量"。
- Astro + Svelte 混合岛架构:日历小部件的静态部分(容器、无 JS 降级外观)由 Astro 渲染,交互部分(切月、选年、点击日期看文章列表)由 Svelte 组件在客户端接管。
Architecture
架构要点说明:
- 三个小部件都通过
WidgetLayout这个公共外壳获得统一的卡片外观、name/id/class/style透传能力,这是小部件层唯一的横向复用点。 SiteStats.astro是纯 Astro 组件:所有统计在 frontmatter 中await完成,直接输出静态 HTML,无客户端框架运行时(仅一段轻量脚本更新两项动态天数)。- 日历小部件内部
Calendar.svelte与各子组件、useCalendar钩子、calendarUtils工具、types/calendar.ts类型共同构成一个完整的 Svelte 子系统;types.ts/components/index.ts/index.ts提供模块的桶式导出。图中日历模块内部箭头基于目录结构与命名约定的模块级描述,具体调用细节请以源文件为准。 - 日历数据走
src/pages/api/calendar-data.json.ts独立端点(虚线表示客户端 fetch),而非构建期内联——这让日历数据可以按需加载。
站点统计的数据流向
SiteStats.astro 的执行被明确切分为构建期与客户端两个阶段,这一分界是理解该组件的关键:
- 构建期产出 6 个统计项,其中 4 个为静态值;
value: 0加注释"将由客户端更新"的两项(运行天数、距最后更新天数)依赖siteStartDate与lastPostDate在浏览器中随当前时间计算。 lastPostDate的计算刻意忽略置顶状态、只按published排序——源码注释明确写了这一点,保证"最后更新"反映真实发布时间线而非置顶干预。
实现细节:站点统计的构建期计算
以下摘录展示 SiteStats.astro frontmatter 中完整的内容统计管线(读取数据 → 清洗正文 → CJK/非 CJK 分别计数 → 汇总):
1import { siteConfig } from "../../../config";
2import I18nKey from "../../../i18n/i18nKey";
3import { i18n } from "../../../i18n/translation";
4import {
5 getCategoryList,
6 getSortedPosts,
7 getTagList,
8} from "../../../utils/content-utils";
9import WidgetLayout from "../common/WidgetLayout.astro";
10
11interface Props {
12 class?: string;
13 style?: string;
14}
15
16// 从配置中获取站点开始日期
17const siteStartDate = siteConfig.siteStartDate || "2025-01-01";
18
19// 获取所有文章
20const posts = await getSortedPosts();
21const categories = await getCategoryList();
22const tags = await getTagList();Source: SiteStats.astro
设计意图说明:
- Props 极简:只接受
class与style,说明该小部件是"自给自足"的——它从内容集合自行取数,不接受外部注入数据,这让它可以出现在任何侧边栏位置而不需要父级配合。 siteStartDate带兜底默认值"2025-01-01":即使主题使用者的config未配置站点起始日,组件也不会构建失败,只是运行天数从一个估计值起算。这是典型的容错式配置消费。
CJK 混合字数统计算法
1// 计算总字数
2let totalWords = 0;
3for (const post of posts) {
4 if (post.body) {
5 let text = post.body;
6
7 // 移除代码块
8 text = text.replace(/```[\s\S]*?```/g, "");
9 // 移除 ` 包裹的行内代码
10 text = text.replace(/`[^`]+`/g, "");
11
12 // 使用与 remark-content.mjs 完全一致的 CJK 正则
13 const cjkPattern =
14 /[\u4e00-\u9fa5\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af\u3000-\u303f\uff00-\uffef]/g;
15
16 const cjkMatches = text.match(cjkPattern);
17 const cjkCount = cjkMatches ? cjkMatches.length : 0;
18
19 // 计算非 CJK 单词数
20 const nonCjkText = text.replace(cjkPattern, " ");
21
22 // 按空白字符分割,计算单词数量
23 const nonCjkWords = nonCjkText
24 .split(/\s+/)
25 .filter((word) => word.trim().length > 0);
26
27 totalWords += cjkCount + nonCjkWords.length;
28 }
29}Source: SiteStats.astro
算法逐步解读:
- 先剔除代码:
/```[\s\S]*?```/g用非贪婪模式移除围栏代码块,/[^]+/g移除行内代码。**为何先做这一步**:若先分词再剔除,代码中的标识符(如useState、fooBar`)会被算作"单词",导致字数虚高,误导读者对正文体量的判断。 - CJK 字符计数:正则覆盖
U+4E00–9FA5(汉字)、U+3040–309F(平假名)、U+30A0–30FF(片假名)、U+AC00–D7AF(谚文)、U+3000–303F(CJK 标点)、U+FF00–FFEF(全角字符)。源码注释强调"使用与 remark-content.mjs 完全一致的 CJK 正则"——这保证小部件显示的总字数与文章阅读页的字数算法口径一致,读者不会在不同页面看到相互矛盾的数字。 - 非 CJK 分词:先把 CJK 字符替换为空格,再
split(/\s+/)并过滤空串。为何替换而不是分开匹配:替换法天然处理了中英混排的边界(你好world会拆成"你好"两个 CJK 字符 + "world" 一个单词)。 post.body存在性检查:草稿或空文件等body为空的条目被跳过,构建不报错。
最后更新时间的计算
1// 获取最新文章日期(忽略置顶状态,只按发布日期排序)
2const latestPost = posts.reduce((latest, post) => {
3 if (!latest) {
4 return post;
5 }
6 return post.data.published > latest.data.published ? post : latest;
7}, posts[0]);
8
9const lastPostDate = latestPost
10 ? latestPost.data.published.toISOString()
11 : null;Source: SiteStats.astro
注意 reduce 初值为 posts[0],配合 if (!latest) 分支处理数组;空列表时 latestPost 为 undefined,lastPostDate 落到 null——这是无文章站点的边界保护。
实现细节:stats 数组与动态项
1const stats = [
2 {
3 icon: "material-symbols:article-outline",
4 label: i18n(I18nKey.siteStatsPostCount),
5 value: posts.length,
6 },
7 {
8 icon: "material-symbols:folder-outline",
9 label: i18n(I18nKey.siteStatsCategoryCount),
10 value: categories.length,
11 },
12 {
13 icon: "material-symbols:label-outline",
14 label: i18n(I18nKey.siteStatsTagCount),
15 value: tags.length,
16 },
17 {
18 icon: "material-symbols:text-ad-outline-rounded",
19 label: i18n(I18nKey.siteStatsTotalWords),
20 value: totalWords,
21 formatted: true,
22 },
23 {
24 icon: "material-symbols:calendar-clock-outline",
25 label: i18n(I18nKey.siteStatsRunningDays),
26 value: 0, // 将由客户端更新
27 suffix: i18n(I18nKey.siteStatsDays).replace("{days}", ""),
28 dynamic: true,
29 id: "running-days",
30 },
31 {
32 icon: "material-symbols:ecg-heart-outline",
33 label: i18n(I18nKey.siteStatsLastUpdate),
34 value: 0, // 将由客户端更新
35 suffix: i18n(I18nKey.siteStatsDaysAgo).replace("{days}", ""),
36 dynamic: true,
37 id: "last-update",
38 },
39];Source: SiteStats.astro
数据驱动渲染的设计意图:6 个统计项被统一为同构的描述对象数组,模板层只需一次 stats.map() 即可渲染整列表格,新增一项统计只需在数组里加对象,无需改模板。其中三类标记值得注意:
formatted: true:总字数用toLocaleString()加千位分隔符(如1,234,567),提升大数字可读性。suffix:通过i18n(I18nKey.siteStatsDays).replace("{days}", "")从带占位符的翻译串里剥出单位后缀——这样单位词随语言切换,而数字部分独立。dynamic: true+id:客户端脚本通过id="running-days"/id="last-update"定位 DOM 节点并回填天数,构建期统一占位为0。
实现细节:渲染外壳
1<WidgetLayout
2 name={i18n(I18nKey.siteStats)}
3 id="site-stats"
4 class={className}
5 style={style}
6>
7 <div class="flex flex-col gap-1">
8 {
9 stats.map((stat) => (
10 <div class="flex items-center justify-between px-2 py-2">
11 <div class="flex items-center gap-2.5 flex-1 min-w-0">
12 <div class="text-(--primary) text-xl shrink-0">
13 <Icon name={stat.icon} />
14 </div>Source: SiteStats.astro
模板将 class/style 原样透传给 WidgetLayout,组件自身不做样式拦截;行内布局采用 Tailwind 原子类(flex items-center justify-between px-2 py-2),图标用 astro-icon 的 Icon 组件按 material-symbols 图标集渲染。
实现细节:日历与卡片目录的模块结构
日历小部件是三者中最复杂的,其 calendar/ 目录采用清晰的分层:
Calendar.astro:Astro 外壳,负责把 Svelte 岛挂载进侧边栏容器。Calendar.svelte:交互根组件,负责状态编排。components/CalendarGrid.svelte/CalendarHeader.svelte/SelectionPanel.svelte/MonthPicker.svelte/YearPicker.svelte/PostList.svelte:子组件按职责拆分——网格渲染、头部导航、月份/年份选择面板、点击日期后的文章列表。hooks/useCalendar.ts:以钩子形式封装日历状态逻辑。utils/calendarUtils.ts:纯函数工具。types/calendar.ts:类型契约。index.ts/components/index.ts:桶式导出,消费方从widgets/calendar导入而无需感知内部路径。
卡片目录小部件则非常轻:仅 CardTOC.astro + index.ts 两个文件,说明它是一个纯 Astro 静态渲染组件(从当前文章标题结构生成目录,无客户端交互岛依赖)。实现细节未在本次读取的源码范围内(源探索预算已用尽),完整实现请直接查看源文件。
数据端点:calendar-data.json.ts
src/pages/api/calendar-data.json.ts 是 Astro 框架的 API 路由,为日历小部件提供"日期 → 文章"的映射数据。由于源探索预算已用尽,其具体实现细节(请求参数、返回 JSON 结构、是否分页)未在本次阅读范围内,请直接查看源文件确认。
Configuration Options
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
siteConfig.siteStartDate | string (ISO 日期) | "2025-01-01" | 站点开始日期,作为"运行天数"计算的起点 |
I18nKey.siteStatsPostCount 等翻译键 | string | 由 i18n 词典定义 | 各统计项的显示文案 |
WidgetLayout name / id | string | "site-stats" 等硬编码值 | 小部件标题与 DOM id |
WidgetLayout class / style | string | 未设置 | 由 Props 透传的外部样式覆盖 |
Failure Modes, Edge Cases & Concurrency
- 空站点(无文章):
latestPost为undefined,lastPostDate为null,"最后更新"项无法计算,客户端脚本应有对应兜底(具体兜底逻辑在未读取的模板/脚本部分)。 post.body为空:字数统计循环中if (post.body)跳过,不抛错。siteStartDate未配置:|| "2025-01-01"兜底,构建不会失败。- 复杂正则的回溯风险:
/```[\s\S]*?```/g为非贪婪匹配,对超长文档需整体扫描,但属于构建期一次性成本,不发生在请求路径上。 - 动态项的时区/时钟依赖:running-days 与 last-update 在客户端按浏览器当前时间计算,不同访客可能看到因时区产生的±1 天差异。
Performance & Operational Notes
- 全部统计为构建期计算:
getSortedPosts()、getCategoryList()、getTagList()在 frontmatter 中 await,产物是纯静态 HTML,零客户端数据请求(区别于 Profile 小部件中基于 Umami 的运行时访问统计)。 - 无水合成本:SiteStats 不依赖 Svelte/Solid 客户端框架,只有极轻的内联脚本更新两个天数数字。
- 字数统计为 O(总字符数):对内容量极大的站点会略增构建时间,但只执行一次。
- 日历数据走独立端点:
calendar-data.json.ts让日历数据可独立于 HTML 构建产物被请求/缓存。
Extension Points
- 新增统计项:在
stats数组追加对象即可(图标 + label + value + 可选 formatted/dynamic/suffix/id),模板零改动。 - 接入自定义数据源:三个取数函数全部来自
utils/content-utils,替换该层即可改用 CMS 等外部数据源,而不动小部件本体。 - 新增小部件:遵循
widgets/<name>/目录 +index.ts导出 + 复用common/WidgetLayout.astro的既有约定。