Files
workinf_Blender_Wasm/tools/web/manual/check-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

101 lines
6.7 KiB
JavaScript

#!/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;