Repository Wiki
LyraVoid/Mizuki

配置架构与覆盖合并机制

Mizuki 主题采用「上游默认配置 + 用户覆盖文件」的双层配置架构:运行时由 Astro 组件直接 import TypeScript 配置,而纯 Node 脚本则通过 scripts/read-site-config.mjs 以「覆盖优先、字段级回退」的合并语义读取同一份配置。本文档覆盖该架构的完整机制、合并算法与边界行为。

Purpose and Scope

本页面覆盖以下内容:

  • src/config/*.ts 默认配置层的组织方式(以 siteConfig.ts 为主要示例);
  • 覆盖层 src/config/overrides/siteConfig.ts 的定位与同步来源;
  • scripts/read-site-config.mjs 的读取管线:SOURCE_PATHS 顺序、extractBlock 花括号配平算法、matchInBlock 的「覆盖 → 默认」字段级回退合并语义;
  • tests/site-config-reader.test.ts 验证的边界行为(空块、缺块、嵌套对象、括号不配平等)。

以下内容留给兄弟页面:

  • 各个具体配置项的业务含义:For 字段级说明,see 对应的 configuration.* 兄弟页面(例如站点基础配置、侧边栏配置等);
  • Astro 构建侧对 src/config/*.ts 的直接 import 用法:属于各组件自身的行为,不在本页展开。

Overview

Mizuki 的配置体系要同时服务两个运行环境:

  1. Astro 构建运行时:.astro / .ts 模块可以直接 import TypeScript 配置文件(例如 siteConfig.ts),类型来自 ../types/config;
  2. 纯 Node 脚本运行时:由 node 直接执行的脚本(如内容同步、数据抓取类脚本)无法 import TypeScript 配置,必须用文本方式读取配置源文件。

覆盖层的设计动机来自头部注释中的说明:

这些脚本直接由 node 运行,无法 import TypeScript 配置,沿用既有的正则读取方式。src/config/overrides/siteConfig.ts 由 sync-content 从内容仓库同步而来,存在时优先命中,读不到再回退 src/config/siteConfig.ts 里的上游默认值。

Source: read-site-config.mjs

也就是说,「深合并(deep merge)」在这个仓库里不是对两份对象做递归合并,而是一种按块定位、按字段回退的有序查找合并:先在覆盖文件的目标配置块内找字段,命中即返回;块内没写该字段,则继续在默认配置的同一块内找;两处都没有才返回 null 交给调用方兜底。这样保证用户只覆盖 bilibili.vmid 时,coverMirror / useWebp 仍能取到上游默认值。

Architecture

Loading diagram...

架构要点:

  • 两条平行通路:运行时(Astro 组件)直接 import src/config/*.ts,享受类型检查;脚本通路则完全基于文本 + 正则。两者读取的是同一组物理文件,覆盖层对脚本通路生效。
  • SOURCE_PATHS 固定两元素顺序:overrides/siteConfig.ts 在前、siteConfig.ts 在后,顺序本身就是优先级。
  • 缓存只做一次磁盘 IO:readSources() 用模块级 cachedSources 缓存文件文本,同一进程内首次调用后不再触碰文件系统。

配置文件布局

src/config/ 目录按功能域拆分为多个独立配置文件,每个文件导出一个强类型常量,类型统一来自 src/types/config:

文件职责域
siteConfig.ts站点全局配置(标题、语言、主题色、featurePages、navbarTitle、bilibili/anime 等)
navBarConfig.ts / sidebarConfig.ts / profileConfig.ts / footerConfig.ts导航、侧栏、个人资料、页脚
commentConfig.ts / announcementConfig.ts / shareConfig.ts / randomPostsConfig.ts / relatedPostsConfig.ts评论区、公告、分享、随机/相关文章
musicConfig.ts / pioConfig.ts / effectsConfig.ts / markdownConfig.ts / licenseConfig.ts / permalinkConfig.ts / expressiveCodeConfig.ts音乐播放器、看板娘、特效、渲染与链接策略

siteConfig.ts 中的顶层块结构(节选)展示了「块 + 嵌套字段」的组织方式,这正是 extractBlock 所锚定的结构:

typescript
1import type { SiteConfig } from "../types/config"; 2 3// 定义站点语言 4const SITE_LANG = "en"; // 语言代码,例如:'en', 'zh_CN', 'ja' 等。 5 6export const siteConfig: SiteConfig = { 7 title: "Mizuki", 8 subtitle: "One demo website", 9 siteURL: "https://mizuki.mysqil.com/", // 请替换为你的站点URL,以斜杠结尾 10 siteStartDate: "2025-01-01", // 站点开始运行日期,用于站点统计组件计算运行天数 11 timeZone: "Asia/Shanghai", // 文章日期使用的 IANA 时区,可改为 Asia/Tokyo、Europe/Berlin 等 12 13 lang: SITE_LANG, 14 15 themeColor: { 16 hue: 240, // 主题色的默认色相,范围从 0 到 360。例如:红色:0,青色:200,蓝绿色:250,粉色:345 17 fixed: false, // 对访问者隐藏主题色选择器 18 },

Source: siteConfig.ts

Core Flow

Loading diagram...

执行顺序的关键点:

  1. 源顺序即优先级:matchInBlock 按 sources 数组顺序遍历,覆盖文件永远排在默认文件之前;
  2. 「块级跳过」与「字段级回退」是两种不同分支:块在当前源里不存在(返回 null)时直接 continue 换下一份源;块存在但字段没写时,同样落到下一份源继续找,而不是就地返回空值;
  3. 短路返回:一旦某份源的块内正则命中,立即 return match[1],后续源不再读取——这就是「覆盖优先」的实现方式;
  4. 兜底语义:全部源都未命中返回 null,由调用方决定默认行为(测试中对应「由调用方兜底」的用例)。

Usage Examples

完整的读取管线实现

scripts/read-site-config.mjs 全文不足 90 行,是整个覆盖合并机制的核心实现:

javascript
1const SOURCE_PATHS = [ 2 path.join(rootDir, "src/config/overrides/siteConfig.ts"), 3 path.join(rootDir, "src/config/siteConfig.ts"), 4]; 5 6let cachedSources = null; 7 8function readSources() { 9 if (!cachedSources) { 10 cachedSources = SOURCE_PATHS.filter((source) => fs.existsSync(source)).map( 11 (source) => fs.readFileSync(source, "utf-8"), 12 ); 13 } 14 return cachedSources; 15}

Source: read-site-config.mjs

设计意图:fs.existsSync 过滤保证覆盖文件是可选的——内容仓库没有同步覆盖文件时,数组里只剩默认配置,管线照常工作;模块级缓存把磁盘 IO 压缩到进程内首次调用。

花括号配平算法(extractBlock)

javascript
1export function extractBlock(content, blockKey) { 2 const opener = content.match(new RegExp(`\\b${blockKey}\\s*:\\s*\\{`)); 3 if (!opener) { 4 return null; 5 } 6 7 let index = opener.index + opener[0].length; 8 let depth = 1; 9 const start = index; 10 11 while (index < content.length && depth > 0) { 12 const char = content[index]; 13 if (char === "{") depth++; 14 else if (char === "}") depth--; 15 index++; 16 } 17 18 return depth === 0 ? content.slice(start, index - 1) : null; 19}

Source: read-site-config.mjs

这是整个机制中最关键的防御点。头部注释解释了为什么必须这样做:

覆盖文件是部分配置且键序任意,如果沿用「块名后面第一个字段」的松散匹配,anime: {} 后面的 font: { mode: ... } 会被误读成番剧模式。这里用花括号配平把搜索范围钉死在块内。

即:\\b${blockKey}\\s*:\\s*\\{ 定位块开括号后,用 depth 计数器扫描到与之配平的闭括号,content.slice(start, index - 1) 只返回块内文本。若扫到文件末尾 depth 仍大于 0(括号不配平,文件被截断),返回 null 视同「没有该块」。

有序合并(matchInBlock)

javascript
1export function matchInBlock(sources, blockKey, pattern) { 2 for (const content of sources) { 3 const block = extractBlock(content, blockKey); 4 if (block === null) { 5 continue; 6 } 7 const match = block.match(pattern); 8 if (match) { 9 return match[1]; 10 } 11 } 12 return null; 13} 14 15/** 16 * 按「覆盖 → 默认」的顺序读取 siteConfig 某个块内的字段,都没命中返回 null。 17 */ 18export function matchSiteConfig(blockKey, pattern) { 19 return matchInBlock(readSources(), blockKey, pattern); 20}

Source: read-site-config.mjs

matchSiteConfig 是对外的门面:它把 SOURCE_PATHS 的固定顺序封装起来,调用方只需要给出 (blockKey, pattern) 两个参数。

测试揭示的合并行为

测试文件直接用字符串字面量模拟覆盖文件与默认文件,完整刻画了合并语义:

typescript
1it("覆盖块里缺少的字段继续回退到默认值", () => { 2 // 只覆盖 vmid,coverMirror / useWebp 仍应取上游默认 3 const override = `export default { bilibili: { vmid: "1129280784" } };`; 4 const sources = [override, DEFAULTS]; 5 6 assert.equal(matchInBlock(sources, "bilibili", VMID), "1129280784"); 7 assert.equal(matchInBlock(sources, "bilibili", COVER_MIRROR), ""); 8 assert.equal(matchInBlock(sources, "bilibili", USE_WEBP), "true"); 9});

Source: site-config-reader.test.ts

块边界防串扰用例:

typescript
1it("取值不会越过块边界串到相邻配置", () => { 2 // anime 块是空的,后面 font.mode 不能被当成番剧模式 3 const override = `export default { 4 anime: {}, 5 font: { mode: "system" }, 6 };`; 7 8 assert.equal(matchInBlock([override, DEFAULTS], "anime", MODE), "local"); 9});

Source: site-config-reader.test.ts

注意断言结果:anime 在覆盖文件里是空块,MODE 正则在块内未命中,于是回退到 DEFAULTS 的 anime 块取到 "local",而不是串读到覆盖文件里 font.mode 的 "system"。

Configuration Options

选项类型默认值说明
SOURCE_PATHS[0]string(路径)<root>/src/config/overrides/siteConfig.ts覆盖层路径,由 sync-content 从内容仓库同步生成;可选存在,不存在时自动跳过
SOURCE_PATHS[1]string(路径)<root>/src/config/siteConfig.ts上游默认配置,字段齐全的回退源
cachedSourcesstring[] | nullnull模块级缓存,首次调用 readSources() 后固化所有源文本,同一进程不再重复读盘
rootDirstring(路径)由 import.meta.url 反解的仓库根path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."),使脚本可以从任意 CWD 调用

配置层(src/config/*.ts)的关键顶层块(以 siteConfig.ts 为例):

块 / 字段类型默认值说明
langstring"en"站点语言代码(SITE_LANG 常量)
themeColor.huenumber240主题色色相,0–360
themeColor.fixedbooleanfalse隐藏访客主题色选择器
featurePages.*booleantrue(全部)anime/diary/friends/projects/skills/timeline/albums/devices/aiTools 页面开关
font.mode"custom" | "system""custom"字体加载策略
anime.mode"bangumi" | "local" | "bilibili""local"番剧页面数据源
bilibili.useWebpbooleantrue封面是否使用 WebP
bilibili.vmidstring"your-bilibili-vmid"B 站用户 UID

Source: siteConfig.ts

API Reference

extractBlock(content: string, blockKey: string): string | null

截取 blockKey: { ... } 的块内文本(不含首尾花括号)。

Parameters:

  • content (string):整份配置源文件文本
  • blockKey (string):顶层块名,如 "bilibili"、"anime"

Returns: 块内文本;块不存在或花括号不配平时返回 null。

边界行为: 嵌套对象(如 banner: { src: { desktop: [...] } })通过 depth 计数正确跨越,不会提前截断;\\b 词边界避免 anime 误匹配到 xxxanime 之类的键名。

matchInBlock(sources: string[], blockKey: string, pattern: RegExp): string | null

在若干份配置文本里按顺序查找 blockKey 块内的字段,返回第一个命中的捕获组(match[1])。

Parameters:

  • sources (string[]):按优先级排序的配置文本数组(覆盖在前,默认在后)
  • blockKey (string):目标顶层块名
  • pattern (RegExp):含至少一个捕获组的正则

Returns: 首个命中的捕获组;所有源都未命中返回 null。

Throws: 不抛出异常;文件缺失已在 readSources() 层面被 existsSync 过滤。

matchSiteConfig(blockKey: string, pattern: RegExp): string | null

门面函数:按「覆盖 → 默认」固定顺序读取 siteConfig 某块内的字段。首次调用触发磁盘读取并缓存。

Source: read-site-config.mjs

Failure Modes, Edge Cases & Concurrency

以下行为全部由 site-config-reader.test.ts 逐条验证:

边界情形行为对应测试
覆盖文件不存在(只有默认源)直接读默认值,anime.mode = "local"、bilibili.useWebp = "true"没有覆盖文件时读上游默认值
覆盖块存在且字段命中立即短路返回覆盖值覆盖文件优先于默认值
覆盖块存在但字段缺失字段级回退到默认源同块内的字段覆盖块里缺少的字段继续回退到默认值
覆盖块为空对象(anime: {})块内正则不命中 → 回退默认源;不会串读到后一个 font.mode取值不会越过块边界串到相邻配置
覆盖文件完全没有该块extractBlock 返回 null → continue 跳过该源覆盖文件里不存在的块直接跳过
所有来源都没有该块返回 null,由调用方兜底所有来源都没有该块时返回 null,由调用方兜底
块内含嵌套对象花括号配平跨越嵌套层级,position 等后置字段仍能匹配块内嵌套对象不会提前截断
花括号不配平(文件截断)extractBlock 返回 null,视同块不存在花括号不配平时视为没有该块

并发与一致性:

  • 模块级 cachedSources 在 Node 单线程事件循环内天然线程安全;代价是进程生命周期内配置变更不可见——若脚本运行中途覆盖文件被 sync-content 重写,本次进程仍读到旧文本。
  • 正则方案不解析 JS 语法,因此对块内注释中的字符串、非常规格式化(如 vmid:"x" 无空格)依赖正则的 \s* 容忍度;带模板字符串或计算属性键的配置无法被识别。

安全提示(来自默认配置注释): bilibili 块中的 BILI_SESSDATA 凭证明确要求通过 .env(本地)或 GitHub Secrets(远程构建)注入,禁止硬编码进覆盖文件。

Source: siteConfig.ts

Performance / Operational Notes

  • 磁盘 IO 最小化:readSources() 的 filter + map + 缓存 模式保证每个进程至多读一次文件系统(最多 2 次 readFileSync),后续调用纯内存操作。
  • O(块长) 的配平扫描:extractBlock 的 while 循环只扫过目标块的字符数,嵌套层级只影响 depth 计数,不引入回溯;对配置文件这种 KB 级文本可视为常量开销。
  • 正则按需编译:调用方传入的 pattern 由调用方管理;extractBlock 内部的 new RegExp(\\b${blockKey}...`)` 在每次调用时新建,因块数量有限,开销可忽略。
  • 运维路径:覆盖层由 sync-content 从内容仓库自动同步,主题升级时用户无需改动上游 siteConfig.ts——这正是双文件设计的核心收益:上游默认值随主题演进,用户自定义集中在覆盖层,升级零冲突。

Extension Points

  • 新增配置块:在 src/config/*.ts 中新增顶层块后,脚本侧立即可以通过 matchSiteConfig("新块名", /字段:\s*["']([^"']+)["']/) 读取,无需修改读取器;
  • 新增覆盖文件:当前 SOURCE_PATHS 硬编码为两个路径。若要为其他配置文件(如 navBarConfig.ts)建立覆盖层,可在 SOURCE_PATHS 数组头部插入对应 overrides/ 路径——数组顺序即优先级,matchInBlock 的遍历逻辑无需改动;
  • 更换正则:字段提取完全由调用方提供的 pattern 决定,支持 ["'] 双引号容忍、true|false 布尔、数字等任意单捕获组正则(测试中定义了 MODE、VMID、COVER_MIRROR、USE_WEBP 四种范例,见 site-config-reader.test.ts)。

Tests

tests/site-config-reader.test.ts 使用 node:test + node:assert/strict,共 8 个用例,通过字符串字面量构造 DEFAULTS(模拟上游默认配置缩影)与 override(模拟部分覆盖文件)直接驱动 extractBlock / matchInBlock。测试同时充当合并语义的行为规格书:覆盖优先、字段回退、块级跳过、null 兜底、块边界防串扰、嵌套不截断、括号不配平容错。