@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/README.md +117 -0
- package/README.zh-CN.md +101 -0
- package/dist/aimd-renderer.css +1 -0
- package/dist/html.js +11 -0
- package/dist/index.js +439 -0
- package/dist/processor-Cv8E7QsA.js +11539 -0
- package/dist/vue.js +17 -0
- package/package.json +84 -0
- package/src/__tests__/renderer.test.ts +388 -0
- package/src/common/annotateStepReferences.ts +110 -0
- package/src/common/assignerHighlighting.ts +159 -0
- package/src/common/assignerVisibility.ts +289 -0
- package/src/common/eventKeys.ts +10 -0
- package/src/common/figureNumbering.ts +126 -0
- package/src/common/processor.ts +1554 -0
- package/src/common/quiz-preview.ts +22 -0
- package/src/common/unified-token-renderer.ts +810 -0
- package/src/css.d.ts +1 -0
- package/src/html/index.ts +33 -0
- package/src/index.ts +114 -0
- package/src/locales.ts +256 -0
- package/src/styles/katex.css +2 -0
- package/src/vue/index.ts +47 -0
- package/src/vue/vue-renderer.ts +1449 -0
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
|
+
}
|