@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
|
@@ -0,0 +1,1554 @@
|
|
|
1
|
+
import type { Element, Properties, Root as HastRoot, Text as HastText } from "hast"
|
|
2
|
+
import type { VFile } from "vfile"
|
|
3
|
+
import type { VNode } from "vue"
|
|
4
|
+
import type {
|
|
5
|
+
AimdFieldType,
|
|
6
|
+
AimdNode,
|
|
7
|
+
AimdQuizNode,
|
|
8
|
+
AimdStepNode,
|
|
9
|
+
ProcessorOptions,
|
|
10
|
+
RenderContext,
|
|
11
|
+
} from "@airalogy/aimd-core/types"
|
|
12
|
+
import type { ExtractedAimdFields } from "@airalogy/aimd-core/types"
|
|
13
|
+
import type { AimdRendererI18nOptions } from "../locales"
|
|
14
|
+
import type { VueRendererOptions } from "../vue/vue-renderer"
|
|
15
|
+
import { resolveQuizPreviewOptions } from "./quiz-preview"
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Sub-module imports
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
import { remarkInsertVisibleAssigners, remarkStripAssignerCodeBlocks } from "./assignerVisibility"
|
|
22
|
+
import { highlightVisibleAssigners } from "./assignerHighlighting"
|
|
23
|
+
import { annotateStepReferenceSequence } from "./annotateStepReferences"
|
|
24
|
+
import { buildFigureChildren, assignFigureSequenceNumbers } from "./figureNumbering"
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Public types
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Render result
|
|
32
|
+
*/
|
|
33
|
+
export interface RenderResult {
|
|
34
|
+
nodes: VNode[]
|
|
35
|
+
fields: ExtractedAimdFields
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type AimdAssignerVisibility = "hidden" | "collapsed" | "expanded"
|
|
39
|
+
|
|
40
|
+
export interface AimdHtmlRendererContext extends RenderContext {
|
|
41
|
+
locale: NonNullable<AimdRendererI18nOptions["locale"]> | string
|
|
42
|
+
messages: ReturnType<typeof createAimdRendererMessages>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type AimdHtmlNodeRenderer = (
|
|
46
|
+
node: AimdNode,
|
|
47
|
+
defaultElement: Element,
|
|
48
|
+
context: AimdHtmlRendererContext,
|
|
49
|
+
) => Element | null | undefined
|
|
50
|
+
|
|
51
|
+
export interface AimdRendererOptions extends ProcessorOptions, AimdRendererI18nOptions {
|
|
52
|
+
assignerVisibility?: AimdAssignerVisibility
|
|
53
|
+
aimdElementRenderers?: Partial<Record<AimdFieldType, AimdHtmlNodeRenderer>>
|
|
54
|
+
groupStepBodies?: boolean
|
|
55
|
+
groupCheckBodies?: boolean
|
|
56
|
+
blockVarTypes?: string[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CustomElementAimdRendererOptions {
|
|
60
|
+
container?: boolean
|
|
61
|
+
stripDefaultChildren?: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assignAimdNodeData(element: Element, node: AimdNode): Element {
|
|
65
|
+
const existingData = (element as any).data || {}
|
|
66
|
+
;(element as any).data = {
|
|
67
|
+
...existingData,
|
|
68
|
+
aimd: node,
|
|
69
|
+
}
|
|
70
|
+
return element
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function cleanProperties(properties: Properties): Properties {
|
|
74
|
+
return Object.fromEntries(
|
|
75
|
+
Object.entries(properties).filter(([, value]) => value !== undefined && value !== null),
|
|
76
|
+
) as Properties
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createCustomElementAimdRenderer(
|
|
80
|
+
tagName: string,
|
|
81
|
+
mapProperties?: (
|
|
82
|
+
node: AimdNode,
|
|
83
|
+
context: AimdHtmlRendererContext,
|
|
84
|
+
defaultElement: Element,
|
|
85
|
+
) => Properties,
|
|
86
|
+
options: CustomElementAimdRendererOptions = {},
|
|
87
|
+
): AimdHtmlNodeRenderer {
|
|
88
|
+
return (node, defaultElement, context) => {
|
|
89
|
+
const mappedProperties = mapProperties ? mapProperties(node, context, defaultElement) : {}
|
|
90
|
+
return assignAimdNodeData({
|
|
91
|
+
...defaultElement,
|
|
92
|
+
tagName,
|
|
93
|
+
properties: cleanProperties({
|
|
94
|
+
...defaultElement.properties,
|
|
95
|
+
...mappedProperties,
|
|
96
|
+
...(options.container ? { "data-aimd-step-container": "true" } : {}),
|
|
97
|
+
...(options.stripDefaultChildren ? { "data-aimd-strip-default-children": "true" } : {}),
|
|
98
|
+
}),
|
|
99
|
+
}, node)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isWhitespaceTextNode(node: unknown): node is HastText {
|
|
104
|
+
return typeof node === "object"
|
|
105
|
+
&& node !== null
|
|
106
|
+
&& (node as HastText).type === "text"
|
|
107
|
+
&& !String((node as HastText).value || "").trim()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isElementNode(node: unknown): node is Element {
|
|
111
|
+
return typeof node === "object"
|
|
112
|
+
&& node !== null
|
|
113
|
+
&& (node as Element).type === "element"
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toCamelCaseKey(value: string): string {
|
|
117
|
+
return value.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase())
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getPropertyValue(properties: Properties | undefined, key: string): string | undefined {
|
|
121
|
+
const value = properties?.[key] ?? properties?.[toCamelCaseKey(key)]
|
|
122
|
+
if (typeof value === "string") {
|
|
123
|
+
return value
|
|
124
|
+
}
|
|
125
|
+
if (typeof value === "boolean" || typeof value === "number") {
|
|
126
|
+
return String(value)
|
|
127
|
+
}
|
|
128
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
129
|
+
const first = value[0]
|
|
130
|
+
return typeof first === "string" ? first : undefined
|
|
131
|
+
}
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isAimdStepElement(node: unknown): node is Element {
|
|
136
|
+
return typeof node === "object"
|
|
137
|
+
&& node !== null
|
|
138
|
+
&& (node as Element).type === "element"
|
|
139
|
+
&& getPropertyValue((node as Element).properties, "data-aimd-type") === "step"
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isAimdCheckElement(node: unknown): node is Element {
|
|
143
|
+
return typeof node === "object"
|
|
144
|
+
&& node !== null
|
|
145
|
+
&& (node as Element).type === "element"
|
|
146
|
+
&& getPropertyValue((node as Element).properties, "data-aimd-type") === "check"
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isHeadingOrDivider(node: unknown): boolean {
|
|
150
|
+
if (typeof node !== "object" || node === null || (node as Element).type !== "element") {
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const tagName = (node as Element).tagName
|
|
155
|
+
return tagName === "hr" || /^h[1-6]$/.test(tagName)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function unwrapStandaloneContainerStep(node: unknown): Element | null {
|
|
159
|
+
if (isAimdStepElement(node)) {
|
|
160
|
+
return getPropertyValue(node.properties, "data-aimd-step-container") === "true" ? node : null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (
|
|
164
|
+
typeof node !== "object"
|
|
165
|
+
|| node === null
|
|
166
|
+
|| (node as Element).type !== "element"
|
|
167
|
+
|| (node as Element).tagName !== "p"
|
|
168
|
+
) {
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const meaningfulChildren = ((node as Element).children || []).filter((child) => !isWhitespaceTextNode(child))
|
|
173
|
+
if (meaningfulChildren.length !== 1 || !isAimdStepElement(meaningfulChildren[0])) {
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const stepElement = meaningfulChildren[0]
|
|
178
|
+
return getPropertyValue(stepElement.properties, "data-aimd-step-container") === "true" ? stepElement : null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function cloneNodeForStepBody<T extends Element | HastText>(node: T): T {
|
|
182
|
+
return JSON.parse(JSON.stringify(node)) as T
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function groupCheckBodiesInParent(parent: HastRoot | Element): void {
|
|
186
|
+
const originalChildren = (parent.children || []) as Array<Element | HastText>
|
|
187
|
+
const nextChildren: Array<Element | HastText> = []
|
|
188
|
+
|
|
189
|
+
for (let index = 0; index < originalChildren.length; index += 1) {
|
|
190
|
+
const currentNode = originalChildren[index]
|
|
191
|
+
|
|
192
|
+
if (typeof currentNode === "object" && currentNode !== null && (currentNode as Element).type === "element") {
|
|
193
|
+
groupCheckBodiesInParent(currentNode as Element)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (
|
|
197
|
+
typeof currentNode !== "object"
|
|
198
|
+
|| currentNode === null
|
|
199
|
+
|| (currentNode as Element).type !== "element"
|
|
200
|
+
|| (currentNode as Element).tagName !== "p"
|
|
201
|
+
) {
|
|
202
|
+
nextChildren.push(currentNode)
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const paragraph = currentNode as Element
|
|
207
|
+
const paragraphChildren = (paragraph.children || []) as Array<Element | HastText>
|
|
208
|
+
const meaningfulChildren = paragraphChildren.filter((child) => !isWhitespaceTextNode(child))
|
|
209
|
+
|
|
210
|
+
if (meaningfulChildren.length < 2 || !isAimdCheckElement(meaningfulChildren[0])) {
|
|
211
|
+
nextChildren.push(currentNode)
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const checkElement = meaningfulChildren[0] as Element
|
|
216
|
+
const checkIndex = paragraphChildren.indexOf(checkElement)
|
|
217
|
+
if (checkIndex < 0) {
|
|
218
|
+
nextChildren.push(currentNode)
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const tailChildren = paragraphChildren
|
|
223
|
+
.slice(checkIndex + 1)
|
|
224
|
+
.map((child) => cloneNodeForStepBody(child as Element | HastText))
|
|
225
|
+
.filter((child) => !isWhitespaceTextNode(child))
|
|
226
|
+
|
|
227
|
+
if (tailChildren.length === 0) {
|
|
228
|
+
nextChildren.push(currentNode)
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
checkElement.properties = {
|
|
233
|
+
...(checkElement.properties || {}),
|
|
234
|
+
"data-aimd-check-container": "true",
|
|
235
|
+
"data-aimd-strip-default-children": "true",
|
|
236
|
+
}
|
|
237
|
+
checkElement.children = [{
|
|
238
|
+
type: "element",
|
|
239
|
+
tagName: "div",
|
|
240
|
+
properties: {
|
|
241
|
+
className: ["aimd-check-body"],
|
|
242
|
+
"data-aimd-check-body": "true",
|
|
243
|
+
},
|
|
244
|
+
children: tailChildren,
|
|
245
|
+
}]
|
|
246
|
+
|
|
247
|
+
nextChildren.push(checkElement)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
parent.children = nextChildren
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function groupCheckBodies(tree: HastRoot): void {
|
|
254
|
+
groupCheckBodiesInParent(tree)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function groupStepBodiesInParent(parent: HastRoot | Element): void {
|
|
258
|
+
const originalChildren = (parent.children || []) as Array<Element | HastText>
|
|
259
|
+
const nextChildren: Array<Element | HastText> = []
|
|
260
|
+
|
|
261
|
+
for (let index = 0; index < originalChildren.length; index += 1) {
|
|
262
|
+
const currentNode = originalChildren[index]
|
|
263
|
+
const stepContainer = unwrapStandaloneContainerStep(currentNode)
|
|
264
|
+
|
|
265
|
+
if (!stepContainer) {
|
|
266
|
+
if (typeof currentNode === "object" && currentNode !== null && (currentNode as Element).type === "element") {
|
|
267
|
+
groupStepBodiesInParent(currentNode as Element)
|
|
268
|
+
}
|
|
269
|
+
nextChildren.push(currentNode)
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const bodyChildren: Array<Element | HastText> = []
|
|
274
|
+
let scanIndex = index + 1
|
|
275
|
+
while (scanIndex < originalChildren.length) {
|
|
276
|
+
const candidate = originalChildren[scanIndex]
|
|
277
|
+
if (unwrapStandaloneContainerStep(candidate) || isHeadingOrDivider(candidate)) {
|
|
278
|
+
break
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (typeof candidate === "object" && candidate !== null && (candidate as Element).type === "element") {
|
|
282
|
+
groupStepBodiesInParent(candidate as Element)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
bodyChildren.push(cloneNodeForStepBody(candidate as Element | HastText))
|
|
286
|
+
scanIndex += 1
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const stripChildren = getPropertyValue(stepContainer.properties, "data-aimd-strip-default-children") === "true"
|
|
290
|
+
stepContainer.children = stripChildren ? [] : [...(stepContainer.children || [])]
|
|
291
|
+
if (bodyChildren.length > 0) {
|
|
292
|
+
stepContainer.children.push({
|
|
293
|
+
type: "element",
|
|
294
|
+
tagName: "div",
|
|
295
|
+
properties: {
|
|
296
|
+
className: ["aimd-step-body"],
|
|
297
|
+
"data-aimd-step-body": "true",
|
|
298
|
+
},
|
|
299
|
+
children: bodyChildren,
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
nextChildren.push(stepContainer)
|
|
304
|
+
index = scanIndex - 1
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
;(parent as HastRoot | Element).children = nextChildren as any
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function groupStepBodies(tree: HastRoot): void {
|
|
311
|
+
groupStepBodiesInParent(tree)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function normalizeAimdTypeToken(value: unknown): string {
|
|
315
|
+
return typeof value === "string" ? value.trim().toLowerCase() : ""
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function getAimdNodeFromElement(element: Element): AimdNode | null {
|
|
319
|
+
const fromData = (element as any).data?.aimd
|
|
320
|
+
if (fromData) {
|
|
321
|
+
return fromData as AimdNode
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const rawJson = getPropertyValue(element.properties, "data-aimd-json")
|
|
325
|
+
if (!rawJson) {
|
|
326
|
+
return null
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
return JSON.parse(rawJson) as AimdNode
|
|
331
|
+
} catch {
|
|
332
|
+
return null
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function isBlockVarElement(node: unknown, blockVarTypes: Set<string>): node is Element {
|
|
337
|
+
if (typeof node !== "object" || node === null || (node as Element).type !== "element") {
|
|
338
|
+
return false
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const aimdNode = getAimdNodeFromElement(node as Element)
|
|
342
|
+
if (!aimdNode || aimdNode.fieldType !== "var") {
|
|
343
|
+
return false
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return blockVarTypes.has(normalizeAimdTypeToken((aimdNode as any).definition?.type))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function promoteBlockVarElement(element: Element): Element {
|
|
350
|
+
const aimdNode = getAimdNodeFromElement(element)
|
|
351
|
+
const className = Array.isArray(element.properties.className)
|
|
352
|
+
? [...element.properties.className]
|
|
353
|
+
: element.properties.className
|
|
354
|
+
? [element.properties.className]
|
|
355
|
+
: []
|
|
356
|
+
|
|
357
|
+
const promoted = {
|
|
358
|
+
...element,
|
|
359
|
+
tagName: "div",
|
|
360
|
+
properties: {
|
|
361
|
+
...element.properties,
|
|
362
|
+
className: [...className, "aimd-block-var"],
|
|
363
|
+
},
|
|
364
|
+
} as Element
|
|
365
|
+
|
|
366
|
+
return aimdNode ? assignAimdNodeData(promoted, aimdNode) : promoted
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function cloneParagraphWithChildren(paragraph: Element, children: Array<Element | HastText>): Element {
|
|
370
|
+
return {
|
|
371
|
+
...paragraph,
|
|
372
|
+
children,
|
|
373
|
+
} as Element
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function createInlineParagraph(children: Array<Element | HastText>): Element {
|
|
377
|
+
return {
|
|
378
|
+
type: "element",
|
|
379
|
+
tagName: "p",
|
|
380
|
+
properties: {},
|
|
381
|
+
children,
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function liftBlockVarParagraph(paragraph: Element, blockVarTypes: Set<string>): Array<Element | HastText> {
|
|
386
|
+
const nextNodes: Array<Element | HastText> = []
|
|
387
|
+
let inlineRun: Array<Element | HastText> = []
|
|
388
|
+
|
|
389
|
+
const flushInlineRun = () => {
|
|
390
|
+
if (!inlineRun.some((child) => !isWhitespaceTextNode(child))) {
|
|
391
|
+
inlineRun = []
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
nextNodes.push(cloneParagraphWithChildren(paragraph, inlineRun))
|
|
396
|
+
inlineRun = []
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
for (const child of (paragraph.children || []) as Array<Element | HastText>) {
|
|
400
|
+
if (isBlockVarElement(child, blockVarTypes)) {
|
|
401
|
+
flushInlineRun()
|
|
402
|
+
nextNodes.push(promoteBlockVarElement(child))
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
inlineRun.push(child)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
flushInlineRun()
|
|
410
|
+
return nextNodes.length > 0 ? nextNodes : [paragraph]
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function liftBlockVarListItem(listItem: Element, blockVarTypes: Set<string>): Element {
|
|
414
|
+
const originalChildren = (listItem.children || []) as Array<Element | HastText>
|
|
415
|
+
const nextChildren: Array<Element | HastText> = []
|
|
416
|
+
let inlineRun: Array<Element | HastText> = []
|
|
417
|
+
let foundDirectBlockVar = false
|
|
418
|
+
|
|
419
|
+
const flushInlineRun = () => {
|
|
420
|
+
if (!inlineRun.some((child) => !isWhitespaceTextNode(child))) {
|
|
421
|
+
inlineRun = []
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
nextChildren.push(createInlineParagraph(inlineRun))
|
|
426
|
+
inlineRun = []
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
for (const child of originalChildren) {
|
|
430
|
+
if (isBlockVarElement(child, blockVarTypes)) {
|
|
431
|
+
foundDirectBlockVar = true
|
|
432
|
+
flushInlineRun()
|
|
433
|
+
nextChildren.push(promoteBlockVarElement(child))
|
|
434
|
+
continue
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (isElementNode(child)) {
|
|
438
|
+
flushInlineRun()
|
|
439
|
+
nextChildren.push(child)
|
|
440
|
+
continue
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
inlineRun.push(child)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
flushInlineRun()
|
|
447
|
+
|
|
448
|
+
return foundDirectBlockVar
|
|
449
|
+
? {
|
|
450
|
+
...listItem,
|
|
451
|
+
children: nextChildren,
|
|
452
|
+
} as Element
|
|
453
|
+
: listItem
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function liftBlockVarElementsInParent(parent: HastRoot | Element, blockVarTypes: Set<string>): void {
|
|
457
|
+
const nextChildren: Array<Element | HastText> = []
|
|
458
|
+
|
|
459
|
+
for (const child of (parent.children || []) as Array<Element | HastText>) {
|
|
460
|
+
if (typeof child !== "object" || child === null || child.type !== "element") {
|
|
461
|
+
nextChildren.push(child)
|
|
462
|
+
continue
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const clonedChild = { ...child } as Element
|
|
466
|
+
if (clonedChild.children?.length) {
|
|
467
|
+
liftBlockVarElementsInParent(clonedChild, blockVarTypes)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (clonedChild.tagName === "p") {
|
|
471
|
+
nextChildren.push(...liftBlockVarParagraph(clonedChild, blockVarTypes))
|
|
472
|
+
continue
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (clonedChild.tagName === "li") {
|
|
476
|
+
nextChildren.push(liftBlockVarListItem(clonedChild, blockVarTypes))
|
|
477
|
+
continue
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
nextChildren.push(clonedChild)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
;(parent as HastRoot | Element).children = nextChildren as any
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function liftBlockVarElements(tree: HastRoot, blockVarTypes?: string[]): void {
|
|
487
|
+
const normalizedTypes = new Set(
|
|
488
|
+
(blockVarTypes ?? [])
|
|
489
|
+
.map(type => normalizeAimdTypeToken(type))
|
|
490
|
+
.filter(Boolean),
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
if (normalizedTypes.size === 0) {
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
liftBlockVarElementsInParent(tree, normalizedTypes)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
// Third-party imports
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
import { toHtml } from "hast-util-to-html"
|
|
505
|
+
import rehypeKatex from "rehype-katex"
|
|
506
|
+
import rehypeRaw from "rehype-raw"
|
|
507
|
+
import remarkBreaks from "remark-breaks"
|
|
508
|
+
import remarkGfm from "remark-gfm"
|
|
509
|
+
import remarkMath from "remark-math"
|
|
510
|
+
import remarkParse from "remark-parse"
|
|
511
|
+
import remarkRehype from "remark-rehype"
|
|
512
|
+
import { unified } from "unified"
|
|
513
|
+
|
|
514
|
+
import { protectAimdInlineTemplates, remarkAimd } from "@airalogy/aimd-core/parser"
|
|
515
|
+
import {
|
|
516
|
+
createAimdRendererMessages,
|
|
517
|
+
getAimdRendererQuizTypeLabel,
|
|
518
|
+
getAimdRendererScopeLabel,
|
|
519
|
+
} from "../locales"
|
|
520
|
+
import { renderToVNodes } from "../vue/vue-renderer"
|
|
521
|
+
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
// Module-level singletons
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
let mathStylesLoadPromise: Promise<unknown> | null = null
|
|
527
|
+
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// Internal helpers that remain in the coordinator
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
const EMPTY_EXTRACTED_FIELDS: ExtractedAimdFields = {
|
|
533
|
+
var: [],
|
|
534
|
+
var_table: [],
|
|
535
|
+
client_assigner: [],
|
|
536
|
+
quiz: [],
|
|
537
|
+
step: [],
|
|
538
|
+
check: [],
|
|
539
|
+
ref_step: [],
|
|
540
|
+
ref_var: [],
|
|
541
|
+
ref_fig: [],
|
|
542
|
+
cite: [],
|
|
543
|
+
fig: [],
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function ensureMathStylesLoaded(mathEnabled: boolean | undefined): Promise<void> {
|
|
547
|
+
if (mathEnabled === false) {
|
|
548
|
+
return
|
|
549
|
+
}
|
|
550
|
+
if (typeof document === "undefined") {
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
if (!mathStylesLoadPromise) {
|
|
554
|
+
mathStylesLoadPromise = import("../styles/katex.css").catch(() => undefined)
|
|
555
|
+
}
|
|
556
|
+
await mathStylesLoadPromise
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function createAimdParseInput(content: string) {
|
|
560
|
+
const { content: protectedContent, templates } = protectAimdInlineTemplates(content)
|
|
561
|
+
const file: VFile = {
|
|
562
|
+
data: {
|
|
563
|
+
aimdInlineTemplates: templates,
|
|
564
|
+
},
|
|
565
|
+
} as unknown as VFile
|
|
566
|
+
|
|
567
|
+
return {
|
|
568
|
+
content: protectedContent,
|
|
569
|
+
file,
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function createEmptyExtractedFields(): ExtractedAimdFields {
|
|
574
|
+
return {
|
|
575
|
+
var: [...EMPTY_EXTRACTED_FIELDS.var],
|
|
576
|
+
var_table: [...EMPTY_EXTRACTED_FIELDS.var_table],
|
|
577
|
+
client_assigner: [...EMPTY_EXTRACTED_FIELDS.client_assigner],
|
|
578
|
+
quiz: [...EMPTY_EXTRACTED_FIELDS.quiz],
|
|
579
|
+
step: [...EMPTY_EXTRACTED_FIELDS.step],
|
|
580
|
+
check: [...EMPTY_EXTRACTED_FIELDS.check],
|
|
581
|
+
ref_step: [...EMPTY_EXTRACTED_FIELDS.ref_step],
|
|
582
|
+
ref_var: [...EMPTY_EXTRACTED_FIELDS.ref_var],
|
|
583
|
+
ref_fig: [...(EMPTY_EXTRACTED_FIELDS.ref_fig || [])],
|
|
584
|
+
cite: [...(EMPTY_EXTRACTED_FIELDS.cite || [])],
|
|
585
|
+
fig: [...(EMPTY_EXTRACTED_FIELDS.fig || [])],
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function getExtractedFields(file: VFile): ExtractedAimdFields {
|
|
590
|
+
return (file.data.aimdFields as ExtractedAimdFields) || createEmptyExtractedFields()
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ---------------------------------------------------------------------------
|
|
594
|
+
// Field-type helpers used by createAimdHandler
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Map field type to CSS class modifier (BEM format)
|
|
599
|
+
*/
|
|
600
|
+
function getFieldTypeClass(fieldType: AimdNode["fieldType"]): string {
|
|
601
|
+
switch (fieldType) {
|
|
602
|
+
case "var_table":
|
|
603
|
+
return "var-table"
|
|
604
|
+
default:
|
|
605
|
+
return fieldType
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const BLANK_PLACEHOLDER_PATTERN = /\[\[([^\[\]\s]+)\]\]/g
|
|
610
|
+
|
|
611
|
+
function buildQuizStemChildren(
|
|
612
|
+
quizType: AimdQuizNode["quizType"],
|
|
613
|
+
stem: string,
|
|
614
|
+
): Array<Element | HastText> {
|
|
615
|
+
if (quizType !== "blank") {
|
|
616
|
+
return [{ type: "text", value: stem }]
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const children: Array<Element | HastText> = []
|
|
620
|
+
let lastIndex = 0
|
|
621
|
+
|
|
622
|
+
for (const match of stem.matchAll(BLANK_PLACEHOLDER_PATTERN)) {
|
|
623
|
+
const start = match.index ?? 0
|
|
624
|
+
const fullMatch = match[0]
|
|
625
|
+
const key = match[1]
|
|
626
|
+
|
|
627
|
+
if (start > lastIndex) {
|
|
628
|
+
children.push({
|
|
629
|
+
type: "text",
|
|
630
|
+
value: stem.slice(lastIndex, start),
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
children.push({
|
|
635
|
+
type: "element",
|
|
636
|
+
tagName: "span",
|
|
637
|
+
properties: {
|
|
638
|
+
className: ["aimd-quiz__blank-placeholder"],
|
|
639
|
+
"data-blank-key": key,
|
|
640
|
+
},
|
|
641
|
+
children: [{ type: "text", value: key }],
|
|
642
|
+
} as Element)
|
|
643
|
+
|
|
644
|
+
lastIndex = start + fullMatch.length
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (lastIndex < stem.length) {
|
|
648
|
+
children.push({
|
|
649
|
+
type: "text",
|
|
650
|
+
value: stem.slice(lastIndex),
|
|
651
|
+
})
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (children.length === 0) {
|
|
655
|
+
children.push({ type: "text", value: stem })
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return children
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function formatScaleOptionLabel(option: NonNullable<AimdQuizNode["options"]>[number]): string {
|
|
662
|
+
if (typeof option.points === "number" && Number.isFinite(option.points)) {
|
|
663
|
+
return `${option.text} (${option.points})`
|
|
664
|
+
}
|
|
665
|
+
return option.text
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function buildScalePreviewChildren(quizNode: AimdQuizNode): Array<Element | HastText> {
|
|
669
|
+
if (!Array.isArray(quizNode.items) || quizNode.items.length === 0 || !Array.isArray(quizNode.options) || quizNode.options.length === 0) {
|
|
670
|
+
return [{
|
|
671
|
+
type: "element",
|
|
672
|
+
tagName: "div",
|
|
673
|
+
properties: { className: ["aimd-scale__empty"] },
|
|
674
|
+
children: [{ type: "text", value: "Scale definition is incomplete." }],
|
|
675
|
+
} as Element]
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (quizNode.display === "list") {
|
|
679
|
+
return [{
|
|
680
|
+
type: "element",
|
|
681
|
+
tagName: "div",
|
|
682
|
+
properties: { className: ["aimd-scale__list"] },
|
|
683
|
+
children: quizNode.items.map(item => ({
|
|
684
|
+
type: "element",
|
|
685
|
+
tagName: "div",
|
|
686
|
+
properties: { className: ["aimd-scale__list-item"] },
|
|
687
|
+
children: [
|
|
688
|
+
{
|
|
689
|
+
type: "element",
|
|
690
|
+
tagName: "div",
|
|
691
|
+
properties: { className: ["aimd-scale__item-stem"] },
|
|
692
|
+
children: [{ type: "text", value: item.stem }],
|
|
693
|
+
} as Element,
|
|
694
|
+
{
|
|
695
|
+
type: "element",
|
|
696
|
+
tagName: "ul",
|
|
697
|
+
properties: { className: ["aimd-scale__item-options"] },
|
|
698
|
+
children: quizNode.options!.map(option => ({
|
|
699
|
+
type: "element",
|
|
700
|
+
tagName: "li",
|
|
701
|
+
properties: {},
|
|
702
|
+
children: [{ type: "text", value: formatScaleOptionLabel(option) }],
|
|
703
|
+
} as Element)),
|
|
704
|
+
} as Element,
|
|
705
|
+
],
|
|
706
|
+
} as Element)),
|
|
707
|
+
} as Element]
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return [{
|
|
711
|
+
type: "element",
|
|
712
|
+
tagName: "table",
|
|
713
|
+
properties: { className: ["aimd-scale__table"] },
|
|
714
|
+
children: [
|
|
715
|
+
{
|
|
716
|
+
type: "element",
|
|
717
|
+
tagName: "thead",
|
|
718
|
+
properties: {},
|
|
719
|
+
children: [{
|
|
720
|
+
type: "element",
|
|
721
|
+
tagName: "tr",
|
|
722
|
+
properties: {},
|
|
723
|
+
children: [
|
|
724
|
+
{
|
|
725
|
+
type: "element",
|
|
726
|
+
tagName: "th",
|
|
727
|
+
properties: { className: ["aimd-scale__item-header"] },
|
|
728
|
+
children: [{ type: "text", value: "Item" }],
|
|
729
|
+
} as Element,
|
|
730
|
+
...quizNode.options.map(option => ({
|
|
731
|
+
type: "element",
|
|
732
|
+
tagName: "th",
|
|
733
|
+
properties: {},
|
|
734
|
+
children: [{ type: "text", value: formatScaleOptionLabel(option) }],
|
|
735
|
+
} as Element)),
|
|
736
|
+
],
|
|
737
|
+
} as Element],
|
|
738
|
+
} as Element,
|
|
739
|
+
{
|
|
740
|
+
type: "element",
|
|
741
|
+
tagName: "tbody",
|
|
742
|
+
properties: {},
|
|
743
|
+
children: quizNode.items.map(item => ({
|
|
744
|
+
type: "element",
|
|
745
|
+
tagName: "tr",
|
|
746
|
+
properties: {},
|
|
747
|
+
children: [
|
|
748
|
+
{
|
|
749
|
+
type: "element",
|
|
750
|
+
tagName: "th",
|
|
751
|
+
properties: { className: ["aimd-scale__item-stem"], scope: "row" },
|
|
752
|
+
children: [{ type: "text", value: item.stem }],
|
|
753
|
+
} as Element,
|
|
754
|
+
...quizNode.options!.map(() => ({
|
|
755
|
+
type: "element",
|
|
756
|
+
tagName: "td",
|
|
757
|
+
properties: { className: ["aimd-scale__cell"] },
|
|
758
|
+
children: [{ type: "text", value: "○" }],
|
|
759
|
+
} as Element)),
|
|
760
|
+
],
|
|
761
|
+
} as Element)),
|
|
762
|
+
} as Element,
|
|
763
|
+
],
|
|
764
|
+
} as Element]
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function buildScaleBandChildren(quizNode: AimdQuizNode): Array<Element | HastText> {
|
|
768
|
+
const bands = Array.isArray((quizNode.grading as any)?.bands)
|
|
769
|
+
? (quizNode.grading as any).bands
|
|
770
|
+
: []
|
|
771
|
+
if (bands.length === 0) {
|
|
772
|
+
return []
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return [{
|
|
776
|
+
type: "element",
|
|
777
|
+
tagName: "ul",
|
|
778
|
+
properties: { className: ["aimd-scale__bands"] },
|
|
779
|
+
children: bands.map((band: any) => ({
|
|
780
|
+
type: "element",
|
|
781
|
+
tagName: "li",
|
|
782
|
+
properties: {},
|
|
783
|
+
children: [{
|
|
784
|
+
type: "text",
|
|
785
|
+
value: `${band.min}-${band.max}: ${band.label}${band.interpretation ? ` · ${band.interpretation}` : ""}`,
|
|
786
|
+
}],
|
|
787
|
+
} as Element)),
|
|
788
|
+
} as Element]
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ---------------------------------------------------------------------------
|
|
792
|
+
// AIMD handler (remark-rehype custom handler)
|
|
793
|
+
// ---------------------------------------------------------------------------
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Custom handler for AIMD nodes in remark-rehype
|
|
797
|
+
* Converts MDAST AIMD nodes to HAST elements
|
|
798
|
+
*/
|
|
799
|
+
function createAimdHandler(options: AimdRendererOptions = {}) {
|
|
800
|
+
const quizPreview = resolveQuizPreviewOptions(options.mode ?? "preview", options.quizPreview)
|
|
801
|
+
const messages = createAimdRendererMessages(options.locale, options.messages)
|
|
802
|
+
const htmlRendererContext: AimdHtmlRendererContext = {
|
|
803
|
+
mode: options.mode ?? "preview",
|
|
804
|
+
readonly: false,
|
|
805
|
+
value: undefined,
|
|
806
|
+
quizPreview: options.quizPreview,
|
|
807
|
+
locale: options.locale ?? "en-US",
|
|
808
|
+
messages,
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return function aimdHandler(state: any, node: AimdNode): Element {
|
|
812
|
+
// Build full node data including step hierarchy
|
|
813
|
+
const nodeData: Record<string, unknown> = {
|
|
814
|
+
type: node.type,
|
|
815
|
+
fieldType: node.fieldType,
|
|
816
|
+
id: node.id,
|
|
817
|
+
scope: node.scope,
|
|
818
|
+
raw: node.raw,
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Add type-specific fields
|
|
822
|
+
if ("definition" in node)
|
|
823
|
+
nodeData.definition = node.definition
|
|
824
|
+
if ("columns" in node)
|
|
825
|
+
nodeData.columns = node.columns
|
|
826
|
+
if ("label" in node)
|
|
827
|
+
nodeData.label = node.label
|
|
828
|
+
if ("refTarget" in node)
|
|
829
|
+
nodeData.refTarget = node.refTarget
|
|
830
|
+
if ("checked_message" in node)
|
|
831
|
+
nodeData.checked_message = node.checked_message
|
|
832
|
+
|
|
833
|
+
// Add quiz-specific fields
|
|
834
|
+
if (node.fieldType === "quiz") {
|
|
835
|
+
const quizNode = node as AimdQuizNode
|
|
836
|
+
nodeData.quizType = quizNode.quizType
|
|
837
|
+
nodeData.stem = quizNode.stem
|
|
838
|
+
nodeData.score = quizNode.score
|
|
839
|
+
nodeData.title = quizNode.title
|
|
840
|
+
nodeData.description = quizNode.description
|
|
841
|
+
nodeData.mode = quizNode.mode
|
|
842
|
+
nodeData.display = quizNode.display
|
|
843
|
+
nodeData.options = quizNode.options
|
|
844
|
+
nodeData.answer = quizNode.answer
|
|
845
|
+
nodeData.blanks = quizNode.blanks
|
|
846
|
+
nodeData.items = quizNode.items
|
|
847
|
+
nodeData.rubric = quizNode.rubric
|
|
848
|
+
nodeData.grading = quizNode.grading
|
|
849
|
+
nodeData.default = quizNode.default
|
|
850
|
+
nodeData.extra = quizNode.extra
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Add fig-specific fields
|
|
854
|
+
if (node.fieldType === "fig") {
|
|
855
|
+
const figNode = node as any
|
|
856
|
+
nodeData.id = figNode.id
|
|
857
|
+
nodeData.src = figNode.src
|
|
858
|
+
nodeData.title = figNode.title
|
|
859
|
+
nodeData.legend = figNode.legend
|
|
860
|
+
nodeData.sequence = figNode.sequence
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Add step-specific fields
|
|
864
|
+
if (node.fieldType === "step") {
|
|
865
|
+
const stepNode = node as AimdStepNode
|
|
866
|
+
nodeData.level = stepNode.level
|
|
867
|
+
nodeData.sequence = stepNode.sequence
|
|
868
|
+
nodeData.step = stepNode.step
|
|
869
|
+
nodeData.parent_id = stepNode.parent_id
|
|
870
|
+
nodeData.prev_id = stepNode.prev_id
|
|
871
|
+
nodeData.next_id = stepNode.next_id
|
|
872
|
+
nodeData.has_children = stepNode.has_children
|
|
873
|
+
nodeData.check = stepNode.check
|
|
874
|
+
nodeData.title = stepNode.title
|
|
875
|
+
nodeData.subtitle = stepNode.subtitle
|
|
876
|
+
nodeData.checked_message = stepNode.checked_message
|
|
877
|
+
nodeData.estimated_duration_ms = stepNode.estimated_duration_ms
|
|
878
|
+
nodeData.timer_mode = stepNode.timer_mode
|
|
879
|
+
nodeData.result = stepNode.result
|
|
880
|
+
nodeData.props = stepNode.props
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Serialize AIMD node data to JSON for preservation through rehypeRaw
|
|
884
|
+
const aimdJson = JSON.stringify(nodeData)
|
|
885
|
+
|
|
886
|
+
const fieldType = node.fieldType
|
|
887
|
+
const id = node.id
|
|
888
|
+
const typeClass = getFieldTypeClass(fieldType)
|
|
889
|
+
|
|
890
|
+
// Determine if this is a reference type
|
|
891
|
+
const isRef = fieldType === "ref_step" || fieldType === "ref_var" || fieldType === "ref_fig"
|
|
892
|
+
const isCite = fieldType === "cite"
|
|
893
|
+
const isQuiz = fieldType === "quiz"
|
|
894
|
+
const isFig = fieldType === "fig"
|
|
895
|
+
const baseClass = isRef
|
|
896
|
+
? "aimd-ref"
|
|
897
|
+
: (isCite ? "aimd-cite" : (isFig ? "aimd-figure" : "aimd-field"))
|
|
898
|
+
const modifierClass = isRef
|
|
899
|
+
? `aimd-ref--${fieldType === "ref_step" ? "step" : (fieldType === "ref_var" ? "var" : "fig")}`
|
|
900
|
+
: (isCite ? "" : (isFig ? "" : `aimd-field--${typeClass}`))
|
|
901
|
+
|
|
902
|
+
// Generate children based on field type
|
|
903
|
+
const children: (Element | HastText)[] = []
|
|
904
|
+
|
|
905
|
+
if (isRef) {
|
|
906
|
+
// Reference: blockquote-style with appropriate content
|
|
907
|
+
if (fieldType === "ref_step") {
|
|
908
|
+
// Step reference: render with step-like field styling, then patch localized sequence later.
|
|
909
|
+
children.push({
|
|
910
|
+
type: "element",
|
|
911
|
+
tagName: "span",
|
|
912
|
+
properties: { className: ["aimd-ref__content"] },
|
|
913
|
+
children: [
|
|
914
|
+
{
|
|
915
|
+
type: "element",
|
|
916
|
+
tagName: "span",
|
|
917
|
+
properties: { className: ["aimd-field", "aimd-field--step", "aimd-field--readonly"] },
|
|
918
|
+
children: [{
|
|
919
|
+
type: "element",
|
|
920
|
+
tagName: "span",
|
|
921
|
+
properties: { className: ["research-step__sequence"] },
|
|
922
|
+
children: [{ type: "text", value: id }],
|
|
923
|
+
} as Element],
|
|
924
|
+
} as Element,
|
|
925
|
+
],
|
|
926
|
+
} as Element)
|
|
927
|
+
}
|
|
928
|
+
else {
|
|
929
|
+
// Variable or figure reference: show as field with scope + id
|
|
930
|
+
const scopeLabel = fieldType === "ref_var" ? messages.scope.var : messages.scope.figure
|
|
931
|
+
children.push({
|
|
932
|
+
type: "element",
|
|
933
|
+
tagName: "span",
|
|
934
|
+
properties: { className: ["aimd-ref__content"] },
|
|
935
|
+
children: [
|
|
936
|
+
{
|
|
937
|
+
type: "element",
|
|
938
|
+
tagName: "span",
|
|
939
|
+
properties: { className: ["aimd-field", "aimd-field--var"] },
|
|
940
|
+
children: [
|
|
941
|
+
{
|
|
942
|
+
type: "element",
|
|
943
|
+
tagName: "span",
|
|
944
|
+
properties: { className: ["aimd-field__scope"] },
|
|
945
|
+
children: [{ type: "text", value: scopeLabel }],
|
|
946
|
+
} as Element,
|
|
947
|
+
{
|
|
948
|
+
type: "element",
|
|
949
|
+
tagName: "span",
|
|
950
|
+
properties: { className: ["aimd-field__name"] },
|
|
951
|
+
children: [{ type: "text", value: id }],
|
|
952
|
+
} as Element,
|
|
953
|
+
],
|
|
954
|
+
} as Element,
|
|
955
|
+
],
|
|
956
|
+
} as Element)
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
else if (isCite) {
|
|
960
|
+
// Citation: [refs]
|
|
961
|
+
const refs = "refs" in node ? (node as any).refs : [id]
|
|
962
|
+
children.push({
|
|
963
|
+
type: "element",
|
|
964
|
+
tagName: "span",
|
|
965
|
+
properties: { className: ["aimd-cite__refs"] },
|
|
966
|
+
children: [{ type: "text", value: `[${refs.join(", ")}]` }],
|
|
967
|
+
} as Element)
|
|
968
|
+
}
|
|
969
|
+
else if (fieldType === "var") {
|
|
970
|
+
// Variable: type label + id + optional type annotation
|
|
971
|
+
const definition = "definition" in node ? node.definition : undefined
|
|
972
|
+
children.push(
|
|
973
|
+
{
|
|
974
|
+
type: "element",
|
|
975
|
+
tagName: "span",
|
|
976
|
+
properties: { className: ["aimd-field__scope"] },
|
|
977
|
+
children: [{ type: "text", value: getAimdRendererScopeLabel("var", messages) }],
|
|
978
|
+
} as Element,
|
|
979
|
+
{
|
|
980
|
+
type: "element",
|
|
981
|
+
tagName: "span",
|
|
982
|
+
properties: { className: ["aimd-field__name"] },
|
|
983
|
+
children: [{ type: "text", value: id }],
|
|
984
|
+
} as Element,
|
|
985
|
+
)
|
|
986
|
+
if (definition?.type) {
|
|
987
|
+
children.push({
|
|
988
|
+
type: "element",
|
|
989
|
+
tagName: "span",
|
|
990
|
+
properties: { className: ["aimd-field__type"] },
|
|
991
|
+
children: [{ type: "text", value: `: ${definition.type}` }],
|
|
992
|
+
} as Element)
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
else if (fieldType === "var_table") {
|
|
996
|
+
// var_table: render header + table preview
|
|
997
|
+
const columns = "columns" in node ? (node as any).columns as string[] : []
|
|
998
|
+
children.push(
|
|
999
|
+
{
|
|
1000
|
+
type: "element",
|
|
1001
|
+
tagName: "div",
|
|
1002
|
+
properties: { className: ["aimd-field__header"] },
|
|
1003
|
+
children: [
|
|
1004
|
+
{
|
|
1005
|
+
type: "element",
|
|
1006
|
+
tagName: "span",
|
|
1007
|
+
properties: { className: ["aimd-field__scope"] },
|
|
1008
|
+
children: [{ type: "text", value: getAimdRendererScopeLabel("var_table", messages) }],
|
|
1009
|
+
} as Element,
|
|
1010
|
+
{
|
|
1011
|
+
type: "element",
|
|
1012
|
+
tagName: "span",
|
|
1013
|
+
properties: { className: ["aimd-field__name"] },
|
|
1014
|
+
children: [{ type: "text", value: id }],
|
|
1015
|
+
} as Element,
|
|
1016
|
+
],
|
|
1017
|
+
} as Element,
|
|
1018
|
+
)
|
|
1019
|
+
if (columns && columns.length > 0) {
|
|
1020
|
+
children.push({
|
|
1021
|
+
type: "element",
|
|
1022
|
+
tagName: "table",
|
|
1023
|
+
properties: { className: ["aimd-field__table-preview"] },
|
|
1024
|
+
children: [
|
|
1025
|
+
{
|
|
1026
|
+
type: "element",
|
|
1027
|
+
tagName: "thead",
|
|
1028
|
+
properties: {},
|
|
1029
|
+
children: [
|
|
1030
|
+
{
|
|
1031
|
+
type: "element",
|
|
1032
|
+
tagName: "tr",
|
|
1033
|
+
properties: {},
|
|
1034
|
+
children: columns.map(col => ({
|
|
1035
|
+
type: "element",
|
|
1036
|
+
tagName: "th",
|
|
1037
|
+
properties: {},
|
|
1038
|
+
children: [{ type: "text", value: col }],
|
|
1039
|
+
} as Element)),
|
|
1040
|
+
} as Element,
|
|
1041
|
+
],
|
|
1042
|
+
} as Element,
|
|
1043
|
+
{
|
|
1044
|
+
type: "element",
|
|
1045
|
+
tagName: "tbody",
|
|
1046
|
+
properties: {},
|
|
1047
|
+
children: [
|
|
1048
|
+
{
|
|
1049
|
+
type: "element",
|
|
1050
|
+
tagName: "tr",
|
|
1051
|
+
properties: {},
|
|
1052
|
+
children: columns.map(() => ({
|
|
1053
|
+
type: "element",
|
|
1054
|
+
tagName: "td",
|
|
1055
|
+
properties: {},
|
|
1056
|
+
children: [{ type: "text", value: "..." }],
|
|
1057
|
+
} as Element)),
|
|
1058
|
+
} as Element,
|
|
1059
|
+
],
|
|
1060
|
+
} as Element,
|
|
1061
|
+
],
|
|
1062
|
+
} as Element)
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
else if (fieldType === "step") {
|
|
1066
|
+
// Step: scope label + sequence + id
|
|
1067
|
+
const stepNode = node as AimdStepNode
|
|
1068
|
+
const stepNum = stepNode.step || "1"
|
|
1069
|
+
|
|
1070
|
+
children.push(
|
|
1071
|
+
{
|
|
1072
|
+
type: "element",
|
|
1073
|
+
tagName: "span",
|
|
1074
|
+
properties: { className: ["aimd-field__scope"] },
|
|
1075
|
+
children: [{ type: "text", value: getAimdRendererScopeLabel("step", messages) }],
|
|
1076
|
+
} as Element,
|
|
1077
|
+
{
|
|
1078
|
+
type: "element",
|
|
1079
|
+
tagName: "span",
|
|
1080
|
+
properties: { className: ["aimd-field__step-num"] },
|
|
1081
|
+
children: [{ type: "text", value: stepNum }],
|
|
1082
|
+
} as Element,
|
|
1083
|
+
{
|
|
1084
|
+
type: "element",
|
|
1085
|
+
tagName: "span",
|
|
1086
|
+
properties: { className: ["aimd-field__name"] },
|
|
1087
|
+
children: [{ type: "text", value: id }],
|
|
1088
|
+
} as Element,
|
|
1089
|
+
)
|
|
1090
|
+
}
|
|
1091
|
+
else if (fieldType === "check") {
|
|
1092
|
+
// Check: checkbox + label
|
|
1093
|
+
const label = "label" in node ? node.label : id
|
|
1094
|
+
children.push(
|
|
1095
|
+
{
|
|
1096
|
+
type: "element",
|
|
1097
|
+
tagName: "input",
|
|
1098
|
+
properties: { type: "checkbox", className: ["aimd-checkbox"], disabled: true },
|
|
1099
|
+
children: [],
|
|
1100
|
+
} as Element,
|
|
1101
|
+
{
|
|
1102
|
+
type: "element",
|
|
1103
|
+
tagName: "span",
|
|
1104
|
+
properties: { className: ["aimd-field__label"] },
|
|
1105
|
+
children: [{ type: "text", value: label || id }],
|
|
1106
|
+
} as Element,
|
|
1107
|
+
)
|
|
1108
|
+
}
|
|
1109
|
+
else if (fieldType === "quiz") {
|
|
1110
|
+
const quizNode = node as AimdQuizNode
|
|
1111
|
+
const quizType = quizNode.quizType
|
|
1112
|
+
const typeLabel = getAimdRendererQuizTypeLabel(quizType, quizNode.mode, messages)
|
|
1113
|
+
|
|
1114
|
+
children.push(
|
|
1115
|
+
{
|
|
1116
|
+
type: "element",
|
|
1117
|
+
tagName: "div",
|
|
1118
|
+
properties: { className: ["aimd-quiz__meta"] },
|
|
1119
|
+
children: [
|
|
1120
|
+
{
|
|
1121
|
+
type: "element",
|
|
1122
|
+
tagName: "span",
|
|
1123
|
+
properties: { className: ["aimd-field__scope"] },
|
|
1124
|
+
children: [{ type: "text", value: getAimdRendererScopeLabel("quiz", messages) }],
|
|
1125
|
+
} as Element,
|
|
1126
|
+
{
|
|
1127
|
+
type: "element",
|
|
1128
|
+
tagName: "span",
|
|
1129
|
+
properties: { className: ["aimd-field__name"] },
|
|
1130
|
+
children: [{ type: "text", value: id }],
|
|
1131
|
+
} as Element,
|
|
1132
|
+
{
|
|
1133
|
+
type: "element",
|
|
1134
|
+
tagName: "span",
|
|
1135
|
+
properties: { className: ["aimd-field__type"] },
|
|
1136
|
+
children: [{ type: "text", value: `(${typeLabel})` }],
|
|
1137
|
+
} as Element,
|
|
1138
|
+
...(quizNode.score !== undefined
|
|
1139
|
+
? [{
|
|
1140
|
+
type: "element",
|
|
1141
|
+
tagName: "span",
|
|
1142
|
+
properties: { className: ["aimd-quiz__score"] },
|
|
1143
|
+
children: [{ type: "text", value: messages.quiz.score(quizNode.score) }],
|
|
1144
|
+
} as Element]
|
|
1145
|
+
: []),
|
|
1146
|
+
],
|
|
1147
|
+
} as Element,
|
|
1148
|
+
{
|
|
1149
|
+
type: "element",
|
|
1150
|
+
tagName: "div",
|
|
1151
|
+
properties: { className: ["aimd-quiz__stem"] },
|
|
1152
|
+
children: buildQuizStemChildren(quizType, quizNode.stem),
|
|
1153
|
+
} as Element,
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
if (typeof quizNode.title === "string" && quizNode.title.trim()) {
|
|
1157
|
+
children.splice(1, 0, {
|
|
1158
|
+
type: "element",
|
|
1159
|
+
tagName: "div",
|
|
1160
|
+
properties: { className: ["aimd-quiz__title"] },
|
|
1161
|
+
children: [{ type: "text", value: quizNode.title }],
|
|
1162
|
+
} as Element)
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if (typeof quizNode.description === "string" && quizNode.description.trim()) {
|
|
1166
|
+
children.push({
|
|
1167
|
+
type: "element",
|
|
1168
|
+
tagName: "div",
|
|
1169
|
+
properties: { className: ["aimd-quiz__description"] },
|
|
1170
|
+
children: [{ type: "text", value: quizNode.description }],
|
|
1171
|
+
} as Element)
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
if (quizType === "choice" && Array.isArray(quizNode.options) && quizNode.options.length > 0) {
|
|
1175
|
+
children.push({
|
|
1176
|
+
type: "element",
|
|
1177
|
+
tagName: "ul",
|
|
1178
|
+
properties: { className: ["aimd-quiz__options"] },
|
|
1179
|
+
children: quizNode.options.map(option => ({
|
|
1180
|
+
type: "element",
|
|
1181
|
+
tagName: "li",
|
|
1182
|
+
properties: {},
|
|
1183
|
+
children: [{ type: "text", value: `${option.key}. ${option.text}` }],
|
|
1184
|
+
} as Element)),
|
|
1185
|
+
} as Element)
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (quizType === "scale") {
|
|
1189
|
+
children.push(...buildScalePreviewChildren(quizNode))
|
|
1190
|
+
children.push(...buildScaleBandChildren(quizNode))
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (quizPreview.showAnswers && quizType === "choice" && quizNode.answer !== undefined) {
|
|
1194
|
+
const answerText = Array.isArray(quizNode.answer)
|
|
1195
|
+
? quizNode.answer.join(", ")
|
|
1196
|
+
: String(quizNode.answer)
|
|
1197
|
+
if (answerText.trim()) {
|
|
1198
|
+
children.push({
|
|
1199
|
+
type: "element",
|
|
1200
|
+
tagName: "div",
|
|
1201
|
+
properties: { className: ["aimd-quiz__answer"] },
|
|
1202
|
+
children: [{ type: "text", value: messages.quiz.answer(answerText) }],
|
|
1203
|
+
} as Element)
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (quizPreview.showAnswers && quizType === "blank" && Array.isArray(quizNode.blanks) && quizNode.blanks.length > 0) {
|
|
1208
|
+
children.push({
|
|
1209
|
+
type: "element",
|
|
1210
|
+
tagName: "ul",
|
|
1211
|
+
properties: { className: ["aimd-quiz__blanks"] },
|
|
1212
|
+
children: quizNode.blanks.map(blank => ({
|
|
1213
|
+
type: "element",
|
|
1214
|
+
tagName: "li",
|
|
1215
|
+
properties: {},
|
|
1216
|
+
children: [{ type: "text", value: `${blank.key}: ${blank.answer}` }],
|
|
1217
|
+
} as Element)),
|
|
1218
|
+
} as Element)
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
if (quizPreview.showRubric && quizType === "open" && typeof quizNode.rubric === "string" && quizNode.rubric.trim()) {
|
|
1222
|
+
children.push({
|
|
1223
|
+
type: "element",
|
|
1224
|
+
tagName: "div",
|
|
1225
|
+
properties: { className: ["aimd-quiz__rubric"] },
|
|
1226
|
+
children: [{ type: "text", value: messages.quiz.rubric(quizNode.rubric) }],
|
|
1227
|
+
} as Element)
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
else if (fieldType === "fig") {
|
|
1231
|
+
// Figure: delegate to figureNumbering module
|
|
1232
|
+
const figNode = node as any
|
|
1233
|
+
children.push(...buildFigureChildren({
|
|
1234
|
+
id: figNode.id || id,
|
|
1235
|
+
src: figNode.src,
|
|
1236
|
+
title: figNode.title,
|
|
1237
|
+
legend: figNode.legend,
|
|
1238
|
+
sequence: figNode.sequence,
|
|
1239
|
+
}))
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Build properties
|
|
1243
|
+
const properties: Properties = {
|
|
1244
|
+
"className": [baseClass, modifierClass],
|
|
1245
|
+
"data-aimd-type": node.fieldType,
|
|
1246
|
+
"data-aimd-id": node.id,
|
|
1247
|
+
"data-aimd-scope": node.scope,
|
|
1248
|
+
"data-aimd-raw": node.raw,
|
|
1249
|
+
"data-aimd-json": aimdJson,
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Add reference href
|
|
1253
|
+
if (isRef) {
|
|
1254
|
+
if (fieldType === "ref_step") {
|
|
1255
|
+
properties.href = `#step-${id}`
|
|
1256
|
+
}
|
|
1257
|
+
else if (fieldType === "ref_var") {
|
|
1258
|
+
properties.href = `#var-${id}`
|
|
1259
|
+
}
|
|
1260
|
+
else if (fieldType === "ref_fig") {
|
|
1261
|
+
properties.href = `#fig-${id}`
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Add step-specific properties
|
|
1266
|
+
if (node.fieldType === "step") {
|
|
1267
|
+
const stepNode = node as AimdStepNode
|
|
1268
|
+
properties["data-aimd-step"] = stepNode.step
|
|
1269
|
+
properties["data-aimd-level"] = stepNode.level
|
|
1270
|
+
properties["data-aimd-check"] = stepNode.check ? "true" : "false"
|
|
1271
|
+
if (options.groupStepBodies) {
|
|
1272
|
+
properties["data-aimd-step-container"] = "true"
|
|
1273
|
+
}
|
|
1274
|
+
if (stepNode.title) {
|
|
1275
|
+
properties["data-aimd-title"] = stepNode.title
|
|
1276
|
+
}
|
|
1277
|
+
if (stepNode.subtitle) {
|
|
1278
|
+
properties["data-aimd-subtitle"] = stepNode.subtitle
|
|
1279
|
+
}
|
|
1280
|
+
if (stepNode.checked_message) {
|
|
1281
|
+
properties["data-aimd-checked-message"] = stepNode.checked_message
|
|
1282
|
+
}
|
|
1283
|
+
if (typeof stepNode.estimated_duration_ms === "number") {
|
|
1284
|
+
properties["data-aimd-estimated-duration-ms"] = stepNode.estimated_duration_ms
|
|
1285
|
+
}
|
|
1286
|
+
if (stepNode.timer_mode) {
|
|
1287
|
+
properties["data-aimd-timer-mode"] = stepNode.timer_mode
|
|
1288
|
+
}
|
|
1289
|
+
if (stepNode.result) {
|
|
1290
|
+
properties["data-aimd-result"] = "true"
|
|
1291
|
+
}
|
|
1292
|
+
properties.id = `step-${id}`
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// Add check id
|
|
1296
|
+
if (node.fieldType === "check") {
|
|
1297
|
+
properties.id = `check-${id}`
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// Add var id
|
|
1301
|
+
if (node.fieldType === "var") {
|
|
1302
|
+
properties.id = `var-${id}`
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// Add var_table id
|
|
1306
|
+
if (node.fieldType === "var_table") {
|
|
1307
|
+
properties.id = `var_table-${id}`
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Add quiz id
|
|
1311
|
+
if (node.fieldType === "quiz") {
|
|
1312
|
+
properties.id = `quiz-${id}`
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Add fig id
|
|
1316
|
+
if (node.fieldType === "fig") {
|
|
1317
|
+
const figNode = node as any
|
|
1318
|
+
properties.id = `fig-${figNode.id || id}`
|
|
1319
|
+
properties["data-aimd-fig-id"] = figNode.id
|
|
1320
|
+
properties["data-aimd-fig-src"] = figNode.src
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Add quiz metadata for fallback reconstruction
|
|
1324
|
+
if (node.fieldType === "quiz") {
|
|
1325
|
+
const quizNode = node as AimdQuizNode
|
|
1326
|
+
properties["data-aimd-quiz-type"] = quizNode.quizType
|
|
1327
|
+
properties["data-aimd-quiz-stem"] = quizNode.stem
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const isGroupedStepContainer = fieldType === "step" && options.groupStepBodies
|
|
1331
|
+
const element: Element = assignAimdNodeData({
|
|
1332
|
+
type: "element",
|
|
1333
|
+
tagName: isRef
|
|
1334
|
+
? "a"
|
|
1335
|
+
: (isFig ? "figure" : ((fieldType === "var_table" || isQuiz || isGroupedStepContainer) ? "div" : "span")),
|
|
1336
|
+
properties,
|
|
1337
|
+
children,
|
|
1338
|
+
}, node)
|
|
1339
|
+
|
|
1340
|
+
const customRenderer = options.aimdElementRenderers?.[node.fieldType]
|
|
1341
|
+
if (customRenderer) {
|
|
1342
|
+
const customElement = customRenderer(node, element, htmlRendererContext)
|
|
1343
|
+
if (customElement) {
|
|
1344
|
+
return assignAimdNodeData(customElement, node)
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
return element
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// ---------------------------------------------------------------------------
|
|
1353
|
+
// Processor pipeline
|
|
1354
|
+
// ---------------------------------------------------------------------------
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Create base processor
|
|
1358
|
+
*/
|
|
1359
|
+
function createBaseProcessor(options: AimdRendererOptions = {}) {
|
|
1360
|
+
const { gfm = true, math = true, breaks = true } = options
|
|
1361
|
+
|
|
1362
|
+
const processor = unified()
|
|
1363
|
+
.use(remarkParse)
|
|
1364
|
+
|
|
1365
|
+
// GFM support (tables, strikethrough, task lists, etc.)
|
|
1366
|
+
if (gfm) {
|
|
1367
|
+
processor.use(remarkGfm)
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// Math formula support
|
|
1371
|
+
if (math) {
|
|
1372
|
+
processor.use(remarkMath)
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
processor.use(remarkInsertVisibleAssigners, options)
|
|
1376
|
+
|
|
1377
|
+
// AIMD syntax support - MUST run before remarkBreaks
|
|
1378
|
+
// to properly parse multiline AIMD syntax like var_table with subvars
|
|
1379
|
+
processor.use(remarkAimd)
|
|
1380
|
+
processor.use(remarkStripAssignerCodeBlocks)
|
|
1381
|
+
|
|
1382
|
+
// Single line break to <br> conversion (default enabled for AIMD)
|
|
1383
|
+
if (breaks) {
|
|
1384
|
+
processor.use(remarkBreaks)
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
return processor
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/**
|
|
1391
|
+
* Create HTML output processor
|
|
1392
|
+
*/
|
|
1393
|
+
export function createHtmlProcessor(options: AimdRendererOptions = {}) {
|
|
1394
|
+
const { math = true, sanitize = true } = options
|
|
1395
|
+
const aimdHandler = createAimdHandler(options)
|
|
1396
|
+
|
|
1397
|
+
const processor = createBaseProcessor(options)
|
|
1398
|
+
.use(remarkRehype, {
|
|
1399
|
+
allowDangerousHtml: true,
|
|
1400
|
+
handlers: {
|
|
1401
|
+
// Custom handler for AIMD nodes
|
|
1402
|
+
aimd: aimdHandler,
|
|
1403
|
+
},
|
|
1404
|
+
} as any)
|
|
1405
|
+
.use(rehypeRaw)
|
|
1406
|
+
|
|
1407
|
+
// Math formula rendering
|
|
1408
|
+
if (math) {
|
|
1409
|
+
processor.use(rehypeKatex)
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
return processor
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// ---------------------------------------------------------------------------
|
|
1416
|
+
// Public render functions
|
|
1417
|
+
// ---------------------------------------------------------------------------
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* Render Markdown/AIMD to HTML string
|
|
1421
|
+
*/
|
|
1422
|
+
export async function renderToHtml(
|
|
1423
|
+
content: string,
|
|
1424
|
+
options: AimdRendererOptions = {},
|
|
1425
|
+
): Promise<{ html: string, fields: ExtractedAimdFields }> {
|
|
1426
|
+
await ensureMathStylesLoaded(options.math)
|
|
1427
|
+
const processor = createHtmlProcessor(options)
|
|
1428
|
+
|
|
1429
|
+
const { content: protectedContent, file } = createAimdParseInput(content)
|
|
1430
|
+
const tree = processor.parse(protectedContent)
|
|
1431
|
+
const hastTree = await processor.run(tree, file) as HastRoot
|
|
1432
|
+
|
|
1433
|
+
const fields = getExtractedFields(file)
|
|
1434
|
+
annotateStepReferenceSequence(hastTree, fields, options)
|
|
1435
|
+
assignFigureSequenceNumbers(hastTree, fields)
|
|
1436
|
+
liftBlockVarElements(hastTree, options.blockVarTypes)
|
|
1437
|
+
if (options.groupStepBodies) {
|
|
1438
|
+
groupStepBodies(hastTree)
|
|
1439
|
+
}
|
|
1440
|
+
if (options.groupCheckBodies) {
|
|
1441
|
+
groupCheckBodies(hastTree)
|
|
1442
|
+
}
|
|
1443
|
+
await highlightVisibleAssigners(hastTree, options)
|
|
1444
|
+
const html = toHtml(hastTree, { allowDangerousHtml: true })
|
|
1445
|
+
|
|
1446
|
+
return { html, fields }
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Render Markdown/AIMD to Vue VNodes
|
|
1451
|
+
*/
|
|
1452
|
+
export async function renderToVue(
|
|
1453
|
+
content: string,
|
|
1454
|
+
options: AimdRendererOptions & VueRendererOptions = {},
|
|
1455
|
+
): Promise<RenderResult> {
|
|
1456
|
+
await ensureMathStylesLoaded(options.math)
|
|
1457
|
+
const processor = createHtmlProcessor(options)
|
|
1458
|
+
|
|
1459
|
+
const { content: protectedContent, file } = createAimdParseInput(content)
|
|
1460
|
+
const tree = processor.parse(protectedContent)
|
|
1461
|
+
const hastTree = await processor.run(tree, file) as HastRoot
|
|
1462
|
+
|
|
1463
|
+
const fields = getExtractedFields(file)
|
|
1464
|
+
annotateStepReferenceSequence(hastTree, fields, options)
|
|
1465
|
+
assignFigureSequenceNumbers(hastTree, fields)
|
|
1466
|
+
liftBlockVarElements(hastTree, options.blockVarTypes)
|
|
1467
|
+
if (options.groupStepBodies) {
|
|
1468
|
+
groupStepBodies(hastTree)
|
|
1469
|
+
}
|
|
1470
|
+
if (options.groupCheckBodies) {
|
|
1471
|
+
groupCheckBodies(hastTree)
|
|
1472
|
+
}
|
|
1473
|
+
await highlightVisibleAssigners(hastTree, options)
|
|
1474
|
+
const nodes = renderToVNodes(hastTree, options)
|
|
1475
|
+
|
|
1476
|
+
return { nodes, fields }
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* Parse Markdown/AIMD and extract field information (no rendering)
|
|
1481
|
+
*/
|
|
1482
|
+
export function parseAndExtract(content: string): ExtractedAimdFields {
|
|
1483
|
+
const processor = unified()
|
|
1484
|
+
.use(remarkParse)
|
|
1485
|
+
.use(remarkAimd)
|
|
1486
|
+
|
|
1487
|
+
const { content: protectedContent, file } = createAimdParseInput(content)
|
|
1488
|
+
const tree = processor.parse(protectedContent)
|
|
1489
|
+
processor.runSync(tree, file)
|
|
1490
|
+
|
|
1491
|
+
return getExtractedFields(file)
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Synchronous render to HTML (for simple scenarios)
|
|
1496
|
+
*/
|
|
1497
|
+
export function renderToHtmlSync(
|
|
1498
|
+
content: string,
|
|
1499
|
+
options: AimdRendererOptions = {},
|
|
1500
|
+
): { html: string, fields: ExtractedAimdFields } {
|
|
1501
|
+
// Sync mode does not support KaTeX stylesheet loading, so keep math disabled here.
|
|
1502
|
+
const processor = createHtmlProcessor({ ...options, math: false })
|
|
1503
|
+
|
|
1504
|
+
const { content: protectedContent, file } = createAimdParseInput(content)
|
|
1505
|
+
const tree = processor.parse(protectedContent)
|
|
1506
|
+
const hastTree = processor.runSync(tree, file) as HastRoot
|
|
1507
|
+
|
|
1508
|
+
const fields = getExtractedFields(file)
|
|
1509
|
+
annotateStepReferenceSequence(hastTree, fields, options)
|
|
1510
|
+
assignFigureSequenceNumbers(hastTree, fields)
|
|
1511
|
+
liftBlockVarElements(hastTree, options.blockVarTypes)
|
|
1512
|
+
if (options.groupStepBodies) {
|
|
1513
|
+
groupStepBodies(hastTree)
|
|
1514
|
+
}
|
|
1515
|
+
const html = toHtml(hastTree, { allowDangerousHtml: true })
|
|
1516
|
+
|
|
1517
|
+
return { html, fields }
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* Create reusable renderer instance
|
|
1522
|
+
*/
|
|
1523
|
+
export function createRenderer(options: AimdRendererOptions = {}) {
|
|
1524
|
+
const processor = createHtmlProcessor(options)
|
|
1525
|
+
|
|
1526
|
+
return {
|
|
1527
|
+
/**
|
|
1528
|
+
* Render to HTML
|
|
1529
|
+
*/
|
|
1530
|
+
async toHtml(content: string): Promise<{ html: string, fields: ExtractedAimdFields }> {
|
|
1531
|
+
return renderToHtml(content, options)
|
|
1532
|
+
},
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* Render to Vue VNodes
|
|
1536
|
+
*/
|
|
1537
|
+
async toVue(
|
|
1538
|
+
content: string,
|
|
1539
|
+
renderOptions?: VueRendererOptions,
|
|
1540
|
+
): Promise<RenderResult> {
|
|
1541
|
+
return renderToVue(content, { ...options, ...renderOptions })
|
|
1542
|
+
},
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Extract fields only
|
|
1546
|
+
*/
|
|
1547
|
+
extractFields(content: string): ExtractedAimdFields {
|
|
1548
|
+
return parseAndExtract(content)
|
|
1549
|
+
},
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// Export default renderer
|
|
1554
|
+
export const defaultRenderer = createRenderer()
|