basalt-ui 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +21 -21
- package/CHANGELOG.md +32 -0
- package/README.md +63 -51
- package/agent/rules/basalt-agent.md +14 -0
- package/agent/rules/basalt-content.md +16 -3
- package/configs/oxlint-plugin.js +7 -3
- package/configs/oxlint-plugin.test.ts +34 -5
- package/configs/oxlint.json +4 -4
- package/dist/agent/adapter.d.ts +150 -0
- package/dist/agent/adapter.d.ts.map +1 -0
- package/dist/agent/adapter.js +482 -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.map +1 -1
- package/dist/agent/ai-sdk-transport.js +6 -2
- 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 +78 -0
- package/dist/agent/id.d.ts.map +1 -1
- 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 +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 +18 -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.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +2 -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 +24 -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 +55 -15
- 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 +5 -1
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- package/dist/agent-chat/message-affordances.d.ts +59 -0
- package/dist/agent-chat/message-affordances.d.ts.map +1 -0
- package/dist/agent-chat/message-affordances.js +7 -0
- package/dist/agent-chat/relative-time.d.ts +27 -0
- package/dist/agent-chat/relative-time.d.ts.map +1 -0
- package/dist/agent-chat/relative-time.js +27 -0
- package/dist/agent-chat/relative-time.test.d.ts +2 -0
- package/dist/agent-chat/relative-time.test.d.ts.map +1 -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-feed-row.d.ts +65 -0
- package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed-row.js +156 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed.d.ts +34 -5
- package/dist/agent-chat/thread-feed.d.ts.map +1 -1
- package/dist/agent-chat/thread-feed.js +52 -19
- package/dist/agent-chat/thread-feed.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-message.d.ts +183 -2
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +329 -37
- 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-outcome-card.d.ts.map +1 -1
- package/dist/agent-chat/thread-outcome-card.js +1 -16
- package/dist/agent-chat/thread-workspace.d.ts +14 -2
- package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
- package/dist/agent-chat/thread-workspace.js +30 -4
- package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
- package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -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/agent-chat/virtualize.d.ts +123 -0
- package/dist/agent-chat/virtualize.d.ts.map +1 -0
- package/dist/agent-chat/virtualize.js +9 -0
- package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
- package/dist/agent-chat/virtualize.type-guard.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/data/index.d.ts +5 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/virtual-list.d.ts.map +1 -1
- package/dist/data/virtual.d.ts +2 -1
- package/dist/data/virtual.d.ts.map +1 -1
- 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 +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -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 +5 -5
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +14 -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 +9 -9
- package/package.json +7 -2
- package/src/agent/adapter.test.ts +1640 -0
- package/src/agent/adapter.ts +945 -0
- package/src/agent/ai-sdk-transport.test.ts +117 -26
- package/src/agent/ai-sdk-transport.ts +8 -2
- 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 +101 -3
- package/src/agent/id.ts +118 -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 +25 -1
- package/src/agent/use-agent-stream.test.tsx +31 -5
- package/src/agent/use-agent-stream.ts +6 -3
- 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 +356 -17
- package/src/agent/use-agent-thread-runs.ts +159 -23
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +93 -7
- package/src/agent-chat/composer.test.tsx +924 -0
- package/src/agent-chat/composer.tsx +521 -52
- package/src/agent-chat/index.ts +13 -1
- package/src/agent-chat/message-affordances.ts +61 -0
- package/src/agent-chat/relative-time.test.ts +95 -0
- package/src/agent-chat/relative-time.ts +55 -0
- package/src/agent-chat/thread-detail-panel.tsx +3 -1
- package/src/agent-chat/thread-feed-row.test.tsx +510 -0
- package/src/agent-chat/thread-feed-row.tsx +274 -0
- package/src/agent-chat/thread-feed.test.tsx +241 -0
- package/src/agent-chat/thread-feed.tsx +119 -18
- package/src/agent-chat/thread-message.test.tsx +1078 -4
- package/src/agent-chat/thread-message.tsx +795 -42
- package/src/agent-chat/thread-outcome-card.tsx +1 -25
- package/src/agent-chat/thread-workspace.test.tsx +169 -0
- package/src/agent-chat/thread-workspace.tsx +70 -6
- package/src/agent-chat/virtualize.ts +132 -0
- package/src/agent-chat/virtualize.type-guard.test.ts +49 -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/data/index.ts +5 -1
- package/src/data/virtual-list.tsx +2 -1
- package/src/data/virtual.ts +2 -1
- package/src/index.ts +18 -1
- package/src/surfaces.ts +13 -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 +21 -6
- package/src/theme/use-basalt-spacing.test.tsx +2 -1
package/src/content/markdown.tsx
CHANGED
|
@@ -5,15 +5,26 @@
|
|
|
5
5
|
* `agent/**` deliberately ships no renderer of its own, since `agent/** -> content` is
|
|
6
6
|
* lint-blocked by design.
|
|
7
7
|
*
|
|
8
|
-
* LAZY-LOADED: react-markdown
|
|
9
|
-
* `import()` (mirrors `agent/streaming-markdown.tsx` and
|
|
10
|
-
* `basalt-ui/content` never eagerly resolves them. Without
|
|
11
|
-
* raw string as plain text
|
|
8
|
+
* LAZY-LOADED: react-markdown, remark-gfm, rehype-sanitize and remend are all OPTIONAL peers,
|
|
9
|
+
* loaded via `React.lazy` / dynamic `import()` (mirrors `agent/streaming-markdown.tsx` and
|
|
10
|
+
* `./highlighter`) — importing `basalt-ui/content` never eagerly resolves any of them. Without
|
|
11
|
+
* react-markdown or remark-gfm, `Markdown` renders the raw string as plain text; without
|
|
12
|
+
* rehype-sanitize the sanitize pass is skipped (dev-warned once); without remend the streaming
|
|
13
|
+
* tail renders unrepaired.
|
|
14
|
+
*
|
|
15
|
+
* SANITIZATION: basalt appends its own `rehype-sanitize` pass AFTER the consumer's `rehypePlugins`
|
|
16
|
+
* — plugin order in unified is registration order, so last is the only position nothing can
|
|
17
|
+
* outrun. `sanitizeSchema` is an ADDITIONS-only extension merged over `BASALT_SANITIZE_SCHEMA`
|
|
18
|
+
* (see `./sanitize`); removing a default allowance is deliberately not expressible.
|
|
19
|
+
*
|
|
20
|
+
* TRUST vs STREAMING: `streaming` is a rendering mode (the text is still arriving); `contentTrust`
|
|
21
|
+
* is the security input (where the text came from). They are independent — a FINISHED agent message
|
|
22
|
+
* is settled AND untrusted. Only `contentTrust` selects the image allowlist default.
|
|
12
23
|
*
|
|
13
24
|
* Default element overrides:
|
|
14
25
|
* - `h1`..`h6` — slugged + hover-anchored (`./heading-components`).
|
|
15
|
-
* - fenced code (`pre`) —
|
|
16
|
-
* (
|
|
26
|
+
* - fenced code (`pre`) — the fence-renderer registry (`./fence-block`): `fenceRenderers[lang]`,
|
|
27
|
+
* then the built-ins (a settled ` ```mermaid ` fence → `MermaidDiagram`), then `CodeBlock`.
|
|
17
28
|
* - `blockquote` — a GFM alert marker (`[!NOTE]` etc.) renders as `Callout`; otherwise passes
|
|
18
29
|
* through (`./callout-alert`).
|
|
19
30
|
* - `a`/`img` — hardened via `allowedLinkPrefixes`/`allowedImagePrefixes`/`defaultOrigin`
|
|
@@ -32,11 +43,11 @@
|
|
|
32
43
|
* import { Markdown } from 'basalt-ui/content'
|
|
33
44
|
*
|
|
34
45
|
* <Markdown>{articleMarkdown}</Markdown>
|
|
35
|
-
* <Markdown density="chat" streaming
|
|
46
|
+
* <Markdown density="chat" streaming contentTrust="untrusted">{streamedText}</Markdown>
|
|
47
|
+
* <Markdown fenceRenderers={{ vega: settledOnly(({ code }) => <VegaChart spec={code} />) }}>
|
|
36
48
|
*/
|
|
37
49
|
import type { CSSProperties, JSX } from 'react'
|
|
38
|
-
import { lazy, memo, Suspense, useMemo } from 'react'
|
|
39
|
-
import remend from 'remend'
|
|
50
|
+
import { lazy, memo, Suspense, useEffect, useMemo, useState } from 'react'
|
|
40
51
|
import type {
|
|
41
52
|
Components,
|
|
42
53
|
ExtraProps,
|
|
@@ -45,10 +56,13 @@ import type {
|
|
|
45
56
|
} from 'react-markdown'
|
|
46
57
|
import { ALERT_CALLOUT_KIND, ALERT_TITLE, detectAlert, stripAlertMarker } from './callout-alert'
|
|
47
58
|
import { FenceBlock } from './fence-block'
|
|
59
|
+
import type { FenceRenderers } from './fence-block'
|
|
48
60
|
import { createHeadingComponents } from './heading-components'
|
|
49
61
|
import { Callout } from './callout'
|
|
50
62
|
import { Prose } from './prose'
|
|
51
63
|
import type { ProseDensity } from './prose'
|
|
64
|
+
import { BASALT_SANITIZE_SCHEMA, mergeSanitizeSchema } from './sanitize'
|
|
65
|
+
import type { SanitizeSchema, SanitizeSchemaExtension, SanitizeSchemaInput } from './sanitize'
|
|
52
66
|
import { SlugTracker } from './slug'
|
|
53
67
|
import { blockSplit } from './block-split'
|
|
54
68
|
import { createUrlTransform } from './url-hardening'
|
|
@@ -57,6 +71,19 @@ import { createUrlTransform } from './url-hardening'
|
|
|
57
71
|
|
|
58
72
|
export type MarkdownComponents = Components
|
|
59
73
|
|
|
74
|
+
// The fence registry's public vocabulary lives in `./fence-block` (next to the built-ins it
|
|
75
|
+
// defines) and is re-exported here — `fenceRenderers` is a `MarkdownProps` field, so this is the
|
|
76
|
+
// module a consumer already imports.
|
|
77
|
+
export { settledOnly } from './fence-block'
|
|
78
|
+
export type { FenceRenderContext, FenceRenderer, FenceRenderers } from './fence-block'
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Provenance of a `Markdown` body — the ONLY input to the image-allowlist default. Deliberately not
|
|
82
|
+
* a boolean: `contentTrust="untrusted"` says at the call site what it means, where `untrusted` /
|
|
83
|
+
* `!trusted` would not.
|
|
84
|
+
*/
|
|
85
|
+
export type MarkdownContentTrust = 'trusted' | 'untrusted'
|
|
86
|
+
|
|
60
87
|
export type MarkdownProps = {
|
|
61
88
|
/** The markdown string to render. Safe to update on every character during streaming. */
|
|
62
89
|
readonly children: string
|
|
@@ -64,21 +91,70 @@ export type MarkdownProps = {
|
|
|
64
91
|
readonly density?: ProseDensity
|
|
65
92
|
/** `Prose`'s measure cap. Defaults to `true` for `'article'` density, `false` for `'chat'`. */
|
|
66
93
|
readonly measure?: boolean
|
|
67
|
-
/** AI-stream mode — block-split + memoize + `remend`-repair the in-flight tail. Default `false`.
|
|
94
|
+
/** AI-stream mode — block-split + memoize + `remend`-repair the in-flight tail. Default `false`.
|
|
95
|
+
* Purely a RENDERING mode: it says the text is still arriving, NOT that it is untrusted. Use
|
|
96
|
+
* `contentTrust` for the security policy. */
|
|
68
97
|
readonly streaming?: boolean
|
|
69
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Where `children` came from — the input to the image policy, independent of `streaming`.
|
|
100
|
+
* `'untrusted'` (model-generated or third-party markdown) drops the `allowedImagePrefixes`
|
|
101
|
+
* default to same-origin `['/']`; `'trusted'` (your own authored content) keeps
|
|
102
|
+
* `['https://', '/']`. An explicit `allowedImagePrefixes` always wins over both.
|
|
103
|
+
*
|
|
104
|
+
* Default: `'untrusted'` when `streaming` is set, `'trusted'` otherwise. That default only
|
|
105
|
+
* preserves the pre-1.12 fail-safe — it is NOT the policy. Settledness has nothing to do with
|
|
106
|
+
* trust: a FINISHED agent message is still model-generated, so any surface rendering agent text
|
|
107
|
+
* must pin `contentTrust="untrusted"` explicitly rather than lean on `streaming`.
|
|
108
|
+
*/
|
|
109
|
+
readonly contentTrust?: MarkdownContentTrust
|
|
110
|
+
/**
|
|
111
|
+
* Per-element overrides merged OVER the defaults (only the keys you supply are replaced).
|
|
112
|
+
*
|
|
113
|
+
* A CUSTOM tag admitted via `sanitizeSchema.tagNames` (e.g. `'x-callout'`) cannot be given an
|
|
114
|
+
* override here: react-markdown's `Components` type is a mapped type over `JSX.IntrinsicElements`,
|
|
115
|
+
* which only has entries for real HTML/SVG tags — a made-up tag name is not a valid key of it, so
|
|
116
|
+
* there is no override slot for it to occupy. Style such a tag with CSS (element/attribute
|
|
117
|
+
* selectors) or a wrapping component instead of trying to intercept it here.
|
|
118
|
+
*/
|
|
70
119
|
readonly components?: MarkdownComponents
|
|
120
|
+
/**
|
|
121
|
+
* Fence renderers keyed by language, consulted BEFORE the built-in mermaid/`CodeBlock` dispatch.
|
|
122
|
+
* Wrap a heavyweight renderer in `settledOnly` to keep it off the still-streaming tail. Pass a
|
|
123
|
+
* referentially stable object — it participates in the streaming memoization.
|
|
124
|
+
*/
|
|
125
|
+
readonly fenceRenderers?: FenceRenderers
|
|
126
|
+
/**
|
|
127
|
+
* ADDITIONS merged into `BASALT_SANITIZE_SCHEMA` over rehype-sanitize's `defaultSchema`.
|
|
128
|
+
* Removals are not expressible — a schema handed to `rehype-sanitize` directly would replace the
|
|
129
|
+
* defaults wholesale (hast-util-sanitize merges with a shallow top-level spread), which is
|
|
130
|
+
* exactly the footgun this shape removes.
|
|
131
|
+
*/
|
|
132
|
+
readonly sanitizeSchema?: SanitizeSchemaExtension
|
|
71
133
|
/** Allowed `href` prefixes. Default `['https://', 'mailto:', '#', '/']`. */
|
|
72
134
|
readonly allowedLinkPrefixes?: readonly string[]
|
|
73
|
-
/** Allowed `img src` prefixes. Default `['https://', '/']
|
|
74
|
-
* (same-origin only) — auto-fetched images are the
|
|
75
|
-
* channel, so remote origins must be opted in explicitly
|
|
135
|
+
/** Allowed `img src` prefixes. Default `['https://', '/']` for trusted content, `['/']`
|
|
136
|
+
* (same-origin only) when `contentTrust` resolves to `'untrusted'` — auto-fetched images are the
|
|
137
|
+
* classic prompt-injection exfiltration channel, so remote origins must be opted in explicitly
|
|
138
|
+
* for model-generated content. */
|
|
76
139
|
readonly allowedImagePrefixes?: readonly string[]
|
|
77
140
|
/** Resolves a relative `href`/`src` to an absolute URL before the allowlist check's result is used. */
|
|
78
141
|
readonly defaultOrigin?: string
|
|
79
142
|
/** Escape hatch — appended after `remark-gfm`. */
|
|
80
143
|
readonly remarkPlugins?: readonly unknown[]
|
|
81
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* Escape hatch — appended after react-markdown's defaults, and BEFORE basalt's sanitize pass
|
|
146
|
+
* (which always runs last, so nothing supplied here can outrun it).
|
|
147
|
+
*
|
|
148
|
+
* react-markdown does NOT parse raw HTML in the markdown SOURCE by default (no `rehype-raw` is
|
|
149
|
+
* included here or by react-markdown itself) — a literal `<script>...</script>` typed into
|
|
150
|
+
* `children` never becomes a hast element; it stays inert text and the sanitize pass never even
|
|
151
|
+
* sees it. Concretely: a test that types a raw `<script>` tag into `children` and asserts it never
|
|
152
|
+
* renders is NOT exercising the sanitize pass — it would pass identically with the pass removed
|
|
153
|
+
* entirely, because the string was never a node the sanitizer could act on in the first place. The
|
|
154
|
+
* sanitize pass only ever sees elements produced by remark/rehype PLUGINS (`remarkPlugins`,
|
|
155
|
+
* `rehypePlugins`, or `rehype-raw` if a consumer adds it here) — that is the surface worth testing
|
|
156
|
+
* against unsafe markup, not raw source text.
|
|
157
|
+
*/
|
|
82
158
|
readonly rehypePlugins?: readonly unknown[]
|
|
83
159
|
readonly className?: string
|
|
84
160
|
readonly style?: CSSProperties
|
|
@@ -91,11 +167,16 @@ type ImgProps = JSX.IntrinsicElements['img'] & ExtraProps
|
|
|
91
167
|
type BlockquoteProps = JSX.IntrinsicElements['blockquote'] & ExtraProps
|
|
92
168
|
type PreProps = JSX.IntrinsicElements['pre'] & ExtraProps
|
|
93
169
|
|
|
94
|
-
|
|
95
|
-
|
|
170
|
+
// `rest` is forwarded, not dropped: it is what the sanitize pass already vetted (only
|
|
171
|
+
// `defaultSchema.attributes.a` + the `'*'` list can reach here), and dropping it broke GFM
|
|
172
|
+
// footnotes in the other direction from the clobber bug — a ref anchor's `id` is the target of its
|
|
173
|
+
// own back-link, so `href="#user-content-fnref-1"` dangled with the id discarded at render time.
|
|
174
|
+
// `node` is react-markdown's hast handle (`ExtraProps`) and must NOT reach the DOM.
|
|
175
|
+
function LinkRenderer({ href, children, node: _node, ...rest }: LinkProps) {
|
|
176
|
+
if (href === undefined) return <span {...rest}>{children}</span>
|
|
96
177
|
const external = href.startsWith('http://') || href.startsWith('https://')
|
|
97
178
|
return (
|
|
98
|
-
<a href={href} {...(external && { target: '_blank', rel: 'noreferrer noopener' })}>
|
|
179
|
+
<a href={href} {...rest} {...(external && { target: '_blank', rel: 'noreferrer noopener' })}>
|
|
99
180
|
{children}
|
|
100
181
|
</a>
|
|
101
182
|
)
|
|
@@ -118,22 +199,137 @@ function BlockquoteRenderer({ children }: BlockquoteProps) {
|
|
|
118
199
|
)
|
|
119
200
|
}
|
|
120
201
|
|
|
121
|
-
function createPreRenderer(settled: boolean) {
|
|
202
|
+
function createPreRenderer(settled: boolean, fenceRenderers: FenceRenderers | undefined) {
|
|
122
203
|
return function PreRenderer({ node }: PreProps) {
|
|
123
|
-
return
|
|
204
|
+
return (
|
|
205
|
+
<FenceBlock
|
|
206
|
+
settled={settled}
|
|
207
|
+
{...(node !== undefined && { node })}
|
|
208
|
+
{...(fenceRenderers !== undefined && { renderers: fenceRenderers })}
|
|
209
|
+
/>
|
|
210
|
+
)
|
|
124
211
|
}
|
|
125
212
|
}
|
|
126
213
|
|
|
127
|
-
function buildMarkdownComponents(
|
|
214
|
+
function buildMarkdownComponents(
|
|
215
|
+
tracker: SlugTracker,
|
|
216
|
+
settled: boolean,
|
|
217
|
+
fenceRenderers: FenceRenderers | undefined,
|
|
218
|
+
): Components {
|
|
128
219
|
return {
|
|
129
220
|
...createHeadingComponents(tracker),
|
|
130
|
-
pre: createPreRenderer(settled),
|
|
221
|
+
pre: createPreRenderer(settled, fenceRenderers),
|
|
131
222
|
blockquote: BlockquoteRenderer,
|
|
132
223
|
a: LinkRenderer,
|
|
133
224
|
img: ImageRenderer,
|
|
134
225
|
}
|
|
135
226
|
}
|
|
136
227
|
|
|
228
|
+
// ── Lazy-loaded rehype-sanitize ───────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
/** The resolved sanitize peer: its plugin plus the `defaultSchema` it re-exports. */
|
|
231
|
+
type MarkdownSanitizer = {
|
|
232
|
+
readonly plugin: unknown
|
|
233
|
+
readonly baseSchema: SanitizeSchemaInput
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let sanitizerPromise: Promise<MarkdownSanitizer | null> | null = null
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Module-level singleton (mirrors `./mermaid`'s `loadMermaidModule`) — resolves once, so the
|
|
240
|
+
* missing-peer warning below is emitted at most once per process.
|
|
241
|
+
*
|
|
242
|
+
* `defaultSchema` is NOT materialized into `BASALT_SANITIZE_SCHEMA` at module scope on purpose:
|
|
243
|
+
* that would need a static import of an OPTIONAL peer and hard-require it for every consumer. The
|
|
244
|
+
* effective schema is composed here instead, where the peer is already in hand.
|
|
245
|
+
*/
|
|
246
|
+
function loadSanitizer(): Promise<MarkdownSanitizer | null> {
|
|
247
|
+
sanitizerPromise ??= import('rehype-sanitize')
|
|
248
|
+
.then((mod) => ({
|
|
249
|
+
plugin: mod.default,
|
|
250
|
+
// No cast: `SanitizeSchemaInput` mirrors hast-util-sanitize's `Schema` INCLUDING its
|
|
251
|
+
// `| null | undefined` field arms, so `defaultSchema` assigns straight across;
|
|
252
|
+
// `mergeSanitizeSchema` normalizes the nulls away and hands back a `SanitizeSchema`.
|
|
253
|
+
baseSchema: mod.defaultSchema,
|
|
254
|
+
}))
|
|
255
|
+
.catch(() => {
|
|
256
|
+
if (process.env['NODE_ENV'] !== 'production') {
|
|
257
|
+
console.warn(
|
|
258
|
+
'[basalt] Markdown: optional peer "rehype-sanitize" is not installed — the sanitize pass is skipped. ' +
|
|
259
|
+
'react-markdown does not parse raw HTML by default, so this only opens a hole if a ' +
|
|
260
|
+
'rehypePlugin (rehype-raw and friends) puts untrusted nodes into the tree. ' +
|
|
261
|
+
'Install it with: bun add rehype-sanitize',
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
return null
|
|
265
|
+
})
|
|
266
|
+
return sanitizerPromise
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* GFM FOOTNOTES: exactly ONE layer may namespace ids, and it has to be `mdast-util-to-hast`'s.
|
|
271
|
+
*
|
|
272
|
+
* TWO layers prefix, and only one of them touches `href`. react-markdown calls `remark-rehype` with
|
|
273
|
+
* no `clobberPrefix` override, so `mdast-util-to-hast` applies its own `'user-content-'` to BOTH
|
|
274
|
+
* sides of a footnote link (`id="user-content-fn-1"`, `href="#user-content-fn-1"`).
|
|
275
|
+
* `hast-util-sanitize` then prefixes again, unconditionally, but only the properties in
|
|
276
|
+
* `defaultSchema.clobber` (`ariaDescribedBy`, `ariaLabelledBy`, `id`, `name`) — `href` is NOT among
|
|
277
|
+
* them. Executed against the installed rehype-sanitize@6.0.0 / mdast-util-to-hast@13.2.1, the pass
|
|
278
|
+
* emitted `id="user-content-user-content-fn-1"` next to `href="#user-content-fn-1"`, so EVERY
|
|
279
|
+
* footnote link, back-link and `aria-describedby` dangled.
|
|
280
|
+
*
|
|
281
|
+
* REJECTED: `remarkRehypeOptions: { clobberPrefix: '' }`, letting the sanitizer own the prefixing.
|
|
282
|
+
* Executed, it only moves the break — `href="#fn-1"` beside `id="user-content-fn-1"`, still
|
|
283
|
+
* dangling, because the sanitizer never rewrites `href`. Also rejected: rewriting hash hrefs in
|
|
284
|
+
* `urlTransform` to re-add the sanitizer's prefix — that runs on EVERY in-document link, so an
|
|
285
|
+
* author's `[x](#some-heading)` would stop matching the un-prefixed slug ids basalt's heading
|
|
286
|
+
* renderers generate downstream of hast.
|
|
287
|
+
*
|
|
288
|
+
* WHAT STILL PROTECTS IDS. Upstream skips clobbering entirely when `clobberPrefix` is falsy
|
|
289
|
+
* (`hast-util-sanitize/lib/index.js:653-657`), so this trades away its `id`/`name` namespacing:
|
|
290
|
+
* - Every id markdown SYNTAX can produce is a footnote id, and `mdast-util-to-hast` still
|
|
291
|
+
* namespaces those under `user-content-fn-`/`user-content-fnref-` — including the
|
|
292
|
+
* attacker-chosen footnote identifier — on the id and the href alike.
|
|
293
|
+
* - Raw HTML never becomes an element: react-markdown ships no `rehype-raw`, so `<div id="x">` in
|
|
294
|
+
* the source stays a `raw` node the sanitize pass drops (executed: it does).
|
|
295
|
+
* - basalt's own heading ids are added by React renderers downstream of hast, so the sanitizer
|
|
296
|
+
* never saw them and never protected them.
|
|
297
|
+
* The residue is a consumer whose `rehypePlugins` INJECT elements (`rehype-raw` and friends):
|
|
298
|
+
* arbitrary `id`/`name` then survives un-namespaced. Such a consumer restores it by adding their
|
|
299
|
+
* own clobbering pass to `rehypePlugins` — basalt's later pass does not un-prefix what an earlier
|
|
300
|
+
* one prefixed.
|
|
301
|
+
*
|
|
302
|
+
* WHY IT IS SET HERE AND NOT AS AN EXTENSION: `mergeSanitizeSchema` deliberately IGNORES an empty
|
|
303
|
+
* `clobberPrefix` from a `SanitizeSchemaExtension` (`adoptClobberPrefix` in `./sanitize`) — that is
|
|
304
|
+
* the additions-only channel refusing to let OUTSIDE code disable a protection. This is not outside
|
|
305
|
+
* code: basalt owns both prefixing layers here, so the pipeline-level decision belongs at the
|
|
306
|
+
* composition site, past the merge. It is not a consumer knob for the same reason.
|
|
307
|
+
*/
|
|
308
|
+
function withSingleClobberLayer(schema: SanitizeSchema) {
|
|
309
|
+
return { ...schema, clobberPrefix: '' }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Consumer plugins first, basalt's sanitize pass LAST — "everything after rehype-sanitize could be
|
|
314
|
+
* unsafe", so last is the only position nothing supplied through the escape hatch can outrun.
|
|
315
|
+
*
|
|
316
|
+
* Exported for the ordering test only; `./index.ts` deliberately does not re-export it.
|
|
317
|
+
*/
|
|
318
|
+
export function composeRehypePlugins({
|
|
319
|
+
sanitizer,
|
|
320
|
+
consumerPlugins,
|
|
321
|
+
sanitizeSchema,
|
|
322
|
+
}: {
|
|
323
|
+
readonly sanitizer: MarkdownSanitizer | null
|
|
324
|
+
readonly consumerPlugins?: readonly unknown[]
|
|
325
|
+
readonly sanitizeSchema?: SanitizeSchemaExtension
|
|
326
|
+
}): readonly unknown[] {
|
|
327
|
+
const consumer = consumerPlugins ?? []
|
|
328
|
+
if (sanitizer === null) return consumer
|
|
329
|
+
const merged = mergeSanitizeSchema(sanitizer.baseSchema, BASALT_SANITIZE_SCHEMA, sanitizeSchema)
|
|
330
|
+
return [...consumer, [sanitizer.plugin, withSingleClobberLayer(merged)]]
|
|
331
|
+
}
|
|
332
|
+
|
|
137
333
|
// ── Lazy-loaded react-markdown + remark-gfm ───────────────────────────────────────────────────
|
|
138
334
|
|
|
139
335
|
type RemarkPluginList = NonNullable<ReactMarkdownOptions['remarkPlugins']>
|
|
@@ -145,6 +341,7 @@ type BridgeProps = {
|
|
|
145
341
|
readonly urlTransform: UrlTransform
|
|
146
342
|
readonly remarkPlugins?: readonly unknown[]
|
|
147
343
|
readonly rehypePlugins?: readonly unknown[]
|
|
344
|
+
readonly sanitizeSchema?: SanitizeSchemaExtension
|
|
148
345
|
}
|
|
149
346
|
|
|
150
347
|
function PlainTextFallback({ text }: { text: string }) {
|
|
@@ -154,28 +351,40 @@ function PlainTextFallback({ text }: { text: string }) {
|
|
|
154
351
|
// The dynamic import chain loads react-markdown + remark-gfm only at render time. If either peer
|
|
155
352
|
// is absent the import fails — `.catch` resolves to the plain-text fallback (no unhandled
|
|
156
353
|
// rejection, no crash), the same optional-peer contract as `agent/streaming-markdown.tsx`.
|
|
354
|
+
// `loadSanitizer` swallows its OWN failure (resolving to `null`), so a missing rehype-sanitize
|
|
355
|
+
// degrades the sanitize pass alone rather than dropping the whole renderer to plain text.
|
|
157
356
|
const LazyReactMarkdown = lazy(() =>
|
|
158
|
-
Promise.all([import('react-markdown'), import('remark-gfm')])
|
|
159
|
-
.then(([{ default: ReactMarkdown }, { default: remarkGfm }]) => {
|
|
357
|
+
Promise.all([import('react-markdown'), import('remark-gfm'), loadSanitizer()])
|
|
358
|
+
.then(([{ default: ReactMarkdown }, { default: remarkGfm }, sanitizer]) => {
|
|
160
359
|
function Bridge({
|
|
161
360
|
text,
|
|
162
361
|
components,
|
|
163
362
|
urlTransform,
|
|
164
363
|
remarkPlugins,
|
|
165
364
|
rehypePlugins,
|
|
365
|
+
sanitizeSchema,
|
|
166
366
|
}: BridgeProps) {
|
|
167
367
|
const remarkPluginsList: RemarkPluginList = [
|
|
168
368
|
remarkGfm,
|
|
169
369
|
...((remarkPlugins ?? []) as RemarkPluginList),
|
|
170
370
|
]
|
|
371
|
+
const rehypePluginsList = useMemo(
|
|
372
|
+
() =>
|
|
373
|
+
composeRehypePlugins({
|
|
374
|
+
sanitizer,
|
|
375
|
+
...(rehypePlugins !== undefined && { consumerPlugins: rehypePlugins }),
|
|
376
|
+
...(sanitizeSchema !== undefined && { sanitizeSchema }),
|
|
377
|
+
}) as RehypePluginList,
|
|
378
|
+
// `sanitizer` is a constant of the enclosing load closure — resolved once, before this
|
|
379
|
+
// component exists, so it is deliberately not a dependency.
|
|
380
|
+
[rehypePlugins, sanitizeSchema],
|
|
381
|
+
)
|
|
171
382
|
return (
|
|
172
383
|
<ReactMarkdown
|
|
173
384
|
components={components}
|
|
174
385
|
urlTransform={urlTransform}
|
|
175
386
|
remarkPlugins={remarkPluginsList}
|
|
176
|
-
|
|
177
|
-
rehypePlugins: rehypePlugins as RehypePluginList,
|
|
178
|
-
})}
|
|
387
|
+
rehypePlugins={rehypePluginsList}
|
|
179
388
|
>
|
|
180
389
|
{text}
|
|
181
390
|
</ReactMarkdown>
|
|
@@ -192,9 +401,11 @@ type BlockProps = {
|
|
|
192
401
|
readonly text: string
|
|
193
402
|
readonly settled: boolean
|
|
194
403
|
readonly components?: MarkdownComponents
|
|
404
|
+
readonly fenceRenderers?: FenceRenderers
|
|
195
405
|
readonly urlTransform: UrlTransform
|
|
196
406
|
readonly remarkPlugins?: readonly unknown[]
|
|
197
407
|
readonly rehypePlugins?: readonly unknown[]
|
|
408
|
+
readonly sanitizeSchema?: SanitizeSchemaExtension
|
|
198
409
|
}
|
|
199
410
|
|
|
200
411
|
// A fresh SlugTracker per block (not shared across blocks) — see the module JSDoc's streaming-mode
|
|
@@ -204,16 +415,18 @@ function BlockRenderer({
|
|
|
204
415
|
text,
|
|
205
416
|
settled,
|
|
206
417
|
components,
|
|
418
|
+
fenceRenderers,
|
|
207
419
|
urlTransform,
|
|
208
420
|
remarkPlugins,
|
|
209
421
|
rehypePlugins,
|
|
422
|
+
sanitizeSchema,
|
|
210
423
|
}: BlockProps) {
|
|
211
424
|
// `text` isn't read in the factory below — it's the reset KEY, forcing a fresh tracker per block.
|
|
212
425
|
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
213
426
|
const tracker = useMemo(() => new SlugTracker(), [text])
|
|
214
427
|
const componentsMap = useMemo(
|
|
215
|
-
() => ({ ...buildMarkdownComponents(tracker, settled), ...components }),
|
|
216
|
-
[tracker, settled, components],
|
|
428
|
+
() => ({ ...buildMarkdownComponents(tracker, settled, fenceRenderers), ...components }),
|
|
429
|
+
[tracker, settled, fenceRenderers, components],
|
|
217
430
|
)
|
|
218
431
|
return (
|
|
219
432
|
<LazyReactMarkdown
|
|
@@ -222,6 +435,7 @@ function BlockRenderer({
|
|
|
222
435
|
urlTransform={urlTransform}
|
|
223
436
|
{...(remarkPlugins !== undefined && { remarkPlugins })}
|
|
224
437
|
{...(rehypePlugins !== undefined && { rehypePlugins })}
|
|
438
|
+
{...(sanitizeSchema !== undefined && { sanitizeSchema })}
|
|
225
439
|
/>
|
|
226
440
|
)
|
|
227
441
|
}
|
|
@@ -230,20 +444,78 @@ function BlockRenderer({
|
|
|
230
444
|
// this memo skip re-rendering an unchanged settled block — see the module JSDoc.
|
|
231
445
|
const MemoizedBlock = memo(BlockRenderer)
|
|
232
446
|
|
|
447
|
+
// ── Lazy-loaded remend ─────────────────────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
type RemendFn = (text: string) => string
|
|
450
|
+
|
|
451
|
+
let remendPromise: Promise<RemendFn | null> | null = null
|
|
452
|
+
/** Set once the singleton resolves, so a LATER-mounted tail starts repaired instead of paying the
|
|
453
|
+
* one unrepaired render again. */
|
|
454
|
+
let resolvedRemend: RemendFn | null = null
|
|
455
|
+
|
|
456
|
+
/** Module-level singleton, same optional-peer contract as `./mermaid`'s `loadMermaidModule`. */
|
|
457
|
+
function loadRemend(): Promise<RemendFn | null> {
|
|
458
|
+
remendPromise ??= import('remend')
|
|
459
|
+
.then((mod) => {
|
|
460
|
+
resolvedRemend = mod.default
|
|
461
|
+
return resolvedRemend
|
|
462
|
+
})
|
|
463
|
+
.catch(() => null)
|
|
464
|
+
return remendPromise
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* `null` until `remend` resolves — and forever if the optional peer is absent. The tail then
|
|
469
|
+
* renders its RAW text: a streaming tail is re-rendered per token anyway, so the worst case is one
|
|
470
|
+
* unrepaired frame at the very start of the first stream, never a blank block or an error.
|
|
471
|
+
*/
|
|
472
|
+
function useRemend(): RemendFn | null {
|
|
473
|
+
const [repair, setRepair] = useState<RemendFn | null>(() => resolvedRemend)
|
|
474
|
+
|
|
475
|
+
useEffect(() => {
|
|
476
|
+
if (repair !== null) return
|
|
477
|
+
let cancelled = false
|
|
478
|
+
loadRemend().then((loaded) => {
|
|
479
|
+
// `setRepair(loaded)` would treat the function as a state UPDATER — wrap it.
|
|
480
|
+
if (!cancelled && loaded !== null) setRepair(() => loaded)
|
|
481
|
+
return undefined
|
|
482
|
+
})
|
|
483
|
+
return () => {
|
|
484
|
+
cancelled = true
|
|
485
|
+
}
|
|
486
|
+
}, [repair])
|
|
487
|
+
|
|
488
|
+
return repair
|
|
489
|
+
}
|
|
490
|
+
|
|
233
491
|
function StreamingTailBlock(props: Omit<BlockProps, 'settled'>) {
|
|
234
|
-
const
|
|
492
|
+
const repair = useRemend()
|
|
493
|
+
const repaired = useMemo(
|
|
494
|
+
() => (repair === null ? props.text : repair(props.text)),
|
|
495
|
+
[repair, props.text],
|
|
496
|
+
)
|
|
235
497
|
return <BlockRenderer {...props} text={repaired} settled={false} />
|
|
236
498
|
}
|
|
237
499
|
|
|
238
500
|
type BodyProps = {
|
|
239
501
|
readonly text: string
|
|
240
502
|
readonly components?: MarkdownComponents
|
|
503
|
+
readonly fenceRenderers?: FenceRenderers
|
|
241
504
|
readonly urlTransform: UrlTransform
|
|
242
505
|
readonly remarkPlugins?: readonly unknown[]
|
|
243
506
|
readonly rehypePlugins?: readonly unknown[]
|
|
507
|
+
readonly sanitizeSchema?: SanitizeSchemaExtension
|
|
244
508
|
}
|
|
245
509
|
|
|
246
|
-
function SettledBody({
|
|
510
|
+
function SettledBody({
|
|
511
|
+
text,
|
|
512
|
+
components,
|
|
513
|
+
fenceRenderers,
|
|
514
|
+
urlTransform,
|
|
515
|
+
remarkPlugins,
|
|
516
|
+
rehypePlugins,
|
|
517
|
+
sanitizeSchema,
|
|
518
|
+
}: BodyProps) {
|
|
247
519
|
return (
|
|
248
520
|
<Suspense fallback={<PlainTextFallback text={text} />}>
|
|
249
521
|
<BlockRenderer
|
|
@@ -251,8 +523,10 @@ function SettledBody({ text, components, urlTransform, remarkPlugins, rehypePlug
|
|
|
251
523
|
settled
|
|
252
524
|
urlTransform={urlTransform}
|
|
253
525
|
{...(components !== undefined && { components })}
|
|
526
|
+
{...(fenceRenderers !== undefined && { fenceRenderers })}
|
|
254
527
|
{...(remarkPlugins !== undefined && { remarkPlugins })}
|
|
255
528
|
{...(rehypePlugins !== undefined && { rehypePlugins })}
|
|
529
|
+
{...(sanitizeSchema !== undefined && { sanitizeSchema })}
|
|
256
530
|
/>
|
|
257
531
|
</Suspense>
|
|
258
532
|
)
|
|
@@ -261,9 +535,11 @@ function SettledBody({ text, components, urlTransform, remarkPlugins, rehypePlug
|
|
|
261
535
|
function StreamingBody({
|
|
262
536
|
text,
|
|
263
537
|
components,
|
|
538
|
+
fenceRenderers,
|
|
264
539
|
urlTransform,
|
|
265
540
|
remarkPlugins,
|
|
266
541
|
rehypePlugins,
|
|
542
|
+
sanitizeSchema,
|
|
267
543
|
}: BodyProps) {
|
|
268
544
|
const blocks = useMemo(() => blockSplit(text), [text])
|
|
269
545
|
const lastIndex = blocks.length - 1
|
|
@@ -275,8 +551,10 @@ function StreamingBody({
|
|
|
275
551
|
text: block,
|
|
276
552
|
urlTransform,
|
|
277
553
|
...(components !== undefined && { components }),
|
|
554
|
+
...(fenceRenderers !== undefined && { fenceRenderers }),
|
|
278
555
|
...(remarkPlugins !== undefined && { remarkPlugins }),
|
|
279
556
|
...(rehypePlugins !== undefined && { rehypePlugins }),
|
|
557
|
+
...(sanitizeSchema !== undefined && { sanitizeSchema }),
|
|
280
558
|
}
|
|
281
559
|
// Block positions are stable during a stream (only the tail is ever replaced in place),
|
|
282
560
|
// so the array index is a safe, stable `key` here.
|
|
@@ -293,20 +571,60 @@ function StreamingBody({
|
|
|
293
571
|
// ── Markdown ───────────────────────────────────────────────────────────────────────────────────
|
|
294
572
|
|
|
295
573
|
const DEFAULT_LINK_PREFIXES: readonly string[] = ['https://', 'mailto:', '#', '/']
|
|
296
|
-
const
|
|
297
|
-
/*
|
|
574
|
+
const TRUSTED_IMAGE_PREFIXES: readonly string[] = ['https://', '/']
|
|
575
|
+
/* Untrusted (model-generated) markdown: images auto-fetch, so an open `https://` default is a
|
|
298
576
|
* prompt-injection exfiltration channel (``). Same-origin only
|
|
299
577
|
* unless the consumer opts specific origins in. Links stay clickable — navigation is user-initiated. */
|
|
300
|
-
const
|
|
578
|
+
const UNTRUSTED_IMAGE_PREFIXES: readonly string[] = ['/']
|
|
579
|
+
|
|
580
|
+
const IMAGE_PREFIXES_BY_TRUST: Readonly<Record<MarkdownContentTrust, readonly string[]>> = {
|
|
581
|
+
trusted: TRUSTED_IMAGE_PREFIXES,
|
|
582
|
+
untrusted: UNTRUSTED_IMAGE_PREFIXES,
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Restricts the lookup to OWN keys of `IMAGE_PREFIXES_BY_TRUST` — same guarding shape as
|
|
587
|
+
* `./fence-block`'s `lookupFenceRenderer` for the model-controlled fence-language registry.
|
|
588
|
+
* `contentTrust` is typed `MarkdownContentTrust`, but that is a compile-time promise only: a plain-
|
|
589
|
+
* JS consumer, or an `as` cast past the type, can hand this ANY string, and `IMAGE_PREFIXES_BY_TRUST`
|
|
590
|
+
* is a plain object literal — indexing it with an unchecked key resolves `Object.prototype` members.
|
|
591
|
+
*
|
|
592
|
+
* EXECUTED against this build, neither bad input "fails closed" on its own:
|
|
593
|
+
* - an unrecognised key (`'nonsense'`) resolves `allowedImagePrefixes` to `undefined`;
|
|
594
|
+
* - a prototype key (`'toString'`, `'constructor'`, …) resolves to a FUNCTION, not a string array.
|
|
595
|
+
* Both then reach `createUrlTransform`'s `isAllowedUrl`, which unconditionally calls
|
|
596
|
+
* `prefixes.some(...)` — `undefined.some` and `Function.prototype.some` are both not a function, so
|
|
597
|
+
* both inputs CRASH the render with an uncaught `TypeError` the instant an `img` is transformed
|
|
598
|
+
* (nothing between `Markdown` and react-markdown's `img` visit catches it). That is not "degrades to
|
|
599
|
+
* plain text with the image dropped" — it takes the whole message down, which is worse than an
|
|
600
|
+
* incidental fail-closed and not something to build on regardless. Fail closed on purpose: an
|
|
601
|
+
* unrecognised or non-own-property `contentTrust` gets the MOST RESTRICTIVE policy (same-origin
|
|
602
|
+
* only), never `undefined` and never a permissive default.
|
|
603
|
+
*/
|
|
604
|
+
function isKnownContentTrust(value: string): value is MarkdownContentTrust {
|
|
605
|
+
return Object.hasOwn(IMAGE_PREFIXES_BY_TRUST, value)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function imagePrefixesForTrust(contentTrust: string): readonly string[] {
|
|
609
|
+
return isKnownContentTrust(contentTrust)
|
|
610
|
+
? IMAGE_PREFIXES_BY_TRUST[contentTrust]
|
|
611
|
+
: UNTRUSTED_IMAGE_PREFIXES
|
|
612
|
+
}
|
|
301
613
|
|
|
302
614
|
export function Markdown({
|
|
303
615
|
children,
|
|
304
616
|
density = 'article',
|
|
305
617
|
measure,
|
|
306
618
|
streaming = false,
|
|
619
|
+
// `streaming` as the trust DEFAULT is backward compatibility only (pre-1.12 the image policy was
|
|
620
|
+
// read straight off `streaming`, so dropping it would silently widen every existing streaming
|
|
621
|
+
// call site). It is a floor, never the policy — see the prop's JSDoc.
|
|
622
|
+
contentTrust = streaming ? 'untrusted' : 'trusted',
|
|
307
623
|
components,
|
|
624
|
+
fenceRenderers,
|
|
625
|
+
sanitizeSchema,
|
|
308
626
|
allowedLinkPrefixes = DEFAULT_LINK_PREFIXES,
|
|
309
|
-
allowedImagePrefixes =
|
|
627
|
+
allowedImagePrefixes = imagePrefixesForTrust(contentTrust),
|
|
310
628
|
defaultOrigin,
|
|
311
629
|
remarkPlugins,
|
|
312
630
|
rehypePlugins,
|
|
@@ -329,8 +647,10 @@ export function Markdown({
|
|
|
329
647
|
text: children,
|
|
330
648
|
urlTransform,
|
|
331
649
|
...(components !== undefined && { components }),
|
|
650
|
+
...(fenceRenderers !== undefined && { fenceRenderers }),
|
|
332
651
|
...(remarkPlugins !== undefined && { remarkPlugins }),
|
|
333
652
|
...(rehypePlugins !== undefined && { rehypePlugins }),
|
|
653
|
+
...(sanitizeSchema !== undefined && { sanitizeSchema }),
|
|
334
654
|
}
|
|
335
655
|
|
|
336
656
|
return (
|