From 7ead565ef40eb3a98becac8e66348627e7e27b10 Mon Sep 17 00:00:00 2001 From: mrbbbaixue Date: Sat, 25 Jul 2026 11:42:32 +0800 Subject: [PATCH 01/11] =?UTF-8?q?docs(plan):=20=E6=96=B0=E5=A2=9E=20Metada?= =?UTF-8?q?ta=20=E6=94=B9=E8=BF=9B=E8=AE=A1=E5=88=92=20PLAN.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 固化 grill-me 决策:XML 数据展平、资源外置、纯 managed 库、 Stage A/B 发布管线、硬失败校验;并约定测试框架改用 MSTest、移除 xunit。 --- PLAN.md | 273 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..2557621 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,273 @@ +# Metadata 改进与完善计划 + +> 状态:已确认(grill-me 决策锁定) +> 范围:本期仅 **Metadata 仓**;Desktop 联调另迭代 +> 优先级:**2 客户端契约 → 5 库产品化 → 1 示例内容** + +--- + +## 0. 已锁定决策 + +| 项 | 结论 | +|---|---| +| 优先级 | **2 客户端契约 → 5 库产品化 → 1 示例内容** | +| 主消费端 | Desktop(本期不改 Desktop 仓) | +| 发布数据 | **XML 展平为单一 `metadata.xml`** | +| 发布资源 | Manifest / 图片 / Markdown **独立文件**,XML 内 **ID 引用** | +| public/private | 仅构建期可见性;线上无 `Include` | +| 用户默认 | Cloudflare Pages 展平产物(可配置默认 URL) | +| 开发者(下期 Desktop) | 调试页指本地仓 → Stage A 编译 → 读缓存 | +| 库形态 | **单一纯 managed 库**:解析 + 编译;支持 **path / URL** | +| 图片 | 库不做 WebP;**仅 CF Stage B** 转 WebP | +| 校验 | **硬失败**,不产出半残发布物 | +| Manifest | **Updater 生成**,本仓只引用;Updater 改造 **记入路线图** | +| MVP 数据 | App `RA3BattleNet` + Mod `Corona` **示例**;其余自行补充 | +| 本期边界 | **仅 Metadata 仓** + Desktop 对接说明 | +| 测试框架 | **MSTest**;移除 xunit / xunit.v3 / xunit.runner 等 | + +--- + +## 1. 目标架构 + +``` +源仓 Metadata/ Stage A (库/CLI, 纯 managed) Stage B (仅 CF) + xml 树 + 资源 → 校验 → 变量 → XML 展平 → Output/ → WebP + 改写引用 → 部署 Pages + ↓ + metadata.xml (数据, 含 SchemaVersion/ContentRevision) + + manifests/ images/ *.md ... (资源, ID 可解析) +``` + +### 1.1 发布契约(Desktop 将依赖) + +- `GET {BaseUrl}/metadata.xml` — 展平后的业务数据树 +- `{BaseUrl}/{相对路径}` — 资源文件 +- 根节点字段:`SchemaVersion`、`ContentRevision` +- 资源通过 ID 引用(如 `manifest-1.5.2.0`、`corona-icon-64px`),**不写死域名** +- 展平树内存在对应 `` / `` 等登记节点,携带 `Source` / `Url` 等定位信息 + +### 1.2 数据 vs 资源 + +| 类型 | 处理 | +|---|---| +| **数据(XML)** | 构建期展平为**单一** `metadata.xml`(去掉 Include,合并业务树) | +| **资源** | Manifest / 图片 / Markdown 等**不进入**展平 XML 正文;独立存放,XML 只保留 ID/路径引用 | + +### 1.3 本地调试 vs 线上(下期 Desktop,本期写清对接点) + +- **最终用户**:默认读 Cloudflare Pages 展平产物(默认 BaseUrl) +- **开发者**:Desktop 调试页可选择指向**本地 Metadata 仓库** → 调用 Stage A 编译到缓存目录 → 与线上同形解析 +- 默认:**手动「重新编译」**;可选目录监视自动编译 +- 生产与调试共用同一套「展平 XML + 资源」解析逻辑,只换内容来源 + +--- + +## 2. 分期落地 + +### 阶段 A — 客户端契约与构建语义(优先级 2) + +#### A1. 发布契约文档 + +- 默认 URL 占位、路径规则、ID 引用、版本字段 +- Stage A vs B 产物差异(业务树同形,图片扩展名可能不同) +- 硬失败错误约定 + +#### A2. XML 展平(核心) + +- 入口 `metadata.xml` 递归处理 Include +- public/private 按规则合并进树,输出**去掉 Include** +- **不内联** Manifest 文件表、图片字节、Markdown 文件正文 +- 产出单一数据文件 + 资源目录 + +#### A3. 变量与校验硬失败 + +- 支持:`${TIMESTAMP}` / `${ENV:}` / `${MD5:}` / `${META:}` / `${this:}` +- 以下情况 **非 0 退出**,禁止发布: + - XSD 失败 + - 循环 Include + - 缺失资源文件 + - 断开的 Image / Markdown / Manifest ID + - 未解析完的 `${...}` 残留 +- 取消「Markdown 只警告仍成功」等软失败行为 + +#### A4. 输出布局 + +``` +Output/ + metadata.xml # 仅展平后的数据 + apps/... # 资源(md / 图 / manifest xml 等) + mods/... +``` + +- 推荐最终只复制**被引用**资源以减小发布面 +- 示例阶段可先全量复制,再收紧 + +#### A5. 契约自测 + +- Include 展平、ID 可解析、硬失败用例 +- CLI 对示例源 `build` → 断言 Output 形状 +- 测试框架见 **§5** + +### 阶段 B — 库产品化(优先级 5) + +#### B1. 包边界 + +- `Ra3.BattleNet.Metadata`:类库(解析 + Stage A 构建) +- 可执行入口:同解决方案 CLI(`dotnet run` / 日后 `dotnet tool`) +- **主库无 SkiaSharp 等原生硬依赖**(WebP 仅 Stage B) + +#### B2. 公共 API(最小面) + +```text +MetadataDocument.Load(path | url) // 解析已展平 XML +MetadataBuilder.Build(sourcePath | sourceUrl, outputDir) // Stage A +// 查询:Applications / Mods / 按 ID 取 Image|Manifest|Markdown 描述 +// BasePath / BaseUrl 用于拼资源位置 +``` + +- `Load(url)`:HTTP(S) 拉取展平后的 `metadata.xml` +- `Build(path)`:本地源仓 +- `Build(url)`:远程源(zip 或静态源树)→ 缓存 → 同本地 Build + - 若首期仅实现 path,URL Build 预留 API,列入紧随迭代 + +#### B3. 版本字段 + +- `SchemaVersion`:契约版本(人工维护) +- `ContentRevision`:构建注入(UTC 时间或 git commit) + +#### B4. 验收 + +- 无 SkiaSharp 引用下 `dotnet test` + `dotnet pack` 通过 +- 示例:`Build(本地)` → `Load(Output/metadata.xml)` → 列出 App/Mod/资源 ID + +### 阶段 C — 发布管线 + +#### C1. `build.sh` 两段式 + +1. 安装/使用 dotnet +2. **Stage A**:CLI Build(纯 managed:校验、变量、XML 展平) +3. **Stage B**(仅 CF/发布):WebP 转换 + 更新 Image Source 引用 +4. 任一步失败即 fail + +#### C2. Cloudflare Pages + +- 部署 `Output/` +- 根路径 → `metadata.xml`(`_redirects` 或 Pages 配置) +- Worker `src/index.js`:删除无效假逻辑或明确仅资产透传 + +#### C3. 文档 + +- `npm run build` / `deploy` / 默认 URL 配置 +- **Desktop 对接说明**(下期):默认 BaseUrl;调试指本地仓;调 Stage A;Load 缓存 + +### 阶段 D — 示例数据(优先级 1,代码稳定后) + +#### D1. 最小合法示例 + +- `Application ID=RA3BattleNet`:Version、Packages、Posts;Changelog/Manifest **ID 齐全** +- `Mod ID=Corona`:CurrentVersion、Icon、Packages、必要 Style/资源 ID +- 各 ID 对应真实存在的资源文件(占位 md / 小图 / 示意 manifest xml 即可) + +#### D2. 自行补充(非本计划实现范围) + +- 更多版本、Mod、文案、真实 Hash/清单 +- 正式 Manifest **以 Updater 生成为准** + +#### D3. 不进本期 + +- 地图下载、50+ Mod 填满 +- Updater Manifest 格式改造(仅路线图) +- Desktop 调试页实现 + +--- + +## 3. 建议实施顺序(代码优先) + +1. **测试栈切换为 MSTest**(§5),保证后续改动在统一测试框架上落地 +2. **契约文档**(README 一节或本文件 §1 固化到用户文档) +3. **拆构建管线**:Stage A API;SkiaSharp 仅 Stage B +4. **实现 XML 展平 + 硬失败校验 + 版本字段** +5. **公共 Load/Build API + MSTest 测试** +6. **修 `build.sh` / wrangler / Worker** +7. **示例数据打到 Stage A 全绿** +8. **Desktop 对接说明写入 README** + +--- + +## 4. 路线图(本期不做) + +| 项 | 说明 | +|---|---| +| Desktop 调试页 | 选本地仓、触发 Build、切换默认 CF URL | +| Updater ↔ Manifest 生成/格式 | 生成器权威、禁止手改 Hash 等(**先保留在计划中**) | +| `Build(url)` 完整远程源 | 若首期只 path,下期补 | +| 仅复制被引用资源 | 发布瘦身 | +| Gitea + Preview URL | 本地 Stage A 已覆盖开发;远端预览可选 | +| 地图元数据、多 Mod 生产内容 | 内容扩展 | + +--- + +## 5. 测试库:MSTest(强制) + +### 5.1 决策 + +- **唯一测试框架:MSTest** +- **移除**:`xunit`、`xunit.v3`、`xunit.runner.visualstudio` 及全局 `Using Include="Xunit"` +- **保留/采用**: + - `MSTest` 元包或 `MSTest.TestFramework` + `MSTest.TestAdapter` + - `Microsoft.NET.Test.Sdk` + - 断言:优先 MSTest 自带 `Assert`;若需更流畅断言可继续评估 FluentAssertions(**非 xunit 依赖**) + - 覆盖率:`coverlet.collector` 可保留 + +### 5.2 迁移动作 + +1. 修改 `Ra3.BattleNet.Metadata.Tests.csproj`:去掉全部 xunit 包,改为 MSTest 包引用 +2. 测试类:`[Fact]` → `[TestMethod]`,类上加 `[TestClass]`;构造/初始化改为 MSTest 生命周期(`[TestInitialize]` 等) +3. 删除 `Xunit` 全局 using;按需 `using Microsoft.VisualStudio.TestTools.UnitTesting;` +4. 全量 `dotnet test` 通过后再合入功能改动 + +### 5.3 约定 + +- 新建测试一律 MSTest,禁止再引入 xunit 系包 +- 契约/硬失败/展平等核心路径必须有 MSTest 覆盖 + +--- + +## 6. 验收标准(本期完成时) + +- [ ] 测试项目为 **MSTest**,无 xunit 系依赖;`dotnet test` 全绿 +- [ ] 硬失败用例覆盖(schema / 缺文件 / 坏引用 / 变量残留 / 循环引用) +- [ ] 类库无 SkiaSharp 等原生硬依赖 +- [ ] `metadata build --src Metadata --dst Output` 得到展平 `metadata.xml` + 资源 +- [ ] 展平 XML 无 Include;含 SchemaVersion/ContentRevision;资源 ID 可解析 +- [ ] CF 脚本含 Stage B WebP;本地 Stage A 可单独跑 +- [ ] 示例:RA3BattleNet + Corona 可通过完整 Stage A +- [ ] README:契约、默认 URL、Desktop 下期对接、Manifest 归属 Updater、测试用 MSTest + +--- + +## 7. 风险与约定 + +- **本地 vs 线上图片扩展名**:客户端按 ID 读节点上的 `Source`,不写死 `.png` +- **展平体积**:只展平 XML **数据**;Manifest 等资源再大也独立文件 +- **与「单文件」表述的关系**:展平的是数据 XML,不是把所有资源塞进一个文件 +- **单库 + 纯 managed**:发布期 WebP 不得把原生依赖带回 Desktop 引用图 +- **Updater**:Manifest 权威在 Updater;其格式/生成改造另项推进,本期 Metadata 只预留 ID 引用与校验 + +--- + +## 8. 决策记录(grill-me 摘要) + +1. 优先级:契约 → 库 → 示例内容 +2. 第一消费端:Desktop +3. 数据 XML 展平;资源外置 + ID 引用 +4. public/private 仅构建期 +5. 用户默认 CF URL;开发者本地仓 + 编译(Desktop 下期) +6. 单库纯 managed;Parse/Build 支持 path 与 URL +7. 本地:编译到缓存再读(与线上同形) +8. URL 契约:单入口 + 相对资源 + SchemaVersion/ContentRevision +9. WebP 仅 CF Stage B;Stage A/B 两段式 +10. 校验硬失败 +11. Manifest 由 Updater 生成;Updater 改动进路线图 +12. MVP 示例:RA3BattleNet + Corona;代码优先,数据示例后自行补 +13. 本期边界:Metadata 库 + CLI + 示例 + 发布管线 + 对接文档 +14. **测试框架:MSTest,去掉 xunit** From ba58e1cc65cb87e4cb97d867664ab229d00b8ad7 Mon Sep 17 00:00:00 2001 From: mrbbbaixue Date: Sat, 25 Jul 2026 11:53:06 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat(metadata):=20=E5=AE=9E=E7=8E=B0=20St?= =?UTF-8?q?age=20A=20=E5=B1=95=E5=B9=B3=E5=A5=91=E7=BA=A6=E4=B8=8E=20MSTes?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 纯 managed Stage A:展平 XML、变量、硬失败校验、SchemaVersion/ContentRevision - MetadataBuilder.Load 支持 path/URL;Build 仅本地 path - Stage B WebP 拆至独立项目;主库移除 SkiaSharp - 测试迁 MSTest;补齐 RA3BattleNet+Corona 示例数据 - 更新 README/build.sh/Worker 发布说明 --- Metadata.sln | 6 + .../ra3battlenet/changelogs/changelogs.xml | 8 +- .../ra3battlenet/changelogs/en-1.5.1.0.md | 3 + .../ra3battlenet/changelogs/en-1.5.2.0.md | 3 + .../ra3battlenet/changelogs/zh-1.5.1.0.md | 3 + .../ra3battlenet/changelogs/zh-1.5.2.0.md | 3 + .../ra3battlenet/posts/news-en-1.5.1.0.md | 3 + .../ra3battlenet/posts/news-en-1.5.2.0.md | 3 + .../ra3battlenet/posts/news-zh-1.5.1.0.md | 3 + .../ra3battlenet/posts/news-zh-1.5.2.0.md | 3 + Metadata/apps/ra3battlenet/posts/posts.xml | 10 +- Metadata/apps/ra3battlenet/ra3battlenet.xml | 9 +- .../mods/corona/changelogs/changelogs.xml | 14 +- Metadata/mods/corona/changelogs/en-3228.md | 3 + Metadata/mods/corona/changelogs/en-3229.md | 3 + Metadata/mods/corona/changelogs/zh-3228.md | 3 + Metadata/mods/corona/changelogs/zh-3229.md | 3 + Metadata/mods/corona/corona.xml | 25 +- Metadata/mods/corona/images/background-1.png | Bin 0 -> 70 bytes Metadata/mods/corona/images/background-2.png | Bin 0 -> 70 bytes Metadata/mods/corona/images/background-3.png | Bin 0 -> 70 bytes Metadata/mods/corona/images/background-4.png | Bin 0 -> 70 bytes Metadata/mods/corona/images/background-5.png | Bin 0 -> 70 bytes Metadata/mods/corona/images/icon-64px.png | Bin 0 -> 70 bytes Metadata/mods/corona/images/logo-colored.png | Bin 0 -> 70 bytes Metadata/mods/corona/manifests/3.228.xml | 18 +- Metadata/mods/corona/manifests/3.229.xml | 15 + Metadata/mods/corona/manifests/manifests.xml | 6 +- Metadata/mods/corona/posts/news-en-3229.md | 3 + Metadata/mods/corona/posts/news-zh-3229.md | 3 + Metadata/mods/corona/posts/posts.xml | 9 +- README.md | 119 +++-- Ra3.BattleNet.Metadata.StageB/Program.cs | 86 ++++ .../Ra3.BattleNet.Metadata.StageB.csproj | 15 + Ra3.BattleNet.Metadata.Tests/IncludeTests.cs | 152 +++---- Ra3.BattleNet.Metadata.Tests/MetadataTests.cs | 405 +++++++----------- .../Ra3.BattleNet.Metadata.Tests.csproj | 12 +- Ra3.BattleNet.Metadata.Tests/StageATests.cs | 193 +++++++++ Ra3.BattleNet.Metadata/ImageProcessor.cs | 141 ------ Ra3.BattleNet.Metadata/MetadataBuilder.cs | 239 +++++++++++ Ra3.BattleNet.Metadata/MetadataFlattener.cs | 164 +++++++ Ra3.BattleNet.Metadata/Program.cs | 248 ++++------- .../Ra3.BattleNet.Metadata.csproj | 4 - Ra3.BattleNet.Metadata/VariableResolver.cs | 31 +- build.sh | 18 +- package.json | 5 +- src/index.js | 28 +- 47 files changed, 1207 insertions(+), 815 deletions(-) create mode 100644 Metadata/apps/ra3battlenet/changelogs/en-1.5.1.0.md create mode 100644 Metadata/apps/ra3battlenet/changelogs/en-1.5.2.0.md create mode 100644 Metadata/apps/ra3battlenet/changelogs/zh-1.5.1.0.md create mode 100644 Metadata/apps/ra3battlenet/changelogs/zh-1.5.2.0.md create mode 100644 Metadata/apps/ra3battlenet/posts/news-en-1.5.1.0.md create mode 100644 Metadata/apps/ra3battlenet/posts/news-en-1.5.2.0.md create mode 100644 Metadata/apps/ra3battlenet/posts/news-zh-1.5.1.0.md create mode 100644 Metadata/apps/ra3battlenet/posts/news-zh-1.5.2.0.md create mode 100644 Metadata/mods/corona/changelogs/en-3228.md create mode 100644 Metadata/mods/corona/changelogs/en-3229.md create mode 100644 Metadata/mods/corona/changelogs/zh-3228.md create mode 100644 Metadata/mods/corona/changelogs/zh-3229.md create mode 100644 Metadata/mods/corona/images/background-1.png create mode 100644 Metadata/mods/corona/images/background-2.png create mode 100644 Metadata/mods/corona/images/background-3.png create mode 100644 Metadata/mods/corona/images/background-4.png create mode 100644 Metadata/mods/corona/images/background-5.png create mode 100644 Metadata/mods/corona/images/icon-64px.png create mode 100644 Metadata/mods/corona/images/logo-colored.png create mode 100644 Metadata/mods/corona/manifests/3.229.xml create mode 100644 Metadata/mods/corona/posts/news-en-3229.md create mode 100644 Metadata/mods/corona/posts/news-zh-3229.md create mode 100644 Ra3.BattleNet.Metadata.StageB/Program.cs create mode 100644 Ra3.BattleNet.Metadata.StageB/Ra3.BattleNet.Metadata.StageB.csproj create mode 100644 Ra3.BattleNet.Metadata.Tests/StageATests.cs delete mode 100644 Ra3.BattleNet.Metadata/ImageProcessor.cs create mode 100644 Ra3.BattleNet.Metadata/MetadataBuilder.cs create mode 100644 Ra3.BattleNet.Metadata/MetadataFlattener.cs diff --git a/Metadata.sln b/Metadata.sln index e850bad..75f552a 100644 --- a/Metadata.sln +++ b/Metadata.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ra3.BattleNet.Metadata", "R EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ra3.BattleNet.Metadata.Tests", "Ra3.BattleNet.Metadata.Tests\Ra3.BattleNet.Metadata.Tests.csproj", "{F9429D39-9063-45C4-9EC5-84A34A4829D8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ra3.BattleNet.Metadata.StageB", "Ra3.BattleNet.Metadata.StageB\Ra3.BattleNet.Metadata.StageB.csproj", "{A9429D39-9063-45C4-9EC5-84A34A4829D9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ Global {F9429D39-9063-45C4-9EC5-84A34A4829D8}.Debug|Any CPU.Build.0 = Debug|Any CPU {F9429D39-9063-45C4-9EC5-84A34A4829D8}.Release|Any CPU.ActiveCfg = Release|Any CPU {F9429D39-9063-45C4-9EC5-84A34A4829D8}.Release|Any CPU.Build.0 = Release|Any CPU + {A9429D39-9063-45C4-9EC5-84A34A4829D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A9429D39-9063-45C4-9EC5-84A34A4829D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A9429D39-9063-45C4-9EC5-84A34A4829D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A9429D39-9063-45C4-9EC5-84A34A4829D9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Metadata/apps/ra3battlenet/changelogs/changelogs.xml b/Metadata/apps/ra3battlenet/changelogs/changelogs.xml index 55e0cbd..1da439d 100644 --- a/Metadata/apps/ra3battlenet/changelogs/changelogs.xml +++ b/Metadata/apps/ra3battlenet/changelogs/changelogs.xml @@ -1,17 +1,13 @@ - ${TIMESTAMP} - - - + - - \ No newline at end of file + diff --git a/Metadata/apps/ra3battlenet/changelogs/en-1.5.1.0.md b/Metadata/apps/ra3battlenet/changelogs/en-1.5.1.0.md new file mode 100644 index 0000000..9cf04ae --- /dev/null +++ b/Metadata/apps/ra3battlenet/changelogs/en-1.5.1.0.md @@ -0,0 +1,3 @@ +# Sample content for en-1.5.1.0.md + +Placeholder changelog/news body for Stage A samples. diff --git a/Metadata/apps/ra3battlenet/changelogs/en-1.5.2.0.md b/Metadata/apps/ra3battlenet/changelogs/en-1.5.2.0.md new file mode 100644 index 0000000..019b50b --- /dev/null +++ b/Metadata/apps/ra3battlenet/changelogs/en-1.5.2.0.md @@ -0,0 +1,3 @@ +# Sample content for en-1.5.2.0.md + +Placeholder changelog/news body for Stage A samples. diff --git a/Metadata/apps/ra3battlenet/changelogs/zh-1.5.1.0.md b/Metadata/apps/ra3battlenet/changelogs/zh-1.5.1.0.md new file mode 100644 index 0000000..03c7aad --- /dev/null +++ b/Metadata/apps/ra3battlenet/changelogs/zh-1.5.1.0.md @@ -0,0 +1,3 @@ +# Sample content for zh-1.5.1.0.md + +Placeholder changelog/news body for Stage A samples. diff --git a/Metadata/apps/ra3battlenet/changelogs/zh-1.5.2.0.md b/Metadata/apps/ra3battlenet/changelogs/zh-1.5.2.0.md new file mode 100644 index 0000000..ff92130 --- /dev/null +++ b/Metadata/apps/ra3battlenet/changelogs/zh-1.5.2.0.md @@ -0,0 +1,3 @@ +# Sample content for zh-1.5.2.0.md + +Placeholder changelog/news body for Stage A samples. diff --git a/Metadata/apps/ra3battlenet/posts/news-en-1.5.1.0.md b/Metadata/apps/ra3battlenet/posts/news-en-1.5.1.0.md new file mode 100644 index 0000000..09715fc --- /dev/null +++ b/Metadata/apps/ra3battlenet/posts/news-en-1.5.1.0.md @@ -0,0 +1,3 @@ +# Sample content for news-en-1.5.1.0.md + +Placeholder changelog/news body for Stage A samples. diff --git a/Metadata/apps/ra3battlenet/posts/news-en-1.5.2.0.md b/Metadata/apps/ra3battlenet/posts/news-en-1.5.2.0.md new file mode 100644 index 0000000..e90678d --- /dev/null +++ b/Metadata/apps/ra3battlenet/posts/news-en-1.5.2.0.md @@ -0,0 +1,3 @@ +# Sample content for news-en-1.5.2.0.md + +Placeholder changelog/news body for Stage A samples. diff --git a/Metadata/apps/ra3battlenet/posts/news-zh-1.5.1.0.md b/Metadata/apps/ra3battlenet/posts/news-zh-1.5.1.0.md new file mode 100644 index 0000000..e07a10a --- /dev/null +++ b/Metadata/apps/ra3battlenet/posts/news-zh-1.5.1.0.md @@ -0,0 +1,3 @@ +# Sample content for news-zh-1.5.1.0.md + +Placeholder changelog/news body for Stage A samples. diff --git a/Metadata/apps/ra3battlenet/posts/news-zh-1.5.2.0.md b/Metadata/apps/ra3battlenet/posts/news-zh-1.5.2.0.md new file mode 100644 index 0000000..e8c80c6 --- /dev/null +++ b/Metadata/apps/ra3battlenet/posts/news-zh-1.5.2.0.md @@ -0,0 +1,3 @@ +# Sample content for news-zh-1.5.2.0.md + +Placeholder changelog/news body for Stage A samples. diff --git a/Metadata/apps/ra3battlenet/posts/posts.xml b/Metadata/apps/ra3battlenet/posts/posts.xml index 531a49c..becc84d 100644 --- a/Metadata/apps/ra3battlenet/posts/posts.xml +++ b/Metadata/apps/ra3battlenet/posts/posts.xml @@ -1,15 +1,13 @@ - ${TIMESTAMP} - - - + + - - \ No newline at end of file + + diff --git a/Metadata/apps/ra3battlenet/ra3battlenet.xml b/Metadata/apps/ra3battlenet/ra3battlenet.xml index b75dd0e..d6e8154 100644 --- a/Metadata/apps/ra3battlenet/ra3battlenet.xml +++ b/Metadata/apps/ra3battlenet/ra3battlenet.xml @@ -1,21 +1,16 @@ - ${TIMESTAMP} - - - 1.5.2.0 - 2025-03-29 @@ -34,7 +29,6 @@ manifest-1.5.1.0 - @@ -48,5 +42,4 @@ - - \ No newline at end of file + diff --git a/Metadata/mods/corona/changelogs/changelogs.xml b/Metadata/mods/corona/changelogs/changelogs.xml index 55e0cbd..4cf9cd7 100644 --- a/Metadata/mods/corona/changelogs/changelogs.xml +++ b/Metadata/mods/corona/changelogs/changelogs.xml @@ -1,17 +1,13 @@ - ${TIMESTAMP} - - - - - - - - \ No newline at end of file + + + + + diff --git a/Metadata/mods/corona/changelogs/en-3228.md b/Metadata/mods/corona/changelogs/en-3228.md new file mode 100644 index 0000000..fcefc1f --- /dev/null +++ b/Metadata/mods/corona/changelogs/en-3228.md @@ -0,0 +1,3 @@ +# Sample content for en-3228.md + +Placeholder changelog/news body for Stage A samples. diff --git a/Metadata/mods/corona/changelogs/en-3229.md b/Metadata/mods/corona/changelogs/en-3229.md new file mode 100644 index 0000000..5c3d8f7 --- /dev/null +++ b/Metadata/mods/corona/changelogs/en-3229.md @@ -0,0 +1,3 @@ +# Sample content for en-3229.md + +Placeholder changelog/news body for Stage A samples. diff --git a/Metadata/mods/corona/changelogs/zh-3228.md b/Metadata/mods/corona/changelogs/zh-3228.md new file mode 100644 index 0000000..a5e3e02 --- /dev/null +++ b/Metadata/mods/corona/changelogs/zh-3228.md @@ -0,0 +1,3 @@ +# Sample content for zh-3228.md + +Placeholder changelog/news body for Stage A samples. diff --git a/Metadata/mods/corona/changelogs/zh-3229.md b/Metadata/mods/corona/changelogs/zh-3229.md new file mode 100644 index 0000000..7e196c5 --- /dev/null +++ b/Metadata/mods/corona/changelogs/zh-3229.md @@ -0,0 +1,3 @@ +# Sample content for zh-3229.md + +Placeholder changelog/news body for Stage A samples. diff --git a/Metadata/mods/corona/corona.xml b/Metadata/mods/corona/corona.xml index 139b1cf..460ab14 100644 --- a/Metadata/mods/corona/corona.xml +++ b/Metadata/mods/corona/corona.xml @@ -1,35 +1,28 @@ - ${TIMESTAMP} - - - - - + + + + + - - - 3.229 corona-icon-64px - - - - 2025-04-01 @@ -68,8 +58,6 @@ manifest-3228 - - @@ -83,5 +71,4 @@ - - \ No newline at end of file + diff --git a/Metadata/mods/corona/images/background-1.png b/Metadata/mods/corona/images/background-1.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 diff --git a/Metadata/mods/corona/images/background-2.png b/Metadata/mods/corona/images/background-2.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 diff --git a/Metadata/mods/corona/images/background-3.png b/Metadata/mods/corona/images/background-3.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 diff --git a/Metadata/mods/corona/images/background-4.png b/Metadata/mods/corona/images/background-4.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 diff --git a/Metadata/mods/corona/images/background-5.png b/Metadata/mods/corona/images/background-5.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 diff --git a/Metadata/mods/corona/images/icon-64px.png b/Metadata/mods/corona/images/icon-64px.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 diff --git a/Metadata/mods/corona/images/logo-colored.png b/Metadata/mods/corona/images/logo-colored.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 diff --git a/Metadata/mods/corona/manifests/3.228.xml b/Metadata/mods/corona/manifests/3.228.xml index 5e6d6c5..2899841 100644 --- a/Metadata/mods/corona/manifests/3.228.xml +++ b/Metadata/mods/corona/manifests/3.228.xml @@ -1,29 +1,15 @@ - ${TIMESTAMP} - - - + corona_3.228.lyi / MOD; ENCRYPTED_FILE; CAN_PATCH; - - - - - - coronaBGM_3.228.lyi - / - MOD; ENCRYPTED_FILE; CAN_PATCH; - - - - \ No newline at end of file + diff --git a/Metadata/mods/corona/manifests/3.229.xml b/Metadata/mods/corona/manifests/3.229.xml new file mode 100644 index 0000000..1f27005 --- /dev/null +++ b/Metadata/mods/corona/manifests/3.229.xml @@ -0,0 +1,15 @@ + + + + ${TIMESTAMP} + + + + + + corona_3.229.lyi + / + MOD; ENCRYPTED_FILE; CAN_PATCH; + + + diff --git a/Metadata/mods/corona/manifests/manifests.xml b/Metadata/mods/corona/manifests/manifests.xml index 803964b..7c8d545 100644 --- a/Metadata/mods/corona/manifests/manifests.xml +++ b/Metadata/mods/corona/manifests/manifests.xml @@ -1,12 +1,10 @@ - ${TIMESTAMP} - - + - \ No newline at end of file + diff --git a/Metadata/mods/corona/posts/news-en-3229.md b/Metadata/mods/corona/posts/news-en-3229.md new file mode 100644 index 0000000..89fa1fb --- /dev/null +++ b/Metadata/mods/corona/posts/news-en-3229.md @@ -0,0 +1,3 @@ +# Sample content for news-en-3229.md + +Placeholder changelog/news body for Stage A samples. diff --git a/Metadata/mods/corona/posts/news-zh-3229.md b/Metadata/mods/corona/posts/news-zh-3229.md new file mode 100644 index 0000000..63b2ba8 --- /dev/null +++ b/Metadata/mods/corona/posts/news-zh-3229.md @@ -0,0 +1,3 @@ +# Sample content for news-zh-3229.md + +Placeholder changelog/news body for Stage A samples. diff --git a/Metadata/mods/corona/posts/posts.xml b/Metadata/mods/corona/posts/posts.xml index a34637f..42a3746 100644 --- a/Metadata/mods/corona/posts/posts.xml +++ b/Metadata/mods/corona/posts/posts.xml @@ -1,14 +1,11 @@ - ${TIMESTAMP} - - - - - \ No newline at end of file + + + diff --git a/README.md b/README.md index 5cd0063..bbe96cc 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,104 @@ -# 红警3战网元数据文件 +# 红警3战网元数据 -此仓库存储的是战网客户端需要的客户端、数据包、模组、地图的描述信息。 +本仓库存储战网客户端所需的**应用 / Mod 版本、清单引用、新闻与资源索引**,经 Stage A 构建后由 Cloudflare Pages 发布。 -【其他说明待编写】 +更完整的产品决策见 [PLAN.md](./PLAN.md)。 -## 模块说明 +## 发布契约(Desktop 默认消费) -``` xml - - - - - +| 项 | 说明 | +|---|---| +| 默认 BaseUrl | `https://metadata.ra3battle.net`(占位,部署后替换为真实 Pages 域名) | +| 数据入口 | `GET {BaseUrl}/metadata.xml` — **已展平**的业务 XML(无 `Include`) | +| 资源 | `{BaseUrl}/{相对路径}` — Manifest / 图片 / Markdown 等**独立文件** | +| 版本字段 | 根节点属性 `SchemaVersion`、`ContentRevision` | +| 资源引用 | XML 内一律 **ID 引用**(如 `manifest-1.5.2.0`),再解析登记节点上的 `Source`/`Url` | +| Manifest 归属 | **Updater 生成**清单文件;本仓只登记 ID 并引用。Updater 格式改造见路线图 | - - - - - - +### Stage A vs Stage B + +- **Stage A(本地 / 库 / CI 必跑,纯 managed)**:校验、变量替换、XML 数据展平、复制资源。 + `dotnet run --project Ra3.BattleNet.Metadata -- build --src=./Metadata --dst=./Output` +- **Stage B(仅发布流水线)**:WebP 转码并改写 Image `Source`。 + `dotnet run --project Ra3.BattleNet.Metadata.StageB -- --dst=./Output` + `build.sh` 默认会跑 Stage B;本地可设 `RUN_STAGE_B=0` 跳过。 + +本地与线上业务树同形;线上图片扩展名可能为 `.webp`,客户端应按节点 `Source` 读取,勿写死 `.png`。 + +### 库 API + +```csharp +// Stage A 构建(本期 Build 仅支持本地 path;远程源构建延后) +MetadataBuilder.Build(sourceDir, outputDir, schemaVersion: "1.0", contentRevision: "git-sha"); + +// 解析已展平产物(path 或 http(s) URL) +var doc = MetadataBuilder.Load(pathOrUrl); +var app = doc.Catalog().Application("RA3BattleNet"); ``` -大部分XML文件都包含Includes字段,本质上Metadata文件可以合并为一个大的XML。但是为了方便编辑和维护进行拆分 -Type分为 public 和 private,public指的是可以被展平(暴露给上级引用者,别的文件可以访问),private代表私有引用仅在此文件中可用,别的文件无法访问 +主库 **无 SkiaSharp**;正式客户端可只引用 `Ra3.BattleNet.Metadata` 做解析与本地 Stage A。 + +### 硬失败 -### 元数据需求 +下列情况 Stage A **非 0 退出**并清理半残输出:循环 Include、缺失资源、断开的 ID 引用、未替换的 `${...}`、校验失败。 -Metadata 需要存储的数据有: +## 本地构建 -1. 客户端本身的版本信息和元数据信息,包括:客户端版本号,数据包最新版本号,客户端更新列表,数据包更新列表,多语言翻译文件等 -2. Mod下载功能(预计50+Mod)需要包含:Mod列表,每个Mod都需要包含ModID,Mod版本号,Mod介绍(图文混合,可能是Markdown),Mod更新列表(更新列表要支持多个文件,并且要支持对不同文件定义哈希值和下载方式),新闻列表,新闻信息(图文混合) -3. 地图下载功能:预留 +```bash +# 仅 Stage A(推荐日常) +dotnet run --project Ra3.BattleNet.Metadata -- build --src=./Metadata --dst=./Output -于是分为:Application, Markdown, Image, +# 测试(MSTest,无 xunit) +dotnet test Metadata.sln +# Cloudflare 风格完整脚本(安装 dotnet + Stage A + Stage B) +bash build.sh +# 跳过 WebP:RUN_STAGE_B=0 bash build.sh +``` + +```bash +npm run build # bash build.sh +npm run deploy # build + wrangler pages deploy Output +``` -存储信息的主要格式为XML,因为数据会引用包含图片素材和HTML/MarkDown素材,所以可能会有文件和XML放在一起。以ID的方法引用,每个松散数据都需要验证哈希(在引用时同时声明MD5,但此MD5需要进行计算) +## 示例数据 -于是,XML数据需要支持变量,比如 ${MD5:} 是对指定ID的松散文件进行哈希值计算,并在Build时替换 -${MD5::}指的是对当前文件进行哈希计算 -需要编写XSD文件,方便对XML进行验证 +- Application:`RA3BattleNet` +- Mod:`Corona` +其余内容可自行补充;正式 Manifest Hash 应以 Updater 生成为准。 -### XML的 ${} 内联变量规定: -ENV: 系统环境变量(CF Pages) +## Desktop 下期对接(本期不改 Desktop 仓) -MD5: 对文件进行md5 ,${MD5::} 指的是计算当前标签文件路径的md5 +1. 用户默认 `BaseUrl` 指向 Cloudflare Pages 展平产物。 +2. 开发者调试页:选择本地 Metadata 仓库路径 → 调用 Stage A `MetadataBuilder.Build` → `Load` 缓存目录中的 `metadata.xml`。 +3. 与线上同一套解析逻辑,仅换内容来源。 -或者 ${MD5:xxxx.txt} 代表对指定文件进行哈希 +## 测试 -META: 绝对路径引用,冒号是此处规定的成员运算符 +- 框架:**MSTest**(`MSTest.TestFramework` + `MSTest.TestAdapter`) +- **禁止**再引入 xunit / xunit.v3 / xunit.runner +- 覆盖:展平、硬失败(缺资源 / 坏 ID / 循环引用 / 变量残留)、示例 Stage A 成功路径 -其他:相对路径引用(当前module或者depot下) +## Include 与 public/private -**举例:** +源树可用 `Include`/`Includes` 分模块维护。`Type=public|private` **仅影响构建期解析**;发布物中无 Include,业务节点合并进单一 `metadata.xml`。 -${META:CoronaLauncher:LauncherVersion}代表 +### 变量 -${ENV:WINVER} 代表获取系统的winver变量值 +| 语法 | 含义 | +|---|---| +| `${TIMESTAMP}` | UTC 时间 | +| `${ENV:NAME}` | 环境变量 | +| `${MD5:}` / `${MD5:rel}` | 当前文件或相对文件 MD5;Markdown `Hash="${MD5::}"` 按 `Source` 资源计算 | +| `${META:...}` / `${this:...}` | 树内/容器 Defines 引用 | -${this:Version} (client.xml中可以找到相同例子),代表同一个Module,也就是Client下的Defines的Version变量值,即1.5.0.0 +## 目录 + +``` +Metadata/ 源数据 +Ra3.BattleNet.Metadata/ 纯 managed 库 + Stage A CLI +Ra3.BattleNet.Metadata.StageB/ 发布用 WebP(SkiaSharp) +Ra3.BattleNet.Metadata.Tests/ MSTest +build.sh CF:Stage A + Stage B +PLAN.md 详细计划与决策记录 +``` diff --git a/Ra3.BattleNet.Metadata.StageB/Program.cs b/Ra3.BattleNet.Metadata.StageB/Program.cs new file mode 100644 index 0000000..25fc7e2 --- /dev/null +++ b/Ra3.BattleNet.Metadata.StageB/Program.cs @@ -0,0 +1,86 @@ +using System.Xml.Linq; +using SkiaSharp; + +namespace Ra3.BattleNet.Metadata.StageB; + +/// +/// Stage B:仅发布流水线使用 — 将 Output 中本地图片转为 WebP 并改写 XML Source。 +/// +internal static class Program +{ + private static readonly HashSet Supported = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".tif" + }; + + private static int Main(string[] args) + { + var outputDir = args.FirstOrDefault(a => a.StartsWith("--dst=", StringComparison.Ordinal)) + ?["--dst=".Length..] ?? "./Output"; + outputDir = Path.GetFullPath(outputDir); + if (!Directory.Exists(outputDir)) + { + Console.Error.WriteLine($"输出目录不存在: {outputDir}"); + return 1; + } + + var flat = Path.Combine(outputDir, "metadata.xml"); + if (!File.Exists(flat)) + { + Console.Error.WriteLine($"找不到展平文件: {flat}"); + return 1; + } + + var doc = XDocument.Load(flat); + var converted = 0; + foreach (var image in doc.Descendants("Image")) + { + var sourceAttr = image.Attribute("Source"); + if (sourceAttr == null || string.IsNullOrWhiteSpace(sourceAttr.Value)) + continue; + if (!string.IsNullOrWhiteSpace(image.Attribute("Url")?.Value)) + continue; + + var sourcePath = Path.Combine(outputDir, sourceAttr.Value.Replace('\\', '/')); + if (!File.Exists(sourcePath)) + continue; + var ext = Path.GetExtension(sourcePath); + if (!Supported.Contains(ext)) + continue; + + var webpPath = Path.ChangeExtension(sourcePath, ".webp"); + if (!ConvertToWebP(sourcePath, webpPath)) + continue; + + sourceAttr.Value = Path.ChangeExtension(sourceAttr.Value, ".webp")!.Replace('\\', '/'); + converted++; + Console.WriteLine($" WebP: {sourceAttr.Value}"); + } + + doc.Save(flat); + Console.WriteLine($"Stage B 完成,转换 {converted} 张图片"); + return 0; + } + + private static bool ConvertToWebP(string sourcePath, string webpPath) + { + try + { + using var input = File.OpenRead(sourcePath); + using var codec = SKCodec.Create(input); + if (codec == null) return false; + using var bitmap = SKBitmap.Decode(codec); + if (bitmap == null) return false; + using var image = SKImage.FromBitmap(bitmap); + using var data = image.Encode(SKEncodedImageFormat.Webp, 90); + using var output = File.Open(webpPath, FileMode.Create, FileAccess.Write); + data.SaveTo(output); + return true; + } + catch (Exception ex) + { + Console.Error.WriteLine($" 转换失败 {sourcePath}: {ex.Message}"); + return false; + } + } +} diff --git a/Ra3.BattleNet.Metadata.StageB/Ra3.BattleNet.Metadata.StageB.csproj b/Ra3.BattleNet.Metadata.StageB/Ra3.BattleNet.Metadata.StageB.csproj new file mode 100644 index 0000000..3831e8b --- /dev/null +++ b/Ra3.BattleNet.Metadata.StageB/Ra3.BattleNet.Metadata.StageB.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + Ra3.BattleNet.Metadata.StageB + + + + + + + diff --git a/Ra3.BattleNet.Metadata.Tests/IncludeTests.cs b/Ra3.BattleNet.Metadata.Tests/IncludeTests.cs index 7eb6795..959d89d 100644 --- a/Ra3.BattleNet.Metadata.Tests/IncludeTests.cs +++ b/Ra3.BattleNet.Metadata.Tests/IncludeTests.cs @@ -1,105 +1,71 @@ using FluentAssertions; -using Xunit; +using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Ra3.BattleNet.Metadata.Tests -{ - public class IncludeTests - { - private readonly string _testDataPath; - - public IncludeTests() - { - _testDataPath = Path.Combine(AppContext.BaseDirectory, "TestData"); - } - - [Fact] - public void Include_PublicType_VisibleToParent() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "access-control-test.xml"); - var metadata = Metadata.LoadFromFile(filePath); - - // Act - var publicElement = metadata.GetElementById("public-element"); - - // Assert - publicElement.Should().NotBeNull("public类型的Include应该对父节点可见"); - publicElement!.Get("ID").Should().Be("public-element"); - } - - [Fact] - public void Include_PrivateType_OnlyVisibleToChildren() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "access-control-test.xml"); - var metadata = Metadata.LoadFromFile(filePath); +namespace Ra3.BattleNet.Metadata.Tests; - // Act - var privateElement = metadata.GetElementById("private-element"); - - // Assert - // private元素应该在子树中可见,但当前实现可能需要调整 - // 这个测试验证了private元素的存在性 - privateElement.Should().NotBeNull(); - } - - [Fact] - public void Include_ParentElement_Accessible() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "access-control-test.xml"); - var metadata = Metadata.LoadFromFile(filePath); - - // Act - var parentElement = metadata.GetElementById("parent-element"); - - // Assert - parentElement.Should().NotBeNull(); - parentElement!.Get("ID").Should().Be("parent-element"); - } - - [Fact] - public void Include_CircularReference_ThrowsException() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "circular-a.xml"); +[TestClass] +public class IncludeTests +{ + private string _testDataPath = null!; - // Act & Assert - var act = () => Metadata.LoadFromFile(filePath); - act.Should().Throw() - .WithMessage("*循环引用*"); - } + [TestInitialize] + public void Init() + { + _testDataPath = Path.Combine(AppContext.BaseDirectory, "TestData"); + } - [Fact] - public void Include_HasParentReference() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "access-control-test.xml"); - var metadata = Metadata.LoadFromFile(filePath); + [TestMethod] + public void Include_PublicType_VisibleToParent() + { + var filePath = Path.Combine(_testDataPath, "access-control-test.xml"); + var metadata = Metadata.LoadFromFile(filePath); + var publicElement = metadata.GetElementById("public-element"); + publicElement.Should().NotBeNull(); + publicElement!.Get("ID").Should().Be("public-element"); + } - // Act - var child = metadata.Children.FirstOrDefault(); + [TestMethod] + public void Include_PrivateType_ExistsInTree() + { + var filePath = Path.Combine(_testDataPath, "access-control-test.xml"); + var metadata = Metadata.LoadFromFile(filePath); + metadata.GetElementById("private-element").Should().NotBeNull(); + } - // Assert - child.Should().NotBeNull(); - child!.Parent.Should().NotBeNull(); - child.Parent.Should().Be(metadata); - } + [TestMethod] + public void Include_ParentElement_Accessible() + { + var filePath = Path.Combine(_testDataPath, "access-control-test.xml"); + var metadata = Metadata.LoadFromFile(filePath); + var parentElement = metadata.GetElementById("parent-element"); + parentElement.Should().NotBeNull(); + parentElement!.Get("ID").Should().Be("parent-element"); + } - [Fact] - public void Include_TypeAttribute_IsPreserved() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "access-control-test.xml"); - var metadata = Metadata.LoadFromFile(filePath); + [TestMethod] + public void Include_CircularReference_ThrowsException() + { + var filePath = Path.Combine(_testDataPath, "circular-a.xml"); + var act = () => Metadata.LoadFromFile(filePath); + act.Should().Throw().WithMessage("*循环引用*"); + } - // Act - var publicChild = metadata.Children.FirstOrDefault(c => c.IncludeType == "public"); - var privateChild = metadata.Children.FirstOrDefault(c => c.IncludeType == "private"); + [TestMethod] + public void Include_HasParentReference() + { + var filePath = Path.Combine(_testDataPath, "access-control-test.xml"); + var metadata = Metadata.LoadFromFile(filePath); + var child = metadata.Children.FirstOrDefault(); + child.Should().NotBeNull(); + child!.Parent.Should().Be(metadata); + } - // Assert - publicChild.Should().NotBeNull("应该有public类型的Include"); - privateChild.Should().NotBeNull("应该有private类型的Include"); - } + [TestMethod] + public void Include_TypeAttribute_IsPreserved() + { + var filePath = Path.Combine(_testDataPath, "access-control-test.xml"); + var metadata = Metadata.LoadFromFile(filePath); + metadata.Children.Any(c => c.IncludeType == "public").Should().BeTrue(); + metadata.Children.Any(c => c.IncludeType == "private").Should().BeTrue(); } } diff --git a/Ra3.BattleNet.Metadata.Tests/MetadataTests.cs b/Ra3.BattleNet.Metadata.Tests/MetadataTests.cs index 15c3d76..3edbf7b 100644 --- a/Ra3.BattleNet.Metadata.Tests/MetadataTests.cs +++ b/Ra3.BattleNet.Metadata.Tests/MetadataTests.cs @@ -1,176 +1,113 @@ using FluentAssertions; -using Xunit; +using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Ra3.BattleNet.Metadata.Tests -{ - public class MetadataTests - { - private readonly string _testDataPath; - - public MetadataTests() - { - _testDataPath = Path.Combine(AppContext.BaseDirectory, "TestData"); - } - - [Fact] - public void LoadFromFile_ValidXml_Success() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); - - // Act - var metadata = Metadata.LoadFromFile(filePath); - - // Assert - metadata.Should().NotBeNull(); - metadata.Name.Should().Be("Metadata"); - metadata.Children.Should().HaveCountGreaterThan(0); - } - - [Fact] - public void LoadFromFile_InvalidRoot_ThrowsException() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "invalid-root.xml"); - - // Act & Assert - var act = () => Metadata.LoadFromFile(filePath); - act.Should().Throw(); - } - - [Fact] - public void LoadFromFile_MissingFile_ThrowsFileNotFoundException() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "nonexistent.xml"); - - // Act & Assert - var act = () => Metadata.LoadFromFile(filePath); - act.Should().Throw(); - } - - [Fact] - public void LoadFromFile_CircularReference_ThrowsException() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "circular-a.xml"); +namespace Ra3.BattleNet.Metadata.Tests; - // Act & Assert - var act = () => Metadata.LoadFromFile(filePath); - act.Should().Throw() - .WithMessage("*循环引用*"); - } - - [Fact] - public void Get_ExistingVariable_ReturnsValue() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); - var metadata = Metadata.LoadFromFile(filePath); - - // Act - var tags = metadata.Find("Tags"); - var versionElement = tags?.Find("Version"); - - // Assert - // 由于XML结构,Version是一个子元素而不是属性 - // 我们应该检查元素的存在性 - tags.Should().NotBeNull(); - versionElement.Should().NotBeNull(); - } - - [Fact] - public void Get_MissingVariable_ReturnsDefault() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); - var metadata = Metadata.LoadFromFile(filePath); - - // Act - var missing = metadata.Get("NonExistent", "default"); - - // Assert - missing.Should().Be("default"); - } - - [Fact] - public void Find_ValidPath_ReturnsMetadata() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); - var metadata = Metadata.LoadFromFile(filePath); - - // Act - var tags = metadata.Find("Tags"); - - // Assert - tags.Should().NotBeNull(); - tags!.Name.Should().Be("Tags"); - } +[TestClass] +public class MetadataTests +{ + private string _testDataPath = null!; - [Fact] - public void GetElementById_ExistingId_ReturnsElement() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); - var metadata = Metadata.LoadFromFile(filePath); + [TestInitialize] + public void Init() + { + _testDataPath = Path.Combine(AppContext.BaseDirectory, "TestData"); + } - // Act - var app = metadata.GetElementById("TestApp"); + [TestMethod] + public void LoadFromFile_ValidXml_Success() + { + var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); + var metadata = Metadata.LoadFromFile(filePath); + metadata.Should().NotBeNull(); + metadata.Name.Should().Be("Metadata"); + metadata.Children.Should().HaveCountGreaterThan(0); + } - // Assert - app.Should().NotBeNull(); - app!.Get("ID").Should().Be("TestApp"); - // Name是子元素,不是属性 - var nameElement = app.Find("Name"); - nameElement.Should().NotBeNull(); - } + [TestMethod] + public void LoadFromFile_InvalidRoot_ThrowsException() + { + var filePath = Path.Combine(_testDataPath, "invalid-root.xml"); + var act = () => Metadata.LoadFromFile(filePath); + act.Should().Throw(); + } - [Fact] - public void GetAllElements_ByName_ReturnsMatchingElements() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); - var metadata = Metadata.LoadFromFile(filePath); + [TestMethod] + public void LoadFromFile_MissingFile_ThrowsFileNotFoundException() + { + var filePath = Path.Combine(_testDataPath, "nonexistent.xml"); + var act = () => Metadata.LoadFromFile(filePath); + act.Should().Throw(); + } - // Act - var applications = metadata.GetAllElements("Application"); + [TestMethod] + public void LoadFromFile_CircularReference_ThrowsException() + { + var filePath = Path.Combine(_testDataPath, "circular-a.xml"); + var act = () => Metadata.LoadFromFile(filePath); + act.Should().Throw() + .WithMessage("*循环引用*"); + } - // Assert - applications.Should().HaveCount(1); - applications[0].Get("ID").Should().Be("TestApp"); - } + [TestMethod] + public void Get_MissingVariable_ReturnsDefault() + { + var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); + var metadata = Metadata.LoadFromFile(filePath); + metadata.Get("NonExistent", "default").Should().Be("default"); + } - [Fact] - public void GetElementPath_ReturnsFullPath() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); - var metadata = Metadata.LoadFromFile(filePath); - var app = metadata.Find("Application"); + [TestMethod] + public void Find_ValidPath_ReturnsMetadata() + { + var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); + var metadata = Metadata.LoadFromFile(filePath); + var tags = metadata.Find("Tags"); + tags.Should().NotBeNull(); + tags!.Name.Should().Be("Tags"); + } - // Act - var path = app?.GetElementPath(); + [TestMethod] + public void GetElementById_ExistingId_ReturnsElement() + { + var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); + var metadata = Metadata.LoadFromFile(filePath); + var app = metadata.GetElementById("TestApp"); + app.Should().NotBeNull(); + app!.Get("ID").Should().Be("TestApp"); + } - // Assert - path.Should().Contain("Metadata"); - path.Should().Contain("Application"); - } + [TestMethod] + public void GetAllElements_ByName_ReturnsMatchingElements() + { + var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); + var metadata = Metadata.LoadFromFile(filePath); + var applications = metadata.GetAllElements("Application"); + applications.Should().HaveCount(1); + applications[0].Get("ID").Should().Be("TestApp"); + } + [TestMethod] + public void GetElementPath_ReturnsFullPath() + { + var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); + var metadata = Metadata.LoadFromFile(filePath); + var app = metadata.Find("Application"); + var path = app?.GetElementPath(); + path.Should().Contain("Metadata"); + path.Should().Contain("Application"); + } - [Fact] - public void ReplaceVariablesInFile_RecursivelyProcessesIncludeFiles() + [TestMethod] + public void ReplaceVariablesInFile_RecursivelyProcessesIncludeFiles() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"metadata-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + try { - // Arrange - var tempDir = Path.Combine(Path.GetTempPath(), $"metadata-test-{Guid.NewGuid():N}"); - Directory.CreateDirectory(tempDir); - - try - { - var rootPath = Path.Combine(tempDir, "metadata.xml"); - var includePath = Path.Combine(tempDir, "included.xml"); - - File.WriteAllText(rootPath, """ + var rootPath = Path.Combine(tempDir, "metadata.xml"); + var includePath = Path.Combine(tempDir, "included.xml"); + File.WriteAllText(rootPath, """ @@ -179,8 +116,7 @@ public void ReplaceVariablesInFile_RecursivelyProcessesIncludeFiles() """); - - File.WriteAllText(includePath, """ + File.WriteAllText(includePath, """ @@ -188,111 +124,64 @@ public void ReplaceVariablesInFile_RecursivelyProcessesIncludeFiles() """); - - Environment.SetEnvironmentVariable("TEST_COMMIT", "abc123", EnvironmentVariableTarget.Process); - var metadata = Metadata.LoadFromFile(rootPath); - - // Act - metadata.ReplaceVariablesInFile(rootPath); - - // Assert - var rootResult = File.ReadAllText(rootPath); - var includeResult = File.ReadAllText(includePath); - - rootResult.Should().Contain("abc123"); - rootResult.Should().NotContain("${ENV:TEST_COMMIT}"); - - includeResult.Should().Contain("abc123"); - includeResult.Should().NotContain("${ENV:TEST_COMMIT}"); - } - finally - { - if (Directory.Exists(tempDir)) - { - Directory.Delete(tempDir, recursive: true); - } - } + Environment.SetEnvironmentVariable("TEST_COMMIT", "abc123", EnvironmentVariableTarget.Process); + var metadata = Metadata.LoadFromFile(rootPath); + metadata.ReplaceVariablesInFile(rootPath); + File.ReadAllText(rootPath).Should().Contain("abc123").And.NotContain("${ENV:TEST_COMMIT}"); + File.ReadAllText(includePath).Should().Contain("abc123").And.NotContain("${ENV:TEST_COMMIT}"); } - - [Fact] - public void GetIncludeTree_ReturnsTreeStructure() + finally { - // Arrange - var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); - var metadata = Metadata.LoadFromFile(filePath); - - // Act - var tree = metadata.GetIncludeTree(); - - // Assert - tree.Should().NotBeNullOrEmpty(); - tree.Should().Contain("Metadata"); + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); } + } - [Fact] - public void ToNodeTree_ShouldKeepLeafValue() - { - // Arrange - var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); - var metadata = Metadata.LoadFromFile(filePath); - - // Act - var root = metadata.ToNodeTree(); - var app = root.Children.Single(c => c.Name == "Application"); - var appName = app.Children.Single(c => c.Name == "Name"); - - // Assert - appName.Value.Should().Be("Test Application"); - } - - [Fact] - public void GetBusinessEntities_ShouldReturnTypedEntities() - { - // Arrange - var filePath = Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml"); - var metadata = Metadata.LoadFromFile(filePath); - - // Act - var entities = metadata.GetBusinessEntities(); - - // Assert - entities.Should().Contain(e => e.EntityType == "Application" && e.Id == "RA3BattleNet"); - entities.Should().Contain(e => e.EntityType == "Mod" && e.Id == "Corona"); - entities.Should().Contain(e => e.EntityType == "Markdown"); - entities.Should().Contain(e => e.EntityType == "Manifest"); - } - - - [Fact] - public void Mods_ShouldExposeVersionAndPackages() - { - // Arrange - var filePath = Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml"); - var metadata = Metadata.LoadFromFile(filePath); - - // Act - var corona = metadata.Mods().Single(m => m.Id == "Corona"); - - // Assert - corona.Version.Should().Be("3.229"); - corona.Packages.Should().NotBeEmpty(); - } + [TestMethod] + public void GetIncludeTree_ReturnsTreeStructure() + { + var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); + var metadata = Metadata.LoadFromFile(filePath); + metadata.GetIncludeTree().Should().Contain("Metadata"); + } - [Fact] - public void Catalog_ShouldProvideConvenientLookup() - { - // Arrange - var filePath = Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml"); - var metadata = Metadata.LoadFromFile(filePath); + [TestMethod] + public void ToNodeTree_ShouldKeepLeafValue() + { + var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); + var metadata = Metadata.LoadFromFile(filePath); + var root = metadata.ToNodeTree(); + var app = root.Children.Single(c => c.Name == "Application"); + var appName = app.Children.Single(c => c.Name == "Name"); + appName.Value.Should().Be("Test Application"); + } - // Act - var catalog = metadata.Catalog(); - var app = catalog.Application("RA3BattleNet"); + [TestMethod] + public void RepoSample_LoadSource_HasAppAndMod() + { + var filePath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml")); + var metadata = Metadata.LoadFromFile(filePath); + metadata.GetBusinessEntities().Should().Contain(e => e.EntityType == "Application" && e.Id == "RA3BattleNet"); + metadata.GetBusinessEntities().Should().Contain(e => e.EntityType == "Mod" && e.Id == "Corona"); + } - // Assert - app.Should().NotBeNull(); - app!.Version.Should().Be("1.5.2.0"); - } + [TestMethod] + public void Mods_ShouldExposeVersionAndPackages() + { + var filePath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml")); + var metadata = Metadata.LoadFromFile(filePath); + var corona = metadata.Mods().Single(m => m.Id == "Corona"); + corona.Version.Should().Be("3.229"); + corona.Packages.Should().NotBeEmpty(); + } + [TestMethod] + public void Catalog_ShouldProvideConvenientLookup() + { + var filePath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml")); + var metadata = Metadata.LoadFromFile(filePath); + var app = metadata.Catalog().Application("RA3BattleNet"); + app.Should().NotBeNull(); + app!.Version.Should().Be("1.5.2.0"); } } diff --git a/Ra3.BattleNet.Metadata.Tests/Ra3.BattleNet.Metadata.Tests.csproj b/Ra3.BattleNet.Metadata.Tests/Ra3.BattleNet.Metadata.Tests.csproj index 01d50de..8045be0 100644 --- a/Ra3.BattleNet.Metadata.Tests/Ra3.BattleNet.Metadata.Tests.csproj +++ b/Ra3.BattleNet.Metadata.Tests/Ra3.BattleNet.Metadata.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,12 +11,8 @@ - - - - - - + + @@ -29,4 +25,4 @@ - \ No newline at end of file + diff --git a/Ra3.BattleNet.Metadata.Tests/StageATests.cs b/Ra3.BattleNet.Metadata.Tests/StageATests.cs new file mode 100644 index 0000000..3a0efea --- /dev/null +++ b/Ra3.BattleNet.Metadata.Tests/StageATests.cs @@ -0,0 +1,193 @@ +using System.Xml.Linq; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Ra3.BattleNet.Metadata.Tests; + +[TestClass] +public class StageATests +{ + [TestMethod] + public void Build_RepoSample_FlattensWithoutInclude_AndResolvesIds() + { + var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); + var src = Path.Combine(repoRoot, "Metadata"); + var dst = Path.Combine(Path.GetTempPath(), $"stage-a-{Guid.NewGuid():N}"); + + try + { + MetadataBuilder.Build(src, dst, schemaVersion: "1.0", contentRevision: "test-rev"); + + var flatPath = Path.Combine(dst, "metadata.xml"); + File.Exists(flatPath).Should().BeTrue(); + + var text = File.ReadAllText(flatPath); + text.Should().NotContain(" a.Id == "RA3BattleNet"); + loaded.Mods().Should().Contain(m => m.Id == "Corona"); + + var manifest = loaded.GetAllElements("Manifest").First(m => m.Get("ID") == "manifest-1.5.2.0"); + var source = manifest.Get("Source"); + source.Should().NotBeNullOrWhiteSpace(); + File.Exists(Path.Combine(dst, source!.Replace('/', Path.DirectorySeparatorChar))).Should().BeTrue(); + + var md = loaded.GetAllElements("Markdown").First(m => m.Get("ID") == "changelog-zh-1.5.2.0"); + var mdSource = md.Get("Source"); + File.Exists(Path.Combine(dst, mdSource!.Replace('/', Path.DirectorySeparatorChar))).Should().BeTrue(); + } + finally + { + if (Directory.Exists(dst)) + Directory.Delete(dst, recursive: true); + } + } + + [TestMethod] + public void Build_MissingMarkdown_HardFails_AndCleansOutput() + { + var temp = Path.Combine(Path.GetTempPath(), $"stage-a-bad-{Guid.NewGuid():N}"); + var src = Path.Combine(temp, "src"); + var dst = Path.Combine(temp, "out"); + Directory.CreateDirectory(src); + try + { + File.WriteAllText(Path.Combine(src, "metadata.xml"), """ + + + + +"""); + + var act = () => MetadataBuilder.Build(src, dst, contentRevision: "x"); + act.Should().Throw(); + Directory.Exists(dst).Should().BeFalse("半残输出应被清理"); + } + finally + { + if (Directory.Exists(temp)) + Directory.Delete(temp, recursive: true); + } + } + + [TestMethod] + public void Build_BadIdReference_HardFails() + { + var temp = Path.Combine(Path.GetTempPath(), $"stage-a-id-{Guid.NewGuid():N}"); + var src = Path.Combine(temp, "src"); + var dst = Path.Combine(temp, "out"); + Directory.CreateDirectory(src); + try + { + File.WriteAllText(Path.Combine(src, "note.md"), "# hello sample markdown body\n"); + File.WriteAllText(Path.Combine(src, "metadata.xml"), """ + + + + + 1.0 + + + no-such-manifest + + + + +"""); + + var act = () => MetadataBuilder.Build(src, dst, contentRevision: "x"); + act.Should().Throw().WithMessage("*no-such-manifest*"); + } + finally + { + if (Directory.Exists(temp)) + Directory.Delete(temp, recursive: true); + } + } + + [TestMethod] + public void Flatten_CircularInclude_Throws() + { + var temp = Path.Combine(Path.GetTempPath(), $"stage-a-circ-{Guid.NewGuid():N}"); + Directory.CreateDirectory(temp); + try + { + File.WriteAllText(Path.Combine(temp, "a.xml"), """ + + + + + + +"""); + File.WriteAllText(Path.Combine(temp, "b.xml"), """ + + + + + + +"""); + File.WriteAllText(Path.Combine(temp, "metadata.xml"), """ + + + + + + +"""); + + var act = () => MetadataBuilder.Build(temp, Path.Combine(temp, "out"), contentRevision: "x"); + act.Should().Throw().WithMessage("*循环引用*"); + } + finally + { + if (Directory.Exists(temp)) + Directory.Delete(temp, recursive: true); + } + } + + [TestMethod] + public void Build_LeftoverVariable_HardFails() + { + var temp = Path.Combine(Path.GetTempPath(), $"stage-a-var-{Guid.NewGuid():N}"); + var src = Path.Combine(temp, "src"); + Directory.CreateDirectory(src); + try + { + File.WriteAllText(Path.Combine(src, "metadata.xml"), """ + + + + ${ENV:THIS_ENV_SHOULD_NOT_EXIST_ZZZ} + + +"""); + var act = () => MetadataBuilder.Build(src, Path.Combine(temp, "out"), contentRevision: "x"); + act.Should().Throw().WithMessage("*残留*"); + } + finally + { + if (Directory.Exists(temp)) + Directory.Delete(temp, recursive: true); + } + } + + [TestMethod] + public void Flatten_Document_HasNoIncludeNodes() + { + var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); + var entry = Path.Combine(repoRoot, "Metadata", "metadata.xml"); + var doc = MetadataFlattener.Flatten(entry, Path.Combine(repoRoot, "Metadata"), "1.0", "rev"); + doc.Descendants().Any(e => e.Name.LocalName is "Include" or "Includes").Should().BeFalse(); + doc.Root!.Attribute("SchemaVersion")!.Value.Should().Be("1.0"); + XDocument.Parse(doc.ToString()).Root!.Attribute("ContentRevision")!.Value.Should().Be("rev"); + } +} diff --git a/Ra3.BattleNet.Metadata/ImageProcessor.cs b/Ra3.BattleNet.Metadata/ImageProcessor.cs deleted file mode 100644 index 3273169..0000000 --- a/Ra3.BattleNet.Metadata/ImageProcessor.cs +++ /dev/null @@ -1,141 +0,0 @@ -using SkiaSharp; - -namespace Ra3.BattleNet.Metadata; - -/// -/// 图片处理工具:将图片转换为 WebP 格式。 -/// -public static class ImageProcessor -{ - private static readonly HashSet SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) - { - ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".tif" - }; - - /// - /// 扫描输出目录中所有 元素引用的本地图片,转换为 WebP 格式。 - /// - /// 已加载的元数据根节点。 - /// 输出目录路径。 - /// 转换的文件数量。 - public static int ConvertImagesToWebP(Metadata metadata, string outputDir) - { - int convertedCount = 0; - var images = metadata.GetAllElements("Image"); - - foreach (var image in images) - { - var source = image.Get("Source"); - if (string.IsNullOrWhiteSpace(source)) - continue; - - var sourcePath = Path.Combine(outputDir, source.Replace('\\', '/')); - if (!File.Exists(sourcePath)) - continue; - - var ext = Path.GetExtension(sourcePath); - if (!SupportedExtensions.Contains(ext)) - continue; - - var webpPath = Path.ChangeExtension(sourcePath, ".webp"); - if (ConvertToWebP(sourcePath, webpPath)) - { - // 更新 Source 引用为 WebP 文件 - var webpRelative = Path.ChangeExtension(source, ".webp").Replace('\\', '/'); - // 通过反射设置变量(因为 _variables 是私有字段) - // 使用公共 API:重新加载时 Source 会指向 webp - // 这里我们直接操作文件,元数据中的 Source 会在后续变量替换时更新 - Console.WriteLine($" WebP: {source} -> {webpRelative}"); - convertedCount++; - } - } - - return convertedCount; - } - - /// - /// 将单张图片转换为 WebP 格式。 - /// - private static bool ConvertToWebP(string sourcePath, string webpPath) - { - try - { - // 如果 WebP 已存在且比源文件新,跳过 - if (File.Exists(webpPath)) - { - var sourceTime = File.GetLastWriteTimeUtc(sourcePath); - var webpTime = File.GetLastWriteTimeUtc(webpPath); - if (webpTime >= sourceTime) - return true; - } - - using var input = File.OpenRead(sourcePath); - using var stream = new SKManagedStream(input); - using var codec = SKCodec.Create(stream); - if (codec == null) - { - Console.WriteLine($" 警告: 无法解码图片 {sourcePath}"); - return false; - } - - var info = codec.Info; - using var bitmap = SKBitmap.Decode(codec); - if (bitmap == null) - { - Console.WriteLine($" 警告: 无法解码图片 {sourcePath}"); - return false; - } - - using var image = SKImage.FromBitmap(bitmap); - using var data = image.Encode(SKEncodedImageFormat.Webp, 90); - - using var output = File.Open(webpPath, FileMode.Create, FileAccess.Write); - data.SaveTo(output); - - Console.WriteLine($" 转换: {Path.GetFileName(sourcePath)} -> {Path.GetFileName(webpPath)} " + - $"({data.Size / 1024} KB)"); - return true; - } - catch (Exception ex) - { - Console.WriteLine($" 警告: 转换图片失败 {sourcePath}: {ex.Message}"); - return false; - } - } - - /// - /// 更新 XML 中 Image 元素的 Source 引用为 .webp 路径。 - /// - public static void UpdateImageSourceReferences(string outputDir) - { - var xmlFiles = Directory.GetFiles(outputDir, "*.xml", SearchOption.AllDirectories); - foreach (var xmlFile in xmlFiles) - { - var content = File.ReadAllText(xmlFile); - if (!content.Contains(" {sourceAttr.Value}"); - } - } - - if (modified) - doc.Save(xmlFile); - } - } -} diff --git a/Ra3.BattleNet.Metadata/MetadataBuilder.cs b/Ra3.BattleNet.Metadata/MetadataBuilder.cs new file mode 100644 index 0000000..228f1e2 --- /dev/null +++ b/Ra3.BattleNet.Metadata/MetadataBuilder.cs @@ -0,0 +1,239 @@ +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace Ra3.BattleNet.Metadata; + +/// +/// Stage A 构建:校验、变量替换、XML 数据展平、复制资源。纯 managed。 +/// +public static class MetadataBuilder +{ + public const string DefaultSchemaVersion = "1.0"; + + private static readonly Regex LeftoverVariablePattern = new(@"\$\{[^}]+\}", RegexOptions.Compiled); + + /// + /// 从本地源目录执行 Stage A 构建。 + /// + /// 含 metadata.xml 的源目录。 + /// 输出目录。 + /// 契约版本,默认 1.0。 + /// 内容修订号;为空则用 UTC 时间戳。 + public static void Build(string sourceDir, string outputDir, string? schemaVersion = null, string? contentRevision = null) + { + if (string.IsNullOrWhiteSpace(sourceDir)) + throw new ArgumentException("源目录不能为空", nameof(sourceDir)); + if (string.IsNullOrWhiteSpace(outputDir)) + throw new ArgumentException("输出目录不能为空", nameof(outputDir)); + + var src = Path.GetFullPath(sourceDir); + var dst = Path.GetFullPath(outputDir); + var entry = Path.Combine(src, "metadata.xml"); + if (!File.Exists(entry)) + throw new FileNotFoundException($"找不到入口文件: {entry}"); + + if (Directory.Exists(dst)) + Directory.Delete(dst, recursive: true); + Directory.CreateDirectory(dst); + + try + { + CopyAll(src, dst); + + var revision = string.IsNullOrWhiteSpace(contentRevision) + ? DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") + : contentRevision; + var version = string.IsNullOrWhiteSpace(schemaVersion) ? DefaultSchemaVersion : schemaVersion; + + var flattened = MetadataFlattener.Flatten(entry, src, version, revision); + var flatPath = Path.Combine(dst, "metadata.xml"); + flattened.Save(flatPath); + + var resolver = new VariableResolver(); + resolver.ReplaceInFile(flatPath); + + var leftover = FindLeftoverVariables(flatPath); + if (leftover.Count > 0) + throw new InvalidOperationException("变量替换后仍有残留: " + string.Join("; ", leftover)); + + ValidateHard(flatPath, dst); + } + catch + { + if (Directory.Exists(dst)) + { + try { Directory.Delete(dst, recursive: true); } catch { /* 尽力清理半残产物 */ } + } + throw; + } + } + + /// + /// 从本地路径或 HTTP(S) URL 加载已展平的 metadata.xml。 + /// + public static Metadata Load(string pathOrUrl) + { + if (string.IsNullOrWhiteSpace(pathOrUrl)) + throw new ArgumentException("路径或 URL 不能为空", nameof(pathOrUrl)); + + if (Uri.TryCreate(pathOrUrl, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + { + return LoadFromUrl(uri); + } + + return Metadata.LoadFromFile(pathOrUrl); + } + + private static Metadata LoadFromUrl(Uri uri) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; + using var stream = client.GetStreamAsync(uri).GetAwaiter().GetResult(); + var temp = Path.Combine(Path.GetTempPath(), $"metadata-url-{Guid.NewGuid():N}.xml"); + try + { + using (var fs = File.Create(temp)) + stream.CopyTo(fs); + return Metadata.LoadFromFile(temp); + } + finally + { + try { File.Delete(temp); } catch { /* ignore */ } + } + } + + private static void CopyAll(string src, string dst) + { + foreach (var file in Directory.GetFiles(src, "*", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(src, file); + var target = Path.Combine(dst, rel); + var dir = Path.GetDirectoryName(target); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + File.Copy(file, target, overwrite: true); + } + } + + private static List FindLeftoverVariables(string xmlPath) + { + var text = File.ReadAllText(xmlPath); + return LeftoverVariablePattern.Matches(text).Select(m => m.Value).Distinct().ToList(); + } + + private static void ValidateHard(string flatPath, string outputDir) + { + var metadata = Metadata.LoadFromFile(flatPath); + var errors = new List(); + + // 仅「登记节点」(带 ID 属性)进入索引;Style 内 id 引用不算登记 + var idIndex = new HashSet(StringComparer.OrdinalIgnoreCase); + var registryImages = metadata.GetAllElements("Image").Where(n => !string.IsNullOrWhiteSpace(n.Get("ID"))).ToList(); + var registryMarkdowns = metadata.GetAllElements("Markdown").Where(n => !string.IsNullOrWhiteSpace(n.Get("ID"))).ToList(); + var registryManifests = metadata.GetAllElements("Manifest").Where(n => !string.IsNullOrWhiteSpace(n.Get("ID"))).ToList(); + + foreach (var node in registryImages.Concat(registryMarkdowns).Concat(registryManifests)) + idIndex.Add(node.Get("ID")!); + + foreach (var md in registryMarkdowns) + { + var id = md.Get("ID")!; + var source = md.Get("Source"); + if (string.IsNullOrWhiteSpace(source)) + { + errors.Add($"Markdown '{id}' 缺少 Source"); + continue; + } + var path = Path.Combine(outputDir, source.Replace('\\', '/')); + if (!File.Exists(path)) + errors.Add($"Markdown 资源不存在: {source} (ID: {id})"); + } + + foreach (var image in registryImages) + { + var id = image.Get("ID")!; + var url = image.Get("Url"); + var source = image.Get("Source"); + if (!string.IsNullOrWhiteSpace(url)) + continue; + if (string.IsNullOrWhiteSpace(source)) + { + errors.Add($"Image '{id}' 缺少 Source 或 Url"); + continue; + } + var path = Path.Combine(outputDir, source.Replace('\\', '/')); + if (!File.Exists(path)) + errors.Add($"Image 资源不存在: {source} (ID: {id})"); + } + + foreach (var manifest in registryManifests) + { + var id = manifest.Get("ID")!; + var source = manifest.Get("Source"); + if (!string.IsNullOrWhiteSpace(source)) + { + var path = Path.Combine(outputDir, source.Replace('\\', '/')); + if (!File.Exists(path)) + errors.Add($"Manifest 资源不存在: {source} (ID: {id})"); + } + } + + foreach (var app in metadata.GetAllElements("Application").Concat(metadata.GetAllElements("Mod"))) + { + ValidateIdRef(app.Find("Icon")?.Value, "Icon", idIndex, errors); + var packages = app.Find("Packages"); + if (packages == null) continue; + foreach (var package in packages.Children.Where(c => c.Name == "Package")) + { + ValidateIdRef(package.Find("Manifest")?.Value, "Manifest", idIndex, errors); + var changelogs = package.Find("Changelogs"); + if (changelogs == null) continue; + foreach (var cl in changelogs.Children.Where(c => c.Name == "Changelog")) + ValidateIdRef(cl.Value, "Changelog", idIndex, errors); + } + + var posts = app.Find("Posts"); + if (posts == null) continue; + foreach (var post in posts.Children.Where(c => c.Name == "Post")) + { + var contents = post.Find("Contents"); + if (contents == null) continue; + foreach (var content in contents.Children.Where(c => c.Name == "Content")) + ValidateIdRef(content.Value, "Post Content", idIndex, errors); + } + + var style = app.Find("Style"); + if (style == null) continue; + var logo = style.Find("Logo"); + if (logo?.Value != null) + ValidateIdRef(logo.Value.Trim(), "Logo", idIndex, errors); + var background = style.Find("Background"); + if (background != null) + { + foreach (var img in background.Children.Where(c => c.Name == "Image")) + ValidateIdRef(img.Value?.Trim(), "Background Image", idIndex, errors); + } + } + + if (string.IsNullOrWhiteSpace(metadata.Get("SchemaVersion")) + && metadata.Find("SchemaVersion") == null) + { + // 属性写在根上 + if (!metadata.Variables.ContainsKey("SchemaVersion")) + errors.Add("缺少 SchemaVersion"); + } + + if (!metadata.Variables.ContainsKey("ContentRevision") && metadata.Find("ContentRevision") == null) + errors.Add("缺少 ContentRevision"); + + if (errors.Count > 0) + throw new InvalidOperationException("Stage A 校验失败:\n- " + string.Join("\n- ", errors)); + } + + private static void ValidateIdRef(string? id, string kind, HashSet idIndex, List errors) + { + if (string.IsNullOrWhiteSpace(id)) return; + if (!idIndex.Contains(id)) + errors.Add($"{kind} 引用未找到 ID: {id}"); + } +} diff --git a/Ra3.BattleNet.Metadata/MetadataFlattener.cs b/Ra3.BattleNet.Metadata/MetadataFlattener.cs new file mode 100644 index 0000000..f357b72 --- /dev/null +++ b/Ra3.BattleNet.Metadata/MetadataFlattener.cs @@ -0,0 +1,164 @@ +using System.Xml.Linq; + +namespace Ra3.BattleNet.Metadata; + +/// +/// 将带 Include 的源 XML 展平为单一数据树(不内联资源文件正文)。 +/// +public static class MetadataFlattener +{ + /// + /// 从入口 metadata.xml 展平,Source 路径改写为相对 sourceRoot。 + /// Manifest 在独立资源文件中时只保留 ID+Source,不把 File 列表并入主树。 + /// + public static XDocument Flatten(string entryPath, string sourceRoot, string schemaVersion, string contentRevision) + { + var fullEntry = Path.GetFullPath(entryPath); + var rootFull = Path.GetFullPath(sourceRoot); + var processing = new HashSet(StringComparer.OrdinalIgnoreCase); + var root = LoadAndExpand(fullEntry, rootFull, processing); + + root.SetAttributeValue("SchemaVersion", schemaVersion); + root.SetAttributeValue("ContentRevision", contentRevision); + + // 去掉残留 Includes / Include / Module + root.Descendants().Where(e => e.Name.LocalName is "Includes" or "Include" or "Module").ToList() + .ForEach(e => e.Remove()); + + return new XDocument(new XDeclaration("1.0", "utf-8", null), root); + } + + private static XElement LoadAndExpand(string filePath, string sourceRoot, HashSet processing) + { + var full = Path.GetFullPath(filePath); + if (!processing.Add(full)) + throw new InvalidOperationException($"检测到循环引用: {full}"); + + if (!File.Exists(full)) + throw new FileNotFoundException($"找不到文件: {full}"); + + var doc = XDocument.Load(full); + var root = doc.Root ?? throw new InvalidOperationException($"无效 XML: {full}"); + if (root.Name.LocalName != "Metadata") + throw new InvalidOperationException($"根节点必须是 Metadata: {full}"); + + var baseDir = Path.GetDirectoryName(full) + ?? throw new InvalidOperationException($"无法确定目录: {full}"); + + ExpandElement(root, baseDir, sourceRoot, processing); + processing.Remove(full); + return root; + } + + private static void ExpandElement(XElement element, string baseDir, string sourceRoot, HashSet processing) + { + // 先处理 Includes 容器与直接 Include + var includeHosts = element.Elements() + .Where(e => e.Name.LocalName is "Includes" or "Include" or "Module") + .ToList(); + + foreach (var host in includeHosts) + { + if (host.Name.LocalName is "Include" or "Module") + { + InsertInclude(host, baseDir, sourceRoot, processing); + host.Remove(); + } + else + { + foreach (var inc in host.Elements().Where(e => e.Name.LocalName is "Include" or "Module").ToList()) + { + InsertInclude(inc, baseDir, sourceRoot, processing); + inc.Remove(); + } + if (!host.HasElements) + host.Remove(); + } + } + + // 改写本层 Image/Markdown 的 Source 为相对 sourceRoot + RewriteSources(element, baseDir, sourceRoot); + + foreach (var child in element.Elements().ToList()) + { + if (child.Name.LocalName is "Includes" or "Include" or "Module") + continue; + ExpandElement(child, baseDir, sourceRoot, processing); + } + } + + private static void InsertInclude(XElement includeEl, string baseDir, string sourceRoot, HashSet processing) + { + var rel = includeEl.Attribute("Source")?.Value ?? includeEl.Attribute("Path")?.Value; + if (string.IsNullOrWhiteSpace(rel)) + throw new InvalidOperationException("Include/Module 缺少 Source/Path"); + + var target = Path.GetFullPath(Path.Combine(baseDir, rel.Replace('\\', '/'))); + var includedRoot = LoadAndExpand(target, sourceRoot, processing); + + // Include 若在 容器内,插入点为容器本身(作为 Metadata 子节点的兄弟), + // 避免内容落在 Includes 内随后被整段删除。 + var anchor = includeEl.Parent is { Name.LocalName: "Includes" } host + ? host + : includeEl; + + foreach (var child in includedRoot.Elements().ToList()) + { + if (child.Name.LocalName is "Tags" or "Includes") + continue; + + if (child.Name.LocalName == "Manifest") + { + // 资源外置:主树只保留 ID + Source(指向原 manifest XML) + var id = child.Attribute("ID")?.Value; + if (string.IsNullOrWhiteSpace(id)) + throw new InvalidOperationException($"Manifest 缺少 ID: {target}"); + + var sourceRel = ToRootRelative(target, sourceRoot); + var stub = new XElement("Manifest", + new XAttribute("ID", id), + new XAttribute("Source", sourceRel)); + anchor.AddBeforeSelf(stub); + continue; + } + + // Markdown/Image/Application/Mod 等:深拷贝 + var clone = new XElement(child); + anchor.AddBeforeSelf(clone); + } + } + + private static void RewriteSources(XElement element, string baseDir, string sourceRoot) + { + foreach (var el in element.Elements().Where(e => e.Name.LocalName is "Image" or "Markdown")) + { + var sourceAttr = el.Attribute("Source"); + if (sourceAttr == null || string.IsNullOrWhiteSpace(sourceAttr.Value)) + continue; + if (!string.IsNullOrWhiteSpace(el.Attribute("Url")?.Value)) + continue; + + var raw = sourceAttr.Value.Replace('\\', '/'); + var fromBase = Path.GetFullPath(Path.Combine(baseDir, raw)); + if (File.Exists(fromBase)) + { + sourceAttr.Value = ToRootRelative(fromBase, sourceRoot); + continue; + } + + var fromRoot = Path.GetFullPath(Path.Combine(sourceRoot, raw)); + if (File.Exists(fromRoot)) + { + sourceAttr.Value = ToRootRelative(fromRoot, sourceRoot); + } + } + } + + private static string ToRootRelative(string absolutePath, string sourceRoot) + { + var rel = Path.GetRelativePath(sourceRoot, absolutePath).Replace('\\', '/'); + if (rel.StartsWith("..", StringComparison.Ordinal)) + throw new InvalidOperationException($"资源不在源目录内: {absolutePath}"); + return rel; + } +} diff --git a/Ra3.BattleNet.Metadata/Program.cs b/Ra3.BattleNet.Metadata/Program.cs index ed88ab0..086aeaa 100644 --- a/Ra3.BattleNet.Metadata/Program.cs +++ b/Ra3.BattleNet.Metadata/Program.cs @@ -1,187 +1,97 @@ -using System; -using System.IO; +namespace Ra3.BattleNet.Metadata; -namespace Ra3.BattleNet.Metadata +internal static class Program { - internal class Program + private const string DefaultSrcFolder = "./Metadata"; + private const string DefaultDstFolder = "./Output"; + + private static int Main(string[] args) { - private const string DefaultSrcFolder = "./Metadata"; - private const string DefaultDstFolder = "./Output"; + var srcFolder = DefaultSrcFolder; + var dstFolder = DefaultDstFolder; + string? schemaVersion = null; + string? contentRevision = null; + var command = "build"; - static void Main(string[] args) + foreach (var arg in args) { - // 解析命令行参数 - var srcFolder = DefaultSrcFolder; - var dstFolder = DefaultDstFolder; - var skipSchema = false; - var skipWebp = false; - - foreach (string arg in args) + if (arg is "build" or "stage-a") + command = "build"; + else if (arg.StartsWith("--src=", StringComparison.Ordinal)) + srcFolder = arg["--src=".Length..]; + else if (arg.StartsWith("--dst=", StringComparison.Ordinal)) + dstFolder = arg["--dst=".Length..]; + else if (arg.StartsWith("--schema-version=", StringComparison.Ordinal)) + schemaVersion = arg["--schema-version=".Length..]; + else if (arg.StartsWith("--content-revision=", StringComparison.Ordinal)) + contentRevision = arg["--content-revision=".Length..]; + else if (arg is "--help" or "-h") { - if (arg.StartsWith("--src=")) - srcFolder = arg.Split('=')[1]; - else if (arg.StartsWith("--dst=")) - dstFolder = arg.Split('=')[1]; - else if (arg == "--skip-schema") - skipSchema = true; - else if (arg == "--skip-webp") - skipWebp = true; + PrintHelp(); + return 0; } + } - Console.WriteLine($"工作目录: {Environment.CurrentDirectory}"); - Console.WriteLine($"元数据源目录: {srcFolder}"); - Console.WriteLine($"输出目录: {dstFolder}"); - Console.WriteLine(); + Console.WriteLine($"工作目录: {Environment.CurrentDirectory}"); + Console.WriteLine($"Stage A 源目录: {srcFolder}"); + Console.WriteLine($"输出目录: {dstFolder}"); + Console.WriteLine(); - try + try + { + if (command != "build") { - // 创建输出目录 - if (!Directory.Exists(dstFolder)) - Directory.CreateDirectory(dstFolder); - - // 1. 加载元数据(带 XSD Schema 验证) - string metadataPath = Path.Combine(srcFolder, "metadata.xml"); - Console.WriteLine($"加载元数据: {metadataPath}"); - - Metadata metadata; - if (!skipSchema) - { - var schemaPath = Path.Combine(srcFolder, "MetadataSchema.xsd"); - if (File.Exists(schemaPath)) - { - Console.WriteLine(" 使用 XSD Schema 验证..."); - metadata = Metadata.LoadFromFileWithSchema(metadataPath, schemaPath); - } - else - { - metadata = Metadata.LoadFromFile(metadataPath); - } - } - else - { - metadata = Metadata.LoadFromFile(metadataPath); - } - Console.WriteLine($"✓ 成功加载元数据({metadata.Children.Count} 个子节点)"); - - // 2. 验证 Markdown 引用 - Console.WriteLine("验证 Markdown 文件引用..."); - var mdErrors = MarkdownValidator.ValidateAll(metadata, srcFolder); - if (!mdErrors.IsValid) - { - Console.WriteLine($" ⚠ Markdown 验证发现 {mdErrors.Errors.Count} 个问题:"); - foreach (var err in mdErrors.Errors) - Console.WriteLine($" - {err}"); - } - else - { - Console.WriteLine(" ✓ 所有 Markdown 引用有效"); - } - - // 3. 复制所有文件到输出目录 - Console.WriteLine("复制文件到输出目录..."); - CopyFiles(srcFolder, dstFolder); - Console.WriteLine("✓ 文件复制完成"); - - // 4. 图片 WebP 转换 - if (!skipWebp) - { - Console.WriteLine("转换图片为 WebP 格式..."); - int converted = ImageProcessor.ConvertImagesToWebP(metadata, dstFolder); - if (converted > 0) - { - Console.WriteLine($" ✓ 转换了 {converted} 张图片"); - Console.WriteLine(" 更新 XML 中的图片引用..."); - ImageProcessor.UpdateImageSourceReferences(dstFolder); - } - else - { - Console.WriteLine(" - 没有需要转换的图片"); - } - } - - // 5. 替换变量(${TIMESTAMP}, ${ENV:...}, ${MD5::})在输出目录中 - string outputMetadataPath = Path.Combine(dstFolder, "metadata.xml"); - Console.WriteLine("替换变量和计算哈希..."); - var outputMetadata = Metadata.LoadFromFile(outputMetadataPath); - outputMetadata.ReplaceVariablesInFile(outputMetadataPath); - Console.WriteLine("✓ 变量替换完成"); - - // 6. 验证处理后的元数据 - Console.WriteLine("验证处理后的元数据..."); - var verifiedMetadata = Metadata.LoadFromFile(outputMetadataPath); - Console.WriteLine("✓ 验证成功"); - Console.WriteLine(); - - // 显示元数据信息 - Console.WriteLine("=== 元数据信息 ==="); - Console.WriteLine($"根节点: {verifiedMetadata.Name}"); - Console.WriteLine($"子节点数: {verifiedMetadata.Children.Count}"); - - var commit = verifiedMetadata.Get("Commit"); - if (!string.IsNullOrEmpty(commit)) - Console.WriteLine($"Commit: {commit}"); - - // 显示 Include 树结构 - Console.WriteLine(); - Console.WriteLine("=== Include 引用树 ==="); - Console.WriteLine(verifiedMetadata.GetIncludeTree()); + Console.Error.WriteLine($"未知命令: {command}"); + return 1; + } - Console.WriteLine("=== 业务实体概览 ==="); - var entities = verifiedMetadata.GetBusinessEntities(); - foreach (var entity in entities) - { - var id = string.IsNullOrWhiteSpace(entity.Id) ? "" : entity.Id; - Console.WriteLine($"- [{entity.EntityType}] {id} @ {entity.Path}"); - } + Console.WriteLine("执行 Stage A(校验 / 变量 / XML 展平 / 复制资源)..."); + MetadataBuilder.Build(srcFolder, dstFolder, schemaVersion, contentRevision); + Console.WriteLine("✓ Stage A 完成"); - Console.WriteLine(); - Console.WriteLine("=== 查询 API 示例(metadata.Mods()) ==="); - foreach (var mod in verifiedMetadata.Mods()) - { - Console.WriteLine($"Mod={mod.Id}, Version={mod.Version}, Packages={mod.Packages.Count}"); - } + var flatPath = Path.Combine(dstFolder, "metadata.xml"); + var loaded = MetadataBuilder.Load(flatPath); + Console.WriteLine(); + Console.WriteLine("=== 展平结果 ==="); + Console.WriteLine($"SchemaVersion={loaded.Get("SchemaVersion")}"); + Console.WriteLine($"ContentRevision={loaded.Get("ContentRevision")}"); + foreach (var app in loaded.Applications()) + Console.WriteLine($"Application id={app.Id} version={app.Version}"); + foreach (var mod in loaded.Mods()) + Console.WriteLine($"Mod id={mod.Id} version={mod.Version}"); - Console.WriteLine(); - Console.WriteLine("处理完成!"); - } - catch (InvalidOperationException ex) when (ex.Message.Contains("循环引用")) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"错误: {ex.Message}"); - Console.ResetColor(); - Environment.Exit(1); - } - catch (Exception ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"处理过程中发生错误: {ex.Message}"); - Console.ResetColor(); - Environment.Exit(1); - } + Console.WriteLine(); + Console.WriteLine("处理完成(Stage A)。WebP 请在发布流水线 Stage B 执行。"); + return 0; } - - /// - /// 复制源目录的所有文件到目标目录,保持目录结构。 - /// - private static void CopyFiles(string srcFolder, string dstFolder) + catch (Exception ex) { - var files = Directory.GetFiles(srcFolder, "*.*", SearchOption.AllDirectories); - int copiedCount = 0; - - foreach (string file in files) - { - string relativePath = Path.GetRelativePath(srcFolder, file); - string targetFilePath = Path.Combine(dstFolder, relativePath); - - string? targetDir = Path.GetDirectoryName(targetFilePath); - if (!string.IsNullOrEmpty(targetDir) && !Directory.Exists(targetDir)) - Directory.CreateDirectory(targetDir); - - File.Copy(file, targetFilePath, overwrite: true); - copiedCount++; - } - - Console.WriteLine($" 已复制 {copiedCount} 个文件"); + Console.ForegroundColor = ConsoleColor.Red; + Console.Error.WriteLine($"错误: {ex.Message}"); + Console.ResetColor(); + return 1; } } + + private static void PrintHelp() + { + Console.WriteLine(""" + Ra3.BattleNet.Metadata — Stage A 构建(纯 managed) + + 用法: + dotnet run --project Ra3.BattleNet.Metadata -- build --src=./Metadata --dst=./Output + + 参数: + --src=DIR 源目录(含 metadata.xml) + --dst=DIR 输出目录 + --schema-version=VER 默认 1.0 + --content-revision=REV 默认 UTC 时间戳 + + 说明: + Stage A: 展平 XML 数据、变量替换、硬失败校验、复制资源 + Stage B(WebP): 仅发布脚本,不在本 CLI 内 + Load(path|url): 见 MetadataBuilder.Load + Build(url): 本期仅支持本地 path,远程源构建延后 + """); + } } diff --git a/Ra3.BattleNet.Metadata/Ra3.BattleNet.Metadata.csproj b/Ra3.BattleNet.Metadata/Ra3.BattleNet.Metadata.csproj index c6b30e6..dfb40ca 100644 --- a/Ra3.BattleNet.Metadata/Ra3.BattleNet.Metadata.csproj +++ b/Ra3.BattleNet.Metadata/Ra3.BattleNet.Metadata.csproj @@ -7,8 +7,4 @@ enable - - - - diff --git a/Ra3.BattleNet.Metadata/VariableResolver.cs b/Ra3.BattleNet.Metadata/VariableResolver.cs index d6462fc..cf65e36 100644 --- a/Ra3.BattleNet.Metadata/VariableResolver.cs +++ b/Ra3.BattleNet.Metadata/VariableResolver.cs @@ -26,11 +26,17 @@ public VariableResolver() public void ReplaceInFile(string filePath) { var fullPath = Path.GetFullPath(filePath); - ReplaceInFileRecursive(fullPath, new HashSet(StringComparer.OrdinalIgnoreCase)); + ReplaceInFileRecursive( + fullPath, + new HashSet(StringComparer.OrdinalIgnoreCase), + new HashSet(StringComparer.OrdinalIgnoreCase)); } - private void ReplaceInFileRecursive(string filePath, HashSet processingPaths) + private void ReplaceInFileRecursive(string filePath, HashSet processingPaths, HashSet processedPaths) { + if (processedPaths.Contains(filePath)) + return; + if (!processingPaths.Add(filePath)) throw new InvalidOperationException($"检测到循环引用: {filePath}"); @@ -39,10 +45,8 @@ private void ReplaceInFileRecursive(string filePath, HashSet processingP var basePath = Path.GetDirectoryName(filePath) ?? throw new InvalidOperationException($"无法确定文件目录: {filePath}"); - // 验证所有 Include/Module 引用的资源存在 ValidateResources(root, basePath); - // 递归处理所有引用的文件 foreach (var include in root.Descendants().Where(e => e.Name.LocalName is "Include" or "Module")) { var path = include.Attribute("Path")?.Value ?? include.Attribute("Source")?.Value; @@ -50,12 +54,14 @@ private void ReplaceInFileRecursive(string filePath, HashSet processingP var referencedFile = Path.GetFullPath(Path.Combine(basePath, path.Replace('\\', '/'))); if (File.Exists(referencedFile)) - ReplaceInFileRecursive(referencedFile, processingPaths); + ReplaceInFileRecursive(referencedFile, processingPaths, processedPaths); } - // 替换当前文件中的变量 ReplaceInElement(root, filePath); doc.Save(filePath); + + processingPaths.Remove(filePath); + processedPaths.Add(filePath); } /// @@ -65,6 +71,19 @@ public void ReplaceInElement(XElement element, string currentFilePath) { foreach (var attr in element.Attributes()) { + // Markdown/Image 的 Hash=${MD5::} 按 Source 资源文件计算 + if (attr.Name.LocalName == "Hash" + && attr.Value.Contains("${MD5::}", StringComparison.Ordinal) + && element.Attribute("Source") is { Value: { Length: > 0 } source}) + { + var dir = Path.GetDirectoryName(currentFilePath) + ?? throw new InvalidOperationException($"无法确定文件目录: {currentFilePath}"); + var resourcePath = Path.GetFullPath(Path.Combine(dir, source.Replace('\\', '/'))); + if (!File.Exists(resourcePath)) + throw new InvalidOperationException($"MD5 目标资源不存在: {source}"); + attr.Value = attr.Value.Replace("${MD5::}", ComputeFileHash(resourcePath), StringComparison.Ordinal); + } + attr.Value = Resolve(attr.Value, element, currentFilePath); } diff --git a/build.sh b/build.sh index d0777d7..5e7896f 100644 --- a/build.sh +++ b/build.sh @@ -1,5 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Stage A: pure managed flatten + validate +# Stage B: WebP only when RUN_STAGE_B=1 (CF publish path) + curl -sSL https://dot.net/v1/dotnet-install.sh > dotnet-install.sh chmod +x dotnet-install.sh ./dotnet-install.sh -c 10.0 -InstallDir ./dotnet ./dotnet/dotnet --version -./dotnet/dotnet run --project ./Ra3.BattleNet.Metadata --src="./Metadata" --dst="./Output" + +./dotnet/dotnet run --project ./Ra3.BattleNet.Metadata --no-launch-profile -- \ + build --src="./Metadata" --dst="./Output" + +if [ "${RUN_STAGE_B:-1}" = "1" ]; then + echo "Running Stage B (WebP)..." + ./dotnet/dotnet run --project ./Ra3.BattleNet.Metadata.StageB --no-launch-profile -- \ + --dst="./Output" +else + echo "Skipping Stage B (set RUN_STAGE_B=1 to enable WebP)." +fi diff --git a/package.json b/package.json index a9c744b..0ee4c75 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,12 @@ "private": true, "scripts": { "build": "bash build.sh", + "build:stage-a": "dotnet run --project ./Ra3.BattleNet.Metadata --no-launch-profile -- build --src=./Metadata --dst=./Output", + "build:stage-b": "dotnet run --project ./Ra3.BattleNet.Metadata.StageB --no-launch-profile -- --dst=./Output", "deploy": "npm run build && npx wrangler pages deploy Output", "dev": "npx wrangler pages dev Output", - "preview": "npm run build && npx wrangler pages dev Output" + "preview": "npm run build:stage-a && npx wrangler pages dev Output", + "test": "dotnet test Metadata.sln --nologo" }, "devDependencies": { "wrangler": "^4.68.0" diff --git a/src/index.js b/src/index.js index 9f24063..45d346d 100644 --- a/src/index.js +++ b/src/index.js @@ -1,25 +1,9 @@ +/** + * Cloudflare Pages / Workers 静态资源透传。 + * 业务数据入口为 /metadata.xml(见 _redirects:/ -> /metadata.xml)。 + */ export default { - async fetch(request, env, ctx) { - // 获取请求的 URL - const url = new URL(request.url); - let path = url.pathname; - - // 处理根路径 - if (path === '/') { - path = '/metadata.xml'; - } - - // 移除开头的斜杠 - if (path.startsWith('/')) { - path = path.slice(1); - } - - try { - // 从 KV 或静态资源获取文件 - // 注意:Cloudflare Workers Sites 会自动处理静态文件 - return await env.ASSETS.fetch(request); - } catch (e) { - return new Response('Not Found', { status: 404 }); - } + async fetch(request, env) { + return env.ASSETS.fetch(request); }, }; From 3ab863d5b9d1403268614c4f059b656d23501f0d Mon Sep 17 00:00:00 2001 From: mrbbbaixue Date: Sat, 25 Jul 2026 11:56:21 +0800 Subject: [PATCH 03/11] =?UTF-8?q?fix(metadata):=20=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=8F=B6=E5=AD=90=20Manifest=20Source=20=E4=B8=8D=E8=A2=AB?= =?UTF-8?q?=E5=B5=8C=E5=A5=97=20Include=20=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 嵌套展开时已带 Source 的 Manifest stub 原样上浮;测试断言指向 manifests/1.5.2.0.xml 且资源内含 File 表。 --- Ra3.BattleNet.Metadata.Tests/StageATests.cs | 19 +++++++++++++++++-- Ra3.BattleNet.Metadata/MetadataFlattener.cs | 10 +++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/Ra3.BattleNet.Metadata.Tests/StageATests.cs b/Ra3.BattleNet.Metadata.Tests/StageATests.cs index 3a0efea..68b5a6e 100644 --- a/Ra3.BattleNet.Metadata.Tests/StageATests.cs +++ b/Ra3.BattleNet.Metadata.Tests/StageATests.cs @@ -37,11 +37,26 @@ public void Build_RepoSample_FlattensWithoutInclude_AndResolvesIds() var manifest = loaded.GetAllElements("Manifest").First(m => m.Get("ID") == "manifest-1.5.2.0"); var source = manifest.Get("Source"); source.Should().NotBeNullOrWhiteSpace(); - File.Exists(Path.Combine(dst, source!.Replace('/', Path.DirectorySeparatorChar))).Should().BeTrue(); + // 必须指向叶子清单资源,不能是中间聚合文件(apps.xml / manifests.xml) + source!.Replace('\\', '/').Should().EndWith("manifests/1.5.2.0.xml"); + source.Should().NotContain("apps.xml"); + source.Should().NotContain("manifests/manifests.xml"); + var manifestPath = Path.Combine(dst, source.Replace('/', Path.DirectorySeparatorChar)); + File.Exists(manifestPath).Should().BeTrue(); + var manifestXml = File.ReadAllText(manifestPath); + manifestXml.Should().Contain("manifest-1.5.2.0"); + manifestXml.Should().Contain(" m.Get("ID") == "manifest-3229"); + coronaManifest.Get("Source")!.Replace('\\', '/').Should().EndWith("manifests/3.229.xml"); + var coronaPath = Path.Combine(dst, coronaManifest.Get("Source")!.Replace('/', Path.DirectorySeparatorChar)); + File.ReadAllText(coronaPath).Should().Contain("corona_3.229.lyi"); var md = loaded.GetAllElements("Markdown").First(m => m.Get("ID") == "changelog-zh-1.5.2.0"); var mdSource = md.Get("Source"); - File.Exists(Path.Combine(dst, mdSource!.Replace('/', Path.DirectorySeparatorChar))).Should().BeTrue(); + mdSource!.Replace('\\', '/').Should().EndWith("changelogs/zh-1.5.2.0.md"); + File.Exists(Path.Combine(dst, mdSource.Replace('/', Path.DirectorySeparatorChar))).Should().BeTrue(); } finally { diff --git a/Ra3.BattleNet.Metadata/MetadataFlattener.cs b/Ra3.BattleNet.Metadata/MetadataFlattener.cs index f357b72..40a8a33 100644 --- a/Ra3.BattleNet.Metadata/MetadataFlattener.cs +++ b/Ra3.BattleNet.Metadata/MetadataFlattener.cs @@ -109,11 +109,19 @@ private static void InsertInclude(XElement includeEl, string baseDir, string sou if (child.Name.LocalName == "Manifest") { - // 资源外置:主树只保留 ID + Source(指向原 manifest XML) + // 资源外置:主树只保留 ID + Source(指向含 File 表的叶子 manifest XML) var id = child.Attribute("ID")?.Value; if (string.IsNullOrWhiteSpace(id)) throw new InvalidOperationException($"Manifest 缺少 ID: {target}"); + // 已是下层展开产生的 stub(带 Source)则原样上浮,禁止用中间 Include 文件覆盖路径 + var existingSource = child.Attribute("Source")?.Value; + if (!string.IsNullOrWhiteSpace(existingSource)) + { + anchor.AddBeforeSelf(new XElement(child)); + continue; + } + var sourceRel = ToRootRelative(target, sourceRoot); var stub = new XElement("Manifest", new XAttribute("ID", id), From 228824f0786ea325cc93e081a59a352158200384 Mon Sep 17 00:00:00 2001 From: mrbbbaixue Date: Sat, 25 Jul 2026 12:02:56 +0800 Subject: [PATCH 04/11] =?UTF-8?q?docs(plan):=20=E6=A0=87=E8=AE=B0=E6=9C=AC?= =?UTF-8?q?=E6=9C=9F=E9=AA=8C=E6=94=B6=E9=A1=B9=E5=B7=B2=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 PLAN.md 状态与 Stage B / Build(url) 实现偏差说明。 --- PLAN.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/PLAN.md b/PLAN.md index 2557621..05d3c0c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,6 +1,6 @@ # Metadata 改进与完善计划 -> 状态:已确认(grill-me 决策锁定) +> 状态:**本期代码已落地**(`dev` 分支) > 范围:本期仅 **Metadata 仓**;Desktop 联调另迭代 > 优先级:**2 客户端契约 → 5 库产品化 → 1 示例内容** @@ -234,14 +234,19 @@ MetadataBuilder.Build(sourcePath | sourceUrl, outputDir) // Stage A ## 6. 验收标准(本期完成时) -- [ ] 测试项目为 **MSTest**,无 xunit 系依赖;`dotnet test` 全绿 -- [ ] 硬失败用例覆盖(schema / 缺文件 / 坏引用 / 变量残留 / 循环引用) -- [ ] 类库无 SkiaSharp 等原生硬依赖 -- [ ] `metadata build --src Metadata --dst Output` 得到展平 `metadata.xml` + 资源 -- [ ] 展平 XML 无 Include;含 SchemaVersion/ContentRevision;资源 ID 可解析 -- [ ] CF 脚本含 Stage B WebP;本地 Stage A 可单独跑 -- [ ] 示例:RA3BattleNet + Corona 可通过完整 Stage A -- [ ] README:契约、默认 URL、Desktop 下期对接、Manifest 归属 Updater、测试用 MSTest +- [x] 测试项目为 **MSTest**,无 xunit 系依赖;`dotnet test` 全绿 +- [x] 硬失败用例覆盖(schema / 缺文件 / 坏引用 / 变量残留 / 循环引用) +- [x] 类库无 SkiaSharp 等原生硬依赖 +- [x] `metadata build --src Metadata --dst Output` 得到展平 `metadata.xml` + 资源 +- [x] 展平 XML 无 Include;含 SchemaVersion/ContentRevision;资源 ID 可解析(Manifest Source 指向叶子清单文件) +- [x] CF 脚本含 Stage B WebP;本地 Stage A 可单独跑 +- [x] 示例:RA3BattleNet + Corona 可通过完整 Stage A +- [x] README:契约、默认 URL、Desktop 下期对接、Manifest 归属 Updater、测试用 MSTest + +### 已实现说明(相对原文的偏差) + +- Stage B 为独立项目 `Ra3.BattleNet.Metadata.StageB`(SkiaSharp),由 `build.sh` 调用 +- `Load(url)` 已支持;`Build(url)` 远程源构建延后(见 §4) --- From 2625854e5a907d69fc86a436e67b411ec9a72a84 Mon Sep 17 00:00:00 2001 From: mrbbbaixue Date: Sat, 25 Jul 2026 12:06:38 +0800 Subject: [PATCH 05/11] =?UTF-8?q?refactor(build):=20StageB=20=E6=94=B9?= =?UTF-8?q?=E5=90=8D=E4=B8=BA=20Imaging=EF=BC=8CWebP=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=20--webp=20=E6=98=BE=E5=BC=8F=E5=BC=80=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 项目 Ra3.BattleNet.Metadata.Imaging(SkiaSharp) - build.sh 默认仅核心构建;--webp/--publish 才 Imaging - npm: build / build:webp / build:release;wrangler 使用 build.sh --webp - 移除 RUN_STAGE_B 控制面 --- Metadata.sln | 4 +- PLAN.md | 25 +++--- README.md | 77 ++++++++++--------- .../Program.cs | 7 +- .../Ra3.BattleNet.Metadata.Imaging.csproj | 2 +- Ra3.BattleNet.Metadata/Program.cs | 14 ++-- build.sh | 30 ++++++-- package.json | 10 +-- wrangler.toml | 4 +- 9 files changed, 99 insertions(+), 74 deletions(-) rename {Ra3.BattleNet.Metadata.StageB => Ra3.BattleNet.Metadata.Imaging}/Program.cs (90%) rename Ra3.BattleNet.Metadata.StageB/Ra3.BattleNet.Metadata.StageB.csproj => Ra3.BattleNet.Metadata.Imaging/Ra3.BattleNet.Metadata.Imaging.csproj (83%) diff --git a/Metadata.sln b/Metadata.sln index 75f552a..0b32bd2 100644 --- a/Metadata.sln +++ b/Metadata.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.12.35514.174 d17.12 @@ -7,7 +7,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ra3.BattleNet.Metadata", "R EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ra3.BattleNet.Metadata.Tests", "Ra3.BattleNet.Metadata.Tests\Ra3.BattleNet.Metadata.Tests.csproj", "{F9429D39-9063-45C4-9EC5-84A34A4829D8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ra3.BattleNet.Metadata.StageB", "Ra3.BattleNet.Metadata.StageB\Ra3.BattleNet.Metadata.StageB.csproj", "{A9429D39-9063-45C4-9EC5-84A34A4829D9}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ra3.BattleNet.Metadata.Imaging", "Ra3.BattleNet.Metadata.Imaging\Ra3.BattleNet.Metadata.Imaging.csproj", "{A9429D39-9063-45C4-9EC5-84A34A4829D9}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/PLAN.md b/PLAN.md index 05d3c0c..68d86e4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -18,7 +18,7 @@ | 用户默认 | Cloudflare Pages 展平产物(可配置默认 URL) | | 开发者(下期 Desktop) | 调试页指本地仓 → Stage A 编译 → 读缓存 | | 库形态 | **单一纯 managed 库**:解析 + 编译;支持 **path / URL** | -| 图片 | 库不做 WebP;**仅 CF Stage B** 转 WebP | +| 图片 | 库不做 WebP;**仅发布路径 Imaging**(`build.sh --webp`) | | 校验 | **硬失败**,不产出半残发布物 | | Manifest | **Updater 生成**,本仓只引用;Updater 改造 **记入路线图** | | MVP 数据 | App `RA3BattleNet` + Mod `Corona` **示例**;其余自行补充 | @@ -30,7 +30,7 @@ ## 1. 目标架构 ``` -源仓 Metadata/ Stage A (库/CLI, 纯 managed) Stage B (仅 CF) +源仓 Metadata/ 核心 build (库/CLI, 纯 managed) Imaging (仅发布 --webp) xml 树 + 资源 → 校验 → 变量 → XML 展平 → Output/ → WebP + 改写引用 → 部署 Pages ↓ metadata.xml (数据, 含 SchemaVersion/ContentRevision) @@ -68,7 +68,7 @@ #### A1. 发布契约文档 - 默认 URL 占位、路径规则、ID 引用、版本字段 -- Stage A vs B 产物差异(业务树同形,图片扩展名可能不同) +- 核心 build vs Imaging 产物差异(业务树同形,图片扩展名可能不同) - 硬失败错误约定 #### A2. XML 展平(核心) @@ -113,7 +113,7 @@ Output/ - `Ra3.BattleNet.Metadata`:类库(解析 + Stage A 构建) - 可执行入口:同解决方案 CLI(`dotnet run` / 日后 `dotnet tool`) -- **主库无 SkiaSharp 等原生硬依赖**(WebP 仅 Stage B) +- **主库无 SkiaSharp 等原生硬依赖**(WebP 仅 Imaging 项目) #### B2. 公共 API(最小面) @@ -141,12 +141,12 @@ MetadataBuilder.Build(sourcePath | sourceUrl, outputDir) // Stage A ### 阶段 C — 发布管线 -#### C1. `build.sh` 两段式 +#### C1. `build.sh`:默认核心,发布 opt-in WebP 1. 安装/使用 dotnet -2. **Stage A**:CLI Build(纯 managed:校验、变量、XML 展平) -3. **Stage B**(仅 CF/发布):WebP 转换 + 更新 Image Source 引用 -4. 任一步失败即 fail +2. **默认**:核心 build(纯 managed:校验、变量、XML 展平) +3. **`--webp` / `--publish`**(CF `wrangler` 使用):Imaging WebP + 改写 Image Source +4. 任一步失败即 fail;**无** `RUN_STAGE_B` 环境变量 #### C2. Cloudflare Pages @@ -239,14 +239,15 @@ MetadataBuilder.Build(sourcePath | sourceUrl, outputDir) // Stage A - [x] 类库无 SkiaSharp 等原生硬依赖 - [x] `metadata build --src Metadata --dst Output` 得到展平 `metadata.xml` + 资源 - [x] 展平 XML 无 Include;含 SchemaVersion/ContentRevision;资源 ID 可解析(Manifest Source 指向叶子清单文件) -- [x] CF 脚本含 Stage B WebP;本地 Stage A 可单独跑 -- [x] 示例:RA3BattleNet + Corona 可通过完整 Stage A +- [x] CF 脚本含 Imaging WebP(`build.sh --webp`);本地默认仅核心 build +- [x] 示例:RA3BattleNet + Corona 可通过完整核心 build - [x] README:契约、默认 URL、Desktop 下期对接、Manifest 归属 Updater、测试用 MSTest ### 已实现说明(相对原文的偏差) -- Stage B 为独立项目 `Ra3.BattleNet.Metadata.StageB`(SkiaSharp),由 `build.sh` 调用 +- Imaging 独立项目 `Ra3.BattleNet.Metadata.Imaging`(SkiaSharp);默认不转 WebP,发布显式 `--webp` / `npm run build:release` - `Load(url)` 已支持;`Build(url)` 远程源构建延后(见 §4) +- 用户可见面不再使用 StageA/StageB、`RUN_STAGE_B` --- @@ -270,7 +271,7 @@ MetadataBuilder.Build(sourcePath | sourceUrl, outputDir) // Stage A 6. 单库纯 managed;Parse/Build 支持 path 与 URL 7. 本地:编译到缓存再读(与线上同形) 8. URL 契约:单入口 + 相对资源 + SchemaVersion/ContentRevision -9. WebP 仅 CF Stage B;Stage A/B 两段式 +9. WebP 仅发布 Imaging(opt-in `--webp`);核心 build 与 Imaging 分离 10. 校验硬失败 11. Manifest 由 Updater 生成;Updater 改动进路线图 12. MVP 示例:RA3BattleNet + Corona;代码优先,数据示例后自行补 diff --git a/README.md b/README.md index bbe96cc..d1c6617 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 红警3战网元数据 -本仓库存储战网客户端所需的**应用 / Mod 版本、清单引用、新闻与资源索引**,经 Stage A 构建后由 Cloudflare Pages 发布。 +本仓库存储战网客户端所需的**应用 / Mod 版本、清单引用、新闻与资源索引**,经核心构建后由 Cloudflare Pages 发布。 更完整的产品决策见 [PLAN.md](./PLAN.md)。 @@ -15,20 +15,33 @@ | 资源引用 | XML 内一律 **ID 引用**(如 `manifest-1.5.2.0`),再解析登记节点上的 `Source`/`Url` | | Manifest 归属 | **Updater 生成**清单文件;本仓只登记 ID 并引用。Updater 格式改造见路线图 | -### Stage A vs Stage B +### 核心构建 vs Imaging(WebP) -- **Stage A(本地 / 库 / CI 必跑,纯 managed)**:校验、变量替换、XML 数据展平、复制资源。 - `dotnet run --project Ra3.BattleNet.Metadata -- build --src=./Metadata --dst=./Output` -- **Stage B(仅发布流水线)**:WebP 转码并改写 Image `Source`。 - `dotnet run --project Ra3.BattleNet.Metadata.StageB -- --dst=./Output` - `build.sh` 默认会跑 Stage B;本地可设 `RUN_STAGE_B=0` 跳过。 +| 路径 | 做什么 | 何时用 | +|---|---|---| +| **核心构建** `build` | 校验、变量、XML 展平、复制资源(**纯 managed,无 SkiaSharp**) | 本地开发、测试、Desktop 调试默认 | +| **Imaging** `build:webp` | 将 Output 内本地图转 WebP 并改写 `Source` | **仅发布**(显式开启) | -本地与线上业务树同形;线上图片扩展名可能为 `.webp`,客户端应按节点 `Source` 读取,勿写死 `.png`。 +```bash +# 本地默认:只要核心产物 +npm run build +# 或 +dotnet run --project Ra3.BattleNet.Metadata -- build --src=./Metadata --dst=./Output + +# 正式发布:核心 + WebP +npm run build:release +# 或(CF / 装 dotnet 的环境) +bash build.sh --webp +``` + +- Cloudflare:`wrangler.toml` 的 `[build].command = "bash build.sh --webp"` +- **没有** `RUN_STAGE_B`:默认不转 WebP,发布用 `--webp` / `build:release` 正向开启 +- 本地与线上业务树同形;线上图片扩展名可能为 `.webp`,客户端按节点 `Source` 读,勿写死 `.png` ### 库 API ```csharp -// Stage A 构建(本期 Build 仅支持本地 path;远程源构建延后) +// 核心构建(本期 Build 仅支持本地 path;远程源构建延后) MetadataBuilder.Build(sourceDir, outputDir, schemaVersion: "1.0", contentRevision: "git-sha"); // 解析已展平产物(path 或 http(s) URL) @@ -36,29 +49,21 @@ var doc = MetadataBuilder.Load(pathOrUrl); var app = doc.Catalog().Application("RA3BattleNet"); ``` -主库 **无 SkiaSharp**;正式客户端可只引用 `Ra3.BattleNet.Metadata` 做解析与本地 Stage A。 +主库 **无 SkiaSharp**;正式客户端可只引用 `Ra3.BattleNet.Metadata` 做解析与本地核心构建。 ### 硬失败 -下列情况 Stage A **非 0 退出**并清理半残输出:循环 Include、缺失资源、断开的 ID 引用、未替换的 `${...}`、校验失败。 +下列情况核心构建 **非 0 退出**并清理半残输出:循环 Include、缺失资源、断开的 ID 引用、未替换的 `${...}`、校验失败。 -## 本地构建 +## 本地命令 ```bash -# 仅 Stage A(推荐日常) -dotnet run --project Ra3.BattleNet.Metadata -- build --src=./Metadata --dst=./Output - -# 测试(MSTest,无 xunit) dotnet test Metadata.sln - -# Cloudflare 风格完整脚本(安装 dotnet + Stage A + Stage B) -bash build.sh -# 跳过 WebP:RUN_STAGE_B=0 bash build.sh -``` - -```bash -npm run build # bash build.sh -npm run deploy # build + wrangler pages deploy Output +npm run build # 核心 +npm run build:webp # 仅 Imaging(需已有 Output) +npm run build:release # 核心 + Imaging +npm run preview # 核心后 pages dev +npm run deploy # release + wrangler pages deploy ``` ## 示例数据 @@ -70,14 +75,14 @@ npm run deploy # build + wrangler pages deploy Output ## Desktop 下期对接(本期不改 Desktop 仓) 1. 用户默认 `BaseUrl` 指向 Cloudflare Pages 展平产物。 -2. 开发者调试页:选择本地 Metadata 仓库路径 → 调用 Stage A `MetadataBuilder.Build` → `Load` 缓存目录中的 `metadata.xml`。 -3. 与线上同一套解析逻辑,仅换内容来源。 +2. 开发者调试页:选择本地 Metadata 仓库路径 → 调用核心 `MetadataBuilder.Build` → `Load` 缓存目录中的 `metadata.xml`。 +3. 与线上同一套解析逻辑,仅换内容来源(本地一般不跑 Imaging)。 ## 测试 -- 框架:**MSTest**(`MSTest.TestFramework` + `MSTest.TestAdapter`) -- **禁止**再引入 xunit / xunit.v3 / xunit.runner -- 覆盖:展平、硬失败(缺资源 / 坏 ID / 循环引用 / 变量残留)、示例 Stage A 成功路径 +- 框架:**MSTest** +- **禁止**再引入 xunit 系包 +- 覆盖:展平、硬失败、示例核心构建成功路径 ## Include 与 public/private @@ -95,10 +100,10 @@ npm run deploy # build + wrangler pages deploy Output ## 目录 ``` -Metadata/ 源数据 -Ra3.BattleNet.Metadata/ 纯 managed 库 + Stage A CLI -Ra3.BattleNet.Metadata.StageB/ 发布用 WebP(SkiaSharp) -Ra3.BattleNet.Metadata.Tests/ MSTest -build.sh CF:Stage A + Stage B -PLAN.md 详细计划与决策记录 +Metadata/ 源数据 +Ra3.BattleNet.Metadata/ 纯 managed 库 + 核心 CLI +Ra3.BattleNet.Metadata.Imaging/ 发布用 WebP(SkiaSharp) +Ra3.BattleNet.Metadata.Tests/ MSTest +build.sh CF:默认核心;--webp 加 Imaging +PLAN.md 详细计划与决策记录 ``` diff --git a/Ra3.BattleNet.Metadata.StageB/Program.cs b/Ra3.BattleNet.Metadata.Imaging/Program.cs similarity index 90% rename from Ra3.BattleNet.Metadata.StageB/Program.cs rename to Ra3.BattleNet.Metadata.Imaging/Program.cs index 25fc7e2..8f79c45 100644 --- a/Ra3.BattleNet.Metadata.StageB/Program.cs +++ b/Ra3.BattleNet.Metadata.Imaging/Program.cs @@ -1,10 +1,11 @@ using System.Xml.Linq; using SkiaSharp; -namespace Ra3.BattleNet.Metadata.StageB; +namespace Ra3.BattleNet.Metadata.Imaging; /// -/// Stage B:仅发布流水线使用 — 将 Output 中本地图片转为 WebP 并改写 XML Source。 +/// 发布可选步骤:将 Output 中本地图片转为 WebP 并改写 metadata.xml 的 Image Source。 +/// 主库不引用本项目;仅 build.sh --webp / npm run build:webp 使用。 /// internal static class Program { @@ -58,7 +59,7 @@ private static int Main(string[] args) } doc.Save(flat); - Console.WriteLine($"Stage B 完成,转换 {converted} 张图片"); + Console.WriteLine($"Imaging 完成,转换 {converted} 张图片"); return 0; } diff --git a/Ra3.BattleNet.Metadata.StageB/Ra3.BattleNet.Metadata.StageB.csproj b/Ra3.BattleNet.Metadata.Imaging/Ra3.BattleNet.Metadata.Imaging.csproj similarity index 83% rename from Ra3.BattleNet.Metadata.StageB/Ra3.BattleNet.Metadata.StageB.csproj rename to Ra3.BattleNet.Metadata.Imaging/Ra3.BattleNet.Metadata.Imaging.csproj index 3831e8b..8de62ce 100644 --- a/Ra3.BattleNet.Metadata.StageB/Ra3.BattleNet.Metadata.StageB.csproj +++ b/Ra3.BattleNet.Metadata.Imaging/Ra3.BattleNet.Metadata.Imaging.csproj @@ -5,7 +5,7 @@ net10.0 enable enable - Ra3.BattleNet.Metadata.StageB + Ra3.BattleNet.Metadata.Imaging diff --git a/Ra3.BattleNet.Metadata/Program.cs b/Ra3.BattleNet.Metadata/Program.cs index 086aeaa..9f70fad 100644 --- a/Ra3.BattleNet.Metadata/Program.cs +++ b/Ra3.BattleNet.Metadata/Program.cs @@ -33,7 +33,7 @@ private static int Main(string[] args) } Console.WriteLine($"工作目录: {Environment.CurrentDirectory}"); - Console.WriteLine($"Stage A 源目录: {srcFolder}"); + Console.WriteLine($"源目录: {srcFolder}"); Console.WriteLine($"输出目录: {dstFolder}"); Console.WriteLine(); @@ -45,9 +45,9 @@ private static int Main(string[] args) return 1; } - Console.WriteLine("执行 Stage A(校验 / 变量 / XML 展平 / 复制资源)..."); + Console.WriteLine("执行核心构建(校验 / 变量 / XML 展平 / 复制资源)..."); MetadataBuilder.Build(srcFolder, dstFolder, schemaVersion, contentRevision); - Console.WriteLine("✓ Stage A 完成"); + Console.WriteLine("✓ 核心构建完成"); var flatPath = Path.Combine(dstFolder, "metadata.xml"); var loaded = MetadataBuilder.Load(flatPath); @@ -61,7 +61,7 @@ private static int Main(string[] args) Console.WriteLine($"Mod id={mod.Id} version={mod.Version}"); Console.WriteLine(); - Console.WriteLine("处理完成(Stage A)。WebP 请在发布流水线 Stage B 执行。"); + Console.WriteLine("处理完成。WebP 请显式运行 Imaging(npm run build:webp 或 bash build.sh --webp)。"); return 0; } catch (Exception ex) @@ -76,7 +76,7 @@ private static int Main(string[] args) private static void PrintHelp() { Console.WriteLine(""" - Ra3.BattleNet.Metadata — Stage A 构建(纯 managed) + Ra3.BattleNet.Metadata — 核心构建(纯 managed) 用法: dotnet run --project Ra3.BattleNet.Metadata -- build --src=./Metadata --dst=./Output @@ -88,8 +88,8 @@ private static void PrintHelp() --content-revision=REV 默认 UTC 时间戳 说明: - Stage A: 展平 XML 数据、变量替换、硬失败校验、复制资源 - Stage B(WebP): 仅发布脚本,不在本 CLI 内 + build: 展平 XML、变量替换、硬失败校验、复制资源 + WebP: 独立项目 Ra3.BattleNet.Metadata.Imaging(npm run build:webp / build.sh --webp) Load(path|url): 见 MetadataBuilder.Load Build(url): 本期仅支持本地 path,远程源构建延后 """); diff --git a/build.sh b/build.sh index 5e7896f..d2ab078 100644 --- a/build.sh +++ b/build.sh @@ -1,21 +1,39 @@ #!/usr/bin/env bash set -euo pipefail -# Stage A: pure managed flatten + validate -# Stage B: WebP only when RUN_STAGE_B=1 (CF publish path) +# 默认:纯 managed 构建(展平 + 校验) +# 发布:bash build.sh --webp (再跑 Imaging / WebP) + +WITH_WEBP=0 +for arg in "$@"; do + case "$arg" in + --webp|--publish|--with-webp) WITH_WEBP=1 ;; + --help|-h) + cat <<'EOF' +用法: bash build.sh [--webp] + + (无参数) 仅核心构建 → ./Output + --webp 核心构建后执行 Imaging(WebP 转码) + --publish 同 --webp +EOF + exit 0 + ;; + esac +done curl -sSL https://dot.net/v1/dotnet-install.sh > dotnet-install.sh chmod +x dotnet-install.sh ./dotnet-install.sh -c 10.0 -InstallDir ./dotnet ./dotnet/dotnet --version +echo ">>> 核心构建 (build)" ./dotnet/dotnet run --project ./Ra3.BattleNet.Metadata --no-launch-profile -- \ build --src="./Metadata" --dst="./Output" -if [ "${RUN_STAGE_B:-1}" = "1" ]; then - echo "Running Stage B (WebP)..." - ./dotnet/dotnet run --project ./Ra3.BattleNet.Metadata.StageB --no-launch-profile -- \ +if [ "$WITH_WEBP" = "1" ]; then + echo ">>> Imaging (WebP)" + ./dotnet/dotnet run --project ./Ra3.BattleNet.Metadata.Imaging --no-launch-profile -- \ --dst="./Output" else - echo "Skipping Stage B (set RUN_STAGE_B=1 to enable WebP)." + echo ">>> 跳过 Imaging(发布请用: bash build.sh --webp)" fi diff --git a/package.json b/package.json index 0ee4c75..3a74b53 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,12 @@ "version": "1.0.0", "private": true, "scripts": { - "build": "bash build.sh", - "build:stage-a": "dotnet run --project ./Ra3.BattleNet.Metadata --no-launch-profile -- build --src=./Metadata --dst=./Output", - "build:stage-b": "dotnet run --project ./Ra3.BattleNet.Metadata.StageB --no-launch-profile -- --dst=./Output", - "deploy": "npm run build && npx wrangler pages deploy Output", + "build": "dotnet run --project ./Ra3.BattleNet.Metadata --no-launch-profile -- build --src=./Metadata --dst=./Output", + "build:webp": "dotnet run --project ./Ra3.BattleNet.Metadata.Imaging --no-launch-profile -- --dst=./Output", + "build:release": "npm run build && npm run build:webp", + "deploy": "npm run build:release && npx wrangler pages deploy Output", "dev": "npx wrangler pages dev Output", - "preview": "npm run build:stage-a && npx wrangler pages dev Output", + "preview": "npm run build && npx wrangler pages dev Output", "test": "dotnet test Metadata.sln --nologo" }, "devDependencies": { diff --git a/wrangler.toml b/wrangler.toml index 0d35fb8..3300717 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -5,9 +5,9 @@ compatibility_date = "2026-02-20" [assets] directory = "./Output" -# 构建配置 +# 发布构建:核心展平 + WebP(与本地默认 npm run build 区分) [build] -command = "bash build.sh" +command = "bash build.sh --webp" # 环境变量配置(如果需要) # [vars] From 4d88bca668f54381b306cccf43a85b361ceba50a Mon Sep 17 00:00:00 2001 From: mrbbbaixue Date: Sat, 25 Jul 2026 12:08:25 +0800 Subject: [PATCH 06/11] =?UTF-8?q?docs(build):=20=E5=B0=86=20Stage=20A=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=8F=AF=E8=A7=81=E6=8E=AA=E8=BE=9E=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E4=B8=BA=E6=A0=B8=E5=BF=83=E6=9E=84=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 错误信息与 PLAN 对齐 Imaging / --webp 命名,避免 Stage 黑话。 --- PLAN.md | 2 +- Ra3.BattleNet.Metadata/MetadataBuilder.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/PLAN.md b/PLAN.md index 68d86e4..cea8710 100644 --- a/PLAN.md +++ b/PLAN.md @@ -16,7 +16,7 @@ | 发布资源 | Manifest / 图片 / Markdown **独立文件**,XML 内 **ID 引用** | | public/private | 仅构建期可见性;线上无 `Include` | | 用户默认 | Cloudflare Pages 展平产物(可配置默认 URL) | -| 开发者(下期 Desktop) | 调试页指本地仓 → Stage A 编译 → 读缓存 | +| 开发者(下期 Desktop) | 调试页指本地仓 → 核心构建 → 读缓存 | | 库形态 | **单一纯 managed 库**:解析 + 编译;支持 **path / URL** | | 图片 | 库不做 WebP;**仅发布路径 Imaging**(`build.sh --webp`) | | 校验 | **硬失败**,不产出半残发布物 | diff --git a/Ra3.BattleNet.Metadata/MetadataBuilder.cs b/Ra3.BattleNet.Metadata/MetadataBuilder.cs index 228f1e2..494ad15 100644 --- a/Ra3.BattleNet.Metadata/MetadataBuilder.cs +++ b/Ra3.BattleNet.Metadata/MetadataBuilder.cs @@ -4,7 +4,7 @@ namespace Ra3.BattleNet.Metadata; /// -/// Stage A 构建:校验、变量替换、XML 数据展平、复制资源。纯 managed。 +/// 核心构建:校验、变量替换、XML 数据展平、复制资源。纯 managed。 /// public static class MetadataBuilder { @@ -13,7 +13,7 @@ public static class MetadataBuilder private static readonly Regex LeftoverVariablePattern = new(@"\$\{[^}]+\}", RegexOptions.Compiled); /// - /// 从本地源目录执行 Stage A 构建。 + /// 从本地源目录执行核心构建(不含 Imaging/WebP)。 /// /// 含 metadata.xml 的源目录。 /// 输出目录。 @@ -227,7 +227,7 @@ private static void ValidateHard(string flatPath, string outputDir) errors.Add("缺少 ContentRevision"); if (errors.Count > 0) - throw new InvalidOperationException("Stage A 校验失败:\n- " + string.Join("\n- ", errors)); + throw new InvalidOperationException("核心构建校验失败:\n- " + string.Join("\n- ", errors)); } private static void ValidateIdRef(string? id, string kind, HashSet idIndex, List errors) From b462110f8ae040fa65f28a7259c987da5904b9f0 Mon Sep 17 00:00:00 2001 From: mrbbbaixue Date: Sat, 25 Jul 2026 12:17:14 +0800 Subject: [PATCH 07/11] =?UTF-8?q?docs(metadata):=20=E6=B6=88=E8=B4=B9?= =?UTF-8?q?=E7=AB=AF=E8=A7=A3=E6=9E=90=E6=96=87=E6=A1=A3=E3=80=81=E5=8F=8C?= =?UTF-8?q?=20XSD=20=E7=A1=AC=E6=A0=A1=E9=AA=8C=E4=B8=8E=20AGENTS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README:Desktop 解析流程、模块属性清单、Load/Catalog 示例 - MetadataSchema + MetadataPublishSchema;构建期源树/发布物硬失败 - SchemaValidator + SchemaTests;AGENTS.md 贡献指引 --- AGENTS.md | 81 ++++ Metadata/MetadataPublishSchema.xsd | 149 ++++++ Metadata/MetadataSchema.xsd | 493 ++++++++------------ README.md | 292 +++++++++--- Ra3.BattleNet.Metadata.Tests/SchemaTests.cs | 127 +++++ Ra3.BattleNet.Metadata.Tests/StageATests.cs | 28 +- Ra3.BattleNet.Metadata/MetadataBuilder.cs | 9 + Ra3.BattleNet.Metadata/MetadataParser.cs | 31 +- Ra3.BattleNet.Metadata/SchemaValidator.cs | 112 +++++ 9 files changed, 937 insertions(+), 385 deletions(-) create mode 100644 AGENTS.md create mode 100644 Metadata/MetadataPublishSchema.xsd create mode 100644 Ra3.BattleNet.Metadata.Tests/SchemaTests.cs create mode 100644 Ra3.BattleNet.Metadata/SchemaValidator.cs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9ac9069 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,81 @@ +# AGENTS.md — Metadata 仓库指引 + +面向自动化 Agent 与人类贡献者。产品决策细节见 [PLAN.md](./PLAN.md),使用方解析见 [README.md](./README.md)。 + +## 仓库职责 + +- **源数据**:`Metadata/**/*.xml` + 图片/Markdown/叶子 Manifest +- **核心库/CLI**:`Ra3.BattleNet.Metadata` — 展平、变量、XSD、语义校验、查询 API(**纯 managed,无 SkiaSharp**) +- **Imaging**:`Ra3.BattleNet.Metadata.Imaging` — 仅发布路径 WebP +- **发布**:Cloudflare Pages 静态托管 `Output/` + +## 常用命令 + +```bash +# 测试(MSTest) +dotnet test Metadata.sln + +# 核心构建(默认;Desktop 本地调试同此) +dotnet run --project Ra3.BattleNet.Metadata -- build --src=./Metadata --dst=./Output + +# 发布构建(核心 + WebP) +npm run build:release +# 或 +bash build.sh --webp +``` + +- **不要**再引入 `RUN_STAGE_B` / `StageB` 控制面。 +- 本地默认 **不** 跑 Imaging;CF:`wrangler.toml` → `bash build.sh --webp`。 + +## Schema + +| 文件 | 校验对象 | 时机 | +|---|---|---| +| `Metadata/MetadataSchema.xsd` | 源树全部 `.xml` | 核心构建开始时硬失败 | +| `Metadata/MetadataPublishSchema.xsd` | 展平 `metadata.xml` | 变量替换后硬失败 | + +实现:`SchemaValidator` + `MetadataBuilder.Build`。 + +改结构时:**同步改 XSD + README 属性清单 + 示例 + 测试**。 + +## 使用方(Desktop)解析要点 + +1. 只消费**展平** `metadata.xml` + 相对资源,不自己展开 Include。 +2. `MetadataBuilder.Load(path|url)` → `Catalog` / `Applications` / `Mods`。 +3. Package.Manifest / Icon / Changelog / Content 等是 **ID**,不是路径。 +4. 登记节点 `Manifest[@Source]` 指向叶子清单 XML(含 File 表)。 +5. 资源 URL = `BaseUrl` + `Source`;本地 = `BasePath` + `Source`。 +6. 调试:本地 `Build` → 同一 `Load`;勿写死 `.png`。 + +完整步骤与属性表:README「使用方如何解析 Metadata」「模块属性清单」。 + +## 代码布局 + +``` +Metadata/metadata.xml 入口 +Metadata/** 分模块 Include 源 +Ra3.BattleNet.Metadata/ + MetadataBuilder.cs 核心 Build / Load + MetadataFlattener.cs Include 展平 + SchemaValidator.cs XSD + VariableResolver.cs ${...} + Metadata.cs / MetadataParser.cs 树与加载 + MetadataQueryExtensions.cs Catalog/Mods/... +Ra3.BattleNet.Metadata.Imaging/ WebP +Ra3.BattleNet.Metadata.Tests/ MSTest +``` + +## 修改约束 + +- FAILFAST:校验失败即停,不留半残 `Output`。 +- 测试只许 **MSTest**,禁止 xunit。 +- 主库禁止加入 SkiaSharp / 原生图片依赖。 +- Manifest **权威在 Updater**;本仓示例 Hash 可为占位。 +- 中文注释与文档;代码标识符 ASCII。 +- git 访问外网仓库时用代理 `http://localhost:7890`(项目惯例)。 + +## 不要做的事 + +- 在 Desktop 仓以外擅自改 Desktop UI(联调另迭代)。 +- 把 File 表内联进发布 `metadata.xml` 的 Manifest stub。 +- 默认开启 WebP 或恢复 `StageB` 命名作为 API。 diff --git a/Metadata/MetadataPublishSchema.xsd b/Metadata/MetadataPublishSchema.xsd new file mode 100644 index 0000000..e402326 --- /dev/null +++ b/Metadata/MetadataPublishSchema.xsd @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Metadata/MetadataSchema.xsd b/Metadata/MetadataSchema.xsd index 7681bbf..ffa715d 100644 --- a/Metadata/MetadataSchema.xsd +++ b/Metadata/MetadataSchema.xsd @@ -1,291 +1,212 @@ + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index d1c6617..cbcd35c 100644 --- a/README.md +++ b/README.md @@ -2,108 +2,274 @@ 本仓库存储战网客户端所需的**应用 / Mod 版本、清单引用、新闻与资源索引**,经核心构建后由 Cloudflare Pages 发布。 -更完整的产品决策见 [PLAN.md](./PLAN.md)。 +- 产品决策:[PLAN.md](./PLAN.md) +- 贡献者 / Agent 指引:[AGENTS.md](./AGENTS.md) -## 发布契约(Desktop 默认消费) +--- + +## 使用方如何解析 Metadata(Desktop 视角) + +发布物是**静态文件树**,不是 RPC。客户端只依赖: + +1. 展平后的 **`metadata.xml`**(业务数据) +2. 资源文件(图片 / Markdown / 叶子 Manifest XML) +3. 可选:本库 `Ra3.BattleNet.Metadata`(C# 解析与查询) + +### 1. 入口与版本 | 项 | 说明 | |---|---| -| 默认 BaseUrl | `https://metadata.ra3battle.net`(占位,部署后替换为真实 Pages 域名) | -| 数据入口 | `GET {BaseUrl}/metadata.xml` — **已展平**的业务 XML(无 `Include`) | -| 资源 | `{BaseUrl}/{相对路径}` — Manifest / 图片 / Markdown 等**独立文件** | -| 版本字段 | 根节点属性 `SchemaVersion`、`ContentRevision` | -| 资源引用 | XML 内一律 **ID 引用**(如 `manifest-1.5.2.0`),再解析登记节点上的 `Source`/`Url` | -| Manifest 归属 | **Updater 生成**清单文件;本仓只登记 ID 并引用。Updater 格式改造见路线图 | +| 默认 BaseUrl | `https://metadata.ra3battle.net`(占位,部署后替换) | +| 数据入口 | `{BaseUrl}/metadata.xml` | +| 根属性 | `SchemaVersion`(契约版本)、`ContentRevision`(内容修订) | +| 资源 | `{BaseUrl}/{相对路径}`,路径来自登记节点的 `Source` | -### 核心构建 vs Imaging(WebP) +发布物 **无 `Include`**。源仓多文件仅供作者维护,使用方只读展平结果。 -| 路径 | 做什么 | 何时用 | -|---|---|---| -| **核心构建** `build` | 校验、变量、XML 展平、复制资源(**纯 managed,无 SkiaSharp**) | 本地开发、测试、Desktop 调试默认 | -| **Imaging** `build:webp` | 将 Output 内本地图转 WebP 并改写 `Source` | **仅发布**(显式开启) | +### 2. Desktop 推荐流程 -```bash -# 本地默认:只要核心产物 -npm run build -# 或 -dotnet run --project Ra3.BattleNet.Metadata -- build --src=./Metadata --dst=./Output +``` +┌─────────────────────────────────────────────────────────────┐ +│ 生产路径 │ +│ BaseUrl = CDN │ +│ Load(BaseUrl + "/metadata.xml") │ +│ → Catalog / Applications / Mods │ +│ → 按 ID 找 Image|Markdown|Manifest 登记节点 │ +│ → 拼 BaseUrl + Source 下载资源 │ +└─────────────────────────────────────────────────────────────┘ -# 正式发布:核心 + WebP -npm run build:release -# 或(CF / 装 dotnet 的环境) -bash build.sh --webp +┌─────────────────────────────────────────────────────────────┐ +│ 开发调试路径 │ +│ 指定本地 Metadata 仓库 → MetadataBuilder.Build(src, cache) │ +│ → Load(cache/metadata.xml) (同一套解析) │ +│ 本地一般不跑 Imaging;线上发布才 --webp │ +└─────────────────────────────────────────────────────────────┘ ``` -- Cloudflare:`wrangler.toml` 的 `[build].command = "bash build.sh --webp"` -- **没有** `RUN_STAGE_B`:默认不转 WebP,发布用 `--webp` / `build:release` 正向开启 -- 本地与线上业务树同形;线上图片扩展名可能为 `.webp`,客户端按节点 `Source` 读,勿写死 `.png` +**步骤(生产):** + +1. 配置 `BaseUrl`(用户默认 CDN;调试页可改本地/预览)。 +2. `GET {BaseUrl}/metadata.xml`(或 `MetadataBuilder.Load(url|path)`)。 +3. 读取 `SchemaVersion`:不兼容则提示升级客户端。 +4. 读取 `ContentRevision`:与本地缓存比较,决定是否整树刷新。 +5. `doc.Catalog().Application("RA3BattleNet")` / `doc.Mods()` 取业务实体。 +6. 对 Package 的 `Manifest` 文本、Icon、Changelog、Post Content 等:**先当 ID**,在展平树中找同 ID 的登记节点。 +7. 用登记节点的 `Source` 或 `Url` 取资源: + - 本地文件:`Path.Combine(basePath, Source)` + - 远端:`new Uri(new Uri(BaseUrl.TrimEnd('/') + "/"), Source)` +8. 叶子 Manifest XML(含 `` 表)用 `Source` 再拉一次并解析,**不要**假设 File 表在 `metadata.xml` 内。 +9. 图片扩展名以节点 `Source` 为准(发布后可能是 `.webp`)。 + +**步骤(开发):** -### 库 API +1. 调试页选择本地仓路径。 +2. `MetadataBuilder.Build(repo/Metadata, cacheDir)`(核心构建,硬失败则展示错误)。 +3. 后续与生产相同:`Load(cacheDir/metadata.xml)`,`basePath = cacheDir`。 + +### 3. 库 API 示例 ```csharp -// 核心构建(本期 Build 仅支持本地 path;远程源构建延后) -MetadataBuilder.Build(sourceDir, outputDir, schemaVersion: "1.0", contentRevision: "git-sha"); +// 生产:URL +var doc = MetadataBuilder.Load("https://metadata.ra3battle.net/metadata.xml"); + +// 开发:本地展平文件 +var doc = MetadataBuilder.Load(@"D:\cache\metadata.xml"); -// 解析已展平产物(path 或 http(s) URL) -var doc = MetadataBuilder.Load(pathOrUrl); var app = doc.Catalog().Application("RA3BattleNet"); +var corona = doc.Mods().Single(m => m.Id == "Corona"); + +// 解析 Manifest ID → 资源路径 +var package = app!.Packages[0]; +var reg = doc.GetAllElements("Manifest") + .First(m => m.Get("ID") == package.ManifestId); +var relative = reg.Get("Source"); // e.g. apps/ra3battlenet/manifests/1.5.2.0.xml +// 再用 BaseUrl/BasePath + relative 取文件 ``` -主库 **无 SkiaSharp**;正式客户端可只引用 `Ra3.BattleNet.Metadata` 做解析与本地核心构建。 +核心构建(作者/CI/Desktop 调试编译): -### 硬失败 +```csharp +MetadataBuilder.Build(sourceDir, outputDir, schemaVersion: "1.0", contentRevision: gitSha); +``` -下列情况核心构建 **非 0 退出**并清理半残输出:循环 Include、缺失资源、断开的 ID 引用、未替换的 `${...}`、校验失败。 +主库 **无 SkiaSharp**。WebP 仅 `Ra3.BattleNet.Metadata.Imaging` / `build.sh --webp`。 -## 本地命令 +### 4. 校验与失败 -```bash -dotnet test Metadata.sln -npm run build # 核心 -npm run build:webp # 仅 Imaging(需已有 Output) -npm run build:release # 核心 + Imaging -npm run preview # 核心后 pages dev -npm run deploy # release + wrangler pages deploy -``` +核心构建 **硬失败**(非 0、清理半残输出): -## 示例数据 +- 源树 XSD(`MetadataSchema.xsd`) +- 发布物 XSD(`MetadataPublishSchema.xsd`) +- 循环 Include、缺资源、断 ID、残留 `${...}` -- Application:`RA3BattleNet` -- Mod:`Corona` -其余内容可自行补充;正式 Manifest Hash 应以 Updater 生成为准。 +--- -## Desktop 下期对接(本期不改 Desktop 仓) +## 模块属性清单 -1. 用户默认 `BaseUrl` 指向 Cloudflare Pages 展平产物。 -2. 开发者调试页:选择本地 Metadata 仓库路径 → 调用核心 `MetadataBuilder.Build` → `Load` 缓存目录中的 `metadata.xml`。 -3. 与线上同一套解析逻辑,仅换内容来源(本地一般不跑 Imaging)。 +以下字段来自**真实示例 + 展平产物**,供 Desktop 建模对照。 -## 测试 +### 根 `Metadata`(发布物) + +| 成员 | 位置 | 说明 | +|---|---|---| +| `SchemaVersion` | 属性 | 契约版本,如 `1.0` | +| `ContentRevision` | 属性 | 构建注入的修订号 | +| `Tags/Commit` | 子元素 | 构建时间戳等 | +| 子节点 | 子元素 | `Application` / `Mod` / 登记用 `Image`·`Markdown`·`Manifest` | + +源树另可有 `Includes/Include`(`Source`、`Type=public|private`),**发布物中不存在**。 -- 框架:**MSTest** -- **禁止**再引入 xunit 系包 -- 覆盖:展平、硬失败、示例核心构建成功路径 +### `Application`(应用,如战网客户端) + +| 成员 | 说明 | +|---|---| +| `@ID` | 应用 ID,如 `RA3BattleNet` | +| `Version` | 当前版本号 | +| `Packages/Package` | 历史版本包列表 | +| `Posts/Post` | 新闻 | + +### `Mod` + +| 成员 | 说明 | +|---|---| +| `@ID` | Mod ID,如 `Corona` | +| `CurrentVersion` | 当前版本 | +| `Icon` | **ID 引用** → `Image` 登记节点 | +| `Style` | UI 样式(见下) | +| `Packages` / `Posts` | 同 Application | -## Include 与 public/private +### `Package` -源树可用 `Include`/`Includes` 分模块维护。`Type=public|private` **仅影响构建期解析**;发布物中无 Include,业务节点合并进单一 `metadata.xml`。 +| 成员 | 说明 | +|---|---| +| `@Version` | 包版本 | +| `ReleaseDate` | 发布日期字符串 | +| `Changelogs/Changelog` | `@Language` + 文本为 Markdown **ID** | +| `Manifest` | 文本为 Manifest **ID**(不是路径) | + +### `Post` + +| 成员 | 说明 | +|---|---| +| `@DateTime` | 时间 | +| `Titles/Title` | `@Language` + 标题文本 | +| `Contents/Content` | `@Language` + 文本为 Markdown **ID** | + +### `Style`(Mod) + +| 成员 | 说明 | +|---|---| +| `Logo` | `@Width` `@Height`,文本为 Image **ID** | +| `Controls/*` | 控件样式(如 `LaunchButton/BorderBrush`、`Label/FontSize`) | +| `Background` | `@Random`;子 `Image` 文本为 Image **ID**(不是登记节点) | + +### 登记节点 `Image` + +| 成员 | 说明 | +|---|---| +| `@ID` | 全局 ID | +| `@Source` | 相对路径(本地资源) | +| `@Url` | 外链(可与 Source 二选一) | -### 变量 +### 登记节点 `Markdown` + +| 成员 | 说明 | +|---|---| +| `@ID` | 全局 ID | +| `@Source` | 相对路径(`.md`) | +| `@Hash` | 资源 MD5(构建时替换) | + +### 登记节点 `Manifest`(**发布物 stub**) + +| 成员 | 说明 | +|---|---| +| `@ID` | 全局 ID | +| `@Source` | 指向叶子清单 XML 的相对路径 | + +### 叶子 Manifest 资源文件(独立 XML,Updater 生成) + +根仍为 `Metadata`,内含完整: + +| 成员 | 说明 | +|---|---| +| `Manifest/@ID` | 与 stub 一致 | +| `File/@Hash` | 文件哈希 | +| `File/FileName` | 文件名 | +| `File/RelativePath` | 相对路径 | +| `File/KindOf` | 类型标记 | +| `File/PatchInfo/Patch` | `@From` `@Method` `@Encryption` | +| `SubManifest` | 子清单引用 | + +--- + +## 核心构建 vs Imaging + +| 路径 | 做什么 | 何时用 | +|---|---|---| +| **核心构建** | XSD + 展平 + 变量 + 资源复制 | 本地、测试、Desktop 调试编译 | +| **Imaging** | WebP + 改写 Image Source | **仅发布**(显式) | + +```bash +npm run build # 核心 +npm run build:webp # 仅 Imaging(需已有 Output) +npm run build:release # 核心 + Imaging +bash build.sh --webp # CF:装 dotnet + 核心 + Imaging +``` + +`wrangler.toml`:`command = "bash build.sh --webp"`。 +**无** `RUN_STAGE_B`:默认不转 WebP。 + +--- + +## Schema 文件 + +| 文件 | 用途 | +|---|---| +| `Metadata/MetadataSchema.xsd` | **源树**全部 `.xml` | +| `Metadata/MetadataPublishSchema.xsd` | **展平** `metadata.xml` | + +构建顺序:源 XSD → 展平 → 变量 → 发布 XSD → 语义硬校验。 + +--- + +## 变量(源树 / 构建时) | 语法 | 含义 | |---|---| | `${TIMESTAMP}` | UTC 时间 | | `${ENV:NAME}` | 环境变量 | -| `${MD5:}` / `${MD5:rel}` | 当前文件或相对文件 MD5;Markdown `Hash="${MD5::}"` 按 `Source` 资源计算 | -| `${META:...}` / `${this:...}` | 树内/容器 Defines 引用 | +| `${MD5:}` / `${MD5:rel}` | 文件 MD5;Markdown `Hash="${MD5::}"` 按 `Source` | +| `${META:...}` / `${this:...}` | 树内引用 | + +--- + +## 本地命令 + +```bash +dotnet test Metadata.sln +dotnet run --project Ra3.BattleNet.Metadata -- build --src=./Metadata --dst=./Output +npm run preview +npm run deploy +``` + +## 示例数据 + +- Application:`RA3BattleNet` +- Mod:`Corona` +正式 Manifest Hash 以 **Updater** 生成为准。 + +## 测试 + +- **MSTest** only(禁止 xunit) +- 覆盖:展平、XSD 硬失败、ID/资源、消费端 Catalog 解析 ## 目录 ``` -Metadata/ 源数据 +Metadata/ 源数据 + XSD Ra3.BattleNet.Metadata/ 纯 managed 库 + 核心 CLI -Ra3.BattleNet.Metadata.Imaging/ 发布用 WebP(SkiaSharp) +Ra3.BattleNet.Metadata.Imaging/ 发布 WebP Ra3.BattleNet.Metadata.Tests/ MSTest -build.sh CF:默认核心;--webp 加 Imaging -PLAN.md 详细计划与决策记录 +AGENTS.md Agent/贡献者 +PLAN.md 计划与决策 ``` diff --git a/Ra3.BattleNet.Metadata.Tests/SchemaTests.cs b/Ra3.BattleNet.Metadata.Tests/SchemaTests.cs new file mode 100644 index 0000000..fc36d66 --- /dev/null +++ b/Ra3.BattleNet.Metadata.Tests/SchemaTests.cs @@ -0,0 +1,127 @@ +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Ra3.BattleNet.Metadata.Tests; + +[TestClass] +public class SchemaTests +{ + private static string RepoRoot => + Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); + + [TestMethod] + public void Build_SampleTree_PassesSourceAndPublishSchema() + { + var src = Path.Combine(RepoRoot, "Metadata"); + var dst = Path.Combine(Path.GetTempPath(), $"schema-ok-{Guid.NewGuid():N}"); + try + { + MetadataBuilder.Build(src, dst, contentRevision: "schema-ok"); + var flat = Path.Combine(dst, "metadata.xml"); + File.Exists(flat).Should().BeTrue(); + + var publishSchema = Path.Combine(src, SchemaValidator.PublishSchemaFileName); + SchemaValidator.ValidateFile(flat, publishSchema).Should().BeEmpty(); + + var loaded = MetadataBuilder.Load(flat); + loaded.Applications().Should().Contain(a => a.Id == "RA3BattleNet"); + loaded.Mods().Should().Contain(m => m.Id == "Corona"); + loaded.Get("SchemaVersion").Should().NotBeNullOrWhiteSpace(); + } + finally + { + if (Directory.Exists(dst)) + Directory.Delete(dst, recursive: true); + } + } + + [TestMethod] + public void Build_ApplicationMissingId_FailsSourceSchema() + { + var temp = Path.Combine(Path.GetTempPath(), $"schema-bad-{Guid.NewGuid():N}"); + var src = Path.Combine(temp, "src"); + var dst = Path.Combine(temp, "out"); + Directory.CreateDirectory(src); + try + { + // 最小可拷贝 schema + File.Copy( + Path.Combine(RepoRoot, "Metadata", SchemaValidator.SourceSchemaFileName), + Path.Combine(src, SchemaValidator.SourceSchemaFileName)); + File.Copy( + Path.Combine(RepoRoot, "Metadata", SchemaValidator.PublishSchemaFileName), + Path.Combine(src, SchemaValidator.PublishSchemaFileName)); + + File.WriteAllText(Path.Combine(src, "metadata.xml"), """ + + + + 1.0 + + +"""); + + var act = () => MetadataBuilder.Build(src, dst, contentRevision: "x"); + act.Should().Throw() + .WithMessage("*XSD*"); + Directory.Exists(dst).Should().BeFalse(); + } + finally + { + if (Directory.Exists(temp)) + Directory.Delete(temp, recursive: true); + } + } + + [TestMethod] + public void PublishSchema_RejectsManifestWithoutSource() + { + var schema = Path.Combine(RepoRoot, "Metadata", SchemaValidator.PublishSchemaFileName); + var temp = Path.Combine(Path.GetTempPath(), $"pub-bad-{Guid.NewGuid():N}.xml"); + try + { + File.WriteAllText(temp, """ + + + + +"""); + var errors = SchemaValidator.ValidateFile(temp, schema); + errors.Should().NotBeEmpty(); + string.Join(" ", errors).Should().Contain("Source"); + } + finally + { + if (File.Exists(temp)) File.Delete(temp); + } + } + + [TestMethod] + public void ConsumerParse_CatalogResolvesIdsAndResources() + { + var src = Path.Combine(RepoRoot, "Metadata"); + var dst = Path.Combine(Path.GetTempPath(), $"parse-{Guid.NewGuid():N}"); + try + { + MetadataBuilder.Build(src, dst, contentRevision: "parse"); + var doc = MetadataBuilder.Load(Path.Combine(dst, "metadata.xml")); + + var app = doc.Catalog().Application("RA3BattleNet"); + app.Should().NotBeNull(); + app!.Version.Should().Be("1.5.2.0"); + app.Packages.Should().NotBeEmpty(); + var manifestId = app.Packages[0].ManifestId; + manifestId.Should().NotBeNullOrWhiteSpace(); + + var reg = doc.GetAllElements("Manifest").First(m => m.Get("ID") == manifestId); + var rel = reg.Get("Source")!; + rel.Replace('\\', '/').Should().Contain("manifests/"); + File.Exists(Path.Combine(dst, rel.Replace('/', Path.DirectorySeparatorChar))).Should().BeTrue(); + } + finally + { + if (Directory.Exists(dst)) + Directory.Delete(dst, recursive: true); + } + } +} diff --git a/Ra3.BattleNet.Metadata.Tests/StageATests.cs b/Ra3.BattleNet.Metadata.Tests/StageATests.cs index 68b5a6e..b6bd132 100644 --- a/Ra3.BattleNet.Metadata.Tests/StageATests.cs +++ b/Ra3.BattleNet.Metadata.Tests/StageATests.cs @@ -7,11 +7,22 @@ namespace Ra3.BattleNet.Metadata.Tests; [TestClass] public class StageATests { + private static string RepoMetadataDir => + Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Metadata")); + + private static void SeedSchemas(string targetDir) + { + Directory.CreateDirectory(targetDir); + foreach (var name in new[] { SchemaValidator.SourceSchemaFileName, SchemaValidator.PublishSchemaFileName }) + { + File.Copy(Path.Combine(RepoMetadataDir, name), Path.Combine(targetDir, name), overwrite: true); + } + } + [TestMethod] public void Build_RepoSample_FlattensWithoutInclude_AndResolvesIds() { - var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); - var src = Path.Combine(repoRoot, "Metadata"); + var src = RepoMetadataDir; var dst = Path.Combine(Path.GetTempPath(), $"stage-a-{Guid.NewGuid():N}"); try @@ -71,7 +82,7 @@ public void Build_MissingMarkdown_HardFails_AndCleansOutput() var temp = Path.Combine(Path.GetTempPath(), $"stage-a-bad-{Guid.NewGuid():N}"); var src = Path.Combine(temp, "src"); var dst = Path.Combine(temp, "out"); - Directory.CreateDirectory(src); + SeedSchemas(src); try { File.WriteAllText(Path.Combine(src, "metadata.xml"), """ @@ -98,7 +109,7 @@ public void Build_BadIdReference_HardFails() var temp = Path.Combine(Path.GetTempPath(), $"stage-a-id-{Guid.NewGuid():N}"); var src = Path.Combine(temp, "src"); var dst = Path.Combine(temp, "out"); - Directory.CreateDirectory(src); + SeedSchemas(src); try { File.WriteAllText(Path.Combine(src, "note.md"), "# hello sample markdown body\n"); @@ -131,7 +142,7 @@ public void Build_BadIdReference_HardFails() public void Flatten_CircularInclude_Throws() { var temp = Path.Combine(Path.GetTempPath(), $"stage-a-circ-{Guid.NewGuid():N}"); - Directory.CreateDirectory(temp); + SeedSchemas(temp); try { File.WriteAllText(Path.Combine(temp, "a.xml"), """ @@ -174,7 +185,7 @@ public void Build_LeftoverVariable_HardFails() { var temp = Path.Combine(Path.GetTempPath(), $"stage-a-var-{Guid.NewGuid():N}"); var src = Path.Combine(temp, "src"); - Directory.CreateDirectory(src); + SeedSchemas(src); try { File.WriteAllText(Path.Combine(src, "metadata.xml"), """ @@ -198,9 +209,8 @@ public void Build_LeftoverVariable_HardFails() [TestMethod] public void Flatten_Document_HasNoIncludeNodes() { - var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); - var entry = Path.Combine(repoRoot, "Metadata", "metadata.xml"); - var doc = MetadataFlattener.Flatten(entry, Path.Combine(repoRoot, "Metadata"), "1.0", "rev"); + var entry = Path.Combine(RepoMetadataDir, "metadata.xml"); + var doc = MetadataFlattener.Flatten(entry, RepoMetadataDir, "1.0", "rev"); doc.Descendants().Any(e => e.Name.LocalName is "Include" or "Includes").Should().BeFalse(); doc.Root!.Attribute("SchemaVersion")!.Value.Should().Be("1.0"); XDocument.Parse(doc.ToString()).Root!.Attribute("ContentRevision")!.Value.Should().Be("rev"); diff --git a/Ra3.BattleNet.Metadata/MetadataBuilder.cs b/Ra3.BattleNet.Metadata/MetadataBuilder.cs index 494ad15..ca799cf 100644 --- a/Ra3.BattleNet.Metadata/MetadataBuilder.cs +++ b/Ra3.BattleNet.Metadata/MetadataBuilder.cs @@ -38,6 +38,10 @@ public static void Build(string sourceDir, string outputDir, string? schemaVersi try { + var sourceSchema = SchemaValidator.FindSchema(src, SchemaValidator.SourceSchemaFileName) + ?? throw new FileNotFoundException($"找不到源树 XSD: {SchemaValidator.SourceSchemaFileName}"); + SchemaValidator.EnsureDirectoryValid(src, sourceSchema, "源树"); + CopyAll(src, dst); var revision = string.IsNullOrWhiteSpace(contentRevision) @@ -56,6 +60,11 @@ public static void Build(string sourceDir, string outputDir, string? schemaVersi if (leftover.Count > 0) throw new InvalidOperationException("变量替换后仍有残留: " + string.Join("; ", leftover)); + var publishSchema = SchemaValidator.FindSchema(src, SchemaValidator.PublishSchemaFileName) + ?? SchemaValidator.FindSchema(dst, SchemaValidator.PublishSchemaFileName) + ?? throw new FileNotFoundException($"找不到发布 XSD: {SchemaValidator.PublishSchemaFileName}"); + SchemaValidator.EnsureValid(flatPath, publishSchema, "发布物"); + ValidateHard(flatPath, dst); } catch diff --git a/Ra3.BattleNet.Metadata/MetadataParser.cs b/Ra3.BattleNet.Metadata/MetadataParser.cs index 2194df5..6c3e591 100644 --- a/Ra3.BattleNet.Metadata/MetadataParser.cs +++ b/Ra3.BattleNet.Metadata/MetadataParser.cs @@ -70,33 +70,11 @@ public static Metadata LoadFromFileWithSchema(string path, string? schemaPath = /// public static List ValidateWithSchema(string xmlPath, string? schemaPath = null) { - var errors = new List(); - schemaPath ??= FindSchemaFile(xmlPath); if (schemaPath == null || !File.Exists(schemaPath)) - { - errors.Add($"找不到 XSD Schema 文件"); - return errors; - } - - try - { - var schemas = new XmlSchemaSet(); - using var schemaReader = XmlReader.Create(schemaPath); - schemas.Add(null, schemaReader); - - var doc = XDocument.Load(xmlPath); - doc.Validate(schemas, (_, e) => - { - errors.Add(e.Message); - }); - } - catch (Exception ex) - { - errors.Add($"Schema 验证失败: {ex.Message}"); - } + return ["找不到 XSD Schema 文件"]; - return errors; + return SchemaValidator.ValidateFile(xmlPath, schemaPath).ToList(); } /// @@ -192,9 +170,8 @@ private static XDocument LoadXDocumentWithSchema(string fullPath, string schemaP if (validationErrors.Count > 0) { - Console.WriteLine($" Schema 验证警告 ({Path.GetFileName(fullPath)}):"); - foreach (var err in validationErrors) - Console.WriteLine($" - {err}"); + throw new InvalidOperationException( + $"XSD 校验失败 ({Path.GetFileName(fullPath)}):\n- " + string.Join("\n- ", validationErrors)); } return doc; diff --git a/Ra3.BattleNet.Metadata/SchemaValidator.cs b/Ra3.BattleNet.Metadata/SchemaValidator.cs new file mode 100644 index 0000000..44a3f67 --- /dev/null +++ b/Ra3.BattleNet.Metadata/SchemaValidator.cs @@ -0,0 +1,112 @@ +using System.Xml; +using System.Xml.Linq; +using System.Xml.Schema; + +namespace Ra3.BattleNet.Metadata; + +/// +/// XSD 硬校验。源树用 MetadataSchema.xsd,发布展平物用 MetadataPublishSchema.xsd。 +/// +public static class SchemaValidator +{ + public const string SourceSchemaFileName = "MetadataSchema.xsd"; + public const string PublishSchemaFileName = "MetadataPublishSchema.xsd"; + + /// + /// 校验单个 XML 文件;返回错误列表(空表示通过)。 + /// + public static IReadOnlyList ValidateFile(string xmlPath, string schemaPath) + { + if (!File.Exists(xmlPath)) + return [$"找不到 XML: {xmlPath}"]; + if (!File.Exists(schemaPath)) + return [$"找不到 XSD: {schemaPath}"]; + + var errors = new List(); + try + { + var schemas = new XmlSchemaSet(); + using (var schemaReader = XmlReader.Create(schemaPath)) + schemas.Add(null, schemaReader); + schemas.Compile(); + + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + ValidationType = ValidationType.Schema, + Schemas = schemas, + IgnoreWhitespace = true + }; + settings.ValidationEventHandler += (_, e) => + { + errors.Add($"{Path.GetFileName(xmlPath)}: {e.Message}"); + }; + + using var reader = XmlReader.Create(xmlPath, settings); + while (reader.Read()) { } + } + catch (Exception ex) + { + errors.Add($"{Path.GetFileName(xmlPath)}: Schema 校验异常: {ex.Message}"); + } + + return errors; + } + + /// + /// 校验目录下全部 .xml(跳过 .xsd)。 + /// + public static IReadOnlyList ValidateDirectory(string directory, string schemaPath) + { + var errors = new List(); + foreach (var xml in Directory.GetFiles(directory, "*.xml", SearchOption.AllDirectories)) + { + errors.AddRange(ValidateFile(xml, schemaPath)); + } + return errors; + } + + /// + /// 在源目录或输出目录中查找 schema 文件。 + /// + public static string? FindSchema(string startDir, string fileName) + { + var dir = Path.GetFullPath(startDir); + while (true) + { + var candidate = Path.Combine(dir, fileName); + if (File.Exists(candidate)) + return candidate; + + var nested = Path.Combine(dir, "Metadata", fileName); + if (File.Exists(nested)) + return nested; + + var parent = Path.GetDirectoryName(dir); + if (parent == null || parent == dir) + break; + dir = parent; + } + + var cwd = Path.Combine(Environment.CurrentDirectory, "Metadata", fileName); + return File.Exists(cwd) ? cwd : null; + } + + /// + /// 有错误则抛 InvalidOperationException。 + /// + public static void EnsureValid(string xmlPath, string schemaPath, string context) + { + var errors = ValidateFile(xmlPath, schemaPath); + if (errors.Count > 0) + throw new InvalidOperationException($"{context} XSD 校验失败:\n- " + string.Join("\n- ", errors)); + } + + public static void EnsureDirectoryValid(string directory, string schemaPath, string context) + { + var errors = ValidateDirectory(directory, schemaPath); + if (errors.Count > 0) + throw new InvalidOperationException($"{context} XSD 校验失败:\n- " + string.Join("\n- ", errors)); + } +} From 5a009733741165f5d64c97d23b2376b88364e8cd Mon Sep 17 00:00:00 2001 From: mrbbbaixue Date: Sat, 25 Jul 2026 12:41:35 +0800 Subject: [PATCH 08/11] =?UTF-8?q?fix(metadata):=20=E5=B1=95=E5=B9=B3?= =?UTF-8?q?=E8=B5=84=E6=BA=90=20ID=20=E4=B8=BA=E8=B7=AF=E5=BE=84=E5=89=8D?= =?UTF-8?q?=E7=BC=80:localId=20=E5=B9=B6=E6=94=B9=E5=86=99=E5=BC=95?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同名资源跨 Mod 通过定义文件路径前缀隔离;作用域内短名解析后写入发布物。 --- AGENTS.md | 3 +- README.md | 25 +++- Ra3.BattleNet.Metadata.Tests/IdScopeTests.cs | 92 ++++++++++++ Ra3.BattleNet.Metadata.Tests/StageATests.cs | 31 ++-- Ra3.BattleNet.Metadata/MetadataFlattener.cs | 141 ++++++++++++++++--- 5 files changed, 258 insertions(+), 34 deletions(-) create mode 100644 Ra3.BattleNet.Metadata.Tests/IdScopeTests.cs diff --git a/AGENTS.md b/AGENTS.md index 9ac9069..24d3ab0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,10 +42,11 @@ bash build.sh --webp 1. 只消费**展平** `metadata.xml` + 相对资源,不自己展开 Include。 2. `MetadataBuilder.Load(path|url)` → `Catalog` / `Applications` / `Mods`。 -3. Package.Manifest / Icon / Changelog / Content 等是 **ID**,不是路径。 +3. Package.Manifest / Icon / Changelog / Content 等是 **限定 ID**(`路径前缀:localId`),不是路径、也不是源树短名。 4. 登记节点 `Manifest[@Source]` 指向叶子清单 XML(含 File 表)。 5. 资源 URL = `BaseUrl` + `Source`;本地 = `BasePath` + `Source`。 6. 调试:本地 `Build` → 同一 `Load`;勿写死 `.png`。 +7. 同名资源靠展平前缀隔离;勿在客户端再实现 public/private Include 语义。 完整步骤与属性表:README「使用方如何解析 Metadata」「模块属性清单」。 diff --git a/README.md b/README.md index cbcd35c..a99ad3e 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ 3. 读取 `SchemaVersion`:不兼容则提示升级客户端。 4. 读取 `ContentRevision`:与本地缓存比较,决定是否整树刷新。 5. `doc.Catalog().Application("RA3BattleNet")` / `doc.Mods()` 取业务实体。 -6. 对 Package 的 `Manifest` 文本、Icon、Changelog、Post Content 等:**先当 ID**,在展平树中找同 ID 的登记节点。 +6. 对 Package 的 `Manifest` 文本、Icon、Changelog、Post Content 等:**先当 ID**(发布物中为**已限定**的全局 ID),在展平树中找同 ID 的登记节点。 7. 用登记节点的 `Source` 或 `Url` 取资源: - 本地文件:`Path.Combine(basePath, Source)` - 远端:`new Uri(new Uri(BaseUrl.TrimEnd('/') + "/"), Source)` @@ -163,11 +163,28 @@ MetadataBuilder.Build(sourceDir, outputDir, schemaVersion: "1.0", contentRevisio | `Controls/*` | 控件样式(如 `LaunchButton/BorderBrush`、`Label/FontSize`) | | `Background` | `@Random`;子 `Image` 文本为 Image **ID**(不是登记节点) | +### 资源 ID 限定(public/private 与局部同名) + +源树里可写短 ID(如 `shared-icon`),不同 Mod 可重名。展平时按**定义文件**生成全局唯一 ID: + +```text +{路径前缀}:{localId} +``` + +- **路径前缀**:定义该登记节点的 XML 相对 `Metadata/` 根、去掉 `.xml` 的路径,`/` 分隔。 + 例:`mods/corona/corona.xml` → 前缀 `mods/corona/corona` + → `mods/corona/corona:corona-icon-64px` +- **引用**(Icon / Logo / Background 内 Image 文本 / Changelog / Content / Package.Manifest)在同一作用域(本文件 + 其 Include 展开结果)内按短名解析,并改写为限定 ID。 +- `Include Type=public|private` 仍控制源树合并范围;**发布物不再保留 Type**,靠限定 ID 隔离同名资源。 +- Application / Mod 的 `@ID`(业务实体 ID)**不**加路径前缀,须全局唯一。 + +Desktop:始终用发布物中的**完整限定 ID** 查表;不要假设短名在全树唯一。 + ### 登记节点 `Image` | 成员 | 说明 | |---|---| -| `@ID` | 全局 ID | +| `@ID` | 限定全局 ID:`{路径前缀}:{localId}` | | `@Source` | 相对路径(本地资源) | | `@Url` | 外链(可与 Source 二选一) | @@ -175,7 +192,7 @@ MetadataBuilder.Build(sourceDir, outputDir, schemaVersion: "1.0", contentRevisio | 成员 | 说明 | |---|---| -| `@ID` | 全局 ID | +| `@ID` | 限定全局 ID | | `@Source` | 相对路径(`.md`) | | `@Hash` | 资源 MD5(构建时替换) | @@ -183,7 +200,7 @@ MetadataBuilder.Build(sourceDir, outputDir, schemaVersion: "1.0", contentRevisio | 成员 | 说明 | |---|---| -| `@ID` | 全局 ID | +| `@ID` | 限定全局 ID | | `@Source` | 指向叶子清单 XML 的相对路径 | ### 叶子 Manifest 资源文件(独立 XML,Updater 生成) diff --git a/Ra3.BattleNet.Metadata.Tests/IdScopeTests.cs b/Ra3.BattleNet.Metadata.Tests/IdScopeTests.cs new file mode 100644 index 0000000..f59ee1f --- /dev/null +++ b/Ra3.BattleNet.Metadata.Tests/IdScopeTests.cs @@ -0,0 +1,92 @@ +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Ra3.BattleNet.Metadata.Tests; + +[TestClass] +public class IdScopeTests +{ + private static string RepoMetadataDir => + Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Metadata")); + + private static void SeedSchemas(string dir) + { + Directory.CreateDirectory(dir); + foreach (var name in new[] { SchemaValidator.SourceSchemaFileName, SchemaValidator.PublishSchemaFileName }) + File.Copy(Path.Combine(RepoMetadataDir, name), Path.Combine(dir, name), overwrite: true); + } + + [TestMethod] + public void QualifyId_UsesPathPrefixColonLocal() + { + MetadataFlattener.QualifyId("mods/corona/corona", "icon").Should().Be("mods/corona/corona:icon"); + MetadataFlattener.LocalId("mods/corona/corona:icon").Should().Be("icon"); + MetadataFlattener.PathPrefix( + Path.Combine(RepoMetadataDir, "mods", "corona", "corona.xml"), + RepoMetadataDir) + .Replace('\\', '/').Should().Be("mods/corona/corona"); + } + + [TestMethod] + public void Build_SameLocalIdInDifferentMods_BothSurviveAsQualified() + { + var temp = Path.Combine(Path.GetTempPath(), $"id-scope-{Guid.NewGuid():N}"); + var src = Path.Combine(temp, "src"); + SeedSchemas(src); + Directory.CreateDirectory(Path.Combine(src, "a")); + Directory.CreateDirectory(Path.Combine(src, "b")); + try + { + File.WriteAllText(Path.Combine(src, "a", "mod.xml"), """ + + + + + shared-icon + + +"""); + File.WriteAllText(Path.Combine(src, "b", "mod.xml"), """ + + + + + shared-icon + + +"""); + // minimal png + var png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="); + File.WriteAllBytes(Path.Combine(src, "a", "icon.png"), png); + File.WriteAllBytes(Path.Combine(src, "b", "icon.png"), png); + + File.WriteAllText(Path.Combine(src, "metadata.xml"), """ + + + + + + + +"""); + + var dst = Path.Combine(temp, "out"); + MetadataBuilder.Build(src, dst, contentRevision: "scope"); + var doc = MetadataBuilder.Load(Path.Combine(dst, "metadata.xml")); + + var images = doc.GetAllElements("Image").Where(i => MetadataFlattener.LocalId(i.Get("ID")!) == "shared-icon").ToList(); + images.Should().HaveCount(2); + images.Select(i => i.Get("ID")).Should().OnlyHaveUniqueItems(); + + var modA = doc.Mods().Single(m => m.Id == "ModA"); + var modB = doc.Mods().Single(m => m.Id == "ModB"); + modA.Icon.Should().Be("a/mod:shared-icon"); + modB.Icon.Should().Be("b/mod:shared-icon"); + } + finally + { + if (Directory.Exists(temp)) + Directory.Delete(temp, recursive: true); + } + } +} diff --git a/Ra3.BattleNet.Metadata.Tests/StageATests.cs b/Ra3.BattleNet.Metadata.Tests/StageATests.cs index b6bd132..bffbd07 100644 --- a/Ra3.BattleNet.Metadata.Tests/StageATests.cs +++ b/Ra3.BattleNet.Metadata.Tests/StageATests.cs @@ -45,29 +45,38 @@ public void Build_RepoSample_FlattensWithoutInclude_AndResolvesIds() loaded.Applications().Should().Contain(a => a.Id == "RA3BattleNet"); loaded.Mods().Should().Contain(m => m.Id == "Corona"); - var manifest = loaded.GetAllElements("Manifest").First(m => m.Get("ID") == "manifest-1.5.2.0"); + // 发布 ID = {路径前缀}:{localId} + var registries = loaded.GetAllElements("Manifest") + .Where(m => !string.IsNullOrEmpty(m.Get("ID"))).ToList(); + var manifest = registries + .First(m => MetadataFlattener.LocalId(m.Get("ID")) == "manifest-1.5.2.0"); + manifest.Get("ID")!.Should().Contain(":"); + manifest.Get("ID")!.Should().EndWith(":manifest-1.5.2.0"); var source = manifest.Get("Source"); source.Should().NotBeNullOrWhiteSpace(); - // 必须指向叶子清单资源,不能是中间聚合文件(apps.xml / manifests.xml) source!.Replace('\\', '/').Should().EndWith("manifests/1.5.2.0.xml"); source.Should().NotContain("apps.xml"); - source.Should().NotContain("manifests/manifests.xml"); var manifestPath = Path.Combine(dst, source.Replace('/', Path.DirectorySeparatorChar)); File.Exists(manifestPath).Should().BeTrue(); - var manifestXml = File.ReadAllText(manifestPath); - manifestXml.Should().Contain("manifest-1.5.2.0"); - manifestXml.Should().Contain(" m.Get("ID") == "manifest-3229"); + var coronaManifest = loaded.GetAllElements("Manifest") + .First(m => MetadataFlattener.LocalId(m.Get("ID")!) == "manifest-3229"); coronaManifest.Get("Source")!.Replace('\\', '/').Should().EndWith("manifests/3.229.xml"); - var coronaPath = Path.Combine(dst, coronaManifest.Get("Source")!.Replace('/', Path.DirectorySeparatorChar)); - File.ReadAllText(coronaPath).Should().Contain("corona_3.229.lyi"); - var md = loaded.GetAllElements("Markdown").First(m => m.Get("ID") == "changelog-zh-1.5.2.0"); + var md = loaded.GetAllElements("Markdown") + .First(m => MetadataFlattener.LocalId(m.Get("ID")!) == "changelog-zh-1.5.2.0"); + md.Get("ID")!.Should().Contain("changelogs"); var mdSource = md.Get("Source"); mdSource!.Replace('\\', '/').Should().EndWith("changelogs/zh-1.5.2.0.md"); File.Exists(Path.Combine(dst, mdSource.Replace('/', Path.DirectorySeparatorChar))).Should().BeTrue(); + + // 引用已改写为限定 ID + var app = loaded.Applications().Single(a => a.Id == "RA3BattleNet"); + app.Packages[0].ManifestId.Should().Be(manifest.Get("ID")); + var corona = loaded.Mods().Single(m => m.Id == "Corona"); + corona.Icon.Should().EndWith(":corona-icon-64px"); + corona.Icon.Should().Contain(":"); } finally { diff --git a/Ra3.BattleNet.Metadata/MetadataFlattener.cs b/Ra3.BattleNet.Metadata/MetadataFlattener.cs index 40a8a33..cf61ac2 100644 --- a/Ra3.BattleNet.Metadata/MetadataFlattener.cs +++ b/Ra3.BattleNet.Metadata/MetadataFlattener.cs @@ -4,12 +4,12 @@ namespace Ra3.BattleNet.Metadata; /// /// 将带 Include 的源 XML 展平为单一数据树(不内联资源文件正文)。 +/// 资源登记 ID 展平为全局唯一:{路径前缀}:{localId},路径前缀为定义文件相对源根、去 .xml 的路径。 /// public static class MetadataFlattener { /// - /// 从入口 metadata.xml 展平,Source 路径改写为相对 sourceRoot。 - /// Manifest 在独立资源文件中时只保留 ID+Source,不把 File 列表并入主树。 + /// 从入口 metadata.xml 展平,Source 路径改写为相对 sourceRoot,ID 按定义文件加前缀。 /// public static XDocument Flatten(string entryPath, string sourceRoot, string schemaVersion, string contentRevision) { @@ -21,13 +21,42 @@ public static XDocument Flatten(string entryPath, string sourceRoot, string sche root.SetAttributeValue("SchemaVersion", schemaVersion); root.SetAttributeValue("ContentRevision", contentRevision); - // 去掉残留 Includes / Include / Module root.Descendants().Where(e => e.Name.LocalName is "Includes" or "Include" or "Module").ToList() .ForEach(e => e.Remove()); return new XDocument(new XDeclaration("1.0", "utf-8", null), root); } + /// 由定义文件绝对路径生成路径前缀(相对 sourceRoot、/ 分隔、无扩展名)。 + public static string PathPrefix(string absoluteFilePath, string sourceRoot) + { + var rel = ToRootRelative(Path.GetFullPath(absoluteFilePath), Path.GetFullPath(sourceRoot)); + if (rel.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) + rel = rel[..^4]; + return rel.Replace('\\', '/'); + } + + /// 将 localId 限定为 {prefix}:{localId};已含 ':' 则原样返回。 + public static string QualifyId(string pathPrefix, string localId) + { + if (string.IsNullOrWhiteSpace(localId)) + throw new InvalidOperationException("ID 不能为空"); + if (localId.Contains(':', StringComparison.Ordinal)) + return localId; + if (string.IsNullOrWhiteSpace(pathPrefix)) + return localId; + return $"{pathPrefix}:{localId}"; + } + + /// 取限定 ID 的局部名(最后一个 ':' 之后);无冒号则整串。 + public static string LocalId(string? id) + { + if (string.IsNullOrEmpty(id)) + return string.Empty; + var i = id.LastIndexOf(':'); + return i < 0 ? id : id[(i + 1)..]; + } + private static XElement LoadAndExpand(string filePath, string sourceRoot, HashSet processing) { var full = Path.GetFullPath(filePath); @@ -44,15 +73,20 @@ private static XElement LoadAndExpand(string filePath, string sourceRoot, HashSe var baseDir = Path.GetDirectoryName(full) ?? throw new InvalidOperationException($"无法确定目录: {full}"); + var prefix = PathPrefix(full, sourceRoot); ExpandElement(root, baseDir, sourceRoot, processing); + RewriteSources(root, baseDir, sourceRoot); + QualifyRegistrationIds(root, prefix); + var scope = BuildScopeMap(root); + RewriteIdReferences(root, scope); + processing.Remove(full); return root; } private static void ExpandElement(XElement element, string baseDir, string sourceRoot, HashSet processing) { - // 先处理 Includes 容器与直接 Include var includeHosts = element.Elements() .Where(e => e.Name.LocalName is "Includes" or "Include" or "Module") .ToList(); @@ -76,9 +110,6 @@ private static void ExpandElement(XElement element, string baseDir, string sourc } } - // 改写本层 Image/Markdown 的 Source 为相对 sourceRoot - RewriteSources(element, baseDir, sourceRoot); - foreach (var child in element.Elements().ToList()) { if (child.Name.LocalName is "Includes" or "Include" or "Module") @@ -96,8 +127,6 @@ private static void InsertInclude(XElement includeEl, string baseDir, string sou var target = Path.GetFullPath(Path.Combine(baseDir, rel.Replace('\\', '/'))); var includedRoot = LoadAndExpand(target, sourceRoot, processing); - // Include 若在 容器内,插入点为容器本身(作为 Metadata 子节点的兄弟), - // 避免内容落在 Includes 内随后被整段删除。 var anchor = includeEl.Parent is { Name.LocalName: "Includes" } host ? host : includeEl; @@ -109,42 +138,120 @@ private static void InsertInclude(XElement includeEl, string baseDir, string sou if (child.Name.LocalName == "Manifest") { - // 资源外置:主树只保留 ID + Source(指向含 File 表的叶子 manifest XML) var id = child.Attribute("ID")?.Value; if (string.IsNullOrWhiteSpace(id)) throw new InvalidOperationException($"Manifest 缺少 ID: {target}"); - // 已是下层展开产生的 stub(带 Source)则原样上浮,禁止用中间 Include 文件覆盖路径 var existingSource = child.Attribute("Source")?.Value; if (!string.IsNullOrWhiteSpace(existingSource)) { + // 已是下层 stub,ID 应已限定 anchor.AddBeforeSelf(new XElement(child)); continue; } var sourceRel = ToRootRelative(target, sourceRoot); + var qualified = id.Contains(':', StringComparison.Ordinal) + ? id + : QualifyId(PathPrefix(target, sourceRoot), id); var stub = new XElement("Manifest", - new XAttribute("ID", id), + new XAttribute("ID", qualified), new XAttribute("Source", sourceRel)); anchor.AddBeforeSelf(stub); continue; } - // Markdown/Image/Application/Mod 等:深拷贝 - var clone = new XElement(child); - anchor.AddBeforeSelf(clone); + anchor.AddBeforeSelf(new XElement(child)); } } + /// 仅限定本文件定义、尚未带 ':' 的登记节点 ID(Image/Markdown/Manifest)。 + private static void QualifyRegistrationIds(XElement root, string pathPrefix) + { + foreach (var el in root.DescendantsAndSelf() + .Where(e => e.Name.LocalName is "Image" or "Markdown" or "Manifest")) + { + var idAttr = el.Attribute("ID"); + if (idAttr == null || string.IsNullOrWhiteSpace(idAttr.Value)) + continue; + // Background 内无 ID 的 Image 引用跳过;登记节点必有 ID + if (!idAttr.Value.Contains(':', StringComparison.Ordinal)) + idAttr.Value = QualifyId(pathPrefix, idAttr.Value); + } + } + + private static Dictionary BuildScopeMap(XElement root) + { + // short localId → qualifiedId;同文件作用域内短名冲突则失败 + var map = new Dictionary(StringComparer.Ordinal); + foreach (var el in root.DescendantsAndSelf() + .Where(e => e.Name.LocalName is "Image" or "Markdown" or "Manifest")) + { + var id = el.Attribute("ID")?.Value; + if (string.IsNullOrWhiteSpace(id)) + continue; + + // 完整限定 ID 始终可查 + map[id] = id; + + var local = LocalId(id); + if (map.TryGetValue(local, out var existing) && + !string.Equals(existing, id, StringComparison.Ordinal)) + { + // 跨 Include 同短名:短名不可再解析(应在各自文件内已改写引用),移出短名键 + map.Remove(local); + continue; + } + map[local] = id; + } + return map; + } + + private static void RewriteIdReferences(XElement root, Dictionary scope) + { + foreach (var el in root.DescendantsAndSelf()) + { + switch (el.Name.LocalName) + { + case "Icon": + case "Manifest" when el.Attribute("ID") == null && !el.HasElements: + case "Changelog": + case "Content": + RewriteTextRef(el, scope); + break; + case "Logo": + RewriteTextRef(el, scope); + break; + case "Image" when el.Attribute("ID") == null: + // Background 等处的 ID 文本引用 + RewriteTextRef(el, scope); + break; + } + } + } + + private static void RewriteTextRef(XElement el, Dictionary scope) + { + var text = el.Value?.Trim(); + if (string.IsNullOrEmpty(text)) + return; + if (scope.TryGetValue(text, out var qualified)) + el.Value = qualified; + // 找不到:留给 ValidateHard 报断引用(可能是笔误) + } + private static void RewriteSources(XElement element, string baseDir, string sourceRoot) { - foreach (var el in element.Elements().Where(e => e.Name.LocalName is "Image" or "Markdown")) + foreach (var el in element.DescendantsAndSelf().Where(e => e.Name.LocalName is "Image" or "Markdown")) { var sourceAttr = el.Attribute("Source"); if (sourceAttr == null || string.IsNullOrWhiteSpace(sourceAttr.Value)) continue; if (!string.IsNullOrWhiteSpace(el.Attribute("Url")?.Value)) continue; + // 仅处理登记节点(有 ID) + if (el.Attribute("ID") == null) + continue; var raw = sourceAttr.Value.Replace('\\', '/'); var fromBase = Path.GetFullPath(Path.Combine(baseDir, raw)); @@ -156,9 +263,7 @@ private static void RewriteSources(XElement element, string baseDir, string sour var fromRoot = Path.GetFullPath(Path.Combine(sourceRoot, raw)); if (File.Exists(fromRoot)) - { sourceAttr.Value = ToRootRelative(fromRoot, sourceRoot); - } } } From 9c3e856f3d95cfe3a23ba64416d50b672f02ee6a Mon Sep 17 00:00:00 2001 From: mrbbbaixue Date: Sat, 25 Jul 2026 12:44:30 +0800 Subject: [PATCH 09/11] =?UTF-8?q?feat(imaging):=20WebP=20=E5=90=8E?= =?UTF-8?q?=E9=87=8D=E7=AE=97=20Hash=EF=BC=8C=E7=BC=96=E8=AF=91=E6=9C=9F?= =?UTF-8?q?=20--webp=20=E4=B8=B2=E8=81=94=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imaging 独立 CLI(不进主 NuGet);核心 build --webp 进程调用; 主包仅 managed 解析/展平,Desktop 只引用主包。 --- AGENTS.md | 4 +- Metadata/MetadataPublishSchema.xsd | 1 + Metadata/MetadataSchema.xsd | 1 + README.md | 21 ++-- Ra3.BattleNet.Metadata.Imaging/Program.cs | 106 +++++++----------- .../Ra3.BattleNet.Metadata.Imaging.csproj | 2 + .../WebPPipeline.cs | 100 +++++++++++++++++ Ra3.BattleNet.Metadata.Tests/ImagingTests.cs | 56 +++++++++ .../Ra3.BattleNet.Metadata.Tests.csproj | 1 + Ra3.BattleNet.Metadata/ImagingInvoker.cs | 81 +++++++++++++ Ra3.BattleNet.Metadata/Program.cs | 32 ++++-- .../Ra3.BattleNet.Metadata.csproj | 6 + build.sh | 20 +--- package.json | 4 +- 14 files changed, 335 insertions(+), 100 deletions(-) create mode 100644 Ra3.BattleNet.Metadata.Imaging/WebPPipeline.cs create mode 100644 Ra3.BattleNet.Metadata.Tests/ImagingTests.cs create mode 100644 Ra3.BattleNet.Metadata/ImagingInvoker.cs diff --git a/AGENTS.md b/AGENTS.md index 24d3ab0..8e011f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,8 @@ ## 仓库职责 - **源数据**:`Metadata/**/*.xml` + 图片/Markdown/叶子 Manifest -- **核心库/CLI**:`Ra3.BattleNet.Metadata` — 展平、变量、XSD、语义校验、查询 API(**纯 managed,无 SkiaSharp**) -- **Imaging**:`Ra3.BattleNet.Metadata.Imaging` — 仅发布路径 WebP +- **核心库/CLI**:`Ra3.BattleNet.Metadata` — 展平、变量、XSD、语义校验、查询 API(**纯 managed,无 SkiaSharp**);**Desktop NuGet 只引此包** +- **Imaging CLI**:`Ra3.BattleNet.Metadata.Imaging` — 编译期工具,WebP + 更新 Hash;**不进主 NuGet**;由 `build --webp` 进程调用 - **发布**:Cloudflare Pages 静态托管 `Output/` ## 常用命令 diff --git a/Metadata/MetadataPublishSchema.xsd b/Metadata/MetadataPublishSchema.xsd index e402326..f664352 100644 --- a/Metadata/MetadataPublishSchema.xsd +++ b/Metadata/MetadataPublishSchema.xsd @@ -138,6 +138,7 @@ + diff --git a/Metadata/MetadataSchema.xsd b/Metadata/MetadataSchema.xsd index ffa715d..7c05928 100644 --- a/Metadata/MetadataSchema.xsd +++ b/Metadata/MetadataSchema.xsd @@ -169,6 +169,7 @@ + diff --git a/README.md b/README.md index a99ad3e..c0ca49e 100644 --- a/README.md +++ b/README.md @@ -223,18 +223,23 @@ Desktop:始终用发布物中的**完整限定 ID** 查表;不要假设短 | 路径 | 做什么 | 何时用 | |---|---|---| -| **核心构建** | XSD + 展平 + 变量 + 资源复制 | 本地、测试、Desktop 调试编译 | -| **Imaging** | WebP + 改写 Image Source | **仅发布**(显式) | +| **核心构建** | XSD + 展平 + 变量 + 资源复制 | 本地、测试、Desktop 调试;**NuGet 主包** | +| **Imaging CLI** | WebP + 改写 Source + **重算 Hash** | 仓库编译/发布工具,**不进主 NuGet** | ```bash -npm run build # 核心 -npm run build:webp # 仅 Imaging(需已有 Output) -npm run build:release # 核心 + Imaging -bash build.sh --webp # CF:装 dotnet + 核心 + Imaging +# 仅核心(Desktop 本地调试同此 API:MetadataBuilder.Build) +npm run build +dotnet run --project Ra3.BattleNet.Metadata -- build --src=./Metadata --dst=./Output + +# 核心 + Imaging(一条命令,--webp 时进程调用 Imaging CLI) +npm run build:release +dotnet run --project Ra3.BattleNet.Metadata -- build --webp --src=./Metadata --dst=./Output +bash build.sh --webp ``` -`wrangler.toml`:`command = "bash build.sh --webp"`。 -**无** `RUN_STAGE_B`:默认不转 WebP。 +- 主 NuGet:`Ra3.BattleNet.Metadata`(解析 + 核心 Build,无 SkiaSharp) +- Imaging:`Ra3.BattleNet.Metadata.Imaging` 独立 CLI;转 WebP 后写 `Image/@Hash` 为 webp 文件 MD5 +- `wrangler.toml`:`bash build.sh --webp` --- diff --git a/Ra3.BattleNet.Metadata.Imaging/Program.cs b/Ra3.BattleNet.Metadata.Imaging/Program.cs index 8f79c45..f7f3583 100644 --- a/Ra3.BattleNet.Metadata.Imaging/Program.cs +++ b/Ra3.BattleNet.Metadata.Imaging/Program.cs @@ -1,87 +1,63 @@ -using System.Xml.Linq; -using SkiaSharp; - namespace Ra3.BattleNet.Metadata.Imaging; /// -/// 发布可选步骤:将 Output 中本地图片转为 WebP 并改写 metadata.xml 的 Image Source。 -/// 主库不引用本项目;仅 build.sh --webp / npm run build:webp 使用。 +/// Imaging CLI:编译/发布阶段可选调用,不进入 Desktop 主 NuGet。 /// internal static class Program { - private static readonly HashSet Supported = new(StringComparer.OrdinalIgnoreCase) - { - ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".tif" - }; - private static int Main(string[] args) { - var outputDir = args.FirstOrDefault(a => a.StartsWith("--dst=", StringComparison.Ordinal)) - ?["--dst=".Length..] ?? "./Output"; - outputDir = Path.GetFullPath(outputDir); - if (!Directory.Exists(outputDir)) + if (args.Any(a => a is "--help" or "-h")) { - Console.Error.WriteLine($"输出目录不存在: {outputDir}"); - return 1; + PrintHelp(); + return 0; } - var flat = Path.Combine(outputDir, "metadata.xml"); - if (!File.Exists(flat)) + // 兼容: webp --dst=... 或 --dst=...(默认 webp) + var command = "webp"; + var dst = "./Output"; + foreach (var arg in args) { - Console.Error.WriteLine($"找不到展平文件: {flat}"); - return 1; - } - - var doc = XDocument.Load(flat); - var converted = 0; - foreach (var image in doc.Descendants("Image")) - { - var sourceAttr = image.Attribute("Source"); - if (sourceAttr == null || string.IsNullOrWhiteSpace(sourceAttr.Value)) - continue; - if (!string.IsNullOrWhiteSpace(image.Attribute("Url")?.Value)) - continue; - - var sourcePath = Path.Combine(outputDir, sourceAttr.Value.Replace('\\', '/')); - if (!File.Exists(sourcePath)) - continue; - var ext = Path.GetExtension(sourcePath); - if (!Supported.Contains(ext)) - continue; - - var webpPath = Path.ChangeExtension(sourcePath, ".webp"); - if (!ConvertToWebP(sourcePath, webpPath)) - continue; - - sourceAttr.Value = Path.ChangeExtension(sourceAttr.Value, ".webp")!.Replace('\\', '/'); - converted++; - Console.WriteLine($" WebP: {sourceAttr.Value}"); + if (arg is "webp" or "images") + command = "webp"; + else if (arg.StartsWith("--dst=", StringComparison.Ordinal)) + dst = arg["--dst=".Length..]; } - doc.Save(flat); - Console.WriteLine($"Imaging 完成,转换 {converted} 张图片"); - return 0; - } - - private static bool ConvertToWebP(string sourcePath, string webpPath) - { try { - using var input = File.OpenRead(sourcePath); - using var codec = SKCodec.Create(input); - if (codec == null) return false; - using var bitmap = SKBitmap.Decode(codec); - if (bitmap == null) return false; - using var image = SKImage.FromBitmap(bitmap); - using var data = image.Encode(SKEncodedImageFormat.Webp, 90); - using var output = File.Open(webpPath, FileMode.Create, FileAccess.Write); - data.SaveTo(output); - return true; + if (command != "webp") + { + Console.Error.WriteLine($"未知命令: {command}"); + return 1; + } + + Console.WriteLine($"Imaging webp → {Path.GetFullPath(dst)}"); + var n = WebPPipeline.Process(dst); + Console.WriteLine($"Imaging 完成,转换 {n} 张图片(已更新 Source 与 Hash)"); + return 0; } catch (Exception ex) { - Console.Error.WriteLine($" 转换失败 {sourcePath}: {ex.Message}"); - return false; + Console.ForegroundColor = ConsoleColor.Red; + Console.Error.WriteLine($"错误: {ex.Message}"); + Console.ResetColor(); + return 1; } } + + private static void PrintHelp() + { + Console.WriteLine(""" + Ra3.BattleNet.Metadata.Imaging — 发布期图片 CLI(非 Desktop NuGet) + + 用法: + dotnet run --project Ra3.BattleNet.Metadata.Imaging -- webp --dst=./Output + + 行为: + 将 Output 中 Image 本地图转为 WebP,改写 Source,并重算 Hash(MD5) + + 由核心 CLI 在 --webp 时自动调用,也可单独执行。 + """); + } } diff --git a/Ra3.BattleNet.Metadata.Imaging/Ra3.BattleNet.Metadata.Imaging.csproj b/Ra3.BattleNet.Metadata.Imaging/Ra3.BattleNet.Metadata.Imaging.csproj index 8de62ce..d1dc9b4 100644 --- a/Ra3.BattleNet.Metadata.Imaging/Ra3.BattleNet.Metadata.Imaging.csproj +++ b/Ra3.BattleNet.Metadata.Imaging/Ra3.BattleNet.Metadata.Imaging.csproj @@ -6,6 +6,8 @@ enable enable Ra3.BattleNet.Metadata.Imaging + + false diff --git a/Ra3.BattleNet.Metadata.Imaging/WebPPipeline.cs b/Ra3.BattleNet.Metadata.Imaging/WebPPipeline.cs new file mode 100644 index 0000000..7891edc --- /dev/null +++ b/Ra3.BattleNet.Metadata.Imaging/WebPPipeline.cs @@ -0,0 +1,100 @@ +using System.Security.Cryptography; +using System.Xml.Linq; +using SkiaSharp; + +namespace Ra3.BattleNet.Metadata.Imaging; + +/// +/// 对已展平 Output 做 WebP 转码:改写 Image/@Source,并按新文件重算 Image/@Hash。 +/// +public static class WebPPipeline +{ + private static readonly HashSet Supported = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".tif" + }; + + /// + /// 处理 outputDir 中的 metadata.xml 与本地图片。 + /// + /// 成功转换的图片数量。 + public static int Process(string outputDir) + { + outputDir = Path.GetFullPath(outputDir); + var flat = Path.Combine(outputDir, "metadata.xml"); + if (!Directory.Exists(outputDir)) + throw new DirectoryNotFoundException($"输出目录不存在: {outputDir}"); + if (!File.Exists(flat)) + throw new FileNotFoundException($"找不到展平文件: {flat}"); + + var doc = XDocument.Load(flat); + var converted = 0; + foreach (var image in doc.Descendants("Image")) + { + // 仅登记节点(带 ID);Background 文本引用无 Source 属性 + if (image.Attribute("ID") == null) + continue; + + var sourceAttr = image.Attribute("Source"); + if (sourceAttr == null || string.IsNullOrWhiteSpace(sourceAttr.Value)) + continue; + if (!string.IsNullOrWhiteSpace(image.Attribute("Url")?.Value)) + continue; + + var sourcePath = Path.Combine(outputDir, sourceAttr.Value.Replace('\\', '/')); + if (!File.Exists(sourcePath)) + continue; + + var ext = Path.GetExtension(sourcePath); + if (!Supported.Contains(ext)) + { + // 已是 webp 等:仍刷新 Hash + if (ext.Equals(".webp", StringComparison.OrdinalIgnoreCase)) + image.SetAttributeValue("Hash", ComputeMd5(sourcePath)); + continue; + } + + var webpPath = Path.ChangeExtension(sourcePath, ".webp")!; + if (!ConvertToWebP(sourcePath, webpPath)) + continue; + + var newRel = Path.ChangeExtension(sourceAttr.Value, ".webp")!.Replace('\\', '/'); + sourceAttr.Value = newRel; + image.SetAttributeValue("Hash", ComputeMd5(webpPath)); + converted++; + } + + doc.Save(flat); + return converted; + } + + public static string ComputeMd5(string filePath) + { + using var md5 = MD5.Create(); + using var stream = File.OpenRead(filePath); + var hash = md5.ComputeHash(stream); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static bool ConvertToWebP(string sourcePath, string webpPath) + { + try + { + using var input = File.OpenRead(sourcePath); + using var codec = SKCodec.Create(input); + if (codec == null) return false; + using var bitmap = SKBitmap.Decode(codec); + if (bitmap == null) return false; + using var image = SKImage.FromBitmap(bitmap); + using var data = image.Encode(SKEncodedImageFormat.Webp, 90); + using var output = File.Open(webpPath, FileMode.Create, FileAccess.Write); + data.SaveTo(output); + return true; + } + catch (Exception ex) + { + Console.Error.WriteLine($" 转换失败 {sourcePath}: {ex.Message}"); + return false; + } + } +} diff --git a/Ra3.BattleNet.Metadata.Tests/ImagingTests.cs b/Ra3.BattleNet.Metadata.Tests/ImagingTests.cs new file mode 100644 index 0000000..533ec68 --- /dev/null +++ b/Ra3.BattleNet.Metadata.Tests/ImagingTests.cs @@ -0,0 +1,56 @@ +using System.Xml.Linq; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Ra3.BattleNet.Metadata.Imaging; + +namespace Ra3.BattleNet.Metadata.Tests; + +[TestClass] +public class ImagingTests +{ + private static string RepoMetadataDir => + Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Metadata")); + + [TestMethod] + public void WebPPipeline_UpdatesSourceAndHash() + { + var dst = Path.Combine(Path.GetTempPath(), $"webp-{Guid.NewGuid():N}"); + try + { + MetadataBuilder.Build(RepoMetadataDir, dst, contentRevision: "webp-test"); + var flat = Path.Combine(dst, "metadata.xml"); + var before = XDocument.Load(flat); + var iconBefore = before.Descendants("Image") + .First(e => e.Attribute("ID")?.Value?.EndsWith(":corona-icon-64px", StringComparison.Ordinal) == true); + var srcBefore = iconBefore.Attribute("Source")!.Value; + srcBefore.Should().EndWith(".png"); + + var n = WebPPipeline.Process(dst); + n.Should().BeGreaterThan(0); + + var after = XDocument.Load(flat); + var icon = after.Descendants("Image") + .First(e => e.Attribute("ID")?.Value?.EndsWith(":corona-icon-64px", StringComparison.Ordinal) == true); + icon.Attribute("Source")!.Value.Replace('\\', '/').Should().EndWith(".webp"); + var hash = icon.Attribute("Hash")?.Value; + hash.Should().NotBeNullOrWhiteSpace(); + hash.Should().HaveLength(32); + + var webpPath = Path.Combine(dst, icon.Attribute("Source")!.Value.Replace('/', Path.DirectorySeparatorChar)); + File.Exists(webpPath).Should().BeTrue(); + WebPPipeline.ComputeMd5(webpPath).Should().Be(hash); + } + finally + { + if (Directory.Exists(dst)) + Directory.Delete(dst, recursive: true); + } + } + + [TestMethod] + public void ImagingInvoker_FindsProjectInRepo() + { + ImagingInvoker.FindImagingProject().Should().NotBeNullOrEmpty(); + File.Exists(ImagingInvoker.FindImagingProject()!).Should().BeTrue(); + } +} diff --git a/Ra3.BattleNet.Metadata.Tests/Ra3.BattleNet.Metadata.Tests.csproj b/Ra3.BattleNet.Metadata.Tests/Ra3.BattleNet.Metadata.Tests.csproj index 8045be0..964b4bb 100644 --- a/Ra3.BattleNet.Metadata.Tests/Ra3.BattleNet.Metadata.Tests.csproj +++ b/Ra3.BattleNet.Metadata.Tests/Ra3.BattleNet.Metadata.Tests.csproj @@ -17,6 +17,7 @@ + diff --git a/Ra3.BattleNet.Metadata/ImagingInvoker.cs b/Ra3.BattleNet.Metadata/ImagingInvoker.cs new file mode 100644 index 0000000..ad04639 --- /dev/null +++ b/Ra3.BattleNet.Metadata/ImagingInvoker.cs @@ -0,0 +1,81 @@ +using System.Diagnostics; + +namespace Ra3.BattleNet.Metadata; + +/// +/// 编译阶段按开关调用 Imaging CLI(进程隔离,主包不引用 SkiaSharp)。 +/// +public static class ImagingInvoker +{ + /// + /// 对已构建的 outputDir 运行 WebP 管线。 + /// + /// 进程退出码。 + public static int RunWebP(string outputDir, TextWriter? log = null) + { + log ??= Console.Out; + outputDir = Path.GetFullPath(outputDir); + + var project = FindImagingProject(); + if (project == null) + { + log.WriteLine("错误: 找不到 Ra3.BattleNet.Metadata.Imaging 项目(编译仓库内应存在)。"); + return 1; + } + + log.WriteLine($">>> Imaging CLI: {project}"); + var psi = new ProcessStartInfo + { + FileName = "dotnet", + ArgumentList = + { + "run", + "--project", project, + "--no-launch-profile", + "--", + "webp", + $"--dst={outputDir}" + }, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var proc = Process.Start(psi) + ?? throw new InvalidOperationException("无法启动 dotnet Imaging 进程"); + + proc.OutputDataReceived += (_, e) => { if (e.Data != null) log.WriteLine(e.Data); }; + proc.ErrorDataReceived += (_, e) => { if (e.Data != null) log.WriteLine(e.Data); }; + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + proc.WaitForExit(); + return proc.ExitCode; + } + + public static string? FindImagingProject() + { + foreach (var start in new[] + { + Environment.CurrentDirectory, + AppContext.BaseDirectory, + Path.GetDirectoryName(typeof(ImagingInvoker).Assembly.Location) ?? "" + }) + { + var dir = start; + while (!string.IsNullOrEmpty(dir)) + { + var candidate = Path.Combine(dir, "Ra3.BattleNet.Metadata.Imaging", + "Ra3.BattleNet.Metadata.Imaging.csproj"); + if (File.Exists(candidate)) + return candidate; + + var parent = Path.GetDirectoryName(dir); + if (parent == dir) break; + dir = parent; + } + } + + return null; + } +} diff --git a/Ra3.BattleNet.Metadata/Program.cs b/Ra3.BattleNet.Metadata/Program.cs index 9f70fad..2bb4ee4 100644 --- a/Ra3.BattleNet.Metadata/Program.cs +++ b/Ra3.BattleNet.Metadata/Program.cs @@ -12,11 +12,14 @@ private static int Main(string[] args) string? schemaVersion = null; string? contentRevision = null; var command = "build"; + var withWebp = false; foreach (var arg in args) { if (arg is "build" or "stage-a") command = "build"; + else if (arg is "--webp" or "--with-webp" or "--publish") + withWebp = true; else if (arg.StartsWith("--src=", StringComparison.Ordinal)) srcFolder = arg["--src=".Length..]; else if (arg.StartsWith("--dst=", StringComparison.Ordinal)) @@ -35,6 +38,7 @@ private static int Main(string[] args) Console.WriteLine($"工作目录: {Environment.CurrentDirectory}"); Console.WriteLine($"源目录: {srcFolder}"); Console.WriteLine($"输出目录: {dstFolder}"); + Console.WriteLine($"WebP: {(withWebp ? "开" : "关")}"); Console.WriteLine(); try @@ -45,10 +49,18 @@ private static int Main(string[] args) return 1; } - Console.WriteLine("执行核心构建(校验 / 变量 / XML 展平 / 复制资源)..."); + Console.WriteLine(">>> 核心构建(校验 / 变量 / XML 展平 / 复制资源)"); MetadataBuilder.Build(srcFolder, dstFolder, schemaVersion, contentRevision); Console.WriteLine("✓ 核心构建完成"); + if (withWebp) + { + Console.WriteLine(">>> Imaging(WebP + Hash)"); + var code = ImagingInvoker.RunWebP(dstFolder); + if (code != 0) + return code; + } + var flatPath = Path.Combine(dstFolder, "metadata.xml"); var loaded = MetadataBuilder.Load(flatPath); Console.WriteLine(); @@ -61,7 +73,9 @@ private static int Main(string[] args) Console.WriteLine($"Mod id={mod.Id} version={mod.Version}"); Console.WriteLine(); - Console.WriteLine("处理完成。WebP 请显式运行 Imaging(npm run build:webp 或 bash build.sh --webp)。"); + Console.WriteLine(withWebp + ? "处理完成(核心 + Imaging)。" + : "处理完成(仅核心)。发布转 WebP: 加 --webp 或 npm run build:release"); return 0; } catch (Exception ex) @@ -76,22 +90,24 @@ private static int Main(string[] args) private static void PrintHelp() { Console.WriteLine(""" - Ra3.BattleNet.Metadata — 核心构建(纯 managed) + Ra3.BattleNet.Metadata — 核心构建 CLI(纯 managed) 用法: dotnet run --project Ra3.BattleNet.Metadata -- build --src=./Metadata --dst=./Output + dotnet run --project Ra3.BattleNet.Metadata -- build --webp --src=./Metadata --dst=./Output 参数: --src=DIR 源目录(含 metadata.xml) --dst=DIR 输出目录 --schema-version=VER 默认 1.0 --content-revision=REV 默认 UTC 时间戳 + --webp / --publish 核心构建后调用 Imaging CLI(WebP + 更新 Hash) + + 包边界: + NuGet Ra3.BattleNet.Metadata = 解析 + 核心 Build(Desktop 引用) + Imaging = 独立 CLI,不进主 NuGet;仓库编译阶段按开关调用 - 说明: - build: 展平 XML、变量替换、硬失败校验、复制资源 - WebP: 独立项目 Ra3.BattleNet.Metadata.Imaging(npm run build:webp / build.sh --webp) - Load(path|url): 见 MetadataBuilder.Load - Build(url): 本期仅支持本地 path,远程源构建延后 + Load(path|url): MetadataBuilder.Load """); } } diff --git a/Ra3.BattleNet.Metadata/Ra3.BattleNet.Metadata.csproj b/Ra3.BattleNet.Metadata/Ra3.BattleNet.Metadata.csproj index dfb40ca..daf577d 100644 --- a/Ra3.BattleNet.Metadata/Ra3.BattleNet.Metadata.csproj +++ b/Ra3.BattleNet.Metadata/Ra3.BattleNet.Metadata.csproj @@ -5,6 +5,12 @@ net10.0 enable enable + + true + Ra3.BattleNet.Metadata + 1.0.0 + RA3 BattleNet metadata parse/query and pure-managed core build (no Imaging/WebP). + RA3BattleNet diff --git a/build.sh b/build.sh index d2ab078..64ad759 100644 --- a/build.sh +++ b/build.sh @@ -1,19 +1,18 @@ #!/usr/bin/env bash set -euo pipefail -# 默认:纯 managed 构建(展平 + 校验) -# 发布:bash build.sh --webp (再跑 Imaging / WebP) +# 默认仅核心;--webp 时核心 CLI 内部串联 Imaging -WITH_WEBP=0 +WEBP_FLAG=() for arg in "$@"; do case "$arg" in - --webp|--publish|--with-webp) WITH_WEBP=1 ;; + --webp|--publish|--with-webp) WEBP_FLAG=(--webp) ;; --help|-h) cat <<'EOF' 用法: bash build.sh [--webp] (无参数) 仅核心构建 → ./Output - --webp 核心构建后执行 Imaging(WebP 转码) + --webp 核心 + Imaging(WebP 与 Hash 更新) --publish 同 --webp EOF exit 0 @@ -26,14 +25,5 @@ chmod +x dotnet-install.sh ./dotnet-install.sh -c 10.0 -InstallDir ./dotnet ./dotnet/dotnet --version -echo ">>> 核心构建 (build)" ./dotnet/dotnet run --project ./Ra3.BattleNet.Metadata --no-launch-profile -- \ - build --src="./Metadata" --dst="./Output" - -if [ "$WITH_WEBP" = "1" ]; then - echo ">>> Imaging (WebP)" - ./dotnet/dotnet run --project ./Ra3.BattleNet.Metadata.Imaging --no-launch-profile -- \ - --dst="./Output" -else - echo ">>> 跳过 Imaging(发布请用: bash build.sh --webp)" -fi + build --src="./Metadata" --dst="./Output" "${WEBP_FLAG[@]+"${WEBP_FLAG[@]}"}" diff --git a/package.json b/package.json index 3a74b53..a544b87 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "private": true, "scripts": { "build": "dotnet run --project ./Ra3.BattleNet.Metadata --no-launch-profile -- build --src=./Metadata --dst=./Output", - "build:webp": "dotnet run --project ./Ra3.BattleNet.Metadata.Imaging --no-launch-profile -- --dst=./Output", - "build:release": "npm run build && npm run build:webp", + "build:webp": "dotnet run --project ./Ra3.BattleNet.Metadata.Imaging --no-launch-profile -- webp --dst=./Output", + "build:release": "dotnet run --project ./Ra3.BattleNet.Metadata --no-launch-profile -- build --webp --src=./Metadata --dst=./Output", "deploy": "npm run build:release && npx wrangler pages deploy Output", "dev": "npx wrangler pages dev Output", "preview": "npm run build && npx wrangler pages dev Output", From c27822fbacf4119b5bd379df8dd486e6acd7b6df Mon Sep 17 00:00:00 2001 From: mrbbbaixue Date: Sat, 25 Jul 2026 12:48:33 +0800 Subject: [PATCH 10/11] =?UTF-8?q?refactor(imaging):=20CLI=20=E5=8F=AA?= =?UTF-8?q?=E8=BD=AC=E5=9B=BE=E5=9B=9E=E4=BC=A0=20Hash=EF=BC=8C=E5=B1=95?= =?UTF-8?q?=E5=B9=B3=E7=AE=A1=E7=BA=BF=E5=86=99=20XML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imaging convert 单图 stdout MD5;Build(convertImages) 展平后调用并改 Source/Hash; 主包不读图库依赖。 --- AGENTS.md | 2 +- README.md | 15 +-- .../ImageConverter.cs | 56 +++++++++ Ra3.BattleNet.Metadata.Imaging/Program.cs | 65 ++++++----- .../WebPPipeline.cs | 100 ---------------- Ra3.BattleNet.Metadata.Tests/ImagingTests.cs | 65 +++++++---- Ra3.BattleNet.Metadata/ImagePostProcessor.cs | 62 ++++++++++ Ra3.BattleNet.Metadata/ImagingInvoker.cs | 109 ++++++++++++++---- Ra3.BattleNet.Metadata/MetadataBuilder.cs | 21 +++- Ra3.BattleNet.Metadata/Program.cs | 40 ++----- package.json | 4 +- 11 files changed, 319 insertions(+), 220 deletions(-) create mode 100644 Ra3.BattleNet.Metadata.Imaging/ImageConverter.cs delete mode 100644 Ra3.BattleNet.Metadata.Imaging/WebPPipeline.cs create mode 100644 Ra3.BattleNet.Metadata/ImagePostProcessor.cs diff --git a/AGENTS.md b/AGENTS.md index 8e011f8..139199c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ - **源数据**:`Metadata/**/*.xml` + 图片/Markdown/叶子 Manifest - **核心库/CLI**:`Ra3.BattleNet.Metadata` — 展平、变量、XSD、语义校验、查询 API(**纯 managed,无 SkiaSharp**);**Desktop NuGet 只引此包** -- **Imaging CLI**:`Ra3.BattleNet.Metadata.Imaging` — 编译期工具,WebP + 更新 Hash;**不进主 NuGet**;由 `build --webp` 进程调用 +- **Imaging CLI**:`Ra3.BattleNet.Metadata.Imaging` — 只转图并 stdout MD5,**不读 XML**;**不进主 NuGet**;`Build(convertImages:true)` / `build --webp` 在展平后按图调用 - **发布**:Cloudflare Pages 静态托管 `Output/` ## 常用命令 diff --git a/README.md b/README.md index c0ca49e..1665236 100644 --- a/README.md +++ b/README.md @@ -224,21 +224,22 @@ Desktop:始终用发布物中的**完整限定 ID** 查表;不要假设短 | 路径 | 做什么 | 何时用 | |---|---|---| | **核心构建** | XSD + 展平 + 变量 + 资源复制 | 本地、测试、Desktop 调试;**NuGet 主包** | -| **Imaging CLI** | WebP + 改写 Source + **重算 Hash** | 仓库编译/发布工具,**不进主 NuGet** | +| **Imaging CLI** | 只转单张图并 **stdout 输出 MD5**(不读 XML) | 仓库编译工具,**不进主 NuGet** | ```bash -# 仅核心(Desktop 本地调试同此 API:MetadataBuilder.Build) +# 仅展平(Desktop 调试:MetadataBuilder.Build) npm run build -dotnet run --project Ra3.BattleNet.Metadata -- build --src=./Metadata --dst=./Output -# 核心 + Imaging(一条命令,--webp 时进程调用 Imaging CLI) -npm run build:release +# 展平阶段按图调 Imaging,再由主程序写回 Source/Hash dotnet run --project Ra3.BattleNet.Metadata -- build --webp --src=./Metadata --dst=./Output +npm run build:release bash build.sh --webp ``` -- 主 NuGet:`Ra3.BattleNet.Metadata`(解析 + 核心 Build,无 SkiaSharp) -- Imaging:`Ra3.BattleNet.Metadata.Imaging` 独立 CLI;转 WebP 后写 `Image/@Hash` 为 webp 文件 MD5 +流程:`展平 → 变量 →(--webp)对每张图 Imaging convert → 主程序改 XML → 发布 XSD/语义校验`。 + +- 主 NuGet:`Ra3.BattleNet.Metadata`(解析 + Build,无 SkiaSharp) +- Imaging:`convert --input=a.png --output=a.webp` → stdout 一行 hash - `wrangler.toml`:`bash build.sh --webp` --- diff --git a/Ra3.BattleNet.Metadata.Imaging/ImageConverter.cs b/Ra3.BattleNet.Metadata.Imaging/ImageConverter.cs new file mode 100644 index 0000000..a98c722 --- /dev/null +++ b/Ra3.BattleNet.Metadata.Imaging/ImageConverter.cs @@ -0,0 +1,56 @@ +using System.Security.Cryptography; +using SkiaSharp; + +namespace Ra3.BattleNet.Metadata.Imaging; + +/// +/// 纯图片处理:转 WebP 并计算输出文件 MD5。不读 XML。 +/// +public static class ImageConverter +{ + private static readonly HashSet Convertible = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".tif" + }; + + /// + /// 将 input 转为 WebP 写入 output,返回 output 的 MD5(小写 hex)。 + /// + public static string ConvertToWebP(string inputPath, string outputPath) + { + inputPath = Path.GetFullPath(inputPath); + outputPath = Path.GetFullPath(outputPath); + if (!File.Exists(inputPath)) + throw new FileNotFoundException($"输入图片不存在: {inputPath}"); + + var ext = Path.GetExtension(inputPath); + if (!Convertible.Contains(ext)) + throw new InvalidOperationException($"不支持的输入格式: {ext}"); + + var dir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + using var input = File.OpenRead(inputPath); + using var codec = SKCodec.Create(input) + ?? throw new InvalidOperationException($"无法解码: {inputPath}"); + using var bitmap = SKBitmap.Decode(codec) + ?? throw new InvalidOperationException($"无法解码位图: {inputPath}"); + using var image = SKImage.FromBitmap(bitmap); + using var data = image.Encode(SKEncodedImageFormat.Webp, 90); + using (var output = File.Open(outputPath, FileMode.Create, FileAccess.Write)) + data.SaveTo(output); + + return ComputeMd5(outputPath); + } + + public static string ComputeMd5(string filePath) + { + using var md5 = MD5.Create(); + using var stream = File.OpenRead(filePath); + return Convert.ToHexString(md5.ComputeHash(stream)).ToLowerInvariant(); + } + + public static bool IsConvertible(string path) => + Convertible.Contains(Path.GetExtension(path)); +} diff --git a/Ra3.BattleNet.Metadata.Imaging/Program.cs b/Ra3.BattleNet.Metadata.Imaging/Program.cs index f7f3583..fc11f0e 100644 --- a/Ra3.BattleNet.Metadata.Imaging/Program.cs +++ b/Ra3.BattleNet.Metadata.Imaging/Program.cs @@ -1,63 +1,72 @@ namespace Ra3.BattleNet.Metadata.Imaging; /// -/// Imaging CLI:编译/发布阶段可选调用,不进入 Desktop 主 NuGet。 +/// Imaging CLI:只处理图片,stdout 输出 MD5。不读/改 XML。 /// internal static class Program { private static int Main(string[] args) { - if (args.Any(a => a is "--help" or "-h")) + if (args.Length == 0 || args.Any(a => a is "--help" or "-h")) { PrintHelp(); - return 0; - } - - // 兼容: webp --dst=... 或 --dst=...(默认 webp) - var command = "webp"; - var dst = "./Output"; - foreach (var arg in args) - { - if (arg is "webp" or "images") - command = "webp"; - else if (arg.StartsWith("--dst=", StringComparison.Ordinal)) - dst = arg["--dst=".Length..]; + return args.Length == 0 ? 1 : 0; } try { - if (command != "webp") + // convert --input=a.png --output=a.webp + // convert a.png a.webp + string? input = null; + string? output = null; + var i = 0; + if (args[0] is "convert" or "webp") + i = 1; + + for (; i < args.Length; i++) + { + var a = args[i]; + if (a.StartsWith("--input=", StringComparison.Ordinal)) + input = a["--input=".Length..]; + else if (a.StartsWith("--output=", StringComparison.Ordinal)) + output = a["--output=".Length..]; + else if (input == null) + input = a; + else if (output == null) + output = a; + } + + if (string.IsNullOrWhiteSpace(input) || string.IsNullOrWhiteSpace(output)) { - Console.Error.WriteLine($"未知命令: {command}"); + Console.Error.WriteLine("需要 --input 与 --output(或两个位置参数)"); return 1; } - Console.WriteLine($"Imaging webp → {Path.GetFullPath(dst)}"); - var n = WebPPipeline.Process(dst); - Console.WriteLine($"Imaging 完成,转换 {n} 张图片(已更新 Source 与 Hash)"); + var hash = ImageConverter.ConvertToWebP(input, output); + // 机器可读:stdout 仅一行 hash,供展平管线解析 + Console.WriteLine(hash); return 0; } catch (Exception ex) { - Console.ForegroundColor = ConsoleColor.Red; - Console.Error.WriteLine($"错误: {ex.Message}"); - Console.ResetColor(); + Console.Error.WriteLine(ex.Message); return 1; } } private static void PrintHelp() { - Console.WriteLine(""" - Ra3.BattleNet.Metadata.Imaging — 发布期图片 CLI(非 Desktop NuGet) + Console.Error.WriteLine(""" + Ra3.BattleNet.Metadata.Imaging — 图片 CLI(不读 XML) 用法: - dotnet run --project Ra3.BattleNet.Metadata.Imaging -- webp --dst=./Output + convert --input=in.png --output=out.webp + convert in.png out.webp - 行为: - 将 Output 中 Image 本地图转为 WebP,改写 Source,并重算 Hash(MD5) + 成功: exit 0,stdout 一行 MD5(小写 hex) + 失败: exit != 0,错误在 stderr - 由核心 CLI 在 --webp 时自动调用,也可单独执行。 + 由核心 Build --webp 在展平阶段按图调用;不进 Desktop NuGet。 """); } } diff --git a/Ra3.BattleNet.Metadata.Imaging/WebPPipeline.cs b/Ra3.BattleNet.Metadata.Imaging/WebPPipeline.cs deleted file mode 100644 index 7891edc..0000000 --- a/Ra3.BattleNet.Metadata.Imaging/WebPPipeline.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System.Security.Cryptography; -using System.Xml.Linq; -using SkiaSharp; - -namespace Ra3.BattleNet.Metadata.Imaging; - -/// -/// 对已展平 Output 做 WebP 转码:改写 Image/@Source,并按新文件重算 Image/@Hash。 -/// -public static class WebPPipeline -{ - private static readonly HashSet Supported = new(StringComparer.OrdinalIgnoreCase) - { - ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".tif" - }; - - /// - /// 处理 outputDir 中的 metadata.xml 与本地图片。 - /// - /// 成功转换的图片数量。 - public static int Process(string outputDir) - { - outputDir = Path.GetFullPath(outputDir); - var flat = Path.Combine(outputDir, "metadata.xml"); - if (!Directory.Exists(outputDir)) - throw new DirectoryNotFoundException($"输出目录不存在: {outputDir}"); - if (!File.Exists(flat)) - throw new FileNotFoundException($"找不到展平文件: {flat}"); - - var doc = XDocument.Load(flat); - var converted = 0; - foreach (var image in doc.Descendants("Image")) - { - // 仅登记节点(带 ID);Background 文本引用无 Source 属性 - if (image.Attribute("ID") == null) - continue; - - var sourceAttr = image.Attribute("Source"); - if (sourceAttr == null || string.IsNullOrWhiteSpace(sourceAttr.Value)) - continue; - if (!string.IsNullOrWhiteSpace(image.Attribute("Url")?.Value)) - continue; - - var sourcePath = Path.Combine(outputDir, sourceAttr.Value.Replace('\\', '/')); - if (!File.Exists(sourcePath)) - continue; - - var ext = Path.GetExtension(sourcePath); - if (!Supported.Contains(ext)) - { - // 已是 webp 等:仍刷新 Hash - if (ext.Equals(".webp", StringComparison.OrdinalIgnoreCase)) - image.SetAttributeValue("Hash", ComputeMd5(sourcePath)); - continue; - } - - var webpPath = Path.ChangeExtension(sourcePath, ".webp")!; - if (!ConvertToWebP(sourcePath, webpPath)) - continue; - - var newRel = Path.ChangeExtension(sourceAttr.Value, ".webp")!.Replace('\\', '/'); - sourceAttr.Value = newRel; - image.SetAttributeValue("Hash", ComputeMd5(webpPath)); - converted++; - } - - doc.Save(flat); - return converted; - } - - public static string ComputeMd5(string filePath) - { - using var md5 = MD5.Create(); - using var stream = File.OpenRead(filePath); - var hash = md5.ComputeHash(stream); - return Convert.ToHexString(hash).ToLowerInvariant(); - } - - private static bool ConvertToWebP(string sourcePath, string webpPath) - { - try - { - using var input = File.OpenRead(sourcePath); - using var codec = SKCodec.Create(input); - if (codec == null) return false; - using var bitmap = SKBitmap.Decode(codec); - if (bitmap == null) return false; - using var image = SKImage.FromBitmap(bitmap); - using var data = image.Encode(SKEncodedImageFormat.Webp, 90); - using var output = File.Open(webpPath, FileMode.Create, FileAccess.Write); - data.SaveTo(output); - return true; - } - catch (Exception ex) - { - Console.Error.WriteLine($" 转换失败 {sourcePath}: {ex.Message}"); - return false; - } - } -} diff --git a/Ra3.BattleNet.Metadata.Tests/ImagingTests.cs b/Ra3.BattleNet.Metadata.Tests/ImagingTests.cs index 533ec68..f643966 100644 --- a/Ra3.BattleNet.Metadata.Tests/ImagingTests.cs +++ b/Ra3.BattleNet.Metadata.Tests/ImagingTests.cs @@ -12,33 +12,43 @@ public class ImagingTests Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Metadata")); [TestMethod] - public void WebPPipeline_UpdatesSourceAndHash() + public void ImageConverter_ReturnsHashOfWebpFile() { - var dst = Path.Combine(Path.GetTempPath(), $"webp-{Guid.NewGuid():N}"); + var dir = Path.Combine(Path.GetTempPath(), $"img-cli-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); try { - MetadataBuilder.Build(RepoMetadataDir, dst, contentRevision: "webp-test"); - var flat = Path.Combine(dst, "metadata.xml"); - var before = XDocument.Load(flat); - var iconBefore = before.Descendants("Image") - .First(e => e.Attribute("ID")?.Value?.EndsWith(":corona-icon-64px", StringComparison.Ordinal) == true); - var srcBefore = iconBefore.Attribute("Source")!.Value; - srcBefore.Should().EndWith(".png"); - - var n = WebPPipeline.Process(dst); - n.Should().BeGreaterThan(0); + var png = Path.Combine(RepoMetadataDir, "mods", "corona", "images", "icon-64px.png"); + var webp = Path.Combine(dir, "icon.webp"); + var hash = ImageConverter.ConvertToWebP(png, webp); + hash.Should().HaveLength(32); + File.Exists(webp).Should().BeTrue(); + ImageConverter.ComputeMd5(webp).Should().Be(hash); + } + finally + { + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + } - var after = XDocument.Load(flat); - var icon = after.Descendants("Image") + [TestMethod] + public void Build_WithConvertImages_UpdatesXmlViaImagingCli() + { + var dst = Path.Combine(Path.GetTempPath(), $"build-webp-{Guid.NewGuid():N}"); + try + { + MetadataBuilder.Build(RepoMetadataDir, dst, contentRevision: "webp", convertImages: true); + var flat = Path.Combine(dst, "metadata.xml"); + var doc = XDocument.Load(flat); + var icon = doc.Descendants("Image") .First(e => e.Attribute("ID")?.Value?.EndsWith(":corona-icon-64px", StringComparison.Ordinal) == true); icon.Attribute("Source")!.Value.Replace('\\', '/').Should().EndWith(".webp"); - var hash = icon.Attribute("Hash")?.Value; - hash.Should().NotBeNullOrWhiteSpace(); + var hash = icon.Attribute("Hash")!.Value; hash.Should().HaveLength(32); - var webpPath = Path.Combine(dst, icon.Attribute("Source")!.Value.Replace('/', Path.DirectorySeparatorChar)); File.Exists(webpPath).Should().BeTrue(); - WebPPipeline.ComputeMd5(webpPath).Should().Be(hash); + ImageConverter.ComputeMd5(webpPath).Should().Be(hash); } finally { @@ -48,9 +58,22 @@ public void WebPPipeline_UpdatesSourceAndHash() } [TestMethod] - public void ImagingInvoker_FindsProjectInRepo() + public void ImagingInvoker_ConvertToWebP_ReturnsHash() { - ImagingInvoker.FindImagingProject().Should().NotBeNullOrEmpty(); - File.Exists(ImagingInvoker.FindImagingProject()!).Should().BeTrue(); + var dir = Path.Combine(Path.GetTempPath(), $"invoker-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var png = Path.Combine(RepoMetadataDir, "mods", "corona", "images", "icon-64px.png"); + var webp = Path.Combine(dir, "out.webp"); + var hash = ImagingInvoker.ConvertToWebP(png, webp); + hash.Should().HaveLength(32); + File.Exists(webp).Should().BeTrue(); + } + finally + { + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } } } diff --git a/Ra3.BattleNet.Metadata/ImagePostProcessor.cs b/Ra3.BattleNet.Metadata/ImagePostProcessor.cs new file mode 100644 index 0000000..46db3ca --- /dev/null +++ b/Ra3.BattleNet.Metadata/ImagePostProcessor.cs @@ -0,0 +1,62 @@ +using System.Xml.Linq; + +namespace Ra3.BattleNet.Metadata; + +/// +/// 展平后:扫描 Image 登记节点,调用 Imaging CLI 转 WebP,由本类改写 XML 的 Source/Hash。 +/// +public static class ImagePostProcessor +{ + private static readonly HashSet Convertible = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".tif" + }; + + /// + /// 处理 outputDir 下 metadata.xml;返回转换张数。 + /// + public static int ApplyWebP(string outputDir, TextWriter? log = null) + { + log ??= Console.Out; + outputDir = Path.GetFullPath(outputDir); + var flatPath = Path.Combine(outputDir, "metadata.xml"); + if (!File.Exists(flatPath)) + throw new FileNotFoundException($"找不到展平文件: {flatPath}"); + + var doc = XDocument.Load(flatPath); + var converted = 0; + + foreach (var image in doc.Descendants("Image")) + { + if (image.Attribute("ID") == null) + continue; + + var sourceAttr = image.Attribute("Source"); + if (sourceAttr == null || string.IsNullOrWhiteSpace(sourceAttr.Value)) + continue; + if (!string.IsNullOrWhiteSpace(image.Attribute("Url")?.Value)) + continue; + + var rel = sourceAttr.Value.Replace('\\', '/'); + var abs = Path.Combine(outputDir, rel); + if (!File.Exists(abs)) + continue; + + var ext = Path.GetExtension(abs); + if (!Convertible.Contains(ext)) + continue; + + var webpAbs = Path.ChangeExtension(abs, ".webp")!; + var webpRel = Path.ChangeExtension(rel, ".webp")!.Replace('\\', '/'); + + log.WriteLine($" convert {rel} -> {webpRel}"); + var hash = ImagingInvoker.ConvertToWebP(abs, webpAbs); + sourceAttr.Value = webpRel; + image.SetAttributeValue("Hash", hash); + converted++; + } + + doc.Save(flatPath); + return converted; + } +} diff --git a/Ra3.BattleNet.Metadata/ImagingInvoker.cs b/Ra3.BattleNet.Metadata/ImagingInvoker.cs index ad04639..7ce44bc 100644 --- a/Ra3.BattleNet.Metadata/ImagingInvoker.cs +++ b/Ra3.BattleNet.Metadata/ImagingInvoker.cs @@ -1,40 +1,31 @@ using System.Diagnostics; +using System.Text; namespace Ra3.BattleNet.Metadata; /// -/// 编译阶段按开关调用 Imaging CLI(进程隔离,主包不引用 SkiaSharp)。 +/// 调用 Imaging CLI:单张图片转 WebP,返回 MD5。主包不引用 SkiaSharp。 /// public static class ImagingInvoker { + private static string? _cachedDll; + private static readonly object Gate = new(); + /// - /// 对已构建的 outputDir 运行 WebP 管线。 + /// 将 input 转为 WebP 写到 output,返回 hash(小写 hex)。 /// - /// 进程退出码。 - public static int RunWebP(string outputDir, TextWriter? log = null) + public static string ConvertToWebP(string inputPath, string outputPath) { - log ??= Console.Out; - outputDir = Path.GetFullPath(outputDir); - - var project = FindImagingProject(); - if (project == null) - { - log.WriteLine("错误: 找不到 Ra3.BattleNet.Metadata.Imaging 项目(编译仓库内应存在)。"); - return 1; - } - - log.WriteLine($">>> Imaging CLI: {project}"); + var dll = EnsureImagingDll(); var psi = new ProcessStartInfo { FileName = "dotnet", ArgumentList = { - "run", - "--project", project, - "--no-launch-profile", - "--", - "webp", - $"--dst={outputDir}" + "exec", dll, + "convert", + $"--input={Path.GetFullPath(inputPath)}", + $"--output={Path.GetFullPath(outputPath)}" }, UseShellExecute = false, RedirectStandardOutput = true, @@ -43,14 +34,32 @@ public static int RunWebP(string outputDir, TextWriter? log = null) }; using var proc = Process.Start(psi) - ?? throw new InvalidOperationException("无法启动 dotnet Imaging 进程"); + ?? throw new InvalidOperationException("无法启动 Imaging CLI"); - proc.OutputDataReceived += (_, e) => { if (e.Data != null) log.WriteLine(e.Data); }; - proc.ErrorDataReceived += (_, e) => { if (e.Data != null) log.WriteLine(e.Data); }; + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + proc.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); }; + proc.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); }; proc.BeginOutputReadLine(); proc.BeginErrorReadLine(); proc.WaitForExit(); - return proc.ExitCode; + + if (proc.ExitCode != 0) + { + var err = stderr.ToString().Trim(); + throw new InvalidOperationException( + string.IsNullOrEmpty(err) + ? $"Imaging CLI 失败 (exit {proc.ExitCode})" + : $"Imaging CLI 失败: {err}"); + } + + var hash = stdout.ToString().Trim() + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .LastOrDefault(); + if (string.IsNullOrWhiteSpace(hash) || hash.Length != 32) + throw new InvalidOperationException($"Imaging CLI 未返回有效 MD5: '{stdout}'"); + + return hash.ToLowerInvariant(); } public static string? FindImagingProject() @@ -78,4 +87,54 @@ public static int RunWebP(string outputDir, TextWriter? log = null) return null; } + + private static string EnsureImagingDll() + { + lock (Gate) + { + if (_cachedDll != null && File.Exists(_cachedDll)) + return _cachedDll; + + var project = FindImagingProject() + ?? throw new InvalidOperationException( + "找不到 Ra3.BattleNet.Metadata.Imaging 项目(仓库编译环境需要)。"); + + var projectDir = Path.GetDirectoryName(project)!; + foreach (var config in new[] { "Debug", "Release" }) + { + var dll = Path.Combine(projectDir, "bin", config, "net10.0", + "Ra3.BattleNet.Metadata.Imaging.dll"); + if (File.Exists(dll)) + { + _cachedDll = dll; + return dll; + } + } + + // 先编译一次再 exec + var build = new ProcessStartInfo + { + FileName = "dotnet", + ArgumentList = { "build", project, "-c", "Debug", "--nologo", "-v", "q" }, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using (var p = Process.Start(build) ?? throw new InvalidOperationException("无法 build Imaging")) + { + p.WaitForExit(); + if (p.ExitCode != 0) + throw new InvalidOperationException("编译 Imaging CLI 失败"); + } + + var built = Path.Combine(projectDir, "bin", "Debug", "net10.0", + "Ra3.BattleNet.Metadata.Imaging.dll"); + if (!File.Exists(built)) + throw new FileNotFoundException($"Imaging 编译后未找到: {built}"); + + _cachedDll = built; + return built; + } + } } diff --git a/Ra3.BattleNet.Metadata/MetadataBuilder.cs b/Ra3.BattleNet.Metadata/MetadataBuilder.cs index ca799cf..3bbb9bd 100644 --- a/Ra3.BattleNet.Metadata/MetadataBuilder.cs +++ b/Ra3.BattleNet.Metadata/MetadataBuilder.cs @@ -13,13 +13,15 @@ public static class MetadataBuilder private static readonly Regex LeftoverVariablePattern = new(@"\$\{[^}]+\}", RegexOptions.Compiled); /// - /// 从本地源目录执行核心构建(不含 Imaging/WebP)。 + /// 从本地源目录执行核心构建。 /// - /// 含 metadata.xml 的源目录。 - /// 输出目录。 - /// 契约版本,默认 1.0。 - /// 内容修订号;为空则用 UTC 时间戳。 - public static void Build(string sourceDir, string outputDir, string? schemaVersion = null, string? contentRevision = null) + /// true 时在展平后调用 Imaging CLI 转 WebP,并由本管线改写 Source/Hash。 + public static void Build( + string sourceDir, + string outputDir, + string? schemaVersion = null, + string? contentRevision = null, + bool convertImages = false) { if (string.IsNullOrWhiteSpace(sourceDir)) throw new ArgumentException("源目录不能为空", nameof(sourceDir)); @@ -60,6 +62,13 @@ public static void Build(string sourceDir, string outputDir, string? schemaVersi if (leftover.Count > 0) throw new InvalidOperationException("变量替换后仍有残留: " + string.Join("; ", leftover)); + // 展平后:按图调用 Imaging(只产 webp+hash),本管线写回 XML + if (convertImages) + { + var n = ImagePostProcessor.ApplyWebP(dst); + Console.WriteLine($" Imaging: 转换 {n} 张图片并写回 Source/Hash"); + } + var publishSchema = SchemaValidator.FindSchema(src, SchemaValidator.PublishSchemaFileName) ?? SchemaValidator.FindSchema(dst, SchemaValidator.PublishSchemaFileName) ?? throw new FileNotFoundException($"找不到发布 XSD: {SchemaValidator.PublishSchemaFileName}"); diff --git a/Ra3.BattleNet.Metadata/Program.cs b/Ra3.BattleNet.Metadata/Program.cs index 2bb4ee4..217f300 100644 --- a/Ra3.BattleNet.Metadata/Program.cs +++ b/Ra3.BattleNet.Metadata/Program.cs @@ -38,7 +38,7 @@ private static int Main(string[] args) Console.WriteLine($"工作目录: {Environment.CurrentDirectory}"); Console.WriteLine($"源目录: {srcFolder}"); Console.WriteLine($"输出目录: {dstFolder}"); - Console.WriteLine($"WebP: {(withWebp ? "开" : "关")}"); + Console.WriteLine($"WebP: {(withWebp ? "开(展平后按图调 Imaging CLI)" : "关")}"); Console.WriteLine(); try @@ -49,17 +49,9 @@ private static int Main(string[] args) return 1; } - Console.WriteLine(">>> 核心构建(校验 / 变量 / XML 展平 / 复制资源)"); - MetadataBuilder.Build(srcFolder, dstFolder, schemaVersion, contentRevision); - Console.WriteLine("✓ 核心构建完成"); - - if (withWebp) - { - Console.WriteLine(">>> Imaging(WebP + Hash)"); - var code = ImagingInvoker.RunWebP(dstFolder); - if (code != 0) - return code; - } + Console.WriteLine(">>> 构建(展平 / 校验" + (withWebp ? " / Imaging" : "") + ")"); + MetadataBuilder.Build(srcFolder, dstFolder, schemaVersion, contentRevision, convertImages: withWebp); + Console.WriteLine("✓ 构建完成"); var flatPath = Path.Combine(dstFolder, "metadata.xml"); var loaded = MetadataBuilder.Load(flatPath); @@ -72,10 +64,6 @@ private static int Main(string[] args) foreach (var mod in loaded.Mods()) Console.WriteLine($"Mod id={mod.Id} version={mod.Version}"); - Console.WriteLine(); - Console.WriteLine(withWebp - ? "处理完成(核心 + Imaging)。" - : "处理完成(仅核心)。发布转 WebP: 加 --webp 或 npm run build:release"); return 0; } catch (Exception ex) @@ -90,24 +78,16 @@ private static int Main(string[] args) private static void PrintHelp() { Console.WriteLine(""" - Ra3.BattleNet.Metadata — 核心构建 CLI(纯 managed) + Ra3.BattleNet.Metadata — 核心构建 用法: - dotnet run --project Ra3.BattleNet.Metadata -- build --src=./Metadata --dst=./Output - dotnet run --project Ra3.BattleNet.Metadata -- build --webp --src=./Metadata --dst=./Output - - 参数: - --src=DIR 源目录(含 metadata.xml) - --dst=DIR 输出目录 - --schema-version=VER 默认 1.0 - --content-revision=REV 默认 UTC 时间戳 - --webp / --publish 核心构建后调用 Imaging CLI(WebP + 更新 Hash) + build --src=./Metadata --dst=./Output + build --webp --src=./Metadata --dst=./Output - 包边界: - NuGet Ra3.BattleNet.Metadata = 解析 + 核心 Build(Desktop 引用) - Imaging = 独立 CLI,不进主 NuGet;仓库编译阶段按开关调用 + --webp: 展平后对每张本地图调用 Imaging CLI(只产 webp+hash), + 由本程序改写 metadata.xml 的 Source/Hash。 - Load(path|url): MetadataBuilder.Load + NuGet 主包不含 Imaging;Imaging 为仓库编译 CLI。 """); } } diff --git a/package.json b/package.json index a544b87..d54e7f8 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "private": true, "scripts": { "build": "dotnet run --project ./Ra3.BattleNet.Metadata --no-launch-profile -- build --src=./Metadata --dst=./Output", - "build:webp": "dotnet run --project ./Ra3.BattleNet.Metadata.Imaging --no-launch-profile -- webp --dst=./Output", - "build:release": "dotnet run --project ./Ra3.BattleNet.Metadata --no-launch-profile -- build --webp --src=./Metadata --dst=./Output", + "build:webp": "dotnet run --project ./Ra3.BattleNet.Metadata --no-launch-profile -- build --webp --src=./Metadata --dst=./Output", + "build:release": "npm run build:webp", "deploy": "npm run build:release && npx wrangler pages deploy Output", "dev": "npx wrangler pages dev Output", "preview": "npm run build && npx wrangler pages dev Output", From c3c9bbf2a36ee90919dc0a72176520bb12371229 Mon Sep 17 00:00:00 2001 From: mrbbbaixue Date: Sat, 25 Jul 2026 13:20:05 +0800 Subject: [PATCH 11/11] =?UTF-8?q?=E2=9C=A8=20feat(metadata):=20=E6=BA=90?= =?UTF-8?q?=E6=A0=91=20Base/InheritFrom=20=E6=9E=84=E5=BB=BA=E6=9C=9F?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E4=B8=8E=20Templates=20=E8=84=9A=E6=89=8B?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发布契约不变:展平后无 Base/InheritFrom;Desktop 仍只读完整实体。 --- AGENTS.md | 4 + Metadata/MetadataSchema.xsd | 25 ++ Metadata/Templates/Application.xml | 21 ++ Metadata/Templates/Mod.xml | 39 +++ Metadata/Templates/README.md | 21 ++ README.md | 28 +- .../InheritanceTests.cs | 308 ++++++++++++++++++ Ra3.BattleNet.Metadata/MetadataFlattener.cs | 3 + Ra3.BattleNet.Metadata/MetadataInheritance.cs | 244 ++++++++++++++ 9 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 Metadata/Templates/Application.xml create mode 100644 Metadata/Templates/Mod.xml create mode 100644 Metadata/Templates/README.md create mode 100644 Ra3.BattleNet.Metadata.Tests/InheritanceTests.cs create mode 100644 Ra3.BattleNet.Metadata/MetadataInheritance.cs diff --git a/AGENTS.md b/AGENTS.md index 139199c..58184c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,8 @@ bash build.sh --webp 改结构时:**同步改 XSD + README 属性清单 + 示例 + 测试**。 +源树 `Base` / `InheritFrom` 仅构建期合并(`MetadataInheritance`);**发布物禁止**带继承痕迹。`Defines` 不做对象继承。 + ## 使用方(Desktop)解析要点 1. 只消费**展平** `metadata.xml` + 相对资源,不自己展开 Include。 @@ -54,10 +56,12 @@ bash build.sh --webp ``` Metadata/metadata.xml 入口 +Metadata/Templates/ 脚手架(不 Include) Metadata/** 分模块 Include 源 Ra3.BattleNet.Metadata/ MetadataBuilder.cs 核心 Build / Load MetadataFlattener.cs Include 展平 + MetadataInheritance.cs Base + InheritFrom 合并 SchemaValidator.cs XSD VariableResolver.cs ${...} Metadata.cs / MetadataParser.cs 树与加载 diff --git a/Metadata/MetadataSchema.xsd b/Metadata/MetadataSchema.xsd index 7c05928..20fe762 100644 --- a/Metadata/MetadataSchema.xsd +++ b/Metadata/MetadataSchema.xsd @@ -10,6 +10,7 @@ + @@ -19,6 +20,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -66,6 +89,7 @@ + @@ -78,6 +102,7 @@ + diff --git a/Metadata/Templates/Application.xml b/Metadata/Templates/Application.xml new file mode 100644 index 0000000..c806523 --- /dev/null +++ b/Metadata/Templates/Application.xml @@ -0,0 +1,21 @@ + + + + + 1.0.0 + + + 2026-01-01 + manifest-100 + + + + + + + placeholder.bin + / + APP; + + + diff --git a/Metadata/Templates/Mod.xml b/Metadata/Templates/Mod.xml new file mode 100644 index 0000000..c9f55f7 --- /dev/null +++ b/Metadata/Templates/Mod.xml @@ -0,0 +1,39 @@ + + + + + + + 1.0.0 + mod-icon-64px + + + + 2026-01-01 + manifest-100 + + + + + + + placeholder.bin + / + MOD; + + + diff --git a/Metadata/Templates/README.md b/Metadata/Templates/README.md new file mode 100644 index 0000000..f3cebec --- /dev/null +++ b/Metadata/Templates/README.md @@ -0,0 +1,21 @@ +# Templates 脚手架 + +本目录**不**被 `metadata.xml` Include,仅供复制新建。 + +## 新建 Mod + +1. 复制 `Mod.xml` 到 `mods//.xml` +2. 改 `Mod/@ID`、资源路径与版本 +3. 在 `mods/mods.xml` 增加 `` +4. 准备 `images/`、`manifests/` 等实际文件 + +## 新建 Application + +1. 复制 `Application.xml` 到 `apps//.xml` +2. 改 `Application/@ID` 与 Packages +3. 在 `apps/apps.xml` 增加 Include + +## 使用 Base 继承(可选) + +见仓库 README「源树继承(Base / InheritFrom)」。 +公共样式可放在独立 XML 的 ``,Mod 写 `InheritFrom` 只填差异。 diff --git a/README.md b/README.md index 1665236..f3d481f 100644 --- a/README.md +++ b/README.md @@ -117,13 +117,38 @@ MetadataBuilder.Build(sourceDir, outputDir, schemaVersion: "1.0", contentRevisio | `Tags/Commit` | 子元素 | 构建时间戳等 | | 子节点 | 子元素 | `Application` / `Mod` / 登记用 `Image`·`Markdown`·`Manifest` | -源树另可有 `Includes/Include`(`Source`、`Type=public|private`),**发布物中不存在**。 +源树另可有 `Includes/Include`(`Source`、`Type=public|private`)、`Base`、`InheritFrom`,**发布物中不存在**。 + +### 源树继承(Base / InheritFrom) + +构建期合并,Desktop **不必**实现继承。 + +| 节点 | 说明 | +|---|---| +| `` | 源树专用骨架;合并后删除 | +| `Mod`/`Application` 的 `@InheritFrom` | 指向 Base 的 ID;合并后删除 | +| `Defines` | **不是**继承,仅服务 `${this:}` 等变量 | + +**合并规则(子优先):** + +| 区域 | 规则 | +|---|---| +| 属性 | 子覆盖;实体 `ID` 只用子节点的;不从 Base 继承 ID | +| 标量(`Version`/`CurrentVersion`/`Icon` 等) | 子有则整元素替换 | +| `Style/Logo`、`Style/Background` | 子有则整段替换 | +| `Style/Controls` | 按控件名合并;控件内按字段名覆盖 | +| `Packages`/`Posts`/`Defines` | 子有则**整段替换**(不做列表 Append) | + +脚手架:`Metadata/Templates/`(复制用,默认不 Include)。 + +硬失败:找不到 Base、Kind 不符、Base ID 与实体 ID 冲突、Base ID 重复。 ### `Application`(应用,如战网客户端) | 成员 | 说明 | |---|---| | `@ID` | 应用 ID,如 `RA3BattleNet` | +| `@InheritFrom` | 源树可选,指向 `Base`(发布物无) | | `Version` | 当前版本号 | | `Packages/Package` | 历史版本包列表 | | `Posts/Post` | 新闻 | @@ -133,6 +158,7 @@ MetadataBuilder.Build(sourceDir, outputDir, schemaVersion: "1.0", contentRevisio | 成员 | 说明 | |---|---| | `@ID` | Mod ID,如 `Corona` | +| `@InheritFrom` | 源树可选,指向 `Base`(发布物无) | | `CurrentVersion` | 当前版本 | | `Icon` | **ID 引用** → `Image` 登记节点 | | `Style` | UI 样式(见下) | diff --git a/Ra3.BattleNet.Metadata.Tests/InheritanceTests.cs b/Ra3.BattleNet.Metadata.Tests/InheritanceTests.cs new file mode 100644 index 0000000..5ad1bfa --- /dev/null +++ b/Ra3.BattleNet.Metadata.Tests/InheritanceTests.cs @@ -0,0 +1,308 @@ +using System.Xml.Linq; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Ra3.BattleNet.Metadata.Tests; + +[TestClass] +public class InheritanceTests +{ + private static string RepoMetadataDir => + Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Metadata")); + + private static void SeedSchemas(string targetDir) + { + Directory.CreateDirectory(targetDir); + foreach (var name in new[] { SchemaValidator.SourceSchemaFileName, SchemaValidator.PublishSchemaFileName }) + { + File.Copy(Path.Combine(RepoMetadataDir, name), Path.Combine(targetDir, name), overwrite: true); + } + } + + [TestMethod] + public void Merge_Controls_ChildOverridesOneField() + { + var root = XElement.Parse(""" + + + + + + + + + """); + + MetadataInheritance.Resolve(root); + + root.Elements("Base").Should().BeEmpty(); + var mod = root.Element("Mod")!; + mod.Attribute("InheritFrom").Should().BeNull(); + mod.Element("Style")!.Element("Controls")!.Element("Label")!.Element("FontSize")!.Value.Should().Be("16"); + mod.Element("Style")!.Element("Controls")!.Element("Label")!.Element("ForegroundBrush")!.Value + .Should().Be("#FFFFFFFF"); + } + + [TestMethod] + public void Merge_Background_ChildReplacesList() + { + var root = XElement.Parse(""" + + + + + + + + + """); + + MetadataInheritance.Resolve(root); + + var bg = root.Element("Mod")!.Element("Style")!.Element("Background")!; + bg.Attribute("Random")!.Value.Should().Be("false"); + bg.Elements("Image").Select(e => e.Value).Should().Equal("child-only"); + } + + [TestMethod] + public void Merge_Packages_ChildReplacesAll() + { + var root = XElement.Parse(""" + + + + + old + + + + + + + new + + + + + """); + + MetadataInheritance.Resolve(root); + + var packages = root.Element("Mod")!.Element("Packages")!.Elements("Package").ToList(); + packages.Should().HaveCount(1); + packages[0].Attribute("Version")!.Value.Should().Be("2.0"); + packages[0].Element("Manifest")!.Value.Should().Be("new"); + } + + [TestMethod] + public void Fail_MissingBase() + { + var root = XElement.Parse(""" + + + 1 + + + """); + + var act = () => MetadataInheritance.Resolve(root); + act.Should().Throw().WithMessage("*找不到 Base*"); + } + + [TestMethod] + public void Fail_KindMismatch() + { + var root = XElement.Parse(""" + + + 1.0 + + + 1 + + + """); + + var act = () => MetadataInheritance.Resolve(root); + act.Should().Throw().WithMessage("*不能继承*"); + } + + [TestMethod] + public void Fail_BaseIdConflictsWithMod() + { + var root = XElement.Parse(""" + + + 0 + + + 1 + + + """); + + var act = () => MetadataInheritance.Resolve(root); + act.Should().Throw().WithMessage("*冲突*"); + } + + [TestMethod] + public void Flatten_Publish_NoBaseNoInheritFrom() + { + var temp = Path.Combine(Path.GetTempPath(), $"inh-flat-{Guid.NewGuid():N}"); + var src = Path.Combine(temp, "src"); + Directory.CreateDirectory(src); + try + { + File.WriteAllText(Path.Combine(src, "metadata.xml"), """ + + + + + + + 9 + icon-a + + + +"""); + File.WriteAllText(Path.Combine(src, "base.xml"), """ + + + + + + +"""); + + var doc = MetadataFlattener.Flatten( + Path.Combine(src, "metadata.xml"), + src, + "1.0", + "rev"); + + var text = doc.ToString(); + text.Should().NotContain(" + + + + + + + + 3.0 + icon-a + + + 2026-01-01 + manifest-1 + + + + +"""); + File.WriteAllText(Path.Combine(src, "base.xml"), """ + + + + + + +"""); + File.WriteAllText(Path.Combine(src, "manifest-1.xml"), """ + + + + + x.bin + / + MOD; + + + +"""); + + MetadataBuilder.Build(src, dst, schemaVersion: "1.0", contentRevision: "inh-test"); + + var flat = File.ReadAllText(Path.Combine(dst, "metadata.xml")); + flat.Should().NotContain(" m.Id == "Demo"); + mod.Version.Should().Be("3.0"); + mod.Icon.Should().Contain("icon-a"); + } + finally + { + if (Directory.Exists(temp)) + Directory.Delete(temp, recursive: true); + } + } +} diff --git a/Ra3.BattleNet.Metadata/MetadataFlattener.cs b/Ra3.BattleNet.Metadata/MetadataFlattener.cs index cf61ac2..5b1f136 100644 --- a/Ra3.BattleNet.Metadata/MetadataFlattener.cs +++ b/Ra3.BattleNet.Metadata/MetadataFlattener.cs @@ -18,6 +18,9 @@ public static XDocument Flatten(string entryPath, string sourceRoot, string sche var processing = new HashSet(StringComparer.OrdinalIgnoreCase); var root = LoadAndExpand(fullEntry, rootFull, processing); + // Include 展开后、写出版本前:合并 Base/InheritFrom,发布物无继承痕迹 + MetadataInheritance.Resolve(root); + root.SetAttributeValue("SchemaVersion", schemaVersion); root.SetAttributeValue("ContentRevision", contentRevision); diff --git a/Ra3.BattleNet.Metadata/MetadataInheritance.cs b/Ra3.BattleNet.Metadata/MetadataInheritance.cs new file mode 100644 index 0000000..6ab2b40 --- /dev/null +++ b/Ra3.BattleNet.Metadata/MetadataInheritance.cs @@ -0,0 +1,244 @@ +using System.Xml.Linq; + +namespace Ra3.BattleNet.Metadata; + +/// +/// 源树 Base + InheritFrom 合并。仅构建期;发布物无 Base/InheritFrom。 +/// +public static class MetadataInheritance +{ + private static readonly string[] ModChildOrder = + ["CurrentVersion", "Icon", "Style", "Packages", "Posts", "Defines"]; + + private static readonly string[] ApplicationChildOrder = + ["Version", "Packages", "Posts", "Defines"]; + + private static readonly string[] StyleChildOrder = ["Logo", "Controls", "Background"]; + + /// + /// 在已展开的根树上解析继承:合并后去掉 InheritFrom,并删除全部 Base。 + /// + public static void Resolve(XElement root) + { + if (root.Name.LocalName != "Metadata") + throw new InvalidOperationException("继承解析要求根节点为 Metadata"); + + var bases = root.Elements().Where(e => e.Name.LocalName == "Base").ToList(); + var baseMap = new Dictionary(StringComparer.Ordinal); + + foreach (var baseEl in bases) + { + var id = baseEl.Attribute("ID")?.Value; + var kind = baseEl.Attribute("Kind")?.Value; + if (string.IsNullOrWhiteSpace(id)) + throw new InvalidOperationException("Base 缺少 ID"); + if (kind is not ("Mod" or "Application")) + throw new InvalidOperationException($"Base '{id}' 的 Kind 必须是 Mod 或 Application"); + if (!baseMap.TryAdd(id, baseEl)) + throw new InvalidOperationException($"Base ID 重复: {id}"); + } + + foreach (var entity in root.Elements().Where(e => e.Name.LocalName is "Mod" or "Application")) + { + var id = entity.Attribute("ID")?.Value; + if (string.IsNullOrWhiteSpace(id)) + continue; + if (baseMap.ContainsKey(id)) + throw new InvalidOperationException($"Base ID 与 {entity.Name.LocalName} ID 冲突: {id}"); + } + + foreach (var entity in root.Elements().Where(e => e.Name.LocalName is "Mod" or "Application").ToList()) + { + var inherit = entity.Attribute("InheritFrom")?.Value; + if (string.IsNullOrWhiteSpace(inherit)) + continue; + + if (!baseMap.TryGetValue(inherit, out var baseEl)) + throw new InvalidOperationException( + $"{entity.Name.LocalName} '{entity.Attribute("ID")?.Value}' 的 InheritFrom 找不到 Base: {inherit}"); + + var kind = baseEl.Attribute("Kind")!.Value; + if (!string.Equals(kind, entity.Name.LocalName, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"{entity.Name.LocalName} '{entity.Attribute("ID")?.Value}' 不能继承 Kind={kind} 的 Base '{inherit}'"); + } + + var merged = MergeEntity(baseEl, entity); + entity.ReplaceWith(merged); + } + + foreach (var baseEl in root.Elements().Where(e => e.Name.LocalName == "Base").ToList()) + baseEl.Remove(); + + var leftover = root.DescendantsAndSelf() + .Where(e => e.Attribute("InheritFrom") != null) + .Select(e => $"{e.Name.LocalName}:{e.Attribute("ID")?.Value}") + .ToList(); + if (leftover.Count > 0) + throw new InvalidOperationException("仍有未解析的 InheritFrom: " + string.Join(", ", leftover)); + } + + internal static XElement MergeEntity(XElement baseEl, XElement child) + { + var result = new XElement(child.Name); + + foreach (var attr in baseEl.Attributes()) + { + if (attr.Name.LocalName is "ID" or "Kind" or "InheritFrom") + continue; + result.SetAttributeValue(attr.Name, attr.Value); + } + + foreach (var attr in child.Attributes()) + { + if (attr.Name.LocalName == "InheritFrom") + continue; + result.SetAttributeValue(attr.Name, attr.Value); + } + + if (string.IsNullOrWhiteSpace(result.Attribute("ID")?.Value)) + throw new InvalidOperationException($"{child.Name.LocalName} 合并后缺少 ID"); + + var order = child.Name.LocalName == "Mod" ? ModChildOrder : ApplicationChildOrder; + var known = new HashSet(order, StringComparer.Ordinal); + + foreach (var name in order) + { + var fromBase = baseEl.Element(name); + var fromChild = child.Element(name); + if (fromBase == null && fromChild == null) + continue; + + if (fromChild == null) + { + result.Add(new XElement(fromBase!)); + continue; + } + + if (fromBase == null) + { + result.Add(new XElement(fromChild)); + continue; + } + + if (name == "Style") + result.Add(MergeStyle(fromBase, fromChild)); + else + result.Add(new XElement(fromChild)); + } + + // 子节点独有、不在白名单顺序表中的元素(扩展口) + foreach (var el in child.Elements()) + { + if (known.Contains(el.Name.LocalName)) + continue; + result.Add(new XElement(el)); + } + + foreach (var el in baseEl.Elements()) + { + if (known.Contains(el.Name.LocalName)) + continue; + if (result.Element(el.Name) != null) + continue; + result.Add(new XElement(el)); + } + + return result; + } + + internal static XElement MergeStyle(XElement baseStyle, XElement childStyle) + { + var result = new XElement("Style"); + foreach (var attr in baseStyle.Attributes()) + result.SetAttributeValue(attr.Name, attr.Value); + foreach (var attr in childStyle.Attributes()) + result.SetAttributeValue(attr.Name, attr.Value); + + foreach (var name in StyleChildOrder) + { + var fromBase = baseStyle.Element(name); + var fromChild = childStyle.Element(name); + if (fromBase == null && fromChild == null) + continue; + + if (fromChild == null) + { + result.Add(new XElement(fromBase!)); + continue; + } + + if (fromBase == null || name is "Logo" or "Background") + { + result.Add(new XElement(fromChild)); + continue; + } + + // Controls 深合并 + result.Add(MergeControls(fromBase, fromChild)); + } + + return result; + } + + internal static XElement MergeControls(XElement baseControls, XElement childControls) + { + var result = new XElement("Controls"); + foreach (var attr in baseControls.Attributes()) + result.SetAttributeValue(attr.Name, attr.Value); + foreach (var attr in childControls.Attributes()) + result.SetAttributeValue(attr.Name, attr.Value); + + var map = new Dictionary(StringComparer.Ordinal); + foreach (var control in baseControls.Elements()) + map[control.Name.LocalName] = new XElement(control); + + foreach (var control in childControls.Elements()) + { + var name = control.Name.LocalName; + if (!map.TryGetValue(name, out var existing)) + { + map[name] = new XElement(control); + continue; + } + + map[name] = MergeNamedBag(existing, control); + } + + foreach (var control in map.Values) + result.Add(control); + + return result; + } + + /// 同名控件:按子元素名覆盖(如 FontSize),子缺省保留 Base。 + internal static XElement MergeNamedBag(XElement baseControl, XElement childControl) + { + var result = new XElement(baseControl.Name); + foreach (var attr in baseControl.Attributes()) + result.SetAttributeValue(attr.Name, attr.Value); + foreach (var attr in childControl.Attributes()) + result.SetAttributeValue(attr.Name, attr.Value); + + var fields = new Dictionary(StringComparer.Ordinal); + foreach (var field in baseControl.Elements()) + fields[field.Name.LocalName] = new XElement(field); + foreach (var field in childControl.Elements()) + fields[field.Name.LocalName] = new XElement(field); + + foreach (var field in fields.Values) + result.Add(field); + + if (!baseControl.HasElements && !childControl.HasElements) + { + var text = childControl.Value; + if (string.IsNullOrEmpty(text)) + text = baseControl.Value; + if (!string.IsNullOrEmpty(text)) + result.Value = text; + } + + return result; + } +}