@assistant-ui/react-streamdown 0.0.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/README.md +163 -0
- package/dist/adapters/PreOverride.d.ts +27 -0
- package/dist/adapters/PreOverride.d.ts.map +1 -0
- package/dist/adapters/PreOverride.js +31 -0
- package/dist/adapters/PreOverride.js.map +1 -0
- package/dist/adapters/code-adapter.d.ts +22 -0
- package/dist/adapters/code-adapter.d.ts.map +1 -0
- package/dist/adapters/code-adapter.js +75 -0
- package/dist/adapters/code-adapter.js.map +1 -0
- package/dist/adapters/components-adapter.d.ts +18 -0
- package/dist/adapters/components-adapter.d.ts.map +1 -0
- package/dist/adapters/components-adapter.js +34 -0
- package/dist/adapters/components-adapter.js.map +1 -0
- package/dist/defaults.d.ts +18 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +37 -0
- package/dist/defaults.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/memoization.d.ts +10 -0
- package/dist/memoization.d.ts.map +1 -0
- package/dist/memoization.js +30 -0
- package/dist/memoization.js.map +1 -0
- package/dist/primitives/StreamdownText.d.ts +60 -0
- package/dist/primitives/StreamdownText.d.ts.map +1 -0
- package/dist/primitives/StreamdownText.js +124 -0
- package/dist/primitives/StreamdownText.js.map +1 -0
- package/dist/types.d.ts +356 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +93 -0
- package/src/__tests__/PreOverride.test.tsx +132 -0
- package/src/__tests__/code-adapter.integration.test.tsx +325 -0
- package/src/__tests__/code-adapter.test.tsx +46 -0
- package/src/__tests__/components-adapter.test.tsx +152 -0
- package/src/__tests__/defaults.test.ts +96 -0
- package/src/__tests__/index.test.ts +40 -0
- package/src/__tests__/memoization.test.ts +71 -0
- package/src/adapters/PreOverride.tsx +52 -0
- package/src/adapters/code-adapter.tsx +148 -0
- package/src/adapters/components-adapter.tsx +51 -0
- package/src/defaults.ts +46 -0
- package/src/index.ts +45 -0
- package/src/memoization.ts +38 -0
- package/src/primitives/StreamdownText.tsx +201 -0
- package/src/types.ts +416 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mergePlugins, DEFAULT_SHIKI_THEME } from "../defaults";
|
|
3
|
+
import type { PluginConfig, ResolvedPluginConfig } from "../types";
|
|
4
|
+
|
|
5
|
+
describe("DEFAULT_SHIKI_THEME", () => {
|
|
6
|
+
it("has light and dark theme", () => {
|
|
7
|
+
expect(DEFAULT_SHIKI_THEME).toEqual(["github-light", "github-dark"]);
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("mergePlugins", () => {
|
|
12
|
+
const mockCodePlugin = { type: "code" };
|
|
13
|
+
const mockMathPlugin = { type: "math" };
|
|
14
|
+
const mockCjkPlugin = { type: "cjk" };
|
|
15
|
+
const mockMermaidPlugin = { type: "mermaid" };
|
|
16
|
+
|
|
17
|
+
it("returns empty object when no plugins provided or detected", () => {
|
|
18
|
+
const result = mergePlugins(undefined, {});
|
|
19
|
+
expect(result).toEqual({});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("uses default plugins when user provides undefined", () => {
|
|
23
|
+
const defaults: ResolvedPluginConfig = {
|
|
24
|
+
code: mockCodePlugin,
|
|
25
|
+
math: mockMathPlugin,
|
|
26
|
+
};
|
|
27
|
+
const result = mergePlugins(undefined, defaults);
|
|
28
|
+
expect(result).toEqual({
|
|
29
|
+
code: mockCodePlugin,
|
|
30
|
+
math: mockMathPlugin,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("uses user plugins over defaults", () => {
|
|
35
|
+
const userCode = { type: "user-code" };
|
|
36
|
+
const userPlugins: PluginConfig = { code: userCode };
|
|
37
|
+
const defaults: ResolvedPluginConfig = { code: mockCodePlugin };
|
|
38
|
+
|
|
39
|
+
const result = mergePlugins(userPlugins, defaults);
|
|
40
|
+
expect(result.code).toBe(userCode);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("disables plugin when set to false", () => {
|
|
44
|
+
const userPlugins: PluginConfig = { code: false };
|
|
45
|
+
const defaults: ResolvedPluginConfig = { code: mockCodePlugin };
|
|
46
|
+
|
|
47
|
+
const result = mergePlugins(userPlugins, defaults);
|
|
48
|
+
expect(result.code).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("allows mixing user plugins with defaults", () => {
|
|
52
|
+
const userMath = { type: "user-math" };
|
|
53
|
+
const userPlugins: PluginConfig = {
|
|
54
|
+
code: false,
|
|
55
|
+
math: userMath,
|
|
56
|
+
};
|
|
57
|
+
const defaults: ResolvedPluginConfig = {
|
|
58
|
+
code: mockCodePlugin,
|
|
59
|
+
cjk: mockCjkPlugin,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const result = mergePlugins(userPlugins, defaults);
|
|
63
|
+
expect(result).toEqual({
|
|
64
|
+
math: userMath,
|
|
65
|
+
cjk: mockCjkPlugin,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("includes mermaid only when explicitly provided", () => {
|
|
70
|
+
const userPlugins: PluginConfig = { mermaid: mockMermaidPlugin };
|
|
71
|
+
const defaults: ResolvedPluginConfig = { code: mockCodePlugin };
|
|
72
|
+
|
|
73
|
+
const result = mergePlugins(userPlugins, defaults);
|
|
74
|
+
expect(result).toEqual({
|
|
75
|
+
code: mockCodePlugin,
|
|
76
|
+
mermaid: mockMermaidPlugin,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("does not include mermaid from defaults", () => {
|
|
81
|
+
// mermaid should never be in defaults, but even if it were,
|
|
82
|
+
// it should not be auto-enabled
|
|
83
|
+
const defaults: ResolvedPluginConfig = {
|
|
84
|
+
code: mockCodePlugin,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const result = mergePlugins(undefined, defaults);
|
|
88
|
+
expect(result.mermaid).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("excludes mermaid when set to false", () => {
|
|
92
|
+
const userPlugins: PluginConfig = { mermaid: false };
|
|
93
|
+
const result = mergePlugins(userPlugins, {});
|
|
94
|
+
expect(result.mermaid).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
StreamdownTextPrimitive,
|
|
4
|
+
useIsStreamdownCodeBlock,
|
|
5
|
+
useStreamdownPreProps,
|
|
6
|
+
memoCompareNodes,
|
|
7
|
+
DEFAULT_SHIKI_THEME,
|
|
8
|
+
parseMarkdownIntoBlocks,
|
|
9
|
+
StreamdownContext,
|
|
10
|
+
} from "../index";
|
|
11
|
+
|
|
12
|
+
describe("package exports", () => {
|
|
13
|
+
it("exports StreamdownTextPrimitive", () => {
|
|
14
|
+
expect(StreamdownTextPrimitive).toBeDefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("exports useIsStreamdownCodeBlock", () => {
|
|
18
|
+
expect(useIsStreamdownCodeBlock).toBeDefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("exports useStreamdownPreProps", () => {
|
|
22
|
+
expect(useStreamdownPreProps).toBeDefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("exports memoCompareNodes", () => {
|
|
26
|
+
expect(memoCompareNodes).toBeDefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("exports DEFAULT_SHIKI_THEME with correct values", () => {
|
|
30
|
+
expect(DEFAULT_SHIKI_THEME).toEqual(["github-light", "github-dark"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("exports parseMarkdownIntoBlocks as a function", () => {
|
|
34
|
+
expect(typeof parseMarkdownIntoBlocks).toBe("function");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("exports StreamdownContext", () => {
|
|
38
|
+
expect(StreamdownContext).toBeDefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createElement } from "react";
|
|
3
|
+
import { memoCompareNodes } from "../memoization";
|
|
4
|
+
|
|
5
|
+
describe("memoCompareNodes", () => {
|
|
6
|
+
it("returns true for identical props", () => {
|
|
7
|
+
const props = { className: "test", id: "foo" };
|
|
8
|
+
expect(memoCompareNodes(props, props)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns true for equal primitive props", () => {
|
|
12
|
+
const prev = { className: "test", count: 5 };
|
|
13
|
+
const next = { className: "test", count: 5 };
|
|
14
|
+
expect(memoCompareNodes(prev, next)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns false for different primitive props", () => {
|
|
18
|
+
const prev = { className: "test", count: 5 };
|
|
19
|
+
const next = { className: "test", count: 6 };
|
|
20
|
+
expect(memoCompareNodes(prev, next)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns false for different number of props", () => {
|
|
24
|
+
const prev = { className: "test" };
|
|
25
|
+
const next = { className: "test", id: "foo" };
|
|
26
|
+
expect(memoCompareNodes(prev, next)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns true for same string children", () => {
|
|
30
|
+
const prev = { children: "hello" };
|
|
31
|
+
const next = { children: "hello" };
|
|
32
|
+
expect(memoCompareNodes(prev, next)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns false for different string children", () => {
|
|
36
|
+
const prev = { children: "hello" };
|
|
37
|
+
const next = { children: "world" };
|
|
38
|
+
expect(memoCompareNodes(prev, next)).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns true for null children", () => {
|
|
42
|
+
const prev = { children: null };
|
|
43
|
+
const next = { children: null };
|
|
44
|
+
expect(memoCompareNodes(prev, next)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns true for same React element type and key", () => {
|
|
48
|
+
const child = createElement("div", { key: "1" }, "content");
|
|
49
|
+
const prev = { children: child };
|
|
50
|
+
const next = { children: child };
|
|
51
|
+
expect(memoCompareNodes(prev, next)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns true for equivalent React elements (same type and key)", () => {
|
|
55
|
+
const prev = { children: createElement("div", { key: "1" }) };
|
|
56
|
+
const next = { children: createElement("div", { key: "1" }) };
|
|
57
|
+
expect(memoCompareNodes(prev, next)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns false for different React element types", () => {
|
|
61
|
+
const prev = { children: createElement("div", { key: "1" }) };
|
|
62
|
+
const next = { children: createElement("span", { key: "1" }) };
|
|
63
|
+
expect(memoCompareNodes(prev, next)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns false for different React element keys", () => {
|
|
67
|
+
const prev = { children: createElement("div", { key: "1" }) };
|
|
68
|
+
const next = { children: createElement("div", { key: "2" }) };
|
|
69
|
+
expect(memoCompareNodes(prev, next)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Element } from "hast";
|
|
4
|
+
import {
|
|
5
|
+
type ComponentPropsWithoutRef,
|
|
6
|
+
createContext,
|
|
7
|
+
memo,
|
|
8
|
+
useContext,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { memoCompareNodes } from "../memoization";
|
|
11
|
+
|
|
12
|
+
type PreOverrideProps = ComponentPropsWithoutRef<"pre"> & {
|
|
13
|
+
node?: Element | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Context that indicates we're inside a <pre> element (code block).
|
|
18
|
+
* Used by code adapter to distinguish inline code from block code.
|
|
19
|
+
*/
|
|
20
|
+
export const PreContext = createContext<PreOverrideProps | null>(null);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Hook to check if the current code element is inside a code block.
|
|
24
|
+
* Returns true if inside a <pre> (code block), false if inline code.
|
|
25
|
+
*/
|
|
26
|
+
export function useIsStreamdownCodeBlock(): boolean {
|
|
27
|
+
return useContext(PreContext) !== null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Hook to get the pre element props when inside a code block.
|
|
32
|
+
* Returns null if not inside a code block.
|
|
33
|
+
*/
|
|
34
|
+
export function useStreamdownPreProps(): PreOverrideProps | null {
|
|
35
|
+
return useContext(PreContext);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pre component override that provides context for child code elements.
|
|
40
|
+
* This enables reliable inline vs block code detection.
|
|
41
|
+
*/
|
|
42
|
+
export const PreOverride = memo(function PreOverride({
|
|
43
|
+
children,
|
|
44
|
+
node,
|
|
45
|
+
...rest
|
|
46
|
+
}: PreOverrideProps) {
|
|
47
|
+
return (
|
|
48
|
+
<PreContext.Provider value={{ node, ...rest }}>
|
|
49
|
+
<pre {...rest}>{children}</pre>
|
|
50
|
+
</PreContext.Provider>
|
|
51
|
+
);
|
|
52
|
+
}, memoCompareNodes);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Element } from "hast";
|
|
4
|
+
import {
|
|
5
|
+
type ComponentPropsWithoutRef,
|
|
6
|
+
type ComponentType,
|
|
7
|
+
isValidElement,
|
|
8
|
+
memo,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import type {
|
|
12
|
+
CodeHeaderProps,
|
|
13
|
+
ComponentsByLanguage,
|
|
14
|
+
SyntaxHighlighterProps,
|
|
15
|
+
} from "../types";
|
|
16
|
+
import { useIsStreamdownCodeBlock } from "./PreOverride";
|
|
17
|
+
|
|
18
|
+
const LANGUAGE_REGEX = /language-([^\s]+)/;
|
|
19
|
+
|
|
20
|
+
type CodeProps = ComponentPropsWithoutRef<"code"> & {
|
|
21
|
+
node?: Element | undefined;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type PreProps = ComponentPropsWithoutRef<"pre"> & {
|
|
25
|
+
node?: Element | undefined;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
interface CodeAdapterOptions {
|
|
29
|
+
SyntaxHighlighter?: ComponentType<SyntaxHighlighterProps> | undefined;
|
|
30
|
+
CodeHeader?: ComponentType<CodeHeaderProps> | undefined;
|
|
31
|
+
componentsByLanguage?: ComponentsByLanguage | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extracts code string from children.
|
|
36
|
+
*/
|
|
37
|
+
function extractCode(children: unknown): string {
|
|
38
|
+
if (typeof children === "string") return children;
|
|
39
|
+
if (!isValidElement(children)) return "";
|
|
40
|
+
|
|
41
|
+
const props = children.props as Record<string, unknown> | null;
|
|
42
|
+
if (props && typeof props["children"] === "string") {
|
|
43
|
+
return props["children"];
|
|
44
|
+
}
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function DefaultPre({ node: _, ...props }: PreProps): ReactNode {
|
|
49
|
+
return <pre {...props} />;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function DefaultCode({ node: _, ...props }: CodeProps): ReactNode {
|
|
53
|
+
return <code {...props} />;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Creates a code component adapter that bridges the assistant-ui
|
|
58
|
+
* SyntaxHighlighter/CodeHeader API to streamdown's code component.
|
|
59
|
+
*/
|
|
60
|
+
export function createCodeAdapter(options: CodeAdapterOptions) {
|
|
61
|
+
const {
|
|
62
|
+
SyntaxHighlighter: UserSyntaxHighlighter,
|
|
63
|
+
CodeHeader: UserCodeHeader,
|
|
64
|
+
componentsByLanguage = {},
|
|
65
|
+
} = options;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Inner component that uses the hook for inline/block detection.
|
|
69
|
+
*/
|
|
70
|
+
function AdaptedCodeInner({
|
|
71
|
+
node,
|
|
72
|
+
className,
|
|
73
|
+
children,
|
|
74
|
+
...props
|
|
75
|
+
}: CodeProps) {
|
|
76
|
+
// Use context-based detection for inline vs block code
|
|
77
|
+
const isCodeBlock = useIsStreamdownCodeBlock();
|
|
78
|
+
|
|
79
|
+
if (!isCodeBlock) {
|
|
80
|
+
// Inline code - render as simple code element
|
|
81
|
+
return (
|
|
82
|
+
<code
|
|
83
|
+
className={`aui-streamdown-inline-code ${className ?? ""}`.trim()}
|
|
84
|
+
{...props}
|
|
85
|
+
>
|
|
86
|
+
{children}
|
|
87
|
+
</code>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Block code - extract language and code content
|
|
92
|
+
const match = className?.match(LANGUAGE_REGEX);
|
|
93
|
+
const language = match?.[1] ?? "";
|
|
94
|
+
const code = extractCode(children);
|
|
95
|
+
|
|
96
|
+
// Get language-specific or fallback components
|
|
97
|
+
const SyntaxHighlighter =
|
|
98
|
+
componentsByLanguage[language]?.SyntaxHighlighter ??
|
|
99
|
+
UserSyntaxHighlighter;
|
|
100
|
+
|
|
101
|
+
const CodeHeader =
|
|
102
|
+
componentsByLanguage[language]?.CodeHeader ?? UserCodeHeader;
|
|
103
|
+
|
|
104
|
+
// If user provided custom SyntaxHighlighter, use it
|
|
105
|
+
if (SyntaxHighlighter) {
|
|
106
|
+
return (
|
|
107
|
+
<>
|
|
108
|
+
{CodeHeader && (
|
|
109
|
+
<CodeHeader node={node} language={language} code={code} />
|
|
110
|
+
)}
|
|
111
|
+
<SyntaxHighlighter
|
|
112
|
+
node={node}
|
|
113
|
+
components={{ Pre: DefaultPre, Code: DefaultCode }}
|
|
114
|
+
language={language}
|
|
115
|
+
code={code}
|
|
116
|
+
/>
|
|
117
|
+
</>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// No custom SyntaxHighlighter - return null to let streamdown handle it
|
|
122
|
+
// This signals to the adapter that we should use streamdown's default
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const AdaptedCode = memo(AdaptedCodeInner, (prev, next) => {
|
|
127
|
+
return (
|
|
128
|
+
prev.className === next.className &&
|
|
129
|
+
prev.children === next.children &&
|
|
130
|
+
prev.node?.position?.start.line === next.node?.position?.start.line &&
|
|
131
|
+
prev.node?.position?.end.line === next.node?.position?.end.line
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return AdaptedCode;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Checks if the code adapter should be used (i.e., user provided custom components).
|
|
140
|
+
*/
|
|
141
|
+
export function shouldUseCodeAdapter(options: CodeAdapterOptions): boolean {
|
|
142
|
+
return !!(
|
|
143
|
+
options.SyntaxHighlighter ||
|
|
144
|
+
options.CodeHeader ||
|
|
145
|
+
(options.componentsByLanguage &&
|
|
146
|
+
Object.keys(options.componentsByLanguage).length > 0)
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import type { StreamdownProps } from "streamdown";
|
|
5
|
+
import { createCodeAdapter, shouldUseCodeAdapter } from "./code-adapter";
|
|
6
|
+
import { PreOverride } from "./PreOverride";
|
|
7
|
+
import type { ComponentsByLanguage, StreamdownTextComponents } from "../types";
|
|
8
|
+
|
|
9
|
+
interface UseAdaptedComponentsOptions {
|
|
10
|
+
components?: StreamdownTextComponents | undefined;
|
|
11
|
+
componentsByLanguage?: ComponentsByLanguage | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Hook that adapts assistant-ui component API to streamdown's component API.
|
|
16
|
+
*
|
|
17
|
+
* Handles:
|
|
18
|
+
* - SyntaxHighlighter -> custom code component
|
|
19
|
+
* - CodeHeader -> custom code component
|
|
20
|
+
* - componentsByLanguage -> custom code component with language dispatch
|
|
21
|
+
* - PreOverride -> context-based inline/block code detection
|
|
22
|
+
*/
|
|
23
|
+
export function useAdaptedComponents({
|
|
24
|
+
components,
|
|
25
|
+
componentsByLanguage,
|
|
26
|
+
}: UseAdaptedComponentsOptions): StreamdownProps["components"] {
|
|
27
|
+
return useMemo(() => {
|
|
28
|
+
const { SyntaxHighlighter, CodeHeader, ...htmlComponents } =
|
|
29
|
+
components ?? {};
|
|
30
|
+
|
|
31
|
+
const codeAdapterOptions = {
|
|
32
|
+
SyntaxHighlighter,
|
|
33
|
+
CodeHeader,
|
|
34
|
+
componentsByLanguage,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const baseComponents = { pre: PreOverride };
|
|
38
|
+
|
|
39
|
+
if (!shouldUseCodeAdapter(codeAdapterOptions)) {
|
|
40
|
+
return { ...htmlComponents, ...baseComponents };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const AdaptedCode = createCodeAdapter(codeAdapterOptions);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
...htmlComponents,
|
|
47
|
+
...baseComponents,
|
|
48
|
+
code: (props) => AdaptedCode(props) ?? undefined,
|
|
49
|
+
};
|
|
50
|
+
}, [components, componentsByLanguage]);
|
|
51
|
+
}
|
package/src/defaults.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { BundledTheme } from "streamdown";
|
|
4
|
+
import type { PluginConfig, ResolvedPluginConfig } from "./types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default Shiki theme for code highlighting.
|
|
8
|
+
* First value is light theme, second is dark theme.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_SHIKI_THEME: [BundledTheme, BundledTheme] = [
|
|
11
|
+
"github-light",
|
|
12
|
+
"github-dark",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const PLUGIN_KEYS = ["code", "math", "cjk"] as const;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Merges user plugin configuration with detected defaults.
|
|
19
|
+
*
|
|
20
|
+
* Rules:
|
|
21
|
+
* - `false` = explicitly disable a plugin
|
|
22
|
+
* - `undefined` = use default (auto-detect)
|
|
23
|
+
* - plugin instance = use provided plugin
|
|
24
|
+
* - mermaid requires explicit enabling (not auto-detected)
|
|
25
|
+
*/
|
|
26
|
+
export function mergePlugins(
|
|
27
|
+
userPlugins: PluginConfig | undefined,
|
|
28
|
+
defaultPlugins: ResolvedPluginConfig,
|
|
29
|
+
): ResolvedPluginConfig {
|
|
30
|
+
const result: Record<string, unknown> = {};
|
|
31
|
+
|
|
32
|
+
for (const key of PLUGIN_KEYS) {
|
|
33
|
+
const userValue = userPlugins?.[key];
|
|
34
|
+
if (userValue === false) continue;
|
|
35
|
+
const value = userValue || defaultPlugins[key];
|
|
36
|
+
if (value) result[key] = value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Mermaid requires explicit enabling (not auto-detected)
|
|
40
|
+
const mermaid = userPlugins?.mermaid;
|
|
41
|
+
if (mermaid && mermaid !== false) {
|
|
42
|
+
result["mermaid"] = mermaid;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return result as ResolvedPluginConfig;
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export { StreamdownTextPrimitive } from "./primitives/StreamdownText";
|
|
2
|
+
export {
|
|
3
|
+
useIsStreamdownCodeBlock,
|
|
4
|
+
useStreamdownPreProps,
|
|
5
|
+
} from "./adapters/PreOverride";
|
|
6
|
+
export { DEFAULT_SHIKI_THEME } from "./defaults";
|
|
7
|
+
export { memoCompareNodes } from "./memoization";
|
|
8
|
+
|
|
9
|
+
export type {
|
|
10
|
+
StreamdownTextPrimitiveProps,
|
|
11
|
+
SyntaxHighlighterProps,
|
|
12
|
+
CodeHeaderProps,
|
|
13
|
+
ComponentsByLanguage,
|
|
14
|
+
StreamdownTextComponents,
|
|
15
|
+
PluginConfig,
|
|
16
|
+
ResolvedPluginConfig,
|
|
17
|
+
CaretStyle,
|
|
18
|
+
ControlsConfig,
|
|
19
|
+
LinkSafetyConfig,
|
|
20
|
+
LinkSafetyModalProps,
|
|
21
|
+
RemendConfig,
|
|
22
|
+
RemendHandler,
|
|
23
|
+
MermaidOptions,
|
|
24
|
+
MermaidErrorComponentProps,
|
|
25
|
+
AllowedTags,
|
|
26
|
+
RemarkRehypeOptions,
|
|
27
|
+
BlockProps,
|
|
28
|
+
SecurityConfig,
|
|
29
|
+
} from "./types";
|
|
30
|
+
|
|
31
|
+
// Re-export streamdown context and utilities
|
|
32
|
+
export { StreamdownContext, parseMarkdownIntoBlocks } from "streamdown";
|
|
33
|
+
|
|
34
|
+
// Re-export streamdown types
|
|
35
|
+
export type {
|
|
36
|
+
StreamdownProps,
|
|
37
|
+
CodeHighlighterPlugin,
|
|
38
|
+
DiagramPlugin,
|
|
39
|
+
MathPlugin,
|
|
40
|
+
CjkPlugin,
|
|
41
|
+
HighlightOptions,
|
|
42
|
+
} from "streamdown";
|
|
43
|
+
|
|
44
|
+
// Re-export shiki types from streamdown
|
|
45
|
+
export type { BundledTheme, BundledLanguage } from "streamdown";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
type ReactElement = { type: unknown; key: unknown };
|
|
6
|
+
|
|
7
|
+
function isReactElement(node: unknown): node is ReactElement {
|
|
8
|
+
return (
|
|
9
|
+
typeof node === "object" && node !== null && "type" in node && "key" in node
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Compares two ReactNode values for shallow equality.
|
|
15
|
+
*/
|
|
16
|
+
function compareNodes(a: ReactNode, b: ReactNode): boolean {
|
|
17
|
+
if (a === b) return true;
|
|
18
|
+
if (!isReactElement(a) || !isReactElement(b)) return false;
|
|
19
|
+
return a.type === b.type && a.key === b.key;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Memo comparison function for components with children prop.
|
|
24
|
+
* Inspired by react-markdown's approach.
|
|
25
|
+
*/
|
|
26
|
+
export function memoCompareNodes<
|
|
27
|
+
T extends { children?: ReactNode; [key: string]: unknown },
|
|
28
|
+
>(prev: Readonly<T>, next: Readonly<T>): boolean {
|
|
29
|
+
const prevKeys = Object.keys(prev).filter((k) => k !== "children");
|
|
30
|
+
const nextKeys = Object.keys(next).filter((k) => k !== "children");
|
|
31
|
+
|
|
32
|
+
if (prevKeys.length !== nextKeys.length) return false;
|
|
33
|
+
for (const key of prevKeys) {
|
|
34
|
+
if (prev[key] !== next[key]) return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return compareNodes(prev.children, next.children);
|
|
38
|
+
}
|