@airalogy/aimd-renderer 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,747 @@
1
+ import type {
2
+ AimdCheckNode,
3
+ AimdFigNode,
4
+ AimdNode,
5
+ AimdQuizNode,
6
+ AimdVarNode,
7
+ AimdVarTableNode,
8
+ RenderContext,
9
+ } from '@airalogy/aimd-core/types'
10
+ import {
11
+ AIMD_FILE_BADGE_BY_KIND,
12
+ AIMD_FILE_REFERENCE_VALUE_KEYS,
13
+ AIMD_RECORD_DATA_SCOPES,
14
+ getAimdAssetMediaSource,
15
+ getAimdDisplayValue,
16
+ getAimdFieldDisplayLabel,
17
+ getAimdFileDisplayName,
18
+ getAimdFileValueId,
19
+ inferAimdAssetKind,
20
+ isAimdAiralogyFileId,
21
+ isAimdBooleanType,
22
+ isAimdCodeType,
23
+ isAimdDnaType,
24
+ isAimdFileLikeType,
25
+ isAimdMarkdownType,
26
+ isAimdPlainRecord,
27
+ normalizeAimdRecordDataValue,
28
+ normalizeAimdString,
29
+ stringifyAimdDisplayValue,
30
+ toAimdBooleanValue,
31
+ type AimdAssetKind,
32
+ type AimdAssetLike,
33
+ type AimdRecordDataScope,
34
+ type AimdRecordDataValue,
35
+ } from '@airalogy/aimd-core/utils'
36
+ import { defineComponent, h, ref, shallowRef, watch, type PropType, type VNode, type VNodeChild } from 'vue'
37
+
38
+ import type { AimdRendererOptions, RenderResult } from '../common/processor'
39
+ import { renderToVue } from '../common/processor'
40
+ import type { AimdComponentRenderer, AimdRendererContext, ElementRenderer, VueRendererOptions } from './vue-renderer'
41
+
42
+ export const AIMD_RECORD_RENDER_SCOPES = AIMD_RECORD_DATA_SCOPES
43
+
44
+ export type AimdRecordRenderScope = AimdRecordDataScope
45
+
46
+ export type ReadonlyRecordAssetKind = AimdAssetKind
47
+
48
+ export type AimdRecordRenderValue = AimdRecordDataValue
49
+
50
+ export interface ReadonlyRecordAsset extends AimdAssetLike {
51
+ url?: string
52
+ href?: string
53
+ name?: string
54
+ filename?: string
55
+ mimeType?: string
56
+ size?: number
57
+ kind?: ReadonlyRecordAssetKind
58
+ downloadName?: string
59
+ }
60
+
61
+ export interface ReadonlyRecordAssetResolveContext {
62
+ fieldId: string
63
+ fieldPath: string
64
+ scope: string
65
+ node: AimdNode
66
+ value: unknown
67
+ normalizedValue: unknown
68
+ fileId?: string
69
+ recordValue: RenderContext['value']
70
+ }
71
+
72
+ export type ReadonlyRecordAssetResolver = (
73
+ context: ReadonlyRecordAssetResolveContext,
74
+ ) => ReadonlyRecordAsset | null | undefined
75
+
76
+ export type ReadonlyRecordRenderContextInput = Partial<RenderContext>
77
+ & Partial<Pick<AimdRendererContext, 'locale' | 'messages'>>
78
+
79
+ export interface ReadonlyRecordVueRendererOptions extends AimdRendererOptions, VueRendererOptions {
80
+ resolveAsset?: ReadonlyRecordAssetResolver
81
+ }
82
+
83
+ export type ReadonlyRecordMarkdownRenderOptions = AimdRendererOptions
84
+ & Pick<VueRendererOptions, 'componentMap' | 'elementRenderers' | 'locale' | 'messages' | 'quizPreview'>
85
+
86
+ type ReadonlyRecordVarValueRendererKind = 'file' | 'boolean' | 'markdown' | 'code' | 'dna' | 'scalar'
87
+
88
+ interface ReadonlyRecordVarValueRendererContext {
89
+ node: AimdNode
90
+ varNode: AimdVarNode
91
+ definition: AimdVarNode['definition']
92
+ kwargs?: Record<string, unknown>
93
+ value: unknown
94
+ recordValue: RenderContext['value']
95
+ asset: ReadonlyRecordAsset | null | undefined
96
+ fileId?: string
97
+ resolveAsset?: ReadonlyRecordAssetResolver
98
+ markdownRenderOptions?: ReadonlyRecordMarkdownRenderOptions
99
+ }
100
+
101
+ interface ReadonlyRecordVarValueRendererEntry {
102
+ kind: ReadonlyRecordVarValueRendererKind
103
+ render: (context: ReadonlyRecordVarValueRendererContext) => VNode | null
104
+ }
105
+
106
+ function fieldPathFor(scope: string, id: string): string {
107
+ return `data.${scope}.${id}`
108
+ }
109
+
110
+ function fieldDisplayLabel(node: AimdNode): string {
111
+ if ((node.fieldType === 'var' || node.fieldType === 'var_table') && 'definition' in node) {
112
+ return getAimdFieldDisplayLabel(node.id, node.definition)
113
+ }
114
+ if (node.fieldType === 'check' && 'label' in node && typeof node.label === 'string' && node.label.trim()) {
115
+ return node.label.trim()
116
+ }
117
+ if (node.fieldType === 'quiz' && 'title' in node && typeof node.title === 'string' && node.title.trim()) {
118
+ return node.title.trim()
119
+ }
120
+ return node.id
121
+ }
122
+
123
+ function fieldMetadataTitle(node: AimdNode): string {
124
+ const label = fieldDisplayLabel(node)
125
+ const path = fieldPathFor(node.scope, node.id)
126
+ return label === node.id ? path : `${label} · ${path}`
127
+ }
128
+
129
+ function recordFieldProps(node: AimdNode, className: string): Record<string, unknown> {
130
+ const label = fieldDisplayLabel(node)
131
+ return {
132
+ class: className,
133
+ title: fieldMetadataTitle(node),
134
+ 'aria-label': label,
135
+ 'data-aimd-type': node.fieldType,
136
+ 'data-aimd-id': node.id,
137
+ 'data-aimd-scope': node.scope,
138
+ 'data-aimd-field-label': label,
139
+ }
140
+ }
141
+
142
+ function createAssetFallbackNode(id: string): AimdNode {
143
+ return {
144
+ type: 'aimd',
145
+ fieldType: 'fig',
146
+ scope: 'fig',
147
+ id,
148
+ raw: id,
149
+ src: id,
150
+ } as AimdNode
151
+ }
152
+
153
+ function resolveAssetForField(
154
+ node: AimdNode,
155
+ value: unknown,
156
+ recordValue: RenderContext['value'],
157
+ resolver: ReadonlyRecordAssetResolver | undefined,
158
+ ): ReadonlyRecordAsset | null | undefined {
159
+ if (!resolver) {
160
+ return undefined
161
+ }
162
+ const normalizedValue = getAimdDisplayValue(value)
163
+ const fileId = getAimdFileValueId(value, AIMD_FILE_REFERENCE_VALUE_KEYS)
164
+ return resolver({
165
+ fieldId: node.id,
166
+ fieldPath: fieldPathFor(node.scope, node.id),
167
+ scope: node.scope,
168
+ node,
169
+ value,
170
+ normalizedValue,
171
+ fileId,
172
+ recordValue,
173
+ })
174
+ }
175
+
176
+ function resolveAssetForReference(
177
+ source: unknown,
178
+ recordValue: RenderContext['value'],
179
+ resolver: ReadonlyRecordAssetResolver | undefined,
180
+ node?: AimdNode,
181
+ ): ReadonlyRecordAsset | null | undefined {
182
+ if (!resolver) {
183
+ return undefined
184
+ }
185
+ const normalizedValue = getAimdDisplayValue(source)
186
+ const fileId = normalizeAimdString(normalizedValue)
187
+ const fallbackNode = node ?? createAssetFallbackNode(fileId ?? 'asset')
188
+ return resolver({
189
+ fieldId: fallbackNode.id,
190
+ fieldPath: node ? fieldPathFor(node.scope, node.id) : (fileId ?? ''),
191
+ scope: fallbackNode.scope,
192
+ node: fallbackNode,
193
+ value: source,
194
+ normalizedValue,
195
+ fileId,
196
+ recordValue,
197
+ })
198
+ }
199
+
200
+ function renderEmptyValue(node: AimdNode): VNode {
201
+ return h('span', recordFieldProps(node, 'aimd-record-field aimd-record-field--empty'), [
202
+ h('span', { class: 'aimd-record-field__missing-label' }, 'Missing'),
203
+ h('span', { class: 'aimd-record-field__missing-name' }, fieldDisplayLabel(node)),
204
+ ])
205
+ }
206
+
207
+ function renderScalarValue(node: AimdNode, value: unknown, className = ''): VNode {
208
+ const displayValue = stringifyAimdDisplayValue(value)
209
+ if (!displayValue) {
210
+ return renderEmptyValue(node)
211
+ }
212
+ return h('span', recordFieldProps(
213
+ node,
214
+ ['aimd-record-field', 'aimd-record-field--scalar', className].filter(Boolean).join(' '),
215
+ ), displayValue)
216
+ }
217
+
218
+ function renderBooleanValue(node: AimdNode, value: unknown): VNode {
219
+ const checked = toAimdBooleanValue(value)
220
+ return h('span', recordFieldProps(node, 'aimd-record-field aimd-record-field--boolean'), [
221
+ h('input', {
222
+ type: 'checkbox',
223
+ checked,
224
+ disabled: true,
225
+ class: 'aimd-checkbox',
226
+ }),
227
+ h('span', { class: 'aimd-record-field__value' }, checked ? 'Yes' : 'No'),
228
+ ])
229
+ }
230
+
231
+ const ReadonlyRecordMarkdownValue = defineComponent({
232
+ name: 'ReadonlyRecordMarkdownValue',
233
+ props: {
234
+ content: {
235
+ type: String,
236
+ required: true,
237
+ },
238
+ fieldProps: {
239
+ type: Object as PropType<Record<string, unknown>>,
240
+ required: true,
241
+ },
242
+ recordValue: {
243
+ type: Object as PropType<RenderContext['value']>,
244
+ default: undefined,
245
+ },
246
+ resolveAsset: {
247
+ type: Function as PropType<ReadonlyRecordAssetResolver>,
248
+ default: undefined,
249
+ },
250
+ renderOptions: {
251
+ type: Object as PropType<ReadonlyRecordMarkdownRenderOptions>,
252
+ default: () => ({}),
253
+ },
254
+ },
255
+ setup(props) {
256
+ const nodes = shallowRef<VNodeChild[]>([])
257
+ const renderError = ref('')
258
+ let requestId = 0
259
+
260
+ async function renderMarkdownContent() {
261
+ const currentRequestId = ++requestId
262
+ renderError.value = ''
263
+
264
+ try {
265
+ const nestedElementRenderers = createReadonlyRecordElementRenderers({
266
+ resolveAsset: props.resolveAsset,
267
+ })
268
+ const rendered = await renderToVue(props.content, {
269
+ ...props.renderOptions,
270
+ mode: 'preview',
271
+ aimdRenderers: {
272
+ fig: createFigRenderer(props.resolveAsset),
273
+ },
274
+ elementRenderers: {
275
+ ...nestedElementRenderers,
276
+ ...(props.renderOptions.elementRenderers ?? {}),
277
+ },
278
+ context: {
279
+ mode: 'preview',
280
+ readonly: true,
281
+ value: props.recordValue,
282
+ quizPreview: props.renderOptions.quizPreview,
283
+ },
284
+ })
285
+
286
+ if (currentRequestId !== requestId) {
287
+ return
288
+ }
289
+
290
+ nodes.value = rendered.nodes
291
+ }
292
+ catch (error) {
293
+ if (currentRequestId !== requestId) {
294
+ return
295
+ }
296
+ nodes.value = []
297
+ renderError.value = error instanceof Error ? error.message : String(error)
298
+ }
299
+ }
300
+
301
+ watch(
302
+ () => [props.content, props.recordValue, props.resolveAsset, props.renderOptions] as const,
303
+ () => {
304
+ void renderMarkdownContent()
305
+ },
306
+ { immediate: true },
307
+ )
308
+
309
+ return () => {
310
+ if (renderError.value) {
311
+ return h('div', {
312
+ ...props.fieldProps,
313
+ class: `${props.fieldProps.class ?? ''} aimd-record-field--markdown-error`,
314
+ 'data-render-error': renderError.value,
315
+ }, [
316
+ h('pre', { class: 'aimd-record-field__markdown-fallback' }, props.content),
317
+ ])
318
+ }
319
+
320
+ return h('div', props.fieldProps, nodes.value.length
321
+ ? nodes.value
322
+ : [h('pre', { class: 'aimd-record-field__markdown-fallback' }, props.content)])
323
+ }
324
+ },
325
+ })
326
+
327
+ function renderMarkdownValue(
328
+ node: AimdNode,
329
+ value: unknown,
330
+ recordValue: RenderContext['value'],
331
+ resolveAsset: ReadonlyRecordAssetResolver | undefined,
332
+ renderOptions: ReadonlyRecordMarkdownRenderOptions | undefined,
333
+ ): VNode {
334
+ const displayValue = stringifyAimdDisplayValue(value)
335
+ if (!displayValue) {
336
+ return renderEmptyValue(node)
337
+ }
338
+ return h(ReadonlyRecordMarkdownValue, {
339
+ content: displayValue,
340
+ fieldProps: recordFieldProps(node, 'aimd-record-field aimd-record-field--markdown'),
341
+ recordValue,
342
+ resolveAsset,
343
+ renderOptions,
344
+ })
345
+ }
346
+
347
+ function renderCodeValue(node: AimdNode, value: unknown, language?: unknown): VNode {
348
+ const lang = normalizeAimdString(language)
349
+ const displayValue = stringifyAimdDisplayValue(value)
350
+ if (!displayValue) {
351
+ return renderEmptyValue(node)
352
+ }
353
+ return h('pre', recordFieldProps(node, 'aimd-record-field aimd-record-field--code'), [
354
+ h('code', {
355
+ class: lang ? `language-${lang}` : undefined,
356
+ }, displayValue),
357
+ ])
358
+ }
359
+
360
+ function renderDnaValue(node: AimdNode, value: unknown): VNode {
361
+ const normalized = getAimdDisplayValue(value)
362
+ const sequence = isAimdPlainRecord(normalized)
363
+ ? normalizeAimdString(normalized.sequence) ?? stringifyAimdDisplayValue(normalized)
364
+ : stringifyAimdDisplayValue(normalized)
365
+ if (!sequence) {
366
+ return renderEmptyValue(node)
367
+ }
368
+ const name = isAimdPlainRecord(normalized) ? normalizeAimdString(normalized.name) : undefined
369
+ return h('div', recordFieldProps(node, 'aimd-record-field aimd-record-field--dna'), [
370
+ name ? h('div', { class: 'aimd-record-field__label' }, name) : null,
371
+ h('pre', { class: 'aimd-record-field__sequence' }, sequence),
372
+ ])
373
+ }
374
+
375
+ function renderFileValue(
376
+ node: AimdNode,
377
+ value: unknown,
378
+ asset: ReadonlyRecordAsset | null | undefined,
379
+ kind: ReadonlyRecordAssetKind,
380
+ ): VNode {
381
+ if (!asset && !getAimdFileValueId(value, AIMD_FILE_REFERENCE_VALUE_KEYS)) {
382
+ return renderEmptyValue(node)
383
+ }
384
+ const mediaSrc = getAimdAssetMediaSource(asset)
385
+ const href = asset?.href ?? asset?.url
386
+ const name = getAimdFileDisplayName(value, asset) || node.id
387
+ const badge = AIMD_FILE_BADGE_BY_KIND[kind]
388
+ const commonProps: Record<string, unknown> = {
389
+ ...recordFieldProps(node, `aimd-record-field aimd-record-field--asset aimd-record-field--${kind}`),
390
+ 'data-aimd-file-id': getAimdFileValueId(value, AIMD_FILE_REFERENCE_VALUE_KEYS),
391
+ }
392
+
393
+ if (kind === 'image' && mediaSrc) {
394
+ return h('figure', commonProps, [
395
+ h('img', {
396
+ src: mediaSrc,
397
+ alt: name,
398
+ loading: 'lazy',
399
+ class: 'aimd-record-field__image',
400
+ }),
401
+ h('figcaption', { class: 'aimd-record-field__caption' }, name),
402
+ ])
403
+ }
404
+
405
+ if (kind === 'audio' && mediaSrc) {
406
+ return h('div', commonProps, [
407
+ h('audio', {
408
+ src: mediaSrc,
409
+ controls: true,
410
+ class: 'aimd-record-field__audio',
411
+ }),
412
+ h('span', { class: 'aimd-record-field__caption' }, name),
413
+ ])
414
+ }
415
+
416
+ if (kind === 'video' && mediaSrc) {
417
+ return h('figure', commonProps, [
418
+ h('video', {
419
+ src: mediaSrc,
420
+ controls: true,
421
+ class: 'aimd-record-field__video',
422
+ }),
423
+ h('figcaption', { class: 'aimd-record-field__caption' }, name),
424
+ ])
425
+ }
426
+
427
+ const label = [
428
+ h('span', { class: 'aimd-record-field__badge' }, badge),
429
+ h('span', { class: 'aimd-record-field__filename' }, name),
430
+ ]
431
+ if (href) {
432
+ return h('a', {
433
+ ...commonProps,
434
+ href,
435
+ download: asset?.downloadName ?? asset?.filename ?? asset?.name ?? undefined,
436
+ }, label)
437
+ }
438
+
439
+ return h('span', {
440
+ ...commonProps,
441
+ class: `${commonProps.class} aimd-record-field--asset-missing`,
442
+ }, label)
443
+ }
444
+
445
+ function createReadonlyRecordVarValueRendererEntries(): ReadonlyRecordVarValueRendererEntry[] {
446
+ return [
447
+ {
448
+ kind: 'file',
449
+ render: ({ node, definition, kwargs, value, asset, fileId }) => {
450
+ if (!isAimdFileLikeType(definition?.type, kwargs) && !asset && !isAimdAiralogyFileId(fileId)) {
451
+ return null
452
+ }
453
+ const kind = inferAimdAssetKind(asset, value, definition?.type, kwargs)
454
+ return renderFileValue(node, value, asset, kind)
455
+ },
456
+ },
457
+ {
458
+ kind: 'boolean',
459
+ render: ({ node, definition, kwargs, value }) =>
460
+ isAimdBooleanType(definition?.type, kwargs) ? renderBooleanValue(node, value) : null,
461
+ },
462
+ {
463
+ kind: 'markdown',
464
+ render: ({ node, definition, kwargs, value, recordValue, resolveAsset, markdownRenderOptions }) =>
465
+ isAimdMarkdownType(definition?.type, kwargs)
466
+ ? renderMarkdownValue(node, value, recordValue, resolveAsset, markdownRenderOptions)
467
+ : null,
468
+ },
469
+ {
470
+ kind: 'code',
471
+ render: ({ node, definition, kwargs, value }) =>
472
+ isAimdCodeType(definition?.type, kwargs)
473
+ ? renderCodeValue(node, value, kwargs?.language ?? kwargs?.lang ?? kwargs?.code_language ?? kwargs?.codeLanguage)
474
+ : null,
475
+ },
476
+ {
477
+ kind: 'dna',
478
+ render: ({ node, definition, kwargs, value }) =>
479
+ isAimdDnaType(definition?.type, kwargs) ? renderDnaValue(node, value) : null,
480
+ },
481
+ {
482
+ kind: 'scalar',
483
+ render: ({ node, value }) => renderScalarValue(node, value),
484
+ },
485
+ ]
486
+ }
487
+
488
+ const READONLY_RECORD_VAR_VALUE_RENDERERS = createReadonlyRecordVarValueRendererEntries()
489
+
490
+ function renderReadonlyRecordVarValue(context: ReadonlyRecordVarValueRendererContext): VNode {
491
+ for (const renderer of READONLY_RECORD_VAR_VALUE_RENDERERS) {
492
+ const vnode = renderer.render(context)
493
+ if (vnode) {
494
+ return vnode
495
+ }
496
+ }
497
+ return renderScalarValue(context.node, context.value)
498
+ }
499
+
500
+ function createVarRenderer(
501
+ resolveRecordAsset?: ReadonlyRecordAssetResolver,
502
+ markdownRenderOptions?: ReadonlyRecordMarkdownRenderOptions,
503
+ ): AimdComponentRenderer {
504
+ return (node, ctx) => {
505
+ const varNode = node as AimdVarNode
506
+ const definition = varNode.definition
507
+ const kwargs = definition?.kwargs
508
+ const value = ctx.value?.var?.[node.id]
509
+ const asset = resolveAssetForField(node, value, ctx.value, resolveRecordAsset)
510
+ const fileId = getAimdFileValueId(value, AIMD_FILE_REFERENCE_VALUE_KEYS)
511
+
512
+ return renderReadonlyRecordVarValue({
513
+ node,
514
+ varNode,
515
+ definition,
516
+ kwargs,
517
+ value,
518
+ recordValue: ctx.value,
519
+ asset,
520
+ fileId,
521
+ resolveAsset: resolveRecordAsset,
522
+ markdownRenderOptions,
523
+ })
524
+ }
525
+ }
526
+
527
+ function normalizeTableRows(value: unknown): Record<string, unknown>[] {
528
+ const normalized = getAimdDisplayValue(value)
529
+ if (Array.isArray(normalized)) {
530
+ return normalized
531
+ .filter((row): row is Record<string, unknown> => isAimdPlainRecord(row))
532
+ .map(row => ({ ...row }))
533
+ }
534
+ if (isAimdPlainRecord(normalized)) {
535
+ return Object.values(normalized)
536
+ .filter((row): row is Record<string, unknown> => isAimdPlainRecord(row))
537
+ .map(row => ({ ...row }))
538
+ }
539
+ return []
540
+ }
541
+
542
+ function createVarTableRenderer(): AimdComponentRenderer {
543
+ return (node, ctx) => {
544
+ const tableNode = node as AimdVarTableNode
545
+ const rows = normalizeTableRows(ctx.value?.var?.[node.id] ?? ctx.value?.var_table?.[node.id])
546
+ const columns = tableNode.columns.length
547
+ ? tableNode.columns
548
+ : [...new Set(rows.flatMap(row => Object.keys(row)))]
549
+ const subvars = tableNode.definition?.subvars
550
+
551
+ if (!columns.length || !rows.length) {
552
+ return h('div', recordFieldProps(node, 'aimd-record-table aimd-record-table--empty'), [
553
+ h('span', { class: 'aimd-record-field__missing-label' }, 'Missing'),
554
+ h('span', { class: 'aimd-record-field__missing-name' }, fieldDisplayLabel(node)),
555
+ ])
556
+ }
557
+
558
+ return h('div', recordFieldProps(node, 'aimd-record-table'), [
559
+ h('table', [
560
+ h('thead', [
561
+ h('tr', columns.map(column => h('th', {
562
+ 'data-column-id': column,
563
+ title: column,
564
+ }, getAimdFieldDisplayLabel(column, subvars?.[column])))),
565
+ ]),
566
+ h('tbody', rows.map(row =>
567
+ h('tr', columns.map(column =>
568
+ h('td', stringifyAimdDisplayValue(row[column])),
569
+ )),
570
+ )),
571
+ ]),
572
+ ])
573
+ }
574
+ }
575
+
576
+ function createCheckRenderer(): AimdComponentRenderer {
577
+ return (node, ctx, children) => {
578
+ const checkNode = node as AimdCheckNode
579
+ const value = ctx.value?.check?.[node.id]
580
+ const normalized = isAimdPlainRecord(value) && 'checked' in value ? value.checked : value
581
+ const checked = toAimdBooleanValue(normalized)
582
+ const label = checkNode.label ?? node.id
583
+ const bodyChildren = children && children.length > 0
584
+ ? children
585
+ : [label]
586
+ return h('label', recordFieldProps(node, 'aimd-record-check'), [
587
+ h('input', {
588
+ type: 'checkbox',
589
+ checked,
590
+ disabled: true,
591
+ class: 'aimd-checkbox',
592
+ }),
593
+ h('span', { class: 'aimd-record-check__body' }, bodyChildren),
594
+ ])
595
+ }
596
+ }
597
+
598
+ function createQuizRenderer(): AimdComponentRenderer {
599
+ return (node, ctx) => {
600
+ const quizNode = node as AimdQuizNode
601
+ const value = ctx.value?.quiz?.[node.id]
602
+ const answer = stringifyAimdDisplayValue(value)
603
+ return h('div', recordFieldProps(node, 'aimd-record-quiz'), [
604
+ h('div', { class: 'aimd-record-quiz__stem' }, quizNode.stem || quizNode.title || node.id),
605
+ answer
606
+ ? h('div', { class: 'aimd-record-quiz__answer' }, answer)
607
+ : h('div', { class: 'aimd-record-quiz__answer aimd-record-quiz__answer--empty' }, 'No answer recorded'),
608
+ ])
609
+ }
610
+ }
611
+
612
+ function mergeClassNames(...values: unknown[]): string | undefined {
613
+ const classNames = values.flatMap((value) => {
614
+ if (!value) {
615
+ return []
616
+ }
617
+ if (Array.isArray(value)) {
618
+ return value.map(item => String(item)).filter(Boolean)
619
+ }
620
+ return String(value).split(/\s+/).filter(Boolean)
621
+ })
622
+ return classNames.length ? [...new Set(classNames)].join(' ') : undefined
623
+ }
624
+
625
+ function createFigRenderer(resolveRecordAsset?: ReadonlyRecordAssetResolver): AimdComponentRenderer {
626
+ return (node, ctx) => {
627
+ const figNode = node as AimdFigNode
628
+ const figId = figNode.id || node.id
629
+ const figSrc = figNode.src || ''
630
+ const asset = resolveAssetForReference(figSrc, ctx.value, resolveRecordAsset, figNode)
631
+ const resolvedSrc = getAimdAssetMediaSource(asset) ?? figSrc
632
+ const figTitle = figNode.title
633
+ const figLegend = figNode.legend
634
+ const figSequence = figNode.sequence
635
+ const captionChildren = [
636
+ figSequence !== undefined || figTitle
637
+ ? h('div', { class: 'aimd-figure__title' }, figSequence !== undefined
638
+ ? ctx.messages.figure.captionTitle(figSequence + 1, figTitle)
639
+ : figTitle)
640
+ : null,
641
+ figLegend ? h('div', { class: 'aimd-figure__legend' }, figLegend) : null,
642
+ ].filter(Boolean)
643
+
644
+ return h('figure', {
645
+ class: 'aimd-figure',
646
+ 'data-aimd-type': 'fig',
647
+ 'data-aimd-fig-id': figId,
648
+ 'data-aimd-fig-src': figSrc,
649
+ id: `fig-${figId}`,
650
+ }, [
651
+ h('img', {
652
+ class: 'aimd-figure__image',
653
+ src: resolvedSrc,
654
+ alt: figTitle || asset?.name || asset?.filename || figId,
655
+ loading: 'lazy',
656
+ }),
657
+ captionChildren.length
658
+ ? h('figcaption', { class: 'aimd-figure__caption' }, captionChildren)
659
+ : null,
660
+ ])
661
+ }
662
+ }
663
+
664
+ function createImageElementRenderer(resolveRecordAsset?: ReadonlyRecordAssetResolver): ElementRenderer {
665
+ return (node, _children, ctx) => {
666
+ const properties = node.properties ?? {}
667
+ const src = normalizeAimdString(properties.src)
668
+ const asset = resolveAssetForReference(src, ctx.value, resolveRecordAsset)
669
+ const resolvedSrc = getAimdAssetMediaSource(asset) ?? src
670
+ const imgProps: Record<string, unknown> = {
671
+ ...properties,
672
+ src: resolvedSrc,
673
+ alt: normalizeAimdString(properties.alt) ?? asset?.name ?? asset?.filename,
674
+ title: normalizeAimdString(properties.title),
675
+ loading: properties.loading ?? 'lazy',
676
+ class: mergeClassNames('aimd-image', properties.className, properties.class),
677
+ }
678
+ delete imgProps.className
679
+ return h('img', imgProps)
680
+ }
681
+ }
682
+
683
+ export function createReadonlyRecordAimdRenderers(
684
+ options: Pick<ReadonlyRecordVueRendererOptions, 'resolveAsset'> & {
685
+ markdownRenderOptions?: ReadonlyRecordMarkdownRenderOptions
686
+ } = {},
687
+ ): Record<string, AimdComponentRenderer> {
688
+ return {
689
+ var: createVarRenderer(options.resolveAsset, options.markdownRenderOptions),
690
+ var_table: createVarTableRenderer(),
691
+ check: createCheckRenderer(),
692
+ quiz: createQuizRenderer(),
693
+ fig: createFigRenderer(options.resolveAsset),
694
+ }
695
+ }
696
+
697
+ export function createReadonlyRecordElementRenderers(
698
+ options: Pick<ReadonlyRecordVueRendererOptions, 'resolveAsset'> = {},
699
+ ): Record<string, ElementRenderer> {
700
+ return {
701
+ img: createImageElementRenderer(options.resolveAsset),
702
+ }
703
+ }
704
+
705
+ export function normalizeRecordRenderValue(recordData: unknown): RenderContext['value'] {
706
+ return normalizeAimdRecordDataValue(recordData) as RenderContext['value']
707
+ }
708
+
709
+ export function createReadonlyRecordRenderContext<T extends ReadonlyRecordRenderContextInput>(
710
+ recordData: unknown,
711
+ context?: T,
712
+ ): T & RenderContext {
713
+ return {
714
+ ...((context ?? {}) as T),
715
+ mode: 'edit',
716
+ readonly: true,
717
+ value: normalizeRecordRenderValue(recordData),
718
+ }
719
+ }
720
+
721
+ export async function renderReadonlyRecordToVue(
722
+ content: string,
723
+ recordData: unknown,
724
+ options: ReadonlyRecordVueRendererOptions = {},
725
+ ): Promise<RenderResult> {
726
+ const { context, resolveAsset: resolveRecordAsset, aimdRenderers, elementRenderers, ...renderOptions } = options
727
+ const readonlyAimdRenderers = createReadonlyRecordAimdRenderers({
728
+ resolveAsset: resolveRecordAsset,
729
+ markdownRenderOptions: {
730
+ ...renderOptions,
731
+ elementRenderers,
732
+ },
733
+ })
734
+ const readonlyElementRenderers = createReadonlyRecordElementRenderers({ resolveAsset: resolveRecordAsset })
735
+ return renderToVue(content, {
736
+ ...renderOptions,
737
+ aimdRenderers: {
738
+ ...readonlyAimdRenderers,
739
+ ...(aimdRenderers ?? {}),
740
+ },
741
+ elementRenderers: {
742
+ ...readonlyElementRenderers,
743
+ ...(elementRenderers ?? {}),
744
+ },
745
+ context: createReadonlyRecordRenderContext(recordData, context),
746
+ })
747
+ }