xhs-minitool-creator 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/bin/create.mjs +321 -0
- package/package.json +39 -0
- package/src/index.mjs +9 -0
- package/src/pack.mjs +188 -0
- package/src/skill.mjs +188 -0
- package/src/validate.mjs +347 -0
- package/src/vite-preset.mjs +46 -0
- package/src/zip.mjs +190 -0
- package/template/.agents/skills/minitool-zip-builder/SKILL.md +47 -0
- package/template/.agents/skills/minitool-zip-builder/references/cross-platform-h5.md +69 -0
- package/template/.agents/skills/minitool-zip-builder/references/css-compatibility.md +171 -0
- package/template/.agents/skills/minitool-zip-builder/references/device-capabilities.md +169 -0
- package/template/.agents/skills/minitool-zip-builder/references/js-compatibility.md +61 -0
- package/template/.agents/skills/minitool-zip-builder/references/jsbridge-api.md +192 -0
- package/template/.agents/skills/minitool-zip-builder/references/performance-budget.md +131 -0
- package/template/.agents/skills/minitool-zip-builder/references/zip-artifact-spec.md +206 -0
- package/template/.agents/skills/minitool-zip-builder/scripts/audit_artifact.mjs +95 -0
- package/template/.agents/skills/minitool-zip-builder/scripts/audit_artifact.py +109 -0
- package/template/.agents/skills/minitool-zip-builder/skill-package.json +5 -0
- package/template/_gitignore +7 -0
- package/template/index.html +47 -0
- package/template/package.json +22 -0
- package/template/public/icons/icon-192.svg +4 -0
- package/template/public/icons/icon-512.svg +4 -0
- package/template/src/lib/storage.js +54 -0
- package/template/src/main.js +81 -0
- package/template/src/styles/app.css +229 -0
- package/template/vite.config.js +9 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 navhu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# xhs-minitool-creator
|
|
2
|
+
|
|
3
|
+
小红书「小工具」脚手架:一键创建合规项目、构建、打包、静态校验,内置官方打包规范 Skill。
|
|
4
|
+
|
|
5
|
+
- **零系统依赖**:ZIP 用纯 Node 实现,校验也是纯 Node —— 不再依赖系统 `zip` / `zipinfo` / Python,Windows / macOS / Linux 均可使用
|
|
6
|
+
- **默认即合规**:Vite 预设内置 `build.target: ['es2017', 'chrome61']`,从源头避免 ES2020 语法泄漏导致的真机白屏
|
|
7
|
+
- **以官方规范为准**:内置官方打包规范 Skill(`minitool-zip-builder`)v1.6.0,校验规则与之一致
|
|
8
|
+
|
|
9
|
+
## 快速开始
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# 创建项目(交互式)
|
|
13
|
+
npx xhs-minitool-creator my-tool
|
|
14
|
+
|
|
15
|
+
# 非交互式
|
|
16
|
+
npx xhs-minitool-creator my-tool --yes --title "我的工具" --theme "#ff2442"
|
|
17
|
+
|
|
18
|
+
cd my-tool
|
|
19
|
+
npm install
|
|
20
|
+
npm run dev # 本地预览
|
|
21
|
+
npm run build # 构建 + 打包,输出 my-tool-xhs-tool.zip
|
|
22
|
+
npm run validate # 静态校验
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
将 `my-tool-xhs-tool.zip` 上传至小红书创作服务平台即可。
|
|
26
|
+
|
|
27
|
+
## CLI 命令
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
xhs-minitool-creator <name> 创建新项目(默认子命令)
|
|
31
|
+
xhs-minitool-creator create <name> [选项] 同上
|
|
32
|
+
xhs-minitool-creator pack [选项] 整理产物并打包
|
|
33
|
+
xhs-minitool-creator validate <目录|zip> 静态校验
|
|
34
|
+
xhs-minitool-creator skill 查看内置官方 Skill 版本
|
|
35
|
+
xhs-minitool-creator skill update --url <地址> 手动更新官方 Skill
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### create 选项
|
|
39
|
+
|
|
40
|
+
| 选项 | 说明 |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `-y, --yes` | 跳过交互,全部使用默认值 |
|
|
43
|
+
| `--title <标题>` | 页面标题(默认同项目名) |
|
|
44
|
+
| `--desc <描述>` | 项目描述 |
|
|
45
|
+
| `--icon <字符>` | 首页图标字符(默认「工」) |
|
|
46
|
+
| `--theme <颜色>` | 主题色(默认 `#ff2442`) |
|
|
47
|
+
|
|
48
|
+
### 编程式使用
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
import { defineMinitoolConfig } from 'xhs-minitool-creator/vite';
|
|
52
|
+
import { pack } from 'xhs-minitool-creator';
|
|
53
|
+
import { validateDir } from 'xhs-minitool-creator/validate';
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 为什么需要这个脚手架
|
|
57
|
+
|
|
58
|
+
参考实现(`create-xhs-minitool`)存在几个长期风险:
|
|
59
|
+
|
|
60
|
+
1. 依赖系统 `zip` / `zipinfo` 二进制,Windows 不可用
|
|
61
|
+
2. 校验依赖 Python,且规则停留在旧版规范
|
|
62
|
+
3. Vite 预设未设置 `build.target`,ES2020 语法(`??`)会泄漏进产物,在低端 WebView 上白屏且无报错
|
|
63
|
+
4. 内置的官方规范快照会随官方演进而过时
|
|
64
|
+
|
|
65
|
+
本脚手架的对应方案:
|
|
66
|
+
|
|
67
|
+
1. ZIP 写入用 Node `zlib.deflateRawSync` 自实现,交叉验证过 macOS `unzip` 与 Python `zipfile`
|
|
68
|
+
2. 校验为纯 Node 静态扫描,规则对齐官方 Skill v1.6.0(含 10 MiB 硬上限、经典脚本约束、ES2017 基线)
|
|
69
|
+
3. 预设显式声明 `build.target: ['es2017', 'chrome61']`
|
|
70
|
+
4. 提供 `skill` 子命令查看版本并手动更新
|
|
71
|
+
|
|
72
|
+
## 官方规范 Skill 的更新
|
|
73
|
+
|
|
74
|
+
官方 CDN **没有稳定的 latest 端点**(URL 内含不可预测的时间戳),因此无法自动发现新版本。做法:
|
|
75
|
+
|
|
76
|
+
1. 从官方文档取得最新 `.skill` 地址
|
|
77
|
+
2. 执行 `xhs-minitool-creator skill update --url <地址>`
|
|
78
|
+
|
|
79
|
+
校验依据随 Skill 更新而变化时,请同时关注本包的新版本发布。
|
|
80
|
+
|
|
81
|
+
## 限制与说明
|
|
82
|
+
|
|
83
|
+
- 静态校验无法替代真机验证,交付前请务必在小红书真机预览
|
|
84
|
+
- 官方规则可能随时调整,关键决策以小红书创作服务平台实时规则为准
|
|
85
|
+
- 本脚手架与小红书官方无隶属关系,`minitool-zip-builder` Skill 版权归小红书所有
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
package/bin/create.mjs
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* xhs-minitool-creator —— 小红书小工具脚手架
|
|
4
|
+
*
|
|
5
|
+
* 子命令:
|
|
6
|
+
* create [name] 创建新项目(默认子命令,可直接 xhs-minitool-creator <name>)
|
|
7
|
+
* pack 整理构建产物并打出可上传 zip
|
|
8
|
+
* validate [dir|zip] 静态合规校验(官方 Skill v1.6.0 规则)
|
|
9
|
+
* skill [info|update] 查看 / 更新内置官方打包规范 Skill
|
|
10
|
+
*
|
|
11
|
+
* 零运行时依赖,仅使用 Node 内置模块。
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { join, resolve, relative, dirname } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { createInterface } from 'node:readline/promises';
|
|
17
|
+
import { stdin, stdout } from 'node:process';
|
|
18
|
+
import { pack } from '../src/pack.mjs';
|
|
19
|
+
import { validateDir, validateZip, formatIssues } from '../src/validate.mjs';
|
|
20
|
+
import { printSkillInfo, skillUpdate } from '../src/skill.mjs';
|
|
21
|
+
|
|
22
|
+
const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
23
|
+
const TEMPLATE_DIR = join(PKG_ROOT, 'template');
|
|
24
|
+
|
|
25
|
+
const HELP = `xhs-minitool-creator —— 小红书小工具脚手架
|
|
26
|
+
|
|
27
|
+
用法:
|
|
28
|
+
xhs-minitool-creator <name> 创建新项目(默认子命令)
|
|
29
|
+
xhs-minitool-creator create <name> [选项] 同上
|
|
30
|
+
xhs-minitool-creator pack [选项] 整理产物并打包
|
|
31
|
+
xhs-minitool-creator validate <目录|zip> 静态校验
|
|
32
|
+
xhs-minitool-creator skill 查看内置官方 Skill 版本
|
|
33
|
+
xhs-minitool-creator skill update --url <地址> 手动更新官方 Skill
|
|
34
|
+
|
|
35
|
+
create 选项:
|
|
36
|
+
-y, --yes 跳过交互,全部使用默认值
|
|
37
|
+
--title <标题> 页面标题(默认同项目名)
|
|
38
|
+
--desc <描述> 项目描述
|
|
39
|
+
--icon <字符> 首页图标字符(默认「工」)
|
|
40
|
+
--theme <颜色> 主题色(默认 #ff2442)
|
|
41
|
+
|
|
42
|
+
pack 选项:
|
|
43
|
+
--outDir <目录> 构建产物目录(默认 xhs-tool)
|
|
44
|
+
--zipName <文件名> 输出 zip 名(默认 <包名>-xhs-tool.zip)
|
|
45
|
+
--strict 警告也视为失败
|
|
46
|
+
|
|
47
|
+
其他:
|
|
48
|
+
-v, --version 显示版本
|
|
49
|
+
-h, --help 显示帮助
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const PKG = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
|
|
53
|
+
|
|
54
|
+
function fail(msg) {
|
|
55
|
+
console.error(`\n✗ ${msg}`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseArgs(argv) {
|
|
60
|
+
const args = { _: [], flags: {} };
|
|
61
|
+
for (let i = 0; i < argv.length; i++) {
|
|
62
|
+
const a = argv[i];
|
|
63
|
+
if (a === '--yes' || a === '-y') args.flags.yes = true;
|
|
64
|
+
else if (a === '--strict') args.flags.strict = true;
|
|
65
|
+
else if (a === '--no-strict' || a === '--strict=false') args.flags.strict = false;
|
|
66
|
+
else if (a === '--title' || a === '--desc' || a === '--icon' || a === '--theme' ||
|
|
67
|
+
a === '--outDir' || a === '--zipName' || a === '--url') {
|
|
68
|
+
const v = argv[i + 1];
|
|
69
|
+
if (!v) fail(`选项 ${a} 缺少参数`);
|
|
70
|
+
args.flags[a.slice(2)] = v;
|
|
71
|
+
i += 1;
|
|
72
|
+
} else if (a === '--version' || a === '-v') args.flags.version = true;
|
|
73
|
+
else if (a === '--help' || a === '-h') args.flags.help = true;
|
|
74
|
+
else if (a.startsWith('-')) fail(`未知选项 ${a}`);
|
|
75
|
+
else args._.push(a);
|
|
76
|
+
}
|
|
77
|
+
return args;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ============================ 创建项目 ============================ */
|
|
81
|
+
|
|
82
|
+
const NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
83
|
+
const RESERVED = new Set(['node_modules', 'src', 'dist', 'xhs-tool', 'public']);
|
|
84
|
+
|
|
85
|
+
function normalizeName(raw) {
|
|
86
|
+
const name = raw.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
87
|
+
if (!name) return null;
|
|
88
|
+
return name.slice(0, 60);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validName(name) {
|
|
92
|
+
if (!NAME_RE.test(name) || RESERVED.has(name)) return false;
|
|
93
|
+
if (name.length > 60) return false;
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function prompt(text, fallback) {
|
|
98
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
99
|
+
try {
|
|
100
|
+
const answer = (await rl.question(text)).trim();
|
|
101
|
+
return answer || fallback;
|
|
102
|
+
} finally {
|
|
103
|
+
rl.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function copyTemplate(targetDir, vars) {
|
|
108
|
+
const srcDir = TEMPLATE_DIR;
|
|
109
|
+
|
|
110
|
+
const walk = (dir) => {
|
|
111
|
+
const out = [];
|
|
112
|
+
for (const name of readdirSync(dir)) {
|
|
113
|
+
const p = join(dir, name);
|
|
114
|
+
if (statSync(p).isDirectory()) out.push(...walk(p));
|
|
115
|
+
else out.push(p);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
let count = 0;
|
|
121
|
+
for (const file of walk(srcDir)) {
|
|
122
|
+
let rel = relative(srcDir, file).split('\\').join('/');
|
|
123
|
+
// npm 发布时 .gitignore 会被吞掉,模板内用 _gitignore 占位,落盘时改回
|
|
124
|
+
if (rel === '_gitignore') rel = '.gitignore';
|
|
125
|
+
|
|
126
|
+
const dest = join(targetDir, rel);
|
|
127
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
128
|
+
|
|
129
|
+
const ext = rel.split('.').pop();
|
|
130
|
+
let content = readFileSync(file);
|
|
131
|
+
|
|
132
|
+
if (['html', 'json', 'css'].includes(ext)) {
|
|
133
|
+
let text = content.toString('utf8');
|
|
134
|
+
if (rel === 'package.json') {
|
|
135
|
+
text = text.replace(/\{\{NAME\}\}/g, vars.name).replace(/\{\{DESC\}\}/g, vars.desc);
|
|
136
|
+
} else if (rel === 'index.html') {
|
|
137
|
+
text = text
|
|
138
|
+
.replace(/\{\{NAME\}\}/g, vars.title)
|
|
139
|
+
.replace(/\{\{TITLE\}\}/g, vars.title)
|
|
140
|
+
.replace(/\{\{DESC\}\}/g, vars.desc)
|
|
141
|
+
.replace(/\{\{ICON\}\}/g, vars.icon);
|
|
142
|
+
} else if (rel.endsWith('.css') && vars.theme && vars.theme !== '#ff2442') {
|
|
143
|
+
text = text.replace(/--accent:\s*#[0-9a-fA-F]{3,8};/, `--accent: ${vars.theme};`);
|
|
144
|
+
}
|
|
145
|
+
content = Buffer.from(text, 'utf8');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
writeFileSync(dest, content);
|
|
149
|
+
count += 1;
|
|
150
|
+
}
|
|
151
|
+
return count;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function createProject(args, flags) {
|
|
155
|
+
const rawName = args._[0];
|
|
156
|
+
const tty = Boolean(stdin.isTTY && stdout.isTTY);
|
|
157
|
+
|
|
158
|
+
let name = rawName ? normalizeName(rawName) : null;
|
|
159
|
+
|
|
160
|
+
if (!flags.yes && tty) {
|
|
161
|
+
console.log('\n创建小红书小工具项目\n');
|
|
162
|
+
|
|
163
|
+
let input = name;
|
|
164
|
+
while (!input || !validName(input)) {
|
|
165
|
+
input = await prompt(
|
|
166
|
+
'项目名(小写字母/数字/连字符,如 lucky-draw):',
|
|
167
|
+
name || '',
|
|
168
|
+
).then((v) => normalizeName(v));
|
|
169
|
+
if (!input) {
|
|
170
|
+
console.log(' 名称无效,请重新输入');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
name = input;
|
|
174
|
+
|
|
175
|
+
const title = await prompt(`页面标题(默认「${name}」):`, name);
|
|
176
|
+
const desc = await prompt('项目描述(可选):', '一个小红书小工具');
|
|
177
|
+
const icon = await prompt('首页图标字符(默认「工」):', '工');
|
|
178
|
+
const theme = await prompt('主题色(默认 #ff2442):', '#ff2442');
|
|
179
|
+
|
|
180
|
+
const finalVars = { name, title, desc, icon, theme };
|
|
181
|
+
console.log(`\n将创建:${name}(标题「${title}」,主题色 ${theme})`);
|
|
182
|
+
const ok = await prompt('确认?(Y/n) :', 'Y');
|
|
183
|
+
if (ok && !/^y/i.test(ok)) fail('已取消');
|
|
184
|
+
await doCreate(name, finalVars);
|
|
185
|
+
} else {
|
|
186
|
+
if (!name) {
|
|
187
|
+
fail('缺少项目名,例如:xhs-minitool-creator lucky-draw');
|
|
188
|
+
}
|
|
189
|
+
if (!validName(name)) {
|
|
190
|
+
fail(`项目名「${name}」不合法:需以小写字母开头,仅含小写字母/数字/连字符`);
|
|
191
|
+
}
|
|
192
|
+
const finalVars = {
|
|
193
|
+
name,
|
|
194
|
+
title: flags.title || name,
|
|
195
|
+
desc: flags.desc || '一个小红书小工具',
|
|
196
|
+
icon: flags.icon || '工',
|
|
197
|
+
theme: flags.theme || '#ff2442',
|
|
198
|
+
};
|
|
199
|
+
await doCreate(name, finalVars);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function doCreate(name, vars) {
|
|
204
|
+
const targetDir = resolve(process.cwd(), name);
|
|
205
|
+
|
|
206
|
+
if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
|
|
207
|
+
fail(`目录 ${name} 已存在且不为空,为避免覆盖请换一个名字或清空该目录`);
|
|
208
|
+
}
|
|
209
|
+
mkdirSync(targetDir, { recursive: true });
|
|
210
|
+
|
|
211
|
+
const count = copyTemplate(targetDir, vars);
|
|
212
|
+
console.log(`\n✓ 已在 ./${name} 创建项目(${count} 个文件,含官方打包规范 Skill v1.6.0)`);
|
|
213
|
+
|
|
214
|
+
console.log('\n下一步:');
|
|
215
|
+
console.log(` cd ${name}`);
|
|
216
|
+
console.log(' npm install');
|
|
217
|
+
console.log(' npm run dev # 本地预览');
|
|
218
|
+
console.log(' npm run build # 构建 + 打包(输出 <项目名>-xhs-tool.zip)');
|
|
219
|
+
console.log(' npm run validate # 静态校验');
|
|
220
|
+
console.log('\n上传入口:小红书创作服务平台(以平台实时规则为准)');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* ============================ 其他子命令 ============================ */
|
|
224
|
+
|
|
225
|
+
function cmdPack(flags) {
|
|
226
|
+
const code = pack({
|
|
227
|
+
outDir: flags.outDir || 'xhs-tool',
|
|
228
|
+
zipName: flags.zipName,
|
|
229
|
+
strict: flags.strict !== undefined ? flags.strict : true,
|
|
230
|
+
});
|
|
231
|
+
process.exit(code);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function cmdValidate(args, flags) {
|
|
235
|
+
const target = args._[0];
|
|
236
|
+
if (!target) {
|
|
237
|
+
fail('请指定要校验的目录或 zip,例如:xhs-minitool-creator validate ./xhs-tool');
|
|
238
|
+
}
|
|
239
|
+
const strict = flags.strict !== undefined ? flags.strict : true;
|
|
240
|
+
|
|
241
|
+
let result;
|
|
242
|
+
if (target.endsWith('.zip')) {
|
|
243
|
+
if (!existsSync(target)) fail(`文件不存在:${target}`);
|
|
244
|
+
result = validateZip(target, readFileSync(target), { strict });
|
|
245
|
+
} else {
|
|
246
|
+
if (!existsSync(target)) fail(`目录不存在:${target}`);
|
|
247
|
+
result = validateDir(target, { strict });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (result.errors.length || result.warnings.length) {
|
|
251
|
+
console.log('\n' + formatIssues(result));
|
|
252
|
+
}
|
|
253
|
+
const { files, bytes } = result.summary || {};
|
|
254
|
+
const sizeText = bytes ? `${(bytes / 1024 / 1024).toFixed(2)} MiB` : '-';
|
|
255
|
+
console.log(`\n${result.errors.length} 错误 / ${result.warnings.length} 警告 · ${files || '-'} 个文件 · ${sizeText}`);
|
|
256
|
+
if (result.errors.length) {
|
|
257
|
+
console.log('✗ 校验未通过');
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
if (result.warnings.length) {
|
|
261
|
+
console.log(strict ? '⚠ 有警告(strict 模式视为失败,可加 --no-strict 忽略)' : '✓ 校验通过(有警告)');
|
|
262
|
+
process.exit(strict ? 2 : 0);
|
|
263
|
+
}
|
|
264
|
+
console.log('✓ 校验通过');
|
|
265
|
+
process.exit(0);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function cmdSkill(args, flags) {
|
|
269
|
+
const sub = args._[0] || 'info';
|
|
270
|
+
if (sub === 'info' || sub === 'show') {
|
|
271
|
+
printSkillInfo();
|
|
272
|
+
process.exit(0);
|
|
273
|
+
}
|
|
274
|
+
if (sub === 'update') {
|
|
275
|
+
try {
|
|
276
|
+
skillUpdate(flags.url, process.cwd());
|
|
277
|
+
process.exit(0);
|
|
278
|
+
} catch (e) {
|
|
279
|
+
fail(e.message);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
fail(`未知 skill 子命令「${sub}」,可用:info | update --url <地址>`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/* ============================ 入口 ============================ */
|
|
286
|
+
|
|
287
|
+
function main() {
|
|
288
|
+
const argv = process.argv.slice(2);
|
|
289
|
+
if (!argv.length) {
|
|
290
|
+
console.log(HELP);
|
|
291
|
+
process.exit(0);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const args = parseArgs(argv);
|
|
295
|
+
if (args.flags.help) {
|
|
296
|
+
console.log(HELP);
|
|
297
|
+
process.exit(0);
|
|
298
|
+
}
|
|
299
|
+
if (args.flags.version) {
|
|
300
|
+
console.log(PKG.version);
|
|
301
|
+
process.exit(0);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 第一个非选项参数是子命令名,否则视为项目名(默认 create)
|
|
305
|
+
const first = args._[0];
|
|
306
|
+
const SUBCOMMANDS = new Set(['create', 'pack', 'validate', 'skill']);
|
|
307
|
+
|
|
308
|
+
if (first && SUBCOMMANDS.has(first)) {
|
|
309
|
+
args._.shift();
|
|
310
|
+
const sub = first;
|
|
311
|
+
if (sub === 'create') return createProject(args, args.flags);
|
|
312
|
+
if (sub === 'pack') return cmdPack(args.flags);
|
|
313
|
+
if (sub === 'validate') return cmdValidate(args, args.flags);
|
|
314
|
+
if (sub === 'skill') return cmdSkill(args, args.flags);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 默认 create
|
|
318
|
+
return createProject(args, args.flags);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
main().catch((e) => fail(e.message || String(e)));
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "xhs-minitool-creator",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "小红书小工具脚手架:创建项目 / Vite 构建预设 / 打包 / 静态校验,内置官方打包规范 Skill",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"xhs-minitool-creator": "bin/create.mjs"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/index.mjs",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.mjs",
|
|
12
|
+
"./vite": "./src/vite-preset.mjs",
|
|
13
|
+
"./pack": "./src/pack.mjs",
|
|
14
|
+
"./validate": "./src/validate.mjs",
|
|
15
|
+
"./skill": "./src/skill.mjs",
|
|
16
|
+
"./zip": "./src/zip.mjs"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"src",
|
|
21
|
+
"template",
|
|
22
|
+
"template/.agents",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18.17.0"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"xiaohongshu",
|
|
31
|
+
"小红书",
|
|
32
|
+
"minitool",
|
|
33
|
+
"小工具",
|
|
34
|
+
"create",
|
|
35
|
+
"vite",
|
|
36
|
+
"scaffold"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT"
|
|
39
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xhs-minitool-creator 库入口
|
|
3
|
+
* CLI 见 bin/create.mjs;此文件供编程式调用(可选)。
|
|
4
|
+
*/
|
|
5
|
+
export { defineMinitoolConfig } from './vite-preset.mjs';
|
|
6
|
+
export { pack, patchIndexHtml } from './pack.mjs';
|
|
7
|
+
export { validateDir, validateZip, formatIssues, ALLOWED_EXT } from './validate.mjs';
|
|
8
|
+
export { createZip, listZipEntries } from './zip.mjs';
|
|
9
|
+
export { skillInfo, skillUpdate, DEFAULT_SKILL_PATH } from './skill.mjs';
|
package/src/pack.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 整理构建产物并打出可上传的 zip
|
|
3
|
+
*
|
|
4
|
+
* 继承并强化原 xhs-minitool-pack 的思路:
|
|
5
|
+
* - 白名单过滤、清空空目录
|
|
6
|
+
* - patchIndexHtml:绝对路径转相对、去掉 crossorigin/manifest、
|
|
7
|
+
* 脚本重建为经典脚本并移到 </body> 前
|
|
8
|
+
* - 从目录内部打包,保证 index.html 位于 zip 根
|
|
9
|
+
*
|
|
10
|
+
* 关键改进:zip 用纯 Node 实现(src/zip.mjs),
|
|
11
|
+
* 不再依赖系统的 zip / zipinfo 二进制,Windows 亦可用。
|
|
12
|
+
*/
|
|
13
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, extname, join, relative, resolve } from 'node:path';
|
|
15
|
+
import { createZip, listZipEntries } from './zip.mjs';
|
|
16
|
+
import { ALLOWED_EXT, validateDir, validateZip, formatIssues } from './validate.mjs';
|
|
17
|
+
|
|
18
|
+
function walk(dir) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const name of readdirSync(dir)) {
|
|
21
|
+
const p = join(dir, name);
|
|
22
|
+
if (statSync(p).isDirectory()) out.push(...walk(p));
|
|
23
|
+
else out.push(p);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function removeUnsupported(outDir) {
|
|
29
|
+
for (const file of walk(outDir)) {
|
|
30
|
+
const ext = extname(file).toLowerCase();
|
|
31
|
+
if (!ALLOWED_EXT.has(ext)) {
|
|
32
|
+
rmSync(file, { force: true });
|
|
33
|
+
console.log(` · 移除不支持的文件:${relative(outDir, file)}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pruneEmptyDirs(dir) {
|
|
39
|
+
for (const name of readdirSync(dir)) {
|
|
40
|
+
const p = join(dir, name);
|
|
41
|
+
if (statSync(p).isDirectory()) {
|
|
42
|
+
pruneEmptyDirs(p);
|
|
43
|
+
if (readdirSync(p).length === 0) rmSync(p, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 修正 index.html 使其符合容器要求。
|
|
50
|
+
* 这是整个流程里最容易出错也最有价值的一步。
|
|
51
|
+
*/
|
|
52
|
+
export function patchIndexHtml(outDir) {
|
|
53
|
+
const htmlPath = join(outDir, 'index.html');
|
|
54
|
+
if (!existsSync(htmlPath)) {
|
|
55
|
+
throw new Error(`${relative(process.cwd(), htmlPath)} 不存在,请先执行构建`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let html = readFileSync(htmlPath, 'utf8');
|
|
59
|
+
|
|
60
|
+
// 绝对路径资源 -> 相对路径(离线 zip 根为 /)
|
|
61
|
+
html = html.replace(/(href|src)="\/([^"]+)"/g, '$1="./$2"');
|
|
62
|
+
// 去掉 crossorigin(离线同源无需,且部分内核处理异常)
|
|
63
|
+
html = html.replace(/\s+crossorigin(?:="[^"]*")?/g, '');
|
|
64
|
+
// 移除 manifest(容器内无意义,且与 PWA 行为冲突)
|
|
65
|
+
html = html.replace(/<link[^>]+rel="manifest"[^>]*>/gi, '');
|
|
66
|
+
|
|
67
|
+
// 收集所有外置脚本,重建为经典脚本(顺带消除 type="module"),
|
|
68
|
+
// 并统一挪到 </body> 前,保证 IIFE 依赖的 DOM 已存在。
|
|
69
|
+
// ^\s* 整行消费保证多次运行幂等(不会累积缩进空白)。
|
|
70
|
+
const scripts = [];
|
|
71
|
+
html = html.replace(
|
|
72
|
+
/^[ \t]*<script(?:\s[^>]*)?\ssrc="(\.\/[^"]+)"(?:\s[^>]*)?>\s*<\/script>[ \t]*\r?\n?/gim,
|
|
73
|
+
(_m, src) => {
|
|
74
|
+
scripts.push(`<script src="${src}"></script>`);
|
|
75
|
+
return '';
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (scripts.length) {
|
|
80
|
+
if (/<\/body>/i.test(html)) {
|
|
81
|
+
html = html.replace(/<\/body>/i, ` ${scripts.join('\n ')}\n </body>`);
|
|
82
|
+
} else {
|
|
83
|
+
html += `\n${scripts.join('\n')}\n`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
writeFileSync(htmlPath, html);
|
|
88
|
+
return scripts.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function copyIcons(outDir, publicDir) {
|
|
92
|
+
for (const icon of ['icon-192.svg', 'icon-512.svg']) {
|
|
93
|
+
const dest = join(outDir, 'icons', icon);
|
|
94
|
+
const src = join(publicDir, 'icons', icon);
|
|
95
|
+
if (!existsSync(dest) && existsSync(src)) {
|
|
96
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
97
|
+
copyFileSync(src, dest);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readPackageName(cwd) {
|
|
103
|
+
const pkgPath = join(cwd, 'package.json');
|
|
104
|
+
if (!existsSync(pkgPath)) return 'minitool';
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).name || 'minitool';
|
|
107
|
+
} catch {
|
|
108
|
+
return 'minitool';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {{
|
|
114
|
+
* cwd?: string, outDir?: string, zipName?: string, publicDir?: string,
|
|
115
|
+
* skipValidate?: boolean, strict?: boolean
|
|
116
|
+
* }} [options]
|
|
117
|
+
*/
|
|
118
|
+
export function pack(options = {}) {
|
|
119
|
+
const cwd = resolve(options.cwd || process.cwd());
|
|
120
|
+
const outDir = resolve(cwd, options.outDir || 'xhs-tool');
|
|
121
|
+
const zipName = options.zipName || `${readPackageName(cwd)}-xhs-tool.zip`;
|
|
122
|
+
const zipPath = resolve(cwd, zipName);
|
|
123
|
+
const publicDir = resolve(cwd, options.publicDir || 'public');
|
|
124
|
+
|
|
125
|
+
if (!existsSync(outDir)) {
|
|
126
|
+
throw new Error(`缺少输出目录 ${relative(cwd, outDir)},请先执行构建`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log('\n▸ 整理产物');
|
|
130
|
+
copyIcons(outDir, publicDir);
|
|
131
|
+
removeUnsupported(outDir);
|
|
132
|
+
pruneEmptyDirs(outDir);
|
|
133
|
+
const scriptCount = patchIndexHtml(outDir);
|
|
134
|
+
console.log(` · 已修正 index.html(${scriptCount} 个脚本重建为经典脚本并移至 body 末尾)`);
|
|
135
|
+
|
|
136
|
+
console.log('\n▸ 打包');
|
|
137
|
+
const files = walk(outDir);
|
|
138
|
+
const entries = files.map((file) => ({
|
|
139
|
+
name: relative(outDir, file).split('\\').join('/'),
|
|
140
|
+
data: readFileSync(file),
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
if (!entries.some((e) => e.name === 'index.html')) {
|
|
144
|
+
throw new Error('产物根目录缺少 index.html');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const zipBuf = createZip(entries);
|
|
148
|
+
writeFileSync(zipPath, zipBuf);
|
|
149
|
+
|
|
150
|
+
// 自检:确认 zip 结构正确(根含 index.html、无禁止文件)
|
|
151
|
+
const listed = listZipEntries(zipBuf).map((e) => e.name);
|
|
152
|
+
if (!listed.includes('index.html')) {
|
|
153
|
+
throw new Error('zip 根目录缺少 index.html(可能多套了一层目录)');
|
|
154
|
+
}
|
|
155
|
+
for (const bad of listed) {
|
|
156
|
+
if (bad.startsWith('node_modules/') || bad.endsWith('.map')) {
|
|
157
|
+
throw new Error(`zip 内含禁止文件:${bad}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const mib = (n) => `${(n / 1024 / 1024).toFixed(2)} MiB`;
|
|
162
|
+
console.log(` · zip 已生成:${relative(cwd, zipPath)}`);
|
|
163
|
+
console.log(` · 条目 ${listed.length} 个,体积 ${mib(zipBuf.length)}(硬上限 10 MiB,建议 ≤ 2 MiB)`);
|
|
164
|
+
|
|
165
|
+
console.log('\n▸ 校验');
|
|
166
|
+
const dirResult = validateDir(outDir, { strict: options.strict });
|
|
167
|
+
const zipResult = validateZip(zipName, zipBuf, { strict: options.strict });
|
|
168
|
+
|
|
169
|
+
const allErrors = [...dirResult.errors, ...zipResult.errors];
|
|
170
|
+
const allWarnings = [...dirResult.warnings, ...zipResult.warnings];
|
|
171
|
+
|
|
172
|
+
if (allErrors.length || allWarnings.length) {
|
|
173
|
+
console.log(formatIssues({ errors: allErrors, warnings: allWarnings }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (allErrors.length) {
|
|
177
|
+
console.log(`\n✗ 校验未通过:${allErrors.length} 个错误,${allWarnings.length} 个警告`);
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
if (allWarnings.length) {
|
|
181
|
+
console.log(`\n⚠ 校验通过(有 ${allWarnings.length} 个警告)`);
|
|
182
|
+
return options.strict === false ? 0 : 2;
|
|
183
|
+
}
|
|
184
|
+
console.log('\n✓ 校验通过:0 错误 0 警告');
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export default pack;
|