配置架构与覆盖合并机制
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 的配置体系要同时服务两个运行环境:
- Astro 构建运行时:
.astro/.ts模块可以直接importTypeScript 配置文件(例如 siteConfig.ts),类型来自../types/config; - 纯 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
架构要点:
- 两条平行通路:运行时(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 所锚定的结构:
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
执行顺序的关键点:
- 源顺序即优先级:
matchInBlock按sources数组顺序遍历,覆盖文件永远排在默认文件之前; - 「块级跳过」与「字段级回退」是两种不同分支:块在当前源里不存在(返回
null)时直接continue换下一份源;块存在但字段没写时,同样落到下一份源继续找,而不是就地返回空值; - 短路返回:一旦某份源的块内正则命中,立即
return match[1],后续源不再读取——这就是「覆盖优先」的实现方式; - 兜底语义:全部源都未命中返回
null,由调用方决定默认行为(测试中对应「由调用方兜底」的用例)。
Usage Examples
完整的读取管线实现
scripts/read-site-config.mjs 全文不足 90 行,是整个覆盖合并机制的核心实现:
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)
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)
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) 两个参数。
测试揭示的合并行为
测试文件直接用字符串字面量模拟覆盖文件与默认文件,完整刻画了合并语义:
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
块边界防串扰用例:
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 | 上游默认配置,字段齐全的回退源 |
cachedSources | string[] | null | null | 模块级缓存,首次调用 readSources() 后固化所有源文本,同一进程不再重复读盘 |
rootDir | string(路径) | 由 import.meta.url 反解的仓库根 | path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."),使脚本可以从任意 CWD 调用 |
配置层(src/config/*.ts)的关键顶层块(以 siteConfig.ts 为例):
| 块 / 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
lang | string | "en" | 站点语言代码(SITE_LANG 常量) |
themeColor.hue | number | 240 | 主题色色相,0–360 |
themeColor.fixed | boolean | false | 隐藏访客主题色选择器 |
featurePages.* | boolean | true(全部) | anime/diary/friends/projects/skills/timeline/albums/devices/aiTools 页面开关 |
font.mode | "custom" | "system" | "custom" | 字体加载策略 |
anime.mode | "bangumi" | "local" | "bilibili" | "local" | 番剧页面数据源 |
bilibili.useWebp | boolean | true | 封面是否使用 WebP |
bilibili.vmid | string | "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 兜底、块边界防串扰、嵌套不截断、括号不配平容错。
Related Links
- scripts/read-site-config.mjs — 覆盖合并读取器实现
- src/config/siteConfig.ts — 上游默认配置(主要示例)
- tests/site-config-reader.test.ts — 合并语义的行为规格
- 各功能域配置的运行时用法:see 对应的
configuration.*兄弟页面