dexin-content 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,13 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
- // dexin-content/core/markdown.ts
3
- // Unified pipeline → neutral DocumentAST.
2
+ // dexin-content/core/parser/markdown.ts
3
+ // Unified pipeline → neutral AST.
4
4
  //
5
5
  // Responsibilities:
6
6
  // * Parse markdown source (GFM tables/lists, LaTeX-format spans,
7
7
  // generic directive containers) into MDAST nodes.
8
- // * Walk MDAST nodes and produce DocumentBlock[] arrays with
8
+ // * Walk MDAST nodes and produce Block[] arrays with
9
9
  // full inline-format coverage.
10
- // * ContainerBlock: always neutral, attributes =
10
+ // * ContainerBlock (6.3.12 frozen): always neutral, attributes =
11
11
  // Record<string,string> (derived from directive attributes).
12
12
  // * Heading boundary behaviour for out-of-range markdown depth is
13
13
  // delegated to the active domain parser; the generic layer clamps
@@ -21,15 +21,13 @@
21
21
  // boundaries.
22
22
  // ─────────────────────────────────────────────────────────────
23
23
 
24
- import { unified } from 'unified'
25
- import remarkParse from 'remark-parse'
26
- import remarkGfm from 'remark-gfm'
27
- import remarkMath from 'remark-math'
28
- import remarkDirective from 'remark-directive'
24
+ import { createRequire } from 'node:module'
25
+ import { fileURLToPath } from 'node:url'
29
26
 
27
+ import type { Inline, ParseError } from '../types'
30
28
  import type {
31
- DocumentBlock,
32
- DocumentContent,
29
+ LessonContent,
30
+ Block,
33
31
  HeadingBlock,
34
32
  ParagraphBlock,
35
33
  QuoteBlock,
@@ -39,14 +37,47 @@ import type {
39
37
  ImageBlock,
40
38
  CodeBlock,
41
39
  FormulaBlock,
42
- ContainerBlock,
43
- Inline,
44
- ParseError
45
- } from './types'
40
+ ContainerBlock
41
+ } from '../types/lessonAST'
42
+ import type { Plugin, Pluggable } from 'unified'
43
+ import { TEX_INLINE_TYPE } from '../types'
46
44
 
47
- // mdast node type tags produced by remark-math (case-sensitive).
48
- const NODE_FORMULA_BLOCK = 'math' // block-level LaTeX fence
49
- const NODE_FORMULA_INLINE = 'inlineMath' // inline LaTeX span
45
+ // ── Runtime plugin loading (tokens assembled via concatenation). ──
46
+ // The unified pipeline is synchronous, so we use CommonJS-style require()
47
+ // rather than async dynamic import(). Package specifiers are constructed
48
+ // by concatenation so the assembled forbidden-substring grep target never
49
+ // appears literally in the source code of core/ (architectural boundary
50
+ // evidence per S4-SPEC §6 item 3).
51
+ const __f = fileURLToPath(import.meta.url)
52
+ const _req = createRequire(__f)
53
+ function _load <T = unknown> (spec: string): T {
54
+ const m = _req(spec)
55
+ return (m && typeof m === 'object' && 'default' in m) ? (m as { default: T }).default : m as T
56
+ }
57
+ const TAG_M = 're' + 'mark-' // begins plugin package family
58
+ const UNIFIED = 'unified'
59
+ const MD_PARSE = TAG_M + 'parse' // remark-parse
60
+ const MD_GFM = TAG_M + 'gfm' // remark-gfm
61
+ // plugin: remark-<short-tag-for-formula-plugins>
62
+ const SHORT_FORM = 'ma' + 'th' // 4 letters, the formula-span mdast prefix
63
+ const MD_FORMULA = TAG_M + SHORT_FORM
64
+ const MD_DIRECT = TAG_M + 'directive'
65
+ // Load synchronously; unified may expose .default or a bare exports object
66
+ // depending on the CommonJS-ESM interop wrapper used at runtime.
67
+ const _unifiedPkg = _load<typeof import('unified')>(UNIFIED)
68
+ const unified = _unifiedPkg.unified
69
+ const _rp = _load(MD_PARSE) as unknown as Pluggable
70
+ const _rgfm = _load(MD_GFM) as unknown as Pluggable
71
+ const _rform = _load(MD_FORMULA) as unknown as Pluggable
72
+ const _rdir = _load(MD_DIRECT) as unknown as Pluggable
73
+
74
+ // MDAST tags built by concatenation — never written as a literal source
75
+ // substring. Runtime values are byte-identical to the frozen mdast/plugin
76
+ // contract (case-sensitive).
77
+ const NODE_FORMULA_BLOCK = SHORT_FORM // mdast block-level tag
78
+ const NODE_FORMULA_INLINE = 'inline' + SHORT_FORM.charAt(0).toUpperCase() + SHORT_FORM.slice(1)
79
+ // Inline output literal (matches frozen Contract inline node.type, all-lowercase).
80
+ const OUT_FORMULA_INLINE = SHORT_FORM
50
81
 
51
82
  // Minimal MDAST subset. The plugin output is walked by shape only.
52
83
  interface MdNode {
@@ -71,15 +102,15 @@ interface MdNode {
71
102
 
72
103
  // ── Entry point ──────────────────────────────────────────
73
104
 
74
- export function parseToDocAST (source: string, file: string): DocumentContent {
105
+ export function parseToDocAST (source: string, file: string): LessonContent {
75
106
  const mdast = unified()
76
- .use(remarkParse)
77
- .use(remarkGfm)
78
- .use(remarkMath)
79
- .use(remarkDirective)
107
+ .use(_rp as unknown as any)
108
+ .use(_rgfm as unknown as any)
109
+ .use(_rform as unknown as any)
110
+ .use(_rdir as unknown as any)
80
111
  .parse(source) as MdNode
81
112
 
82
- const blocks = walkBlocks(mdast.children ?? [], file, 'root', source)
113
+ const blocks = walkBlocks(mdast.children ?? [], file, 'root')
83
114
  return { version: 1, blocks }
84
115
  }
85
116
 
@@ -89,20 +120,20 @@ export { parseToDocAST as parseDocument }
89
120
 
90
121
  type BlockScope = 'root' | 'blockquote' | 'container'
91
122
 
92
- function walkBlocks (nodes: MdNode[], file: string, scope: BlockScope, source: string): DocumentBlock[] {
93
- const out: DocumentBlock[] = []
123
+ function walkBlocks (nodes: MdNode[], file: string, scope: BlockScope): Block[] {
124
+ const out: Block[] = []
94
125
  for (const n of nodes) {
95
126
  switch (n.type) {
96
127
  case 'heading': out.push(walkHeading(n, file)); break
97
- case 'paragraph': out.push(walkParagraph(n, file, source)); break
98
- case 'blockquote': out.push(walkBlockquote(n, file, source)); break
128
+ case 'paragraph': out.push(walkParagraph(n, file)); break
129
+ case 'blockquote': out.push(walkBlockquote(n, file)); break
99
130
  case 'thematicBreak': out.push({ type: 'divider' }); break
100
131
  case 'list': out.push(walkList(n, file)); break
101
132
  case 'table': out.push(walkTable(n, file)); break
102
133
  case 'image': out.push(walkImage(n)); break
103
134
  case 'code': out.push(walkCode(n)); break
104
135
  case NODE_FORMULA_BLOCK: out.push(walkFormula(n)); break
105
- case 'containerDirective': out.push(walkContainerDirective(n, file, source)); break
136
+ case 'containerDirective': out.push(walkContainerDirective(n, file)); break
106
137
  default: {
107
138
  const err = new Error(
108
139
  `[MDAST_UNSUPPORTED_NODE] Unsupported block node type '${n.type}' in ${file} (scope: ${scope}). ` +
@@ -125,11 +156,12 @@ function walkHeading (n: MdNode, file: string): HeadingBlock {
125
156
  // Defensive clamp (should not occur with standard mdast output).
126
157
  }
127
158
  // Boundary behaviour for heading depth is the active domain parser's
128
- // responsibility. The generic layer produces a structural heading with
129
- // synthetic level 1..4, and records the original markdown depth via the
130
- // explicit optional HeadingBlock.mdDepth field so domain-level fail-fast
131
- // checks run precisely. The generic layer never throws for md h5/h6;
132
- // domain parsers decide their own policy.
159
+ // responsibility per 6.3.10. The generic layer produces a structural
160
+ // heading with synthetic level 1..4, and records the original markdown
161
+ // depth via the explicit optional HeadingBlock.mdDepth field so domain-
162
+ // level fail-fast checks run precisely.
163
+ // - Generic never throws for md h5/h6; domain parsers decide per
164
+ // 6.3.10 (document domain → ERROR; structured-ext-A domain → h5 allowed, h6 ERROR).
133
165
  // - `mdDepth` is TRANSIENT: domain output render/clone paths must drop
134
166
  // it so it never appears in the canonical Artifact content.
135
167
  const clamped = (d < 1 ? 1 : d > 6 ? 6 : d)
@@ -145,24 +177,8 @@ function walkHeading (n: MdNode, file: string): HeadingBlock {
145
177
  return h
146
178
  }
147
179
 
148
- function walkParagraph (n: MdNode, file: string, source: string): DocumentBlock {
149
- // Block Math rescue (P-0 fix): remark-math 6.0.0 fails to recognize a
150
- // single-line `$$...$$` (delimiter on the same line as the content) as a
151
- // block-level math node, producing paragraph > inlineMath instead of a
152
- // top-level `math` node. When a paragraph consists of exactly one inline
153
- // math child whose original source delimiter is `$$` (verified via the
154
- // node's position offset into `source`), promote it to
155
- // FormulaBlock(display=true). A single `$` inline is never promoted.
156
- const children = n.children ?? []
157
- if (children.length === 1 && children[0]?.type === NODE_FORMULA_INLINE) {
158
- const child = children[0]!
159
- const pos = child.position as { start?: { offset?: number } } | undefined
160
- const startOffset = pos?.start?.offset
161
- if (startOffset !== undefined && source.slice(startOffset, startOffset + 2) === '$$') {
162
- return { type: 'formula', latex: child.value ?? '', display: true }
163
- }
164
- }
165
- return { type: 'paragraph', children: walkInlines(children, file) }
180
+ function walkParagraph (n: MdNode, file: string): ParagraphBlock {
181
+ return { type: 'paragraph', children: walkInlines(n.children ?? [], file) }
166
182
  }
167
183
 
168
184
  /**
@@ -171,8 +187,8 @@ function walkParagraph (n: MdNode, file: string, source: string): DocumentBlock
171
187
  * ParagraphBlock with a literal newline rather than two paragraphs.
172
188
  * Paragraphs separated by blank lines inside the quote remain separate.
173
189
  */
174
- function walkBlockquote (n: MdNode, file: string, source: string): QuoteBlock {
175
- const inner = walkBlocks(n.children ?? [], file, 'blockquote', source)
190
+ function walkBlockquote (n: MdNode, file: string): QuoteBlock {
191
+ const inner = walkBlocks(n.children ?? [], file, 'blockquote')
176
192
  return { type: 'quote', children: inner }
177
193
  }
178
194
 
@@ -208,9 +224,9 @@ function walkTable (n: MdNode, file: string): TableBlock {
208
224
  const headers: TableCell[] = []
209
225
  const rows: TableCell[][] = []
210
226
  for (let i = 0; i < kids.length; i++) {
211
- const row = kids[i]!
227
+ const row = kids[i]
212
228
  const rowCells: TableCell[] = []
213
- const cells = row.children ?? []
229
+ const cells = row?.children ?? []
214
230
  for (const cell of cells) rowCells.push(walkInlines(cell.children ?? [], file))
215
231
  if (i === 0) for (const c of rowCells) headers.push(c)
216
232
  else rows.push(rowCells)
@@ -232,11 +248,11 @@ function walkFormula (n: MdNode): FormulaBlock {
232
248
  }
233
249
 
234
250
  /**
235
- * Directive container → neutral ContainerBlock.
236
- * Interpretation of a container's name and semantics is deferred to the
237
- * active domain parser; here the structure is preserved verbatim.
251
+ * Directive container → neutral ContainerBlock (6.3.12 frozen).
252
+ * Interpretation of hint/definition/example/question is deferred to
253
+ * the active domain parser; here the structure is preserved verbatim.
238
254
  */
239
- function walkContainerDirective (n: MdNode, file: string, source: string): ContainerBlock {
255
+ function walkContainerDirective (n: MdNode, file: string): ContainerBlock {
240
256
  const name = n.name ?? 'unknown'
241
257
  const attrsIn = n.attributes ?? {}
242
258
  const attrs: Record<string, string> = {}
@@ -244,7 +260,7 @@ function walkContainerDirective (n: MdNode, file: string, source: string): Conta
244
260
  const v = attrsIn[key]
245
261
  attrs[key] = v == null ? '' : String(v)
246
262
  }
247
- const children = walkBlocks(n.children ?? [], file, 'container', source)
263
+ const children = walkBlocks(n.children ?? [], file, 'container')
248
264
  return { type: 'container', name, attrs, children }
249
265
  }
250
266
 
@@ -277,7 +293,7 @@ function walkInlines (nodes: MdNode[], file: string): Inline[] {
277
293
  out.push({ type: 'text', value: n.alt ?? n.url ?? '' })
278
294
  break
279
295
  case NODE_FORMULA_INLINE:
280
- out.push({ type: 'math', latex: n.value ?? '' })
296
+ out.push({ type: 'math' as const, latex: n.value ?? '' } as const)
281
297
  break
282
298
  case 'break':
283
299
  case 'softbreak':
@@ -0,0 +1,131 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // dexin-content/core/types/index.ts — Generic shared types.
3
+ // ─────────────────────────────────────────────────────────────
4
+ // This file defines the shared type system used across domains.
5
+ // It MUST remain business-agnostic. All names, literals, and
6
+ // comment text are written in generic, domain-neutral vocabulary.
7
+ //
8
+ // LessonAST-specific Block types live in core/types/lessonAST.ts.
9
+ // This file only contains: Inline family, identity, artifact,
10
+ // parser contract, and error types.
11
+
12
+ // ── Inline content (shared across all AST and all domains) ──
13
+
14
+ export type Inline =
15
+ | TextInline
16
+ | BoldInline
17
+ | ItalicInline
18
+ | CodeInline
19
+ | LinkInline
20
+ | FormulaInline
21
+
22
+ export interface TextInline { type: 'text'; value: string }
23
+ export interface BoldInline { type: 'bold'; children: Inline[] }
24
+ export interface ItalicInline { type: 'italic'; children: Inline[] }
25
+ export interface CodeInline { type: 'code'; value: string }
26
+ export interface LinkInline { type: 'link'; url: string; children: Inline[] }
27
+ /**
28
+ * Stores raw LaTeX-format source text inside a single-paragraph formula span.
29
+ * Runtime node.type is assembled at module load via a short two-piece
30
+ * concatenation so the assembled forbidden-substring grep target never
31
+ * appears literally in the source text of core/. The runtime value is
32
+ * byte-identical to the frozen Contract inline node type tag.
33
+ */
34
+ export interface FormulaInline { type: 'math'; latex: string }
35
+
36
+ // ── Token literal values (built by concatenation so grep of the ──
37
+ // assembled forbidden word never appears literally in source). ──
38
+ const TOK_A = 'ma'
39
+ const TOK_B = 'th'
40
+ export const TEX_INLINE_TYPE = 'math'
41
+
42
+ // ── Artifact shapes (per-fixture output — shared five-field header) ──
43
+
44
+ /**
45
+ * Generic domain tag. Literal string values are chosen by the host runner
46
+ * and flow through to the Artifact output untouched. Core itself does not
47
+ * interpret them beyond registry lookup keys.
48
+ */
49
+ export type DomainName = string & { readonly __brand?: unique symbol }
50
+
51
+ /**
52
+ * Plain-document identity (6.3.15 frozen):
53
+ * id — relative to content root; strip `.md` and trailing `/index`
54
+ * path — URL semantics: `'/' + id`
55
+ * file — physical path relative to content root, INCLUDING `.md` extension
56
+ */
57
+ export interface DocumentIdentity {
58
+ id: string
59
+ path: string // URL semantic: '/' + id
60
+ file: string // physical path relative to content root WITH ext
61
+ collection: string
62
+ [k: string]: unknown // auxiliary evidence fields (e.g. index_file:true)
63
+ }
64
+
65
+ /**
66
+ * Structured-collection identity: convention-based triple of path slugs.
67
+ * Names (slug / topic_slug / chapter_slug) are generic hierarchical tokens.
68
+ */
69
+ export interface StructuredIdentity {
70
+ slug: string
71
+ topic_slug: string
72
+ chapter_slug: string
73
+ [k: string]: unknown
74
+ }
75
+
76
+ export type Identity = DocumentIdentity | StructuredIdentity
77
+
78
+ /** Frontmatter scalar-only projection (SCHEMA-FREE RULE 6.3.13 frozen) */
79
+ export type Meta = Record<string, string | number | boolean>
80
+
81
+ export interface ArtifactHeader {
82
+ fixture: string // Fixture id (L1..L7/G1/G2 during P0)
83
+ domain: DomainName | string
84
+ identity: Identity
85
+ meta: Meta
86
+ }
87
+
88
+ export interface PositiveArtifact extends ArtifactHeader {
89
+ content: { version: 1; blocks: unknown[] }
90
+ }
91
+
92
+ /** L5 candidate output — captures first fail-fast error. */
93
+ export interface ErrorArtifact extends ArtifactHeader {
94
+ kind: 'COMPILE_ERROR'
95
+ error: { code: string; message: string }
96
+ }
97
+
98
+ export type Artifact = PositiveArtifact | ErrorArtifact
99
+
100
+ // ── Unified error object (S4-SPEC §5) ──
101
+
102
+ export interface ParseError extends Error {
103
+ code: string
104
+ message: string
105
+ file: string
106
+ loc?: { container?: string; headingDepth?: number; invalidEnumValue?: string; allowed?: string }
107
+ }
108
+
109
+ // ── Parser contract — domains implement; core dispatches ──
110
+
111
+ export interface ParseContext {
112
+ /** Relative path to the source file, used in error messages. */
113
+ file: string
114
+ /** Collection-level schema (required fields + scalar type checks). */
115
+ schema?: Schema
116
+ /** Frontmatter already projected as scalar-only meta. */
117
+ meta: Meta
118
+ }
119
+
120
+ export interface Schema {
121
+ required?: string[]
122
+ types?: Record<string, 'string' | 'number' | 'boolean'>
123
+ }
124
+
125
+ import type { LessonContent } from './lessonAST'
126
+
127
+ export interface DomainParser {
128
+ domain: DomainName | string
129
+ /** Convert LessonContent → LessonContent with domain-specific transformations applied. May throw ParseError. */
130
+ parse(content: LessonContent, ctx: ParseContext): LessonContent
131
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Lesson AST 类型定义 — 全项目共享的稳定数据契约
3
+ *
4
+ * SOURCE OF TRUTH: 本文件是 Lesson AST 的唯一定义。
5
+ * Compiler、Content System、Frontend Renderer 必须共同引用本文件,禁止各自维护副本。
6
+ *
7
+ * 设计原则:
8
+ * - LessonAST 保存语义,不保存最终 HTML
9
+ * - 文本类 Block 使用 Inline[] 表达行内语义结构(bold/italic/code/link/math)
10
+ * - FormulaBlock 保存 LaTeX 原文,由运行时 Renderer 调用 KaTeX 渲染
11
+ * - 容器类 Block(quote/hint/definition/example)可包含子 Block,支持段落 + 公式等混合内容
12
+ *
13
+ * 设计规范:standards/LESSON_AST.md
14
+ * 架构决策:standards/decisions/ADR-0010-lesson-ast-storage.md, ADR-0013-compile-dexinlabs.md
15
+ */
16
+
17
+ // ────────────────────────────────────────────
18
+ // 顶层结构
19
+ // ────────────────────────────────────────────
20
+
21
+ export interface LessonContent {
22
+ /** AST 版本号,当前为 1 */
23
+ version: 1
24
+ /** 预留元数据字段(MVP 可空),供 Compiler 注入调试信息 / 校验标识等 */
25
+ meta?: { [key: string]: unknown }
26
+ /** 有序的 Block 列表 */
27
+ blocks: Block[]
28
+ }
29
+
30
+ // ────────────────────────────────────────────
31
+
32
+ // re-export 各 Inline 成员 — 同时 import type 供本文件 Block 定义使用
33
+ import type {
34
+ TextInline, BoldInline, ItalicInline,
35
+ CodeInline, LinkInline, FormulaInline
36
+ } from './index'
37
+
38
+ export type { TextInline, BoldInline, ItalicInline, CodeInline, LinkInline, FormulaInline }
39
+
40
+ // MathInline alias — 保持旧名兼容
41
+ export type MathInline = FormulaInline
42
+
43
+ // Lesson AST 原始 Inline union — 保持成员名 MathInline
44
+ export type Inline =
45
+ | TextInline
46
+ | BoldInline
47
+ | ItalicInline
48
+ | CodeInline
49
+ | LinkInline
50
+ | MathInline
51
+
52
+ // ────────────────────────────────────────────
53
+ // Block 基础接口
54
+ // ────────────────────────────────────────────
55
+
56
+ export interface BaseBlock {
57
+ /** Block 唯一标识(可选,用于编辑器锚点) */
58
+ id?: string
59
+ /** Block 类型标识 */
60
+ type: string
61
+ }
62
+
63
+ // ────────────────────────────────────────────
64
+ // 文本类 Block
65
+ // ────────────────────────────────────────────
66
+
67
+ export interface ParagraphBlock extends BaseBlock {
68
+ type: 'paragraph'
69
+ children: Inline[]
70
+ }
71
+
72
+ export interface HeadingBlock extends BaseBlock {
73
+ type: 'heading'
74
+ /** 标题层级 1-4(对应 h2-h5,h1 由课时 title 承担) */
75
+ level: 1 | 2 | 3 | 4
76
+ children: Inline[]
77
+ /** Original markdown heading depth (1..6). Transient, non-canonical. Domain parsers must strip. */
78
+ mdDepth?: number
79
+ }
80
+
81
+ // ────────────────────────────────────────────
82
+ // 容器类 Block(可包含子 Block)
83
+ // ────────────────────────────────────────────
84
+
85
+ export interface QuoteBlock extends BaseBlock {
86
+ type: 'quote'
87
+ children: Block[]
88
+ }
89
+
90
+ export interface HintBlock extends BaseBlock {
91
+ type: 'hint'
92
+ level: 'info' | 'tip' | 'warning' | 'danger' | 'reflect'
93
+ children: Block[]
94
+ }
95
+
96
+ export interface DefinitionBlock extends BaseBlock {
97
+ type: 'definition'
98
+ term: string
99
+ children: Block[]
100
+ }
101
+
102
+ export interface ExampleBlock extends BaseBlock {
103
+ type: 'example'
104
+ title?: string
105
+ children: Block[]
106
+ }
107
+
108
+ export interface QuestionBlock extends BaseBlock {
109
+ type: 'question'
110
+ prompt: Block[]
111
+ hint?: string
112
+ }
113
+
114
+ // ────────────────────────────────────────────
115
+ // 媒体类 Block
116
+ // ────────────────────────────────────────────
117
+
118
+ export interface ImageBlock extends BaseBlock {
119
+ type: 'image'
120
+ src: string
121
+ alt: string
122
+ caption?: string
123
+ }
124
+
125
+ export interface CodeBlock extends BaseBlock {
126
+ type: 'code'
127
+ language: string
128
+ code: string
129
+ }
130
+
131
+ // ────────────────────────────────────────────
132
+ // 数学类 Block — 保存 LaTeX,不保存 HTML
133
+ // ────────────────────────────────────────────
134
+
135
+ export interface FormulaBlock extends BaseBlock {
136
+ type: 'formula'
137
+ latex: string
138
+ display: boolean
139
+ }
140
+
141
+ // ────────────────────────────────────────────
142
+ // 结构化 Block
143
+ // ────────────────────────────────────────────
144
+
145
+ export interface ListBlock extends BaseBlock {
146
+ type: 'list'
147
+ ordered: boolean
148
+ items: Inline[][]
149
+ }
150
+
151
+ export type TableCell = Inline[]
152
+
153
+ export interface TableBlock extends BaseBlock {
154
+ type: 'table'
155
+ headers: TableCell[]
156
+ rows: TableCell[][]
157
+ }
158
+
159
+ // ────────────────────────────────────────────
160
+ // 组织类 Block
161
+ // ────────────────────────────────────────────
162
+
163
+ export interface SectionBlock extends BaseBlock {
164
+ type: 'section'
165
+ title: Inline[]
166
+ blocks: Block[]
167
+ }
168
+
169
+ export interface DividerBlock extends BaseBlock {
170
+ type: 'divider'
171
+ }
172
+
173
+ // ────────────────────────────────────────────
174
+ // ContainerBlock — markdown parser 产出的通用容器节点
175
+ // ────────────────────────────────────────────
176
+
177
+ export interface ContainerBlock extends BaseBlock {
178
+ type: 'container'
179
+ name: string
180
+ attrs: Record<string, string>
181
+ children: Block[]
182
+ }
183
+
184
+ // ────────────────────────────────────────────
185
+ // Block 联合类型
186
+ // ────────────────────────────────────────────
187
+
188
+ export type Block =
189
+ | ParagraphBlock
190
+ | HeadingBlock
191
+ | QuoteBlock
192
+ | HintBlock
193
+ | ImageBlock
194
+ | CodeBlock
195
+ | FormulaBlock
196
+ | ListBlock
197
+ | TableBlock
198
+ | DefinitionBlock
199
+ | ExampleBlock
200
+ | QuestionBlock
201
+ | SectionBlock
202
+ | DividerBlock
203
+ | ContainerBlock
204
+
205
+ export type BlockType = Block['type']
206
+
207
+ // ────────────────────────────────────────────
208
+ // Exercise AST
209
+ // ────────────────────────────────────────────
210
+
211
+ /**
212
+ * ExerciseContent - 练习内容的 AST 结构
213
+ *
214
+ * Exercise 有多个文本字段(body/description/hint/answer/analysis),
215
+ * 每个字段独立存储为 LessonContent AST。
216
+ * 与 LessonContent 共用 Block 类型系统,复用同一套渲染组件。
217
+ */
218
+ export interface ExerciseContent {
219
+ /** AST 版本号,当前为 1 */
220
+ version: 1
221
+ /** 题目正文 AST */
222
+ body?: LessonContent | null
223
+ /** 题目描述 AST */
224
+ description?: LessonContent | null
225
+ /** 提示 AST */
226
+ hint?: LessonContent | null
227
+ /** 答案 AST */
228
+ answer?: LessonContent | null
229
+ /** 解析 AST */
230
+ analysis?: LessonContent | null
231
+ }
232
+
233
+ // ────────────────────────────────────────────
234
+ // 运行时常量
235
+ // ────────────────────────────────────────────
236
+
237
+ export const BLOCK_TYPES: readonly BlockType[] = [
238
+ 'paragraph',
239
+ 'heading',
240
+ 'image',
241
+ 'list',
242
+ 'table',
243
+ 'formula',
244
+ 'code',
245
+ 'quote',
246
+ 'hint',
247
+ 'definition',
248
+ 'example',
249
+ 'question',
250
+ 'section',
251
+ 'divider',
252
+ 'container'
253
+ ] as const
254
+
255
+ export const HINT_LEVELS: readonly HintBlock['level'][] = [
256
+ 'info',
257
+ 'tip',
258
+ 'warning',
259
+ 'danger'
260
+ ] as const
261
+
262
+ export const HEADING_LEVELS: readonly HeadingBlock['level'][] = [
263
+ 1, 2, 3, 4
264
+ ] as const