build-course-content.mjs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
  2. import { resolve } from 'node:path';
  3. const root = process.cwd();
  4. const catalog = [
  5. ['api', 'API 服务开发', 16],
  6. ['database', '数据库开发', 12],
  7. ['api-database', 'API + 数据库', 12],
  8. ['tasks', '异步任务开发', 16],
  9. ['redis', 'Redis 缓存', 10],
  10. ['docker', 'Docker 容器化', 10],
  11. ['linux', 'Linux 部署', 10],
  12. ['troubleshooting', '线上问题排查', 12],
  13. ];
  14. const errors = [];
  15. let total = 0;
  16. const report = [];
  17. mkdirSync(resolve(root, 'public/downloads'), { recursive: true });
  18. const text = (value) => typeof value === 'string' && value.trim().length > 0;
  19. const list = (value, min = 1) => Array.isArray(value) && value.length >= min;
  20. function verify(l, slug, index) {
  21. const prefix = `${slug}/${l?.id || index}`;
  22. if (l?.id !== String(index + 1).padStart(2, '0'))
  23. errors.push(`${prefix}: invalid lesson ID`);
  24. for (const key of ['group', 'title', 'summary'])
  25. if (!text(l?.[key])) errors.push(`${prefix}: missing ${key}`);
  26. if ('minutes' in l)
  27. errors.push(`${prefix}: instructor duration metadata is not allowed`);
  28. for (const key of [
  29. 'objectives',
  30. 'concepts',
  31. 'steps',
  32. 'checks',
  33. 'pitfalls',
  34. 'refs',
  35. ])
  36. if (!list(l?.[key])) errors.push(`${prefix}: missing ${key}`);
  37. for (const c of l.concepts || [])
  38. if (!text(c.title) || !text(c.body))
  39. errors.push(`${prefix}: incomplete concept`);
  40. for (const s of l.steps || [])
  41. if (!text(s.title) || !text(s.body))
  42. errors.push(`${prefix}: incomplete step`);
  43. for (const p of l.pitfalls || [])
  44. if (!text(p.symptom) || !text(p.cause) || !text(p.fix))
  45. errors.push(`${prefix}: incomplete troubleshooting`);
  46. for (const key of ['task', 'hint', 'answer'])
  47. if (!text(l.exercise?.[key]))
  48. errors.push(`${prefix}: missing exercise ${key}`);
  49. for (const ref of l.refs || []) {
  50. try {
  51. if (!text(ref.label) || new URL(ref.url).protocol !== 'https:')
  52. throw new Error();
  53. } catch {
  54. errors.push(`${prefix}: invalid reference ${ref.url}`);
  55. }
  56. }
  57. if (slug !== 'api') {
  58. if (
  59. !list(l.objectives, 3) ||
  60. !list(l.steps, 4) ||
  61. !list(l.concepts, 3) ||
  62. !list(l.checks, 3) ||
  63. !list(l.pitfalls, 2) ||
  64. !list(l.refs, 2)
  65. )
  66. errors.push(`${prefix}: insufficient practical lesson detail`);
  67. if (
  68. !text(l.production?.scenario) ||
  69. !list(l.production?.decisions) ||
  70. !text(l.production?.failureDrill) ||
  71. !list(l.production?.acceptance)
  72. )
  73. errors.push(`${prefix}: incomplete production exercise`);
  74. }
  75. }
  76. const bullets = (items) => items.map((s) => `- ${s}`).join('\n');
  77. function markdown(title, lessons) {
  78. return (
  79. `# ${title}\n\n术核 AI · 后端工程课件\n\n` +
  80. lessons
  81. .map((l) => {
  82. let out = `## ${l.id} ${l.title}\n\n${l.summary}\n\n### 本节目标\n\n${bullets(l.objectives)}\n\n### 必要概念\n\n`;
  83. out += l.concepts
  84. .map((c) => `#### ${c.title}\n\n${c.body}\n`)
  85. .join('\n');
  86. out +=
  87. '\n### 动手实现\n\n' +
  88. l.steps
  89. .map(
  90. (s, i) =>
  91. `#### ${i + 1}. ${s.title}\n\n${s.body}\n${s.code ? `\n文件:${s.filename || '终端 / 当前课时'}\n\n\`\`\`\`${s.lang || 'text'}\n${s.code}\n\`\`\`\`\n` : ''}`,
  92. )
  93. .join('\n');
  94. out += `\n### 验收结果\n\n${bullets(l.checks)}\n`;
  95. if (l.production) {
  96. const p = l.production;
  97. out += `\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`;
  98. }
  99. out +=
  100. '\n### 常见错误\n\n' +
  101. l.pitfalls
  102. .map(
  103. (p) =>
  104. `#### ${p.symptom}\n\n原因:${p.cause}\n\n处理:${p.fix}\n`,
  105. )
  106. .join('\n');
  107. out += `\n### 扩展练习\n\n${l.exercise.task}\n\n提示:${l.exercise.hint}\n\n参考答案:\n\n${l.exercise.answer}\n\n### 技术文档\n\n${l.refs.map((r) => `- [${r.label}](${r.url})`).join('\n')}\n`;
  108. return out;
  109. })
  110. .join('\n---\n\n')
  111. );
  112. }
  113. for (const [slug, title, count] of catalog) {
  114. const path = resolve(
  115. root,
  116. slug === 'api' ? 'app/lessons.json' : `public/content/${slug}.json`,
  117. );
  118. let lessons;
  119. try {
  120. lessons = JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''));
  121. } catch (error) {
  122. errors.push(`${slug}: ${error.message}`);
  123. continue;
  124. }
  125. if (!Array.isArray(lessons) || lessons.length !== count) {
  126. errors.push(`${slug}: expected ${count} lessons`);
  127. continue;
  128. }
  129. lessons.forEach((l, i) => verify(l, slug, i));
  130. total += lessons.length;
  131. writeFileSync(
  132. resolve(root, `public/downloads/${slug}-lessons.md`),
  133. markdown(title, lessons),
  134. );
  135. report.push({
  136. slug,
  137. lessons: lessons.length,
  138. steps: lessons.reduce((n, l) => n + l.steps.length, 0),
  139. codeBlocks: lessons.reduce(
  140. (n, l) => n + l.steps.filter((s) => s.code).length,
  141. 0,
  142. ),
  143. });
  144. }
  145. if (errors.length) {
  146. console.error(errors.join('\n'));
  147. process.exit(1);
  148. }
  149. console.log(
  150. JSON.stringify(
  151. { modules: report.length, lessons: total, details: report },
  152. null,
  153. 2,
  154. ),
  155. );