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/src/skill.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 官方打包规范 Skill(minitool-zip-builder)的版本查看与手动更新
|
|
3
|
+
*
|
|
4
|
+
* 重要限制:经实测,官方 CDN(fe-static.xhscdn.com)**没有稳定的 latest 端点**,
|
|
5
|
+
* 下载 URL 形如:
|
|
6
|
+
* https://fe-static.xhscdn.com/mini-tool/<时间戳>/minitool-zip-builder-<版本>.skill
|
|
7
|
+
* 其中时间戳不可预测,因此**无法自动发现新版本**,只能由用户从官方文档取得
|
|
8
|
+
* 最新 URL 后手动更新。本模块对此不作伪装,如实告知。
|
|
9
|
+
*
|
|
10
|
+
* 官方 Skill 权威下载地址:https://fe-static.xhscdn.com/mini-tool/
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
13
|
+
import { join, dirname, relative, resolve } from 'node:path';
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
|
|
17
|
+
export const SKILL_DIR_NAME = 'minitool-zip-builder';
|
|
18
|
+
export const DEFAULT_SKILL_PATH = '.agents/skills/minitool-zip-builder';
|
|
19
|
+
|
|
20
|
+
/** 超过该天数提示用户去官方文档核对版本 */
|
|
21
|
+
const STALE_DAYS = 90;
|
|
22
|
+
|
|
23
|
+
function readSkillVersion(skillDir) {
|
|
24
|
+
const skillPath = join(skillDir, 'SKILL.md');
|
|
25
|
+
if (!existsSync(skillPath)) return null;
|
|
26
|
+
const text = readFileSync(skillPath, 'utf8');
|
|
27
|
+
const m = text.match(/^\s*version:\s*["']?([0-9][0-9.]*)["']?\s*$/m);
|
|
28
|
+
return m ? m[1] : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function walk(dir) {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const name of readdirSync(dir)) {
|
|
34
|
+
const p = join(dir, name);
|
|
35
|
+
if (statSync(p).isDirectory()) out.push(...walk(p));
|
|
36
|
+
else out.push(p);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 查看项目内 Skill 版本
|
|
43
|
+
* @param {string} cwd
|
|
44
|
+
*/
|
|
45
|
+
export function skillInfo(cwd = process.cwd()) {
|
|
46
|
+
const dir = resolve(cwd);
|
|
47
|
+
const skillDir = join(dir, DEFAULT_SKILL_PATH);
|
|
48
|
+
|
|
49
|
+
if (!existsSync(skillDir)) {
|
|
50
|
+
return {
|
|
51
|
+
found: false,
|
|
52
|
+
path: DEFAULT_SKILL_PATH,
|
|
53
|
+
message: `未找到官方 Skill(期望路径 ${DEFAULT_SKILL_PATH})`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const version = readSkillVersion(skillDir);
|
|
58
|
+
const files = walk(skillDir).map((f) => relative(skillDir, f).split('\\').join('/'));
|
|
59
|
+
|
|
60
|
+
let mtime = null;
|
|
61
|
+
try {
|
|
62
|
+
const st = statSync(join(skillDir, 'SKILL.md'));
|
|
63
|
+
mtime = st.mtime;
|
|
64
|
+
} catch { /* ignore */ }
|
|
65
|
+
|
|
66
|
+
const ageDays = mtime ? Math.floor((Date.now() - mtime.getTime()) / 86400000) : null;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
found: true,
|
|
70
|
+
version,
|
|
71
|
+
path: DEFAULT_SKILL_PATH,
|
|
72
|
+
files,
|
|
73
|
+
lastModified: mtime,
|
|
74
|
+
ageDays,
|
|
75
|
+
stale: ageDays !== null && ageDays > STALE_DAYS,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function printSkillInfo(cwd = process.cwd()) {
|
|
80
|
+
const info = skillInfo(cwd);
|
|
81
|
+
if (!info.found) {
|
|
82
|
+
console.log(`\n${info.message}`);
|
|
83
|
+
return info;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log('\n官方打包规范 Skill');
|
|
87
|
+
console.log(` 路径 : ${info.path}`);
|
|
88
|
+
console.log(` 版本 : ${info.version || '(未标注)'}`);
|
|
89
|
+
console.log(` 文件数 : ${info.files.length}`);
|
|
90
|
+
if (info.lastModified) {
|
|
91
|
+
console.log(` 同步时间 : ${info.lastModified.toISOString().slice(0, 10)}(${info.ageDays} 天前)`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log('\n reference 文档:');
|
|
95
|
+
for (const f of info.files.filter((f) => f.startsWith('references/')).sort()) {
|
|
96
|
+
console.log(` · ${f}`);
|
|
97
|
+
}
|
|
98
|
+
const scripts = info.files.filter((f) => f.startsWith('scripts/'));
|
|
99
|
+
if (scripts.length) {
|
|
100
|
+
console.log(' 审计脚本:');
|
|
101
|
+
for (const f of scripts.sort()) console.log(` · ${f}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (info.stale) {
|
|
105
|
+
console.log(`\n ⚠ 距上次同步已 ${info.ageDays} 天,建议去官方文档核对是否有新版本。`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log('\n 说明:官方 CDN 无稳定的 latest 端点,无法自动发现新版本。');
|
|
109
|
+
console.log(' 更新方式:从官方文档取得最新 .skill 地址后执行');
|
|
110
|
+
console.log(' xhs-minitool-creator skill update --url <地址>');
|
|
111
|
+
return info;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 从官方 CDN 下载并覆盖项目内的 Skill
|
|
116
|
+
* @param {string} url .skill 文件地址
|
|
117
|
+
* @param {string} cwd
|
|
118
|
+
*/
|
|
119
|
+
export function skillUpdate(url, cwd = process.cwd()) {
|
|
120
|
+
const dir = resolve(cwd);
|
|
121
|
+
const target = join(dir, DEFAULT_SKILL_PATH);
|
|
122
|
+
|
|
123
|
+
if (!url || !/^https?:\/\//i.test(url)) {
|
|
124
|
+
throw new Error('请提供有效的 .skill 地址,例如 --url https://fe-static.xhscdn.com/mini-tool/<时间戳>/minitool-zip-builder-<版本>.skill');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const workDir = join(tmpdir(), `xmc-skill-${Date.now()}`);
|
|
128
|
+
const dlPath = join(workDir, 'skill.zip');
|
|
129
|
+
mkdirSync(workDir, { recursive: true });
|
|
130
|
+
|
|
131
|
+
console.log(`\n▸ 下载 ${url}`);
|
|
132
|
+
try {
|
|
133
|
+
execFileSync('curl', ['-sSL', '--fail', '-o', dlPath, url], { stdio: 'inherit' });
|
|
134
|
+
} catch {
|
|
135
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
136
|
+
throw new Error('下载失败:请确认地址有效且网络可访问');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log('▸ 解压');
|
|
140
|
+
try {
|
|
141
|
+
// 优先用系统 unzip;不可用时回退到 Python
|
|
142
|
+
try {
|
|
143
|
+
execFileSync('unzip', ['-o', '-q', dlPath, '-d', workDir], { stdio: 'pipe' });
|
|
144
|
+
} catch {
|
|
145
|
+
execFileSync('python3', [
|
|
146
|
+
'-c',
|
|
147
|
+
`import zipfile;zipfile.ZipFile(${JSON.stringify(dlPath)}).extractall(${JSON.stringify(workDir)})`,
|
|
148
|
+
], { stdio: 'pipe' });
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
152
|
+
throw new Error('解压失败:需要 unzip 或 python3');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 定位解压出的 skill 目录
|
|
156
|
+
let src = join(workDir, SKILL_DIR_NAME);
|
|
157
|
+
if (!existsSync(src)) {
|
|
158
|
+
for (const name of readdirSync(workDir)) {
|
|
159
|
+
const p = join(workDir, name);
|
|
160
|
+
if (statSync(p).isDirectory() && existsSync(join(p, 'SKILL.md'))) {
|
|
161
|
+
src = p;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const newVersion = readSkillVersion(src);
|
|
168
|
+
if (!newVersion) {
|
|
169
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
170
|
+
throw new Error('下载内容中未找到有效的 SKILL.md,请确认地址指向官方 .skill 包');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const oldVersion = readSkillVersion(target);
|
|
174
|
+
console.log(`▸ 更新 ${oldVersion || '(无)'} → ${newVersion}`);
|
|
175
|
+
|
|
176
|
+
rmSync(target, { recursive: true, force: true });
|
|
177
|
+
mkdirSync(target, { recursive: true });
|
|
178
|
+
for (const file of walk(src)) {
|
|
179
|
+
const rel = relative(src, file);
|
|
180
|
+
const dest = join(target, rel);
|
|
181
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
182
|
+
writeFileSync(dest, readFileSync(file));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
186
|
+
console.log(`\n✓ 已更新到 v${newVersion}`);
|
|
187
|
+
return { from: oldVersion, to: newVersion };
|
|
188
|
+
}
|
package/src/validate.mjs
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 小工具静态合规校验(纯 Node,零依赖,不依赖 Python)
|
|
3
|
+
*
|
|
4
|
+
* 规则基线:官方 Skill minitool-zip-builder v1.6.0
|
|
5
|
+
* - zip-artifact-spec.md 目录结构 / 文件类型 / CSP / 路径 / 自检
|
|
6
|
+
* - device-capabilities.md 可用与禁用能力
|
|
7
|
+
* - js-compatibility.md Chrome 61 / ES2017 基线
|
|
8
|
+
* - performance-budget.md 体积门禁(10 MiB 硬上限)
|
|
9
|
+
*
|
|
10
|
+
* 注意:静态检查无法替代真机验证。未实测时应在交付说明中标注。
|
|
11
|
+
*/
|
|
12
|
+
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
13
|
+
import { extname, join, relative, resolve, dirname } from 'node:path';
|
|
14
|
+
|
|
15
|
+
export const ALLOWED_EXT = new Set([
|
|
16
|
+
'.html', '.css', '.js',
|
|
17
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg',
|
|
18
|
+
'.woff', '.woff2', '.json',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const HARD_ZIP_LIMIT = 10 * 1024 * 1024; // 10 MiB 上传硬上限
|
|
22
|
+
const SUGGEST_ZIP_LIMIT = 2 * 1024 * 1024; // 2 MiB 建议值
|
|
23
|
+
const BASE64_LIMIT = 1024 * 1024; // 单条 Base64 解码后 1 MiB
|
|
24
|
+
const BASE64_WARN = 100 * 1024; // 超过 100 KiB 提示风险
|
|
25
|
+
const SINGLE_TEXT_WARN = 2 * 1024 * 1024; // 单个文本文件 > 2 MiB 提示
|
|
26
|
+
const TOTAL_TEXT_WARN = 5 * 1024 * 1024; // 文本合计 > 5 MiB 提示
|
|
27
|
+
|
|
28
|
+
/** 被禁能力:命中即 ERROR */
|
|
29
|
+
const FORBIDDEN_PATTERNS = [
|
|
30
|
+
// 网络
|
|
31
|
+
{ re: /\bfetch\s*\(/g, name: 'fetch()', tip: '容器禁网,数据改打包 .json 或写进 JS 常量' },
|
|
32
|
+
{ re: /\bnew\s+XMLHttpRequest\b/g, name: 'XMLHttpRequest', tip: '移除' },
|
|
33
|
+
{ re: /\bnew\s+WebSocket\s*\(/g, name: 'WebSocket', tip: '移除,无轮询替代' },
|
|
34
|
+
{ re: /\bnew\s+EventSource\s*\(/g, name: 'EventSource', tip: '移除' },
|
|
35
|
+
{ re: /\bnew\s+RTCPeerConnection\s*\(/g, name: 'RTCPeerConnection', tip: '移除' },
|
|
36
|
+
{ re: /navigator\.sendBeacon/g, name: 'sendBeacon', tip: '移除' },
|
|
37
|
+
// 动态代码
|
|
38
|
+
{ re: /\beval\s*\(/g, name: 'eval()', tip: '改写为静态逻辑' },
|
|
39
|
+
{ re: /\bnew\s+Function\s*\(/g, name: 'new Function()', tip: '改写为静态逻辑' },
|
|
40
|
+
{ re: /\bWebAssembly\b/g, name: 'WebAssembly', tip: '移除或改纯 JS 实现' },
|
|
41
|
+
// 后台
|
|
42
|
+
{ re: /\bnew\s+Worker\s*\(/g, name: 'Worker', tip: '逻辑放主线程' },
|
|
43
|
+
{ re: /\bnew\s+SharedWorker\s*\(/g, name: 'SharedWorker', tip: '移除' },
|
|
44
|
+
{ re: /serviceWorker\s*\./g, name: 'Service Worker', tip: '移除' },
|
|
45
|
+
{ re: /\bSharedArrayBuffer\b/g, name: 'SharedArrayBuffer', tip: '移除' },
|
|
46
|
+
// 定位 / 硬件 / 传感器 / 剪贴板
|
|
47
|
+
{ re: /navigator\.geolocation/g, name: 'geolocation', tip: '移除' },
|
|
48
|
+
{ re: /navigator\.clipboard/g, name: 'clipboard', tip: '改为展示可选中文本引导长按复制' },
|
|
49
|
+
{ re: /execCommand\s*\(\s*['"](copy|cut|paste)/g, name: 'execCommand(copy)', tip: '移除' },
|
|
50
|
+
{ re: /navigator\.(bluetooth|usb|hid|serial)\b/g, name: '硬件连接 API', tip: '移除' },
|
|
51
|
+
{ re: /\bnew\s+(Accelerometer|Gyroscope|Magnetometer)\s*\(/g, name: '传感器', tip: '改用触摸手势' },
|
|
52
|
+
{ re: /\bDevice(Motion|Orientation)Event\b/g, name: '设备传感器事件', tip: '移除' },
|
|
53
|
+
{ re: /navigator\.(getBattery|connection|locks)\b/g, name: '设备信息 / 锁', tip: '移除' },
|
|
54
|
+
{ re: /navigator\.credentials/g, name: 'WebAuthn', tip: '移除' },
|
|
55
|
+
{ re: /navigator\.storage\.persist/g, name: 'storage.persist', tip: '移除' },
|
|
56
|
+
{ re: /mediaDevices\.enumerateDevices/g, name: 'enumerateDevices', tip: '移除' },
|
|
57
|
+
{ re: /getDisplayMedia/g, name: 'getDisplayMedia', tip: '移除' },
|
|
58
|
+
// 窗口
|
|
59
|
+
{ re: /window\.open\s*\(/g, name: 'window.open', tip: '改为单页内视图切换' },
|
|
60
|
+
{ re: /window\.prompt\s*\(/g, name: 'window.prompt', tip: '改页内 Modal' },
|
|
61
|
+
{ re: /requestFullscreen|webkitRequestFullscreen/g, name: 'requestFullscreen', tip: '用 CSS 沉浸式布局' },
|
|
62
|
+
{ re: /\bPaymentRequest\b/g, name: 'PaymentRequest', tip: '移除' },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** HTML 层面的违规:命中即 ERROR */
|
|
66
|
+
const HTML_PATTERNS = [
|
|
67
|
+
{ re: /<script(?![^>]*\bsrc=)[^>]*>[\s\S]*?<\/script>/gi, name: '内联 <script>', tip: '提取为包内 .js,用 <script src> 引入' },
|
|
68
|
+
{ re: /\son(click|error|load|change|input|submit|focus|blur|mouse\w+|touch\w+|key\w+)\s*=/gi, name: '行内事件属性 (onclick 等)', tip: '改用 addEventListener' },
|
|
69
|
+
{ re: /<script[^>]*\stype\s*=\s*["']module["']/gi, name: '<script type="module">', tip: '改为经典脚本(module 在离线 zip 下解析不可靠)' },
|
|
70
|
+
{ re: /<iframe\b/gi, name: '<iframe>', tip: '内容直接写进页面' },
|
|
71
|
+
{ re: /<object\b/gi, name: '<object>', tip: '移除' },
|
|
72
|
+
{ re: /<base\s+href/gi, name: '<base href>', tip: '删除,会破坏真机路径' },
|
|
73
|
+
{ re: /<a[^>]*\starget\s*=\s*["']_blank["']/gi, name: 'target="_blank"', tip: '移除' },
|
|
74
|
+
{ re: /<a[^>]*\sdownload\b/gi, name: 'a[download]', tip: '移除' },
|
|
75
|
+
{ re: /<form[^>]*>/gi, name: '<form>', tip: '需 preventDefault 后用 JS 处理,不得提交跳转' },
|
|
76
|
+
{ re: /(?:src|href)\s*=\s*["']https?:\/\//gi, name: '外部 http(s) 资源引用', tip: '下载后打进 zip 改相对路径' },
|
|
77
|
+
{ re: /<meta[^>]+http-equiv\s*=\s*["']Content-Security-Policy/gi, name: '自建 CSP meta', tip: '安全策略由容器统一管理,删除' },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** JS 中的 module 语法:命中即 ERROR */
|
|
81
|
+
const JS_MODULE_PATTERNS = [
|
|
82
|
+
{ re: /(^|\n)\s*import\s+[^;\n]*from\s+['"][^'"]+['"]/g, name: 'import 语句', tip: '产物须为经典脚本,改用构建链打包' },
|
|
83
|
+
{ re: /(^|\n)\s*import\s*\(/g, name: '动态 import()', tip: '移除' },
|
|
84
|
+
{ re: /(^|\n)\s*export\s+(default|const|let|var|function|class|\{)/g, name: 'export 语句', tip: '产物须为经典脚本' },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
function walk(dir) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const name of readdirSync(dir)) {
|
|
90
|
+
const p = join(dir, name);
|
|
91
|
+
if (statSync(p).isDirectory()) out.push(...walk(p));
|
|
92
|
+
else out.push(p);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function lineOf(text, index) {
|
|
98
|
+
return text.slice(0, index).split('\n').length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function scanPatterns(text, file, patterns, issues) {
|
|
102
|
+
for (const { re, name, tip } of patterns) {
|
|
103
|
+
re.lastIndex = 0;
|
|
104
|
+
let m;
|
|
105
|
+
let count = 0;
|
|
106
|
+
let firstLine = 0;
|
|
107
|
+
while ((m = re.exec(text)) !== null) {
|
|
108
|
+
count += 1;
|
|
109
|
+
if (count === 1) firstLine = lineOf(text, m.index);
|
|
110
|
+
if (m.index === re.lastIndex) re.lastIndex += 1;
|
|
111
|
+
if (count > 20) break;
|
|
112
|
+
}
|
|
113
|
+
if (count) {
|
|
114
|
+
issues.push({
|
|
115
|
+
level: 'ERROR',
|
|
116
|
+
file,
|
|
117
|
+
line: firstLine,
|
|
118
|
+
rule: name,
|
|
119
|
+
message: `出现 ${count} 处:${tip}`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 校验产物目录
|
|
127
|
+
* @param {string} outDir 构建产物目录(含 index.html)
|
|
128
|
+
* @param {{ strict?: boolean }} [options] strict 为 true 时 WARNING 也视为失败
|
|
129
|
+
* @returns {{ code: number, errors: object[], warnings: object[], summary: object }}
|
|
130
|
+
*/
|
|
131
|
+
export function validateDir(outDir, options = {}) {
|
|
132
|
+
const dir = resolve(outDir);
|
|
133
|
+
const issues = [];
|
|
134
|
+
const info = { files: 0, bytes: 0, textBytes: 0 };
|
|
135
|
+
|
|
136
|
+
if (!existsSync(dir)) {
|
|
137
|
+
return {
|
|
138
|
+
code: 1,
|
|
139
|
+
errors: [{ level: 'ERROR', file: '-', rule: '目录不存在', message: `缺少目录 ${dir},请先执行构建` }],
|
|
140
|
+
warnings: [],
|
|
141
|
+
summary: { files: 0, bytes: 0 },
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const indexPath = join(dir, 'index.html');
|
|
146
|
+
if (!existsSync(indexPath)) {
|
|
147
|
+
issues.push({
|
|
148
|
+
level: 'ERROR', file: 'index.html', rule: '缺少入口',
|
|
149
|
+
message: 'zip 根目录必须有 index.html',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const files = walk(dir);
|
|
154
|
+
|
|
155
|
+
for (const file of files) {
|
|
156
|
+
const rel = relative(dir, file).split('\\').join('/');
|
|
157
|
+
const size = statSync(file).size;
|
|
158
|
+
info.files += 1;
|
|
159
|
+
info.bytes += size;
|
|
160
|
+
|
|
161
|
+
const ext = extname(file).toLowerCase();
|
|
162
|
+
|
|
163
|
+
// 文件类型白名单
|
|
164
|
+
if (!ALLOWED_EXT.has(ext)) {
|
|
165
|
+
issues.push({
|
|
166
|
+
level: 'ERROR', file: rel, rule: '不支持的文件类型',
|
|
167
|
+
message: `${ext} 不在白名单内,交付前会被移除`,
|
|
168
|
+
});
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 禁止的垃圾文件
|
|
173
|
+
if (rel.includes('node_modules') || ext === '.map') {
|
|
174
|
+
issues.push({
|
|
175
|
+
level: 'ERROR', file: rel, rule: '禁止文件',
|
|
176
|
+
message: '不得包含 node_modules / source map',
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (ext === '.html') {
|
|
181
|
+
const text = readFileSync(file, 'utf8');
|
|
182
|
+
scanPatterns(text, rel, HTML_PATTERNS, issues);
|
|
183
|
+
|
|
184
|
+
// 本地引用的资源必须真实存在(防止构建后仍引用未打包的源文件)
|
|
185
|
+
const baseDir = dirname(rel) === '.' ? dir : join(dir, dirname(rel));
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
const refRe = /(?:src|href)\s*=\s*["'](\.\.?\/[^"'#?]+)["']/gi;
|
|
188
|
+
let rm;
|
|
189
|
+
while ((rm = refRe.exec(text)) !== null) {
|
|
190
|
+
const ref = rm[1].split('/').filter((s) => s && s !== '.').join('/');
|
|
191
|
+
if (seen.has(ref)) continue;
|
|
192
|
+
seen.add(ref);
|
|
193
|
+
if (!existsSync(join(baseDir, ref))) {
|
|
194
|
+
issues.push({
|
|
195
|
+
level: 'ERROR', file: rel, rule: '引用文件缺失',
|
|
196
|
+
message: `引用了不存在的文件 ./${ref}(构建是否遗漏?),容器中会加载失败`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!/viewport-fit\s*=\s*cover/i.test(text)) {
|
|
202
|
+
issues.push({
|
|
203
|
+
level: 'WARNING', file: rel, rule: 'viewport 缺 viewport-fit=cover',
|
|
204
|
+
message: '安全区需要配合 viewport-fit=cover',
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (!/<meta\s+charset=/i.test(text)) {
|
|
208
|
+
issues.push({ level: 'WARNING', file: rel, rule: '缺 charset', message: '建议声明 UTF-8' });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (ext === '.js') {
|
|
213
|
+
const text = readFileSync(file, 'utf8');
|
|
214
|
+
info.textBytes += size;
|
|
215
|
+
scanPatterns(text, rel, JS_MODULE_PATTERNS, issues);
|
|
216
|
+
scanPatterns(text, rel, FORBIDDEN_PATTERNS, issues);
|
|
217
|
+
|
|
218
|
+
// ES2018+ 语法探测(Chrome 61 不支持,属硬失败)
|
|
219
|
+
if (/\?\?[^?]/.test(text) || /\?\?=?\s/.test(text)) {
|
|
220
|
+
issues.push({
|
|
221
|
+
level: 'ERROR', file: rel, rule: '空值合并 ?? (ES2020)',
|
|
222
|
+
message: 'Chrome 61 不支持,会解析失败导致白屏;设置 build.target 为 es2017/chrome61',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (/\?\.[a-zA-Z_$[(]/.test(text) && !/\?\.\d/.test(text)) {
|
|
226
|
+
issues.push({
|
|
227
|
+
level: 'ERROR', file: rel, rule: '可选链 ?. (ES2020)',
|
|
228
|
+
message: 'Chrome 61 不支持;设置 build.target 为 es2017/chrome61',
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (/(^|[^.\w])\.\.\.[a-zA-Z_$[{]/m.test(text) && /\?\?/.test(text) === false) {
|
|
232
|
+
// 对象 spread 误报率高,仅提示
|
|
233
|
+
issues.push({
|
|
234
|
+
level: 'WARNING', file: rel, rule: '疑似对象展开 ... (ES2018)',
|
|
235
|
+
message: '若命中需由构建链转译到 ES2017',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 大 Base64 检测
|
|
240
|
+
const b64 = text.match(/["']data:[^"';\s]+;base64,[A-Za-z0-9+/=]+["']/g);
|
|
241
|
+
if (b64) {
|
|
242
|
+
for (const b of b64) {
|
|
243
|
+
const payloadLen = b.length - b.indexOf(',') - 2;
|
|
244
|
+
const decoded = Math.floor((payloadLen * 3) / 4);
|
|
245
|
+
if (decoded > BASE64_LIMIT) {
|
|
246
|
+
issues.push({
|
|
247
|
+
level: 'ERROR', file: rel, rule: '大 Base64',
|
|
248
|
+
message: `单条解码后约 ${(decoded / 1024 / 1024).toFixed(2)} MiB,超过 1 MiB 上限,须改为独立包内文件`,
|
|
249
|
+
});
|
|
250
|
+
} else if (decoded > BASE64_WARN) {
|
|
251
|
+
issues.push({
|
|
252
|
+
level: 'WARNING', file: rel, rule: '较大 Base64',
|
|
253
|
+
message: `单条解码后约 ${(decoded / 1024).toFixed(0)} KiB,建议改为独立包内文件`,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (ext === '.css') {
|
|
261
|
+
const text = readFileSync(file, 'utf8');
|
|
262
|
+
info.textBytes += size;
|
|
263
|
+
const cssUrls = text.match(/url\(\s*['"]?https?:\/\/[^)]*\)/gi);
|
|
264
|
+
if (cssUrls) {
|
|
265
|
+
issues.push({
|
|
266
|
+
level: 'ERROR', file: rel, rule: 'CSS 外部资源',
|
|
267
|
+
message: `出现 ${cssUrls.length} 处 url(http...),须下载后打进包内`,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (ext === '.json') info.textBytes += size;
|
|
273
|
+
|
|
274
|
+
// 单个文本过大
|
|
275
|
+
if (['.html', '.css', '.js', '.json'].includes(ext) && size > SINGLE_TEXT_WARN) {
|
|
276
|
+
issues.push({
|
|
277
|
+
level: 'WARNING', file: rel, rule: '单个文本文件过大',
|
|
278
|
+
message: `${(size / 1024 / 1024).toFixed(2)} MiB 超过 2 MiB,请确认未塞入大型数据集`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 文本合计
|
|
284
|
+
if (info.textBytes > TOTAL_TEXT_WARN) {
|
|
285
|
+
issues.push({
|
|
286
|
+
level: 'WARNING', file: '-', rule: '文本文件合计过大',
|
|
287
|
+
message: `${(info.textBytes / 1024 / 1024).toFixed(2)} MiB 超过 5 MiB,请确认未塞入大型数据集`,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 总体积(目录口径,zip 会略小)
|
|
292
|
+
if (info.bytes > HARD_ZIP_LIMIT) {
|
|
293
|
+
issues.push({
|
|
294
|
+
level: 'ERROR', file: '-', rule: '总体积超硬上限',
|
|
295
|
+
message: `${(info.bytes / 1024 / 1024).toFixed(2)} MiB 超过 10 MiB 上传硬上限`,
|
|
296
|
+
});
|
|
297
|
+
} else if (info.bytes > SUGGEST_ZIP_LIMIT) {
|
|
298
|
+
issues.push({
|
|
299
|
+
level: 'WARNING', file: '-', rule: '总体积超建议值',
|
|
300
|
+
message: `${(info.bytes / 1024 / 1024).toFixed(2)} MiB 超过建议的 2 MiB,建议优化图片/媒体`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const errors = issues.filter((i) => i.level === 'ERROR');
|
|
305
|
+
const warnings = issues.filter((i) => i.level === 'WARNING');
|
|
306
|
+
const strict = options.strict !== false;
|
|
307
|
+
const code = errors.length ? 1 : strict && warnings.length ? 2 : 0;
|
|
308
|
+
|
|
309
|
+
return { code, errors, warnings, summary: { files: info.files, bytes: info.bytes } };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** 校验已打好的 zip 文件 */
|
|
313
|
+
export function validateZip(zipPath, zipBytes, options = {}) {
|
|
314
|
+
const issues = [];
|
|
315
|
+
const size = zipBytes.length;
|
|
316
|
+
|
|
317
|
+
if (size > HARD_ZIP_LIMIT) {
|
|
318
|
+
issues.push({
|
|
319
|
+
level: 'ERROR', file: zipPath, rule: 'zip 超硬上限',
|
|
320
|
+
message: `${(size / 1024 / 1024).toFixed(2)} MiB 超过 10 MiB 上传硬上限,无法上传`,
|
|
321
|
+
});
|
|
322
|
+
} else if (size > SUGGEST_ZIP_LIMIT) {
|
|
323
|
+
issues.push({
|
|
324
|
+
level: 'WARNING', file: zipPath, rule: 'zip 超建议值',
|
|
325
|
+
message: `${(size / 1024 / 1024).toFixed(2)} MiB 超过建议的 2 MiB`,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const errors = issues.filter((i) => i.level === 'ERROR');
|
|
330
|
+
const warnings = issues.filter((i) => i.level === 'WARNING');
|
|
331
|
+
const strict = options.strict !== false;
|
|
332
|
+
return {
|
|
333
|
+
code: errors.length ? 1 : strict && warnings.length ? 2 : 0,
|
|
334
|
+
errors,
|
|
335
|
+
warnings,
|
|
336
|
+
summary: { bytes: size },
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function formatIssues(result) {
|
|
341
|
+
const lines = [];
|
|
342
|
+
for (const i of [...result.errors, ...result.warnings]) {
|
|
343
|
+
const loc = i.file && i.file !== '-' ? `${i.file}${i.line ? ':' + i.line : ''}` : '-';
|
|
344
|
+
lines.push(`[${i.level}] ${loc} — ${i.rule}\n ${i.message}`);
|
|
345
|
+
}
|
|
346
|
+
return lines.join('\n');
|
|
347
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 小红书小工具 Vite 预设
|
|
3
|
+
*
|
|
4
|
+
* 相对原 xhs-minitool-vite-config 的关键修正:
|
|
5
|
+
* ✅ 显式 build.target = ['es2017','chrome61']
|
|
6
|
+
* 原预设未设置 target,Vite 默认值会保留 `??` 等 ES2020 语法,
|
|
7
|
+
* 在 Android 8.1 / WebView 61 上解析失败 —— 症状是「页面白屏且无任何报错」。
|
|
8
|
+
*
|
|
9
|
+
* 其余沿用合理部分:相对 base、IIFE 单入口、不做资源内联、关闭 CSS 分片与预加载。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @returns {import('vite').UserConfig} */
|
|
13
|
+
export function defineMinitoolConfig(overrides = {}) {
|
|
14
|
+
return {
|
|
15
|
+
base: './',
|
|
16
|
+
build: {
|
|
17
|
+
outDir: 'xhs-tool',
|
|
18
|
+
emptyOutDir: true,
|
|
19
|
+
assetsInlineLimit: 0,
|
|
20
|
+
cssCodeSplit: false,
|
|
21
|
+
modulePreload: false,
|
|
22
|
+
// 官方 Skill v1.6.0 js-compatibility.md 要求:最终产物面向 Chrome 61 / ES2017
|
|
23
|
+
target: ['es2017', 'chrome61'],
|
|
24
|
+
rollupOptions: {
|
|
25
|
+
output: {
|
|
26
|
+
// 经典脚本:容器离线加载下 module 的相对 import 解析不可靠
|
|
27
|
+
format: 'iife',
|
|
28
|
+
inlineDynamicImports: true,
|
|
29
|
+
entryFileNames: 'app.js',
|
|
30
|
+
chunkFileNames: 'chunk-[name].js',
|
|
31
|
+
assetFileNames: (assetInfo) => {
|
|
32
|
+
const name = assetInfo.name || '';
|
|
33
|
+
if (name.endsWith('.css')) return 'app.css';
|
|
34
|
+
if (/\.(woff2?)$/i.test(name)) return 'fonts/[name][extname]';
|
|
35
|
+
if (/\.(png|jpe?g|gif|webp|svg)$/i.test(name)) return 'images/[name][extname]';
|
|
36
|
+
return 'assets/[name][extname]';
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
...(overrides.build || {}),
|
|
41
|
+
},
|
|
42
|
+
...Object.fromEntries(Object.entries(overrides).filter(([k]) => k !== 'build')),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default defineMinitoolConfig;
|