@airalogy/aimd-renderer 2.4.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.
@@ -0,0 +1,1554 @@
1
+ import type { Element, Properties, Root as HastRoot, Text as HastText } from "hast"
2
+ import type { VFile } from "vfile"
3
+ import type { VNode } from "vue"
4
+ import type {
5
+ AimdFieldType,
6
+ AimdNode,
7
+ AimdQuizNode,
8
+ AimdStepNode,
9
+ ProcessorOptions,
10
+ RenderContext,
11
+ } from "@airalogy/aimd-core/types"
12
+ import type { ExtractedAimdFields } from "@airalogy/aimd-core/types"
13
+ import type { AimdRendererI18nOptions } from "../locales"
14
+ import type { VueRendererOptions } from "../vue/vue-renderer"
15
+ import { resolveQuizPreviewOptions } from "./quiz-preview"
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Sub-module imports
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import { remarkInsertVisibleAssigners, remarkStripAssignerCodeBlocks } from "./assignerVisibility"
22
+ import { highlightVisibleAssigners } from "./assignerHighlighting"
23
+ import { annotateStepReferenceSequence } from "./annotateStepReferences"
24
+ import { buildFigureChildren, assignFigureSequenceNumbers } from "./figureNumbering"
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Public types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Render result
32
+ */
33
+ export interface RenderResult {
34
+ nodes: VNode[]
35
+ fields: ExtractedAimdFields
36
+ }
37
+
38
+ export type AimdAssignerVisibility = "hidden" | "collapsed" | "expanded"
39
+
40
+ export interface AimdHtmlRendererContext extends RenderContext {
41
+ locale: NonNullable<AimdRendererI18nOptions["locale"]> | string
42
+ messages: ReturnType<typeof createAimdRendererMessages>
43
+ }
44
+
45
+ export type AimdHtmlNodeRenderer = (
46
+ node: AimdNode,
47
+ defaultElement: Element,
48
+ context: AimdHtmlRendererContext,
49
+ ) => Element | null | undefined
50
+
51
+ export interface AimdRendererOptions extends ProcessorOptions, AimdRendererI18nOptions {
52
+ assignerVisibility?: AimdAssignerVisibility
53
+ aimdElementRenderers?: Partial<Record<AimdFieldType, AimdHtmlNodeRenderer>>
54
+ groupStepBodies?: boolean
55
+ groupCheckBodies?: boolean
56
+ blockVarTypes?: string[]
57
+ }
58
+
59
+ export interface CustomElementAimdRendererOptions {
60
+ container?: boolean
61
+ stripDefaultChildren?: boolean
62
+ }
63
+
64
+ function assignAimdNodeData(element: Element, node: AimdNode): Element {
65
+ const existingData = (element as any).data || {}
66
+ ;(element as any).data = {
67
+ ...existingData,
68
+ aimd: node,
69
+ }
70
+ return element
71
+ }
72
+
73
+ function cleanProperties(properties: Properties): Properties {
74
+ return Object.fromEntries(
75
+ Object.entries(properties).filter(([, value]) => value !== undefined && value !== null),
76
+ ) as Properties
77
+ }
78
+
79
+ export function createCustomElementAimdRenderer(
80
+ tagName: string,
81
+ mapProperties?: (
82
+ node: AimdNode,
83
+ context: AimdHtmlRendererContext,
84
+ defaultElement: Element,
85
+ ) => Properties,
86
+ options: CustomElementAimdRendererOptions = {},
87
+ ): AimdHtmlNodeRenderer {
88
+ return (node, defaultElement, context) => {
89
+ const mappedProperties = mapProperties ? mapProperties(node, context, defaultElement) : {}
90
+ return assignAimdNodeData({
91
+ ...defaultElement,
92
+ tagName,
93
+ properties: cleanProperties({
94
+ ...defaultElement.properties,
95
+ ...mappedProperties,
96
+ ...(options.container ? { "data-aimd-step-container": "true" } : {}),
97
+ ...(options.stripDefaultChildren ? { "data-aimd-strip-default-children": "true" } : {}),
98
+ }),
99
+ }, node)
100
+ }
101
+ }
102
+
103
+ function isWhitespaceTextNode(node: unknown): node is HastText {
104
+ return typeof node === "object"
105
+ && node !== null
106
+ && (node as HastText).type === "text"
107
+ && !String((node as HastText).value || "").trim()
108
+ }
109
+
110
+ function isElementNode(node: unknown): node is Element {
111
+ return typeof node === "object"
112
+ && node !== null
113
+ && (node as Element).type === "element"
114
+ }
115
+
116
+ function toCamelCaseKey(value: string): string {
117
+ return value.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase())
118
+ }
119
+
120
+ function getPropertyValue(properties: Properties | undefined, key: string): string | undefined {
121
+ const value = properties?.[key] ?? properties?.[toCamelCaseKey(key)]
122
+ if (typeof value === "string") {
123
+ return value
124
+ }
125
+ if (typeof value === "boolean" || typeof value === "number") {
126
+ return String(value)
127
+ }
128
+ if (Array.isArray(value) && value.length > 0) {
129
+ const first = value[0]
130
+ return typeof first === "string" ? first : undefined
131
+ }
132
+ return undefined
133
+ }
134
+
135
+ function isAimdStepElement(node: unknown): node is Element {
136
+ return typeof node === "object"
137
+ && node !== null
138
+ && (node as Element).type === "element"
139
+ && getPropertyValue((node as Element).properties, "data-aimd-type") === "step"
140
+ }
141
+
142
+ function isAimdCheckElement(node: unknown): node is Element {
143
+ return typeof node === "object"
144
+ && node !== null
145
+ && (node as Element).type === "element"
146
+ && getPropertyValue((node as Element).properties, "data-aimd-type") === "check"
147
+ }
148
+
149
+ function isHeadingOrDivider(node: unknown): boolean {
150
+ if (typeof node !== "object" || node === null || (node as Element).type !== "element") {
151
+ return false
152
+ }
153
+
154
+ const tagName = (node as Element).tagName
155
+ return tagName === "hr" || /^h[1-6]$/.test(tagName)
156
+ }
157
+
158
+ function unwrapStandaloneContainerStep(node: unknown): Element | null {
159
+ if (isAimdStepElement(node)) {
160
+ return getPropertyValue(node.properties, "data-aimd-step-container") === "true" ? node : null
161
+ }
162
+
163
+ if (
164
+ typeof node !== "object"
165
+ || node === null
166
+ || (node as Element).type !== "element"
167
+ || (node as Element).tagName !== "p"
168
+ ) {
169
+ return null
170
+ }
171
+
172
+ const meaningfulChildren = ((node as Element).children || []).filter((child) => !isWhitespaceTextNode(child))
173
+ if (meaningfulChildren.length !== 1 || !isAimdStepElement(meaningfulChildren[0])) {
174
+ return null
175
+ }
176
+
177
+ const stepElement = meaningfulChildren[0]
178
+ return getPropertyValue(stepElement.properties, "data-aimd-step-container") === "true" ? stepElement : null
179
+ }
180
+
181
+ function cloneNodeForStepBody<T extends Element | HastText>(node: T): T {
182
+ return JSON.parse(JSON.stringify(node)) as T
183
+ }
184
+
185
+ function groupCheckBodiesInParent(parent: HastRoot | Element): void {
186
+ const originalChildren = (parent.children || []) as Array<Element | HastText>
187
+ const nextChildren: Array<Element | HastText> = []
188
+
189
+ for (let index = 0; index < originalChildren.length; index += 1) {
190
+ const currentNode = originalChildren[index]
191
+
192
+ if (typeof currentNode === "object" && currentNode !== null && (currentNode as Element).type === "element") {
193
+ groupCheckBodiesInParent(currentNode as Element)
194
+ }
195
+
196
+ if (
197
+ typeof currentNode !== "object"
198
+ || currentNode === null
199
+ || (currentNode as Element).type !== "element"
200
+ || (currentNode as Element).tagName !== "p"
201
+ ) {
202
+ nextChildren.push(currentNode)
203
+ continue
204
+ }
205
+
206
+ const paragraph = currentNode as Element
207
+ const paragraphChildren = (paragraph.children || []) as Array<Element | HastText>
208
+ const meaningfulChildren = paragraphChildren.filter((child) => !isWhitespaceTextNode(child))
209
+
210
+ if (meaningfulChildren.length < 2 || !isAimdCheckElement(meaningfulChildren[0])) {
211
+ nextChildren.push(currentNode)
212
+ continue
213
+ }
214
+
215
+ const checkElement = meaningfulChildren[0] as Element
216
+ const checkIndex = paragraphChildren.indexOf(checkElement)
217
+ if (checkIndex < 0) {
218
+ nextChildren.push(currentNode)
219
+ continue
220
+ }
221
+
222
+ const tailChildren = paragraphChildren
223
+ .slice(checkIndex + 1)
224
+ .map((child) => cloneNodeForStepBody(child as Element | HastText))
225
+ .filter((child) => !isWhitespaceTextNode(child))
226
+
227
+ if (tailChildren.length === 0) {
228
+ nextChildren.push(currentNode)
229
+ continue
230
+ }
231
+
232
+ checkElement.properties = {
233
+ ...(checkElement.properties || {}),
234
+ "data-aimd-check-container": "true",
235
+ "data-aimd-strip-default-children": "true",
236
+ }
237
+ checkElement.children = [{
238
+ type: "element",
239
+ tagName: "div",
240
+ properties: {
241
+ className: ["aimd-check-body"],
242
+ "data-aimd-check-body": "true",
243
+ },
244
+ children: tailChildren,
245
+ }]
246
+
247
+ nextChildren.push(checkElement)
248
+ }
249
+
250
+ parent.children = nextChildren
251
+ }
252
+
253
+ function groupCheckBodies(tree: HastRoot): void {
254
+ groupCheckBodiesInParent(tree)
255
+ }
256
+
257
+ function groupStepBodiesInParent(parent: HastRoot | Element): void {
258
+ const originalChildren = (parent.children || []) as Array<Element | HastText>
259
+ const nextChildren: Array<Element | HastText> = []
260
+
261
+ for (let index = 0; index < originalChildren.length; index += 1) {
262
+ const currentNode = originalChildren[index]
263
+ const stepContainer = unwrapStandaloneContainerStep(currentNode)
264
+
265
+ if (!stepContainer) {
266
+ if (typeof currentNode === "object" && currentNode !== null && (currentNode as Element).type === "element") {
267
+ groupStepBodiesInParent(currentNode as Element)
268
+ }
269
+ nextChildren.push(currentNode)
270
+ continue
271
+ }
272
+
273
+ const bodyChildren: Array<Element | HastText> = []
274
+ let scanIndex = index + 1
275
+ while (scanIndex < originalChildren.length) {
276
+ const candidate = originalChildren[scanIndex]
277
+ if (unwrapStandaloneContainerStep(candidate) || isHeadingOrDivider(candidate)) {
278
+ break
279
+ }
280
+
281
+ if (typeof candidate === "object" && candidate !== null && (candidate as Element).type === "element") {
282
+ groupStepBodiesInParent(candidate as Element)
283
+ }
284
+
285
+ bodyChildren.push(cloneNodeForStepBody(candidate as Element | HastText))
286
+ scanIndex += 1
287
+ }
288
+
289
+ const stripChildren = getPropertyValue(stepContainer.properties, "data-aimd-strip-default-children") === "true"
290
+ stepContainer.children = stripChildren ? [] : [...(stepContainer.children || [])]
291
+ if (bodyChildren.length > 0) {
292
+ stepContainer.children.push({
293
+ type: "element",
294
+ tagName: "div",
295
+ properties: {
296
+ className: ["aimd-step-body"],
297
+ "data-aimd-step-body": "true",
298
+ },
299
+ children: bodyChildren,
300
+ })
301
+ }
302
+
303
+ nextChildren.push(stepContainer)
304
+ index = scanIndex - 1
305
+ }
306
+
307
+ ;(parent as HastRoot | Element).children = nextChildren as any
308
+ }
309
+
310
+ function groupStepBodies(tree: HastRoot): void {
311
+ groupStepBodiesInParent(tree)
312
+ }
313
+
314
+ function normalizeAimdTypeToken(value: unknown): string {
315
+ return typeof value === "string" ? value.trim().toLowerCase() : ""
316
+ }
317
+
318
+ function getAimdNodeFromElement(element: Element): AimdNode | null {
319
+ const fromData = (element as any).data?.aimd
320
+ if (fromData) {
321
+ return fromData as AimdNode
322
+ }
323
+
324
+ const rawJson = getPropertyValue(element.properties, "data-aimd-json")
325
+ if (!rawJson) {
326
+ return null
327
+ }
328
+
329
+ try {
330
+ return JSON.parse(rawJson) as AimdNode
331
+ } catch {
332
+ return null
333
+ }
334
+ }
335
+
336
+ function isBlockVarElement(node: unknown, blockVarTypes: Set<string>): node is Element {
337
+ if (typeof node !== "object" || node === null || (node as Element).type !== "element") {
338
+ return false
339
+ }
340
+
341
+ const aimdNode = getAimdNodeFromElement(node as Element)
342
+ if (!aimdNode || aimdNode.fieldType !== "var") {
343
+ return false
344
+ }
345
+
346
+ return blockVarTypes.has(normalizeAimdTypeToken((aimdNode as any).definition?.type))
347
+ }
348
+
349
+ function promoteBlockVarElement(element: Element): Element {
350
+ const aimdNode = getAimdNodeFromElement(element)
351
+ const className = Array.isArray(element.properties.className)
352
+ ? [...element.properties.className]
353
+ : element.properties.className
354
+ ? [element.properties.className]
355
+ : []
356
+
357
+ const promoted = {
358
+ ...element,
359
+ tagName: "div",
360
+ properties: {
361
+ ...element.properties,
362
+ className: [...className, "aimd-block-var"],
363
+ },
364
+ } as Element
365
+
366
+ return aimdNode ? assignAimdNodeData(promoted, aimdNode) : promoted
367
+ }
368
+
369
+ function cloneParagraphWithChildren(paragraph: Element, children: Array<Element | HastText>): Element {
370
+ return {
371
+ ...paragraph,
372
+ children,
373
+ } as Element
374
+ }
375
+
376
+ function createInlineParagraph(children: Array<Element | HastText>): Element {
377
+ return {
378
+ type: "element",
379
+ tagName: "p",
380
+ properties: {},
381
+ children,
382
+ }
383
+ }
384
+
385
+ function liftBlockVarParagraph(paragraph: Element, blockVarTypes: Set<string>): Array<Element | HastText> {
386
+ const nextNodes: Array<Element | HastText> = []
387
+ let inlineRun: Array<Element | HastText> = []
388
+
389
+ const flushInlineRun = () => {
390
+ if (!inlineRun.some((child) => !isWhitespaceTextNode(child))) {
391
+ inlineRun = []
392
+ return
393
+ }
394
+
395
+ nextNodes.push(cloneParagraphWithChildren(paragraph, inlineRun))
396
+ inlineRun = []
397
+ }
398
+
399
+ for (const child of (paragraph.children || []) as Array<Element | HastText>) {
400
+ if (isBlockVarElement(child, blockVarTypes)) {
401
+ flushInlineRun()
402
+ nextNodes.push(promoteBlockVarElement(child))
403
+ continue
404
+ }
405
+
406
+ inlineRun.push(child)
407
+ }
408
+
409
+ flushInlineRun()
410
+ return nextNodes.length > 0 ? nextNodes : [paragraph]
411
+ }
412
+
413
+ function liftBlockVarListItem(listItem: Element, blockVarTypes: Set<string>): Element {
414
+ const originalChildren = (listItem.children || []) as Array<Element | HastText>
415
+ const nextChildren: Array<Element | HastText> = []
416
+ let inlineRun: Array<Element | HastText> = []
417
+ let foundDirectBlockVar = false
418
+
419
+ const flushInlineRun = () => {
420
+ if (!inlineRun.some((child) => !isWhitespaceTextNode(child))) {
421
+ inlineRun = []
422
+ return
423
+ }
424
+
425
+ nextChildren.push(createInlineParagraph(inlineRun))
426
+ inlineRun = []
427
+ }
428
+
429
+ for (const child of originalChildren) {
430
+ if (isBlockVarElement(child, blockVarTypes)) {
431
+ foundDirectBlockVar = true
432
+ flushInlineRun()
433
+ nextChildren.push(promoteBlockVarElement(child))
434
+ continue
435
+ }
436
+
437
+ if (isElementNode(child)) {
438
+ flushInlineRun()
439
+ nextChildren.push(child)
440
+ continue
441
+ }
442
+
443
+ inlineRun.push(child)
444
+ }
445
+
446
+ flushInlineRun()
447
+
448
+ return foundDirectBlockVar
449
+ ? {
450
+ ...listItem,
451
+ children: nextChildren,
452
+ } as Element
453
+ : listItem
454
+ }
455
+
456
+ function liftBlockVarElementsInParent(parent: HastRoot | Element, blockVarTypes: Set<string>): void {
457
+ const nextChildren: Array<Element | HastText> = []
458
+
459
+ for (const child of (parent.children || []) as Array<Element | HastText>) {
460
+ if (typeof child !== "object" || child === null || child.type !== "element") {
461
+ nextChildren.push(child)
462
+ continue
463
+ }
464
+
465
+ const clonedChild = { ...child } as Element
466
+ if (clonedChild.children?.length) {
467
+ liftBlockVarElementsInParent(clonedChild, blockVarTypes)
468
+ }
469
+
470
+ if (clonedChild.tagName === "p") {
471
+ nextChildren.push(...liftBlockVarParagraph(clonedChild, blockVarTypes))
472
+ continue
473
+ }
474
+
475
+ if (clonedChild.tagName === "li") {
476
+ nextChildren.push(liftBlockVarListItem(clonedChild, blockVarTypes))
477
+ continue
478
+ }
479
+
480
+ nextChildren.push(clonedChild)
481
+ }
482
+
483
+ ;(parent as HastRoot | Element).children = nextChildren as any
484
+ }
485
+
486
+ function liftBlockVarElements(tree: HastRoot, blockVarTypes?: string[]): void {
487
+ const normalizedTypes = new Set(
488
+ (blockVarTypes ?? [])
489
+ .map(type => normalizeAimdTypeToken(type))
490
+ .filter(Boolean),
491
+ )
492
+
493
+ if (normalizedTypes.size === 0) {
494
+ return
495
+ }
496
+
497
+ liftBlockVarElementsInParent(tree, normalizedTypes)
498
+ }
499
+
500
+ // ---------------------------------------------------------------------------
501
+ // Third-party imports
502
+ // ---------------------------------------------------------------------------
503
+
504
+ import { toHtml } from "hast-util-to-html"
505
+ import rehypeKatex from "rehype-katex"
506
+ import rehypeRaw from "rehype-raw"
507
+ import remarkBreaks from "remark-breaks"
508
+ import remarkGfm from "remark-gfm"
509
+ import remarkMath from "remark-math"
510
+ import remarkParse from "remark-parse"
511
+ import remarkRehype from "remark-rehype"
512
+ import { unified } from "unified"
513
+
514
+ import { protectAimdInlineTemplates, remarkAimd } from "@airalogy/aimd-core/parser"
515
+ import {
516
+ createAimdRendererMessages,
517
+ getAimdRendererQuizTypeLabel,
518
+ getAimdRendererScopeLabel,
519
+ } from "../locales"
520
+ import { renderToVNodes } from "../vue/vue-renderer"
521
+
522
+ // ---------------------------------------------------------------------------
523
+ // Module-level singletons
524
+ // ---------------------------------------------------------------------------
525
+
526
+ let mathStylesLoadPromise: Promise<unknown> | null = null
527
+
528
+ // ---------------------------------------------------------------------------
529
+ // Internal helpers that remain in the coordinator
530
+ // ---------------------------------------------------------------------------
531
+
532
+ const EMPTY_EXTRACTED_FIELDS: ExtractedAimdFields = {
533
+ var: [],
534
+ var_table: [],
535
+ client_assigner: [],
536
+ quiz: [],
537
+ step: [],
538
+ check: [],
539
+ ref_step: [],
540
+ ref_var: [],
541
+ ref_fig: [],
542
+ cite: [],
543
+ fig: [],
544
+ }
545
+
546
+ async function ensureMathStylesLoaded(mathEnabled: boolean | undefined): Promise<void> {
547
+ if (mathEnabled === false) {
548
+ return
549
+ }
550
+ if (typeof document === "undefined") {
551
+ return
552
+ }
553
+ if (!mathStylesLoadPromise) {
554
+ mathStylesLoadPromise = import("../styles/katex.css").catch(() => undefined)
555
+ }
556
+ await mathStylesLoadPromise
557
+ }
558
+
559
+ function createAimdParseInput(content: string) {
560
+ const { content: protectedContent, templates } = protectAimdInlineTemplates(content)
561
+ const file: VFile = {
562
+ data: {
563
+ aimdInlineTemplates: templates,
564
+ },
565
+ } as unknown as VFile
566
+
567
+ return {
568
+ content: protectedContent,
569
+ file,
570
+ }
571
+ }
572
+
573
+ function createEmptyExtractedFields(): ExtractedAimdFields {
574
+ return {
575
+ var: [...EMPTY_EXTRACTED_FIELDS.var],
576
+ var_table: [...EMPTY_EXTRACTED_FIELDS.var_table],
577
+ client_assigner: [...EMPTY_EXTRACTED_FIELDS.client_assigner],
578
+ quiz: [...EMPTY_EXTRACTED_FIELDS.quiz],
579
+ step: [...EMPTY_EXTRACTED_FIELDS.step],
580
+ check: [...EMPTY_EXTRACTED_FIELDS.check],
581
+ ref_step: [...EMPTY_EXTRACTED_FIELDS.ref_step],
582
+ ref_var: [...EMPTY_EXTRACTED_FIELDS.ref_var],
583
+ ref_fig: [...(EMPTY_EXTRACTED_FIELDS.ref_fig || [])],
584
+ cite: [...(EMPTY_EXTRACTED_FIELDS.cite || [])],
585
+ fig: [...(EMPTY_EXTRACTED_FIELDS.fig || [])],
586
+ }
587
+ }
588
+
589
+ function getExtractedFields(file: VFile): ExtractedAimdFields {
590
+ return (file.data.aimdFields as ExtractedAimdFields) || createEmptyExtractedFields()
591
+ }
592
+
593
+ // ---------------------------------------------------------------------------
594
+ // Field-type helpers used by createAimdHandler
595
+ // ---------------------------------------------------------------------------
596
+
597
+ /**
598
+ * Map field type to CSS class modifier (BEM format)
599
+ */
600
+ function getFieldTypeClass(fieldType: AimdNode["fieldType"]): string {
601
+ switch (fieldType) {
602
+ case "var_table":
603
+ return "var-table"
604
+ default:
605
+ return fieldType
606
+ }
607
+ }
608
+
609
+ const BLANK_PLACEHOLDER_PATTERN = /\[\[([^\[\]\s]+)\]\]/g
610
+
611
+ function buildQuizStemChildren(
612
+ quizType: AimdQuizNode["quizType"],
613
+ stem: string,
614
+ ): Array<Element | HastText> {
615
+ if (quizType !== "blank") {
616
+ return [{ type: "text", value: stem }]
617
+ }
618
+
619
+ const children: Array<Element | HastText> = []
620
+ let lastIndex = 0
621
+
622
+ for (const match of stem.matchAll(BLANK_PLACEHOLDER_PATTERN)) {
623
+ const start = match.index ?? 0
624
+ const fullMatch = match[0]
625
+ const key = match[1]
626
+
627
+ if (start > lastIndex) {
628
+ children.push({
629
+ type: "text",
630
+ value: stem.slice(lastIndex, start),
631
+ })
632
+ }
633
+
634
+ children.push({
635
+ type: "element",
636
+ tagName: "span",
637
+ properties: {
638
+ className: ["aimd-quiz__blank-placeholder"],
639
+ "data-blank-key": key,
640
+ },
641
+ children: [{ type: "text", value: key }],
642
+ } as Element)
643
+
644
+ lastIndex = start + fullMatch.length
645
+ }
646
+
647
+ if (lastIndex < stem.length) {
648
+ children.push({
649
+ type: "text",
650
+ value: stem.slice(lastIndex),
651
+ })
652
+ }
653
+
654
+ if (children.length === 0) {
655
+ children.push({ type: "text", value: stem })
656
+ }
657
+
658
+ return children
659
+ }
660
+
661
+ function formatScaleOptionLabel(option: NonNullable<AimdQuizNode["options"]>[number]): string {
662
+ if (typeof option.points === "number" && Number.isFinite(option.points)) {
663
+ return `${option.text} (${option.points})`
664
+ }
665
+ return option.text
666
+ }
667
+
668
+ function buildScalePreviewChildren(quizNode: AimdQuizNode): Array<Element | HastText> {
669
+ if (!Array.isArray(quizNode.items) || quizNode.items.length === 0 || !Array.isArray(quizNode.options) || quizNode.options.length === 0) {
670
+ return [{
671
+ type: "element",
672
+ tagName: "div",
673
+ properties: { className: ["aimd-scale__empty"] },
674
+ children: [{ type: "text", value: "Scale definition is incomplete." }],
675
+ } as Element]
676
+ }
677
+
678
+ if (quizNode.display === "list") {
679
+ return [{
680
+ type: "element",
681
+ tagName: "div",
682
+ properties: { className: ["aimd-scale__list"] },
683
+ children: quizNode.items.map(item => ({
684
+ type: "element",
685
+ tagName: "div",
686
+ properties: { className: ["aimd-scale__list-item"] },
687
+ children: [
688
+ {
689
+ type: "element",
690
+ tagName: "div",
691
+ properties: { className: ["aimd-scale__item-stem"] },
692
+ children: [{ type: "text", value: item.stem }],
693
+ } as Element,
694
+ {
695
+ type: "element",
696
+ tagName: "ul",
697
+ properties: { className: ["aimd-scale__item-options"] },
698
+ children: quizNode.options!.map(option => ({
699
+ type: "element",
700
+ tagName: "li",
701
+ properties: {},
702
+ children: [{ type: "text", value: formatScaleOptionLabel(option) }],
703
+ } as Element)),
704
+ } as Element,
705
+ ],
706
+ } as Element)),
707
+ } as Element]
708
+ }
709
+
710
+ return [{
711
+ type: "element",
712
+ tagName: "table",
713
+ properties: { className: ["aimd-scale__table"] },
714
+ children: [
715
+ {
716
+ type: "element",
717
+ tagName: "thead",
718
+ properties: {},
719
+ children: [{
720
+ type: "element",
721
+ tagName: "tr",
722
+ properties: {},
723
+ children: [
724
+ {
725
+ type: "element",
726
+ tagName: "th",
727
+ properties: { className: ["aimd-scale__item-header"] },
728
+ children: [{ type: "text", value: "Item" }],
729
+ } as Element,
730
+ ...quizNode.options.map(option => ({
731
+ type: "element",
732
+ tagName: "th",
733
+ properties: {},
734
+ children: [{ type: "text", value: formatScaleOptionLabel(option) }],
735
+ } as Element)),
736
+ ],
737
+ } as Element],
738
+ } as Element,
739
+ {
740
+ type: "element",
741
+ tagName: "tbody",
742
+ properties: {},
743
+ children: quizNode.items.map(item => ({
744
+ type: "element",
745
+ tagName: "tr",
746
+ properties: {},
747
+ children: [
748
+ {
749
+ type: "element",
750
+ tagName: "th",
751
+ properties: { className: ["aimd-scale__item-stem"], scope: "row" },
752
+ children: [{ type: "text", value: item.stem }],
753
+ } as Element,
754
+ ...quizNode.options!.map(() => ({
755
+ type: "element",
756
+ tagName: "td",
757
+ properties: { className: ["aimd-scale__cell"] },
758
+ children: [{ type: "text", value: "○" }],
759
+ } as Element)),
760
+ ],
761
+ } as Element)),
762
+ } as Element,
763
+ ],
764
+ } as Element]
765
+ }
766
+
767
+ function buildScaleBandChildren(quizNode: AimdQuizNode): Array<Element | HastText> {
768
+ const bands = Array.isArray((quizNode.grading as any)?.bands)
769
+ ? (quizNode.grading as any).bands
770
+ : []
771
+ if (bands.length === 0) {
772
+ return []
773
+ }
774
+
775
+ return [{
776
+ type: "element",
777
+ tagName: "ul",
778
+ properties: { className: ["aimd-scale__bands"] },
779
+ children: bands.map((band: any) => ({
780
+ type: "element",
781
+ tagName: "li",
782
+ properties: {},
783
+ children: [{
784
+ type: "text",
785
+ value: `${band.min}-${band.max}: ${band.label}${band.interpretation ? ` · ${band.interpretation}` : ""}`,
786
+ }],
787
+ } as Element)),
788
+ } as Element]
789
+ }
790
+
791
+ // ---------------------------------------------------------------------------
792
+ // AIMD handler (remark-rehype custom handler)
793
+ // ---------------------------------------------------------------------------
794
+
795
+ /**
796
+ * Custom handler for AIMD nodes in remark-rehype
797
+ * Converts MDAST AIMD nodes to HAST elements
798
+ */
799
+ function createAimdHandler(options: AimdRendererOptions = {}) {
800
+ const quizPreview = resolveQuizPreviewOptions(options.mode ?? "preview", options.quizPreview)
801
+ const messages = createAimdRendererMessages(options.locale, options.messages)
802
+ const htmlRendererContext: AimdHtmlRendererContext = {
803
+ mode: options.mode ?? "preview",
804
+ readonly: false,
805
+ value: undefined,
806
+ quizPreview: options.quizPreview,
807
+ locale: options.locale ?? "en-US",
808
+ messages,
809
+ }
810
+
811
+ return function aimdHandler(state: any, node: AimdNode): Element {
812
+ // Build full node data including step hierarchy
813
+ const nodeData: Record<string, unknown> = {
814
+ type: node.type,
815
+ fieldType: node.fieldType,
816
+ id: node.id,
817
+ scope: node.scope,
818
+ raw: node.raw,
819
+ }
820
+
821
+ // Add type-specific fields
822
+ if ("definition" in node)
823
+ nodeData.definition = node.definition
824
+ if ("columns" in node)
825
+ nodeData.columns = node.columns
826
+ if ("label" in node)
827
+ nodeData.label = node.label
828
+ if ("refTarget" in node)
829
+ nodeData.refTarget = node.refTarget
830
+ if ("checked_message" in node)
831
+ nodeData.checked_message = node.checked_message
832
+
833
+ // Add quiz-specific fields
834
+ if (node.fieldType === "quiz") {
835
+ const quizNode = node as AimdQuizNode
836
+ nodeData.quizType = quizNode.quizType
837
+ nodeData.stem = quizNode.stem
838
+ nodeData.score = quizNode.score
839
+ nodeData.title = quizNode.title
840
+ nodeData.description = quizNode.description
841
+ nodeData.mode = quizNode.mode
842
+ nodeData.display = quizNode.display
843
+ nodeData.options = quizNode.options
844
+ nodeData.answer = quizNode.answer
845
+ nodeData.blanks = quizNode.blanks
846
+ nodeData.items = quizNode.items
847
+ nodeData.rubric = quizNode.rubric
848
+ nodeData.grading = quizNode.grading
849
+ nodeData.default = quizNode.default
850
+ nodeData.extra = quizNode.extra
851
+ }
852
+
853
+ // Add fig-specific fields
854
+ if (node.fieldType === "fig") {
855
+ const figNode = node as any
856
+ nodeData.id = figNode.id
857
+ nodeData.src = figNode.src
858
+ nodeData.title = figNode.title
859
+ nodeData.legend = figNode.legend
860
+ nodeData.sequence = figNode.sequence
861
+ }
862
+
863
+ // Add step-specific fields
864
+ if (node.fieldType === "step") {
865
+ const stepNode = node as AimdStepNode
866
+ nodeData.level = stepNode.level
867
+ nodeData.sequence = stepNode.sequence
868
+ nodeData.step = stepNode.step
869
+ nodeData.parent_id = stepNode.parent_id
870
+ nodeData.prev_id = stepNode.prev_id
871
+ nodeData.next_id = stepNode.next_id
872
+ nodeData.has_children = stepNode.has_children
873
+ nodeData.check = stepNode.check
874
+ nodeData.title = stepNode.title
875
+ nodeData.subtitle = stepNode.subtitle
876
+ nodeData.checked_message = stepNode.checked_message
877
+ nodeData.estimated_duration_ms = stepNode.estimated_duration_ms
878
+ nodeData.timer_mode = stepNode.timer_mode
879
+ nodeData.result = stepNode.result
880
+ nodeData.props = stepNode.props
881
+ }
882
+
883
+ // Serialize AIMD node data to JSON for preservation through rehypeRaw
884
+ const aimdJson = JSON.stringify(nodeData)
885
+
886
+ const fieldType = node.fieldType
887
+ const id = node.id
888
+ const typeClass = getFieldTypeClass(fieldType)
889
+
890
+ // Determine if this is a reference type
891
+ const isRef = fieldType === "ref_step" || fieldType === "ref_var" || fieldType === "ref_fig"
892
+ const isCite = fieldType === "cite"
893
+ const isQuiz = fieldType === "quiz"
894
+ const isFig = fieldType === "fig"
895
+ const baseClass = isRef
896
+ ? "aimd-ref"
897
+ : (isCite ? "aimd-cite" : (isFig ? "aimd-figure" : "aimd-field"))
898
+ const modifierClass = isRef
899
+ ? `aimd-ref--${fieldType === "ref_step" ? "step" : (fieldType === "ref_var" ? "var" : "fig")}`
900
+ : (isCite ? "" : (isFig ? "" : `aimd-field--${typeClass}`))
901
+
902
+ // Generate children based on field type
903
+ const children: (Element | HastText)[] = []
904
+
905
+ if (isRef) {
906
+ // Reference: blockquote-style with appropriate content
907
+ if (fieldType === "ref_step") {
908
+ // Step reference: render with step-like field styling, then patch localized sequence later.
909
+ children.push({
910
+ type: "element",
911
+ tagName: "span",
912
+ properties: { className: ["aimd-ref__content"] },
913
+ children: [
914
+ {
915
+ type: "element",
916
+ tagName: "span",
917
+ properties: { className: ["aimd-field", "aimd-field--step", "aimd-field--readonly"] },
918
+ children: [{
919
+ type: "element",
920
+ tagName: "span",
921
+ properties: { className: ["research-step__sequence"] },
922
+ children: [{ type: "text", value: id }],
923
+ } as Element],
924
+ } as Element,
925
+ ],
926
+ } as Element)
927
+ }
928
+ else {
929
+ // Variable or figure reference: show as field with scope + id
930
+ const scopeLabel = fieldType === "ref_var" ? messages.scope.var : messages.scope.figure
931
+ children.push({
932
+ type: "element",
933
+ tagName: "span",
934
+ properties: { className: ["aimd-ref__content"] },
935
+ children: [
936
+ {
937
+ type: "element",
938
+ tagName: "span",
939
+ properties: { className: ["aimd-field", "aimd-field--var"] },
940
+ children: [
941
+ {
942
+ type: "element",
943
+ tagName: "span",
944
+ properties: { className: ["aimd-field__scope"] },
945
+ children: [{ type: "text", value: scopeLabel }],
946
+ } as Element,
947
+ {
948
+ type: "element",
949
+ tagName: "span",
950
+ properties: { className: ["aimd-field__name"] },
951
+ children: [{ type: "text", value: id }],
952
+ } as Element,
953
+ ],
954
+ } as Element,
955
+ ],
956
+ } as Element)
957
+ }
958
+ }
959
+ else if (isCite) {
960
+ // Citation: [refs]
961
+ const refs = "refs" in node ? (node as any).refs : [id]
962
+ children.push({
963
+ type: "element",
964
+ tagName: "span",
965
+ properties: { className: ["aimd-cite__refs"] },
966
+ children: [{ type: "text", value: `[${refs.join(", ")}]` }],
967
+ } as Element)
968
+ }
969
+ else if (fieldType === "var") {
970
+ // Variable: type label + id + optional type annotation
971
+ const definition = "definition" in node ? node.definition : undefined
972
+ children.push(
973
+ {
974
+ type: "element",
975
+ tagName: "span",
976
+ properties: { className: ["aimd-field__scope"] },
977
+ children: [{ type: "text", value: getAimdRendererScopeLabel("var", messages) }],
978
+ } as Element,
979
+ {
980
+ type: "element",
981
+ tagName: "span",
982
+ properties: { className: ["aimd-field__name"] },
983
+ children: [{ type: "text", value: id }],
984
+ } as Element,
985
+ )
986
+ if (definition?.type) {
987
+ children.push({
988
+ type: "element",
989
+ tagName: "span",
990
+ properties: { className: ["aimd-field__type"] },
991
+ children: [{ type: "text", value: `: ${definition.type}` }],
992
+ } as Element)
993
+ }
994
+ }
995
+ else if (fieldType === "var_table") {
996
+ // var_table: render header + table preview
997
+ const columns = "columns" in node ? (node as any).columns as string[] : []
998
+ children.push(
999
+ {
1000
+ type: "element",
1001
+ tagName: "div",
1002
+ properties: { className: ["aimd-field__header"] },
1003
+ children: [
1004
+ {
1005
+ type: "element",
1006
+ tagName: "span",
1007
+ properties: { className: ["aimd-field__scope"] },
1008
+ children: [{ type: "text", value: getAimdRendererScopeLabel("var_table", messages) }],
1009
+ } as Element,
1010
+ {
1011
+ type: "element",
1012
+ tagName: "span",
1013
+ properties: { className: ["aimd-field__name"] },
1014
+ children: [{ type: "text", value: id }],
1015
+ } as Element,
1016
+ ],
1017
+ } as Element,
1018
+ )
1019
+ if (columns && columns.length > 0) {
1020
+ children.push({
1021
+ type: "element",
1022
+ tagName: "table",
1023
+ properties: { className: ["aimd-field__table-preview"] },
1024
+ children: [
1025
+ {
1026
+ type: "element",
1027
+ tagName: "thead",
1028
+ properties: {},
1029
+ children: [
1030
+ {
1031
+ type: "element",
1032
+ tagName: "tr",
1033
+ properties: {},
1034
+ children: columns.map(col => ({
1035
+ type: "element",
1036
+ tagName: "th",
1037
+ properties: {},
1038
+ children: [{ type: "text", value: col }],
1039
+ } as Element)),
1040
+ } as Element,
1041
+ ],
1042
+ } as Element,
1043
+ {
1044
+ type: "element",
1045
+ tagName: "tbody",
1046
+ properties: {},
1047
+ children: [
1048
+ {
1049
+ type: "element",
1050
+ tagName: "tr",
1051
+ properties: {},
1052
+ children: columns.map(() => ({
1053
+ type: "element",
1054
+ tagName: "td",
1055
+ properties: {},
1056
+ children: [{ type: "text", value: "..." }],
1057
+ } as Element)),
1058
+ } as Element,
1059
+ ],
1060
+ } as Element,
1061
+ ],
1062
+ } as Element)
1063
+ }
1064
+ }
1065
+ else if (fieldType === "step") {
1066
+ // Step: scope label + sequence + id
1067
+ const stepNode = node as AimdStepNode
1068
+ const stepNum = stepNode.step || "1"
1069
+
1070
+ children.push(
1071
+ {
1072
+ type: "element",
1073
+ tagName: "span",
1074
+ properties: { className: ["aimd-field__scope"] },
1075
+ children: [{ type: "text", value: getAimdRendererScopeLabel("step", messages) }],
1076
+ } as Element,
1077
+ {
1078
+ type: "element",
1079
+ tagName: "span",
1080
+ properties: { className: ["aimd-field__step-num"] },
1081
+ children: [{ type: "text", value: stepNum }],
1082
+ } as Element,
1083
+ {
1084
+ type: "element",
1085
+ tagName: "span",
1086
+ properties: { className: ["aimd-field__name"] },
1087
+ children: [{ type: "text", value: id }],
1088
+ } as Element,
1089
+ )
1090
+ }
1091
+ else if (fieldType === "check") {
1092
+ // Check: checkbox + label
1093
+ const label = "label" in node ? node.label : id
1094
+ children.push(
1095
+ {
1096
+ type: "element",
1097
+ tagName: "input",
1098
+ properties: { type: "checkbox", className: ["aimd-checkbox"], disabled: true },
1099
+ children: [],
1100
+ } as Element,
1101
+ {
1102
+ type: "element",
1103
+ tagName: "span",
1104
+ properties: { className: ["aimd-field__label"] },
1105
+ children: [{ type: "text", value: label || id }],
1106
+ } as Element,
1107
+ )
1108
+ }
1109
+ else if (fieldType === "quiz") {
1110
+ const quizNode = node as AimdQuizNode
1111
+ const quizType = quizNode.quizType
1112
+ const typeLabel = getAimdRendererQuizTypeLabel(quizType, quizNode.mode, messages)
1113
+
1114
+ children.push(
1115
+ {
1116
+ type: "element",
1117
+ tagName: "div",
1118
+ properties: { className: ["aimd-quiz__meta"] },
1119
+ children: [
1120
+ {
1121
+ type: "element",
1122
+ tagName: "span",
1123
+ properties: { className: ["aimd-field__scope"] },
1124
+ children: [{ type: "text", value: getAimdRendererScopeLabel("quiz", messages) }],
1125
+ } as Element,
1126
+ {
1127
+ type: "element",
1128
+ tagName: "span",
1129
+ properties: { className: ["aimd-field__name"] },
1130
+ children: [{ type: "text", value: id }],
1131
+ } as Element,
1132
+ {
1133
+ type: "element",
1134
+ tagName: "span",
1135
+ properties: { className: ["aimd-field__type"] },
1136
+ children: [{ type: "text", value: `(${typeLabel})` }],
1137
+ } as Element,
1138
+ ...(quizNode.score !== undefined
1139
+ ? [{
1140
+ type: "element",
1141
+ tagName: "span",
1142
+ properties: { className: ["aimd-quiz__score"] },
1143
+ children: [{ type: "text", value: messages.quiz.score(quizNode.score) }],
1144
+ } as Element]
1145
+ : []),
1146
+ ],
1147
+ } as Element,
1148
+ {
1149
+ type: "element",
1150
+ tagName: "div",
1151
+ properties: { className: ["aimd-quiz__stem"] },
1152
+ children: buildQuizStemChildren(quizType, quizNode.stem),
1153
+ } as Element,
1154
+ )
1155
+
1156
+ if (typeof quizNode.title === "string" && quizNode.title.trim()) {
1157
+ children.splice(1, 0, {
1158
+ type: "element",
1159
+ tagName: "div",
1160
+ properties: { className: ["aimd-quiz__title"] },
1161
+ children: [{ type: "text", value: quizNode.title }],
1162
+ } as Element)
1163
+ }
1164
+
1165
+ if (typeof quizNode.description === "string" && quizNode.description.trim()) {
1166
+ children.push({
1167
+ type: "element",
1168
+ tagName: "div",
1169
+ properties: { className: ["aimd-quiz__description"] },
1170
+ children: [{ type: "text", value: quizNode.description }],
1171
+ } as Element)
1172
+ }
1173
+
1174
+ if (quizType === "choice" && Array.isArray(quizNode.options) && quizNode.options.length > 0) {
1175
+ children.push({
1176
+ type: "element",
1177
+ tagName: "ul",
1178
+ properties: { className: ["aimd-quiz__options"] },
1179
+ children: quizNode.options.map(option => ({
1180
+ type: "element",
1181
+ tagName: "li",
1182
+ properties: {},
1183
+ children: [{ type: "text", value: `${option.key}. ${option.text}` }],
1184
+ } as Element)),
1185
+ } as Element)
1186
+ }
1187
+
1188
+ if (quizType === "scale") {
1189
+ children.push(...buildScalePreviewChildren(quizNode))
1190
+ children.push(...buildScaleBandChildren(quizNode))
1191
+ }
1192
+
1193
+ if (quizPreview.showAnswers && quizType === "choice" && quizNode.answer !== undefined) {
1194
+ const answerText = Array.isArray(quizNode.answer)
1195
+ ? quizNode.answer.join(", ")
1196
+ : String(quizNode.answer)
1197
+ if (answerText.trim()) {
1198
+ children.push({
1199
+ type: "element",
1200
+ tagName: "div",
1201
+ properties: { className: ["aimd-quiz__answer"] },
1202
+ children: [{ type: "text", value: messages.quiz.answer(answerText) }],
1203
+ } as Element)
1204
+ }
1205
+ }
1206
+
1207
+ if (quizPreview.showAnswers && quizType === "blank" && Array.isArray(quizNode.blanks) && quizNode.blanks.length > 0) {
1208
+ children.push({
1209
+ type: "element",
1210
+ tagName: "ul",
1211
+ properties: { className: ["aimd-quiz__blanks"] },
1212
+ children: quizNode.blanks.map(blank => ({
1213
+ type: "element",
1214
+ tagName: "li",
1215
+ properties: {},
1216
+ children: [{ type: "text", value: `${blank.key}: ${blank.answer}` }],
1217
+ } as Element)),
1218
+ } as Element)
1219
+ }
1220
+
1221
+ if (quizPreview.showRubric && quizType === "open" && typeof quizNode.rubric === "string" && quizNode.rubric.trim()) {
1222
+ children.push({
1223
+ type: "element",
1224
+ tagName: "div",
1225
+ properties: { className: ["aimd-quiz__rubric"] },
1226
+ children: [{ type: "text", value: messages.quiz.rubric(quizNode.rubric) }],
1227
+ } as Element)
1228
+ }
1229
+ }
1230
+ else if (fieldType === "fig") {
1231
+ // Figure: delegate to figureNumbering module
1232
+ const figNode = node as any
1233
+ children.push(...buildFigureChildren({
1234
+ id: figNode.id || id,
1235
+ src: figNode.src,
1236
+ title: figNode.title,
1237
+ legend: figNode.legend,
1238
+ sequence: figNode.sequence,
1239
+ }))
1240
+ }
1241
+
1242
+ // Build properties
1243
+ const properties: Properties = {
1244
+ "className": [baseClass, modifierClass],
1245
+ "data-aimd-type": node.fieldType,
1246
+ "data-aimd-id": node.id,
1247
+ "data-aimd-scope": node.scope,
1248
+ "data-aimd-raw": node.raw,
1249
+ "data-aimd-json": aimdJson,
1250
+ }
1251
+
1252
+ // Add reference href
1253
+ if (isRef) {
1254
+ if (fieldType === "ref_step") {
1255
+ properties.href = `#step-${id}`
1256
+ }
1257
+ else if (fieldType === "ref_var") {
1258
+ properties.href = `#var-${id}`
1259
+ }
1260
+ else if (fieldType === "ref_fig") {
1261
+ properties.href = `#fig-${id}`
1262
+ }
1263
+ }
1264
+
1265
+ // Add step-specific properties
1266
+ if (node.fieldType === "step") {
1267
+ const stepNode = node as AimdStepNode
1268
+ properties["data-aimd-step"] = stepNode.step
1269
+ properties["data-aimd-level"] = stepNode.level
1270
+ properties["data-aimd-check"] = stepNode.check ? "true" : "false"
1271
+ if (options.groupStepBodies) {
1272
+ properties["data-aimd-step-container"] = "true"
1273
+ }
1274
+ if (stepNode.title) {
1275
+ properties["data-aimd-title"] = stepNode.title
1276
+ }
1277
+ if (stepNode.subtitle) {
1278
+ properties["data-aimd-subtitle"] = stepNode.subtitle
1279
+ }
1280
+ if (stepNode.checked_message) {
1281
+ properties["data-aimd-checked-message"] = stepNode.checked_message
1282
+ }
1283
+ if (typeof stepNode.estimated_duration_ms === "number") {
1284
+ properties["data-aimd-estimated-duration-ms"] = stepNode.estimated_duration_ms
1285
+ }
1286
+ if (stepNode.timer_mode) {
1287
+ properties["data-aimd-timer-mode"] = stepNode.timer_mode
1288
+ }
1289
+ if (stepNode.result) {
1290
+ properties["data-aimd-result"] = "true"
1291
+ }
1292
+ properties.id = `step-${id}`
1293
+ }
1294
+
1295
+ // Add check id
1296
+ if (node.fieldType === "check") {
1297
+ properties.id = `check-${id}`
1298
+ }
1299
+
1300
+ // Add var id
1301
+ if (node.fieldType === "var") {
1302
+ properties.id = `var-${id}`
1303
+ }
1304
+
1305
+ // Add var_table id
1306
+ if (node.fieldType === "var_table") {
1307
+ properties.id = `var_table-${id}`
1308
+ }
1309
+
1310
+ // Add quiz id
1311
+ if (node.fieldType === "quiz") {
1312
+ properties.id = `quiz-${id}`
1313
+ }
1314
+
1315
+ // Add fig id
1316
+ if (node.fieldType === "fig") {
1317
+ const figNode = node as any
1318
+ properties.id = `fig-${figNode.id || id}`
1319
+ properties["data-aimd-fig-id"] = figNode.id
1320
+ properties["data-aimd-fig-src"] = figNode.src
1321
+ }
1322
+
1323
+ // Add quiz metadata for fallback reconstruction
1324
+ if (node.fieldType === "quiz") {
1325
+ const quizNode = node as AimdQuizNode
1326
+ properties["data-aimd-quiz-type"] = quizNode.quizType
1327
+ properties["data-aimd-quiz-stem"] = quizNode.stem
1328
+ }
1329
+
1330
+ const isGroupedStepContainer = fieldType === "step" && options.groupStepBodies
1331
+ const element: Element = assignAimdNodeData({
1332
+ type: "element",
1333
+ tagName: isRef
1334
+ ? "a"
1335
+ : (isFig ? "figure" : ((fieldType === "var_table" || isQuiz || isGroupedStepContainer) ? "div" : "span")),
1336
+ properties,
1337
+ children,
1338
+ }, node)
1339
+
1340
+ const customRenderer = options.aimdElementRenderers?.[node.fieldType]
1341
+ if (customRenderer) {
1342
+ const customElement = customRenderer(node, element, htmlRendererContext)
1343
+ if (customElement) {
1344
+ return assignAimdNodeData(customElement, node)
1345
+ }
1346
+ }
1347
+
1348
+ return element
1349
+ }
1350
+ }
1351
+
1352
+ // ---------------------------------------------------------------------------
1353
+ // Processor pipeline
1354
+ // ---------------------------------------------------------------------------
1355
+
1356
+ /**
1357
+ * Create base processor
1358
+ */
1359
+ function createBaseProcessor(options: AimdRendererOptions = {}) {
1360
+ const { gfm = true, math = true, breaks = true } = options
1361
+
1362
+ const processor = unified()
1363
+ .use(remarkParse)
1364
+
1365
+ // GFM support (tables, strikethrough, task lists, etc.)
1366
+ if (gfm) {
1367
+ processor.use(remarkGfm)
1368
+ }
1369
+
1370
+ // Math formula support
1371
+ if (math) {
1372
+ processor.use(remarkMath)
1373
+ }
1374
+
1375
+ processor.use(remarkInsertVisibleAssigners, options)
1376
+
1377
+ // AIMD syntax support - MUST run before remarkBreaks
1378
+ // to properly parse multiline AIMD syntax like var_table with subvars
1379
+ processor.use(remarkAimd)
1380
+ processor.use(remarkStripAssignerCodeBlocks)
1381
+
1382
+ // Single line break to <br> conversion (default enabled for AIMD)
1383
+ if (breaks) {
1384
+ processor.use(remarkBreaks)
1385
+ }
1386
+
1387
+ return processor
1388
+ }
1389
+
1390
+ /**
1391
+ * Create HTML output processor
1392
+ */
1393
+ export function createHtmlProcessor(options: AimdRendererOptions = {}) {
1394
+ const { math = true, sanitize = true } = options
1395
+ const aimdHandler = createAimdHandler(options)
1396
+
1397
+ const processor = createBaseProcessor(options)
1398
+ .use(remarkRehype, {
1399
+ allowDangerousHtml: true,
1400
+ handlers: {
1401
+ // Custom handler for AIMD nodes
1402
+ aimd: aimdHandler,
1403
+ },
1404
+ } as any)
1405
+ .use(rehypeRaw)
1406
+
1407
+ // Math formula rendering
1408
+ if (math) {
1409
+ processor.use(rehypeKatex)
1410
+ }
1411
+
1412
+ return processor
1413
+ }
1414
+
1415
+ // ---------------------------------------------------------------------------
1416
+ // Public render functions
1417
+ // ---------------------------------------------------------------------------
1418
+
1419
+ /**
1420
+ * Render Markdown/AIMD to HTML string
1421
+ */
1422
+ export async function renderToHtml(
1423
+ content: string,
1424
+ options: AimdRendererOptions = {},
1425
+ ): Promise<{ html: string, fields: ExtractedAimdFields }> {
1426
+ await ensureMathStylesLoaded(options.math)
1427
+ const processor = createHtmlProcessor(options)
1428
+
1429
+ const { content: protectedContent, file } = createAimdParseInput(content)
1430
+ const tree = processor.parse(protectedContent)
1431
+ const hastTree = await processor.run(tree, file) as HastRoot
1432
+
1433
+ const fields = getExtractedFields(file)
1434
+ annotateStepReferenceSequence(hastTree, fields, options)
1435
+ assignFigureSequenceNumbers(hastTree, fields)
1436
+ liftBlockVarElements(hastTree, options.blockVarTypes)
1437
+ if (options.groupStepBodies) {
1438
+ groupStepBodies(hastTree)
1439
+ }
1440
+ if (options.groupCheckBodies) {
1441
+ groupCheckBodies(hastTree)
1442
+ }
1443
+ await highlightVisibleAssigners(hastTree, options)
1444
+ const html = toHtml(hastTree, { allowDangerousHtml: true })
1445
+
1446
+ return { html, fields }
1447
+ }
1448
+
1449
+ /**
1450
+ * Render Markdown/AIMD to Vue VNodes
1451
+ */
1452
+ export async function renderToVue(
1453
+ content: string,
1454
+ options: AimdRendererOptions & VueRendererOptions = {},
1455
+ ): Promise<RenderResult> {
1456
+ await ensureMathStylesLoaded(options.math)
1457
+ const processor = createHtmlProcessor(options)
1458
+
1459
+ const { content: protectedContent, file } = createAimdParseInput(content)
1460
+ const tree = processor.parse(protectedContent)
1461
+ const hastTree = await processor.run(tree, file) as HastRoot
1462
+
1463
+ const fields = getExtractedFields(file)
1464
+ annotateStepReferenceSequence(hastTree, fields, options)
1465
+ assignFigureSequenceNumbers(hastTree, fields)
1466
+ liftBlockVarElements(hastTree, options.blockVarTypes)
1467
+ if (options.groupStepBodies) {
1468
+ groupStepBodies(hastTree)
1469
+ }
1470
+ if (options.groupCheckBodies) {
1471
+ groupCheckBodies(hastTree)
1472
+ }
1473
+ await highlightVisibleAssigners(hastTree, options)
1474
+ const nodes = renderToVNodes(hastTree, options)
1475
+
1476
+ return { nodes, fields }
1477
+ }
1478
+
1479
+ /**
1480
+ * Parse Markdown/AIMD and extract field information (no rendering)
1481
+ */
1482
+ export function parseAndExtract(content: string): ExtractedAimdFields {
1483
+ const processor = unified()
1484
+ .use(remarkParse)
1485
+ .use(remarkAimd)
1486
+
1487
+ const { content: protectedContent, file } = createAimdParseInput(content)
1488
+ const tree = processor.parse(protectedContent)
1489
+ processor.runSync(tree, file)
1490
+
1491
+ return getExtractedFields(file)
1492
+ }
1493
+
1494
+ /**
1495
+ * Synchronous render to HTML (for simple scenarios)
1496
+ */
1497
+ export function renderToHtmlSync(
1498
+ content: string,
1499
+ options: AimdRendererOptions = {},
1500
+ ): { html: string, fields: ExtractedAimdFields } {
1501
+ // Sync mode does not support KaTeX stylesheet loading, so keep math disabled here.
1502
+ const processor = createHtmlProcessor({ ...options, math: false })
1503
+
1504
+ const { content: protectedContent, file } = createAimdParseInput(content)
1505
+ const tree = processor.parse(protectedContent)
1506
+ const hastTree = processor.runSync(tree, file) as HastRoot
1507
+
1508
+ const fields = getExtractedFields(file)
1509
+ annotateStepReferenceSequence(hastTree, fields, options)
1510
+ assignFigureSequenceNumbers(hastTree, fields)
1511
+ liftBlockVarElements(hastTree, options.blockVarTypes)
1512
+ if (options.groupStepBodies) {
1513
+ groupStepBodies(hastTree)
1514
+ }
1515
+ const html = toHtml(hastTree, { allowDangerousHtml: true })
1516
+
1517
+ return { html, fields }
1518
+ }
1519
+
1520
+ /**
1521
+ * Create reusable renderer instance
1522
+ */
1523
+ export function createRenderer(options: AimdRendererOptions = {}) {
1524
+ const processor = createHtmlProcessor(options)
1525
+
1526
+ return {
1527
+ /**
1528
+ * Render to HTML
1529
+ */
1530
+ async toHtml(content: string): Promise<{ html: string, fields: ExtractedAimdFields }> {
1531
+ return renderToHtml(content, options)
1532
+ },
1533
+
1534
+ /**
1535
+ * Render to Vue VNodes
1536
+ */
1537
+ async toVue(
1538
+ content: string,
1539
+ renderOptions?: VueRendererOptions,
1540
+ ): Promise<RenderResult> {
1541
+ return renderToVue(content, { ...options, ...renderOptions })
1542
+ },
1543
+
1544
+ /**
1545
+ * Extract fields only
1546
+ */
1547
+ extractFields(content: string): ExtractedAimdFields {
1548
+ return parseAndExtract(content)
1549
+ },
1550
+ }
1551
+ }
1552
+
1553
+ // Export default renderer
1554
+ export const defaultRenderer = createRenderer()