@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,810 @@
1
+ import type { Element } from "hast"
2
+ /**
3
+ * Unified-based token renderer
4
+ * Provides compatibility layer for migrating from markdown-it tokenRenderer
5
+ */
6
+ import type { Component, VNode } from "vue"
7
+ import type {
8
+ AimdCheckNode,
9
+ AimdQuizNode,
10
+ AimdStepNode,
11
+ AimdVarNode,
12
+ AimdVarTableNode,
13
+ } from "@airalogy/aimd-core/types"
14
+ import type { AimdNode, QuizPreviewOptions, RenderContext } from "@airalogy/aimd-core/types"
15
+ import type { ExtractedAimdFields } from "@airalogy/aimd-core/types"
16
+ import type { AimdRendererI18nOptions, AimdRendererMessages } from "../locales"
17
+ import type { AimdComponentRenderer, ElementRenderer, ShikiHighlighter, VueRendererOptions } from "../vue/vue-renderer"
18
+ import type { AimdRendererOptions, RenderResult } from "./processor"
19
+ import { resolveQuizPreviewOptions, type ResolvedQuizPreviewOptions } from "./quiz-preview"
20
+ import { h } from "vue"
21
+ import {
22
+ createAimdRendererMessages,
23
+ getAimdRendererQuizTypeLabel,
24
+ getAimdRendererScopeLabel,
25
+ resolveAimdRendererLocale,
26
+ } from "../locales"
27
+ import { parseAndExtract, renderToVue } from "./processor"
28
+
29
+ /**
30
+ * Token props interface (compatible with IAIMDItemProps)
31
+ */
32
+ export interface TokenProps {
33
+ scope: string
34
+ prop: string
35
+ type?: string
36
+ value?: unknown
37
+ label?: string
38
+ disabled?: boolean
39
+ title?: string
40
+ required?: boolean
41
+ [key: string]: unknown
42
+ }
43
+
44
+ /**
45
+ * Token-like object for getTokenProps compatibility
46
+ */
47
+ export interface TokenLike {
48
+ meta?: {
49
+ node?: {
50
+ id: string
51
+ scope: string
52
+ type?: string
53
+ }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Asset response interface
59
+ */
60
+ export interface AssetResponse {
61
+ url: string
62
+ [key: string]: unknown
63
+ }
64
+
65
+ /**
66
+ * Render mode type
67
+ */
68
+ export type RenderMode = "preview" | "edit" | "timeline" | "report"
69
+
70
+ /**
71
+ * Unified token renderer options
72
+ * Compatible with TokenRendererBaseOptions from markdown-it version
73
+ */
74
+ export interface UnifiedTokenRendererOptions {
75
+ /**
76
+ * Get props for a field from external data source
77
+ */
78
+ getTokenProps?: (token: TokenLike) => Promise<TokenProps | null>
79
+ /**
80
+ * Get static research assets (images, files)
81
+ */
82
+ getStaticResearchAssets?: (id: string) => Promise<AssetResponse | null>
83
+ /**
84
+ * Render mode
85
+ */
86
+ mode: RenderMode | (() => RenderMode)
87
+ /**
88
+ * Shiki highlighter for code blocks
89
+ */
90
+ highlighter?: ShikiHighlighter | null | (() => ShikiHighlighter | null)
91
+ /**
92
+ * Custom Vue components
93
+ */
94
+ components?: {
95
+ AIMDItem?: Component
96
+ AIMDTag?: Component
97
+ AIMDStepRef?: Component
98
+ StepRenderer?: Component
99
+ CheckRenderer?: Component
100
+ PreviewRenderer?: Component
101
+ AssetRenderer?: Component
102
+ EmbeddedRenderer?: Component
103
+ MermaidBlock?: Component
104
+ }
105
+ /**
106
+ * Quiz preview visibility policy
107
+ */
108
+ quizPreview?: QuizPreviewOptions
109
+ /**
110
+ * Built-in renderer locale
111
+ */
112
+ locale?: AimdRendererI18nOptions["locale"]
113
+ /**
114
+ * Optional overrides for built-in renderer copy
115
+ */
116
+ messages?: AimdRendererI18nOptions["messages"]
117
+ /**
118
+ * Assigner block visibility policy in rendered output.
119
+ */
120
+ assignerVisibility?: AimdRendererOptions["assignerVisibility"]
121
+ }
122
+
123
+ /**
124
+ * Get scope display key
125
+ */
126
+ function getScopeKey(scope: string): string {
127
+ return scope === "var_table" ? "table" : scope
128
+ }
129
+
130
+ const BLANK_PLACEHOLDER_PATTERN = /\[\[([^\[\]\s]+)\]\]/g
131
+
132
+ function buildQuizStemChildren(
133
+ quizType: AimdQuizNode["quizType"],
134
+ stem: string,
135
+ ): VNode[] {
136
+ if (quizType !== "blank") {
137
+ return [h("span", stem)]
138
+ }
139
+
140
+ const children: VNode[] = []
141
+ let lastIndex = 0
142
+
143
+ for (const match of stem.matchAll(BLANK_PLACEHOLDER_PATTERN)) {
144
+ const start = match.index ?? 0
145
+ const fullMatch = match[0]
146
+ const key = match[1]
147
+
148
+ if (start > lastIndex) {
149
+ children.push(h("span", stem.slice(lastIndex, start)))
150
+ }
151
+
152
+ children.push(
153
+ h("span", {
154
+ class: "aimd-quiz__blank-placeholder",
155
+ "data-blank-key": key,
156
+ }, key),
157
+ )
158
+
159
+ lastIndex = start + fullMatch.length
160
+ }
161
+
162
+ if (lastIndex < stem.length) {
163
+ children.push(h("span", stem.slice(lastIndex)))
164
+ }
165
+
166
+ if (children.length === 0) {
167
+ children.push(h("span", stem))
168
+ }
169
+
170
+ return children
171
+ }
172
+
173
+ function formatScaleOptionLabel(option: NonNullable<AimdQuizNode["options"]>[number]): string {
174
+ if (typeof option.points === "number" && Number.isFinite(option.points)) {
175
+ return `${option.text} (${option.points})`
176
+ }
177
+ return option.text
178
+ }
179
+
180
+ function buildScalePreviewChildren(quizNode: AimdQuizNode): VNode[] {
181
+ if (!Array.isArray(quizNode.items) || quizNode.items.length === 0 || !Array.isArray(quizNode.options) || quizNode.options.length === 0) {
182
+ return [h("div", { class: "aimd-scale__empty" }, "Scale definition is incomplete.")]
183
+ }
184
+
185
+ if (quizNode.display === "list") {
186
+ return [
187
+ h("div", { class: "aimd-scale__list" }, quizNode.items.map(item =>
188
+ h("div", { class: "aimd-scale__list-item" }, [
189
+ h("div", { class: "aimd-scale__item-stem" }, item.stem),
190
+ h("ul", { class: "aimd-scale__item-options" }, quizNode.options!.map(option =>
191
+ h("li", formatScaleOptionLabel(option)),
192
+ )),
193
+ ]),
194
+ )),
195
+ ]
196
+ }
197
+
198
+ return [
199
+ h("table", { class: "aimd-scale__table" }, [
200
+ h("thead", [
201
+ h("tr", [
202
+ h("th", { class: "aimd-scale__item-header" }, "Item"),
203
+ ...quizNode.options.map(option => h("th", formatScaleOptionLabel(option))),
204
+ ]),
205
+ ]),
206
+ h("tbody", quizNode.items.map(item =>
207
+ h("tr", [
208
+ h("th", { class: "aimd-scale__item-stem", scope: "row" }, item.stem),
209
+ ...quizNode.options!.map(() => h("td", { class: "aimd-scale__cell" }, "○")),
210
+ ]),
211
+ )),
212
+ ]),
213
+ ]
214
+ }
215
+
216
+ function buildScaleBandChildren(quizNode: AimdQuizNode): VNode[] {
217
+ const bands = Array.isArray((quizNode.grading as any)?.bands) ? (quizNode.grading as any).bands : []
218
+ if (bands.length === 0) {
219
+ return []
220
+ }
221
+
222
+ return [
223
+ h("ul", { class: "aimd-scale__bands" }, bands.map((band: any) =>
224
+ h("li", `${band.min}-${band.max}: ${band.label}${band.interpretation ? ` · ${band.interpretation}` : ""}`),
225
+ )),
226
+ ]
227
+ }
228
+
229
+ /**
230
+ * Render preview tag for AIMD field
231
+ */
232
+ function renderPreviewTag(
233
+ scope: string,
234
+ id: string,
235
+ messages: AimdRendererMessages,
236
+ columns?: string[],
237
+ ): VNode {
238
+ const scopeKey = getScopeKey(scope)
239
+ const scopeLabel = getAimdRendererScopeLabel(scope, messages)
240
+
241
+ // var_table: render tag with table preview inside
242
+ if (scope === "var_table") {
243
+ const children: VNode[] = [
244
+ h("div", { class: "aimd-field__header" }, [
245
+ h("span", { class: "aimd-field__scope" }, messages.scope.table),
246
+ h("span", { class: "aimd-field__name" }, id),
247
+ ]),
248
+ ]
249
+ // Add table preview inside the container
250
+ if (columns && columns.length > 0) {
251
+ children.push(
252
+ h("table", { class: "aimd-field__table-preview" }, [
253
+ h("thead", [
254
+ h("tr", columns.map(col => h("th", col))),
255
+ ]),
256
+ h("tbody", [
257
+ h("tr", columns.map(() => h("td", "..."))),
258
+ ]),
259
+ ]),
260
+ )
261
+ }
262
+ return h("div", {
263
+ "class": "aimd-field aimd-field--var-table",
264
+ "data-aimd-type": "var_table",
265
+ "data-aimd-id": id,
266
+ }, children)
267
+ }
268
+
269
+ const classSuffix = scopeKey === "table" ? "var-table" : scopeKey
270
+
271
+ return h("span", {
272
+ "class": `aimd-field aimd-field--${classSuffix}`,
273
+ "data-aimd-type": scopeKey,
274
+ "data-aimd-id": id,
275
+ }, [
276
+ h("span", { class: "aimd-field__scope" }, scopeLabel),
277
+ h("span", { class: "aimd-field__name" }, id),
278
+ ])
279
+ }
280
+
281
+ /**
282
+ * Create AIMD renderers based on options
283
+ */
284
+ function createAimdRenderers(options: UnifiedTokenRendererOptions): Record<string, AimdComponentRenderer> {
285
+ const { getTokenProps, mode, components = {} } = options
286
+ const messages = createAimdRendererMessages(options.locale, options.messages)
287
+ const {
288
+ AIMDItem,
289
+ AIMDTag,
290
+ AIMDStepRef,
291
+ StepRenderer,
292
+ CheckRenderer,
293
+ PreviewRenderer,
294
+ } = components
295
+
296
+ const getMode = (): RenderMode => typeof mode === "function" ? mode() : mode
297
+ const isPreview = () => getMode() === "preview"
298
+ const getQuizPreview = (): ResolvedQuizPreviewOptions =>
299
+ resolveQuizPreviewOptions(getMode(), options.quizPreview)
300
+
301
+ return {
302
+ var: async (node, ctx, children) => {
303
+ const varNode = node as AimdVarNode
304
+ const { id, scope } = varNode
305
+
306
+ if (isPreview()) {
307
+ if (PreviewRenderer) {
308
+ return h(PreviewRenderer, { type: "var" }, {
309
+ default: () => children,
310
+ name: () => id,
311
+ })
312
+ }
313
+ return renderPreviewTag(scope, id, messages)
314
+ }
315
+
316
+ // Edit mode
317
+ if (getTokenProps && AIMDItem) {
318
+ const item = await getTokenProps({ meta: { node: { id, scope } } })
319
+ if (item) {
320
+ return h("span", {
321
+ "class": "aimd-field-wrapper aimd-field-wrapper--inline",
322
+ "id": `${scope}-${id}`,
323
+ "data-has-variable": "true",
324
+ }, [h(AIMDItem, item)])
325
+ }
326
+ }
327
+
328
+ return renderPreviewTag(scope, id, messages)
329
+ },
330
+
331
+ var_table: async (node, ctx, children) => {
332
+ const tableNode = node as AimdVarTableNode
333
+ const { id, scope, columns } = tableNode
334
+
335
+ if (isPreview()) {
336
+ if (PreviewRenderer) {
337
+ return h(PreviewRenderer, { type: "var_table" }, {
338
+ default: () => children,
339
+ name: () => id,
340
+ })
341
+ }
342
+ // Preview mode: render inline tag with columns info
343
+ return renderPreviewTag(scope, id, messages, columns)
344
+ }
345
+
346
+ // Edit mode
347
+ if (getTokenProps && AIMDTag) {
348
+ const item = await getTokenProps({ meta: { node: { id, scope } } })
349
+ return h(AIMDTag, { ...item, props: columns })
350
+ }
351
+
352
+ return renderPreviewTag(scope, id, messages, columns)
353
+ },
354
+
355
+ quiz: async (node, ctx, children) => {
356
+ const quizNode = node as AimdQuizNode
357
+ const { id, scope, quizType, stem, score } = quizNode
358
+ const typeLabel = getAimdRendererQuizTypeLabel(quizType, quizNode.mode, messages)
359
+
360
+ if (isPreview()) {
361
+ if (PreviewRenderer) {
362
+ return h(PreviewRenderer, { type: "quiz" }, {
363
+ default: () => children,
364
+ name: () => id,
365
+ })
366
+ }
367
+ const previewChildren: VNode[] = [
368
+ h("div", { class: "aimd-quiz__meta" }, [
369
+ h("span", { class: "aimd-field__scope" }, getAimdRendererScopeLabel(scope, messages)),
370
+ h("span", { class: "aimd-field__name" }, id),
371
+ h("span", { class: "aimd-field__type" }, `(${typeLabel})`),
372
+ score !== undefined ? h("span", { class: "aimd-quiz__score" }, messages.quiz.score(score)) : null,
373
+ ]),
374
+ h("div", { class: "aimd-quiz__stem" }, buildQuizStemChildren(quizType, stem || id)),
375
+ ]
376
+
377
+ if (typeof quizNode.title === "string" && quizNode.title.trim()) {
378
+ previewChildren.splice(1, 0, h("div", { class: "aimd-quiz__title" }, quizNode.title))
379
+ }
380
+ if (typeof quizNode.description === "string" && quizNode.description.trim()) {
381
+ previewChildren.push(h("div", { class: "aimd-quiz__description" }, quizNode.description))
382
+ }
383
+
384
+ if (quizType === "choice" && Array.isArray(quizNode.options) && quizNode.options.length > 0) {
385
+ previewChildren.push(
386
+ h("ul", { class: "aimd-quiz__options" }, quizNode.options.map(opt =>
387
+ h("li", `${opt.key}. ${opt.text}`),
388
+ )),
389
+ )
390
+ }
391
+
392
+ if (quizType === "scale") {
393
+ previewChildren.push(...buildScalePreviewChildren(quizNode))
394
+ previewChildren.push(...buildScaleBandChildren(quizNode))
395
+ }
396
+
397
+ const quizPreview = getQuizPreview()
398
+
399
+ if (quizPreview.showAnswers && quizType === "choice" && quizNode.answer !== undefined) {
400
+ const answerText = Array.isArray(quizNode.answer)
401
+ ? quizNode.answer.join(", ")
402
+ : String(quizNode.answer)
403
+ if (answerText.trim()) {
404
+ previewChildren.push(
405
+ h("div", { class: "aimd-quiz__answer" }, messages.quiz.answer(answerText)),
406
+ )
407
+ }
408
+ }
409
+
410
+ if (quizPreview.showAnswers && quizType === "blank" && Array.isArray(quizNode.blanks) && quizNode.blanks.length > 0) {
411
+ previewChildren.push(
412
+ h("ul", { class: "aimd-quiz__blanks" }, quizNode.blanks.map(blank =>
413
+ h("li", `${blank.key}: ${blank.answer}`),
414
+ )),
415
+ )
416
+ }
417
+
418
+ if (quizPreview.showRubric && quizType === "open" && typeof quizNode.rubric === "string" && quizNode.rubric.trim()) {
419
+ previewChildren.push(
420
+ h("div", { class: "aimd-quiz__rubric" }, messages.quiz.rubric(quizNode.rubric)),
421
+ )
422
+ }
423
+
424
+ return h("div", {
425
+ "class": "aimd-field aimd-field--quiz",
426
+ "data-aimd-type": "quiz",
427
+ "data-aimd-id": id,
428
+ }, previewChildren)
429
+ }
430
+
431
+ // Edit mode
432
+ if (getTokenProps && AIMDItem) {
433
+ const item = await getTokenProps({ meta: { node: { id, scope } } })
434
+ if (item) {
435
+ return h("span", {
436
+ "class": "aimd-field-wrapper aimd-field-wrapper--inline",
437
+ "id": `${scope}-${id}`,
438
+ "data-has-variable": "true",
439
+ }, [h(AIMDItem, item)])
440
+ }
441
+ }
442
+
443
+ return h("div", {
444
+ "class": "aimd-field aimd-field--quiz",
445
+ "data-aimd-type": "quiz",
446
+ "data-aimd-id": id,
447
+ }, [
448
+ h("div", { class: "aimd-quiz__meta" }, [
449
+ h("span", { class: "aimd-field__scope" }, getAimdRendererScopeLabel(scope, messages)),
450
+ h("span", { class: "aimd-field__name" }, id),
451
+ h("span", { class: "aimd-field__type" }, `(${typeLabel})`),
452
+ ]),
453
+ ...(typeof quizNode.title === "string" && quizNode.title.trim()
454
+ ? [h("div", { class: "aimd-quiz__title" }, quizNode.title)]
455
+ : []),
456
+ h("div", { class: "aimd-quiz__stem" }, buildQuizStemChildren(quizType, stem || id)),
457
+ ...(typeof quizNode.description === "string" && quizNode.description.trim()
458
+ ? [h("div", { class: "aimd-quiz__description" }, quizNode.description)]
459
+ : []),
460
+ ...(quizType === "scale" ? [...buildScalePreviewChildren(quizNode), ...buildScaleBandChildren(quizNode)] : []),
461
+ ])
462
+ },
463
+
464
+ step: async (node, ctx, children) => {
465
+ const stepNode = node as AimdStepNode
466
+ const { id, scope, step, check } = stepNode
467
+
468
+ if (isPreview()) {
469
+ if (PreviewRenderer) {
470
+ return h(PreviewRenderer, { type: "step" }, {
471
+ default: () => children,
472
+ name: () => id,
473
+ })
474
+ }
475
+ return h("span", { class: "research-step__sequence" }, messages.step.sequence(step))
476
+ }
477
+
478
+ // Edit mode
479
+ if (getTokenProps && StepRenderer) {
480
+ const item = await getTokenProps({ meta: { node: { id, scope } } })
481
+ const annotationItem = await getTokenProps({ meta: { node: { id, scope, type: "step-annotation" } } })
482
+
483
+ return h(StepRenderer, {
484
+ item,
485
+ annotationItem,
486
+ name: id,
487
+ step: String(step),
488
+ check,
489
+ }, {
490
+ default: () => children,
491
+ })
492
+ }
493
+
494
+ return h("span", { class: "research-step__sequence" }, messages.step.sequence(step))
495
+ },
496
+
497
+ check: async (node, ctx, children) => {
498
+ const checkNode = node as AimdCheckNode
499
+ const { id, scope, label } = checkNode
500
+
501
+ if (isPreview()) {
502
+ if (PreviewRenderer) {
503
+ return h(PreviewRenderer, { type: "check" }, {
504
+ default: () => children,
505
+ name: () => id,
506
+ })
507
+ }
508
+ return renderPreviewTag(scope, id, messages)
509
+ }
510
+
511
+ // Edit mode
512
+ if (getTokenProps && CheckRenderer) {
513
+ const item = await getTokenProps({ meta: { node: { id, scope } } })
514
+ const annotationItem = await getTokenProps({ meta: { node: { id, scope, type: "check-annotation" } } })
515
+
516
+ return h(CheckRenderer, {
517
+ item,
518
+ annotationItem,
519
+ name: id,
520
+ }, {
521
+ default: () => children,
522
+ })
523
+ }
524
+
525
+ return renderPreviewTag(scope, id, messages)
526
+ },
527
+
528
+ ref_step: (node, ctx) => {
529
+ const { id } = node
530
+ const refTarget = "refTarget" in node ? node.refTarget : id
531
+ const stepSequence = "stepSequence" in node && typeof (node as any).stepSequence === "string"
532
+ ? (node as any).stepSequence
533
+ : undefined
534
+ const displayText = stepSequence ? messages.step.reference(stepSequence) : refTarget
535
+
536
+ if (AIMDStepRef) {
537
+ return h(AIMDStepRef, { name: refTarget, type: "step", stepSequence })
538
+ }
539
+
540
+ return h("a", {
541
+ class: "aimd-ref aimd-ref--step",
542
+ href: `#step-${refTarget}`,
543
+ "data-aimd-step-sequence": stepSequence,
544
+ title: refTarget,
545
+ }, [
546
+ h("span", { class: "aimd-ref__content" }, [
547
+ h("span", { class: "aimd-field aimd-field--step aimd-field--readonly" }, [
548
+ h("span", { class: "research-step__sequence" }, displayText),
549
+ ]),
550
+ ]),
551
+ ])
552
+ },
553
+
554
+ ref_var: (node, ctx) => {
555
+ const { id } = node
556
+ const refTarget = "refTarget" in node ? node.refTarget : id
557
+ const referencedValue = ctx.mode === "edit" ? getReferencedVarDisplayValue(ctx.value, refTarget) : null
558
+
559
+ if (AIMDStepRef) {
560
+ return h(AIMDStepRef, {
561
+ name: refTarget,
562
+ type: "var",
563
+ contextValue: ctx.value,
564
+ displayValue: referencedValue ?? undefined,
565
+ })
566
+ }
567
+
568
+ if (ctx.mode !== "edit") {
569
+ return h("a", {
570
+ class: "aimd-ref aimd-ref--var",
571
+ href: `#var-${refTarget}`,
572
+ title: refTarget,
573
+ }, [
574
+ h("span", { class: "aimd-ref__icon" }, "📌"),
575
+ h("span", { class: "aimd-ref__name" }, refTarget),
576
+ ])
577
+ }
578
+
579
+ return h("span", {
580
+ class: "aimd-ref aimd-ref--var",
581
+ "data-aimd-ref": refTarget,
582
+ title: refTarget,
583
+ }, [
584
+ h("span", { class: "aimd-ref__content" }, [
585
+ referencedValue !== null
586
+ ? h("span", {
587
+ class: "aimd-field aimd-field--var aimd-field--readonly",
588
+ "data-aimd-id": refTarget,
589
+ "data-aimd-scope": "var",
590
+ }, [
591
+ h("span", { class: "aimd-field__value" }, referencedValue),
592
+ ])
593
+ : h("span", { class: "aimd-field aimd-field--var" }, [
594
+ h("span", { class: "aimd-field__scope" }, messages.scope.var),
595
+ h("span", { class: "aimd-field__name" }, refTarget),
596
+ ]),
597
+ ]),
598
+ ])
599
+ },
600
+ }
601
+ }
602
+
603
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
604
+ return typeof value === "object" && value !== null && !Array.isArray(value)
605
+ }
606
+
607
+ function getReferencedVarDisplayValue(
608
+ value: RenderContext["value"] | undefined,
609
+ refTarget: string,
610
+ ): string | null {
611
+ const fieldData = value?.var?.[refTarget]
612
+ const resolvedValue = isPlainObject(fieldData) && "value" in fieldData
613
+ ? fieldData.value
614
+ : fieldData
615
+
616
+ if (resolvedValue === undefined || resolvedValue === null || resolvedValue === "") {
617
+ return null
618
+ }
619
+
620
+ if (Array.isArray(resolvedValue)) {
621
+ return resolvedValue.map(item => String(item)).join(", ")
622
+ }
623
+
624
+ if (isPlainObject(resolvedValue)) {
625
+ try {
626
+ return JSON.stringify(resolvedValue)
627
+ }
628
+ catch {
629
+ return null
630
+ }
631
+ }
632
+
633
+ return String(resolvedValue)
634
+ }
635
+
636
+ /**
637
+ * Create element renderers based on options
638
+ */
639
+ function createElementRenderers(options: UnifiedTokenRendererOptions): Record<string, ElementRenderer> {
640
+ const { getStaticResearchAssets, highlighter, components = {} } = options
641
+ const { AssetRenderer, EmbeddedRenderer, MermaidBlock } = components
642
+
643
+ const renderers: Record<string, ElementRenderer> = {}
644
+
645
+ // Image renderer
646
+ if (AssetRenderer || getStaticResearchAssets) {
647
+ renderers.img = (node, children, ctx) => {
648
+ const { src, alt } = node.properties || {}
649
+
650
+ if (AssetRenderer) {
651
+ return h(AssetRenderer, {
652
+ src: src as string,
653
+ alt: alt as string,
654
+ getStaticResearchAssets,
655
+ })
656
+ }
657
+
658
+ return h("img", { src, alt, class: "aimd-image", loading: "lazy" })
659
+ }
660
+ }
661
+
662
+ // Code block renderer with Mermaid support
663
+ renderers.pre = (node, children, ctx) => {
664
+ const codeNode = node.children.find(
665
+ (child): child is Element => child.type === "element" && child.tagName === "code",
666
+ )
667
+
668
+ if (!codeNode) {
669
+ return h("pre", {}, children)
670
+ }
671
+
672
+ // Get language
673
+ const className = codeNode.properties?.className
674
+ let lang = "text"
675
+ if (Array.isArray(className)) {
676
+ const langClass = className.find(c => typeof c === "string" && c.startsWith("language-"))
677
+ if (langClass && typeof langClass === "string") {
678
+ lang = langClass.replace("language-", "")
679
+ }
680
+ }
681
+
682
+ // Get code content
683
+ const codeContent = codeNode.children
684
+ .map(child => (child.type === "text" ? child.value : ""))
685
+ .join("")
686
+
687
+ // Check for Mermaid
688
+ const firstLine = codeContent.split(/\n/)[0].trim()
689
+ const isMermaid = lang === "mermaid"
690
+ || firstLine === "gantt"
691
+ || firstLine === "sequenceDiagram"
692
+ || /^graph (?:TB|BT|RL|LR|TD);?$/.test(firstLine)
693
+
694
+ if (isMermaid && MermaidBlock) {
695
+ return h(MermaidBlock, { code: codeContent })
696
+ }
697
+
698
+ // Use Shiki if available
699
+ const hl = typeof highlighter === "function" ? highlighter() : highlighter
700
+ if (hl) {
701
+ try {
702
+ const highlightedHtml = hl.codeToHtml(codeContent, {
703
+ lang,
704
+ theme: "github-dark",
705
+ })
706
+ return h("div", {
707
+ "class": "shiki-code-block",
708
+ "data-lang": lang,
709
+ "innerHTML": highlightedHtml,
710
+ })
711
+ }
712
+ catch (error) {
713
+ console.error("Failed to highlight code:", error)
714
+ }
715
+ }
716
+
717
+ // Fallback
718
+ return h("pre", { class: `language-${lang}` }, h("code", { class: `language-${lang}` }, codeContent),
719
+ )
720
+ }
721
+
722
+ // Iframe renderer
723
+ if (EmbeddedRenderer) {
724
+ renderers.iframe = (node, children, ctx) => {
725
+ return h(EmbeddedRenderer, {
726
+ contentProps: { ...node.properties, credentialless: true },
727
+ component: "iframe",
728
+ })
729
+ }
730
+
731
+ // Video renderer
732
+ renderers.video = (node, children, ctx) => {
733
+ return h(EmbeddedRenderer, {
734
+ contentProps: node.properties,
735
+ component: "video",
736
+ })
737
+ }
738
+ }
739
+
740
+ return renderers
741
+ }
742
+
743
+ /**
744
+ * Unified token renderer context
745
+ * Compatible interface for migration from markdown-it
746
+ */
747
+ export interface UnifiedRendererContext {
748
+ /**
749
+ * Render markdown/AIMD content to Vue VNodes
750
+ */
751
+ render: (content: string) => Promise<RenderResult>
752
+ /**
753
+ * Extract fields from content
754
+ */
755
+ extractFields: (content: string) => ExtractedAimdFields
756
+ /**
757
+ * Vue renderer options (for use with renderToVue)
758
+ */
759
+ vueOptions: VueRendererOptions
760
+ }
761
+
762
+ /**
763
+ * Create unified-based token renderer
764
+ * Provides API compatible with createDefaultTokenRenderer from markdown-it version
765
+ */
766
+ export function createUnifiedTokenRenderer(options: UnifiedTokenRendererOptions): UnifiedRendererContext {
767
+ const getMode = (): RenderMode => typeof options.mode === "function" ? options.mode() : options.mode
768
+ const getQuizPreview = (): ResolvedQuizPreviewOptions =>
769
+ resolveQuizPreviewOptions(getMode(), options.quizPreview)
770
+ const locale = resolveAimdRendererLocale(options.locale)
771
+ const messages = createAimdRendererMessages(locale, options.messages)
772
+
773
+ const aimdRenderers = createAimdRenderers(options)
774
+ const elementRenderers = createElementRenderers(options)
775
+
776
+ const vueOptions: VueRendererOptions = {
777
+ locale,
778
+ messages,
779
+ context: {
780
+ mode: getMode() === "timeline" ? "preview" : getMode() as "preview" | "edit" | "report",
781
+ readonly: getMode() === "preview",
782
+ quizPreview: getQuizPreview(),
783
+ locale,
784
+ messages,
785
+ },
786
+ aimdRenderers,
787
+ elementRenderers,
788
+ }
789
+
790
+ return {
791
+ async render(content: string): Promise<RenderResult> {
792
+ return renderToVue(content, {
793
+ gfm: true,
794
+ math: true,
795
+ breaks: true,
796
+ assignerVisibility: options.assignerVisibility,
797
+ ...vueOptions,
798
+ })
799
+ },
800
+
801
+ extractFields(content: string): ExtractedAimdFields {
802
+ return parseAndExtract(content)
803
+ },
804
+
805
+ vueOptions,
806
+ }
807
+ }
808
+
809
+ // Re-export types for convenience
810
+ export type { ExtractedAimdFields, RenderResult, VueRendererOptions }