diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 294866c32..8b90908b4 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.50", + "version": "2026-08-26.51", "skills": [ { "name": "agc-unity-editor", @@ -204,7 +204,7 @@ "scripts/validate.mjs", "scripts/vite.config.xhs-minitool.mjs" ], - "sha256": "83f78c763407f749ffeb859843a111fe6922a77971cc84be35a6d618de4745b1" + "sha256": "25e302b74418c81511f95684f4752f227018d89ab1a3bf4e53a73d1516740f19" } ] } diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/.selective_rule.txt b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/.selective_rule.txt index a7d556b5a..cbfb8d2df 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/.selective_rule.txt +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/.selective_rule.txt @@ -71,3 +71,9 @@ - scripts/vite.config.xhs-minitool.mjs(原 packages/vite-config-example/vite.config.xhs-minitool.mjs) - references/MANUAL_CHECKS.md(原 packages/validate-script/MANUAL_CHECKS.md) - pack.mjs 的 import 改为 './validate.mjs';performance-budget.md 的门禁链接改为 '../scripts/validate.mjs';xhs/packages 已空。 + +收尾职责归位(2026-10-05) +- 背景:pack.mjs 之前会就地改写 dist(把 index.html 改成相对路径 / 去 module 痕迹 / 脚本移到 body 末尾 / 删空目录 / 兜底复制 icon),再打 zip。dist 只有跑完 pack 才合规,validate 看到的是中间态。 +- 决策:dist 收尾移进 `scripts/vite.config.xhs-minitool.mjs` 的 `xhsMinitoolArtifactPlugin`(`closeBundle` 钩子,`apply: 'build'` + `enforce: 'post'`)。构建结束 dist 即合规;`pack.mjs` 只做白名单预检与打 zip,不再写盘。 +- 口径:编译约束留在 `minitoolBaseConfig`,收尾只在插件里做一次,禁止在 pack 或项目脚本里重复。 +- 验证:`scripts/.vite-config.test.mjs` 真跑一遍 `vite build`,断言 dist 无 `type="module"` / `crossorigin` / 根路径、脚本在 body 末尾、空目录被清、`copyPublicDir: false` 时 icon 兜底;`scripts/.pack.test.mjs` 断言 pack 不再改写 index.html。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/SKILL.md index f9dd9a1ff..c693ee429 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/SKILL.md @@ -8,10 +8,10 @@ metadata: # 目标: * 实现打包脚本 `build:xhs-minitool` 支持vite构建, validate, pack, 产出zip到固定文件: .export/xhs-minitool.zip - 1. 复制(并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范. + 1. 复制(并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范. 构建分两段: `minitoolBaseConfig` 管编译约束, 配置内置的 `xhsMinitoolArtifactPlugin` 在 build 收尾时把 dist 整理成可直接打 zip 的形状(相对路径 / 去 module 痕迹 / 脚本移到 body 末尾 / 清空空目录 / 兜底 icon). **不要**在 pack 或别处重复这些收尾. 2. 使用 `scripts/validate.mjs` 验证上述配置构建产物目录, 这个脚本实现了一些硬性检查, 此外有一些手动检查项: `references/manual-checks.md` - 3. 使用 `scripts/pack.mjs` 打包成zip + 3. 使用 `scripts/pack.mjs` 打包成zip; 它只读 dist 打 zip, 不改写 dist 4. 跑通流程后请把需要的脚本配置复制到项目里(以免依赖skill), 并形成最终的打包脚本 * 不干扰正常的web构建 @@ -23,11 +23,12 @@ metadata: # 脚本参数: | 脚本 | 参数 | |----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------| -| `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(与打包时的 `--vite-built-dir` 必须是同一个目录); `defineMinitoolConfig(overrides)`(项目已有 vite.config 时用它合并) | +| `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(与打包时的 `--vite-built-dir` 必须是同一个目录); `defineMinitoolConfig(overrides)`(项目已有 vite.config 时用它合并); `xhsMinitoolArtifactPlugin()`(已有 vite.config 时单独加插件做收尾) | | `scripts/validate.mjs` | `[project]`、`--json`、`--strict` | | `scripts/pack.mjs` | `--vite-built-dir `、`--zip-out ` | # 一些情况: +* 项目已有自己的 vite.config 时, 用 `defineMinitoolConfig(overrides)` 合并, 或至少把 `xhsMinitoolArtifactPlugin()` 加进 `plugins`; 漏掉插件会让 dist 保留 `type="module"` / 绝对路径, validate 会直接报错. * 打包大小限制, 需要精简游戏内容/删减资源/压缩素材 请和用户讨论 * 如果使用了外部能力, 参考platform-abstract skill对项目先重构 * 小红书的条件可能有变化, 请以用户反馈为准, 并调整本地的构建脚本 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs index 83f5596c5..51f5585eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -81,6 +81,25 @@ test('干净产物目录仍能正常打包', () => { assert.ok(existsSync(zipOut), 'zip 未生成'); }); +test('pack 只打 zip,不再改写产物目录', () => { + const root = writeBuiltDir('no-rewrite'); + // 故意留一份「构建后还没收尾」的 index.html:type=module / crossorigin / 根路径脚本。 + const unfinalized = '' + + '' + + '' + + '' + + ''; + writeFileSync(join(root, 'index.html'), unfinalized, 'utf8'); + + const zipOut = join(BASE, 'no-rewrite-out', 'xhs-minitool.zip'); + assert.equal(packMinitool({ cwd: BASE, viteBuiltDir: 'no-rewrite', zipOut }), 0); + assert.equal( + readFileSync(join(root, 'index.html'), 'utf8'), + unfinalized, + 'pack 改写了 index.html;收尾应属于 vite.config.xhs-minitool.mjs 的插件', + ); +}); + test('CLI 拒绝不认识的参数,不写出 zip', () => { const dir = writeBuiltDir('cli'); const zipOut = join(BASE, 'cli-out.zip'); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite-config.test.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite-config.test.mjs new file mode 100644 index 000000000..ce88ab25d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite-config.test.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +/** + * vite.config.xhs-minitool.mjs 的收尾插件端到端测试:真跑一遍 `vite build`, + * 验证 dist 在 build 结束时已经合规(相对路径、无 module 痕迹、脚本在 body 末尾、 + * 空目录被清掉、icon 兜底),而 pack 只负责打 zip。 + * + * 这是本地开发辅助(`.` 前缀),不随 Skill Pack 发布。 + */ +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test, { after } from 'node:test'; + +import { packMinitool } from './pack.mjs'; +import { findUnsupportedFiles, validate } from './validate.mjs'; +import { defineMinitoolConfig, pruneEmptyDirs } from './vite.config.xhs-minitool.mjs'; + +const BASE = mkdtempSync(join(tmpdir(), 'xhs-vite-config-tests-')); + +after(() => { + rmSync(BASE, { recursive: true, force: true }); +}); + +const SOURCE_HTML = '\n' + + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' fixture\n' + + ' \n' + + ' \n' + + '
\n' + + ' \n' + + ' \n' + + '\n'; + +/** 造一个最小 vite 项目;返回项目根。 */ +function writeProject(name) { + const root = join(BASE, name); + mkdirSync(join(root, 'src'), { recursive: true }); + mkdirSync(join(root, 'public', 'icons'), { recursive: true }); + mkdirSync(join(root, 'public', 'empty-dir'), { recursive: true }); + writeFileSync(join(root, 'index.html'), SOURCE_HTML, 'utf8'); + writeFileSync( + join(root, 'src', 'main.js'), + "import './style.css';\ndocument.getElementById('app').textContent = 'ok';\n", + 'utf8', + ); + writeFileSync(join(root, 'src', 'style.css'), 'body { color: red; }\n', 'utf8'); + for (const icon of ['icon-192.svg', 'icon-512.svg']) { + writeFileSync( + join(root, 'public', 'icons', icon), + '\n', + 'utf8', + ); + } + return root; +} + +test('vite 构建收尾:dist 在 closeBundle 后即可直接打 zip', async () => { + const root = writeProject('finalized'); + const { build } = await import('vite'); + const config = defineMinitoolConfig({ root, logLevel: 'silent' }); + + await build({ ...config, configFile: false }); + const outDir = join(root, 'dist-xhs-minitool'); + + assert.ok(existsSync(join(outDir, 'index.html')), 'index.html 未生成'); + assert.ok(existsSync(join(outDir, 'app.js')), 'app.js 未生成'); + assert.ok(existsSync(join(outDir, 'app.css')), 'app.css 未生成'); + + const html = readFileSync(join(outDir, 'index.html'), 'utf8'); + const head = html.slice(0, html.search(/<\/head>/i)); + assert.doesNotMatch(html, /type="module"/i, '仍残留 type="module"'); + assert.doesNotMatch(html, /crossorigin/i, '仍残留 crossorigin'); + assert.doesNotMatch(html, /rel="modulepreload"/i, '仍残留 modulepreload'); + assert.doesNotMatch(html, /\b(?:src|href)="\//, '仍有根路径资源引用'); + assert.doesNotMatch(head, /`); - return ''; - }, - ); - - if (scripts.length) { - if (/<\/body>/i.test(html)) { - html = html.replace(/<\/body>/i, ` ${scripts.join('\n ')}\n `); - } else { - html += `\n${scripts.join('\n')}\n`; - } - } - - writeFileSync(htmlPath, html); -} - -function pruneEmptyDirs(dir) { - for (const name of readdirSync(dir)) { - const path = join(dir, name); - if (statSync(path).isDirectory()) { - pruneEmptyDirs(path); - if (readdirSync(path).length === 0) rmSync(path, { recursive: true, force: true }); - } - } -} - /* ------------------------------------------------------------------ */ /* 纯 Node zip 写入(store / deflate,UTF-8 文件名,ZIP64 直接报错) */ /* ------------------------------------------------------------------ */ @@ -273,7 +231,6 @@ function makeZip(viteBuiltDir, zipPath) { * cwd?: string, * viteBuiltDir?: string, * zipOut: string, - * publicDir?: string, * }} options */ export function packMinitool(options = {}) { @@ -283,31 +240,18 @@ export function packMinitool(options = {}) { const cwd = resolve(options.cwd || process.cwd()); const viteBuiltDir = resolve(cwd, options.viteBuiltDir || 'dist-xhs-minitool'); const zipPath = resolve(cwd, options.zipOut); - const publicDir = resolve(cwd, options.publicDir || 'public'); if (!existsSync(viteBuiltDir)) { throw new Error(`缺少 vite 构建产物目录 ${viteBuiltDir}`); } - // 先预检、后改动:白名单外的文件直接报错,不删除。指错目录时也不会误删源码。 + // 只预检、不改写:白名单外的文件直接报错。产物收尾已由 vite 插件做完,这里不应再动 dist。 assertNoUnsupportedFiles(viteBuiltDir); // 落点的父目录可能还不存在(`.export/` 是宿主按需创建的),先建再写。 mkdirSync(dirname(zipPath), { recursive: true }); - for (const icon of ['icon-192.svg', 'icon-512.svg']) { - const dest = join(viteBuiltDir, 'icons', icon); - const src = join(publicDir, 'icons', icon); - if (!existsSync(dest) && existsSync(src)) { - mkdirSync(dirname(dest), { recursive: true }); - copyFileSync(src, dest); - } - } - - pruneEmptyDirs(viteBuiltDir); - patchIndexHtml(viteBuiltDir); makeZip(viteBuiltDir, zipPath); - console.log(`\n小红书小工具包已整理:${viteBuiltDir}`); return 0; } diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs index 85cbce8b6..037b5b5a2 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs @@ -8,12 +8,30 @@ * import { defineMinitoolConfig } from '<此文件路径>'; * export default defineMinitoolConfig({ plugins: [...] }); * + * 构建分两段,边界只有一条:**本配置负责把 dist 变成合规产物,pack.mjs 只负责打 zip。** + * + * - 编译段:`minitoolBaseConfig` 固定 base / target / 单入口 IIFE / 无 sourcemap 等约束。 + * - 收尾段:`xhsMinitoolArtifactPlugin` 在 bundle 写盘后原地整理 dist——相对路径、去掉 + * module 痕迹、脚本移到 前、清空目录、兜底 icon。这样 dist 在任何时刻都是 + * 「可校验、可直接打 zip」的状态,不需要 pack 再回头改它。 + * * 下面 build.outDir 的值与打包脚本的 --vite-built-dir 是同一个目录,两处必须一致: * pack.mjs 会在打包前预检该目录;出现白名单之外的文件类型会直接报错,不会删除任何文件。 * - * 约束来源:minitool-zip-builder/references/js-compatibility.md、css-compatibility.md、 - * zip-artifact-spec.md —— Chrome 61 / ES2017 基线、相对路径、IIFE 单入口、无 sourcemap。 + * 约束来源:references/js-compatibility.md、css-compatibility.md、zip-artifact-spec.md + * —— Chrome 61 / ES2017 基线、相对路径、IIFE 单入口、无 sourcemap。 */ +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; import { defineConfig, mergeConfig } from 'vite'; /** JS 最低基线:Android 8.1 出厂 Chrome / WebView 61(完整支持 ES2017)。 */ @@ -21,6 +39,120 @@ export const MINITOOL_JS_TARGET = ['es2017', 'chrome61']; /** CSS 目标内核:Chrome 61;iOS 18.4 能力只走能力检测后的增强路径。 */ export const MINITOOL_CSS_TARGET = ['chrome61']; +/** 小红书要求包内自带的小工具 icon;publicDir 被关掉时由插件兜底复制。 */ +const FALLBACK_ICONS = ['icon-192.svg', 'icon-512.svg']; + +/** + * 把 vite 生成的绝对资源路径改为相对路径,并抹掉 module / 预加载痕迹。 + * + * 幂等:对已经处理过的 index.html 再跑一次不会重复改。缺失 index.html 时直接抛错, + * 因为那说明构建配置指错了目录,继续打 zip 只会交付一个坏包。 + * + * @param {string} outDir 已解析为绝对路径的构建输出目录 + */ +export function patchIndexHtml(outDir) { + const htmlPath = join(outDir, 'index.html'); + if (!existsSync(htmlPath)) { + throw new Error(`${htmlPath} 不存在,请先执行 vite build`); + } + + let html = readFileSync(htmlPath, 'utf8'); + + html = html.replace(/(href|src)="\/([^"]+)"/g, '$1="./$2"'); + html = html.replace(/\s+crossorigin(?:="[^"]*")?/g, ''); + html = html.replace(/\s+type="module"/gi, ''); + html = html.replace(/]+rel="(?:manifest|modulepreload)"[^>]*>/gi, ''); + + const scripts = []; + html = html.replace( + /]*)?\ssrc="(\.\/[^"]+)"(?:\s[^>]*)?>\s*<\/script>\s*/gi, + (_m, src) => { + scripts.push(``); + return ''; + }, + ); + + if (scripts.length) { + if (/<\/body>/i.test(html)) { + html = html.replace(/<\/body>/i, ` ${scripts.join('\n ')}\n `); + } else { + html += `\n${scripts.join('\n')}\n`; + } + } + + writeFileSync(htmlPath, html); +} + +/** + * publicDir 被关掉(build.copyPublicDir: false)时,把小红书要求的 icon 兜底复制进 dist。 + * + * @param {string} outDir + * @param {string} publicDir + */ +export function copyFallbackIcons(outDir, publicDir) { + if (!publicDir) return; + for (const icon of FALLBACK_ICONS) { + const dest = join(outDir, 'icons', icon); + const src = join(publicDir, 'icons', icon); + if (!existsSync(dest) && existsSync(src)) { + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(src, dest); + } + } +} + +/** 递归删除空目录;zip 里不留无意义目录条目。 */ +export function pruneEmptyDirs(dir) { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + pruneEmptyDirs(path); + if (readdirSync(path).length === 0) rmSync(path, { recursive: true, force: true }); + } + } +} + +/** + * dist 收尾插件:在 bundle 写盘后原地整理产物,让 pack 只需打 zip。 + * + * 用 `closeBundle`(最后一个构建钩子)而不是 `writeBundle`:publicDir 已复制完, + * 且排在其它插件之后,不会和还在写文件的插件抢目录。`apply: 'build'` 保证 dev server 不触发。 + * + * 注意:rollup 在构建失败时**也会**调 `closeBundle`。所以用 `buildEnd(error)` 记下失败, + * 失败就直接返回——否则会留下只复制了 icon 的半成品 dist,还会用「index.html 不存在」 + * 覆盖掉真正的语法报错。 + * + * @returns {import('vite').Plugin} + */ +export function xhsMinitoolArtifactPlugin() { + /** @type {string} */ + let outDir = ''; + /** @type {string} */ + let publicDir = ''; + let buildFailed = false; + + return { + name: 'xhs-minitool-artifact', + apply: 'build', + enforce: 'post', + configResolved(config) { + // config.build.outDir 是相对 config.root 的路径,必须自己解析成绝对路径。 + outDir = resolve(config.root, config.build.outDir); + publicDir = config.publicDir; + }, + buildEnd(error) { + buildFailed = Boolean(error); + }, + closeBundle() { + if (buildFailed) return; + // 先校验 index.html:指错目录的成功构建在这里就失败,不会先建出一堆 icon。 + patchIndexHtml(outDir); + copyFallbackIcons(outDir, publicDir); + pruneEmptyDirs(outDir); + }, + }; +} + export const minitoolBaseConfig = { base: './', build: { @@ -52,6 +184,7 @@ export const minitoolBaseConfig = { }, }, }, + plugins: [xhsMinitoolArtifactPlugin()], }; /**