@dbx-tools/ui-mastra 0.9.110 → 0.9.112
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/index.ts +2 -3
- package/package.json +6 -7
- package/src/react/bubbles.tsx +1 -1
- package/src/react/markdown.tsx +3 -44
- package/src/react/tool-pill.tsx +97 -37
- package/src/support/_shiki-runtime.ts +0 -27
- package/src/support/shiki-plugin.ts +0 -158
package/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Hand edits are overwritten on the next watch; this file is read-only.
|
|
4
4
|
|
|
5
5
|
export const PACKAGE_IDENTIFIER = "@dbx-tools/ui-mastra";
|
|
6
|
-
export const PACKAGE_VERSION = "0.9.
|
|
6
|
+
export const PACKAGE_VERSION = "0.9.112";
|
|
7
7
|
export * as reactBubbles from "./src/react/bubbles.tsx";
|
|
8
8
|
export * as reactChatApprovals from "./src/react/chat-approvals.ts";
|
|
9
9
|
export * as reactChatComposer from "./src/react/chat-composer.tsx";
|
|
@@ -35,7 +35,6 @@ export * as supportExport from "./src/support/export.ts";
|
|
|
35
35
|
export * as supportMastraClient from "./src/support/mastra-client.ts";
|
|
36
36
|
export * as supportModelSelection from "./src/support/model-selection.ts";
|
|
37
37
|
export * as supportRequestContext from "./src/support/request-context.ts";
|
|
38
|
-
export * as supportShikiPlugin from "./src/support/shiki-plugin.ts";
|
|
39
38
|
export * as supportThreadLabels from "./src/support/thread-labels.ts";
|
|
40
39
|
export * as supportThreadSessions from "./src/support/thread-sessions.ts";
|
|
41
40
|
export * as supportThreadTabs from "./src/support/thread-tabs.ts";
|
|
@@ -52,7 +51,7 @@ export type { DataRow } from "./src/react/data-grid.tsx";
|
|
|
52
51
|
export { MarkdownWithEmbeds } from "./src/react/embed-slots.tsx";
|
|
53
52
|
export { ExportMenu } from "./src/react/export-menu.tsx";
|
|
54
53
|
export { FeedbackControls } from "./src/react/feedback-controls.tsx";
|
|
55
|
-
export { AssistantMarkdown, ToolMarkdown, SqlBlock,
|
|
54
|
+
export { AssistantMarkdown, ToolMarkdown, SqlBlock, SourceBlock } from "./src/react/markdown.tsx";
|
|
56
55
|
export type { MastraAssistantMode, MastraAssistantSide, MastraAssistantLauncherPosition, MastraAssistantResizeOptions, MastraAssistantLauncherOptions, MastraAssistantController, MastraAssistantProps } from "./src/react/mastra-assistant.tsx";
|
|
57
56
|
export { useMastraChat, MastraChat } from "./src/react/mastra-chat.tsx";
|
|
58
57
|
export type { UseMastraChatOptions, MastraChatProps } from "./src/react/mastra-chat.tsx";
|
package/package.json
CHANGED
|
@@ -29,11 +29,11 @@
|
|
|
29
29
|
"react-dom": "~19.2.4"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@dbx-tools/shared-core": "^0.9.
|
|
33
|
-
"@dbx-tools/shared-genie": "^0.9.
|
|
34
|
-
"@dbx-tools/shared-mastra": "^0.9.
|
|
35
|
-
"@dbx-tools/shared-model": "^0.9.
|
|
36
|
-
"@dbx-tools/ui": "^0.9.
|
|
32
|
+
"@dbx-tools/shared-core": "^0.9.112",
|
|
33
|
+
"@dbx-tools/shared-genie": "^0.9.112",
|
|
34
|
+
"@dbx-tools/shared-mastra": "^0.9.112",
|
|
35
|
+
"@dbx-tools/shared-model": "^0.9.112",
|
|
36
|
+
"@dbx-tools/ui": "^0.9.112",
|
|
37
37
|
"@mastra/client-js": "1.50.0",
|
|
38
38
|
"@mastra/core": "1.71.0",
|
|
39
39
|
"@tanstack/react-table": "^8.21.3",
|
|
@@ -42,7 +42,6 @@
|
|
|
42
42
|
"echarts-for-react": "^3.0.2",
|
|
43
43
|
"lucide-react": "^0.554.0",
|
|
44
44
|
"marked": "^18.0.5",
|
|
45
|
-
"shiki": "^3.0.0",
|
|
46
45
|
"sql-formatter": "^15.6.9",
|
|
47
46
|
"streamdown": "^2.5.0"
|
|
48
47
|
},
|
|
@@ -50,7 +49,7 @@
|
|
|
50
49
|
"publishConfig": {
|
|
51
50
|
"access": "public"
|
|
52
51
|
},
|
|
53
|
-
"version": "0.9.
|
|
52
|
+
"version": "0.9.112",
|
|
54
53
|
"dbxToolsConfig": {
|
|
55
54
|
"tags": [
|
|
56
55
|
"ui"
|
package/src/react/bubbles.tsx
CHANGED
|
@@ -192,7 +192,7 @@ const ToolApprovalCard = ({
|
|
|
192
192
|
{asEmailInput(input) ? (
|
|
193
193
|
<EmailApprovalPreview email={asEmailInput(input)!} />
|
|
194
194
|
) : (
|
|
195
|
-
<RecordPreview value={input} className="rounded bg-background/40" />
|
|
195
|
+
<RecordPreview value={input} className="max-h-80 rounded bg-background/40" />
|
|
196
196
|
)}
|
|
197
197
|
{expired ? (
|
|
198
198
|
<div className="mt-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
package/src/react/markdown.tsx
CHANGED
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
Tooltip,
|
|
11
11
|
TooltipContent,
|
|
12
12
|
TooltipTrigger,
|
|
13
|
+
createShikiPlugin,
|
|
14
|
+
highlightToHtml,
|
|
15
|
+
useHighlightedHtml,
|
|
13
16
|
cn,
|
|
14
17
|
} from "@dbx-tools/ui/react";
|
|
15
18
|
import { CheckIcon, CopyIcon } from "lucide-react";
|
|
@@ -17,7 +20,6 @@ import React, { useEffect, useRef, useState } from "react";
|
|
|
17
20
|
import { Streamdown } from "streamdown";
|
|
18
21
|
import { DataGrid, TABLE_WRAPPER_CLASSES, colorizeDelta, type DataRow } from "./data-grid.tsx";
|
|
19
22
|
import { copyText } from "../support/clipboard.ts";
|
|
20
|
-
import { createShikiPlugin, highlightToHtml } from "../support/shiki-plugin.ts";
|
|
21
23
|
|
|
22
24
|
// Markdown rendering for the chat: the streaming `Streamdown` engine
|
|
23
25
|
// wired with shiki highlighting and AppKit table primitives, plus the
|
|
@@ -181,27 +183,6 @@ const TOOL_MARKDOWN_COMPONENTS = {
|
|
|
181
183
|
*/
|
|
182
184
|
const SHIKI_PLUGIN = { code: createShikiPlugin() };
|
|
183
185
|
|
|
184
|
-
/** Resolve syntax-highlighted HTML while preserving plaintext during lazy load. */
|
|
185
|
-
function useHighlightedHtml(source: string, language: string): string | null {
|
|
186
|
-
const [highlighted, setHighlighted] = useState<{
|
|
187
|
-
source: string;
|
|
188
|
-
language: string;
|
|
189
|
-
html: string;
|
|
190
|
-
} | null>(null);
|
|
191
|
-
useEffect(() => {
|
|
192
|
-
let active = true;
|
|
193
|
-
void highlightToHtml(source, language).then((result) => {
|
|
194
|
-
if (active) setHighlighted({ source, language, html: result });
|
|
195
|
-
});
|
|
196
|
-
return () => {
|
|
197
|
-
active = false;
|
|
198
|
-
};
|
|
199
|
-
}, [language, source]);
|
|
200
|
-
return highlighted?.source === source && highlighted.language === language
|
|
201
|
-
? highlighted.html
|
|
202
|
-
: null;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
186
|
/**
|
|
206
187
|
* Per-word fade-in applied while a reply is still streaming. Streamdown
|
|
207
188
|
* wraps each newly arrived token in a `[data-sd-animate]` span and only
|
|
@@ -397,28 +378,6 @@ export const SqlBlock = ({ sql }: { sql: string }) => {
|
|
|
397
378
|
);
|
|
398
379
|
};
|
|
399
380
|
|
|
400
|
-
/**
|
|
401
|
-
* Render formatted JSON with the same lazily loaded Shiki runtime used by
|
|
402
|
-
* SQL and fenced Markdown code blocks.
|
|
403
|
-
*/
|
|
404
|
-
export const JsonBlock = ({ json, className }: { json: string; className?: string }) => {
|
|
405
|
-
const html = useHighlightedHtml(json, "json");
|
|
406
|
-
return (
|
|
407
|
-
<pre
|
|
408
|
-
className={cn(
|
|
409
|
-
"max-w-full overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed",
|
|
410
|
-
className,
|
|
411
|
-
)}
|
|
412
|
-
>
|
|
413
|
-
{html === null ? (
|
|
414
|
-
<code>{json}</code>
|
|
415
|
-
) : (
|
|
416
|
-
<code data-dbx-highlighted-code dangerouslySetInnerHTML={{ __html: html }} />
|
|
417
|
-
)}
|
|
418
|
-
</pre>
|
|
419
|
-
);
|
|
420
|
-
};
|
|
421
|
-
|
|
422
381
|
/** Compact syntax-highlighted source block for tool request previews. */
|
|
423
382
|
export const SourceBlock = ({
|
|
424
383
|
source,
|
package/src/react/tool-pill.tsx
CHANGED
|
@@ -4,11 +4,13 @@ import {
|
|
|
4
4
|
Collapsible,
|
|
5
5
|
CollapsibleContent,
|
|
6
6
|
CollapsibleTrigger,
|
|
7
|
+
JsonBlock,
|
|
8
|
+
RecordPreview,
|
|
7
9
|
Spinner,
|
|
8
10
|
cn,
|
|
9
11
|
} from "@dbx-tools/ui/react";
|
|
10
12
|
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
|
|
11
|
-
import {
|
|
13
|
+
import { SourceBlock, SqlBlock, ToolMarkdown } from "./markdown.tsx";
|
|
12
14
|
import type { ToolEvent, ToolProgress } from "./types.ts";
|
|
13
15
|
|
|
14
16
|
// Consolidated tool-session pill and its Genie progress detail view:
|
|
@@ -22,6 +24,9 @@ import type { ToolEvent, ToolProgress } from "./types.ts";
|
|
|
22
24
|
* `Execute Command` rather than `Mastra Workspace Execute Command`.
|
|
23
25
|
*/
|
|
24
26
|
const MASTRA_WORKSPACE_TOOL_PREFIX = "mastra_workspace_";
|
|
27
|
+
const TOOL_LABELS: Readonly<Record<string, string>> = {
|
|
28
|
+
execute_typescript: "Run Workflow",
|
|
29
|
+
};
|
|
25
30
|
|
|
26
31
|
/**
|
|
27
32
|
* Turn a snake/camel tool id into a Title Cased label the user can
|
|
@@ -38,6 +43,8 @@ const MASTRA_WORKSPACE_TOOL_PREFIX = "mastra_workspace_";
|
|
|
38
43
|
* `myCoolTool` -> `My Cool Tool`
|
|
39
44
|
*/
|
|
40
45
|
export const humanizeToolName = (toolName: string): string => {
|
|
46
|
+
const explicitLabel = TOOL_LABELS[toolName];
|
|
47
|
+
if (explicitLabel) return explicitLabel;
|
|
41
48
|
const label = toolName.startsWith(MASTRA_WORKSPACE_TOOL_PREFIX)
|
|
42
49
|
? toolName.slice(MASTRA_WORKSPACE_TOOL_PREFIX.length)
|
|
43
50
|
: toolName;
|
|
@@ -383,20 +390,50 @@ export function formatRawToolPayload(value: unknown): string {
|
|
|
383
390
|
return JSON.stringify(value, null, 2) ?? String(value);
|
|
384
391
|
}
|
|
385
392
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
393
|
+
/** Return structured JSON from an object value or a complete JSON string. */
|
|
394
|
+
export function structuredToolPayload(value: unknown): unknown | undefined {
|
|
395
|
+
if (typeof value === "object" && value !== null) return value;
|
|
396
|
+
if (typeof value !== "string") return undefined;
|
|
397
|
+
const trimmed = value.trim();
|
|
398
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return undefined;
|
|
399
|
+
try {
|
|
400
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
401
|
+
return typeof parsed === "object" && parsed !== null ? parsed : undefined;
|
|
402
|
+
} catch {
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const RawToolPayload = ({
|
|
408
|
+
label,
|
|
409
|
+
value,
|
|
410
|
+
}: {
|
|
411
|
+
label: "Request" | "Response" | "Output";
|
|
412
|
+
value: unknown;
|
|
413
|
+
}) => {
|
|
414
|
+
const structured = structuredToolPayload(value);
|
|
415
|
+
return (
|
|
416
|
+
<Collapsible className="rounded border border-border/60 bg-background/40">
|
|
417
|
+
<CollapsibleTrigger className="group flex w-full items-center gap-1.5 px-2 py-1 text-left text-xs uppercase tracking-wide text-muted-foreground hover:text-foreground">
|
|
418
|
+
<ChevronDownIcon className="size-3 shrink-0 transition-transform group-data-[state=closed]:-rotate-90" />
|
|
419
|
+
<span>{label}</span>
|
|
420
|
+
</CollapsibleTrigger>
|
|
421
|
+
<CollapsibleContent>
|
|
422
|
+
{structured === undefined ? (
|
|
423
|
+
<JsonBlock
|
|
424
|
+
json={formatRawToolPayload(value)}
|
|
425
|
+
className="max-h-80 border-t border-border/50 px-2 py-2 text-foreground"
|
|
426
|
+
/>
|
|
427
|
+
) : (
|
|
428
|
+
<RecordPreview
|
|
429
|
+
value={structured}
|
|
430
|
+
className="max-h-80 border-t border-border/50 bg-transparent text-foreground"
|
|
431
|
+
/>
|
|
432
|
+
)}
|
|
433
|
+
</CollapsibleContent>
|
|
434
|
+
</Collapsible>
|
|
435
|
+
);
|
|
436
|
+
};
|
|
400
437
|
|
|
401
438
|
export type ToolInputPresentation = {
|
|
402
439
|
label: "Code" | "Command" | "Output";
|
|
@@ -590,6 +627,43 @@ const hasExpandableDetails = (event: ToolEvent): boolean => {
|
|
|
590
627
|
);
|
|
591
628
|
};
|
|
592
629
|
|
|
630
|
+
const ToolCallDetails = ({
|
|
631
|
+
event,
|
|
632
|
+
showQuestion = false,
|
|
633
|
+
}: {
|
|
634
|
+
event: ToolEvent;
|
|
635
|
+
showQuestion?: boolean;
|
|
636
|
+
}) => {
|
|
637
|
+
const question = askGenieQuestion(event);
|
|
638
|
+
const summary = summarizeProgress(event.progress ?? []);
|
|
639
|
+
const inputPresentation =
|
|
640
|
+
"input" in event ? toolInputPresentation(event.toolName, event.input) : undefined;
|
|
641
|
+
const outputPresentation =
|
|
642
|
+
"output" in event ? toolOutputPresentation(event.toolName, event.output) : undefined;
|
|
643
|
+
|
|
644
|
+
return (
|
|
645
|
+
<div className="flex flex-col gap-2">
|
|
646
|
+
{showQuestion && question ? (
|
|
647
|
+
<p className="break-words px-0.5 text-xs italic text-foreground/80">{question}</p>
|
|
648
|
+
) : null}
|
|
649
|
+
{inputPresentation ? (
|
|
650
|
+
<ToolInputSource presentation={inputPresentation} />
|
|
651
|
+
) : "input" in event ? (
|
|
652
|
+
<RawToolPayload label="Request" value={event.input} />
|
|
653
|
+
) : null}
|
|
654
|
+
<WebSearchProgressDetails event={event} />
|
|
655
|
+
<ToolProgressDetails summary={summary} omitQuestion />
|
|
656
|
+
{outputPresentation?.language === "json" ? (
|
|
657
|
+
<RawToolPayload label="Output" value={outputPresentation.source} />
|
|
658
|
+
) : outputPresentation ? (
|
|
659
|
+
<ToolInputSource presentation={outputPresentation} />
|
|
660
|
+
) : "output" in event ? (
|
|
661
|
+
<RawToolPayload label="Response" value={event.output} />
|
|
662
|
+
) : null}
|
|
663
|
+
</div>
|
|
664
|
+
);
|
|
665
|
+
};
|
|
666
|
+
|
|
593
667
|
/**
|
|
594
668
|
* One row inside {@link ToolSessionPill}. Always-visible header
|
|
595
669
|
* shows the status icon, the tool's verb (`Called Ask Genie`,
|
|
@@ -608,12 +682,7 @@ const ToolCallRow = ({ event }: { event: ToolEvent }) => {
|
|
|
608
682
|
const isRunning = event.status === "running";
|
|
609
683
|
const isError = event.status === "error";
|
|
610
684
|
const question = askGenieQuestion(event);
|
|
611
|
-
const summary = summarizeProgress(event.progress ?? []);
|
|
612
685
|
const expandable = hasExpandableDetails(event);
|
|
613
|
-
const inputPresentation =
|
|
614
|
-
"input" in event ? toolInputPresentation(event.toolName, event.input) : undefined;
|
|
615
|
-
const outputPresentation =
|
|
616
|
-
"output" in event ? toolOutputPresentation(event.toolName, event.output) : undefined;
|
|
617
686
|
// Inner rows defer to the live wire status when running (e.g.
|
|
618
687
|
// "Executing query") so users tracking the open pill see the
|
|
619
688
|
// backend's freshest state - not just the static
|
|
@@ -659,19 +728,8 @@ const ToolCallRow = ({ event }: { event: ToolEvent }) => {
|
|
|
659
728
|
{header}
|
|
660
729
|
</CollapsibleTrigger>
|
|
661
730
|
<CollapsibleContent>
|
|
662
|
-
<div className="
|
|
663
|
-
{
|
|
664
|
-
<ToolInputSource presentation={inputPresentation} />
|
|
665
|
-
) : "input" in event ? (
|
|
666
|
-
<RawToolPayload label="Request" value={event.input} />
|
|
667
|
-
) : null}
|
|
668
|
-
<WebSearchProgressDetails event={event} />
|
|
669
|
-
<ToolProgressDetails summary={summary} omitQuestion />
|
|
670
|
-
{outputPresentation ? (
|
|
671
|
-
<ToolInputSource presentation={outputPresentation} />
|
|
672
|
-
) : "output" in event ? (
|
|
673
|
-
<RawToolPayload label="Response" value={event.output} />
|
|
674
|
-
) : null}
|
|
731
|
+
<div className="px-2 pb-2">
|
|
732
|
+
<ToolCallDetails event={event} />
|
|
675
733
|
</div>
|
|
676
734
|
</CollapsibleContent>
|
|
677
735
|
</Collapsible>
|
|
@@ -762,10 +820,12 @@ export const ToolSessionPill = ({ events }: { events: ToolEvent[] }) => {
|
|
|
762
820
|
)}
|
|
763
821
|
</CollapsibleTrigger>
|
|
764
822
|
<CollapsibleContent>
|
|
765
|
-
<div className="mt-1.5 flex flex-col gap-1">
|
|
766
|
-
{events.
|
|
767
|
-
<
|
|
768
|
-
)
|
|
823
|
+
<div className={cn("mt-1.5", events.length > 1 && "flex flex-col gap-1")}>
|
|
824
|
+
{events.length === 1 ? (
|
|
825
|
+
<ToolCallDetails event={latest} showQuestion />
|
|
826
|
+
) : (
|
|
827
|
+
events.map((event) => <ToolCallRow key={event.id} event={event} />)
|
|
828
|
+
)}
|
|
769
829
|
</div>
|
|
770
830
|
</CollapsibleContent>
|
|
771
831
|
</Collapsible>
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fine-grained Shiki runtime containing only the chat's supported languages.
|
|
3
|
-
*
|
|
4
|
-
* This module is itself dynamically imported, so its grammar, theme, and WASM
|
|
5
|
-
* dependencies remain off the network until a supported code block appears.
|
|
6
|
-
*/
|
|
7
|
-
import { createHighlighterCore } from "shiki/core";
|
|
8
|
-
import { createOnigurumaEngine } from "shiki/engine/oniguruma";
|
|
9
|
-
import bash from "shiki/langs/bash.mjs";
|
|
10
|
-
import javascript from "shiki/langs/javascript.mjs";
|
|
11
|
-
import json from "shiki/langs/json.mjs";
|
|
12
|
-
import markdown from "shiki/langs/markdown.mjs";
|
|
13
|
-
import python from "shiki/langs/python.mjs";
|
|
14
|
-
import sql from "shiki/langs/sql.mjs";
|
|
15
|
-
import typescript from "shiki/langs/typescript.mjs";
|
|
16
|
-
import yaml from "shiki/langs/yaml.mjs";
|
|
17
|
-
import githubDarkHighContrast from "shiki/themes/github-dark-high-contrast.mjs";
|
|
18
|
-
import githubLightHighContrast from "shiki/themes/github-light-high-contrast.mjs";
|
|
19
|
-
|
|
20
|
-
/** Create the shared highlighter with the exact configured language set. */
|
|
21
|
-
export function createHighlighter() {
|
|
22
|
-
return createHighlighterCore({
|
|
23
|
-
themes: [githubLightHighContrast, githubDarkHighContrast],
|
|
24
|
-
langs: [sql, python, typescript, javascript, json, bash, yaml, markdown],
|
|
25
|
-
engine: createOnigurumaEngine(import("shiki/wasm")),
|
|
26
|
-
});
|
|
27
|
-
}
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
import { stringUtils } from "@dbx-tools/shared-core";
|
|
2
|
-
import type { BundledLanguage, BundledTheme } from "shiki";
|
|
3
|
-
import type { HighlighterCore } from "shiki/core";
|
|
4
|
-
import type { CodeHighlighterPlugin } from "streamdown";
|
|
5
|
-
|
|
6
|
-
// Streamdown 2.x ships syntax highlighting as an opt-in plugin and has
|
|
7
|
-
// no shiki dependency of its own; without a `code` plugin every fenced
|
|
8
|
-
// block renders as uncolored plaintext. This module provides a small
|
|
9
|
-
// shiki-backed highlighter to wire in via `plugins={{ code }}`.
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Languages we highlight in the chat. SQL is the primary one (Genie
|
|
13
|
-
* query previews); the rest cover code the assistant might emit. Kept
|
|
14
|
-
* to a curated set so shiki only bundles these grammars.
|
|
15
|
-
*/
|
|
16
|
-
const LANGUAGES = [
|
|
17
|
-
"text",
|
|
18
|
-
"plaintext",
|
|
19
|
-
"txt",
|
|
20
|
-
"sql",
|
|
21
|
-
"python",
|
|
22
|
-
"typescript",
|
|
23
|
-
"javascript",
|
|
24
|
-
"json",
|
|
25
|
-
"bash",
|
|
26
|
-
"yaml",
|
|
27
|
-
"markdown",
|
|
28
|
-
] as const;
|
|
29
|
-
|
|
30
|
-
/** High-contrast theme pair used for every syntax surface. */
|
|
31
|
-
const THEMES = [
|
|
32
|
-
"github-light-high-contrast",
|
|
33
|
-
"github-dark-high-contrast",
|
|
34
|
-
] as const satisfies readonly [BundledTheme, BundledTheme];
|
|
35
|
-
|
|
36
|
-
/** Languages we can tokenize, as a set for O(1) support checks. */
|
|
37
|
-
const SUPPORTED = new Set<string>(LANGUAGES);
|
|
38
|
-
|
|
39
|
-
let _highlighter: HighlighterCore | null = null;
|
|
40
|
-
let _loading: Promise<HighlighterCore> | null = null;
|
|
41
|
-
|
|
42
|
-
/** Lazily create the shared shiki highlighter (singleton, loaded once). */
|
|
43
|
-
function loadHighlighter(): Promise<HighlighterCore> {
|
|
44
|
-
_loading ??= import("./_shiki-runtime.ts")
|
|
45
|
-
.then(({ createHighlighter }) => createHighlighter())
|
|
46
|
-
.then((highlighter) => {
|
|
47
|
-
_highlighter = highlighter;
|
|
48
|
-
return highlighter;
|
|
49
|
-
});
|
|
50
|
-
return _loading;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function splitThemeColor(
|
|
54
|
-
value: string | undefined,
|
|
55
|
-
darkVariable: "--shiki-dark" | "--shiki-dark-bg",
|
|
56
|
-
): { light?: string; dark?: string } {
|
|
57
|
-
if (!value) return {};
|
|
58
|
-
const marker = `;${darkVariable}:`;
|
|
59
|
-
const index = value.indexOf(marker);
|
|
60
|
-
if (index < 0) return { light: value };
|
|
61
|
-
return {
|
|
62
|
-
light: value.slice(0, index),
|
|
63
|
-
dark: value.slice(index + marker.length),
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Tokenize `code` with the active themes into Streamdown's result shape. */
|
|
68
|
-
function highlightTokens(h: HighlighterCore, code: string, language: string) {
|
|
69
|
-
const { tokens, fg, bg, rootStyle } = h.codeToTokens(code, {
|
|
70
|
-
lang: language as BundledLanguage,
|
|
71
|
-
themes: { light: THEMES[0], dark: THEMES[1] },
|
|
72
|
-
});
|
|
73
|
-
const foreground = splitThemeColor(fg, "--shiki-dark");
|
|
74
|
-
const background = splitThemeColor(bg, "--shiki-dark-bg");
|
|
75
|
-
const darkStyles = [
|
|
76
|
-
foreground.dark ? `--shiki-dark:${foreground.dark}` : "",
|
|
77
|
-
background.dark ? `--shiki-dark-bg:${background.dark}` : "",
|
|
78
|
-
]
|
|
79
|
-
.filter(Boolean)
|
|
80
|
-
.join(";");
|
|
81
|
-
return {
|
|
82
|
-
tokens,
|
|
83
|
-
fg: foreground.light,
|
|
84
|
-
bg: background.light,
|
|
85
|
-
rootStyle: [rootStyle, darkStyles].filter(Boolean).join(";"),
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Escape HTML-significant characters (from the shared string utils). */
|
|
90
|
-
const escapeHtml = stringUtils.escapeHtml;
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Highlight `code` into minimal inline HTML: one colored `<span>` per
|
|
94
|
-
* token, lines joined by real newlines, with no line-number gutter or
|
|
95
|
-
* per-line wrapper elements. Meant to drop straight into a
|
|
96
|
-
* `<pre><code>` so the rendered text stays cleanly selectable and
|
|
97
|
-
* copyable. Falls back to plain escaped text when the language isn't
|
|
98
|
-
* supported or shiki fails to parse the snippet.
|
|
99
|
-
*/
|
|
100
|
-
export async function highlightToHtml(code: string, language: string): Promise<string> {
|
|
101
|
-
if (!SUPPORTED.has(language)) return escapeHtml(code);
|
|
102
|
-
const h = await loadHighlighter();
|
|
103
|
-
try {
|
|
104
|
-
const { tokens } = highlightTokens(h, code, language);
|
|
105
|
-
return tokens
|
|
106
|
-
.map((line) =>
|
|
107
|
-
line
|
|
108
|
-
.map((token) => {
|
|
109
|
-
const light = token.htmlStyle?.color ?? token.color ?? "inherit";
|
|
110
|
-
const dark = token.htmlStyle?.["--shiki-dark"] ?? light;
|
|
111
|
-
const style = `--sdm-c:${light};--shiki-dark:${dark}`;
|
|
112
|
-
return `<span style="${escapeHtml(style)}">${escapeHtml(token.content)}</span>`;
|
|
113
|
-
})
|
|
114
|
-
.join(""),
|
|
115
|
-
)
|
|
116
|
-
.join("\n");
|
|
117
|
-
} catch {
|
|
118
|
-
return escapeHtml(code);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* shiki-backed code highlighter plugin for Streamdown. The highlighter
|
|
124
|
-
* loads asynchronously: the first call for any block returns `null`
|
|
125
|
-
* and resolves through the `callback` once shiki is ready, after which
|
|
126
|
-
* results are synchronous. Unsupported languages return `null` so the
|
|
127
|
-
* block stays plaintext rather than throwing.
|
|
128
|
-
*/
|
|
129
|
-
export function createShikiPlugin(): CodeHighlighterPlugin {
|
|
130
|
-
const isSupported = (language: string): boolean => SUPPORTED.has(language);
|
|
131
|
-
|
|
132
|
-
return {
|
|
133
|
-
name: "shiki",
|
|
134
|
-
type: "code-highlighter",
|
|
135
|
-
getSupportedLanguages: () => [...LANGUAGES],
|
|
136
|
-
getThemes: () => [...THEMES],
|
|
137
|
-
supportsLanguage: (language) => isSupported(language),
|
|
138
|
-
highlight: (options, callback) => {
|
|
139
|
-
if (!isSupported(options.language)) return null;
|
|
140
|
-
const language = options.language;
|
|
141
|
-
if (_highlighter) {
|
|
142
|
-
try {
|
|
143
|
-
return highlightTokens(_highlighter, options.code, language);
|
|
144
|
-
} catch {
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
void loadHighlighter().then((h) => {
|
|
149
|
-
try {
|
|
150
|
-
callback?.(highlightTokens(h, options.code, language));
|
|
151
|
-
} catch {
|
|
152
|
-
// Parse failure for this block - leave it as plaintext.
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
return null;
|
|
156
|
-
},
|
|
157
|
-
};
|
|
158
|
-
}
|