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>
101 lines
6.7 KiB
JavaScript
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;
|