@kushagradhawan/kookie-blocks 0.1.9 → 0.1.11

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.
Files changed (60) hide show
  1. package/dist/cjs/components/code/CodeBlock.js +1 -1
  2. package/dist/cjs/components/code/CodeBlock.js.map +3 -3
  3. package/dist/cjs/components/index.d.ts +1 -0
  4. package/dist/cjs/components/index.d.ts.map +1 -1
  5. package/dist/cjs/components/index.js +1 -1
  6. package/dist/cjs/components/index.js.map +2 -2
  7. package/dist/cjs/components/markdown/StreamingMarkdown.d.ts +78 -0
  8. package/dist/cjs/components/markdown/StreamingMarkdown.d.ts.map +1 -0
  9. package/dist/cjs/components/markdown/StreamingMarkdown.js +2 -0
  10. package/dist/cjs/components/markdown/StreamingMarkdown.js.map +7 -0
  11. package/dist/cjs/components/markdown/createMarkdownComponents.d.ts +27 -0
  12. package/dist/cjs/components/markdown/createMarkdownComponents.d.ts.map +1 -0
  13. package/dist/cjs/components/markdown/createMarkdownComponents.js +3 -0
  14. package/dist/cjs/components/markdown/createMarkdownComponents.js.map +7 -0
  15. package/dist/cjs/components/markdown/index.d.ts +6 -0
  16. package/dist/cjs/components/markdown/index.d.ts.map +1 -0
  17. package/dist/cjs/components/markdown/index.js +2 -0
  18. package/dist/cjs/components/markdown/index.js.map +7 -0
  19. package/dist/cjs/components/markdown/types.d.ts +32 -0
  20. package/dist/cjs/components/markdown/types.d.ts.map +1 -0
  21. package/dist/cjs/components/markdown/types.js +2 -0
  22. package/dist/cjs/components/markdown/types.js.map +7 -0
  23. package/dist/cjs/components/markdown/utils/markdownStreaming.d.ts +32 -0
  24. package/dist/cjs/components/markdown/utils/markdownStreaming.d.ts.map +1 -0
  25. package/dist/cjs/components/markdown/utils/markdownStreaming.js +5 -0
  26. package/dist/cjs/components/markdown/utils/markdownStreaming.js.map +7 -0
  27. package/dist/esm/components/code/CodeBlock.js +1 -1
  28. package/dist/esm/components/code/CodeBlock.js.map +3 -3
  29. package/dist/esm/components/index.d.ts +1 -0
  30. package/dist/esm/components/index.d.ts.map +1 -1
  31. package/dist/esm/components/index.js +1 -1
  32. package/dist/esm/components/index.js.map +2 -2
  33. package/dist/esm/components/markdown/StreamingMarkdown.d.ts +78 -0
  34. package/dist/esm/components/markdown/StreamingMarkdown.d.ts.map +1 -0
  35. package/dist/esm/components/markdown/StreamingMarkdown.js +2 -0
  36. package/dist/esm/components/markdown/StreamingMarkdown.js.map +7 -0
  37. package/dist/esm/components/markdown/createMarkdownComponents.d.ts +27 -0
  38. package/dist/esm/components/markdown/createMarkdownComponents.d.ts.map +1 -0
  39. package/dist/esm/components/markdown/createMarkdownComponents.js +3 -0
  40. package/dist/esm/components/markdown/createMarkdownComponents.js.map +7 -0
  41. package/dist/esm/components/markdown/index.d.ts +6 -0
  42. package/dist/esm/components/markdown/index.d.ts.map +1 -0
  43. package/dist/esm/components/markdown/index.js +2 -0
  44. package/dist/esm/components/markdown/index.js.map +7 -0
  45. package/dist/esm/components/markdown/types.d.ts +32 -0
  46. package/dist/esm/components/markdown/types.d.ts.map +1 -0
  47. package/dist/esm/components/markdown/types.js +1 -0
  48. package/dist/esm/components/markdown/types.js.map +7 -0
  49. package/dist/esm/components/markdown/utils/markdownStreaming.d.ts +32 -0
  50. package/dist/esm/components/markdown/utils/markdownStreaming.d.ts.map +1 -0
  51. package/dist/esm/components/markdown/utils/markdownStreaming.js +5 -0
  52. package/dist/esm/components/markdown/utils/markdownStreaming.js.map +7 -0
  53. package/package.json +10 -1
  54. package/src/components/code/CodeBlock.tsx +6 -6
  55. package/src/components/index.ts +1 -0
  56. package/src/components/markdown/StreamingMarkdown.tsx +183 -0
  57. package/src/components/markdown/createMarkdownComponents.tsx +206 -0
  58. package/src/components/markdown/index.ts +8 -0
  59. package/src/components/markdown/types.ts +31 -0
  60. package/src/components/markdown/utils/markdownStreaming.ts +297 -0
@@ -0,0 +1,183 @@
1
+ "use client";
2
+
3
+ import React, { memo, useMemo, type ReactNode } from "react";
4
+ import ReactMarkdown, { type Components } from "react-markdown";
5
+ import remarkGfm from "remark-gfm";
6
+ import rehypeRaw from "rehype-raw";
7
+ import hardenReactMarkdown from "harden-react-markdown";
8
+ import { Box, Flex } from "@kushagradhawan/kookie-ui";
9
+ import { createMarkdownComponents } from "./createMarkdownComponents";
10
+ import { completeUnterminatedMarkdown, parseMarkdownIntoBlocks } from "./utils/markdownStreaming";
11
+ import type { MarkdownComponentOptions } from "./types";
12
+
13
+ const HardenedMarkdown = hardenReactMarkdown(ReactMarkdown);
14
+
15
+ const LINK_PREFIXES = ["https://", "http://", "/"];
16
+ const IMAGE_PREFIXES = ["https://", "http://", "/", "data:"];
17
+ const ALLOWED_PROTOCOLS = ["mailto:", "tel:", "data:", "http:", "https:"];
18
+ const DEFAULT_APP_ORIGIN = typeof window !== "undefined" && window.location?.origin ? window.location.origin : "https://app.kookie.ai";
19
+
20
+ /**
21
+ * Options for StreamingMarkdown component
22
+ */
23
+ export type StreamingMarkdownOptions = MarkdownComponentOptions & {
24
+ /**
25
+ * Security origin for link/image validation
26
+ * @default window.location.origin or "https://app.kookie.ai"
27
+ */
28
+ defaultOrigin?: string;
29
+
30
+ /**
31
+ * Whether to enable block-level memoization for performance
32
+ * Recommended for streaming scenarios where content updates frequently
33
+ * @default true
34
+ */
35
+ enableBlockMemoization?: boolean;
36
+
37
+ /**
38
+ * Custom parser for splitting content into blocks
39
+ * If not provided, content will be rendered as a single block
40
+ * For optimal streaming performance, use marked.lexer with GFM enabled
41
+ */
42
+ blockParser?: (content: string) => Array<{ raw?: string }>;
43
+
44
+ /**
45
+ * Override default component mappings
46
+ */
47
+ components?: Partial<Components>;
48
+ };
49
+
50
+ type StreamingMarkdownProps = {
51
+ /**
52
+ * Markdown content to render (supports streaming/incomplete markdown)
53
+ */
54
+ content: string;
55
+
56
+ /**
57
+ * Unique identifier for this markdown instance (used for keys)
58
+ */
59
+ id: string;
60
+
61
+ /**
62
+ * Optional configuration
63
+ */
64
+ options?: StreamingMarkdownOptions;
65
+ };
66
+
67
+ type MarkdownBlockProps = {
68
+ content: string;
69
+ defaultOrigin: string;
70
+ components: Components;
71
+ };
72
+
73
+ /**
74
+ * Resolves the default origin for security validation
75
+ */
76
+ function resolveDefaultOrigin(customOrigin?: string): string {
77
+ if (customOrigin) {
78
+ return customOrigin;
79
+ }
80
+ return DEFAULT_APP_ORIGIN;
81
+ }
82
+
83
+ /**
84
+ * Memoized markdown block component for efficient streaming rendering
85
+ */
86
+ const MarkdownBlock = memo(
87
+ ({ content, defaultOrigin, components }: MarkdownBlockProps) => {
88
+ return (
89
+ <Box width="100%">
90
+ <HardenedMarkdown
91
+ defaultOrigin={defaultOrigin}
92
+ allowedLinkPrefixes={LINK_PREFIXES}
93
+ allowedImagePrefixes={IMAGE_PREFIXES}
94
+ allowedProtocols={ALLOWED_PROTOCOLS}
95
+ allowDataImages
96
+ components={components}
97
+ remarkPlugins={[remarkGfm]}
98
+ rehypePlugins={[rehypeRaw]}
99
+ >
100
+ {content}
101
+ </HardenedMarkdown>
102
+ </Box>
103
+ );
104
+ },
105
+ (previous, next) => previous.content === next.content && previous.defaultOrigin === next.defaultOrigin && previous.components === next.components
106
+ );
107
+
108
+ MarkdownBlock.displayName = "MarkdownBlock";
109
+
110
+ /**
111
+ * StreamingMarkdown - A drop-in markdown renderer designed for AI streaming.
112
+ *
113
+ * Features:
114
+ * - Unterminated block parsing (handles incomplete markdown during streaming)
115
+ * - Block-level memoization for performance
116
+ * - Security hardening (validates links/images)
117
+ * - GitHub Flavored Markdown support
118
+ * - KookieUI component integration
119
+ * - Code syntax highlighting via CodeBlock
120
+ *
121
+ * @example
122
+ * ```tsx
123
+ * import { StreamingMarkdown } from '@kushagradhawan/kookie-blocks';
124
+ * import { marked } from 'marked';
125
+ *
126
+ * function ChatMessage({ message }) {
127
+ * return (
128
+ * <StreamingMarkdown
129
+ * content={message.content}
130
+ * id={message.id}
131
+ * options={{
132
+ * blockParser: (content) => marked.lexer(content, { gfm: true }),
133
+ * enableBlockMemoization: true,
134
+ * }}
135
+ * />
136
+ * );
137
+ * }
138
+ * ```
139
+ */
140
+ export function StreamingMarkdown({ content, id, options = {} }: StreamingMarkdownProps) {
141
+ const { defaultOrigin: customOrigin, enableBlockMemoization = true, blockParser, components: customComponents = {}, ...componentOptions } = options;
142
+
143
+ // Resolve security origin
144
+ const defaultOrigin = useMemo(() => resolveDefaultOrigin(customOrigin), [customOrigin]);
145
+
146
+ // Create component mappings with custom overrides
147
+ const markdownComponents = useMemo(() => {
148
+ const baseComponents = createMarkdownComponents(componentOptions);
149
+ return {
150
+ ...baseComponents,
151
+ ...customComponents,
152
+ };
153
+ }, [componentOptions, customComponents]);
154
+
155
+ // Parse content into blocks for memoization (if enabled and parser provided)
156
+ const blocks = useMemo(() => {
157
+ if (!enableBlockMemoization || !blockParser) {
158
+ // No block splitting - just complete unterminated markdown
159
+ const completed = completeUnterminatedMarkdown(content);
160
+ return completed.trim() ? [completed] : [];
161
+ }
162
+
163
+ return parseMarkdownIntoBlocks(content, blockParser);
164
+ }, [content, enableBlockMemoization, blockParser]);
165
+
166
+ if (!blocks.length) {
167
+ return null;
168
+ }
169
+
170
+ // Single block - no need for wrapper
171
+ if (blocks.length === 1) {
172
+ return <MarkdownBlock content={blocks[0]} defaultOrigin={defaultOrigin} components={markdownComponents} />;
173
+ }
174
+
175
+ // Multiple blocks - render with flex wrapper
176
+ return (
177
+ <Flex direction="column" gap="2" width="100%">
178
+ {blocks.map((block, index) => (
179
+ <MarkdownBlock key={`${id}-block-${index}`} content={block} defaultOrigin={defaultOrigin} components={markdownComponents} />
180
+ ))}
181
+ </Flex>
182
+ );
183
+ }
@@ -0,0 +1,206 @@
1
+ import React, { type ReactNode } from "react";
2
+ import type { Components } from "react-markdown";
3
+ import { Blockquote, Box, Code, Flex, Heading, Text, Table } from "@kushagradhawan/kookie-ui";
4
+ import { CodeBlock } from "../code";
5
+ import type { MarkdownComponentOptions, MarkdownChildrenProps } from "./types";
6
+
7
+ /**
8
+ * Extracts language from className (e.g., "language-typescript" -> "typescript")
9
+ */
10
+ function extractLanguage(className?: string): string {
11
+ if (!className) {
12
+ return "text";
13
+ }
14
+ const match = className.match(/language-([\w-]+)/i);
15
+ return match?.[1] ?? "text";
16
+ }
17
+
18
+ /**
19
+ * Extracts code string from ReactNode children
20
+ */
21
+ function extractCode(children?: ReactNode): string {
22
+ let code = "";
23
+ if (!children) {
24
+ return code;
25
+ }
26
+ if (typeof children === "string") {
27
+ code = children;
28
+ } else if (Array.isArray(children)) {
29
+ code = children.map((child) => (typeof child === "string" ? child : "")).join("");
30
+ }
31
+ // Trim trailing newlines but preserve internal whitespace
32
+ return code.replace(/^\n+|\n+$/g, "");
33
+ }
34
+
35
+ /**
36
+ * Creates markdown component mappings that work with both react-markdown and MDX.
37
+ * Uses KookieUI components for consistent styling across all projects.
38
+ *
39
+ * @param options - Optional configuration for component behavior
40
+ * @returns Component mappings for markdown/MDX renderers
41
+ *
42
+ * @example
43
+ * ```tsx
44
+ * // In react-markdown
45
+ * <ReactMarkdown components={createMarkdownComponents()}>
46
+ * {content}
47
+ * </ReactMarkdown>
48
+ *
49
+ * // In MDX
50
+ * export function useMDXComponents(components: MDXComponents) {
51
+ * return {
52
+ * ...createMarkdownComponents(),
53
+ * ...components,
54
+ * };
55
+ * }
56
+ * ```
57
+ */
58
+ export function createMarkdownComponents(options: MarkdownComponentOptions = {}): Components {
59
+ const { codeBlockCollapsible = false, imageComponent, inlineCodeHighContrast = true } = options;
60
+
61
+ return {
62
+ // Headings with consistent visual hierarchy
63
+ h1: ({ children }: MarkdownChildrenProps) => (
64
+ <Heading size="8" weight="medium" as="h1" style={{ marginTop: "1rem", marginBottom: "0.5rem" }}>
65
+ {children}
66
+ </Heading>
67
+ ),
68
+ h2: ({ children }: MarkdownChildrenProps) => (
69
+ <Heading weight="medium" size="5" as="h2" style={{ marginTop: "0.875rem", marginBottom: "0.5rem" }}>
70
+ {children}
71
+ </Heading>
72
+ ),
73
+ h3: ({ children }: MarkdownChildrenProps) => (
74
+ <Heading weight="medium" size="4" as="h3" style={{ marginTop: "0.75rem", marginBottom: "0.5rem" }}>
75
+ {children}
76
+ </Heading>
77
+ ),
78
+ h4: ({ children }: MarkdownChildrenProps) => (
79
+ <Heading weight="medium" size="3" as="h4" style={{ marginTop: "0.625rem", marginBottom: "0.5rem" }}>
80
+ {children}
81
+ </Heading>
82
+ ),
83
+ h5: ({ children }: MarkdownChildrenProps) => (
84
+ <Heading weight="medium" size="3" as="h5" style={{ marginTop: "0.5rem", marginBottom: "0.5rem" }}>
85
+ {children}
86
+ </Heading>
87
+ ),
88
+ h6: ({ children }: MarkdownChildrenProps) => (
89
+ <Heading weight="medium" size="3" as="h6" style={{ marginTop: "0.5rem", marginBottom: "0.5rem" }}>
90
+ {children}
91
+ </Heading>
92
+ ),
93
+
94
+ // Paragraph text
95
+ p: ({ children }: MarkdownChildrenProps) => (
96
+ <Text size="3" as="p" style={{ lineHeight: "1.6" }}>
97
+ {children}
98
+ </Text>
99
+ ),
100
+
101
+ // Code - inline vs block
102
+ code: ({ className, children, inline }: { className?: string; children?: ReactNode; inline?: boolean }) => {
103
+ const code = extractCode(children);
104
+
105
+ // Block code: has className (language) OR is not marked as inline
106
+ // Inline code: explicitly marked as inline=true, or no className and short single-line content
107
+ const isInlineCode = inline === true || (inline === undefined && !className && !code.includes("\n") && code.length < 100);
108
+
109
+ if (isInlineCode) {
110
+ return (
111
+ <Code highContrast={inlineCodeHighContrast} size="3">
112
+ {code}
113
+ </Code>
114
+ );
115
+ }
116
+
117
+ return (
118
+ <Box my="2" style={{ minWidth: 0 }}>
119
+ <CodeBlock code={code} language={extractLanguage(className)} collapsible={codeBlockCollapsible} />
120
+ </Box>
121
+ );
122
+ },
123
+
124
+ // Lists
125
+ ul: ({ children }: MarkdownChildrenProps) => (
126
+ <ul style={{ marginTop: "0.5rem", marginBottom: "0.5rem", lineHeight: "1.6", paddingLeft: "1.5rem", listStyleType: "disc" }}>{children}</ul>
127
+ ),
128
+ ol: ({ children }: MarkdownChildrenProps) => (
129
+ <ol style={{ marginTop: "0.5rem", marginBottom: "0.5rem", lineHeight: "1.6", paddingLeft: "1.5rem", listStyleType: "decimal" }}>{children}</ol>
130
+ ),
131
+ li: ({ children }: MarkdownChildrenProps) => <li style={{ marginBottom: "0.25rem", lineHeight: "1.6" }}>{children}</li>,
132
+
133
+ // Blockquote
134
+ blockquote: ({ children }: MarkdownChildrenProps) => <Blockquote>{children}</Blockquote>,
135
+
136
+ // Links
137
+ a: ({ href, children }: { href?: string; children?: ReactNode }) => (
138
+ <a href={href} style={{ color: "var(--accent-9)", textDecoration: "underline" }}>
139
+ {children}
140
+ </a>
141
+ ),
142
+
143
+ // Text styling
144
+ strong: ({ children }: MarkdownChildrenProps) => (
145
+ <Text weight="medium" style={{ lineHeight: "1.6" }}>
146
+ {children}
147
+ </Text>
148
+ ),
149
+ em: ({ children }: MarkdownChildrenProps) => <Text style={{ lineHeight: "1.6", fontStyle: "italic" }}>{children}</Text>,
150
+
151
+ // Horizontal rule
152
+ hr: () => (
153
+ <hr
154
+ style={{
155
+ color: "var(--gray-6)",
156
+ marginTop: "0.5rem",
157
+ marginBottom: "0.5rem",
158
+ height: "1px",
159
+ width: "100%",
160
+ opacity: 0.5,
161
+ }}
162
+ />
163
+ ),
164
+
165
+ // Pre wrapper (pass through to let code handle it)
166
+ pre: ({ children }: MarkdownChildrenProps) => <>{children}</>,
167
+
168
+ // Tables using KookieUI
169
+ table: ({ children }: MarkdownChildrenProps) => (
170
+ <Box my="2" style={{ overflowX: "auto" }}>
171
+ <Table.Root size="2" variant="ghost">
172
+ {children}
173
+ </Table.Root>
174
+ </Box>
175
+ ),
176
+ thead: ({ children }: MarkdownChildrenProps) => <Table.Header>{children}</Table.Header>,
177
+ tbody: ({ children }: MarkdownChildrenProps) => <Table.Body>{children}</Table.Body>,
178
+ tr: ({ children }: MarkdownChildrenProps) => <Table.Row>{children}</Table.Row>,
179
+ th: ({ children }: MarkdownChildrenProps) => <Table.ColumnHeaderCell>{children}</Table.ColumnHeaderCell>,
180
+ td: ({ children }: MarkdownChildrenProps) => <Table.Cell>{children}</Table.Cell>,
181
+
182
+ // HTML elements for raw HTML support
183
+ sub: ({ children }: MarkdownChildrenProps) => <sub>{children}</sub>,
184
+ sup: ({ children }: MarkdownChildrenProps) => <sup>{children}</sup>,
185
+ br: () => <br />,
186
+
187
+ // Images - use custom component if provided
188
+ img: imageComponent
189
+ ? (props: React.ImgHTMLAttributes<HTMLImageElement>) => {
190
+ const { src, alt, width, height } = props;
191
+ if (!src || typeof src !== "string") return null;
192
+ return imageComponent({
193
+ src,
194
+ alt: alt ?? "Image",
195
+ width: width ? String(width) : undefined,
196
+ height: height ? String(height) : undefined,
197
+ });
198
+ }
199
+ : undefined,
200
+
201
+ // Details/Summary for expandable sections
202
+ details: ({ children }: MarkdownChildrenProps) => <details style={{ padding: "0.5rem 0" }}>{children}</details>,
203
+ summary: ({ children }: MarkdownChildrenProps) => <summary style={{ cursor: "pointer", fontWeight: 500 }}>{children}</summary>,
204
+ };
205
+ }
206
+
@@ -0,0 +1,8 @@
1
+ export { StreamingMarkdown } from "./StreamingMarkdown";
2
+ export type { StreamingMarkdownOptions } from "./StreamingMarkdown";
3
+
4
+ export { createMarkdownComponents } from "./createMarkdownComponents";
5
+ export type { MarkdownComponentOptions, MarkdownChildrenProps } from "./types";
6
+
7
+ export { completeUnterminatedMarkdown, parseMarkdownIntoBlocks } from "./utils/markdownStreaming";
8
+
@@ -0,0 +1,31 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * Options for customizing markdown component behavior
5
+ */
6
+ export type MarkdownComponentOptions = {
7
+ /**
8
+ * Whether code blocks should be collapsible
9
+ * @default false
10
+ */
11
+ codeBlockCollapsible?: boolean;
12
+
13
+ /**
14
+ * Custom image component
15
+ */
16
+ imageComponent?: (props: { src?: string; alt?: string; width?: string; height?: string }) => ReactNode;
17
+
18
+ /**
19
+ * Whether to use high contrast for inline code
20
+ * @default true
21
+ */
22
+ inlineCodeHighContrast?: boolean;
23
+ };
24
+
25
+ /**
26
+ * Common props for markdown child components
27
+ */
28
+ export type MarkdownChildrenProps = {
29
+ children?: ReactNode;
30
+ };
31
+