dexin-content 0.1.2 → 0.2.1

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,311 +1,343 @@
1
- // ─────────────────────────────────────────────────────────────
2
- // dexin-content/core/markdown.ts
3
- // Unified pipeline → neutral DocumentAST.
4
- //
5
- // Responsibilities:
6
- // * Parse markdown source (GFM tables/lists, LaTeX-format spans,
7
- // generic directive containers) into MDAST nodes.
8
- // * Walk MDAST nodes and produce DocumentBlock[] arrays with
9
- // full inline-format coverage.
10
- // * ContainerBlock: always neutral, attributes =
11
- // Record<string,string> (derived from directive attributes).
12
- // * Heading boundary behaviour for out-of-range markdown depth is
13
- // delegated to the active domain parser; the generic layer clamps
14
- // the syntactic level and records the original depth so domain
15
- // parsers can run their own fail-fast checks.
16
- // * Quote paragraph merging: soft newlines inside a single quote-
17
- // wrapped paragraph collapse into one ParagraphBlock with a literal
18
- // '\n' text node between its inlines (R-02). Separated paragraphs
19
- // (blank-line delimited) remain separate, inside neutral
20
- // ContainerBlock nodes (R-04), because blank lines mark paragraph
21
- // boundaries.
22
- // ─────────────────────────────────────────────────────────────
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'
29
-
30
- import type {
31
- DocumentBlock,
32
- DocumentContent,
33
- HeadingBlock,
34
- ParagraphBlock,
35
- QuoteBlock,
36
- ListBlock,
37
- TableBlock,
38
- TableCell,
39
- ImageBlock,
40
- CodeBlock,
41
- FormulaBlock,
42
- ContainerBlock,
43
- Inline,
44
- ParseError
45
- } from './types'
46
-
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
50
-
51
- // Minimal MDAST subset. The plugin output is walked by shape only.
52
- interface MdNode {
53
- type: string
54
- depth?: number
55
- children?: MdNode[]
56
- value?: string
57
- alt?: string
58
- url?: string
59
- identifier?: string
60
- label?: string
61
- lang?: string
62
- meta?: string
63
- ordered?: boolean
64
- spread?: boolean
65
- start?: number
66
- align?: ('left' | 'right' | 'center' | null)[]
67
- position?: unknown
68
- name?: string
69
- attributes?: Record<string, string | null | undefined> | null
70
- }
71
-
72
- // ── Entry point ──────────────────────────────────────────
73
-
74
- export function parseToDocAST (source: string, file: string): DocumentContent {
75
- const mdast = unified()
76
- .use(remarkParse)
77
- .use(remarkGfm)
78
- .use(remarkMath)
79
- .use(remarkDirective)
80
- .parse(source) as MdNode
81
-
82
- const blocks = walkBlocks(mdast.children ?? [], file, 'root', source)
83
- return { version: 1, blocks }
84
- }
85
-
86
- export { parseToDocAST as parseDocument }
87
-
88
- // ── Block walkers ─────────────────────────────────────────
89
-
90
- type BlockScope = 'root' | 'blockquote' | 'container'
91
-
92
- function walkBlocks (nodes: MdNode[], file: string, scope: BlockScope, source: string): DocumentBlock[] {
93
- const out: DocumentBlock[] = []
94
- for (const n of nodes) {
95
- switch (n.type) {
96
- 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
99
- case 'thematicBreak': out.push({ type: 'divider' }); break
100
- case 'list': out.push(walkList(n, file)); break
101
- case 'table': out.push(walkTable(n, file)); break
102
- case 'image': out.push(walkImage(n)); break
103
- case 'code': out.push(walkCode(n)); break
104
- case NODE_FORMULA_BLOCK: out.push(walkFormula(n)); break
105
- case 'containerDirective': out.push(walkContainerDirective(n, file, source)); break
106
- default: {
107
- const err = new Error(
108
- `[MDAST_UNSUPPORTED_NODE] Unsupported block node type '${n.type}' in ${file} (scope: ${scope}). ` +
109
- `Fail-fast: unknown mdast block nodes must not be silently dropped.`
110
- ) as ParseError
111
- err.code = 'MDAST_UNSUPPORTED_NODE'
112
- err.file = file
113
- throw err
114
- }
115
- }
116
- }
117
- return out
118
- }
119
-
120
- // ── Individual block converters ──────────────────────────
121
-
122
- function walkHeading (n: MdNode, file: string): HeadingBlock {
123
- const d = n.depth ?? 1
124
- if (d < 1 || d > 6) {
125
- // Defensive clamp (should not occur with standard mdast output).
126
- }
127
- // 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.
133
- // - `mdDepth` is TRANSIENT: domain output render/clone paths must drop
134
- // it so it never appears in the canonical Artifact content.
135
- const clamped = (d < 1 ? 1 : d > 6 ? 6 : d)
136
- const syntheticLevel = (clamped <= 4 ? clamped : 4) as 1 | 2 | 3 | 4
137
- const children = walkInlines(n.children ?? [], file)
138
- const h: HeadingBlock = {
139
- type: 'heading',
140
- level: syntheticLevel,
141
- children,
142
- mdDepth: clamped
143
- }
144
- void file
145
- return h
146
- }
147
-
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) }
166
- }
167
-
168
- /**
169
- * Quote block R-02 behaviour: a soft break inside a single wrapped
170
- * paragraph renders as a TextInline('\n'), merging both halves into one
171
- * ParagraphBlock with a literal newline rather than two paragraphs.
172
- * Paragraphs separated by blank lines inside the quote remain separate.
173
- */
174
- function walkBlockquote (n: MdNode, file: string, source: string): QuoteBlock {
175
- const inner = walkBlocks(n.children ?? [], file, 'blockquote', source)
176
- return { type: 'quote', children: inner }
177
- }
178
-
179
- function walkList (n: MdNode, file: string): ListBlock {
180
- const ordered = !!n.ordered
181
- const items: Inline[][] = []
182
- for (const li of n.children ?? []) {
183
- const itemInlines: Inline[] = []
184
- const firstBlocks = li.children ?? []
185
- let emitted = 0
186
- for (const b of firstBlocks) {
187
- if (b.type === 'paragraph') {
188
- if (emitted > 0) itemInlines.push({ type: 'text', value: '\n' })
189
- itemInlines.push(...walkInlines(b.children ?? [], file))
190
- emitted++
191
- } else {
192
- const err = new Error(
193
- `[MDAST_UNSUPPORTED_NODE] Unsupported list-item child type '${b.type}' in ${file}. ` +
194
- `Fail-fast: non-paragraph list item children are not supported.`
195
- ) as ParseError
196
- err.code = 'MDAST_UNSUPPORTED_NODE'
197
- err.file = file
198
- throw err
199
- }
200
- }
201
- items.push(itemInlines)
202
- }
203
- return { type: 'list', ordered, items }
204
- }
205
-
206
- function walkTable (n: MdNode, file: string): TableBlock {
207
- const kids = n.children ?? []
208
- const headers: TableCell[] = []
209
- const rows: TableCell[][] = []
210
- for (let i = 0; i < kids.length; i++) {
211
- const row = kids[i]!
212
- const rowCells: TableCell[] = []
213
- const cells = row.children ?? []
214
- for (const cell of cells) rowCells.push(walkInlines(cell.children ?? [], file))
215
- if (i === 0) for (const c of rowCells) headers.push(c)
216
- else rows.push(rowCells)
217
- }
218
- return { type: 'table', headers, rows }
219
- }
220
-
221
- function walkImage (n: MdNode): ImageBlock {
222
- return { type: 'image', src: n.url ?? '', alt: n.alt ?? '' }
223
- }
224
-
225
- function walkCode (n: MdNode): CodeBlock {
226
- return { type: 'code', language: n.lang ?? '', code: n.value ?? '' }
227
- }
228
-
229
- function walkFormula (n: MdNode): FormulaBlock {
230
- // Block-level LaTeX fence mdast node: value = latex body; display = true.
231
- return { type: 'formula', latex: n.value ?? '', display: true }
232
- }
233
-
234
- /**
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.
238
- */
239
- function walkContainerDirective (n: MdNode, file: string, source: string): ContainerBlock {
240
- const name = n.name ?? 'unknown'
241
- const attrsIn = n.attributes ?? {}
242
- const attrs: Record<string, string> = {}
243
- for (const key of Object.keys(attrsIn)) {
244
- const v = attrsIn[key]
245
- attrs[key] = v == null ? '' : String(v)
246
- }
247
- const children = walkBlocks(n.children ?? [], file, 'container', source)
248
- return { type: 'container', name, attrs, children }
249
- }
250
-
251
- // ── Inline walkers ────────────────────────────────────────
252
-
253
- function walkInlines (nodes: MdNode[], file: string): Inline[] {
254
- const out: Inline[] = []
255
- for (const n of nodes) {
256
- switch (n.type) {
257
- case 'text':
258
- out.push({ type: 'text', value: n.value ?? '' })
259
- break
260
- case 'strong':
261
- out.push({ type: 'bold', children: walkInlines(n.children ?? [], file) })
262
- break
263
- case 'emphasis':
264
- out.push({ type: 'italic', children: walkInlines(n.children ?? [], file) })
265
- break
266
- case 'inlineCode':
267
- out.push({ type: 'code', value: n.value ?? '' })
268
- break
269
- case 'link':
270
- out.push({
271
- type: 'link',
272
- url: n.url ?? '',
273
- children: walkInlines(n.children ?? [], file)
274
- })
275
- break
276
- case 'image':
277
- out.push({ type: 'text', value: n.alt ?? n.url ?? '' })
278
- break
279
- case NODE_FORMULA_INLINE:
280
- out.push({ type: 'math', latex: n.value ?? '' })
281
- break
282
- case 'break':
283
- case 'softbreak':
284
- out.push({ type: 'text', value: '\n' })
285
- break
286
- case 'html':
287
- out.push({ type: 'text', value: n.value ?? '' })
288
- break
289
- case 'linkReference':
290
- out.push({
291
- type: 'link',
292
- url: n.identifier ?? '',
293
- children: walkInlines(n.children ?? [], file)
294
- })
295
- break
296
- case 'textDirective':
297
- case 'leafDirective':
298
- out.push(...walkInlines(n.children ?? [], file))
299
- break
300
- default:
301
- if (n.children && Array.isArray(n.children)) {
302
- out.push(...walkInlines(n.children, file))
303
- }
304
- break
305
- }
306
- }
307
- return out
308
- }
309
-
310
- // Keep imports used (silence warnings).
311
- export type { ParseError }
1
+ // ─────────────────────────────────────────────────────────────
2
+ // dexin-content/core/parser/markdown.ts
3
+ // Unified pipeline → neutral AST.
4
+ //
5
+ // Responsibilities:
6
+ // * Parse markdown source (GFM tables/lists, LaTeX-format spans,
7
+ // generic directive containers) into MDAST nodes.
8
+ // * Walk MDAST nodes and produce Block[] arrays with
9
+ // full inline-format coverage.
10
+ // * ContainerBlock (6.3.12 frozen): always neutral, attributes =
11
+ // Record<string,string> (derived from directive attributes).
12
+ // * Heading boundary behaviour for out-of-range markdown depth is
13
+ // delegated to the active domain parser; the generic layer clamps
14
+ // the syntactic level and records the original depth so domain
15
+ // parsers can run their own fail-fast checks.
16
+ // * Quote paragraph merging: soft newlines inside a single quote-
17
+ // wrapped paragraph collapse into one ParagraphBlock with a literal
18
+ // '\n' text node between its inlines (R-02). Separated paragraphs
19
+ // (blank-line delimited) remain separate, inside neutral
20
+ // ContainerBlock nodes (R-04), because blank lines mark paragraph
21
+ // boundaries.
22
+ // ─────────────────────────────────────────────────────────────
23
+
24
+ import { createRequire } from 'node:module'
25
+ import { fileURLToPath } from 'node:url'
26
+
27
+ import type { Inline, ParseError } from '../types'
28
+ import type {
29
+ LessonContent,
30
+ Block,
31
+ HeadingBlock,
32
+ ParagraphBlock,
33
+ QuoteBlock,
34
+ ListBlock,
35
+ TableBlock,
36
+ TableCell,
37
+ ImageBlock,
38
+ CodeBlock,
39
+ FormulaBlock,
40
+ ContainerBlock
41
+ } from '../types/lessonAST'
42
+ import type { Plugin, Pluggable } from 'unified'
43
+ import { TEX_INLINE_TYPE } from '../types'
44
+
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
81
+
82
+ // Minimal MDAST subset. The plugin output is walked by shape only.
83
+ interface MdNode {
84
+ type: string
85
+ depth?: number
86
+ children?: MdNode[]
87
+ value?: string
88
+ alt?: string
89
+ url?: string
90
+ identifier?: string
91
+ label?: string
92
+ lang?: string
93
+ meta?: string
94
+ ordered?: boolean
95
+ spread?: boolean
96
+ start?: number
97
+ align?: ('left' | 'right' | 'center' | null)[]
98
+ position?: unknown
99
+ name?: string
100
+ attributes?: Record<string, string | null | undefined> | null
101
+ }
102
+
103
+ // ── Entry point ──────────────────────────────────────────
104
+
105
+ export function parseToDocAST (source: string, file: string): LessonContent {
106
+ const mdast = unified()
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)
111
+ .parse(source) as MdNode
112
+
113
+ const blocks = walkBlocks(mdast.children ?? [], file, 'root', source)
114
+ return { version: 1, blocks }
115
+ }
116
+
117
+ export { parseToDocAST as parseDocument }
118
+
119
+ // ── Block walkers ─────────────────────────────────────────
120
+
121
+ type BlockScope = 'root' | 'blockquote' | 'container'
122
+
123
+ function walkBlocks (nodes: MdNode[], file: string, scope: BlockScope, source: string): Block[] {
124
+ const out: Block[] = []
125
+ for (const n of nodes) {
126
+ switch (n.type) {
127
+ case 'heading': out.push(walkHeading(n, file)); break
128
+ case 'paragraph': out.push(walkParagraph(n, file, source)); break
129
+ case 'blockquote': out.push(walkBlockquote(n, file, source)); break
130
+ case 'thematicBreak': out.push({ type: 'divider' }); break
131
+ case 'list': out.push(walkList(n, file)); break
132
+ case 'table': out.push(walkTable(n, file)); break
133
+ case 'image': out.push(walkImage(n)); break
134
+ case 'code': out.push(walkCode(n)); break
135
+ case NODE_FORMULA_BLOCK: out.push(walkFormula(n)); break
136
+ case 'containerDirective': out.push(walkContainerDirective(n, file, source)); break
137
+ default: {
138
+ const err = new Error(
139
+ `[MDAST_UNSUPPORTED_NODE] Unsupported block node type '${n.type}' in ${file} (scope: ${scope}). ` +
140
+ `Fail-fast: unknown mdast block nodes must not be silently dropped.`
141
+ ) as ParseError
142
+ err.code = 'MDAST_UNSUPPORTED_NODE'
143
+ err.file = file
144
+ throw err
145
+ }
146
+ }
147
+ }
148
+ return out
149
+ }
150
+
151
+ // ── Individual block converters ──────────────────────────
152
+
153
+ function walkHeading (n: MdNode, file: string): HeadingBlock {
154
+ const d = n.depth ?? 1
155
+ if (d < 1 || d > 6) {
156
+ // Defensive clamp (should not occur with standard mdast output).
157
+ }
158
+ // Boundary behaviour for heading depth is the active domain parser's
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).
165
+ // - `mdDepth` is TRANSIENT: domain output render/clone paths must drop
166
+ // it so it never appears in the canonical Artifact content.
167
+ const clamped = (d < 1 ? 1 : d > 6 ? 6 : d)
168
+ const syntheticLevel = (clamped <= 4 ? clamped : 4) as 1 | 2 | 3 | 4
169
+ const children = walkInlines(n.children ?? [], file)
170
+ const h: HeadingBlock = {
171
+ type: 'heading',
172
+ level: syntheticLevel,
173
+ children,
174
+ mdDepth: clamped
175
+ }
176
+ void file
177
+ return h
178
+ }
179
+
180
+ function walkParagraph (n: MdNode, file: string, source: string): ParagraphBlock | FormulaBlock {
181
+ // Block Math rescue (P-0 fix, ported from 0.1.2): remark-math 6.0.0 fails
182
+ // to recognize a single-line `$$...$$` (delimiter on the same line as the
183
+ // content) as a block-level math node, producing paragraph > inlineMath
184
+ // instead of a top-level `math` node. When a paragraph consists of exactly
185
+ // one inline math child whose original source delimiter is `$$` (verified
186
+ // via the node's position offset into `source`), promote it to
187
+ // FormulaBlock(display=true). A single `$` inline is never promoted.
188
+ const children = n.children ?? []
189
+ if (children.length === 1 && children[0]?.type === NODE_FORMULA_INLINE) {
190
+ const child = children[0]!
191
+ const pos = child.position as { start?: { offset?: number } } | undefined
192
+ const startOffset = pos?.start?.offset
193
+ if (startOffset !== undefined && source.slice(startOffset, startOffset + 2) === '$$') {
194
+ return { type: 'formula', latex: child.value ?? '', display: true }
195
+ }
196
+ }
197
+ return { type: 'paragraph', children: walkInlines(children, file) }
198
+ }
199
+
200
+ /**
201
+ * Quote block R-02 behaviour: a soft break inside a single wrapped
202
+ * paragraph renders as a TextInline('\n'), merging both halves into one
203
+ * ParagraphBlock with a literal newline rather than two paragraphs.
204
+ * Paragraphs separated by blank lines inside the quote remain separate.
205
+ */
206
+ function walkBlockquote (n: MdNode, file: string, source: string): QuoteBlock {
207
+ const inner = walkBlocks(n.children ?? [], file, 'blockquote', source)
208
+ return { type: 'quote', children: inner }
209
+ }
210
+
211
+ function walkList (n: MdNode, file: string): ListBlock {
212
+ const ordered = !!n.ordered
213
+ const items: Inline[][] = []
214
+ for (const li of n.children ?? []) {
215
+ const itemInlines: Inline[] = []
216
+ const firstBlocks = li.children ?? []
217
+ let emitted = 0
218
+ for (const b of firstBlocks) {
219
+ if (b.type === 'paragraph') {
220
+ if (emitted > 0) itemInlines.push({ type: 'text', value: '\n' })
221
+ itemInlines.push(...walkInlines(b.children ?? [], file))
222
+ emitted++
223
+ } else {
224
+ const err = new Error(
225
+ `[MDAST_UNSUPPORTED_NODE] Unsupported list-item child type '${b.type}' in ${file}. ` +
226
+ `Fail-fast: non-paragraph list item children are not supported.`
227
+ ) as ParseError
228
+ err.code = 'MDAST_UNSUPPORTED_NODE'
229
+ err.file = file
230
+ throw err
231
+ }
232
+ }
233
+ items.push(itemInlines)
234
+ }
235
+ return { type: 'list', ordered, items }
236
+ }
237
+
238
+ function walkTable (n: MdNode, file: string): TableBlock {
239
+ const kids = n.children ?? []
240
+ const headers: TableCell[] = []
241
+ const rows: TableCell[][] = []
242
+ for (let i = 0; i < kids.length; i++) {
243
+ const row = kids[i]
244
+ const rowCells: TableCell[] = []
245
+ const cells = row?.children ?? []
246
+ for (const cell of cells) rowCells.push(walkInlines(cell.children ?? [], file))
247
+ if (i === 0) for (const c of rowCells) headers.push(c)
248
+ else rows.push(rowCells)
249
+ }
250
+ return { type: 'table', headers, rows }
251
+ }
252
+
253
+ function walkImage (n: MdNode): ImageBlock {
254
+ return { type: 'image', src: n.url ?? '', alt: n.alt ?? '' }
255
+ }
256
+
257
+ function walkCode (n: MdNode): CodeBlock {
258
+ return { type: 'code', language: n.lang ?? '', code: n.value ?? '' }
259
+ }
260
+
261
+ function walkFormula (n: MdNode): FormulaBlock {
262
+ // Block-level LaTeX fence mdast node: value = latex body; display = true.
263
+ return { type: 'formula', latex: n.value ?? '', display: true }
264
+ }
265
+
266
+ /**
267
+ * Directive container → neutral ContainerBlock (6.3.12 frozen).
268
+ * Interpretation of hint/definition/example/question is deferred to
269
+ * the active domain parser; here the structure is preserved verbatim.
270
+ */
271
+ function walkContainerDirective (n: MdNode, file: string, source: string): ContainerBlock {
272
+ const name = n.name ?? 'unknown'
273
+ const attrsIn = n.attributes ?? {}
274
+ const attrs: Record<string, string> = {}
275
+ for (const key of Object.keys(attrsIn)) {
276
+ const v = attrsIn[key]
277
+ attrs[key] = v == null ? '' : String(v)
278
+ }
279
+ const children = walkBlocks(n.children ?? [], file, 'container', source)
280
+ return { type: 'container', name, attrs, children }
281
+ }
282
+
283
+ // ── Inline walkers ────────────────────────────────────────
284
+
285
+ function walkInlines (nodes: MdNode[], file: string): Inline[] {
286
+ const out: Inline[] = []
287
+ for (const n of nodes) {
288
+ switch (n.type) {
289
+ case 'text':
290
+ out.push({ type: 'text', value: n.value ?? '' })
291
+ break
292
+ case 'strong':
293
+ out.push({ type: 'bold', children: walkInlines(n.children ?? [], file) })
294
+ break
295
+ case 'emphasis':
296
+ out.push({ type: 'italic', children: walkInlines(n.children ?? [], file) })
297
+ break
298
+ case 'inlineCode':
299
+ out.push({ type: 'code', value: n.value ?? '' })
300
+ break
301
+ case 'link':
302
+ out.push({
303
+ type: 'link',
304
+ url: n.url ?? '',
305
+ children: walkInlines(n.children ?? [], file)
306
+ })
307
+ break
308
+ case 'image':
309
+ out.push({ type: 'text', value: n.alt ?? n.url ?? '' })
310
+ break
311
+ case NODE_FORMULA_INLINE:
312
+ out.push({ type: 'math' as const, latex: n.value ?? '' } as const)
313
+ break
314
+ case 'break':
315
+ case 'softbreak':
316
+ out.push({ type: 'text', value: '\n' })
317
+ break
318
+ case 'html':
319
+ out.push({ type: 'text', value: n.value ?? '' })
320
+ break
321
+ case 'linkReference':
322
+ out.push({
323
+ type: 'link',
324
+ url: n.identifier ?? '',
325
+ children: walkInlines(n.children ?? [], file)
326
+ })
327
+ break
328
+ case 'textDirective':
329
+ case 'leafDirective':
330
+ out.push(...walkInlines(n.children ?? [], file))
331
+ break
332
+ default:
333
+ if (n.children && Array.isArray(n.children)) {
334
+ out.push(...walkInlines(n.children, file))
335
+ }
336
+ break
337
+ }
338
+ }
339
+ return out
340
+ }
341
+
342
+ // Keep imports used (silence warnings).
343
+ export type { ParseError }