@elabs-ai/components-ai 4.0.0 → 4.1.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.
- package/README.md +61 -11
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1109 -202
- package/dist/index.js +4867 -2878
- package/dist/index.js.map +1 -1
- package/package.json +31 -16
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +21 -1
- package/src/_streamdown-safety.ts +170 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +28 -14
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +15 -12
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +263 -3
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +2 -1
- package/src/conversation.stories.tsx +63 -2
- package/src/conversation.test.tsx +13 -0
- package/src/conversation.tsx +20 -2
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +188 -0
- package/src/diff-view.tsx +642 -0
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +232 -1
- package/src/markdown-view.tsx +150 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +29 -1
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +176 -0
- package/src/message.tsx +90 -4
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
- package/src/model-provider-logo.tsx +149 -0
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- package/src/reasoning.tsx +27 -13
- package/src/sandbox.stories.tsx +9 -1
- package/src/schema-display.tsx +5 -2
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -1
- package/src/stack-trace.tsx +6 -2
- package/src/streamdown-i18n.test.tsx +1 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +67 -53
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.tsx +15 -8
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/web-preview.tsx +8 -3
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.tsx +0 -225
- package/src/terminal.tsx +0 -244
package/src/index.ts
CHANGED
|
@@ -20,11 +20,13 @@
|
|
|
20
20
|
* See ATTRIBUTION.md and .claude/rules/attribution.md.
|
|
21
21
|
*/
|
|
22
22
|
export * from "./agent";
|
|
23
|
+
export * from "./agent-event";
|
|
23
24
|
export * from "./agent-timeline";
|
|
24
25
|
export * from "./artifact";
|
|
25
26
|
export * from "./asset-preview";
|
|
26
27
|
export * from "./attachments";
|
|
27
28
|
export * from "./audio-player";
|
|
29
|
+
export * from "./audio-visualizer";
|
|
28
30
|
export * from "./canvas";
|
|
29
31
|
export * from "./chain-of-thought";
|
|
30
32
|
export * from "./chat-greeting";
|
|
@@ -34,10 +36,10 @@ export * from "./commit";
|
|
|
34
36
|
export * from "./composer";
|
|
35
37
|
export * from "./confirmation";
|
|
36
38
|
export * from "./connection";
|
|
37
|
-
export * from "./context";
|
|
38
39
|
export * from "./context-panel";
|
|
39
40
|
export * from "./controls";
|
|
40
41
|
export * from "./conversation";
|
|
42
|
+
export * from "./diff-view";
|
|
41
43
|
export * from "./edge";
|
|
42
44
|
export * from "./environment-variables";
|
|
43
45
|
export * from "./file-tree";
|
|
@@ -45,13 +47,13 @@ export * from "./gallery";
|
|
|
45
47
|
export * from "./grouped-parts";
|
|
46
48
|
export * from "./image";
|
|
47
49
|
export * from "./inline-citation";
|
|
48
|
-
export * from "./interactive-terminal";
|
|
49
50
|
export * from "./jsx-preview";
|
|
50
51
|
// Mermaid loads on first diagram render, not in the entry chunk. `preloadMermaid`
|
|
51
52
|
// warms it early when a surface is known to render diagrams.
|
|
52
53
|
export { createLazyMermaidPlugin, lazyMermaid, preloadMermaid } from "./_lazy-mermaid";
|
|
53
54
|
export * from "./markdown-view";
|
|
54
55
|
export * from "./message";
|
|
56
|
+
export * from "./message-compare";
|
|
55
57
|
export * from "./message-edit";
|
|
56
58
|
export * from "./message-feedback";
|
|
57
59
|
export * from "./message-form";
|
|
@@ -59,21 +61,27 @@ export * from "./message-form-spec";
|
|
|
59
61
|
export * from "./message-table";
|
|
60
62
|
export * from "./message-table-spec";
|
|
61
63
|
export * from "./mic-selector";
|
|
62
|
-
export * from "./model-
|
|
64
|
+
export * from "./model-provider-logo";
|
|
63
65
|
export * from "./motion-config";
|
|
64
66
|
export * from "./node";
|
|
65
67
|
export * from "./open-in-chat";
|
|
66
68
|
export * from "./package-info";
|
|
67
69
|
export * from "./panel";
|
|
68
70
|
export * from "./part-groups";
|
|
71
|
+
export * from "./permission-mode-select";
|
|
69
72
|
export * from "./persona";
|
|
70
73
|
export * from "./plan";
|
|
71
74
|
export * from "./prompt-input";
|
|
75
|
+
export * from "./prompt-input-effort";
|
|
76
|
+
export * from "./prompt-input-mode";
|
|
77
|
+
export * from "./prompt-input-slash";
|
|
72
78
|
export * from "./queue";
|
|
73
79
|
export * from "./reasoning";
|
|
74
80
|
export * from "./sandbox";
|
|
75
81
|
export * from "./schema-display";
|
|
76
82
|
export * from "./selection-toolbar";
|
|
83
|
+
export * from "./session-header";
|
|
84
|
+
export * from "./session-status-bar";
|
|
77
85
|
export * from "./shimmer";
|
|
78
86
|
export * from "./snippet";
|
|
79
87
|
export * from "./sources";
|
|
@@ -81,12 +89,14 @@ export * from "./speech-input";
|
|
|
81
89
|
export * from "./stack-trace";
|
|
82
90
|
export * from "./suggestion";
|
|
83
91
|
export * from "./task";
|
|
84
|
-
export * from "./terminal";
|
|
85
92
|
export * from "./test-results";
|
|
93
|
+
export * from "./token-usage";
|
|
86
94
|
export * from "./tool";
|
|
87
95
|
export * from "./tool-result-card";
|
|
88
96
|
export * from "./toolbar";
|
|
89
97
|
export * from "./transcription";
|
|
98
|
+
export * from "./turn-status";
|
|
99
|
+
export * from "./use-audio-level";
|
|
90
100
|
export * from "./voice-selector";
|
|
91
101
|
export * from "./web-preview";
|
|
92
102
|
|
|
@@ -18,7 +18,7 @@ function Stat({ label, value, delta }: { label: string; value: string; delta?: s
|
|
|
18
18
|
<div className="rounded-lg border bg-card p-4 text-card-foreground">
|
|
19
19
|
<div className="text-meta text-muted-foreground">{label}</div>
|
|
20
20
|
<div className="text-title tabular-nums">{value}</div>
|
|
21
|
-
{delta ? <div className="text-caption text-primary">{delta}</div> : null}
|
|
21
|
+
{delta ? <div className="text-caption text-primary-text">{delta}</div> : null}
|
|
22
22
|
</div>
|
|
23
23
|
);
|
|
24
24
|
}
|
|
@@ -50,7 +50,7 @@ const DASHBOARD =
|
|
|
50
50
|
"</div>";
|
|
51
51
|
|
|
52
52
|
const meta = {
|
|
53
|
-
title: "AI/
|
|
53
|
+
title: "AI/JSXPreview",
|
|
54
54
|
component: JSXPreview,
|
|
55
55
|
parameters: { layout: "padded" },
|
|
56
56
|
} satisfies Meta<typeof JSXPreview>;
|
|
@@ -1,11 +1,47 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import type { ComponentProps } from "react";
|
|
3
|
+
import {
|
|
4
|
+
InlineCitation,
|
|
5
|
+
InlineCitationCard,
|
|
6
|
+
InlineCitationCardBody,
|
|
7
|
+
InlineCitationCardTrigger,
|
|
8
|
+
} from "./inline-citation";
|
|
2
9
|
import { MarkdownView } from "./markdown-view";
|
|
3
10
|
|
|
4
11
|
const meta = {
|
|
5
12
|
title: "AI/MarkdownView",
|
|
6
13
|
component: MarkdownView,
|
|
7
14
|
tags: ["autodocs"],
|
|
8
|
-
parameters: {
|
|
15
|
+
parameters: {
|
|
16
|
+
layout: "padded",
|
|
17
|
+
docs: {
|
|
18
|
+
description: {
|
|
19
|
+
component:
|
|
20
|
+
"A model-authored answer or a read-only markdown DOCUMENT, rendered as a " +
|
|
21
|
+
"document — never as Shiki source. " +
|
|
22
|
+
"Pick a markdown renderer by where the markdown is going to be READ: a " +
|
|
23
|
+
"read-only document in a chat or a side rail → `AI/MarkdownView`; the preview " +
|
|
24
|
+
"pane of the markdown editor → `Editor/MarkdownPreview`; a file the app did " +
|
|
25
|
+
"not write → the markdown adapter behind `Viewer/FileViewer`; streaming into " +
|
|
26
|
+
"a message as the model writes it → `MessageResponse` on `AI/Message`. See " +
|
|
27
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs)." +
|
|
28
|
+
" Its own seams: `baseHeadingLevel` constrains the heading rungs so a " +
|
|
29
|
+
"document `#` inside a 20rem rail lands on the `title` rung instead of " +
|
|
30
|
+
"becoming the biggest text on screen, and the sanitiser chain is LOCKED — " +
|
|
31
|
+
"`rehypePlugins` is omitted at the type level and stripped at runtime, so a " +
|
|
32
|
+
"caller cannot widen what a model is allowed to emit. It understands no brand " +
|
|
33
|
+
"directives; those belong to the editor preview.\n\n" +
|
|
34
|
+
"All three map their element tree onto the same `Prose*` primitives owned " +
|
|
35
|
+
"by `@elabs-ai/components-ui` (`@elabs-ai/components-editor` re-exports them " +
|
|
36
|
+
"under short names). The element MAPS stay per-surface on purpose — " +
|
|
37
|
+
"`@elabs-ai/components-ai`, `@elabs-ai/components-editor` and " +
|
|
38
|
+
"`@elabs-ai/components-viewer` are leaves that may not import one another, and " +
|
|
39
|
+
"`streamdown` deliberately never moves down into `@elabs-ai/components-ui`, or " +
|
|
40
|
+
"every consumer of every foundation component would carry it. One prose source, " +
|
|
41
|
+
"several renderers.",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
9
45
|
} satisfies Meta<typeof MarkdownView>;
|
|
10
46
|
export default meta;
|
|
11
47
|
type Story = StoryObj<typeof meta>;
|
|
@@ -55,3 +91,58 @@ export const ConstrainedHeadings: Story = {
|
|
|
55
91
|
),
|
|
56
92
|
],
|
|
57
93
|
};
|
|
94
|
+
|
|
95
|
+
const CITED_DOC = `## Q3 answer
|
|
96
|
+
|
|
97
|
+
Revenue grew **12.4% QoQ**, led by EMEA[1](https://example.com/q3-report) with a smaller
|
|
98
|
+
lift from APAC[2](https://example.com/apac-notes).
|
|
99
|
+
|
|
100
|
+
Regular links are unaffected — see the [dashboard](https://example.com/dashboard) for the
|
|
101
|
+
full breakdown.
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
/** A markdown `a` renderer that swaps a `[1](url)`-style citation marker for an
|
|
105
|
+
* interactive `InlineCitation` chip; any other link renders exactly as before. */
|
|
106
|
+
const citationLinkComponent: NonNullable<
|
|
107
|
+
ComponentProps<typeof MarkdownView>["components"]
|
|
108
|
+
>["a"] = ({ href, children, node: _node, ...props }) => {
|
|
109
|
+
const label = typeof children === "string" ? children : "";
|
|
110
|
+
if (href && /^\d+$/.test(label)) {
|
|
111
|
+
return (
|
|
112
|
+
<InlineCitation>
|
|
113
|
+
<InlineCitationCard>
|
|
114
|
+
<InlineCitationCardTrigger sources={[href]} />
|
|
115
|
+
<InlineCitationCardBody>
|
|
116
|
+
<div className="p-3 text-meta text-muted-foreground">Source {label}</div>
|
|
117
|
+
</InlineCitationCardBody>
|
|
118
|
+
</InlineCitationCard>
|
|
119
|
+
</InlineCitation>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return (
|
|
123
|
+
<a href={href} rel="noopener noreferrer" target="_blank" {...props}>
|
|
124
|
+
{children}
|
|
125
|
+
</a>
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The #10 motivating use case: a RAG answer's markdown emits `[1](url)`-style
|
|
131
|
+
* citation markers. Overriding `components.a` swaps those for `InlineCitation`
|
|
132
|
+
* chips — every other element (the `##` heading, the "dashboard" link) still
|
|
133
|
+
* renders through the internal Prose* map because `MarkdownView` MERGES the
|
|
134
|
+
* override in per key rather than replacing the whole map.
|
|
135
|
+
*/
|
|
136
|
+
export const InlineCitations: Story = {
|
|
137
|
+
args: {
|
|
138
|
+
children: CITED_DOC,
|
|
139
|
+
components: { a: citationLinkComponent },
|
|
140
|
+
},
|
|
141
|
+
decorators: [
|
|
142
|
+
(Story) => (
|
|
143
|
+
<div className="max-w-prose">
|
|
144
|
+
<Story />
|
|
145
|
+
</div>
|
|
146
|
+
),
|
|
147
|
+
],
|
|
148
|
+
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { math } from "@streamdown/math";
|
|
1
2
|
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
-
import {
|
|
3
|
+
import type { ComponentProps } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
5
|
import { MarkdownView } from "./markdown-view";
|
|
4
6
|
|
|
5
7
|
afterEach(cleanup);
|
|
@@ -34,3 +36,232 @@ describe("MarkdownView (#193, research 04 §5 — document, not Shiki source)",
|
|
|
34
36
|
expect(screen.getByRole("heading", { level: 2, name: "Board note" })).toBeInTheDocument();
|
|
35
37
|
});
|
|
36
38
|
});
|
|
39
|
+
|
|
40
|
+
describe("MarkdownView components/plugins overrides (#10 — inline citations)", () => {
|
|
41
|
+
const CITATION_DOC = `# Board note
|
|
42
|
+
|
|
43
|
+
Revenue grew[1](https://example.com/report) this quarter.
|
|
44
|
+
|
|
45
|
+
A paragraph with [a link](https://example.com) and \`inline\` code.
|
|
46
|
+
|
|
47
|
+
- first
|
|
48
|
+
- second
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
it("uses a consumer-supplied `components` entry for one element type", () => {
|
|
52
|
+
render(
|
|
53
|
+
<MarkdownView
|
|
54
|
+
components={{
|
|
55
|
+
a: ({ href, children }) => (
|
|
56
|
+
<span data-testid="citation-chip" data-href={href}>
|
|
57
|
+
[{children}]
|
|
58
|
+
</span>
|
|
59
|
+
),
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{CITATION_DOC}
|
|
63
|
+
</MarkdownView>,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// The overridden `a` renders through the consumer's component…
|
|
67
|
+
const chips = screen.getAllByTestId("citation-chip");
|
|
68
|
+
expect(chips).toHaveLength(2);
|
|
69
|
+
expect(chips[0]).toHaveAttribute("data-href", "https://example.com/report");
|
|
70
|
+
// …and NOT through the internal ProseLink (no anchor role at all now).
|
|
71
|
+
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("keeps the internal prose styling for element types the consumer did NOT override", () => {
|
|
75
|
+
// `baseHeadingLevel` is what makes this discriminating: a plain object
|
|
76
|
+
// merge/replace of `components` still satisfies role=heading (Streamdown's
|
|
77
|
+
// OWN default `h1` is still a real <h1>), but ONLY buildProseComponents()
|
|
78
|
+
// knows about baseHeadingLevel's h1→h2 remap. If MarkdownView's merge drops
|
|
79
|
+
// the internal map for keys the consumer didn't set, this regresses to h1.
|
|
80
|
+
render(
|
|
81
|
+
<MarkdownView
|
|
82
|
+
baseHeadingLevel={2}
|
|
83
|
+
components={{
|
|
84
|
+
a: ({ children }) => <span data-testid="citation-chip">{children}</span>,
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{CITATION_DOC}
|
|
88
|
+
</MarkdownView>,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
|
|
92
|
+
expect(screen.getByRole("heading", { level: 2, name: "Board note" })).toBeInTheDocument();
|
|
93
|
+
expect(screen.getAllByRole("listitem")).toHaveLength(2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("merges a real `plugins.cjk` override in (append), keeps sanitisation on, and keeps the untouched `plugins.math` default alive (#10)", () => {
|
|
97
|
+
// A real, discriminating lock — NOT `plugins={{}}` (that exercises zero
|
|
98
|
+
// slots and passes identically under merge, replace, or a no-op; #10
|
|
99
|
+
// review I3). This test supplies a genuine `cjk` plugin (one of the two
|
|
100
|
+
// slots MarkdownView actually reaches — `code`/`mermaid`/`renderers` are
|
|
101
|
+
// consulted only inside Streamdown's OWN default `code` renderer, which
|
|
102
|
+
// `buildProseComponents()` always shadows) and proves BOTH halves of the
|
|
103
|
+
// merge property:
|
|
104
|
+
// 1. Streamdown's default rehypePlugins (raw → sanitize → harden)
|
|
105
|
+
// still hold — a <script> is stripped.
|
|
106
|
+
// 2. The supplied `cjk` plugin APPENDS (its remark transformer runs)
|
|
107
|
+
// *and* the internal `math` default the consumer did NOT set
|
|
108
|
+
// SURVIVES alongside it (`$$x^2$$` renders as real KaTeX, not
|
|
109
|
+
// literal text). A REPLACE implementation
|
|
110
|
+
// (`plugins = pluginOverrides`) would drop `math` — along with
|
|
111
|
+
// `code`/`mermaid` — the moment a consumer sets `cjk`, and this
|
|
112
|
+
// assertion would fail.
|
|
113
|
+
const cjkRemarkSpy = vi.fn(() => (tree: unknown) => tree);
|
|
114
|
+
const customCjkPlugin: NonNullable<
|
|
115
|
+
NonNullable<ComponentProps<typeof MarkdownView>["plugins"]>["cjk"]
|
|
116
|
+
> = {
|
|
117
|
+
name: "cjk",
|
|
118
|
+
remarkPlugins: [],
|
|
119
|
+
remarkPluginsAfter: [cjkRemarkSpy],
|
|
120
|
+
remarkPluginsBefore: [],
|
|
121
|
+
type: "cjk",
|
|
122
|
+
};
|
|
123
|
+
const UNSAFE_DOC = `# Note
|
|
124
|
+
|
|
125
|
+
<script>window.__pwned = true;</script>
|
|
126
|
+
|
|
127
|
+
$$x^2$$
|
|
128
|
+
`;
|
|
129
|
+
render(<MarkdownView plugins={{ cjk: customCjkPlugin }}>{UNSAFE_DOC}</MarkdownView>);
|
|
130
|
+
|
|
131
|
+
// (1) sanitisation still holds.
|
|
132
|
+
expect(document.querySelector("script")).not.toBeInTheDocument();
|
|
133
|
+
expect(document.body.textContent).not.toMatch(/pwned/);
|
|
134
|
+
// (2a) the supplied `cjk` plugin appended (its transformer ran)…
|
|
135
|
+
expect(cjkRemarkSpy).toHaveBeenCalled();
|
|
136
|
+
// (2b) …and the internal `math` default the consumer did not set is
|
|
137
|
+
// still active — real KaTeX markup, not the literal `$$x^2$$` text.
|
|
138
|
+
expect(document.querySelector(".katex")).toBeInTheDocument();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("MarkdownView sanitiser is not overridable (#36)", () => {
|
|
143
|
+
it("ignores a caller-supplied rehypePlugins array (the sanitiser is not overridable)", () => {
|
|
144
|
+
// A rehype plugin that appends a <script> AFTER the pipeline. Under the bug the
|
|
145
|
+
// consumer array REPLACES [rehypeRaw, rehypeSanitize, harden], so nothing strips it.
|
|
146
|
+
const injectScript = () => (tree: { children: unknown[] }) => {
|
|
147
|
+
tree.children.push({
|
|
148
|
+
type: "element",
|
|
149
|
+
tagName: "script",
|
|
150
|
+
properties: {},
|
|
151
|
+
children: [{ type: "text", value: "globalThis.__pwned = true" }],
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
const { container } = render(
|
|
155
|
+
// `as any`: a JS consumer, an `any`, or a wider spread object still reaches
|
|
156
|
+
// this path even after the type-level `Omit`, so the assertion must exercise
|
|
157
|
+
// the RUNTIME strip, not the type. (`as never` doesn't typecheck as a JSX
|
|
158
|
+
// spread — TS2698, "Spread types may only be created from object types" —
|
|
159
|
+
// `any` is the cast the issue itself names as the bypass vector.)
|
|
160
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- deliberate: proves the runtime strip, not the type
|
|
161
|
+
<MarkdownView {...({ rehypePlugins: [injectScript] } as any)}>{"# hi"}</MarkdownView>,
|
|
162
|
+
);
|
|
163
|
+
expect(container.querySelector("script")).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("SUPPORTS a caller-supplied remarkPlugins array, and still sanitises what it injects", () => {
|
|
167
|
+
// PR #74 review, round 1: `remarkPlugins` is NOT a sanitiser override. It runs
|
|
168
|
+
// upstream of [rehypeRaw, rehypeSanitize, harden], which Streamdown derives
|
|
169
|
+
// without reading it — so the prop stays supported (no `as any` below: the TYPE
|
|
170
|
+
// must accept it) while its output is still sanitised. Both halves are asserted,
|
|
171
|
+
// so re-adding it to the runtime strip fails, and dropping the rehype chain fails.
|
|
172
|
+
const ran = vi.fn();
|
|
173
|
+
const injectHostile = () => (tree: { children: unknown[] }) => {
|
|
174
|
+
ran();
|
|
175
|
+
tree.children.push({
|
|
176
|
+
type: "html",
|
|
177
|
+
value: '<script>globalThis.__pwned = true</script><img src="x" onerror="void 0">',
|
|
178
|
+
});
|
|
179
|
+
tree.children.push({
|
|
180
|
+
type: "paragraph",
|
|
181
|
+
data: { hName: "script", hChildren: [{ type: "text", value: "globalThis.__x = 1" }] },
|
|
182
|
+
children: [],
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
const { container } = render(
|
|
186
|
+
<MarkdownView remarkPlugins={[injectHostile]}>{"# hi"}</MarkdownView>,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(ran).toHaveBeenCalled(); // the prop really reached Streamdown
|
|
190
|
+
expect(container.querySelector("script")).toBeNull();
|
|
191
|
+
expect(container.querySelector("[onerror]")).toBeNull();
|
|
192
|
+
expect(document.body.textContent).not.toMatch(/pwned/);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("MarkdownView trusted plugin slots run after the sanitiser (#76)", () => {
|
|
197
|
+
// `plugins.math.rehypePlugin` is appended to the END of Streamdown's rehype
|
|
198
|
+
// pipeline, i.e. AFTER `rehype-raw` → `rehype-sanitize` → `rehype-harden`, so
|
|
199
|
+
// whatever it emits is never re-sanitised. That is a deliberate, documented
|
|
200
|
+
// trusted-code seam (a consumer who can supply an executable `Pluggable` can
|
|
201
|
+
// already run code in their own bundle) — but until #76 it had no runtime
|
|
202
|
+
// half at all, unlike `rehypePlugins`. These three tests are the runtime half:
|
|
203
|
+
// it WARNS (1), it does not become noise (2), and it stays OPEN (3).
|
|
204
|
+
const injectScript = () => (tree: { children: unknown[] }) => {
|
|
205
|
+
tree.children.push({
|
|
206
|
+
type: "element",
|
|
207
|
+
tagName: "script",
|
|
208
|
+
properties: {},
|
|
209
|
+
children: [{ type: "text", value: "globalThis.__pwned76 = true" }],
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
const evilMath = { ...math, rehypePlugin: injectScript };
|
|
213
|
+
|
|
214
|
+
it("warns when a consumer replaces plugins.math.rehypePlugin", () => {
|
|
215
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
216
|
+
try {
|
|
217
|
+
render(<MarkdownView plugins={{ math: evilMath }}>{"# hi"}</MarkdownView>);
|
|
218
|
+
const messages = warn.mock.calls.flat().join("\n");
|
|
219
|
+
expect(messages).toMatch(/math\.rehypePlugin/);
|
|
220
|
+
expect(messages).toMatch(/after/i);
|
|
221
|
+
} finally {
|
|
222
|
+
warn.mockRestore();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("does not warn for the default plugin set or a safe slot override", () => {
|
|
227
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
228
|
+
try {
|
|
229
|
+
render(<MarkdownView>{"# hi"}</MarkdownView>);
|
|
230
|
+
cleanup();
|
|
231
|
+
// A `cjk` override is remark-stage only — its output is re-sanitised
|
|
232
|
+
// downstream, so it is NOT a trust boundary and must stay silent. This is
|
|
233
|
+
// the arm that stops the warning degrading into noise consumers learn to
|
|
234
|
+
// ignore.
|
|
235
|
+
const customCjkPlugin: NonNullable<
|
|
236
|
+
NonNullable<ComponentProps<typeof MarkdownView>["plugins"]>["cjk"]
|
|
237
|
+
> = {
|
|
238
|
+
name: "cjk",
|
|
239
|
+
remarkPlugins: [],
|
|
240
|
+
remarkPluginsAfter: [],
|
|
241
|
+
remarkPluginsBefore: [],
|
|
242
|
+
type: "cjk",
|
|
243
|
+
};
|
|
244
|
+
render(<MarkdownView plugins={{ cjk: customCjkPlugin }}>{"# hi"}</MarkdownView>);
|
|
245
|
+
expect(warn).not.toHaveBeenCalled();
|
|
246
|
+
} finally {
|
|
247
|
+
warn.mockRestore();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("documents the boundary rather than closing it (the slot stays open)", () => {
|
|
252
|
+
// Counter-intuitive but load-bearing: this pins the deliberate trusted-code
|
|
253
|
+
// escape hatch OPEN. A future "hardening" that strips the slot would break a
|
|
254
|
+
// legitimate consumer (a real KaTeX/math plugin) silently — this fails loudly
|
|
255
|
+
// instead. The defence is the warning above plus the documented boundary,
|
|
256
|
+
// NOT removing the capability.
|
|
257
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
258
|
+
try {
|
|
259
|
+
const { container } = render(
|
|
260
|
+
<MarkdownView plugins={{ math: evilMath }}>{"# hi"}</MarkdownView>,
|
|
261
|
+
);
|
|
262
|
+
expect(container.querySelector("script")).not.toBeNull();
|
|
263
|
+
} finally {
|
|
264
|
+
warn.mockRestore();
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
});
|
package/src/markdown-view.tsx
CHANGED
|
@@ -7,8 +7,28 @@
|
|
|
7
7
|
* was the ASSET-2 defect: `CodeBlock code={…} language="markdown"`). Built on
|
|
8
8
|
* the EXISTING `streamdown` dependency (no new heavy dep), but unlike the
|
|
9
9
|
* streaming `MessageResponse` it maps the element tree onto the promoted
|
|
10
|
-
* `Prose*` primitives from `@elabs-ai/components-ui` via a `components` map
|
|
11
|
-
*
|
|
10
|
+
* `Prose*` primitives from `@elabs-ai/components-ui` via a `components` map.
|
|
11
|
+
*
|
|
12
|
+
* What is SHARED is the `Prose*` set — plus the Streamdown locale bridge in
|
|
13
|
+
* `_streamdown-i18n` and `ui`'s `streamdown-translations` — NOT the renderer and
|
|
14
|
+
* NOT the element map. The wording this docblock used to carry ("one source-owned
|
|
15
|
+
* prose set for chat answers, the editor preview and this view") kept being read
|
|
16
|
+
* as "one renderer", and it never meant that. Three branded document renderers
|
|
17
|
+
* ship, and their element MAPS stay per-surface on purpose:
|
|
18
|
+
* - this view — `baseHeadingLevel` for a constrained rung, and a sanitiser
|
|
19
|
+
* chain locked at the type level AND at runtime;
|
|
20
|
+
* - `@elabs-ai/components-editor`'s `MarkdownPreview` — the brand `:::card` /
|
|
21
|
+
* `:::callout` / `::metric` / `:::timeline` dialect, and every standard
|
|
22
|
+
* element wrapped to attach `data-sourcepos`, the ghost-diff wash and the
|
|
23
|
+
* search wash, with slug ids and a heading-action slot on headings;
|
|
24
|
+
* - `@elabs-ai/components-viewer`'s markdown adapter — a file the app did not
|
|
25
|
+
* write, so no plugins and no streaming.
|
|
26
|
+
* `ai`, `editor` and `viewer` are layer-2 leaves that may not import one
|
|
27
|
+
* another, so the half that COULD move down already has; and `streamdown` must
|
|
28
|
+
* never follow it into `ui` — see the rule stated in
|
|
29
|
+
* `packages/ui/src/lib/streamdown-translations.ts` — or every consumer of every
|
|
30
|
+
* foundation component would carry it. Re-litigated and re-ratified 2026-09-03:
|
|
31
|
+
* the three maps are the decision, not an unpaid debt.
|
|
12
32
|
*
|
|
13
33
|
* `baseHeadingLevel` is the constrained-rung seam (research 04 §5 / 09 §G.2):
|
|
14
34
|
* inside a narrow rail pass `baseHeadingLevel={2}` so a document `#` renders
|
|
@@ -26,12 +46,18 @@ import {
|
|
|
26
46
|
type ProseHeadingLevel,
|
|
27
47
|
} from "@elabs-ai/components-ui";
|
|
28
48
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
29
|
-
import {
|
|
49
|
+
import { stripSanitizerOverrides, warnOnTrustedPluginSlots } from "./_streamdown-safety";
|
|
50
|
+
import {
|
|
51
|
+
useStreamdownMermaidOptions,
|
|
52
|
+
useStreamdownPlugins,
|
|
53
|
+
useStreamdownTranslations,
|
|
54
|
+
} from "./_streamdown-i18n";
|
|
30
55
|
import type { ComponentProps } from "react";
|
|
31
56
|
import { useMemo } from "react";
|
|
32
57
|
import { Streamdown } from "streamdown";
|
|
33
58
|
|
|
34
59
|
type StreamdownComponents = NonNullable<ComponentProps<typeof Streamdown>["components"]>;
|
|
60
|
+
type StreamdownPlugins = ComponentProps<typeof Streamdown>["plugins"];
|
|
35
61
|
|
|
36
62
|
const MAX_HEADING_LEVEL = 6;
|
|
37
63
|
|
|
@@ -75,7 +101,7 @@ function buildProseComponents(baseHeadingLevel: ProseHeadingLevel): StreamdownCo
|
|
|
75
101
|
|
|
76
102
|
export interface MarkdownViewProps extends Omit<
|
|
77
103
|
ComponentProps<typeof Streamdown>,
|
|
78
|
-
"components" | "plugins"
|
|
104
|
+
"components" | "plugins" | "rehypePlugins"
|
|
79
105
|
> {
|
|
80
106
|
/**
|
|
81
107
|
* The prose level a markdown `#` maps to; deeper headings shift with it
|
|
@@ -83,21 +109,138 @@ export interface MarkdownViewProps extends Omit<
|
|
|
83
109
|
* embeds (a context rail) so headings stay on the constrained rung.
|
|
84
110
|
*/
|
|
85
111
|
baseHeadingLevel?: ProseHeadingLevel;
|
|
112
|
+
/**
|
|
113
|
+
* Per-element renderer overrides, MERGED over the internal Prose* map
|
|
114
|
+
* (#10) — a consumer entry wins for its key; every key the consumer does
|
|
115
|
+
* NOT set keeps rendering through `buildProseComponents()`. This is the
|
|
116
|
+
* seam for rendering inline citations: override `a` (or a custom node
|
|
117
|
+
* type) to swap a `[1](url)`-style marker for an `InlineCitation` chip
|
|
118
|
+
* while headings/lists/etc. keep the branded prose styling.
|
|
119
|
+
*
|
|
120
|
+
* Deliberately a MERGE, not a replace: sanitisation is unaffected either
|
|
121
|
+
* way (see `plugins` below), but a wholesale replace would silently drop
|
|
122
|
+
* the branded styling from every element the consumer didn't think to
|
|
123
|
+
* re-declare — a citation override should not have to also re-implement
|
|
124
|
+
* headings/lists/code to keep them on-brand.
|
|
125
|
+
*
|
|
126
|
+
* **Known constraint: a `components` change alone does not force a
|
|
127
|
+
* re-render of already-rendered markdown (#10).** Streamdown memoizes on
|
|
128
|
+
* `children`/`plugins`/theme/etc but NOT on `components`
|
|
129
|
+
* (`streamdown@2.5.0`'s top-level `memo` comparator omits it), so if the
|
|
130
|
+
* SAME markdown string is still current when an override's closed-over
|
|
131
|
+
* data changes — e.g. an inline-citation chip that resolves its title
|
|
132
|
+
* asynchronously — the new render function is captured but nothing
|
|
133
|
+
* schedules Streamdown to call it again. Don't rely on a `components`
|
|
134
|
+
* closure to reflect state that arrives after the initial render; instead
|
|
135
|
+
* have the overriding component read that state itself (a shared context,
|
|
136
|
+
* a store keyed by citation id, or an internal `useState`/subscription
|
|
137
|
+
* inside the override) so IT re-renders independently of `MarkdownView`'s
|
|
138
|
+
* own render pass — the pattern `InlineCitation`/`InlineCitationCard`
|
|
139
|
+
* already use.
|
|
140
|
+
*/
|
|
141
|
+
components?: StreamdownComponents;
|
|
142
|
+
/**
|
|
143
|
+
* Streamdown plugin-slot overrides (`cjk`/`code`/`math`/`mermaid`/
|
|
144
|
+
* `renderers`), MERGED per key over the internal defaults (the reactive,
|
|
145
|
+
* brand-token-derived `code` plugin and i18n-aware `cjk`/`math`/`mermaid`
|
|
146
|
+
* set — see `_streamdown-i18n.ts`).
|
|
147
|
+
*
|
|
148
|
+
* **This narrow `plugins` prop can only APPEND — it can never displace
|
|
149
|
+
* Streamdown's default `rehypePlugins` chain** (`rehype-raw` →
|
|
150
|
+
* `rehype-sanitize` → `rehype-harden`). `MarkdownView` never sets
|
|
151
|
+
* `rehypePlugins` itself, and nothing in `PluginConfig` removes or replaces
|
|
152
|
+
* a member of that pipeline, so the sanitiser cannot be turned off through
|
|
153
|
+
* this prop. But two of the five slots are **not** upstream of it and must
|
|
154
|
+
* be treated as trusted code:
|
|
155
|
+
* - **`math.rehypePlugin` runs AFTER `rehype-sanitize`/`rehype-harden`**
|
|
156
|
+
* (appended to the end of the rehype pipeline, verified against
|
|
157
|
+
* `streamdown@2.5.0`'s `dist/chunk-BO2N2NFS.js`) — its output is never
|
|
158
|
+
* re-sanitised.
|
|
159
|
+
* - **`mermaid` never enters the rehype/remark pipeline at all.** Its
|
|
160
|
+
* `getMermaid().render()` result is written via
|
|
161
|
+
* `dangerouslySetInnerHTML` (streamdown's only such sink). brand-ui's own
|
|
162
|
+
* `useStreamdownPlugins()` default pins `securityLevel: "strict"`
|
|
163
|
+
* (`_lazy-mermaid.ts`); a replacement `mermaid` plugin must sanitise its
|
|
164
|
+
* own SVG output the same way.
|
|
165
|
+
* - `cjk` (remark-stage only — its output is re-sanitised downstream by
|
|
166
|
+
* the rehype pipeline like any other remark result), `code` (feeds only
|
|
167
|
+
* `shikiTheme`, no HTML injection) and `renderers` (ordinary React
|
|
168
|
+
* components rendered with the fenced block's raw `code` string as a
|
|
169
|
+
* `string` prop, the same trusted-component boundary as a `components`
|
|
170
|
+
* override) do not bypass sanitisation.
|
|
171
|
+
*
|
|
172
|
+
* **`rehypePlugins` is NOT exposed on this component (#36, fixed).** Unlike
|
|
173
|
+
* `plugins`, a caller-supplied `rehypePlugins` array would REPLACE
|
|
174
|
+
* Streamdown's default rehype pipeline wholesale rather than extending it,
|
|
175
|
+
* silently dropping `rehype-raw`/`rehype-sanitize`/`rehype-harden` and
|
|
176
|
+
* letting a plain markdown string execute script in the host page — so
|
|
177
|
+
* `MarkdownViewProps` `Omit`s it at the type level AND
|
|
178
|
+
* `stripSanitizerOverrides()` deletes it at runtime before the `{...props}`
|
|
179
|
+
* spread below, even if a JS consumer or a cast reaches this component with
|
|
180
|
+
* it set. If you need to widen sanitisation, use
|
|
181
|
+
* `allowedTags`/`literalTagContent`, which MERGE into the sanitize schema
|
|
182
|
+
* instead of replacing the pipeline. See
|
|
183
|
+
* `packages/ai/src/_streamdown-safety.ts`.
|
|
184
|
+
*
|
|
185
|
+
* **`remarkPlugins` IS supported** (PR #74 review, round 1 — the original
|
|
186
|
+
* #36 fix over-reached and removed it too). The remark stage runs strictly
|
|
187
|
+
* upstream of the rehype chain, and Streamdown builds its rehype list
|
|
188
|
+
* without reading `remarkPlugins`, so everything a remark plugin emits is
|
|
189
|
+
* still sanitised downstream — measured across raw-`html` mdast nodes,
|
|
190
|
+
* `data.hName`/`hChildren` hast elements, `hProperties` event handlers,
|
|
191
|
+
* `javascript:` link URLs and smuggled `raw` hast children. Note the
|
|
192
|
+
* ordinary (non-security) footgun: your array REPLACES Streamdown's own
|
|
193
|
+
* remark defaults (`remark-gfm`, `codeMeta`), so spread
|
|
194
|
+
* `Object.values(defaultRemarkPlugins)` from `streamdown` back in if you
|
|
195
|
+
* want to keep GFM tables/strikethrough.
|
|
196
|
+
*/
|
|
197
|
+
plugins?: StreamdownPlugins;
|
|
86
198
|
}
|
|
87
199
|
|
|
88
200
|
export const MarkdownView = ({
|
|
89
201
|
baseHeadingLevel = 1,
|
|
90
202
|
className,
|
|
91
203
|
children,
|
|
204
|
+
components: componentOverrides,
|
|
205
|
+
plugins: pluginOverrides,
|
|
92
206
|
...props
|
|
93
207
|
}: MarkdownViewProps) => {
|
|
94
|
-
|
|
208
|
+
// Streamdown installs [rehypeRaw, rehypeSanitize, harden] as the DEFAULT VALUE of
|
|
209
|
+
// `rehypePlugins`; a supplied array REPLACES it. This component renders untrusted
|
|
210
|
+
// model output, so the chain is not overridable. Widen with `allowedTags` /
|
|
211
|
+
// `literalTagContent`, which merge into the sanitize schema. See issue #36.
|
|
212
|
+
// `remarkPlugins` is deliberately left alone — it runs upstream of the rehype
|
|
213
|
+
// chain and cannot bypass it (PR #74 review; see `_streamdown-safety.ts`).
|
|
214
|
+
stripSanitizerOverrides(props);
|
|
215
|
+
const internalComponents = useMemo(
|
|
216
|
+
() => buildProseComponents(baseHeadingLevel),
|
|
217
|
+
[baseHeadingLevel],
|
|
218
|
+
);
|
|
219
|
+
// Consumer entries win per key; every other key falls through to the
|
|
220
|
+
// internal Prose* map — see the `components` prop doc above.
|
|
221
|
+
const components = useMemo(
|
|
222
|
+
() => ({ ...internalComponents, ...componentOverrides }),
|
|
223
|
+
[internalComponents, componentOverrides],
|
|
224
|
+
);
|
|
95
225
|
// Streamdown's own chrome (code copy, table menus) reads the locale seam (#310).
|
|
96
226
|
// Spread AFTER so an explicit `translations` prop still wins (ADR 0017).
|
|
97
227
|
const translations = useStreamdownTranslations();
|
|
98
228
|
// Brand-token-derived `code` plugin, not the package's static github-*
|
|
99
229
|
// default (#315 follow-up) — re-derives when the active theme changes.
|
|
100
|
-
const
|
|
230
|
+
const internalPlugins = useStreamdownPlugins();
|
|
231
|
+
// Same per-key merge as `components` — see the `plugins` prop doc above.
|
|
232
|
+
// `math.rehypePlugin`/`mermaid` are the two slots that land in the DOM after
|
|
233
|
+
// (or outside) the sanitiser chain, so replacing one is a TRUSTED-CODE
|
|
234
|
+
// decision. The runtime half of that documented boundary is a dev warning —
|
|
235
|
+
// the plugin still runs; stripping it would break the legitimate use (#76).
|
|
236
|
+
const plugins = useMemo(() => {
|
|
237
|
+
warnOnTrustedPluginSlots(pluginOverrides, internalPlugins);
|
|
238
|
+
return { ...internalPlugins, ...pluginOverrides };
|
|
239
|
+
}, [internalPlugins, pluginOverrides]);
|
|
240
|
+
// The TOP-LEVEL Streamdown `mermaid` prop (distinct from `plugins.mermaid`
|
|
241
|
+
// above) — catches a failed diagram render, including a missing `mermaid`
|
|
242
|
+
// optional peer (issue #33). See `_streamdown-i18n.ts`.
|
|
243
|
+
const mermaidOptions = useStreamdownMermaidOptions();
|
|
101
244
|
return (
|
|
102
245
|
<Streamdown
|
|
103
246
|
data-slot="markdown-view"
|
|
@@ -105,6 +248,7 @@ export const MarkdownView = ({
|
|
|
105
248
|
components={components}
|
|
106
249
|
plugins={plugins}
|
|
107
250
|
translations={translations}
|
|
251
|
+
mermaid={mermaidOptions}
|
|
108
252
|
{...props}
|
|
109
253
|
>
|
|
110
254
|
{children}
|