@elabs-ai/components-ai 4.0.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 (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
@@ -0,0 +1,237 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The React Flow half of `@elabs-ai/components-ai`'s in-chat agent
5
+ * workspace graph — `Canvas`, `Controls`, `Edge`, `Node`, `Panel`, `Toolbar` —
6
+ * split out so the engine can be `lazy()`-loaded.
7
+ *
8
+ * `@xyflow/react` declares no `sideEffects`, so the six static imports it used to
9
+ * have (one per public module, plus `@xyflow/react/dist/style.css`) put the whole
10
+ * engine into the entry chunk of every consumer, canvas rendered or not. **The
11
+ * stylesheet import has to live here too**: a bare
12
+ * `import "@xyflow/react/dist/style.css"` in `canvas.tsx` keeps the edge alive on
13
+ * its own.
14
+ *
15
+ * This is ONE boundary for all six parts on purpose (see `_flow-lazy.ts`): six
16
+ * boundaries would be six chunks all pulling the same engine. The public modules
17
+ * keep their `import type { … } from "@xyflow/react"` lines (types erase) and
18
+ * their prop types, and render a `lazy()` wrapper over the implementations here.
19
+ * `connection.tsx` is type-only already and needs no boundary.
20
+ *
21
+ * See ADR 0019 and `pnpm heavy-deps:check`.
22
+ *
23
+ * @lazy-boundary This module must only ever be reached via `import()`. The gate
24
+ * fails if anything imports it statically, which would put React Flow back in
25
+ * the entry chunk and make the `lazy()` pointless.
26
+ */
27
+ import { Card } from "@elabs-ai/components-ui";
28
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
29
+ import type { EdgeProps, InternalNode, Node as FlowNode } from "@xyflow/react";
30
+ import {
31
+ Background,
32
+ BaseEdge,
33
+ Controls as ControlsPrimitive,
34
+ getBezierPath,
35
+ getSimpleBezierPath,
36
+ Handle,
37
+ NodeToolbar,
38
+ Panel as PanelPrimitive,
39
+ Position,
40
+ ReactFlow,
41
+ useInternalNode,
42
+ } from "@xyflow/react";
43
+
44
+ import "@xyflow/react/dist/style.css";
45
+
46
+ import type { CanvasProps } from "./canvas";
47
+ import type { ControlsProps } from "./controls";
48
+ import type { NodeProps } from "./node";
49
+ import type { PanelProps } from "./panel";
50
+ import type { ToolbarProps } from "./toolbar";
51
+
52
+ const deleteKeyCode = ["Backspace", "Delete"];
53
+
54
+ /**
55
+ * The React Flow attribution badge is hidden on both canvas surfaces (here and
56
+ * `@elabs-ai/components-flow`'s `CanvasShell`). `@xyflow/react` is
57
+ * MIT — the licence requires the notice in source copies, not a rendered badge —
58
+ * and xyflow asks that the badge only be hidden under a React Flow Pro
59
+ * subscription. Keeping it is therefore a product/commercial decision, and this
60
+ * repo's is to hide it. A consumer can restore it per-canvas with
61
+ * `proOptions={{ hideAttribution: false }}`, which still wins via `...props`.
62
+ */
63
+ const proOptions = { hideAttribution: true };
64
+
65
+ export const Canvas = ({ children, ...props }: CanvasProps) => (
66
+ <ReactFlow
67
+ deleteKeyCode={deleteKeyCode}
68
+ fitView
69
+ panOnDrag={false}
70
+ panOnScroll
71
+ proOptions={proOptions}
72
+ selectionOnDrag={true}
73
+ zoomOnDoubleClick={false}
74
+ {...props}
75
+ >
76
+ <Background bgColor="var(--sidebar)" />
77
+ {children}
78
+ </ReactFlow>
79
+ );
80
+
81
+ export const Controls = ({ className, ...props }: ControlsProps) => (
82
+ <ControlsPrimitive
83
+ className={cn(
84
+ "gap-px overflow-hidden rounded-md border bg-card p-1 shadow-none!",
85
+ "[&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!",
86
+ className,
87
+ )}
88
+ {...props}
89
+ />
90
+ );
91
+
92
+ export const EdgeTemporary = ({
93
+ id,
94
+ sourceX,
95
+ sourceY,
96
+ targetX,
97
+ targetY,
98
+ sourcePosition,
99
+ targetPosition,
100
+ }: EdgeProps) => {
101
+ const [edgePath] = getSimpleBezierPath({
102
+ sourcePosition,
103
+ sourceX,
104
+ sourceY,
105
+ targetPosition,
106
+ targetX,
107
+ targetY,
108
+ });
109
+
110
+ return (
111
+ <BaseEdge
112
+ className="stroke-1 stroke-ring"
113
+ id={id}
114
+ path={edgePath}
115
+ style={{
116
+ strokeDasharray: "5, 5",
117
+ }}
118
+ />
119
+ );
120
+ };
121
+
122
+ const getHandleCoordsByPosition = (node: InternalNode<FlowNode>, handlePosition: Position) => {
123
+ // Choose the handle type based on position - Left is for target, Right is for source
124
+ const handleType = handlePosition === Position.Left ? "target" : "source";
125
+
126
+ const handle = node.internals.handleBounds?.[handleType]?.find(
127
+ (h) => h.position === handlePosition,
128
+ );
129
+
130
+ if (!handle) {
131
+ return [0, 0] as const;
132
+ }
133
+
134
+ let offsetX = handle.width / 2;
135
+ let offsetY = handle.height / 2;
136
+
137
+ // this is a tiny detail to make the markerEnd of an edge visible.
138
+ // The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset
139
+ // when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position
140
+ switch (handlePosition) {
141
+ case Position.Left: {
142
+ offsetX = 0;
143
+ break;
144
+ }
145
+ case Position.Right: {
146
+ offsetX = handle.width;
147
+ break;
148
+ }
149
+ case Position.Top: {
150
+ offsetY = 0;
151
+ break;
152
+ }
153
+ case Position.Bottom: {
154
+ offsetY = handle.height;
155
+ break;
156
+ }
157
+ default: {
158
+ throw new Error(`Invalid handle position: ${handlePosition}`);
159
+ }
160
+ }
161
+
162
+ const x = node.internals.positionAbsolute.x + handle.x + offsetX;
163
+ const y = node.internals.positionAbsolute.y + handle.y + offsetY;
164
+
165
+ return [x, y] as const;
166
+ };
167
+
168
+ const getEdgeParams = (source: InternalNode<FlowNode>, target: InternalNode<FlowNode>) => {
169
+ const sourcePos = Position.Right;
170
+ const [sx, sy] = getHandleCoordsByPosition(source, sourcePos);
171
+ const targetPos = Position.Left;
172
+ const [tx, ty] = getHandleCoordsByPosition(target, targetPos);
173
+
174
+ return {
175
+ sourcePos,
176
+ sx,
177
+ sy,
178
+ targetPos,
179
+ tx,
180
+ ty,
181
+ };
182
+ };
183
+
184
+ export const EdgeAnimated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
185
+ const sourceNode = useInternalNode(source);
186
+ const targetNode = useInternalNode(target);
187
+
188
+ if (!(sourceNode && targetNode)) {
189
+ return null;
190
+ }
191
+
192
+ const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(sourceNode, targetNode);
193
+
194
+ const [edgePath] = getBezierPath({
195
+ sourcePosition: sourcePos,
196
+ sourceX: sx,
197
+ sourceY: sy,
198
+ targetPosition: targetPos,
199
+ targetX: tx,
200
+ targetY: ty,
201
+ });
202
+
203
+ return (
204
+ <>
205
+ <BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} />
206
+ <circle fill="var(--primary)" r="4">
207
+ <animateMotion dur="2s" path={edgePath} repeatCount="indefinite" />
208
+ </circle>
209
+ </>
210
+ );
211
+ };
212
+
213
+ export const Node = ({ handles, className, ...props }: NodeProps) => (
214
+ <Card
215
+ className={cn("node-container relative size-full h-auto w-sm gap-0 rounded-md p-0", className)}
216
+ {...props}
217
+ >
218
+ {handles.target && <Handle position={Position.Left} type="target" />}
219
+ {handles.source && <Handle position={Position.Right} type="source" />}
220
+ {props.children}
221
+ </Card>
222
+ );
223
+
224
+ export const Panel = ({ className, ...props }: PanelProps) => (
225
+ <PanelPrimitive
226
+ className={cn("m-4 overflow-hidden rounded-md border bg-card p-1", className)}
227
+ {...props}
228
+ />
229
+ );
230
+
231
+ export const Toolbar = ({ className, ...props }: ToolbarProps) => (
232
+ <NodeToolbar
233
+ className={cn("flex items-center gap-1 rounded-sm border bg-background p-1.5", className)}
234
+ position={Position.Bottom}
235
+ {...props}
236
+ />
237
+ );
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The one dynamic-import seam every React-Flow-backed component in
3
+ * `@elabs-ai/components-ai` goes through.
4
+ *
5
+ * `@xyflow/react` declares no `sideEffects`, so a static import from any of the
6
+ * six public modules (`canvas`, `controls`, `edge`, `node`, `panel`, `toolbar`)
7
+ * put the whole engine into the entry chunk of every consumer. They all reach it
8
+ * through **one** boundary module — `_flow-boundary.tsx` — rather than six, so
9
+ * the engine lands in exactly one chunk: six boundaries would mean six chunks
10
+ * each pulling the same engine, and a `<Node>` rendered inside an already-loaded
11
+ * `<Canvas>` would fetch a second copy.
12
+ *
13
+ * Because every part resolves from the same specifier, the parts _inside_ a
14
+ * canvas resolve from the module cache the canvas already populated.
15
+ *
16
+ * This module is deliberately NOT itself a lazy boundary (it carries no such
17
+ * docblock tag, and `pnpm heavy-deps:check` would fail the six static imports
18
+ * below if it did): it holds no engine import of its own, only the `import()`
19
+ * call, so the public modules may import it statically. See ADR 0019.
20
+ */
21
+ import { lazy } from "react";
22
+ import type { ComponentType, LazyExoticComponent } from "react";
23
+
24
+ type FlowBoundaryModule = typeof import("./_flow-boundary");
25
+
26
+ /**
27
+ * Wrap one implementation from the React Flow boundary in a `lazy()` component.
28
+ *
29
+ * @param pick Selects the implementation from the boundary module.
30
+ */
31
+ export const lazyFlowPart = <P>(
32
+ pick: (module: FlowBoundaryModule) => ComponentType<P>,
33
+ ): LazyExoticComponent<ComponentType<P>> =>
34
+ lazy(() => import("./_flow-boundary").then((m) => ({ default: pick(m) })));
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The xterm.js half of `InteractiveTerminal`, split out so it can be reached
3
+ * through a dynamic `import()`.
4
+ *
5
+ * `@xterm/xterm` declares no `sideEffects`, so a static import from
6
+ * `interactive-terminal.tsx` put the whole emulator — plus its stylesheet — into
7
+ * the entry chunk of every `@elabs-ai/components-ai` consumer, including
8
+ * the vast majority that never render a terminal. The **stylesheet import has to
9
+ * live here too**: a bare `import "@xterm/xterm/css/xterm.css"` in the public
10
+ * module keeps the edge alive on its own.
11
+ *
12
+ * Unlike `_persona-rive.tsx` (a `lazy()` component boundary), this one follows
13
+ * `_lazy-mermaid.ts`: the engine is reached through a **late-called interface**.
14
+ * `InteractiveTerminal` only ever constructs xterm inside its mount effect —
15
+ * never during render — so the public component keeps its `forwardRef`, its
16
+ * imperative handle and its container `<div>` unchanged, and awaits this module
17
+ * inside that effect. No `Suspense` seam, no skeleton swap, no change to the box
18
+ * the terminal paints into (the container is sized by the consumer either way).
19
+ *
20
+ * `ITheme` and the two constructor types stay `import type` in the public module
21
+ * (types erase), which is what keeps `buildInteractiveTerminalTheme` public.
22
+ *
23
+ * See ADR 0019 and `pnpm heavy-deps:check`.
24
+ *
25
+ * @lazy-boundary This module must only ever be reached via `import()`. The gate
26
+ * fails if anything imports it statically, which would put xterm back in the
27
+ * entry chunk and make the dynamic import pointless.
28
+ */
29
+ import "@xterm/xterm/css/xterm.css";
30
+
31
+ export { FitAddon } from "@xterm/addon-fit";
32
+ export { Terminal as XTerm } from "@xterm/xterm";
@@ -0,0 +1,101 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { createLazyMermaidPlugin, lazyMermaid, preloadMermaid } from "./_lazy-mermaid";
4
+
5
+ /**
6
+ * The Mermaid engine is mocked so we can observe how the plugin drives it.
7
+ *
8
+ * NOTE this file tests the plugin's *behaviour*. That the engine is genuinely
9
+ * lazy is a property of the BUILD, not of jsdom — `vi.mock` factories are
10
+ * hoisted and cached, so a counter here would prove nothing. The static proof is
11
+ * `pnpm heavy-deps:check`, which asserts no `packages/ai/src` module statically
12
+ * imports mermaid, plus the built `packages/ai/dist/index.js` carrying
13
+ * `import("mermaid")` and no `@streamdown/mermaid` edge.
14
+ */
15
+ const initialize = vi.fn();
16
+ const render = vi.fn(async (id: string) => ({ svg: `<svg id="${id}"/>` }));
17
+
18
+ vi.mock("mermaid", () => ({ default: { initialize, render } }));
19
+
20
+ describe("lazy mermaid plugin", () => {
21
+ beforeEach(() => {
22
+ initialize.mockClear();
23
+ render.mockClear();
24
+ });
25
+
26
+ it("exposes the DiagramPlugin contract Streamdown expects", () => {
27
+ expect(lazyMermaid).toMatchObject({
28
+ language: "mermaid",
29
+ name: "mermaid",
30
+ type: "diagram",
31
+ });
32
+ expect(typeof lazyMermaid.getMermaid).toBe("function");
33
+ });
34
+
35
+ it("touches the engine only on render, never on getMermaid", () => {
36
+ // Streamdown calls getMermaid() immediately before awaiting render(); the
37
+ // plugin object being present from the first frame is what avoids a
38
+ // "raw source flashes, then becomes a diagram" transition.
39
+ createLazyMermaidPlugin().getMermaid();
40
+ expect(initialize).not.toHaveBeenCalled();
41
+ expect(render).not.toHaveBeenCalled();
42
+ });
43
+
44
+ it("delegates render to the engine and returns its svg", async () => {
45
+ const result = await createLazyMermaidPlugin().getMermaid().render("d1", "graph TD; A-->B;");
46
+
47
+ expect(render).toHaveBeenCalledWith("d1", "graph TD; A-->B;");
48
+ expect(result.svg).toContain("d1");
49
+ });
50
+
51
+ it("initializes exactly once across repeated renders", async () => {
52
+ const instance = createLazyMermaidPlugin().getMermaid();
53
+
54
+ await instance.render("d1", "graph TD; A-->B;");
55
+ await instance.render("d2", "graph TD; C-->D;");
56
+
57
+ expect(initialize).toHaveBeenCalledTimes(1);
58
+ expect(render).toHaveBeenCalledTimes(2);
59
+ });
60
+
61
+ it("applies the upstream brand defaults, including securityLevel: strict", async () => {
62
+ await createLazyMermaidPlugin().getMermaid().render("d1", "graph TD; A-->B;");
63
+
64
+ expect(initialize).toHaveBeenCalledWith(
65
+ expect.objectContaining({
66
+ fontFamily: "monospace",
67
+ securityLevel: "strict",
68
+ startOnLoad: false,
69
+ suppressErrorRendering: true,
70
+ theme: "default",
71
+ }),
72
+ );
73
+ });
74
+
75
+ it("merges a per-plugin config over the defaults", async () => {
76
+ await createLazyMermaidPlugin({ config: { theme: "dark" } })
77
+ .getMermaid()
78
+ .render("d1", "graph TD; A-->B;");
79
+
80
+ expect(initialize).toHaveBeenCalledWith(
81
+ expect.objectContaining({ securityLevel: "strict", theme: "dark" }),
82
+ );
83
+ });
84
+
85
+ it("lets getMermaid(config) override, and re-initializes for it", async () => {
86
+ const plugin = createLazyMermaidPlugin();
87
+
88
+ await plugin.getMermaid().render("d1", "graph TD; A-->B;");
89
+ await plugin.getMermaid({ theme: "forest" }).render("d2", "graph TD; C-->D;");
90
+
91
+ expect(initialize).toHaveBeenCalledTimes(2);
92
+ expect(initialize).toHaveBeenLastCalledWith(
93
+ expect.objectContaining({ securityLevel: "strict", theme: "forest" }),
94
+ );
95
+ });
96
+
97
+ it("preloadMermaid resolves without rendering anything", async () => {
98
+ expect(() => preloadMermaid()).not.toThrow();
99
+ expect(render).not.toHaveBeenCalled();
100
+ });
101
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * A drop-in replacement for `@streamdown/mermaid`'s `mermaid` plugin that loads
3
+ * the Mermaid engine **on first diagram render** instead of at import time.
4
+ *
5
+ * Why this exists
6
+ * ---------------
7
+ * `@streamdown/mermaid`'s dist opens with a static `import n from "mermaid"`, and
8
+ * neither it nor `mermaid`/`dompurify`/`d3` declares `sideEffects`, so a bundler
9
+ * must keep the whole edge. `@elabs-ai/components-ai` imported that plugin from three modules
10
+ * (`message`, `reasoning`, `markdown-view`), which put Mermaid + d3 + DOMPurify —
11
+ * several MB — in the **entry chunk** of every consumer, including the vast
12
+ * majority that never render a diagram.
13
+ *
14
+ * How it avoids a render flash
15
+ * ----------------------------
16
+ * `DiagramPlugin` is not the engine; it is a lazy accessor. Streamdown calls
17
+ * `getMermaid()` only from inside its async diagram-render path (immediately
18
+ * before `await instance.render(...)`), never at module or component init. So the
19
+ * plugin object can be present from the very first frame — Streamdown always
20
+ * treats a ```mermaid fence as a diagram — while the engine itself arrives with a
21
+ * dynamic import inside `render()`. No content-sniffing, no plugin swap, no
22
+ * suspense seam at the call sites, and no "raw source flashes then becomes a
23
+ * diagram" transition.
24
+ *
25
+ * The upstream plugin is ~10 lines; its defaults and initialize-once semantics
26
+ * are mirrored exactly below. The `DiagramPlugin` type is still imported (as a
27
+ * type, so it erases) from `@streamdown/mermaid`, which keeps this honest against
28
+ * the real contract at typecheck time.
29
+ */
30
+ import type { DiagramPlugin, MermaidConfig, MermaidInstance } from "@streamdown/mermaid";
31
+
32
+ /** Mirrors `@streamdown/mermaid`'s defaults — notably `securityLevel: "strict"`. */
33
+ const DEFAULT_CONFIG: MermaidConfig = {
34
+ fontFamily: "monospace",
35
+ securityLevel: "strict",
36
+ startOnLoad: false,
37
+ suppressErrorRendering: true,
38
+ theme: "default",
39
+ };
40
+
41
+ type MermaidModule = {
42
+ initialize: (config: MermaidConfig) => void;
43
+ render: (id: string, source: string) => Promise<{ svg: string }>;
44
+ };
45
+
46
+ /**
47
+ * Module-level singleton: the engine is fetched at most once per app, however
48
+ * many diagrams or Streamdown instances render.
49
+ */
50
+ let enginePromise: Promise<MermaidModule> | undefined;
51
+
52
+ const loadEngine = (): Promise<MermaidModule> => {
53
+ enginePromise ??= import("mermaid").then((m) => m.default as unknown as MermaidModule);
54
+ return enginePromise;
55
+ };
56
+
57
+ /**
58
+ * Start fetching the Mermaid engine ahead of time.
59
+ *
60
+ * Optional. Call it when you already know the surface will render diagrams (on
61
+ * route entry, or when a conversation is known to contain them) so the first
62
+ * diagram doesn't pay the import latency. Safe to call repeatedly; safe to
63
+ * ignore — rendering a diagram loads the engine either way.
64
+ */
65
+ export const preloadMermaid = (): void => {
66
+ void loadEngine();
67
+ };
68
+
69
+ /**
70
+ * Create a lazy Mermaid diagram plugin for Streamdown.
71
+ *
72
+ * @param options.config Mermaid config merged over the brand defaults.
73
+ */
74
+ export const createLazyMermaidPlugin = (
75
+ options: { config?: MermaidConfig } = {},
76
+ ): DiagramPlugin => {
77
+ let initialized = false;
78
+ let config: MermaidConfig = { ...DEFAULT_CONFIG, ...options.config };
79
+
80
+ const instance: MermaidInstance = {
81
+ // Upstream calls `mermaid.initialize` synchronously here. We can't (the
82
+ // engine may not have loaded yet), so record the config and apply it inside
83
+ // `render`, which is the only place it can matter — Streamdown always calls
84
+ // `getMermaid()` immediately before awaiting `render()`.
85
+ initialize(next: MermaidConfig) {
86
+ config = { ...DEFAULT_CONFIG, ...options.config, ...next };
87
+ initialized = false;
88
+ },
89
+ async render(id: string, source: string) {
90
+ const engine = await loadEngine();
91
+ if (!initialized) {
92
+ engine.initialize(config);
93
+ initialized = true;
94
+ }
95
+ return engine.render(id, source);
96
+ },
97
+ };
98
+
99
+ return {
100
+ getMermaid(next?: MermaidConfig) {
101
+ if (next) {
102
+ instance.initialize(next);
103
+ }
104
+ return instance;
105
+ },
106
+ language: "mermaid",
107
+ name: "mermaid",
108
+ type: "diagram",
109
+ };
110
+ };
111
+
112
+ /**
113
+ * The shared lazy Mermaid plugin — the `@elabs-ai/components-ai` replacement for
114
+ * `@streamdown/mermaid`'s eager `mermaid` export. One instance for the whole
115
+ * app, so the engine and its initialize-once state are shared.
116
+ */
117
+ export const lazyMermaid: DiagramPlugin = createLazyMermaidPlugin();