@airalogy/aimd-renderer 2.6.0 → 2.8.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.
Files changed (41) hide show
  1. package/README.md +38 -0
  2. package/README.zh-CN.md +38 -0
  3. package/dist/aimd-renderer.css +1 -1
  4. package/dist/common/assetUrls.d.ts +8 -0
  5. package/dist/common/assetUrls.d.ts.map +1 -0
  6. package/dist/common/citationNumbering.d.ts +11 -0
  7. package/dist/common/citationNumbering.d.ts.map +1 -0
  8. package/dist/common/criticMarkup.d.ts +10 -0
  9. package/dist/common/criticMarkup.d.ts.map +1 -0
  10. package/dist/common/processor.d.ts +2 -0
  11. package/dist/common/processor.d.ts.map +1 -1
  12. package/dist/html/index.d.ts +1 -0
  13. package/dist/html/index.d.ts.map +1 -1
  14. package/dist/html.js +1 -1
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +80 -73
  18. package/dist/locales.d.ts +3 -0
  19. package/dist/locales.d.ts.map +1 -1
  20. package/dist/{processor-CHbNEcN8.js → processor-C_zN3-hL.js} +3454 -2534
  21. package/dist/readonly-record-renderer-G9xOkOFe.js +711 -0
  22. package/dist/vue/index.d.ts +2 -0
  23. package/dist/vue/index.d.ts.map +1 -1
  24. package/dist/vue/readonly-record-renderer.d.ts +42 -0
  25. package/dist/vue/readonly-record-renderer.d.ts.map +1 -0
  26. package/dist/vue/vue-renderer.d.ts +7 -1
  27. package/dist/vue/vue-renderer.d.ts.map +1 -1
  28. package/dist/vue.js +20 -13
  29. package/package.json +2 -2
  30. package/src/__tests__/renderer.test.ts +536 -1
  31. package/src/common/assetUrls.ts +22 -0
  32. package/src/common/citationNumbering.ts +265 -0
  33. package/src/common/criticMarkup.ts +97 -0
  34. package/src/common/processor.ts +206 -25
  35. package/src/html/index.ts +4 -0
  36. package/src/index.ts +23 -0
  37. package/src/locales.ts +9 -0
  38. package/src/styles/katex.css +213 -0
  39. package/src/vue/index.ts +22 -0
  40. package/src/vue/readonly-record-renderer.ts +747 -0
  41. package/src/vue/vue-renderer.ts +183 -9
@@ -1,4 +1,7 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { mount } from '@vue/test-utils'
3
+ import { h } from 'vue'
4
+ import { describe, expect, it, vi } from 'vitest'
2
5
 
3
6
  import { resolveQuizPreviewOptions } from '../common/quiz-preview'
4
7
  import {
@@ -9,8 +12,18 @@ import {
9
12
  createRenderer,
10
13
  } from '../common/processor'
11
14
  import { getFinalIndent, parseFieldTag } from '../index'
15
+ import {
16
+ createReadonlyRecordRenderContext,
17
+ normalizeRecordRenderValue,
18
+ renderReadonlyRecordToVue,
19
+ } from '../vue/readonly-record-renderer'
12
20
  import { createCodeBlockRenderer, createStepCardRenderer } from '../vue/vue-renderer'
13
21
 
22
+ const rendererStylesPath = existsSync('src/styles/katex.css')
23
+ ? 'src/styles/katex.css'
24
+ : 'packages/npm/aimd-renderer/src/styles/katex.css'
25
+ const rendererStyles = readFileSync(rendererStylesPath, 'utf8')
26
+
14
27
  function findVNodeByType(node: any, expectedType: string): any | null {
15
28
  if (!node || typeof node !== 'object') {
16
29
  return null
@@ -36,6 +49,37 @@ function findVNodeByType(node: any, expectedType: string): any | null {
36
49
  return null
37
50
  }
38
51
 
52
+ function findVNodeByClass(node: any, expectedClass: string): any | null {
53
+ if (!node || typeof node !== 'object') {
54
+ return null
55
+ }
56
+
57
+ const className = node.props?.class
58
+ const classes = Array.isArray(className)
59
+ ? className
60
+ : typeof className === 'string'
61
+ ? className.split(/\s+/)
62
+ : []
63
+ if (classes.includes(expectedClass)) {
64
+ return node
65
+ }
66
+
67
+ const children = Array.isArray(node.children)
68
+ ? node.children
69
+ : Array.isArray(node.component?.subTree?.children)
70
+ ? node.component.subTree.children
71
+ : []
72
+
73
+ for (const child of children) {
74
+ const match = findVNodeByClass(child, expectedClass)
75
+ if (match) {
76
+ return match
77
+ }
78
+ }
79
+
80
+ return null
81
+ }
82
+
39
83
  function collectVNodeText(node: any): string {
40
84
  if (node == null) {
41
85
  return ''
@@ -194,12 +238,183 @@ describe('renderToHtmlSync', () => {
194
238
  expect(html).toContain('Hello')
195
239
  })
196
240
 
241
+ it('renders CriticMarkup review marks to semantic HTML', () => {
242
+ const { html } = renderToHtmlSync('Add {++new++}, delete {--old--}, replace {~~old~>new~~}, comment {>>check units<<}, highlight {==important==}.')
243
+
244
+ expect(html).toContain('<ins class="aimd-critic aimd-critic--addition"')
245
+ expect(html).toContain('data-critic-kind="addition"')
246
+ expect(html).toContain('<del class="aimd-critic aimd-critic--deletion"')
247
+ expect(html).toContain('data-critic-kind="deletion"')
248
+ expect(html).toContain('<span class="aimd-critic aimd-critic--substitution"')
249
+ expect(html).toContain('aimd-critic--substitution-old')
250
+ expect(html).toContain('aimd-critic--substitution-new')
251
+ expect(html).toContain('<span class="aimd-critic aimd-critic--comment"')
252
+ expect(html).toContain('check units')
253
+ expect(html).toContain('<mark class="aimd-critic aimd-critic--highlight"')
254
+ expect(html).toContain('important')
255
+ expect(html).not.toContain('{++new++}')
256
+ expect(html).not.toContain('{--old--}')
257
+ expect(html).not.toContain('{~~old~>new~~}')
258
+ expect(html).not.toContain('{>>check units<<}')
259
+ expect(html).not.toContain('{==important==}')
260
+ })
261
+
262
+ it('does not parse CriticMarkup inside inline code', () => {
263
+ const { html } = renderToHtmlSync('Literal `{++not a mark++}` and `{~~old~>new~~}` remain code.')
264
+
265
+ expect(html).toContain('<code>{++not a mark++}</code>')
266
+ expect(html).toContain('<code>{~~old~>new~~}</code>')
267
+ expect(html).not.toContain('aimd-critic--addition')
268
+ expect(html).not.toContain('aimd-critic--substitution')
269
+ })
270
+
197
271
  it('renders AIMD var fields', () => {
198
272
  const { html, fields } = renderToHtmlSync('{{var|temperature}}')
199
273
  expect(fields.var).toContain('temperature')
200
274
  expect(html).toContain('temperature')
201
275
  })
202
276
 
277
+ it('resolves figure asset URLs during HTML rendering', () => {
278
+ const seenContexts: Array<{ src: string, kind: string, id?: string, title?: string }> = []
279
+ const { html } = renderToHtmlSync([
280
+ '```fig',
281
+ 'id: workflow_diagram',
282
+ 'src: files/workflow-diagram.svg',
283
+ 'title: Workflow Diagram',
284
+ '```',
285
+ ].join('\n'), {
286
+ resolveAssetUrl: (src, context) => {
287
+ seenContexts.push({ src, ...context })
288
+ return `/assets/${src.split('/').pop()}`
289
+ },
290
+ })
291
+
292
+ expect(seenContexts).toEqual([{
293
+ src: 'files/workflow-diagram.svg',
294
+ kind: 'fig',
295
+ id: 'workflow_diagram',
296
+ title: 'Workflow Diagram',
297
+ }])
298
+ expect(html).toContain('src="/assets/workflow-diagram.svg"')
299
+ })
300
+
301
+ it('renders internal references without route-breaking bare hash hrefs', () => {
302
+ const { html } = renderToHtmlSync([
303
+ 'As shown in {{ref_fig|workflow_diagram}}, follow {{ref_step|prepare_sample}}.',
304
+ '',
305
+ '```fig',
306
+ 'id: workflow_diagram',
307
+ 'src: files/workflow-diagram.svg',
308
+ 'title: Workflow Diagram',
309
+ '```',
310
+ '',
311
+ '{{step|prepare_sample}}',
312
+ ].join('\n'))
313
+
314
+ expect(html).not.toContain('href="#fig-workflow_diagram"')
315
+ expect(html).not.toContain('href="#step-prepare_sample"')
316
+ expect(html).toContain('<span class="aimd-ref aimd-ref--fig"')
317
+ expect(html).toContain('data-aimd-ref-target="workflow_diagram"')
318
+ expect(html).toContain('data-aimd-ref-kind="fig"')
319
+ expect(html).toContain('<span class="aimd-ref aimd-ref--step"')
320
+ expect(html).toContain('data-aimd-ref-target="prepare_sample"')
321
+ expect(html).toContain('data-aimd-ref-kind="step"')
322
+ })
323
+
324
+ it('styles rendered figures as attached image-caption blocks', () => {
325
+ expect(rendererStyles).toMatch(/\.aimd-figure \{[\s\S]*?width: fit-content;/)
326
+ expect(rendererStyles).toMatch(/\.aimd-figure \{[\s\S]*?overflow: hidden;/)
327
+ expect(rendererStyles).not.toContain('box-shadow: 0 10px 28px')
328
+ expect(rendererStyles).toMatch(/\.aimd-figure__caption \{[\s\S]*?border-top: 1px solid #d8e2ef;/)
329
+ expect(rendererStyles).toMatch(/\.aimd-figure__legend \{[\s\S]*?margin: 4px 0 0;/)
330
+ })
331
+
332
+ it('styles citation popovers as selectable hoverable content', () => {
333
+ expect(rendererStyles).toContain('.aimd-cite__popover')
334
+ expect(rendererStyles).not.toContain('.aimd-cite__ref::after')
335
+ expect(rendererStyles).toMatch(/\.aimd-cite__popover \{[\s\S]*?pointer-events: none;/)
336
+ expect(rendererStyles).toMatch(/\.aimd-cite__popover \{[\s\S]*?user-select: text;/)
337
+ expect(rendererStyles).toMatch(/\.aimd-cite__popover::before \{[\s\S]*?height: 10px;/)
338
+ expect(rendererStyles).toMatch(/\.aimd-cite__ref:hover \.aimd-cite__popover,[\s\S]*?pointer-events: auto;/)
339
+ })
340
+
341
+ it('styles internal references as focusable route-safe targets', () => {
342
+ expect(rendererStyles).toMatch(/\.aimd-ref\[data-aimd-ref-target\] \{[\s\S]*?cursor: pointer;/)
343
+ expect(rendererStyles).toMatch(/\.aimd-ref\[data-aimd-ref-target\]:focus-visible \{[\s\S]*?outline: 2px solid rgba\(25, 118, 210, 0\.36\);/)
344
+ })
345
+
346
+ it('renders citation markers with reference tooltips and refs blocks', () => {
347
+ const content = [
348
+ 'Cite the method {{cite|yang2025airalogyaiempowereduniversaldata, doe2024protocol}}.',
349
+ '',
350
+ '```refs',
351
+ '@misc{yang2025airalogyaiempowereduniversaldata,',
352
+ ' title={Airalogy: AI-empowered universal data digitization for research automation},',
353
+ ' author={Zijie Yang and Qiji Zhou and Fang Guo and Sijie Zhang and Yexun Xi and Jinglei Nie and Yudian Zhu and Liping Huang and Chou Wu and Yonghe Xia and Xiaoyu Ma and Yingming Pu and Panzhong Lu and Junshu Pan and Mingtao Chen and Tiannan Guo and Yanmei Dou and Hongyu Chen and Anping Zeng and Jiaxing Huang and Tian Xu and Yue Zhang},',
354
+ ' year={2025},',
355
+ ' eprint={2506.18586},',
356
+ ' archivePrefix={arXiv},',
357
+ ' primaryClass={cs.AI},',
358
+ ' url={https://arxiv.org/abs/2506.18586},',
359
+ '}',
360
+ '',
361
+ '@misc{doe2024protocol,',
362
+ ' title = "Protocol Notes",',
363
+ ' author = "Doe, Jane",',
364
+ ' year = "2024",',
365
+ ' url = "https://example.com/protocol"',
366
+ '}',
367
+ '```',
368
+ ].join('\n')
369
+
370
+ const { html, fields } = renderToHtmlSync(content)
371
+
372
+ expect(fields.cite).toEqual(['yang2025airalogyaiempowereduniversaldata', 'doe2024protocol'])
373
+ expect(fields.refs?.[0]).toMatchObject({
374
+ id: 'yang2025airalogyaiempowereduniversaldata',
375
+ entry_type: 'misc',
376
+ title: 'Airalogy: AI-empowered universal data digitization for research automation',
377
+ url: 'https://arxiv.org/abs/2506.18586',
378
+ })
379
+ expect(fields.refs?.[1]).toMatchObject({
380
+ id: 'doe2024protocol',
381
+ url: 'https://example.com/protocol',
382
+ })
383
+ expect(html).not.toContain('href="#ref-yang2025airalogyaiempowereduniversaldata"')
384
+ expect(html).not.toContain('href="#ref-doe2024protocol"')
385
+ expect(html).toContain('role="doc-noteref"')
386
+ expect(html).toContain('data-aimd-ref-id="yang2025airalogyaiempowereduniversaldata"')
387
+ expect(html).toContain('data-aimd-ref-summary="Zijie Yang')
388
+ expect(html).toContain('data-aimd-citation-labels="1,2"')
389
+ expect(html).toContain('<span class="aimd-cite__label">1</span>')
390
+ expect(html).toContain('<span class="aimd-cite__label">2</span>')
391
+ expect(html).toContain('<span class="aimd-cite__popover" role="tooltip">Zijie Yang')
392
+ expect(html).not.toContain('<span class="aimd-cite__label">yang2025airalogyaiempowereduniversaldata</span>')
393
+ expect(html).toContain('<section class="aimd-refs"')
394
+ expect(html).toContain('id="ref-yang2025airalogyaiempowereduniversaldata"')
395
+ expect(html).toContain('Airalogy: AI-empowered universal data digitization for research automation')
396
+ expect(html).toContain('href="https://arxiv.org/abs/2506.18586"')
397
+ expect(html).toContain('href="https://example.com/protocol"')
398
+ })
399
+
400
+ it('moves refs blocks to the end of the rendered document', () => {
401
+ const content = [
402
+ '```refs',
403
+ '@misc{doe2024protocol,',
404
+ ' title = "Protocol Notes",',
405
+ ' author = "Doe, Jane",',
406
+ ' year = "2024"',
407
+ '}',
408
+ '```',
409
+ '',
410
+ 'The body continues after the refs declaration.',
411
+ ].join('\n')
412
+
413
+ const { html } = renderToHtmlSync(content)
414
+
415
+ expect(html.indexOf('<p>The body continues after the refs declaration.</p>')).toBeLessThan(html.indexOf('<section class="aimd-refs"'))
416
+ })
417
+
203
418
  it('renders AIMD var field display metadata', () => {
204
419
  const { html, fields } = renderToHtmlSync('{{var|record_date: str, title="Record date", description="ISO date", examples=["2026-05-26", "2026-05-27"]}}')
205
420
  expect(fields.var_definitions?.[0]?.title).toBe('Record date')
@@ -405,6 +620,120 @@ describe('renderToHtmlSync', () => {
405
620
  })
406
621
 
407
622
  describe('renderToVue', () => {
623
+ it('renders references as Vue nodes', async () => {
624
+ const { nodes } = await renderToVue(
625
+ [
626
+ 'Cite {{cite|yang2025airalogyaiempowereduniversaldata}}.',
627
+ '',
628
+ '```refs',
629
+ '@misc{yang2025airalogyaiempowereduniversaldata,',
630
+ ' title={Airalogy: AI-empowered universal data digitization for research automation},',
631
+ ' author={Zijie Yang and Qiji Zhou and Fang Guo and Sijie Zhang and Yexun Xi and Jinglei Nie and Yudian Zhu and Liping Huang and Chou Wu and Yonghe Xia and Xiaoyu Ma and Yingming Pu and Panzhong Lu and Junshu Pan and Mingtao Chen and Tiannan Guo and Yanmei Dou and Hongyu Chen and Anping Zeng and Jiaxing Huang and Tian Xu and Yue Zhang},',
632
+ ' year={2025},',
633
+ ' eprint={2506.18586},',
634
+ ' archivePrefix={arXiv},',
635
+ ' primaryClass={cs.AI},',
636
+ ' url={https://arxiv.org/abs/2506.18586},',
637
+ '}',
638
+ '```',
639
+ ].join('\n'),
640
+ )
641
+
642
+ const refsNode = nodes
643
+ .map(node => findVNodeByType(node, 'section'))
644
+ .find(Boolean) as any
645
+ const citeNode = nodes
646
+ .map(node => findVNodeByClass(node, 'aimd-cite'))
647
+ .find(Boolean) as any
648
+
649
+ expect(refsNode).toBeTruthy()
650
+ expect(refsNode.props.class).toBe('aimd-refs')
651
+ expect(refsNode.props.id).toBe('refs')
652
+ expect(citeNode).toBeTruthy()
653
+ const citeRefNode = findVNodeByClass(citeNode, 'aimd-cite__ref') as any
654
+ const citeLabelNode = findVNodeByClass(citeNode, 'aimd-cite__label') as any
655
+ const citePopoverNode = findVNodeByClass(citeNode, 'aimd-cite__popover') as any
656
+ expect(citeRefNode.type).toBe('span')
657
+ expect(citeRefNode.props.href).toBeUndefined()
658
+ expect(citeRefNode.props['data-aimd-ref-summary']).toContain('Airalogy: AI-empowered universal data digitization for research automation')
659
+ expect(collectVNodeText(citeLabelNode).trim()).toBe('1')
660
+ expect(citePopoverNode.type).toBe('span')
661
+ expect(citePopoverNode.props.role).toBe('tooltip')
662
+ expect(collectVNodeText(citePopoverNode)).toContain('Airalogy: AI-empowered universal data digitization for research automation')
663
+ expect(collectVNodeText(nodes)).toContain('References')
664
+ expect(collectVNodeText(nodes)).toContain('Airalogy: AI-empowered universal data digitization for research automation')
665
+ expect(collectVNodeText(citeNode)).not.toContain('yang2025airalogyaiempowereduniversaldata')
666
+ })
667
+
668
+ it('resolves figure asset URLs during Vue rendering', async () => {
669
+ const { nodes } = await renderToVue([
670
+ '```fig',
671
+ 'id: workflow_diagram',
672
+ 'src: files/workflow-diagram.svg',
673
+ 'title: Workflow Diagram',
674
+ '```',
675
+ ].join('\n'), {
676
+ resolveAssetUrl: (src, context) => {
677
+ expect(context).toMatchObject({
678
+ kind: 'fig',
679
+ id: 'workflow_diagram',
680
+ title: 'Workflow Diagram',
681
+ })
682
+ return `/assets/${src.split('/').pop()}`
683
+ },
684
+ })
685
+
686
+ const img = findVNodeByType({ children: nodes }, 'img') as any
687
+ expect(img).toBeTruthy()
688
+ expect(img.props.src).toBe('/assets/workflow-diagram.svg')
689
+ })
690
+
691
+ it('renders Vue figure references as route-safe internal markers', async () => {
692
+ const { nodes } = await renderToVue([
693
+ 'As shown in {{ref_fig|workflow_diagram}}.',
694
+ '',
695
+ '```fig',
696
+ 'id: workflow_diagram',
697
+ 'src: files/workflow-diagram.svg',
698
+ 'title: Workflow Diagram',
699
+ '```',
700
+ ].join('\n'))
701
+
702
+ const figRefNode = findVNodeByClass({ children: nodes }, 'aimd-ref--fig') as any
703
+ expect(figRefNode).toBeTruthy()
704
+ expect(figRefNode.type).toBe('span')
705
+ expect(figRefNode.props.href).toBeUndefined()
706
+ expect(figRefNode.props['data-aimd-ref-target']).toBe('workflow_diagram')
707
+ expect(figRefNode.props['data-aimd-ref-kind']).toBe('fig')
708
+ expect(figRefNode.props.tabindex).toBe(0)
709
+ })
710
+
711
+ it('moves refs Vue nodes to the end of the rendered document', async () => {
712
+ const { nodes } = await renderToVue(
713
+ [
714
+ '```refs',
715
+ '@misc{doe2024protocol,',
716
+ ' title = "Protocol Notes",',
717
+ ' author = "Doe, Jane",',
718
+ ' year = "2024"',
719
+ '}',
720
+ '```',
721
+ '',
722
+ 'The body continues after the refs declaration.',
723
+ ].join('\n'),
724
+ )
725
+
726
+ const rootChildren = Array.isArray((nodes[0] as any)?.children)
727
+ ? (nodes[0] as any).children
728
+ : nodes
729
+ const refsIndex = rootChildren.findIndex((node: any) => Boolean(findVNodeByClass(node, 'aimd-refs')))
730
+ const bodyIndex = rootChildren.findIndex((node: any) => collectVNodeText(node).includes('The body continues after the refs declaration.'))
731
+
732
+ expect(bodyIndex).toBeGreaterThanOrEqual(0)
733
+ expect(refsIndex).toBeGreaterThanOrEqual(0)
734
+ expect(bodyIndex).toBeLessThan(refsIndex)
735
+ })
736
+
408
737
  it('renders code blocks with line numbers and wrapping classes', async () => {
409
738
  const { nodes } = await renderToVue(
410
739
  '```json\n{\n "model":"qwen3.6-flash","enable_search":true\n}\n```',
@@ -543,6 +872,212 @@ describe('renderToVue', () => {
543
872
  })
544
873
  })
545
874
 
875
+ // ---------------------------------------------------------------------------
876
+ // readonly record rendering
877
+ // ---------------------------------------------------------------------------
878
+
879
+ describe('readonly record rendering', () => {
880
+ it('normalizes bare record data and full record payload wrappers', () => {
881
+ expect(normalizeRecordRenderValue({
882
+ var: { sample_id: { value: 'S-001' } },
883
+ step: { prepare: { checked: true } },
884
+ var_table: {
885
+ samples: [{ sample_id: 'S-001' }],
886
+ },
887
+ })).toEqual({
888
+ var: { sample_id: { value: 'S-001' } },
889
+ step: { prepare: { checked: true } },
890
+ check: {},
891
+ quiz: {},
892
+ var_table: {
893
+ samples: [{ sample_id: 'S-001' }],
894
+ },
895
+ })
896
+
897
+ expect(normalizeRecordRenderValue({
898
+ record_id: 'rec-001',
899
+ data: {
900
+ var: { sample_id: 'S-002' },
901
+ quiz: { qc: ['A', 'B'] },
902
+ },
903
+ })).toEqual({
904
+ var: { sample_id: 'S-002' },
905
+ var_table: {},
906
+ step: {},
907
+ check: {},
908
+ quiz: { qc: ['A', 'B'] },
909
+ })
910
+ })
911
+
912
+ it('creates an edit-mode readonly render context for record-backed documents', () => {
913
+ const context = createReadonlyRecordRenderContext(
914
+ { data: { var: { sample_id: 'S-003' } } },
915
+ { quizPreview: { showAnswers: true } },
916
+ )
917
+
918
+ expect(context.mode).toBe('edit')
919
+ expect(context.readonly).toBe(true)
920
+ expect(context.quizPreview).toEqual({ showAnswers: true })
921
+ expect(context.value?.var.sample_id).toBe('S-003')
922
+ })
923
+
924
+ it('renders record values into AIMD fields without enabling input editing', async () => {
925
+ const { nodes } = await renderReadonlyRecordToVue(
926
+ [
927
+ 'Sample {{var|sample_id: str}}',
928
+ '',
929
+ '{{check|prepared}} Prepared.',
930
+ ].join('\n'),
931
+ {
932
+ data: {
933
+ var: { sample_id: { value: 'S-004' } },
934
+ check: { prepared: { checked: true } },
935
+ },
936
+ },
937
+ {
938
+ groupCheckBodies: true,
939
+ },
940
+ )
941
+
942
+ expect(collectVNodeText(nodes)).toContain('S-004')
943
+ expect(collectVNodeText(nodes)).not.toContain('sample_id')
944
+ const input = findVNodeByType({ children: nodes }, 'input') as any
945
+ expect(input).toBeTruthy()
946
+ expect(input.props.checked).toBe(true)
947
+ expect(input.props.disabled).toBe(true)
948
+ })
949
+
950
+ it('shows readable missing labels only when record values are absent', async () => {
951
+ const { nodes } = await renderReadonlyRecordToVue(
952
+ 'Sample {{var|sample_id: str, title="Sample ID"}}',
953
+ {
954
+ data: {
955
+ var: {},
956
+ },
957
+ },
958
+ )
959
+
960
+ expect(collectVNodeText(nodes)).toContain('Missing')
961
+ expect(collectVNodeText(nodes)).toContain('Sample ID')
962
+ const missing = findVNodeByType({ children: nodes }, 'span') as any
963
+ expect(missing.props.title).toContain('data.var.sample_id')
964
+ })
965
+
966
+ it('renders file-backed image fields with resolved assets', async () => {
967
+ const { nodes } = await renderReadonlyRecordToVue(
968
+ 'Site photo: {{var|site_photo: FileIdPNG}}',
969
+ {
970
+ data: {
971
+ var: {
972
+ site_photo: 'airalogy.id.file.site-photo.png',
973
+ },
974
+ },
975
+ },
976
+ {
977
+ resolveAsset: context => context.fileId === 'airalogy.id.file.site-photo.png'
978
+ ? {
979
+ url: 'blob:site-photo',
980
+ filename: 'site-photo.png',
981
+ mimeType: 'image/png',
982
+ }
983
+ : null,
984
+ },
985
+ )
986
+
987
+ const img = findVNodeByType({ children: nodes }, 'img') as any
988
+ expect(img).toBeTruthy()
989
+ expect(img.props.src).toBe('blob:site-photo')
990
+ expect(img.props.alt).toBe('site-photo.png')
991
+ })
992
+
993
+ it('renders manifest-resolved file fields even when the protocol did not declare FileId type', async () => {
994
+ const { nodes } = await renderReadonlyRecordToVue(
995
+ 'Attachment: {{var|sample_file}}',
996
+ {
997
+ data: {
998
+ var: {
999
+ sample_file: 'airalogy.id.file.sample-note.txt',
1000
+ },
1001
+ },
1002
+ },
1003
+ {
1004
+ resolveAsset: context => context.fieldPath === 'data.var.sample_file'
1005
+ ? {
1006
+ href: 'blob:sample-note',
1007
+ filename: 'sample-note.txt',
1008
+ mimeType: 'text/plain',
1009
+ }
1010
+ : null,
1011
+ },
1012
+ )
1013
+
1014
+ const link = findVNodeByType({ children: nodes }, 'a') as any
1015
+ expect(link).toBeTruthy()
1016
+ expect(link.props.href).toBe('blob:sample-note')
1017
+ expect(collectVNodeText(link)).toContain('sample-note.txt')
1018
+ })
1019
+
1020
+ it('resolves markdown image elements through readonly record assets', async () => {
1021
+ const { nodes } = await renderReadonlyRecordToVue(
1022
+ '![Calibration chart](airalogy.id.file.chart.svg)',
1023
+ { data: { var: {} } },
1024
+ {
1025
+ resolveAsset: context => context.fileId === 'airalogy.id.file.chart.svg'
1026
+ ? {
1027
+ url: 'blob:chart',
1028
+ filename: 'chart.svg',
1029
+ mimeType: 'image/svg+xml',
1030
+ }
1031
+ : null,
1032
+ },
1033
+ )
1034
+
1035
+ const img = findVNodeByType({ children: nodes }, 'img') as any
1036
+ expect(img).toBeTruthy()
1037
+ expect(img.props.src).toBe('blob:chart')
1038
+ expect(img.props.alt).toBe('Calibration chart')
1039
+ })
1040
+
1041
+ it('renders AiralogyMarkdown record values through the AIMD Vue renderer', async () => {
1042
+ const { nodes } = await renderReadonlyRecordToVue(
1043
+ 'Report:\n\n{{var|report: AiralogyMarkdown}}',
1044
+ {
1045
+ data: {
1046
+ var: {
1047
+ report: [
1048
+ '# Finding',
1049
+ '',
1050
+ '![Chart](airalogy.id.file.chart.svg)',
1051
+ '',
1052
+ 'Nested token: {{var|nested: str, title="Nested value"}}',
1053
+ ].join('\n'),
1054
+ },
1055
+ },
1056
+ },
1057
+ {
1058
+ resolveAsset: context => context.fileId === 'airalogy.id.file.chart.svg'
1059
+ ? {
1060
+ url: 'blob:chart',
1061
+ filename: 'chart.svg',
1062
+ mimeType: 'image/svg+xml',
1063
+ }
1064
+ : null,
1065
+ },
1066
+ )
1067
+
1068
+ const wrapper = mount({
1069
+ render: () => h('div', nodes),
1070
+ })
1071
+
1072
+ await vi.waitFor(() => {
1073
+ expect(wrapper.find('.aimd-record-field--markdown h1').text()).toBe('Finding')
1074
+ expect(wrapper.find('.aimd-record-field--markdown img').attributes('src')).toBe('blob:chart')
1075
+ })
1076
+ expect(wrapper.find('.aimd-record-field--markdown').text()).toContain('Nested value')
1077
+ wrapper.unmount()
1078
+ })
1079
+ })
1080
+
546
1081
  // ---------------------------------------------------------------------------
547
1082
  // createRenderer
548
1083
  // ---------------------------------------------------------------------------
@@ -0,0 +1,22 @@
1
+ export interface AimdAssetUrlResolveContext {
2
+ kind: "fig"
3
+ id?: string
4
+ title?: string
5
+ }
6
+
7
+ export type AimdAssetUrlResolver = (
8
+ src: string,
9
+ context: AimdAssetUrlResolveContext,
10
+ ) => string | null | undefined
11
+
12
+ export function resolveAimdAssetUrl(
13
+ src: string | undefined,
14
+ resolver: AimdAssetUrlResolver | undefined,
15
+ context: AimdAssetUrlResolveContext,
16
+ ): string | undefined {
17
+ if (!src || !resolver) {
18
+ return src
19
+ }
20
+
21
+ return resolver(src, context) || src
22
+ }