/**
* 用户手册生成脚本 (MDX 版本)
*
* 从 MDX 文档编译生成 HTML 格式的用户手册,
* 支持 Screenshot 组件引用 E2E 测试生成的截图。
*
* 用法: npx ts-node scripts/generate-manual.ts
*/
import * as fs from 'fs';
import * as path from 'path';
import matter from 'gray-matter';
import yaml from 'js-yaml';
import { compile } from '@mdx-js/mdx';
import { glob } from 'glob';
// CDN 基础路径(由 deploy-hybrid.js 设置)
const CDN_BASE = process.env.MANUAL_CDN_BASE || '';
// 配置类型定义
interface Config {
site: {
title: string;
description: string;
logo: string;
};
categories: Record;
screenshots: {
source: string;
output: string;
};
output: {
dir: string;
format: string;
};
}
// 页面元数据
interface PageMeta {
title: string;
description?: string;
category?: string;
order?: number;
}
// 页面数据
interface Page {
slug: string;
filePath: string;
meta: PageMeta;
content: string;
html: string;
}
const SITE_URL = 'https://opentu.ai';
const SITE_NAME = 'Opentu';
const MANUAL_PATH = '/user-manual';
const MANUAL_BASE_URL = `${SITE_URL}${MANUAL_PATH}`;
const DEFAULT_OG_IMAGE = `${SITE_URL}/product_showcase/aitu-01.png`;
const PAGE_OG_IMAGES: Record = {
'ai-generation-image-generation': `${SITE_URL}/product_showcase/aitu-01.png`,
'drawing-flowchart': `${SITE_URL}/product_showcase/流程图.gif`,
'drawing-mindmap': `${SITE_URL}/product_showcase/思维导图.gif`,
};
// 读取配置文件
function readConfig(configPath: string): Config {
const content = fs.readFileSync(configPath, 'utf-8');
return yaml.load(content) as Config;
}
// 获取版本号
function getVersion(): string {
try {
const packageJson = JSON.parse(
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8')
);
return packageJson.version || '0.0.0';
} catch {
return '0.0.0';
}
}
// 简单的 Markdown 转 HTML(不使用 MDX 运行时)
function markdownToHtml(markdown: string, screenshotsDir: string): string {
let html = markdown;
// 处理代码块(必须在行内代码之前处理)
html = html.replace(
/```(\w*)\n([\s\S]*?)```/g,
(_, lang, code) => {
const escapedCode = code
.replace(/&/g, '&')
.replace(//g, '>')
.trim();
return `${escapedCode}
`;
}
);
// 处理 Screenshot 组件
html = html.replace(
//g,
(_, id, alt) => {
const imgPath = CDN_BASE ? `${CDN_BASE}/screenshots/${id}.png` : `screenshots/${id}.png`;
return `
`;
}
);
// 处理标题(从小到大,避免误匹配)
html = html.replace(/^#### (.+)$/gm, '$1
');
html = html.replace(/^### (.+)$/gm, '$1
');
html = html.replace(/^## (.+)$/gm, '$1
');
html = html.replace(/^# (.+)$/gm, '$1
');
// 处理粗体和斜体
html = html.replace(/\*\*([^*]+)\*\*/g, '$1');
html = html.replace(/\*([^*]+)\*/g, '$1');
// 处理行内代码(在代码块之后处理)
html = html.replace(/`([^`\n]+)`/g, '$1');
// 处理链接
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
// 处理 tip/note 块
html = html.replace(
/:::tip\n([\s\S]*?):::/g,
'提示:$1
'
);
html = html.replace(
/:::note\n([\s\S]*?):::/g,
'注意:$1
'
);
// 处理无序列表
html = html.replace(/^- (.+)$/gm, '$1');
html = html.replace(/(.*<\/li>\n)+/g, (match) => ``);
// 处理表格
html = html.replace(
/\|(.+)\|\n\|[-|]+\|\n((?:\|.+\|\n)+)/g,
(_, header, rows) => {
const headers = header.split('|').filter((s: string) => s.trim()).map((s: string) => `${s.trim()} | `).join('');
const bodyRows = rows.trim().split('\n').map((row: string) => {
const cells = row.split('|').filter((s: string) => s.trim()).map((s: string) => `${s.trim()} | `).join('');
return `${cells}
`;
}).join('');
return ``;
}
);
// 处理段落(简单处理:连续的非标签文本)
const lines = html.split('\n');
const processedLines: string[] = [];
let inParagraph = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const isHtmlTag = /^<[a-z]|^<\/[a-z]/i.test(line);
const isEmpty = line === '';
if (isEmpty) {
if (inParagraph) {
processedLines.push('
');
inParagraph = false;
}
processedLines.push('');
} else if (isHtmlTag) {
if (inParagraph) {
processedLines.push('');
inParagraph = false;
}
processedLines.push(line);
} else {
if (!inParagraph) {
processedLines.push('');
inParagraph = true;
}
processedLines.push(line);
}
}
if (inParagraph) {
processedLines.push('
');
}
let result = processedLines.join('\n');
// 如果设置了 CDN 基础路径,替换静态资源路径
if (CDN_BASE) {
// 替换 gifs/ 路径
result = result.replace(/src="gifs\//g, `src="${CDN_BASE}/gifs/`);
// 替换 screenshots/ 路径(补充处理直接写在 MDX 中的情况)
result = result.replace(/src="screenshots\//g, `src="${CDN_BASE}/screenshots/`);
}
// product_showcase 目录在主应用中,使用相对路径(不通过 CDN)
// MDX 文件中可能使用 ../product_showcase/ 或 ../../product_showcase/ 等不同深度的相对路径
// 输出的 HTML 都在 user-manual/ 目录下,统一转换为 ../product_showcase/
result = result.replace(/src="(?:\.\.\/)+product_showcase\//g, `src="../product_showcase/`);
return result;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
function extractSummaryFromMarkdown(markdown: string): string {
const blocks = markdown.split(/\n\s*\n/);
let inCodeBlock = false;
for (const block of blocks) {
const lines = block
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
continue;
}
const cleanedLines: string[] = [];
let isValidParagraph = true;
for (const line of lines) {
if (line.startsWith('```')) {
inCodeBlock = !inCodeBlock;
isValidParagraph = false;
break;
}
if (
inCodeBlock ||
line.startsWith('#') ||
line.startsWith('<') ||
line.startsWith('![') ||
line.startsWith('|') ||
line.startsWith(':::') ||
/^[-*]\s/.test(line) ||
/^\d+\.\s/.test(line)
) {
isValidParagraph = false;
break;
}
cleanedLines.push(line.replace(/[*`_~]/g, ''));
}
if (!isValidParagraph || cleanedLines.length === 0) {
continue;
}
return normalizeWhitespace(cleanedLines.join(' ')).slice(0, 96);
}
return '';
}
function getPageTitle(page: Page, siteTitle: string): string {
if (page.slug === 'index') {
return `${siteTitle}首页 | AI 应用平台、画布工作区、流程图与思维导图`;
}
return `${page.meta.title} | ${siteTitle}`;
}
function getPageDescription(page: Page, siteDescription: string): string {
if (page.meta.description) {
return normalizeWhitespace(page.meta.description).slice(0, 160);
}
if (page.slug === 'index') {
return 'Opentu 用户手册首页,了解 AI 应用平台、画布工作区、AI 绘图、AI 视频生成、流程图、思维导图、素材库与任务工作流的核心用法。';
}
const summary = extractSummaryFromMarkdown(page.content);
if (summary) {
const normalizedSummary = summary.slice(0, 120).replace(/[。!?.!?]+$/u, '');
return `${normalizedSummary}。查看 ${page.meta.title} 的操作步骤、使用技巧与常见问题。`.slice(0, 160);
}
return `${page.meta.title} 使用指南。${siteDescription}`.slice(0, 160);
}
function getPageKeywords(page: Page, config: Config): string {
const categoryName = page.meta.category ? config.categories[page.meta.category]?.name : '';
const values = [
'Opentu',
page.meta.title,
categoryName,
'用户手册',
'教程',
'AI 应用平台',
'画布工作区',
].filter(Boolean);
return Array.from(new Set(values)).join(',');
}
function getCanonicalUrl(page: Page): string {
return page.slug === 'index'
? `${MANUAL_BASE_URL}/index.html`
: `${MANUAL_BASE_URL}/${page.slug}.html`;
}
function getOgImage(page: Page): string {
return PAGE_OG_IMAGES[page.slug] || DEFAULT_OG_IMAGE;
}
function generateStructuredData(page: Page, description: string, canonicalUrl: string, config: Config): string {
const pageType = page.slug === 'index' ? 'CollectionPage' : 'TechArticle';
const payload = {
'@context': 'https://schema.org',
'@type': pageType,
headline: page.meta.title,
name: page.meta.title,
description,
inLanguage: 'zh-CN',
url: canonicalUrl,
image: getOgImage(page),
isPartOf: {
'@type': 'WebSite',
name: config.site.title,
url: `${MANUAL_BASE_URL}/index.html`,
},
publisher: {
'@type': 'Organization',
name: SITE_NAME,
url: SITE_URL,
logo: {
'@type': 'ImageObject',
url: `${SITE_URL}/icons/android-chrome-512x512.png`,
},
},
about: [
page.meta.title,
'AI 应用平台',
'画布工作区',
'流程图',
'思维导图',
],
};
return JSON.stringify(payload);
}
// 生成 HTML 头部和样式
function generateHtmlHead(page: Page, config: Config): string {
const title = getPageTitle(page, config.site.title);
const description = getPageDescription(page, config.site.description);
const canonicalUrl = getCanonicalUrl(page);
const ogImage = getOgImage(page);
const keywords = getPageKeywords(page, config);
const structuredData = generateStructuredData(page, description, canonicalUrl, config);
return `
${escapeHtml(title)}
`;
}
// 生成侧边栏导航
function generateSidebar(pages: Page[], config: Config, currentSlug: string): string {
// 按分类组织页面
const byCategory = new Map();
let indexPage: Page | null = null;
for (const page of pages) {
if (page.slug === 'index') {
indexPage = page;
continue;
}
const category = page.meta.category || 'advanced';
if (!byCategory.has(category)) {
byCategory.set(category, []);
}
byCategory.get(category)!.push(page);
}
// 按分类顺序和页面顺序排序
const sortedCategories = Array.from(byCategory.entries())
.sort((a, b) => {
const orderA = config.categories[a[0]]?.order || 999;
const orderB = config.categories[b[0]]?.order || 999;
return orderA - orderB;
});
for (const [, categoryPages] of sortedCategories) {
categoryPages.sort((a, b) => (a.meta.order || 0) - (b.meta.order || 0));
}
let html = '';
return html;
}
// 生成单个页面
function generatePage(page: Page, allPages: Page[], config: Config, version: string): string {
let html = generateHtmlHead(page, config);
html += `
${generateSidebar(allPages, config, page.slug)}
${page.html}
`;
return html;
}
// 复制截图文件
function copyScreenshots(sourceDir: string, outputDir: string): number {
if (!fs.existsSync(sourceDir)) {
console.log(`⚠️ 截图源目录不存在: ${sourceDir}`);
return 0;
}
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const files = fs.readdirSync(sourceDir);
let copied = 0;
for (const file of files) {
if (file.endsWith('.png') || file.endsWith('.jpg') || file.endsWith('.jpeg')) {
const sourcePath = path.join(sourceDir, file);
// 从文件名提取 ID(去掉哈希后缀)
let targetName = file;
// 处理带哈希的文件名,如 "ai-step-0-abc123.png" -> "ai-step-0.png"
const hashMatch = file.match(/^(.+)-[a-f0-9]{8,}\.png$/);
if (hashMatch) {
targetName = `${hashMatch[1]}.png`;
}
const targetPath = path.join(outputDir, targetName);
fs.copyFileSync(sourcePath, targetPath);
copied++;
}
}
return copied;
}
// 从 E2E 测试结果目录复制新截图
function copyE2EScreenshots(outputDir: string): number {
const e2eScreenshotsDir = path.join(process.cwd(), 'apps', 'web-e2e', 'test-results', 'manual-screenshots');
if (!fs.existsSync(e2eScreenshotsDir)) {
console.log(`ℹ️ E2E 截图目录不存在: ${e2eScreenshotsDir}`);
console.log(` 运行 'pnpm manual:screenshots' 生成截图`);
return 0;
}
const copied = copyScreenshots(e2eScreenshotsDir, outputDir);
if (copied > 0) {
console.log(`📷 从 E2E 测试结果复制了 ${copied} 个截图`);
}
return copied;
}
// 从 E2E 测试结果复制 GIF 文件
function copyE2EGifs(outputDir: string): number {
const gifsOutputDir = path.join(outputDir, 'gifs');
const e2eTestResults = path.join(process.cwd(), 'apps', 'web-e2e', 'test-results');
if (!fs.existsSync(e2eTestResults)) {
return 0;
}
// 确保输出目录存在
if (!fs.existsSync(gifsOutputDir)) {
fs.mkdirSync(gifsOutputDir, { recursive: true });
}
let copied = 0;
// 递归查找所有 GIF 文件
function findGifs(dir: string) {
if (!fs.existsSync(dir)) return;
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
findGifs(fullPath);
} else if (item.endsWith('.gif')) {
// 从目录名提取有意义的文件名
const parentDir = path.basename(path.dirname(fullPath));
let targetName = item;
// 如果是 E2E 生成的目录,提取测试名称作为文件名
if (parentDir.includes('manual-gen')) {
const match = parentDir.match(/GIF-动图录制-(.+?)-manual/);
if (match) {
targetName = match[1].replace(/[^a-zA-Z0-9\u4e00-\u9fa5-]/g, '-') + '.gif';
}
}
const targetPath = path.join(gifsOutputDir, targetName);
fs.copyFileSync(fullPath, targetPath);
copied++;
}
}
}
// 也检查 gifs 目录(如果 video-to-gif.js 已经生成了)
const gifsSourceDir = path.join(process.cwd(), 'apps', 'web', 'public', 'user-manual', 'gifs');
if (fs.existsSync(gifsSourceDir)) {
const files = fs.readdirSync(gifsSourceDir);
for (const file of files) {
if (file.endsWith('.gif')) {
const sourcePath = path.join(gifsSourceDir, file);
const targetPath = path.join(gifsOutputDir, file);
fs.copyFileSync(sourcePath, targetPath);
copied++;
}
}
}
findGifs(e2eTestResults);
if (copied > 0) {
console.log(`🎬 复制了 ${copied} 个 GIF 动图`);
}
return copied;
}
// 主函数
async function main() {
const manualDir = path.join(process.cwd(), 'docs', 'user-manual');
const contentDir = path.join(manualDir, 'content');
const configPath = path.join(manualDir, 'config.yaml');
console.log('🔍 读取配置...');
// 读取配置
if (!fs.existsSync(configPath)) {
console.error('❌ 配置文件不存在:', configPath);
process.exit(1);
}
const config = readConfig(configPath);
const version = getVersion();
// 解析输出目录路径
const outputDir = path.resolve(manualDir, config.output.dir);
const screenshotsOutputDir = path.join(outputDir, 'screenshots');
const screenshotsSourceDir = path.resolve(manualDir, config.screenshots.source);
// 确保输出目录存在
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
console.log(`📦 版本: ${version}`);
console.log(`📁 内容目录: ${contentDir}`);
console.log(`📁 输出目录: ${outputDir}`);
// 查找所有 MDX 文件
const mdxFiles = await glob('**/*.mdx', { cwd: contentDir });
console.log(`📄 找到 ${mdxFiles.length} 个 MDX 文件`);
if (mdxFiles.length === 0) {
console.error('❌ 没有找到 MDX 文件');
process.exit(1);
}
// 解析所有页面
const pages: Page[] = [];
for (const mdxFile of mdxFiles) {
const filePath = path.join(contentDir, mdxFile);
const fileContent = fs.readFileSync(filePath, 'utf-8');
// 解析 frontmatter
const { data, content } = matter(fileContent);
const meta = data as PageMeta;
// 生成 slug
const slug = mdxFile
.replace(/\.mdx$/, '')
.replace(/\//g, '-')
.replace(/^-/, '');
// 转换 Markdown 为 HTML
const html = markdownToHtml(content, screenshotsOutputDir);
pages.push({
slug: slug === 'index' ? 'index' : slug,
filePath,
meta,
content,
html,
});
}
// 生成 HTML 文件
for (const page of pages) {
const pageHtml = generatePage(page, pages, config, version);
const outputPath = path.join(outputDir, `${page.slug}.html`);
fs.writeFileSync(outputPath, pageHtml);
console.log(`✅ 生成: ${page.slug}.html`);
}
// 先从 E2E 测试结果复制新截图(如果存在)
const e2eCopied = copyE2EScreenshots(screenshotsOutputDir);
// 再从配置的源目录复制(可能有一些非 E2E 生成的截图)
const sourceCopied = copyScreenshots(screenshotsSourceDir, screenshotsOutputDir);
if (sourceCopied > 0) {
console.log(`📷 从源目录复制了 ${sourceCopied} 个截图`);
}
// 复制 GIF 动图
copyE2EGifs(outputDir);
console.log(`\n🎉 用户手册生成完成!`);
console.log(`📁 输出目录: ${outputDir}`);
console.log(`📄 共 ${pages.length} 个页面`);
}
main().catch(console.error);