AGC 小红书导出 skill:dist 收尾移进 vite 插件,pack 只读不写

- 新增 `xhsMinitoolArtifactPlugin`(`apply: 'build'` + `enforce: 'post'` 的 `closeBundle`):相对路径改写、去 `type="module"` / `crossorigin`、脚本移到 body 末尾、清空空目录、`copyPublicDir: false` 时的 icon 兜底都在构建收尾做完,dist 出来即合规;
- `pack.mjs` 只做白名单预检与打 zip,不再改写 dist:validate 看到的不再是「只有跑过 pack 才合规」的中间态;
- 新增 `.vite-config.test.mjs`:真跑一遍 `vite build` 断言上述收尾,并覆盖 icon 兜底、构建失败不留半成品 dist、`pruneEmptyDirs` 只删空目录;`.pack.test.mjs` 补断言 pack 不改写 index.html;
- SKILL.md 写清两段构建、以及「项目已有 vite.config 时要单独把插件加进 plugins」;`.selective_rule.txt` 记下这次职责归位的原因与验证口径;
- manifest 指纹同步(version=2026-08-26.51)。
This commit is contained in:
2026-10-06 16:10:16 +08:00
parent 7614f741e1
commit 21a2856661
7 changed files with 330 additions and 74 deletions
@@ -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"
}
]
}
@@ -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。
@@ -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 <dir>`、`--zip-out <path>` |
# 一些情况:
* 项目已有自己的 vite.config 时, 用 `defineMinitoolConfig(overrides)` 合并, 或至少把 `xhsMinitoolArtifactPlugin()` 加进 `plugins`; 漏掉插件会让 dist 保留 `type="module"` / 绝对路径, validate 会直接报错.
* 打包大小限制, 需要精简游戏内容/删减资源/压缩素材 请和用户讨论
* 如果使用了外部能力, 参考platform-abstract skill对项目先重构
* 小红书的条件可能有变化, 请以用户反馈为准, 并调整本地的构建脚本
@@ -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 = '<!doctype html><html lang="zh-CN"><head>'
+ '<meta charset="UTF-8">'
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">'
+ '<script type="module" crossorigin src="/app.js"></script>'
+ '</head><body></body></html>';
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');
@@ -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 = '<!doctype html>\n'
+ '<html lang="zh-CN">\n'
+ ' <head>\n'
+ ' <meta charset="UTF-8" />\n'
+ ' <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />\n'
+ ' <title>fixture</title>\n'
+ ' </head>\n'
+ ' <body>\n'
+ ' <div id="app"></div>\n'
+ ' <script type="module" src="/src/main.js"></script>\n'
+ ' </body>\n'
+ '</html>\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),
'<svg xmlns="http://www.w3.org/2000/svg"></svg>\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, /<script/i, '脚本没有移出 <head>');
assert.match(html, /<script src="\.\/app\.js"><\/script>/,
'app.js 未以经典脚本出现在 body 末尾');
// public/ 下的 icon 被 vite 复制;public/empty-dir 是空目录,构建后不应留在 dist。
assert.ok(existsSync(join(outDir, 'icons', 'icon-192.svg')), 'icon-192.svg 缺失');
assert.ok(existsSync(join(outDir, 'icons', 'icon-512.svg')), 'icon-512.svg 缺失');
assert.equal(existsSync(join(outDir, 'empty-dir')), false, '空目录没有被清理');
// dist 已是最终形状:没有白名单外文件,validate 零 findings,pack 直接成功。
assert.deepEqual(findUnsupportedFiles(outDir), []);
assert.deepEqual(validate(outDir).findings, []);
const zipOut = join(BASE, 'out', 'xhs-minitool.zip');
assert.equal(packMinitool({ cwd: root, zipOut }), 0);
assert.ok(existsSync(zipOut), 'zip 未生成');
});
test('关掉 copyPublicDir 时插件兜底复制小红书 icon', async () => {
const root = writeProject('fallback-icons');
writeFileSync(join(root, 'public', 'extra.txt'), 'not shipped\n', 'utf8');
const { build } = await import('vite');
const config = defineMinitoolConfig({
root,
logLevel: 'silent',
build: { copyPublicDir: false },
});
await build({ ...config, configFile: false });
const outDir = join(root, 'dist-xhs-minitool');
assert.ok(existsSync(join(outDir, 'icons', 'icon-192.svg')), '兜底 icon-192.svg 未复制');
assert.ok(existsSync(join(outDir, 'icons', 'icon-512.svg')), '兜底 icon-512.svg 未复制');
assert.equal(existsSync(join(outDir, 'extra.txt')), false, 'copyPublicDir=false 时不应复制其它 public 文件');
});
test('构建失败时不收尾:不覆盖真实报错,也不留下半成品 dist', async () => {
const root = join(BASE, 'failed-build');
mkdirSync(join(root, 'src'), { recursive: true });
mkdirSync(join(root, 'public', 'icons'), { recursive: true });
writeFileSync(join(root, 'index.html'), SOURCE_HTML, 'utf8');
writeFileSync(join(root, 'src', 'main.js'), 'this is not valid js {{\n', 'utf8');
writeFileSync(
join(root, 'public', 'icons', 'icon-192.svg'),
'<svg xmlns="http://www.w3.org/2000/svg"></svg>\n',
'utf8',
);
const { build } = await import('vite');
const config = defineMinitoolConfig({ root, logLevel: 'silent' });
await assert.rejects(
build({ ...config, configFile: false }),
(error) => {
assert.doesNotMatch(error.message, /xhs-minitool-artifact/,
'插件把自己的报错盖在了真实构建错误上');
return true;
},
);
const outDir = join(root, 'dist-xhs-minitool');
assert.equal(existsSync(join(outDir, 'index.html')), false, '失败的构建不该有 index.html');
assert.equal(existsSync(join(outDir, 'icons')), false, '失败的构建不该被插件写入 icon');
});
test('pruneEmptyDirs 只删空目录,保留有内容的目录', () => {
const root = join(BASE, 'prune');
mkdirSync(join(root, 'empty', 'nested'), { recursive: true });
mkdirSync(join(root, 'kept'), { recursive: true });
writeFileSync(join(root, 'kept', 'file.js'), 'console.log(1);\n', 'utf8');
pruneEmptyDirs(root);
assert.equal(existsSync(join(root, 'empty')), false, '空目录未删除');
assert.ok(existsSync(join(root, 'kept', 'file.js')), '有内容的目录被误删');
});
@@ -1,13 +1,13 @@
#!/usr/bin/env node
/**
* 整理小红书小工具最终提交包(纯 Node,不依赖 zip / zipinfo 系统命令):
* - 去掉构建残留与不允许的文件类型
* - 校正 HTML 资源为相对路径、去掉 module 痕迹
* - 将脚本移到 body 末尾并保持顺序
* 把已经合规的 vite 构建产物打成小红书小工具 zip(纯 Node,不依赖 zip / zipinfo 系统命令):
* - 预检产物目录:出现白名单之外的文件类型时整体失败,不改磁盘
* - 用 node:zlib 写出 zip(index.html 在 zip 根目录、不多套一层目录)
*
* dist 的收尾(相对路径、去 module 痕迹、脚本移到 body 末尾、清空目录、兜底 icon)已由
* `vite.config.xhs-minitool.mjs` 的 `xhs-minitool-artifact` 插件在构建时完成;本脚本不再改 dist。
*/
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
@@ -32,10 +32,11 @@ const FORBIDDEN_ENTRY =
const USAGE = `用法:node pack.mjs [--vite-built-dir <dir>] --zip-out <path>
把 vite 构建产物整理成符合小红书规范、根目录带 index.html 的 zip。
把 vite 构建产物打成根目录带 index.html 的 zip;产物应先由 vite.config.xhs-minitool.mjs 收尾。
--vite-built-dir <dir> vite 的构建输出目录,必须与 vite config 的 build.outDir 一致。默认 dist-xhs-minitool。
该目录出现白名单之外的文件类型时直接报错退出并列出文件,不会删除任何文件。
本脚本不改写该目录,只读它来打 zip。
--zip-out <path> 落点(例如 AGC 导出面板要求的 .export/xhs-minitool.zip):
相对路径按当前工作目录解析,父目录不存在会自动建。`;
@@ -43,8 +44,8 @@ const USAGE = `用法:node pack.mjs [--vite-built-dir <dir>] --zip-out <path>
* 打包前预检:产物目录里出现小红书白名单之外的文件类型时整体失败,列出路径但**不改磁盘**。
*
* 旧实现 `ensureAllowedOnly` 会就地删除这些文件;一旦 `--vite-built-dir` 指到源码目录,
* 就会静默删掉用户的 `.ts` / `.vue` / `.scss` 等源码。现在改为在改动产物之前拦下并报错,
* 删除与否由调用方按提示决定。
* 就会静默删掉用户的 `.ts` / `.vue` / `.scss` 等源码。现在改为在打包前拦下并报错,
* 删除与否由调用方按提示决定(dist 的合规收尾已由 vite 插件负责)。
*/
function assertNoUnsupportedFiles(viteBuiltDir) {
const unsupported = findUnsupportedFiles(viteBuiltDir);
@@ -59,49 +60,6 @@ function assertNoUnsupportedFiles(viteBuiltDir) {
);
}
function patchIndexHtml(viteBuiltDir) {
const htmlPath = join(viteBuiltDir, 'index.html');
if (!existsSync(htmlPath)) {
throw new Error(`${relative(process.cwd(), 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(/<link[^>]+rel="(?:manifest|modulepreload)"[^>]*>/gi, '');
const scripts = [];
html = html.replace(
/<script(?:\s[^>]*)?\ssrc="(\.\/[^"]+)"(?:\s[^>]*)?>\s*<\/script>\s*/gi,
(_m, src) => {
scripts.push(`<script src="${src}"></script>`);
return '';
},
);
if (scripts.length) {
if (/<\/body>/i.test(html)) {
html = html.replace(/<\/body>/i, ` ${scripts.join('\n ')}\n </body>`);
} 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;
}
@@ -8,12 +8,30 @@
* import { defineMinitoolConfig } from '<此文件路径>';
* export default defineMinitoolConfig({ plugins: [...] });
*
* 构建分两段,边界只有一条:**本配置负责把 dist 变成合规产物,pack.mjs 只负责打 zip。**
*
* - 编译段:`minitoolBaseConfig` 固定 base / target / 单入口 IIFE / 无 sourcemap 等约束。
* - 收尾段:`xhsMinitoolArtifactPlugin` 在 bundle 写盘后原地整理 dist——相对路径、去掉
* module 痕迹、脚本移到 </body> 前、清空目录、兜底 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(/<link[^>]+rel="(?:manifest|modulepreload)"[^>]*>/gi, '');
const scripts = [];
html = html.replace(
/<script(?:\s[^>]*)?\ssrc="(\.\/[^"]+)"(?:\s[^>]*)?>\s*<\/script>\s*/gi,
(_m, src) => {
scripts.push(`<script src="${src}"></script>`);
return '';
},
);
if (scripts.length) {
if (/<\/body>/i.test(html)) {
html = html.replace(/<\/body>/i, ` ${scripts.join('\n ')}\n </body>`);
} 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()],
};
/**