md-editor-v3 的 MdPreview 不管正文里有没有代码、公式,都会从 unpkg.com 加载 highlight.js、katex、mermaid、echarts(mermaid 还是另一份 11.x)。而我们只用到最基础的 Markdown:题面 95% 是富文本存的 HTML,教程是标题、表格、图片,AI 输出是标题、列表、 表格、少量代码块,库里一处公式、mermaid 都没有。 - 显示:新组件 shared/components/Markdown.vue,直接用 markdown-it,代码高亮走本地的 highlight.js(和练一练共用 shared/highlight.ts,补了 C++ / SQL)。9 处 MdPreview 全换掉。 正文默认 16px,教程也统一成 16px。 - 编辑:后台教程编辑器换成项目已有的 CodeMirror,加一排格式按钮;图片可以按按钮选、 粘贴、拖进来,都是先上传再插入。md-editor-v3 从依赖里删掉。 - AI 生成的内容(AI 提示、AI 分析、班级报告)不再放行原生 HTML:学生能在代码里诱导 AI 吐出 <img onerror>,老师在后台一打开就执行。库里 1196 条 AI 输出没有一个 HTML 标签,关掉不影响显示。题面、教程照旧放行。 - 长英文改成在单词之间折行,原来 break-all 会把 "I live" 断成 "I l / ive"。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
5.2 KiB
TypeScript
159 lines
5.2 KiB
TypeScript
import type { Exercise } from "utils/types"
|
||
|
||
/**
|
||
* 把一课的 Markdown 正文拆成几段,各段交给不同的组件渲染:
|
||
*
|
||
* - `heading`:二级标题单独拿出来,自己出 id —— 右边「这一课」的大纲靠它跳转、高亮;
|
||
* - `goals`:「## 学习目标」那一节画成「学完这课你能」的方框;
|
||
* - `code`:带语言的代码块(```python / ```c)换成能改、能运行的代码块;
|
||
* 不带语言的(「输出结果」那种)留在 Markdown 里照常显示;
|
||
* - `exercise`:`[[exercise:36]]` 占位符换成练一练;
|
||
* - `md`:其余的交给 Markdown.vue。
|
||
*
|
||
* 正文开头的「# 标题」「**所属项目:…**」和紧跟着的项目配图不渲染:标题和项目页面上方
|
||
* 已经有了,每课都重复一张同样的项目图只是把正文往下推。
|
||
*/
|
||
export type LessonSegment =
|
||
| { type: "md"; content: string }
|
||
| { type: "heading"; id: string; text: string }
|
||
| { type: "goals"; id: string; items: string[] }
|
||
| { type: "code"; lang: "python" | "c"; code: string; index: number }
|
||
| { type: "exercise"; exercise: Exercise; index: number }
|
||
|
||
/** 大纲里的一项:一个二级标题,挂着它这一节里的练一练 */
|
||
export interface OutlineItem {
|
||
id: string
|
||
text: string
|
||
exerciseIds: number[]
|
||
}
|
||
|
||
const CODE_LANGS: Record<string, "python" | "c"> = {
|
||
python: "python",
|
||
py: "python",
|
||
python3: "python",
|
||
c: "c",
|
||
cpp: "c",
|
||
"c++": "c",
|
||
}
|
||
|
||
const EXERCISE_RE = /\[\[exercise:(\d+)\]\]/g
|
||
const GOALS_TITLE = "学习目标"
|
||
|
||
/** 去掉开头的标题、所属项目、项目配图(中间的空行一起) */
|
||
function stripPreamble(lines: string[]) {
|
||
let i = 0
|
||
const skipBlank = () => {
|
||
while (i < lines.length && !lines[i]!.trim()) i++
|
||
}
|
||
skipBlank()
|
||
if (lines[i]?.startsWith("# ")) i++
|
||
skipBlank()
|
||
if (/^\*\*所属项目[::]/.test(lines[i]?.trim() ?? "")) {
|
||
i++
|
||
skipBlank()
|
||
if (/^!\[[^\]]*\]\([^)]*\)\s*$/.test(lines[i]?.trim() ?? "")) i++
|
||
}
|
||
return lines.slice(i)
|
||
}
|
||
|
||
export function parseLesson(content: string, exercises: Exercise[]) {
|
||
const byId = new Map(exercises.map((item) => [item.id, item]))
|
||
const segments: LessonSegment[] = []
|
||
const outline: OutlineItem[] = []
|
||
let md: string[] = []
|
||
let codeIndex = 0
|
||
let exerciseIndex = 0
|
||
let headingIndex = 0
|
||
|
||
const flush = () => {
|
||
const text = md.join("\n")
|
||
if (text.trim()) segments.push({ type: "md", content: text })
|
||
md = []
|
||
}
|
||
|
||
const lines = stripPreamble(content.replace(/\r\n/g, "\n").split("\n"))
|
||
let i = 0
|
||
while (i < lines.length) {
|
||
const line = lines[i]!
|
||
const fence = line.match(/^(\s*)(```+|~~~+)\s*([\w+#-]*)\s*$/)
|
||
if (fence) {
|
||
const marker = fence[2]!
|
||
const lang = CODE_LANGS[fence[3]!.toLowerCase()]
|
||
const body: string[] = []
|
||
let j = i + 1
|
||
while (j < lines.length && !lines[j]!.trim().startsWith(marker)) body.push(lines[j++]!)
|
||
if (lang) {
|
||
flush()
|
||
segments.push({ type: "code", lang, code: body.join("\n"), index: codeIndex++ })
|
||
} else {
|
||
md.push(line, ...body, lines[j] ?? marker)
|
||
}
|
||
i = j + 1
|
||
continue
|
||
}
|
||
|
||
const heading = line.match(/^##\s+(.+?)\s*#*\s*$/)
|
||
if (heading) {
|
||
flush()
|
||
const text = heading[1]!.trim()
|
||
const id = `section-${headingIndex++}`
|
||
if (text === GOALS_TITLE) {
|
||
// 学习目标:收到下一个分隔线或标题为止,只要列表项
|
||
const items: string[] = []
|
||
let j = i + 1
|
||
while (j < lines.length && !/^(---+|##\s)/.test(lines[j]!.trim())) {
|
||
const item = lines[j]!.match(/^\s*[-*+]\s+(.+)$/)
|
||
if (item) items.push(item[1]!.trim())
|
||
j++
|
||
}
|
||
segments.push({ type: "goals", id, items })
|
||
outline.push({ id, text: "学完这课你能", exerciseIds: [] })
|
||
i = j
|
||
continue
|
||
}
|
||
segments.push({ type: "heading", id, text })
|
||
outline.push({ id, text, exerciseIds: [] })
|
||
i++
|
||
continue
|
||
}
|
||
|
||
// 节与节之间的分隔线:二级标题本身就隔开了,再画一道线是重复
|
||
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) {
|
||
i++
|
||
continue
|
||
}
|
||
|
||
if (EXERCISE_RE.test(line)) {
|
||
EXERCISE_RE.lastIndex = 0
|
||
let last = 0
|
||
for (const match of line.matchAll(EXERCISE_RE)) {
|
||
md.push(line.slice(last, match.index))
|
||
last = match.index! + match[0].length
|
||
const exercise = byId.get(Number(match[1]))
|
||
if (!exercise) continue
|
||
flush()
|
||
segments.push({ type: "exercise", exercise, index: exerciseIndex++ })
|
||
outline[outline.length - 1]?.exerciseIds.push(exercise.id)
|
||
}
|
||
md.push(line.slice(last))
|
||
i++
|
||
continue
|
||
}
|
||
|
||
md.push(line)
|
||
i++
|
||
}
|
||
flush()
|
||
|
||
// 正文里没放占位符的练一练接在最后:学完一课要全做对,看不见的题会让这课永远学不完
|
||
const placed = new Set(
|
||
segments.flatMap((item) => (item.type === "exercise" ? [item.exercise.id] : [])),
|
||
)
|
||
for (const exercise of exercises) {
|
||
if (placed.has(exercise.id)) continue
|
||
segments.push({ type: "exercise", exercise, index: exerciseIndex++ })
|
||
outline[outline.length - 1]?.exerciseIds.push(exercise.id)
|
||
}
|
||
return { segments, outline, exerciseCount: exerciseIndex }
|
||
}
|