Files
workinf_Blender_Wasm/tools/web/manual/generate-manual.mjs
mes123456 b932db7642 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>
2026-11-13 11:58:40 +08:00

185 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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;