Skip to content

Commit abbadcb

Browse files
authored
Merge pull request #524 from Simon-He95/feat/react-local-component-maps
feat(react): add direct streaming and HTML component maps
2 parents 8a4f73d + edc4ba1 commit abbadcb

34 files changed

Lines changed: 3007 additions & 231 deletions

docs/.vitepress/twoslash/markstream-react.d.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,31 @@
1-
import type { ComponentType } from 'react'
1+
import type { ComponentType, PropsWithChildren, ReactNode } from 'react'
22
import type { BaseNode } from 'stream-markdown-parser'
33

4+
export type RenderNodeFn = (node: BaseNode, key: string | number, ctx: RenderContext) => ReactNode
5+
6+
export interface RenderContext {
7+
customId?: string
8+
isDark?: boolean
9+
final?: boolean
10+
}
11+
12+
export interface NodeComponentProps<TNode = unknown> {
13+
node: TNode
14+
ctx?: RenderContext
15+
renderNode?: RenderNodeFn
16+
indexKey?: string | number
17+
customId?: string
18+
isDark?: boolean
19+
typewriter?: boolean
20+
fade?: boolean
21+
children?: ReactNode
22+
}
23+
24+
export type StreamingComponent<TNode = any> = ComponentType<NodeComponentProps<TNode>>
25+
export type StreamingComponentMap = Record<string, StreamingComponent<any>>
26+
export type HtmlComponent<P extends object = any> = ComponentType<PropsWithChildren<P>>
27+
export type HtmlComponentMap = Record<string, HtmlComponent<any>>
28+
429
export interface NodeRendererProps {
530
content?: string
631
nodes?: BaseNode[]
@@ -14,6 +39,8 @@ export interface NodeRendererProps {
1439

1540
export declare const MarkdownRender: ComponentType<NodeRendererProps>
1641
export declare const NodeRenderer: ComponentType<NodeRendererProps>
42+
export declare function defineStreamingComponents<const T extends Record<string, ComponentType<any>>>(components: T): T
43+
export declare function defineHtmlComponents<const T extends Record<string, ComponentType<any>>>(components: T): T
1744
export declare function setMermaidWorker(worker: Worker): void
1845
export declare function setKaTeXWorker(worker: Worker): void
1946
export declare function enableMermaid(): void

docs/guide/react-components.md

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
markstream-react provides the same powerful components as markstream-vue, but built for React. All components support React 18+ with full TypeScript support.
44

5-
The root `markstream-react`, `markstream-react/next`, and `markstream-react/server` entrypoints all ship declaration files. Shared renderer and component types such as `NodeRendererProps`, `NodeRendererCodeBlockProps`, `NodeComponentProps`, `RenderContext`, `RenderNodeFn`, `CustomComponentMap`, `CodeBlockMonacoOptions`, `MarkdownCodeBlockNodeProps`, `ListItemNodeProps`, `HtmlPreviewFrameProps`, `TooltipProps`, `TooltipPlacement`, and `LinkNodeStyleProps` can be imported directly from the entrypoint you use.
5+
The root `markstream-react`, `markstream-react/next`, and `markstream-react/server` entrypoints all ship declaration files. Shared renderer and component types such as `NodeRendererProps`, `NodeRendererCodeBlockProps`, `NodeComponentProps`, `StreamingComponentMap`, `HtmlComponentMap`, `RenderContext`, `RenderNodeFn`, `CustomComponentMap`, `CodeBlockMonacoOptions`, `MarkdownCodeBlockNodeProps`, `ListItemNodeProps`, `HtmlPreviewFrameProps`, `TooltipProps`, `TooltipPlacement`, and `LinkNodeStyleProps` can be imported directly from the entrypoint you use.
66
## Main Component: MarkdownRender
77

88
The primary component for rendering markdown content in React.
@@ -21,6 +21,8 @@ The primary component for rendering markdown content in React.
2121
| `final` | `boolean` | `false` | Marks end-of-stream; stops emitting streaming `loading` nodes |
2222
| `parseOptions` | `ParseOptions` | - | Parser options and token hooks (only when `content` is provided) |
2323
| `customHtmlTags` | `readonly string[]` | - | HTML-like tags emitted as custom nodes (e.g. `thinking`) |
24+
| `streamingComponents` | `StreamingComponentMap` | - | Renderer-local HTML-like tag components that receive `NodeComponentProps` and are automatically added to the parser's effective `customHtmlTags` |
25+
| `htmlComponents` | `HtmlComponentMap` | - | Renderer-local HTML-like tag components for the raw/dynamic HTML path; components receive sanitized HTML attributes and `children` |
2426
| `htmlPolicy` | `'safe' \| 'escape' \| 'trusted'` | `'safe'` | Controls `html_block` / `html_inline` rendering. `safe` blocks active/embed/form tags, `escape` shows literal HTML text, and `trusted` restores the broader trusted HTML behavior while still stripping scripts and unsafe attrs. |
2527
| `customMarkdownIt` | `(md: MarkdownIt) => MarkdownIt` | - | Customize the internal MarkdownIt instance |
2628
| `debugPerformance` | `boolean` | `false` | Log parse/render timing and virtualization stats (dev only) |
@@ -148,6 +150,72 @@ This is markstream-react.`
148150
}
149151
```
150152

153+
## Custom HTML-like Components
154+
155+
For new React code, prefer renderer-local component maps when your content contains HTML-like custom tags.
156+
157+
Use `streamingComponents` when the component needs the parser-backed streaming node contract. Keys are normalized like `customHtmlTags`, automatically added to the parser's effective custom tag list, and work with incomplete tags while content is streaming.
158+
159+
Use `htmlComponents` when the component should render through the raw/dynamic HTML path and receive sanitized HTML attributes plus `children`. Attribute values are converted to primitive prop values, but attribute names are preserved from the source HTML, so `class` remains `class`. These components may still rerender while content streams, but they do not receive `node.loading` or the parser node contract.
160+
161+
For typed component definitions, prefer `defineStreamingComponents(...)` and `defineHtmlComponents(...)`. `StreamingComponentMap` and `HtmlComponentMap` describe the broad runtime map shape accepted by the renderer; the helper functions perform the per-entry contract checks that catch mixing parser-backed `NodeComponentProps` components into the HTML props path.
162+
163+
```tsx
164+
import type { NodeComponentProps } from 'markstream-react'
165+
import type React from 'react'
166+
import MarkdownRender, { defineHtmlComponents, defineStreamingComponents } from 'markstream-react'
167+
168+
interface DocumentLinkNode {
169+
type: 'documentlink'
170+
tag: 'documentlink'
171+
attrs?: [string, string][]
172+
content: string
173+
loading?: boolean
174+
}
175+
176+
function getAttr(attrs: [string, string][] | undefined, name: string) {
177+
return attrs?.find(([key]) => key === name)?.[1]
178+
}
179+
180+
function DocumentLink({ node }: NodeComponentProps<DocumentLinkNode>) {
181+
return (
182+
<a
183+
href={`/documents/${getAttr(node.attrs, 'id')}`}
184+
aria-busy={node.loading || undefined}
185+
>
186+
{node.content}
187+
</a>
188+
)
189+
}
190+
191+
function Badge({ kind, children }: React.PropsWithChildren<{ kind?: string }>) {
192+
return <span data-kind={kind}>{children}</span>
193+
}
194+
195+
const streamingComponents = defineStreamingComponents({
196+
documentlink: DocumentLink,
197+
})
198+
199+
const htmlComponents = defineHtmlComponents({
200+
badge: Badge,
201+
})
202+
203+
function App({ content, isDone }: { content: string, isDone: boolean }) {
204+
return (
205+
<MarkdownRender
206+
content={content}
207+
final={isDone}
208+
streamingComponents={streamingComponents}
209+
htmlComponents={htmlComponents}
210+
/>
211+
)
212+
}
213+
```
214+
215+
`customHtmlTags` remains available as a lower-level parser option. `htmlComponents` can also handle tags listed in `customHtmlTags`, but it still receives sanitized HTML attributes and `children`; only `streamingComponents` receives `NodeComponentProps`. `setCustomComponents` and `customId` remain supported for compatibility, shared application-level registration, and existing node overrides. If the same normalized tag appears in both `streamingComponents` and `htmlComponents`, `streamingComponents` wins and a development warning is emitted once.
216+
217+
This API split fixes discoverability and typing around the two component contracts. HTML safety is still handled by `htmlPolicy` and the existing sanitization rules; the split is not a security boundary.
218+
151219
## Code Block Components
152220

153221
### MarkdownCodeBlockNode

docs/guide/react-markdown-migration.md

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -67,34 +67,77 @@ This means `components.h1` does not become `components.h1` again. It usually bec
6767
| `react-markdown` | `markstream-react` | Notes |
6868
|---|---|---|
6969
| `children` | `content` | Pass the Markdown string through `content`. |
70-
| `components` | `setCustomComponents(id?, mapping)` | `react-markdown` keys are HTML tags; `markstream-react` keys are node types such as `heading`, `link`, `paragraph`, `image`, `code_block`, `inline_code`. |
70+
| `components` | `streamingComponents`, `htmlComponents`, or `setCustomComponents(id?, mapping)` | For HTML-like custom tags, prefer renderer-local maps. Use `streamingComponents` for parser-backed `NodeComponentProps`; use `htmlComponents` for sanitized HTML attributes and `children`. Legacy node overrides still use `setCustomComponents`. |
7171
| `remarkPlugins` | `customMarkdownIt` | Use `markdown-it` plugins instead of `remark` plugins. Many common Markdown features already work without extra plugins. |
7272
| `remarkPlugins={[remarkGfm]}` | Often removable | Tables, task checkboxes, strikethrough, code fences, and other common constructs are already supported by the parser. Re-check edge cases before deleting plugin code. |
7373
| `rehypePlugins` | No direct equivalent | There is no public `rehype` stage. Use custom node renderers, `customHtmlTags`, `parseOptions`, or post-process `nodes` instead. |
74-
| `rehypeRaw` | Usually not needed | HTML-like tags are already parsed. For custom tags, prefer `customHtmlTags={['thinking']}` plus `setCustomComponents`. |
74+
| `rehypeRaw` | Usually not needed | HTML-like tags are already parsed. For custom tags, prefer `streamingComponents` or `htmlComponents` depending on the prop contract you need. |
7575
| `skipHtml` | No direct prop | HTML nodes render by default, with built-in blocking of dangerous attributes/tags and unsafe URLs in the HTML renderers. If you must remove HTML entirely, prefilter the input or parsed nodes yourself. |
7676
| `allowedElements` / `disallowedElements` / `allowElement` | No direct prop | Filter the parsed `nodes` tree yourself, or replace specific node renderers. |
7777
| `unwrapDisallowed` | Manual node filtering | Implement this in your node post-processing step if you need it. |
7878
| `urlTransform` | `parseOptions.validateLink` + custom `link` renderer | `validateLink` is for allow/deny. If you need URL rewriting, do it in a custom `link` renderer or while post-processing nodes. |
7979

8080
## Upgrade note: custom HTML-like tags
8181

82-
Current `markstream-react` releases no longer infer custom HTML tag names from `setCustomComponents(...)`. If your app renders model output such as `<DocumentLink id="...">title</DocumentLink>` and expects a custom component to receive `props.node`, add the tag to `customHtmlTags`:
82+
New applications should use renderer-local maps for HTML-like custom tags:
8383

8484
```tsx
85+
import type { NodeComponentProps } from 'markstream-react'
86+
import type React from 'react'
87+
import MarkdownRender from 'markstream-react'
88+
89+
function DocumentLink({ node }: NodeComponentProps<any>) {
90+
return <span>{node.content}</span>
91+
}
92+
93+
function Badge({ kind, children }: React.PropsWithChildren<{ kind?: string }>) {
94+
return <span data-kind={kind}>{children}</span>
95+
}
96+
97+
const renderer = (
98+
<MarkdownRender
99+
content={content}
100+
final={isDone}
101+
streamingComponents={{ documentlink: DocumentLink }}
102+
htmlComponents={{ badge: Badge }}
103+
/>
104+
)
105+
```
106+
107+
`streamingComponents` selects the parser-backed streaming-node contract. Its keys are added to the parser's effective `customHtmlTags`, so incomplete tags can render with `node.attrs`, `node.content`, and `node.loading`.
108+
109+
`htmlComponents` selects the raw/dynamic HTML contract. Components receive sanitized HTML attributes plus `children`, not `props.node`. Attribute values are converted to primitive prop values, but names are preserved from source HTML, so `class` stays `class`.
110+
111+
`customHtmlTags` remains available as a lower-level parser option. `setCustomComponents` and `customId` remain supported for compatibility, shared application-level registration, and built-in node overrides.
112+
113+
Migration example:
114+
115+
```tsx
116+
// Before
85117
setCustomComponents('chat', {
86118
documentlink: DocumentLink,
87119
})
88120

89-
React.createElement(MarkdownRender, {
90-
customId: 'chat',
91-
content,
92-
final: isDone,
93-
customHtmlTags: ['documentlink'],
94-
})
121+
const before = (
122+
<MarkdownRender
123+
customId="chat"
124+
customHtmlTags={['documentlink']}
125+
content={content}
126+
/>
127+
)
128+
129+
// After
130+
const after = (
131+
<MarkdownRender
132+
content={content}
133+
streamingComponents={{
134+
documentlink: DocumentLink,
135+
}}
136+
/>
137+
)
95138
```
96139

97-
Without `customHtmlTags`, the tag stays on the raw HTML dynamic rendering path. The component can still render, but it receives HTML-style props such as `{ id, children }` instead of `NodeComponentProps`, and it will not receive `node.loading` for streaming partial renders. This auto-inference removal was an undocumented breaking change for apps that relied on the previous behavior.
140+
Current `markstream-react` releases no longer infer custom HTML tag names from `setCustomComponents(...)`. Without `customHtmlTags` or `streamingComponents`, a complete custom-looking tag stays on the raw HTML dynamic rendering path and receives HTML-style props such as `{ id, children }`. This auto-inference removal was an undocumented breaking change for apps that relied on the previous behavior. The new local maps make that contract explicit in types.
98141

99142
## Migrating `components`
100143

@@ -167,7 +210,7 @@ Useful node-type translations:
167210
- `p` -> `paragraph`
168211
- `img` -> `image`
169212
- `code` / `pre` -> `code_block` or `inline_code`
170-
- Custom HTML-like tags -> `customHtmlTags` + `setCustomComponents`
213+
- Custom HTML-like tags -> `streamingComponents` for `NodeComponentProps`, or `htmlComponents` for sanitized HTML attributes/children
171214

172215
## Migrating code highlighting
173216

@@ -241,25 +284,24 @@ If your old `rehypeRaw` usage was mainly there to support custom tags such as `<
241284

242285
```tsx
243286
import type { NodeComponentProps } from 'markstream-react'
244-
import MarkdownRender, { setCustomComponents } from 'markstream-react'
287+
import MarkdownRender from 'markstream-react'
245288

246289
function ThinkingNode({ node }: NodeComponentProps<any>) {
247290
return <aside className="thinking-box">{node.content}</aside>
248291
}
249292

250-
setCustomComponents('chat', { thinking: ThinkingNode })
251-
252293
export function Message({ markdown }: { markdown: string }) {
253294
return (
254295
<MarkdownRender
255-
customId="chat"
256296
content={markdown}
257-
customHtmlTags={['thinking']}
297+
streamingComponents={{ thinking: ThinkingNode }}
258298
/>
259299
)
260300
}
261301
```
262302

303+
This API split is about discoverability and typing. HTML safety is still handled by `htmlPolicy` and the existing sanitization rules; do not treat the component-map split as a security boundary.
304+
263305
## Streaming upgrade path
264306

265307
You do not need to adopt streaming on day one. A practical migration path is:
@@ -293,7 +335,7 @@ For most chat streams, the simpler `content` + smooth streaming path is the firs
293335

294336
- Replace `<Markdown>{markdown}</Markdown>` with `<MarkdownRender content={markdown} />`.
295337
- Import `markstream-react/index.css`.
296-
- Move `components` overrides into `setCustomComponents(customId, mapping)`.
338+
- Move custom HTML-like tags into `streamingComponents` or `htmlComponents`; keep `setCustomComponents(customId, mapping)` for existing node overrides and shared registrations.
297339
- Remove plugins that are now redundant before re-adding custom ones.
298340
- Re-check any `rehype`-based HTML filtering or transformation logic.
299341
- If your app renders incremental output, evaluate `content` + smooth streaming first; upgrade to `nodes` when you need AST control or external parsing.

docs/guide/react-next-ssr.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,43 @@ export default function Page() {
2727
}
2828
```
2929

30+
Direct component maps contain React component functions, so keep them inside a local Client Component wrapper when you use `markstream-react/next` from an App Router Server Component. Server Components should only pass serializable props such as `content` into that wrapper, matching the [Next.js Client Component prop rules](https://nextjs.org/docs/app/api-reference/directives/use-client).
31+
32+
```tsx
33+
// app/markdown-renderer.tsx
34+
'use client'
35+
36+
import type { NodeComponentProps } from 'markstream-react/next'
37+
import MarkdownRender, { defineStreamingComponents } from 'markstream-react/next'
38+
39+
function DocumentLink(props: NodeComponentProps<{ content: string }>) {
40+
return <a>{props.node.content}</a>
41+
}
42+
43+
const streamingComponents = defineStreamingComponents({
44+
documentlink: DocumentLink,
45+
})
46+
47+
export function ClientMarkdown({ content }: { content: string }) {
48+
return (
49+
<MarkdownRender
50+
content={content}
51+
final
52+
streamingComponents={streamingComponents}
53+
/>
54+
)
55+
}
56+
```
57+
58+
```tsx
59+
// app/page.tsx
60+
import { ClientMarkdown } from './markdown-renderer'
61+
62+
export default function Page() {
63+
return <ClientMarkdown content="<DocumentLink>Open</DocumentLink>" />
64+
}
65+
```
66+
3067
## Pure Server Rendering with `markstream-react/server`
3168

3269
```tsx
@@ -41,6 +78,8 @@ export default function Page() {
4178
}
4279
```
4380

81+
The pure server entry does not create a Client Component boundary, so server files can pass `streamingComponents` and `htmlComponents` directly when the render should remain server-only.
82+
4483
## Custom Components and Custom Tags
4584

4685
If you register overrides from a server file, prefer the server helpers so you avoid importing the root package side effects:

0 commit comments

Comments
 (0)