feat(manual): add the illustrated Web Blender HTML user manual

A single-page 28-chapter manual generated from manual.json covers the
11 workspaces, all React editors, OPFS save/reopen, explicit local
downloads, import/export routes, preferences/keymap and the capability
matrix. It reuses verified course screenshots and adds 14 fresh
Chromium captures for shell, storage, area, camera, preferences, IO and
troubleshooting chapters.

Manual-driven fixes: Save As now states OPFS-only, GLTF import is
restored, quick toolbar honors its preference, startup workspace is
applied, Properties tabs jump to sections, Outliner fallback search
filters, and the editor-header ⋮ opens the area menu. Help and the
tutorial landing link the new manual; the PDF remains as an archive.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-13 11:58:40 +08:00
parent aa8cc1313e
commit b932db7642
37 changed files with 1996 additions and 16 deletions

View File

@@ -0,0 +1,156 @@
#!/usr/bin/env node
/**
* Capture the screenshots referenced by the Web Blender user manual.
*
* Usage:
* DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome \
* node tools/web/manual/capture-manual.mjs --port 5184 --gpu hardware
*/
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.resolve(HERE, "../../..");
const WEB = path.join(REPO, "web");
const requireFromWeb = createRequire(path.join(WEB, "package.json"));
const { chromium } = requireFromWeb("playwright");
const OUT = path.join(WEB, "app/public/manuals/images");
const STUDIO = path.join(WEB, "app/public/tutorial/pbr/studio.blend");
const GLB = path.join(REPO, "re/猴头.glb");
const REPORT = path.join(REPO, "tests/golden/USER-MANUAL-001/capture-report.json");
const args = process.argv.slice(2);
const flag = (name, fallback) => { const index = args.indexOf(`--${name}`); return index >= 0 && args[index + 1] ? args[index + 1] : fallback; };
const port = Number.parseInt(flag("port", "5184"), 10) || 5184;
const baseUrl = `http://127.0.0.1:${port}`;
const gpuMode = flag("gpu", process.env.GPU_MODE ?? "hardware");
const hardwareArgs = ["--no-sandbox", "--use-gl=angle", "--use-angle=gl", "--ignore-gpu-blocklist", "--enable-gpu-rasterization", "--enable-zero-copy", "--enable-unsafe-webgpu"];
const swiftshaderArgs = ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"];
async function waitForServer() {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
try { const response = await fetch(baseUrl); if (response.status < 500) return; } catch { /* retry */ }
await new Promise((resolve) => setTimeout(resolve, 400));
}
throw new Error(`server unavailable: ${baseUrl}`);
}
const domClick = (locator) => locator.evaluate((element) => element.click());
async function main() {
await waitForServer();
fs.mkdirSync(OUT, { recursive: true });
fs.mkdirSync(path.dirname(REPORT), { recursive: true });
const browser = await chromium.launch({ headless: false, executablePath: process.env.CHROME_PATH ?? "/usr/bin/google-chrome", args: gpuMode === "hardware" ? hardwareArgs : swiftshaderArgs });
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
const images = [];
const failures = [];
const shot = async (name) => {
const file = path.join(OUT, `${name}.jpg`);
await page.screenshot({ path: file, type: "jpeg", quality: 82 });
images.push({ name, file: path.relative(REPO, file) });
console.log(`captured ${name}`);
};
const closeMenu = async () => { await page.keyboard.press("Escape"); await page.waitForTimeout(200); };
try {
await page.goto(`${baseUrl}/?ui=web`, { waitUntil: "domcontentloaded" });
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO);
await page.waitForFunction(() => /Objects 6/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), null, { timeout: 120_000 });
await page.locator('[data-testid="blend-file-input"]').setInputFiles(GLB);
await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), null, { timeout: 120_000 });
await page.waitForTimeout(900);
await domClick(page.locator('button[aria-label="保存项目"]'));
await page.waitForFunction(() => document.querySelector("main.blender-app")?.getAttribute("data-user-action-save-status") === "SUCCEEDED", null, { timeout: 60_000 });
await shot("ch01-first-save");
await page.getByRole("button", { name: "文件", exact: true }).click();
await page.getByRole("menuitem", { name: "最近打开", exact: true }).click();
await page.waitForTimeout(400);
await shot("ch03-recent");
await closeMenu();
await page.getByRole("button", { name: "文件", exact: true }).click();
await page.getByRole("menuitem", { name: "恢复", exact: true }).click();
await page.getByRole("menuitem", { name: "恢复浏览器恢复点", exact: true }).click();
await page.getByRole("dialog", { name: "恢复自动保存", exact: true }).waitFor({ timeout: 30_000 });
await shot("ch05-recovery");
await page.getByRole("dialog", { name: "恢复自动保存", exact: true }).getByRole("button", { name: "取消", exact: true }).click();
await page.waitForTimeout(300);
const properties = page.locator(".properties-area");
await properties.locator(".area-type-switch .area-menu button").evaluate((element) => element.click());
await page.getByRole("menu").last().waitFor({ timeout: 10_000 });
await shot("ch06-area-menu");
await page.getByRole("menuitem", { name: "垂直分割(左右)", exact: true }).click();
await page.waitForTimeout(500);
await shot("ch06-area-split");
await page.locator(".workspace-grid > .area-frame .area-type-switch .area-menu button").first().evaluate((element) => element.click());
await page.getByRole("menuitem", { name: "重置工作区布局", exact: true }).click();
await page.waitForTimeout(400);
const canvas = page.locator(".viewport-area canvas.viewport-canvas").first();
await canvas.focus();
await page.getByRole("button", { name: "相机视图", exact: true }).evaluate((element) => element.click());
await page.waitForTimeout(700);
await shot("ch07-camera-view");
await page.getByRole("button", { name: "相机视图", exact: true }).evaluate((element) => element.click());
await page.waitForTimeout(300);
const row = page.locator(".tree-row.child, [role=treeitem]").first();
if (await row.count()) await row.evaluate((element) => element.click());
await page.waitForTimeout(400);
await shot("ch08-outliner");
await properties.locator('button[title="修改器属性"]').evaluate((element) => element.click());
await page.waitForTimeout(400);
await shot("ch09-properties-tabs");
await page.getByText("Physics", { exact: true }).first().scrollIntoViewIfNeeded().catch(() => undefined);
await page.waitForTimeout(400);
await shot("ch21-physics");
await page.getByRole("button", { name: "文件", exact: true }).click();
await page.getByRole("menuitem", { name: "导入", exact: true }).click();
await page.waitForTimeout(300);
await shot("ch25-import-menu");
await closeMenu();
await page.getByRole("button", { name: "文件", exact: true }).click();
await page.getByRole("menuitem", { name: "导出", exact: true }).click();
await page.waitForTimeout(300);
await shot("ch25-export-menu");
await closeMenu();
await page.getByRole("button", { name: "编辑", exact: true }).click();
await page.getByRole("menuitem", { name: "偏好设置", exact: true }).click();
await page.getByTestId("preferences-window").waitFor({ timeout: 30_000 });
await shot("ch27-preferences");
await page.getByRole("button", { name: "键位", exact: true }).click();
await page.waitForTimeout(300);
await shot("ch27-keymap");
await page.getByRole("button", { name: "关闭偏好设置", exact: true }).click();
await page.getByRole("button", { name: "帮助", exact: true }).click();
await page.waitForTimeout(300);
await shot("ch28-help");
await closeMenu();
}
catch (error) {
failures.push(error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error));
await page.screenshot({ path: path.join(OUT, "capture-failure.jpg"), type: "jpeg", quality: 82 }).catch(() => undefined);
}
finally {
await browser.close().catch(() => undefined);
const report = { schemaVersion: 1, status: failures.length ? "FAIL" : "PASS", generatedAt: new Date().toISOString(), gpuMode, images, failures };
fs.writeFileSync(REPORT, JSON.stringify(report, null, 2) + "\n", "utf8");
console.log(JSON.stringify({ status: report.status, images: images.length, failures: failures.length, report: REPORT }, null, 2));
if (failures.length) process.exitCode = 1;
}
}
main().catch((error) => { console.error(error); process.exit(1); });

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env node
/**
* Validate the user manual source and generated single-page HTML.
*
* Usage:
* node tools/web/manual/check-manual.mjs
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.resolve(HERE, "../../..");
const SOURCE = path.join(REPO, "user-manual/WEB_Blender_Web_Manual_20261113/manual.json");
const MATRIX = path.join(HERE, "feature-matrix.json");
const OUT = path.join(REPO, "web/app/public/manuals");
const PUBLIC = path.join(REPO, "web/app/public");
const REPORT = path.join(REPO, "tests/golden/USER-MANUAL-001/check-report.json");
const errors = [];
const manual = JSON.parse(fs.readFileSync(SOURCE, "utf8"));
const matrix = JSON.parse(fs.readFileSync(MATRIX, "utf8"));
const htmlPath = path.join(OUT, "index.html");
const html = fs.existsSync(htmlPath) ? fs.readFileSync(htmlPath, "utf8") : "";
if (!html) errors.push("missing manuals/index.html");
if (manual.chapters.length < 25 || manual.chapters.length > 32) errors.push(`chapter count ${manual.chapters.length} outside 25-32`);
if (new Set(manual.chapters.map((chapter) => chapter.id)).size !== manual.chapters.length) errors.push("duplicate chapter ids");
const chapterIds = new Set(manual.chapters.map((chapter) => chapter.id));
for (const chapter of manual.chapters) {
if (!chapter.title || !chapter.summary) errors.push(`${chapter.id} missing title/summary`);
if (!Array.isArray(chapter.steps) || chapter.steps.length < 3) errors.push(`${chapter.id} needs at least 3 steps`);
if (!chapter.steps.some((step) => step.image)) errors.push(`${chapter.id} has no screenshot`);
for (const prerequisite of chapter.prerequisites ?? []) if (!chapterIds.has(prerequisite)) errors.push(`${chapter.id} prerequisite ${prerequisite} missing`);
for (const step of chapter.steps) {
if (!step.title || !step.text) errors.push(`${chapter.id} step missing title/text`);
if (!step.image) continue;
const file = step.image.startsWith("/") ? path.join(PUBLIC, step.image.slice(1)) : path.join(OUT, step.image);
if (!fs.existsSync(file)) errors.push(`${chapter.id} missing image ${step.image}`);
}
}
const workspaces = ["Layout", "Modeling", "Sculpting", "UV Editing", "Texture Paint", "Shading", "Animation", "Rendering", "Compositing", "Geometry Nodes", "Scripting"];
for (const workspace of workspaces) {
if (!manual.chapters.some((chapter) => (chapter.workspaces ?? []).includes(workspace))) errors.push(`workspace not covered: ${workspace}`);
}
const editorKeywords = ["3D Viewport", "Outliner", "Properties", "Timeline", "Dope Sheet", "Graph Editor", "Image Editor", "Shader Editor", "Compositor", "Geometry Nodes", "Spreadsheet", "Text Editor", "Python Console", "Info"];
const corpus = JSON.stringify(manual);
for (const keyword of editorKeywords) if (!corpus.includes(keyword)) errors.push(`editor not covered: ${keyword}`);
for (const part of manual.parts ?? []) {
for (const id of part.chapterIds) if (!chapterIds.has(id)) errors.push(`part ${part.id} references missing chapter ${id}`);
}
if (!manual.storagePolicy?.rules?.some((rule) => rule.includes("OPFS"))) errors.push("storage policy does not mention OPFS");
if (!manual.storagePolicy?.rules?.some((rule) => rule.includes("下载"))) errors.push("storage policy does not mention manual download");
const stale = [/同时下载 \.blend 文件/];
for (const pattern of stale) if (pattern.test(corpus)) errors.push(`stale save/download claim: ${pattern}`);
if (!manual.gaps?.length) errors.push("manual gap register is empty");
for (const gap of manual.gaps ?? []) {
if (gap.status === "FIXED") {
for (const file of gap.files ?? []) if (!fs.existsSync(path.join(REPO, file))) errors.push(`gap ${gap.id} missing source ${file}`);
for (const test of gap.tests ?? []) if (!fs.existsSync(path.join(REPO, test))) errors.push(`gap ${gap.id} missing test ${test}`);
}
}
const validStatuses = new Set(Object.keys(matrix.legend));
for (const capability of matrix.capabilities ?? []) {
if (!validStatuses.has(capability.status)) errors.push(`invalid matrix status ${capability.status}`);
if (capability.chapter && !chapterIds.has(capability.chapter)) errors.push(`matrix chapter missing ${capability.chapter}`);
}
const areas = new Set((matrix.capabilities ?? []).map((capability) => capability.area));
for (const area of ["Layout", "Modeling", "Sculpting", "UV Editing", "Texture Paint", "Shading", "Animation", "Rendering", "Compositing", "Geometry Nodes", "Scripting", "Files", "IO", "Settings"]) if (!areas.has(area)) errors.push(`matrix area missing ${area}`);
if (html) {
const chapters = (html.match(/class="manual-chapter"/g) ?? []).length;
if (chapters !== manual.chapters.length) errors.push(`html chapters ${chapters} != ${manual.chapters.length}`);
if (!/<section class="manual-appendix" id="appendix-support"/.test(html)) errors.push("missing support matrix appendix");
if (/<style\b/i.test(html)) errors.push("inline style block");
if (/<script(?![^>]*\bsrc=)[^>]*>/i.test(html)) errors.push("inline script block");
if (/\son[a-z]+\s*=/i.test(html)) errors.push("inline event handler");
if (/https?:\/\//i.test(html)) errors.push("remote URL in manual");
if (!/<script src="\.\/manual\.js" defer><\/script>/.test(html)) errors.push("missing external manual.js");
if (!/<link rel="stylesheet" href="\.\/manual\.css"/.test(html)) errors.push("missing external manual.css");
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
if (new Set(ids).size !== ids.length) errors.push("duplicate HTML ids");
for (const match of html.matchAll(/href="#([^"]+)"/g)) if (!ids.includes(match[1])) errors.push(`broken anchor #${match[1]}`);
const figures = (html.match(/<figure>/g) ?? []).length;
if (figures < manual.chapters.length) errors.push(`figures ${figures} < chapters`);
for (const match of html.matchAll(/<img[^>]+src="([^"]+)"[^>]*>/g)) {
if (!/\balt="[^"]+"/.test(match[0])) errors.push(`img without alt: ${match[1]}`);
}
}
if (!fs.existsSync(path.join(OUT, "manual.css"))) errors.push("missing manual.css");
if (!fs.existsSync(path.join(OUT, "manual.js"))) errors.push("missing manual.js");
if (!fs.existsSync(path.join(OUT, "manual-search.json"))) errors.push("missing manual-search.json");
const result = { schemaVersion: 1, status: errors.length === 0 ? "PASS" : "FAIL", chapters: manual.chapters.length, parts: manual.parts?.length ?? 0, capabilities: matrix.capabilities?.length ?? 0, gaps: manual.gaps?.length ?? 0, errors };
fs.mkdirSync(path.dirname(REPORT), { recursive: true });
fs.writeFileSync(REPORT, JSON.stringify(result, null, 2) + "\n", "utf8");
console.log(JSON.stringify(result, null, 2));
if (errors.length) process.exitCode = 1;

View File

@@ -0,0 +1,44 @@
{
"schemaVersion": 1,
"lastVerified": "2026-11-13",
"sourceCommit": "aa8cc1313",
"legend": {
"usable": "可在当前 Web UI 完成",
"partial": "可用但有明确范围/限制",
"native-only": "需要原生桌面/服务器会话",
"unsupported": "当前 Web 版不支持"
},
"capabilities": [
{ "area": "Layout", "feature": "3D 视口导航/选择/变换/相机视图", "status": "usable", "chapter": "ch07" },
{ "area": "Layout", "feature": "Outliner 选择/重命名/拖放/可见性", "status": "usable", "chapter": "ch08" },
{ "area": "Layout", "feature": "Properties 对象/修改器/材质/场景", "status": "usable", "chapter": "ch09" },
{ "area": "Layout", "feature": "Timeline 跳帧/关键帧", "status": "usable", "chapter": "ch19" },
{ "area": "Modeling", "feature": "网格编辑/快捷拓扑操作", "status": "partial", "note": "无完整建模工具箱/框选", "chapter": "ch11" },
{ "area": "Sculpting", "feature": "Draw/Inflate/Grab/Smooth、遮罩/面组/Remesh 入口", "status": "partial", "note": "笔刷从属性应用,非连续雕刻", "chapter": "ch11" },
{ "area": "UV Editing", "feature": "选择/变换/展开/打包/缝合", "status": "partial", "note": "需要可写原生 UV 区域", "chapter": "ch14" },
{ "area": "Texture Paint", "feature": "2D packed 图像绘制/撤销/重做/Make Local", "status": "partial", "note": "不支持 UDIM/投影/4K 生产级", "chapter": "ch17" },
{ "area": "Shading", "feature": "材质/Principled/14 类 Shader 节点/预览", "status": "partial", "note": "仅 allowlist 节点", "chapter": "ch15" },
{ "area": "Shading", "feature": "PBR/RE 资产库/Append/Link/packed 图", "status": "usable", "chapter": "ch16" },
{ "area": "Animation", "feature": "Timeline/Dope Sheet/关键帧/Action", "status": "usable", "chapter": "ch19" },
{ "area": "Animation", "feature": "Graph Editor/NLA/驱动器", "status": "partial", "note": "Graph 简化 SVG;NLA 表单", "chapter": "ch20" },
{ "area": "Animation", "feature": "Web 播放/音频", "status": "partial", "note": "本地帧步进,非原生求值", "chapter": "ch19" },
{ "area": "Rendering", "feature": "PBR 渲染/诊断视图/PNG 手动下载", "status": "usable", "chapter": "ch23" },
{ "area": "Rendering", "feature": "原生 EEVEE/Cycles 渲染", "status": "native-only", "note": "需要原生/服务器后端", "chapter": "ch23" },
{ "area": "Compositing", "feature": "9 类节点/连线/执行/Viewer/提交", "status": "partial", "note": "节点目录受限;资源/属性有限制", "chapter": "ch24" },
{ "area": "Geometry Nodes", "feature": "16 类 allowlist 节点/连线/提交", "status": "partial", "note": "无 Simulation Zone/任意节点", "chapter": "ch18" },
{ "area": "Geometry Nodes", "feature": "Spreadsheet 对象/顶点/边/面/UV/属性/CSV", "status": "partial", "note": "只读、250k 行上限", "chapter": "ch18" },
{ "area": "Scripting", "feature": "Text Editor/Python Console/Info", "status": "partial", "note": "Web 控制台依赖原生会话", "chapter": "ch26" },
{ "area": "Files", "feature": "新建/打开/最近项目/恢复/撤销", "status": "usable", "chapter": "ch03" },
{ "area": "Files", "feature": "保存到 OPFS/下载 .blend/另存为/副本", "status": "usable", "chapter": "ch04" },
{ "area": "Files", "feature": "自动恢复点/诊断报告", "status": "usable", "chapter": "ch05" },
{ "area": "IO", "feature": "GLTF/GLB/OBJ/STL/PLY 导入", "status": "partial", "note": "GLTF 仅嵌入资源;OBJ 不含 MTL 文件夹流程", "chapter": "ch25" },
{ "area": "IO", "feature": "浏览器 GLB/OBJ 导出", "status": "partial", "note": "GLB 本地;OBJ 本地/原生", "chapter": "ch25" },
{ "area": "IO", "feature": "USD/Alembic/FBX 导出", "status": "native-only", "note": "运行时 receipt 可能未注册", "chapter": "ch25" },
{ "area": "IO", "feature": "图像/VFont/URDF 导入", "status": "usable", "chapter": "ch25" },
{ "area": "Settings", "feature": "Preferences/Keymap/区域布局/相机锁定持久化", "status": "usable", "chapter": "ch27" },
{ "area": "Settings", "feature": "主题/系统/Add-ons/完整键位", "status": "unsupported", "chapter": "ch27" },
{ "area": "Simulation", "feature": "物理参数/缓存入口", "status": "partial", "note": "本地播放/求解未接通", "chapter": "ch21" },
{ "area": "Sequencer", "feature": "Strip 表单/原生媒体传输", "status": "partial", "note": "Web 最终导出依赖服务器;无本地媒体选择器", "chapter": "ch25" },
{ "area": "Camera", "feature": "Numpad0/Camera to View/锁定持久化", "status": "partial", "note": "无 object-as-camera/自动关键帧/约束分支", "chapter": "ch07" }
]
}

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env node
/**
* Generate the single-page Web Blender user manual.
*
* Usage:
* node tools/web/manual/generate-manual.mjs
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.resolve(HERE, "../../..");
const SOURCE = path.join(REPO, "user-manual/WEB_Blender_Web_Manual_20261113/manual.json");
const MATRIX = path.join(HERE, "feature-matrix.json");
const PUBLIC = path.join(REPO, "web/app/public");
const OUT = path.join(PUBLIC, "manuals");
const escapeHtml = (value) => String(value)
.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
.replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const manual = JSON.parse(fs.readFileSync(SOURCE, "utf8"));
const matrix = JSON.parse(fs.readFileSync(MATRIX, "utf8"));
const missing = [];
const existing = new Set();
function resolveImage(src) {
const file = src.startsWith("/") ? path.join(PUBLIC, src.slice(1)) : path.join(OUT, src);
if (!fs.existsSync(file)) missing.push(src);
return file;
}
function imageHtml(src, alt, caption) {
if (!src) return "";
resolveImage(src);
return `<figure><img src="${escapeHtml(src)}" alt="${escapeHtml(alt ?? caption ?? "")}" loading="lazy" width="1600" height="900" /><figcaption>${escapeHtml(caption ?? alt ?? "")}</figcaption></figure>`;
}
const chapterById = new Map(manual.chapters.map((chapter) => [chapter.id, chapter]));
const navParts = manual.parts.map((part) => `<div class="manual-nav-group"><strong>${escapeHtml(part.title)}</strong>${part.chapterIds.map((id) => {
const chapter = chapterById.get(id);
return chapter ? `<a href="#${escapeHtml(id)}" data-nav-id="${escapeHtml(id)}"><span>${chapter.number}</span>${escapeHtml(chapter.title)}</a>` : "";
}).join("")}</div>`).join("");
let figure = 0;
const chaptersHtml = manual.chapters.map((chapter) => {
const steps = chapter.steps.map((step, index) => {
const fig = step.image ? imageHtml(step.image, step.alt, `图 ${chapter.number}.${index + 1} ${step.title}`) : "";
const callout = step.callout ? `<p class="manual-callout ${escapeHtml(step.callout.kind)}">${escapeHtml(step.callout.text)}</p>` : "";
return `<li id="${escapeHtml(chapter.id)}-s${index + 1}"><h3>${index + 1}. ${escapeHtml(step.title)}</h3><p>${escapeHtml(step.text)}</p>${callout}${fig}</li>`;
}).join("");
const objectives = `<ul>${(chapter.objectives ?? []).map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>`;
const prereq = (chapter.prerequisites ?? []).length ? `<p class="manual-meta">前置:${chapter.prerequisites.map((id) => {
const target = chapterById.get(id);
return target ? `<a href="#${escapeHtml(id)}">第 ${target.number} 章</a>` : escapeHtml(id);
}).join("、")}</p>` : "";
const checks = (chapter.checks ?? []).length ? `<div class="manual-block"><h3>完成检查</h3><ul>${chapter.checks.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul></div>` : "";
const tips = (chapter.tips ?? []).length ? `<div class="manual-block"><h3>提示</h3><ul>${chapter.tips.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul></div>` : "";
const limits = (chapter.limits ?? []).filter(Boolean).length ? `<div class="manual-block manual-limit"><h3>限制 / 不支持</h3><ul>${chapter.limits.filter(Boolean).map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul></div>` : "";
const links = (chapter.links ?? []).length ? `<div class="manual-block"><h3>相关链接</h3><ul>${chapter.links.map((link) => `<li><a href="${escapeHtml(link.href)}">${escapeHtml(link.label)}</a></li>`).join("")}</ul></div>` : "";
const searchText = [chapter.title, chapter.summary, ...(chapter.objectives ?? []), ...chapter.steps.map((step) => `${step.title} ${step.text}`), ...(chapter.tips ?? []), ...(chapter.limits ?? [])].join(" ").toLowerCase();
return `<article class="manual-chapter" id="${escapeHtml(chapter.id)}" data-chapter-id="${escapeHtml(chapter.id)}" data-search="${escapeHtml(searchText)}">
<header><p class="manual-chapter-kicker">第 ${chapter.number} 章 · ${escapeHtml(chapter.level)} · ${escapeHtml(chapter.workspace)}</p><h2>${escapeHtml(chapter.title)}</h2><p class="manual-summary">${escapeHtml(chapter.summary)}</p>${prereq}</header>
<div class="manual-block"><h3>本章目标</h3>${objectives}</div>
<ol class="manual-steps">${steps}</ol>
${checks}${tips}${limits}${links}
<nav class="manual-chapter-nav">${chapter.number > 1 ? `<a href="#${escapeHtml(manual.chapters[chapter.number - 2].id)}">← 上一章</a>` : "<span />"}${chapter.number < manual.chapters.length ? `<a href="#${escapeHtml(manual.chapters[chapter.number].id)}">下一章 →</a>` : "<span />"}</nav>
</article>`;
}).join("\n");
const supportRows = matrix.capabilities.map((item) => `<tr><td>${escapeHtml(item.area)}</td><td>${escapeHtml(item.feature)}</td><td><span class="status ${escapeHtml(item.status)}">${escapeHtml(matrix.legend[item.status] ?? item.status)}</span></td><td>${escapeHtml(item.note ?? "")}</td><td>${item.chapter ? `<a href="#${escapeHtml(item.chapter)}">${escapeHtml(item.chapter)}</a>` : ""}</td></tr>`).join("");
const support = `<section class="manual-appendix" id="appendix-support"><h2>附录 B:能力支持矩阵</h2><p>${escapeHtml(matrix.legend.usable)};${escapeHtml(matrix.legend.partial)};${escapeHtml(matrix.legend["native-only"])};${escapeHtml(matrix.legend.unsupported)}。</p><div class="manual-table-wrap"><table><thead><tr><th>区域</th><th>功能</th><th>状态</th><th>说明</th><th>章节</th></tr></thead><tbody>${supportRows}</tbody></table></div></section>`;
const appendixHtml = manual.appendices.map((appendix) => `<section class="manual-appendix" id="${escapeHtml(appendix.id)}"><h2>${escapeHtml(appendix.title)}</h2><div class="manual-table-wrap"><table><tbody>${appendix.rows.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}</tr>`).join("")}</tbody></table></div></section>`).join("\n");
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(manual.title)}</title>
<link rel="stylesheet" href="./manual.css" />
<script src="./manual.js" defer></script>
</head>
<body>
<header class="manual-topbar">
<button type="button" class="manual-menu-toggle" aria-label="打开目录">☰</button>
<a class="manual-brand" href="/">Web Blender 用户手册</a>
<label class="manual-search"><span>搜索</span><input type="search" aria-label="搜索手册" placeholder="搜索功能、按钮、限制…" /></label>
<output class="manual-search-status" role="status"></output>
<nav><a href="/?ui=web">打开 Web Blender</a><a href="/tutorial/full/index.html">全站入门课程</a><a href="/manuals/WEB_Blender_Function_User_Guide.pdf">PDF 旧版</a></nav>
</header>
<div class="manual-layout">
<aside class="manual-sidebar" aria-label="手册目录">
<nav class="manual-nav">${navParts}<div class="manual-nav-group"><strong>附录</strong><a href="#appendix-support" data-nav-id="appendix-support"><span>B</span>能力支持矩阵</a>${manual.appendices.map((appendix) => `<a href="#${escapeHtml(appendix.id)}" data-nav-id="${escapeHtml(appendix.id)}"><span>A</span>${escapeHtml(appendix.title.replace(/^附录 [A-Z]:/, ""))}</a>`).join("")}</div></nav>
</aside>
<main class="manual-main">
<section class="manual-hero">
<p class="manual-kicker">UM-${escapeHtml(manual.version)} · ${escapeHtml(manual.lastVerified)} · ${escapeHtml(manual.desktopVersion)}</p>
<h1>${escapeHtml(manual.title)}</h1>
<p>${escapeHtml(manual.intro)}</p>
<div class="manual-policy"><strong>存储规则</strong><ul>${manual.storagePolicy.rules.map((rule) => `<li>${escapeHtml(rule)}</li>`).join("")}</ul></div>
</section>
${chaptersHtml}
${support}
${appendixHtml}
<footer class="manual-footer">手册版本 ${escapeHtml(manual.version)} · 验证日期 ${escapeHtml(manual.lastVerified)} · 源码 ${escapeHtml(manual.sourceCommit)} · 不宣称完整桌面 Blender parity。</footer>
</main>
</div>
</body>
</html>
`;
const css = `:root{color-scheme:dark;--bg:#191b1a;--panel:#232624;--panel-2:#2b2f2c;--line:#414743;--text:#e7eae7;--muted:#a8b0a9;--accent:#79a98a;--accent-2:#d6b36a;--danger:#c9836f}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.75 "Noto Sans SC",system-ui,-apple-system,"Segoe UI",sans-serif}a{color:#9bc7a9}
.manual-topbar{position:sticky;top:0;z-index:30;display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:8px 14px;background:#202321f2;border-bottom:1px solid var(--line);backdrop-filter:blur(6px)}
.manual-brand{font-weight:700;color:#eef3ee;text-decoration:none}.manual-menu-toggle{display:none;width:30px;height:28px;background:#343a35;color:#eee;border-radius:4px}
.manual-search{display:flex;align-items:center;gap:5px;flex:1 1 240px;max-width:520px;color:var(--muted);font-size:12px}.manual-search input{flex:1;min-width:0;padding:5px 8px;color:var(--text);background:#151716;border:1px solid #4d554f;border-radius:4px}
.manual-search-status{color:var(--accent-2);font-size:12px}.manual-topbar nav{display:flex;gap:12px;margin-left:auto;font-size:12px}.manual-topbar nav a{color:var(--muted);text-decoration:none}
.manual-layout{display:grid;grid-template-columns:300px minmax(0,1fr);align-items:start}.manual-sidebar{position:sticky;top:49px;max-height:calc(100vh - 49px);overflow:auto;padding:14px 10px;border-right:1px solid var(--line);background:#1e211f}
.manual-nav-group{margin-bottom:14px}.manual-nav-group>strong{display:block;margin:0 0 4px 7px;color:var(--accent-2);font-size:11px;letter-spacing:.08em}.manual-nav a{display:flex;gap:7px;padding:5px 8px;border-radius:4px;color:#cbd2cc;text-decoration:none;font-size:12px;line-height:1.35}.manual-nav a:hover{background:#2b302c}.manual-nav a span{flex:none;color:#8b9b8f;font-family:monospace}.manual-nav a[hidden]{display:none}.manual-nav a.active{background:#355343;color:#eaf3ec}
.manual-main{min-width:0;padding:24px clamp(16px,4vw,52px) 70px;overflow-wrap:anywhere}.manual-main p,.manual-main li{overflow-wrap:anywhere;word-break:break-word}.manual-hero{max-width:1050px;margin:0 auto 26px;padding:26px clamp(16px,3vw,30px);background:linear-gradient(135deg,#24302a,#242625 65%,#2f2a20);border:1px solid var(--line);border-radius:8px}.manual-kicker{color:var(--accent-2);font-size:12px;letter-spacing:.06em}.manual-hero h1{margin:6px 0 10px;font-size:clamp(28px,4vw,44px)}.manual-policy{margin-top:14px;padding:10px 14px;border-left:3px solid var(--accent-2);background:#292a25;font-size:13px}.manual-policy strong{color:#e6d1a4}.manual-policy ul{margin:4px 0 0;padding-left:18px}
.manual-chapter,.manual-appendix{max-width:1050px;margin:0 auto 28px;padding:22px clamp(16px,3vw,30px);background:var(--panel);border:1px solid var(--line);border-radius:8px}.manual-chapter h2,.manual-appendix h2{margin:4px 0 8px;line-height:1.3}.manual-chapter-kicker{margin:0;color:var(--accent-2);font-size:11px;letter-spacing:.06em}.manual-summary{color:#cdd4ce}.manual-meta{color:var(--muted);font-size:12px}
.manual-block{margin:16px 0}.manual-block h3{margin:0 0 5px;font-size:14px;color:#cfe0d4}.manual-block ul{margin:0;padding-left:20px}.manual-limit{border-left:3px solid var(--danger);padding-left:12px}
.manual-steps{margin:18px 0 0;padding:0;list-style:none;counter-reset:step}.manual-steps>li{position:relative;margin:0 0 24px;padding:0 0 0 38px}.manual-steps>li::before{counter-increment:step;content:counter(step);position:absolute;left:0;top:0;display:grid;place-items:center;width:26px;height:26px;border-radius:50%;background:#355343;color:#e8f2ec;font-size:12px;font-weight:700}.manual-steps h3{margin:2px 0 6px;font-size:14px;color:#e6ece7}.manual-steps p{margin:0 0 8px}
figure{margin:10px 0 0}.manual-steps img{display:block;width:100%;height:auto;border:1px solid var(--line);border-radius:6px;background:#111}figcaption{margin-top:5px;color:#8f9a92;font-size:11px}
.manual-callout{margin:8px 0;padding:8px 11px;background:#2c302a;border-left:3px solid var(--accent);border-radius:3px}.manual-callout.limit{border-left-color:var(--danger)}.manual-callout.warning{border-left-color:var(--accent-2)}
.manual-chapter-nav{display:flex;justify-content:space-between;margin-top:20px;padding-top:10px;border-top:1px solid var(--line);font-size:12px}.manual-chapter-nav a{color:var(--accent);text-decoration:none}
.manual-table-wrap{width:100%;max-width:100%;overflow-x:auto}.manual-table-wrap table{width:100%;table-layout:fixed;border-collapse:collapse;font-size:12px}.manual-table-wrap td,.manual-table-wrap th{padding:6px 8px;text-align:left;border:1px solid #454c47;vertical-align:top;overflow-wrap:anywhere;word-break:break-word}.manual-table-wrap th{background:#313732}.status{padding:1px 6px;border-radius:8px;font-size:10px;white-space:nowrap}.status.usable{background:#2f513d;color:#d8f0de}.status.partial{background:#514a2d;color:#f1e1ae}.status.native-only{background:#3b4554;color:#d7e3f5}.status.unsupported{background:#4c3331;color:#f1d4ce}
.manual-footer{max-width:1050px;margin:20px auto 0;padding-top:14px;border-top:1px solid var(--line);color:#8f9a92;font-size:11px}
@media(max-width:900px){.manual-topbar nav{display:none}.manual-menu-toggle{display:block}.manual-layout{grid-template-columns:1fr}.manual-sidebar{position:fixed;left:0;top:49px;bottom:0;z-index:25;width:280px;transform:translateX(-100%);transition:transform .15s}.manual-sidebar.open{transform:none;box-shadow:20px 0 40px #0008}.manual-main{padding-top:14px}}
@media print{.manual-topbar,.manual-sidebar,.manual-menu-toggle,.manual-chapter-nav,.manual-search-status{display:none!important}.manual-layout{display:block}.manual-main{padding:0}.manual-chapter,.manual-appendix{break-inside:avoid;border:0;padding:0;margin:0 0 18px}.manual-hero{break-after:page}figure img{max-height:420px;object-fit:contain} }
`;
const js = `(() => {
const sidebar = document.querySelector('.manual-sidebar');
const toggle = document.querySelector('.manual-menu-toggle');
const search = document.querySelector('.manual-search input');
const status = document.querySelector('.manual-search-status');
const links = Array.from(document.querySelectorAll('.manual-nav a[data-nav-id]'));
const articles = Array.from(document.querySelectorAll('.manual-chapter'));
const apply = () => {
const query = (search?.value || '').trim().toLowerCase();
let matches = 0;
for (const link of links) {
const article = document.querySelector('#' + CSS.escape(link.dataset.navId));
const text = (article?.dataset.search || link.textContent || '').toLowerCase();
const hit = !query || text.includes(query) || (link.textContent || '').toLowerCase().includes(query);
link.hidden = !hit;
if (hit) matches += 1;
}
for (const article of articles) {
const text = (article.dataset.search || '').toLowerCase();
article.hidden = Boolean(query) && !text.includes(query);
}
if (status) status.textContent = query ? matches + ' 个匹配' : '';
};
search?.addEventListener('input', apply);
toggle?.addEventListener('click', () => sidebar?.classList.toggle('open'));
window.addEventListener('keydown', (event) => {
if (event.key === '/' && document.activeElement !== search) { event.preventDefault(); search?.focus(); }
if (event.key === 'Escape' && search) { search.value = ''; apply(); search.blur(); }
});
const observer = 'IntersectionObserver' in window ? new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const id = entry.target.id;
for (const link of links) link.classList.toggle('active', link.dataset.navId === id);
}
}, { rootMargin: '-30% 0px -60% 0px' }) : null;
for (const article of articles) observer?.observe(article);
})();
`;
fs.mkdirSync(OUT, { recursive: true });
fs.mkdirSync(path.join(OUT, "images"), { recursive: true });
fs.writeFileSync(path.join(OUT, "index.html"), html, "utf8");
fs.writeFileSync(path.join(OUT, "manual.css"), css, "utf8");
fs.writeFileSync(path.join(OUT, "manual.js"), js, "utf8");
fs.writeFileSync(path.join(OUT, "manual-search.json"), JSON.stringify({ schemaVersion: 1, chapters: manual.chapters.map((chapter) => ({ id: chapter.id, title: chapter.title, summary: chapter.summary })) }, null, 2) + "\n", "utf8");
const manifest = { schemaVersion: 1, generatedAt: new Date().toISOString(), version: manual.version, lastVerified: manual.lastVerified, sourceCommit: manual.sourceCommit, images: [...new Set(manual.chapters.flatMap((chapter) => chapter.steps.map((step) => step.image).filter(Boolean)))].map((src) => ({ src, file: src.startsWith("/") ? src : `/${src}`, exists: existing.has(src) || fs.existsSync(src.startsWith("/") ? path.join(PUBLIC, src.slice(1)) : path.join(OUT, src)) })) };
fs.writeFileSync(path.join(OUT, "images", "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", "utf8");
console.log(JSON.stringify({ status: missing.length ? "MISSING_ASSETS" : "PASS", chapters: manual.chapters.length, appendices: manual.appendices.length, missing, output: path.join(OUT, "index.html") }, null, 2));
if (missing.length) process.exitCode = 1;