@airalogy/aimd-renderer 2.4.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +10 -5
  3. package/README.zh-CN.md +10 -5
  4. package/dist/__tests__/renderer.test.d.ts +2 -0
  5. package/dist/__tests__/renderer.test.d.ts.map +1 -0
  6. package/dist/aimd-renderer.css +1 -1
  7. package/dist/common/annotateStepReferences.d.ts +10 -0
  8. package/dist/common/annotateStepReferences.d.ts.map +1 -0
  9. package/dist/common/assignerHighlighting.d.ts +14 -0
  10. package/dist/common/assignerHighlighting.d.ts.map +1 -0
  11. package/dist/common/assignerVisibility.d.ts +33 -0
  12. package/dist/common/assignerVisibility.d.ts.map +1 -0
  13. package/dist/common/eventKeys.d.ts +20 -0
  14. package/dist/common/eventKeys.d.ts.map +1 -0
  15. package/dist/common/figureNumbering.d.ts +30 -0
  16. package/dist/common/figureNumbering.d.ts.map +1 -0
  17. package/dist/common/processor.d.ts +96 -0
  18. package/dist/common/processor.d.ts.map +1 -0
  19. package/dist/common/quiz-preview.d.ts +11 -0
  20. package/dist/common/quiz-preview.d.ts.map +1 -0
  21. package/dist/common/unified-token-renderer.d.ts +124 -0
  22. package/dist/common/unified-token-renderer.d.ts.map +1 -0
  23. package/dist/html/index.d.ts +9 -0
  24. package/dist/html/index.d.ts.map +1 -0
  25. package/dist/html.js +1 -1
  26. package/dist/index.d.ts +23 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +270 -225
  29. package/dist/locales.d.ts +52 -0
  30. package/dist/locales.d.ts.map +1 -0
  31. package/dist/{processor-Cv8E7QsA.js → processor-CHbNEcN8.js} +2977 -2212
  32. package/dist/vue/index.d.ts +10 -0
  33. package/dist/vue/index.d.ts.map +1 -0
  34. package/dist/vue/vue-renderer.d.ts +159 -0
  35. package/dist/vue/vue-renderer.d.ts.map +1 -0
  36. package/dist/vue.js +10 -9
  37. package/package.json +17 -17
  38. package/src/__tests__/renderer.test.ts +220 -2
  39. package/src/common/processor.ts +177 -43
  40. package/src/common/unified-token-renderer.ts +106 -26
  41. package/src/index.ts +3 -0
  42. package/src/locales.ts +5 -0
  43. package/src/styles/katex.css +109 -0
  44. package/src/vue/index.ts +3 -0
  45. package/src/vue/vue-renderer.ts +320 -50
@@ -1,6 +1,13 @@
1
1
  import type { Element, Root as HastRoot, Text as HastText, RootContent } from "hast"
2
2
  import type { Component, VNode, VNodeChild } from "vue"
3
3
  import type { AimdNode, AimdQuizNode, AimdStepNode, RenderContext } from "@airalogy/aimd-core/types"
4
+ import {
5
+ formatAimdExampleValue,
6
+ getAimdFieldDescription,
7
+ getAimdFieldDisplayLabel,
8
+ getAimdFieldExamples,
9
+ getAimdFieldTitle,
10
+ } from "@airalogy/aimd-core/utils"
4
11
  import { Fragment, h } from "vue"
5
12
  import type { AimdRendererI18nOptions, AimdRendererLocale, AimdRendererMessages } from "../locales"
6
13
  import { resolveQuizPreviewOptions, type ResolvedQuizPreviewOptions } from "../common/quiz-preview"
@@ -152,6 +159,74 @@ function buildScaleBandChildren(quizNode: AimdQuizNode): VNodeChild[] {
152
159
  ]
153
160
  }
154
161
 
162
+ interface FieldMetadataHelp {
163
+ tooltip: string
164
+ description?: string
165
+ examples: string[]
166
+ }
167
+
168
+ function getFieldHelpText(definition: { kwargs?: Record<string, unknown> } | undefined): FieldMetadataHelp {
169
+ const description = getAimdFieldDescription(definition)
170
+ const examples = getAimdFieldExamples(definition)
171
+ .map(formatAimdExampleValue)
172
+ .map(example => example.trim())
173
+ .filter(Boolean)
174
+ const exampleText = examples.length > 0 ? `e.g. ${examples.join(", ")}` : undefined
175
+ const tooltipLines = [description, exampleText].filter((value): value is string => Boolean(value))
176
+
177
+ return {
178
+ tooltip: tooltipLines.join("\n"),
179
+ description,
180
+ examples,
181
+ }
182
+ }
183
+
184
+ function renderFieldMetadataPopover(help: FieldMetadataHelp): VNode | null {
185
+ if (!help.description && help.examples.length === 0) {
186
+ return null
187
+ }
188
+ const children: VNode[] = []
189
+ if (help.description) {
190
+ children.push(h("span", {
191
+ class: "aimd-field__metadata-popover-line",
192
+ }, help.description))
193
+ }
194
+ if (help.examples.length > 0) {
195
+ children.push(h("span", { class: "aimd-field__metadata-examples" }, [
196
+ h("span", { class: "aimd-field__metadata-examples-label" }, "e.g."),
197
+ ...help.examples.map((example, index) => h("span", {
198
+ key: `${index}-${example}`,
199
+ class: "aimd-field__metadata-example",
200
+ }, example)),
201
+ ]))
202
+ }
203
+ return h("span", {
204
+ class: "aimd-field__metadata-popover",
205
+ role: "tooltip",
206
+ }, children)
207
+ }
208
+
209
+ function renderFieldName(id: string, definition: { kwargs?: Record<string, unknown> } | undefined): VNode {
210
+ const displayTitle = getAimdFieldDisplayLabel(id, definition)
211
+ const hasCustomTitle = getAimdFieldTitle(definition) !== undefined && displayTitle !== id
212
+ const help = getFieldHelpText(definition)
213
+ const hasHelp = Boolean(help.description) || help.examples.length > 0
214
+
215
+ return h("span", {
216
+ class: [
217
+ "aimd-field__name",
218
+ (hasCustomTitle || hasHelp) ? "aimd-field__name--with-metadata" : undefined,
219
+ hasHelp ? "aimd-field__metadata-host" : undefined,
220
+ ],
221
+ tabindex: hasHelp ? 0 : undefined,
222
+ "aria-label": help.tooltip || undefined,
223
+ }, [
224
+ h("span", { class: "aimd-field__title" }, displayTitle),
225
+ hasCustomTitle ? h("span", { class: "aimd-field__key" }, id) : null,
226
+ renderFieldMetadataPopover(help),
227
+ ])
228
+ }
229
+
155
230
  function isStepBodyVNode(node: unknown): node is VNode {
156
231
  if (!node || typeof node !== "object") {
157
232
  return false
@@ -209,11 +284,11 @@ const defaultAimdRenderers: Record<string, AimdComponentRenderer> = {
209
284
  "data-aimd-type": "var",
210
285
  "data-aimd-id": id,
211
286
  "data-aimd-scope": scope,
212
- }, [
213
- h("span", { class: "aimd-field__scope" }, getAimdRendererScopeLabel(scope, ctx.messages)),
214
- h("span", { class: "aimd-field__name" }, id),
215
- definition?.type ? h("span", { class: "aimd-field__type" }, `: ${definition.type}`) : null,
216
- ])
287
+ }, [
288
+ h("span", { class: "aimd-field__scope" }, getAimdRendererScopeLabel(scope, ctx.messages)),
289
+ renderFieldName(id, definition),
290
+ definition?.type ? h("span", { class: "aimd-field__type" }, `: ${definition.type}`) : null,
291
+ ])
217
292
  }
218
293
 
219
294
  // Edit mode - render as editable field with value display
@@ -236,24 +311,28 @@ const defaultAimdRenderers: Record<string, AimdComponentRenderer> = {
236
311
  },
237
312
 
238
313
  var_table: (node, ctx) => {
239
- const { id } = node
240
- const columns = "columns" in node ? node.columns : []
314
+ const { id } = node
315
+ const columns = "columns" in node ? node.columns : []
316
+ const definition = "definition" in node ? node.definition : undefined
317
+ const subvarDefs = definition?.subvars
241
318
 
242
- if (ctx.mode === "preview") {
319
+ if (ctx.mode === "preview") {
243
320
  // Preview mode: render tag with table preview inside
244
321
  const children: VNodeChild[] = [
245
- h("div", { class: "aimd-field__header" }, [
246
- h("span", { class: "aimd-field__scope" }, ctx.messages.scope.table),
247
- h("span", { class: "aimd-field__name" }, id),
248
- ]),
249
- ]
322
+ h("div", { class: "aimd-field__header" }, [
323
+ h("span", { class: "aimd-field__scope" }, ctx.messages.scope.table),
324
+ renderFieldName(id, definition),
325
+ ]),
326
+ ]
250
327
  // Add table preview inside the container
251
328
  if (columns && columns.length > 0) {
252
329
  children.push(
253
330
  h("table", { class: "aimd-field__table-preview" }, [
254
- h("thead", [
255
- h("tr", columns.map(col => h("th", col))),
256
- ]),
331
+ h("thead", [
332
+ h("tr", columns.map(col => h("th", {
333
+ "data-column-id": col,
334
+ }, [renderFieldName(col, subvarDefs?.[col])]))),
335
+ ]),
257
336
  h("tbody", [
258
337
  h("tr", columns.map(() => h("td", "..."))),
259
338
  ]),
@@ -298,7 +377,7 @@ const defaultAimdRenderers: Record<string, AimdComponentRenderer> = {
298
377
  : null,
299
378
  ]
300
379
 
301
- if (quizType === "choice" && Array.isArray(quizNode.options) && quizNode.options.length > 0) {
380
+ if ((quizType === "choice" || quizType === "true_false") && Array.isArray(quizNode.options) && quizNode.options.length > 0) {
302
381
  previewChildren.push(
303
382
  h("ul", { class: "aimd-quiz__options" }, quizNode.options.map(opt =>
304
383
  h("li", `${opt.key}. ${opt.text}`),
@@ -313,7 +392,7 @@ const defaultAimdRenderers: Record<string, AimdComponentRenderer> = {
313
392
 
314
393
  const quizPreview = resolveQuizPreviewOptionsFromContext(ctx)
315
394
 
316
- if (quizPreview.showAnswers && quizType === "choice" && quizNode.answer !== undefined) {
395
+ if (quizPreview.showAnswers && (quizType === "choice" || quizType === "true_false") && quizNode.answer !== undefined) {
317
396
  const answerText = Array.isArray(quizNode.answer)
318
397
  ? quizNode.answer.join(", ")
319
398
  : String(quizNode.answer)
@@ -1285,9 +1364,167 @@ export function createStepCardRenderer(
1285
1364
  */
1286
1365
  export interface ShikiHighlighter {
1287
1366
  codeToHtml: (code: string, options: { lang: string, theme: string }) => string
1367
+ codeToTokensBase?: (code: string, options: { lang: string, theme: string }) => Array<Array<{ content: string, color?: string, bgColor?: string, fontStyle?: number, htmlStyle?: string | Record<string, string> }>>
1288
1368
  codeToTokensWithThemes?: (code: string, options: { lang: string, themes: Record<string, string> }) => Array<Array<{ content: string, variants: Record<string, { color: string }> }>>
1289
1369
  }
1290
1370
 
1371
+ export interface CodeBlockRendererOptions {
1372
+ theme?: string
1373
+ lineNumbers?: boolean
1374
+ wrap?: boolean
1375
+ className?: string
1376
+ }
1377
+
1378
+ export interface LoadShikiHighlighterOptions {
1379
+ themes?: string[]
1380
+ langs?: string[]
1381
+ }
1382
+
1383
+ type CodeBlockToken = {
1384
+ content: string
1385
+ color?: string
1386
+ bgColor?: string
1387
+ fontStyle?: number
1388
+ htmlStyle?: string | Record<string, string>
1389
+ }
1390
+
1391
+ const DEFAULT_CODE_BLOCK_THEME = "github-light"
1392
+ const DEFAULT_CODE_HIGHLIGHTER_THEMES = [DEFAULT_CODE_BLOCK_THEME]
1393
+ const DEFAULT_CODE_HIGHLIGHTER_LANGS = [
1394
+ "bash",
1395
+ "css",
1396
+ "html",
1397
+ "javascript",
1398
+ "json",
1399
+ "jsonc",
1400
+ "markdown",
1401
+ "python",
1402
+ "shellscript",
1403
+ "sql",
1404
+ "toml",
1405
+ "typescript",
1406
+ "xml",
1407
+ "yaml",
1408
+ ]
1409
+ let defaultCodeHighlighterPromise: Promise<ShikiHighlighter> | null = null
1410
+
1411
+ export async function loadShikiHighlighter(
1412
+ options: LoadShikiHighlighterOptions = {},
1413
+ ): Promise<ShikiHighlighter> {
1414
+ const hasCustomOptions = Boolean(options.themes || options.langs)
1415
+ const create = async () => {
1416
+ const { createHighlighter } = await import("shiki")
1417
+ return createHighlighter({
1418
+ themes: (options.themes ?? DEFAULT_CODE_HIGHLIGHTER_THEMES) as never,
1419
+ langs: (options.langs ?? DEFAULT_CODE_HIGHLIGHTER_LANGS) as never,
1420
+ }) as Promise<unknown> as Promise<ShikiHighlighter>
1421
+ }
1422
+
1423
+ if (hasCustomOptions) {
1424
+ return create()
1425
+ }
1426
+
1427
+ defaultCodeHighlighterPromise ??= create()
1428
+ return defaultCodeHighlighterPromise
1429
+ }
1430
+
1431
+ function resolveCodeBlockRendererOptions(
1432
+ optionsOrTheme: string | CodeBlockRendererOptions | undefined,
1433
+ ): Required<CodeBlockRendererOptions> {
1434
+ if (typeof optionsOrTheme === "string") {
1435
+ return {
1436
+ theme: optionsOrTheme,
1437
+ lineNumbers: false,
1438
+ wrap: false,
1439
+ className: "",
1440
+ }
1441
+ }
1442
+
1443
+ return {
1444
+ theme: optionsOrTheme?.theme ?? DEFAULT_CODE_BLOCK_THEME,
1445
+ lineNumbers: optionsOrTheme?.lineNumbers ?? false,
1446
+ wrap: optionsOrTheme?.wrap ?? false,
1447
+ className: optionsOrTheme?.className ?? "",
1448
+ }
1449
+ }
1450
+
1451
+ function getCodeLanguage(codeNode: Element): string {
1452
+ const className = codeNode.properties?.className
1453
+ if (Array.isArray(className)) {
1454
+ const langClass = className.find(c => typeof c === "string" && c.startsWith("language-"))
1455
+ return typeof langClass === "string" ? langClass.replace("language-", "") : "text"
1456
+ }
1457
+
1458
+ return typeof className === "string" && className.startsWith("language-")
1459
+ ? className.replace("language-", "")
1460
+ : "text"
1461
+ }
1462
+
1463
+ function getCodeContent(codeNode: Element): string {
1464
+ return codeNode.children
1465
+ .map(child => (child.type === "text" ? child.value : ""))
1466
+ .join("")
1467
+ }
1468
+
1469
+ function normalizeCodeContent(code: string): string {
1470
+ return code.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n$/, "")
1471
+ }
1472
+
1473
+ function splitCodeLines(code: string): CodeBlockToken[][] {
1474
+ const lines = code.split(/\r\n|\r|\n/)
1475
+ return (lines.length ? lines : [""]).map(line => [{ content: line }])
1476
+ }
1477
+
1478
+ function getLineIndentColumns(tokens: CodeBlockToken[]): number {
1479
+ const line = tokens.map(token => token.content).join("")
1480
+ let columns = 0
1481
+ for (const char of line) {
1482
+ if (char === " ") {
1483
+ columns += 1
1484
+ }
1485
+ else if (char === "\t") {
1486
+ columns += 2
1487
+ }
1488
+ else {
1489
+ break
1490
+ }
1491
+ }
1492
+ return Math.min(columns, 24)
1493
+ }
1494
+
1495
+ function hasCodeTokenContent(tokens: CodeBlockToken[]): boolean {
1496
+ return tokens.some(token => token.content.length > 0)
1497
+ }
1498
+
1499
+ function getCodeTokenStyle(token: CodeBlockToken): string | Record<string, string> | undefined {
1500
+ if (token.htmlStyle) {
1501
+ return token.htmlStyle
1502
+ }
1503
+
1504
+ const style: Record<string, string> = {}
1505
+ if (token.color) {
1506
+ style.color = token.color
1507
+ }
1508
+ if (token.bgColor) {
1509
+ style.backgroundColor = token.bgColor
1510
+ }
1511
+ if (typeof token.fontStyle === "number") {
1512
+ if (token.fontStyle & 1) style.fontStyle = "italic"
1513
+ if (token.fontStyle & 2) style.fontWeight = "700"
1514
+ if (token.fontStyle & 4) style.textDecoration = "underline"
1515
+ }
1516
+
1517
+ return Object.keys(style).length > 0 ? style : undefined
1518
+ }
1519
+
1520
+ function renderLegacyPlainCodeBlock(lang: string, codeContent: string): VNode {
1521
+ return h("pre", {
1522
+ class: `language-${lang}`,
1523
+ }, h("code", {
1524
+ class: `language-${lang}`,
1525
+ }, codeContent))
1526
+ }
1527
+
1291
1528
  /**
1292
1529
  * Create code block element renderer with Shiki support
1293
1530
  * @param highlighter - Shiki highlighter instance (can be reactive ref)
@@ -1295,8 +1532,11 @@ export interface ShikiHighlighter {
1295
1532
  */
1296
1533
  export function createCodeBlockRenderer(
1297
1534
  highlighter: ShikiHighlighter | null | (() => ShikiHighlighter | null),
1298
- defaultTheme = "github-dark",
1535
+ optionsOrTheme: string | CodeBlockRendererOptions = DEFAULT_CODE_BLOCK_THEME,
1299
1536
  ): ElementRenderer {
1537
+ const options = resolveCodeBlockRendererOptions(optionsOrTheme)
1538
+ const useLegacyHtmlOutput = typeof optionsOrTheme === "string"
1539
+
1300
1540
  return (node, children, ctx) => {
1301
1541
  // Find code element inside pre
1302
1542
  const codeNode = node.children.find(
@@ -1307,39 +1547,38 @@ export function createCodeBlockRenderer(
1307
1547
  return h("pre", {}, children)
1308
1548
  }
1309
1549
 
1310
- // Get language from class
1311
- const className = codeNode.properties?.className
1312
- let lang = "text"
1313
- if (Array.isArray(className)) {
1314
- const langClass = className.find(c => typeof c === "string" && c.startsWith("language-"))
1315
- if (langClass && typeof langClass === "string") {
1316
- lang = langClass.replace("language-", "")
1550
+ const lang = getCodeLanguage(codeNode)
1551
+ const codeContent = normalizeCodeContent(getCodeContent(codeNode))
1552
+ const hl = typeof highlighter === "function" ? highlighter() : highlighter
1553
+ let tokenLines = splitCodeLines(codeContent)
1554
+
1555
+ if (useLegacyHtmlOutput) {
1556
+ if (hl?.codeToHtml) {
1557
+ try {
1558
+ const highlightedHtml = hl.codeToHtml(codeContent, {
1559
+ lang,
1560
+ theme: options.theme,
1561
+ })
1562
+
1563
+ return h("div", {
1564
+ "class": "shiki-code-block",
1565
+ "data-lang": lang,
1566
+ "innerHTML": highlightedHtml,
1567
+ })
1568
+ }
1569
+ catch (error) {
1570
+ console.error("Failed to highlight code:", error)
1571
+ }
1317
1572
  }
1318
- }
1319
- else if (typeof className === "string" && className.startsWith("language-")) {
1320
- lang = className.replace("language-", "")
1321
- }
1322
-
1323
- // Get code content
1324
- const codeContent = codeNode.children
1325
- .map(child => (child.type === "text" ? child.value : ""))
1326
- .join("")
1327
1573
 
1328
- // Get highlighter
1329
- const hl = typeof highlighter === "function" ? highlighter() : highlighter
1574
+ return renderLegacyPlainCodeBlock(lang, codeContent)
1575
+ }
1330
1576
 
1331
- // Use Shiki if available
1332
- if (hl) {
1577
+ if (hl?.codeToTokensBase) {
1333
1578
  try {
1334
- const highlightedHtml = hl.codeToHtml(codeContent, {
1579
+ tokenLines = hl.codeToTokensBase(codeContent, {
1335
1580
  lang,
1336
- theme: defaultTheme,
1337
- })
1338
-
1339
- return h("div", {
1340
- "class": "shiki-code-block",
1341
- "data-lang": lang,
1342
- "innerHTML": highlightedHtml,
1581
+ theme: options.theme,
1343
1582
  })
1344
1583
  }
1345
1584
  catch (error) {
@@ -1347,9 +1586,40 @@ export function createCodeBlockRenderer(
1347
1586
  }
1348
1587
  }
1349
1588
 
1350
- // Fallback: render without highlighting
1351
- return h("pre", { class: `language-${lang}` }, h("code", { class: `language-${lang}` }, codeContent),
1352
- )
1589
+ return h("pre", {
1590
+ "class": [
1591
+ "aimd-code-block",
1592
+ options.lineNumbers ? "aimd-code-block--line-numbers" : "",
1593
+ options.wrap ? "aimd-code-block--wrap" : "",
1594
+ options.className,
1595
+ `language-${lang}`,
1596
+ ].filter(Boolean).join(" "),
1597
+ "data-lang": lang,
1598
+ }, h("code", {
1599
+ class: [
1600
+ "aimd-code-block__code",
1601
+ `language-${lang}`,
1602
+ ],
1603
+ }, tokenLines.map((lineTokens, lineIndex) => h("span", {
1604
+ class: "aimd-code-block__line",
1605
+ "data-line": String(lineIndex + 1),
1606
+ }, [
1607
+ options.lineNumbers
1608
+ ? h("span", {
1609
+ class: "aimd-code-block__line-number",
1610
+ "aria-hidden": "true",
1611
+ }, String(lineIndex + 1))
1612
+ : null,
1613
+ h("span", {
1614
+ class: "aimd-code-block__line-code",
1615
+ style: { "--aimd-code-wrap-indent": `${getLineIndentColumns(lineTokens)}ch` },
1616
+ }, hasCodeTokenContent(lineTokens)
1617
+ ? lineTokens.map((token, tokenIndex) => h("span", {
1618
+ key: `${lineIndex}:${tokenIndex}`,
1619
+ style: getCodeTokenStyle(token),
1620
+ }, token.content))
1621
+ : "\u00a0"),
1622
+ ]))))
1353
1623
  }
1354
1624
  }
1355
1625