Feat/适配小红书小工具导出 #630

Merged
k88936 merged 158 commits from feat/adapt-xhs-skill into master 2026-10-07 10:09:38 +08:00
133 changed files with 12812 additions and 1533 deletions
+4
View File
@@ -167,8 +167,12 @@ module.exports = {
// ts-rs 生成绑定:不接受 eslint --fix 二次改写,必须与原始输出逐字节一致
'packages/shared/src/contracts/generated/**',
'apps/ai-game-creator-shell/src/view/project-development/chat/generated/**',
'apps/ai-game-creator-shell/src/view/project-development/export/generated/**',
'apps/ai-game-creator-shell/src/services/generated/**',
'apps/ai-game-creator-shell/src/features/project-workspace/generated/**',
// 审核 Skill Pack:内容按 SHA-256 定址并经 include_bytes! 编译进客户端,
// 不接受 eslint --fix 改写(.prettierignore 同样忽略该目录,避免指纹漂移)
'apps/ai-game-creator-shell/src-tauri/resources/agc-skills/**',
'target',
'src/main.tsx',
'src/App.tsx',
+1
View File
@@ -30,4 +30,5 @@
packages/shared/src/contracts/generated/** linguist-generated=true whitespace=-trailing-space
apps/ai-game-creator-shell/src/features/ui-editor/types/** linguist-generated=true whitespace=-trailing-space
apps/ai-game-creator-shell/src/view/project-development/chat/generated/** linguist-generated=true whitespace=-trailing-space
apps/ai-game-creator-shell/src/view/project-development/export/generated/** linguist-generated=true whitespace=-trailing-space
apps/ai-game-creator-shell/src/services/generated/** linguist-generated=true whitespace=-trailing-space
+1
View File
@@ -8,6 +8,7 @@ packages/shared/src/contracts/generated/
public/Icons
apps/ai-game-creator-shell/src/features/ui-editor/types/
apps/ai-game-creator-shell/src/view/project-development/chat/generated/
apps/ai-game-creator-shell/src/view/project-development/export/generated/
apps/ai-game-creator-shell/src/services/generated/
apps/ai-game-creator-shell/src-tauri/resources/agc-skills/
# 预览桥脚本:随包注入浏览器的资源,按字节搬运自 Rust 内联字符串,禁止 prettier 二次改写
@@ -7,8 +7,10 @@ import test from 'node:test';
import { fileURLToPath } from 'node:url';
import {
collectBundledFiles,
computeSkillContentFingerprint,
inspectSkillPack,
isSafeSkillRelativePath,
} from './skill-pack-manifest.mjs';
test('bundled skill pack manifest is synchronized', () => {
@@ -16,6 +18,37 @@ test('bundled skill pack manifest is synchronized', () => {
assert.deepEqual(result.mismatches, []);
});
test('hidden skill paths are rejected and never declared', () => {
assert.equal(isSafeSkillRelativePath('SKILL.md'), true);
assert.equal(isSafeSkillRelativePath('references/contract.md'), true);
assert.equal(isSafeSkillRelativePath('.selective_rule.txt'), false);
assert.equal(isSafeSkillRelativePath('scripts/.pack.test.mjs'), false);
assert.equal(isSafeSkillRelativePath('.hidden/SKILL.md'), false);
});
test('skill pack collector ignores hidden files and directories', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-skill-hidden-'));
try {
fs.mkdirSync(path.join(root, 'demo', 'scripts'), { recursive: true });
fs.writeFileSync(path.join(root, 'demo', 'SKILL.md'), '# demo\n');
fs.writeFileSync(path.join(root, 'demo', '.selective_rule.txt'), 'local\n');
fs.writeFileSync(
path.join(root, 'demo', 'scripts', 'pack.mjs'),
'export {};\n',
);
fs.writeFileSync(
path.join(root, 'demo', 'scripts', '.pack.test.mjs'),
'test\n',
);
assert.deepEqual(collectBundledFiles(root), [
'demo/SKILL.md',
'demo/scripts/pack.mjs',
]);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('skill content fingerprint canonicalizes CRLF', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-skill-pack-'));
try {
@@ -13,7 +13,9 @@ export const EXPECTED_SKILL_NAMES = Object.freeze([
'agc-project-structure',
'agc-unity-editor',
'agc-web-game-development',
'platform-abstract',
'taonier-art-assets',
'vite-export-xhs-minitool',
]);
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
@@ -27,6 +29,17 @@ function canonicalTextBytes(filePath) {
return Buffer.from(decoded.replaceAll('\r\n', '\n'), 'utf8');
}
/**
* 隐藏文件/目录名(以 `.` 开头)永远不进入审核 Skill Pack。
*
* 约定:Skill 目录里以 `.` 开头的文件和目录是本地开发辅助(例如
* `.selective_rule.txt`、`scripts/.pack.test.mjs`),既不写进 manifest,
* 也不做指纹、不安装、不可通过 `agc_read_skill_resource` 读取。
*/
export function isHiddenSkillEntryName(name) {
return typeof name === 'string' && name.startsWith('.');
}
export function isSafeSkillRelativePath(value) {
if (
typeof value !== 'string' ||
@@ -40,7 +53,11 @@ export function isSafeSkillRelativePath(value) {
return value
.split('/')
.every(
(segment) => segment.length > 0 && segment !== '.' && segment !== '..',
(segment) =>
segment.length > 0 &&
segment !== '.' &&
segment !== '..' &&
!isHiddenSkillEntryName(segment),
);
}
@@ -70,10 +87,14 @@ export function computeSkillContentFingerprint(rootDir, entry) {
return digest.digest('hex');
}
function collectBundledFiles(rootDir) {
export function collectBundledFiles(rootDir) {
const files = [];
const walk = (directory, prefix) => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
// 隐藏文件/目录不参与审核:它们是本地开发辅助,不声明、不指纹、不安装。
if (isHiddenSkillEntryName(entry.name)) {
continue;
}
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
const absolutePath = path.join(directory, entry.name);
if (entry.isSymbolicLink()) {
@@ -4,6 +4,8 @@
"conversation.list.description": "按序读取当前项目已记录的 Codex 返回摘要。",
"conversation.read.description": "读取当前项目的一条已记录 Codex 返回;只能使用 conversation.list 返回的 recordId。",
"agc_read_skill_resource.description": "读取审核通过的 AGC Skill 指导文件;仅允许清单内 skillName 和相对文件名。",
"agc_install_skill_resource.description": "把审核通过的 AGC Skill 自带文件原样复制到当前项目相对路径;宿主直接读内置字节,不会像读取那样被大文件截断,也不需要原生 cp 或审批,一次一个文件。仍走 agc_write_file 的合同与租约门。",
"agc_install_skill_resource.parameters.destinationPath": "当前项目根下的目标相对路径,例如 scripts/validate.mjs",
"agc_write_file.description": "把文本写入当前 AGC 项目的相对路径,用于代码、配置、资源依赖或说明文件。",
"agc_apply_patch.description": "使用官方 apply_patch 语法修改当前项目,支持 Add/Delete/Update/Move。固定当前项目为工作目录;一次最多64KiB UTF-8、256个操作,并受实际平台参数上限约束。完整检查全部源与目标后执行;失败可能已部分修改,先读取当前文件再提出新补丁。该工具可与独立的读取、生成和计划调用并行;同文件修改与依赖其结果的构建、检查须等待补丁回执。超时、取消或 needsReconciliation=true 时停止,不自动重放。",
"agc_update_plan.description": "更新当前回合的进度计划,字段与 update_plan 相同:可选 explanation,以及 plan 中的 step/status(pending、in_progress、completed)。它可与其它独立工具并行;同一计划的连续更新按依赖顺序提交。计划完成只表示进度,不代替宿主交付验收。",
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.42",
"version": "2026-08-26.70",
"skills": [
{
"name": "agc-unity-editor",
@@ -166,6 +166,45 @@
"references/projection-contract.md"
],
"sha256": "77c69762910891e0eef5557480a5e46e743f5dec78ee958679e8d6f1e7990531"
},
{
"name": "platform-abstract",
"purpose": "把环境相关的调用从 core 里收口,让同一份逻辑能跑在多个 target 上",
"triggers": [
"抽离平台相关能力",
"同一份逻辑要跑在多个 target",
"新增非 Web 构建目标"
],
"requiredTools": [],
"files": [
"SKILL.md"
],
"sha256": "26e5b0b1d2fac6321eff0c834e15bce83ce70a435965c510254e40f3c16370e2"
},
{
"name": "vite-export-xhs-minitool",
"purpose": "把 vite 项目导出并打包为符合小红书小工具规范的 zip 制品",
"triggers": [
"导出小红书小工具制品",
"校验或打包 xhs minitool 产物",
"适配小红书小工具运行环境"
],
"requiredTools": [],
"files": [
"SKILL.md",
"references/cross-platform-h5.md",
"references/css-compatibility.md",
"references/device-capabilities.md",
"references/js-api.md",
"references/js-compatibility.md",
"references/manual-checks.md",
"references/performance-budget.md",
"references/zip-artifact-spec.md",
"scripts/pack.mjs",
"scripts/validate.mjs",
"scripts/vite.config.xhs-minitool.mjs"
],
"sha256": "fd869242fdd40bb46fc3fbf30d1509c79d306e769e87dad3430b2ab0e5449b12"
}
]
}
@@ -0,0 +1,84 @@
---
name: platform-abstract
description: 把环境相关的调用从 core 里收口,让同一份逻辑能跑在多个 target 上。
---
# 抽出平台抽象
core 不直接调环境 API;每个 target 给一份**同名导出**的实现,构建时选一份。
## 例子
```ts
// platform/web.ts
export async function save(score: number) {
const res = await fetch('/api/score', {
method: 'POST',
body: JSON.stringify({ score }),
});
if (!res.ok) throw new Error('save failed');
}
// platform/platform-b.ts —— 同名同参,实现不同
export async function save(score: number) {
await host.request({ url: '/api/score', method: 'POST', data: { score } });
}
// caller —— 不认识 fetch / host
import { save } from '#platform';
export async function saveScore(score: number) {
await save(score);
}
```
「接口」就是两边的同名导出,靠 TS 结构化类型在**各自的 target build** 里校验。两份实现漂移只会在各自构建时暴露——口头约定即可,不需要单点类型。`#platform` 怎么指到具体实现见「选实现」。
## 结构
```
src/
├── .../
│ └── use-case.ts # 只 import '#platform'
└── platform/ # 每个 target 一份同名导出
├── web.ts
└── platform-b.ts
```
## vite.config
`vite.config.ts` 就是**默认(web)**的那份,保持不动;新 target 单独一份,用 `mergeConfig` 在默认之上叠差异。不要再加 `vite.config.base.ts`。
```ts
// vite.config.ts —— 默认 = web
import path from 'node:path';
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
alias: { '#platform': path.resolve(__dirname, 'src/platform/web.ts') },
},
});
```
```ts
// vite.config.platform-b.ts —— 只写差异
import path from 'node:path';
import { defineConfig, mergeConfig } from 'vite';
import a from './vite.config';
export default mergeConfig(
a,
defineConfig({
build: { outDir: 'dist-platform-b' },
resolve: {
alias: { '#platform': path.resolve(__dirname, 'src/platform/platform-b.ts') },
},
}),
);
```
## 选实现
只有 alias 一种:caller 只 import `'#platform'`,每份 config 指向自己的实现。
alias 必须用**对象**写法——`mergeConfig` 才会按 key 覆盖;写成数组会拼接,两份实现同时留着。`#platform` 是打包期 alias,TS 侧还要在 tsconfig 里配一条同名 `paths`,否则类型检查找不到模块。
@@ -0,0 +1,59 @@
【取舍记录】minitool-zip-builder MUST-only 清理
原则
- references/ 只保留 MUST(必须 / 须 / 禁止 / 不得),非 MUST 的建议、可选、工具用法全部删除。
- 脚本能确定性判定的内容,不再在 skill 里重复,避免两处漂移。
已删除的文件
- scripts/audit_artifact.mjs
- 原因:体积审计已折进 scripts/validate.mjs。
- 影响:目录或 zip 的体积门禁改由 validate 检查;pack 打包后会自动复跑。
逐文件删除与去处
- SKILL.md
- 步骤 9:删掉 tsc / Vite 已保证项(语法错误、未定义函数、加载顺序),改为指向 references/MANUAL_CHECKS.md。
- 步骤 10:删掉 Node / Python / 人工分支,改为运行 validate.mjs 与 pack.mjs。
- 步骤 3 与 Reference 表:删掉已不存在的「目录结构 / 路径规则 / 打包自检」描述。
- 被外部精简重写为 vite-export-xhs-minitool,只剩 Reference 表,原步骤 9 / 10 已不存在;Reference 表指向「index.html 要求」。
- zip-artifact-spec.md
- 删 §1 目录结构与打包方式:pack.mjs 保证 zip 根 index.html、不多套层。
- 删 §4 路径与引用规则:base:'./' 与 pack.mjs 保证相对路径。
- 删 §6 打包前自检:validate / pack / MANUAL_CHECKS 已覆盖。
- 删 §5 表格里「资源全为相对路径」「脚本外置」两行(前者 Vite 保证,后者 §3 CSP 已声明)。
- 删 §5 的「index.html 模板」整段 HTML 骨架与其中的 reset / 字体 CSS;标题改为「index.html 要求」,只留要求表与 <title> 说明。
- 原因:模板是 pack 之后的 dist 形状,对 Vite 源 index.html 是错的(源需要 <script type="module"> 入口);reset / 字体 CSS 不是容器要求;doctype / charset / lang 由 validate 的 *_MISSING 兜。
- 删 §5 表格里 viewport(width=device-width / initial-scale=1.0 / viewport-fit=cover)、不用 `<base href>`、不自建 CSP `<meta>` 三行。
- 原因:validate 的 VIEWPORT_MISSING / VIEWPORT_WIDTH_MISSING / VIEWPORT_SCALE_MISSING / VIEWPORT_FIT_MISSING / BASE_URL / CSP_META 已确定性判定,不在 skill 里重复。
- 保留 §2 文件类型、§3 容器 CSP、§5 的「不引用外部资源」与 `<title>` 说明。
- device-capabilities.md
- 删 §6 能力扫描清单:validate 的禁用 API code 已全覆盖。
- 保留 §1–§5(可用能力、禁用能力与替代、常见交互写法)。
- js-compatibility.md
- 删 §1 的 ES2017 语法枚举与「避免 lookbehind」:语法转译由 Vite target 保证;lookbehind 已由 validate 报 REGEX_LOOKBEHIND。
- 删 §2 的 Vite target 片段:已在 vite.config.xhs-minitool.mjs。
- 删 §4 的日期解析 / Intl 布局余量建议,保留「不依赖对象遍历顺序」。
- 删 §5 交付检查。
- 保留 §3 Web API 能力检测、§4 跨内核 MUST。
- css-compatibility.md
- 删 §5 的两条 Autoprefixer 行(可选工具用法)。
- 删 §7 交付检查;§6 改为只保留「必须跟随可视高度时」的处理要求。
- 保留 §1–§4、§6(基线层、Chrome 61 不可唯一实现表、行为检测写法、安全区)。
- cross-platform-h5.md
- 删「优先 Pointer Events」的优先措辞;删「系统字体栈」建议;删 §5 表格的「建议」列。
- 保留安全区组合、不写死宽度、图片自适应、§6 自检。
- performance-budget.md
- 删 §1 的 Node / Python 选择命令与 audit 脚本调用;§1 只留 10 MiB 与单条 Base64 1 MiB 两条硬门禁,指向 validate。
- 删 §1 的 2 MiB / 100 KiB / 5 MiB 建议阈值(仍在 validate 里作为 WARN,见下)。
- 删 §6 交付检查;删「优先按顺序缩减」「优先 WebP」「优先短片段」「优先对象 URL」「优先 WebGL 1」「避免无意义绘制」「代码可按设备数据调整」等非 MUST。
- 保留 §2 分页 / 虚拟化 / 防抖 / 分批、§3 preload+poster+释放 objectURL+不得长 Base64、§4 逐项关闭 / 禁止每帧回读 / visibilitychange / contextlost、§5 不按机型 + 必须兜底。
- js-api.md
- 未删,全文保留(API 契约本身即硬约束)。文件中少量「建议」是 API 用法提示,未按 MUST-only 过滤。
仅保留在代码、未写回文档的非 MUST(如需调整改这里或改脚本)
- validate 的 WARN 阈值:zip > 2 MiB、单条 Base64 > 100 KiB、单个文本 > 2 MiB、文本合计 > 5 MiB。
- validate 的检查码与禁用 API 模式表。
- MANUAL_CHECKS.md 的 7 条脚本测不到项。
口径说明(避免后续误判为缺失)
- zip-artifact-spec.md 删除 §1 / §4 / §6 后,保留 §2 / §3 / §5,编号故意留空,未重排,避免打断跨文件引用。
- device-capabilities.md 现止于 §5;performance-budget.md 现止于 §5;js-compatibility.md 现止于 §4;css-compatibility.md 现止于 §6。
@@ -0,0 +1,52 @@
---
name: vite-export-xhs-minitool
description: >-
vite 项目导出适配小红书小工具的制品
metadata:
version: "1.7.0"
---
# 目标:
* 实现打包脚本 `build:xhs-minitool` 支持vite构建, validate, pack, 产出zip到固定文件: .export/xhs-minitool.zip
1. 复制 (并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范.
2. 使用 `scripts/validate.mjs` 验证上述配置构建产物目录, 这个脚本实现了一些硬性检查, 此外有一些手动检查项:
`references/manual-checks.md`
3. 使用 `scripts/pack.mjs` 打包成zip; `--zip-out` 默认 **`../.export/xhs-minitool.zip`** (假设 cwd 是 `game/` 子工程,
产物落在 **项目根** `.export/xhs-minitool.zip`; 相对 cwd 解析)
4. 跑通流程后请把需要的脚本配置复制 (agc_install_skill_resource )到项目里 (以免依赖skill), 并形成最终的打包脚本
* 不干扰正常的web构建
# 假设和默认项:
* vite项目目录: game/
* 各个工具 (包括示例vite配置) 默认cwd 就是 vite 项目根(所以对于非标准的目录结构需要给出显式的参数来适应)
# 脚本参数:
| 脚本 | 参数 |
|----------------------------------------|----------------------------------------------------------------------------------------------------------------|
| `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(默认 `dist-xhs-minitool`;与打包时的 `--vite-built-dir` 必须是同一个目录) |
| `scripts/validate.mjs` | `[project]`(默认 `dist-xhs-minitool`)、`--json` |
| `scripts/pack.mjs` | `--vite-built-dir <dir>`(默认 `dist-xhs-minitool`)、`--zip-out <path>`(默认 `../.export/xhs-minitool.zip`) |
# 一些情况:
* 打包大小限制, 需要精简游戏内容/删减资源/压缩素材 请和用户讨论
* 如果使用了外部能力, 参考platform-abstract skill对项目先重构
* 小红书的条件可能有变化, 请以用户反馈为准, 并调整本地的构建脚本
以下是参考文档;「何时读」命中时必须读:
## Reference
| 文档 | 何时读 |
|-------------------------------------------------------------|--------------------------------------------------------------------------------------------|
| [js-api.md](references/js-api.md) | 本地 API 快照; |
| [zip-artifact-spec.md](references/zip-artifact-spec.md) | 写/改 `index.html`、选择文件类型或处理脚本外置 / 容器 CSP 时 |
| [device-capabilities.md](references/device-capabilities.md) | 处理端能力时:哪些 Web 能力可用 / 不可用及替代写法、如何实现常见交互(手势、拍照、选图等) |
| [js-compatibility.md](references/js-compatibility.md) | 写 JS / 选择构建产物时:Chrome 61 硬基线、不可用 API 与替代写法 |
| [css-compatibility.md](references/css-compatibility.md) | 写 CSS / 选择构建产物时:Chrome 61 硬基线、不支持的能力与替代写法 |
| [cross-platform-h5.md](references/cross-platform-h5.md) | 适配多端时:触摸、滚动、安全区、PC 模拟器与真机差异 |
| [performance-budget.md](references/performance-budget.md) | 包体、静态数据、Base64、媒体、长列表与 WebGL 资源控制和降级 |
@@ -0,0 +1,68 @@
# 跨端 H5 适配
> 小工具同一份 H5 同时跑在 PC 模拟器与真机 WebView。以下是保证两端一致体验的适配要点。
CSS 的最低语法与布局能力以 [css-compatibility.md](./css-compatibility.md) 为准;本文件只说明触摸、滚动、安全区和设备形态差异。
---
## 1. 触摸
```css
body { -webkit-touch-callout: none; }
.touchable:active { opacity: 0.7; }
html { touch-action: manipulation; }
```
交互用 Pointer Events(`pointerdown/move/up`)统一处理鼠标与触摸;纯触摸场景用 `touchstart/touchmove/touchend`。
---
## 2. 滚动
```css
.scroll-container {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
}
```
纵向回弹由容器控制,HTML 无需额外配置。
---
## 3. 安全区
```css
.custom-nav { padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px)); }
.bottom-bar { padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)); }
```
需配合 `<meta name="viewport" ... viewport-fit=cover>`。PC 模拟器不产生真实 `env()`,而是注入 `--safe-area-inset-*` 变量模拟安全区;真机 `env()` 为真实值。用 `var(--safe-area-inset-*, env(...))` 组合,两端都生效。
---
## 4. 布局与媒体
- 页面级容器用 `%` / `flex` / `vw`,勿写死 `width: 375px`
- 图片 `max-width: 100%`
---
## 5. PC 模拟器 vs 真机
| 特性 | PC 模拟器 | 真机 |
|--------|-------------------------------------|----------------|
| 触摸 | 鼠标 → touch 模拟 | 原生 touch |
| 安全区 | 注入 `--safe-area-inset-*` 变量模拟 | `env()` 真实值 |
| 软键盘 | 无 | 遮挡输入框 |
---
## 6. 自检
- [ ] 交互用 pointer / touch events,未依赖鼠标 hover 才能触发的关键操作
- [ ] 布局自适应,无写死像素宽度
- [ ] 安全区用 `var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` 组合,配合 `viewport-fit=cover`
- [ ] 图片自适应且体积受控
@@ -0,0 +1,57 @@
# CSS 兼容性规范
> 目标内核固定为 **Android 8.1 出场 Chrome / WebView 61**。晚于 61 的 CSS 能力一律不得出现在最终产物里:浏览器会静默忽略,`validate.mjs` 判为 `CSS_UNSUPPORTED_FEATURE`(ERROR)。
## 1. 硬性要求
- 最终 zip 内的 CSS 必须能被 Chrome 61 完整解析;不得保留任何晚于 61 的选择器、声明或 at-rule。
- 浏览器静默丢弃无法解析的 CSS,不像 JS 会抛异常;兼容性以最终产物为准,源码用了构建工具不代表产物已兼容。
- 不用 UA、Android 版本或机型字符串决定样式能力。
## 2. Chrome 61 不支持的能力与替代写法
| 能力 | 必须改用的写法 |
|-------------------------------------------------------------|---------------------------------------------------------------------------------------|
| Flex `gap` / `row-gap` / `column-gap` | Flex 用子项单边 `margin`;Grid 用 `grid-gap` |
| `aspect-ratio` | 固定媒体尺寸,或用百分比 `padding-top` 比例盒;内容绝对定位 |
| `min()` / `max()` / `clamp()` | 固定值、百分比或 `calc()`;需要分档时用媒体查询 |
| `inset`、`margin-inline`、`padding-block` 等逻辑属性 / 简写 | `top/right/bottom/left` 与 `margin-left/right`、`padding-top/bottom` 等物理属性 |
| `overflow: clip` | `overflow: hidden` |
| `:focus-visible` | `:focus` |
| `:has()` | 由 JS 在父元素上切换状态 class |
| `@container` | viewport 媒体查询,或由 JS 按容器尺寸切换 class |
| `subgrid` | 普通 Grid、Flex 或显式轨道尺寸 |
| `@layer` / `@property` | 只允许构建期展开;最终产物不得保留 |
| `dvh` / `svh` / `lvh` | `%` / `100vh`;受软键盘影响的全屏高度用 JS 维护 CSS 变量并保留 `100vh` 兜底 |
| `color-mix()`、`oklab()`、`oklch()` 等现代颜色 | `#hex`、`rgb()`、`rgba()` 或 `hsl()` |
| `backdrop-filter` | 先给不依赖模糊的实色 / 半透明背景 |
| `text-wrap: balance` 等现代排版属性 | 保留普通换行;不要让其决定关键区域高度 |
## 3. 布局与前缀
Chrome 61 可使用 Flexbox、基础 Grid、媒体查询、CSS Variables、`calc()`、transform、transition 和 animation。仍须处理以下跨端差异:
- Flex 子项内有长文本、图片或滚动区域时,按方向显式设置 `min-width: 0` 或 `min-height: 0`,避免内容撑破容器。
- Flex 间距用子项单边 `margin`;Grid 间距用 `grid-gap`。两者都不要用裸 `gap`。
- 需要隐藏文本时使用 `overflow: hidden; text-overflow: ellipsis; white-space: nowrap`。多行截断使用 `display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: <行数>; overflow: hidden`,并保证截断失效时页面仍可用。
- `user-select`、`appearance`、文字截断和毛玻璃等 WebKit 相关能力按需同时写 `-webkit-` 前缀与标准声明。不要机械给所有属性加前缀。
- 关键操作不能只在 `:hover` 出现;触摸端默认可见,鼠标悬停效果可放 `@media (hover: hover)`。
## 4. 视口与安全区
- 页面宽度使用 `%`、Flex 或基础 Grid,不写死 `375px` 等单一机型宽度。
- `100vh` 在移动端地址栏、容器高度变化和软键盘出现时可能不等于可视高度。必须跟随可视高度时,由 JS 监听尺寸变化并维护 `--app-height`,同时保留 `100vh` 回退。
- 安全区规则须配合 `viewport-fit=cover`,用 `var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` 组合;具体见 [cross-platform-h5.md](./cross-platform-h5.md)。
- 现代桌面浏览器或 PC 模拟器通过不等于 Chrome 61 通过。能够运行旧内核时,至少检查首屏、滚动区、弹层、表单、横竖屏 / 尺寸变化和核心交互;无法运行时在交付说明中标记“Chrome 61 CSS 兼容性未实测”。
## 5. 构建链目标
项目已有构建链时,浏览器目标至少设置为:
```text
Chrome >= 61
ios_saf >= 18.4
```
- 压缩器也须使用相同浏览器目标,避免把兼容写法重新合并成 Chrome 61 无法解析的现代语法。
- 交付前检查构建后的 CSS;zip 中只保留最终静态产物,不带 source map 和构建配置。
@@ -0,0 +1,109 @@
# 小工具能力清单
> 小工具运行在受限容器中:**纯本地、不联网**,把它当作一个能力受限的浏览器页面。
> **以本文为基线**:命中「不可用」项必须移除或改用替代写法。
>
> **实现优先级**:先获取[小工具在线文档](https://miniapp-sandbox.xiaohongshu.com/minitool/doc)并查找匹配的容器能力;文档明确支持时必须优先使用,仅在没有匹配能力或当前环境不满足文档条件时才采用兼容的 Web 方案。
## 目录
- §1 可用能力
- §2 不可用能力(Web API)
- §3 不可用行为
- §4 WebGL / 图形计算边界
- §5 常见交互怎么实现
---
## 1. 可用能力
### 页面与渲染
标准 HTML / CSS / JS 可用,但最终产物须满足目标内核基线:JS 见 [js-compatibility.md](./js-compatibility.md),CSS 见 [css-compatibility.md](./css-compatibility.md)。可使用基线内的 Flexbox / Grid / 动画 / 媒体查询、Canvas 2D(`getContext('2d')`)、WebGL(`getContext('webgl'/'webgl2')`,能力边界见 §4、性能与低端机降级见 [performance-budget.md](./performance-budget.md) §4–5),文本选择不限制。
### 媒体与文件
| 能力 | 用法 | 约束 |
|-----------------|--------------------------------------------------------|----------------------------------------------------------------|
| 摄像头 | `navigator.mediaDevices.getUserMedia({ video: true })` | 用户手势触发 + 系统弹窗授权 |
| 麦克风 | `navigator.mediaDevices.getUserMedia({ audio: true })` | 用户手势触发 + 系统弹窗授权 |
| 选择图片 / 拍照 | `<input type="file">` | 系统选择器接管,**仅能选图片和视频**(无论 `accept` 如何设置) |
| 音视频播放 | `<video>` / `<audio>` | 内联播放,媒体文件须打包在内 |
### 数据存储
数据存储方案以[小工具在线文档](https://miniapp-sandbox.xiaohongshu.com/minitool/doc)的当前规则为准。数据按小工具隔离,不保证永久持久化。
### 交互
`alert()` / `confirm()` 可用,以原生 UI 展示。
---
## 2. 不可用能力(Web API)
以下 API 已禁用,调用会抛异常、返回空值或被拦截,必须移除或改用替代写法。
| 分类 | 涉及 API | 替代方案 |
|----------|--------------------------------------------------------------------------------------------------------------------------|------------------------------------------------|
| 定位 | `navigator.geolocation.getCurrentPosition` / `watchPosition` | 移除 |
| 剪贴板 | `navigator.clipboard.readText` / `writeText`、`document.execCommand('copy'/'cut'/'paste')` | 展示可选中文本,引导用户长按 / 选中手动复制 |
| 硬件连接 | `navigator.bluetooth` / `navigator.usb` / `navigator.hid` / `navigator.serial` | 移除 |
| 传感器 | `new Accelerometer()` / `new Gyroscope()` / `new Magnetometer()`、环境光、`DeviceMotionEvent` / `DeviceOrientationEvent` | 改用触摸 / 指针手势(见 §5),摇一摇类移除 |
| 实时通信 | `new WebSocket()`、`new EventSource()`、`new RTCPeerConnection()` | 移除(不联网,无轮询替代) |
| 后台运行 | Web Worker、SharedWorker、Service Worker(`navigator.serviceWorker.register`) | 移除,逻辑放主线程 |
| 屏幕 | `getDisplayMedia`(屏幕共享)、`Element.requestFullscreen`(全屏由容器统一管理) | 全屏用 CSS 沉浸式布局实现视觉全屏 |
| 设备信息 | `navigator.getBattery`、`navigator.connection`、`navigator.mediaDevices.enumerateDevices` | 移除 |
| 存储进阶 | `navigator.storage.persist`(持久化)、跨域存储访问 | 移除;获取在线文档并按当前能力替代关系选择方案 |
| 凭据 | `navigator.credentials.get` / `create`(WebAuthn)、`navigator.locks` | 移除 |
| 窗口 | `window.open`(弹新窗口)、`window.prompt` | 单页内 JS 切换视图 DOM;输入用页内 Modal |
移动端 WebView 本身也不支持:支付 `PaymentRequest`、系统通知 / 推送、NFC、MIDI、XR / AR / VR、后台同步 / 下载、PWA 安装、窗口管理、指针 / 键盘锁定。一律移除。
---
## 3. 不可用行为
| 行为 | 说明 | 替代方案 |
|-------------------|----------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|
| 网络请求 | `fetch` / `XMLHttpRequest`、加载外部图片 / 字体 / 媒体等一切联网请求 | 所有资源打包在内,改本地相对引用;仅小型配置 / 数据可随包提供,大型只读数据集不适合小工具,见 [performance-budget.md](./performance-budget.md) §2 |
| 动态执行代码 | `eval()`、`new Function()` | 改写为静态逻辑 |
| WebAssembly | WASM 编译执行(依赖 WASM 的库无法运行) | 移除或改用纯 JS 实现 |
| iframe / object | 内嵌 iframe / object,或被外部页面嵌入 | 内容直接写进页面 |
| 表单跳转提交 | `<form>` 提交跳转 | `addEventListener('submit', e => e.preventDefault())` 后用 JS 处理 |
| 文件下载 | `a[download]`、blob 下载 | 移除 |
| 打开外链 / 新窗口 | `target="_blank"`、`window.open`、跳转站外 URL | 单页内 JS 切换视图 DOM |
| 跳转其他小工具 | 小工具间互相跳转 | 移除 |
| 长按菜单 | 系统长按菜单已禁用 | 用自定义交互替代 |
| 插件 | Flash 等浏览器插件 | 移除 |
---
## 4. WebGL / 图形计算边界
纯 WebGL 渲染可用,组合能力受限:
| 场景 | 是否可用 |
|---------------------------------------------------------|---------------------------|
| 包内资源 / Canvas / 内存对象作为纹理 | ✅ |
| 外部域名图片作为纹理 | 🔴 不联网,纹理须打包在内 |
| 依赖 WASM 的加速库(Draco / Basis / ONNX / 抠图算法等) | 🔴 |
| 依赖 Worker 的离屏渲染(OffscreenCanvas + Worker) | 🔴 |
| SharedArrayBuffer 多线程 | 🔴 |
WebGL 适合用包内资源做本地渲染;AI 图像处理等重计算(需联网或 WASM 模型)无法支持。WebGL 可用不等于低端真机性能足够:DPR、像素、纹理、draw call、几何预算、动态降档与兜底必须遵守 [performance-budget.md](./performance-budget.md) §4–5。
---
## 5. 常见交互怎么实现
| 需求 | 实现 |
|--------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|
| 容器提供的功能 | 获取[小工具在线文档](https://miniapp-sandbox.xiaohongshu.com/minitool/doc),优先使用其中匹配的能力;仅在无匹配能力或不满足文档条件时采用兼容的 Web 方案 |
| 手势 / 拖拽 / 滑动 | `addEventListener('touchstart'/'touchmove'/'touchend')` 或 Pointer Events(`pointerdown`/`move`/`up`) |
| 拍照 / 录音 | `getUserMedia(...)`,由按钮点击等用户手势触发 + 授权 |
| 选择图片 / 视频 | `<input type="file">` |
| 复制文本 | 展示可选中文本,引导用户长按 / 选中复制 |
| 视觉全屏 | CSS 布局(`100vh` / flex + 隐藏滚动) |
| 页面跳转 | 单页内用 JS 切换视图 DOM |
| 输入弹窗 | 页内 Modal 组件 |
@@ -0,0 +1,310 @@
# 小工具 JS API 本地参考
> 本文是 2026-09-23 的本地快照,按 `docs/index.html` 的 JS API 章节复刻,保留参数表、约束与示例。实现时应优先获取[小工具在线文档](https://miniapp-sandbox.xiaohongshu.com/minitool/doc);仅当远程文档无法获取时,才以本文作为 API 契约参考。
容器自动注入 `window.xhs`,无需在包内引入 SDK。端能力从 `window.xhs.miniTool` 调用。
## 调用约定
- 传入 `success`、`fail`、`complete` 任一回调时,API 返回 `undefined`;均不传时返回 Promise。
- 成功结果包含 `errMsg: "<api>:ok"` 与对应业务字段;失败结果包含 `errMsg: "<api>:fail ..."` 与可选 `errCode`。
- 调用前检查 `window.xhs`、`window.xhs.miniTool` 和具体方法是否存在,并为低版本或未注入环境提供降级处理。
- 只调用本文列出的 API,不直接调用原生 bridge。
```js
const miniTool = window.xhs && window.xhs.miniTool;
if (miniTool && typeof miniTool.saveImageToPhotosAlbum === "function") {
try {
await miniTool.saveImageToPhotosAlbum({ filePath });
} catch (error) {
console.log(error.errMsg, error.errCode);
}
}
```
## API 一览
| API | 用途 | 最低客户端版本 |
|---------------------------------------------------------------------------------------------------------------------------|--------------------------------|----------------|
| `postNote` | 打开笔记发布页并携带内容和媒体 | — |
| `saveImageToPhotosAlbum` | 保存图片至系统相册 | — |
| `writeTempFile` | 将 base64 写入临时文件 | — |
| `getLaunchOptions` / `window.xhs.launchOptions` | 获取启动参数和环境信息 | — |
| `setStorage` / `getStorage` / `getStorageInfo` / `removeStorage` / `clearStorage` | 小工具本地缓存 | 9.46 |
| `saveFile` / `writeFile` / `appendFile` / `readFile` / `readDir` / `statFile` / `unlink` / `mkdir` / `getFileStorageInfo` | 本地文件系统 | 9.49 |
| `interactionOpenApi` | 唤起评论区并携带评论草稿 | 9.49 |
图片、视频与封面等媒体字段只接受 `data:` base64 或本地文件路径;容器不联网,网络地址不可用。体积较大的 base64 建议先用 `writeTempFile` 换成 `filePath` 再传递。
## 启动参数与版本判断
同步读取启动参数:
```js
const launchOptions = window.xhs && window.xhs.launchOptions;
const userDataPath = launchOptions && launchOptions.miniToolEnv && launchOptions.miniToolEnv.userDataPath;
```
同步值不可用时,检查 `getLaunchOptions` 存在后异步读取:
```js
const miniTool = window.xhs && window.xhs.miniTool;
const launchOptions = await miniTool.getLaunchOptions();
const { userDataPath } = launchOptions.miniToolEnv;
```
`miniToolEnv.userDataPath` 是持久文件目录根路径。仅在它之后拼接相对路径;不要硬编码、解析或改写端上返回的文件句柄。
`miniToolEnv.buildVersion` 的末三位为编译序号,判断客户端版本时忽略。例如 `9462004` 代表客户端 `9.46.2`,用于版本比较的值为 `9462`:
```js
function getClientVersion(buildVersion) {
return Math.floor((Number(buildVersion) || 0) / 1000);
}
function isClientVersionAtLeast(buildVersion, minimumClientVersion) {
return getClientVersion(buildVersion) >= minimumClientVersion;
}
```
完整的同步优先、异步回退读取方式:
```js
function readBuildVersion(launchOptions) {
const miniToolEnv = launchOptions && launchOptions.miniToolEnv;
return Number(miniToolEnv && miniToolEnv.buildVersion) || 0;
}
async function getBuildVersion() {
const xhs = window.xhs;
const syncBuildVersion = readBuildVersion(xhs && xhs.launchOptions);
if (syncBuildVersion) return syncBuildVersion;
const miniTool = xhs && xhs.miniTool;
if (!miniTool || typeof miniTool.getLaunchOptions !== "function") return 0;
try {
return readBuildVersion(await miniTool.getLaunchOptions());
} catch (error) {
return 0;
}
}
```
## postNote
打开笔记发布页。`mediaInfo` 必传,`image_resources`、`video_resources`、`live_photo_sources` 至少提供一种。
| 字段 | 类型 | 说明 |
|--------------------------------|-----------------------------|------------------------------------------------------------|
| `title` | string | 标题,最长 20 字 |
| `content` | string | 正文,最长 1000 字 |
| `pageType` | string | `video_publish`、`photo_publish` 或 `slides_edit`(9.43+) |
| `mediaInfo.image_resources` | `{ url }[]` | 图片,1–18 张;`url` 为 data URI 或本地路径 |
| `mediaInfo.video_resources` | `{ video_url, cover_url? }` | 单个视频及可选封面 |
| `mediaInfo.live_photo_sources` | `{ url, video_url }[]` | 实况照片,1–18 组(9.43+) |
```js
await window.xhs.miniTool.postNote({
title: "我的作品",
content: "用小工具生成的",
pageType: "photo_publish",
mediaInfo: {
image_resources: [{ url: "data:image/png;base64,..." }],
},
});
```
```js
// 视频笔记
await window.xhs.miniTool.postNote({
pageType: "video_publish",
mediaInfo: {
video_resources: { video_url: videoPath, cover_url: coverPath },
},
});
// 实况笔记(客户端 9.43+)
await window.xhs.miniTool.postNote({
pageType: "slides_edit",
mediaInfo: {
live_photo_sources: [{ url: coverPath, video_url: videoPath }],
},
});
```
成功回调只表示发布页已被唤起并由用户点击发布,不代表笔记最终审核通过;不要据此做强一致业务状态。
## saveImageToPhotosAlbum
| 字段 | 类型 | 必填 | 说明 |
|------------|--------|------|--------------------------------------------------------------------------------------|
| `filePath` | string | 是 | 本地图片:`data:` base64 或 `writeTempFile` 返回的路径;不支持 `http(s)://` 网络地址 |
```js
const dataUrl = canvas.toDataURL("image/png");
await window.xhs.miniTool.saveImageToPhotosAlbum({ filePath: dataUrl });
```
应由用户点击等主动操作触发;首次调用可能请求相册权限。大图建议先通过 `writeTempFile` 落成文件,再保存至相册。
## writeTempFile
将 Canvas 或选图结果等 base64 数据写为临时文件:
```js
const { filePath } = await window.xhs.miniTool.writeTempFile({
data: canvas.toDataURL("image/png"),
});
await window.xhs.miniTool.saveImageToPhotosAlbum({ filePath });
await window.xhs.miniTool.postNote({
mediaInfo: { image_resources: [{ url: filePath }] },
});
```
| 字段 | 类型 | 说明 |
|------------|--------|---------------------------------------------------|
| `data` | string | 必填,base64 数据;支持带 `data:` 前缀的 data URI |
| `filePath` | string | 成功返回的临时文件路径 |
临时文件应即用即弃,不可作为长期持久化路径。支持常见图片与视频类型:png、jpeg、webp、gif、mp4。
## Storage 本地缓存
Storage API 在客户端 9.46+ 可用。`data` 只支持 JSON 字符串;对象或数组需要先序列化,读取后再解析。
| API | 参数 | 结果 / 说明 |
|------------------|----------------------------------------------------|--------------------------------------------------|
| `setStorage` | `key: string`、`data: string`、`encrypt?: boolean` | 写入或覆盖缓存 |
| `getStorage` | `key: string`、`encrypt?: boolean` | 返回 `{ data }`;`encrypt` 与写入时一致 |
| `getStorageInfo` | 无业务参数 | 返回 `{ keys, currentSize, limitSize }`,单位 KB |
| `removeStorage` | `key: string` | 删除指定缓存 |
| `clearStorage` | 无业务参数 | 清空当前小工具缓存 |
单个 key 最大 1MB,当前小工具总缓存最大 10MB;`encrypt` 默认 `false`。
以下封装会在 9.46+ 使用 Storage,并在低版本回退到浏览器存储;调用方必须处理其返回的 `false`,不能假设数据已成功持久化:
```js
const STORAGE_MIN_CLIENT_VERSION = 9460;
async function setLocalData(key, data) {
let serializedData;
try {
serializedData = JSON.stringify(data);
} catch (error) {
return false;
}
if (typeof serializedData !== "string") return false;
const buildVersion = await getBuildVersion();
const miniTool = window.xhs && window.xhs.miniTool;
if (
isClientVersionAtLeast(buildVersion, STORAGE_MIN_CLIENT_VERSION) &&
miniTool &&
typeof miniTool.setStorage === "function"
) {
try {
await miniTool.setStorage({ key, data: serializedData });
return true;
} catch (error) {
return false;
}
}
try {
localStorage.setItem(key, serializedData);
return true;
} catch (error) {
return false;
}
}
```
```js
await window.xhs.miniTool.setStorage({
key: "profile",
data: JSON.stringify({ nickname: "小红薯" }),
});
const { data } = await window.xhs.miniTool.getStorage({ key: "profile" });
const profile = data === null ? null : JSON.parse(data);
```
低版本可以按需降级到浏览器存储,但必须处理读写失败,并容忍数据丢失或被清理。
## 文件系统
文件系统 API 在客户端 9.49+ 可用。文件和二进制数据使用文件系统保存,目录根路径来自 `miniToolEnv.userDataPath`。
| API | 参数 | 结果 / 说明 |
| --- | --- | --- |
| `saveFile` | `tempFilePath: string`、`filePath?: string \| null` | 将临时文件移动至持久目录,返回 `{ savedFilePath }` |
| `writeFile` | `filePath`、`data`、`encoding: "utf8" \| "base64"` | 覆盖写入,返回 `{ writtenBytes }` |
| `appendFile` | `filePath`、`data`、`encoding: "utf8" \| "base64"` | 追加写入,返回 `{ writtenBytes }` |
| `readFile` | `filePath`、`encoding`、`position?`、`length?` | 返回 `{ data, bytesRead, eof }` |
| `readDir` | `dirPath` | 返回 `{ files, truncated }` |
| `statFile` | `filePath` | 返回 `{ size, lastModified, isDir }` |
| `unlink` | `filePath` | 删除持久目录中的文件 |
| `mkdir` | `dirPath`、`recursive?` | 创建持久目录 |
| `getFileStorageInfo` | 无业务参数 | 返回 `{ usedBytes, limitBytes, fileCount, tmpUsedBytes, writeChunkMaxBytes, readChunkMaxBytes }` |
```js
const options = await window.xhs.miniTool.getLaunchOptions();
const filePath = options.miniToolEnv.userDataPath + "/drafts/note.json";
await window.xhs.miniTool.writeFile({
filePath,
data: JSON.stringify({ title: "草稿" }),
encoding: "utf8",
});
const { data } = await window.xhs.miniTool.readFile({
filePath,
encoding: "utf8",
});
```
- `writeFile` 为覆盖写,`appendFile` 为追加写。写大文件时,第一片使用 `writeFile`,后续片串行使用 `appendFile`。
- 分片大小以 `getFileStorageInfo` 返回的 `writeChunkMaxBytes` 和 `readChunkMaxBytes` 为准,不要硬编码。
- 渲染图片或视频时直接使用文件句柄作为 `img.src`、`video.src` 或 CSS 资源;需要字节时才使用 `readFile`。
- `usr` 是本地工作区,不是备份空间。卸载、清数据或包清理后可能丢失,重要数据应可重建或由用户导出。
## interactionOpenApi 发布评论
评论区能力在客户端 9.49+ 可用,应由用户点击等主动操作触发。调用后容器会统一关闭小工具,再拉起评论区。
| 字段 | 类型 | 说明 |
|--------------------------------|---------|------------------------------------------------------|
| `payload` | object | 必填,评论草稿;字段由评论侧定义 |
| `payload.action` | string | 发布评论时使用 `post_comment` |
| `payload.content` | string | 可选,评论文本 |
| `payload.media_bean` | array | 可选,有序图片列表;媒体路径使用本地文件句柄 |
| `payload.miniToolSnapshotInfo` | string | 可选,小工具附加状态 JSON 字符串;最大 2KB,超限无效 |
| `saveToAlbum` | boolean | 可选,是否将图片同步保存到相册;默认 `true` |
```js
const result = await window.xhs.miniTool.interactionOpenApi({
payload: {
action: "post_comment",
content: "快来和我 PK!",
media_bean: [{
media_type: "image",
cover_image_url: imageFilePath,
}],
miniToolSnapshotInfo: JSON.stringify({ page: "result" }),
},
saveToAlbum: true,
});
// { routed, savedToAlbum, albumFailReason? }
```
- 评论文本和图片均可不传,仍可唤起评论区。
- 目前仅支持图片:`{ media_type: "image", cover_image_url }`;不支持视频或实况图。
- 媒体路径必须是容器可访问的本地文件句柄;不支持网络 URL、`data:` URI 或绝对路径。
- 用户从评论区重新打开小工具时,可读取有效的 `miniToolSnapshotInfo` 以恢复业务状态;恢复逻辑由开发者实现。
- `routed` 表示评论侧路由是否成功。相册保存失败时,原因通过可选字段 `albumFailReason` 返回。
@@ -0,0 +1,42 @@
# JavaScript 兼容性规范
> 目标内核固定为 **Android 8.1 出场 Chrome / WebView 61**。最终代码必须能在 Chrome 61 解析和运行;晚于 Chrome 61 的语法与运行时 API 一律不得出现在最终产物里,`validate.mjs` 判为 ERROR。Chrome 61 完整支持 ES2017,最终代码以 ES2017 为构建目标。
## 1. 语法基线
最终 zip 中的 JS 须兼容 Chrome 61:
- ES2018+ 语法须由构建链转译,例如对象 spread、异步迭代、可选链、空值合并、逻辑赋值、class 私有字段、static block、BigInt 字面量和 top-level await;晚于 ES2017 的运行时 API 须改用基线写法。
语法不兼容会在脚本解析阶段直接失败,无法通过运行时 `if` 兜底。
## 2. 有构建链与无构建链
### 直接交付静态三件套
没有现成构建链时,不为兼容性临时引入 Babel、core-js 或新的 npm 依赖;直接按 ES2017 编写。
### 项目已有构建链
构建目标须为 ES2017 / Chrome 61,由 `vite.config.xhs-minitool.mjs` 固化。
最终产物须:
- 转译到 ES2017 / Chrome 61;
- 只把构建后的静态文件放进 zip,不带 `node_modules`、source map 或构建配置。
转译只解决语法,不会自动补齐所有运行时 API。不要因为构建成功就假定新 API 可用。
## 3. 运行时 API
- ES2017 内置 API 和基础 DOM API 可直接使用。
- `String.prototype.replaceAll`、`Array.prototype.at`、`Object.hasOwn`、`structuredClone` 等更新 API 晚于 Chrome 61,最终产物不得直接使用(`validate.mjs` 的 `MODERN_RUNTIME_API` 会拦下);用 `split()/join()`、下标、`Object.prototype.hasOwnProperty.call`、手写深拷贝等基线写法替代。
- 其余非基础 Web API 使用前做能力检测;不可用时降级为简单替代实现或给出清晰提示。
- 只补功能实际需要的小型本地 fallback,不引入整套通用 polyfill;所有代码仍须随包离线交付。
- 能力检测基于对象 / 方法是否存在,不按 UA、机型或系统版本字符串分支。
- 不为被容器明确禁止的能力添加 polyfill;能力边界仍以 [device-capabilities.md](./device-capabilities.md) 为准。
## 4. 跨内核行为
- 不依赖对象遍历顺序表达业务优先级;需要顺序时使用数组。
- 触摸、滚动和安全区规则见 [cross-platform-h5.md](./cross-platform-h5.md)。
@@ -0,0 +1,14 @@
## 端能力
- 相机 / 麦克风由用户手势触发,并处理系统弹窗授权。
## JS 兼容
- 除 `replaceAll` / `.at` / `Object.hasOwn` / `structuredClone` 这类晚于 Chrome 61 的 API(须用基线写法替代)外,使用非基础 Web API 前先做能力检测;不可用时降级为简单替代实现或给出清晰提示;不按 UA、机型或系统版本字符串分支。
## CSS 兼容
- 最终产物只使用 Chrome 61 可解析的 CSS;`validate.mjs` 已拦下 gap、`min()/max()/clamp()`、逻辑属性、`:focus-visible`、`:has()`、`@container`、`dvh` 等,额外人工确认构建后没有残留。
- Flex 间距用子项 `margin`,Grid 用 `grid-gap`;不使用 flex `gap`。
- Flex 子项内有长文本、图片或滚动区域时,按方向显式设置 `min-width: 0` 或 `min-height: 0`。
- 关键操作不能只在 `:hover` 出现,触摸端默认可见;安全区用 `var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` 组合,配合 `viewport-fit=cover`。
## 性能
- WebGL 有动态降档、页面隐藏暂停、context lost 处理和非 WebGL 兜底。
@@ -0,0 +1,98 @@
# 小工具性能预算与降级规范
> 本文提供移动端 WebView 的设计预算与降级要求。Skill 只能检查静态产物和代码设计,不能从源码推断真实帧率、内存或真机表现;没有运行数据时必须明确标记“未实测”。
## 目录
- §1 交付门禁
- §2 静态数据与长列表
- §3 图片、音频与视频
- §4 WebGL 资源与降级基线
- §5 运行时降级与兜底
---
## 1. 交付门禁
| 项目 | 门禁 | 原因 |
| --- | --- | --- |
| 最终 zip | **不超过 10 MiB** | 10 MiB 是上传上限,不是性能目标 |
| 单条 Base64 | 解码后不超过 1 MiB;超过 1 MiB 必须改成独立包内文件 | Base64 体积约增加 1/3,还会产生字符串与解码副本 |
单条 Base64 由 [validate.mjs](../scripts/validate.mjs) 在产物目录检查;最终 zip 包体由 [pack.mjs](../scripts/pack.mjs) 在打包后检查。
## 2. 静态数据与长列表
`.js` / `.json` 是静态资源,不是数据库。**不要把数万条记录、完整业务库、日志或抓取结果生成成 JS 数组。** JSON 与 JS 分文件只能改善组织,不能消除下载、解析和内存成本。
按以下顺序缩减:
1. 只保留完成核心功能必需的字段和记录,删除重复字段、长描述和历史快照。
2. 能预计算的统计、搜索索引和分类结果在构建期生成;不要在首屏对全量数据反复遍历。
3. 大型只读数据集改为摘要、分段样例或让用户通过 `<input type="file">` 按需导入。若完整离线数据不可删且仍超门禁,应明确说明该需求不适合小工具,而不是继续打包。
4. 用户产生或导入的数据需要持久化时,根据任务开始时从在线文档整理的端能力、最低版本和替代关系选择方案;没有适用于目标客户端的端能力时使用浏览器本地存储。运行期存储不得用于掩盖巨大的内置种子数据。
渲染列表时:
- 首屏只创建可见项;长列表使用分页或虚拟滚动,不一次性拼接整份 `innerHTML`。
- 搜索输入按交互频率和数据规模采用防抖,避免每次按键都触发完整查询;最终输入应及时执行。数据量较大时可预先建立小型索引,避免反复扫描所有字段。
- 非首屏工作分批执行并主动让出主线程,避免把解析、计算和渲染集中在同一个同步流程中。
- 不在循环中反复读写布局属性;批量生成 DOM 后一次挂载。
---
## 3. 图片、音频与视频
- 小型图片、图标等可以使用 Base64;超过 1 MiB 不允许内嵌。
- 图片按真机展示尺寸缩放并压缩;不要为了 300 px 展示区域打包 4K 原图。
- 音视频先裁剪时长,再降低分辨率、帧率和码率。5 分钟视频通常不适合随小工具离线交付。
- 视频设置 `preload="metadata"` 或 `preload="none"`,提供 `poster`;未进入播放页前不要创建或解码媒体。
- 同时只保留必要的媒体实例;离开页面后暂停播放、清空不再使用的 `src` 并释放对象 URL。
`FileReader.readAsDataURL()` 可用于用户刚选择的小文件预览或在线文档明确要求 data URI 的短暂转换;大文件不要把结果持久写回静态源码,并在不用时 `URL.revokeObjectURL()`。注意:即使体积很小,`<video>` / `<audio>` 的 `data:` 媒体源仍不受容器 CSP 支持,应引用包内媒体文件。
---
## 4. WebGL 资源与降级基线
WebGL 是可用能力,不代表所有设备都能稳定运行复杂场景。以下数值是生成代码时采用的保守设计预算,不是 Skill 对实际性能的测量结果。目标体验按 **30 FPS 可交互**设计,60 FPS 仅作为有运行数据支持时的高档增强。
普通 Canvas 2D 不纳入本节的 GPU 分档要求;按实际展示尺寸创建画布。只有使用 WebGL 上下文时才执行以下资源预算、降档和 context 兜底。
### 初始预算
| 指标 | 默认档 | 低档 / 降级档 |
| --- | --- | --- |
| WebGL drawing buffer DPR | `min(devicePixelRatio, 1.5)` | `1` |
| WebGL drawing buffer 像素数 | 不超过约 200 万 | 不超过约 100 万 |
| 单张纹理边长 | 不超过 2048 | 不超过 1024 |
| 估算纹理显存 | 不超过 64 MiB | 不超过 32 MiB |
| 每帧 draw call | 不超过 100 | 不超过 50 |
| 每帧三角形 | 不超过 100k | 不超过 50k |
| 帧率目标 | 稳定 30 FPS,设备有余量再升档 | 稳定 24–30 FPS |
这些是移动 WebView 的保守初始预算;没有实测数据时保持预算与降级路径,不得声称已经达到某个帧率或通过真机性能验收。
### 渲染规则
- 兼容 WebGL 1;使用 WebGL 2 特性时必须检测能力并提供 WebGL 1 或非 WebGL 兜底。
- 初始化先使用低 / 中档,不按高 DPR 直接创建最大缓冲区。尺寸变化时重新计算并继续受像素预算约束。
- 纹理使用实际需要的尺寸;复用纹理、材质、几何体和 framebuffer。估算 RGBA8 纹理最低占用:`宽 × 高 × 4`,mipmap 还会额外增加约 1/3。
- 合并可合并的几何与 draw call,视锥 / 距离裁剪不可见对象;粒子、阴影、后处理、透明叠加和实时反射必须能逐项关闭。
- shader 在初始化或切换场景时编译;纹理上传和模型解析分批进行,不在动画帧中首次集中完成。
- 不在动画循环内创建对象 / 数组 / 大字符串;禁止每帧 `readPixels()`、`toDataURL()`、大面积 `getImageData()` 或同步回读 GPU。
- 页面不可见时通过 `visibilitychange` 停止 `requestAnimationFrame`、媒体和定时器;恢复后重置时间差,不补算大量帧。
- 处理 `webglcontextlost` / `webglcontextrestored`;context 丢失时停止渲染并展示轻量状态,不要无限重建。
---
## 5. 运行时降级与兜底
不要依赖机型名单。以实际帧耗时和能力检测决定档位:
1. 首次进入采用低 / 中档,逐步加载非必要效果。
2. 运行时观测到持续掉帧或交互响应变差时,逐级降低 DPR、粒子数、阴影、后处理、可视距离和动画频率。没有运行数据时只确认降级路径存在,不判断是否达到触发条件。
3. 降到最低档仍不可交互时,停止高成本循环并切换 Canvas 2D、静态图或简化 DOM 视图。
4. `getContext()` 失败、shader 编译 / 链接失败或 context 反复丢失时,必须进入可理解的兜底界面;不能白屏、死循环重试或持续弹错。
如果核心功能并不依赖 3D,默认选择 DOM / CSS / Canvas 2D。WebGL 应解决明确的视觉或计算需求,而不是作为普通表单、列表和信息展示的默认技术栈。
@@ -0,0 +1,59 @@
# 小工具 ZIP 静态包构建规范
> 小工具是基于离线 H5 的 app 形式,**纯本地、不联网**,所有资源须打包在 zip 内。窗口样式、导航栏、下拉刷新等外壳行为由**容器**统一控制,无需在包内声明。
## 目录
- §2 支持的文件类型
- §3 资源加载规则(容器 CSP)
- §5 index.html 要求
---
## 2. 支持的文件类型
zip 内仅允许以下类型:
| 类型 | 用途 |
|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------|
| `.html` | 入口,有且只有一个 `index.html` |
| `.css` | 样式文件 |
| `.js` | 脚本文件 |
| `.png` / `.jpg` / `.jpeg` / `.gif` / `.webp` / `.svg` | 图片资源 |
| `.woff` / `.woff2` | 字体文件 |
| `.json` | 小型静态数据 / 配置;不得作为大型内置数据库,体积门禁见 [performance-budget.md](./performance-budget.md) |
---
## 3. 资源加载规则(容器 CSP)
容器对页面**如何加载各类资源**有强制约束。除包内文件外,按类型另允许 `data:` / `blob:` 等内存来源。
| 资源类型 | 允许 | 禁止 |
|------------------------------|---------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| 脚本 `<script>` | 引用包内脚本 `<script src="./app.js">`(同源外链) | 内联 `<script>...</script>`;行内事件 `onclick="..."`;`javascript:` URI;`eval()` / `new Function()`;WebAssembly;外部域名 / `data:` / `blob:` 脚本 |
| 样式 `<style>` / `<link>` | 内联 `<style>`、行内 `style="..."`、包内样式表 | 外部域名样式表 |
| 图片 `<img>` / CSS 背景图 | 包内图片 `<img src="./a.png">`;`data:` URI(base64 内嵌);`blob:`(`createObjectURL` 内存对象,如选图预览) | 外部域名图片 |
| 字体 `@font-face` | 包内字体文件 | 外部域名字体 |
| 音视频 `<video>` / `<audio>` | 包内媒体文件 | 外部域名媒体、`data:` / `blob:` 媒体 |
| iframe / object | — | 全部禁止 |
关键点:
- **脚本必须外置**:容器 CSP 的 `script-src` 不含 `unsafe-inline`,内联 `<script>...</script>`、行内事件 `onclick="..."`、`javascript:` URI 均不可用。JS 写进包内 `.js` 用 `<script src>` 引入,事件用 `addEventListener` 绑定。
- **脚本必须是经典脚本**:只用 `<script src="./app.js">`,**不要 `type="module"`**,JS 里也不要 `import` / `export`。zip 离线加载、无目录服务,module 的相对 `import` 解析不可靠,典型症状是「页面渲染出来但 JS 完全不执行」。要拆多个 JS 文件时按依赖顺序写多个 `<script src>`,靠 `window` 命名空间协作,并避免 top-level `await`。
- **脚本须兼容目标 WebView**:直接交付的 JS 可使用 ES2017;已有构建链可使用更新语法,但最终须转译为面向 Chrome 61 的 ES2017 产物,见 [js-compatibility.md](./js-compatibility.md)。
- **样式可内联**:`<style>` 与 `style="..."` 都能用,无需外置。
- **样式须兼容目标 WebView**:只使用 Chrome 61 可解析的 CSS;`gap`、`min()/max()/clamp()`、逻辑属性等晚于 61 的能力由 `validate.mjs` 判为 ERROR,见 [css-compatibility.md](./css-compatibility.md)。
- **选图预览**:`<img src>` 配 `data:`(`FileReader.readAsDataURL`)或 `blob:`(`URL.createObjectURL`)均可显示;大图优先 `blob:` 并及时 `URL.revokeObjectURL()`。静态资源不得转成长 Base64 塞进源码,见 [performance-budget.md](./performance-budget.md)。
- 外部 CDN 一律加载不到,所有资源全部打包进小工具。
---
## 5. index.html 要求
| 规则 | 原因 |
|-------------------------------------------------|----------------------------------|
| 不引用任何外部资源(图片 / CSS / JS / 字体) | 外部资源加载不到,须全部打进 zip |
- `<title>` 仅影响文档标题;导航栏标题由容器 UI 配置。
@@ -0,0 +1,173 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
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';
import test, { after } from 'node:test';
import { fileURLToPath } from 'node:url';
import { auditZipFile, packMinitool } from './pack.mjs';
import { findUnsupportedFiles } from './validate.mjs';
const PACK = fileURLToPath(new URL('./pack.mjs', import.meta.url));
const BASE = mkdtempSync(join(tmpdir(), 'xhs-pack-tests-'));
after(() => {
rmSync(BASE, { recursive: true, force: true });
});
const VALID_HTML = '<!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">'
+ '</head><body><script src="./app.js"></script></body></html>';
/** 造一个最小的 vite 构建产物目录;extra 用来放白名单外的文件。 */
function writeBuiltDir(name, extra = {}) {
const root = join(BASE, name);
mkdirSync(root, { recursive: true });
writeFileSync(join(root, 'index.html'), VALID_HTML, 'utf8');
writeFileSync(join(root, 'app.js'), "console.log('ok');\n", 'utf8');
for (const [relativePath, content] of Object.entries(extra)) {
const path = join(root, relativePath);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content, 'utf8');
}
return root;
}
test('zip budget is classified', () => {
assert.deepEqual(auditZipFile('tool.zip', 1024), []);
assert.equal(auditZipFile('tool.zip', 3 * 1024 * 1024)[0].code, 'ZIP_OVER_RECOMMENDED');
assert.equal(auditZipFile('tool.zip', 11 * 1024 * 1024)[0].code, 'ZIP_OVER_HARD_LIMIT');
});
test('findUnsupportedFiles 只报白名单外的扩展名', () => {
const root = writeBuiltDir('precheck', {
'notes.md': 'readme\n',
'src/config.ts': 'export const x = 1;\n',
});
const found = findUnsupportedFiles(root).map((item) => item.path).sort();
assert.deepEqual(found, ['notes.md', 'src/config.ts']);
});
test('存在不支持文件时打包整体失败,且不删除任何文件', () => {
const root = writeBuiltDir('no-delete', {
'notes.md': 'readme\n',
'style.scss': 'body { color: red; }\n',
});
assert.throws(
() => packMinitool({
cwd: BASE,
viteBuiltDir: 'no-delete',
zipOut: 'no-delete-out/xhs-minitool.zip',
}),
/没有删除任何文件/,
);
assert.ok(existsSync(join(root, 'notes.md')), 'notes.md 被删了');
assert.ok(existsSync(join(root, 'style.scss')), 'style.scss 被删了');
assert.equal(existsSync(join(BASE, 'no-delete-out')), false, '失败时不应写出 zip');
});
test('makeZip 的校验也发生在落盘前,失败时不留 zip', () => {
// 缺 index.html:文件扩展名都合法,预检会放行,失败点在 makeZip。
const noIndex = writeBuiltDir('no-index');
rmSync(join(noIndex, 'index.html'));
const noIndexOut = join(BASE, 'no-index-out', 'xhs-minitool.zip');
assert.throws(
() => packMinitool({ cwd: BASE, viteBuiltDir: 'no-index', zipOut: noIndexOut }),
/缺少 index\.html/,
);
assert.equal(existsSync(noIndexOut), false, '缺 index.html 时不应写出 zip');
// 含禁止条目:vite.config.js 扩展名在白名单里,但命中 FORBIDDEN_ENTRY。
writeBuiltDir('forbidden-entry', { 'vite.config.js': 'export default {};\n' });
const forbiddenOut = join(BASE, 'forbidden-entry-out', 'xhs-minitool.zip');
assert.throws(
() => packMinitool({
cwd: BASE,
viteBuiltDir: 'forbidden-entry',
zipOut: forbiddenOut,
}),
/禁止文件/,
);
assert.equal(existsSync(forbiddenOut), false, '含禁止条目时不应写出 zip');
});
test('干净产物目录仍能正常打包', () => {
writeBuiltDir('clean');
const zipOut = join(BASE, 'clean-out', 'xhs-minitool.zip');
const code = packMinitool({
cwd: BASE,
viteBuiltDir: 'clean',
zipOut,
});
assert.equal(code, 0);
assert.ok(existsSync(zipOut), 'zip 未生成');
});
test('--zip-out 默认 ../.export/xhs-minitool.zip(假设 cwd 是 game/)', () => {
const game = join(BASE, 'default-layout', 'game');
writeBuiltDir(join('default-layout', 'game', 'dist-xhs-minitool'));
assert.equal(packMinitool({ cwd: game }), 0);
assert.ok(
existsSync(join(BASE, 'default-layout', '.export', 'xhs-minitool.zip')),
'默认落点不在项目根的 .export/',
);
});
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('--zip-out 落在 cwd 的 .export 下时给出 workdir 警告', () => {
writeBuiltDir('workdir');
const warnings = [];
const original = console.warn;
console.warn = (line) => warnings.push(String(line));
try {
// 可疑:相对 cwd 解析成 <cwd>/.export/...;cwd 若是 game/ 子工程就落错地方。
packMinitool({ cwd: BASE, viteBuiltDir: 'workdir', zipOut: '.export/xhs-minitool.zip' });
// 别的相对落点(不落在 cwd 的 .export 下)不提醒。
packMinitool({
cwd: BASE,
viteBuiltDir: 'workdir',
zipOut: join('other', 'xhs-minitool.zip'),
});
} finally {
console.warn = original;
}
assert.equal(
warnings.filter((line) => line.includes('--zip-out')).length,
1,
warnings.join('\n'),
);
});
test('CLI 拒绝不认识的参数,不写出 zip', () => {
const dir = writeBuiltDir('cli');
const zipOut = join(BASE, 'cli-out.zip');
const result = spawnSync(
process.execPath,
[PACK, '--vite-built-dir', dir, '--out-dir', dir, '--zip-out', zipOut],
{ cwd: BASE, encoding: 'utf8' },
);
assert.equal(result.status, 2);
assert.match(result.stderr, /不认识的参数/);
assert.equal(existsSync(zipOut), false, '参数被拒时不应写出 zip');
});
@@ -0,0 +1,424 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { spawnSync } from 'node:child_process';
import test, { after } from 'node:test';
import { fileURLToPath } from 'node:url';
import { splitUrl, unquote, validate } from './validate.mjs';
const VALIDATOR = fileURLToPath(new URL('./validate.mjs', import.meta.url));
const BASE = mkdtempSync(join(tmpdir(), 'xhs-validator-tests-'));
after(() => {
rmSync(BASE, { recursive: true, force: true });
});
const VALID_HTML = '<!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">'
+ '<link rel="stylesheet" href="./styles.css">'
+ '</head><body>'
+ '<img src="./icon.svg" alt="">'
+ '<script src="./app.js"></script>'
+ '</body></html>';
const VALID_FILES = {
'styles.css': ".hero { background: url('./icon.svg'); }\n",
'app.js': "console.log('ok');\n",
'icon.svg': '<svg xmlns="http://www.w3.org/2000/svg"></svg>\n',
};
function writeProject(root, html, extra = {}) {
mkdirSync(root, { recursive: true });
writeFileSync(join(root, 'index.html'), html, 'utf8');
for (const [name, content] of Object.entries(extra)) {
const path = join(root, name);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content, 'utf8');
}
}
function codes(root) {
return new Set(validate(root).findings.map((item) => item.code));
}
function runCli(root, ...args) {
return spawnSync(process.execPath, [VALIDATOR, root, ...args], {
encoding: 'utf8',
});
}
function expectCodes(name, html, extra, expected) {
const root = join(BASE, name);
writeProject(root, html, extra);
const actual = codes(root);
const missing = [...expected].filter((code) => !actual.has(code));
assert.deepEqual(missing, [], `${name}: missing ${missing}; actual=${[...actual].sort()}`);
}
test('valid project has no findings', () => {
const root = join(BASE, 'valid');
writeProject(root, VALID_HTML, VALID_FILES);
assert.deepEqual(validate(root).findings, []);
});
test('inline and unclosed scripts are rejected', () => {
expectCodes(
'inline_script',
"<!doctype html><script>console.log('inline')</script>",
{},
['INLINE_SCRIPT'],
);
expectCodes(
'unclosed_inline_script',
"<!doctype html><script>console.log('inline')",
{},
['INLINE_SCRIPT', 'UNCLOSED_SCRIPT_TAG'],
);
});
test('resource paths must stay packaged and relative', () => {
expectCodes(
'resource_escape',
'<!doctype html><script src="../outside.js"></script>',
{},
['RESOURCE_OUTSIDE_ROOT'],
);
expectCodes(
'absolute_resource',
'<!doctype html><script src="/app.js"></script>',
{},
['ABSOLUTE_RESOURCE_PATH'],
);
expectCodes(
'missing_css_url',
'<!doctype html><link rel="stylesheet" href="./styles.css">',
{ 'styles.css': ".hero { background: url('./missing.png'); }\n" },
['MISSING_LOCAL_RESOURCE'],
);
});
test('navigation and embedded content are rejected', () => {
expectCodes(
'mailto_navigation',
'<!doctype html><a href="mailto:test@example.com">mail</a>',
{},
['LINK_NAVIGATION'],
);
expectCodes(
'meta_refresh',
'<!doctype html><meta http-equiv="refresh" content="0;url=next.html">',
{},
['META_REFRESH'],
);
expectCodes(
'base_url',
'<!doctype html><base href="./assets/">',
{},
['BASE_URL'],
);
expectCodes(
'form_navigation',
'<!doctype html><form action="javascript:void(0)"></form>',
{},
['FORM_NAVIGATION'],
);
});
test('unsupported mobile APIs are flagged', () => {
expectCodes(
'unsupported_mobile_apis',
'<!doctype html><script src="./app.js"></script>',
{
'app.js': 'new PaymentRequest([], {});\n'
+ 'Notification.requestPermission();\n'
+ 'new NDEFReader();\n'
+ 'navigator.requestMIDIAccess();\n'
+ "navigator.xr.requestSession('inline');\n"
+ 'document.body.requestPointerLock();\n'
+ 'new SyncManager();\n',
},
[
'PAYMENT_REQUEST', 'NOTIFICATION_API', 'NFC_API',
'MIDI_API', 'XR_API', 'POINTER_KEYBOARD_LOCK', 'BACKGROUND_SYNC',
],
);
});
test('network, storage, filesystem and download APIs are flagged', () => {
expectCodes(
'network_storage_filesystem_download',
'<!doctype html><script src="./app.js"></script>',
{
'app.js': "navigator.sendBeacon('/log', 'x');\n"
+ "new WebTransport('https://example.com');\n"
+ 'navigator.storage.persist();\n'
+ 'showSaveFilePicker();\n'
+ "anchor.download = 'file.txt';\n",
},
[
'NETWORK_BEACON', 'NETWORK_WEBTRANSPORT', 'PERSISTENT_STORAGE',
'FILE_SYSTEM_ACCESS', 'PROGRAMMATIC_DOWNLOAD',
],
);
});
test('PWA manifest, plugins and area navigation are rejected', () => {
expectCodes(
'pwa_plugin_and_area_navigation',
'<!doctype html><link rel="manifest" href="./manifest.json">'
+ '<applet></applet><map><area href="./next.html"></map>',
{ 'manifest.json': '{}\n' },
['PWA_MANIFEST', 'EMBEDDED_CONTENT', 'LINK_NAVIGATION'],
);
});
test('data/blob, javascript: and dynamic paths are classified', () => {
expectCodes(
'data_blob',
'<!doctype html><img src="data:image/png;base64,AA">'
+ '<source srcset="data:image/png;base64,AA">'
+ '<script src="blob:abc"></script>',
{},
['IMAGE_DATA_BLOB_VERSION', 'DISALLOWED_DATA_BLOB'],
);
expectCodes(
'javascript_uri',
'<!doctype html><a href="javascript:void(0)">x</a><img src="javascript:alert(1)">',
{},
['JAVASCRIPT_URI'],
);
expectCodes(
'dynamic_path',
'<!doctype html><script src="./{{name}}.js"></script>',
{},
['DYNAMIC_RESOURCE_PATH'],
);
});
test('inline events, file accept limits and form review produce warnings', () => {
const root = join(BASE, 'warnings');
writeProject(
root,
'<!doctype html><body onload="x()">'
+ '<input type="file" accept="image/*,.txt">'
+ '<form action="#"></form>'
+ '</body>',
);
const actual = codes(root);
for (const code of ['INLINE_EVENT', 'FILE_ACCEPT_LIMIT', 'FORM_REVIEW']) {
assert.ok(actual.has(code), `missing ${code}; actual=${[...actual].sort()}`);
}
});
test('multiple or missing entry HTML files are rejected', () => {
const many = join(BASE, 'multi_html');
writeProject(many, '<!doctype html>', { 'other.html': '<!doctype html>' });
assert.ok(codes(many).has('HTML_ENTRY_COUNT'));
const none = join(BASE, 'no_html');
mkdirSync(none, { recursive: true });
writeFileSync(join(none, 'app.js'), 'console.log(1);\n');
assert.ok(codes(none).has('HTML_ENTRY_COUNT'));
});
test('entry must be root index.html', () => {
const renamed = join(BASE, 'renamed_entry');
mkdirSync(renamed, { recursive: true });
writeFileSync(join(renamed, 'main.html'), '<!doctype html>', 'utf8');
assert.ok(codes(renamed).has('ENTRY_NOT_INDEX_HTML'));
const nested = join(BASE, 'nested_entry');
mkdirSync(join(nested, 'app'), { recursive: true });
writeFileSync(join(nested, 'app', 'index.html'), '<!doctype html>', 'utf8');
assert.ok(codes(nested).has('ENTRY_NOT_INDEX_HTML'));
});
test('html template and classic-script requirements are enforced', () => {
expectCodes(
'html_template',
'<!doctype html><html><head>'
+ '<meta http-equiv="Content-Security-Policy" content="default-src \'self\'">'
+ '</head><body><script type="module" src="./app.js"></script></body></html>',
{ 'app.js': 'export const a = 1;\nconst b = obj?.c ?? 2;\n' },
[
'CHARSET_MISSING', 'VIEWPORT_MISSING', 'HTML_LANG_MISSING', 'CSP_META',
'MODULE_SCRIPT', 'ESM_SYNTAX', 'ES2018_PLUS_SYNTAX',
],
);
});
test('Chrome 61 不支持的 CSS 与运行时 API 直接报 ERROR', () => {
const root = join(BASE, 'modern_css');
writeProject(root, VALID_HTML, {
...VALID_FILES,
'styles.css': '.a { display: flex; gap: 8px; width: min(100%, 20rem); color: oklch(0.5 0.1 20); }\n',
'app.js': "list.replaceAll('a', 'b');\nObject.hasOwn({}, 'x');\n",
});
const findings = validate(root).findings;
const css = findings.find((item) => item.code === 'CSS_UNSUPPORTED_FEATURE');
assert.ok(css, `missing CSS_UNSUPPORTED_FEATURE; actual=${[...codes(root)].sort()}`);
assert.equal(css.severity, 'ERROR');
const runtime = findings.find((item) => item.code === 'MODERN_RUNTIME_API');
assert.ok(runtime, `missing MODERN_RUNTIME_API; actual=${[...codes(root)].sort()}`);
assert.equal(runtime.severity, 'ERROR');
});
test('grid-gap / -webkit- 前缀写法不算 Chrome 61 不支持', () => {
const root = join(BASE, 'legacy_gap');
writeProject(root, VALID_HTML, {
...VALID_FILES,
'styles.css': '.grid { display: grid; grid-gap: 8px; }\n'
+ '.cols { -webkit-column-gap: 8px; }\n',
});
assert.ok(!codes(root).has('CSS_UNSUPPORTED_FEATURE'), `actual=${[...codes(root)].sort()}`);
});
test('字符串与注释里的现代语法 / #hex / #选择器不算命中', () => {
const root = join(BASE, 'js_noise');
writeProject(root, VALID_HTML, {
...VALID_FILES,
'app.js': '// obj?.c ?? 2\n'
+ 'var color = "#e8f4ff";\n'
+ "var hit = document.querySelector('#hit');\n"
+ '// import.meta 也只出现在注释里\n',
});
assert.ok(!codes(root).has('ES2018_PLUS_SYNTAX'), `actual=${[...codes(root)].sort()}`);
});
test('注释与字符串里的 API 名不算命中,但字符串参数规则仍生效', () => {
const noisy = join(BASE, 'api_noise');
writeProject(noisy, VALID_HTML, {
...VALID_FILES,
// `fetch(` / `XMLHttpRequest` / `new Function(` / `eval(` 只出现在注释与字符串里:不是调用。
'app.js': '// 不能用 fetch( 和 XMLHttpRequest 请求网络\n'
+ 'var note = "new Function( 也不允许";\n'
+ "var evalNote = 'eval(\\'x\\')';\n",
});
const noisyCodes = codes(noisy);
assert.ok(!noisyCodes.has('NETWORK_FETCH'), `actual=${[...noisyCodes].sort()}`);
assert.ok(!noisyCodes.has('NETWORK_XHR'), `actual=${[...noisyCodes].sort()}`);
assert.ok(!noisyCodes.has('DYNAMIC_FUNCTION'), `actual=${[...noisyCodes].sort()}`);
assert.ok(!noisyCodes.has('DYNAMIC_EVAL'), `actual=${[...noisyCodes].sort()}`);
const real = join(BASE, 'api_real');
writeProject(real, VALID_HTML, {
...VALID_FILES,
// 这条规则要匹配的就是字符串参数:剥掉字符串就会漏检。
'app.js': "document.querySelector('a').setAttribute('download', '');\n",
});
const realCodes = codes(real);
assert.ok(realCodes.has('PROGRAMMATIC_DOWNLOAD'), `actual=${[...realCodes].sort()}`);
});
test('CSS 里的外部 URL 仍然报错(不会被当 // 注释抹掉)', () => {
const root = join(BASE, 'css_external_url');
writeProject(root, VALID_HTML, {
...VALID_FILES,
'styles.css': ".hero { background: url('https://cdn.evil.example/bg.png'); }\n",
});
assert.ok(codes(root).has('EXTERNAL_URL'), `actual=${[...codes(root)].sort()}`);
});
test('JS 字符串里的外部 URL 仍然报错', () => {
const root = join(BASE, 'js_external_url');
writeProject(root, VALID_HTML, {
...VALID_FILES,
'app.js': "fetch('https://api.evil.example/data');\n",
});
const actual = codes(root);
assert.ok(actual.has('NETWORK_FETCH'), `actual=${[...actual].sort()}`);
assert.ok(actual.has('EXTERNAL_URL'), `actual=${[...actual].sort()}`);
});
test('真正的 class 私有字段仍然报错', () => {
const root = join(BASE, 'private_field_real');
writeProject(root, VALID_HTML, {
...VALID_FILES,
'app.js': 'class Counter { #count = 0; }\n',
});
assert.ok(codes(root).has('ES2018_PLUS_SYNTAX'), `actual=${[...codes(root)].sort()}`);
});
test('oversized inline base64 is warned', () => {
const root = join(BASE, 'base64_budget');
const payload = 'A'.repeat(200 * 1024);
writeProject(root, VALID_HTML, {
...VALID_FILES,
'app.js': `var img = "data:image/png;base64,${payload}";\n`,
});
assert.ok(codes(root).has('BASE64_LARGE'), `actual=${[...codes(root)].sort()}`);
});
test('unsupported file types and invalid UTF-8 text are rejected', () => {
const unsupported = join(BASE, 'unsupported_file');
writeProject(unsupported, '<!doctype html>', { 'app.ts': 'let x = 1;\n' });
assert.ok(codes(unsupported).has('UNSUPPORTED_FILE'));
const invalid = join(BASE, 'invalid_utf8');
mkdirSync(invalid, { recursive: true });
writeFileSync(join(invalid, 'index.html'), Buffer.from([
0x3c, 0x21, 0x64, 0x6f, 0x63, 0x74, 0x79, 0x70, 0x65, 0x20, 0xff, 0xfe,
]));
assert.ok(codes(invalid).has('TEXT_DECODE'));
});
test('symlinks are rejected', (t) => {
const root = join(BASE, 'symlink_case');
const outside = join(BASE, 'outside.js');
writeProject(root, '<!doctype html><title>ok</title>');
writeFileSync(outside, "console.log('outside')\n");
try {
symlinkSync(outside, join(root, 'linked.js'));
} catch {
t.skip('symlink creation not permitted on this platform');
return;
}
assert.ok(codes(root).has('SYMLINK'));
});
test('CLI exit codes: pass, warning and error', () => {
const validRoot = join(BASE, 'valid_cli');
writeProject(validRoot, VALID_HTML, VALID_FILES);
const validCli = runCli(validRoot, '--json');
assert.equal(validCli.status, 0, validCli.stderr);
const payload = JSON.parse(validCli.stdout);
assert.deepEqual(payload.summary, { errors: 0, warnings: 0 });
const warningRoot = join(BASE, 'warning_cli');
writeProject(warningRoot, '<!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">'
+ '</head><body><img src="data:image/png;base64,AA" alt=""></body></html>');
// 有 WARNING 也照样通过:退出码只由 ERROR 决定,没有 --strict 这种「警告即失败」的模式。
assert.equal(runCli(warningRoot).status, 0);
const errorRoot = join(BASE, 'error_cli');
writeProject(errorRoot, '<!doctype html><script>bad()</script>');
assert.equal(runCli(errorRoot).status, 1);
});
test('CLI rejects missing directories and bad arguments', () => {
assert.equal(runCli(join(BASE, 'does-not-exist')).status, 2);
const bad = spawnSync(process.execPath, [VALIDATOR, '--nope'], { encoding: 'utf8' });
assert.equal(bad.status, 2);
});
test('splitUrl mirrors urlsplit for packaged references', () => {
assert.deepEqual(splitUrl('./a.png'), { scheme: '', netloc: '', path: './a.png' });
assert.deepEqual(splitUrl('//cdn.example.com/a.png'), {
scheme: '', netloc: 'cdn.example.com', path: '/a.png',
});
assert.deepEqual(splitUrl('https://example.com/a.png'), {
scheme: 'https', netloc: 'example.com', path: '/a.png',
});
assert.deepEqual(splitUrl('mailto:a@b.c'), { scheme: 'mailto', netloc: '', path: 'a@b.c' });
assert.deepEqual(splitUrl('?x=1'), { scheme: '', netloc: '', path: '' });
});
test('unquote tolerates malformed escapes', () => {
assert.equal(unquote('a%20b.png'), 'a b.png');
assert.equal(unquote('100%25.png'), '100%.png');
assert.equal(unquote('bad%zz.png'), 'bad%zz.png');
});
@@ -0,0 +1,169 @@
#!/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('defineMinitoolConfig 始终追加收尾插件,且只跑一次', () => {
const withExtra = defineMinitoolConfig({ plugins: [{ name: 'extra' }] });
const names = withExtra.plugins.map((plugin) => plugin.name);
assert.ok(names.includes('xhs-minitool-artifact'), `缺少收尾插件: ${names}`);
assert.equal(
names.filter((name) => name === 'xhs-minitool-artifact').length,
1,
`收尾插件重复: ${names}`,
);
assert.ok(names.includes('extra'), 'overrides 的插件丢了');
assert.equal(names.at(-1), 'xhs-minitool-artifact', `收尾插件不在最后: ${names}`);
const noPlugins = defineMinitoolConfig({ plugins: [] });
assert.deepEqual(noPlugins.plugins.map((plugin) => plugin.name), ['xhs-minitool-artifact']);
});
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')), '有内容的目录被误删');
});
@@ -0,0 +1,405 @@
#!/usr/bin/env node
/**
* 把已经合规的 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 {
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { deflateRawSync } from 'node:zlib';
const MIB = 1024 * 1024;
const ZIP_HARD_LIMIT = 10 * MIB;
const ZIP_RECOMMENDED = 2 * MIB;
/**
* 默认落点:假设 npm 脚本挂在 `game/` 子工程(cwd = `game/`),宿主在**项目根**
* `.export/xhs-minitool.zip` 找产物,所以默认写 `../.export/xhs-minitool.zip`。
* 脚本挂在项目根 `package.json`(cwd = 项目根)时用 `--zip-out .export/xhs-minitool.zip` 覆盖。
*/
const DEFAULT_ZIP_OUT = join('..', '.export', 'xhs-minitool.zip');
const FORBIDDEN_ENTRY =
/(^|\/)(?:node_modules|\.git)\/|(^|\/)\.DS_Store$|\.map$|(^|\/)(?:vite|webpack|rollup)\.config\.[^/]+$/i;
const USAGE = `用法:node pack.mjs [--vite-built-dir <dir>] [--zip-out <path>]
把 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> 落点。默认 ${DEFAULT_ZIP_OUT}(假设 cwd 是 game/ 子工程,产物落在项目根的 .export/xhs-minitool.zip);
相对路径按当前工作目录解析,父目录不存在会自动建。`;
/**
* 产物白名单与遍历逻辑刻意在 pack 内自存一份:pack.mjs 要能单独复制进项目,不 import validate.mjs。
* 改动时须与 validate.mjs 的 ALLOWED_EXTENSIONS / SKIP_DIRS 同步。
*/
const ALLOWED_EXTENSIONS = new Set([
'.html', '.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.webp',
'.svg', '.woff', '.woff2', '.json',
]);
const SKIP_DIRS = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', '.cache']);
function* walkArtifactFiles(dir) {
for (const name of readdirSync(dir).sort()) {
const full = join(dir, name);
let stats;
try {
stats = lstatSync(full);
} catch {
continue;
}
if (stats.isSymbolicLink()) continue;
if (stats.isDirectory()) {
if (SKIP_DIRS.has(name)) continue;
yield* walkArtifactFiles(full);
} else if (stats.isFile()) {
yield full;
}
}
}
/** 列出产物目录里白名单外的文件;path 用 `/` 分隔,方便报错展示。 */
function findUnsupportedFiles(root) {
const base = resolve(root);
const unsupported = [];
for (const path of walkArtifactFiles(base)) {
const ext = extname(path).toLowerCase();
if (!ALLOWED_EXTENSIONS.has(ext)) {
unsupported.push({ path: relative(base, path).split(sep).join('/'), ext });
}
}
return unsupported;
}
/**
* 打包前预检:产物目录里出现小红书白名单之外的文件类型时整体失败,列出路径但**不改磁盘**。
*
* 旧实现 `ensureAllowedOnly` 会就地删除这些文件;一旦 `--vite-built-dir` 指到源码目录,
* 就会静默删掉用户的 `.ts` / `.vue` / `.scss` 等源码。现在改为在打包前拦下并报错,
* 删除与否由调用方按提示决定(dist 的合规收尾已由 vite 插件负责)。
*/
function assertNoUnsupportedFiles(viteBuiltDir) {
const unsupported = findUnsupportedFiles(viteBuiltDir);
if (unsupported.length === 0) return;
const list = unsupported
.map((item) => ` - ${item.path}${item.ext ? ` (${item.ext})` : ' (无扩展名)'}`)
.join('\n');
throw new Error(
`vite 构建产物目录里有 ${unsupported.length} 个小红书不支持的文件;为避免误删,脚本没有删除任何文件:\n`
+ `${list}\n`
+ '请在 vite 构建里去来源(如 sourcemap: false),或把 --vite-built-dir 指向真正的构建输出目录。',
);
}
/* ------------------------------------------------------------------ */
/* 纯 Node zip 写入(store / deflate,UTF-8 文件名,ZIP64 直接报错) */
/* ------------------------------------------------------------------ */
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
return table;
})();
function crc32(buffer) {
let c = 0xffffffff;
for (let i = 0; i < buffer.length; i += 1) {
c = CRC_TABLE[(c ^ buffer[i]) & 0xff] ^ (c >>> 8);
}
return (c ^ 0xffffffff) >>> 0;
}
/**
* 收集目录下所有条目(目录条目在前,文件名统一 / 分隔,保持排序以稳定产物)。
*
* 已知取舍(评审提出):本函数用 `statSync`(跟随符号链接)且**不**跳 `SKIP_DIRS`,而校验脚本
* 预检用的 `walkArtifactFiles` 是 `lstatSync`(跳过链接)且会跳 `SKIP_DIRS`。两者口径不一致:
* 链接目标的内容会被按链接名打进包,`venv` / `.cache` 这类目录也可能绕过扩展名白名单。
*
* **决定不改,理由**:「链接算不算合法产物」还没有定论,而预检对链接是**静默跳过**、不是拒绝;
* 照评审建议在打包侧对链接直接抛错,会和预检的口径打架,也会动到现有可能依赖链接的包。真要统一
* 时必须两份实现一起改成同一口径,并补一条「预检与打包看到同一批文件」的用例,那是独立的一次改动。
*/
function collectZipEntries(rootDir) {
const entries = [];
const visit = (dir) => {
for (const name of readdirSync(dir).sort()) {
const full = join(dir, name);
const st = statSync(full);
const nameInZip = relative(rootDir, full).split(sep).join('/');
if (st.isDirectory()) {
entries.push({ name: `${nameInZip}/`, dir: true, full });
visit(full);
} else {
entries.push({ name: nameInZip, dir: false, full });
}
}
};
visit(rootDir);
return entries;
}
/** 把已收集的条目编码成完整 zip 字节。只算内存,不落盘——校验没过时磁盘上不能留半成品。 */
function encodeZip(entries) {
const now = new Date();
const dosTime =
((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xffff;
const dosDate =
(((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff;
const localParts = [];
const centralParts = [];
let offset = 0;
for (const entry of entries) {
const nameBuf = Buffer.from(entry.name, 'utf8');
let data = Buffer.alloc(0);
let method = 0;
let crc = 0;
let uncompressed = 0;
if (!entry.dir) {
const raw = readFileSync(entry.full);
uncompressed = raw.length;
const deflated = deflateRawSync(raw, { level: 9 });
if (deflated.length < raw.length) {
data = deflated;
method = 8;
} else {
data = raw;
}
crc = crc32(raw);
}
if (data.length > 0xffffffff || uncompressed > 0xffffffff || offset > 0xffffffff) {
throw new Error(`条目 ${entry.name} 超过 4 GiB,当前 Node zip 实现不支持 ZIP64`);
}
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(method, 8);
local.writeUInt16LE(dosTime, 10);
local.writeUInt16LE(dosDate, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(data.length, 18);
local.writeUInt32LE(uncompressed, 22);
local.writeUInt16LE(nameBuf.length, 26);
local.writeUInt16LE(0, 28);
localParts.push(local, nameBuf, data);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(method, 10);
central.writeUInt16LE(dosTime, 12);
central.writeUInt16LE(dosDate, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(data.length, 20);
central.writeUInt32LE(uncompressed, 24);
central.writeUInt16LE(nameBuf.length, 28);
central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32);
central.writeUInt16LE(0, 34);
central.writeUInt16LE(0, 36);
central.writeUInt32LE(entry.dir ? 0x10 : 0, 38);
central.writeUInt32LE(offset, 42);
centralParts.push(central, nameBuf);
offset += local.length + nameBuf.length + data.length;
}
const localBuf = Buffer.concat(localParts);
const centralBuf = Buffer.concat(centralParts);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(entries.length, 8);
eocd.writeUInt16LE(entries.length, 10);
eocd.writeUInt32LE(centralBuf.length, 12);
eocd.writeUInt32LE(localBuf.length, 16);
eocd.writeUInt16LE(0, 20);
return Buffer.concat([localBuf, centralBuf, eocd]);
}
/** 落盘版:写 zip 并返回条目名列表(保持既有导出名,供只想要 zip 的调用方使用)。 */
export function writeZip(rootDir, zipPath) {
const entries = collectZipEntries(rootDir);
const buffer = encodeZip(entries);
rmSync(zipPath, { force: true });
writeFileSync(zipPath, buffer);
return entries.map((entry) => entry.name);
}
export function auditZipFile(file, size) {
const findings = [];
const name = basename(file);
if (size > ZIP_HARD_LIMIT) {
findings.push({
severity: 'ERROR', code: 'ZIP_OVER_HARD_LIMIT', path: name, line: 1,
message: `最终 zip 约 ${(size / MIB).toFixed(2)} MiB,超过 10 MiB 上传上限。`,
});
} else if (size > ZIP_RECOMMENDED) {
findings.push({
severity: 'WARNING', code: 'ZIP_OVER_RECOMMENDED', path: name, line: 1,
message: `最终 zip 约 ${(size / MIB).toFixed(2)} MiB,超过 2 MiB 建议值。`,
});
}
return findings;
}
function makeZip(viteBuiltDir, zipPath) {
// 先把条目和字节都算完、校验通过,最后才落盘:中途失败时预期输出路径上不能留一份半成品,
// 更不能让宿主把半成品当成本次构建的产物。
const entries = collectZipEntries(viteBuiltDir);
const names = entries.map((entry) => entry.name);
if (!names.includes('index.html')) {
throw new Error('zip 根目录缺少 index.html(可能多套了一层目录)');
}
const bad = names.find((entry) => FORBIDDEN_ENTRY.test(entry));
if (bad) {
throw new Error(`zip 内含禁止文件:${bad}`);
}
const buffer = encodeZip(entries);
for (const item of auditZipFile(zipPath, buffer.length)) {
if (item.severity === 'ERROR') throw new Error(item.message);
console.warn(`[warn] ${item.message}`);
}
rmSync(zipPath, { force: true });
writeFileSync(zipPath, buffer);
console.log(
`zip 已生成:${zipPath}(${entries.length} 个条目,${(buffer.length / MIB).toFixed(2)} MiB)`,
);
}
/**
* cwd 歧义预警:宿主在**项目根** `.export/xhs-minitool.zip` 找产物,而 `--zip-out` 相对 cwd 解析。
* npm 脚本挂在 `game/` 子工程时 cwd 就是 `game/`,写 `.export/...` 会落到 `game/.export/...`,
* 导出判失败却没有任何提示。这里只在落点位于「当前 cwd 的 `.export/`」时提醒(`../.export/...`
* 或绝对路径不触发),把 cwd 与绝对落点打出来,让 workdir 问题不再静默。
*/
function warnIfWorkdirRelative(cwd, zipPath, zipOut) {
if (typeof zipOut !== 'string' || isAbsolute(zipOut)) return;
const relativeOut = relative(cwd, zipPath);
if (relativeOut.startsWith('..') || !relativeOut.startsWith(`.export${sep}`)) return;
console.warn(
`[warn] --zip-out 相对 cwd 解析:cwd=${cwd},落点=${zipPath}。`
+ '宿主在项目根 `.export/xhs-minitool.zip` 找产物;若 cwd 是 game/ 子工程,'
+ '请写 `../.export/xhs-minitool.zip`,否则会落在 game/.export/ 导致导出判失败。',
);
}
/**
* @param {{
* cwd?: string,
* viteBuiltDir?: string,
* zipOut?: string,
* }} options
*/
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 || DEFAULT_ZIP_OUT);
warnIfWorkdirRelative(cwd, zipPath, options.zipOut);
if (!existsSync(viteBuiltDir)) {
throw new Error(`缺少 vite 构建产物目录 ${viteBuiltDir}`);
}
// 只预检、不改写:白名单外的文件直接报错。产物收尾已由 vite 插件做完,这里不应再动 dist。
assertNoUnsupportedFiles(viteBuiltDir);
// 落点的父目录可能还不存在(`.export/` 是宿主按需创建的),先建再写。
mkdirSync(dirname(zipPath), { recursive: true });
makeZip(viteBuiltDir, zipPath);
return 0;
}
export default packMinitool;
function isMainModule() {
const entry = process.argv[1];
if (!entry) return false;
try {
if (realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url))) return true;
} catch {
/* fall through to URL comparison */
}
try {
return import.meta.url === pathToFileURL(entry).href;
} catch {
return false;
}
}
if (isMainModule()) {
const raw = process.argv.slice(2);
if (raw.includes('--help') || raw.includes('-h')) {
console.log(USAGE);
process.exit(0);
}
// 取走一个参数和它的值;剩下的非空参数说明调用方传了本脚本不认的东西。
const takeFlag = (name) => {
const idx = raw.indexOf(name);
if (idx === -1) return undefined;
const value = raw[idx + 1];
if (!value || value.startsWith('-')) {
throw new Error(`${name} 后面要跟一个值`);
}
raw.splice(idx, 2);
return value;
};
let viteBuiltDir;
let zipOut;
try {
viteBuiltDir = takeFlag('--vite-built-dir');
zipOut = takeFlag('--zip-out') || DEFAULT_ZIP_OUT;
// 不认识的参数当场拒绝:否则调用方以为它生效了,产物落到别处还查不出来。
if (raw.length > 0) {
throw new Error(`不认识的参数:${raw.join(' ')}`);
}
} catch (err) {
console.error(err instanceof Error ? err.message : err);
console.error(USAGE);
process.exit(2);
}
try {
packMinitool({ viteBuiltDir, zipOut });
process.exit(0);
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
}
}
@@ -0,0 +1,212 @@
/**
* 小红书小工具 Vite 配置(可直接作为项目 vite.config 使用):
*
* vite build --config <此文件路径>
*
* 或在项目自己的 vite.config 里合并:
*
* 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 会在打包前预检该目录;出现白名单之外的文件类型会直接报错,不会删除任何文件。
*
* 约束来源: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)。 */
export const MINITOOL_JS_TARGET = ['es2017', 'chrome61'];
/** CSS 目标内核:Chrome 61。 */
export const MINITOOL_CSS_TARGET = ['chrome61'];
/** 小红书要求包内自带的小工具 icon;publicDir 被关掉时由插件兜底复制。 */
const FALLBACK_ICONS = ['icon-192.svg', 'icon-512.svg'];
/** 收尾插件名;defineMinitoolConfig 按它去重,保证插件只跑一次。 */
const ARTIFACT_PLUGIN_NAME = 'xhs-minitool-artifact';
/**
* 把 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: ARTIFACT_PLUGIN_NAME,
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: {
target: MINITOOL_JS_TARGET,
cssTarget: MINITOOL_CSS_TARGET,
outDir: 'dist-xhs-minitool',
emptyOutDir: true,
sourcemap: false,
assetsInlineLimit: 0,
cssCodeSplit: false,
modulePreload: false,
rollupOptions: {
output: {
format: 'iife',
inlineDynamicImports: true,
entryFileNames: 'app.js',
chunkFileNames: 'chunk-[name].js',
assetFileNames: (assetInfo) => {
const name = assetInfo.name || '';
if (name.endsWith('.css')) return 'app.css';
if (name.endsWith('.woff2') || name.endsWith('.woff')) {
return 'fonts/[name][extname]';
}
if (/\.(png|jpe?g|gif|webp|svg)$/i.test(name)) {
return 'icons/[name][extname]';
}
return 'assets/[name][extname]';
},
},
},
},
plugins: [xhsMinitoolArtifactPlugin()],
};
/**
* 合并项目配置,并保证收尾插件一定在 `plugins` 里。
*
* `mergeConfig` 对数组是拼接,正常传 `{ plugins: [...] }` 不会丢掉 base 的插件;但调用方
* 若先展开 `minitoolBaseConfig` 再覆盖 `plugins`,插件就被挤掉了。这里按 name 去重后
* 追加一个新实例:无论 overrides 怎么给,插件都在最后且只跑一次。
*
* @param {import('vite').UserConfig} [overrides]
* @returns {import('vite').UserConfig}
*/
export function defineMinitoolConfig(overrides = {}) {
const merged = mergeConfig(defineConfig(minitoolBaseConfig), overrides);
merged.plugins = [
...(merged.plugins ?? []).filter((plugin) => plugin?.name !== ARTIFACT_PLUGIN_NAME),
xhsMinitoolArtifactPlugin(),
];
return merged;
}
export default defineMinitoolConfig();

Some files were not shown because too many files have changed in this diff Show More