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
@@ -1,39 +1,266 @@
1
1
  /**
2
- * FenceBlock — renders a fenced code block's hast `pre` node as `CodeBlock` or `MermaidDiagram`
2
+ * FenceBlock — renders a fenced code block's hast `pre` node through the fence-renderer registry
3
3
  * (docs/CONTENT-SPEC.md §2 decision 4 / §6). Shared by every `pre` override `./markdown` builds
4
4
  * (one per streamed block, each with its own `settled` flag).
5
5
  *
6
- * `mermaid` fences render as a plain `CodeBlock` while `settled` is `false` (the in-flight
7
- * streaming tail) and upgrade to `MermaidDiagram` once the fence's block settles — no flicker, no
8
- * wasted parse pass on a still-changing diagram. Every other fenced language hides its copy
9
- * action while unsettled for the same reason (copying a still-streaming block is misleading).
6
+ * ONE mechanism, two sources: a consumer's `fenceRenderers[language]` is consulted first, then
7
+ * `BUILT_IN_FENCE_RENDERERS[language]`, then the plain `CodeBlock` fallback. `mermaid` is no longer
8
+ * a hardcoded branch — it is a built-in entry defined with `settledOnly`, exactly the helper a
9
+ * consumer uses for its own heavyweight renderer.
10
10
  *
11
- * Not part of the public surface.
11
+ * `settledOnly` renderers (mermaid included) render a plain `CodeBlock` while `settled` is `false`
12
+ * (the in-flight streaming tail) and upgrade once the fence's block settles — no flicker, no wasted
13
+ * parse pass on a still-changing diagram. Every fenced language hides its copy action while
14
+ * unsettled for the same reason (copying a still-streaming block is misleading).
15
+ *
16
+ * NEITHER a decline nor a throw may take the message down — a model-authored fence body (JSON for a
17
+ * `vega-lite` renderer, a payload for a `card` renderer, …) is routinely malformed, and this is a
18
+ * render path: the same invariant that made `spliceText` clamp instead of throw and dropped the
19
+ * runtime `assertNever` from `coalesceParts` applies here. `CodeBlock` is always a correct rendering
20
+ * of any fence, which is what makes it safe as the universal fallback:
21
+ * - A renderer returns `undefined` to DECLINE ("I cannot render this, use the default"). `null` is
22
+ * a different, deliberate result — "render nothing" — and is passed through unchanged.
23
+ * - A renderer that THROWS is contained two different ways depending on where the throw happens —
24
+ * see `renderFenceSafely` (a throw from the renderer function itself) and `FenceRenderBoundary` (a
25
+ * throw from a component the renderer merely returns) below — and both fall back to `CodeBlock`
26
+ * with a once-per-fence dev warning (`warnFenceRendererThrew`).
27
+ *
28
+ * `FenceBlock` itself is not part of the public surface; `FenceRenderer`/`FenceRenderContext`/
29
+ * `settledOnly` are, re-exported through `./markdown` (the props that carry them live on
30
+ * `MarkdownProps`).
12
31
  */
32
+ import { Component, useRef } from 'react'
33
+ import type { ReactNode } from 'react'
13
34
  import type { Element } from 'hast'
14
35
  import { CodeBlock } from './code-block'
15
36
  import { extractFenceInfo } from './markdown-hast'
16
37
  import { MermaidDiagram } from './mermaid'
17
38
 
39
+ // ── Public registry types ─────────────────────────────────────────────────────────────────────
40
+
41
+ export type FenceRenderContext = {
42
+ /** The fence body, without the surrounding fence markers. */
43
+ readonly code: string
44
+ /** The info string's first word (` ```ts title="x" ` → `'ts'`), absent on a bare fence. */
45
+ readonly language?: string
46
+ /** Raw meta string after the language on the opening fence line (`'title="x"'` above). */
47
+ readonly meta?: string
48
+ /** Parsed `title="…"` out of `meta` — the filename tab `CodeBlock` renders. */
49
+ readonly title?: string
50
+ /** `false` only for the in-flight tail block of a streaming message. Monotone: never true→false. */
51
+ readonly settled: boolean
52
+ }
53
+
54
+ /**
55
+ * `undefined` DECLINES — the fence falls back to the default `CodeBlock` rendering, no different
56
+ * from an unclaimed language. `null` is NOT a decline; it is a deliberate "render nothing" result
57
+ * and is used as-is. A renderer that THROWS is also contained and falls back to `CodeBlock` (see the
58
+ * module doc above) — no renderer needs its own try/parse/fallback scaffolding to survive malformed,
59
+ * model-authored fence bodies.
60
+ */
61
+ export type FenceRenderer = (ctx: FenceRenderContext) => ReactNode
62
+
63
+ /** Fence renderers keyed by language. */
64
+ export type FenceRenderers = Readonly<Record<string, FenceRenderer>>
65
+
66
+ /** The registry's terminal fallback — also what an unsettled `settledOnly` renderer shows. */
67
+ const defaultFenceRenderer: FenceRenderer = ({ code, language, title, settled }) => (
68
+ <CodeBlock
69
+ code={code}
70
+ showCopy={settled}
71
+ {...(language !== undefined && { language })}
72
+ {...(title !== undefined && { title })}
73
+ />
74
+ )
75
+
76
+ /**
77
+ * Wraps a renderer that is only worth running on finished text: renders the plain `CodeBlock`
78
+ * while the fence is still streaming, then `render` once its block settles. The built-in `mermaid`
79
+ * entry IS this — a consumer's own heavyweight fence (a chart spec, a diagram DSL) wants the same
80
+ * deal.
81
+ *
82
+ * @example
83
+ * import { settledOnly } from 'basalt-ui/content'
84
+ *
85
+ * <Markdown fenceRenderers={{ vega: settledOnly(({ code }) => <VegaChart spec={code} />) }}>
86
+ */
87
+ export function settledOnly(render: FenceRenderer): FenceRenderer {
88
+ return (ctx) => (ctx.settled ? render(ctx) : defaultFenceRenderer(ctx))
89
+ }
90
+
91
+ /** basalt's own fence renderers. A consumer entry for the same key wins over these. */
92
+ export const BUILT_IN_FENCE_RENDERERS: FenceRenderers = {
93
+ mermaid: settledOnly(({ code }) => <MermaidDiagram code={code} />),
94
+ }
95
+
96
+ // ── FenceBlock ────────────────────────────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * The raw meta string lives on the `code` child's `data.meta` (mdast-util-to-hast puts it there,
100
+ * never on the surrounding `pre`). `extractFenceInfo` parses `title="…"` out of it but does not
101
+ * surface the raw string, and widening `FenceInfo` is not this module's call — so read it here.
102
+ */
103
+ function extractFenceMeta(preNode: Element): string | undefined {
104
+ const codeNode = preNode.children.find(
105
+ (child): child is Element => child.type === 'element' && child.tagName === 'code',
106
+ )
107
+ const data = codeNode?.data as { meta?: string } | undefined
108
+ return data?.meta
109
+ }
110
+
18
111
  export type FenceBlockProps = {
19
112
  readonly node?: Element
20
113
  readonly settled: boolean
114
+ /** Consumer overrides, consulted BEFORE `BUILT_IN_FENCE_RENDERERS`. */
115
+ readonly renderers?: FenceRenderers
116
+ }
117
+
118
+ /**
119
+ * Registry lookup restricted to OWN keys — the only safe way to index either registry.
120
+ *
121
+ * `language` is the fence info string, so it is MODEL-CONTROLLED: `markdown-hast.ts` reads it off
122
+ * the `language-*` class, and `defaultSchema.attributes.code` (`[['className', /^language-./]]`)
123
+ * lets any such class through the sanitize pass. Both registries are plain object literals, so a
124
+ * bare `map[language]` resolves `Object.prototype` members and the `??` fallback never fires:
125
+ * ` ```valueOf `, ` ```hasOwnProperty ` and ` ```isPrototypeOf ` THREW when the resolved method was
126
+ * called unbound, ` ```__proto__ ` threw on a non-callable, and ` ```toString `/` ```constructor `
127
+ * rendered garbage. `Object.hasOwn` also rejects `__proto__` in a consumer literal (that key sets
128
+ * the prototype rather than defining an own property), and the `typeof` gate covers a consumer map
129
+ * that reached here through a cast with a non-function value.
130
+ */
131
+ function lookupFenceRenderer(
132
+ registry: FenceRenderers | undefined,
133
+ language: string,
134
+ ): FenceRenderer | undefined {
135
+ if (registry === undefined || !Object.hasOwn(registry, language)) return undefined
136
+ const candidate = registry[language]
137
+ return typeof candidate === 'function' ? candidate : undefined
21
138
  }
22
139
 
23
- export function FenceBlock({ node, settled }: FenceBlockProps) {
140
+ // ── Throw containment ────────────────────────────────────────────────────────────────────────────
141
+
142
+ /** A `useRef(false)` handle, mutated in place — same per-instance "warn once" idiom as
143
+ * `useHoverSync`'s `warnedRef` (`charts/hooks/useHoverSync.ts`) rather than a module-level
144
+ * singleton: a singleton would make the FIRST fence anywhere in the process to throw silence the
145
+ * warning for every OTHER, unrelated broken fence for the rest of the run (and, worse, for every
146
+ * later test in this suite — this file has module-singleton order-dependence history). Scoping the
147
+ * gate to one `FenceBlock`'s `useRef` means each rendered fence position gets its own warning, reset
148
+ * only on remount, and unrelated fences/tests never interact through it. */
149
+ type WarnedGate = { current: boolean }
150
+
151
+ /**
152
+ * Formats + emits the dev-only "a fence renderer threw" warning at most once per `warned` gate.
153
+ * Shared by BOTH containment paths below (`renderFenceSafely`'s try/catch and
154
+ * `FenceRenderBoundary.componentDidCatch`) so a fence that throws via either mechanism still only
155
+ * warns once. Mirrors the `process.env['NODE_ENV'] + console.warn('[basalt] …')` idiom used by
156
+ * `agent/merge.ts`'s `clampOffset` — including leaving the resulting oxlint `no-console` warning
157
+ * unsuppressed, the established convention in this package.
158
+ */
159
+ function warnFenceRendererThrew(warned: WarnedGate, error: unknown): void {
160
+ if (warned.current) return
161
+ warned.current = true
162
+ if (process.env['NODE_ENV'] !== 'production') {
163
+ console.warn(
164
+ '[basalt] FenceBlock: a fence renderer threw — falling back to the default CodeBlock rendering.',
165
+ error,
166
+ )
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Calls `render(ctx)` inside a try/catch — contains a SYNCHRONOUS throw from the renderer FUNCTION
172
+ * itself (e.g. `JSON.parse(code)` failing before the renderer ever returns anything). This catch
173
+ * sits in the same call stack as the invocation, so it only ever sees a throw that happens INSIDE
174
+ * this call — never one from a component the renderer merely returns as a descriptor (React invokes
175
+ * that component's own function body later, in a stack frame this function has already returned
176
+ * from — see `FenceRenderBoundary` for that case).
177
+ *
178
+ * Also resolves the decline convention here: `undefined` falls back to `defaultFenceRenderer`;
179
+ * `null` (or any other `ReactNode`) passes through unchanged.
180
+ */
181
+ function renderFenceSafely(
182
+ render: FenceRenderer,
183
+ ctx: FenceRenderContext,
184
+ warned: WarnedGate,
185
+ ): ReactNode {
186
+ let rendered: ReactNode
187
+ try {
188
+ rendered = render(ctx)
189
+ } catch (error) {
190
+ warnFenceRendererThrew(warned, error)
191
+ return defaultFenceRenderer(ctx)
192
+ }
193
+ return rendered === undefined ? defaultFenceRenderer(ctx) : rendered
194
+ }
195
+
196
+ type FenceRenderBoundaryProps = {
197
+ readonly ctx: FenceRenderContext
198
+ readonly warned: WarnedGate
199
+ readonly children: ReactNode
200
+ }
201
+ type FenceRenderBoundaryState = { hasError: boolean }
202
+
203
+ /**
204
+ * Contains a throw from the SUBTREE a `FenceRenderer` RETURNS — e.g.
205
+ * `settledOnly(({ code }) => <VegaChart spec={code} />)` where `VegaChart` itself throws while
206
+ * parsing `code` during ITS OWN render. `renderFenceSafely`'s try/catch cannot reach this case:
207
+ * `<VegaChart spec={code} />` is only a descriptor at the point the renderer returns it — React
208
+ * invokes `VegaChart`'s function body later, during its own render pass, outside the stack frame
209
+ * `renderFenceSafely` controls. An error boundary is the only mechanism that reaches a render-phase
210
+ * throw at that depth.
211
+ *
212
+ * `key={ctx.code}` at the call site (below) remounts this boundary — fresh `hasError: false` — every
213
+ * time the fence body changes, so a still-streaming fence that starts malformed and later becomes
214
+ * valid gets a genuine retry instead of staying permanently tripped.
215
+ *
216
+ * KNOWN GAP (standard React error-boundary limits): a throw from an event handler, from an effect
217
+ * running after an already-successful commit, or from asynchronous code is NOT caught here — only a
218
+ * throw during the RENDER of the returned subtree is contained.
219
+ */
220
+ class FenceRenderBoundary extends Component<FenceRenderBoundaryProps, FenceRenderBoundaryState> {
221
+ constructor(props: FenceRenderBoundaryProps) {
222
+ super(props)
223
+ this.state = { hasError: false }
224
+ }
225
+
226
+ static getDerivedStateFromError(): FenceRenderBoundaryState {
227
+ return { hasError: true }
228
+ }
229
+
230
+ override componentDidCatch(error: unknown): void {
231
+ warnFenceRendererThrew(this.props.warned, error)
232
+ }
233
+
234
+ override render(): ReactNode {
235
+ return this.state.hasError ? defaultFenceRenderer(this.props.ctx) : this.props.children
236
+ }
237
+ }
238
+
239
+ export function FenceBlock({ node, settled, renderers }: FenceBlockProps) {
240
+ const warned = useRef(false)
24
241
  const fence = node ? extractFenceInfo(node) : undefined
25
242
  if (!fence) return null
26
243
 
27
- if (fence.language === 'mermaid' && settled) {
28
- return <MermaidDiagram code={fence.code} />
244
+ const meta = node ? extractFenceMeta(node) : undefined
245
+ const ctx: FenceRenderContext = {
246
+ code: fence.code,
247
+ settled,
248
+ ...(fence.language !== undefined && { language: fence.language }),
249
+ ...(fence.title !== undefined && { title: fence.title }),
250
+ ...(meta !== undefined && { meta }),
29
251
  }
30
252
 
253
+ const language = fence.language
254
+ const render =
255
+ language === undefined
256
+ ? undefined
257
+ : (lookupFenceRenderer(renderers, language) ??
258
+ lookupFenceRenderer(BUILT_IN_FENCE_RENDERERS, language))
259
+ const active = render ?? defaultFenceRenderer
260
+
31
261
  return (
32
- <CodeBlock
33
- code={fence.code}
34
- showCopy={settled}
35
- {...(fence.language !== undefined && { language: fence.language })}
36
- {...(fence.title !== undefined && { title: fence.title })}
37
- />
262
+ <FenceRenderBoundary key={ctx.code} ctx={ctx} warned={warned}>
263
+ {renderFenceSafely(active, ctx, warned)}
264
+ </FenceRenderBoundary>
38
265
  )
39
266
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * heading-components — the slug/anchor/foreign-id contract (docs/CONTENT-SPEC.md §2 decision 6 /
3
+ * §7). `createHeadingComponents` builds one renderer per level against a shared `SlugTracker`; each
4
+ * test below builds its OWN tracker so a slug-collision counter never leaks across cases.
5
+ *
6
+ * `createHeadingComponents` is not part of the public surface (not re-exported by `./index`), same
7
+ * test idiom as `composeRehypePlugins` in `./markdown.test.tsx` — imported straight from the module.
8
+ */
9
+ import type { ComponentType, JSX } from 'react'
10
+ import type { Components, ExtraProps } from 'react-markdown'
11
+ import { MantineProvider } from '@mantine/core'
12
+ import { render, screen } from '@testing-library/react'
13
+ import { describe, expect, test } from 'bun:test'
14
+ import { createHeadingComponents } from './heading-components'
15
+ import { SlugTracker } from './slug'
16
+
17
+ function renderHeading(ui: React.ReactElement) {
18
+ return render(<MantineProvider>{ui}</MantineProvider>)
19
+ }
20
+
21
+ type HeadingProps = JSX.IntrinsicElements['h2'] & ExtraProps
22
+
23
+ /** Narrows `Components['h2']` (`ComponentType | keyof JSX.IntrinsicElements | undefined`) down to
24
+ * the function component `createHeadingComponents` actually builds — no cast, a runtime check. */
25
+ function h2Of(components: Partial<Components>): ComponentType<HeadingProps> {
26
+ const H2 = components.h2
27
+ if (typeof H2 !== 'function') throw new Error('expected createHeadingComponents to build h2')
28
+ return H2
29
+ }
30
+
31
+ describe('heading-components', () => {
32
+ test('an ordinary heading is slugged, gets an id, and gets the anchor affordance', () => {
33
+ const H2 = h2Of(createHeadingComponents(new SlugTracker()))
34
+ renderHeading(<H2>Introduction</H2>)
35
+
36
+ const heading = screen.getByRole('heading', { level: 2, name: 'Introduction' })
37
+ expect(heading.id).toBe('introduction')
38
+ expect(screen.getByLabelText('Copy link to section')).toBeDefined()
39
+ })
40
+
41
+ test('a heading arriving with an id keeps that id instead of the slugged one', () => {
42
+ const H2 = h2Of(createHeadingComponents(new SlugTracker()))
43
+ renderHeading(<H2 id="footnote-label">Footnotes</H2>)
44
+
45
+ // The slugger would produce 'footnotes' — the incoming id must win outright.
46
+ const heading = screen.getByRole('heading', { level: 2, name: 'Footnotes' })
47
+ expect(heading.id).toBe('footnote-label')
48
+ })
49
+
50
+ test('a heading arriving with a class keeps it', () => {
51
+ const H2 = h2Of(createHeadingComponents(new SlugTracker()))
52
+ renderHeading(<H2 className="sr-only">Footnotes</H2>)
53
+
54
+ const heading = screen.getByRole('heading', { level: 2, name: 'Footnotes' })
55
+ expect(heading.className).toBe('sr-only')
56
+ })
57
+
58
+ test('a heading that keeps its own id is not given the anchor affordance', () => {
59
+ const H2 = h2Of(createHeadingComponents(new SlugTracker()))
60
+ renderHeading(<H2 id="footnote-label">Footnotes</H2>)
61
+
62
+ screen.getByRole('heading', { level: 2, name: 'Footnotes' })
63
+ expect(screen.queryByLabelText('Copy link to section')).toBeNull()
64
+ })
65
+ })
@@ -5,7 +5,19 @@
5
5
  *
6
6
  * Ids come from `SlugTracker` — pass a document-scoped instance for correct cross-heading dedup,
7
7
  * or a fresh per-block instance in `Markdown`'s `streaming` mode (see that module's JSDoc for the
8
- * accepted collision tradeoff there).
8
+ * accepted collision tradeoff there). That is the default path, for an ordinary prose heading with
9
+ * no id of its own.
10
+ *
11
+ * FOREIGN IDS: a heading can already arrive with an `id` — remark-gfm's footnotes section heading
12
+ * is one (`<h2 id="footnote-label" class="sr-only">Footnotes</h2>`, referenced by the surrounding
13
+ * `<section>`'s `aria-labelledby`), and a consumer's own `rehypePlugins` injecting a heading is
14
+ * another. The rule is general, not a footnote special case: an id this renderer did NOT assign is
15
+ * something else's address for that heading, and slugging over it breaks whatever points at it. So
16
+ * an incoming `id` wins outright — no re-slug, no dedup registration against `tracker` (the heading
17
+ * isn't part of this document's slug namespace), and no `HeadingAnchor` (the affordance's premise is
18
+ * "copy a link to the id THIS renderer just minted"; a foreign id's meaning is basalt's to respect,
19
+ * not to advertise). Every other incoming prop (`className` included) is forwarded either way — the
20
+ * one thing always stripped is the hast `node` handle, which must never reach the DOM.
9
21
  *
10
22
  * Not part of the public surface.
11
23
  */
@@ -38,12 +50,21 @@ function headingText(node: ReactNode): string {
38
50
 
39
51
  function createHeadingRenderer(level: HeadingLevel, tracker: SlugTracker) {
40
52
  const Tag = HEADING_TAGS[level]
41
- return function HeadingRenderer({ children }: HeadingProps) {
42
- const id = tracker.slug(headingText(children))
53
+ // `node` is react-markdown's hast handle (`ExtraProps`) — read by neither branch, and must not
54
+ // reach the DOM, so it's stripped the same way `LinkRenderer` strips it in `./markdown`.
55
+ return function HeadingRenderer({ id, children, node: _node, ...rest }: HeadingProps) {
56
+ if (id !== undefined) {
57
+ return (
58
+ <Tag id={id} {...rest}>
59
+ {children}
60
+ </Tag>
61
+ )
62
+ }
63
+ const slug = tracker.slug(headingText(children))
43
64
  return (
44
- <Tag id={id}>
65
+ <Tag id={slug} {...rest}>
45
66
  {children}
46
- <HeadingAnchor id={id} />
67
+ <HeadingAnchor id={slug} />
47
68
  </Tag>
48
69
  )
49
70
  }
@@ -4,7 +4,8 @@
4
4
  * Mantine-coupled (like `./forms`/`./notifications`/`./data`): prose typography, a shiki-backed
5
5
  * code block (optional peer, lazy singleton), a semantic callout, a scroll-spy TOC rail, a
6
6
  * scroll-driven reading-progress bar, a streaming-aware `Markdown` renderer (react-markdown +
7
- * remark-gfm, optional peers), `MermaidDiagram` (beautiful-mermaid, optional peer), the MDX
7
+ * remark-gfm + rehype-sanitize + remend, all optional peers) with its fence-renderer registry,
8
+ * `MermaidDiagram` (beautiful-mermaid, optional peer), the MDX
8
9
  * component map, the docs-page frame (`ArticleLayout`), overview cards (`ArticleCard`/
9
10
  * `ArticleGrid`), and the contextual-help drawer (`GuideLink`/`GuideDrawer`). See
10
11
  * `agent/rules/basalt-content.md` for the full doctrine + the content-collections recipe.
@@ -30,8 +31,23 @@ export type { ReadingTime } from './slug'
30
31
  export { HeadingAnchor } from './heading-anchor'
31
32
  export type { HeadingAnchorProps } from './heading-anchor'
32
33
 
33
- export { Markdown } from './markdown'
34
- export type { MarkdownComponents, MarkdownProps } from './markdown'
34
+ export { Markdown, settledOnly } from './markdown'
35
+ export type {
36
+ FenceRenderContext,
37
+ FenceRenderer,
38
+ FenceRenderers,
39
+ MarkdownComponents,
40
+ MarkdownContentTrust,
41
+ MarkdownProps,
42
+ } from './markdown'
43
+
44
+ export { BASALT_SANITIZE_SCHEMA, mergeSanitizeSchema } from './sanitize'
45
+ export type {
46
+ SanitizePropertyDefinition,
47
+ SanitizeSchema,
48
+ SanitizeSchemaExtension,
49
+ SanitizeSchemaInput,
50
+ } from './sanitize'
35
51
 
36
52
  export { MermaidDiagram } from './mermaid'
37
53
  export type { MermaidDiagramProps } from './mermaid'