@elabs-ai/components-ai 4.0.0 → 4.2.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/README.md +62 -12
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1152 -218
  9. package/dist/index.js +5601 -3241
  10. package/dist/index.js.map +1 -1
  11. package/package.json +30 -15
  12. package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
  13. package/src/__contract__/chat-shell.contract.test.tsx +49 -0
  14. package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
  15. package/src/__contract__/image.contract.test.tsx +49 -0
  16. package/src/__contract__/markdown-view.contract.test.tsx +49 -0
  17. package/src/__contract__/message-feedback.contract.test.tsx +49 -0
  18. package/src/__contract__/message-form.contract.test.tsx +49 -0
  19. package/src/__contract__/message-table.contract.test.tsx +49 -0
  20. package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
  21. package/src/__contract__/persona.contract.test.tsx +49 -0
  22. package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
  23. package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
  24. package/src/_audio-player-media-chrome.tsx +102 -15
  25. package/src/_chat-shell-rail.tsx +2 -2
  26. package/src/_flow-boundary.tsx +68 -49
  27. package/src/_lazy-boundary-conformance.ts +38 -0
  28. package/src/_lazy-cjk.test.ts +43 -0
  29. package/src/_lazy-cjk.ts +73 -0
  30. package/src/_lazy-engine-boundary.tsx +61 -0
  31. package/src/_lazy-math.test.ts +63 -0
  32. package/src/_lazy-math.ts +90 -0
  33. package/src/_lazy-mermaid-absent.test.ts +53 -0
  34. package/src/_lazy-mermaid.test.ts +15 -0
  35. package/src/_lazy-mermaid.ts +24 -1
  36. package/src/_mermaid-error-panel.test.tsx +50 -0
  37. package/src/_mermaid-error-panel.tsx +66 -0
  38. package/src/_persona-rive.tsx +62 -10
  39. package/src/_streamdown-i18n.ts +94 -22
  40. package/src/_streamdown-safety.ts +170 -0
  41. package/src/_theme-scope-store.test.ts +83 -0
  42. package/src/_theme-scope-store.ts +103 -0
  43. package/src/agent-event.stories.tsx +97 -0
  44. package/src/agent-event.test.tsx +145 -0
  45. package/src/agent-event.tsx +187 -0
  46. package/src/agent-timeline.stories.tsx +9 -1
  47. package/src/agent.test.tsx +47 -0
  48. package/src/agent.tsx +43 -24
  49. package/src/agentic-workspace.stories.tsx +27 -20
  50. package/src/artifact.tsx +17 -14
  51. package/src/asset-preview.test.tsx +40 -0
  52. package/src/asset-preview.tsx +83 -12
  53. package/src/attachments.tsx +7 -4
  54. package/src/audio-player.test.tsx +78 -0
  55. package/src/audio-player.tsx +243 -56
  56. package/src/audio-visualizer.stories.tsx +126 -0
  57. package/src/audio-visualizer.test.tsx +438 -0
  58. package/src/audio-visualizer.tsx +367 -0
  59. package/src/canvas.stories.tsx +150 -1
  60. package/src/chat-shell.stories.tsx +18 -3
  61. package/src/chat.stories.tsx +16 -2
  62. package/src/code-block.stories.tsx +9 -1
  63. package/src/code-block.test.tsx +100 -1
  64. package/src/code-block.tsx +166 -103
  65. package/src/commit.tsx +30 -41
  66. package/src/composer.stories.tsx +372 -9
  67. package/src/composer.test.tsx +357 -6
  68. package/src/composer.tsx +217 -35
  69. package/src/confirmation.stories.tsx +72 -1
  70. package/src/confirmation.test.tsx +216 -2
  71. package/src/confirmation.tsx +264 -4
  72. package/src/context-panel.stories.tsx +9 -1
  73. package/src/context-panel.tsx +3 -2
  74. package/src/conversation.stories.tsx +86 -2
  75. package/src/conversation.test.tsx +66 -0
  76. package/src/conversation.tsx +52 -9
  77. package/src/diff-view.stories.tsx +196 -0
  78. package/src/diff-view.test.tsx +238 -0
  79. package/src/diff-view.tsx +697 -0
  80. package/src/environment-variables.tsx +20 -37
  81. package/src/file-tree.test.tsx +21 -0
  82. package/src/file-tree.tsx +12 -2
  83. package/src/gallery.tsx +4 -2
  84. package/src/index.ts +14 -4
  85. package/src/inline-citation.tsx +5 -5
  86. package/src/jsx-preview.stories.tsx +2 -2
  87. package/src/jsx-preview.tsx +151 -43
  88. package/src/markdown-view.stories.tsx +92 -1
  89. package/src/markdown-view.test.tsx +237 -2
  90. package/src/markdown-view.tsx +157 -6
  91. package/src/message-compare.stories.tsx +175 -0
  92. package/src/message-compare.test.tsx +207 -0
  93. package/src/message-compare.tsx +453 -0
  94. package/src/message-form.stories.tsx +65 -4
  95. package/src/message-form.test.tsx +8 -2
  96. package/src/message-form.tsx +15 -7
  97. package/src/message-table.stories.tsx +2 -2
  98. package/src/message-table.test.tsx +7 -0
  99. package/src/message-table.tsx +8 -4
  100. package/src/message.stories.tsx +9 -1
  101. package/src/message.test.tsx +247 -1
  102. package/src/message.tsx +125 -15
  103. package/src/microcopy.test.tsx +40 -0
  104. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  105. package/src/model-provider-logo.test.tsx +109 -0
  106. package/src/model-provider-logo.tsx +183 -0
  107. package/src/open-in-chat.tsx +50 -29
  108. package/src/package-info.tsx +12 -12
  109. package/src/permission-mode-select.stories.tsx +82 -0
  110. package/src/permission-mode-select.test.tsx +100 -0
  111. package/src/permission-mode-select.tsx +137 -0
  112. package/src/persona-missing-peer.test.tsx +54 -0
  113. package/src/persona.tsx +68 -22
  114. package/src/plan.stories.tsx +166 -0
  115. package/src/plan.test.tsx +267 -0
  116. package/src/plan.tsx +182 -20
  117. package/src/prompt-input-effort.stories.tsx +123 -0
  118. package/src/prompt-input-effort.test.tsx +83 -0
  119. package/src/prompt-input-effort.tsx +136 -0
  120. package/src/prompt-input-mode.stories.tsx +108 -0
  121. package/src/prompt-input-mode.test.tsx +99 -0
  122. package/src/prompt-input-mode.tsx +169 -0
  123. package/src/prompt-input-slash.stories.tsx +211 -0
  124. package/src/prompt-input-slash.test.tsx +262 -0
  125. package/src/prompt-input-slash.tsx +541 -0
  126. package/src/prompt-input.stories.tsx +2 -2
  127. package/src/prompt-input.test.tsx +67 -1
  128. package/src/prompt-input.tsx +42 -4
  129. package/src/queue.tsx +4 -4
  130. package/src/reasoning.tsx +42 -17
  131. package/src/sandbox.stories.tsx +9 -1
  132. package/src/sandbox.tsx +3 -3
  133. package/src/schema-display.test.tsx +56 -0
  134. package/src/schema-display.tsx +71 -37
  135. package/src/selection-toolbar.stories.tsx +9 -1
  136. package/src/session-header.stories.tsx +128 -0
  137. package/src/session-header.test.tsx +138 -0
  138. package/src/session-header.tsx +243 -0
  139. package/src/session-status-bar.stories.tsx +73 -0
  140. package/src/session-status-bar.test.tsx +94 -0
  141. package/src/session-status-bar.tsx +165 -0
  142. package/src/snippet.stories.tsx +9 -1
  143. package/src/snippet.test.tsx +6 -2
  144. package/src/snippet.tsx +14 -34
  145. package/src/speech-input.test.tsx +109 -0
  146. package/src/speech-input.tsx +31 -3
  147. package/src/stack-trace.tsx +24 -36
  148. package/src/streamdown-i18n.test.tsx +24 -1
  149. package/src/task.stories.tsx +13 -3
  150. package/src/templates-ai-assistant.stories.tsx +21 -1
  151. package/src/test-results.tsx +47 -27
  152. package/src/token-usage.stories.tsx +24 -0
  153. package/src/token-usage.test.tsx +92 -0
  154. package/src/{context.tsx → token-usage.tsx} +73 -59
  155. package/src/tool-result-card.stories.tsx +9 -1
  156. package/src/tool.stories.tsx +13 -3
  157. package/src/tool.test.tsx +65 -0
  158. package/src/tool.tsx +73 -30
  159. package/src/transcription.tsx +1 -1
  160. package/src/turn-status.stories.tsx +124 -0
  161. package/src/turn-status.test.tsx +74 -0
  162. package/src/turn-status.tsx +174 -0
  163. package/src/use-audio-level.ts +104 -0
  164. package/src/voice-selector.tsx +5 -5
  165. package/src/web-preview.test.tsx +51 -1
  166. package/src/web-preview.tsx +46 -10
  167. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  168. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  169. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  170. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  171. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  172. package/src/_interactive-terminal-xterm.ts +0 -32
  173. package/src/blocks-ai-composer.stories.tsx +0 -83
  174. package/src/context.stories.tsx +0 -16
  175. package/src/interactive-terminal.stories.tsx +0 -165
  176. package/src/interactive-terminal.test.tsx +0 -448
  177. package/src/interactive-terminal.tsx +0 -444
  178. package/src/model-selector.test.tsx +0 -55
  179. package/src/model-selector.tsx +0 -225
  180. package/src/terminal.tsx +0 -244
@@ -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: { layout: "padded" },
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 { cleanup, render, screen } from "@testing-library/react";
2
- import { afterEach, describe, expect, it } from "vitest";
1
+ import { math } from "@streamdown/math";
2
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
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,236 @@ 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)", async () => {
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
+ // `math` is now lazy-loaded off the source text (#perf-5) — it starts
139
+ // `undefined` and arrives after a dynamic import, hence `waitFor`.
140
+ await waitFor(() => {
141
+ expect(document.querySelector(".katex")).toBeInTheDocument();
142
+ });
143
+ });
144
+ });
145
+
146
+ describe("MarkdownView sanitiser is not overridable (#36)", () => {
147
+ it("ignores a caller-supplied rehypePlugins array (the sanitiser is not overridable)", () => {
148
+ // A rehype plugin that appends a <script> AFTER the pipeline. Under the bug the
149
+ // consumer array REPLACES [rehypeRaw, rehypeSanitize, harden], so nothing strips it.
150
+ const injectScript = () => (tree: { children: unknown[] }) => {
151
+ tree.children.push({
152
+ type: "element",
153
+ tagName: "script",
154
+ properties: {},
155
+ children: [{ type: "text", value: "globalThis.__pwned = true" }],
156
+ });
157
+ };
158
+ const { container } = render(
159
+ // `as any`: a JS consumer, an `any`, or a wider spread object still reaches
160
+ // this path even after the type-level `Omit`, so the assertion must exercise
161
+ // the RUNTIME strip, not the type. (`as never` doesn't typecheck as a JSX
162
+ // spread — TS2698, "Spread types may only be created from object types" —
163
+ // `any` is the cast the issue itself names as the bypass vector.)
164
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- deliberate: proves the runtime strip, not the type
165
+ <MarkdownView {...({ rehypePlugins: [injectScript] } as any)}>{"# hi"}</MarkdownView>,
166
+ );
167
+ expect(container.querySelector("script")).toBeNull();
168
+ });
169
+
170
+ it("SUPPORTS a caller-supplied remarkPlugins array, and still sanitises what it injects", () => {
171
+ // PR #74 review, round 1: `remarkPlugins` is NOT a sanitiser override. It runs
172
+ // upstream of [rehypeRaw, rehypeSanitize, harden], which Streamdown derives
173
+ // without reading it — so the prop stays supported (no `as any` below: the TYPE
174
+ // must accept it) while its output is still sanitised. Both halves are asserted,
175
+ // so re-adding it to the runtime strip fails, and dropping the rehype chain fails.
176
+ const ran = vi.fn();
177
+ const injectHostile = () => (tree: { children: unknown[] }) => {
178
+ ran();
179
+ tree.children.push({
180
+ type: "html",
181
+ value: '<script>globalThis.__pwned = true</script><img src="x" onerror="void 0">',
182
+ });
183
+ tree.children.push({
184
+ type: "paragraph",
185
+ data: { hName: "script", hChildren: [{ type: "text", value: "globalThis.__x = 1" }] },
186
+ children: [],
187
+ });
188
+ };
189
+ const { container } = render(
190
+ <MarkdownView remarkPlugins={[injectHostile]}>{"# hi"}</MarkdownView>,
191
+ );
192
+
193
+ expect(ran).toHaveBeenCalled(); // the prop really reached Streamdown
194
+ expect(container.querySelector("script")).toBeNull();
195
+ expect(container.querySelector("[onerror]")).toBeNull();
196
+ expect(document.body.textContent).not.toMatch(/pwned/);
197
+ });
198
+ });
199
+
200
+ describe("MarkdownView trusted plugin slots run after the sanitiser (#76)", () => {
201
+ // `plugins.math.rehypePlugin` is appended to the END of Streamdown's rehype
202
+ // pipeline, i.e. AFTER `rehype-raw` → `rehype-sanitize` → `rehype-harden`, so
203
+ // whatever it emits is never re-sanitised. That is a deliberate, documented
204
+ // trusted-code seam (a consumer who can supply an executable `Pluggable` can
205
+ // already run code in their own bundle) — but until #76 it had no runtime
206
+ // half at all, unlike `rehypePlugins`. These three tests are the runtime half:
207
+ // it WARNS (1), it does not become noise (2), and it stays OPEN (3).
208
+ const injectScript = () => (tree: { children: unknown[] }) => {
209
+ tree.children.push({
210
+ type: "element",
211
+ tagName: "script",
212
+ properties: {},
213
+ children: [{ type: "text", value: "globalThis.__pwned76 = true" }],
214
+ });
215
+ };
216
+ const evilMath = { ...math, rehypePlugin: injectScript };
217
+
218
+ it("warns when a consumer replaces plugins.math.rehypePlugin", () => {
219
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
220
+ try {
221
+ render(<MarkdownView plugins={{ math: evilMath }}>{"# hi"}</MarkdownView>);
222
+ const messages = warn.mock.calls.flat().join("\n");
223
+ expect(messages).toMatch(/math\.rehypePlugin/);
224
+ expect(messages).toMatch(/after/i);
225
+ } finally {
226
+ warn.mockRestore();
227
+ }
228
+ });
229
+
230
+ it("does not warn for the default plugin set or a safe slot override", () => {
231
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
232
+ try {
233
+ render(<MarkdownView>{"# hi"}</MarkdownView>);
234
+ cleanup();
235
+ // A `cjk` override is remark-stage only — its output is re-sanitised
236
+ // downstream, so it is NOT a trust boundary and must stay silent. This is
237
+ // the arm that stops the warning degrading into noise consumers learn to
238
+ // ignore.
239
+ const customCjkPlugin: NonNullable<
240
+ NonNullable<ComponentProps<typeof MarkdownView>["plugins"]>["cjk"]
241
+ > = {
242
+ name: "cjk",
243
+ remarkPlugins: [],
244
+ remarkPluginsAfter: [],
245
+ remarkPluginsBefore: [],
246
+ type: "cjk",
247
+ };
248
+ render(<MarkdownView plugins={{ cjk: customCjkPlugin }}>{"# hi"}</MarkdownView>);
249
+ expect(warn).not.toHaveBeenCalled();
250
+ } finally {
251
+ warn.mockRestore();
252
+ }
253
+ });
254
+
255
+ it("documents the boundary rather than closing it (the slot stays open)", () => {
256
+ // Counter-intuitive but load-bearing: this pins the deliberate trusted-code
257
+ // escape hatch OPEN. A future "hardening" that strips the slot would break a
258
+ // legitimate consumer (a real KaTeX/math plugin) silently — this fails loudly
259
+ // instead. The defence is the warning above plus the documented boundary,
260
+ // NOT removing the capability.
261
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
262
+ try {
263
+ const { container } = render(
264
+ <MarkdownView plugins={{ math: evilMath }}>{"# hi"}</MarkdownView>,
265
+ );
266
+ expect(container.querySelector("script")).not.toBeNull();
267
+ } finally {
268
+ warn.mockRestore();
269
+ }
270
+ });
271
+ });
@@ -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 — one
11
- * source-owned prose set for chat answers, the editor preview and this view.
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,19 @@ 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 { useStreamdownPlugins, useStreamdownTranslations } from "./_streamdown-i18n";
49
+ import { stripSanitizerOverrides, warnOnTrustedPluginSlots } from "./_streamdown-safety";
50
+ import {
51
+ getStreamdownPluginsKey,
52
+ useStreamdownMermaidOptions,
53
+ useStreamdownPlugins,
54
+ useStreamdownTranslations,
55
+ } from "./_streamdown-i18n";
30
56
  import type { ComponentProps } from "react";
31
57
  import { useMemo } from "react";
32
58
  import { Streamdown } from "streamdown";
33
59
 
34
60
  type StreamdownComponents = NonNullable<ComponentProps<typeof Streamdown>["components"]>;
61
+ type StreamdownPlugins = ComponentProps<typeof Streamdown>["plugins"];
35
62
 
36
63
  const MAX_HEADING_LEVEL = 6;
37
64
 
@@ -75,7 +102,7 @@ function buildProseComponents(baseHeadingLevel: ProseHeadingLevel): StreamdownCo
75
102
 
76
103
  export interface MarkdownViewProps extends Omit<
77
104
  ComponentProps<typeof Streamdown>,
78
- "components" | "plugins"
105
+ "components" | "plugins" | "rehypePlugins"
79
106
  > {
80
107
  /**
81
108
  * The prose level a markdown `#` maps to; deeper headings shift with it
@@ -83,28 +110,152 @@ export interface MarkdownViewProps extends Omit<
83
110
  * embeds (a context rail) so headings stay on the constrained rung.
84
111
  */
85
112
  baseHeadingLevel?: ProseHeadingLevel;
113
+ /**
114
+ * Per-element renderer overrides, MERGED over the internal Prose* map
115
+ * (#10) — a consumer entry wins for its key; every key the consumer does
116
+ * NOT set keeps rendering through `buildProseComponents()`. This is the
117
+ * seam for rendering inline citations: override `a` (or a custom node
118
+ * type) to swap a `[1](url)`-style marker for an `InlineCitation` chip
119
+ * while headings/lists/etc. keep the branded prose styling.
120
+ *
121
+ * Deliberately a MERGE, not a replace: sanitisation is unaffected either
122
+ * way (see `plugins` below), but a wholesale replace would silently drop
123
+ * the branded styling from every element the consumer didn't think to
124
+ * re-declare — a citation override should not have to also re-implement
125
+ * headings/lists/code to keep them on-brand.
126
+ *
127
+ * **Known constraint: a `components` change alone does not force a
128
+ * re-render of already-rendered markdown (#10).** Streamdown memoizes on
129
+ * `children`/`plugins`/theme/etc but NOT on `components`
130
+ * (`streamdown@2.5.0`'s top-level `memo` comparator omits it), so if the
131
+ * SAME markdown string is still current when an override's closed-over
132
+ * data changes — e.g. an inline-citation chip that resolves its title
133
+ * asynchronously — the new render function is captured but nothing
134
+ * schedules Streamdown to call it again. Don't rely on a `components`
135
+ * closure to reflect state that arrives after the initial render; instead
136
+ * have the overriding component read that state itself (a shared context,
137
+ * a store keyed by citation id, or an internal `useState`/subscription
138
+ * inside the override) so IT re-renders independently of `MarkdownView`'s
139
+ * own render pass — the pattern `InlineCitation`/`InlineCitationCard`
140
+ * already use.
141
+ */
142
+ components?: StreamdownComponents;
143
+ /**
144
+ * Streamdown plugin-slot overrides (`cjk`/`code`/`math`/`mermaid`/
145
+ * `renderers`), MERGED per key over the internal defaults (the reactive,
146
+ * brand-token-derived `code` plugin and i18n-aware `cjk`/`math`/`mermaid`
147
+ * set — see `_streamdown-i18n.ts`).
148
+ *
149
+ * **This narrow `plugins` prop can only APPEND — it can never displace
150
+ * Streamdown's default `rehypePlugins` chain** (`rehype-raw` →
151
+ * `rehype-sanitize` → `rehype-harden`). `MarkdownView` never sets
152
+ * `rehypePlugins` itself, and nothing in `PluginConfig` removes or replaces
153
+ * a member of that pipeline, so the sanitiser cannot be turned off through
154
+ * this prop. But two of the five slots are **not** upstream of it and must
155
+ * be treated as trusted code:
156
+ * - **`math.rehypePlugin` runs AFTER `rehype-sanitize`/`rehype-harden`**
157
+ * (appended to the end of the rehype pipeline, verified against
158
+ * `streamdown@2.5.0`'s `dist/chunk-BO2N2NFS.js`) — its output is never
159
+ * re-sanitised.
160
+ * - **`mermaid` never enters the rehype/remark pipeline at all.** Its
161
+ * `getMermaid().render()` result is written via
162
+ * `dangerouslySetInnerHTML` (streamdown's only such sink). brand-ui's own
163
+ * `useStreamdownPlugins()` default pins `securityLevel: "strict"`
164
+ * (`_lazy-mermaid.ts`); a replacement `mermaid` plugin must sanitise its
165
+ * own SVG output the same way.
166
+ * - `cjk` (remark-stage only — its output is re-sanitised downstream by
167
+ * the rehype pipeline like any other remark result), `code` (feeds only
168
+ * `shikiTheme`, no HTML injection) and `renderers` (ordinary React
169
+ * components rendered with the fenced block's raw `code` string as a
170
+ * `string` prop, the same trusted-component boundary as a `components`
171
+ * override) do not bypass sanitisation.
172
+ *
173
+ * **`rehypePlugins` is NOT exposed on this component (#36, fixed).** Unlike
174
+ * `plugins`, a caller-supplied `rehypePlugins` array would REPLACE
175
+ * Streamdown's default rehype pipeline wholesale rather than extending it,
176
+ * silently dropping `rehype-raw`/`rehype-sanitize`/`rehype-harden` and
177
+ * letting a plain markdown string execute script in the host page — so
178
+ * `MarkdownViewProps` `Omit`s it at the type level AND
179
+ * `stripSanitizerOverrides()` deletes it at runtime before the `{...props}`
180
+ * spread below, even if a JS consumer or a cast reaches this component with
181
+ * it set. If you need to widen sanitisation, use
182
+ * `allowedTags`/`literalTagContent`, which MERGE into the sanitize schema
183
+ * instead of replacing the pipeline. See
184
+ * `packages/ai/src/_streamdown-safety.ts`.
185
+ *
186
+ * **`remarkPlugins` IS supported** (PR #74 review, round 1 — the original
187
+ * #36 fix over-reached and removed it too). The remark stage runs strictly
188
+ * upstream of the rehype chain, and Streamdown builds its rehype list
189
+ * without reading `remarkPlugins`, so everything a remark plugin emits is
190
+ * still sanitised downstream — measured across raw-`html` mdast nodes,
191
+ * `data.hName`/`hChildren` hast elements, `hProperties` event handlers,
192
+ * `javascript:` link URLs and smuggled `raw` hast children. Note the
193
+ * ordinary (non-security) footgun: your array REPLACES Streamdown's own
194
+ * remark defaults (`remark-gfm`, `codeMeta`), so spread
195
+ * `Object.values(defaultRemarkPlugins)` from `streamdown` back in if you
196
+ * want to keep GFM tables/strikethrough.
197
+ */
198
+ plugins?: StreamdownPlugins;
86
199
  }
87
200
 
88
201
  export const MarkdownView = ({
89
202
  baseHeadingLevel = 1,
90
203
  className,
91
204
  children,
205
+ components: componentOverrides,
206
+ plugins: pluginOverrides,
92
207
  ...props
93
208
  }: MarkdownViewProps) => {
94
- const components = useMemo(() => buildProseComponents(baseHeadingLevel), [baseHeadingLevel]);
209
+ // Streamdown installs [rehypeRaw, rehypeSanitize, harden] as the DEFAULT VALUE of
210
+ // `rehypePlugins`; a supplied array REPLACES it. This component renders untrusted
211
+ // model output, so the chain is not overridable. Widen with `allowedTags` /
212
+ // `literalTagContent`, which merge into the sanitize schema. See issue #36.
213
+ // `remarkPlugins` is deliberately left alone — it runs upstream of the rehype
214
+ // chain and cannot bypass it (PR #74 review; see `_streamdown-safety.ts`).
215
+ stripSanitizerOverrides(props);
216
+ const internalComponents = useMemo(
217
+ () => buildProseComponents(baseHeadingLevel),
218
+ [baseHeadingLevel],
219
+ );
220
+ // Consumer entries win per key; every other key falls through to the
221
+ // internal Prose* map — see the `components` prop doc above.
222
+ const components = useMemo(
223
+ () => ({ ...internalComponents, ...componentOverrides }),
224
+ [internalComponents, componentOverrides],
225
+ );
95
226
  // Streamdown's own chrome (code copy, table menus) reads the locale seam (#310).
96
227
  // Spread AFTER so an explicit `translations` prop still wins (ADR 0017).
97
228
  const translations = useStreamdownTranslations();
98
229
  // Brand-token-derived `code` plugin, not the package's static github-*
99
230
  // default (#315 follow-up) — re-derives when the active theme changes.
100
- const plugins = useStreamdownPlugins();
231
+ // `math`/`cjk` are lazy-loaded off the raw markdown source (#perf-5, see
232
+ // `_streamdown-i18n.ts`) — pass `children` so they load only when this
233
+ // view actually needs them.
234
+ const internalPlugins = useStreamdownPlugins(typeof children === "string" ? children : "");
235
+ // Same per-key merge as `components` — see the `plugins` prop doc above.
236
+ // `math.rehypePlugin`/`mermaid` are the two slots that land in the DOM after
237
+ // (or outside) the sanitiser chain, so replacing one is a TRUSTED-CODE
238
+ // decision. The runtime half of that documented boundary is a dev warning —
239
+ // the plugin still runs; stripping it would break the legitimate use (#76).
240
+ const plugins = useMemo(() => {
241
+ warnOnTrustedPluginSlots(pluginOverrides, internalPlugins);
242
+ return { ...internalPlugins, ...pluginOverrides };
243
+ }, [internalPlugins, pluginOverrides]);
244
+ // The TOP-LEVEL Streamdown `mermaid` prop (distinct from `plugins.mermaid`
245
+ // above) — catches a failed diagram render, including a missing `mermaid`
246
+ // optional peer (issue #33). See `_streamdown-i18n.ts`.
247
+ const mermaidOptions = useStreamdownMermaidOptions();
101
248
  return (
102
249
  <Streamdown
250
+ // Forces a remount the first time the lazy math/cjk slot resolves —
251
+ // see `getStreamdownPluginsKey`'s doc in `_streamdown-i18n.ts` for why.
252
+ key={getStreamdownPluginsKey(internalPlugins)}
103
253
  data-slot="markdown-view"
104
254
  className={cn("space-y-3 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", className)}
105
255
  components={components}
106
256
  plugins={plugins}
107
257
  translations={translations}
258
+ mermaid={mermaidOptions}
108
259
  {...props}
109
260
  >
110
261
  {children}