Repository Wiki
LyraVoid/Mizuki

辅助脚本与站点配置读取

Mizuki 主题仓库在 scripts/ 目录下维护了一组由 node 直接运行的构建辅助脚本(字体子集化、图片转换、内容同步、IndexNow 推送等)。由于这些脚本运行在构建链路而非 Astro 运行时中,无法 import TypeScript 配置模块,因此仓库提供了两个专门的辅助模块:scripts/read-site-config.mjs(以正则方式读取 siteConfig.ts 的顶层配置块)与 scripts/load-env.js(手工解析 .env 文件并注入 process.env)。本文页覆盖这两个辅助模块的完整实现机制,以及它们在构建工具链中的定位。

Purpose and Scope

本页覆盖的内容:

  • scripts/read-site-config.mjs:站点配置取值助手,包含 extractBlock、matchInBlock、matchSiteConfig 三个导出函数及其「覆盖 → 默认」两级回退逻辑。
  • scripts/load-env.js:.env 文件加载器,为纯 Node 脚本提供 import.meta.env 的等价能力。
  • 上述两个模块被构建脚本消费的方式,以及为什么采用正则/手工解析而非真正 import 配置文件。

本页不覆盖、留给兄弟页面的内容:

  • scripts/ 下具体业务脚本(字体子集化流水线 scripts/compress-fonts/*、图片转换 scripts/convert-images.js、内容同步 scripts/sync-content.js 等)各自的实现细节属于独立的脚本页。
  • src/config/siteConfig.ts 本身的配置项语义,属于站点配置页。
  • Astro 运行时读取配置的方式(Vite/Astro 自身的加载链路),不在本页范围内。

Overview

scripts/ 目录中的脚本(如 prepare-fonts.mjs、update-anime.mjs、indexnow-submit.js、sync-content.js 等)在 pnpm 脚本阶段由 node 直接执行。这一执行环境有两个天然限制:

  1. Node 原生不能直接 import TypeScript 文件——src/config/siteConfig.ts 及其覆盖文件 src/config/overrides/siteConfig.ts 都是 TS 模块,纯 Node 脚本无法复用。
  2. 没有 Vite 的 env 注入——Astro 组件里常见的 import.meta.env.PUBLIC_XXX 在纯 Node 上下文不可用,需要自己解析 .env。

为此仓库提供了两个轻量辅助模块,均为零依赖、单文件实现,避免给构建链引入额外的转译或 dotenv 依赖:

模块职责读取对象
scripts/read-site-config.mjs从 TS 配置文本中按「块」提取字段值src/config/overrides/siteConfig.ts → src/config/siteConfig.ts
scripts/load-env.js解析 .env 并写入 process.env仓库根目录 .env

核心使用场景:

  • 字体子集化:需要读取 font 配置块(字体模式、字体列表等)决定子集化策略。
  • 番剧页更新:需要读取 anime 配置块获得站点域名等信息。
  • IndexNow 提交:需要 .env 中的 INDEXNOW_KEY 等密钥。
  • 内容同步 / 初始化:需要站点基础配置确定内容仓库映射。

Architecture

下图展示两个辅助模块在构建工具链中的位置与数据流向:

Loading diagram...

设计意图解读:

  • 为什么是正则而不是 import:脚本由 node 直接运行,Node 原生不解析 TS。与其为几个脚本引入 esbuild/tsx 转译依赖,不如沿用已有的正则读取方式,保持构建链零额外开销。
  • 为什么有覆盖优先级:src/config/overrides/siteConfig.ts 是 sync-content 从内容仓库同步而来的部分覆盖配置(键序任意、字段不全),必须与上游默认值 src/config/siteConfig.ts 组合成「覆盖优先、缺失回退」的读取链。
  • 为什么 loadEnv 不用 dotenv:.env 语法极简(KEY=VALUE + 注释 + 可选引号),手工解析约 20 行即可完成,避免运行时依赖。

Main Content:read-site-config.mjs 实现机制

模块加载与配置源定位

模块在导入时即确定两个配置源路径,优先级为覆盖文件在前、默认文件在后:

javascript
1const rootDir = path.resolve( 2 path.dirname(fileURLToPath(import.meta.url)), 3 "..", 4); 5 6const SOURCE_PATHS = [ 7 path.join(rootDir, "src/config/overrides/siteConfig.ts"), 8 path.join(rootDir, "src/config/siteConfig.ts"), 9];

Source: read-site-config.mjs

rootDir 通过 import.meta.url 反推仓库根目录(脚本位于 scripts/ 下,向上退一级),因此脚本无论从哪个工作目录被调用,配置路径都稳定指向仓库内的真实文件。

源文件读取与模块级缓存

javascript
1let cachedSources = null; 2 3function readSources() { 4 if (!cachedSources) { 5 cachedSources = SOURCE_PATHS.filter((source) => fs.existsSync(source)).map( 6 (source) => fs.readFileSync(source, "utf-8"), 7 ); 8 } 9 return cachedSources; 10}

Source: read-site-config.mjs

三个值得注意的细节:

  1. 存在性过滤:覆盖文件是可选的(由 sync-content 按需生成),fs.existsSync 过滤后,缓存数组里只剩实际存在的文件。
  2. 一次性缓存:cachedSources 在首次读取后被固化。脚本生命周期内配置不会变化,缓存既省 I/O,也保证多次取值的一致性。
  3. 顺序即优先级:数组顺序「覆盖 → 默认」直接构成后续查找的优先顺序。

extractBlock:花括号配平截取配置块

这是模块的核心算法。文件头注释解释了为什么需要它——覆盖文件是部分配置且键序任意,如果沿用旧版「块名后面第一个字段」的松散匹配,anime: {}(空块)后面紧跟的 font: { mode: ... } 会被误读成番剧模式。因此必须用花括号配平把搜索范围钉死在目标块内:

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

执行过程分两步:

  1. 定位块头:正则 \b${blockKey}\s*:\s*\{ 匹配 font: { 这样的顶层键声明。\b 词边界防止 myfont: 误命中 font:。
  2. 配平扫描:从 { 之后开始逐字符扫描,遇到 { 深度加一、遇到 } 深度减一,深度归零时结束。返回值是块内文本(不含首尾花括号)。

若扫描到文件末尾深度仍大于 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}

Source: read-site-config.mjs

这里有一个容易被忽略但注释里明确说明的关键行为:当覆盖文件中存在该块、但没写这个字段时,继续往后找默认文件,而不是就地返回空值。这就是「未覆盖字段回退到默认配置」的实现——循环不因「块存在」而终止,只因「字段命中」而终止。match[1] 表明调用方传入的 pattern 必须包含一个捕获组。

matchSiteConfig:面向调用方的门面

javascript
export function matchSiteConfig(blockKey, pattern) { return matchInBlock(readSources(), blockKey, pattern); }

Source: read-site-config.mjs

matchSiteConfig 是唯一被业务脚本使用的入口(另两个函数导出用于测试或更细粒度的控制),它把源加载 + 缓存 + 顺序匹配封装成一次调用。取值一律限定在指定的顶层配置块内,避免跨块串读。

Main Content:load-env.js 实现机制

load-env.js 解决的是另一个问题:纯 Node 脚本没有 Vite 的 import.meta.env 注入,需要自己把 .env 读进 process.env:

javascript
1const __filename = fileURLToPath(import.meta.url); 2const __dirname = path.dirname(__filename); 3const rootDir = path.resolve(__dirname, ".."); 4 5// 加载 .env 文件 6export function loadEnv() { 7 const envPath = path.join(rootDir, ".env"); 8 if (fs.existsSync(envPath)) { 9 const envContent = fs.readFileSync(envPath, "utf-8"); 10 envContent.split("\n").forEach((line) => { 11 const line_ = line.trim(); 12 // 跳过注释和空行 13 if (!line_ || line_.startsWith("#")) return; 14 15 const match = line_.match(/^([^=]+)=(.*)$/); 16 if (match) { 17 const key = match[1].trim(); 18 let value = match[2].trim(); 19 // 移除引号 20 value = value.replace(/^["']|["']$/g, ""); 21 process.env[key] = value; 22 } 23 }); 24 } 25}

Source: load-env.js

实现要点:

  • 路径定位:与 read-site-config.mjs 相同的 import.meta.url → 根目录反推手法,.env 固定读取仓库根目录。
  • 行级解析:按 \n 拆行,trim 后跳过空行与 # 注释行。
  • 键值正则:^([^=]+)=(.*)$ 要求行内至少含一个 =;key 与 value 各自 trim。
  • 引号剥离:^["']|["']$ 去掉首尾成对的单/双引号,兼容 KEY="value" 与 KEY='value' 写法。
  • 直接覆写 process.env:后续脚本通过 process.env.INDEXNOW_KEY 等直接取值,与 Node 生态惯例一致。
  • 无导出前缀无副作用:只有显式调用 loadEnv() 才发生读取,模块导入本身零开销。

Core Flow:一次配置读取的完整时序

以字体准备脚本读取 font 块中某字段为例:

Loading diagram...

整个链路的设计取舍是:用一次全文件读入 + 模块级缓存换取后续取值的零 I/O,同时通过「块存在但字段缺失继续找」的循环语义,让部分覆盖配置无需补全所有字段即可生效。

Usage Examples

读取站点配置块内字段(典型调用形态)

调用方传入块名与带捕获组的正则,即可按「覆盖 → 默认」顺序取值。以下形态与 font / anime 块的实际结构对应:

javascript
1import { matchSiteConfig } from "./read-site-config.mjs"; 2 3// 读取 font 块内的 mode 字段(覆盖文件未写时回退默认文件) 4const fontMode = matchSiteConfig("font", /mode:\s*"([^"]+)"/); 5 6// 读取 anime 块内嵌套字段的判断 7const animeMode = matchSiteConfig("anime", /mode:\s*"([^"]+)"/);

以上调用形态依据 read-site-config.mjs 的函数签名与文件头注释描述的 anime / font 块使用场景整理;scripts/ 下各业务脚本对这些函数的具体消费代码见各脚本自身。

在脚本中加载 .env 密钥

javascript
1import { loadEnv } from "./load-env.js"; 2 3loadEnv(); 4// 此后可直接读取 process.env 中的键值 5const indexNowKey = process.env.INDEXNOW_KEY;

Source: load-env.js

(示例中的键名仅为 process.env 取值方式演示,实际可用键以仓库 .env 内容为准。)

API Reference

read-site-config.mjs

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

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

  • 参数 content (string):完整配置文件文本;blockKey (string):顶层块名,用于构造 \b${blockKey}\s*:\s*\{ 匹配。
  • 返回:块内文本;块不存在或花括号未配平(文件末尾深度仍 > 0)时返回 null。
  • 设计要点:以字符级配平代替松散的后向匹配,防止键序任意的部分覆盖配置造成跨块误读(如 anime: {} 后的 font 块被读成番剧配置)。

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

在若干份配置文本里按顺序查找 blockKey 块内的字段。

  • 参数:sources (string[]) 按优先级排列的文本数组;blockKey (string);pattern (RegExp) 必须含第 1 捕获组。
  • 返回:第一个命中的捕获组;块存在但字段缺失时继续下一份源;全部未命中返回 null。

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

按「覆盖 → 默认」顺序读取 siteConfig 某个块内的字段(业务脚本的主要入口)。

  • 参数:blockKey (string) 顶层配置块名;pattern (RegExp) 含第 1 捕获组的字段正则。
  • 返回:命中字段值或 null。
  • 副作用:首次调用触发配置文件读取并写入模块级缓存,之后调用零 I/O。

load-env.js

loadEnv(): void

解析仓库根目录 .env 并把键值写入 process.env。

  • 参数:无。
  • 返回:无。.env 不存在时静默返回。
  • 行为:跳过空行与 # 注释行;按 ^([^=]+)=(.*)$ 提取键值并各自 trim;剥离首尾成对单/双引号;直接覆写同名 process.env 键。

Failure Modes, Edge Cases & Concurrency

场景行为说明
覆盖文件不存在SOURCE_PATHS 过滤后只剩默认文件覆盖文件由 sync-content 按需生成,缺失是正常状态
块在覆盖中存在但字段未写跳过该源继续找默认文件「未覆盖字段回退默认」的核心语义,见 matchInBlock 循环
配置文件花括号不闭合extractBlock 返回 null不返回截断文本,避免越界误读
blockKey 是其他键的子串\b 词边界保护myfont: 不会命中 font: 的匹配
同名块出现多次命中第一个String.match 只取首个匹配
.env 文件缺失loadEnv 静默返回不抛错,脚本继续以现有 env 运行
.env 值含成对引号首尾引号被剥离`^["']
行内无 =整行被忽略正则要求至少一个 =
重复调用 loadEnv每次重读文件并覆写无缓存;但 readSources 有缓存,二者行为不同
多次调用 matchSiteConfig复用首次读取结果模块级缓存;若脚本运行中配置文件被外部修改,不会感知

并发说明:这些模块只在单进程 Node 脚本内使用,无并发场景;缓存是在模块作用域上的单例,跨脚本进程不共享(每个 node 进程各自加载)。

Performance & Operational Notes / Extension Points

  • 性能:extractBlock 是 O(块长度) 的字符扫描,且每次 matchInBlock 对每份源都会重新截块;由于配置文件通常只有几百行、取值次数有限,加上 readSources 缓存避免了重复磁盘 I/O,实际开销可忽略。
  • 可维护性约束:正则读取强依赖 siteConfig.ts 的文本形态(顶层块 + 字面量值)。若配置改用复杂表达式(函数、模板字符串、条件值),正则会静默读不到并返回 null——这是该方案的固有边界。
  • 扩展点 1(新增配置块):直接用新的 blockKey 调用 matchSiteConfig 即可,无需改动辅助模块;只要目标块保持在顶层花括号结构内。
  • 扩展点 2(新增环境变量):在 .env 写入 KEY=VALUE 后调用 loadEnv(),再从 process.env.KEY 取值;无需修改 load-env.js。
  • 扩展点 3(更多配置源):向 SOURCE_PATHS 追加路径即可扩大回退链,顺序即优先级。
  • 若未来需要真正 import TS 配置:需要引入 esbuild/tsx 转译或改由 Astro 集成在构建期导出 JSON,这会改变当前「零依赖纯 Node」的定位,属于构建链结构调整。