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>
185 lines
17 KiB
JavaScript
185 lines
17 KiB
JavaScript
#!/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("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
||
.replaceAll('"', """).replaceAll("'", "'");
|
||
|
||
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;
|