@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,1449 @@
1
+ import type { Element, Root as HastRoot, Text as HastText, RootContent } from "hast"
2
+ import type { Component, VNode, VNodeChild } from "vue"
3
+ import type { AimdNode, AimdQuizNode, AimdStepNode, RenderContext } from "@airalogy/aimd-core/types"
4
+ import { Fragment, h } from "vue"
5
+ import type { AimdRendererI18nOptions, AimdRendererLocale, AimdRendererMessages } from "../locales"
6
+ import { resolveQuizPreviewOptions, type ResolvedQuizPreviewOptions } from "../common/quiz-preview"
7
+ import {
8
+ createAimdRendererMessages,
9
+ DEFAULT_AIMD_RENDERER_LOCALE,
10
+ getAimdRendererQuizTypeLabel,
11
+ getAimdRendererScopeLabel,
12
+ resolveAimdRendererLocale,
13
+ } from "../locales"
14
+
15
+ /**
16
+ * Extended Element data type
17
+ */
18
+ export interface AimdElementData {
19
+ aimd?: AimdNode
20
+ }
21
+
22
+ /**
23
+ * AIMD component renderer function type
24
+ * Can return VNode directly or a Promise for async rendering
25
+ */
26
+ export type AimdComponentRenderer = (
27
+ node: AimdNode,
28
+ ctx: AimdRendererContext,
29
+ children?: VNodeChild[]
30
+ ) => VNode | Promise<VNode> | null
31
+
32
+ /**
33
+ * Element renderer function type
34
+ */
35
+ export type ElementRenderer = (
36
+ node: Element,
37
+ children: VNodeChild[],
38
+ ctx: AimdRendererContext
39
+ ) => VNode | null
40
+
41
+ export interface AimdRendererContext extends RenderContext {
42
+ locale: AimdRendererLocale
43
+ messages: AimdRendererMessages
44
+ }
45
+
46
+ export interface AimdStepCardRendererOptions {
47
+ showScopeLabel?: boolean
48
+ showCheckBadge?: boolean
49
+ bodyClassName?: string
50
+ className?: string
51
+ }
52
+
53
+ function resolveQuizPreviewOptionsFromContext(ctx: RenderContext): ResolvedQuizPreviewOptions {
54
+ return resolveQuizPreviewOptions(ctx.mode, ctx.quizPreview)
55
+ }
56
+
57
+ const BLANK_PLACEHOLDER_PATTERN = /\[\[([^\[\]\s]+)\]\]/g
58
+
59
+ function buildQuizStemChildren(
60
+ quizType: AimdQuizNode["quizType"],
61
+ stem: string,
62
+ ): VNodeChild[] {
63
+ if (quizType !== "blank") {
64
+ return [stem]
65
+ }
66
+
67
+ const children: VNodeChild[] = []
68
+ let lastIndex = 0
69
+
70
+ for (const match of stem.matchAll(BLANK_PLACEHOLDER_PATTERN)) {
71
+ const start = match.index ?? 0
72
+ const fullMatch = match[0]
73
+ const key = match[1]
74
+
75
+ if (start > lastIndex) {
76
+ children.push(stem.slice(lastIndex, start))
77
+ }
78
+
79
+ children.push(
80
+ h("span", {
81
+ class: "aimd-quiz__blank-placeholder",
82
+ "data-blank-key": key,
83
+ }, key),
84
+ )
85
+ lastIndex = start + fullMatch.length
86
+ }
87
+
88
+ if (lastIndex < stem.length) {
89
+ children.push(stem.slice(lastIndex))
90
+ }
91
+
92
+ if (children.length === 0) {
93
+ children.push(stem)
94
+ }
95
+
96
+ return children
97
+ }
98
+
99
+ function formatScaleOptionLabel(option: NonNullable<AimdQuizNode["options"]>[number]): string {
100
+ if (typeof option.points === "number" && Number.isFinite(option.points)) {
101
+ return `${option.text} (${option.points})`
102
+ }
103
+ return option.text
104
+ }
105
+
106
+ function buildScalePreviewChildren(quizNode: AimdQuizNode): VNodeChild[] {
107
+ if (!Array.isArray(quizNode.items) || quizNode.items.length === 0 || !Array.isArray(quizNode.options) || quizNode.options.length === 0) {
108
+ return [h("div", { class: "aimd-scale__empty" }, "Scale definition is incomplete.")]
109
+ }
110
+
111
+ if (quizNode.display === "list") {
112
+ return [
113
+ h("div", { class: "aimd-scale__list" }, quizNode.items.map(item =>
114
+ h("div", { class: "aimd-scale__list-item" }, [
115
+ h("div", { class: "aimd-scale__item-stem" }, item.stem),
116
+ h("ul", { class: "aimd-scale__item-options" }, quizNode.options!.map(option =>
117
+ h("li", formatScaleOptionLabel(option)),
118
+ )),
119
+ ]),
120
+ )),
121
+ ]
122
+ }
123
+
124
+ return [
125
+ h("table", { class: "aimd-scale__table" }, [
126
+ h("thead", [
127
+ h("tr", [
128
+ h("th", { class: "aimd-scale__item-header" }, "Item"),
129
+ ...quizNode.options.map(option => h("th", formatScaleOptionLabel(option))),
130
+ ]),
131
+ ]),
132
+ h("tbody", quizNode.items.map(item =>
133
+ h("tr", [
134
+ h("th", { class: "aimd-scale__item-stem", scope: "row" }, item.stem),
135
+ ...quizNode.options!.map(() => h("td", { class: "aimd-scale__cell" }, "○")),
136
+ ]),
137
+ )),
138
+ ]),
139
+ ]
140
+ }
141
+
142
+ function buildScaleBandChildren(quizNode: AimdQuizNode): VNodeChild[] {
143
+ const bands = Array.isArray((quizNode.grading as any)?.bands) ? (quizNode.grading as any).bands : []
144
+ if (bands.length === 0) {
145
+ return []
146
+ }
147
+
148
+ return [
149
+ h("ul", { class: "aimd-scale__bands" }, bands.map((band: any) =>
150
+ h("li", `${band.min}-${band.max}: ${band.label}${band.interpretation ? ` · ${band.interpretation}` : ""}`),
151
+ )),
152
+ ]
153
+ }
154
+
155
+ function isStepBodyVNode(node: unknown): node is VNode {
156
+ if (!node || typeof node !== "object") {
157
+ return false
158
+ }
159
+
160
+ const props = (node as VNode).props as Record<string, unknown> | null | undefined
161
+ if (!props) {
162
+ return false
163
+ }
164
+
165
+ const classValue = props.class
166
+ const classNames = Array.isArray(classValue)
167
+ ? classValue
168
+ : typeof classValue === "string"
169
+ ? [classValue]
170
+ : []
171
+
172
+ return props["data-aimd-step-body"] === "true"
173
+ || props["data-aimd-step-body"] === true
174
+ || props.dataAimdStepBody === "true"
175
+ || props.dataAimdStepBody === true
176
+ || classNames.some((className) => typeof className === "string" && className.includes("aimd-step-body"))
177
+ }
178
+
179
+ function normalizeStepCardBodyChildren(children?: VNodeChild[]): VNodeChild[] {
180
+ if (!children || children.length === 0) {
181
+ return []
182
+ }
183
+
184
+ const groupedBody = children.find((child) => isStepBodyVNode(child))
185
+ if (groupedBody && typeof groupedBody === "object" && groupedBody !== null) {
186
+ const groupedChildren = (groupedBody as VNode).children
187
+ if (Array.isArray(groupedChildren)) {
188
+ return groupedChildren as VNodeChild[]
189
+ }
190
+ if (groupedChildren !== null && groupedChildren !== undefined) {
191
+ return [groupedChildren as VNodeChild]
192
+ }
193
+ }
194
+
195
+ return children
196
+ }
197
+
198
+ /**
199
+ * Default AIMD component renderers
200
+ */
201
+ const defaultAimdRenderers: Record<string, AimdComponentRenderer> = {
202
+ var: (node, ctx) => {
203
+ const { id, scope } = node
204
+ const definition = "definition" in node ? node.definition : undefined
205
+
206
+ if (ctx.mode === "preview") {
207
+ return h("span", {
208
+ "class": "aimd-field aimd-field--var",
209
+ "data-aimd-type": "var",
210
+ "data-aimd-id": id,
211
+ "data-aimd-scope": scope,
212
+ }, [
213
+ h("span", { class: "aimd-field__scope" }, getAimdRendererScopeLabel(scope, ctx.messages)),
214
+ h("span", { class: "aimd-field__name" }, id),
215
+ definition?.type ? h("span", { class: "aimd-field__type" }, `: ${definition.type}`) : null,
216
+ ])
217
+ }
218
+
219
+ // Edit mode - render as editable field with value display
220
+ const fieldData = ctx.value?.[scope]?.[id]
221
+ const value = typeof fieldData === "object" && fieldData !== null && "value" in fieldData
222
+ ? (fieldData as { value: unknown }).value
223
+ : fieldData
224
+ const displayValue = value !== undefined && value !== null && value !== "" ? String(value) : id
225
+
226
+ return h("span", {
227
+ "class": "aimd-field aimd-field--var aimd-field--editable",
228
+ "data-aimd-type": "var",
229
+ "data-aimd-id": id,
230
+ "data-aimd-scope": scope,
231
+ "data-has-variable": "true",
232
+ "id": `${scope}-${id}`,
233
+ }, [
234
+ h("span", { class: "aimd-field__value" }, displayValue),
235
+ ])
236
+ },
237
+
238
+ var_table: (node, ctx) => {
239
+ const { id } = node
240
+ const columns = "columns" in node ? node.columns : []
241
+
242
+ if (ctx.mode === "preview") {
243
+ // Preview mode: render tag with table preview inside
244
+ const children: VNodeChild[] = [
245
+ h("div", { class: "aimd-field__header" }, [
246
+ h("span", { class: "aimd-field__scope" }, ctx.messages.scope.table),
247
+ h("span", { class: "aimd-field__name" }, id),
248
+ ]),
249
+ ]
250
+ // Add table preview inside the container
251
+ if (columns && columns.length > 0) {
252
+ children.push(
253
+ h("table", { class: "aimd-field__table-preview" }, [
254
+ h("thead", [
255
+ h("tr", columns.map(col => h("th", col))),
256
+ ]),
257
+ h("tbody", [
258
+ h("tr", columns.map(() => h("td", "..."))),
259
+ ]),
260
+ ]),
261
+ )
262
+ }
263
+ return h("div", {
264
+ "class": "aimd-field aimd-field--var-table",
265
+ "data-aimd-type": "var_table",
266
+ "data-aimd-id": id,
267
+ }, children)
268
+ }
269
+
270
+ // Edit mode: render empty container (will be replaced by AIMDTag component)
271
+ return h("div", {
272
+ "class": "aimd-field aimd-field--var-table aimd-field--editable",
273
+ "data-aimd-type": "var_table",
274
+ "data-aimd-id": id,
275
+ "id": `var_table-${id}`,
276
+ })
277
+ },
278
+
279
+ quiz: (node, ctx) => {
280
+ const quizNode = node as AimdQuizNode
281
+ const { id, scope, quizType, stem, score } = quizNode
282
+ const typeLabel = getAimdRendererQuizTypeLabel(quizType, quizNode.mode, ctx.messages)
283
+
284
+ if (ctx.mode === "preview") {
285
+ const previewChildren: VNodeChild[] = [
286
+ h("div", { class: "aimd-quiz__meta" }, [
287
+ h("span", { class: "aimd-field__scope" }, getAimdRendererScopeLabel(scope, ctx.messages)),
288
+ h("span", { class: "aimd-field__name" }, id),
289
+ h("span", { class: "aimd-field__type" }, `(${typeLabel})`),
290
+ score !== undefined ? h("span", { class: "aimd-quiz__score" }, ctx.messages.quiz.score(score)) : null,
291
+ ]),
292
+ typeof quizNode.title === "string" && quizNode.title.trim()
293
+ ? h("div", { class: "aimd-quiz__title" }, quizNode.title)
294
+ : null,
295
+ h("div", { class: "aimd-quiz__stem" }, buildQuizStemChildren(quizType, stem || id)),
296
+ typeof quizNode.description === "string" && quizNode.description.trim()
297
+ ? h("div", { class: "aimd-quiz__description" }, quizNode.description)
298
+ : null,
299
+ ]
300
+
301
+ if (quizType === "choice" && Array.isArray(quizNode.options) && quizNode.options.length > 0) {
302
+ previewChildren.push(
303
+ h("ul", { class: "aimd-quiz__options" }, quizNode.options.map(opt =>
304
+ h("li", `${opt.key}. ${opt.text}`),
305
+ )),
306
+ )
307
+ }
308
+
309
+ if (quizType === "scale") {
310
+ previewChildren.push(...buildScalePreviewChildren(quizNode))
311
+ previewChildren.push(...buildScaleBandChildren(quizNode))
312
+ }
313
+
314
+ const quizPreview = resolveQuizPreviewOptionsFromContext(ctx)
315
+
316
+ if (quizPreview.showAnswers && quizType === "choice" && quizNode.answer !== undefined) {
317
+ const answerText = Array.isArray(quizNode.answer)
318
+ ? quizNode.answer.join(", ")
319
+ : String(quizNode.answer)
320
+ if (answerText.trim()) {
321
+ previewChildren.push(
322
+ h("div", { class: "aimd-quiz__answer" }, ctx.messages.quiz.answer(answerText)),
323
+ )
324
+ }
325
+ }
326
+
327
+ if (quizPreview.showAnswers && quizType === "blank" && Array.isArray(quizNode.blanks) && quizNode.blanks.length > 0) {
328
+ previewChildren.push(
329
+ h("ul", { class: "aimd-quiz__blanks" }, quizNode.blanks.map(blank =>
330
+ h("li", `${blank.key}: ${blank.answer}`),
331
+ )),
332
+ )
333
+ }
334
+
335
+ if (quizPreview.showRubric && quizType === "open" && typeof quizNode.rubric === "string" && quizNode.rubric.trim()) {
336
+ previewChildren.push(
337
+ h("div", { class: "aimd-quiz__rubric" }, ctx.messages.quiz.rubric(quizNode.rubric)),
338
+ )
339
+ }
340
+
341
+ return h("div", {
342
+ "class": "aimd-field aimd-field--quiz",
343
+ "data-aimd-type": "quiz",
344
+ "data-aimd-id": id,
345
+ "data-aimd-scope": scope,
346
+ "id": `quiz-${id}`,
347
+ }, previewChildren)
348
+ }
349
+
350
+ const fieldData = ctx.value?.[scope]?.[id]
351
+ let displayValue: string
352
+ if (Array.isArray(fieldData)) {
353
+ displayValue = fieldData.join(", ")
354
+ }
355
+ else if (isPlainObject(fieldData)) {
356
+ displayValue = JSON.stringify(fieldData)
357
+ }
358
+ else if (fieldData === undefined || fieldData === null || fieldData === "") {
359
+ displayValue = id
360
+ }
361
+ else {
362
+ displayValue = String(fieldData)
363
+ }
364
+
365
+ return h("div", {
366
+ "class": "aimd-field aimd-field--quiz aimd-field--editable",
367
+ "data-aimd-type": "quiz",
368
+ "data-aimd-id": id,
369
+ "data-aimd-scope": scope,
370
+ "id": `quiz-${id}`,
371
+ }, [
372
+ h("span", { class: "aimd-field__value" }, displayValue),
373
+ ])
374
+ },
375
+
376
+ step: (node, ctx, children) => {
377
+ const { id, scope } = node
378
+ const stepNode = node as AimdStepNode
379
+ const stepNum = stepNode.step || "1"
380
+
381
+ if (ctx.mode === "preview") {
382
+ // Preview mode: render as localized step label + sequence + id
383
+ return h("span", {
384
+ "class": "aimd-field aimd-field--step",
385
+ "data-aimd-type": "step",
386
+ "data-aimd-id": id,
387
+ "data-aimd-step": stepNum,
388
+ "id": `step-${id}`,
389
+ }, [
390
+ h("span", { class: "aimd-field__scope" }, getAimdRendererScopeLabel(scope, ctx.messages)),
391
+ h("span", { class: "aimd-field__step-num" }, stepNum),
392
+ h("span", { class: "aimd-field__name" }, id),
393
+ ])
394
+ }
395
+
396
+ // Edit mode - render step container with children
397
+ return h("div", {
398
+ "class": "aimd-field aimd-field--step aimd-field--editable research-step__item",
399
+ "data-aimd-type": "step",
400
+ "data-aimd-id": id,
401
+ "data-aimd-step": stepNum,
402
+ "data-aimd-level": stepNode.level,
403
+ "id": `step-${id}`,
404
+ }, [
405
+ h("div", { class: "research-step__header" }, [
406
+ h("span", { class: "research-step__sequence" }, ctx.messages.step.sequence(stepNum)),
407
+ ]),
408
+ children && children.length > 0
409
+ ? h("div", { class: "research-step__content" }, children)
410
+ : null,
411
+ ])
412
+ },
413
+
414
+ check: (node, ctx, children) => {
415
+ const { id, scope } = node
416
+ const label = "label" in node ? node.label : id
417
+
418
+ if (ctx.mode === "preview") {
419
+ // Preview mode: render with checkbox (disabled)
420
+ return h("label", {
421
+ "class": "aimd-field aimd-field--check",
422
+ "data-aimd-type": "check",
423
+ "data-aimd-id": id,
424
+ "id": `check-${id}`,
425
+ }, [
426
+ h("input", {
427
+ type: "checkbox",
428
+ disabled: true,
429
+ class: "aimd-checkbox",
430
+ }),
431
+ h("span", { class: "aimd-field__label" }, label),
432
+ ])
433
+ }
434
+
435
+ const scopeValue = ctx.value?.[scope]?.[id]
436
+ const checked = typeof scopeValue === "object" && scopeValue !== null && "checked" in scopeValue
437
+ ? Boolean((scopeValue as Record<string, unknown>).checked)
438
+ : false
439
+
440
+ return h("label", {
441
+ "class": "aimd-field aimd-field--check aimd-field--editable",
442
+ "data-aimd-type": "check",
443
+ "data-aimd-id": id,
444
+ "id": `check-${id}`,
445
+ }, [
446
+ h("input", {
447
+ type: "checkbox",
448
+ checked,
449
+ disabled: ctx.readonly,
450
+ class: "aimd-checkbox",
451
+ onChange: (e: Event) => {
452
+ if (!ctx.value)
453
+ return
454
+
455
+ const scopeValues = (ctx.value[scope] ??= {})
456
+ const nextChecked = (e.target as HTMLInputElement).checked
457
+ const currentValue = scopeValues[id]
458
+
459
+ if (typeof currentValue === "object" && currentValue !== null) {
460
+ (currentValue as Record<string, unknown>).checked = nextChecked
461
+ }
462
+ else {
463
+ scopeValues[id] = { checked: nextChecked }
464
+ }
465
+ },
466
+ }),
467
+ h("span", { class: "aimd-field__label" }, label),
468
+ children && children.length > 0 ? children : null,
469
+ ])
470
+ },
471
+
472
+ ref_step: (node, ctx) => {
473
+ const { id } = node
474
+ const refTarget = "refTarget" in node ? node.refTarget : id
475
+ const stepSequence = "stepSequence" in node && typeof (node as any).stepSequence === "string"
476
+ ? (node as any).stepSequence
477
+ : undefined
478
+ const displayText = stepSequence ? ctx.messages.step.reference(stepSequence) : refTarget
479
+
480
+ return h("span", {
481
+ "class": "aimd-ref aimd-ref--step",
482
+ "data-aimd-type": "ref_step",
483
+ "data-aimd-ref": refTarget,
484
+ "data-aimd-step-sequence": stepSequence,
485
+ "title": refTarget,
486
+ }, [
487
+ h("span", { class: "aimd-ref__content" }, [
488
+ h("span", { class: "aimd-field aimd-field--step aimd-field--readonly" }, [
489
+ h("span", { class: "research-step__sequence" }, displayText),
490
+ ]),
491
+ ]),
492
+ ])
493
+ },
494
+
495
+ ref_var: (node, ctx) => {
496
+ const { id } = node
497
+ const refTarget = "refTarget" in node ? node.refTarget : id
498
+ const referencedValue = ctx.mode === "edit" ? getReferencedVarDisplayValue(ctx.value, refTarget) : null
499
+
500
+ return h("span", {
501
+ "class": "aimd-ref aimd-ref--var",
502
+ "data-aimd-type": "ref_var",
503
+ "data-aimd-ref": refTarget,
504
+ "title": refTarget,
505
+ }, [
506
+ h("span", { class: "aimd-ref__content" }, [
507
+ referencedValue !== null
508
+ ? h("span", {
509
+ class: "aimd-field aimd-field--var aimd-field--readonly",
510
+ "data-aimd-id": refTarget,
511
+ "data-aimd-scope": "var",
512
+ }, [
513
+ h("span", { class: "aimd-field__value" }, referencedValue),
514
+ ])
515
+ : h("span", { class: "aimd-field aimd-field--var" }, [
516
+ h("span", { class: "aimd-field__scope" }, ctx.messages.scope.var),
517
+ h("span", { class: "aimd-field__name" }, refTarget),
518
+ ]),
519
+ ]),
520
+ ])
521
+ },
522
+
523
+ ref_fig: (node, ctx) => {
524
+ const { id } = node
525
+ const refTarget = "refTarget" in node ? node.refTarget : id
526
+ const figureNumber = "figureNumber" in node ? (node as any).figureNumber : undefined
527
+
528
+ // Display figure number if available, otherwise show ID
529
+ const displayText = ctx.messages.figure.reference(figureNumber !== undefined ? figureNumber : refTarget)
530
+
531
+ // Render as link reference to the figure
532
+ return h("a", {
533
+ "class": "aimd-ref aimd-ref--fig",
534
+ "data-aimd-type": "ref_fig",
535
+ "data-aimd-ref": refTarget,
536
+ "href": `#fig-${refTarget}`,
537
+ }, [
538
+ h("span", { class: "aimd-ref__content" }, displayText),
539
+ ])
540
+ },
541
+
542
+ cite: (node, ctx) => {
543
+ const refs = "refs" in node ? (node as any).refs : [node.id]
544
+
545
+ return h("span", {
546
+ "class": "aimd-cite",
547
+ "data-aimd-type": "cite",
548
+ "data-aimd-refs": refs.join(","),
549
+ }, [
550
+ h("span", { class: "aimd-cite__refs" }, `[${refs.join(", ")}]`),
551
+ ])
552
+ },
553
+
554
+ fig: (node, ctx) => {
555
+ const figNode = node as any
556
+ const figId = figNode.id || node.id
557
+ const figSrc = figNode.src || ""
558
+ const figTitle = figNode.title
559
+ const figLegend = figNode.legend
560
+ const figSequence = figNode.sequence
561
+
562
+ const children: VNodeChild[] = []
563
+
564
+ // Image element
565
+ children.push(
566
+ h("img", {
567
+ class: "aimd-figure__image",
568
+ src: figSrc,
569
+ alt: figTitle || figId,
570
+ loading: "lazy",
571
+ }),
572
+ )
573
+
574
+ // Caption (title + legend)
575
+ if (figTitle || figLegend || figSequence !== undefined) {
576
+ const captionChildren: VNodeChild[] = []
577
+
578
+ // Figure number and title
579
+ if (figSequence !== undefined || figTitle) {
580
+ const titleText = figSequence !== undefined
581
+ ? ctx.messages.figure.captionTitle(figSequence + 1, figTitle)
582
+ : figTitle
583
+ captionChildren.push(
584
+ h("div", { class: "aimd-figure__title" }, titleText),
585
+ )
586
+ }
587
+
588
+ // Legend
589
+ if (figLegend) {
590
+ captionChildren.push(
591
+ h("div", { class: "aimd-figure__legend" }, figLegend),
592
+ )
593
+ }
594
+
595
+ children.push(
596
+ h("figcaption", { class: "aimd-figure__caption" }, captionChildren),
597
+ )
598
+ }
599
+
600
+ return h("figure", {
601
+ "class": "aimd-figure",
602
+ "data-aimd-type": "fig",
603
+ "data-aimd-fig-id": figId,
604
+ "data-aimd-fig-src": figSrc,
605
+ "id": `fig-${figId}`,
606
+ }, children)
607
+ },
608
+ }
609
+
610
+ /**
611
+ * Render options
612
+ */
613
+ export interface VueRendererOptions {
614
+ /**
615
+ * Built-in renderer locale
616
+ */
617
+ locale?: AimdRendererI18nOptions["locale"]
618
+ /**
619
+ * Optional overrides for built-in renderer copy
620
+ */
621
+ messages?: AimdRendererI18nOptions["messages"]
622
+ /**
623
+ * Render mode shorthand (used when context is not provided)
624
+ */
625
+ mode?: RenderContext["mode"]
626
+ /**
627
+ * Quiz preview visibility shorthand (used when context is not provided)
628
+ */
629
+ quizPreview?: RenderContext["quizPreview"]
630
+ /**
631
+ * Render context
632
+ */
633
+ context?: RenderContext & Partial<Pick<AimdRendererContext, "locale" | "messages">>
634
+ /**
635
+ * Custom AIMD component renderers
636
+ * Override default renderers or add new ones
637
+ */
638
+ aimdRenderers?: Record<string, AimdComponentRenderer>
639
+ /**
640
+ * Custom HTML element renderers
641
+ * Render specific HTML tags with custom components
642
+ */
643
+ elementRenderers?: Record<string, ElementRenderer>
644
+ /**
645
+ * Component map for rendering Vue components by tag name
646
+ * e.g., { 'step-renderer': StepRendererComponent }
647
+ */
648
+ componentMap?: Record<string, Component>
649
+ }
650
+
651
+ /**
652
+ * Default render context
653
+ */
654
+ const defaultContext: AimdRendererContext = {
655
+ mode: "preview",
656
+ readonly: false,
657
+ locale: DEFAULT_AIMD_RENDERER_LOCALE,
658
+ messages: createAimdRendererMessages(DEFAULT_AIMD_RENDERER_LOCALE),
659
+ }
660
+
661
+ function resolveRenderContext(options: VueRendererOptions): AimdRendererContext {
662
+ const topLevelMode = options.mode
663
+ const topLevelQuizPreview = options.quizPreview
664
+ const context = options.context
665
+ const locale = context?.locale ?? resolveAimdRendererLocale(options.locale)
666
+ const messages = context?.messages ?? createAimdRendererMessages(locale, options.messages)
667
+
668
+ return {
669
+ ...defaultContext,
670
+ ...(context ?? {}),
671
+ mode: context?.mode ?? topLevelMode ?? defaultContext.mode,
672
+ quizPreview: context?.quizPreview ?? topLevelQuizPreview ?? undefined,
673
+ locale,
674
+ messages,
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Figure context for tracking figure numbers and references
680
+ */
681
+ export interface FigureContext {
682
+ /** Map from fig ID to sequence number */
683
+ figureNumbers: Map<string, number>
684
+ /** Current figure sequence counter */
685
+ sequence: number
686
+ }
687
+
688
+ /**
689
+ * Create initial figure context
690
+ */
691
+ function createFigureContext(): FigureContext {
692
+ return {
693
+ figureNumbers: new Map(),
694
+ sequence: 0,
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Pre-process HAST tree to assign figure numbers
700
+ */
701
+ function preprocessFigures(node: HastRoot | RootContent, figCtx: FigureContext): void {
702
+ if (node.type === "root") {
703
+ for (const child of node.children) {
704
+ preprocessFigures(child, figCtx)
705
+ }
706
+ return
707
+ }
708
+
709
+ if (node.type === "element") {
710
+ const element = node as Element
711
+ const aimdType = element.properties?.["data-aimd-type"] || element.properties?.dataAimdType
712
+
713
+ // Process fig nodes
714
+ if (aimdType === "fig") {
715
+ const figId = element.properties?.["data-aimd-fig-id"] || element.properties?.dataAimdFigId
716
+ if (figId && typeof figId === "string") {
717
+ // Assign sequence number if not already assigned
718
+ if (!figCtx.figureNumbers.has(figId)) {
719
+ figCtx.figureNumbers.set(figId, figCtx.sequence)
720
+ figCtx.sequence++
721
+ }
722
+
723
+ // Update AIMD node data if present
724
+ const aimdData = (element.data as AimdElementData | undefined)?.aimd
725
+ if (aimdData && "sequence" in aimdData) {
726
+ (aimdData as any).sequence = figCtx.figureNumbers.get(figId)
727
+ }
728
+ }
729
+ }
730
+
731
+ // Recursively process children
732
+ for (const child of element.children || []) {
733
+ preprocessFigures(child, figCtx)
734
+ }
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Parse AIMD node from HAST element properties
740
+ */
741
+ function parseAimdFromProps(props: Record<string, unknown>): AimdNode | undefined {
742
+ let parsedFromJson: Record<string, unknown> | undefined
743
+
744
+ // Try to get from JSON attribute first
745
+ const jsonData = props["data-aimd-json"] || props.dataAimdJson
746
+ if (jsonData && typeof jsonData === "string") {
747
+ try {
748
+ parsedFromJson = JSON.parse(jsonData) as Record<string, unknown>
749
+ }
750
+ catch (e) {
751
+ console.warn("Failed to parse AIMD JSON:", e)
752
+ }
753
+ }
754
+
755
+ // Reconstruct from individual properties
756
+ const fieldType = (props["data-aimd-type"] || props.dataAimdType) as string
757
+ const id = (props["data-aimd-id"] || props.dataAimdId) as string
758
+ const scope = (props["data-aimd-scope"] || props.dataAimdScope) as string
759
+ const raw = (props["data-aimd-raw"] || props.dataAimdRaw) as string
760
+
761
+ if (!fieldType || !id) {
762
+ return undefined
763
+ }
764
+
765
+ const baseNode: Record<string, unknown> = {
766
+ type: "aimd" as const,
767
+ fieldType: fieldType as AimdNode["fieldType"],
768
+ id,
769
+ scope: (scope || "var") as AimdNode["scope"],
770
+ raw: raw || `{{${fieldType}|${id}}}`,
771
+ }
772
+
773
+ const stepSequence = props["data-aimd-step-sequence"] || props.dataAimdStepSequence
774
+ if (parsedFromJson) {
775
+ if (typeof stepSequence === "string" && stepSequence.trim()) {
776
+ parsedFromJson.stepSequence = stepSequence
777
+ }
778
+ return parsedFromJson as unknown as AimdNode
779
+ }
780
+
781
+ if (typeof stepSequence === "string" && stepSequence.trim()) {
782
+ baseNode.stepSequence = stepSequence
783
+ }
784
+
785
+ // Add step-specific properties
786
+ if (fieldType === "step") {
787
+ const step = (props["data-aimd-step"] || props.dataAimdStep) as string
788
+ const level = Number.parseInt((props["data-aimd-level"] || props.dataAimdLevel) as string || "0", 10)
789
+ return {
790
+ ...baseNode,
791
+ fieldType: "step",
792
+ level,
793
+ sequence: 0,
794
+ step: step || "1",
795
+ } as AimdNode
796
+ }
797
+
798
+ // Add fig-specific properties
799
+ if (fieldType === "fig") {
800
+ const figId = (props["data-aimd-fig-id"] || props.dataAimdFigId) as string
801
+ const figSrc = (props["data-aimd-fig-src"] || props.dataAimdFigSrc) as string
802
+ return {
803
+ ...baseNode,
804
+ fieldType: "fig",
805
+ id: figId || id,
806
+ src: figSrc || "",
807
+ title: undefined,
808
+ legend: undefined,
809
+ } as AimdNode
810
+ }
811
+
812
+ // Add quiz-specific properties
813
+ if (fieldType === "quiz") {
814
+ const quizType = (props["data-aimd-quiz-type"] || props.dataAimdQuizType || "open") as string
815
+ const stem = (props["data-aimd-quiz-stem"] || props.dataAimdQuizStem || "") as string
816
+ return {
817
+ ...baseNode,
818
+ fieldType: "quiz",
819
+ quizType: quizType as AimdQuizNode["quizType"],
820
+ stem,
821
+ } as AimdNode
822
+ }
823
+
824
+ return baseNode as unknown as AimdNode
825
+ }
826
+
827
+ /**
828
+ * Convert HAST element properties to Vue props
829
+ */
830
+ function convertProperties(properties: Record<string, unknown>): Record<string, unknown> {
831
+ const props: Record<string, unknown> = {}
832
+
833
+ for (const [key, value] of Object.entries(properties)) {
834
+ // Convert className to class
835
+ if (key === "className") {
836
+ props.class = Array.isArray(value) ? value.join(" ") : value
837
+ }
838
+ else if (key === "htmlFor") {
839
+ props.for = value
840
+ }
841
+ else if (key.startsWith("data")) {
842
+ // Convert camelCase data attributes to kebab-case
843
+ const dataKey = key.replace(/([A-Z])/g, "-$1").toLowerCase()
844
+ props[dataKey] = value
845
+ }
846
+ else {
847
+ props[key] = value
848
+ }
849
+ }
850
+
851
+ return props
852
+ }
853
+
854
+ function isPromiseLike<T>(value: unknown): value is PromiseLike<T> {
855
+ return typeof value === "object" && value !== null && "then" in value
856
+ }
857
+
858
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
859
+ return typeof value === "object" && value !== null && !Array.isArray(value)
860
+ }
861
+
862
+ function getReferencedVarDisplayValue(
863
+ value: RenderContext["value"] | undefined,
864
+ refTarget: string,
865
+ ): string | null {
866
+ const fieldData = value?.var?.[refTarget]
867
+ const resolvedValue = isPlainObject(fieldData) && "value" in fieldData
868
+ ? fieldData.value
869
+ : fieldData
870
+
871
+ if (resolvedValue === undefined || resolvedValue === null || resolvedValue === "") {
872
+ return null
873
+ }
874
+
875
+ if (Array.isArray(resolvedValue)) {
876
+ return resolvedValue.map(item => String(item)).join(", ")
877
+ }
878
+
879
+ if (isPlainObject(resolvedValue)) {
880
+ try {
881
+ return JSON.stringify(resolvedValue)
882
+ }
883
+ catch {
884
+ return null
885
+ }
886
+ }
887
+
888
+ return String(resolvedValue)
889
+ }
890
+
891
+ /**
892
+ * Void elements that don't have children
893
+ */
894
+ const VOID_ELEMENTS = new Set([
895
+ "img",
896
+ "br",
897
+ "hr",
898
+ "input",
899
+ "meta",
900
+ "link",
901
+ "area",
902
+ "base",
903
+ "col",
904
+ "embed",
905
+ "param",
906
+ "source",
907
+ "track",
908
+ "wbr",
909
+ ])
910
+
911
+ /**
912
+ * Convert HAST node to Vue VNode
913
+ */
914
+ export function hastToVue(
915
+ node: HastRoot | RootContent,
916
+ options: VueRendererOptions = {},
917
+ figCtx?: FigureContext,
918
+ ): VNodeChild {
919
+ const {
920
+ aimdRenderers = {},
921
+ elementRenderers = {},
922
+ componentMap = {},
923
+ } = options
924
+ const context = resolveRenderContext(options)
925
+
926
+ // Merge custom renderers with defaults
927
+ const mergedAimdRenderers = { ...defaultAimdRenderers, ...aimdRenderers }
928
+
929
+ // Initialize figure context on root
930
+ if (!figCtx && node.type === "root") {
931
+ figCtx = createFigureContext()
932
+ preprocessFigures(node, figCtx)
933
+ }
934
+
935
+ // Handle root node
936
+ if (node.type === "root") {
937
+ const children = node.children
938
+ .map(child => hastToVue(child, options, figCtx))
939
+ .filter(Boolean)
940
+ return h(Fragment, null, children)
941
+ }
942
+
943
+ // Handle text node
944
+ if (node.type === "text") {
945
+ return (node as HastText).value
946
+ }
947
+
948
+ // Handle element node
949
+ if (node.type === "element") {
950
+ const element = node as Element
951
+ const { tagName, properties = {}, children = [] } = element
952
+
953
+ // Check for AIMD node
954
+ const aimdType = properties["data-aimd-type"] || properties.dataAimdType
955
+ if (aimdType) {
956
+ // Try to get AIMD data from node.data first
957
+ let aimdData = (element.data as AimdElementData | undefined)?.aimd
958
+
959
+ // If not found, parse from properties
960
+ if (!aimdData) {
961
+ aimdData = parseAimdFromProps(properties as Record<string, unknown>)
962
+ }
963
+
964
+ if (aimdData) {
965
+ // Add figure sequence number if this is a fig node
966
+ if (aimdData.fieldType === "fig" && figCtx) {
967
+ const figId = aimdData.id
968
+ const sequence = figCtx.figureNumbers.get(figId)
969
+ if (sequence !== undefined) {
970
+ (aimdData as any).sequence = sequence
971
+ }
972
+ }
973
+
974
+ // Add figure number to ref_fig references
975
+ if (aimdData.fieldType === "ref_fig" && figCtx) {
976
+ const refTarget = aimdData.refTarget
977
+ const sequence = figCtx.figureNumbers.get(refTarget)
978
+ if (sequence !== undefined) {
979
+ (aimdData as any).figureNumber = sequence + 1
980
+ }
981
+ }
982
+
983
+ const renderer = mergedAimdRenderers[aimdData.fieldType]
984
+ if (renderer) {
985
+ // Process children for container elements (step, check)
986
+ const childVNodes = children
987
+ .map(child => hastToVue(child, options, figCtx))
988
+ .filter(Boolean)
989
+ const result = renderer(aimdData, context, childVNodes)
990
+ if (isPromiseLike<VNode>(result)) {
991
+ if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
992
+ console.warn(
993
+ `[aimd-renderer] Async renderer for "${aimdData.fieldType}" returned a Promise, `
994
+ + `but hastToVue() is synchronous. The node will be skipped. `
995
+ + `Use a synchronous renderer or handle async rendering at a higher level.`,
996
+ )
997
+ }
998
+ return null
999
+ }
1000
+ if (result) {
1001
+ return result
1002
+ }
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ // Check for component map
1008
+ if (componentMap[tagName]) {
1009
+ const childVNodes = children
1010
+ .map(child => hastToVue(child, options, figCtx))
1011
+ .filter(Boolean)
1012
+ return h(componentMap[tagName], convertProperties(properties as Record<string, unknown>), childVNodes)
1013
+ }
1014
+
1015
+ // Check custom element renderer
1016
+ if (elementRenderers[tagName]) {
1017
+ const childVNodes = children
1018
+ .map(child => hastToVue(child, options, figCtx))
1019
+ .filter(Boolean)
1020
+ const rendered = elementRenderers[tagName](element, childVNodes, context)
1021
+ if (rendered !== null && rendered !== undefined) {
1022
+ return rendered
1023
+ }
1024
+ }
1025
+
1026
+ // Convert properties
1027
+ const vueProps = convertProperties(properties as Record<string, unknown>)
1028
+
1029
+ // Handle void elements
1030
+ if (VOID_ELEMENTS.has(tagName)) {
1031
+ return h(tagName, vueProps)
1032
+ }
1033
+
1034
+ // Recursively process children
1035
+ const childVNodes = children
1036
+ .map(child => hastToVue(child, options, figCtx))
1037
+ .filter(Boolean)
1038
+
1039
+ return h(tagName, vueProps, childVNodes)
1040
+ }
1041
+
1042
+ // Handle comment node (ignore)
1043
+ if (node.type === "comment") {
1044
+ return null
1045
+ }
1046
+
1047
+ // Handle doctype (ignore)
1048
+ if (node.type === "doctype") {
1049
+ return null
1050
+ }
1051
+
1052
+ // Handle raw node
1053
+ if (node.type === "raw") {
1054
+ return h("span", {
1055
+ innerHTML: (node as any).value,
1056
+ })
1057
+ }
1058
+
1059
+ return null
1060
+ }
1061
+
1062
+ /**
1063
+ * Convert HAST tree to Vue VNode array
1064
+ */
1065
+ export function renderToVNodes(
1066
+ tree: HastRoot,
1067
+ options: VueRendererOptions = {},
1068
+ ): VNode[] {
1069
+ const result = hastToVue(tree, options)
1070
+
1071
+ if (result === null || result === undefined) {
1072
+ return []
1073
+ }
1074
+
1075
+ if (Array.isArray(result)) {
1076
+ return result.filter((v): v is VNode => v !== null && typeof v === "object")
1077
+ }
1078
+
1079
+ if (typeof result === "object" && "type" in result) {
1080
+ return [result as VNode]
1081
+ }
1082
+
1083
+ // Wrap text node in span
1084
+ return [h("span", null, String(result))]
1085
+ }
1086
+
1087
+ /**
1088
+ * Create a custom AIMD renderer that wraps a Vue component
1089
+ */
1090
+ export function createComponentRenderer(
1091
+ component: Component,
1092
+ propsMapper?: (node: AimdNode, ctx: AimdRendererContext) => Record<string, unknown>,
1093
+ ): AimdComponentRenderer {
1094
+ return (node, ctx, children) => {
1095
+ const props = propsMapper ? propsMapper(node, ctx) : { node, ctx }
1096
+ return h(component, props, children ? { default: () => children } : undefined)
1097
+ }
1098
+ }
1099
+
1100
+ export function createStepCardRenderer(
1101
+ options: AimdStepCardRendererOptions = {},
1102
+ ): AimdComponentRenderer {
1103
+ const {
1104
+ showScopeLabel = true,
1105
+ showCheckBadge = true,
1106
+ bodyClassName = "",
1107
+ className = "",
1108
+ } = options
1109
+
1110
+ return (node, ctx, children) => {
1111
+ const stepNode = node as AimdStepNode
1112
+ const stepLabel = stepNode.step || "?"
1113
+ const title = stepNode.title || stepNode.id
1114
+ const subtitle = stepNode.subtitle || ""
1115
+ const isResult = Boolean(stepNode.result)
1116
+ const hasCheck = Boolean(stepNode.check)
1117
+ const level = Number(stepNode.level || 1)
1118
+ const bodyChildren = normalizeStepCardBodyChildren(children)
1119
+
1120
+ const rootClasses = [
1121
+ "aimd-step-card",
1122
+ className,
1123
+ level > 1 ? `aimd-step-card--level-${level}` : "",
1124
+ isResult ? "aimd-step-card--result" : "",
1125
+ hasCheck ? "aimd-step-card--checkable" : "",
1126
+ ].filter(Boolean)
1127
+
1128
+ return h("article", {
1129
+ class: rootClasses,
1130
+ "data-aimd-step-id": stepNode.id,
1131
+ "data-aimd-step-level": String(level),
1132
+ style: {
1133
+ display: "grid",
1134
+ gap: "14px",
1135
+ padding: level > 1 ? "16px 18px 16px 20px" : "18px 20px",
1136
+ margin: "12px 0",
1137
+ borderRadius: "18px",
1138
+ border: "1px solid rgba(26, 39, 31, 0.12)",
1139
+ background: isResult
1140
+ ? "linear-gradient(180deg, rgba(255,248,233,0.98) 0%, rgba(255,252,245,0.98) 100%)"
1141
+ : "linear-gradient(180deg, rgba(248,252,249,0.98) 0%, rgba(255,255,255,0.98) 100%)",
1142
+ boxShadow: "0 18px 40px rgba(15, 31, 23, 0.08)",
1143
+ position: "relative",
1144
+ overflow: "hidden",
1145
+ },
1146
+ }, [
1147
+ h("header", {
1148
+ class: "aimd-step-card__header",
1149
+ style: {
1150
+ display: "flex",
1151
+ alignItems: "flex-start",
1152
+ justifyContent: "space-between",
1153
+ gap: "14px",
1154
+ },
1155
+ }, [
1156
+ h("div", {
1157
+ style: {
1158
+ display: "flex",
1159
+ gap: "14px",
1160
+ alignItems: "flex-start",
1161
+ minWidth: "0",
1162
+ },
1163
+ }, [
1164
+ h("div", {
1165
+ class: "aimd-step-card__badge",
1166
+ style: {
1167
+ minWidth: "42px",
1168
+ height: "42px",
1169
+ borderRadius: "14px",
1170
+ display: "inline-flex",
1171
+ alignItems: "center",
1172
+ justifyContent: "center",
1173
+ fontWeight: "700",
1174
+ fontSize: "14px",
1175
+ color: "#0d5139",
1176
+ background: hasCheck
1177
+ ? "linear-gradient(180deg, rgba(212,242,227,1) 0%, rgba(184,233,208,1) 100%)"
1178
+ : "linear-gradient(180deg, rgba(232,241,236,1) 0%, rgba(216,232,222,1) 100%)",
1179
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.65)",
1180
+ flexShrink: 0,
1181
+ },
1182
+ }, stepLabel),
1183
+ h("div", {
1184
+ style: {
1185
+ display: "grid",
1186
+ gap: "6px",
1187
+ minWidth: "0",
1188
+ },
1189
+ }, [
1190
+ showScopeLabel
1191
+ ? h("div", {
1192
+ style: {
1193
+ fontSize: "11px",
1194
+ fontWeight: "700",
1195
+ letterSpacing: "0.12em",
1196
+ textTransform: "uppercase",
1197
+ color: "#5d7066",
1198
+ },
1199
+ }, ctx.messages.scope.step)
1200
+ : null,
1201
+ h("div", {
1202
+ class: "aimd-step-card__title",
1203
+ style: {
1204
+ fontSize: "18px",
1205
+ fontWeight: "700",
1206
+ lineHeight: "1.2",
1207
+ color: "#1b2b22",
1208
+ wordBreak: "break-word",
1209
+ },
1210
+ }, title),
1211
+ subtitle
1212
+ ? h("div", {
1213
+ class: "aimd-step-card__subtitle",
1214
+ style: {
1215
+ fontSize: "13px",
1216
+ lineHeight: "1.45",
1217
+ color: "#5c6f65",
1218
+ },
1219
+ }, subtitle)
1220
+ : null,
1221
+ ]),
1222
+ ]),
1223
+ h("div", {
1224
+ style: {
1225
+ display: "flex",
1226
+ gap: "8px",
1227
+ alignItems: "center",
1228
+ flexWrap: "wrap",
1229
+ justifyContent: "flex-end",
1230
+ },
1231
+ }, [
1232
+ showCheckBadge && hasCheck
1233
+ ? h("span", {
1234
+ style: {
1235
+ display: "inline-flex",
1236
+ alignItems: "center",
1237
+ gap: "6px",
1238
+ padding: "6px 10px",
1239
+ borderRadius: "999px",
1240
+ background: "rgba(22, 114, 79, 0.10)",
1241
+ color: "#0d5139",
1242
+ fontSize: "11px",
1243
+ fontWeight: "700",
1244
+ letterSpacing: "0.04em",
1245
+ textTransform: "uppercase",
1246
+ },
1247
+ }, "Check")
1248
+ : null,
1249
+ isResult
1250
+ ? h("span", {
1251
+ style: {
1252
+ display: "inline-flex",
1253
+ alignItems: "center",
1254
+ padding: "6px 10px",
1255
+ borderRadius: "999px",
1256
+ background: "rgba(181, 118, 0, 0.10)",
1257
+ color: "#8a4f00",
1258
+ fontSize: "11px",
1259
+ fontWeight: "700",
1260
+ letterSpacing: "0.04em",
1261
+ textTransform: "uppercase",
1262
+ },
1263
+ }, "Result")
1264
+ : null,
1265
+ ]),
1266
+ ]),
1267
+ bodyChildren.length > 0
1268
+ ? h("div", {
1269
+ class: ["aimd-step-card__body", bodyClassName].filter(Boolean),
1270
+ style: {
1271
+ display: "grid",
1272
+ gap: "10px",
1273
+ color: "#24352c",
1274
+ fontSize: "14px",
1275
+ lineHeight: "1.75",
1276
+ },
1277
+ }, bodyChildren)
1278
+ : null,
1279
+ ])
1280
+ }
1281
+ }
1282
+
1283
+ /**
1284
+ * Shiki highlighter type (compatible with @shikijs/core)
1285
+ */
1286
+ export interface ShikiHighlighter {
1287
+ codeToHtml: (code: string, options: { lang: string, theme: string }) => string
1288
+ codeToTokensWithThemes?: (code: string, options: { lang: string, themes: Record<string, string> }) => Array<Array<{ content: string, variants: Record<string, { color: string }> }>>
1289
+ }
1290
+
1291
+ /**
1292
+ * Create code block element renderer with Shiki support
1293
+ * @param highlighter - Shiki highlighter instance (can be reactive ref)
1294
+ * @param defaultTheme - Default theme to use
1295
+ */
1296
+ export function createCodeBlockRenderer(
1297
+ highlighter: ShikiHighlighter | null | (() => ShikiHighlighter | null),
1298
+ defaultTheme = "github-dark",
1299
+ ): ElementRenderer {
1300
+ return (node, children, ctx) => {
1301
+ // Find code element inside pre
1302
+ const codeNode = node.children.find(
1303
+ (child): child is Element => child.type === "element" && child.tagName === "code",
1304
+ )
1305
+
1306
+ if (!codeNode) {
1307
+ return h("pre", {}, children)
1308
+ }
1309
+
1310
+ // Get language from class
1311
+ const className = codeNode.properties?.className
1312
+ let lang = "text"
1313
+ if (Array.isArray(className)) {
1314
+ const langClass = className.find(c => typeof c === "string" && c.startsWith("language-"))
1315
+ if (langClass && typeof langClass === "string") {
1316
+ lang = langClass.replace("language-", "")
1317
+ }
1318
+ }
1319
+ else if (typeof className === "string" && className.startsWith("language-")) {
1320
+ lang = className.replace("language-", "")
1321
+ }
1322
+
1323
+ // Get code content
1324
+ const codeContent = codeNode.children
1325
+ .map(child => (child.type === "text" ? child.value : ""))
1326
+ .join("")
1327
+
1328
+ // Get highlighter
1329
+ const hl = typeof highlighter === "function" ? highlighter() : highlighter
1330
+
1331
+ // Use Shiki if available
1332
+ if (hl) {
1333
+ try {
1334
+ const highlightedHtml = hl.codeToHtml(codeContent, {
1335
+ lang,
1336
+ theme: defaultTheme,
1337
+ })
1338
+
1339
+ return h("div", {
1340
+ "class": "shiki-code-block",
1341
+ "data-lang": lang,
1342
+ "innerHTML": highlightedHtml,
1343
+ })
1344
+ }
1345
+ catch (error) {
1346
+ console.error("Failed to highlight code:", error)
1347
+ }
1348
+ }
1349
+
1350
+ // Fallback: render without highlighting
1351
+ return h("pre", { class: `language-${lang}` }, h("code", { class: `language-${lang}` }, codeContent),
1352
+ )
1353
+ }
1354
+ }
1355
+
1356
+ /**
1357
+ * Create Mermaid diagram element renderer
1358
+ * @param MermaidComponent - Vue component to render Mermaid diagrams
1359
+ */
1360
+ export function createMermaidRenderer(
1361
+ MermaidComponent: Component,
1362
+ ): ElementRenderer {
1363
+ return (node, children, ctx) => {
1364
+ // Find code element inside pre
1365
+ const codeNode = node.children.find(
1366
+ (child): child is Element => child.type === "element" && child.tagName === "code",
1367
+ )
1368
+
1369
+ if (!codeNode) {
1370
+ return null
1371
+ }
1372
+
1373
+ // Check if it's a mermaid block
1374
+ const className = codeNode.properties?.className
1375
+ const isMermaid = Array.isArray(className)
1376
+ ? className.some(c => typeof c === "string" && c.includes("mermaid"))
1377
+ : typeof className === "string" && className.includes("mermaid")
1378
+
1379
+ if (!isMermaid) {
1380
+ return null // Not a mermaid block, use default rendering
1381
+ }
1382
+
1383
+ // Get code content
1384
+ const codeContent = codeNode.children
1385
+ .map(child => (child.type === "text" ? child.value : ""))
1386
+ .join("")
1387
+
1388
+ return h(MermaidComponent, {
1389
+ code: codeContent,
1390
+ // Keep compatibility with MermaidBlock-style component APIs.
1391
+ attrs: {},
1392
+ })
1393
+ }
1394
+ }
1395
+
1396
+ /**
1397
+ * Asset resolver function type
1398
+ */
1399
+ export type AssetResolver = (id: string) => Promise<{ url: string } | null>
1400
+
1401
+ /**
1402
+ * Create image element renderer with asset resolution
1403
+ * @param AssetComponent - Vue component to render assets (optional)
1404
+ * @param getAsset - Function to resolve asset ID to URL
1405
+ */
1406
+ export function createAssetRenderer(
1407
+ getAsset?: AssetResolver,
1408
+ AssetComponent?: Component,
1409
+ ): ElementRenderer {
1410
+ return (node, children, ctx) => {
1411
+ const { src, alt, title } = node.properties || {}
1412
+
1413
+ // If AssetComponent is provided, use it
1414
+ if (AssetComponent) {
1415
+ return h(AssetComponent, {
1416
+ src: src as string,
1417
+ alt: alt as string,
1418
+ title: title as string,
1419
+ getStaticResearchAssets: getAsset,
1420
+ })
1421
+ }
1422
+
1423
+ // Default image rendering
1424
+ return h("img", {
1425
+ src: src as string,
1426
+ alt: alt as string,
1427
+ title: title as string,
1428
+ class: "aimd-image",
1429
+ loading: "lazy",
1430
+ })
1431
+ }
1432
+ }
1433
+
1434
+ /**
1435
+ * Create iframe/video element renderer for embedded content
1436
+ * @param EmbeddedComponent - Vue component to render embedded content
1437
+ */
1438
+ export function createEmbeddedRenderer(
1439
+ EmbeddedComponent: Component,
1440
+ ): ElementRenderer {
1441
+ return (node, children, ctx) => {
1442
+ const props = node.properties || {}
1443
+
1444
+ return h(EmbeddedComponent, {
1445
+ contentProps: props,
1446
+ component: node.tagName,
1447
+ })
1448
+ }
1449
+ }