合并远端 master 到美术包合同修复分支
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m20s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m48s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m16s
Project CI / Backend tests (pull_request) Successful in 7m25s
Project CI / Frontend tests (pull_request) Successful in 3m10s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m28s
Project CI / Repository checks (pull_request) Successful in 6m39s
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m20s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m48s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m16s
Project CI / Backend tests (pull_request) Successful in 7m25s
Project CI / Frontend tests (pull_request) Successful in 3m10s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m28s
Project CI / Repository checks (pull_request) Successful in 6m39s
Project CI / Native shell tests (pull_request) Has been cancelled
同步 origin/master 的最新代码、测试与文档变更 保留图集按实际产物返回的提示词并移除已退役发布说明 合并技能包清单并递增版本,保留双方项目经验记录
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 二次改写
|
||||
|
||||
@@ -130,14 +130,10 @@ const allowedUncalledTauriCommands = [
|
||||
// React 不再把页面消息当作持久化事实;策划/Runtime 由 Rust coordinator 写入,
|
||||
// 该通用命令仅保留给 Rust 内部链路与测试。
|
||||
'append_local_conversation_message',
|
||||
// 发行 facade 由 typed native service 通过注入的 invoke 调用;整包准备、旧分片上传
|
||||
// 与资料命令仍保留给 Rust/旧 native 测试,不允许重新接回 React 直连 HTTP。
|
||||
'generate_game_distribution_cover',
|
||||
'prepare_local_project_game_package',
|
||||
'read_game_cover_generation_price',
|
||||
'suggest_game_distribution_publish_metadata',
|
||||
// 发行 facade 由 typed native service 通过注入的 invoke 调用;发布状态回读也走同一条
|
||||
// native service,不允许重新接回 React 直连 HTTP。封面生成 / 封面报价 / 资料建议三条命令
|
||||
// 已随旧的发布表单一起退役(Rust 侧实现与注册都已删除),不再登记。
|
||||
'read_game_distribution_publication',
|
||||
'upload_local_project_game_package',
|
||||
// 账户与钱包由 Rust typed command 持有 origin/Bearer/envelope;命令名在 `accountHost.ts`
|
||||
// 里以字面量出现,静态扫描仍按共享注册表核验。
|
||||
'read_profile_recharge_center',
|
||||
@@ -151,7 +147,6 @@ const allowedUncalledTauriCommands = [
|
||||
// 仍注册在 Rust 侧供 native 流程与 Rust 测试使用,仅不出现在 App 前端源码里。
|
||||
'check_game_creator_llm_config',
|
||||
'diff_local_project_checkpoint',
|
||||
'list_local_project_export_packages',
|
||||
'read_local_agent_memory',
|
||||
'read_local_game_memory',
|
||||
'read_local_project_file',
|
||||
|
||||
@@ -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,10 @@ export const EXPECTED_SKILL_NAMES = Object.freeze([
|
||||
'agc-project-structure',
|
||||
'agc-unity-editor',
|
||||
'agc-web-game-development',
|
||||
'platform-abstract',
|
||||
'taonier-art-assets',
|
||||
'vite-export-taonier',
|
||||
'vite-export-xhs-minitool',
|
||||
]);
|
||||
|
||||
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
@@ -27,6 +30,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 +54,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 +88,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
@@ -6,7 +6,6 @@
|
||||
"playtest.tetris": "完成合同要求 tetris-v1 交互试玩。game/index.html 必须持续更新 <script id=\"playable-web-game-state\" type=\"application/json\">,JSON 固定包含 schemaVersion=playable-web-game-state.v1、单调递增 sequence、phase=ready|playing|won|lost、正整数 level,以及 gameplay={kind:'tetris',activePieceId,rotation,row,lockedPieces,lineClearChecks,clearedLines,occupiedCells};gameplay 可包含额外 telemetry 字段,但这些字段不能替代固定必填字段。界面必须提供 data-playtest-id=\"start\"、data-playtest-id=\"primary-action\" 与 data-playtest-id=\"restart\" 的真实控件;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。primary-action 必须同步旋转同一 activePieceId,不能只推进 sequence、替换活动方块或更新装饰状态;start 后 2 秒观察内必须出现同一 activePieceId 的 row 下落或真实锁定;primary-action 后 3 秒内必须真实锁定方块,锁定后 activePieceId 必须变化、lockedPieces 恰好增加 1、lineClearChecks 推进,且 occupiedCells 与 clearedLines 必须体现新增方块或实际消行,不能只增加计数;restart 后 occupiedCells、lockedPieces、lineClearChecks 与 clearedLines 必须全部归零。初始状态必须是 ready 且 level 为正整数;start 后状态必须推进并进入 playing,并至少持续 2 秒保持 playing。primary-action 必须同步推进 sequence 与 rotation;动作后 phase 可为 playing、won 或 lost,若保持 playing 则继续观察最多 3 秒以取得锁定和消行检查证据。restart 后必须推进 sequence、恢复 ready 或 playing,并持续 3 秒稳定观察。全部观察期间 sequence 不得回退;若首轮进入 lost,必须按 generic-v1 的重开重试合同证明第二次能进入 won 或保持 playing,不能固定失败。",
|
||||
"playtest.laneDefense": "完成合同要求 lane-defense-v1 交互试玩。game/index.html 必须持续更新 <script id=\"playable-web-game-state\" type=\"application/json\">,JSON 固定包含 schemaVersion=playable-web-game-state.v1、单调递增 sequence、phase=ready|playing|won|lost、正整数 level、selectedDefenderId、defenders 数组、enemies 数组;每个 enemy 必须含非空 id、非负 lane、会随移动变化的 position、health 与正数 maxHealth。界面必须清晰显示一个原创项目标题、至少两个原创防御单位选项、资源与波次状态,以及开始、加速、下一关和重开等可理解操作;玩法类型不授权复刻现有游戏,不得沿用、翻译或近似改写现有作品的角色、单位名、Logo、贴图、标志性布局或受保护视觉语言。界面必须提供 data-playtest-id=\"start\"、data-playtest-id=\"defender-option\"、data-playtest-id=\"lane-cell\"、data-playtest-id=\"speed-up\"、data-playtest-id=\"next-level\"、data-playtest-id=\"restart\" 的真实可点击控件;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。防御单位多选项 UI 只能给一个真实控件设置 data-playtest-id=\"defender-option\" 作为自动化入口,关卡多格 UI 只能给一个真实控件设置 data-playtest-id=\"lane-cell\" 作为自动化入口,其余选项和格子不得复用这两个固定值。受控试玩会依次开始、选择并放置防御单位、加速,要求敌人移动并受伤、关卡进入 won;随后 next-level 必须让 level 增加,restart 必须再次推进 sequence 并回到 ready 或 playing。",
|
||||
"owner.visualUsage": "本轮必须实际接入已登记的平台美术切片:先用 asset.list 读取 assets/art-spritesheet-slices/manifest.json,再在 game/index.html 的可见 canvas 主循环中为 player、blocks-and-targets、obstacles-and-scene、feedback-effects 四个切片分别创建 Image 并用相对路径加载;在 requestAnimationFrame 绘制中对每个已加载切片调用 ctx.drawImage(image, dx, dy, dw, dh) 或九参数裁剪形式,目标区域必须可见且至少 32×32。只放置 <img>/<picture>、只展示整张 assets/art-spritesheet.png、只写路径或只在注释中引用都不满足完成合同。",
|
||||
"owner.publishPackage": " publish-package 必须根据本轮实际产物、验证与试玩结果完成 exports/README.md,不得留下模板字段或 forbidden marker。禁止在表示“已完成”或“无”的句子中复述任何 forbidden marker 字面词;请直接陈述实际完成内容。所有 Markdown checklist 必须使用 [x] 或 [X],不得保留未勾选项。",
|
||||
"owner.visualRequirement": "任务声明中的视觉图片按项目需求选择工具、数量、输出路径、尺寸和布局;图集按实际产物数量返回,查看图片识别用途。以实际声明资源的登记状态作为验收依据。",
|
||||
"owner.verifyCodePrototype": "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。",
|
||||
"owner.verifyArtifact": "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。",
|
||||
@@ -16,7 +15,7 @@
|
||||
"background.previewReadiness": "{base}\n\n这是 只读静态验证任务,不要修改项目文件。固定核心动作是且只能是 command.run_limited(commandId=game.static_smoke);通过后直接交付验证结论,不要调用其它命令、项目 mutation 或 task.update。",
|
||||
"background.previewPlaytest": "{base}\n\n这是 只读试玩验收任务,不要修改项目文件。固定核心动作是且只能是 preview.validate;完成当前 revision 的桌面与移动试玩后直接交付验收结论,不要调用项目 mutation、其它预览动作或 task.update。",
|
||||
"background.artDirectionWithoutCredentials": "{base}\n\n这是 无生图凭据只读协调任务。当前未配置 External Editor 生图凭据,上述 seed task 中 assets/art-spec.png 图片产物与生成验收条款在本轮不适用;只交付正式视觉方向结论,不要修改项目文件,不调用 canvas.asset_generate、game.static_smoke、project.verify、command.run_limited、preview 或 task.update。",
|
||||
"background.coordination": "{base}\n\n这是 只读协调任务,不要修改项目文件,也不要为了 manifest 内部回执路径写入 memory/、game/、assets/ 或 exports/。只读取当前项目事实,完成方向协调、审查或验收并直接交付结论;不要调用 task.update。",
|
||||
"background.coordination": "{base}\n\n这是 只读协调任务,不要修改项目文件,也不要为了 manifest 内部回执路径写入 memory/、game/ 或 assets/。只读取当前项目事实,完成方向协调、审查或验收并直接交付结论;不要调用 task.update。",
|
||||
"background.relaxed": "处理 manifest ready 任务:{}\n\n任务 ID:{}\n专业组:{}\n角色:{}\n依赖(仅供参考):{}\n\n这是并行自主执行任务。请在当前项目根内按你的职责自行规划和调用可用工具,可以与其它任务同时进行。完成后直接回复实际完成情况。",
|
||||
"background.standard": "处理 manifest ready 任务:{}\n\n任务 ID:{}\n专业组:{}\n角色:{}\n依赖:{}\n预期产物:{}\n验收标准:{}\n\n请按你的 Agent 职责自主规划、调用可用工具、记录观察,并在完成或阻塞时更新任务状态。",
|
||||
"codexAppServer.baseInstructionsFallback": "You are Codex working directly in the user's Genarrative game project. Follow the AGC system instructions, inspect and modify files in the current workspace when needed, and report concrete progress and failures. Base completion claims on observed results.",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -42,6 +42,6 @@
|
||||
"default_downstream": "Generator 必须响应本角色交付物并保持可试玩原型闭环。",
|
||||
"code_acceptance_risk": "缺 canvas 绘制、输入监听、胜负状态、重开或使用远程资源都会触发 Evaluator 返工。",
|
||||
"asset_acceptance_risk": "未记录画板或本地资产占位会影响资产回流验收。",
|
||||
"publishing_acceptance_risk": "缺发布包装会影响最终 exports/README.md 与运营组 handoff。",
|
||||
"publishing_acceptance_risk": "缺发布包装会影响运营组 handoff 与发布资料整理。",
|
||||
"default_acceptance_risk": "输出空泛或偏离用户需求会增加 Generator 返工概率。"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.44",
|
||||
"version": "2026-08-26.75",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-unity-editor",
|
||||
@@ -166,6 +166,61 @@
|
||||
"references/projection-contract.md"
|
||||
],
|
||||
"sha256": "77c69762910891e0eef5557480a5e46e743f5dec78ee958679e8d6f1e7990531"
|
||||
},
|
||||
{
|
||||
"name": "platform-abstract",
|
||||
"purpose": "把环境相关的调用从 core 里收口,让同一份逻辑能跑在多个 target 上",
|
||||
"triggers": [
|
||||
"抽离平台相关能力",
|
||||
"同一份逻辑要跑在多个 target",
|
||||
"新增非 Web 构建目标"
|
||||
],
|
||||
"requiredTools": [],
|
||||
"files": [
|
||||
"SKILL.md"
|
||||
],
|
||||
"sha256": "26e5b0b1d2fac6321eff0c834e15bce83ce70a435965c510254e40f3c16370e2"
|
||||
},
|
||||
{
|
||||
"name": "vite-export-taonier",
|
||||
"purpose": "把 vite 项目导出并打包为陶泥儿可托管 H5 的 zip 制品",
|
||||
"triggers": [
|
||||
"导出陶泥儿 H5 制品",
|
||||
"校验或打包 taonier 产物",
|
||||
"适配陶泥儿 H5 托管环境"
|
||||
],
|
||||
"requiredTools": [],
|
||||
"files": [
|
||||
"SKILL.md",
|
||||
"scripts/pack.mjs",
|
||||
"scripts/vite.config.taonier.mjs"
|
||||
],
|
||||
"sha256": "c73edcd88384e8824c5d6b932562196020b305e926a7837929272130df515364"
|
||||
},
|
||||
{
|
||||
"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`,否则类型检查找不到模块。
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: vite-export-taonier
|
||||
description: >-
|
||||
vite 项目导出陶泥儿可托管 H5 制品
|
||||
metadata:
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# 目标:
|
||||
|
||||
* 实现打包脚本 `build:taonier`: 用 `scripts/vite.config.taonier.mjs` 把 vite 项目构建到 `dist-taonier`,
|
||||
再用 `scripts/pack.mjs` 产出 zip 到固定文件: `.export/taonier.zip`
|
||||
1. 复制 (并按实际情况修改) `scripts/vite.config.taonier.mjs` 作为 vite 构建配置, 这个配置把 dist 收尾成可托管的 H5
|
||||
根目录: 相对路径入口、根目录 `index.html`、清空空目录. 该配置不做语法降级.
|
||||
2. 使用 `scripts/pack.mjs` 打包成 zip; `--zip-out` 默认 **`../.export/taonier.zip`** (假设 cwd 是 `game/` 子工程,
|
||||
产物落在 **项目根** `.export/taonier.zip`; 相对 cwd 解析).
|
||||
3. 跑通流程后请把需要的脚本配置复制 (agc_install_skill_resource )到项目里 (以免依赖skill), 并形成最终的打包脚本.
|
||||
|
||||
* 不干扰正常的web构建
|
||||
|
||||
# 平台要求:
|
||||
|
||||
* 仅支持单个 `.zip` 压缩包.
|
||||
* zip 解压后**根目录**须有 `index.html`(**不得有外层文件夹**): 服务端只认存档根条目 `index.html`,
|
||||
`game/index.html` 之类会被判缺入口而拒收.
|
||||
* 因此 `scripts/pack.mjs` 默认把构建产物内容直接放到 zip 存档根 (`index.html`、`assets/...`), 结构不合格时
|
||||
整体失败, 不会留下可误传的 zip. `--root-dir` 是可选参数: 只有调用方显式给出时才会额外套一层顶层文件夹.
|
||||
|
||||
# 假设和默认项:
|
||||
|
||||
* vite项目目录: game/
|
||||
* 各个工具 (包括示例vite配置) 默认cwd 就是 vite 项目根 (所以对于非标准的目录结构需要给出显式的参数来适应)
|
||||
* 陶泥儿是普通 H5 托管, 没有小红书小工具那份 Chrome 61 / IIFE / 禁内联脚本约束; 本配置不做语法降级, 只保证相对路径与
|
||||
根 `index.html`.
|
||||
|
||||
# 脚本参数:
|
||||
|
||||
| 脚本 | 参数 |
|
||||
|-----------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `scripts/vite.config.taonier.mjs` | `build.outDir`(默认 `dist-taonier`;与打包时的 `--vite-built-dir` 必须是同一个目录) |
|
||||
| `scripts/pack.mjs` | `--vite-built-dir <dir>`(默认 `dist-taonier`)、`--zip-out <path>`(默认 `../.export/taonier.zip`,必须以 `.zip` 结尾)、`--root-dir <name>`(**可选**,默认不套外层文件夹;给出时才把内容套进单层文件夹 `<name>/`) |
|
||||
|
||||
# 一些情况:
|
||||
|
||||
* 需要精简游戏内容/删减资源/压缩素材 请和用户讨论
|
||||
* 如果使用了外部能力, 参考platform-abstract skill对项目先重构
|
||||
* 陶泥儿的托管条件可能有变化, 请以用户反馈为准, 并调整本地的构建脚本
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* pack.mjs 的本地测试(`.` 前缀,不随 Skill Pack 发布):
|
||||
* 重点覆盖陶泥儿的三条硬要求——只收 .zip、解压后只有一个顶层文件夹、且该文件夹根目录直接带
|
||||
* index.html。
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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 { packTaonier } from './pack.mjs';
|
||||
|
||||
const PACK = fileURLToPath(new URL('./pack.mjs', import.meta.url));
|
||||
const BASE = mkdtempSync(join(tmpdir(), 'taonier-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">'
|
||||
+ '</head><body><div id="app"></div>'
|
||||
+ '<script type="module" src="./assets/app.js"></script></body></html>';
|
||||
|
||||
/** 造一个最小的 H5 构建产物目录;extra 用来放额外文件。 */
|
||||
function writeBuiltDir(name, extra = {}) {
|
||||
const root = join(BASE, name);
|
||||
mkdirSync(join(root, 'assets'), { recursive: true });
|
||||
writeFileSync(join(root, 'index.html'), VALID_HTML, 'utf8');
|
||||
writeFileSync(join(root, 'assets', '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;
|
||||
}
|
||||
|
||||
/** 直接解析中央目录读取 zip 条目名:验证真实写出的字节,而不是只看返回值。 */
|
||||
function zipEntryNames(zipPath) {
|
||||
const buf = readFileSync(zipPath);
|
||||
const eocd = buf.length - 22;
|
||||
assert.equal(buf.readUInt32LE(eocd), 0x06054b50, '未找到 EOCD');
|
||||
const count = buf.readUInt16LE(eocd + 10);
|
||||
let offset = buf.readUInt32LE(eocd + 16);
|
||||
const names = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
assert.equal(buf.readUInt32LE(offset), 0x02014b50, '中央目录签名错误');
|
||||
const nameLength = buf.readUInt16LE(offset + 28);
|
||||
const extraLength = buf.readUInt16LE(offset + 30);
|
||||
const commentLength = buf.readUInt16LE(offset + 32);
|
||||
names.push(buf.subarray(offset + 46, offset + 46 + nameLength).toString('utf8'));
|
||||
offset += 46 + nameLength + extraLength + commentLength;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
test('干净产物默认不套外层:index.html 直接位于 zip 存档根', () => {
|
||||
writeBuiltDir('clean');
|
||||
const zipOut = join(BASE, 'clean-out', 'taonier.zip');
|
||||
assert.equal(packTaonier({ cwd: BASE, viteBuiltDir: 'clean', zipOut }), 0);
|
||||
|
||||
const names = zipEntryNames(zipOut);
|
||||
assert.ok(names.includes('index.html'), `缺少存档根 index.html: ${names}`);
|
||||
assert.ok(names.includes('assets/app.js'), `缺少 assets/app.js: ${names}`);
|
||||
// 不得有外层文件夹:条目不许多出 game/ 之类的前缀。
|
||||
assert.deepEqual(
|
||||
names.filter((name) => !name.startsWith('assets/') && name !== 'index.html'),
|
||||
[],
|
||||
`存档根出现了计划外条目: ${names}`,
|
||||
);
|
||||
assert.ok(!names.includes('game/index.html'), `默认不应套外层文件夹: ${names}`);
|
||||
});
|
||||
|
||||
test('显式传 rootDir: null 也不套外层文件夹', () => {
|
||||
writeBuiltDir('explicit-null-root');
|
||||
const zipOut = join(BASE, 'explicit-null-root-out', 'taonier.zip');
|
||||
assert.equal(
|
||||
packTaonier({ cwd: BASE, viteBuiltDir: 'explicit-null-root', zipOut, rootDir: null }),
|
||||
0,
|
||||
);
|
||||
const names = zipEntryNames(zipOut);
|
||||
assert.ok(names.includes('index.html'), `缺少存档根 index.html: ${names}`);
|
||||
assert.ok(!names.some((name) => name.startsWith('game/')), `不应套外层: ${names}`);
|
||||
});
|
||||
|
||||
test('--root-dir 可选:给了才套指定顶层文件夹', () => {
|
||||
writeBuiltDir('custom-root');
|
||||
const zipOut = join(BASE, 'custom-root-out', 'taonier.zip');
|
||||
assert.equal(
|
||||
packTaonier({ cwd: BASE, viteBuiltDir: 'custom-root', zipOut, rootDir: 'my-h5' }),
|
||||
0,
|
||||
);
|
||||
const names = zipEntryNames(zipOut);
|
||||
assert.ok(names.includes('my-h5/index.html'), `缺少 my-h5/index.html: ${names}`);
|
||||
assert.deepEqual(names.filter((name) => !name.startsWith('my-h5/')), []);
|
||||
});
|
||||
|
||||
test('产物根缺少 index.html 时整体失败,且不写出 zip', () => {
|
||||
const root = join(BASE, 'no-index');
|
||||
mkdirSync(join(root, 'assets'), { recursive: true });
|
||||
writeFileSync(join(root, 'assets', 'app.js'), 'console.log(1);\n', 'utf8');
|
||||
const zipOut = join(BASE, 'no-index-out', 'taonier.zip');
|
||||
assert.throws(
|
||||
() => packTaonier({ cwd: BASE, viteBuiltDir: 'no-index', zipOut }),
|
||||
/缺少 index\.html/,
|
||||
);
|
||||
assert.equal(existsSync(zipOut), false, '失败时不应写出 zip');
|
||||
});
|
||||
|
||||
test('只接受 .zip 落点', () => {
|
||||
writeBuiltDir('only-zip');
|
||||
assert.throws(
|
||||
() => packTaonier({ cwd: BASE, viteBuiltDir: 'only-zip', zipOut: 'only-zip-out/game.tar' }),
|
||||
/只支持 \.zip/,
|
||||
);
|
||||
});
|
||||
|
||||
test('非法 --root-dir 被拒绝', () => {
|
||||
writeBuiltDir('bad-root');
|
||||
for (const rootDir of ['', '..', 'a/b', 'a\\b']) {
|
||||
assert.throws(
|
||||
() => packTaonier({ cwd: BASE, viteBuiltDir: 'bad-root', zipOut: join(BASE, 'x.zip'), rootDir }),
|
||||
/--root-dir/,
|
||||
`rootDir=${JSON.stringify(rootDir)} 未被拒绝`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('zip 内含 sourcemap 时失败并删除已写出的文件', () => {
|
||||
writeBuiltDir('with-map', { 'assets/app.js.map': '{}\n' });
|
||||
const zipOut = join(BASE, 'with-map-out', 'taonier.zip');
|
||||
assert.throws(
|
||||
() => packTaonier({ cwd: BASE, viteBuiltDir: 'with-map', zipOut }),
|
||||
/禁止文件/,
|
||||
);
|
||||
assert.equal(existsSync(zipOut), false, '不合规的 zip 不应留在落点');
|
||||
});
|
||||
|
||||
test('普通 .map 游戏资源不算 sourcemap', () => {
|
||||
writeBuiltDir('with-level-map', { 'assets/level.map': '{"layers":[]}\n' });
|
||||
const zipOut = join(BASE, 'with-level-map-out', 'taonier.zip');
|
||||
assert.equal(packTaonier({ cwd: BASE, viteBuiltDir: 'with-level-map', zipOut }), 0);
|
||||
assert.ok(zipEntryNames(zipOut).includes('assets/level.map'));
|
||||
});
|
||||
|
||||
test('--zip-out 落在产物目录内时被拒绝', () => {
|
||||
const root = writeBuiltDir('zip-inside');
|
||||
assert.throws(
|
||||
() => packTaonier({
|
||||
cwd: BASE,
|
||||
viteBuiltDir: 'zip-inside',
|
||||
zipOut: join(root, 'taonier.zip'),
|
||||
}),
|
||||
/不能落在 --vite-built-dir 内/,
|
||||
);
|
||||
});
|
||||
|
||||
test('产物含符号链接时失败', () => {
|
||||
const root = writeBuiltDir('with-symlink');
|
||||
symlinkSync(join(root, 'assets', 'app.js'), join(root, 'assets', 'link.js'));
|
||||
assert.throws(
|
||||
() => packTaonier({ cwd: BASE, viteBuiltDir: 'with-symlink', zipOut: join(BASE, 'symlink.zip') }),
|
||||
/符号链接/,
|
||||
);
|
||||
});
|
||||
|
||||
test('默认参数:dist-taonier + 存档根 index.html + ../.export/taonier.zip(假设 cwd 是 game/)', () => {
|
||||
const game = join(BASE, 'default-layout', 'game');
|
||||
writeBuiltDir(join('default-layout', 'game', 'dist-taonier'));
|
||||
assert.equal(packTaonier({ cwd: game }), 0);
|
||||
const zipOut = join(BASE, 'default-layout', '.export', 'taonier.zip');
|
||||
assert.ok(existsSync(zipOut), '默认落点不在项目根的 .export/taonier.zip');
|
||||
const names = zipEntryNames(zipOut);
|
||||
assert.ok(names.includes('index.html'), `默认应当在存档根放 index.html: ${names}`);
|
||||
assert.ok(!names.includes('game/index.html'), `默认不应套外层文件夹: ${names}`);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* vite.config.taonier.mjs 的收尾插件端到端测试:真跑一遍 `vite build`,验证 dist 在 build
|
||||
* 结束时已经是可托管的 H5 根目录(根 index.html、资源相对路径、空目录被清掉),而 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 { packTaonier } from './pack.mjs';
|
||||
import {
|
||||
TAONIER_OUT_DIR,
|
||||
assertH5Root,
|
||||
defineTaonierConfig,
|
||||
pruneEmptyDirs,
|
||||
taonierArtifactPlugin,
|
||||
} from './vite.config.taonier.mjs';
|
||||
|
||||
const BASE = mkdtempSync(join(tmpdir(), 'taonier-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" />\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', 'empty-dir'), { recursive: true });
|
||||
writeFileSync(join(root, 'index.html'), SOURCE_HTML, 'utf8');
|
||||
writeFileSync(
|
||||
join(root, 'src', 'main.js'),
|
||||
"document.getElementById('app').textContent = 'ok';\n",
|
||||
'utf8',
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
test('config 的 outDir 固定为 dist-taonier', () => {
|
||||
assert.equal(TAONIER_OUT_DIR, 'dist-taonier');
|
||||
assert.equal(defineTaonierConfig({}).build.outDir, 'dist-taonier');
|
||||
});
|
||||
|
||||
test('vite 构建收尾:dist 可直接被打成存档根带 index.html 的 zip', async () => {
|
||||
const root = writeProject('finalized');
|
||||
const { build } = await import('vite');
|
||||
const config = defineTaonierConfig({ root, logLevel: 'silent' });
|
||||
|
||||
await build({ ...config, configFile: false });
|
||||
const outDir = join(root, 'dist-taonier');
|
||||
|
||||
assert.ok(existsSync(join(outDir, 'index.html')), 'index.html 未生成');
|
||||
const html = readFileSync(join(outDir, 'index.html'), 'utf8');
|
||||
assert.doesNotMatch(html, /\b(?:src|href)="\//, '仍有根路径资源引用');
|
||||
assert.match(html, /src="\.\/assets\/[^"]+\.js"/, '入口脚本不是相对路径');
|
||||
// public/empty-dir 是空目录,收尾后不应留在 dist。
|
||||
assert.equal(existsSync(join(outDir, 'empty-dir')), false, '空目录没有被清理');
|
||||
|
||||
const zipOut = join(BASE, 'out', 'taonier.zip');
|
||||
assert.equal(packTaonier({ cwd: root, zipOut }), 0);
|
||||
assert.ok(existsSync(zipOut), 'zip 未生成');
|
||||
});
|
||||
|
||||
test('base 被改成根路径时构建失败', async () => {
|
||||
const root = writeProject('absolute-base');
|
||||
const { build } = await import('vite');
|
||||
const config = defineTaonierConfig({ root, logLevel: 'silent', base: '/' });
|
||||
|
||||
await assert.rejects(
|
||||
build({ ...config, configFile: false }),
|
||||
/根路径资源/,
|
||||
'根路径引用没有被收尾插件拦下',
|
||||
);
|
||||
});
|
||||
|
||||
test('构建失败时不收尾:不覆盖真实报错', async () => {
|
||||
const root = join(BASE, 'failed-build');
|
||||
mkdirSync(join(root, 'src'), { recursive: true });
|
||||
writeFileSync(join(root, 'index.html'), SOURCE_HTML, 'utf8');
|
||||
writeFileSync(join(root, 'src', 'main.js'), 'this is not valid js {{\n', 'utf8');
|
||||
|
||||
const { build } = await import('vite');
|
||||
const config = defineTaonierConfig({ root, logLevel: 'silent' });
|
||||
await assert.rejects(
|
||||
build({ ...config, configFile: false }),
|
||||
(error) => {
|
||||
assert.doesNotMatch(error.message, /taonier-artifact/,
|
||||
'插件把自己的报错盖在了真实构建错误上');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(existsSync(join(root, 'dist-taonier', 'index.html')), false,
|
||||
'失败的构建不该有 index.html');
|
||||
});
|
||||
|
||||
test('defineTaonierConfig 始终追加收尾插件,且只跑一次', () => {
|
||||
const withExtra = defineTaonierConfig({ plugins: [{ name: 'extra' }] });
|
||||
const names = withExtra.plugins.map((plugin) => plugin.name);
|
||||
assert.ok(names.includes('taonier-artifact'), `缺少收尾插件: ${names}`);
|
||||
assert.equal(
|
||||
names.filter((name) => name === 'taonier-artifact').length,
|
||||
1,
|
||||
`收尾插件重复: ${names}`,
|
||||
);
|
||||
assert.ok(names.includes('extra'), 'overrides 的插件丢了');
|
||||
assert.equal(names.at(-1), 'taonier-artifact', `收尾插件不在最后: ${names}`);
|
||||
});
|
||||
|
||||
test('assertH5Root 拦截根路径引用与缺失的 index.html', () => {
|
||||
const root = join(BASE, 'unit-assert');
|
||||
mkdirSync(root, { recursive: true });
|
||||
assert.throws(() => assertH5Root(root), /index\.html 不存在/);
|
||||
|
||||
writeFileSync(join(root, 'index.html'), '<script src="/app.js"></script>\n', 'utf8');
|
||||
assert.throws(() => assertH5Root(root), /根路径资源/);
|
||||
|
||||
writeFileSync(join(root, 'index.html'), '<script src="./app.js"></script>\n', 'utf8');
|
||||
assert.doesNotThrow(() => assertH5Root(root));
|
||||
});
|
||||
|
||||
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')), '有内容的目录被误删');
|
||||
});
|
||||
|
||||
test('taonierArtifactPlugin 声明为 build-only 的后置插件', () => {
|
||||
const plugin = taonierArtifactPlugin();
|
||||
assert.equal(plugin.apply, 'build');
|
||||
assert.equal(plugin.enforce, 'post');
|
||||
});
|
||||
+470
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 把 vite 构建产物打成陶泥儿 H5 托管 zip(纯 Node,不依赖 zip / zipinfo 系统命令)。
|
||||
*
|
||||
* 陶泥儿的上传要求很短:
|
||||
* - 仅支持单个 .zip 压缩包;
|
||||
* - zip 存档根目录必须直接包含 index.html(**不得有外层文件夹**)。
|
||||
*
|
||||
* 所以本脚本只做三件确定性的事:
|
||||
* 1. 把 `--vite-built-dir`(默认 dist-taonier)的内容原样放到 zip 存档根;
|
||||
* 2. 用 node:zlib 写出 zip,index.html 位于存档根 `index.html`;
|
||||
* 3. 校验结构与构建目录根:不合格时整体失败,不留下可误传的 zip。
|
||||
*
|
||||
* 陶泥儿服务端只认存档根 `index.html`(`game/index.html` 会被判 MissingEntry),所以默认**不套**
|
||||
* 外层文件夹;`--root-dir` 只在调用方确有需要时显式给出,才会额外套一层顶层文件夹。
|
||||
*
|
||||
* dist 的收尾(相对路径、根 index.html、清空目录)已由 `vite.config.taonier.mjs` 的
|
||||
* `taonier-artifact` 插件在构建时完成;本脚本只读 dist,不改写它。
|
||||
*/
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 默认落点:假设 npm 脚本挂在 `game/` 子工程(cwd = `game/`),宿主在**项目根**
|
||||
* `.export/taonier.zip` 找产物,所以默认写 `../.export/taonier.zip`。
|
||||
* 脚本挂在项目根 `package.json`(cwd = 项目根)时用 `--zip-out .export/taonier.zip` 覆盖。
|
||||
*/
|
||||
const DEFAULT_ZIP_OUT = join('..', '.export', 'taonier.zip');
|
||||
const DEFAULT_VITE_BUILT_DIR = 'dist-taonier';
|
||||
|
||||
/**
|
||||
* 可选外层文件夹名。陶泥儿要求存档根直接有 index.html,所以默认 `null`=不套外层;
|
||||
* 调用方显式给 `--root-dir` 时才套一层(名字不能含路径分隔符)。
|
||||
*/
|
||||
const DEFAULT_ROOT_DIR = null;
|
||||
|
||||
/**
|
||||
* 最终包不应携带的开发/构建残留;命中即失败。
|
||||
*
|
||||
* 只拦 `*.js.map` / `*.css.map`(构建生成的 sourcemap),不拦所有 `.map`——有些引擎把关卡
|
||||
* 数据存成 `level.map`,那是正常游戏资源。
|
||||
*/
|
||||
const FORBIDDEN_ENTRY =
|
||||
/(^|\/)(?:node_modules|\.git)\/|(^|\/)\.DS_Store$|\.(?:js|css)\.map$|(^|\/)(?:vite|webpack|rollup)\.config\.[^/]+$/i;
|
||||
|
||||
const USAGE = `用法:node pack.mjs [--vite-built-dir <dir>] [--zip-out <path>] [--root-dir <name>]
|
||||
|
||||
把 vite 构建产物打成陶泥儿 H5 zip(仅 .zip,存档根直接带 index.html)。
|
||||
|
||||
--vite-built-dir <dir> vite 的构建输出目录,必须与 vite config 的 build.outDir 一致。默认 ${DEFAULT_VITE_BUILT_DIR}。
|
||||
该目录根下必须有 index.html;本脚本只读它,不改写。
|
||||
--zip-out <path> 落点,必须以 .zip 结尾。默认 ${DEFAULT_ZIP_OUT}(假设 cwd 是 game/ 子工程,
|
||||
产物落在项目根 .export/);相对路径按当前工作目录解析,父目录不存在会自动建。
|
||||
--root-dir <name> 可选。默认不套外层文件夹(构建产物内容直接位于存档根);给了才把内容套进
|
||||
单层文件夹 <name>/。不能包含路径分隔符。`;
|
||||
|
||||
/** 顶层文件夹名必须是单个安全路径段:非空、不含分隔符、不是 . / ..;`null` 表示不套外层。 */
|
||||
export function normalizeRootDir(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
const name = String(value).trim();
|
||||
if (!name || name === '.' || name === '..') {
|
||||
throw new Error('--root-dir 必须是一个非空的文件夹名');
|
||||
}
|
||||
if (name.includes('/') || name.includes('\\')) {
|
||||
throw new Error(`--root-dir 不能包含路径分隔符:${name}`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 纯 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集 dist 下所有条目。`rootName` 为 `null` 时不加前缀:构建目录内容直接落在存档根
|
||||
* (`index.html`、`assets/...`);给了 `rootName` 才整体挂到 `<rootName>/` 前缀下。
|
||||
*
|
||||
* 目录条目在前、文件名用 `/` 分隔、保持排序,产物稳定。套外层时顶层文件夹自身也写一个目录
|
||||
* 条目,这样即使 dist 为空(实际不会),解压仍能得到唯一文件夹。
|
||||
*/
|
||||
function collectZipEntries(rootDir, rootName) {
|
||||
const prefix = rootName ? `${rootName}/` : '';
|
||||
const entries = rootName ? [{ name: prefix, dir: true, full: rootDir }] : [];
|
||||
const visit = (dir) => {
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
const full = join(dir, name);
|
||||
const st = lstatSync(full);
|
||||
const nameInZip = `${prefix}${relative(rootDir, full).split(sep).join('/')}`;
|
||||
if (st.isDirectory()) {
|
||||
entries.push({ name: `${nameInZip}/`, dir: true, full });
|
||||
visit(full);
|
||||
} else if (st.isFile()) {
|
||||
entries.push({ name: nameInZip, dir: false, full });
|
||||
} else {
|
||||
// FIFO / socket / 设备文件既不是目录也不是普通文件;静默跳过会打出一个缺文件的包。
|
||||
throw new Error(`产物目录含非普通文件条目:${full};请清理后再打包`);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(rootDir);
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写出 zip。数据按条目顺序**流式**写盘,只在内存里保留中央目录,避免大包在内存里再拼一份完整副本。
|
||||
*
|
||||
* @returns {string[]} zip 内条目名(含目录条目),用于结构复核
|
||||
*/
|
||||
export function writeZip(rootDir, zipPath, rootName) {
|
||||
return writeZipEntries(collectZipEntries(rootDir, rootName), zipPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把已收集的条目流式写入 zip。
|
||||
*
|
||||
* 单独抽一层是为了让调用方能先把条目列表拿去做结构校验、再落盘:结构不合规时落点上不能出现
|
||||
* 半成品,也不能把落点原有的文件覆盖掉。数据本身仍然边读边写,不在内存里拼完整副本。
|
||||
*/
|
||||
function writeZipEntries(entries, zipPath) {
|
||||
if (entries.length > 0xffff) {
|
||||
throw new Error(`zip 条目数 ${entries.length} 超过 65535,当前 Node zip 实现不支持 ZIP64`);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 先写同目录临时文件、全部成功后再原子改名覆盖落点:任何写盘失败(磁盘满、4 GiB 上限、
|
||||
// 读源文件时被删)都不会截断或删掉落点上原有的 zip。
|
||||
const tempPath = `${zipPath}.tmp-${process.pid}`;
|
||||
const fd = openSync(tempPath, 'w');
|
||||
const centralParts = [];
|
||||
let offset = 0;
|
||||
|
||||
try {
|
||||
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) {
|
||||
// TODO(perf): 这里每个文件都整读一遍、再压出一份副本,单文件峰值内存约 2×;H5 里超大
|
||||
// 音频/贴图/WASM 可能顶爆内存或导出失败。彻底做法是改用 ZIP data descriptor(通用标志位
|
||||
// bit 3):先写数据、后回填压缩大小与 CRC,实现按文件流式写盘;代价是本地 zip 头与中央
|
||||
// 目录记账要重写。当前保留现状,先在此标注。
|
||||
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);
|
||||
|
||||
writeSync(fd, local);
|
||||
writeSync(fd, nameBuf);
|
||||
if (data.length > 0) writeSync(fd, 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 centralBuf = Buffer.concat(centralParts);
|
||||
if (centralBuf.length > 0xffffffff) {
|
||||
throw new Error('中央目录超过 4 GiB,当前 Node zip 实现不支持 ZIP64');
|
||||
}
|
||||
const centralOffset = offset;
|
||||
writeSync(fd, centralBuf);
|
||||
|
||||
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(centralOffset, 16);
|
||||
eocd.writeUInt16LE(0, 20);
|
||||
writeSync(fd, eocd);
|
||||
} catch (error) {
|
||||
closeSync(fd);
|
||||
rmSync(tempPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
closeSync(fd);
|
||||
renameSync(tempPath, zipPath);
|
||||
|
||||
return entries.map((entry) => entry.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复核 zip 结构:存档根直接包含 `index.html`。
|
||||
*
|
||||
* `rootName` 为 `null`(默认)时要求条目名恰好是 `index.html`;给了 `rootName` 时要求
|
||||
* `<rootName>/index.html`,且所有条目都在该文件夹内。
|
||||
*
|
||||
* @param {string[]} entries writeZip 返回的条目名
|
||||
* @param {string | null} rootName
|
||||
*/
|
||||
function assertArchiveStructure(entries, rootName) {
|
||||
const prefix = rootName ? `${rootName}/` : '';
|
||||
const indexName = `${prefix}index.html`;
|
||||
if (!entries.includes(indexName)) {
|
||||
throw new Error(
|
||||
`zip 存档根缺少 ${indexName};陶泥儿要求存档根目录直接包含 index.html(不得有外层文件夹)`,
|
||||
);
|
||||
}
|
||||
if (rootName) {
|
||||
const outside = entries.find((name) => !name.startsWith(prefix));
|
||||
if (outside) {
|
||||
throw new Error(`zip 里出现 ${prefix} 之外的条目:${outside};套了 --root-dir 时所有条目都必须在其中`);
|
||||
}
|
||||
}
|
||||
const bad = entries
|
||||
.map((name) => name.slice(prefix.length))
|
||||
.find((name) => FORBIDDEN_ENTRY.test(name));
|
||||
if (bad) {
|
||||
throw new Error(`zip 内含禁止文件:${prefix}${bad}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 产物目录必须存在、根下有 index.html,且不含符号链接(链接不会被打进包)。 */
|
||||
function assertPackedSource(viteBuiltDir) {
|
||||
if (!existsSync(viteBuiltDir)) {
|
||||
throw new Error(`缺少 vite 构建产物目录 ${viteBuiltDir}`);
|
||||
}
|
||||
if (!statSync(viteBuiltDir).isDirectory()) {
|
||||
throw new Error(`vite 构建产物路径不是目录 ${viteBuiltDir}`);
|
||||
}
|
||||
const indexPath = join(viteBuiltDir, 'index.html');
|
||||
if (!existsSync(indexPath)) {
|
||||
throw new Error(
|
||||
`产物根目录缺少 index.html:${indexPath};`
|
||||
+ '陶泥儿要求 zip 存档根目录直接包含 index.html',
|
||||
);
|
||||
}
|
||||
assertNoSymlinks(viteBuiltDir);
|
||||
}
|
||||
|
||||
function assertNoSymlinks(dir) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
const st = lstatSync(full);
|
||||
if (st.isSymbolicLink()) {
|
||||
throw new Error(`产物目录含符号链接:${full};请复制真实文件后再打包`);
|
||||
}
|
||||
if (st.isDirectory()) assertNoSymlinks(full);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打包并复核。结构(存档根 index.html、可选外层文件夹、禁止条目)在落盘前就校验;写盘走
|
||||
* 临时文件 + 原子改名,任何一步失败都不会截断或删掉落点上原有的 zip。
|
||||
*/
|
||||
function makeZip(viteBuiltDir, zipPath, rootName) {
|
||||
const entries = collectZipEntries(viteBuiltDir, rootName);
|
||||
// 先校验再落盘:落点原有文件不会被这次失败覆盖,也不存在「不合规 zip 短暂存在」的窗口。
|
||||
assertArchiveStructure(
|
||||
entries.map((entry) => entry.name),
|
||||
rootName,
|
||||
);
|
||||
|
||||
try {
|
||||
writeZipEntries(entries, zipPath);
|
||||
|
||||
const size = statSync(zipPath).size;
|
||||
const location = rootName ? `顶层文件夹 ${rootName}/` : '存档根 index.html';
|
||||
console.log(
|
||||
`zip 已生成:${zipPath}(${entries.length} 个条目,${(size / MIB).toFixed(2)} MiB,${location})`,
|
||||
);
|
||||
} catch (error) {
|
||||
// 不再删除 zipPath:写盘只落在临时文件,失败时原有产物必须保留。
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${message};未覆盖原有 zip`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* cwd 歧义预警:宿主在**项目根** `.export/taonier.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/taonier.zip` 找产物;若 cwd 是 game/ 子工程,'
|
||||
+ '请写 `../.export/taonier.zip`,否则会落在 game/.export/ 导致导出判失败。',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* cwd?: string,
|
||||
* viteBuiltDir?: string,
|
||||
* zipOut?: string,
|
||||
* rootDir?: string | null,
|
||||
* }} options
|
||||
*/
|
||||
export function packTaonier(options = {}) {
|
||||
const cwd = resolve(options.cwd || process.cwd());
|
||||
const viteBuiltDir = resolve(cwd, options.viteBuiltDir || DEFAULT_VITE_BUILT_DIR);
|
||||
const zipPath = resolve(cwd, options.zipOut || DEFAULT_ZIP_OUT);
|
||||
const rootName = normalizeRootDir(options.rootDir ?? DEFAULT_ROOT_DIR);
|
||||
|
||||
if (!zipPath.toLowerCase().endsWith('.zip')) {
|
||||
throw new Error(`陶泥儿只支持 .zip 压缩包,--zip-out 必须以 .zip 结尾:${zipPath}`);
|
||||
}
|
||||
|
||||
// 落点在产物目录内时,上一次的 zip 会被当成资源打进新包(写盘前已 collect),也会在写盘
|
||||
// 中途被覆盖;直接拒绝这种自包含布局。
|
||||
const insideBuiltDir = relative(viteBuiltDir, zipPath);
|
||||
if (insideBuiltDir === '' || (!insideBuiltDir.startsWith('..') && !isAbsolute(insideBuiltDir))) {
|
||||
throw new Error(`--zip-out 不能落在 --vite-built-dir 内:${zipPath}`);
|
||||
}
|
||||
|
||||
warnIfWorkdirRelative(cwd, zipPath, options.zipOut);
|
||||
assertPackedSource(viteBuiltDir);
|
||||
|
||||
// 落点的父目录可能还不存在(`.export/` 是宿主按需创建的),先建再写。
|
||||
mkdirSync(dirname(zipPath), { recursive: true });
|
||||
|
||||
makeZip(viteBuiltDir, zipPath, rootName);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export default packTaonier;
|
||||
|
||||
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;
|
||||
let rootDir;
|
||||
try {
|
||||
viteBuiltDir = takeFlag('--vite-built-dir');
|
||||
zipOut = takeFlag('--zip-out') || DEFAULT_ZIP_OUT;
|
||||
// 不给就是 null=不套外层文件夹;给了才套。
|
||||
rootDir = takeFlag('--root-dir');
|
||||
// 不认识的参数当场拒绝:否则调用方以为它生效了,产物落到别处还查不出来。
|
||||
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 {
|
||||
packTaonier({ viteBuiltDir, zipOut, rootDir });
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 陶泥儿 H5 托管 Vite 配置(可直接作为项目 vite.config 使用):
|
||||
*
|
||||
* vite build --config <此文件路径>
|
||||
*
|
||||
* 或在项目自己的 vite.config 里合并:
|
||||
*
|
||||
* import { defineTaonierConfig } from '<此文件路径>';
|
||||
* export default defineTaonierConfig({ plugins: [...] });
|
||||
*
|
||||
* 构建边界只有一条:**本配置负责把 dist 变成一个可托管的 H5 根目录,pack.mjs 只负责按契约打 zip。**
|
||||
*
|
||||
* 陶泥儿就是普通 H5 托管(不同于小红书小工具 vite-export-xhs-minitool):平台没有 Chrome 61
|
||||
* 基线、IIFE 单入口、禁内联脚本等运行约束,所以本配置不降级语法,也不重写 index.html。只固定
|
||||
* 三件与上传契约有关的事:
|
||||
*
|
||||
* - base: './' 相对路径。zip 存档根就是 `<index.html>`,会被托管在任意子路径,根路径引用
|
||||
* (/assets/...)在子路径下会 404。
|
||||
* - build.outDir 与 pack.mjs 的 --vite-built-dir 是同一个目录(默认 dist-taonier),两处
|
||||
* 必须一致。
|
||||
* - sourcemap 置 false:不把 .map 打进上传包(pack 也会拒绝构建 sourcemap),也减小包体。
|
||||
*
|
||||
* 收尾插件只做契约校验:dist 根必须有 index.html,且 index.html 不得引用根路径资源。这样
|
||||
* 「指错目录」或「误改 base」会在构建结束时就失败,而不是上传后才暴露问题。
|
||||
*
|
||||
* 平台约束:仅支持单个 .zip 压缩包;zip 解压后根目录必须直接包含 index.html(不得有外层文件夹),
|
||||
* 与 pack.mjs 的默认布局一致。
|
||||
*/
|
||||
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { defineConfig, mergeConfig } from 'vite';
|
||||
|
||||
/** 产物目录;必须与 pack.mjs 的 --vite-built-dir 默认值一致。 */
|
||||
export const TAONIER_OUT_DIR = 'dist-taonier';
|
||||
|
||||
/** 收尾插件名;defineTaonierConfig 按它去重,保证插件只跑一次。 */
|
||||
const ARTIFACT_PLUGIN_NAME = 'taonier-artifact';
|
||||
|
||||
/**
|
||||
* 校验 dist 是否是一个合格的 H5 根目录:根 index.html 存在,且不引用根路径资源。
|
||||
*
|
||||
* 缺失 index.html 时直接抛错——那说明构建配置指错了目录,继续打 zip 只会交付一个平台拒收的包
|
||||
* (解压后唯一文件夹下没有 index.html)。出现 `src="/…"` / `href="/…"` 同样抛错:交付物会被
|
||||
* 托管在子路径,根路径引用一定 404。
|
||||
*
|
||||
* @param {string} outDir 已解析为绝对路径的构建输出目录
|
||||
*/
|
||||
export function assertH5Root(outDir) {
|
||||
const htmlPath = join(outDir, 'index.html');
|
||||
if (!existsSync(htmlPath)) {
|
||||
throw new Error(`${htmlPath} 不存在,请先执行 vite build,并确认 build.outDir 指向构建输出目录`);
|
||||
}
|
||||
|
||||
const html = readFileSync(htmlPath, 'utf8');
|
||||
// 同时匹配单/双引号属性,且容忍 `=` 两侧空白;只看双引号会漏掉手写 HTML 或插件产物里的
|
||||
// `src='/app.js'`,让误配 base 的产物通过校验、上传后整站 404。
|
||||
const absoluteRefs = [...html.matchAll(/\s(?:src|href)\s*=\s*(["'])(\/[^"']*)\1/gi)].map(
|
||||
(match) => match[2],
|
||||
);
|
||||
if (absoluteRefs.length > 0) {
|
||||
throw new Error(
|
||||
`index.html 仍引用根路径资源(${absoluteRefs.join(', ')});`
|
||||
+ "请把 vite base 设为 './',否则 zip 解压到子路径后资源会 404",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 递归删除空目录;zip 里不留无意义目录条目。 */
|
||||
export function pruneEmptyDirs(dir) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name);
|
||||
// 用 lstatSync 而不是 statSync:后者会跟随符号链接,可能递归走出构建产物目录,
|
||||
// 甚至对指向祖先的链接递归到栈溢出;符号链接一律跳过。
|
||||
if (lstatSync(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)` 记下失败,
|
||||
* 失败就直接返回——否则会用「index.html 不存在」盖掉真正的语法报错。
|
||||
*
|
||||
* @returns {import('vite').Plugin}
|
||||
*/
|
||||
export function taonierArtifactPlugin() {
|
||||
/** @type {string} */
|
||||
let outDir = '';
|
||||
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);
|
||||
},
|
||||
buildEnd(error) {
|
||||
buildFailed = Boolean(error);
|
||||
},
|
||||
closeBundle() {
|
||||
if (buildFailed) return;
|
||||
assertH5Root(outDir);
|
||||
pruneEmptyDirs(outDir);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const taonierBaseConfig = {
|
||||
base: './',
|
||||
build: {
|
||||
outDir: TAONIER_OUT_DIR,
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
},
|
||||
plugins: [taonierArtifactPlugin()],
|
||||
};
|
||||
|
||||
/**
|
||||
* 合并项目配置,并保证收尾插件一定在 `plugins` 里。
|
||||
*
|
||||
* `mergeConfig` 对数组是拼接,正常传 `{ plugins: [...] }` 不会丢掉 base 的插件;但调用方
|
||||
* 若先展开 `taonierBaseConfig` 再覆盖 `plugins`,插件就被挤掉了。这里按 name 去重后追加一个
|
||||
* 新实例:无论 overrides 怎么给,插件都在最后且只跑一次。
|
||||
*
|
||||
* @param {import('vite').UserConfig} [overrides]
|
||||
* @returns {import('vite').UserConfig}
|
||||
*/
|
||||
export function defineTaonierConfig(overrides = {}) {
|
||||
const merged = mergeConfig(defineConfig(taonierBaseConfig), overrides);
|
||||
// mergeConfig 会用 overrides 里的标量覆盖 build.outDir,而 pack.mjs 的 --vite-built-dir
|
||||
// 默认值仍是 TAONIER_OUT_DIR;两边一旦不一致就会打包到陈旧/缺失的目录。这里在合并后
|
||||
// 重新钉死产物目录,保证构建输出与打包读取始终是同一个目录。
|
||||
merged.build = { ...(merged.build ?? {}), outDir: TAONIER_OUT_DIR };
|
||||
merged.plugins = [
|
||||
...(merged.plugins ?? []).filter((plugin) => plugin?.name !== ARTIFACT_PLUGIN_NAME),
|
||||
taonierArtifactPlugin(),
|
||||
];
|
||||
return merged;
|
||||
}
|
||||
|
||||
export default defineTaonierConfig();
|
||||
+59
@@ -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。
|
||||
+52
@@ -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 资源控制和降级 |
|
||||
+68
@@ -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`
|
||||
- [ ] 图片自适应且体积受控
|
||||
+57
@@ -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 和构建配置。
|
||||
+109
@@ -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 组件 |
|
||||
+310
@@ -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` 返回。
|
||||
+42
@@ -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)。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user