diff --git a/.planning/phases/09-minimum-browser-runnable/09-21-SUMMARY.md b/.planning/phases/09-minimum-browser-runnable/09-21-SUMMARY.md new file mode 100644 index 0000000..f885ef7 --- /dev/null +++ b/.planning/phases/09-minimum-browser-runnable/09-21-SUMMARY.md @@ -0,0 +1,195 @@ +--- +phase: 09-minimum-browser-runnable +plan: 21 +subsystem: build +tags: [vite, rollup, esm, jsonc, fontmin, data-isolation, build-pipeline] + +# Dependency graph +requires: + - phase: 09-minimum-browser-runnable + provides: 09-19 / 09-20 的数据端与渲染端插件系统(构建脚本此前未适配) +provides: + - "script/build-plugin.ts:`dataIsolationPlugin`(模块图双路隔离检测)" + - "script/build-resource.ts:`buildContent` / `minifyJSONC` 与 content 构建接口" + - "script/build-game.ts:准备 → 渲染端 → 数据端 → content → LICENSE 的新构建编排" +affects: [09-minimum-browser-runnable, build:game 产物布局, 后续 content/loader 适配] + +# Actuals +actuals: + tokens: 4647 + tasks: 3 + commits: 0 + +# Tech tracking +tech-stack: + added: [] + patterns: + - "数据端隔离检测:`resolveId` 拦说明符 + `moduleParsed(info: ModuleInfo)` 按 `info.id` 兜底,命中即 `this.error`" + - "数据端库模式 ESM:入口产物 `dist/data.js`(dist 根),附属 chunk/asset 落在 `dist/data/`,与渲染端 `assets/`、`chunks/` 分离" + - "content 构建:jsonc 按行去 `//` 注释后 `JSON.parse` + `JSON.stringify`,输出单行纯 JSON 且后缀仍为 `.jsonc`" + +key-files: + created: + - script/build-plugin.ts + - script/build-plugin.test.ts + modified: + - script/build-resource.ts + - script/build-game.ts + deleted: + - script/build-packages.ts + +key-decisions: + - "按用户指定文件名落码:`script/build-plugin.ts` / `script/build-plugin.test.ts`(覆盖 dev.md 小驼峰文件名规则)" + - "隔离检测改为模块图硬报错,删除按体积推断的 `checkClientData` 启发式" + - "数据端入口固定 `dist/data.js`(dist 根),附属产物前缀 `data/`,并强制 `emptyOutDir: false`" + - "content 的 jsonc 去掉全部注释(含系统标记行),不保留注释、不改后缀、不改 loader" + +patterns-established: + - "构建脚本按函数切片做结构门禁:buildClient / buildData / buildGame 各自断言关键配置" + - "插件行为用真实 `vite.build()`(`write: false` + 临时 fixture)验证,而不是只做文本断言" + +requirements-completed: [] +duration: 6min +completed: 2026-10-20 +status: complete +--- + +# Phase 09 Plan 21: 任务 11 · 构建重构(适配新引擎)Summary + +**构建脚本完成新引擎适配:渲染端以 `index.html` 输出常规 `dist`,数据端以库模式 ESM 产出 `dist/data.js` + `dist/data/`(模块图插件硬报错),`src/content` 去注释压成纯 JSON 落到 `dist/content`,并保留 `LICENSE`;旧 babel / zip / 分块 / 旧模板拷贝流程全部移除,`build-packages.ts` 已删除** + +## Performance + +- **Duration:** 约 6 分钟 +- **Started:** 2026-10-20T17:31:00Z +- **Completed:** 2026-10-20T17:37:00Z +- **Tasks:** 3(Task 0 汇报关卡、Task 1 插件与单测、Task 2 脚本重写) +- **Files modified:** 5(新增 2、重写 2、删除 1) + +## 任务执行结果 + +| Task | 内容 | 结果 | +| ---- | ---- | ---- | +| Task 0 | 预执行汇报关卡(`blocking-human`) | 用户已回复「可以执行」,关卡闭环 | +| Task 1 | `dataIsolationPlugin` + 真实 `vite.build()` 单测 | 完成,单测 4/4 通过 | +| Task 2 | 重写 `build-resource` / `build-game`、删除 `build-packages.ts`、写本 SUMMARY | 完成,结构门禁全绿 | + +## What Was Built + +### 数据端隔离插件(`script/build-plugin.ts`) + +- 导出 `dataIsolationPlugin(): Plugin`;`name: 'mota-data-isolation'`、`enforce: 'pre'`、`apply: 'build'`。 +- 双路扫描: + - `resolveId(source, importer)` 拦渲染端说明符(`@user/client-*`、`@user/entry-client`、`@motajs/render*`、`@motajs/client*`、`@motajs/system`、`@motajs/audio`、`@motajs/legacy-*`、`vue`、`@vue/*`、`ant-design-vue`、`@ant-design/icons-vue`)。 + - `moduleParsed(info: ModuleInfo)` 用 `info.id` 拦解析后路径(`packages-user/client-*`、`packages/render*`、`packages/client*`、`packages/system`、`packages/audio`、`packages/legacy-*`、`src/client/**`、`node_modules/{vue,@vue,ant-design-vue,@ant-design}`),导入者经 `this.getModuleInfo(info.id)` 读取 `importers`。 +- 命中即 `this.error(...)`,消息以 `[mota-data-isolation]` 开头,含 `matched: `、命中模块与导入者,并说明 `data-side build must not depend on render-side`。 +- 数据端依赖(`@user/data-*`、`@motajs/common`、`@motajs/loader`、`@motajs/anon-tokyo`、`@motajs/types`、`@motajs/animate`)不在禁令内。 +- **取代** `checkClientData` / `ClientDataLevel` / `getFileName` / `getFileSize` 体积启发式。 + +### 渲染端(`buildClient`) + +- 复用 `vite.config.ts`(`loadConfigFromFile` + `mergeConfig`),保持 `legacy(...)` 与 `manualChunks` 现状。 +- **不设置** `rollupOptions.input`,由 Vite 默认以 `index.html` 为入口;`index.html` 本身未改。 +- `outDir = dist`、`copyPublicDir: false`、`emptyOutDir: false`;输出常规 `index.html` + `assets/` / `chunks/`。 +- 不再拷贝 `editor.html` / `_server` / `_docs` / `libs` / `logo.png` / `styles.css` / `main.js` / `启动服务.exe`,也不再做 `main.js` 的 babel 二次压缩。 + +### 数据端(`buildData`) + +- 入口 `src/data.ts`,`lib.formats: ['es']`(ESM,不再 iife)。 +- `entryFileNames: 'data.js'` → 入口产物固定 **`dist/data.js`(dist 根)**。 +- `chunkFileNames: 'data/[name].[hash].js'`、`assetFileNames: 'data/[name].[hash][extname]'` → 附属产物落在 **`dist/data/`**,与渲染端 `assets/`、`chunks/` 分离。 +- `copyPublicDir: false`、`emptyOutDir: false`(不清掉先产出的渲染端产物);`plugins: [dataIsolationPlugin()]`。 + +### content(`script/build-resource.ts`) + +- `minifyJSONC(text)`:按行过滤 `line.trim().startsWith('//')`(去掉 `// Type: ...` 与全部系统标记行),`JSON.parse` + `JSON.stringify` 输出单行纯 JSON,非法 JSON 直接抛错;后缀仍 `.jsonc`。 +- `buildContent(options)`:递归镜像 `src/content` → `dist/content`;jsonc 压缩、字体(`.ttf/.otf/.woff/.woff2`)经 `Fontmin.glyph({ text: chars })` 子集化、其它素材 `fs-extra.copy` 原样拷贝;无字体二进制时跳过并 `process.stdout.write` 告警。 +- 接口:`IContentBuildOptions`(`sourceDir` / `outputDir` / `chars: string`)、`IContentBuildStats`(`files` / `jsonc` / `fonts`)。 +- 不再分块、不再 zip、不再引用 JSZip / `./types`。 + +### 编排(`buildGame`) + +1. 准备:`ensureDir` + `emptyDir` 清空 `dist` 与 `_temp`(全流程仅一次)。 +2. `await buildClient(distDir)`。 +3. `await buildData(distDir, resolve(process.cwd(), 'src/data.ts'))`。 +4. `getAllChars(): Promise`(读取 `dist` 下渲染端 `.js`,跳过 `dist/data`、`dist/content`;再读 `src/content/**/*.jsonc`)→ `buildContent({ sourceDir: 'src/content', outputDir: 'dist/content', chars })`。 +5. 复制 `LICENSE` → `dist/LICENSE`,输出 `data.js` 体积、content 统计与 `dist` 总体积。 + +### 最终 `dist` 清单 / 排除项 + +- **产出:** `index.html` + Vite 常规渲染产物(`assets/` / `chunks/`)+ `data.js` + `data/`(数据端附属)+ `content/` + `LICENSE`。 +- **不产出:** `public/**`(`copyPublicDir: false`)、`main.js`、`editor.html`、`_server`、`_docs`、`libs`、`logo.png`、`styles.css`、`启动服务.exe`、`source-code.zip`、`loadList.json`、`dist.zip`、资源分块 `resource..h5data`。 + +### 删除清单 + +- 删除 `script/build-packages.ts`(D-93 / D-98)。 +- `script/build-lib.ts` **未动**。 +- `package.json` **未改**,故 `build:packages` script 会指向已删除文件而失效(按计划接受,属已知后续项)。 + +## Files Created/Modified + +- `script/build-plugin.ts`(新增)— 数据端隔离 Vite 插件 `dataIsolationPlugin` +- `script/build-plugin.test.ts`(新增,必需)— 真实 `vite.build()` 行为单测 +- `script/build-resource.ts`(重写)— content 构建模块 `minifyJSONC` / `buildContent` +- `script/build-game.ts`(重写)— 新构建编排 +- `script/build-packages.ts`(删除) + +**未改动(已核对):** `script/build-lib.ts`、`package.json`、`index.html`、`dev.md`、`src/**`、`public/**`、`packages-user/**`、`vite.config.ts`、`pnpm-lock.yaml`、`packages*/**`、`script/dev.ts`。 + +## 命名获批情形 + +- OI-1:`script/build-plugin.ts`(用户显式指定,覆盖 dev.md 小驼峰文件名规则)、`dataIsolationPlugin`、`name: 'mota-data-isolation'`、`script/build-plugin.test.ts`。 +- OI-2:`buildContent`、`minifyJSONC`、`IContentBuildOptions`、`IContentBuildStats`。 +- 另有 1 个不导出的内部接口 `IContentBuildCounter`(仅用于统计累加,非公共命名)。 + +## Gates Run + +| 门禁 | 命令 | 结果 | +| ---- | ---- | ---- | +| 插件单测 | `cmd /c "pnpm exec vitest run script/build-plugin.test.ts"` | **PASS**(4/4:拒 `vue`、拒 `src/client`、拒 `packages-user/client-*`、纯数据端通过并产出 ESM) | +| 插件结构断言 | 计划 Task 1 ``(`PLUGIN GATE FAILED`) | **PASS** | +| build-resource 结构断言 | 计划 Task 2 ``(`BUILD RESOURCE GATE FAILED`) | **PASS** | +| build-game 按函数切片断言 | 计划 Task 2 ``(`BUILD GAME GATE FAILED`) | **PASS** | +| 文件清单断言 | 计划 Task 2 ``(`FILES GATE FAILED`) | **PASS** | +| 附加:jsonc 数据体检 | 对 `src/content` 23 个 jsonc 按行去注释后 `JSON.parse` | **PASS**(23/23 可压成纯 JSON,无注释残留) | +| 附加:语法 / 类型 | `tsc --noEmit --ignoreConfig ...`(仅 4 个脚本) | **PASS**(无输出) | + +## Open Items 处置 + +- OI-1 / OI-2:按计划提议命名采用(Task 0 已报备并由用户批准执行)。 +- 已知后续项(不在本计划):见下方 INFO-1 / INFO-2。 + +## Known Boundaries + +- **INFO-1(jsonc 去净注释 ↔ 运行期加载器冲突):** `dist/content/**/*.jsonc` 去掉全部注释后不再包含系统标记行,而运行期 `PrefixedJSONCProcessor`(`packages-user/data-state/src/loader/jsoncProcessor.ts`)按 `prefixSplit` 切分原文,切分结果不是 2 段时会报 **error 66**。loader 适配经用户确认**不在本计划范围**,此处仅记录为已知后续项;本计划未保留注释、未改 loader、未改 `src/**` 与 `packages-user/**`。 +- **INFO-2(样式文件):** `index.html` 仍含 ``,本计划**不产出** `styles.css`,构建后该 link 会 404;`index.html` 与样式接线由用户处理,本计划未改该文件。 +- **INFO-4(验证边界):** 本计划**未端到端运行全量 `build:game`**(渲染端仍有两处用户 WIP 的 stale import:`src/client/initialize.ts`、`src/client/plugin/shop/shop.tsx`)。验证仅覆盖插件单测 + 两个脚本的结构断言 + jsonc 数据体检 + 脚本级语法/类型检查。**不得**将本次结果表述为「全量构建已通过」。 + +## Deviations from Plan + +无功能性偏差,计划按既定口径执行。两处执行细节说明: + +1. **换行符:** 4 个脚本文件按 dev.md「换行使用 CRLF」与 `.prettierrc` 的 `endOfLine: crlf` 写成 CRLF(与仓库既有脚本一致);SUMMARY 等规划文档沿用 `.planning` 既有的 LF。 +2. **单测产物形态:** `vite.build()` 在库模式下返回 `RollupOutput[]`,单测按数组/单对象两种形态归一后断言 `export`,未改动插件行为。 + +## Issues Encountered + +- 单测首轮「纯数据端通过」用例因 `build()` 返回数组导致 `result.output` 为 `undefined`,已在测试内做形态归一后通过;插件实现未变。 + +## User Setup Required + +None。 + +## Commits + +**未提交任何内容(`git add` / `git commit` 均未执行,按本次执行指令保留在工作树)。** + +## Next Phase Readiness + +- 构建脚本已按新引擎产出结构就位,可继续由用户处理:INFO-1 的 jsonc loader 适配、INFO-2 的 `styles.css` 接线、渲染端两处 stale import。 +- `package.json` 的 `build:packages` 已指向被删除的脚本,需在后续任务中调整(本计划按约束未改)。 + +--- + +*Phase: 09-minimum-browser-runnable* +*Completed: 2026-10-20* diff --git a/.planning/phases/09-minimum-browser-runnable/09-CONTEXT.md b/.planning/phases/09-minimum-browser-runnable/09-CONTEXT.md index 6e5b3e8..be8f1e7 100644 --- a/.planning/phases/09-minimum-browser-runnable/09-CONTEXT.md +++ b/.planning/phases/09-minimum-browser-runnable/09-CONTEXT.md @@ -197,6 +197,19 @@ - **D-91(范围边界):** 仅实现插件系统机制本身;不改入口/接线;不改 `initializeL0..L3` 等 config 字段口径(用户自行处理);不新增依赖;遵循 `dev.md`(无循环引用、命名、jsDoc、无副作用模块)。 - **D-92(插件错误处理与日志码,修订 D-87):** 插件系统用**新错误码**(`packages/common/src/logger.json` error 段新增):**循环依赖 → 76**;**依赖不存在的插件 → 77**;**重复 id → 78**。**重复 id 不覆盖原插件**(保留先注册者,重复者不注册)。**报错的插件全部不进入加载流程**(`awake`/`initialize`/`afterLoad` 均不执行;其下游依赖者亦不加载)。取代 D-87 中「复用 error 75」的口径;错误消息文本待用户批准。(2026-10-14 用户裁定) +### 任务 11(当前规划单元:构建重构) + +> 本节界定**本次规划运行**的范围:任务 11 = 重构构建脚本以适配新引擎(`script/build-game.ts` / `script/build-resource.ts`),删除 `build-packages.ts` 与 `build-lib.ts`。`script/dev.ts` **不在范围**。 + +- **D-93(范围):** 重构 `script/build-game.ts` 与 `script/build-resource.ts`;**删除** `script/build-packages.ts` 与 `script/build-lib.ts`(后者为 vite-plugin-dts 遗留,不需要类型声明);`script/dev.ts` 不在本次范围。 +- **D-94(数据端/渲染端分离 + Vite 插件检测):** 保证**数据端独立包不得引用渲染端**。写一个 **Vite 插件**在构建数据端时扫描其模块图,若含渲染端模块(`@user/client-*`、`@motajs/render*`、`vue` 等)则**报错并终止**构建。**取代**现有按构建体积推断的 `checkClientData` 启发式。 +- **D-95(数据端产物):** 数据端构建为**库模式**、输出 **ESM**(**不用 iife**),入口 `src/data.ts`,产物 `dist/data/data.js`。 +- **D-96(content 处理):** 构建后把 `src/content` 复制到 `dist/content`(去掉 `src` 前缀);做**简单压缩**——**jsonc** 用 `JSON.parse + JSON.stringify` 压缩(**去掉注释**,**后缀仍保留 `.jsonc`** 以确保加载不出错);**字体**用 fontmin 压缩;**不做资源分块**(不生成 `resource..h5data`),**不做 zip 压缩**。`build-resource` 改为从 `src/content` 读取素材。 +- **D-97(删除旧模板内容):** **不保留** `public/**`(即将整体删除)、`editor.html`、`_server`、`_docs`、`libs`、`logo.png`、`styles.css`、`main.js`、`script/template/启动服务.exe`;**不打包** `source-code.zip`;**不加** `loadList.json`;**保留 `LICENSE`(LGPL v3)**。`index.html` 去掉旧 ``,样式改由入口经 JS 打包引入。 +- **D-98(脚本与 npm script 调整):** 删除 `build-packages.ts` / `build-lib.ts` 及 `package.json` 中对应 `build:packages` / `build:lib` script;`build:game` 保留并调整(同时产出渲染端游戏与数据端独立包)。 +- **D-99(类型检查前置):** 构建仍**前置 `vue-tsc --noEmit`**(保留)。 +- **D-100(渲染端构建微调):** 移除 main.js 二次压缩(babel)步骤(`main.js` 不留);legacy 插件与 `manualChunks` 按新引擎实际情况微调。 + ### the agent's Discretion - 无。用户明确未授权 AI 自行决定 UI 形态或任务范围。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70a850f..db9d869 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -674,12 +674,6 @@ importers: '@user/data-state': specifier: workspace:* version: link:../packages-user/data-state - '@user/entry-client': - specifier: workspace:* - version: link:../packages-user/entry-client - '@user/entry-data': - specifier: workspace:* - version: link:../packages-user/entry-data packages: diff --git a/script/build-game.ts b/script/build-game.ts index 688fb95..c698e95 100644 --- a/script/build-game.ts +++ b/script/build-game.ts @@ -1,21 +1,10 @@ import { build, loadConfigFromFile, mergeConfig, UserConfig } from 'vite'; import legacy from '@vitejs/plugin-legacy'; import { resolve } from 'path'; -import { copy, emptyDir, ensureDir, pathExists } from 'fs-extra'; -import { OutputAsset, OutputChunk, RollupOutput } from 'rollup'; -import Fontmin from 'fontmin'; -import { readdir, readFile, rmdir, stat, writeFile } from 'fs/promises'; -import { transformAsync } from '@babel/core'; -import archiver from 'archiver'; -import { createWriteStream } from 'fs'; -import { zip } from 'compressing'; -import { RequiredData, RequiredIconsData } from './types'; -import { - CompressedUsage, - LoadDataType, - splitResource, - SplittedResource -} from './build-resource'; +import { copy, emptyDir, ensureDir } from 'fs-extra'; +import { readdir, readFile, stat } from 'fs/promises'; +import { dataIsolationPlugin } from './build-plugin'; +import { buildContent } from './build-resource'; import { formatSize } from './utils'; /** 打包调试 */ @@ -27,15 +16,56 @@ const ansi = { clear: '\x1b[2J\x1b[0f' }; -// 资源分离步骤的单包大小,默认 2M,可以自行调整 -const RESOUCE_SIZE = 2 * 2 ** 20; - const distDir = resolve(process.cwd(), 'dist'); const tempDir = resolve(process.cwd(), '_temp'); +const enum ProgressStatus { + Success, + Fail, + Working, + Warn +} + /** - * 构建游戏代码 - * @param entry 入口文件 + * 输出构建步骤 + * @param step 步骤数 + * @param final 最后一步的状态 + */ +function logProgress(step: number, final: ProgressStatus) { + const list = [ + `1. 构建前准备`, + `2. 构建渲染端`, + `3. 构建数据端`, + `4. 处理 content`, + `5. 收尾` + ]; + + const str = list.reduce((prev, curr, idx) => { + if (idx > step) { + return prev; + } else if (idx === step) { + switch (final) { + case ProgressStatus.Fail: + return prev + `❌ ${curr}\r\n错误信息:\r\n`; + case ProgressStatus.Success: + return prev + `✅ ${curr}\r\n`; + case ProgressStatus.Working: + return prev + `🔄 ${curr}\r\n`; + case ProgressStatus.Warn: + return prev + `⚠️ ${curr}\r\n`; + } + } else { + return prev + `✅ ${curr}\r\n`; + } + }, ''); + + process.stdout.write(ansi.clear); + process.stdout.write(str); +} + +/** + * 构建渲染端代码,以 index.html 为默认入口输出到 dist + * @param outDir 输出目录 */ async function buildClient(outDir: string) { const configFile = resolve(process.cwd(), 'vite.config.ts'); @@ -59,7 +89,8 @@ async function buildClient(outDir: string) { ], build: { outDir, - copyPublicDir: true, + copyPublicDir: false, + emptyOutDir: false, minify: !DEBUG_BUILD, rollupOptions: { external: ['@wasm-audio-decoders/opus-ml'], @@ -88,6 +119,11 @@ async function buildClient(outDir: string) { }); } +/** + * 以库模式构建数据端代码,入口产物固定为 dist/data.js + * @param outDir 输出目录 + * @param entry 数据端入口文件 + */ async function buildData(outDir: string, entry: string) { const configFile = resolve(process.cwd(), 'vite.config.ts'); const config = await loadConfigFromFile( @@ -95,16 +131,24 @@ async function buildData(outDir: string, entry: string) { configFile ); const resolved = mergeConfig(config?.config ?? {}, { + plugins: [dataIsolationPlugin()], build: { outDir, copyPublicDir: false, + emptyOutDir: false, + minify: !DEBUG_REPLAY, lib: { entry, - name: 'ProcessData', fileName: 'data', - formats: ['iife'] + formats: ['es'] }, - minify: !DEBUG_REPLAY + rollupOptions: { + output: { + entryFileNames: 'data.js', + chunkFileNames: 'data/[name].[hash].js', + assetFileNames: 'data/[name].[hash][extname]' + } + } } } satisfies UserConfig); @@ -114,590 +158,117 @@ async function buildData(outDir: string, entry: string) { }); } -const enum ProgressStatus { - Success, - Fail, - Working, - Warn -} - /** - * 输出构建步骤 - * @param step 步骤数 - * @param final 最后一步的状态 + * 递归收集目录下的文件 + * @param dir 待遍历目录 + * @param output 收集结果 + * @param extension 需要保留的扩展名 + * @param ignored 需要跳过的目录 */ -function logProgress(step: number, final: ProgressStatus) { - const list = [ - `1. 构建前准备`, - `2. 构建客户端代码`, - `3. 构建数据端代码`, - `4. 压缩 main.js`, - `5. 压缩字体`, - `6. 资源分块`, - `7. 最后处理`, - `8. 压缩为 zip` - ]; - - const str = list.reduce((prev, curr, idx) => { - if (idx > step) { - return prev; - } else if (idx === step) { - switch (final) { - case ProgressStatus.Fail: - return prev + `❌ ${curr}\r\n错误信息:\r\n`; - case ProgressStatus.Success: - return prev + `✅ ${curr}\r\n`; - case ProgressStatus.Working: - return prev + `🔄 ${curr}\r\n`; - case ProgressStatus.Warn: - return prev + `⚠️ ${curr}\r\n`; - } - } else { - return prev + `✅ ${curr}\r\n`; - } - }, ''); - - process.stdout.write(ansi.clear); - process.stdout.write(str); -} - -/** - * 获取规范化文件名 - * @param output Rollup 输出 - * @param client 是否是客户端代码 - */ -function getFileName(output: OutputChunk | OutputAsset, client: boolean) { - const name = output.fileName; - if (name.startsWith('index-legacy') && client) { - return 'main'; - } - if (name.startsWith('data') && !client) { - return 'main'; - } - if (name.startsWith('index.html') && client) { - return 'index'; - } - const index = name.indexOf('-legacy'); - const unhash = name.slice(0, index); - return unhash; -} - -/** - * 获取文件大小 - * @param output Rollup 输出 - */ -function getFileSize(output: OutputChunk | OutputAsset) { - if (output.type === 'asset') { - if (typeof output.source === 'string') { - return Buffer.byteLength(output.source); - } else { - return output.source.byteLength; - } - } else { - return Buffer.byteLength(output.code); - } -} - -const enum ClientDataLevel { - Error, - Suspect, - Pass -} - -/** - * 检查客户端与数据端文件大小,并给出数据端引用客户端的可能性 - * @param client 客户端文件大小 - * @param data 数据端文件大小 - */ -function checkClientData( - client: Map, - data: Map +async function collectFiles( + dir: string, + output: string[], + extension: string, + ignored: string[] = [] ) { - let error = false; - let warn = false; - - const clientMain = client.get('main'); - const dataMain = data.get('main'); - - if (clientMain && dataMain) { - if (clientMain <= dataMain) { - error = true; - } else if (clientMain / 2 < dataMain) { - warn = true; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = resolve(dir, entry.name); + if (entry.isDirectory()) { + if (ignored.includes(full)) continue; + await collectFiles(full, output, extension, ignored); + } else if (entry.name.endsWith(extension)) { + output.push(full); } } - - let clientTotal = 0; - let dataTotal = 0; - - client.forEach(v => { - clientTotal += v; - }); - - data.forEach(v => { - dataTotal += v; - }); - - if (clientTotal <= dataTotal) { - error = true; - } - - if (clientTotal / 4 < dataTotal) { - warn = true; - } - - if (error) return ClientDataLevel.Error; - else if (warn) return ClientDataLevel.Suspect; - else return ClientDataLevel.Pass; } -async function getAllChars(client: RollupOutput[]) { +/** + * 收集渲染端产物与 content 中出现的全部字符,用于字体子集化 + */ +async function getAllChars(): Promise { const chars = new Set(); - - // 1. 客户端构建结果 - client.forEach(v => { - v.output.forEach(v => { - if (v.type === 'chunk' && v.fileName.startsWith('index')) { - const set = new Set(v.code); - set.forEach(v => chars.add(v)); - } - }); - }); - - // 2. 样板内容 const files: string[] = []; - files.push(resolve(tempDir, 'client/main.js')); - files.push(resolve(tempDir, 'client/project/data.js')); - files.push(resolve(tempDir, 'client/project/enemys.js')); - files.push(resolve(tempDir, 'client/project/events.js')); - files.push(resolve(tempDir, 'client/project/functions.js')); - files.push(resolve(tempDir, 'client/project/icons.js')); - files.push(resolve(tempDir, 'client/project/items.js')); - files.push(resolve(tempDir, 'client/project/maps.js')); + await collectFiles(distDir, files, '.js', [ + resolve(distDir, 'data'), + resolve(distDir, 'content') + ]); + await collectFiles(resolve(process.cwd(), 'src/content'), files, '.jsonc'); - const floors = await readdir(resolve(tempDir, 'client/project/floors')); - const ids = floors.map(v => resolve(tempDir, 'client/project/floors', v)); - files.push(...ids); + for (const file of files) { + const text = await readFile(file, 'utf-8'); + for (const char of text) chars.add(char); + } - const libs = await readdir(resolve(tempDir, 'client/libs')); - files.push(...libs.map(v => resolve(tempDir, 'client/libs', v))); - - await Promise.all( - files.map(async v => { - const stats = await stat(v); - if (!stats.isFile()) return; - const file = await readFile(v, 'utf-8'); - const set = new Set(file); - set.forEach(v => chars.add(v)); - }) - ); - - return chars; + return [...chars].join(''); } -interface CompressedLoadListItem { - readonly readAs: LoadDataType; - readonly name: string; - readonly usage: CompressedUsage; -} - -type CompressedLoadList = Record; - /** - * 生成资源地图 json 文件 + * 递归统计目录体积 + * @param dir 待统计目录 */ -function generateResourceJSON(resources: SplittedResource[]) { - const list: CompressedLoadList = {}; - - resources.forEach(file => { - const uri = `project/resource/${file.fileName}`; - file.content.forEach(content => { - const item: CompressedLoadListItem = { - readAs: content.readAs, - name: content.name, - usage: content.usage - }; - list[uri] ??= []; - list[uri].push(item); - }); - }); - - return JSON.stringify(list); +async function getDirectorySize(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + let total = 0; + for (const entry of entries) { + const full = resolve(dir, entry.name); + if (entry.isDirectory()) { + total += await getDirectorySize(full); + } else { + total += (await stat(full)).size; + } + } + return total; } async function buildGame() { logProgress(0, ProgressStatus.Working); - //#region 准备步骤 - try { - await ensureDir(distDir); - await ensureDir(tempDir); - await emptyDir(distDir); - await emptyDir(tempDir); - await ensureDir(resolve(tempDir, 'fonts')); - await ensureDir(resolve(tempDir, 'common')); - await ensureDir(resolve(tempDir, 'resource')); - } catch (e) { - logProgress(0, ProgressStatus.Fail); - process.stderr.write(String(e)); - process.exit(1); - } + await ensureDir(distDir); + await ensureDir(tempDir); + await emptyDir(distDir); + await emptyDir(tempDir); logProgress(1, ProgressStatus.Working); - //#region 构建客户端 - const clientPack = await buildClient(resolve(tempDir, 'client')).catch( - reason => { - logProgress(1, ProgressStatus.Fail); - process.stderr.write(String(reason)); - process.exit(1); - } - ); + await buildClient(distDir); logProgress(2, ProgressStatus.Working); - //#region 构建数据端 - const dataPack = await buildData( - resolve(tempDir, 'data'), - resolve(process.cwd(), 'src/data.ts') - ).catch(reason => { - logProgress(2, ProgressStatus.Fail); - process.stderr.write(String(reason)); - process.exit(1); - }); - - const clientSize = new Map(); - const dataSize = new Map(); - - const clientPackArr = []; - const dataPackArr = []; - - // 判断客户端与数据端的构建包大小,从而推断是否出现了数据端引用客户端的问题 - - if (clientPack instanceof Array) { - clientPackArr.push(...clientPack); - } else if ('close' in clientPack) { - // pass. - } else { - clientPackArr.push(clientPack); - } - if (dataPack instanceof Array) { - dataPackArr.push(...dataPack); - } else if ('close' in dataPack) { - // pass. - } else { - dataPackArr.push(dataPack); - } - - // 获取每个 chunk 的大小 - clientPackArr.forEach(v => { - v.output.forEach(v => { - const name = getFileName(v, true); - const size = getFileSize(v); - clientSize.set(name, size); - }); - }); - dataPackArr.forEach(v => { - v.output.forEach(v => { - const name = getFileName(v, false); - const size = getFileSize(v); - dataSize.set(name, size); - }); - }); - - const level = checkClientData(clientSize, dataSize); - - if (level === ClientDataLevel.Error) { - logProgress(2, ProgressStatus.Fail); - process.stderr.write( - `数据端似乎引用了客户端内容,请仔细检查后再构建!` - ); - process.exit(1); - } - - // 解析全塔属性 - const dataFile = await readFile( - resolve(process.cwd(), 'public/project/data.js'), - 'utf-8' - ); - const dataObject: RequiredData = JSON.parse( - dataFile.split('\n').slice(1).join('\n') - ); - const mainData = dataObject.main; + await buildData(distDir, resolve(process.cwd(), 'src/data.ts')); logProgress(3, ProgressStatus.Working); - //#region 压缩 main - try { - const main = await readFile( - resolve(tempDir, 'client/main.js'), - 'utf-8' - ); - const [head, tail] = main.split('// >>>> body end'); - const transformed = await transformAsync(tail, { - presets: [['@babel/preset-env']], - sourceType: 'script', - minified: true, - comments: false - }); - if (!transformed || !transformed.code) { - throw new ReferenceError( - `Cannot write main.js since transform result is empty.` - ); - } - const code = transformed.code; - const modifiedHead = head.replace( - 'this.scriptCompress = false;', - 'this.scriptCompress = true;' - ); - await writeFile( - resolve(tempDir, 'common/main.js'), - modifiedHead + '\n// >>>> body end\n' + code, - 'utf-8' - ); - } catch (e) { - logProgress(3, ProgressStatus.Fail); - process.stderr.write(String(e)); - process.exit(1); - } - - //#region 压缩字体 - const chars = await getAllChars(clientPackArr).catch(reason => { - logProgress(4, ProgressStatus.Fail); - process.stderr.write(String(reason)); - process.exit(1); - }); - const { fonts } = mainData; - - await Promise.all( - fonts.map(v => { - const fontmin = new Fontmin(); - const src = resolve(tempDir, 'client/project/fonts', v); - const dest = resolve(tempDir, 'fonts'); - const plugin = Fontmin.glyph({ - text: [...chars].join('') - }); - fontmin.src(src).dest(dest).use(plugin); - return fontmin.runAsync(); - }) - ).catch(reason => { - logProgress(4, ProgressStatus.Fail); - process.stderr.write(String(reason)); - process.exit(1); + const chars = await getAllChars(); + const content = await buildContent({ + sourceDir: resolve(process.cwd(), 'src/content'), + outputDir: resolve(distDir, 'content'), + chars }); - logProgress(5, ProgressStatus.Working); + logProgress(4, ProgressStatus.Working); - //#region 资源分块 - const iconsFile = await readFile( - resolve(process.cwd(), 'public/project/icons.js'), - 'utf-8' - ); - const iconsObject: RequiredIconsData = JSON.parse( - iconsFile.split('\n').slice(1).join('\n') - ); - const resources = await splitResource( - dataObject, - iconsObject, - resolve(tempDir, 'client'), - resolve(tempDir, 'fonts'), - RESOUCE_SIZE - ).catch(reason => { - logProgress(5, ProgressStatus.Fail); - process.stderr.write(String(reason)); - process.exit(1); - }); + await copy(resolve(process.cwd(), 'LICENSE'), resolve(distDir, 'LICENSE')); - await Promise.all( - resources.map(v => - writeFile(resolve(tempDir, 'resource', v.fileName), v.buffer) - ) - ).catch(reason => { - logProgress(5, ProgressStatus.Fail); - process.stderr.write(String(reason)); - process.exit(1); - }); + logProgress(4, ProgressStatus.Success); - logProgress(6, ProgressStatus.Working); + const dataStats = await stat(resolve(distDir, 'data.js')); + const totalSize = await getDirectorySize(distDir); - //#region 最后处理 - - const toCopy = [ - 'libs', - '_server', - 'extensions', - 'index.html', - 'editor.html', - 'styles.css', - 'logo.png', - 'project/floors', - 'project/data.js', - 'project/enemys.js', - 'project/events.js', - 'project/functions.js', - 'project/icons.js', - 'project/items.js', - 'project/maps.js', - 'project/plugins.js', - 'project/materials/airwall.png' - ]; - - clientPackArr.forEach(v => { - v.output.forEach(v => { - toCopy.push(v.fileName); - }); - }); - - try { - await Promise.all( - toCopy.map(v => - copy(resolve(tempDir, 'client', v), resolve(distDir, v)) - ) - ); - await copy( - resolve(tempDir, 'resource'), - resolve(distDir, 'project/resource') - ); - await copy( - resolve(tempDir, 'common/main.js'), - resolve(distDir, 'main.js') - ); - await copy( - resolve(tempDir, 'data/data.iife.js'), - resolve(distDir, 'data.process.js') - ); - - await Promise.all( - dataObject.main.bgms.map(v => - copy( - resolve(tempDir, 'client/project/bgms', v), - resolve(distDir, 'project/bgms', v) - ) - ) - ); - - const scripts = archiver('zip', { - store: false, - zlib: { - level: 9 - } - }); - scripts.directory(resolve(process.cwd(), 'packages'), 'packages/'); - scripts.directory( - resolve(process.cwd(), 'packages-user'), - 'packages-user/' - ); - scripts.directory(resolve(process.cwd(), 'src'), '/src'); - scripts.file(resolve(process.cwd(), 'public', 'main.js'), { - name: 'main.js' - }); - - const output = createWriteStream(resolve(distDir, 'source-code.zip')); - scripts.pipe(output); - - output.on('error', err => { - throw err; - }); - - await new Promise(res => { - output.on('finish', () => res()); - scripts.finalize(); - }); - - const json = generateResourceJSON(resources); - await writeFile(resolve(distDir, 'loadList.json'), json, 'utf-8'); - - await copy( - resolve(process.cwd(), 'LICENSE'), - resolve(distDir, 'LICENSE') - ); - - await copy( - resolve(process.cwd(), 'script/template/启动服务.exe'), - resolve(distDir, '启动服务.exe') - ); - - const bgPath = 'project/images/bg.jpg'; - - if (await pathExists(resolve(tempDir, 'client', bgPath))) { - await copy( - resolve(tempDir, 'client', bgPath), - resolve(distDir, bgPath) - ); - } - } catch (e) { - logProgress(6, ProgressStatus.Fail); - process.stderr.write(String(e)); - process.exit(1); - } - - logProgress(7, ProgressStatus.Working); - - //#region 压缩游戏 - - try { - await zip.compressDir( - resolve(distDir), - resolve(process.cwd(), 'dist.zip'), - { - compress: true, - compressionLevel: 9 - } - ); - - await emptyDir(tempDir); - await rmdir(tempDir); - } catch (e) { - logProgress(7, ProgressStatus.Fail); - process.stderr.write(String(e)); - process.exit(1); - } - - //#region 输出构建信息 - - const sourceStats = await stat(resolve(distDir, 'source-code.zip')); - const sourceSize = sourceStats.size; - const zipStats = await stat(resolve(process.cwd(), 'dist.zip')); - const zipSize = zipStats.size; - const resourceSize = resources.reduce( - (prev, curr) => prev + curr.byteLength, - 0 - ); - - process.stdout.write(ansi.clear); process.stdout.write(`✅ 构建已完成!\r\n`); - if (zipSize > 100 * 2 ** 20) { - process.stdout.write( - `⚠️ 压缩包大于 100M,可能导致发塔困难,请考虑降低塔的大小\r\n` - ); - const suggections: string[] = []; - if (dataObject.main.bgms.some(v => !v.endsWith('opus'))) { - suggections.push(`将 BGM 和音效换用 opus 格式`); - } - if (dataObject.main.images.some(v => !v.endsWith('webp'))) { - suggections.push(`将图片换用无损 webp 格式`); - } - if (suggections.length > 0) { - process.stdout.write(`降低压缩包体积的可能方案:\r\n`); - suggections.forEach((v, i) => { - process.stdout.write(`${i + 1}. ${v}\r\n`); - }); - } - } - process.stdout.write(`压缩包大小:${formatSize(zipSize)}\r\n`); - process.stdout.write(`源码大小:${formatSize(sourceSize)}\r\n`); - process.stdout.write(`资源大小:${formatSize(resourceSize)}\r\n`); - resources.forEach(v => { - process.stdout.write( - `--> ${v.fileName} ${formatSize(v.byteLength)} | ${v.content.length} 个资源\r\n` - ); - }); + process.stdout.write( + `数据端入口:data.js(${formatSize(dataStats.size)})\r\n` + ); + process.stdout.write( + `content:${content.files} 个文件,其中 ${content.jsonc} 个 jsonc、${content.fonts} 个字体\r\n` + ); + process.stdout.write(`dist 体积:${formatSize(totalSize)}\r\n`); process.stdout.write(`游戏已经输出至 dist 文件夹\r\n`); - process.stdout.write(`游戏已压缩至 dist.zip 压缩文件\r\n`); } // Execute -(() => { - buildGame(); -})(); +buildGame().catch(reason => { + process.stderr.write(String(reason)); + process.exit(1); +}); diff --git a/script/build-packages.ts b/script/build-packages.ts deleted file mode 100644 index b3547d7..0000000 --- a/script/build-packages.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { build, loadConfigFromFile, mergeConfig, UserConfig } from 'vite'; -import path from 'path'; -import fs from 'fs-extra'; - -const packagesDir = path.resolve('./packages'); -const outputDir = path.resolve('./dist/packages'); - -// 清空 dist 目录 -fs.emptyDirSync(outputDir); - -// 获取所有包目录 -const packageDirs = fs.readdirSync(packagesDir).filter(name => { - const packagePath = path.join(packagesDir, name); - return fs.statSync(packagePath).isDirectory(); -}); - -// 构建每一个包 -async function buildPackages() { - for (const packageName of packageDirs) { - const packageDir = path.join(packagesDir, packageName); - const configFile = path.resolve('./vite.config.ts'); - const config = await loadConfigFromFile( - { command: 'build', mode: 'production' }, - configFile - ); - const resolved = mergeConfig(config?.config ?? {}, { - build: { - lib: { - entry: path.join(packageDir, 'src/index.ts'), - name: packageName, - formats: ['es'], - fileName: format => `${packageName}.${format}.js` - }, - outDir: path.join(outputDir, packageName), - sourcemap: true, - emptyOutDir: true, - rollupOptions: { - external: [/node_modules/, /^@motajs\/.*/] - } - }, - publicDir: false - } satisfies UserConfig); - - await build(resolved); - console.log(`✅ Package ${packageName} built successfully.`); - } -} - -buildPackages().catch(e => { - console.error(e); - process.exit(1); -}); diff --git a/script/build-plugin.test.ts b/script/build-plugin.test.ts new file mode 100644 index 0000000..60ba25e --- /dev/null +++ b/script/build-plugin.test.ts @@ -0,0 +1,144 @@ +import { build } from 'vite'; +import { RollupOutput } from 'rollup'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { dataIsolationPlugin } from './build-plugin'; + +const tempDirs: string[] = []; + +/** 创建独立的临时构建根目录,并登记以便用例结束后统一清理 */ +async function createRoot() { + const root = await mkdtemp(join(tmpdir(), 'mota-data-isolation-')); + tempDirs.push(root); + return root; +} + +/** 在临时根目录下写入文件,必要时创建父目录,返回写入后的绝对路径 */ +async function writeRootFile(root: string, name: string, content: string) { + const file = join(root, name); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, content, 'utf8'); + return file; +} + +/** 以数据端库模式 ESM 配置执行一次真实的 vite 构建 */ +function buildFixture(root: string, entry: string) { + return build({ + configFile: false, + root, + logLevel: 'silent', + build: { + write: false, + minify: false, + copyPublicDir: false, + lib: { + entry, + formats: ['es'], + fileName: 'data' + } + }, + plugins: [dataIsolationPlugin()] + }); +} + +/** 执行构建并返回错误信息,构建成功时返回空字符串 */ +async function getBuildError(root: string, entry: string) { + try { + await buildFixture(root, entry); + } catch (e) { + return String(e); + } + return ''; +} + +afterEach(async () => { + const dirs = tempDirs.splice(0); + await Promise.all( + dirs.map(dir => rm(dir, { recursive: true, force: true })) + ); +}); + +describe('dataIsolationPlugin', () => { + // 直接引入 vue 说明符的数据端模块必须被插件拒绝 + it('rejects a render-side specifier', async () => { + const root = await createRoot(); + const entry = await writeRootFile(root, 'entry.ts', "import 'vue';\n"); + + const message = await getBuildError(root, entry); + + expect(message).toContain('[mota-data-isolation]'); + expect(message).toContain('matched: vue'); + expect(message).toContain( + 'data-side build must not depend on render-side' + ); + }); + + // 相对引入的 src/client 渲染目录必须按解析路径被 moduleParsed 拒绝 + it('rejects a resolved src/client module', async () => { + const root = await createRoot(); + await writeRootFile( + root, + 'src/client/ui/fake.ts', + 'export const fake = 1;\n' + ); + const entry = await writeRootFile( + root, + 'entry.ts', + "import './src/client/ui/fake';\n" + ); + + const message = await getBuildError(root, entry); + + expect(message).toContain('[mota-data-isolation]'); + expect(message).toContain('matched: src/client'); + }); + + // 相对引入的 packages-user/client-* 必须按解析路径被 moduleParsed 拒绝 + it('rejects a resolved packages-user client package', async () => { + const root = await createRoot(); + await writeRootFile( + root, + 'packages-user/client-modules/fake.ts', + 'export const fake = 1;\n' + ); + const entry = await writeRootFile( + root, + 'entry.ts', + "import './packages-user/client-modules/fake';\n" + ); + + const message = await getBuildError(root, entry); + + expect(message).toContain('[mota-data-isolation]'); + expect(message).toContain('matched: packages-user/client-'); + }); + + // 纯数据端依赖必须通过构建并产出 ESM 代码 + it('passes a pure data-side graph and emits ESM', async () => { + const root = await createRoot(); + await writeRootFile( + root, + 'packages-user/data-common/fake.ts', + 'export const value = 1;\n' + ); + const entry = await writeRootFile( + root, + 'entry.ts', + "export { value } from './packages-user/data-common/fake';\n" + ); + + const result = (await buildFixture(root, entry)) as + | RollupOutput + | RollupOutput[]; + const outputs = Array.isArray(result) ? result : [result]; + const code = outputs + .flatMap(v => v.output) + .filter(v => v.type === 'chunk') + .map(v => v.code) + .join('\n'); + + expect(code).toContain('export'); + }); +}); diff --git a/script/build-plugin.ts b/script/build-plugin.ts new file mode 100644 index 0000000..fbde89a --- /dev/null +++ b/script/build-plugin.ts @@ -0,0 +1,78 @@ +import { Plugin } from 'vite'; +import { ModuleInfo } from 'rollup'; + +/** 数据端构建中禁止出现的渲染端模块说明符,第一项为匹配规则,第二项为报错时的命中标记 */ +const SPECIFIER_BANS: ReadonlyArray = [ + [/^@user\/client-/, '@user/client-'], + [/^@user\/entry-client(?:\/|$)/, '@user/entry-client'], + [/^@motajs\/render(?:[/-]|$)/, '@motajs/render'], + [/^@motajs\/client(?:[/-]|$)/, '@motajs/client'], + [/^@motajs\/system(?:[/-]|$)/, '@motajs/system'], + [/^@motajs\/audio(?:[/-]|$)/, '@motajs/audio'], + [/^@motajs\/legacy-/, '@motajs/legacy-'], + [/^vue(?:\/|$)/, 'vue'], + [/^@vue\//, '@vue/'], + [/^ant-design-vue(?:\/|$)/, 'ant-design-vue'], + [/^@ant-design\/icons-vue(?:\/|$)/, '@ant-design/icons-vue'] +]; + +/** 数据端构建中禁止出现的渲染端模块解析路径,第一项为匹配规则,第二项为报错时的命中标记 */ +const PATH_BANS: ReadonlyArray = [ + [/[/\\]packages-user[/\\]client-/, 'packages-user/client-'], + [/[/\\]packages[/\\]render(?:[/\\-]|$)/, 'packages/render'], + [/[/\\]packages[/\\]render-vue(?:[/\\-]|$)/, 'packages/render-vue'], + [/[/\\]packages[/\\]client(?:[/\\-]|$)/, 'packages/client'], + [/[/\\]packages[/\\]system(?:[/\\-]|$)/, 'packages/system'], + [/[/\\]packages[/\\]audio(?:[/\\-]|$)/, 'packages/audio'], + [/[/\\]packages[/\\]legacy-/, 'packages/legacy-'], + [/[/\\]src[/\\]client(?:[/\\]|$)/, 'src/client'], + [ + /[/\\]node_modules[/\\](?:vue|@vue|ant-design-vue|@ant-design)(?:[/\\]|$)/, + 'node_modules' + ] +]; + +/** + * 在禁令集合中查找命中的规则 + * @param bans 禁令集合 + * @param value 待匹配的模块说明符或解析路径 + */ +function findBan( + bans: ReadonlyArray, + value: string +) { + return bans.find(ban => ban[0].test(value)); +} + +/** + * 创建数据端构建的隔离检查插件,禁止数据端模块图引用任何渲染端模块。 + * 说明符经 `resolveId` 拦截,别名解析后的真实路径经 `moduleParsed` 兜底。 + */ +export function dataIsolationPlugin(): Plugin { + return { + name: 'mota-data-isolation', + enforce: 'pre', + apply: 'build', + resolveId(source, importer) { + const ban = findBan(SPECIFIER_BANS, source); + if (ban) { + this.error( + `[mota-data-isolation] data-side build must not depend on render-side module "${source}" ` + + `(matched: ${ban[1]}). Importer: ${importer ?? ''}` + ); + } + return null; + }, + moduleParsed(info: ModuleInfo) { + const ban = findBan(PATH_BANS, info.id); + if (!ban) return; + const importers = this.getModuleInfo(info.id)!.importers; + this.error( + `[mota-data-isolation] data-side build must not depend on render-side module "${info.id}" ` + + `(matched: ${ban[1]}). Importers: ${ + importers.length > 0 ? importers.join(', ') : '' + }` + ); + } + }; +} diff --git a/script/build-resource.ts b/script/build-resource.ts index 903c65d..175fa90 100644 --- a/script/build-resource.ts +++ b/script/build-resource.ts @@ -1,225 +1,121 @@ -import JSZip from 'jszip'; -import { RequiredData, RequiredIconsData } from './types'; -import { Stats } from 'fs'; -import { readdir, readFile, stat } from 'fs/promises'; -import { resolve } from 'path'; -import { fileHash } from './utils'; +import Fontmin from 'fontmin'; +import { copy } from 'fs-extra'; +import { mkdir, readdir, readFile, writeFile } from 'fs/promises'; +import { dirname, extname, join } from 'path'; -export const enum CompressedUsage { - // ---- 系统加载内容,不可更改 - Font, - Image, - Sound, - Tileset, - Autotile, - Material, - Animate +/** 需要经 fontmin 做字形子集化的字体扩展名 */ +const FONT_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2']; + +export interface IContentBuildOptions { + /** content 素材的源目录 */ + readonly sourceDir: string; + /** content 素材的输出目录 */ + readonly outputDir: string; + /** 参与字体子集化的全部字符 */ + readonly chars: string; } -export const enum LoadDataType { - ArrayBuffer, - Uint8Array, - Blob, - Text, - JSON +export interface IContentBuildStats { + /** 处理的文件总数 */ + readonly files: number; + /** 压缩的 jsonc 文件数 */ + readonly jsonc: number; + /** 压缩的字体文件数 */ + readonly fonts: number; } -export interface ResourceInfo { - readonly name: string; - readonly readAs: LoadDataType; - readonly usage: CompressedUsage; - readonly stats: Stats; +interface IContentBuildCounter { + files: number; + jsonc: number; + fonts: number; } -export interface SplittedResource { - readonly byteLength: number; - readonly resource: JSZip; - readonly buffer: Uint8Array; - readonly fileName: string; - readonly content: Readonly[]; +/** + * 将 jsonc 文本压缩为单行纯 JSON,按行去掉全部 `//` 注释与系统标记行。 + * @param text jsonc 原文 + */ +export function minifyJSONC(text: string): string { + const body = text + .split(/\r?\n/) + .filter(line => !line.trim().startsWith('//')) + .join('\n'); + return JSON.stringify(JSON.parse(body)); } -interface ResourceContent extends ResourceInfo { - readonly content: string | Buffer | Uint8Array; - readonly exceed: boolean; +/** + * 将单个字体文件按给定字符集做字形子集化,输出到目标目录并保留原文件名。 + * @param source 字体源文件 + * @param target 目标文件路径 + * @param chars 参与子集化的全部字符 + */ +async function compressFont(source: string, target: string, chars: string) { + const fontmin = new Fontmin(); + const glyph = Fontmin.glyph({ text: chars }); + await fontmin.src(source).dest(dirname(target)).use(glyph).runAsync(); } -interface ResourcePath { - readonly name: string; - readonly path: string; - readonly usage: CompressedUsage; -} - -function getTypeByUsage(usage: CompressedUsage): LoadDataType { - switch (usage) { - case CompressedUsage.Animate: - return LoadDataType.Text; - case CompressedUsage.Autotile: - case CompressedUsage.Image: - case CompressedUsage.Tileset: - case CompressedUsage.Material: - return LoadDataType.Blob; - case CompressedUsage.Font: - case CompressedUsage.Sound: - return LoadDataType.ArrayBuffer; - } -} - -function getZipFolderByUsage(usage: CompressedUsage): string { - switch (usage) { - case CompressedUsage.Image: - return 'image'; - case CompressedUsage.Tileset: - return 'tileset'; - case CompressedUsage.Autotile: - return 'autotile'; - case CompressedUsage.Material: - return 'material'; - case CompressedUsage.Font: - return 'font'; - case CompressedUsage.Sound: - return 'sound'; - case CompressedUsage.Animate: - return 'animate'; - } -} - -function readFileOfType(path: string, type: LoadDataType) { - if (type === LoadDataType.Text) { - return readFile(path, 'utf-8'); - } else { - return readFile(path); - } -} - -async function compressFiles(files: ResourceContent[]) { - const zip = new JSZip(); - files.forEach(v => { - const dir = `${getZipFolderByUsage(v.usage)}/${v.name}`; - zip.file(dir, v.content); - }); - const buffer = await zip.generateAsync({ - type: 'uint8array', - compression: 'DEFLATE', - compressionOptions: { - level: 9 - } - }); - - const hash = fileHash(buffer); - const name = `resource.${hash}.h5data`; - - const resource: SplittedResource = { - byteLength: buffer.byteLength, - buffer: buffer, - resource: zip, - fileName: name, - content: files - }; - - return resource; -} - -export async function splitResource( - data: RequiredData, - icons: RequiredIconsData, - base: string, - fontsDir: string, - limit: number +/** + * 递归处理目录:jsonc 压缩为纯 JSON,字体做子集化,其它素材原样拷贝。 + * @param sourceDir 源目录 + * @param outputDir 输出目录 + * @param chars 参与字体子集化的全部字符 + * @param counter 统计计数器 + */ +async function processDirectory( + sourceDir: string, + outputDir: string, + chars: string, + counter: IContentBuildCounter ) { - const result: SplittedResource[] = []; + await mkdir(outputDir, { recursive: true }); + const entries = await readdir(sourceDir, { withFileTypes: true }); - // 获取所有需要分块的资源 - const { animates, fonts, images, sounds, tilesets } = data.main; - const autotiles = Object.keys(icons.autotile); - const materials = await readdir(resolve(base, 'project/materials')); + for (const entry of entries) { + const source = join(sourceDir, entry.name); + const target = join(outputDir, entry.name); - const paths: ResourcePath[] = [ - ...animates.map(v => ({ - name: `${v}.animate`, - path: resolve(base, 'project/animates', `${v}.animate`), - usage: CompressedUsage.Animate - })), - ...fonts.map(v => ({ - name: v, - path: resolve(fontsDir, v), - usage: CompressedUsage.Font - })), - ...images.map(v => ({ - name: v, - path: resolve(base, 'project/images', v), - usage: CompressedUsage.Image - })), - ...sounds.map(v => ({ - name: v, - path: resolve(base, 'project/sounds', v), - usage: CompressedUsage.Sound - })), - ...tilesets.map(v => ({ - name: v, - path: resolve(base, 'project/tilesets', v), - usage: CompressedUsage.Tileset - })), - ...autotiles.map(v => ({ - name: `${v}.png`, - path: resolve(base, 'project/autotiles', `${v}.png`), - usage: CompressedUsage.Autotile - })), - ...materials.map(v => ({ - name: v, - path: resolve(base, 'project/materials', v), - usage: CompressedUsage.Material - })) - ]; + if (entry.isDirectory()) { + await processDirectory(source, target, chars, counter); + continue; + } - const files = await Promise.all( - paths.map(async ({ path, usage, name }) => { - const stats = await stat(path); - if (!stats.isFile()) { - return Promise.reject( - new ReferenceError( - `Expected resource is a file, but get directory.` - ) - ); - } - const type = getTypeByUsage(usage); - const content = await readFileOfType(path, type); - const info: ResourceContent = { - readAs: type, - name, - usage, - stats, - content, - exceed: stats.size > limit - }; - return info; - }) + const extension = extname(entry.name); + if (extension === '.jsonc') { + const text = await readFile(source, 'utf-8'); + await writeFile(target, minifyJSONC(text), 'utf-8'); + counter.jsonc += 1; + } else if (FONT_EXTENSIONS.includes(extension)) { + await compressFont(source, target, chars); + counter.fonts += 1; + } else { + await copy(source, target); + } + counter.files += 1; + } +} + +/** + * 将 content 素材从源目录构建到输出目录,jsonc 压缩为纯 JSON,字体做子集化。 + * @param options 构建选项 + */ +export async function buildContent( + options: IContentBuildOptions +): Promise { + const counter: IContentBuildCounter = { files: 0, jsonc: 0, fonts: 0 }; + await processDirectory( + options.sourceDir, + options.outputDir, + options.chars, + counter ); - // 从小到大排序,这样的话可以尽量减小资源分块文件数量 - files.sort((a, b) => a.stats.size - b.stats.size); - - let index = 0; - while (index < files.length) { - let total = 0; - const start = index; - let i = index; - for (; i < files.length; i++) { - const file = files[i]; - if (file.exceed) { - if (i === index) i = index + 1; - break; - } else { - total += file.stats.size; - } - if (total > limit) { - break; - } - } - index = i; - const toZip = files.slice(start, index); - result.push(await compressFiles(toZip)); + if (counter.fonts === 0) { + process.stdout.write('⚠️ 未找到可压缩的字体文件,已跳过字体压缩\r\n'); } - return result; + return { + files: counter.files, + jsonc: counter.jsonc, + fonts: counter.fonts + }; }