@airalogy/aimd-renderer 2.5.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.
- package/README.md +44 -5
- package/README.zh-CN.md +44 -5
- package/dist/aimd-renderer.css +1 -1
- package/dist/common/criticMarkup.d.ts +10 -0
- package/dist/common/criticMarkup.d.ts.map +1 -0
- package/dist/common/processor.d.ts.map +1 -1
- package/dist/common/unified-token-renderer.d.ts.map +1 -1
- package/dist/html.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +263 -211
- package/dist/{processor-B2mByErv.js → processor-BOCQYqXE.js} +3065 -2146
- package/dist/readonly-record-renderer-CkzY7UvT.js +711 -0
- package/dist/vue/index.d.ts +2 -1
- package/dist/vue/index.d.ts.map +1 -1
- package/dist/vue/readonly-record-renderer.d.ts +42 -0
- package/dist/vue/readonly-record-renderer.d.ts.map +1 -0
- package/dist/vue/vue-renderer.d.ts +22 -1
- package/dist/vue/vue-renderer.d.ts.map +1 -1
- package/dist/vue.js +17 -9
- package/package.json +6 -6
- package/src/__tests__/renderer.test.ts +435 -2
- package/src/common/criticMarkup.ts +97 -0
- package/src/common/processor.ts +186 -43
- package/src/common/unified-token-renderer.ts +104 -24
- package/src/index.ts +21 -0
- package/src/styles/katex.css +174 -0
- package/src/vue/index.ts +21 -0
- package/src/vue/readonly-record-renderer.ts +747 -0
- package/src/vue/vue-renderer.ts +318 -48
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { h } from 'vue'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
4
|
|
|
3
5
|
import { resolveQuizPreviewOptions } from '../common/quiz-preview'
|
|
4
6
|
import {
|
|
@@ -9,7 +11,12 @@ import {
|
|
|
9
11
|
createRenderer,
|
|
10
12
|
} from '../common/processor'
|
|
11
13
|
import { getFinalIndent, parseFieldTag } from '../index'
|
|
12
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
createReadonlyRecordRenderContext,
|
|
16
|
+
normalizeRecordRenderValue,
|
|
17
|
+
renderReadonlyRecordToVue,
|
|
18
|
+
} from '../vue/readonly-record-renderer'
|
|
19
|
+
import { createCodeBlockRenderer, createStepCardRenderer } from '../vue/vue-renderer'
|
|
13
20
|
|
|
14
21
|
function findVNodeByType(node: any, expectedType: string): any | null {
|
|
15
22
|
if (!node || typeof node !== 'object') {
|
|
@@ -149,6 +156,38 @@ describe('parseAndExtract', () => {
|
|
|
149
156
|
expect(fields.var).toContain('age')
|
|
150
157
|
expect(fields.step.length).toBeGreaterThan(0)
|
|
151
158
|
})
|
|
159
|
+
|
|
160
|
+
it('extracts multiline var tables with object-list defaults', () => {
|
|
161
|
+
const content = `{{var|monitoring_sites: list[MonitoringSite] = [
|
|
162
|
+
{"site_id": "S01", "latitude": 30.0, "longitude": 120.0, "elevation_m": 128.0},
|
|
163
|
+
{"site_id": "S02", "latitude": 30.1, "longitude": 120.1, "elevation_m": 82.0}
|
|
164
|
+
],
|
|
165
|
+
title = "Monitoring sites",
|
|
166
|
+
subvars = [
|
|
167
|
+
var(site_id: str, title = "Site ID"),
|
|
168
|
+
var(latitude: float, title = "Latitude"),
|
|
169
|
+
var(longitude: float, title = "Longitude"),
|
|
170
|
+
var(elevation_m: float, title = "Elevation")
|
|
171
|
+
]
|
|
172
|
+
}}`
|
|
173
|
+
const fields = parseAndExtract(content)
|
|
174
|
+
|
|
175
|
+
expect(fields.var_table[0]).toMatchObject({
|
|
176
|
+
id: 'monitoring_sites',
|
|
177
|
+
title: 'Monitoring sites',
|
|
178
|
+
type_annotation: 'list[MonitoringSite]',
|
|
179
|
+
default: [
|
|
180
|
+
{ site_id: 'S01', latitude: 30, longitude: 120, elevation_m: 128 },
|
|
181
|
+
{ site_id: 'S02', latitude: 30.1, longitude: 120.1, elevation_m: 82 },
|
|
182
|
+
],
|
|
183
|
+
})
|
|
184
|
+
expect(fields.var_table[0]?.subvars.map(subvar => subvar.id)).toEqual([
|
|
185
|
+
'site_id',
|
|
186
|
+
'latitude',
|
|
187
|
+
'longitude',
|
|
188
|
+
'elevation_m',
|
|
189
|
+
])
|
|
190
|
+
})
|
|
152
191
|
})
|
|
153
192
|
|
|
154
193
|
// ---------------------------------------------------------------------------
|
|
@@ -162,12 +201,87 @@ describe('renderToHtmlSync', () => {
|
|
|
162
201
|
expect(html).toContain('Hello')
|
|
163
202
|
})
|
|
164
203
|
|
|
204
|
+
it('renders CriticMarkup review marks to semantic HTML', () => {
|
|
205
|
+
const { html } = renderToHtmlSync('Add {++new++}, delete {--old--}, replace {~~old~>new~~}, comment {>>check units<<}, highlight {==important==}.')
|
|
206
|
+
|
|
207
|
+
expect(html).toContain('<ins class="aimd-critic aimd-critic--addition"')
|
|
208
|
+
expect(html).toContain('data-critic-kind="addition"')
|
|
209
|
+
expect(html).toContain('<del class="aimd-critic aimd-critic--deletion"')
|
|
210
|
+
expect(html).toContain('data-critic-kind="deletion"')
|
|
211
|
+
expect(html).toContain('<span class="aimd-critic aimd-critic--substitution"')
|
|
212
|
+
expect(html).toContain('aimd-critic--substitution-old')
|
|
213
|
+
expect(html).toContain('aimd-critic--substitution-new')
|
|
214
|
+
expect(html).toContain('<span class="aimd-critic aimd-critic--comment"')
|
|
215
|
+
expect(html).toContain('check units')
|
|
216
|
+
expect(html).toContain('<mark class="aimd-critic aimd-critic--highlight"')
|
|
217
|
+
expect(html).toContain('important')
|
|
218
|
+
expect(html).not.toContain('{++new++}')
|
|
219
|
+
expect(html).not.toContain('{--old--}')
|
|
220
|
+
expect(html).not.toContain('{~~old~>new~~}')
|
|
221
|
+
expect(html).not.toContain('{>>check units<<}')
|
|
222
|
+
expect(html).not.toContain('{==important==}')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('does not parse CriticMarkup inside inline code', () => {
|
|
226
|
+
const { html } = renderToHtmlSync('Literal `{++not a mark++}` and `{~~old~>new~~}` remain code.')
|
|
227
|
+
|
|
228
|
+
expect(html).toContain('<code>{++not a mark++}</code>')
|
|
229
|
+
expect(html).toContain('<code>{~~old~>new~~}</code>')
|
|
230
|
+
expect(html).not.toContain('aimd-critic--addition')
|
|
231
|
+
expect(html).not.toContain('aimd-critic--substitution')
|
|
232
|
+
})
|
|
233
|
+
|
|
165
234
|
it('renders AIMD var fields', () => {
|
|
166
235
|
const { html, fields } = renderToHtmlSync('{{var|temperature}}')
|
|
167
236
|
expect(fields.var).toContain('temperature')
|
|
168
237
|
expect(html).toContain('temperature')
|
|
169
238
|
})
|
|
170
239
|
|
|
240
|
+
it('renders AIMD var field display metadata', () => {
|
|
241
|
+
const { html, fields } = renderToHtmlSync('{{var|record_date: str, title="Record date", description="ISO date", examples=["2026-05-26", "2026-05-27"]}}')
|
|
242
|
+
expect(fields.var_definitions?.[0]?.title).toBe('Record date')
|
|
243
|
+
expect(fields.var_definitions?.[0]?.description).toBe('ISO date')
|
|
244
|
+
expect(fields.var_definitions?.[0]?.examples).toEqual(['2026-05-26', '2026-05-27'])
|
|
245
|
+
expect(html).toContain('data-aimd-title="Record date"')
|
|
246
|
+
expect(html).toContain('data-aimd-description="ISO date"')
|
|
247
|
+
expect(html).toContain('data-aimd-examples="2026-05-26, 2026-05-27"')
|
|
248
|
+
expect(html).toContain('Record date')
|
|
249
|
+
expect(html).toContain('record_date')
|
|
250
|
+
expect(html).toContain('ISO date')
|
|
251
|
+
expect(html).toContain('aria-label="ISO date')
|
|
252
|
+
expect(html).not.toContain('title="ISO date')
|
|
253
|
+
expect(html).not.toContain('aimd-field__description')
|
|
254
|
+
expect(html).toContain('aimd-field__metadata-popover')
|
|
255
|
+
expect(html).toContain('aimd-field__metadata-examples')
|
|
256
|
+
expect(html).toContain('aimd-field__metadata-example')
|
|
257
|
+
expect(html).toContain('tabindex="0"')
|
|
258
|
+
expect(html).toContain('2026-05-26')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('renders AIMD var_table and column display metadata', () => {
|
|
262
|
+
const { html, fields } = renderToHtmlSync('{{var_table|samples, title="Samples", description="Measured rows", examples=["S-001 row"], subvars=[var(sample_id: str, title="Sample ID", description="Tube identifier", examples=["S-001"])]}}')
|
|
263
|
+
expect(fields.var_table[0]?.title).toBe('Samples')
|
|
264
|
+
expect(fields.var_table[0]?.description).toBe('Measured rows')
|
|
265
|
+
expect(fields.var_table[0]?.examples).toEqual(['S-001 row'])
|
|
266
|
+
expect(fields.var_table[0]?.subvars[0]?.title).toBe('Sample ID')
|
|
267
|
+
expect(fields.var_table[0]?.subvars[0]?.examples).toEqual(['S-001'])
|
|
268
|
+
expect(html).toContain('Samples')
|
|
269
|
+
expect(html).toContain('samples')
|
|
270
|
+
expect(html).toContain('Measured rows')
|
|
271
|
+
expect(html).toContain('S-001 row')
|
|
272
|
+
expect(html).toContain('data-column-id="sample_id"')
|
|
273
|
+
expect(html).toContain('Sample ID')
|
|
274
|
+
expect(html).toContain('Tube identifier')
|
|
275
|
+
expect(html).toContain('aria-label="Measured rows')
|
|
276
|
+
expect(html).toContain('aria-label="Tube identifier')
|
|
277
|
+
expect(html).not.toContain('title="Measured rows')
|
|
278
|
+
expect(html).not.toContain('title="Tube identifier')
|
|
279
|
+
expect(html).not.toContain('aimd-field__description')
|
|
280
|
+
expect(html).toContain('aimd-field__metadata-popover')
|
|
281
|
+
expect(html).toContain('aimd-field__metadata-example')
|
|
282
|
+
expect(html).toContain('S-001')
|
|
283
|
+
})
|
|
284
|
+
|
|
171
285
|
it('returns extracted fields alongside HTML', () => {
|
|
172
286
|
const { fields } = renderToHtmlSync('{{step|wash}} and {{check|verify}}')
|
|
173
287
|
expect(fields.step.length).toBeGreaterThan(0)
|
|
@@ -201,6 +315,31 @@ describe('renderToHtmlSync', () => {
|
|
|
201
315
|
expect(html).toContain('Answer: true')
|
|
202
316
|
})
|
|
203
317
|
|
|
318
|
+
it('extracts true/false option followups', () => {
|
|
319
|
+
const { fields } = renderToHtmlSync(
|
|
320
|
+
[
|
|
321
|
+
'```quiz',
|
|
322
|
+
'id: q_true_false_followups',
|
|
323
|
+
'type: true_false',
|
|
324
|
+
'stem: "Was precipitate observed?"',
|
|
325
|
+
'options:',
|
|
326
|
+
' - key: true',
|
|
327
|
+
' text: "Yes"',
|
|
328
|
+
' followups:',
|
|
329
|
+
' - key: color',
|
|
330
|
+
' type: str',
|
|
331
|
+
' title: Color',
|
|
332
|
+
' - key: false',
|
|
333
|
+
' text: "No"',
|
|
334
|
+
'```',
|
|
335
|
+
].join('\n'),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
expect(fields.quiz[0].options?.[0]?.followups).toEqual([
|
|
339
|
+
{ key: 'color', type: 'str', required: true, title: 'Color' },
|
|
340
|
+
])
|
|
341
|
+
})
|
|
342
|
+
|
|
204
343
|
it('supports host custom element renderers for AIMD nodes', () => {
|
|
205
344
|
const { html } = renderToHtmlSync(
|
|
206
345
|
"{{step|verify, 2, title='Verify Output', subtitle='Cross-check', check=True, result=True}}\n\nStep body content.",
|
|
@@ -303,6 +442,94 @@ describe('renderToHtmlSync', () => {
|
|
|
303
442
|
})
|
|
304
443
|
|
|
305
444
|
describe('renderToVue', () => {
|
|
445
|
+
it('renders code blocks with line numbers and wrapping classes', async () => {
|
|
446
|
+
const { nodes } = await renderToVue(
|
|
447
|
+
'```json\n{\n "model":"qwen3.6-flash","enable_search":true\n}\n```',
|
|
448
|
+
{
|
|
449
|
+
elementRenderers: {
|
|
450
|
+
pre: createCodeBlockRenderer(null, {
|
|
451
|
+
lineNumbers: true,
|
|
452
|
+
wrap: true,
|
|
453
|
+
}),
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
const pre = findVNodeByType(nodes[0], 'pre') as any
|
|
459
|
+
expect(pre).toBeTruthy()
|
|
460
|
+
expect(pre.props.class).toContain('aimd-code-block')
|
|
461
|
+
expect(pre.props.class).toContain('aimd-code-block--line-numbers')
|
|
462
|
+
expect(pre.props.class).toContain('aimd-code-block--wrap')
|
|
463
|
+
expect(pre.props['data-lang']).toBe('json')
|
|
464
|
+
const code = findVNodeByType(pre, 'code') as any
|
|
465
|
+
expect(code.children).toHaveLength(3)
|
|
466
|
+
expect(code.children[1].children[1].props.style['--aimd-code-wrap-indent']).toBe('2ch')
|
|
467
|
+
expect(collectVNodeText(pre)).toContain('1')
|
|
468
|
+
expect(collectVNodeText(pre)).toContain('qwen3.6-flash')
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
it('keeps blank code lines visible without adding an extra line for the trailing markdown fence newline', async () => {
|
|
472
|
+
const { nodes } = await renderToVue(
|
|
473
|
+
'```json\n{\n\n "model": "qwen3.6-flash"\n}\n```',
|
|
474
|
+
{
|
|
475
|
+
elementRenderers: {
|
|
476
|
+
pre: createCodeBlockRenderer(null, {
|
|
477
|
+
lineNumbers: true,
|
|
478
|
+
wrap: true,
|
|
479
|
+
}),
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
const pre = findVNodeByType(nodes[0], 'pre') as any
|
|
485
|
+
const code = findVNodeByType(pre, 'code') as any
|
|
486
|
+
expect(code.children).toHaveLength(4)
|
|
487
|
+
expect(code.children[1].children[1].children).toBe('\u00a0')
|
|
488
|
+
expect(code.children[2].children[1].props.style['--aimd-code-wrap-indent']).toBe('2ch')
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('uses Shiki token output when a highlighter is available', async () => {
|
|
492
|
+
const highlighter = {
|
|
493
|
+
codeToHtml: (code: string) => code,
|
|
494
|
+
codeToTokensBase: () => [
|
|
495
|
+
[
|
|
496
|
+
{ content: ' ', color: '#24292e' },
|
|
497
|
+
{ content: '"model"', color: '#005cc5' },
|
|
498
|
+
],
|
|
499
|
+
],
|
|
500
|
+
}
|
|
501
|
+
const { nodes } = await renderToVue('```json\n "model"\n```', {
|
|
502
|
+
elementRenderers: {
|
|
503
|
+
pre: createCodeBlockRenderer(highlighter, {
|
|
504
|
+
lineNumbers: true,
|
|
505
|
+
wrap: true,
|
|
506
|
+
}),
|
|
507
|
+
},
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
const pre = findVNodeByType(nodes[0], 'pre') as any
|
|
511
|
+
const code = findVNodeByType(pre, 'code') as any
|
|
512
|
+
const tokenSpans = code.children[0].children[1].children
|
|
513
|
+
expect(tokenSpans[0].props.style).toEqual({ color: '#24292e' })
|
|
514
|
+
expect(tokenSpans[1].props.style).toEqual({ color: '#005cc5' })
|
|
515
|
+
expect(tokenSpans[1].children).toBe('"model"')
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it('keeps the legacy plain pre fallback when called with a theme string and no highlighter', async () => {
|
|
519
|
+
const { nodes } = await renderToVue('```json\n{"model":"qwen"}\n```', {
|
|
520
|
+
elementRenderers: {
|
|
521
|
+
pre: createCodeBlockRenderer(null, 'github-light'),
|
|
522
|
+
},
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
const pre = findVNodeByType(nodes[0], 'pre') as any
|
|
526
|
+
expect(pre.props.class).toBe('language-json')
|
|
527
|
+
expect(pre.props.class).not.toContain('aimd-code-block')
|
|
528
|
+
const code = findVNodeByType(pre, 'code') as any
|
|
529
|
+
expect(code.props.class).toBe('language-json')
|
|
530
|
+
expect(code.children).toBe('{"model":"qwen"}')
|
|
531
|
+
})
|
|
532
|
+
|
|
306
533
|
it('renders host-ready step cards with grouped body content', async () => {
|
|
307
534
|
const { nodes } = await renderToVue(
|
|
308
535
|
"{{step|verify, 2, title='Verify Output', subtitle='Cross-check', check=True}}\n\nStep body content.",
|
|
@@ -353,6 +580,212 @@ describe('renderToVue', () => {
|
|
|
353
580
|
})
|
|
354
581
|
})
|
|
355
582
|
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
// readonly record rendering
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
describe('readonly record rendering', () => {
|
|
588
|
+
it('normalizes bare record data and full record payload wrappers', () => {
|
|
589
|
+
expect(normalizeRecordRenderValue({
|
|
590
|
+
var: { sample_id: { value: 'S-001' } },
|
|
591
|
+
step: { prepare: { checked: true } },
|
|
592
|
+
var_table: {
|
|
593
|
+
samples: [{ sample_id: 'S-001' }],
|
|
594
|
+
},
|
|
595
|
+
})).toEqual({
|
|
596
|
+
var: { sample_id: { value: 'S-001' } },
|
|
597
|
+
step: { prepare: { checked: true } },
|
|
598
|
+
check: {},
|
|
599
|
+
quiz: {},
|
|
600
|
+
var_table: {
|
|
601
|
+
samples: [{ sample_id: 'S-001' }],
|
|
602
|
+
},
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
expect(normalizeRecordRenderValue({
|
|
606
|
+
record_id: 'rec-001',
|
|
607
|
+
data: {
|
|
608
|
+
var: { sample_id: 'S-002' },
|
|
609
|
+
quiz: { qc: ['A', 'B'] },
|
|
610
|
+
},
|
|
611
|
+
})).toEqual({
|
|
612
|
+
var: { sample_id: 'S-002' },
|
|
613
|
+
var_table: {},
|
|
614
|
+
step: {},
|
|
615
|
+
check: {},
|
|
616
|
+
quiz: { qc: ['A', 'B'] },
|
|
617
|
+
})
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it('creates an edit-mode readonly render context for record-backed documents', () => {
|
|
621
|
+
const context = createReadonlyRecordRenderContext(
|
|
622
|
+
{ data: { var: { sample_id: 'S-003' } } },
|
|
623
|
+
{ quizPreview: { showAnswers: true } },
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
expect(context.mode).toBe('edit')
|
|
627
|
+
expect(context.readonly).toBe(true)
|
|
628
|
+
expect(context.quizPreview).toEqual({ showAnswers: true })
|
|
629
|
+
expect(context.value?.var.sample_id).toBe('S-003')
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
it('renders record values into AIMD fields without enabling input editing', async () => {
|
|
633
|
+
const { nodes } = await renderReadonlyRecordToVue(
|
|
634
|
+
[
|
|
635
|
+
'Sample {{var|sample_id: str}}',
|
|
636
|
+
'',
|
|
637
|
+
'{{check|prepared}} Prepared.',
|
|
638
|
+
].join('\n'),
|
|
639
|
+
{
|
|
640
|
+
data: {
|
|
641
|
+
var: { sample_id: { value: 'S-004' } },
|
|
642
|
+
check: { prepared: { checked: true } },
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
groupCheckBodies: true,
|
|
647
|
+
},
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
expect(collectVNodeText(nodes)).toContain('S-004')
|
|
651
|
+
expect(collectVNodeText(nodes)).not.toContain('sample_id')
|
|
652
|
+
const input = findVNodeByType({ children: nodes }, 'input') as any
|
|
653
|
+
expect(input).toBeTruthy()
|
|
654
|
+
expect(input.props.checked).toBe(true)
|
|
655
|
+
expect(input.props.disabled).toBe(true)
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
it('shows readable missing labels only when record values are absent', async () => {
|
|
659
|
+
const { nodes } = await renderReadonlyRecordToVue(
|
|
660
|
+
'Sample {{var|sample_id: str, title="Sample ID"}}',
|
|
661
|
+
{
|
|
662
|
+
data: {
|
|
663
|
+
var: {},
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
expect(collectVNodeText(nodes)).toContain('Missing')
|
|
669
|
+
expect(collectVNodeText(nodes)).toContain('Sample ID')
|
|
670
|
+
const missing = findVNodeByType({ children: nodes }, 'span') as any
|
|
671
|
+
expect(missing.props.title).toContain('data.var.sample_id')
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
it('renders file-backed image fields with resolved assets', async () => {
|
|
675
|
+
const { nodes } = await renderReadonlyRecordToVue(
|
|
676
|
+
'Site photo: {{var|site_photo: FileIdPNG}}',
|
|
677
|
+
{
|
|
678
|
+
data: {
|
|
679
|
+
var: {
|
|
680
|
+
site_photo: 'airalogy.id.file.site-photo.png',
|
|
681
|
+
},
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
{
|
|
685
|
+
resolveAsset: context => context.fileId === 'airalogy.id.file.site-photo.png'
|
|
686
|
+
? {
|
|
687
|
+
url: 'blob:site-photo',
|
|
688
|
+
filename: 'site-photo.png',
|
|
689
|
+
mimeType: 'image/png',
|
|
690
|
+
}
|
|
691
|
+
: null,
|
|
692
|
+
},
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
const img = findVNodeByType({ children: nodes }, 'img') as any
|
|
696
|
+
expect(img).toBeTruthy()
|
|
697
|
+
expect(img.props.src).toBe('blob:site-photo')
|
|
698
|
+
expect(img.props.alt).toBe('site-photo.png')
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
it('renders manifest-resolved file fields even when the protocol did not declare FileId type', async () => {
|
|
702
|
+
const { nodes } = await renderReadonlyRecordToVue(
|
|
703
|
+
'Attachment: {{var|sample_file}}',
|
|
704
|
+
{
|
|
705
|
+
data: {
|
|
706
|
+
var: {
|
|
707
|
+
sample_file: 'airalogy.id.file.sample-note.txt',
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
{
|
|
712
|
+
resolveAsset: context => context.fieldPath === 'data.var.sample_file'
|
|
713
|
+
? {
|
|
714
|
+
href: 'blob:sample-note',
|
|
715
|
+
filename: 'sample-note.txt',
|
|
716
|
+
mimeType: 'text/plain',
|
|
717
|
+
}
|
|
718
|
+
: null,
|
|
719
|
+
},
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
const link = findVNodeByType({ children: nodes }, 'a') as any
|
|
723
|
+
expect(link).toBeTruthy()
|
|
724
|
+
expect(link.props.href).toBe('blob:sample-note')
|
|
725
|
+
expect(collectVNodeText(link)).toContain('sample-note.txt')
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
it('resolves markdown image elements through readonly record assets', async () => {
|
|
729
|
+
const { nodes } = await renderReadonlyRecordToVue(
|
|
730
|
+
'',
|
|
731
|
+
{ data: { var: {} } },
|
|
732
|
+
{
|
|
733
|
+
resolveAsset: context => context.fileId === 'airalogy.id.file.chart.svg'
|
|
734
|
+
? {
|
|
735
|
+
url: 'blob:chart',
|
|
736
|
+
filename: 'chart.svg',
|
|
737
|
+
mimeType: 'image/svg+xml',
|
|
738
|
+
}
|
|
739
|
+
: null,
|
|
740
|
+
},
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
const img = findVNodeByType({ children: nodes }, 'img') as any
|
|
744
|
+
expect(img).toBeTruthy()
|
|
745
|
+
expect(img.props.src).toBe('blob:chart')
|
|
746
|
+
expect(img.props.alt).toBe('Calibration chart')
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
it('renders AiralogyMarkdown record values through the AIMD Vue renderer', async () => {
|
|
750
|
+
const { nodes } = await renderReadonlyRecordToVue(
|
|
751
|
+
'Report:\n\n{{var|report: AiralogyMarkdown}}',
|
|
752
|
+
{
|
|
753
|
+
data: {
|
|
754
|
+
var: {
|
|
755
|
+
report: [
|
|
756
|
+
'# Finding',
|
|
757
|
+
'',
|
|
758
|
+
'',
|
|
759
|
+
'',
|
|
760
|
+
'Nested token: {{var|nested: str, title="Nested value"}}',
|
|
761
|
+
].join('\n'),
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
resolveAsset: context => context.fileId === 'airalogy.id.file.chart.svg'
|
|
767
|
+
? {
|
|
768
|
+
url: 'blob:chart',
|
|
769
|
+
filename: 'chart.svg',
|
|
770
|
+
mimeType: 'image/svg+xml',
|
|
771
|
+
}
|
|
772
|
+
: null,
|
|
773
|
+
},
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
const wrapper = mount({
|
|
777
|
+
render: () => h('div', nodes),
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
await vi.waitFor(() => {
|
|
781
|
+
expect(wrapper.find('.aimd-record-field--markdown h1').text()).toBe('Finding')
|
|
782
|
+
expect(wrapper.find('.aimd-record-field--markdown img').attributes('src')).toBe('blob:chart')
|
|
783
|
+
})
|
|
784
|
+
expect(wrapper.find('.aimd-record-field--markdown').text()).toContain('Nested value')
|
|
785
|
+
wrapper.unmount()
|
|
786
|
+
})
|
|
787
|
+
})
|
|
788
|
+
|
|
356
789
|
// ---------------------------------------------------------------------------
|
|
357
790
|
// createRenderer
|
|
358
791
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Element, Text as HastText } from "hast"
|
|
2
|
+
import type {
|
|
3
|
+
AimdCriticMarkupBaseNode,
|
|
4
|
+
AimdCriticMarkupChild,
|
|
5
|
+
AimdCriticMarkupNode,
|
|
6
|
+
AimdCriticSubstitutionNode,
|
|
7
|
+
} from "@airalogy/aimd-core/types"
|
|
8
|
+
|
|
9
|
+
function createTextHast(value: string): HastText {
|
|
10
|
+
return { type: "text", value }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isCriticMarkupNode(node: AimdCriticMarkupChild): node is AimdCriticMarkupNode {
|
|
14
|
+
return typeof node.type === "string" && node.type.startsWith("critic")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function renderInlineChildren(state: any, children: AimdCriticMarkupChild[] = []): Array<Element | HastText> {
|
|
18
|
+
return children.flatMap((child) => {
|
|
19
|
+
if (child.type === "text") {
|
|
20
|
+
return [createTextHast(child.value ?? "")] as Array<Element | HastText>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (isCriticMarkupNode(child)) {
|
|
24
|
+
const handler = criticMarkupHandlers[child.type as keyof typeof criticMarkupHandlers] as
|
|
25
|
+
| ((state: any, node: any) => Element)
|
|
26
|
+
| undefined
|
|
27
|
+
if (handler) {
|
|
28
|
+
return [handler(state, child)]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const rendered = typeof state.one === "function" ? state.one(child, undefined) : null
|
|
33
|
+
return rendered ? [rendered as Element | HastText] : []
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createCriticElement(
|
|
38
|
+
tagName: string,
|
|
39
|
+
kind: string,
|
|
40
|
+
children: Array<Element | HastText>,
|
|
41
|
+
extraClassNames: string[] = [],
|
|
42
|
+
): Element {
|
|
43
|
+
return {
|
|
44
|
+
type: "element",
|
|
45
|
+
tagName,
|
|
46
|
+
properties: {
|
|
47
|
+
className: ["aimd-critic", `aimd-critic--${kind}`, ...extraClassNames],
|
|
48
|
+
"data-critic-kind": kind,
|
|
49
|
+
},
|
|
50
|
+
children,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const criticMarkupHandlers = {
|
|
55
|
+
criticAddition(state: any, node: AimdCriticMarkupBaseNode): Element {
|
|
56
|
+
return createCriticElement("ins", "addition", renderInlineChildren(state, node.children))
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
criticDeletion(state: any, node: AimdCriticMarkupBaseNode): Element {
|
|
60
|
+
return createCriticElement("del", "deletion", renderInlineChildren(state, node.children))
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
criticHighlight(state: any, node: AimdCriticMarkupBaseNode): Element {
|
|
64
|
+
return createCriticElement("mark", "highlight", renderInlineChildren(state, node.children))
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
criticComment(_state: any, node: AimdCriticMarkupBaseNode): Element {
|
|
68
|
+
const value = node.value.trim()
|
|
69
|
+
return createCriticElement("span", "comment", [
|
|
70
|
+
{
|
|
71
|
+
type: "element",
|
|
72
|
+
tagName: "span",
|
|
73
|
+
properties: { className: ["aimd-critic__comment-label"] },
|
|
74
|
+
children: [createTextHast("Comment")],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: "element",
|
|
78
|
+
tagName: "span",
|
|
79
|
+
properties: { className: ["aimd-critic__comment-body"] },
|
|
80
|
+
children: [createTextHast(value)],
|
|
81
|
+
},
|
|
82
|
+
], value ? [] : ["aimd-critic--empty-comment"])
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
criticSubstitution(state: any, node: AimdCriticSubstitutionNode): Element {
|
|
86
|
+
return createCriticElement("span", "substitution", [
|
|
87
|
+
createCriticElement("del", "deletion", renderInlineChildren(state, node.oldChildren), ["aimd-critic--substitution-old"]),
|
|
88
|
+
{
|
|
89
|
+
type: "element",
|
|
90
|
+
tagName: "span",
|
|
91
|
+
properties: { className: ["aimd-critic__replacement-arrow"], "aria-hidden": "true" },
|
|
92
|
+
children: [createTextHast("->")],
|
|
93
|
+
},
|
|
94
|
+
createCriticElement("ins", "addition", renderInlineChildren(state, node.newChildren), ["aimd-critic--substitution-new"]),
|
|
95
|
+
])
|
|
96
|
+
},
|
|
97
|
+
}
|