diff --git a/.gitignore b/.gitignore index bf892414d5..43c9ccb89b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,12 @@ pnpm-debug.log* /content/ *.backup +# config overrides synced from the content repository +/src/config/overrides/ + +# output of `pnpm export-config` +/overrides-export/ + # Large zip files *.zip diff --git a/docs/CONTENT_REPOSITORY.md b/docs/CONTENT_REPOSITORY.md index f8548fd5c4..10befc0529 100644 --- a/docs/CONTENT_REPOSITORY.md +++ b/docs/CONTENT_REPOSITORY.md @@ -24,9 +24,22 @@ Mizuki-Content/ │ ├── albums/ # 相册图片 │ ├── diary/ # 日记图片 │ └── posts/ # 文章图片 +├── overrides/ # 配置覆盖(可选,见 CONTENT_SEPARATION.md) +│ ├── siteConfig.ts +│ └── profileConfig.ts └── README.md ``` +各目录同步到代码仓库的位置: + +| 内容仓库 | 代码仓库 | +| --- | --- | +| `posts/` | `src/content/posts/` | +| `spec/` | `src/content/spec/` | +| `data/` | `src/data/` | +| `images/` | `public/images/` | +| `overrides/` | `src/config/overrides/` | + ## 🚀 快速开始 ### 1. 创建新的内容仓库 diff --git a/docs/CONTENT_SEPARATION.md b/docs/CONTENT_SEPARATION.md index 9405b13cbc..18f6039ecd 100644 --- a/docs/CONTENT_SEPARATION.md +++ b/docs/CONTENT_SEPARATION.md @@ -7,6 +7,7 @@ - [快速开始](#-快速开始) - [ENABLE_CONTENT_SYNC 控制开关](#-enable_content_sync-控制开关) - [配置方式](#-配置方式) +- [配置覆盖 (overrides)](#-配置覆盖-overrides) - [私有仓库](#-私有仓库配置) - [CI/CD 部署](#-cicd-部署) - [常用命令](#-常用命令) @@ -219,6 +220,153 @@ CONTENT_REPO_URL=git@github.com:your-username/Mizuki-Content-Private.git --- +## 🧩 配置覆盖 (overrides) + +### 解决什么问题 + +文章和数据可以放进内容仓库,但 `src/config/` 下的配置项默认必须直接改代码仓库的源文件。对于 fork 上游、定期合并上游更新的用户,配置文件是冲突重灾区:上游每次调整配置结构,都会和本地的个人值冲突。 + +配置覆盖把个人配置值也搬进内容仓库:`src/config/*.ts` 保持上游原版,个人值写在内容仓库的 `overrides/` 里,构建时深合并。跟进上游更新时,配置文件不再产生冲突;上游**新增**配置项自动生效,只有上游**修改结构**时才会暴露问题,且表现为编译期类型错误。 + +**这是可选功能,也是实验性功能。** 不创建 `overrides/` 目录时,所有配置等同于上游默认值,行为与现状完全一致;但启用前请阅读[迁移步骤](#迁移步骤把已有配置搬进内容仓库)中的注意事项。 + +### 工作方式 + +``` +内容仓库 overrides/ ──sync-content──> 代码仓库 src/config/overrides/ + │ + src/config/index.ts 在导出前深合并 ↓ + + 最终配置 = deepMerge(上游默认配置, 同名覆盖文件的 default 导出) +``` + +`src/config/overrides/` 已加入 `.gitignore`,不会进入代码仓库的提交历史。 + +### 目录与命名 + +**覆盖文件名 = 被覆盖的导出常量名**(注意有几个和上游文件名不同): + +``` +内容仓库 +└── overrides/ + ├── siteConfig.ts → siteConfig (上游 siteConfig.ts) + ├── profileConfig.ts → profileConfig (上游 profileConfig.ts) + ├── navBarConfig.ts → navBarConfig (上游 navBarConfig.ts) + ├── musicPlayerConfig.ts → musicPlayerConfig (上游 musicConfig.ts) + ├── sakuraConfig.ts → sakuraConfig (上游 effectsConfig.ts) + └── ... +``` + +可覆盖的 18 个配置:`announcementConfig`、`commentConfig`、`expressiveCodeConfig`、`footerConfig`、`fullscreenWallpaperConfig`、`licenseConfig`、`markdownConfig`、`musicPlayerConfig`、`navBarConfig`、`permalinkConfig`、`pioConfig`、`profileConfig`、`randomPostsConfig`、`relatedPostsConfig`、`sakuraConfig`、`shareConfig`、`sidebarLayoutConfig`、`siteConfig`。 + +文件名不在名单内会在构建期报错并列出合法名单,不会静默失效。 + +### 迁移步骤:把已有配置搬进内容仓库 + +> ⚠️ **实验性功能,慎用**:配置分离(overrides)目前仍处于早期阶段,测试覆盖的场景有限,边界情况未必都验证到。迁移前请先提交或备份当前配置,迁移后务必完整校验一遍;重要站点建议先观察一段时间再决定是否长期使用。 + +如果你已经改过 `src/config/` 里的配置,可以用 `pnpm export-config` 自动抽出「与上游不同的字段」生成覆盖文件,不需要手抄。 + +**前置条件**:代码仓库里有一个「配置还是上游原版」的 git remote 可作基准。fork 用户通常已经有了: + +```bash +git remote -v # 确认有 upstream(或 origin)指向上游仓库 +git fetch upstream # 更新基准 +``` + +**迁移流程**: + +```bash +# 1. 导出个人配置:与上游基准逐字段比对,结果写入 overrides-export/ +pnpm export-config + +# 2. 把覆盖文件放进内容仓库 +cp overrides-export/*.ts <内容仓库>/overrides/ + +# 3. 还原代码仓库的配置为上游原版 +# 还原后 src/config/ 不再有个人改动,之后合并上游不会在配置上冲突 +git checkout upstream/master -- src/config/ + +# 4. 同步并校验 +pnpm sync-content && pnpm type-check && pnpm build +``` + +**要点**: + +- 导出基准自动挑选(依次尝试 `upstream/master`、`upstream/main`、`origin/master`、`origin/main`),不对时用 `--ref=` 指定;`--out=<目录>` 可以直接写进内容仓库。 +- 导出前脚本会自检「覆盖合并回去是否等于你当前的配置」,无法还原时会报出具体原因,不会生成不一致的覆盖文件。 +- 校验标准:`type-check` 通过,且构建出的站点与迁移前渲染一致。 +- 内容仓库如果配置了部署触发工作流,记得把 `overrides/**` 加进 `paths`,见下面的[触发部署](#触发部署)。 + +**回滚**:删掉内容仓库 `overrides/` 里的文件、重新 `pnpm sync-content`,配置即回到上游默认值;想完全退回直接修改 `src/config/*.ts` 的旧方式也可以,代码不需要任何改动。 + +### 写法 + +只写你想改的字段,其余自动取上游默认值: + +```ts +// overrides/siteConfig.ts +import type { DeepPartial, SiteConfig } from "../../types/config"; + +export default { + title: "我的站点", + siteURL: "https://example.com/", + lang: "zh_CN", + banner: { + src: { + desktop: ["/images/banner/desktop.webp"], + mobile: ["/images/banner/mobile.webp"], + }, + carousel: { interval: 8 }, + }, +} satisfies DeepPartial; +``` + +要点: + +- 必须是 `export default`,命名导出不会被识别(构建期报错); +- 用 `DeepPartial` 而不是 `Partial`:`Partial` 只让顶层键可选,写 `carousel: { interval: 8 }` 会因为缺少 `enable`、`switchable` 而报错; +- `navBarConfig.links` 里的预设项是枚举,要写 `LinkPreset.Home` 并 `import { LinkPreset } from "../../types/config"`,不能写裸数字; +- 相对路径 `../../types/config` 是同步到 `src/config/overrides/` 之后的位置。内容仓库本身没有 TypeScript 环境,类型检查在代码仓库执行 `pnpm type-check` 时进行。 + +### 合并语义 + +| 情况 | 行为 | +| --- | --- | +| 双方都是普通对象 | 深合并,覆盖值只影响写到的字段 | +| 数组 | 整体替换,不拼接 | +| 标量、`null` | 整体替换 | +| 覆盖值里显式写 `undefined` 的键 | 跳过,保留默认值 | +| 没有对应覆盖文件 | 原样使用上游默认值 | + +举例:默认 `banner.carousel` 是 `{ enable: true, interval: 3, switchable: true }`,覆盖里只写 `{ interval: 8 }`,结果是 `{ enable: true, interval: 8, switchable: true }`;数组则是整体替换,比如覆盖 `banner.src.desktop` 只写 1 张图,结果就是这 1 张,不会与默认列表拼接。 + +### 触发部署 + +内容仓库的 `trigger-build.yml` 需要把 `overrides/**` 加进 `paths`,否则只改配置不会触发站点重新构建: + +```yaml +on: + push: + branches: [main] + paths: + - "posts/**" + - "spec/**" + - "data/**" + - "images/**" + - "overrides/**" +``` + +### 已知限制 + +- **深合并表达不了「删除」。** 覆盖只能改值或加字段,没法把上游默认里的某个键去掉。`pnpm export-config` 遇到这种情况会明确报出来,需要手工处理。 +- **评论语言不会自动跟随 `siteConfig.lang`。** `src/config/commentConfig.ts` 在模块顶层引用 `siteConfig.ts` 里的语言常量填充 Twikoo / Giscus 的 `lang`,覆盖 `siteConfig.lang` 时需要同时提供 `overrides/commentConfig.ts` 覆盖对应字段。 +- **读取配置请统一走 `@/config` 入口。** 直接 `import { siteConfig } from "@/config/siteConfig"` 会绕过合并,拿到未覆盖的默认值。 +- **开发服务器不监听内容仓库。** `src/config/overrides/` 在每次 dev/build 前由 sync-content 从内容仓库复制而来;dev 运行中修改覆盖文件需要重启 `pnpm dev` 才会重新同步。 +- **`scripts/compress-fonts/` 暂不读取覆盖值**,该目录是独立的手动工具,不在 `pnpm build` 流程内。番剧数据脚本(`update-anime` / `update-bangumi` / `update-bilibili`)已经会优先读覆盖值。 + +--- + ## 🔄 自动构建触发 (内容更新时) ### 问题 @@ -372,6 +520,7 @@ CONTENT_REPO_URL=https://YOUR_TOKEN@github.com/your-username/Mizuki-Content-Priv |------|------| | `pnpm run init-content` | 运行交互式初始化向导 | | `pnpm run sync-content` | 手动同步内容仓库 | +| `pnpm run export-config` | 导出个人配置为 `overrides/` 覆盖文件 | | `pnpm run check` | 运行 Astro 诊断 | | `pnpm run type-check` | 运行 TypeScript 类型检查 | | `pnpm dev` | 启动开发服务器 (自动同步) | diff --git a/package.json b/package.json index 34bd56ab14..efac175f78 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "sync-content": "node scripts/sync-content.js", "init-content": "node scripts/init-content-repo.js", + "export-config": "node --experimental-transform-types scripts/export-config.mjs", "predev": "node scripts/sync-content.js || true", "prebuild": "node scripts/sync-content.js || true", "dev": "astro dev", diff --git a/scripts/export-config.mjs b/scripts/export-config.mjs new file mode 100644 index 0000000000..16480b49fa --- /dev/null +++ b/scripts/export-config.mjs @@ -0,0 +1,314 @@ +#!/usr/bin/env node + +/** + * 一键导出个性化配置 + * + * 把当前 src/config/ 与上游原版逐项比对,只把「你改过的字段」导出成 + * overrides/ 覆盖文件,供内容仓库使用。 + * + * 用法: + * pnpm export-config # 自动挑选上游基准 + * pnpm export-config --ref=upstream/master + * pnpm export-config --out=../MyBlog-Content/overrides + * + * 基准 ref 必须指向「配置还是上游原版」的提交,脚本从 git 里取出那一版 + * 配置做对比,因此不需要你在磁盘上另留一份干净副本。 + */ + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import { register } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { isDeepStrictEqual } from "node:util"; + +import { deepMerge } from "../src/config/deepMerge.ts"; +import { LinkPreset } from "../src/types/config.ts"; + +// 配置文件里存在 `from "../types/config"` 这类省略扩展名的写法,Node 自身 +// 解析不了,补一个 resolve 钩子。 +register( + `data:text/javascript,${encodeURIComponent(` + export async function resolve(specifier, context, next) { + try { + return await next(specifier, context); + } catch (error) { + if (specifier.startsWith(".") && !/\\.[cm]?[jt]s$/.test(specifier)) { + return next(specifier + ".ts", context); + } + throw error; + } + } + `)}`, +); + +const rootDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +/** 覆盖文件名 = 导出常量名,与 src/config/overrideLoader.ts 的名单保持一致 */ +const CONFIGS = [ + ["announcementConfig", "announcementConfig", "AnnouncementConfig"], + ["backgroundWallpaper", "fullscreenWallpaperConfig", "FullscreenWallpaperConfig"], + ["commentConfig", "commentConfig", "CommentConfig"], + ["effectsConfig", "sakuraConfig", "SakuraConfig"], + ["expressiveCodeConfig", "expressiveCodeConfig", "ExpressiveCodeConfig"], + ["footerConfig", "footerConfig", "FooterConfig"], + ["licenseConfig", "licenseConfig", "LicenseConfig"], + ["markdownConfig", "markdownConfig", "MarkdownEnhancementConfig"], + ["musicConfig", "musicPlayerConfig", "MusicPlayerConfig"], + ["navBarConfig", "navBarConfig", "NavBarConfig"], + ["permalinkConfig", "permalinkConfig", "PermalinkConfig"], + ["pioConfig", "pioConfig", "PioConfig"], + ["profileConfig", "profileConfig", "ProfileConfig"], + ["randomPostsConfig", "randomPostsConfig", "RandomPostsConfig"], + ["relatedPostsConfig", "relatedPostsConfig", "RelatedPostsConfig"], + ["shareConfig", "shareConfig", "ShareConfig"], + ["sidebarConfig", "sidebarLayoutConfig", "SidebarLayoutConfig"], + ["siteConfig", "siteConfig", "SiteConfig"], +]; + +/** MarkdownEnhancementConfig 声明在配置文件自身,不在 types/config.ts */ +const TYPE_IN_CONFIG_FILE = new Set(["MarkdownEnhancementConfig"]); + +const LINK_PRESET_NAMES = new Map( + Object.entries(LinkPreset) + .filter(([, value]) => typeof value === "number") + .map(([name, value]) => [value, name]), +); + +function parseArgs(argv) { + const args = { ref: null, out: path.join(rootDir, "overrides-export") }; + for (const arg of argv) { + if (arg.startsWith("--ref=")) args.ref = arg.slice(6); + else if (arg.startsWith("--out=")) args.out = path.resolve(rootDir, arg.slice(6)); + else { + console.error(`未知参数:${arg}`); + process.exit(1); + } + } + return args; +} + +function git(args, options = {}) { + return execFileSync("git", args, { + cwd: rootDir, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); +} + +function refExists(ref) { + try { + git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]); + return true; + } catch { + return false; + } +} + +function resolveBaseRef(requested) { + if (requested) { + if (!refExists(requested)) { + console.error(`✘ 找不到 git ref:${requested}`); + process.exit(1); + } + return requested; + } + + const candidates = ["upstream/master", "upstream/main", "origin/master", "origin/main"]; + const found = candidates.find(refExists); + if (!found) { + console.error("✘ 找不到可用的上游基准,请用 --ref= 指定"); + console.error(` 已尝试:${candidates.join("、")}`); + process.exit(1); + } + return found; +} + +/** 把某个 ref 下的 src/config 与 src/types 取出到临时目录 */ +function materializeBaseTree(ref) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mizuki-base-")); + const files = git(["ls-tree", "-r", "--name-only", ref, "src/config", "src/types"]) + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.endsWith(".ts")); + + for (const file of files) { + const target = path.join(dir, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, git(["show", `${ref}:${file}`])); + } + return dir; +} + +function isPlainObject(value) { + if (typeof value !== "object" || value === null) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** 只保留与上游不同的字段 */ +function minimalDiff(base, mine) { + if (!isPlainObject(base) || !isPlainObject(mine)) { + return isDeepStrictEqual(base, mine) ? undefined : mine; + } + + const diff = {}; + for (const [key, value] of Object.entries(mine)) { + const sub = minimalDiff(base[key], value); + if (sub !== undefined) diff[key] = sub; + } + return Object.keys(diff).length ? diff : undefined; +} + +/** 深合并表达不了「上游有、你删掉」的键,单独找出来提示 */ +function findRemovedPaths(base, mine, trail = [], out = []) { + if (!isPlainObject(base) || !isPlainObject(mine)) return out; + for (const key of Object.keys(base)) { + if (!(key in mine) || mine[key] === undefined) { + out.push([...trail, key].join(".")); + } else { + findRemovedPaths(base[key], mine[key], [...trail, key], out); + } + } + return out; +} + +function formatKey(key) { + return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key); +} + +function serialize(value, depth, useLinkPreset) { + const pad = "\t".repeat(depth); + const padInner = "\t".repeat(depth + 1); + + if (Array.isArray(value)) { + if (value.length === 0) return "[]"; + const items = value.map((item) => { + if (useLinkPreset && typeof item === "number" && LINK_PRESET_NAMES.has(item)) { + return `${padInner}LinkPreset.${LINK_PRESET_NAMES.get(item)}`; + } + return `${padInner}${serialize(item, depth + 1, useLinkPreset)}`; + }); + return `[\n${items.join(",\n")},\n${pad}]`; + } + + if (isPlainObject(value)) { + const keys = Object.keys(value); + if (keys.length === 0) return "{}"; + const entries = keys.map( + (key) => + `${padInner}${formatKey(key)}: ${serialize(value[key], depth + 1, useLinkPreset)}`, + ); + return `{\n${entries.join(",\n")},\n${pad}}`; + } + + return JSON.stringify(value); +} + +function renderFile(exportName, typeName, override) { + const usesLinkPreset = exportName === "navBarConfig"; + const typeImport = TYPE_IN_CONFIG_FILE.has(typeName) + ? `import type { DeepPartial } from "../../types/config";\nimport type { ${typeName} } from "../markdownConfig";` + : `import type { DeepPartial, ${typeName} } from "../../types/config";`; + + const valueImport = usesLinkPreset + ? `import { LinkPreset } from "../../types/config";\n` + : ""; + + return `${valueImport}${typeImport} + +export default ${serialize(override, 0, usesLinkPreset)} satisfies DeepPartial<${typeName}>; +`; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const baseRef = resolveBaseRef(args.ref); + const baseHash = git(["rev-parse", "--short", baseRef]).trim(); + + console.log(`上游基准:${baseRef} (${baseHash})`); + console.log(`导出目录:${path.relative(rootDir, args.out) || "."}\n`); + + const baseDir = materializeBaseTree(baseRef); + const written = []; + const identical = []; + const problems = []; + + try { + for (const [file, exportName, typeName] of CONFIGS) { + const baseFile = path.join(baseDir, "src/config", `${file}.ts`); + if (!fs.existsSync(baseFile)) { + problems.push(`${exportName}:上游基准里没有 src/config/${file}.ts,跳过`); + continue; + } + + const base = (await import(pathToUrl(baseFile)))[exportName]; + const mine = (await import(pathToUrl(path.join(rootDir, "src/config", `${file}.ts`))))[exportName]; + + const override = minimalDiff(base, mine); + if (override === undefined) { + identical.push(exportName); + continue; + } + + // 自检:导出的覆盖必须能还原出当前配置 + if (!isDeepStrictEqual(deepMerge(base, override), mine)) { + const removed = findRemovedPaths(base, mine); + problems.push( + `${exportName}:覆盖无法还原当前配置${removed.length ? `,深合并表达不了这些被删掉的键:${removed.join("、")}` : ""}`, + ); + continue; + } + + fs.mkdirSync(args.out, { recursive: true }); + fs.writeFileSync( + path.join(args.out, `${exportName}.ts`), + renderFile(exportName, typeName, override), + ); + written.push([exportName, Object.keys(override).length]); + } + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + + for (const [name, count] of written) { + console.log(` 已导出 ${name}.ts(${count} 个顶层键)`); + } + if (identical.length) { + console.log(`\n与上游一致、无需覆盖:${identical.join("、")}`); + } + if (problems.length) { + console.log("\n需要手工处理:"); + for (const problem of problems) console.log(` ✘ ${problem}`); + } + + if (!written.length) { + console.log("\n当前配置与上游完全一致,没有需要导出的内容。"); + return; + } + + const outRel = path.relative(rootDir, args.out) || "."; + console.log(`\n共导出 ${written.length} 个覆盖文件到 ${outRel}/`); + console.log("\n接下来:"); + console.log(` 1. 复制到内容仓库:cp ${outRel}/*.ts <内容仓库>/overrides/`); + console.log(" 2. 把 src/config/ 还原成上游原版:"); + console.log(` git checkout ${baseRef} -- src/config/`); + console.log(" 3. 同步并校验:pnpm sync-content && pnpm type-check && pnpm build"); + console.log("\n详见 docs/CONTENT_SEPARATION.md 的「配置覆盖」章节。"); + + if (problems.length) process.exitCode = 1; +} + +function pathToUrl(filePath) { + return `file:///${filePath.split(path.sep).join("/")}`; +} + +main().catch((error) => { + console.error("✘ 导出失败:", error.message); + process.exit(1); +}); diff --git a/scripts/read-site-config.mjs b/scripts/read-site-config.mjs new file mode 100644 index 0000000000..ae2945614e --- /dev/null +++ b/scripts/read-site-config.mjs @@ -0,0 +1,85 @@ +/** + * 站点配置取值助手(供 Node 脚本使用) + * + * 这些脚本直接由 node 运行,无法 import TypeScript 配置,沿用既有的正则读取 + * 方式。src/config/overrides/siteConfig.ts 由 sync-content 从内容仓库同步而来, + * 存在时优先命中,读不到再回退 src/config/siteConfig.ts 里的上游默认值。 + * + * 取值一律限定在指定的顶层配置块内。覆盖文件是部分配置且键序任意,如果沿用 + * 「块名后面第一个字段」的松散匹配,`anime: {}` 后面的 `font: { mode: ... }` + * 会被误读成番剧模式。这里用花括号配平把搜索范围钉死在块内。 + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +const SOURCE_PATHS = [ + path.join(rootDir, "src/config/overrides/siteConfig.ts"), + path.join(rootDir, "src/config/siteConfig.ts"), +]; + +let cachedSources = null; + +function readSources() { + if (!cachedSources) { + cachedSources = SOURCE_PATHS.filter((source) => fs.existsSync(source)).map( + (source) => fs.readFileSync(source, "utf-8"), + ); + } + return cachedSources; +} + +/** + * 截取 `blockKey: { ... }` 的块内文本,块不存在时返回 null。 + */ +export function extractBlock(content, blockKey) { + const opener = content.match(new RegExp(`\\b${blockKey}\\s*:\\s*\\{`)); + if (!opener) { + return null; + } + + let index = opener.index + opener[0].length; + let depth = 1; + const start = index; + + while (index < content.length && depth > 0) { + const char = content[index]; + if (char === "{") depth++; + else if (char === "}") depth--; + index++; + } + + return depth === 0 ? content.slice(start, index - 1) : null; +} + +/** + * 在若干份配置文本里按顺序查找 blockKey 块内的字段,返回第一个命中的捕获组。 + * + * 覆盖文件里存在该块但没写这个字段时,继续往后一份文本找,而不是就地返回 + * 空值,保证未覆盖的字段回退到默认配置。 + */ +export function matchInBlock(sources, blockKey, pattern) { + for (const content of sources) { + const block = extractBlock(content, blockKey); + if (block === null) { + continue; + } + const match = block.match(pattern); + if (match) { + return match[1]; + } + } + return null; +} + +/** + * 按「覆盖 → 默认」的顺序读取 siteConfig 某个块内的字段,都没命中返回 null。 + */ +export function matchSiteConfig(blockKey, pattern) { + return matchInBlock(readSources(), blockKey, pattern); +} diff --git a/scripts/sync-content.js b/scripts/sync-content.js index 7fe630fad6..4f80078c6f 100644 --- a/scripts/sync-content.js +++ b/scripts/sync-content.js @@ -98,6 +98,9 @@ const contentMappings = [ { src: "spec", dest: "src/content/spec" }, { src: "data", dest: "src/data" }, { src: "images", dest: "public/images" }, + // 覆盖文件是带相对导入的 TS 模块,符号链接会被 Vite 解析到内容仓库真实 + // 路径导致找不到 types/config,因此复制进代码仓库而不是建链接 + { src: "overrides", dest: "src/config/overrides", copy: true }, ]; for (const mapping of contentMappings) { @@ -105,10 +108,37 @@ for (const mapping of contentMappings) { const destPath = path.join(rootDir, mapping.dest); if (!fs.existsSync(srcPath)) { + // 内容仓库删掉 overrides/ 后清掉上次的副本,避免旧配置继续生效 + if (mapping.copy) { + const stat = fs.lstatSync(destPath, { throwIfNoEntry: false }); + if (stat) { + if (stat.isSymbolicLink()) { + fs.unlinkSync(destPath); + } else { + fs.rmSync(destPath, { recursive: true, force: true }); + } + console.log(`已清理失效的配置覆盖:${mapping.dest}`); + } + } console.log(`跳过不存在的源目录:${mapping.src}`); continue; } + // 覆盖目录由本脚本复制维护,直接删除重建,不走备份逻辑 + if (mapping.copy) { + const stat = fs.lstatSync(destPath, { throwIfNoEntry: false }); + if (stat) { + if (stat.isSymbolicLink()) { + fs.unlinkSync(destPath); + } else { + fs.rmSync(destPath, { recursive: true, force: true }); + } + } + copyRecursive(srcPath, destPath); + console.log(`已复制配置覆盖:${mapping.src} -> ${mapping.dest}`); + continue; + } + // 如果目标已存在且不是符号链接,备份它 if (fs.existsSync(destPath) && !fs.lstatSync(destPath).isSymbolicLink()) { const backupPath = `${destPath}.backup`; diff --git a/scripts/update-anime.mjs b/scripts/update-anime.mjs index 29e23ad5fc..ed957c372c 100644 --- a/scripts/update-anime.mjs +++ b/scripts/update-anime.mjs @@ -1,27 +1,10 @@ import { spawn } from "child_process"; -import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; +import { matchSiteConfig } from "./read-site-config.mjs"; -const CONFIG_PATH = path.join( - path.dirname(fileURLToPath(import.meta.url)), - "../src/config/siteConfig.ts", -); - -async function getAnimeModeFromConfig() { - try { - const configContent = await fs.readFile(CONFIG_PATH, "utf-8"); - const match = configContent.match( - /anime:\s*\{[\s\S]*?mode:\s*["']([^"']+)["']/, - ); - - if (match && match[1]) { - return match[1]; - } - return "bangumi"; - } catch (error) { - return "bangumi"; - } +function getAnimeModeFromConfig() { + return matchSiteConfig("anime", /mode:\s*["']([^"']+)["']/) || "bangumi"; } function runScript(scriptPath) { @@ -46,7 +29,7 @@ function runScript(scriptPath) { } async function main() { - const mode = await getAnimeModeFromConfig(); + const mode = getAnimeModeFromConfig(); const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); if (mode === "bilibili") { diff --git a/scripts/update-bangumi.mjs b/scripts/update-bangumi.mjs index 6a4ee40e34..c71cecbe2a 100644 --- a/scripts/update-bangumi.mjs +++ b/scripts/update-bangumi.mjs @@ -1,59 +1,33 @@ import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; +import { matchSiteConfig } from "./read-site-config.mjs"; const API_BASE = "https://api.bgm.tv"; -const CONFIG_PATH = path.join( - path.dirname(fileURLToPath(import.meta.url)), - "../src/config/siteConfig.ts", -); const OUTPUT_FILE = path.join( path.dirname(fileURLToPath(import.meta.url)), "../src/data/bangumi-data.json", ); -async function getUserIdFromConfig() { - try { - const configContent = await fs.readFile(CONFIG_PATH, "utf-8"); - const match = configContent.match( - /bangumi:\s*\{[\s\S]*?userId:\s*["']([^"']+)["']/, - ); +function getUserIdFromConfig() { + const userId = matchSiteConfig("bangumi", /userId:\s*["']([^"']+)["']/); - if (match && match[1]) { - const userId = match[1]; - if ( - userId === "your-bangumi-id" || - userId === "your-user-id" || - !userId - ) { - console.warn( - "Warning: userId in src/config/siteConfig.ts appears to be a default value.", - ); - return userId; - } - return userId; - } - throw new Error("Could not find bangumi.userId in config/siteConfig.ts"); - } catch (error) { + if (!userId) { console.error("✘ Failed to read Bangumi ID from config/siteConfig.ts"); - throw error; + throw new Error("Could not find bangumi.userId in config/siteConfig.ts"); } -} -async function getAnimeModeFromConfig() { - try { - const configContent = await fs.readFile(CONFIG_PATH, "utf-8"); - const match = configContent.match( - /anime:\s*\{[\s\S]*?mode:\s*["']([^"']+)["']/, + if (userId === "your-bangumi-id" || userId === "your-user-id") { + console.warn( + "Warning: userId in src/config/siteConfig.ts appears to be a default value.", ); - - if (match && match[1]) { - return match[1]; - } - return "bangumi"; - } catch (error) { - return "bangumi"; } + + return userId; +} + +function getAnimeModeFromConfig() { + return matchSiteConfig("anime", /mode:\s*["']([^"']+)["']/) || "bangumi"; } // 模拟延迟防止 API 限制 @@ -203,7 +177,7 @@ async function processData(items, status) { async function main() { console.log("Initializing Bangumi data update script..."); - const animeMode = await getAnimeModeFromConfig(); + const animeMode = getAnimeModeFromConfig(); if (animeMode !== "bangumi") { console.log( `Detected current anime mode is "${animeMode}", skipping Bangumi data update.`, @@ -211,7 +185,7 @@ async function main() { return; } - const USER_ID = await getUserIdFromConfig(); + const USER_ID = getUserIdFromConfig(); console.log(`Read User ID: ${USER_ID}`); const collections = [ diff --git a/scripts/update-bilibili.mjs b/scripts/update-bilibili.mjs index ea4b5c8e34..a353ecb980 100644 --- a/scripts/update-bilibili.mjs +++ b/scripts/update-bilibili.mjs @@ -3,15 +3,12 @@ import path from "path"; import { fileURLToPath } from "url"; import axios from "axios"; import { loadEnv } from "./load-env.js"; +import { matchSiteConfig } from "./read-site-config.mjs"; loadEnv(); const API_BASE = "https://api.bilibili.com/x/space/bangumi/follow/list"; const PAGE_SIZE = 30; -const CONFIG_PATH = path.join( - path.dirname(fileURLToPath(import.meta.url)), - "../src/config/siteConfig.ts", -); const OUTPUT_FILE = path.join( path.dirname(fileURLToPath(import.meta.url)), "../src/data/bilibili-data.json", @@ -40,65 +37,36 @@ async function withRetry(apiCall, retries = 3) { } } -async function getUserIdFromConfig() { - try { - const configContent = await fs.readFile(CONFIG_PATH, "utf-8"); - const match = configContent.match( - /bilibili:\s*\{[\s\S]*?vmid:\s*["']([^"']+)["']/, - ); +function getUserIdFromConfig() { + const vmid = matchSiteConfig("bilibili", /vmid:\s*["']([^"']+)["']/); - if (match && match[1]) { - const vmid = match[1]; - if (!vmid || vmid.trim() === "") { - console.warn("Warning: vmid in src/config/siteConfig.ts is empty."); - return null; - } - return vmid; - } - throw new Error("Could not find bilibili.vmid in config/siteConfig.ts"); - } catch (error) { + if (vmid === null) { console.error("✘ Failed to read Bilibili vmid from config/siteConfig.ts"); - throw error; + throw new Error("Could not find bilibili.vmid in config/siteConfig.ts"); } + + if (vmid.trim() === "") { + console.warn("Warning: vmid in src/config/siteConfig.ts is empty."); + return null; + } + + return vmid; } async function getSessdataFromConfig() { return process.env.BILI_SESSDATA || ""; } -async function getCoverMirrorFromConfig() { - try { - const configContent = await fs.readFile(CONFIG_PATH, "utf-8"); - const match = configContent.match(/coverMirror:\s*["']([^"']*)["']/); - return match ? match[1] : ""; - } catch { - return ""; - } +function getCoverMirrorFromConfig() { + return matchSiteConfig("bilibili", /coverMirror:\s*["']([^"']*)["']/) ?? ""; } -async function getUseWebpFromConfig() { - try { - const configContent = await fs.readFile(CONFIG_PATH, "utf-8"); - return !configContent.match(/useWebp:\s*false/); - } catch { - return true; - } +function getUseWebpFromConfig() { + return matchSiteConfig("bilibili", /useWebp:\s*(true|false)/) !== "false"; } -async function getAnimeModeFromConfig() { - try { - const configContent = await fs.readFile(CONFIG_PATH, "utf-8"); - const match = configContent.match( - /anime:\s*\{[\s\S]*?mode:\s*["']([^"']+)["']/, - ); - - if (match && match[1]) { - return match[1]; - } - return "bangumi"; - } catch (error) { - return "bangumi"; - } +function getAnimeModeFromConfig() { + return matchSiteConfig("anime", /mode:\s*["']([^"']+)["']/) || "bangumi"; } async function getDataPage(vmid, status, typeNum = 1) { @@ -317,7 +285,7 @@ async function processData( async function main() { console.log("Initializing Bilibili data update script..."); - const animeMode = await getAnimeModeFromConfig(); + const animeMode = getAnimeModeFromConfig(); if (animeMode !== "bilibili") { console.log( `Detected current anime mode is "${animeMode}", skipping Bilibili data update.`, @@ -325,7 +293,7 @@ async function main() { return; } - const VMID = await getUserIdFromConfig(); + const VMID = getUserIdFromConfig(); if (!VMID) { console.error( "✘ Bilibili vmid is not set. Please set it in src/config/siteConfig.ts", @@ -335,8 +303,8 @@ async function main() { console.log(`Read User ID: ${VMID}`); const SESSDATA = await getSessdataFromConfig(); - const coverMirror = await getCoverMirrorFromConfig(); - const useWebp = await getUseWebpFromConfig(); + const coverMirror = getCoverMirrorFromConfig(); + const useWebp = getUseWebpFromConfig(); // 获取三种状态的数据 (1=想看, 2=在看, 3=已看) console.log("\nFetching Bilibili bangumi data..."); diff --git a/src/config/deepMerge.ts b/src/config/deepMerge.ts new file mode 100644 index 0000000000..4f106a270d --- /dev/null +++ b/src/config/deepMerge.ts @@ -0,0 +1,41 @@ +/** + * 配置覆盖的合并语义 + * + * 供 src/config/index.ts 把 src/config/overrides/ 下的部分覆盖合并进上游默认 + * 配置。这里刻意不引入任何 Vite 专有语法(如 import.meta.glob),以便直接用 + * node --experimental-strip-types 做单元测试。 + * + * 合并规则: + * - 双方都是普通对象 → 递归合并,覆盖值只影响写到的字段; + * - 其余情况(数组、标量、null)→ 覆盖值整体替换,不做拼接; + * - 覆盖值中显式写成 undefined 的键 → 跳过,保留默认值。 + * + * 合并不会修改 base,默认配置对象始终保持原样。 + */ +export function deepMerge(base: T, override: unknown): T { + if (override === undefined) { + return base; + } + + if (!isPlainObject(base) || !isPlainObject(override)) { + return override as T; + } + + const merged: Record = { ...base }; + for (const [key, value] of Object.entries(override)) { + if (value === undefined) { + continue; + } + merged[key] = deepMerge(merged[key], value); + } + + return merged as T; +} + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null) { + return false; + } + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} diff --git a/src/config/index.ts b/src/config/index.ts index 141dc75061..d25e634736 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -8,7 +8,7 @@ * 导出名称 │ 文件 │ 说明 * ─────────────────────────────┼────────────────────────────┼────────────────────────────── * siteConfig │ siteConfig.ts │ 站点核心配置(标题、语言、主题色、横幅、字体、特色页面开关等) - * SITE_LANG │ siteConfig.ts │ 站点语言常量(从 siteConfig 中导出) + * SITE_LANG │ (派生) │ 站点语言常量(取合并后的 siteConfig.lang) * fullscreenWallpaperConfig │ backgroundWallpaper.ts │ 全屏壁纸模式配置(图片源、轮播、透明度、模糊) * navBarConfig │ navBarConfig.ts │ 导航栏菜单配置(链接、多级下拉菜单) * profileConfig │ profileConfig.ts │ 个人资料(头像、昵称、简介、社交链接) @@ -28,6 +28,18 @@ * widgetConfigs │ (聚合) │ 侧边栏 Widget 配置聚合对象 * * ══════════════════════════════════════════════════════════════ + * 配置覆盖(可选) + * ══════════════════════════════════════════════════════════════ + * + * 各配置文件保存上游默认值,本文件在导出前把 src/config/overrides/ 下的同名 + * 覆盖文件深合并进来: + * + * 最终配置 = deepMerge(上游默认配置, overrides/<导出名>.ts 的 default 导出) + * + * 覆盖目录由 sync-content 从内容仓库的 overrides/ 同步,不存在时所有配置等同 + * 于上游默认值。详见 docs/CONTENT_SEPARATION.md。 + * + * ══════════════════════════════════════════════════════════════ * 类型定义 * ══════════════════════════════════════════════════════════════ * @@ -48,50 +60,99 @@ * import { siteConfig } from "src/config"; * * 以上三种方式都会自动解析到此 index.ts 文件。 + * 请始终从本入口读取配置,直接 import 某个配置文件会绕过覆盖合并。 */ -export { announcementConfig } from "./announcementConfig"; +import { announcementConfig as announcementDefaults } from "./announcementConfig"; +import { fullscreenWallpaperConfig as fullscreenWallpaperDefaults } from "./backgroundWallpaper"; +import { commentConfig as commentDefaults } from "./commentConfig"; +import { sakuraConfig as sakuraDefaults } from "./effectsConfig"; +import { expressiveCodeConfig as expressiveCodeDefaults } from "./expressiveCodeConfig"; +import { footerConfig as footerDefaults } from "./footerConfig"; +import { licenseConfig as licenseDefaults } from "./licenseConfig"; +import { markdownConfig as markdownDefaults } from "./markdownConfig"; +import { musicPlayerConfig as musicPlayerDefaults } from "./musicConfig"; +import { navBarConfig as navBarDefaults } from "./navBarConfig"; +import { withOverride } from "./overrideLoader"; +import { permalinkConfig as permalinkDefaults } from "./permalinkConfig"; +import { pioConfig as pioDefaults } from "./pioConfig"; +import { profileConfig as profileDefaults } from "./profileConfig"; +import { randomPostsConfig as randomPostsDefaults } from "./randomPostsConfig"; +import { relatedPostsConfig as relatedPostsDefaults } from "./relatedPostsConfig"; +import { shareConfig as shareDefaults } from "./shareConfig"; +import { sidebarLayoutConfig as sidebarLayoutDefaults } from "./sidebarConfig"; +import { siteConfig as siteDefaults } from "./siteConfig"; + +// ─── 站点核心 ─────────────────────────────────────────────── +export const siteConfig = withOverride("siteConfig", siteDefaults); + +// SITE_LANG 从合并后的站点配置派生,覆盖 siteConfig.lang 后会一并生效。 +// 注意:commentConfig.ts 在模块顶层引用了 siteConfig.ts 里的同名常量填充评论 +// 语言,若覆盖了 siteConfig.lang,需要同时覆盖 commentConfig 的对应字段。 +export const SITE_LANG = siteConfig.lang; // ─── 外观与壁纸 ───────────────────────────────────────────── -export { fullscreenWallpaperConfig } from "./backgroundWallpaper"; +export const fullscreenWallpaperConfig = withOverride( + "fullscreenWallpaperConfig", + fullscreenWallpaperDefaults, +); + // ─── 互动功能 ─────────────────────────────────────────────── -export { commentConfig } from "./commentConfig"; -export { sakuraConfig } from "./effectsConfig"; +export const commentConfig = withOverride("commentConfig", commentDefaults); +export const sakuraConfig = withOverride("sakuraConfig", sakuraDefaults); + // ─── 代码块 ───────────────────────────────────────────────── -export { expressiveCodeConfig } from "./expressiveCodeConfig"; -export { footerConfig } from "./footerConfig"; +export const expressiveCodeConfig = withOverride( + "expressiveCodeConfig", + expressiveCodeDefaults, +); + +export const footerConfig = withOverride("footerConfig", footerDefaults); + // ─── 内容与版权 ───────────────────────────────────────────── -export { licenseConfig } from "./licenseConfig"; -export { markdownConfig } from "./markdownConfig"; +export const licenseConfig = withOverride("licenseConfig", licenseDefaults); +export const markdownConfig = withOverride("markdownConfig", markdownDefaults); + // ─── 多媒体 ───────────────────────────────────────────────── -export { musicPlayerConfig } from "./musicConfig"; +export const musicPlayerConfig = withOverride( + "musicPlayerConfig", + musicPlayerDefaults, +); + // ─── 导航栏 ───────────────────────────────────────────────── -export { navBarConfig } from "./navBarConfig"; -export { permalinkConfig } from "./permalinkConfig"; -export { pioConfig } from "./pioConfig"; +export const navBarConfig = withOverride("navBarConfig", navBarDefaults); +export const permalinkConfig = withOverride( + "permalinkConfig", + permalinkDefaults, +); +export const pioConfig = withOverride("pioConfig", pioDefaults); + // ─── 个人资料 ─────────────────────────────────────────────── -export { profileConfig } from "./profileConfig"; -export { randomPostsConfig } from "./randomPostsConfig"; +export const profileConfig = withOverride("profileConfig", profileDefaults); +export const randomPostsConfig = withOverride( + "randomPostsConfig", + randomPostsDefaults, +); + // ─── 文章推荐 ─────────────────────────────────────────────── -export { relatedPostsConfig } from "./relatedPostsConfig"; -export { shareConfig } from "./shareConfig"; +export const relatedPostsConfig = withOverride( + "relatedPostsConfig", + relatedPostsDefaults, +); +export const shareConfig = withOverride("shareConfig", shareDefaults); + // ─── 布局 ─────────────────────────────────────────────────── -export { sidebarLayoutConfig } from "./sidebarConfig"; -// ─── 站点核心 ─────────────────────────────────────────────── -export { SITE_LANG, siteConfig } from "./siteConfig"; +export const sidebarLayoutConfig = withOverride( + "sidebarLayoutConfig", + sidebarLayoutDefaults, +); -import { announcementConfig } from "./announcementConfig"; -import { fullscreenWallpaperConfig } from "./backgroundWallpaper"; -import { sakuraConfig } from "./effectsConfig"; -import { musicPlayerConfig } from "./musicConfig"; -import { pioConfig } from "./pioConfig"; -// ─── Widget 配置聚合(供 Swup 等运行时使用)──────────────── -import { profileConfig } from "./profileConfig"; -import { randomPostsConfig } from "./randomPostsConfig"; -import { relatedPostsConfig } from "./relatedPostsConfig"; -import { shareConfig } from "./shareConfig"; -import { sidebarLayoutConfig } from "./sidebarConfig"; +export const announcementConfig = withOverride( + "announcementConfig", + announcementDefaults, +); +// ─── Widget 配置聚合(供 Swup 等运行时使用)──────────────── export const widgetConfigs = { profile: profileConfig, announcement: announcementConfig, diff --git a/src/config/overrideLoader.ts b/src/config/overrideLoader.ts new file mode 100644 index 0000000000..3c780cfb6a --- /dev/null +++ b/src/config/overrideLoader.ts @@ -0,0 +1,74 @@ +/** + * 配置覆盖加载器 + * + * 从 src/config/overrides/ 读取用户的部分配置覆盖,合并进上游默认配置。 + * 该目录由 sync-content 从内容仓库的 overrides/ 同步而来,不随代码仓库提交; + * 目录缺失时 import.meta.glob 返回空对象,所有配置退回上游默认值。 + * + * 约定:覆盖文件名 = 被覆盖的导出常量名。例如 overrides/sakuraConfig.ts 覆盖 + * sakuraConfig(注意上游文件名是 effectsConfig.ts)。文件名拼错会在构建期报错, + * 而不是静默失效。 + */ +import { deepMerge } from "./deepMerge"; + +const OVERRIDABLE_CONFIGS = [ + "announcementConfig", + "commentConfig", + "expressiveCodeConfig", + "footerConfig", + "fullscreenWallpaperConfig", + "licenseConfig", + "markdownConfig", + "musicPlayerConfig", + "navBarConfig", + "permalinkConfig", + "pioConfig", + "profileConfig", + "randomPostsConfig", + "relatedPostsConfig", + "sakuraConfig", + "shareConfig", + "sidebarLayoutConfig", + "siteConfig", +] as const; + +export type OverridableConfigName = (typeof OVERRIDABLE_CONFIGS)[number]; + +const overrideModules = import.meta.glob<{ default?: unknown }>( + "./overrides/*.ts", + { eager: true }, +); + +const overrides = collectOverrides(); + +function collectOverrides(): Map { + const allowed = new Set(OVERRIDABLE_CONFIGS); + const collected = new Map(); + + for (const [modulePath, module] of Object.entries(overrideModules)) { + const name = modulePath.replace(/^.*\//, "").replace(/\.ts$/, ""); + + if (!allowed.has(name)) { + throw new Error( + `Unknown config override "${modulePath}". The file name must match one of the exported config names: ${OVERRIDABLE_CONFIGS.join(", ")}`, + ); + } + + if (module?.default === undefined) { + throw new Error( + `Config override "${modulePath}" has no default export. Expected: export default { ... } satisfies DeepPartial<${name === "siteConfig" ? "SiteConfig" : "..."}>`, + ); + } + + collected.set(name, module.default); + } + + return collected; +} + +/** + * 把同名覆盖合并进默认配置。没有对应覆盖文件时原样返回默认配置。 + */ +export function withOverride(name: OverridableConfigName, base: T): T { + return deepMerge(base, overrides.get(name)); +} diff --git a/src/types/config.ts b/src/types/config.ts index 838b74a6ff..72f43e395d 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -591,3 +591,16 @@ export interface ThirdPartyAnalyticsConfig { enable: boolean; // 是否启用第三方统计(Microsoft Clarity),默认关闭 clarityId?: string; // Clarity 项目 ID } + +/** + * 递归可选类型,供 src/config/overrides/ 下的配置覆盖文件使用。 + * + * `Partial` 只把顶层键变成可选,无法表达「只改 themeColor.hue、其余取 + * 默认值」这类部分覆盖;数组分支直接短路,避免 `string[]` 退化成 + * `(string | undefined)[]`(数组在合并时本就整体替换)。 + */ +export type DeepPartial = T extends readonly unknown[] + ? T + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T; diff --git a/src/utils/image-utils.ts b/src/utils/image-utils.ts index 1a7985dcbd..fc803dad80 100644 --- a/src/utils/image-utils.ts +++ b/src/utils/image-utils.ts @@ -1,4 +1,4 @@ -import { siteConfig } from "../config/siteConfig"; +import { siteConfig } from "../config"; import type { ImageFormat } from "../types/config"; import { matchesNoReferrerDomain } from "./image-referrer"; diff --git a/tests/config-overrides.test.ts b/tests/config-overrides.test.ts new file mode 100644 index 0000000000..a6373cc917 --- /dev/null +++ b/tests/config-overrides.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { deepMerge } from "../src/config/deepMerge.ts"; + +describe("config override merge", () => { + it("只覆盖写到的字段,其余取默认值", () => { + const base = { + themeColor: { hue: 240, fixed: false }, + banner: { position: "center", carousel: { enable: true, interval: 3 } }, + }; + + const merged = deepMerge(base, { + banner: { carousel: { interval: 8 } }, + }); + + assert.deepEqual(merged, { + themeColor: { hue: 240, fixed: false }, + banner: { position: "center", carousel: { enable: true, interval: 8 } }, + }); + }); + + it("数组整体替换而不是拼接", () => { + const base = { src: { desktop: ["1.webp", "2.webp", "3.webp"] } }; + + const merged = deepMerge(base, { src: { desktop: ["only.webp"] } }); + + assert.deepEqual(merged.src.desktop, ["only.webp"]); + }); + + it("跳过覆盖值中显式写成 undefined 的键", () => { + const merged = deepMerge({ title: "Mizuki" }, { title: undefined }); + + assert.equal(merged.title, "Mizuki"); + }); + + it("覆盖值为 null 时按整体替换处理", () => { + const merged = deepMerge( + { credit: { url: "https://example.com/" } }, + { + credit: { url: null }, + }, + ); + + assert.equal(merged.credit.url, null); + }); + + it("覆盖缺失时原样返回默认配置", () => { + const base = { title: "Mizuki" }; + + assert.equal(deepMerge(base, undefined), base); + }); + + it("不修改默认配置对象", () => { + const base = { themeColor: { hue: 240, fixed: false } }; + + deepMerge(base, { themeColor: { hue: 30 } }); + + assert.equal(base.themeColor.hue, 240); + }); + + it("默认值里不存在的键会被补上", () => { + const merged = deepMerge<{ keywords?: string[] }>( + {}, + { + keywords: ["blog"], + }, + ); + + assert.deepEqual(merged.keywords, ["blog"]); + }); +}); diff --git a/tests/site-config-reader.test.ts b/tests/site-config-reader.test.ts new file mode 100644 index 0000000000..772626dfb1 --- /dev/null +++ b/tests/site-config-reader.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { extractBlock, matchInBlock } from "../scripts/read-site-config.mjs"; + +// 上游默认配置的缩影:字段齐全,块顺序固定 +const DEFAULTS = ` +export const siteConfig: SiteConfig = { + navbarTitle: { mode: "text-icon", text: "MizukiUI" }, + font: { mode: "custom" }, + bangumi: { userId: "your-bangumi-id", fetchOnDev: false }, + bilibili: { vmid: "", coverMirror: "", useWebp: true }, + anime: { mode: "local" }, +}; +`; + +const MODE = /mode:\s*["']([^"']+)["']/; +const VMID = /vmid:\s*["']([^"']*)["']/; +const COVER_MIRROR = /coverMirror:\s*["']([^"']*)["']/; +const USE_WEBP = /useWebp:\s*(true|false)/; + +describe("Node 脚本读取站点配置", () => { + it("没有覆盖文件时读上游默认值", () => { + assert.equal(matchInBlock([DEFAULTS], "anime", MODE), "local"); + assert.equal(matchInBlock([DEFAULTS], "bilibili", USE_WEBP), "true"); + }); + + it("覆盖文件优先于默认值", () => { + const override = `export default { anime: { mode: "bilibili" } };`; + + assert.equal(matchInBlock([override, DEFAULTS], "anime", MODE), "bilibili"); + }); + + it("覆盖块里缺少的字段继续回退到默认值", () => { + // 只覆盖 vmid,coverMirror / useWebp 仍应取上游默认 + const override = `export default { bilibili: { vmid: "1129280784" } };`; + const sources = [override, DEFAULTS]; + + assert.equal(matchInBlock(sources, "bilibili", VMID), "1129280784"); + assert.equal(matchInBlock(sources, "bilibili", COVER_MIRROR), ""); + assert.equal(matchInBlock(sources, "bilibili", USE_WEBP), "true"); + }); + + it("取值不会越过块边界串到相邻配置", () => { + // anime 块是空的,后面 font.mode 不能被当成番剧模式 + const override = `export default { + anime: {}, + font: { mode: "system" }, + };`; + + assert.equal(matchInBlock([override, DEFAULTS], "anime", MODE), "local"); + }); + + it("覆盖文件里不存在的块直接跳过", () => { + const override = `export default { title: "我的站点" };`; + + assert.equal(matchInBlock([override, DEFAULTS], "anime", MODE), "local"); + }); + + it("所有来源都没有该块时返回 null,由调用方兜底", () => { + assert.equal(matchInBlock([DEFAULTS], "notAConfigBlock", MODE), null); + }); + + it("块内嵌套对象不会提前截断", () => { + const content = `{ + banner: { src: { desktop: ["a"] }, position: "center" }, + anime: { mode: "bangumi" }, + }`; + + const banner = extractBlock(content, "banner"); + assert.match(banner, /position:\s*"center"/); + assert.equal(matchInBlock([content], "anime", MODE), "bangumi"); + }); + + it("花括号不配平时视为没有该块", () => { + assert.equal(extractBlock(`anime: { mode: "local"`, "anime"), null); + }); +});