basalt-ui 1.11.0 → 1.13.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 (301) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +63 -51
  4. package/agent/rules/basalt-agent.md +14 -0
  5. package/agent/rules/basalt-content.md +16 -3
  6. package/configs/oxlint-plugin.js +7 -3
  7. package/configs/oxlint-plugin.test.ts +34 -5
  8. package/configs/oxlint.json +4 -4
  9. package/dist/agent/adapter.d.ts +150 -0
  10. package/dist/agent/adapter.d.ts.map +1 -0
  11. package/dist/agent/adapter.js +482 -0
  12. package/dist/agent/adapter.test.d.ts +2 -0
  13. package/dist/agent/adapter.test.d.ts.map +1 -0
  14. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  15. package/dist/agent/ai-sdk-transport.js +6 -2
  16. package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
  17. package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
  18. package/dist/agent/coalesce.d.ts +20 -4
  19. package/dist/agent/coalesce.d.ts.map +1 -1
  20. package/dist/agent/coalesce.js +76 -8
  21. package/dist/agent/coalesce.test.d.ts +2 -0
  22. package/dist/agent/coalesce.test.d.ts.map +1 -0
  23. package/dist/agent/foreign.test.d.ts +2 -0
  24. package/dist/agent/foreign.test.d.ts.map +1 -0
  25. package/dist/agent/id.d.ts +78 -0
  26. package/dist/agent/id.d.ts.map +1 -1
  27. package/dist/agent/id.js +30 -0
  28. package/dist/agent/id.test.d.ts +2 -0
  29. package/dist/agent/id.test.d.ts.map +1 -0
  30. package/dist/agent/index.d.ts +2 -0
  31. package/dist/agent/index.d.ts.map +1 -1
  32. package/dist/agent/index.js +3 -0
  33. package/dist/agent/merge.d.ts.map +1 -1
  34. package/dist/agent/merge.js +19 -7
  35. package/dist/agent/merge.test.d.ts +2 -0
  36. package/dist/agent/merge.test.d.ts.map +1 -0
  37. package/dist/agent/part-list.d.ts +49 -34
  38. package/dist/agent/part-list.d.ts.map +1 -1
  39. package/dist/agent/part-list.js +42 -6
  40. package/dist/agent/part-list.test.d.ts +2 -0
  41. package/dist/agent/part-list.test.d.ts.map +1 -0
  42. package/dist/agent/parts.d.ts +46 -10
  43. package/dist/agent/parts.d.ts.map +1 -1
  44. package/dist/agent/parts.js +16 -6
  45. package/dist/agent/parts.test.d.ts +2 -0
  46. package/dist/agent/parts.test.d.ts.map +1 -0
  47. package/dist/agent/thread.d.ts +18 -0
  48. package/dist/agent/thread.d.ts.map +1 -1
  49. package/dist/agent/thread.js +8 -2
  50. package/dist/agent/thread.test.d.ts +2 -0
  51. package/dist/agent/thread.test.d.ts.map +1 -0
  52. package/dist/agent/transport.test.d.ts +2 -0
  53. package/dist/agent/transport.test.d.ts.map +1 -0
  54. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  55. package/dist/agent/use-agent-stream.js +2 -2
  56. package/dist/agent/use-agent-stream.test.d.ts +2 -0
  57. package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
  58. package/dist/agent/use-agent-thread-runs.d.ts +24 -3
  59. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  60. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
  61. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
  62. package/dist/agent/use-agent-thread-runs.js +55 -15
  63. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
  64. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
  65. package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
  66. package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
  67. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
  68. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
  69. package/dist/agent-chat/composer.d.ts +130 -8
  70. package/dist/agent-chat/composer.d.ts.map +1 -1
  71. package/dist/agent-chat/composer.js +189 -23
  72. package/dist/agent-chat/composer.test.d.ts +2 -0
  73. package/dist/agent-chat/composer.test.d.ts.map +1 -0
  74. package/dist/agent-chat/index.d.ts +5 -1
  75. package/dist/agent-chat/index.d.ts.map +1 -1
  76. package/dist/agent-chat/index.js +2 -0
  77. package/dist/agent-chat/message-affordances.d.ts +59 -0
  78. package/dist/agent-chat/message-affordances.d.ts.map +1 -0
  79. package/dist/agent-chat/message-affordances.js +7 -0
  80. package/dist/agent-chat/relative-time.d.ts +27 -0
  81. package/dist/agent-chat/relative-time.d.ts.map +1 -0
  82. package/dist/agent-chat/relative-time.js +27 -0
  83. package/dist/agent-chat/relative-time.test.d.ts +2 -0
  84. package/dist/agent-chat/relative-time.test.d.ts.map +1 -0
  85. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  86. package/dist/agent-chat/thread-detail-panel.js +1 -1
  87. package/dist/agent-chat/thread-feed-row.d.ts +65 -0
  88. package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
  89. package/dist/agent-chat/thread-feed-row.js +156 -0
  90. package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
  91. package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
  92. package/dist/agent-chat/thread-feed.d.ts +34 -5
  93. package/dist/agent-chat/thread-feed.d.ts.map +1 -1
  94. package/dist/agent-chat/thread-feed.js +52 -19
  95. package/dist/agent-chat/thread-feed.test.d.ts +2 -0
  96. package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
  97. package/dist/agent-chat/thread-message.d.ts +183 -2
  98. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  99. package/dist/agent-chat/thread-message.js +329 -37
  100. package/dist/agent-chat/thread-message.test.d.ts +2 -0
  101. package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
  102. package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
  103. package/dist/agent-chat/thread-outcome-card.js +1 -16
  104. package/dist/agent-chat/thread-workspace.d.ts +14 -2
  105. package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
  106. package/dist/agent-chat/thread-workspace.js +30 -4
  107. package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
  108. package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -0
  109. package/dist/agent-chat/tool-chip.test.d.ts +2 -0
  110. package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
  111. package/dist/agent-chat/virtualize.d.ts +123 -0
  112. package/dist/agent-chat/virtualize.d.ts.map +1 -0
  113. package/dist/agent-chat/virtualize.js +9 -0
  114. package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
  115. package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +1 -0
  116. package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
  117. package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
  118. package/dist/charts/utils/domain.test.d.ts +2 -0
  119. package/dist/charts/utils/domain.test.d.ts.map +1 -0
  120. package/dist/cli/check-theme.test.d.ts +2 -0
  121. package/dist/cli/check-theme.test.d.ts.map +1 -0
  122. package/dist/cli/help.test.d.ts +2 -0
  123. package/dist/cli/help.test.d.ts.map +1 -0
  124. package/dist/cli/index.d.ts.map +1 -1
  125. package/dist/cli/init-sync.test.d.ts +2 -0
  126. package/dist/cli/init-sync.test.d.ts.map +1 -0
  127. package/dist/cli/placement-engine.test.d.ts +2 -0
  128. package/dist/cli/placement-engine.test.d.ts.map +1 -0
  129. package/dist/cli/tarball-parity.test.d.ts +2 -0
  130. package/dist/cli/tarball-parity.test.d.ts.map +1 -0
  131. package/dist/cli/template-format.test.d.ts +2 -0
  132. package/dist/cli/template-format.test.d.ts.map +1 -0
  133. package/dist/cli/template-ledger.test.d.ts +2 -0
  134. package/dist/cli/template-ledger.test.d.ts.map +1 -0
  135. package/dist/cli/tokens-css.test.d.ts +2 -0
  136. package/dist/cli/tokens-css.test.d.ts.map +1 -0
  137. package/dist/content/article-actions.test.d.ts +2 -0
  138. package/dist/content/article-actions.test.d.ts.map +1 -0
  139. package/dist/content/article-model.test.d.ts +2 -0
  140. package/dist/content/article-model.test.d.ts.map +1 -0
  141. package/dist/content/block-split.test.d.ts +2 -0
  142. package/dist/content/block-split.test.d.ts.map +1 -0
  143. package/dist/content/fence-block.d.ts +37 -10
  144. package/dist/content/fence-block.d.ts.map +1 -1
  145. package/dist/content/fence-block.js +80 -14
  146. package/dist/content/heading-components.d.ts.map +1 -1
  147. package/dist/content/heading-components.js +7 -4
  148. package/dist/content/heading-components.test.d.ts +2 -0
  149. package/dist/content/heading-components.test.d.ts.map +1 -0
  150. package/dist/content/index.d.ts +6 -3
  151. package/dist/content/index.d.ts.map +1 -1
  152. package/dist/content/index.js +5 -1
  153. package/dist/content/markdown.d.ts +101 -14
  154. package/dist/content/markdown.d.ts.map +1 -1
  155. package/dist/content/markdown.js +141 -28
  156. package/dist/content/markdown.test.d.ts +2 -0
  157. package/dist/content/markdown.test.d.ts.map +1 -0
  158. package/dist/content/prose.module.css.test.d.ts +2 -0
  159. package/dist/content/prose.module.css.test.d.ts.map +1 -0
  160. package/dist/content/sanitize.d.ts +170 -0
  161. package/dist/content/sanitize.d.ts.map +1 -0
  162. package/dist/content/sanitize.js +178 -0
  163. package/dist/content/sanitize.test.d.ts +2 -0
  164. package/dist/content/sanitize.test.d.ts.map +1 -0
  165. package/dist/content/slug.test.d.ts +2 -0
  166. package/dist/content/slug.test.d.ts.map +1 -0
  167. package/dist/dashboard/stat-card.test.d.ts +2 -0
  168. package/dist/dashboard/stat-card.test.d.ts.map +1 -0
  169. package/dist/data/index.d.ts +5 -1
  170. package/dist/data/index.d.ts.map +1 -1
  171. package/dist/data/virtual-list.d.ts.map +1 -1
  172. package/dist/data/virtual.d.ts +2 -1
  173. package/dist/data/virtual.d.ts.map +1 -1
  174. package/dist/guard/check-source.test.d.ts +2 -0
  175. package/dist/guard/check-source.test.d.ts.map +1 -0
  176. package/dist/guard/guard-hook.test.d.ts +2 -0
  177. package/dist/guard/guard-hook.test.d.ts.map +1 -0
  178. package/dist/index.d.ts +4 -4
  179. package/dist/index.d.ts.map +1 -1
  180. package/dist/index.js +11 -1
  181. package/dist/provider/index.test.d.ts +2 -0
  182. package/dist/provider/index.test.d.ts.map +1 -0
  183. package/dist/provider/lab-theme.test.d.ts +2 -0
  184. package/dist/provider/lab-theme.test.d.ts.map +1 -0
  185. package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
  186. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
  187. package/dist/styles.floor.test.d.ts +2 -0
  188. package/dist/styles.floor.test.d.ts.map +1 -0
  189. package/dist/surfaces.d.ts +5 -5
  190. package/dist/surfaces.d.ts.map +1 -1
  191. package/dist/surfaces.js +14 -4
  192. package/dist/theme/border-coverage.test.d.ts +18 -0
  193. package/dist/theme/border-coverage.test.d.ts.map +1 -0
  194. package/dist/theme/contrast.test.d.ts +2 -0
  195. package/dist/theme/contrast.test.d.ts.map +1 -0
  196. package/dist/theme/density-option.test.d.ts +2 -0
  197. package/dist/theme/density-option.test.d.ts.map +1 -0
  198. package/dist/theme/density-relations.test.d.ts +2 -0
  199. package/dist/theme/density-relations.test.d.ts.map +1 -0
  200. package/dist/theme/derive-theme.test.d.ts +2 -0
  201. package/dist/theme/derive-theme.test.d.ts.map +1 -0
  202. package/dist/theme/fonts-option.test.d.ts +2 -0
  203. package/dist/theme/fonts-option.test.d.ts.map +1 -0
  204. package/dist/theme/radius-option.test.d.ts +2 -0
  205. package/dist/theme/radius-option.test.d.ts.map +1 -0
  206. package/dist/theme/radius.test.d.ts +2 -0
  207. package/dist/theme/radius.test.d.ts.map +1 -0
  208. package/dist/theme/shadow-surfaces.test.d.ts +26 -0
  209. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
  210. package/dist/theme/spacing.test.d.ts +2 -0
  211. package/dist/theme/spacing.test.d.ts.map +1 -0
  212. package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
  213. package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
  214. package/dist/theme-lab/derive-controls.test.d.ts +2 -0
  215. package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
  216. package/dist/theme-lab/derive-state.test.d.ts +2 -0
  217. package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
  218. package/dist/theme-lab/theme-lab.test.d.ts +2 -0
  219. package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
  220. package/dist/tokens/build-fonts-css.test.d.ts +2 -0
  221. package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
  222. package/dist/tokens/build-radius-css.test.d.ts +2 -0
  223. package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
  224. package/dist/tokens/density.test.d.ts +2 -0
  225. package/dist/tokens/density.test.d.ts.map +1 -0
  226. package/dist/tokens/derive-radius.test.d.ts +2 -0
  227. package/dist/tokens/derive-radius.test.d.ts.map +1 -0
  228. package/dist/tokens/derive.test.d.ts +2 -0
  229. package/dist/tokens/derive.test.d.ts.map +1 -0
  230. package/dist/tokens/palette.test.d.ts +2 -0
  231. package/dist/tokens/palette.test.d.ts.map +1 -0
  232. package/dist/vite-color-mix.test.d.ts +2 -0
  233. package/dist/vite-color-mix.test.d.ts.map +1 -0
  234. package/dist/vite.test.d.ts +2 -0
  235. package/dist/vite.test.d.ts.map +1 -0
  236. package/llms.txt +9 -9
  237. package/package.json +7 -2
  238. package/src/agent/adapter.test.ts +1640 -0
  239. package/src/agent/adapter.ts +945 -0
  240. package/src/agent/ai-sdk-transport.test.ts +117 -26
  241. package/src/agent/ai-sdk-transport.ts +8 -2
  242. package/src/agent/coalesce.test.ts +202 -9
  243. package/src/agent/coalesce.ts +126 -15
  244. package/src/agent/foreign.test.ts +5 -1
  245. package/src/agent/id.test.ts +101 -3
  246. package/src/agent/id.ts +118 -0
  247. package/src/agent/index.ts +8 -0
  248. package/src/agent/merge.test.ts +87 -0
  249. package/src/agent/merge.ts +57 -10
  250. package/src/agent/part-list.test.tsx +31 -1
  251. package/src/agent/part-list.tsx +77 -29
  252. package/src/agent/parts.test.ts +121 -4
  253. package/src/agent/parts.ts +78 -17
  254. package/src/agent/thread.test.ts +75 -2
  255. package/src/agent/thread.ts +25 -1
  256. package/src/agent/use-agent-stream.test.tsx +31 -5
  257. package/src/agent/use-agent-stream.ts +6 -3
  258. package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
  259. package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
  260. package/src/agent/use-agent-thread-runs.test.tsx +356 -17
  261. package/src/agent/use-agent-thread-runs.ts +159 -23
  262. package/src/agent/use-agent-thread-runs.wedge.test.tsx +93 -7
  263. package/src/agent-chat/composer.test.tsx +924 -0
  264. package/src/agent-chat/composer.tsx +521 -52
  265. package/src/agent-chat/index.ts +13 -1
  266. package/src/agent-chat/message-affordances.ts +61 -0
  267. package/src/agent-chat/relative-time.test.ts +95 -0
  268. package/src/agent-chat/relative-time.ts +55 -0
  269. package/src/agent-chat/thread-detail-panel.tsx +3 -1
  270. package/src/agent-chat/thread-feed-row.test.tsx +510 -0
  271. package/src/agent-chat/thread-feed-row.tsx +274 -0
  272. package/src/agent-chat/thread-feed.test.tsx +241 -0
  273. package/src/agent-chat/thread-feed.tsx +119 -18
  274. package/src/agent-chat/thread-message.test.tsx +1078 -4
  275. package/src/agent-chat/thread-message.tsx +795 -42
  276. package/src/agent-chat/thread-outcome-card.tsx +1 -25
  277. package/src/agent-chat/thread-workspace.test.tsx +169 -0
  278. package/src/agent-chat/thread-workspace.tsx +70 -6
  279. package/src/agent-chat/virtualize.ts +132 -0
  280. package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
  281. package/src/cli/index.ts +0 -1
  282. package/src/cli/template-format.test.ts +2 -2
  283. package/src/content/fence-block.tsx +242 -15
  284. package/src/content/heading-components.test.tsx +65 -0
  285. package/src/content/heading-components.tsx +26 -5
  286. package/src/content/index.ts +19 -3
  287. package/src/content/markdown.test.tsx +603 -0
  288. package/src/content/markdown.tsx +355 -35
  289. package/src/content/sanitize.test.ts +659 -0
  290. package/src/content/sanitize.ts +510 -0
  291. package/src/dashboard/stat-card.test.tsx +6 -1
  292. package/src/data/index.ts +5 -1
  293. package/src/data/virtual-list.tsx +2 -1
  294. package/src/data/virtual.ts +2 -1
  295. package/src/index.ts +18 -1
  296. package/src/surfaces.ts +13 -3
  297. package/src/theme/contrast.test.ts +1 -4
  298. package/src/theme/density-relations.test.ts +4 -2
  299. package/src/theme/fonts-option.test.ts +7 -1
  300. package/src/theme/shadow-surfaces.test.ts +21 -6
  301. 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,12 +43,23 @@
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
50
  import type { Components } from 'react-markdown';
51
+ import type { FenceRenderers } from './fence-block.js';
39
52
  import type { ProseDensity } from './prose.js';
53
+ import type { SanitizeSchemaExtension, SanitizeSchemaInput } from './sanitize.js';
40
54
  export type MarkdownComponents = Components;
55
+ export { settledOnly } from './fence-block.js';
56
+ export type { FenceRenderContext, FenceRenderer, FenceRenderers } from './fence-block.js';
57
+ /**
58
+ * Provenance of a `Markdown` body — the ONLY input to the image-allowlist default. Deliberately not
59
+ * a boolean: `contentTrust="untrusted"` says at the call site what it means, where `untrusted` /
60
+ * `!trusted` would not.
61
+ */
62
+ export type MarkdownContentTrust = 'trusted' | 'untrusted';
41
63
  export type MarkdownProps = {
42
64
  /** The markdown string to render. Safe to update on every character during streaming. */
43
65
  readonly children: string;
@@ -45,24 +67,89 @@ export type MarkdownProps = {
45
67
  readonly density?: ProseDensity;
46
68
  /** `Prose`'s measure cap. Defaults to `true` for `'article'` density, `false` for `'chat'`. */
47
69
  readonly measure?: boolean;
48
- /** AI-stream mode — block-split + memoize + `remend`-repair the in-flight tail. Default `false`. */
70
+ /** AI-stream mode — block-split + memoize + `remend`-repair the in-flight tail. Default `false`.
71
+ * Purely a RENDERING mode: it says the text is still arriving, NOT that it is untrusted. Use
72
+ * `contentTrust` for the security policy. */
49
73
  readonly streaming?: boolean;
50
- /** Per-element overrides merged OVER the defaults (only the keys you supply are replaced). */
74
+ /**
75
+ * Where `children` came from — the input to the image policy, independent of `streaming`.
76
+ * `'untrusted'` (model-generated or third-party markdown) drops the `allowedImagePrefixes`
77
+ * default to same-origin `['/']`; `'trusted'` (your own authored content) keeps
78
+ * `['https://', '/']`. An explicit `allowedImagePrefixes` always wins over both.
79
+ *
80
+ * Default: `'untrusted'` when `streaming` is set, `'trusted'` otherwise. That default only
81
+ * preserves the pre-1.12 fail-safe — it is NOT the policy. Settledness has nothing to do with
82
+ * trust: a FINISHED agent message is still model-generated, so any surface rendering agent text
83
+ * must pin `contentTrust="untrusted"` explicitly rather than lean on `streaming`.
84
+ */
85
+ readonly contentTrust?: MarkdownContentTrust;
86
+ /**
87
+ * Per-element overrides merged OVER the defaults (only the keys you supply are replaced).
88
+ *
89
+ * A CUSTOM tag admitted via `sanitizeSchema.tagNames` (e.g. `'x-callout'`) cannot be given an
90
+ * override here: react-markdown's `Components` type is a mapped type over `JSX.IntrinsicElements`,
91
+ * which only has entries for real HTML/SVG tags — a made-up tag name is not a valid key of it, so
92
+ * there is no override slot for it to occupy. Style such a tag with CSS (element/attribute
93
+ * selectors) or a wrapping component instead of trying to intercept it here.
94
+ */
51
95
  readonly components?: MarkdownComponents;
96
+ /**
97
+ * Fence renderers keyed by language, consulted BEFORE the built-in mermaid/`CodeBlock` dispatch.
98
+ * Wrap a heavyweight renderer in `settledOnly` to keep it off the still-streaming tail. Pass a
99
+ * referentially stable object — it participates in the streaming memoization.
100
+ */
101
+ readonly fenceRenderers?: FenceRenderers;
102
+ /**
103
+ * ADDITIONS merged into `BASALT_SANITIZE_SCHEMA` over rehype-sanitize's `defaultSchema`.
104
+ * Removals are not expressible — a schema handed to `rehype-sanitize` directly would replace the
105
+ * defaults wholesale (hast-util-sanitize merges with a shallow top-level spread), which is
106
+ * exactly the footgun this shape removes.
107
+ */
108
+ readonly sanitizeSchema?: SanitizeSchemaExtension;
52
109
  /** Allowed `href` prefixes. Default `['https://', 'mailto:', '#', '/']`. */
53
110
  readonly allowedLinkPrefixes?: readonly string[];
54
- /** Allowed `img src` prefixes. Default `['https://', '/']`; in `streaming` mode `['/']`
55
- * (same-origin only) — auto-fetched images are the classic prompt-injection exfiltration
56
- * channel, so remote origins must be opted in explicitly for model-generated content. */
111
+ /** Allowed `img src` prefixes. Default `['https://', '/']` for trusted content, `['/']`
112
+ * (same-origin only) when `contentTrust` resolves to `'untrusted'` — auto-fetched images are the
113
+ * classic prompt-injection exfiltration channel, so remote origins must be opted in explicitly
114
+ * for model-generated content. */
57
115
  readonly allowedImagePrefixes?: readonly string[];
58
116
  /** Resolves a relative `href`/`src` to an absolute URL before the allowlist check's result is used. */
59
117
  readonly defaultOrigin?: string;
60
118
  /** Escape hatch — appended after `remark-gfm`. */
61
119
  readonly remarkPlugins?: readonly unknown[];
62
- /** Escape hatch — appended after react-markdown's defaults. */
120
+ /**
121
+ * Escape hatch — appended after react-markdown's defaults, and BEFORE basalt's sanitize pass
122
+ * (which always runs last, so nothing supplied here can outrun it).
123
+ *
124
+ * react-markdown does NOT parse raw HTML in the markdown SOURCE by default (no `rehype-raw` is
125
+ * included here or by react-markdown itself) — a literal `<script>...</script>` typed into
126
+ * `children` never becomes a hast element; it stays inert text and the sanitize pass never even
127
+ * sees it. Concretely: a test that types a raw `<script>` tag into `children` and asserts it never
128
+ * renders is NOT exercising the sanitize pass — it would pass identically with the pass removed
129
+ * entirely, because the string was never a node the sanitizer could act on in the first place. The
130
+ * sanitize pass only ever sees elements produced by remark/rehype PLUGINS (`remarkPlugins`,
131
+ * `rehypePlugins`, or `rehype-raw` if a consumer adds it here) — that is the surface worth testing
132
+ * against unsafe markup, not raw source text.
133
+ */
63
134
  readonly rehypePlugins?: readonly unknown[];
64
135
  readonly className?: string;
65
136
  readonly style?: CSSProperties;
66
137
  };
67
- export declare function Markdown({ children, density, measure, streaming, components, allowedLinkPrefixes, allowedImagePrefixes, defaultOrigin, remarkPlugins, rehypePlugins, className, style, }: MarkdownProps): JSX.Element;
138
+ /** The resolved sanitize peer: its plugin plus the `defaultSchema` it re-exports. */
139
+ type MarkdownSanitizer = {
140
+ readonly plugin: unknown;
141
+ readonly baseSchema: SanitizeSchemaInput;
142
+ };
143
+ /**
144
+ * Consumer plugins first, basalt's sanitize pass LAST — "everything after rehype-sanitize could be
145
+ * unsafe", so last is the only position nothing supplied through the escape hatch can outrun.
146
+ *
147
+ * Exported for the ordering test only; `./index.ts` deliberately does not re-export it.
148
+ */
149
+ export declare function composeRehypePlugins({ sanitizer, consumerPlugins, sanitizeSchema, }: {
150
+ readonly sanitizer: MarkdownSanitizer | null;
151
+ readonly consumerPlugins?: readonly unknown[];
152
+ readonly sanitizeSchema?: SanitizeSchemaExtension;
153
+ }): readonly unknown[];
154
+ export declare function Markdown({ children, density, measure, streaming, contentTrust, components, fenceRenderers, sanitizeSchema, allowedLinkPrefixes, allowedImagePrefixes, defaultOrigin, remarkPlugins, rehypePlugins, className, style, }: MarkdownProps): JSX.Element;
68
155
  //# sourceMappingURL=markdown.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../../src/content/markdown.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,GAAG,EAAE,MAAM,OAAO,CAAA;AAG/C,OAAO,KAAK,EACV,UAAU,EAIX,MAAM,gBAAgB,CAAA;AAMvB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAO3C,MAAM,MAAM,kBAAkB,GAAG,UAAU,CAAA;AAE3C,MAAM,MAAM,aAAa,GAAG;IAC1B,yFAAyF;IACzF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,yEAAyE;IACzE,QAAQ,CAAC,OAAO,CAAC,EAAE,YAAY,CAAA;IAC/B,+FAA+F;IAC/F,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,oGAAoG;IACpG,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B,8FAA8F;IAC9F,QAAQ,CAAC,UAAU,CAAC,EAAE,kBAAkB,CAAA;IACxC,4EAA4E;IAC5E,QAAQ,CAAC,mBAAmB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAChD;;6FAEyF;IACzF,QAAQ,CAAC,oBAAoB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACjD,uGAAuG;IACvG,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B,kDAAkD;IAClD,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,OAAO,EAAE,CAAA;IAC3C,+DAA+D;IAC/D,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,OAAO,EAAE,CAAA;IAC3C,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,CAAA;CAC/B,CAAA;AAyND,wBAAgB,QAAQ,CAAC,EACvB,QAAQ,EACR,OAAmB,EACnB,OAAO,EACP,SAAiB,EACjB,UAAU,EACV,mBAA2C,EAC3C,oBAAoF,EACpF,aAAa,EACb,aAAa,EACb,aAAa,EACb,SAAS,EACT,KAAK,GACN,EAAE,aAAa,eA+Bf"}
1
+ {"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../../src/content/markdown.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,GAAG,EAAE,MAAM,OAAO,CAAA;AAE/C,OAAO,KAAK,EACV,UAAU,EAIX,MAAM,gBAAgB,CAAA;AAGvB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAInD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAE3C,OAAO,KAAK,EAAkB,uBAAuB,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAA;AAO9F,MAAM,MAAM,kBAAkB,GAAG,UAAU,CAAA;AAK3C,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC3C,YAAY,EAAE,kBAAkB,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAEtF;;;;GAIG;AACH,MAAM,MAAM,oBAAoB,GAAG,SAAS,GAAG,WAAW,CAAA;AAE1D,MAAM,MAAM,aAAa,GAAG;IAC1B,yFAAyF;IACzF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,yEAAyE;IACzE,QAAQ,CAAC,OAAO,CAAC,EAAE,YAAY,CAAA;IAC/B,+FAA+F;IAC/F,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B;;iDAE6C;IAC7C,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,oBAAoB,CAAA;IAC5C;;;;;;;;OAQG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,kBAAkB,CAAA;IACxC;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,cAAc,CAAA;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,uBAAuB,CAAA;IACjD,4EAA4E;IAC5E,QAAQ,CAAC,mBAAmB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAChD;;;sCAGkC;IAClC,QAAQ,CAAC,oBAAoB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACjD,uGAAuG;IACvG,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B,kDAAkD;IAClD,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,OAAO,EAAE,CAAA;IAC3C;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,OAAO,EAAE,CAAA;IAC3C,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,CAAA;CAC/B,CAAA;AAqED,qFAAqF;AACrF,KAAK,iBAAiB,GAAG;IACvB,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;IACxB,QAAQ,CAAC,UAAU,EAAE,mBAAmB,CAAA;CACzC,CAAA;AA8ED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,EACnC,SAAS,EACT,eAAe,EACf,cAAc,GACf,EAAE;IACD,QAAQ,CAAC,SAAS,EAAE,iBAAiB,GAAG,IAAI,CAAA;IAC5C,QAAQ,CAAC,eAAe,CAAC,EAAE,SAAS,OAAO,EAAE,CAAA;IAC7C,QAAQ,CAAC,cAAc,CAAC,EAAE,uBAAuB,CAAA;CAClD,GAAG,SAAS,OAAO,EAAE,CAKrB;AA2RD,wBAAgB,QAAQ,CAAC,EACvB,QAAQ,EACR,OAAmB,EACnB,OAAO,EACP,SAAiB,EAIjB,YAAkD,EAClD,UAAU,EACV,cAAc,EACd,cAAc,EACd,mBAA2C,EAC3C,oBAA0D,EAC1D,aAAa,EACb,aAAa,EACb,aAAa,EACb,SAAS,EACT,KAAK,GACN,EAAE,aAAa,eAiCf"}
@@ -1,18 +1,19 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
- import { lazy, memo, Suspense, useMemo } from "react";
3
- import remend from "remend";
2
+ import { lazy, memo, Suspense, useEffect, useMemo, useState } from "react";
4
3
  import { ALERT_CALLOUT_KIND, ALERT_TITLE, detectAlert, stripAlertMarker } from "./callout-alert.js";
5
4
  import { FenceBlock } from "./fence-block.js";
6
5
  import { createHeadingComponents } from "./heading-components.js";
7
6
  import { Callout } from "./callout.js";
8
7
  import { Prose } from "./prose.js";
8
+ import { BASALT_SANITIZE_SCHEMA, mergeSanitizeSchema } from "./sanitize.js";
9
9
  import { SlugTracker } from "./slug.js";
10
10
  import { blockSplit } from "./block-split.js";
11
11
  import { createUrlTransform } from "./url-hardening.js";
12
- function LinkRenderer({ href, children }) {
13
- if (href === void 0) return /* @__PURE__ */ jsx("span", { children });
12
+ import { settledOnly } from "./fence-block.js";
13
+ function LinkRenderer({ href, children, node: _node, ...rest }) {
14
+ if (href === void 0) return /* @__PURE__ */ jsx("span", { ...rest, children });
14
15
  const external = href.startsWith("http://") || href.startsWith("https://");
15
- return /* @__PURE__ */ jsx("a", { href, ...external && { target: "_blank", rel: "noreferrer noopener" }, children });
16
+ return /* @__PURE__ */ jsx("a", { href, ...rest, ...external && { target: "_blank", rel: "noreferrer noopener" }, children });
16
17
  }
17
18
  function ImageRenderer({ src, alt }) {
18
19
  if (typeof src !== "string" || src === "") return /* @__PURE__ */ jsx("span", { "aria-label": alt });
@@ -23,45 +24,92 @@ function BlockquoteRenderer({ children }) {
23
24
  if (!alert) return /* @__PURE__ */ jsx("blockquote", { children });
24
25
  return /* @__PURE__ */ jsx(Callout, { kind: ALERT_CALLOUT_KIND[alert.kind], title: ALERT_TITLE[alert.kind], children: stripAlertMarker(children, alert.marker) });
25
26
  }
26
- function createPreRenderer(settled) {
27
+ function createPreRenderer(settled, fenceRenderers) {
27
28
  return function PreRenderer({ node }) {
28
- return /* @__PURE__ */ jsx(FenceBlock, { settled, ...node !== void 0 && { node } });
29
+ return /* @__PURE__ */ jsx(
30
+ FenceBlock,
31
+ {
32
+ settled,
33
+ ...node !== void 0 && { node },
34
+ ...fenceRenderers !== void 0 && { renderers: fenceRenderers }
35
+ }
36
+ );
29
37
  };
30
38
  }
31
- function buildMarkdownComponents(tracker, settled) {
39
+ function buildMarkdownComponents(tracker, settled, fenceRenderers) {
32
40
  return {
33
41
  ...createHeadingComponents(tracker),
34
- pre: createPreRenderer(settled),
42
+ pre: createPreRenderer(settled, fenceRenderers),
35
43
  blockquote: BlockquoteRenderer,
36
44
  a: LinkRenderer,
37
45
  img: ImageRenderer
38
46
  };
39
47
  }
48
+ let sanitizerPromise = null;
49
+ function loadSanitizer() {
50
+ sanitizerPromise ??= import("rehype-sanitize").then((mod) => ({
51
+ plugin: mod.default,
52
+ // No cast: `SanitizeSchemaInput` mirrors hast-util-sanitize's `Schema` INCLUDING its
53
+ // `| null | undefined` field arms, so `defaultSchema` assigns straight across;
54
+ // `mergeSanitizeSchema` normalizes the nulls away and hands back a `SanitizeSchema`.
55
+ baseSchema: mod.defaultSchema
56
+ })).catch(() => {
57
+ if (process.env["NODE_ENV"] !== "production") {
58
+ console.warn(
59
+ '[basalt] Markdown: optional peer "rehype-sanitize" is not installed \u2014 the sanitize pass is skipped. react-markdown does not parse raw HTML by default, so this only opens a hole if a rehypePlugin (rehype-raw and friends) puts untrusted nodes into the tree. Install it with: bun add rehype-sanitize'
60
+ );
61
+ }
62
+ return null;
63
+ });
64
+ return sanitizerPromise;
65
+ }
66
+ function withSingleClobberLayer(schema) {
67
+ return { ...schema, clobberPrefix: "" };
68
+ }
69
+ function composeRehypePlugins({
70
+ sanitizer,
71
+ consumerPlugins,
72
+ sanitizeSchema
73
+ }) {
74
+ const consumer = consumerPlugins ?? [];
75
+ if (sanitizer === null) return consumer;
76
+ const merged = mergeSanitizeSchema(sanitizer.baseSchema, BASALT_SANITIZE_SCHEMA, sanitizeSchema);
77
+ return [...consumer, [sanitizer.plugin, withSingleClobberLayer(merged)]];
78
+ }
40
79
  function PlainTextFallback({ text }) {
41
80
  return /* @__PURE__ */ jsx("span", { style: { whiteSpace: "pre-wrap" }, children: text });
42
81
  }
43
82
  const LazyReactMarkdown = lazy(
44
- () => Promise.all([import("react-markdown"), import("remark-gfm")]).then(([{ default: ReactMarkdown }, { default: remarkGfm }]) => {
83
+ () => Promise.all([import("react-markdown"), import("remark-gfm"), loadSanitizer()]).then(([{ default: ReactMarkdown }, { default: remarkGfm }, sanitizer]) => {
45
84
  function Bridge({
46
85
  text,
47
86
  components,
48
87
  urlTransform,
49
88
  remarkPlugins,
50
- rehypePlugins
89
+ rehypePlugins,
90
+ sanitizeSchema
51
91
  }) {
52
92
  const remarkPluginsList = [
53
93
  remarkGfm,
54
94
  ...remarkPlugins ?? []
55
95
  ];
96
+ const rehypePluginsList = useMemo(
97
+ () => composeRehypePlugins({
98
+ sanitizer,
99
+ ...rehypePlugins !== void 0 && { consumerPlugins: rehypePlugins },
100
+ ...sanitizeSchema !== void 0 && { sanitizeSchema }
101
+ }),
102
+ // `sanitizer` is a constant of the enclosing load closure — resolved once, before this
103
+ // component exists, so it is deliberately not a dependency.
104
+ [rehypePlugins, sanitizeSchema]
105
+ );
56
106
  return /* @__PURE__ */ jsx(
57
107
  ReactMarkdown,
58
108
  {
59
109
  components,
60
110
  urlTransform,
61
111
  remarkPlugins: remarkPluginsList,
62
- ...rehypePlugins !== void 0 && {
63
- rehypePlugins
64
- },
112
+ rehypePlugins: rehypePluginsList,
65
113
  children: text
66
114
  }
67
115
  );
@@ -73,14 +121,16 @@ function BlockRenderer({
73
121
  text,
74
122
  settled,
75
123
  components,
124
+ fenceRenderers,
76
125
  urlTransform,
77
126
  remarkPlugins,
78
- rehypePlugins
127
+ rehypePlugins,
128
+ sanitizeSchema
79
129
  }) {
80
130
  const tracker = useMemo(() => new SlugTracker(), [text]);
81
131
  const componentsMap = useMemo(
82
- () => ({ ...buildMarkdownComponents(tracker, settled), ...components }),
83
- [tracker, settled, components]
132
+ () => ({ ...buildMarkdownComponents(tracker, settled, fenceRenderers), ...components }),
133
+ [tracker, settled, fenceRenderers, components]
84
134
  );
85
135
  return /* @__PURE__ */ jsx(
86
136
  LazyReactMarkdown,
@@ -89,16 +139,53 @@ function BlockRenderer({
89
139
  components: componentsMap,
90
140
  urlTransform,
91
141
  ...remarkPlugins !== void 0 && { remarkPlugins },
92
- ...rehypePlugins !== void 0 && { rehypePlugins }
142
+ ...rehypePlugins !== void 0 && { rehypePlugins },
143
+ ...sanitizeSchema !== void 0 && { sanitizeSchema }
93
144
  }
94
145
  );
95
146
  }
96
147
  const MemoizedBlock = memo(BlockRenderer);
148
+ let remendPromise = null;
149
+ let resolvedRemend = null;
150
+ function loadRemend() {
151
+ remendPromise ??= import("remend").then((mod) => {
152
+ resolvedRemend = mod.default;
153
+ return resolvedRemend;
154
+ }).catch(() => null);
155
+ return remendPromise;
156
+ }
157
+ function useRemend() {
158
+ const [repair, setRepair] = useState(() => resolvedRemend);
159
+ useEffect(() => {
160
+ if (repair !== null) return;
161
+ let cancelled = false;
162
+ loadRemend().then((loaded) => {
163
+ if (!cancelled && loaded !== null) setRepair(() => loaded);
164
+ return void 0;
165
+ });
166
+ return () => {
167
+ cancelled = true;
168
+ };
169
+ }, [repair]);
170
+ return repair;
171
+ }
97
172
  function StreamingTailBlock(props) {
98
- const repaired = useMemo(() => remend(props.text), [props.text]);
173
+ const repair = useRemend();
174
+ const repaired = useMemo(
175
+ () => repair === null ? props.text : repair(props.text),
176
+ [repair, props.text]
177
+ );
99
178
  return /* @__PURE__ */ jsx(BlockRenderer, { ...props, text: repaired, settled: false });
100
179
  }
101
- function SettledBody({ text, components, urlTransform, remarkPlugins, rehypePlugins }) {
180
+ function SettledBody({
181
+ text,
182
+ components,
183
+ fenceRenderers,
184
+ urlTransform,
185
+ remarkPlugins,
186
+ rehypePlugins,
187
+ sanitizeSchema
188
+ }) {
102
189
  return /* @__PURE__ */ jsx(Suspense, { fallback: /* @__PURE__ */ jsx(PlainTextFallback, { text }), children: /* @__PURE__ */ jsx(
103
190
  BlockRenderer,
104
191
  {
@@ -106,17 +193,21 @@ function SettledBody({ text, components, urlTransform, remarkPlugins, rehypePlug
106
193
  settled: true,
107
194
  urlTransform,
108
195
  ...components !== void 0 && { components },
196
+ ...fenceRenderers !== void 0 && { fenceRenderers },
109
197
  ...remarkPlugins !== void 0 && { remarkPlugins },
110
- ...rehypePlugins !== void 0 && { rehypePlugins }
198
+ ...rehypePlugins !== void 0 && { rehypePlugins },
199
+ ...sanitizeSchema !== void 0 && { sanitizeSchema }
111
200
  }
112
201
  ) });
113
202
  }
114
203
  function StreamingBody({
115
204
  text,
116
205
  components,
206
+ fenceRenderers,
117
207
  urlTransform,
118
208
  remarkPlugins,
119
- rehypePlugins
209
+ rehypePlugins,
210
+ sanitizeSchema
120
211
  }) {
121
212
  const blocks = useMemo(() => blockSplit(text), [text]);
122
213
  const lastIndex = blocks.length - 1;
@@ -125,23 +216,41 @@ function StreamingBody({
125
216
  text: block,
126
217
  urlTransform,
127
218
  ...components !== void 0 && { components },
219
+ ...fenceRenderers !== void 0 && { fenceRenderers },
128
220
  ...remarkPlugins !== void 0 && { remarkPlugins },
129
- ...rehypePlugins !== void 0 && { rehypePlugins }
221
+ ...rehypePlugins !== void 0 && { rehypePlugins },
222
+ ...sanitizeSchema !== void 0 && { sanitizeSchema }
130
223
  };
131
224
  return index === lastIndex ? /* @__PURE__ */ jsx(StreamingTailBlock, { ...shared }, index) : /* @__PURE__ */ jsx(MemoizedBlock, { ...shared, settled: true }, index);
132
225
  }) });
133
226
  }
134
227
  const DEFAULT_LINK_PREFIXES = ["https://", "mailto:", "#", "/"];
135
- const DEFAULT_IMAGE_PREFIXES = ["https://", "/"];
136
- const STREAMING_IMAGE_PREFIXES = ["/"];
228
+ const TRUSTED_IMAGE_PREFIXES = ["https://", "/"];
229
+ const UNTRUSTED_IMAGE_PREFIXES = ["/"];
230
+ const IMAGE_PREFIXES_BY_TRUST = {
231
+ trusted: TRUSTED_IMAGE_PREFIXES,
232
+ untrusted: UNTRUSTED_IMAGE_PREFIXES
233
+ };
234
+ function isKnownContentTrust(value) {
235
+ return Object.hasOwn(IMAGE_PREFIXES_BY_TRUST, value);
236
+ }
237
+ function imagePrefixesForTrust(contentTrust) {
238
+ return isKnownContentTrust(contentTrust) ? IMAGE_PREFIXES_BY_TRUST[contentTrust] : UNTRUSTED_IMAGE_PREFIXES;
239
+ }
137
240
  function Markdown({
138
241
  children,
139
242
  density = "article",
140
243
  measure,
141
244
  streaming = false,
245
+ // `streaming` as the trust DEFAULT is backward compatibility only (pre-1.12 the image policy was
246
+ // read straight off `streaming`, so dropping it would silently widen every existing streaming
247
+ // call site). It is a floor, never the policy — see the prop's JSDoc.
248
+ contentTrust = streaming ? "untrusted" : "trusted",
142
249
  components,
250
+ fenceRenderers,
251
+ sanitizeSchema,
143
252
  allowedLinkPrefixes = DEFAULT_LINK_PREFIXES,
144
- allowedImagePrefixes = streaming ? STREAMING_IMAGE_PREFIXES : DEFAULT_IMAGE_PREFIXES,
253
+ allowedImagePrefixes = imagePrefixesForTrust(contentTrust),
145
254
  defaultOrigin,
146
255
  remarkPlugins,
147
256
  rehypePlugins,
@@ -161,8 +270,10 @@ function Markdown({
161
270
  text: children,
162
271
  urlTransform,
163
272
  ...components !== void 0 && { components },
273
+ ...fenceRenderers !== void 0 && { fenceRenderers },
164
274
  ...remarkPlugins !== void 0 && { remarkPlugins },
165
- ...rehypePlugins !== void 0 && { rehypePlugins }
275
+ ...rehypePlugins !== void 0 && { rehypePlugins },
276
+ ...sanitizeSchema !== void 0 && { sanitizeSchema }
166
277
  };
167
278
  return /* @__PURE__ */ jsx(
168
279
  Prose,
@@ -176,5 +287,7 @@ function Markdown({
176
287
  );
177
288
  }
178
289
  export {
179
- Markdown
290
+ Markdown,
291
+ composeRehypePlugins,
292
+ settledOnly
180
293
  };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=markdown.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown.test.d.ts","sourceRoot":"","sources":["../../src/content/markdown.test.tsx"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=prose.module.css.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prose.module.css.test.d.ts","sourceRoot":"","sources":["../../src/content/prose.module.css.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Sanitize-schema composition for `Markdown` (docs/AGENT-CHAT-SPEC.md §7). Owns three things: the
3
+ * additions-only extension type a consumer hands to `Markdown`'s `sanitizeSchema` prop, basalt's
4
+ * own additions layer, and the additive deep merge that composes both over `rehype-sanitize`'s
5
+ * `defaultSchema`.
6
+ *
7
+ * WHY A DATA EXTENSION, NOT A `(base) => Schema` CALLBACK: a callback can return `{}` and silently
8
+ * disable sanitization, and a "never replace" comment is not enforcement. `SanitizeSchemaExtension`
9
+ * makes removal UNREPRESENTABLE — there is no code path in `mergeSanitizeSchema` that shrinks the
10
+ * base. That, plus `./markdown` appending its sanitize pass AFTER the consumer's `rehypePlugins`,
11
+ * is what keeps the baseline un-defeatable from outside the package.
12
+ *
13
+ * The `Schema` shape is MIRRORED here rather than imported from `hast-util-sanitize`, so the
14
+ * content layer's types never depend on an optional peer. `rehype-sanitize` is dynamically imported
15
+ * by `./markdown`, which composes the effective schema at load time:
16
+ *
17
+ * mergeSanitizeSchema(defaultSchema, BASALT_SANITIZE_SCHEMA, props.sanitizeSchema)
18
+ *
19
+ * THE TRAP THIS MODULE EXISTS TO AVOID: `hast-util-sanitize` merges a supplied schema with a
20
+ * shallow top-level spread (`{...defaultSchema, ...options}`). Passing an `attributes` object
21
+ * therefore REPLACES every default attribute allowance rather than adding to it — one consumer
22
+ * addition would silently strip `href` from links, `className` from code fences, and the whole
23
+ * `'*'` fallback. Upstream's answer is the third-party `deepmerge` package; basalt owns the small
24
+ * merge below instead of taking the dependency.
25
+ *
26
+ * "ADDITIONS ONLY" IS A STATEMENT ABOUT THE SANITIZER'S BEHAVIOUR, NOT ABOUT THE SCHEMA'S SHAPE.
27
+ * A merge that only ever grows arrays still removes allowances, because of three things
28
+ * `hast-util-sanitize@5` does when it CONSUMES the schema (`lib/index.js`, verified against the
29
+ * installed copy). Each is load-bearing for the code below:
30
+ *
31
+ * 1. `clobberPrefix` is gated on TRUTHINESS — `schema.clobber && schema.clobberPrefix &&
32
+ * schema.clobber.includes(key)`. ANY falsy prefix (`''`, but equally `null`, `0`, `false`,
33
+ * `NaN` — whatever an untyped caller or a cast smuggles through) therefore turns clobber
34
+ * protection OFF entirely and emits `id`/`name`/`ariaLabelledBy`/`ariaDescribedBy` unprefixed,
35
+ * which is the DOM-clobbering hole `user-content-` exists to close. See `adoptClobberPrefix`.
36
+ * 2. The `'*'` fallback is consulted ONLY when the tag-specific lookup yields `null`/`undefined`.
37
+ * For an ARRAY-valued property (`className`, `headers`, …) a matching tag-specific entry
38
+ * returns `[]` — neither — so the fallback is skipped. Introducing a tag-specific entry for a
39
+ * property the base only allowed via `'*'` therefore DELETES that allowance. See
40
+ * `rescueWildcardDefinitions`.
41
+ * 3. `findDefinition` returns the FIRST entry whose name matches, so any later entry for the same
42
+ * property name is dead code (it also treats a `'data*'` entry as the fallback for any
43
+ * `data`-prefixed key). Concatenating a consumer's entry after the base's is a silent no-op.
44
+ * See `unionDefinitions`, which collapses to ONE entry per property name.
45
+ */
46
+ /**
47
+ * One entry in an `attributes` allow-list. A bare string allows any value for that property; the
48
+ * tuple form allow-lists specific values (a `RegExp` matches a value, e.g. `['className',
49
+ * /^language-./]`). A ONE-element tuple (`['className']`) also allows any value — upstream only
50
+ * treats an entry as an allow-list when `definition.length > 1`.
51
+ *
52
+ * The `null`/`undefined` arms mirror upstream's `PropertyDefinition` exactly (its rest element is
53
+ * `Exclude<Properties[keyof Properties], Array<any>> | RegExp`, which includes both). They are
54
+ * inert: a sanitized value is always a boolean, number or string, so `allowed === value` can never
55
+ * match one.
56
+ */
57
+ export type SanitizePropertyDefinition = string | readonly [string, ...ReadonlyArray<boolean | number | string | RegExp | null | undefined>];
58
+ /**
59
+ * Structural mirror of `hast-util-sanitize`'s `Schema`, defined here rather than imported so the
60
+ * content layer never depends on an OPTIONAL peer's types. This is the OUTPUT shape: no `null`
61
+ * arms, so nothing downstream has to re-check a field the merge already normalized away.
62
+ */
63
+ export type SanitizeSchema = {
64
+ readonly allowComments?: boolean;
65
+ readonly allowDoctypes?: boolean;
66
+ readonly ancestors?: Readonly<Record<string, readonly string[]>>;
67
+ readonly attributes?: Readonly<Record<string, readonly SanitizePropertyDefinition[]>>;
68
+ readonly clobber?: readonly string[];
69
+ readonly clobberPrefix?: string;
70
+ readonly protocols?: Readonly<Record<string, readonly string[]>>;
71
+ readonly required?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
72
+ readonly strip?: readonly string[];
73
+ readonly tagNames?: readonly string[];
74
+ };
75
+ /**
76
+ * INPUT shape for `mergeSanitizeSchema`'s base — `SanitizeSchema` with upstream's `| null |
77
+ * undefined` on every field (and on every `protocols` value), so `rehype-sanitize`'s own
78
+ * `defaultSchema` assigns to it directly with NO cast at the call site.
79
+ *
80
+ * It is deliberately separate from `SanitizeSchema` rather than a widening of it: the null arms
81
+ * belong to what callers may HAND IN, not to what the merge HANDS BACK. `normalizeSchemaInput`
82
+ * drops them, so no `null` ever reaches the exported output type.
83
+ */
84
+ export type SanitizeSchemaInput = {
85
+ readonly allowComments?: boolean | null | undefined;
86
+ readonly allowDoctypes?: boolean | null | undefined;
87
+ readonly ancestors?: Readonly<Record<string, readonly string[]>> | null | undefined;
88
+ readonly attributes?: Readonly<Record<string, readonly SanitizePropertyDefinition[]>> | null | undefined;
89
+ readonly clobber?: readonly string[] | null | undefined;
90
+ readonly clobberPrefix?: string | null | undefined;
91
+ readonly protocols?: Readonly<Record<string, readonly string[] | null | undefined>> | null | undefined;
92
+ readonly required?: Readonly<Record<string, Readonly<Record<string, unknown>>>> | null | undefined;
93
+ readonly strip?: readonly string[] | null | undefined;
94
+ readonly tagNames?: readonly string[] | null | undefined;
95
+ };
96
+ /**
97
+ * ADDITIONS ONLY — the four fields it is safe to widen from outside the package. Removal is
98
+ * deliberately not expressible: there is no way to shrink `tagNames`, drop an attribute allowance,
99
+ * narrow `protocols`, or clear `strip`/`clobber`/`ancestors`/`required`.
100
+ *
101
+ * `clobberPrefix` is the one replace-valued field. Upstream's actual gate is `schema.clobberPrefix`
102
+ * read for TRUTHINESS (`lib/index.js`), not `=== ''` — so `null`, `0`, `false`, `NaN`, and `''` are
103
+ * ALL disarming values, and only a genuine non-empty string keeps the guard live. TypeScript cannot
104
+ * express "a non-empty string" on a plain data field (and the declared `string` type is a
105
+ * compile-time claim a JS caller or an `as` cast can defeat), so `adoptClobberPrefix` re-checks
106
+ * `typeof` at runtime and treats anything that is not a non-empty string as a disarm attempt —
107
+ * ignored with a dev warning, never applied.
108
+ */
109
+ export type SanitizeSchemaExtension = {
110
+ readonly tagNames?: readonly string[];
111
+ readonly attributes?: Readonly<Record<string, readonly SanitizePropertyDefinition[]>>;
112
+ readonly protocols?: Readonly<Record<string, readonly string[]>>;
113
+ readonly clobberPrefix?: string;
114
+ };
115
+ /**
116
+ * basalt's own element allowances, layered over `rehype-sanitize`'s `defaultSchema`.
117
+ *
118
+ * THIS IS THE ADDITIONS LAYER, NOT THE FULL BASELINE. It is deliberately NOT a materialized
119
+ * `defaultSchema + basalt` schema: `defaultSchema` lives in `rehype-sanitize`, an OPTIONAL peer, so
120
+ * materializing it at module scope would require a static import that hard-requires the peer for
121
+ * every consumer of `basalt-ui/content` — and vendoring a copy would silently drift from upstream's
122
+ * GitHub-grade baseline, which for a security default is worse. The effective schema is composed
123
+ * where the peer is dynamically imported (see the module JSDoc).
124
+ *
125
+ * IT IS EMPTY, AND THAT IS THE HONEST ANSWER — everything basalt's markdown pipeline emits into the
126
+ * hast tree is already permitted by `defaultSchema`, verified entry by entry:
127
+ * - fenced code — `pre` > `code` with a `language-*` class: `defaultSchema.attributes.code` is
128
+ * `[['className', /^language-./]]`, which covers `language-mermaid` too. The fence's title comes
129
+ * from the `code` node's `data.meta`, and `data` is preserved by the sanitizer verbatim.
130
+ * - headings — plain `h1`..`h6`; the slug `id` and the hover anchor are added by basalt's React
131
+ * renderers AFTER sanitization, so they never pass through the schema.
132
+ * - GFM alerts — an ordinary `blockquote` whose first paragraph carries a `[!NOTE]`-style text
133
+ * marker; `Callout` is chosen in the renderer, again downstream of the hast tree.
134
+ * - GFM tables / task lists / footnotes / strikethrough — `table`+`align`, `input[type=checkbox]
135
+ * [disabled]`, `.task-list-item`, `section[data-footnotes]`, `del` are all explicit
136
+ * `defaultSchema` allowances.
137
+ * - links/images — `href`/`src` survive the schema and are then narrowed further by
138
+ * `./url-hardening`'s prefix allowlist.
139
+ *
140
+ * So: do not "fill this in". Every entry added here widens the security baseline for every consumer
141
+ * and must be justified by output basalt actually emits. `style`, event handlers, and new protocols
142
+ * are out of bounds; an app-specific need belongs in that app's `sanitizeSchema` prop.
143
+ */
144
+ export declare const BASALT_SANITIZE_SCHEMA: SanitizeSchemaExtension;
145
+ /**
146
+ * Deep-merges additions into a base schema. Additive ONLY — and "additive" is measured by what the
147
+ * SANITIZER then allows, not by array lengths (see the module JSDoc for the three upstream lookup
148
+ * rules this has to model):
149
+ *
150
+ * - `tagNames` and `protocols` are concatenated per key, de-duplicated.
151
+ * - `attributes` are merged per tag into ONE entry per property name whose allow-list is the union
152
+ * of base and addition — and a tag gaining its first entry for a property keeps whatever `'*'`
153
+ * granted it, so a widening never shadows the wildcard off.
154
+ * - `clobberPrefix` is the single replace-valued field (a later extension wins; an omitted one
155
+ * leaves the previous value intact) — except the empty string, which is ignored.
156
+ *
157
+ * Never removes anything from `base`: keys no extension mentions (`strip`, `clobber`, `ancestors`,
158
+ * `required`, `allowComments`, `allowDoctypes`) pass through untouched.
159
+ *
160
+ * The `base` parameter takes the nullable `SanitizeSchemaInput` so `rehype-sanitize`'s own
161
+ * `defaultSchema` assigns with no cast; the RETURN type stays non-null.
162
+ *
163
+ * Pure and order-stable: no input is mutated, and the output ordering is a deterministic function
164
+ * of the inputs, so snapshots don't churn.
165
+ *
166
+ * @example
167
+ * mergeSanitizeSchema(defaultSchema, BASALT_SANITIZE_SCHEMA, { tagNames: ['hermes-badge'] })
168
+ */
169
+ export declare function mergeSanitizeSchema(base: SanitizeSchemaInput, ...extensions: readonly (SanitizeSchemaExtension | undefined)[]): SanitizeSchema;
170
+ //# sourceMappingURL=sanitize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../../src/content/sanitize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAIH;;;;;;;;;;GAUG;AACH,MAAM,MAAM,0BAA0B,GAClC,MAAM,GACN,SAAS,CAAC,MAAM,EAAE,GAAG,aAAa,CAAC,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC,CAAA;AAE9F;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAA;IAChC,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAA;IAChC,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAA;IAChE,QAAQ,CAAC,UAAU,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,0BAA0B,EAAE,CAAC,CAAC,CAAA;IACrF,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAA;IAChE,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,CAAA;IAC/E,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAClC,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CACtC,CAAA;AACD;;;;;;;;GAQG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,GAAG,IAAI,GAAG,SAAS,CAAA;IACnD,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,GAAG,IAAI,GAAG,SAAS,CAAA;IACnD,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,CAAA;IACnF,QAAQ,CAAC,UAAU,CAAC,EAChB,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,0BAA0B,EAAE,CAAC,CAAC,GAC/D,IAAI,GACJ,SAAS,CAAA;IACb,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,CAAA;IACvD,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;IAClD,QAAQ,CAAC,SAAS,CAAC,EACf,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC,GAC9D,IAAI,GACJ,SAAS,CAAA;IACb,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,CAAA;IAClG,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,CAAA;IACrD,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,CAAA;CACzD,CAAA;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,uBAAuB,GAAG;IACpC,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACrC,QAAQ,CAAC,UAAU,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,0BAA0B,EAAE,CAAC,CAAC,CAAA;IACrF,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAA;IAChE,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAChC,CAAA;AAID;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,sBAAsB,EAAE,uBAA4B,CAAA;AAySjE;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,mBAAmB,EACzB,GAAG,UAAU,EAAE,SAAS,CAAC,uBAAuB,GAAG,SAAS,CAAC,EAAE,GAC9D,cAAc,CA0BhB"}