Repository Wiki
LyraVoid/Mizuki

日历、目录与站点统计小部件

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

Loading diagram...

架构要点说明:

  • 三个小部件都通过 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 的执行被明确切分为构建期与客户端两个阶段,这一分界是理解该组件的关键:

Loading diagram...
  • 构建期产出 6 个统计项,其中 4 个为静态值;value: 0 加注释"将由客户端更新"的两项(运行天数、距最后更新天数)依赖 siteStartDate 与 lastPostDate 在浏览器中随当前时间计算。
  • lastPostDate 的计算刻意忽略置顶状态、只按 published 排序——源码注释明确写了这一点,保证"最后更新"反映真实发布时间线而非置顶干预。

实现细节:站点统计的构建期计算

以下摘录展示 SiteStats.astro frontmatter 中完整的内容统计管线(读取数据 → 清洗正文 → CJK/非 CJK 分别计数 → 汇总):

typescript
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 混合字数统计算法

typescript
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

算法逐步解读:

  1. 先剔除代码:/```[\s\S]*?```/g 用非贪婪模式移除围栏代码块,/[^]+/g移除行内代码。**为何先做这一步**:若先分词再剔除,代码中的标识符(如useState、fooBar`)会被算作"单词",导致字数虚高,误导读者对正文体量的判断。
  2. CJK 字符计数:正则覆盖 U+4E00–9FA5(汉字)、U+3040–309F(平假名)、U+30A0–30FF(片假名)、U+AC00–D7AF(谚文)、U+3000–303F(CJK 标点)、U+FF00–FFEF(全角字符)。源码注释强调"使用与 remark-content.mjs 完全一致的 CJK 正则"——这保证小部件显示的总字数与文章阅读页的字数算法口径一致,读者不会在不同页面看到相互矛盾的数字。
  3. 非 CJK 分词:先把 CJK 字符替换为空格,再 split(/\s+/) 并过滤空串。为何替换而不是分开匹配:替换法天然处理了中英混排的边界(你好world 会拆成"你好"两个 CJK 字符 + "world" 一个单词)。
  4. post.body 存在性检查:草稿或空文件等 body 为空的条目被跳过,构建不报错。

最后更新时间的计算

typescript
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 数组与动态项

typescript
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。

实现细节:渲染外壳

astro
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.siteStartDatestring (ISO 日期)"2025-01-01"站点开始日期,作为"运行天数"计算的起点
I18nKey.siteStatsPostCount 等翻译键string由 i18n 词典定义各统计项的显示文案
WidgetLayout name / idstring"site-stats" 等硬编码值小部件标题与 DOM id
WidgetLayout class / stylestring未设置由 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 的既有约定。

Sources

(1 files)