import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { resolve } from 'node:path'; import { verifyReview, reviewMarkdown } from './review-content.mjs'; const root = process.cwd(); const catalog = [ ['api', 'API 服务开发', 16], ['database', '数据库开发', 12], ['api-database', 'API + 数据库', 12], ['tasks', '异步任务开发', 16], ['redis', 'Redis 缓存', 10], ['docker', 'Docker 容器化', 10], ['linux', 'Linux 部署', 10], ['troubleshooting', '线上问题排查', 12], ['distributed', '分布式系统设计', 12], ['review', '复盘与自测', 9], ]; const errors = []; let total = 0; const report = []; const validatedModules = []; mkdirSync(resolve(root, 'public/downloads'), { recursive: true }); const text = (value) => typeof value === 'string' && value.trim().length > 0; const list = (value, min = 1) => Array.isArray(value) && value.length >= min; function verify(l, slug, index) { if (slug === 'review') { errors.push( ...verifyReview( l, index, new Map( validatedModules.map((module) => [module.slug, module.lessons]), ), ), ); return; } const prefix = `${slug}/${l?.id || index}`; const theory = slug === 'distributed'; if ( theory && (l.kind !== 'theory' || l.production || l.steps?.some((s) => s.code || s.lang || s.filename)) ) errors.push( `${prefix}: theory lessons must not contain practical code or production drills`, ); if ( theory && (!text(l.interview?.question) || !text(l.interview?.answer) || !list(l.interview?.followUps)) ) errors.push(`${prefix}: missing interview question, answer or follow-ups`); if (l?.id !== String(index + 1).padStart(2, '0')) errors.push(`${prefix}: invalid lesson ID`); for (const key of ['group', 'title', 'summary']) if (!text(l?.[key])) errors.push(`${prefix}: missing ${key}`); if ( !text(l.intro?.scenario) || !text(l.intro?.outcome) || !list(l.intro?.startingPoint) || l.intro.startingPoint.some((item) => !text(item)) ) errors.push( `${prefix}: missing self-study scenario, starting point or outcome`, ); if ('minutes' in l) errors.push(`${prefix}: instructor duration metadata is not allowed`); for (const key of [ 'objectives', 'concepts', 'steps', 'checks', 'pitfalls', 'refs', ]) if (!list(l?.[key])) errors.push(`${prefix}: missing ${key}`); for (const c of l.concepts || []) if (!text(c.title) || !text(c.body)) errors.push(`${prefix}: incomplete concept`); for (const [stepIndex, s] of (l.steps || []).entries()) { const stepPrefix = `${prefix}/step-${stepIndex + 1}`; for (const key of ['title', 'body', 'expected', 'explanation']) if (!text(s[key])) errors.push(`${stepPrefix}: missing ${key}`); if ( s.expected === s.body || s.explanation === s.body || s.expected === s.explanation ) errors.push( `${stepPrefix}: action, observation and explanation must be distinct`, ); } for (const p of l.pitfalls || []) if (!text(p.symptom) || !text(p.cause) || !text(p.fix)) errors.push(`${prefix}: incomplete troubleshooting`); for (const key of ['task', 'hint', 'answer']) if (!text(l.exercise?.[key])) errors.push(`${prefix}: missing exercise ${key}`); for (const ref of l.refs || []) { try { if (!text(ref.label) || new URL(ref.url).protocol !== 'https:') throw new Error(); } catch { errors.push(`${prefix}: invalid reference ${ref.url}`); } } if (slug !== 'api') { if ( !list(l.objectives, 3) || !list(l.steps, 4) || !list(l.concepts, 3) || !list(l.checks, 3) || !list(l.pitfalls, 2) || !list(l.refs, 2) ) errors.push(`${prefix}: insufficient lesson detail`); if ( !theory && (!text(l.production?.scenario) || !list(l.production?.decisions) || !text(l.production?.failureDrill) || !list(l.production?.acceptance)) ) errors.push(`${prefix}: incomplete production exercise`); } } const bullets = (items) => items.map((s) => `- ${s}`).join('\n'); function markdown(title, lessons) { return ( `# ${title}\n\n术核 AI · 后端工程课件\n\n` + lessons .map((l) => { const theory = l.kind === 'theory'; let out = `## ${l.id} ${l.title}\n\n${l.summary}\n\n### 从一个问题开始\n\n${l.intro.scenario}\n\n**这次的终点**\n\n${l.intro.outcome}\n\n#### 开始前,确认你在这里\n\n${bullets(l.intro.startingPoint)}\n\n`; out += theory ? '先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。\n' : '每次只做一个步骤,先核对结果,再读原因。结果不一致时,先排查当前步骤,再继续往下。\n'; out += `\n### 这节课要完成什么\n\n${bullets(l.objectives)}\n`; out += `\n### ${theory ? '一步步推演场景' : '一步步完成案例'}\n\n` + l.steps .map( (s, i) => `#### ${i + 1}. ${s.title}\n\n${s.body}\n${s.code ? `\n文件:${s.filename || '终端 / 当前课时'}\n\n\`\`\`\`${s.lang || 'text'}\n${s.code}\n\`\`\`\`\n` : ''}\n**${theory ? '对照你的推演' : '你应该看到什么'}**\n\n${s.expected}\n\n**${theory ? '从这里理解原因' : '刚才发生了什么'}**\n\n${s.explanation}\n`, ) .join('\n'); out += `\n### ${theory ? '检查推演结果' : '检查学习结果'}\n\n${bullets(l.checks)}\n`; if (l.production) { const p = l.production; out += `\n### 进一步验证:约束与故障\n\n先通过上面的学习结果检查,再做这一组实验。\n\n${p.scenario}\n\n#### 实现决策\n\n${bullets(p.decisions)}\n\n#### 隔离环境演练\n\n${p.failureDrill}\n\n#### 恢复验收\n\n${bullets(p.acceptance)}\n`; } out += `\n### ${theory ? '常见误区' : '遇到问题时查这里'}\n\n` + l.pitfalls .map( (p) => `#### ${p.symptom}\n\n原因:${p.cause}\n\n处理:${p.fix}\n`, ) .join('\n'); if (l.interview) out += `\n### 面试问答\n\n${l.interview.question}\n\n${l.interview.answer}\n\n追问:\n\n${bullets(l.interview.followUps)}\n`; out += `\n### ${theory ? '自己设计一次' : '自己试一次'}\n\n${l.exercise.task}\n\n提示:${l.exercise.hint}\n\n参考答案:\n\n${l.exercise.answer}\n\n### 核心概念回顾\n\n${theory ? '回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。' : '回想刚才的操作、现象与选择,用下面的概念解释你已经完成的案例。'}\n\n`; out += l.concepts .map((c) => `#### ${c.title}\n\n${c.body}\n`) .join('\n'); out += `\n### ${theory ? '延伸阅读' : '技术文档'}\n\n${l.refs.map((r) => `- [${r.label}](${r.url})`).join('\n')}\n`; return out; }) .join('\n---\n\n') ); } for (const [slug, title, count] of catalog) { const path = resolve( root, slug === 'api' ? 'app/lessons.json' : `public/content/${slug}.json`, ); let lessons; try { lessons = JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, '')); } catch (error) { errors.push(`${slug}: ${error.message}`); continue; } if (!Array.isArray(lessons) || lessons.length !== count) { errors.push(`${slug}: expected ${count} lessons`); continue; } lessons.forEach((l, i) => verify(l, slug, i)); total += lessons.length; validatedModules.push({ slug, title, lessons }); report.push({ slug, lessons: lessons.length, steps: lessons.reduce((n, l) => n + (l.steps?.length || 0), 0), codeBlocks: lessons.reduce( (n, l) => n + (l.steps?.filter((s) => s.code).length || 0), 0, ), }); } if (errors.length) { console.error(errors.join('\n')); process.exit(1); } for (const { slug, title, lessons } of validatedModules) { writeFileSync( resolve(root, `public/downloads/${slug}-lessons.md`), slug === 'review' ? reviewMarkdown(lessons) : markdown(title, lessons), ); } console.log( JSON.stringify( { modules: report.length, lessons: total, details: report }, null, 2, ), );