@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.
package/dist/vue.js ADDED
@@ -0,0 +1,17 @@
1
+ import { D as a, c as d, f as s, g as n, h as t, i as o, j as R, k as c, l as m, m as A, n as E, o as l, q as i, e as C } from "./processor-Cv8E7QsA.js";
2
+ export {
3
+ a as DEFAULT_AIMD_RENDERER_LOCALE,
4
+ d as createAimdRendererMessages,
5
+ s as createAssetRenderer,
6
+ n as createCodeBlockRenderer,
7
+ t as createComponentRenderer,
8
+ o as createEmbeddedRenderer,
9
+ R as createMermaidRenderer,
10
+ c as createRenderer,
11
+ m as createStepCardRenderer,
12
+ A as defaultRenderer,
13
+ E as hastToVue,
14
+ l as renderToVNodes,
15
+ i as renderToVue,
16
+ C as resolveAimdRendererLocale
17
+ };
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "@airalogy/aimd-renderer",
3
+ "type": "module",
4
+ "version": "2.4.1",
5
+ "description": "AIMD rendering engines for HTML and Vue",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/airalogy/aimd.git",
10
+ "directory": "packages/aimd-renderer"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/airalogy/aimd/issues"
14
+ },
15
+ "homepage": "https://github.com/airalogy/aimd/tree/main/packages/aimd-renderer#readme",
16
+ "keywords": [
17
+ "aimd",
18
+ "markdown",
19
+ "renderer",
20
+ "vue",
21
+ "html"
22
+ ],
23
+ "exports": {
24
+ ".": {
25
+ "types": "./src/index.ts",
26
+ "import": "./src/index.ts"
27
+ },
28
+ "./html": {
29
+ "types": "./src/html/index.ts",
30
+ "import": "./src/html/index.ts"
31
+ },
32
+ "./vue": {
33
+ "types": "./src/vue/index.ts",
34
+ "import": "./src/vue/index.ts"
35
+ },
36
+ "./styles": {
37
+ "import": "./src/styles/katex.css",
38
+ "default": "./src/styles/katex.css"
39
+ }
40
+ },
41
+ "main": "./src/index.ts",
42
+ "types": "./src/index.ts",
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "files": [
47
+ "dist",
48
+ "src"
49
+ ],
50
+ "scripts": {
51
+ "type-check": "tsc --noEmit",
52
+ "build:types": "tsc --emitDeclarationOnly --declaration --outDir dist",
53
+ "build": "vite build && pnpm run build:types",
54
+ "dev": "vite build --watch",
55
+ "test": "pnpm run build && node --test ./tests/*.test.mjs"
56
+ },
57
+ "peerDependencies": {
58
+ "vue": "^3.5.17"
59
+ },
60
+ "dependencies": {
61
+ "@airalogy/aimd-core": "workspace:^",
62
+ "@types/hast": "^3.0.4",
63
+ "hast-util-to-html": "^9.0.5",
64
+ "katex": "catalog:",
65
+ "rehype-katex": "^7.0.1",
66
+ "rehype-raw": "^7.0.0",
67
+ "remark-breaks": "^4.0.0",
68
+ "remark-gfm": "^4.0.1",
69
+ "remark-math": "^6.0.0",
70
+ "remark-parse": "^11.0.0",
71
+ "remark-rehype": "^11.1.2",
72
+ "shiki": "^2.5.0",
73
+ "unified": "^11.0.5",
74
+ "vfile": "^6.0.3"
75
+ },
76
+ "devDependencies": {
77
+ "@types/node": "^24.3.0",
78
+ "@vitejs/plugin-vue": "^6.0.1",
79
+ "typescript": "5.8.3",
80
+ "vite": "^7.1.3",
81
+ "vite-tsconfig-paths": "^5.1.4",
82
+ "vue": "^3.5.17"
83
+ }
84
+ }
@@ -0,0 +1,388 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { resolveQuizPreviewOptions } from '../common/quiz-preview'
4
+ import {
5
+ createCustomElementAimdRenderer,
6
+ parseAndExtract,
7
+ renderToHtmlSync,
8
+ renderToVue,
9
+ createRenderer,
10
+ } from '../common/processor'
11
+ import { getFinalIndent, parseFieldTag } from '../index'
12
+ import { createStepCardRenderer } from '../vue/vue-renderer'
13
+
14
+ function findVNodeByType(node: any, expectedType: string): any | null {
15
+ if (!node || typeof node !== 'object') {
16
+ return null
17
+ }
18
+
19
+ if (node.type === expectedType) {
20
+ return node
21
+ }
22
+
23
+ const children = Array.isArray(node.children)
24
+ ? node.children
25
+ : Array.isArray(node.component?.subTree?.children)
26
+ ? node.component.subTree.children
27
+ : []
28
+
29
+ for (const child of children) {
30
+ const match = findVNodeByType(child, expectedType)
31
+ if (match) {
32
+ return match
33
+ }
34
+ }
35
+
36
+ return null
37
+ }
38
+
39
+ function collectVNodeText(node: any): string {
40
+ if (node == null) {
41
+ return ''
42
+ }
43
+
44
+ if (typeof node === 'string') {
45
+ return node
46
+ }
47
+
48
+ if (Array.isArray(node)) {
49
+ return node.map((item) => collectVNodeText(item)).join(' ')
50
+ }
51
+
52
+ if (typeof node === 'object') {
53
+ return collectVNodeText(node.children)
54
+ }
55
+
56
+ return ''
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // resolveQuizPreviewOptions
61
+ // ---------------------------------------------------------------------------
62
+
63
+ describe('resolveQuizPreviewOptions', () => {
64
+ it('defaults to hidden in preview mode', () => {
65
+ const result = resolveQuizPreviewOptions('preview')
66
+ expect(result.showAnswers).toBe(false)
67
+ expect(result.showRubric).toBe(false)
68
+ })
69
+
70
+ it('defaults to revealed in report mode', () => {
71
+ const result = resolveQuizPreviewOptions('report')
72
+ expect(result.showAnswers).toBe(true)
73
+ expect(result.showRubric).toBe(true)
74
+ })
75
+
76
+ it('normalizes timeline to preview', () => {
77
+ const result = resolveQuizPreviewOptions('timeline')
78
+ expect(result.showAnswers).toBe(false)
79
+ expect(result.showRubric).toBe(false)
80
+ })
81
+
82
+ it('respects explicit overrides', () => {
83
+ const result = resolveQuizPreviewOptions('preview', {
84
+ showAnswers: true,
85
+ showRubric: false,
86
+ })
87
+ expect(result.showAnswers).toBe(true)
88
+ expect(result.showRubric).toBe(false)
89
+ })
90
+
91
+ it('overrides report defaults', () => {
92
+ const result = resolveQuizPreviewOptions('report', {
93
+ showAnswers: false,
94
+ })
95
+ expect(result.showAnswers).toBe(false)
96
+ expect(result.showRubric).toBe(true)
97
+ })
98
+
99
+ it('handles unknown modes as non-report', () => {
100
+ const result = resolveQuizPreviewOptions('unknown')
101
+ expect(result.showAnswers).toBe(false)
102
+ expect(result.showRubric).toBe(false)
103
+ })
104
+ })
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // parseAndExtract
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe('parseAndExtract', () => {
111
+ it('extracts var fields', () => {
112
+ const fields = parseAndExtract('{{var|temperature: float = 36.5}}')
113
+ expect(fields.var).toContain('temperature')
114
+ })
115
+
116
+ it('extracts step fields', () => {
117
+ const fields = parseAndExtract('{{step|wash_hands}}')
118
+ expect(fields.step.length).toBeGreaterThan(0)
119
+ })
120
+
121
+ it('extracts check fields', () => {
122
+ const fields = parseAndExtract('{{check|verify_result}}')
123
+ expect(fields.check.length).toBeGreaterThan(0)
124
+ })
125
+
126
+ it('returns empty fields for plain markdown', () => {
127
+ const fields = parseAndExtract('# Hello World\n\nJust some text.')
128
+ expect(fields.var).toHaveLength(0)
129
+ expect(fields.step).toHaveLength(0)
130
+ expect(fields.quiz).toHaveLength(0)
131
+ })
132
+
133
+ it('extracts multiple fields from mixed content', () => {
134
+ const content = [
135
+ '{{var|name: str = "Alice"}}',
136
+ '{{var|age: int = 25}}',
137
+ '{{step|step1}}',
138
+ ].join('\n\n')
139
+ const fields = parseAndExtract(content)
140
+ expect(fields.var).toContain('name')
141
+ expect(fields.var).toContain('age')
142
+ expect(fields.step.length).toBeGreaterThan(0)
143
+ })
144
+ })
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // renderToHtmlSync
148
+ // ---------------------------------------------------------------------------
149
+
150
+ describe('renderToHtmlSync', () => {
151
+ it('renders plain markdown to HTML', () => {
152
+ const { html } = renderToHtmlSync('# Hello')
153
+ expect(html).toContain('<h1')
154
+ expect(html).toContain('Hello')
155
+ })
156
+
157
+ it('renders AIMD var fields', () => {
158
+ const { html, fields } = renderToHtmlSync('{{var|temperature}}')
159
+ expect(fields.var).toContain('temperature')
160
+ expect(html).toContain('temperature')
161
+ })
162
+
163
+ it('returns extracted fields alongside HTML', () => {
164
+ const { fields } = renderToHtmlSync('{{step|wash}} and {{check|verify}}')
165
+ expect(fields.step.length).toBeGreaterThan(0)
166
+ expect(fields.check.length).toBeGreaterThan(0)
167
+ })
168
+
169
+ it('renders GFM tables', () => {
170
+ const content = '| A | B |\n|---|---|\n| 1 | 2 |'
171
+ const { html } = renderToHtmlSync(content)
172
+ expect(html).toContain('<table')
173
+ expect(html).toContain('<td')
174
+ })
175
+
176
+ it('supports host custom element renderers for AIMD nodes', () => {
177
+ const { html } = renderToHtmlSync(
178
+ "{{step|verify, 2, title='Verify Output', subtitle='Cross-check', check=True, result=True}}\n\nStep body content.",
179
+ {
180
+ groupStepBodies: true,
181
+ aimdElementRenderers: {
182
+ step: createCustomElementAimdRenderer('step-card', (node) => {
183
+ const stepNode = node as any
184
+ return {
185
+ 'step-id': stepNode.id,
186
+ 'step-number': stepNode.step,
187
+ title: stepNode.title,
188
+ subtitle: stepNode.subtitle,
189
+ level: String(stepNode.level),
190
+ 'has-check': stepNode.check ? 'true' : undefined,
191
+ 'is-result': stepNode.result ? 'true' : undefined,
192
+ }
193
+ }, {
194
+ container: true,
195
+ stripDefaultChildren: true,
196
+ }),
197
+ },
198
+ },
199
+ )
200
+
201
+ expect(html).toContain('<step-card')
202
+ expect(html).toContain('step-id="verify"')
203
+ expect(html).toContain('step-number="1"')
204
+ expect(html).toContain('title="Verify Output"')
205
+ expect(html).toContain('subtitle="Cross-check"')
206
+ expect(html).toContain('has-check="true"')
207
+ expect(html).toContain('is-result="true"')
208
+ expect(html).toContain('data-aimd-step-body="true"')
209
+ expect(html).toContain('Step body content.')
210
+ })
211
+
212
+ it('stops grouped step bodies at headings and dividers', () => {
213
+ const { html } = renderToHtmlSync(
214
+ [
215
+ '## Section',
216
+ '',
217
+ "{{step|step1, title='Step One'}}",
218
+ '',
219
+ 'Body one.',
220
+ '',
221
+ '---',
222
+ '',
223
+ '## Next',
224
+ '',
225
+ '{{step|step2}}',
226
+ '',
227
+ 'Body two.',
228
+ ].join('\n'),
229
+ {
230
+ groupStepBodies: true,
231
+ aimdElementRenderers: {
232
+ step: createCustomElementAimdRenderer('step-card', (node) => ({
233
+ 'step-id': node.id,
234
+ 'step-number': (node as any).step,
235
+ title: (node as any).title || node.id,
236
+ }), {
237
+ container: true,
238
+ stripDefaultChildren: true,
239
+ }),
240
+ },
241
+ },
242
+ )
243
+
244
+ expect(html).toContain('<h2>Section</h2>')
245
+ expect(html).toContain('<hr>')
246
+ expect(html).toContain('<h2>Next</h2>')
247
+ expect(html).toContain('step-id="step1"')
248
+ expect(html).toContain('step-id="step2"')
249
+ expect(html).toContain('Body one.')
250
+ expect(html).toContain('Body two.')
251
+ expect(html.indexOf('Body one.')).toBeLessThan(html.indexOf('<hr>'))
252
+ expect(html.indexOf('<hr>')).toBeLessThan(html.indexOf('step-id="step2"'))
253
+ })
254
+
255
+ it('can lift block-style var types out of inline paragraphs', () => {
256
+ const { html } = renderToHtmlSync(
257
+ 'Experiment summary: {{var|summary: AiralogyMarkdown}}',
258
+ { blockVarTypes: ['AiralogyMarkdown'] },
259
+ )
260
+
261
+ expect(html).toContain('<p>Experiment summary: </p>')
262
+ expect(html).toContain('<div class="aimd-field aimd-field--var aimd-block-var"')
263
+ expect(html).not.toContain('<p>Experiment summary: <span')
264
+ })
265
+
266
+ it('can lift block-style var types out of tight list items', () => {
267
+ const { html } = renderToHtmlSync(
268
+ '- Experiment summary: {{var|summary: AiralogyMarkdown}}',
269
+ { blockVarTypes: ['AiralogyMarkdown'] },
270
+ )
271
+
272
+ expect(html).toContain('<li><p>Experiment summary: </p><div class="aimd-field aimd-field--var aimd-block-var"')
273
+ expect(html).not.toContain('<li>Experiment summary: <span')
274
+ })
275
+ })
276
+
277
+ describe('renderToVue', () => {
278
+ it('renders host-ready step cards with grouped body content', async () => {
279
+ const { nodes } = await renderToVue(
280
+ "{{step|verify, 2, title='Verify Output', subtitle='Cross-check', check=True}}\n\nStep body content.",
281
+ {
282
+ groupStepBodies: true,
283
+ aimdRenderers: {
284
+ step: createStepCardRenderer(),
285
+ },
286
+ },
287
+ )
288
+
289
+ expect(nodes).toHaveLength(1)
290
+ const card = findVNodeByType(nodes[0], 'article') as any
291
+ expect(card).toBeTruthy()
292
+ expect(card.props.class).toContain('aimd-step-card')
293
+ expect(card.props['data-aimd-step-id']).toBe('verify')
294
+ const header = card.children[0] as any
295
+ const leftCluster = header.children[0] as any
296
+ const contentStack = leftCluster.children[1] as any
297
+ expect(contentStack.children[1].children).toBe('Verify Output')
298
+ expect(contentStack.children[2].children).toBe('Cross-check')
299
+ const body = card.children[1] as any
300
+ expect(body.props.class).toContain('aimd-step-card__body')
301
+ expect(collectVNodeText(body)).toContain('Step body content.')
302
+ })
303
+
304
+ it('groups inline check body copy into the check renderer when enabled', async () => {
305
+ const { nodes } = await renderToVue(
306
+ '{{check|measurement_complete, checked_message="量子测量完成"}} 确认所有孔位的量子共振值已记录完毕。',
307
+ {
308
+ groupCheckBodies: true,
309
+ aimdRenderers: {
310
+ check: (node, _ctx, children) => ({
311
+ type: 'section',
312
+ props: { 'data-test-check-id': node.id },
313
+ children,
314
+ }) as any,
315
+ },
316
+ },
317
+ )
318
+
319
+ expect(nodes).toHaveLength(1)
320
+ const card = findVNodeByType(nodes[0], 'section') as any
321
+ expect(card).toBeTruthy()
322
+ expect(card.props['data-test-check-id']).toBe('measurement_complete')
323
+ expect(collectVNodeText(card)).toContain('确认所有孔位的量子共振值已记录完毕')
324
+ expect(collectVNodeText(card)).not.toContain('measurement_complete')
325
+ })
326
+ })
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // createRenderer
330
+ // ---------------------------------------------------------------------------
331
+
332
+ describe('createRenderer', () => {
333
+ it('creates a reusable renderer', () => {
334
+ const renderer = createRenderer()
335
+ expect(renderer).toHaveProperty('toHtml')
336
+ expect(renderer).toHaveProperty('toVue')
337
+ expect(renderer).toHaveProperty('extractFields')
338
+ })
339
+
340
+ it('renderer.extractFields works', () => {
341
+ const renderer = createRenderer()
342
+ const fields = renderer.extractFields('{{var|x}}')
343
+ expect(fields.var).toContain('x')
344
+ })
345
+ })
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // getFinalIndent
349
+ // ---------------------------------------------------------------------------
350
+
351
+ describe('getFinalIndent', () => {
352
+ it('returns simple index for level 1', () => {
353
+ expect(getFinalIndent({ sequence: 0, level: 1 })).toBe('1')
354
+ expect(getFinalIndent({ sequence: 4, level: 1 })).toBe('5')
355
+ })
356
+
357
+ it('builds hierarchical indent from parent chain', () => {
358
+ const parent = { sequence: 0, level: 1, parent: undefined }
359
+ expect(getFinalIndent({ parent, sequence: 1, level: 2 })).toBe('1.2')
360
+ })
361
+
362
+ it('handles deeply nested parents', () => {
363
+ const grandparent = { sequence: 0, level: 1, parent: undefined }
364
+ const parent = { sequence: 2, level: 2, parent: grandparent }
365
+ expect(getFinalIndent({ parent, sequence: 0, level: 3 })).toBe('1.3.1')
366
+ })
367
+ })
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // parseFieldTag
371
+ // ---------------------------------------------------------------------------
372
+
373
+ describe('parseFieldTag', () => {
374
+ it('parses simple var tag', () => {
375
+ const result = parseFieldTag('var|temperature')
376
+ expect(result).toEqual([{ type: 'var', name: 'temperature' }])
377
+ })
378
+
379
+ it('parses step tag', () => {
380
+ const result = parseFieldTag('step|wash_hands')
381
+ expect(result).toEqual([{ type: 'step', name: 'wash_hands' }])
382
+ })
383
+
384
+ it('parses var_table tag', () => {
385
+ const result = parseFieldTag('var_table|measurements|col1,col2')
386
+ expect(result[0].type).toBe('var_table')
387
+ })
388
+ })
@@ -0,0 +1,110 @@
1
+ import type { Element, Root as HastRoot } from "hast"
2
+ import type { ExtractedAimdFields } from "@airalogy/aimd-core/types"
3
+ import type { AimdNode } from "@airalogy/aimd-core/types"
4
+ import type { AimdRendererOptions } from "./processor"
5
+ import { createAimdRendererMessages } from "../locales"
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Step sequence map
9
+ // ---------------------------------------------------------------------------
10
+
11
+ function buildStepSequenceMap(fields: ExtractedAimdFields): Map<string, string> {
12
+ const sequenceMap = new Map<string, string>()
13
+
14
+ for (const step of fields.step_hierarchy || []) {
15
+ if (typeof step.id === "string" && step.id.trim() && typeof step.step === "string" && step.step.trim()) {
16
+ sequenceMap.set(step.id, step.step)
17
+ }
18
+ }
19
+
20
+ return sequenceMap
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // HAST annotation pass
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * Walk a HAST tree and inject the resolved step-sequence numbers into every
29
+ * `ref_step` element so the rendered output shows e.g. "Step 2.1" instead of
30
+ * the raw id.
31
+ */
32
+ export function annotateStepReferenceSequence(
33
+ tree: HastRoot,
34
+ fields: ExtractedAimdFields,
35
+ options: AimdRendererOptions,
36
+ ): void {
37
+ const stepSequenceMap = buildStepSequenceMap(fields)
38
+ if (stepSequenceMap.size === 0) {
39
+ return
40
+ }
41
+
42
+ const messages = createAimdRendererMessages(options.locale, options.messages)
43
+
44
+ const visitNode = (node: HastRoot | Element): void => {
45
+ if (node.type === "element") {
46
+ const element = node as Element
47
+ const aimdType = element.properties?.["data-aimd-type"] || element.properties?.dataAimdType
48
+
49
+ if (aimdType === "ref_step") {
50
+ const refTarget = element.properties?.["data-aimd-id"] || element.properties?.dataAimdId
51
+ if (typeof refTarget === "string") {
52
+ const stepSequence = stepSequenceMap.get(refTarget)
53
+ if (stepSequence) {
54
+ element.properties["data-aimd-step-sequence"] = stepSequence
55
+ element.properties.title = refTarget
56
+
57
+ const aimdData = (element.data as { aimd?: AimdNode } | undefined)?.aimd
58
+ if (aimdData) {
59
+ ;(aimdData as any).stepSequence = stepSequence
60
+ }
61
+
62
+ const jsonData = element.properties["data-aimd-json"]
63
+ if (typeof jsonData === "string") {
64
+ try {
65
+ const parsed = JSON.parse(jsonData) as Record<string, unknown>
66
+ parsed.stepSequence = stepSequence
67
+ element.properties["data-aimd-json"] = JSON.stringify(parsed)
68
+ }
69
+ catch {
70
+ // Ignore malformed fallback JSON and keep runtime metadata only.
71
+ }
72
+ }
73
+
74
+ element.children = [{
75
+ type: "element",
76
+ tagName: "span",
77
+ properties: { className: ["aimd-ref__content"] },
78
+ children: [{
79
+ type: "element",
80
+ tagName: "span",
81
+ properties: { className: ["aimd-field", "aimd-field--step", "aimd-field--readonly"] },
82
+ children: [{
83
+ type: "element",
84
+ tagName: "span",
85
+ properties: { className: ["research-step__sequence"] },
86
+ children: [{ type: "text", value: messages.step.reference(stepSequence) }],
87
+ } as Element],
88
+ } as Element],
89
+ } as Element]
90
+ }
91
+ }
92
+ }
93
+
94
+ for (const child of element.children || []) {
95
+ if (child.type === "element") {
96
+ visitNode(child)
97
+ }
98
+ }
99
+ return
100
+ }
101
+
102
+ for (const child of node.children) {
103
+ if (child.type === "element") {
104
+ visitNode(child)
105
+ }
106
+ }
107
+ }
108
+
109
+ visitNode(tree)
110
+ }