feat: add agent-first minitool studio MVP
CI / verify (push) Has been cancelled

This commit is contained in:
2026-09-17 21:54:33 +08:00
commit 74a8f7cdf3
51 changed files with 3855 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run typecheck
- name: Unit tests
run: npm test
- name: Production build
run: npm run build
+6
View File
@@ -0,0 +1,6 @@
node_modules/
.next/
data/projects/*
!data/projects/.gitkeep
.DS_Store
*.log
+39
View File
@@ -0,0 +1,39 @@
# Codex 接入说明
Rednote Tool Studio 是确定性的项目、构建与校验服务,不内嵌任何 LLM。Codex、Claude Code 或其他编码代理通过 HTTP 操作它。
## 建议工作流
1. `POST /api/projects` 创建项目,保存响应中的 `id` 和 `revision`。
2. `GET /api/projects/{id}` 获取文件列表。
3. 逐个读取必要文件,避免覆盖用户未要求修改的内容。
4. 使用 `PUT /files/{path}` 或 `POST /patch` 写入,并始终传当前 `revision`。
5. 若修改了 `package.json`,调用 `POST /dependencies/install`。安装过程禁用依赖生命周期脚本。
6. `POST /build` 后读取结构化报告;根据 `code`、`file`、`line` 和 `suggestion` 修复。
7. `POST /validate` 直到 `status` 为 `passed`。
8. `POST /package`,将 `artifactUrl` 返回给用户。
完整接口契约位于 `GET /openapi.json`。
## 约束
- 小工具纯本地运行,不调用网络接口,不加载任何外链资源。
- 不使用内联脚本、行内事件、`type="module"`、`import()`、Web Worker、WASM 或受限设备 API。
- 源码可以用 React JSX;服务会将最终代码打包成面向 Chrome 61 的 ES2017 经典脚本。
- `index.html` 必须位于 ZIP 根目录。
- 修改失败并返回 HTTP 409 时,重新获取项目与文件,再基于最新 revision 应用改动。
- 校验通过表示静态门禁通过,不等同于小红书模拟器或真机验收。
## 示例
```bash
curl -X POST http://localhost:3000/api/projects \
-H 'Content-Type: application/json' \
-d '{"name":"周末灵感签","template":"basic"}'
curl -X POST http://localhost:3000/api/projects/PROJECT_ID/package \
-H 'Content-Type: application/json' \
-d '{}'
```
不要把服务暴露到不可信网络。后续部署到共享环境时,应在 API 前增加身份认证、项目授权和请求限流。
+66
View File
@@ -0,0 +1,66 @@
# Rednote Tool Studio
一个面向编码代理的小红书小工具制作器。界面使用 Next.js + React,外部 Codex、Claude Code 或其他代理通过 HTTP API 创建、修改、预览、校验并导出离线 H5 ZIP;应用本身不内嵌 LLM,也不持有模型密钥。
## MVP 能力
- 项目管理页面、源码编辑器与手机预览
- 文件读写和批量修改 API,使用 revision 避免并发覆盖
- 可发现的 OpenAPI 3.1 契约
- React 小工具模板
- 项目级依赖安装(禁用 npm 生命周期脚本)
- ES2017 / Chrome 61 经典脚本构建
- 受限 API、HTML/CSP、文件结构与资源检查
- 固定版本 `minitool-zip-builder 1.6.0` 审计
- 校验通过后生成 ZIP
- 闲置项目 `node_modules` 自动清理
## 本地运行
```bash
npm install
npm run dev
```
访问 `http://localhost:3000`。API 契约位于 `http://localhost:3000/openapi.json`,编码代理说明见 [AGENT_GUIDE.md](./AGENT_GUIDE.md)。
## 闲置依赖清理
服务启动后每小时扫描一次托管项目。项目超过 24 小时没有 API、预览或构建访问时,会删除:
```text
data/projects/<project-id>/workspace/node_modules
```
源码、锁文件、构建产物和 ZIP 不会被删除;正在构建的项目不会被清理。需要依赖时可依据锁文件重新安装。可通过环境变量调整:
```bash
NODE_MODULES_IDLE_HOURS=48 CLEANUP_INTERVAL_MS=3600000 npm run dev
```
也可显式调用:
```bash
curl -X POST http://localhost:3000/api/maintenance/cleanup \
-H 'Content-Type: application/json' \
-d '{"idleHours":24}'
```
## 校验边界
内置 skill 的审计脚本主要检查包体和文本文件体积。Studio 在它之外增加 HTML、受限 API、外链和产物结构检查。静态检查通过仍不代表已经完成小红书模拟器或真机验收。
仓库固定包含 `minitool-zip-builder 1.6.0`,来源为:
```text
https://fe-static.xhscdn.com/mini-tool/20260831163932/minitool-zip-builder-1.6.0.skill
SHA-256 29c04115fd89d7eab7b81775f4287ae20c569ad3794d25d8404dc0ec3ec3b65e
```
## 测试
```bash
npm run typecheck
npm test
npm run build
```
@@ -0,0 +1,17 @@
import fs from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
import { errorResponse } from "../../../../../lib/http";
import { projectRoot, touchProject } from "../../../../../lib/storage";
export const runtime = "nodejs";
export async function GET(_: Request, context: { params: Promise<{ projectId: string; file: string }> }) {
try {
const { projectId, file } = await context.params;
if (file !== "minitool.zip") throw new Error("Unknown artifact");
await touchProject(projectId);
const data = await fs.readFile(path.join(projectRoot(projectId), "artifacts", file));
return new NextResponse(data, { headers: { "Content-Type": "application/zip", "Content-Disposition": `attachment; filename="${projectId}-minitool.zip"` } });
} catch (error) { return errorResponse(error); }
}
+14
View File
@@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { cleanupIdleNodeModules } from "../../../../lib/cleanup";
import { errorResponse } from "../../../../lib/http";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const idleHours = body.idleHours === undefined ? undefined : Number(body.idleHours);
if (idleHours !== undefined && (!Number.isFinite(idleHours) || idleHours < 0)) throw new Error("idleHours must be a non-negative number");
return NextResponse.json(await cleanupIdleNodeModules(idleHours));
} catch (error) { return errorResponse(error); }
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { buildProject } from "../../../../../lib/build";
import { errorResponse } from "../../../../../lib/http";
import { touchProject } from "../../../../../lib/storage";
export const runtime = "nodejs";
export const maxDuration = 60;
export async function POST(_: Request, context: { params: Promise<{ id: string }> }) {
try {
const { id } = await context.params;
await touchProject(id);
const result = await buildProject(id);
return NextResponse.json({ report: result.report, previewUrl: `/api/projects/${id}/preview/index.html` });
} catch (error) { return errorResponse(error); }
}
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { installProjectDependencies } from "../../../../../../../lib/build";
import { errorResponse } from "../../../../../../../lib/http";
import { touchProject } from "../../../../../../../lib/storage";
export const runtime = "nodejs";
export const maxDuration = 120;
export async function POST(_: Request, context: { params: Promise<{ id: string }> }) {
try {
const { id } = await context.params;
await touchProject(id);
return NextResponse.json(await installProjectDependencies(id));
} catch (error) { return errorResponse(error); }
}
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { errorResponse } from "../../../../../../lib/http";
import { readProjectFile, writeProjectFiles } from "../../../../../../lib/storage";
export const runtime = "nodejs";
type Context = { params: Promise<{ id: string; path: string[] }> };
export async function GET(_: NextRequest, context: Context) {
try {
const params = await context.params;
return NextResponse.json({ content: await readProjectFile(params.id, params.path) });
} catch (error) { return errorResponse(error); }
}
export async function PUT(request: NextRequest, context: Context) {
try {
const params = await context.params;
const body = await request.json();
if (typeof body.content !== "string") throw new Error("content must be a string");
const project = await writeProjectFiles(params.id, [{ path: params.path.join("/"), content: body.content }], body.revision);
return NextResponse.json({ project });
} catch (error) { return errorResponse(error); }
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { packageProject } from "../../../../../lib/build";
import { errorResponse } from "../../../../../lib/http";
import { touchProject } from "../../../../../lib/storage";
export const runtime = "nodejs";
export const maxDuration = 60;
export async function POST(_: Request, context: { params: Promise<{ id: string }> }) {
try {
const { id } = await context.params;
await touchProject(id);
const result = await packageProject(id);
return NextResponse.json({ report: result.report, artifactUrl: result.zipPath ? `/api/artifacts/${id}/minitool.zip` : undefined });
} catch (error) { return errorResponse(error); }
}
+14
View File
@@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { errorResponse } from "../../../../../lib/http";
import { writeProjectFiles } from "../../../../../lib/storage";
export const runtime = "nodejs";
export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) {
try {
const { id } = await context.params;
const body = await request.json();
if (!Array.isArray(body.changes) || body.changes.some((item: unknown) => !item || typeof (item as { path?: unknown }).path !== "string" || typeof (item as { content?: unknown }).content !== "string")) throw new Error("changes must contain path and content strings");
return NextResponse.json({ project: await writeProjectFiles(id, body.changes, body.revision) });
} catch (error) { return errorResponse(error); }
}
@@ -0,0 +1,23 @@
import fs from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
import { buildProject } from "../../../../../../lib/build";
import { errorResponse } from "../../../../../../lib/http";
import { projectRoot, touchProject } from "../../../../../../lib/storage";
export const runtime = "nodejs";
const CONTENT_TYPES: Record<string, string> = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".woff": "font/woff", ".woff2": "font/woff2" };
export async function GET(_: Request, context: { params: Promise<{ id: string; path?: string[] }> }) {
try {
const params = await context.params;
await touchProject(params.id);
const segments = params.path?.length ? params.path : ["index.html"];
const dist = path.join(projectRoot(params.id), "dist");
let target = path.resolve(dist, ...segments);
if (!target.startsWith(`${dist}${path.sep}`)) throw new Error("Invalid preview path");
try { await fs.access(target); } catch { await buildProject(params.id); target = path.resolve(dist, ...segments); }
return new NextResponse(await fs.readFile(target), { headers: { "Content-Type": CONTENT_TYPES[path.extname(target)] || "application/octet-stream", "Cache-Control": "no-store" } });
} catch (error) { return errorResponse(error); }
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { errorResponse } from "../../../../lib/http";
import { getProject, listFiles } from "../../../../lib/storage";
export const runtime = "nodejs";
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
try {
const { id } = await context.params;
const [project, files] = await Promise.all([getProject(id), listFiles(id)]);
return NextResponse.json({ project, files });
} catch (error) { return errorResponse(error); }
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { buildProject } from "../../../../../lib/build";
import { errorResponse } from "../../../../../lib/http";
import { touchProject } from "../../../../../lib/storage";
export const runtime = "nodejs";
export async function POST(_: Request, context: { params: Promise<{ id: string }> }) {
try {
const { id } = await context.params;
await touchProject(id);
const result = await buildProject(id);
return NextResponse.json({ report: result.report });
} catch (error) { return errorResponse(error); }
}
+17
View File
@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { createProject, listProjects } from "../../../lib/storage";
import { errorResponse } from "../../../lib/http";
export const runtime = "nodejs";
export async function GET() {
try { return NextResponse.json({ projects: await listProjects() }); }
catch (error) { return errorResponse(error); }
}
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
return NextResponse.json({ project: await createProject(body) }, { status: 201 });
} catch (error) { return errorResponse(error); }
}
+58
View File
@@ -0,0 +1,58 @@
:root { color-scheme: light; --ink: #25372f; --muted: #7a827e; --line: #dde1dc; --cream: #f6f4ee; --paper: #fffefa; --green: #315542; --red: #ff2442; }
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--cream); color: var(--ink); font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; }
button, textarea, input { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; }
.studio-shell { min-height: 100vh; }
.topbar { height: 66px; padding: 0 20px; display: flex; align-items: center; justify-content: space-between; background: var(--paper); border-bottom: 1px solid var(--line); }
.topbar > div { display: flex; align-items: center; gap: 10px; }
.topbar small { color: var(--muted); padding-left: 10px; border-left: 1px solid var(--line); }
.logo { width: 31px; height: 31px; display: grid; place-items: center; color: white; background: var(--red); border-radius: 10px; font-weight: 800; }
.actions a, .actions button, .panel-head button, .command-grid button { border: 1px solid var(--line); background: white; color: var(--ink); border-radius: 8px; padding: 9px 13px; text-decoration: none; }
.actions button, .command-grid .primary { color: white; background: var(--green); border-color: var(--green); }
.workspace { display: grid; grid-template-columns: 190px minmax(330px, 1fr) minmax(330px, 430px) 290px; height: calc(100vh - 66px); }
.sidebar, .editor-panel, .preview-panel, .inspect-panel { min-width: 0; border-right: 1px solid var(--line); background: var(--paper); }
.sidebar, .inspect-panel { padding: 18px 12px; overflow-y: auto; }
.section-label { margin: 0 8px 10px; color: #929995; font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
.files-label { margin-top: 28px; }
.project-row, .file-row, .empty-project { width: 100%; border: 0; background: transparent; border-radius: 8px; text-align: left; padding: 10px; color: #526059; }
.project-row { display: flex; justify-content: space-between; gap: 8px; }
.project-row small { color: #9aa19d; }
.project-row.selected, .file-row.selected { background: #e8eee9; color: var(--green); font-weight: 650; }
.file-row { padding: 7px 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.empty-project { border: 1px dashed #bbc4be; text-align: center; }
.editor-panel, .preview-panel { display: flex; flex-direction: column; background: #eef0ec; }
.panel-head { height: 58px; flex: 0 0 auto; padding: 0 14px; display: flex; align-items: center; justify-content: space-between; background: var(--paper); border-bottom: 1px solid var(--line); }
.panel-head div { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.panel-head strong, .panel-head small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.panel-head small { color: var(--muted); font-size: 10px; }
.editor-panel textarea { flex: 1; resize: none; border: 0; outline: none; padding: 20px; background: #17201c; color: #dce9df; font: 12px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 2; }
.statusbar { height: 32px; background: white; border-top: 1px solid var(--line); color: var(--muted); display: flex; align-items: center; gap: 7px; padding: 0 12px; font-size: 11px; }
.ok-dot, .error-dot { width: 7px; height: 7px; border-radius: 50%; background: #54a56d; }
.error-dot { background: #d95454; }
.preview-panel { align-items: center; overflow: auto; }
.preview-panel .panel-head { width: 100%; }
.phone { position: relative; flex: 0 0 auto; width: 330px; height: 660px; margin: 24px; padding: 12px 8px 8px; background: #202622; border: 4px solid #3b423e; border-radius: 37px; box-shadow: 0 16px 45px rgba(28, 45, 37, .18); }
.phone iframe { width: 100%; height: 100%; border: 0; border-radius: 25px; background: white; }
.speaker { position: absolute; z-index: 2; top: 6px; left: 50%; width: 55px; height: 5px; transform: translateX(-50%); border-radius: 5px; background: #090b0a; }
.inspect-panel { border-right: 0; }
.command-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.command-grid button { font-size: 12px; padding: 10px 4px; }
.report, .report-placeholder, .agent-note { margin-top: 18px; padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: #fafbf8; }
.report { display: flex; flex-direction: column; gap: 5px; }
.report.passed { border-color: #b9d4bf; background: #f2f8f2; }
.report.failed { border-color: #e1bcbc; background: #fff7f6; }
.report > small, .report article span { color: var(--muted); font-size: 10px; }
.report pre { margin: 9px 0; white-space: pre-wrap; font-size: 10px; }
.report article { border-top: 1px solid var(--line); padding-top: 10px; margin-top: 6px; }
.report article b { display: block; color: #bd4545; font-size: 11px; }
.report article p { font-size: 12px; margin: 7px 0; }
.report-placeholder { color: var(--muted); text-align: center; font-size: 12px; line-height: 1.7; }
.report-placeholder span { display: inline-grid; place-items: center; width: 35px; height: 35px; border: 1px solid #bbccc1; border-radius: 50%; color: var(--green); }
.agent-note { margin-top: 26px; background: #eef2ee; border: 0; }
.agent-note strong { font-size: 12px; }
.agent-note p { color: #68736d; font-size: 11px; line-height: 1.7; }
button:disabled { cursor: not-allowed; opacity: .55; }
@media (max-width: 1180px) { .workspace { grid-template-columns: 170px minmax(320px, 1fr) 380px; } .inspect-panel { display: none; } }
@media (max-width: 820px) { .workspace { grid-template-columns: 150px 1fr; } .preview-panel { display: none; } .topbar small { display: none; } }
+11
View File
@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "小红书小工具 Studio",
description: "通过 HTTP API 让 Codex 等编码代理创建、预览、检查并打包小红书小工具。",
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return <html lang="zh-CN"><body>{children}</body></html>;
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
export function GET(request: Request) {
const origin = new URL(request.url).origin;
return NextResponse.json({
openapi: "3.1.0",
info: { title: "Rednote Tool Studio API", version: "0.1.0", description: "供 Codex 等外部编码代理创建、修改、构建、校验并打包小红书离线 H5 小工具。写操作使用 revision 进行乐观并发控制。" },
servers: [{ url: origin }],
paths: {
"/api/projects": {
get: { operationId: "listProjects", summary: "列出项目", responses: { "200": { description: "项目列表" } } },
post: { operationId: "createProject", summary: "从模板创建项目", requestBody: { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, template: { type: "string", default: "basic" } } } } } }, responses: { "201": { description: "已创建" } } },
},
"/api/projects/{id}": { get: { operationId: "getProject", summary: "读取项目元数据与文件列表", parameters: [{ $ref: "#/components/parameters/ProjectId" }], responses: { "200": { description: "项目详情" } } } },
"/api/projects/{id}/files/{path}": {
get: { operationId: "readProjectFile", summary: "读取 UTF-8 源文件", parameters: [{ $ref: "#/components/parameters/ProjectId" }, { $ref: "#/components/parameters/FilePath" }], responses: { "200": { description: "文件内容" } } },
put: { operationId: "writeProjectFile", summary: "写入单个源文件", parameters: [{ $ref: "#/components/parameters/ProjectId" }, { $ref: "#/components/parameters/FilePath" }], requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/FileWrite" } } } }, responses: { "200": { description: "已保存" }, "409": { description: "revision 冲突" } } },
},
"/api/projects/{id}/patch": { post: { operationId: "patchProjectFiles", summary: "原子地写入多个文件", parameters: [{ $ref: "#/components/parameters/ProjectId" }], requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["revision", "changes"], properties: { revision: { type: "integer" }, changes: { type: "array", items: { type: "object", required: ["path", "content"], properties: { path: { type: "string" }, content: { type: "string" } } } } } } } } }, responses: { "200": { description: "已更新" }, "409": { description: "revision 冲突" } } } },
"/api/projects/{id}/build": { post: { operationId: "buildProject", summary: "构建 ES2017 / Chrome 61 离线产物", parameters: [{ $ref: "#/components/parameters/ProjectId" }], responses: { "200": { description: "构建结果和预览地址" } } } },
"/api/projects/{id}/dependencies/install": { post: { operationId: "installProjectDependencies", summary: "根据 package.json 安装项目依赖;禁用生命周期脚本", parameters: [{ $ref: "#/components/parameters/ProjectId" }], responses: { "200": { description: "安装日志" } } } },
"/api/projects/{id}/validate": { post: { operationId: "validateProject", summary: "构建并运行语义检查与 skill 审计", parameters: [{ $ref: "#/components/parameters/ProjectId" }], responses: { "200": { description: "结构化校验报告" } } } },
"/api/projects/{id}/package": { post: { operationId: "packageProject", summary: "校验并生成 ZIP", parameters: [{ $ref: "#/components/parameters/ProjectId" }], responses: { "200": { description: "校验报告和 artifactUrl;失败时不生成 ZIP" } } } },
"/api/projects/{id}/preview/{path}": { get: { operationId: "previewArtifact", summary: "读取预览产物", parameters: [{ $ref: "#/components/parameters/ProjectId" }, { $ref: "#/components/parameters/FilePath" }], responses: { "200": { description: "静态产物" } } } },
"/api/maintenance/cleanup": { post: { operationId: "cleanupIdleNodeModules", summary: "清理闲置项目 node_modules", requestBody: { content: { "application/json": { schema: { type: "object", properties: { idleHours: { type: "number", minimum: 0, default: 24 } } } } } }, responses: { "200": { description: "清理结果" } } } },
},
components: {
parameters: {
ProjectId: { name: "id", in: "path", required: true, schema: { type: "string" } },
FilePath: { name: "path", in: "path", required: true, schema: { type: "string" }, description: "项目相对路径,例如 src/App.jsx" },
},
schemas: {
FileWrite: { type: "object", required: ["content", "revision"], properties: { content: { type: "string" }, revision: { type: "integer", description: "来自项目元数据的当前 revision" } } },
},
},
});
}
+5
View File
@@ -0,0 +1,5 @@
import Studio from "../components/Studio";
export default function Home() {
return <Studio />;
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
type Project = { id: string; name: string; revision: number; updatedAt: string; nodeModulesCleanedAt?: string };
type Report = { status: "passed" | "failed"; skillVersion: string; auditOutput: string; issues: Array<{ severity: string; code: string; file: string; line?: number; message: string; suggestion: string }>; limits: { zipBytes?: number; unpackedBytes: number } };
async function api<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, { ...init, headers: { "Content-Type": "application/json", ...(init?.headers || {}) } });
const data = await response.json();
if (!response.ok) throw new Error(data.error || `请求失败:${response.status}`);
return data;
}
export default function Studio() {
const [projects, setProjects] = useState<Project[]>([]);
const [selected, setSelected] = useState<Project | null>(null);
const [files, setFiles] = useState<string[]>([]);
const [activeFile, setActiveFile] = useState("src/App.jsx");
const [content, setContent] = useState("");
const [report, setReport] = useState<Report | null>(null);
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("连接已就绪");
const [previewKey, setPreviewKey] = useState(0);
const loadProjects = useCallback(async () => {
const data = await api<{ projects: Project[] }>("/api/projects");
setProjects(data.projects);
if (!selected && data.projects[0]) setSelected(data.projects[0]);
}, [selected]);
useEffect(() => { void loadProjects(); }, [loadProjects]);
useEffect(() => {
if (!selected) return;
void api<{ files: string[] }>(`/api/projects/${selected.id}`).then((data) => setFiles(data.files));
}, [selected]);
useEffect(() => {
if (!selected || !activeFile) return;
void api<{ content: string }>(`/api/projects/${selected.id}/files/${activeFile}`).then((data) => setContent(data.content));
}, [selected, activeFile]);
const previewUrl = useMemo(() => selected ? `/api/projects/${selected.id}/preview/index.html?v=${previewKey}` : "about:blank", [selected, previewKey]);
async function createProject() {
setBusy("create");
try {
const data = await api<{ project: Project }>("/api/projects", { method: "POST", body: JSON.stringify({ name: `灵感小工具 ${projects.length + 1}`, template: "basic" }) });
setSelected(data.project); setReport(null); setMessage("项目已创建"); await loadProjects();
} catch (error) { setMessage((error as Error).message); } finally { setBusy(""); }
}
async function save() {
if (!selected) return;
setBusy("save");
try {
const data = await api<{ project: Project }>(`/api/projects/${selected.id}/files/${activeFile}`, { method: "PUT", body: JSON.stringify({ content, revision: selected.revision }) });
setSelected(data.project); setMessage(`已保存 · revision ${data.project.revision}`);
} catch (error) { setMessage((error as Error).message); } finally { setBusy(""); }
}
async function run(action: "build" | "validate" | "package") {
if (!selected) return;
setBusy(action);
try {
const data = await api<{ report: Report; artifactUrl?: string }>(`/api/projects/${selected.id}/${action}`, { method: "POST", body: "{}" });
setReport(data.report); setPreviewKey((value) => value + 1);
setMessage(data.report.status === "passed" ? `${action} 已通过` : `${action} 发现 ${data.report.issues.length} 个问题`);
if (data.artifactUrl) setMessage(`ZIP 已生成:${data.artifactUrl}`);
} catch (error) { setMessage((error as Error).message); } finally { setBusy(""); }
}
return <main className="studio-shell">
<header className="topbar">
<div><span className="logo">R</span><strong>Rednote Tool Studio</strong><small>Agent-first 小工具工作台</small></div>
<div className="actions"><a href="/openapi.json" target="_blank">OpenAPI</a><button onClick={createProject} disabled={Boolean(busy)}>+ 新建项目</button></div>
</header>
<section className="workspace">
<aside className="sidebar">
<p className="section-label">项目</p>
{projects.length === 0 && <button className="empty-project" onClick={createProject}>创建第一个小工具</button>}
{projects.map((project) => <button key={project.id} className={`project-row ${selected?.id === project.id ? "selected" : ""}`} onClick={() => { setSelected(project); setReport(null); }}><span>{project.name}</span><small>r{project.revision}</small></button>)}
{selected && <><p className="section-label files-label">文件</p>{files.map((file) => <button key={file} className={`file-row ${activeFile === file ? "selected" : ""}`} onClick={() => setActiveFile(file)}>{file}</button>)}</>}
</aside>
<section className="editor-panel">
<div className="panel-head"><div><strong>{activeFile}</strong><small>{selected ? selected.id : "未选择项目"}</small></div><button onClick={save} disabled={!selected || Boolean(busy)}>{busy === "save" ? "保存中…" : "保存"}</button></div>
<textarea value={content} onChange={(event) => setContent(event.target.value)} spellCheck={false} disabled={!selected} aria-label="文件编辑器" />
<div className="statusbar"><span className={report?.status === "failed" ? "error-dot" : "ok-dot"} />{message}</div>
</section>
<section className="preview-panel">
<div className="panel-head"><div><strong>手机预览</strong><small>离线 H5 产物</small></div><button onClick={() => run("build")} disabled={!selected || Boolean(busy)}>{busy === "build" ? "构建中…" : "构建并刷新"}</button></div>
<div className="phone"><div className="speaker" /><iframe src={previewUrl} title="小工具预览" /></div>
</section>
<aside className="inspect-panel">
<p className="section-label">交付门禁</p>
<div className="command-grid"><button onClick={() => run("validate")} disabled={!selected || Boolean(busy)}>检查当前产物</button><button className="primary" onClick={() => run("package")} disabled={!selected || Boolean(busy)}>生成 ZIP</button></div>
{report ? <div className={`report ${report.status}`}><strong>{report.status === "passed" ? "检查通过" : "需要修复"}</strong><small>skill {report.skillVersion} · {(report.limits.unpackedBytes / 1024).toFixed(1)} KiB</small><pre>{report.auditOutput}</pre>{report.issues.map((issue) => <article key={`${issue.code}-${issue.file}-${issue.line || 0}`}><b>{issue.code}</b><span>{issue.file}{issue.line ? `:${issue.line}` : ""}</span><p>{issue.message}</p><small>{issue.suggestion}</small></article>)}</div> : <div className="report-placeholder"><span>✓</span><p>构建后显示结构化检查结果、修复建议和 skill 审计摘要。</p></div>}
<div className="agent-note"><strong>为 Codex 设计</strong><p>通过文件 API 修改代码,用 revision 防止覆盖,再调用 build、validate 和 package。完整契约见 OpenAPI。</p></div>
</aside>
</section>
</main>;
}
+1
View File
@@ -0,0 +1 @@
+6
View File
@@ -0,0 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { startCleanupScheduler } = await import("./lib/cleanup");
startCleanupScheduler();
}
}
+80
View File
@@ -0,0 +1,80 @@
import fs from "node:fs/promises";
import path from "node:path";
import { build } from "esbuild";
import JSZip from "jszip";
import { spawn } from "node:child_process";
import { projectRoot, workspaceRoot } from "./storage";
import { validateArtifact } from "./validation";
const activeOperations = new Set<string>();
export function isBuildActive(id: string) { return activeOperations.has(id); }
export async function buildProject(id: string) {
if (activeOperations.has(id)) throw new Error("Project is already busy");
activeOperations.add(id);
const workspace = workspaceRoot(id);
const dist = path.join(projectRoot(id), "dist");
try {
await fs.rm(dist, { recursive: true, force: true });
await fs.mkdir(path.join(dist, "assets"), { recursive: true });
await build({
absWorkingDir: workspace,
entryPoints: ["src/App.jsx"],
bundle: true,
outfile: path.join(dist, "assets", "main.js"),
format: "iife",
target: ["es2017", "chrome61"],
minify: true,
sourcemap: false,
legalComments: "inline",
define: { "process.env.NODE_ENV": '"production"' },
nodePaths: [path.join(process.cwd(), "node_modules"), path.join(workspace, "node_modules")],
logLevel: "silent",
});
await fs.copyFile(path.join(workspace, "src", "index.html"), path.join(dist, "index.html"));
await fs.copyFile(path.join(workspace, "src", "style.css"), path.join(dist, "assets", "style.css"));
return { dist, report: await validateArtifact(dist) };
} finally {
activeOperations.delete(id);
}
}
export async function installProjectDependencies(id: string) {
if (activeOperations.has(id)) throw new Error("Project is already busy");
activeOperations.add(id);
const workspace = workspaceRoot(id);
try {
await fs.access(path.join(workspace, "package.json"));
const output = await new Promise<string>((resolve, reject) => {
const child = spawn("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: workspace, env: { ...process.env, npm_config_update_notifier: "false" } });
let text = "";
child.stdout.on("data", (chunk) => { text += chunk; });
child.stderr.on("data", (chunk) => { text += chunk; });
child.on("error", reject);
child.on("close", (code) => code === 0 ? resolve(text.trim()) : reject(new Error(`npm install failed (${code}): ${text.trim()}`)));
});
return { output };
} finally {
activeOperations.delete(id);
}
}
async function addDirectoryToZip(zip: JSZip, root: string, dir = root) {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) await addDirectoryToZip(zip, root, full);
else zip.file(path.relative(root, full).split(path.sep).join("/"), await fs.readFile(full));
}
}
export async function packageProject(id: string) {
const { dist, report } = await buildProject(id);
if (report.status === "failed") return { report };
const artifacts = path.join(projectRoot(id), "artifacts");
await fs.mkdir(artifacts, { recursive: true });
const zipPath = path.join(artifacts, "minitool.zip");
const zip = new JSZip();
await addDirectoryToZip(zip, dist);
await fs.writeFile(zipPath, await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE", compressionOptions: { level: 9 } }));
return { zipPath, report: await validateArtifact(dist, zipPath) };
}
+43
View File
@@ -0,0 +1,43 @@
import fs from "node:fs/promises";
import path from "node:path";
import { CLEANUP_INTERVAL_MS, DEFAULT_IDLE_HOURS, PROJECTS_ROOT } from "./config";
import { isBuildActive } from "./build";
import type { ProjectMeta } from "./types";
let schedulerStarted = false;
export function isProjectIdle(lastAccessAt: string, idleHours: number, now = Date.now()) {
const timestamp = Date.parse(lastAccessAt);
return Number.isFinite(timestamp) && timestamp <= now - idleHours * 60 * 60 * 1000;
}
export async function cleanupIdleNodeModules(idleHours = DEFAULT_IDLE_HOURS) {
await fs.mkdir(PROJECTS_ROOT, { recursive: true });
const cutoff = Date.now() - idleHours * 60 * 60 * 1000;
const cleaned: string[] = [];
for (const entry of await fs.readdir(PROJECTS_ROOT, { withFileTypes: true })) {
if (!entry.isDirectory() || isBuildActive(entry.name)) continue;
const root = path.join(PROJECTS_ROOT, entry.name);
try {
const metaPath = path.join(root, "project.json");
const meta: ProjectMeta = JSON.parse(await fs.readFile(metaPath, "utf8"));
if (!isProjectIdle(meta.lastAccessAt, idleHours, Date.now())) continue;
const modules = path.join(root, "workspace", "node_modules");
const stat = await fs.lstat(modules).catch(() => null);
if (!stat?.isDirectory() || stat.isSymbolicLink()) continue;
await fs.rm(modules, { recursive: true, force: true });
meta.nodeModulesCleanedAt = new Date().toISOString();
await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
cleaned.push(entry.name);
} catch { /* one broken project must not stop maintenance */ }
}
return { cleaned, idleHours, checkedAt: new Date().toISOString() };
}
export function startCleanupScheduler() {
if (schedulerStarted || process.env.NODE_ENV === "test") return;
schedulerStarted = true;
const timer = setInterval(() => { void cleanupIdleNodeModules(); }, CLEANUP_INTERVAL_MS);
timer.unref();
void cleanupIdleNodeModules();
}
+9
View File
@@ -0,0 +1,9 @@
import path from "node:path";
export const APP_ROOT = process.cwd();
export const PROJECTS_ROOT = path.join(APP_ROOT, "data", "projects");
export const TEMPLATES_ROOT = path.join(APP_ROOT, "templates");
export const SKILL_ROOT = path.join(APP_ROOT, "vendor", "minitool-zip-builder");
export const SKILL_VERSION = "1.6.0";
export const DEFAULT_IDLE_HOURS = Number(process.env.NODE_MODULES_IDLE_HOURS || 24);
export const CLEANUP_INTERVAL_MS = Number(process.env.CLEANUP_INTERVAL_MS || 60 * 60 * 1000);
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
export function errorResponse(error: unknown) {
const value = error as Error & { status?: number; code?: string };
const status = value.status || (value.code === "ENOENT" ? 404 : 400);
return NextResponse.json({ error: value.message || "Unknown error" }, { status });
}
+108
View File
@@ -0,0 +1,108 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { PROJECTS_ROOT, TEMPLATES_ROOT } from "./config";
import type { ProjectMeta } from "./types";
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,48}$/;
export function assertProjectId(id: string) {
if (!ID_PATTERN.test(id)) throw new Error("Invalid project id");
}
export function projectRoot(id: string) {
assertProjectId(id);
return path.join(PROJECTS_ROOT, id);
}
export function workspaceRoot(id: string) {
return path.join(projectRoot(id), "workspace");
}
export function safeWorkspacePath(id: string, segments: string[]) {
const root = workspaceRoot(id);
const target = path.resolve(root, ...segments);
if (target !== root && !target.startsWith(`${root}${path.sep}`)) throw new Error("Path escapes workspace");
if (segments.some((part) => part === "node_modules" || part === ".git")) throw new Error("Protected path");
return target;
}
async function readMetaFile(id: string): Promise<ProjectMeta> {
return JSON.parse(await fs.readFile(path.join(projectRoot(id), "project.json"), "utf8"));
}
export async function touchProject(id: string) {
const meta = await readMetaFile(id);
meta.lastAccessAt = new Date().toISOString();
await fs.writeFile(path.join(projectRoot(id), "project.json"), JSON.stringify(meta, null, 2));
return meta;
}
export async function getProject(id: string, touch = true) {
return touch ? touchProject(id) : readMetaFile(id);
}
export async function listProjects() {
await fs.mkdir(PROJECTS_ROOT, { recursive: true });
const entries = await fs.readdir(PROJECTS_ROOT, { withFileTypes: true });
const results = await Promise.all(entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
try { return await readMetaFile(entry.name); } catch { return null; }
}));
return results.filter((item): item is ProjectMeta => Boolean(item)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export async function createProject(input: { name?: string; template?: string }) {
const template = input.template || "basic";
const templateRoot = path.join(TEMPLATES_ROOT, template);
await fs.access(templateRoot);
const slug = (input.name || "新小工具").toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-").replace(/^-|-$/g, "").slice(0, 28) || "tool";
const id = `${slug}-${crypto.randomBytes(3).toString("hex")}`;
const root = projectRoot(id);
await fs.mkdir(root, { recursive: true });
await fs.cp(templateRoot, workspaceRoot(id), { recursive: true });
const now = new Date().toISOString();
const meta: ProjectMeta = { id, name: input.name || "新小工具", template, revision: 1, createdAt: now, updatedAt: now, lastAccessAt: now };
await fs.writeFile(path.join(root, "project.json"), JSON.stringify(meta, null, 2));
return meta;
}
export async function listFiles(id: string) {
await touchProject(id);
const root = workspaceRoot(id);
const files: string[] = [];
async function walk(dir: string) {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
if (["node_modules", ".git", "dist"].includes(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) await walk(full);
else files.push(path.relative(root, full).split(path.sep).join("/"));
}
}
await walk(root);
return files.sort();
}
export async function readProjectFile(id: string, segments: string[]) {
await touchProject(id);
return fs.readFile(safeWorkspacePath(id, segments), "utf8");
}
export async function writeProjectFiles(id: string, changes: Array<{ path: string; content: string }>, expectedRevision?: number) {
const meta = await readMetaFile(id);
if (expectedRevision !== undefined && expectedRevision !== meta.revision) {
const error = new Error(`Revision conflict: expected ${expectedRevision}, current ${meta.revision}`);
(error as Error & { status?: number }).status = 409;
throw error;
}
for (const change of changes) {
const target = safeWorkspacePath(id, change.path.split("/"));
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, change.content, "utf8");
}
const now = new Date().toISOString();
meta.revision += 1;
meta.updatedAt = now;
meta.lastAccessAt = now;
await fs.writeFile(path.join(projectRoot(id), "project.json"), JSON.stringify(meta, null, 2));
return meta;
}
+28
View File
@@ -0,0 +1,28 @@
export type ProjectMeta = {
id: string;
name: string;
template: string;
revision: number;
createdAt: string;
updatedAt: string;
lastAccessAt: string;
nodeModulesCleanedAt?: string;
};
export type ValidationIssue = {
severity: "error" | "warning";
code: string;
file: string;
line?: number;
message: string;
suggestion: string;
};
export type ValidationReport = {
status: "passed" | "failed";
skillVersion: string;
checkedAt: string;
issues: ValidationIssue[];
auditOutput: string;
limits: { zipBytes?: number; unpackedBytes: number };
};
+97
View File
@@ -0,0 +1,97 @@
import fs from "node:fs/promises";
import path from "node:path";
import { spawn } from "node:child_process";
import { SKILL_ROOT, SKILL_VERSION } from "./config";
import type { ValidationIssue, ValidationReport } from "./types";
const ALLOWED_EXTENSIONS = new Set([".html", ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".woff", ".woff2", ".json"]);
const FORBIDDEN = [
["NETWORK_FETCH", /\bfetch\s*\(|\bXMLHttpRequest\b|\bnew\s+(?:WebSocket|EventSource|RTCPeerConnection)\s*\(/, "删除联网逻辑,改用包内资源或本地数据"],
["WORKER_FORBIDDEN", /\bnew\s+(?:Worker|SharedWorker)\s*\(|navigator\.serviceWorker/, "将逻辑放回主线程"],
["DYNAMIC_CODE", /\beval\s*\(|\bnew\s+Function\s*\(|\bWebAssembly\b/, "改为静态 JavaScript 逻辑"],
["DEVICE_API", /navigator\.(?:geolocation|clipboard|bluetooth|usb|hid|serial|getBattery|connection|credentials|locks)/, "移除容器禁用的设备能力"],
["WINDOW_API", /window\.(?:open|prompt)\s*\(|\.requestFullscreen\s*\(/, "使用单页视图或页内弹层"],
] as const;
async function collectFiles(root: string) {
const files: string[] = [];
async function walk(dir: string) {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) await walk(full); else files.push(full);
}
}
await walk(root);
return files;
}
function lineOf(text: string, index: number) { return text.slice(0, index).split("\n").length; }
function runAudit(target: string) {
return new Promise<string>((resolve) => {
const child = spawn(process.execPath, [path.join(SKILL_ROOT, "scripts", "audit_artifact.mjs"), target]);
let output = "";
child.stdout.on("data", (chunk) => { output += chunk; });
child.stderr.on("data", (chunk) => { output += chunk; });
child.on("close", () => resolve(output.trim()));
child.on("error", (error) => resolve(`Audit unavailable: ${error.message}`));
});
}
export async function validateArtifact(distRoot: string, zipPath?: string): Promise<ValidationReport> {
const issues: ValidationIssue[] = [];
const files = await collectFiles(distRoot);
const rel = (file: string) => path.relative(distRoot, file).split(path.sep).join("/");
if (!files.some((file) => rel(file) === "index.html")) issues.push({ severity: "error", code: "MISSING_INDEX", file: "index.html", message: "ZIP 根目录缺少 index.html", suggestion: "将入口文件输出到产物根目录" });
let unpackedBytes = 0;
for (const file of files) {
const name = rel(file);
const stat = await fs.stat(file);
unpackedBytes += stat.size;
if (!ALLOWED_EXTENSIONS.has(path.extname(file).toLowerCase())) issues.push({ severity: "error", code: "UNSUPPORTED_FILE", file: name, message: "产物包含不支持的文件类型", suggestion: "从最终 ZIP 中移除此文件" });
if (![".html", ".css", ".js", ".json"].includes(path.extname(file))) continue;
const text = await fs.readFile(file, "utf8");
for (const [code, pattern, suggestion] of FORBIDDEN) {
const match = pattern.exec(text);
if (match) issues.push({ severity: "error", code, file: name, line: lineOf(text, match.index), message: `发现受限能力:${match[0]}`, suggestion });
}
if (path.extname(file) === ".html") {
const checks: Array<[string, RegExp, string]> = [
["INLINE_SCRIPT", /<script(?![^>]*\bsrc=)[^>]*>/i, "将脚本移动到包内 .js 文件"],
["INLINE_EVENT", /\son[a-z]+\s*=/i, "使用 addEventListener 或 React 事件"],
["MODULE_SCRIPT", /<script[^>]+type=["']module["']/i, "构建为普通经典脚本"],
["FORBIDDEN_ELEMENT", /<(?:iframe|object|base)\b/i, "移除容器不支持的元素"],
];
for (const [code, pattern, suggestion] of checks) {
const match = pattern.exec(text);
if (match) issues.push({ severity: "error", code, file: name, line: lineOf(text, match.index), message: `HTML 不符合离线规范:${match[0]}`, suggestion });
}
const resourcePattern = /(?:src|href)=["']([^"']+)["']/gi;
for (const match of text.matchAll(resourcePattern)) {
const resource = match[1];
if (/^https?:\/\//i.test(resource)) issues.push({ severity: "error", code: "EXTERNAL_RESOURCE", file: name, line: lineOf(text, match.index || 0), message: `发现外部资源:${resource}`, suggestion: "下载资源并改为 ./ 开头的包内相对路径" });
else if (resource.startsWith("./")) {
const target = path.resolve(path.dirname(file), resource);
if (!target.startsWith(`${distRoot}${path.sep}`) || !(await fs.stat(target).catch(() => null))?.isFile()) issues.push({ severity: "error", code: "MISSING_RESOURCE", file: name, line: lineOf(text, match.index || 0), message: `引用的包内资源不存在:${resource}`, suggestion: "修正路径或将资源加入产物" });
}
}
}
if (path.extname(file) === ".css") {
const urlPattern = /url\(\s*["']?([^"')]+)["']?\s*\)/gi;
for (const match of text.matchAll(urlPattern)) {
const resource = match[1];
if (/^https?:\/\//i.test(resource)) issues.push({ severity: "error", code: "EXTERNAL_RESOURCE", file: name, line: lineOf(text, match.index || 0), message: `发现外部资源:${resource}`, suggestion: "下载资源并使用包内相对路径" });
else if (!/^(?:data:|blob:|#)/i.test(resource)) {
const target = path.resolve(path.dirname(file), resource);
if (!target.startsWith(`${distRoot}${path.sep}`) || !(await fs.stat(target).catch(() => null))?.isFile()) issues.push({ severity: "error", code: "MISSING_RESOURCE", file: name, line: lineOf(text, match.index || 0), message: `引用的包内资源不存在:${resource}`, suggestion: "修正路径或将资源加入产物" });
}
}
}
}
const directoryAudit = await runAudit(distRoot);
const zipAudit = zipPath ? await runAudit(zipPath) : "";
const auditOutput = [directoryAudit, zipAudit].filter(Boolean).join("\n");
if (/\bERROR\b/.test(auditOutput)) issues.push({ severity: "error", code: "SKILL_AUDIT", file: ".", message: auditOutput, suggestion: "按 minitool-zip-builder 审计信息缩减或修复产物" });
const zipBytes = zipPath ? (await fs.stat(zipPath)).size : undefined;
return { status: issues.some((issue) => issue.severity === "error") ? "failed" : "passed", skillVersion: SKILL_VERSION, checkedAt: new Date().toISOString(), issues, auditOutput, limits: { zipBytes, unpackedBytes } };
}
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const config: NextConfig = {
serverExternalPackages: ["esbuild"],
};
export default config;
+1514
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "rednote-tool-studio",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "tsx --test tests/*.test.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"esbuild": "0.28.2",
"jszip": "3.10.2",
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tsx": "^4.20.0",
"typescript": "^5.9.0"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "rednote-minitool",
"private": true,
"version": "1.0.0",
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
}
}
+22
View File
@@ -0,0 +1,22 @@
import React, { useState } from "react";
import { createRoot } from "react-dom/client";
const cards = [
{ icon: "☀", title: "去看一场日落", detail: "找一个视野开阔的地方,留半小时给天空。" },
{ icon: "✎", title: "写下三个小确幸", detail: "记录今天让你嘴角上扬的三个瞬间。" },
{ icon: "♧", title: "散步到一条新街", detail: "不设目的地,走进一条以前没有去过的小路。" },
];
function App() {
const [card, setCard] = useState(cards[0]);
const draw = () => setCard(cards[(cards.indexOf(card) + 1) % cards.length]);
return <main className="page">
<header><span>WEEKEND NOTE</span><b>VOL. 01</b></header>
<section className="intro"><small>A LITTLE PLAN FOR TODAY</small><h1>今天,给自己<br /><em>一点小期待。</em></h1></section>
<article><div className="icon">{card.icon}</div><p>YOUR WEEKEND NOTE</p><h2>{card.title}</h2><div className="rule" /><span>{card.detail}</span></article>
<button onClick={draw}>换一张灵感 <b>↗</b></button>
<footer>慢一点,也很好。</footer>
</main>;
}
createRoot(document.getElementById("root")).render(<App />);
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>我的小工具</title>
<link rel="stylesheet" href="./assets/style.css">
</head>
<body>
<div id="root"></div>
<script src="./assets/main.js"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
* { box-sizing: border-box; }
html, body, #root { min-height: 100%; }
body { margin: 0; background: #f4f1e8; color: #294136; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent; }
button { font: inherit; -webkit-appearance: none; appearance: none; }
button:focus { outline: 2px solid #b7653f; outline-offset: 3px; }
.page { width: 100%; min-height: 100vh; padding: 28px 24px 20px; padding-top: calc(28px + var(--safe-area-inset-top, env(safe-area-inset-top, 0px))); padding-bottom: calc(20px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))); }
header { display: flex; align-items: center; justify-content: space-between; font-size: 9px; letter-spacing: 2px; }
.intro { padding: 54px 0 30px; }
.intro small { color: #7f897e; font-size: 8px; letter-spacing: 2px; }
h1 { margin: 13px 0 0; font-size: 35px; line-height: 1.42; font-weight: 650; }
h1 em { color: #b7653f; font-style: normal; }
article { min-height: 310px; padding: 30px 27px; background: #fffdf5; border: 1px solid #e4dcc8; border-radius: 16px; box-shadow: 0 14px 34px rgba(54, 64, 51, .07); }
.icon { color: #b7653f; font-size: 52px; }
article p { color: #979886; font-size: 8px; letter-spacing: 1.7px; margin-top: 30px; }
article h2 { margin: 13px 0 20px; font-size: 23px; }
.rule { width: 32px; height: 2px; margin-bottom: 20px; background: #b7653f; }
article span { color: #707a70; font-size: 13px; line-height: 1.8; }
.page > button { width: 100%; margin-top: 18px; padding: 17px 20px; border: 0; border-radius: 10px; background: #345644; color: #fffdf5; text-align: left; }
.page > button b { float: right; }
.page > button:active { opacity: .78; }
footer { padding: 25px 0 5px; color: #92978f; text-align: center; font-size: 10px; }
+10
View File
@@ -0,0 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isProjectIdle } from "../lib/cleanup";
test("marks only projects beyond the configured idle window", () => {
const now = Date.parse("2026-09-17T12:00:00.000Z");
assert.equal(isProjectIdle("2026-09-16T11:59:59.000Z", 24, now), true);
assert.equal(isProjectIdle("2026-09-16T12:00:01.000Z", 24, now), false);
assert.equal(isProjectIdle("not-a-date", 24, now), false);
});
+42
View File
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { validateArtifact } from "../lib/validation";
async function fixture(files: Record<string, string>) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rednote-validator-"));
for (const [name, content] of Object.entries(files)) {
const target = path.join(root, name);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, content);
}
return root;
}
test("accepts a minimal offline artifact", async (t) => {
const root = await fixture({
"index.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="./assets/style.css"></head><body><script src="./assets/main.js"></script></body></html>',
"assets/main.js": 'document.body.addEventListener("click", function () {});',
"assets/style.css": "body { color: #333; }",
});
t.after(() => fs.rm(root, { recursive: true, force: true }));
const report = await validateArtifact(root);
assert.equal(report.status, "passed");
assert.match(report.auditOutput, /PASS/);
});
test("reports forbidden APIs, inline handlers and missing resources", async (t) => {
const root = await fixture({
"index.html": '<!doctype html><html><body><img src="./missing.png"><button onclick="go()">go</button><script src="./main.js"></script></body></html>',
"main.js": 'fetch("/api");',
});
t.after(() => fs.rm(root, { recursive: true, force: true }));
const report = await validateArtifact(root);
const codes = report.issues.map((issue) => issue.code);
assert.equal(report.status, "failed");
assert.ok(codes.includes("NETWORK_FETCH"));
assert.ok(codes.includes("INLINE_EVENT"));
assert.ok(codes.includes("MISSING_RESOURCE"));
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }]
},
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "data", "templates"]
}
+47
View File
@@ -0,0 +1,47 @@
---
name: minitool-zip-builder
description: >-
小红书小工具构建开发指南:把 H5 页面打包成符合容器规范的离线 zip。
新建或改写小工具 / H5 页面、打包小工具 zip、处理端能力限制与容器 CSP 约束时使用。
metadata:
version: "1.6.0"
---
# 小工具 ZIP 构建指南
**小工具是一种基于离线 H5 实现的 app 形式**:你写一套标准网页(以 `index.html` 为入口),打包成 `.zip`,由容器(PC 模拟器 / 真机 WebView)加载运行。它本质就是 Web,HTML/CSS/JS 经验直接适用——只是运行在受控容器里:**纯本地、不联网,所有资源须打包在内**,且部分 Web 能力被收紧。
目标产物:可直接上传的 **`.zip` 静态包**,在 PC 模拟器与真机行为一致。
## 何时使用
- 从零新建小工具页面并打包成 `.zip`
- 将已有纯 H5 页面改写为小工具规范并打包
## 工作流程
每一步**动手前必须先读对应 reference 并严格遵守其全部约束**,不要凭记忆产出:
1. **编写 / 适配 HTML** — 先读 [zip-artifact-spec.md](references/zip-artifact-spec.md):目录结构、`index.html` 模板、路径与资源引用规则,按其编写
2. **端能力合规** — 先读 [device-capabilities.md](references/device-capabilities.md):对照「不可用能力 / 行为」逐项核对,命中项移除或改用其给出的替代写法
3. **Native 能力(JSBridge)** — 需要发笔记、存相册、跳原生页等能力时,先读 [jsbridge-api.md](references/jsbridge-api.md):仅使用文档列出的 `window.xhs.miniTool.*` API,参数与必填项以该文档为准,未列出的字段不要传
4. **JS 兼容性** — 先读 [js-compatibility.md](references/js-compatibility.md):以 Android 8.1 出场 Chrome / WebView 61 为最低基线;直接交付的 JS 可使用 ES2017,更新语法须由已有构建链转译,Web API 须做能力检测
5. **CSS 兼容性** — 先读 [css-compatibility.md](references/css-compatibility.md):采用“Chrome 61 基线层 + 能力检测增强层”;只为实际使用的新能力提供局部回退,不维护两套完整 CSS
6. **跨端适配** — 先读 [cross-platform-h5.md](references/cross-platform-h5.md):触摸、滚动、安全区、PC vs 真机差异
7. **性能设计** — 先读 [performance-budget.md](references/performance-budget.md):控制源码 / 静态数据 / Base64 / 媒体体积;使用 WebGL 时必须控制 GPU 资源,并提供运行时降级
8. **正确性自查** — 静态核对页面能正常运行、无违规能力(被禁 API 无调用 / 残留、脚本加载顺序、引用资源都在 zip 内、改写时未误改业务逻辑),见 [zip-artifact-spec.md](references/zip-artifact-spec.md) 自检清单
9. **审计并打包** — 按 [performance-budget.md](references/performance-budget.md) 的环境分支选择 Node、Python 或人工审计;修复全部错误,逐条核对各 reference 末尾的自检清单后再交付。审计脚本是辅助工具,不得因运行时缺失跳过门禁
> **产出前提**:交付的 zip 必须同时满足 `zip-artifact-spec.md`、`device-capabilities.md`、`js-compatibility.md`、`css-compatibility.md`、`performance-budget.md` 与(若使用 JSBridge)`jsbridge-api.md` 的全部约束。任何约束以 reference 为准。
## Reference
| 文档 | 何时读 |
| --- | --- |
| [zip-artifact-spec.md](references/zip-artifact-spec.md) | 写 HTML / 打包时:目录结构、`index.html` 模板、路径与资源引用规则、打包自检 |
| [device-capabilities.md](references/device-capabilities.md) | 处理端能力时:哪些 Web 能力可用 / 不可用及替代写法、如何实现常见交互(手势、拍照、选图等) |
| [jsbridge-api.md](references/jsbridge-api.md) | 调用 Native 能力时:`window.xhs.miniTool.*` 全量 API、参数约束、示例与常见组合 |
| [js-compatibility.md](references/js-compatibility.md) | 写 JS / 选择构建产物时:Android 8.1 出场 Chrome / WebView 61 最低基线、Web API 检测与局部降级 |
| [css-compatibility.md](references/css-compatibility.md) | 写 CSS / 选择构建产物时:Chrome 61 基线、能力检测、现代 CSS 增强与局部回退 |
| [cross-platform-h5.md](references/cross-platform-h5.md) | 适配多端时:触摸、滚动、安全区、PC 模拟器与真机差异 |
| [performance-budget.md](references/performance-budget.md) | 开发和交付前:包体、静态数据、Base64、媒体、长列表与 WebGL 资源控制和降级 |
@@ -0,0 +1,69 @@
# 跨端 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%`
- 用系统字体栈,避免非必要 `.woff2`
---
## 5. PC 模拟器 vs 真机
| 特性 | PC 模拟器 | 真机 | 建议 |
| --- | --- | --- | --- |
| 触摸 | 鼠标 → touch 模拟 | 原生 touch | 用 pointer events 统一 |
| 安全区 | 注入 `--safe-area-inset-*` 变量模拟 | `env()` 真实值 | 用 `var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` 组合 |
| 软键盘 | 无 | 遮挡输入框 | 监听 `visualViewport` 处理 |
---
## 6. 自检
- [ ] 交互用 pointer / touch events,未依赖鼠标 hover 才能触发的关键操作
- [ ] 布局自适应,无写死像素宽度
- [ ] 安全区用 `var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` 组合,配合 `viewport-fit=cover`
- [ ] 图片自适应且体积受控
@@ -0,0 +1,171 @@
# CSS 兼容性规范
> 小工具 CSS 的最低兼容基线是 **Android 8.1 出场 Chrome / WebView 61**。这不等于只能写旧 CSS:交付物采用“Chrome 61 可用的基线层 + 能力检测后的增强层”,在新内核上使用更合适的 CSS 能力。
## 1. 基线原则
- 基线层保证布局、文字、按钮和核心交互在 Chrome 61 可用;增强层可以使用新 CSS 改善布局、视觉或交互。
- 按功能点提供回退与增强,不维护两套完整页面或两份完整样式表,避免双份样式逐渐漂移。
- 根据特性选择检测方法:能用声明级回退的按层叠覆盖,能准确查询的使用 `@supports`,语法检测无法证明实际布局行为时使用 JS 做最小行为检测。
- 浏览器会静默丢弃无法解析的选择器、声明或整条 at-rule。不能像 JS 一样靠异常发现 CSS 不兼容,必须检查最终产物和实际布局。
- 兼容性以最终 zip 内的 CSS 为准。源码使用了构建工具,不代表产物已经兼容。
## 2. Chrome 61 不可作为唯一实现的能力
以下能力晚于 Chrome 61,不能作为唯一实现。先提供基线路径,再用适合该能力的检测方式启用增强。
| 能力 | 基线写法 / 降级方式 |
| --- | --- |
| Flexbox `gap` / `row-gap` / `column-gap` | 基线用子项单边 `margin`;增强用 JS 实际测量 Flex 布局后切换 class,不能只检测 `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 Queries(`@container`) | 使用 viewport 媒体查询,或由 JS 按容器尺寸切换 class |
| Subgrid | 使用普通 Grid、Flex 或显式轨道尺寸 |
| CSS Nesting、Cascade Layers(`@layer`)、`@property` | 已有构建链能可靠展开时才在源码使用;最终产物不得保留为核心规则 |
| `dvh` / `svh` / `lvh` | 先用 `%` / `100vh`;受软键盘影响的全屏高度用 JS 维护 CSS 变量并保留静态兜底 |
| `color-mix()`、`oklab()`、`oklch()` 等现代颜色 | 先写 `#hex`、`rgb()`、`rgba()` 或 `hsl()` 颜色 |
| `backdrop-filter` | 先给不依赖模糊的实色 / 半透明背景;模糊仅作增强,并同时考虑 `-webkit-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` 形成基线;需要时通过行为检测启用 `gap` 并清除 margin。动态增删子项时确认首尾间距仍正确。
- Grid 只使用基础显式 / 隐式轨道;间距使用 Chrome 61 可解析的 `grid-gap`。不要使用 Subgrid、Masonry 或依赖新语法的自动布局。
- 需要隐藏文本时使用 `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. 基线层与增强层
### 声明回退和 `@supports`
先写 Chrome 61 可用的完整基线,再覆盖增强:
```css
.panel {
background: rgba(255, 255, 255, 0.96);
}
@supports ((-webkit-backdrop-filter: blur(12px)) or (backdrop-filter: blur(12px))) {
.panel {
background: rgba(255, 255, 255, 0.72);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
}
```
同一属性使用“旧值在前、新值在后”的回退顺序:
```css
.page {
min-height: 100vh;
min-height: var(--app-height, 100vh);
}
```
安全区须先保留普通值,再使用容器注入变量与 `env()`:
```css
.bottom-bar {
padding-bottom: 0;
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
}
```
`@supports` 适合检测 `aspect-ratio`、`backdrop-filter`、动态视口单位等能由单个声明准确表达的能力。需要由 JS 切换 class 时,可以使用 `CSS.supports()` 检测同类声明。任何检测路径都必须保留基础样式。
### Flex gap 必须检测布局行为
`CSS.supports('gap', '1px')` 和 `@supports (gap: 1px)` 只能证明浏览器认识该属性和值,不能证明 `gap` 在 Flexbox 中生效;支持 Grid gap、但不支持 Flex gap 的内核也可能通过语法检测。需要启用 Flex gap 时,实际创建一次 Flex 容器并测量:
```js
function supportsFlexGap() {
var flex = document.createElement('div');
flex.style.position = 'absolute';
flex.style.visibility = 'hidden';
flex.style.display = 'flex';
flex.style.flexDirection = 'column';
flex.style.rowGap = '1px';
flex.appendChild(document.createElement('div'));
flex.appendChild(document.createElement('div'));
document.body.appendChild(flex);
var supported = flex.scrollHeight === 1;
flex.parentNode.removeChild(flex);
return supported;
}
if (supportsFlexGap()) {
document.documentElement.classList.add('supports-flex-gap');
}
```
对应 CSS 只维护一套组件规则,其中 margin 是基线,gap 是增强:
```css
.actions {
display: flex;
}
.actions > * + * {
margin-left: 12px;
}
.supports-flex-gap .actions {
column-gap: 12px;
}
.supports-flex-gap .actions > * + * {
margin-left: 0;
}
```
行为检测放在包内经典脚本中,并在 `document.body` 存在后、核心页面渲染前执行。检测一次即可,不要在 resize 或渲染循环中反复测量。
不要用 UA、Android 版本或机型字符串决定样式能力。能力检测决定是否启用增强,Chrome 61 基线层始终保留。
## 5. 有构建链与无构建链
### 直接交付静态文件
没有现成构建链时,不为 CSS 兼容性临时引入 PostCSS、Autoprefixer 或新的 npm 依赖;直接按 Chrome 61 基线编写。
### 项目已有构建链
将目标浏览器至少设置为:
```text
Chrome >= 61
ios_saf >= 18.4
```
- 可使用已有的 Autoprefixer 补必要前缀;若源码使用 nesting 等新语法,须确认已有插件会把它转换成普通 CSS。
- Autoprefixer 只补前缀,不会把 Flex `gap`、`aspect-ratio`、`:has()`、Container Queries 或动态视口单位自动改写成等价的旧布局。
- 压缩器也须使用相同浏览器目标,避免把兼容写法重新合并成 Chrome 61 无法解析的现代语法。
- 交付前检查构建后的 CSS;zip 中只保留最终静态产物,不带 source map 和构建配置。
## 6. 视口、安全区与实测
- 页面宽度使用 `%`、Flex 或基础 Grid,不写死 `375px` 等单一机型宽度。
- `100vh` 在移动端地址栏、容器高度变化和软键盘出现时可能不等于可视高度。关键全屏区域优先使用父容器百分比;必须跟随可视高度时,由 JS 监听尺寸变化并维护 `--app-height`,同时保留 `100vh` 回退。
- 安全区规则须配合 `viewport-fit=cover`,具体组合见 [cross-platform-h5.md](./cross-platform-h5.md)。
- 现代桌面浏览器或 PC 模拟器通过不等于 Chrome 61 通过。能够运行旧内核时,至少检查首屏、滚动区、弹层、表单、横竖屏 / 尺寸变化和核心交互;无法运行时在交付说明中标记“Chrome 61 CSS 兼容性未实测”。
## 7. 交付检查
- [ ] Chrome 61 基线层能够独立完成核心布局和交互,新内核通过能力检测启用增强层
- [ ] 未维护两套完整 CSS;只为实际使用的现代能力提供局部回退与增强
- [ ] Flex gap 使用布局行为检测,或仅使用 margin 基线;未把 `@supports (gap: ...)` / `CSS.supports('gap', ...)` 当作 Flex gap 检测
- [ ] `aspect-ratio`、`clamp()`、逻辑属性、`:has()`、Container Queries、Subgrid、CSS Nesting、动态视口单位等实际使用项均有合适的回退与检测 / 构建转换
- [ ] Flex 子项已检查溢出与滚动,必要处有 `min-width: 0` / `min-height: 0`
- [ ] 关键操作在触摸端可见,不依赖 `:hover`;焦点样式不只依赖 `:focus-visible`
- [ ] 安全区、全屏高度、软键盘、长文本和多行截断均有基线回退
- [ ] 使用构建链时已检查最终 CSS,而不是只检查源码或 Autoprefixer 是否运行
- [ ] 未进行 Chrome 61 / Android 8.1 实际验证时明确标记“CSS 兼容性未实测”
@@ -0,0 +1,169 @@
# 小工具能力清单
> 小工具运行在受限容器中:**纯本地、不联网**,把它当作一个能力受限的浏览器页面。
> **以本文为基线**:命中「不可用」项必须移除或改用替代写法。
## 目录
- §1 可用能力
- §2 Native 能力(JSBridge)
- §3 不可用能力(Web API)
- §4 不可用行为
- §5 WebGL / 图形计算边界
- §6 常见交互怎么实现
- §7 能力扫描清单
---
## 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')`,能力边界见 §5、性能与低端机降级见 [performance-budget.md](./performance-budget.md) §4–5),文本选择不限制。
### 媒体与文件
| 能力 | 用法 | 约束 |
| --- | --- | --- |
| 摄像头 | `navigator.mediaDevices.getUserMedia({ video: true })` | 用户手势触发 + 系统弹窗授权 |
| 麦克风 | `navigator.mediaDevices.getUserMedia({ audio: true })` | 用户手势触发 + 系统弹窗授权 |
| 选择图片 / 拍照 | `<input type="file">` | 系统选择器接管,**仅能选图片和视频**(无论 `accept` 如何设置) |
| 音视频播放 | `<video>` / `<audio>` | 内联播放,媒体文件须打包在内 |
### 数据存储
`localStorage` / `sessionStorage` / `IndexedDB` / `Cookie` / `Cache API` 均可用,按小工具独立隔离,其他小工具与外部无法访问。
Cookie 仅作本地存储:可读写、按 origin 隔离,但因不联网**不会随请求发往服务端**,不能用于登录态 / 鉴权。需要本地存储优先用 `localStorage` / `IndexedDB`。数据不保证永久持久化。
### 交互
`alert()` / `confirm()` 可用,以原生 UI 展示。
---
## 2. Native 能力(JSBridge)
容器会注入 **`window.xhs.miniTool.*`**,通过它调用 Native 能力(发笔记、存相册、跳 App 页等)。
| 规则 | 说明 |
| --- | --- |
| 唯一入口 | 只用 `window.xhs.miniTool.<apiName>(options)`,**禁止**自行 `postMessage` 到 bridge |
| 契约来源 | 以 [`jsbridge-api.md`](./jsbridge-api.md) 为准 |
| 参数校验 | 必填项、长度、数组上限等以 [`jsbridge-api.md`](./jsbridge-api.md) 为准 |
| 本地路径 | `saveImageToPhotosAlbum.filePath` 不支持网络 URL;base64 可先 `writeTempFile` 换 `filePath` |
| data:uri | `writeTempFile.data` 必须是完整 `data:<mime>;base64,...`(`canvas.toDataURL()` 原样传),裸 base64 会失败 |
| 发笔记 | `postNote.mediaInfo` 必填;图片走 `image_resources[].url`,视频走 `video_resources` |
| 跳原生页 | `openRedPage.type` 须命中 Native 规则表白名单,`params` 为语义参数 |
完整 API 列表、字段表与示例 → **[jsbridge-api.md](./jsbridge-api.md)**。
---
## 3. 不可用能力(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`(持久化)、跨域存储访问 | 移除,本地缓存用 `localStorage` / `IndexedDB` |
| 凭据 | `navigator.credentials.get` / `create`(WebAuthn)、`navigator.locks` | 移除 |
| 窗口 | `window.open`(弹新窗口)、`window.prompt` | 单页内 JS 切换视图 DOM;输入用页内 Modal |
移动端 WebView 本身也不支持:支付 `PaymentRequest`、系统通知 / 推送、NFC、MIDI、XR / AR / VR、后台同步 / 下载、PWA 安装、窗口管理、指针 / 键盘锁定。一律移除。
---
## 4. 不可用行为
| 行为 | 说明 | 替代方案 |
| --- | --- | --- |
| 网络请求 | `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 等浏览器插件 | 移除 |
---
## 5. 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。
---
## 6. 常见交互怎么实现
| 需求 | 实现 |
| --- | --- |
| 手势 / 拖拽 / 滑动 | `addEventListener('touchstart'/'touchmove'/'touchend')` 或 Pointer Events(`pointerdown`/`move`/`up`) |
| 拍照 / 录音 | `getUserMedia(...)`,由按钮点击等用户手势触发 + 授权 |
| 选择图片 / 视频 | `<input type="file">` |
| 复制文本 | 展示可选中文本,引导用户长按 / 选中复制 |
| 视觉全屏 | CSS 布局(`100vh` / flex + 隐藏滚动) |
| 页面跳转 | 单页内用 JS 切换视图 DOM |
| 输入弹窗 | 页内 Modal 组件 |
| 保存图片到相册 | `writeTempFile({ data: canvas.toDataURL(...) })`(须完整 data:uri)→ `saveImageToPhotosAlbum({ filePath })`,见 [jsbridge-api.md](./jsbridge-api.md) |
| 发布笔记 | `postNote({ mediaInfo, title?, content? })` |
| 跳转 App 搜索 / 用户页等 | `openRedPage({ type, params? })`,`type` 须在白名单内 |
---
## 7. 能力扫描清单
扫描代码,命中下列模式则**必须删除或改用替代写法**:
```
fetch( / XMLHttpRequest / new WebSocket( / new EventSource( / new RTCPeerConnection(
navigator.geolocation.getCurrentPosition / watchPosition
navigator.clipboard.readText / writeText · document.execCommand('copy')
navigator.bluetooth / navigator.usb / navigator.hid / navigator.serial
navigator.getBattery / navigator.connection / navigator.credentials / navigator.locks
navigator.mediaDevices.enumerateDevices / navigator.mediaDevices.getDisplayMedia
navigator.storage.persist / navigator.serviceWorker.register
new Worker( / new SharedWorker(
new Accelerometer() / new Gyroscope() / new Magnetometer()
DeviceMotionEvent / DeviceOrientationEvent / addEventListener('devicemotion' / 'deviceorientation')
Element.requestFullscreen / webkitRequestFullscreen
eval( / new Function( / WebAssembly.
window.open( / window.prompt(
location.href = / location.assign( (跳转站外 URL)
<form ...> 提交跳转 · <a ... download> · target="_blank"
<iframe> / <object>
<script src="https://..."> / <link href="https://..."> / <img src="https://..."> / CSS url(https://...)
—— 外部资源一律加载不到,须下载后打进 zip 改本地相对引用
```
**允许保留**:
```
navigator.mediaDevices.getUserMedia({ video / audio }) // 用户手势 + 授权
<input type="file"> // 选图片 / 视频
localStorage / sessionStorage / IndexedDB / Cookie / Cache API // 独立隔离
alert() / confirm()
touch / pointer events // 手势交互
标准 DOM / CSS / Canvas 2D / WebGL 渲染
```
@@ -0,0 +1,61 @@
# JavaScript 兼容性规范
> 小工具 JavaScript 的最低兼容基线是 **Android 8.1 出场 Chrome / WebView 61**。最终代码必须能在 Chrome 61 解析和运行;iOS 18.4+ 支持的额外能力只能用于能力检测后的增强路径。Chrome 61 完整支持 ES2017,最终代码以 ES2017 为构建目标。
## 1. 语法基线
最终 zip 中的 JS 须兼容 Chrome 61:
- 直接编写并交付 `.js` 时可使用 ES2017,包括 async / await、`Object.entries` / `Object.values`、`Array.prototype.includes`、`String.prototype.padStart` / `padEnd`。
- ES2018+ 语法须由构建链转译,例如对象 spread、异步迭代、可选链、空值合并、逻辑赋值、class 私有字段、static block、BigInt 字面量和 top-level await;更新的运行时 API 仍须做能力检测或提供必要的局部实现。
- 正则表达式避免依赖 lookbehind、Unicode property escapes 等较新的语法特性。
语法不兼容会在脚本解析阶段直接失败,无法通过运行时 `if` 兜底。
## 2. 有构建链与无构建链
### 直接交付静态三件套
没有现成构建链时,不为兼容性临时引入 Babel、core-js 或新的 npm 依赖;直接按 ES2017 编写。
### 项目已有构建链
可以在源码中使用构建链已支持的新语法。Vite 项目使用以下构建目标:
```js
// vite.config.js
export default {
build: { target: ['es2017', 'chrome61'] },
}
```
最终产物须:
- 转译到 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 不应直接作为唯一实现路径。
- 使用非基础 Web API 前先做能力检测;不可用时隐藏增强功能、使用简单替代实现或给出清晰提示。
- 只补功能实际需要的小型本地 fallback,不引入整套通用 polyfill;所有代码仍须随包离线交付。
- 能力检测基于对象 / 方法是否存在,不按 UA、机型或系统版本字符串分支。
- 不为被容器明确禁止的能力添加 polyfill;能力边界仍以 [device-capabilities.md](./device-capabilities.md) 为准。
## 4. 跨内核行为
- 日期字符串使用明确的 ISO 格式或拆分为年月日构造,不依赖非标准字符串解析。
- 不依赖对象遍历顺序表达业务优先级;需要顺序时使用数组。
- 对 `Intl` 格式化结果、字体度量、滚动和软键盘行为保留布局余量,不把不同内核的细微差异当作固定输出。
- 触摸、滚动和安全区规则见 [cross-platform-h5.md](./cross-platform-h5.md)。
## 5. 交付检查
- [ ] 直接交付的 JS 不超出 ES2017;使用更新语法时已有构建链负责转译
- [ ] 使用 Vite 时构建目标包含 `es2017` 和 `chrome61`,且 zip 中只保留最终静态文件
- [ ] 新 Web API 有能力检测和局部降级,不使用 UA 猜测能力
- [ ] 未把语法转译误认为运行时 API polyfill
- [ ] 未进行实际设备验证时标记“兼容性未实测”,不宣称已在真机通过
+192
View File
@@ -0,0 +1,192 @@
# 小工具 JSBridge API 规范
容器会注入 **`window.xhs.miniTool.*`**,业务代码通过它调用 Native 能力。字段以本文档为准,表中未声明的字段不要传。
## 调用约定
| 项 | 规则 |
| --- | --- |
| 入口 | `window.xhs.miniTool.<apiName>(options)` |
| 两种用法 | 不传回调 → 返回 `Promise`;传 `success` / `fail` / `complete` 任一 → 返回 `undefined`,经回调拿结果 |
| 成功 | Promise resolve / `success(result)`;关注业务字段(见各 API「结果」),无业务字段则结果为空 |
| 失败 | Promise reject / `fail(error)`;失败原因看 `error.errMsg`(形如 `<apiName>:fail …`),可能带 `errCode` |
| `complete` | 成功或失败都会回调,参数为对应的 result / error |
| 禁止 | 不要调用本文未列出的 API,不要传字段表未声明的字段 |
## API 索引
| API | 说明 |
| --- | --- |
| [`postNote`](#postnote) | 发布笔记 |
| [`saveImageToPhotosAlbum`](#saveimagetophotosalbum) | 保存图片到系统相册 |
| [`openRedPage`](#openredpage) | 通用原生页面跳转 |
| [`writeTempFile`](#writetempfile) | base64 转临时文件 |
## postNote
发布笔记。
- **调用**:`window.xhs.miniTool.postNote(options)`
### 使用规则
- `mediaInfo` 必填;`image_resources`(图文)、`video_resources`(视频)、`live_photo_resources`(实况)至少传一种,可同时传。
- 所有地址(`url` / `video_url` / `cover_url`)承载 base64 data:uri 或网络地址,格式由 Native 侧校验。
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | :---: | --- |
| `title` | string | 否 | 标题,最长 20 |
| `content` | string | 否 | 正文,最长 1000 |
| `pageType` | `"video_publish" \| "photo_publish" \| "slides_edit"` | 否 | 页面类型 |
| `mediaInfo` | object | 是 | 见「mediaInfo」 |
| `tags` | string | 否 | 标签 |
**`mediaInfo`**
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | :---: | --- |
| `image_resources` | `{ url }[]` | 否 | 图文,1–18 张;`url` 图片地址 |
| `video_resources` | `{ video_url, cover_url? }` | 否 | 视频;`video_url` 视频地址,`cover_url` 可选封面 |
| `live_photo_resources` | `{ url, video_url }[]` | 否 | 实况,1–18 张;`url` 动图封面,`video_url` 动图视频 |
### 结果
成功结果仅含通用 `errMsg`,无额外业务字段。
### 示例
图文笔记:
```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: "...", cover_url: "..." },
},
});
```
## saveImageToPhotosAlbum
保存图片到系统相册。
- **调用**:`window.xhs.miniTool.saveImageToPhotosAlbum(options)`
### 使用规则
- `filePath` 只接受 base64 data:uri 或本地路径,传网络地址(`http(s)://`)会失败。
- 已有 base64 时可先调 `writeTempFile` 换取本地 `filePath`,再传入本 API。
- 需由用户主动操作触发;首次调用可能弹系统相册权限。
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | :---: | --- |
| `filePath` | string | 是 | 本地图片路径(base64 data:uri 或本地引用),不支持网络地址 |
### 结果
成功结果仅含通用 `errMsg`,无额外业务字段。
### 示例
```js
await window.xhs.miniTool.saveImageToPhotosAlbum({
filePath: "data:image/png;base64,...",
});
```
## openRedPage
通用原生页面跳转。
- **调用**:`window.xhs.miniTool.openRedPage(options)`
### 使用规则
- `type` 命中 Native 规则表白名单才放行,未命中直接失败;规则表由客户端维护。
- `params` 为语义参数,由规则表映射到目标页面;信任字段由客户端强制注入。
- 跳转会离开当前小工具页面,调用前应完成本地状态持久化。
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | :---: | --- |
| `type` | string | 是 | 规则表 key(如 search / note / user…) |
| `params` | object | 否 | 语义参数(如 `{ keyword }`) |
### 结果
成功结果仅含通用 `errMsg`,无额外业务字段。
### 示例
```js
await window.xhs.miniTool.openRedPage({
type: "search",
params: { keyword: "连衣裙" },
});
```
## writeTempFile
base64 转临时文件,返回可传给其他 API 的 `filePath`。
- **调用**:`window.xhs.miniTool.writeTempFile(options)`
### 使用规则
- 用于把内存中的 base64(Canvas 导出、选图预览等)落成本地文件,换取 `filePath`。
- **`data` 必须是完整 data:uri**,即 `data:<mime>;base64,<payload>` 开头(如 `data:image/png;base64,iVBORw0KGgo...`);只传裸 base64 字符串会失败。
- `canvas.toDataURL()` / `FileReader.readAsDataURL()` 的返回值已是 data:uri,**不要**再截取 `,` 之后的部分。
- 返回的 `filePath` 为临时文件,即用即弃。
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | :---: | --- |
| `data` | string | 是 | 完整 data:uri(`data:<mime>;base64,<payload>`),不接受裸 base64 |
### 结果
成功结果在通用 `errMsg` 之外附带:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `filePath` | string | 转换得到的临时文件路径 |
### 示例
```js
// canvas.toDataURL() 直接就是 data:uri,原样传入
const { filePath } = await window.xhs.miniTool.writeTempFile({
data: canvas.toDataURL("image/png"), // "data:image/png;base64,iVBORw0KGgo..."
});
await window.xhs.miniTool.saveImageToPhotosAlbum({ filePath });
```
错误用法:
```js
// ✗ 裸 base64,会失败
await window.xhs.miniTool.writeTempFile({ data: "iVBORw0KGgo..." });
// ✗ 手动去掉了 data:uri 前缀
await window.xhs.miniTool.writeTempFile({
data: canvas.toDataURL("image/png").split(",")[1],
});
```
@@ -0,0 +1,131 @@
# 小工具性能预算与降级规范
> 本文提供移动端 WebView 的设计预算与降级要求。Skill 只能检查静态产物和代码设计,不能从源码推断真实帧率、内存或真机表现;没有运行数据时必须明确标记“未实测”。
## 目录
- §1 交付门禁
- §2 静态数据与长列表
- §3 图片、音频与视频
- §4 WebGL 资源与降级基线
- §5 运行时降级与兜底
- §6 交付检查
---
## 1. 交付门禁
| 项目 | 门禁 | 原因 |
| --- | --- | --- |
| 最终 zip | **不超过 10 MiB**;建议不超过 2 MiB | 10 MiB 是上传上限,不是性能目标 |
| 单个 `.html` / `.css` / `.js` / `.json` | 不设硬上限;超过 2 MiB 触发风险提示 | 大源码会增加读取、解析、编译和峰值内存;即使压缩后很小也一样 |
| 上述文本文件解压后合计 | 不设硬上限;超过 5 MiB 触发风险提示 | 用于发现把数据库或生成产物塞进代码包的情况 |
| 单条 Base64 | 解码后不超过 1 MiB;超过 100 KiB 提示风险,超过 1 MiB 视为大 Base64,必须改成独立包内文件 | Base64 体积约增加 1/3,还会产生字符串与解码副本 |
先检查可用运行时,有哪个就用哪个;两个脚本均为单文件且只使用各自标准库,不要为了审计临时安装 Node、Python 或第三方依赖:
```bash
# 有 Node 18+:脚本零第三方依赖
node <skill目录>/scripts/audit_artifact.mjs ./dist
node <skill目录>/scripts/audit_artifact.mjs ./tool.zip
# 有 Python 3:脚本只用标准库,并可检查 zip 内部文本
python3 <skill目录>/scripts/audit_artifact.py ./dist
python3 <skill目录>/scripts/audit_artifact.py ./tool.zip
```
两个运行时都可用时任选其一;Python 版可额外读取 zip 的文件清单与解压后体积。Node 版对产物目录做完整体积审计,对最终 zip 只检查包体,因此必须在压缩前先审计目录。脚本只依据文件系统和 zip 元数据做确定性检查:zip 超过 10 MiB 报 `ERROR`;zip 超过建议的 2 MiB、单个或合计文本较大报 `WARN`。
脚本不解析或猜测 JS / HTML / CSS 语义。Base64、WebGL 结构与降级策略由本规范约束,并在 §6 按代码逻辑审查。仓库发布检查会把两个脚本复制到无项目依赖的临时目录实际执行;依赖 pip / npm 包或相对模块时发布失败。
如果 Node 与 Python 都不可用,仍须人工完成 §6 清单,并至少:查看最终 zip 文件大小;确认 Base64 资源的生成来源与实际字节数;检查最大的 HTML / CSS / JS / JSON 是否混入大型数据;逐项检查 WebGL 预算与降级。交付说明中标记“未运行自动审计”及人工检查结果,不得直接声称审计通过。不要通过改后缀或运行时解压字符串来隐藏大数据,这只会把开销推迟到运行时。
---
## 2. 静态数据与长列表
`.js` / `.json` 是静态资源,不是数据库。**不要把数万条记录、完整业务库、日志或抓取结果生成成 JS 数组。** JSON 与 JS 分文件只能改善组织,不能消除下载、解析和内存成本。
优先按以下顺序缩减:
1. 只保留完成核心功能必需的字段和记录,删除重复字段、长描述和历史快照。
2. 能预计算的统计、搜索索引和分类结果在构建期生成;不要在首屏对全量数据反复遍历。
3. 大型只读数据集改为摘要、分段样例或让用户通过 `<input type="file">` 按需导入。若完整离线数据不可删且仍超门禁,应明确说明该需求不适合小工具,而不是继续打包。
4. 用户产生或导入的数据写入 IndexedDB;IndexedDB 用于运行期持久化,不用于掩盖巨大的内置种子数据。
渲染列表时:
- 首屏只创建可见项;长列表使用分页或虚拟滚动,不一次性拼接整份 `innerHTML`。
- 搜索输入按交互频率和数据规模采用防抖,避免每次按键都触发完整查询;最终输入应及时执行。数据量较大时可预先建立小型索引,避免反复扫描所有字段。
- 非首屏工作分批执行并主动让出主线程,避免把解析、计算和渲染集中在同一个同步流程中。
- 不在循环中反复读写布局属性;批量生成 DOM 后一次挂载。
---
## 3. 图片、音频与视频
- 小型图片、图标等可以使用 Base64;单条解码后超过 100 KiB 时应优先改为包内文件,超过 1 MiB 不允许内嵌。
- 图片按真机展示尺寸缩放并压缩,优先 WebP;不要为了 300 px 展示区域打包 4K 原图。
- 音视频先裁剪时长,再降低分辨率、帧率和码率。5 分钟视频通常不适合随小工具离线交付;优先改为短片段、封面 + 交互说明,或让用户运行时自行选择本地视频。
- 视频设置 `preload="metadata"` 或 `preload="none"`,提供 `poster`;未进入播放页前不要创建或解码媒体。
- 同时只保留必要的媒体实例;离开页面后暂停播放、清空不再使用的 `src` 并释放对象 URL。
`FileReader.readAsDataURL()` 可用于用户刚选择的小文件预览或 JSBridge 明确要求 data URI 的短暂转换;大文件不要把结果持久写回静态源码,优先用对象 URL 预览,并在不用时 `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 应解决明确的视觉或计算需求,而不是作为普通表单、列表和信息展示的默认技术栈。
---
## 6. 交付检查
- [ ] 已按环境选择 Node / Python 自动审计;若两者均不可用,已记录人工审计结果与“未运行自动审计”
- [ ] 未把大数据集、日志或生成内容写进 JS / JSON,长列表已分页或虚拟化
- [ ] 单条 Base64 解码后不超过 1 MiB;超过 100 KiB 的条目已检查并优先改为独立文件
- [ ] 静态设计上未把大文件解析、全量 DOM 构建和大资源初始化集中在首屏同步流程;实际耗时没有运行数据时标记“未实测”
- [ ] WebGL 默认受 DPR、像素、纹理、draw call 和三角形预算约束
- [ ] WebGL 有动态降档、页面隐藏暂停、context lost 处理和非 WebGL 兜底
- [ ] 已区分静态检查与运行实测:有用户提供的运行数据时记录设备、首屏时间、帧率和降级情况;没有数据时标记“性能未实测”,不宣称真机性能合格
@@ -0,0 +1,206 @@
# 小工具 ZIP 静态包构建规范
> 小工具是基于离线 H5 的 app 形式,**纯本地、不联网**,所有资源须打包在 zip 内。窗口样式、导航栏、下拉刷新等外壳行为由**容器**统一控制,无需在包内声明。
## 目录
- §1 目录结构与打包
- §2 支持的文件类型
- §3 资源加载规则(容器 CSP)
- §4 路径与引用规则
- §5 index.html 模板
- §6 打包前自检
---
## 1. 目录结构与打包
**唯一硬性要求:`index.html` 位于 zip 根目录作为入口。** 其余文件 / 文件夹随你组织 —— 可平铺,也可按需分目录(`assets/`、`images/`、`audios/` 等),用相对路径引用即可。
平铺示例:
```
tool.zip
├── index.html # 必需 — 入口,必须在根目录
├── main.js
├── style.css
└── logo.png
```
分目录示例:
```
tool.zip
├── index.html # 必需 — 入口,必须在根目录
└── assets/
├── style.css
├── main.js
└── images/
└── ...
```
| 路径 | 要求 | 说明 |
| --- | --- | --- |
| `index.html` | **必须在 zip 根目录** | 唯一入口;不可改名、不可放进子目录 |
| 其余文件 / 文件夹 | 自由 | JS / CSS / 图片 / 音频等,平铺或分目录皆可,相对路径引用 |
### 禁止出现在 zip 内
- `node_modules`、`.git`、`.DS_Store`
- `*.map`、构建配置文件(`vite.config.*`、`webpack.config.*` 等)
- 把整个项目多包一层目录,导致 `index.html` 不在根(错误:`app/index.html`;正确:根目录就有 `index.html`)
### 打包方式(关键)
**压缩的是「与 `index.html` 同级的那批文件」本身,不是它们所在的文件夹。** 必须先进入该目录再压缩当前目录内容,否则解压后会多套一层目录、`index.html` 不在根,容器无法加载。
```bash
# ✅ 正确:进入目录,压缩目录“内容”(index.html 直接在 zip 根)
cd dist && zip -r ../tool.zip . -x '*.DS_Store'
# ❌ 错误:压缩目录“本身”,解压后多一层 dist/,index.html 变成 dist/index.html
zip -r tool.zip dist
```
解压后顶层应直接看到 `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 增强;只做功能点级回退,不维护两套完整 CSS,见 [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 一律加载不到,所有资源全部打包进小工具。
---
## 4. 路径与引用规则
| 规则 | 正确 | 错误 |
| --- | --- | --- |
| 资源引用 | `./assets/main.js` | `/assets/main.js`(绝对路径) |
| 入口 | 根目录 `index.html` | `src/index.html` |
| base | 不使用 | `<base href="...">` |
| 外部资源 | 下载后打进 zip 再相对引用 | 任何 `https://...` 在线引用 |
| 单页 | 一个 `index.html`,视图 JS 切换 | 多 HTML 页面站点 |
---
## 5. index.html 模板
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>应用标题</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
-webkit-user-select: none;
user-select: none;
}
</style>
<link rel="stylesheet" href="./assets/style.css" />
</head>
<body>
<!-- 内容 -->
<script src="./assets/main.js"></script>
</body>
</html>
```
| 规则 | 原因 |
| --- | --- |
| viewport 含 `width=device-width, initial-scale=1.0, viewport-fit=cover` | 真机 + 模拟器布局与安全区 |
| 资源全为相对路径 `./assets/...` | 离线 zip 根为 `/` |
| 脚本外置 `<script src>`,用 `addEventListener` 绑事件 | 容器禁止内联脚本与行内事件 |
| 不用 `<base href>` | 破坏真机路径 |
| 不引用任何外部资源(图片 / CSS / JS / 字体) | 外部资源加载不到,须全部打进 zip |
| 不自建 CSP `<meta>` | 安全策略由容器统一管理 |
- `<title>` 仅影响文档标题;导航栏标题由容器 UI 配置。
---
## 6. 打包前自检
### 包结构
- [ ] `index.html` 在 zip 根目录(不在任何子目录里)
- [ ] 解压后顶层直接是文件,**未多套一层目录**(压缩的是目录内容而非目录本身)
- [ ] 仅含支持的文件类型(见 §2),无开发垃圾文件(`node_modules` / `*.map` / 构建配置等)
### index.html 与资源
- [ ] `<!DOCTYPE html>` + `lang="zh-CN"` + `charset=UTF-8`
- [ ] viewport 含 `width=device-width, initial-scale=1.0, viewport-fit=cover`
- [ ] 全部资源为相对路径,无 `http(s)://` 外部引用(图片、第三方库、字体等已打进 zip)
- [ ] 脚本全部外置:无内联 `<script>`、无 `onclick=` 等行内事件、无 `javascript:` / `eval` / `new Function`
- [ ] 脚本为经典脚本:无 `type="module"`,JS 内无 `import` / `export`
- [ ] JS 兼容 [js-compatibility.md](./js-compatibility.md):直接交付代码不超出 ES2017,或已有构建链生成面向 Chrome 61 的 ES2017 产物
- [ ] CSS 兼容 [css-compatibility.md](./css-compatibility.md):Chrome 61 基线可用,现代 CSS 通过合适的能力检测启用,并已检查最终构建产物
- [ ] 图片可用包内文件 / `data:` / `blob:`;音视频、字体仅用包内文件
- [ ] 无 `<base href>`、无 `<iframe>` / `<object>`、无自建 CSP `<meta>`
### 端能力(见 [device-capabilities.md](./device-capabilities.md))
- [ ] 未使用不可用能力(网络请求、定位、剪贴板、传感器、Worker、WebRTC 等)
- [ ] 相机 / 麦克风 / 选图用法符合「用户手势触发 + 授权」
- [ ] 若使用 JSBridge:仅调用 [jsbridge-api.md](./jsbridge-api.md) 列出的 API,参数符合 schema
### 正确性(静态自查)
- [ ] 被禁能力**无调用 / 残留**(按 device-capabilities.md 扫描清单逐项 grep)
- [ ] JS 无语法错误;关键逻辑通读无明显运行时报错(如调用未定义函数、引用 `null` DOM)
- [ ] 多文件 JS 依赖关系 / 加载顺序正确
- [ ] 页面引用的每个资源(脚本 / 样式 / 图片 / 音频)都已打进 zip,且路径正确
- [ ] 改写场景:仅替换被禁能力,未顺手改动其余业务逻辑与 UI
- [ ] 核心交互在代码层面自洽:事件有绑定、依赖的 DOM 存在、回调闭环完整
### 体积
- [ ] 总包(zip)不超过 10MB(上限);为获得更好的加载体验,建议控制在 2MB 以内
- [ ] 单条 Base64 解码后不超过 1MiB;超过 100KiB 时优先改为独立包内文件
- [ ] 单个 HTML / CSS / JS / JSON 超过 2MiB、文本合计超过 5MiB 时已人工检查,确认没有把大型数据库 / 生成内容塞进代码包
- [ ] 已按 [performance-budget.md](./performance-budget.md) 的环境分支审计产物目录与最终 zip;无运行时时已完成人工门禁并明确记录
- [ ] 明显超出建议值时已按 [performance-budget.md](./performance-budget.md) 优化,而不是依赖高压缩率掩盖解压后的大源码 / 大数据
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/** Audit deterministic mini-tool package and text-file size limits. */
import fs from "node:fs";
import path from "node:path";
const MIB = 1024 * 1024;
const ZIP_LIMIT = 10 * MIB;
const ZIP_RECOMMENDED = 2 * MIB;
const TEXT_FILE_WARNING = 2 * MIB;
const TEXT_TOTAL_WARNING = 5 * MIB;
const TEXT_SUFFIXES = new Set([".html", ".css", ".js", ".json"]);
function formatSize(size) {
return `${(size / MIB).toFixed(2)} MiB`;
}
function walkDirectory(root) {
const files = [];
function walk(current) {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
if (entry.name === ".git") continue;
const target = path.join(current, entry.name);
if (entry.isDirectory()) walk(target);
else if (entry.isFile()) files.push(target);
}
}
walk(root);
return files;
}
function auditDirectory(root) {
const errors = [];
const warnings = [];
const files = walkDirectory(root);
let textTotal = 0;
for (const file of files) {
if (!TEXT_SUFFIXES.has(path.extname(file).toLowerCase())) continue;
const size = fs.statSync(file).size;
const name = path.relative(root, file).split(path.sep).join("/");
textTotal += size;
if (size > TEXT_FILE_WARNING) {
warnings.push(`${name}: text file is ${formatSize(size)}; review parse and memory cost`);
}
}
if (textTotal > TEXT_TOTAL_WARNING) {
warnings.push(`uncompressed HTML/CSS/JS/JSON total is ${formatSize(textTotal)}; review for embedded databases or generated content`);
}
return { errors, warnings, count: files.length };
}
function auditZipSize(file) {
const errors = [];
const warnings = [];
const size = fs.statSync(file).size;
if (size > ZIP_LIMIT) errors.push(`${path.basename(file)}: zip is ${formatSize(size)}; hard limit is 10 MiB`);
else if (size > ZIP_RECOMMENDED) warnings.push(`${path.basename(file)}: zip is ${formatSize(size)}; recommended target is 2 MiB`);
return { errors, warnings, count: 1, note: "Node audit checks zip size only; artifact contents must be audited before packaging" };
}
function main() {
const target = process.argv[2];
if (!target) {
console.error("Usage: node audit_artifact.mjs <artifact-directory-or-zip>");
return 2;
}
const resolved = path.resolve(target);
if (!fs.existsSync(resolved)) {
console.error(`ERROR: path not found: ${resolved}`);
return 2;
}
let result;
try {
result = fs.statSync(resolved).isDirectory()
? auditDirectory(resolved)
: auditZipSize(resolved);
} catch (error) {
console.error(`ERROR: cannot inspect ${resolved}: ${error.message}`);
return 2;
}
for (const message of result.errors) console.log(`ERROR: ${message}`);
for (const message of result.warnings) console.log(`WARN: ${message}`);
if (result.note) console.log(`NOTE: ${result.note}`);
if (result.errors.length) {
console.log(`FAILED: ${result.errors.length} error(s), ${result.warnings.length} warning(s)`);
return 1;
}
console.log(`PASS: ${result.count} file(s), ${result.warnings.length} warning(s)`);
return 0;
}
process.exitCode = main();
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Audit deterministic mini-tool package and text-file size limits."""
from __future__ import annotations
import sys
import zipfile
from pathlib import Path, PurePosixPath
MIB = 1024 * 1024
ZIP_LIMIT = 10 * MIB
ZIP_RECOMMENDED = 2 * MIB
TEXT_FILE_WARNING = 2 * MIB
TEXT_TOTAL_WARNING = 5 * MIB
TEXT_SUFFIXES = {".html", ".css", ".js", ".json"}
def format_size(size: int) -> str:
return f"{size / MIB:.2f} MiB"
def audit_entries(entries: list[tuple[str, int]]) -> list[str]:
warnings: list[str] = []
text_total = 0
for name, size in entries:
if PurePosixPath(name).suffix.lower() not in TEXT_SUFFIXES:
continue
text_total += size
if size > TEXT_FILE_WARNING:
warnings.append(
f"{name}: text file is {format_size(size)}; review parse and memory cost"
)
if text_total > TEXT_TOTAL_WARNING:
warnings.append(
f"uncompressed HTML/CSS/JS/JSON total is {format_size(text_total)}; "
"review for embedded databases or generated content"
)
return warnings
def read_directory(path: Path) -> list[tuple[str, int]]:
return [
(file.relative_to(path).as_posix(), file.stat().st_size)
for file in sorted(path.rglob("*"))
if file.is_file() and ".git" not in file.relative_to(path).parts
]
def read_zip(path: Path) -> tuple[list[tuple[str, int]], list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
if path.stat().st_size > ZIP_LIMIT:
errors.append(
f"{path.name}: zip is {format_size(path.stat().st_size)}; hard limit is 10 MiB"
)
elif path.stat().st_size > ZIP_RECOMMENDED:
warnings.append(
f"{path.name}: zip is {format_size(path.stat().st_size)}; recommended target is 2 MiB"
)
with zipfile.ZipFile(path) as archive:
infos = [info for info in archive.infolist() if not info.is_dir()]
entries = [(info.filename, info.file_size) for info in infos]
return entries, errors, warnings
def main() -> int:
if len(sys.argv) != 2:
print("Usage: python3 audit_artifact.py <artifact-directory-or-zip>")
return 2
path = Path(sys.argv[1]).resolve()
if not path.exists():
print(f"ERROR: path not found: {path}")
return 2
errors: list[str] = []
warnings: list[str] = []
try:
if path.is_dir():
entries = read_directory(path)
elif zipfile.is_zipfile(path):
entries, zip_errors, zip_warnings = read_zip(path)
errors.extend(zip_errors)
warnings.extend(zip_warnings)
else:
print(f"ERROR: expected a directory or zip file: {path}")
return 2
except (OSError, zipfile.BadZipFile) as exc:
print(f"ERROR: cannot inspect {path}: {exc}")
return 2
warnings.extend(audit_entries(entries))
for message in errors:
print(f"ERROR: {message}")
for message in warnings:
print(f"WARN: {message}")
if errors:
print(f"FAILED: {len(errors)} error(s), {len(warnings)} warning(s)")
return 1
print(f"PASS: {len(entries)} file(s), {len(warnings)} warning(s)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+5
View File
@@ -0,0 +1,5 @@
{
"name": "minitool-zip-builder",
"version": "1.6.0",
"format": "cursor-skill-zip-v1"
}