basalt-ui 1.10.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 +25 -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.d.ts +3 -52
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +108 -24
- 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 +42 -0
- package/dist/agent/coalesce.d.ts.map +1 -0
- package/dist/agent/coalesce.js +137 -0
- package/dist/agent/coalesce.test.d.ts +2 -0
- package/dist/agent/coalesce.test.d.ts.map +1 -0
- package/dist/agent/foreign.d.ts +97 -0
- package/dist/agent/foreign.d.ts.map +1 -0
- package/dist/agent/foreign.js +18 -0
- package/dist/agent/foreign.test.d.ts +2 -0
- package/dist/agent/foreign.test.d.ts.map +1 -0
- package/dist/agent/history.d.ts +2 -0
- package/dist/agent/history.d.ts.map +1 -1
- package/dist/agent/id.d.ts +65 -0
- package/dist/agent/id.d.ts.map +1 -0
- package/dist/agent/id.js +30 -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 +11 -4
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +18 -3
- package/dist/agent/merge.d.ts +8 -0
- package/dist/agent/merge.d.ts.map +1 -0
- package/dist/agent/merge.js +35 -0
- 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 +53 -7
- 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 +135 -20
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +138 -11
- 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.d.ts +39 -3
- package/dist/agent/transport.d.ts.map +1 -1
- package/dist/agent/transport.js +20 -17
- 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.d.ts +2 -1
- package/dist/agent/use-agent-stream.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +5 -2
- 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.d.ts +9 -3
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- 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.js +75 -23
- 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 +3 -1
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- 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 +24 -7
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +115 -45
- 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.d.ts +21 -0
- package/dist/agent-chat/tool-chip.d.ts.map +1 -0
- package/dist/agent-chat/tool-chip.js +120 -0
- 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/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +1 -3
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- 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 +5 -5
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +13 -4
- 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 +7 -7
- 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 +380 -55
- package/src/agent/ai-sdk-transport.ts +206 -53
- package/src/agent/coalesce.test.ts +387 -0
- package/src/agent/coalesce.ts +229 -0
- package/src/agent/foreign.test.ts +51 -0
- package/src/agent/foreign.ts +140 -0
- package/src/agent/history.ts +2 -0
- package/src/agent/id.test.ts +75 -0
- package/src/agent/id.ts +91 -0
- package/src/agent/index.ts +34 -3
- package/src/agent/merge.test.ts +206 -0
- package/src/agent/merge.ts +114 -0
- package/src/agent/part-list.test.tsx +112 -0
- package/src/agent/part-list.tsx +108 -35
- package/src/agent/parts.test.ts +516 -57
- package/src/agent/parts.ts +323 -32
- package/src/agent/thread.test.ts +75 -2
- package/src/agent/thread.ts +21 -1
- package/src/agent/transport.test.ts +70 -0
- package/src/agent/transport.ts +72 -20
- package/src/agent/use-agent-stream.test.tsx +53 -19
- package/src/agent/use-agent-stream.ts +13 -3
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +138 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +44 -11
- package/src/agent/use-agent-thread-runs.test.tsx +418 -22
- package/src/agent/use-agent-thread-runs.ts +170 -26
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +15 -7
- package/src/agent-chat/composer.test.tsx +924 -0
- package/src/agent-chat/composer.tsx +521 -52
- package/src/agent-chat/index.ts +5 -1
- package/src/agent-chat/thread-detail-panel.tsx +3 -1
- package/src/agent-chat/thread-message.test.tsx +351 -0
- package/src/agent-chat/thread-message.tsx +262 -64
- package/src/agent-chat/thread-workspace.tsx +1 -1
- package/src/agent-chat/tool-chip.test.tsx +139 -0
- package/src/agent-chat/tool-chip.tsx +219 -0
- 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/guard/check-source.test.ts +8 -4
- package/src/guard/index.ts +1 -3
- package/src/index.ts +15 -1
- package/src/surfaces.ts +13 -4
- 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
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToolChip — a one-line collapsed row for a tool-call part, expanding to its input/output JSON.
|
|
3
|
+
*
|
|
4
|
+
* Replaces `thread-message.tsx`'s old always-expanded `ToolRenderer`, which unconditionally
|
|
5
|
+
* rendered both input and output `<pre>` blocks. Collapsed, it shows a mono micro-label, the tool
|
|
6
|
+
* name, a state dot, and `durationMs` when present; expanded, it shows whatever the part's state
|
|
7
|
+
* actually carries — input, output, the surviving `rawInput` for a call whose input never
|
|
8
|
+
* validated, the error text, the denial reason, or the approve/deny affordances.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { ToolChip } from 'basalt-ui/agent-chat'
|
|
12
|
+
*
|
|
13
|
+
* <ToolChip
|
|
14
|
+
* part={part}
|
|
15
|
+
* onApprove={(id) => api.runs.approve.post({ approvalId: id })}
|
|
16
|
+
* onDeny={(id, reason) => api.runs.deny.post({ approvalId: id, reason })}
|
|
17
|
+
* />
|
|
18
|
+
*/
|
|
19
|
+
import { Box, Button, Collapse, Group, Stack, Text, UnstyledButton } from '@mantine/core'
|
|
20
|
+
import { useDisclosure } from '@mantine/hooks'
|
|
21
|
+
import type { JSX } from 'react'
|
|
22
|
+
import { isToolCallSettled } from '../agent'
|
|
23
|
+
import type { ToolCallPart } from '../agent'
|
|
24
|
+
import { alpha, VX } from '../tokens'
|
|
25
|
+
|
|
26
|
+
// Mirrors thread-message.tsx's micro-label/rail/code-block idiom (docs/DESIGN-SPEC.md §3/§5) —
|
|
27
|
+
// duplicated locally rather than imported, matching the existing part-list.tsx/thread-message.tsx
|
|
28
|
+
// precedent of each Mantine-facing renderer file owning its own copy of these style objects.
|
|
29
|
+
const MICRO_LABEL_STYLE = {
|
|
30
|
+
fontFamily: 'var(--basalt-font-mono)',
|
|
31
|
+
fontSize: VX.text.micro,
|
|
32
|
+
fontWeight: 500,
|
|
33
|
+
letterSpacing: '0.06em',
|
|
34
|
+
textTransform: 'uppercase',
|
|
35
|
+
color: VX.faint,
|
|
36
|
+
} as const
|
|
37
|
+
const RAIL_STYLE = {
|
|
38
|
+
borderLeft: `2px solid ${VX.divider}`,
|
|
39
|
+
paddingLeft: 'var(--vx-space-agent-rail-inset-x)',
|
|
40
|
+
} as const
|
|
41
|
+
const CODE_BLOCK_STYLE = {
|
|
42
|
+
margin: 0,
|
|
43
|
+
marginTop: 'var(--vx-space-agent-part-gap-top)',
|
|
44
|
+
fontFamily: 'var(--basalt-font-mono)',
|
|
45
|
+
backgroundColor: alpha(VX.ink, 0.06),
|
|
46
|
+
borderRadius: 'var(--vx-radius-card)',
|
|
47
|
+
padding: 'var(--vx-space-agent-code-inset)',
|
|
48
|
+
whiteSpace: 'pre-wrap',
|
|
49
|
+
overflowWrap: 'anywhere',
|
|
50
|
+
} as const
|
|
51
|
+
|
|
52
|
+
/** Per-state (label, dot color) — the exhaustive switch that drives the collapsed row. */
|
|
53
|
+
function stateMeta(part: ToolCallPart): { readonly label: string; readonly dotToken: string } {
|
|
54
|
+
switch (part.state) {
|
|
55
|
+
case 'input-streaming':
|
|
56
|
+
return { label: 'streaming', dotToken: VX.status.neutral }
|
|
57
|
+
case 'input-available':
|
|
58
|
+
// No 'running' state on the wire — 'input-available' IS the running state (input complete,
|
|
59
|
+
// no output yet). This label is a UI-only gloss on that wire state, not a new state.
|
|
60
|
+
return { label: 'running', dotToken: VX.status.neutral }
|
|
61
|
+
case 'approval-requested':
|
|
62
|
+
return { label: 'needs approval', dotToken: VX.status.warn }
|
|
63
|
+
case 'approval-responded':
|
|
64
|
+
return part.approval.approved
|
|
65
|
+
? { label: 'approved', dotToken: VX.status.neutral }
|
|
66
|
+
: { label: 'declined', dotToken: VX.status.bad }
|
|
67
|
+
case 'output-available':
|
|
68
|
+
// preliminary vs final must be visually distinguishable — the label carries that here; the
|
|
69
|
+
// expanded output body below is identical either way.
|
|
70
|
+
return part.preliminary === true
|
|
71
|
+
? { label: 'preliminary', dotToken: VX.status.warn }
|
|
72
|
+
: { label: 'done', dotToken: VX.status.good }
|
|
73
|
+
case 'output-error':
|
|
74
|
+
return { label: 'error', dotToken: VX.status.bad }
|
|
75
|
+
case 'output-denied':
|
|
76
|
+
return { label: 'denied', dotToken: VX.status.bad }
|
|
77
|
+
default: {
|
|
78
|
+
// `part` is `never` here ONLY at compile time — this switch is genuinely exhaustive today,
|
|
79
|
+
// so adding an eighth ToolCallPart state without a case above makes this assignment a tsc
|
|
80
|
+
// error, the same gate `assertNever` provides. But `part` at RUNTIME can be a value this
|
|
81
|
+
// build never declared: `createThreadsStore` persists to localStorage with no shape
|
|
82
|
+
// validation, and `edenTransport` trusts the wire with zero validation, so a 1.10.0-shaped
|
|
83
|
+
// flat tool part (`state` undefined) or a future/foreign `state` string both reach here.
|
|
84
|
+
// That must never THROW during render — mirrors ThreadTranscript's fallbackRenderer
|
|
85
|
+
// doctrine (unknown data renders inert, it doesn't blank the transcript) — so, unlike a
|
|
86
|
+
// genuinely-exhaustive switch elsewhere, this default returns a neutral descriptor instead
|
|
87
|
+
// of calling assertNever.
|
|
88
|
+
const exhaustive: never = part
|
|
89
|
+
void exhaustive
|
|
90
|
+
return { label: 'unknown', dotToken: VX.status.neutral }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatDuration(ms: number): string {
|
|
96
|
+
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type ToolChipProps = {
|
|
100
|
+
readonly part: ToolCallPart
|
|
101
|
+
readonly defaultExpanded?: boolean
|
|
102
|
+
/** Rendered only in 'approval-requested'. Omit both to render a read-only chip. */
|
|
103
|
+
readonly onApprove?: (approvalId: string) => void
|
|
104
|
+
readonly onDeny?: (approvalId: string, reason?: string) => void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A collapsed-by-default row for a single tool-call part. All seven wire states render something
|
|
109
|
+
* sane; the switch above still gates an eighth (compile-time-unhandled) state as a tsc error via a
|
|
110
|
+
* `const _: never = part` assignment, but unlike a truly-closed switch, its `default` branch never
|
|
111
|
+
* throws at RUNTIME — a value from persisted/wire data that reaches here with a `state` this build
|
|
112
|
+
* doesn't recognize renders an inert 'unknown' chip instead of blanking the transcript.
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* <ToolChip part={part} />
|
|
116
|
+
*/
|
|
117
|
+
export function ToolChip({
|
|
118
|
+
part,
|
|
119
|
+
defaultExpanded = false,
|
|
120
|
+
onApprove,
|
|
121
|
+
onDeny,
|
|
122
|
+
}: ToolChipProps): JSX.Element {
|
|
123
|
+
const [expanded, { toggle }] = useDisclosure(defaultExpanded)
|
|
124
|
+
const { label, dotToken } = stateMeta(part)
|
|
125
|
+
const settled = isToolCallSettled(part)
|
|
126
|
+
|
|
127
|
+
const input = 'input' in part ? part.input : undefined
|
|
128
|
+
const rawInput = part.state === 'output-error' ? part.rawInput : undefined
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<Box style={RAIL_STYLE}>
|
|
132
|
+
<Stack gap={4}>
|
|
133
|
+
<UnstyledButton onClick={toggle}>
|
|
134
|
+
<Group gap={6} align="center" wrap="nowrap">
|
|
135
|
+
<Box
|
|
136
|
+
aria-hidden
|
|
137
|
+
style={{
|
|
138
|
+
width: 6,
|
|
139
|
+
height: 6,
|
|
140
|
+
flexShrink: 0,
|
|
141
|
+
borderRadius: '50%', // theme-allow: a 6px state dot is a circle, not a surface corner — no radius token expresses it
|
|
142
|
+
backgroundColor: dotToken,
|
|
143
|
+
}}
|
|
144
|
+
/>
|
|
145
|
+
<Text style={MICRO_LABEL_STYLE}>{part.toolName}</Text>
|
|
146
|
+
<Text size="xs" c="dimmed">
|
|
147
|
+
{label}
|
|
148
|
+
</Text>
|
|
149
|
+
{part.durationMs !== undefined && (
|
|
150
|
+
<Text
|
|
151
|
+
style={{
|
|
152
|
+
fontFamily: 'var(--basalt-font-mono)',
|
|
153
|
+
fontSize: VX.text.micro,
|
|
154
|
+
color: VX.faint,
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
{formatDuration(part.durationMs)}
|
|
158
|
+
</Text>
|
|
159
|
+
)}
|
|
160
|
+
{!settled && (
|
|
161
|
+
<Text size="xs" c="dimmed">
|
|
162
|
+
…
|
|
163
|
+
</Text>
|
|
164
|
+
)}
|
|
165
|
+
</Group>
|
|
166
|
+
</UnstyledButton>
|
|
167
|
+
<Collapse expanded={expanded}>
|
|
168
|
+
<Stack gap={6}>
|
|
169
|
+
{input !== undefined && (
|
|
170
|
+
<Text component="pre" size="xs" style={CODE_BLOCK_STYLE}>
|
|
171
|
+
{JSON.stringify(input, null, 2)}
|
|
172
|
+
</Text>
|
|
173
|
+
)}
|
|
174
|
+
{rawInput !== undefined && (
|
|
175
|
+
<Text component="pre" size="xs" style={CODE_BLOCK_STYLE}>
|
|
176
|
+
{JSON.stringify(rawInput, null, 2)}
|
|
177
|
+
</Text>
|
|
178
|
+
)}
|
|
179
|
+
{part.state === 'output-available' && (
|
|
180
|
+
<Text component="pre" size="xs" style={CODE_BLOCK_STYLE}>
|
|
181
|
+
{JSON.stringify(part.output, null, 2)}
|
|
182
|
+
</Text>
|
|
183
|
+
)}
|
|
184
|
+
{part.state === 'output-error' && (
|
|
185
|
+
<Text size="xs" style={{ color: VX.status.bad }}>
|
|
186
|
+
{part.errorText}
|
|
187
|
+
</Text>
|
|
188
|
+
)}
|
|
189
|
+
{part.state === 'output-denied' && part.approval.reason !== undefined && (
|
|
190
|
+
<Text size="xs" c="dimmed">
|
|
191
|
+
{part.approval.reason}
|
|
192
|
+
</Text>
|
|
193
|
+
)}
|
|
194
|
+
{part.state === 'approval-requested' &&
|
|
195
|
+
(onApprove !== undefined || onDeny !== undefined) && (
|
|
196
|
+
<Group gap={6}>
|
|
197
|
+
{onApprove !== undefined && (
|
|
198
|
+
<Button size="compact-sm" onClick={() => onApprove(part.approval.id)}>
|
|
199
|
+
Approve
|
|
200
|
+
</Button>
|
|
201
|
+
)}
|
|
202
|
+
{onDeny !== undefined && (
|
|
203
|
+
<Button
|
|
204
|
+
size="compact-sm"
|
|
205
|
+
variant="light"
|
|
206
|
+
color="red"
|
|
207
|
+
onClick={() => onDeny(part.approval.id)}
|
|
208
|
+
>
|
|
209
|
+
Deny
|
|
210
|
+
</Button>
|
|
211
|
+
)}
|
|
212
|
+
</Group>
|
|
213
|
+
)}
|
|
214
|
+
</Stack>
|
|
215
|
+
</Collapse>
|
|
216
|
+
</Stack>
|
|
217
|
+
</Box>
|
|
218
|
+
)
|
|
219
|
+
}
|
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
|
}
|