diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8afa1a..01cee0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,22 @@ jobs: - name: Run tests run: bun run test - - name: Type check - run: bun run type-check - - - name: Build - run: bun run build + - name: Type check (strict - warnings are errors) + run: | + set -o pipefail + # Capture TypeScript output - any output indicates errors/warnings + output=$(bun run type-check 2>&1) || exit 1 + if [ -n "$output" ] && echo "$output" | grep -qE "error TS|warning"; then + echo "$output" + exit 1 + fi + + - name: Build (strict - warnings are errors) + run: | + set -o pipefail + # Build and fail on any TypeScript errors/warnings + bun run build 2>&1 | tee build_output.txt + if grep -qE "error TS|warning" build_output.txt; then + echo "Build produced errors or warnings" + exit 1 + fi diff --git a/apps/debugger-ios/ios/Podfile.lock b/apps/debugger-ios/ios/Podfile.lock index 0282bd3..44f892f 100644 --- a/apps/debugger-ios/ios/Podfile.lock +++ b/apps/debugger-ios/ios/Podfile.lock @@ -28,6 +28,8 @@ PODS: - Yoga - ExpoAsset (12.0.10): - ExpoModulesCore + - ExpoClipboard (8.0.8): + - ExpoModulesCore - ExpoFileSystem (19.0.19): - ExpoModulesCore - ExpoFont (14.0.9): @@ -1935,6 +1937,7 @@ DEPENDENCIES: - EXConstants (from `../../../node_modules/expo-constants/ios`) - Expo (from `../../../node_modules/expo`) - ExpoAsset (from `../../../node_modules/expo-asset/ios`) + - ExpoClipboard (from `../../../node_modules/expo-clipboard/ios`) - ExpoFileSystem (from `../../../node_modules/expo-file-system/ios`) - ExpoFont (from `../../../node_modules/expo-font/ios`) - ExpoHaptics (from `../../../node_modules/expo-haptics/ios`) @@ -2022,6 +2025,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/expo" ExpoAsset: :path: "../../../node_modules/expo-asset/ios" + ExpoClipboard: + :path: "../../../node_modules/expo-clipboard/ios" ExpoFileSystem: :path: "../../../node_modules/expo-file-system/ios" ExpoFont: @@ -2184,6 +2189,7 @@ SPEC CHECKSUMS: EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3 Expo: 111394d38f32be09385d4c7f70cc96d2da438d0d ExpoAsset: d839c8eae8124470332408427327e8f88beb2dfd + ExpoClipboard: b36b287d8356887844bb08ed5c84b5979bb4dd1e ExpoFileSystem: 77157a101e03150a4ea4f854b4dd44883c93ae0a ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961 ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84 diff --git a/apps/debugger-ios/package.json b/apps/debugger-ios/package.json index c634165..265dbaf 100644 --- a/apps/debugger-ios/package.json +++ b/apps/debugger-ios/package.json @@ -21,6 +21,7 @@ "@shopify/flash-list": "2.0.2", "expo": "~54.0.25", "expo-asset": "~12.0.10", + "expo-clipboard": "~8.0.8", "expo-font": "~14.0.9", "expo-haptics": "~15.0.7", "expo-status-bar": "~3.0.8", diff --git a/apps/debugger-ios/src/components/Icon.tsx b/apps/debugger-ios/src/components/Icon.tsx index 9a9b284..f8c6ec7 100644 --- a/apps/debugger-ios/src/components/Icon.tsx +++ b/apps/debugger-ios/src/components/Icon.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Lucide } from '@react-native-vector-icons/lucide'; +import { Lucide, type LucideIconName } from '@react-native-vector-icons/lucide'; type IconProps = { name: string; @@ -10,12 +10,12 @@ type IconProps = { /** * Icon component for mobile (iOS/Android) - uses Lucide font icons - * + * * Font icons are more performant on mobile than SVG rendering. * Icon names should match Lucide icon names (e.g., "plus", "chevron-right"). */ export function Icon({ name, size = 24, color = '#000', strokeWidth }: IconProps) { // Font icons don't support strokeWidth, but we accept it for API consistency - return ; + return ; } diff --git a/apps/debugger-ios/src/components/Icon.web.tsx b/apps/debugger-ios/src/components/Icon.web.tsx index 951a4a9..c0e568b 100644 --- a/apps/debugger-ios/src/components/Icon.web.tsx +++ b/apps/debugger-ios/src/components/Icon.web.tsx @@ -8,9 +8,16 @@ type IconProps = { strokeWidth?: number; }; +// Type for lucide icon components +type LucideIconComponent = React.ComponentType<{ + size?: number; + color?: string; + strokeWidth?: number; +}>; + /** * Icon component for web - uses Lucide SVG icons - * + * * Converts icon names to PascalCase and looks them up in lucide-react-native. * Example: "plus" -> "Plus", "chevron-right" -> "ChevronRight" */ @@ -20,15 +27,16 @@ export function Icon({ name, size = 24, color = '#000', strokeWidth }: IconProps .split('-') .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(''); - + // Look up the icon component from lucide-react-native - const IconComponent = (LucideIcons as Record>)[pascalName]; - - if (!IconComponent) { + // Use unknown first to avoid type comparison issues with mixed exports + const IconComponent = (LucideIcons as unknown as Record)[pascalName]; + + if (!IconComponent || typeof IconComponent !== 'function') { console.warn(`Icon "${name}" (resolved to "${pascalName}") not found in lucide-react-native`); return null; } - + return ; } diff --git a/apps/debugger-ios/src/data/testMessages.ts b/apps/debugger-ios/src/data/testMessages.ts index 1911c0d..593d0f9 100644 --- a/apps/debugger-ios/src/data/testMessages.ts +++ b/apps/debugger-ios/src/data/testMessages.ts @@ -193,6 +193,81 @@ The most important thing is to **start building** and iterate from there. Don't let analysis paralysis hold you back!`; +// Streaming response for testing all custom renderers +export const STREAMING_ALL_ELEMENTS_RESPONSE = `[MSG #NEW] Here's a comprehensive test of all custom renderers: + +# Heading Level 1 + +This is a paragraph with [a link](https://example.com) in it. + +## Heading Level 2 + +> This is a blockquote. +> It can span multiple lines. +> And include **bold** and *italic* text. + +### Heading Level 3 + +Here's an image: + +![Sample Image](https://picsum.photos/seed/custom-renderer/400/200) + +#### Heading Level 4 + +And a table: + +| Feature | Supported | Notes | +| :--- | :---: | ---: | +| Images | Yes | With alt text | +| Links | Yes | With title | +| Tables | Yes | With alignment | +| Blockquotes | Yes | Nested too | + +##### Heading Level 5 + +Finally, some code: + +\`\`\`typescript +function greet(name: string): string { + return \`Hello, \${name}!\`; +} +\`\`\` + +###### Heading Level 6 + +That's all the custom renderers!`; + +// Streaming response with code block - for testing custom code block renderer +export const STREAMING_CODE_RESPONSE = `[MSG #NEW] Here's a code example: + +\`\`\`typescript +function fibonacci(n: number): number { + if (n <= 1) return n; + return fibonacci(n - 1) + fibonacci(n - 2); +} + +// Usage +console.log(fibonacci(10)); // 55 +\`\`\` + +And here's another example in Python: + +\`\`\`python +def quicksort(arr): + if len(arr) <= 1: + return arr + pivot = arr[len(arr) // 2] + left = [x for x in arr if x < pivot] + middle = [x for x in arr if x == pivot] + right = [x for x in arr if x > pivot] + return quicksort(left) + middle + quicksort(right) + +# Example +print(quicksort([3, 6, 8, 10, 1, 2, 1])) +\`\`\` + +That's the recursive approach for both!`; + // Helper to create a new message with proper numbering export function createUserMessage(content: string, existingCount: number): Message { const num = existingCount + 1; diff --git a/apps/debugger-ios/src/screens/ChatTestScreen.tsx b/apps/debugger-ios/src/screens/ChatTestScreen.tsx index b511f88..d15c9e6 100644 --- a/apps/debugger-ios/src/screens/ChatTestScreen.tsx +++ b/apps/debugger-ios/src/screens/ChatTestScreen.tsx @@ -1,11 +1,20 @@ import { useState, useCallback, useMemo } from 'react'; -import { View, Text, Pressable, TextInput, Image } from 'react-native'; +import { View, Text, Pressable, TextInput, Image, ScrollView } from 'react-native'; import { StyleSheet, UnistylesRuntime } from 'react-native-unistyles'; import { FlashList } from '@shopify/flash-list'; import { LegendList } from '@legendapp/list'; -import { StreamdownRN } from 'streamdown-rn'; +import { + StreamdownRN, + type CodeBlockRendererProps, + type ImageRendererProps, + type LinkRendererProps, + type BlockquoteRendererProps, + type TableRendererProps, + type HeadingRendererProps, +} from 'streamdown-rn'; import Markdown from 'react-native-markdown-display'; import { debugComponentRegistry } from '@darkresearch/debug-components'; +import * as Clipboard from 'expo-clipboard'; // Custom rules to fix "key prop being spread" warning in react-native-markdown-display const markdownRules = { @@ -29,17 +38,336 @@ import { Message, INITIAL_MESSAGES, STREAMING_RESPONSE, + STREAMING_CODE_RESPONSE, + STREAMING_ALL_ELEMENTS_RESPONSE, createUserMessage, createAssistantMessage, } from '../data/testMessages'; -type RenderMode = 'streamdown' | 'markdown'; +type RenderMode = 'streamdown' | 'markdown' | 'custom-codeblock' | 'custom-all'; + +/** + * Custom Code Block Renderer with Copy Button + * + * Demonstrates custom code block rendering with: + * - Language label + * - Copy button + * - Horizontal scroll for long lines + */ +function CustomCodeBlockWithCopyButton({ code, language, theme }: CodeBlockRendererProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await Clipboard.setStringAsync(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + + {language} + + + {copied ? 'Copied!' : 'Copy'} + + + + + {code} + + + ); +} + +// Styles for custom code block +const customCodeStyles = StyleSheet.create((theme) => ({ + container: { + backgroundColor: '#1e1e1e', + borderRadius: 8, + marginVertical: 8, + overflow: 'hidden', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: '#2d2d2d', + borderBottomWidth: 1, + borderBottomColor: '#3d3d3d', + }, + language: { + color: '#888', + fontSize: 12, + fontWeight: '600', + textTransform: 'uppercase', + }, + copyButton: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 4, + backgroundColor: '#4ade80', + }, + copyText: { + color: '#000', + fontSize: 11, + fontWeight: '600', + }, + codeScroll: { + padding: 12, + }, + code: { + fontFamily: 'monospace', + fontSize: 13, + color: '#d4d4d4', + lineHeight: 20, + }, +})); type ChatTestScreenProps = { listType: ListType; onBack: () => void; }; +/** + * Custom Image Renderer + * Demonstrates custom image rendering with a border and label + */ +function CustomImage({ src, alt, theme }: ImageRendererProps) { + return ( + + CUSTOM IMAGE + + {alt && {alt}} + + ); +} + +/** + * Custom Link Renderer + * Demonstrates custom link styling + */ +function CustomLink({ href, children, theme }: LinkRendererProps) { + return ( + + 🔗 {children} + + ); +} + +/** + * Custom Blockquote Renderer + * Demonstrates custom blockquote with a colored bar + */ +function CustomBlockquote({ children, theme }: BlockquoteRendererProps) { + return ( + + CUSTOM BLOCKQUOTE + + + + {children} + + + + ); +} + +/** + * Custom Table Renderer + * Demonstrates custom table styling + */ +function CustomTable({ headers, rows, alignments, theme }: TableRendererProps) { + return ( + + CUSTOM TABLE + + + {headers.map((header, i) => ( + + {header} + + ))} + + {rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + {cell} + + ))} + + ))} + + + ); +} + +/** + * Custom Heading Renderer + * Demonstrates custom heading with level indicator + */ +function CustomHeading({ level, children, theme }: HeadingRendererProps) { + const sizes = [26, 22, 18, 16, 14, 12]; + return ( + + H{level} + + {children} + + + ); +} + +// Styles for all custom renderers +const allRenderersStyles = StyleSheet.create((theme) => ({ + // Common label for all custom renderers + customLabel: { + backgroundColor: '#4ade80', + color: '#000', + fontSize: 10, + fontWeight: 'bold', + paddingHorizontal: 8, + paddingVertical: 3, + alignSelf: 'flex-start', + borderRadius: 4, + marginBottom: 6, + letterSpacing: 1, + }, + // Image + imageContainer: { + marginVertical: 8, + borderRadius: 8, + overflow: 'hidden', + borderWidth: 2, + borderColor: '#4ade80', + padding: 8, + backgroundColor: 'rgba(74, 222, 128, 0.1)', + }, + image: { + width: '100%', + aspectRatio: 16 / 9, + borderRadius: 4, + }, + imageAlt: { + color: '#888', + fontSize: 12, + marginTop: 6, + fontStyle: 'italic', + }, + // Link + link: { + color: '#4ade80', + fontWeight: '600', + }, + // Blockquote + blockquote: { + marginVertical: 8, + borderRadius: 8, + overflow: 'hidden', + borderWidth: 2, + borderColor: '#4ade80', + padding: 8, + backgroundColor: 'rgba(74, 222, 128, 0.1)', + }, + blockquoteInner: { + flexDirection: 'row', + }, + blockquoteBar: { + width: 4, + backgroundColor: '#4ade80', + borderRadius: 2, + marginRight: 12, + }, + blockquoteContent: { + flex: 1, + }, + // Table + table: { + marginVertical: 8, + borderRadius: 8, + overflow: 'hidden', + borderWidth: 2, + borderColor: '#4ade80', + padding: 8, + backgroundColor: 'rgba(74, 222, 128, 0.1)', + }, + tableInner: { + borderRadius: 4, + overflow: 'hidden', + borderWidth: 1, + borderColor: '#4ade80', + }, + tableHeader: { + flexDirection: 'row', + backgroundColor: 'rgba(74, 222, 128, 0.3)', + }, + tableRow: { + flexDirection: 'row', + borderTopWidth: 1, + borderTopColor: '#4ade80', + }, + tableCell: { + flex: 1, + padding: 8, + }, + tableHeaderText: { + color: '#4ade80', + fontWeight: 'bold', + fontSize: 12, + }, + tableCellText: { + color: '#fff', + fontSize: 12, + }, + // Heading + headingContainer: { + flexDirection: 'row', + alignItems: 'center', + marginVertical: 6, + gap: 8, + backgroundColor: 'rgba(74, 222, 128, 0.1)', + padding: 8, + borderRadius: 6, + borderLeftWidth: 4, + borderLeftColor: '#4ade80', + }, + headingLevel: { + backgroundColor: '#4ade80', + color: '#000', + fontSize: 10, + fontWeight: 'bold', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + headingText: { + color: '#fff', + fontWeight: 'bold', + flex: 1, + }, +})); + +// Memoize renderers object to avoid recreating on every render +const customCodeRenderers = { codeBlock: CustomCodeBlockWithCopyButton }; + +// All custom renderers for testing all element types +const customAllRenderers = { + codeBlock: CustomCodeBlockWithCopyButton, + image: CustomImage, + link: CustomLink, + blockquote: CustomBlockquote, + table: CustomTable, + heading: CustomHeading, +}; + export function ChatTestScreen({ listType, onBack }: ChatTestScreenProps) { const [messages, setMessages] = useState(INITIAL_MESSAGES); const [inputText, setInputText] = useState(''); @@ -66,11 +394,19 @@ export function ChatTestScreen({ listType, onBack }: ChatTestScreenProps) { setIsStreaming(true); setMessages((prev) => [...prev, assistantMessage]); + // Use appropriate response based on render mode + const baseResponse = + renderMode === 'custom-all' + ? STREAMING_ALL_ELEMENTS_RESPONSE + : renderMode === 'custom-codeblock' + ? STREAMING_CODE_RESPONSE + : STREAMING_RESPONSE; + // Stream characters const interval = setInterval(() => { currentIndex++; const msgNum = messages.length + 2; - const streamContent = STREAMING_RESPONSE.replace('[MSG #NEW]', `[MSG #${msgNum}]`); + const streamContent = baseResponse.replace('[MSG #NEW]', `[MSG #${msgNum}]`); const currentContent = streamContent.slice(0, currentIndex); setMessages((prev) => @@ -90,7 +426,7 @@ export function ChatTestScreen({ listType, onBack }: ChatTestScreenProps) { setIsStreaming(false); } }, 15); - }, [messages.length]); + }, [messages.length, renderMode]); const renderMessage = useCallback( ({ item, index }: { item: Message; index: number }) => { @@ -116,6 +452,20 @@ export function ChatTestScreen({ listType, onBack }: ChatTestScreenProps) { {isUser ? ( {item.content} + ) : renderMode === 'custom-all' ? ( + + {item.content} + + ) : renderMode === 'custom-codeblock' ? ( + + {item.content} + ) : renderMode === 'streamdown' ? ( {item.content} @@ -157,7 +507,39 @@ export function ChatTestScreen({ listType, onBack }: ChatTestScreenProps) { renderMode === 'streamdown' && styles.toggleTextActive, ]} > - StreamdownRN + Streamdown + + + setRenderMode('custom-codeblock')} + style={[ + styles.toggleButton, + renderMode === 'custom-codeblock' && styles.toggleButtonActive, + ]} + > + + Code + + + setRenderMode('custom-all')} + style={[ + styles.toggleButton, + renderMode === 'custom-all' && styles.toggleButtonActive, + ]} + > + + All item.id} - estimatedItemSize={150} - maintainVisibleContentPosition={{ startRenderingFromBottom: true }} + extraData={renderMode} renderItem={renderMessage} contentContainerStyle={styles.listContent} /> @@ -199,6 +580,7 @@ export function ChatTestScreen({ listType, onBack }: ChatTestScreenProps) { item.id} + extraData={renderMode} renderItem={renderMessage} alignItemsAtEnd maintainScrollAtEnd @@ -268,26 +650,29 @@ const styles = StyleSheet.create((theme) => ({ }, toggleContainer: { flexDirection: 'row', - padding: 8, - gap: 8, + paddingHorizontal: 12, + paddingVertical: 8, + gap: 6, backgroundColor: theme.colors.statusBg, borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, toggleButton: { - flex: 1, - padding: 10, - borderRadius: 8, - backgroundColor: '#2a2a2a', - alignItems: 'center', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 20, + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: '#444', }, toggleButtonActive: { backgroundColor: '#4ade80', + borderColor: '#4ade80', }, toggleText: { color: '#888', - fontSize: 14, - fontWeight: '600', + fontSize: 13, + fontWeight: '500', }, toggleTextActive: { color: '#000', diff --git a/apps/debugger-ios/src/unistyles.config.ts b/apps/debugger-ios/src/unistyles.config.ts index 9cffc1c..a83597b 100644 --- a/apps/debugger-ios/src/unistyles.config.ts +++ b/apps/debugger-ios/src/unistyles.config.ts @@ -1,19 +1,31 @@ import { StyleSheet } from 'react-native-unistyles'; +// Theme definition +const darkTheme = { + colors: { + bg: '#1a1a1a', + statusBg: '#141414', + border: '#333', + text: '#888', + placeholder: '#666', + connected: '#4ade80', + }, +} as const; + +type AppThemes = { + dark: typeof darkTheme; +}; + +// Module augmentation for react-native-unistyles +declare module 'react-native-unistyles' { + export interface UnistylesThemes extends AppThemes {} +} + // Configure Unistyles theme - must run before any StyleSheet.create() // This file is imported at the entry point (index.js) to ensure proper initialization order StyleSheet.configure({ themes: { - dark: { - colors: { - bg: '#1a1a1a', - statusBg: '#141414', - border: '#333', - text: '#888', - placeholder: '#666', - connected: '#4ade80', - }, - }, + dark: darkTheme, }, settings: { initialTheme: 'dark', diff --git a/apps/debugger-ios/tsconfig.json b/apps/debugger-ios/tsconfig.json index d6f6058..d36772f 100644 --- a/apps/debugger-ios/tsconfig.json +++ b/apps/debugger-ios/tsconfig.json @@ -13,6 +13,7 @@ }, "include": [ "**/*.ts", - "**/*.tsx" + "**/*.tsx", + "../../packages/streamdown-rn/src/types/**/*.d.ts" ] } diff --git a/bun.lock b/bun.lock index c515beb..ce0b1f9 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ }, "apps/debugger": { "name": "@darkresearch/debugger", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@babel/runtime": "^7.28.4", "@darkresearch/debug-components": "workspace:*", @@ -48,7 +48,7 @@ }, "apps/debugger-ios": { "name": "@darkresearch/debugger-ios", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@babel/runtime": "^7.28.4", "@darkresearch/debug-components": "workspace:*", @@ -60,6 +60,7 @@ "@shopify/flash-list": "2.0.2", "expo": "~54.0.25", "expo-asset": "~12.0.10", + "expo-clipboard": "~8.0.8", "expo-font": "~14.0.9", "expo-haptics": "~15.0.7", "expo-status-bar": "~3.0.8", @@ -86,7 +87,7 @@ }, "apps/starter": { "name": "@darkresearch/starter", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@babel/runtime": "^7.28.4", "@expo-google-fonts/geist": "^0.4.1", @@ -118,7 +119,7 @@ }, "packages/debug-components": { "name": "@darkresearch/debug-components", - "version": "0.2.0", + "version": "0.2.1", "devDependencies": { "@types/react": "^19.0.0", "typescript": "~5.9.3", @@ -131,7 +132,7 @@ }, "packages/galerie-rn": { "name": "galerie-rn", - "version": "0.2.0", + "version": "0.2.1", "devDependencies": { "@types/react": "~19.0.0", "@types/react-native": "^0.73.0", @@ -146,7 +147,7 @@ }, "packages/streamdown-rn": { "name": "streamdown-rn", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "prismjs": "^1.29.0", "react-native-syntax-highlighter": "^2.1.0", @@ -155,7 +156,9 @@ }, "devDependencies": { "@changesets/cli": "^2.29.7", + "@testing-library/react-native": "^13.2.0", "@types/react": "~19.2.7", + "react-test-renderer": "^19.0.0", "typescript": "~5.9.3", }, "peerDependencies": { @@ -558,10 +561,14 @@ "@jest/create-cache-key-function": ["@jest/create-cache-key-function@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3" } }, "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA=="], + "@jest/diff-sequences": ["@jest/diff-sequences@30.0.1", "", {}, "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw=="], + "@jest/environment": ["@jest/environment@29.7.0", "", { "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0" } }, "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw=="], "@jest/fake-timers": ["@jest/fake-timers@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", "@types/node": "*", "jest-message-util": "^29.7.0", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ=="], + "@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], + "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], "@jest/transform": ["@jest/transform@29.7.0", "", { "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.6.3", "@jridgewell/trace-mapping": "^0.3.18", "babel-plugin-istanbul": "^6.1.1", "chalk": "^4.0.0", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.9", "jest-haste-map": "^29.7.0", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", "micromatch": "^4.0.4", "pirates": "^4.0.4", "slash": "^3.0.0", "write-file-atomic": "^4.0.2" } }, "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw=="], @@ -664,6 +671,8 @@ "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], + "@testing-library/react-native": ["@testing-library/react-native@13.3.3", "", { "dependencies": { "jest-matcher-utils": "^30.0.5", "picocolors": "^1.1.1", "pretty-format": "^30.0.5", "redent": "^3.0.0" }, "peerDependencies": { "jest": ">=29.0.0", "react": ">=18.2.0", "react-native": ">=0.71", "react-test-renderer": ">=18.2.0" }, "optionalPeers": ["jest"] }, "sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -1032,6 +1041,8 @@ "expo-asset": ["expo-asset@12.0.10", "", { "dependencies": { "@expo/image-utils": "^0.8.7", "expo-constants": "~18.0.10" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-pZyeJkoDsALh4gpCQDzTA/UCLaPH/1rjQNGubmLn/uDM27S4iYJb/YWw4+CNZOtd5bCUOhDPg5DtGQnydNFSXg=="], + "expo-clipboard": ["expo-clipboard@8.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA=="], + "expo-constants": ["expo-constants@18.0.10", "", { "dependencies": { "@expo/config": "~12.0.10", "@expo/env": "~2.0.7" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Rhtv+X974k0Cahmvx6p7ER5+pNhBC0XbP1lRviL2J1Xl4sT2FBaIuIxF/0I0CbhOsySf0ksqc5caFweAy9Ewiw=="], "expo-file-system": ["expo-file-system@19.0.19", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-OrpOV4fEBFMFv+jy7PnENpPbsWoBmqWGidSwh1Ai52PLl6JIInYGfZTc6kqyPNGtFTwm7Y9mSWnE8g+dtLxu7g=="], @@ -1172,6 +1183,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -1236,12 +1249,16 @@ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "jest-diff": ["jest-diff@30.2.0", "", { "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.2.0" } }, "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A=="], + "jest-environment-node": ["jest-environment-node@29.7.0", "", { "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw=="], "jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="], "jest-haste-map": ["jest-haste-map@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/graceful-fs": "^4.1.3", "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", "graceful-fs": "^4.2.9", "jest-regex-util": "^29.6.3", "jest-util": "^29.7.0", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.2" } }, "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA=="], + "jest-matcher-utils": ["jest-matcher-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.2.0", "pretty-format": "30.2.0" } }, "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg=="], + "jest-message-util": ["jest-message-util@29.7.0", "", { "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "micromatch": "^4.0.4", "pretty-format": "^29.7.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" } }, "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w=="], "jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="], @@ -1486,6 +1503,8 @@ "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -1664,7 +1683,7 @@ "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], - "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="], "react-native": ["react-native@0.81.5", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "@react-native/virtualized-lists": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw=="], @@ -1700,6 +1719,8 @@ "react-syntax-highlighter": ["react-syntax-highlighter@6.1.2", "", { "dependencies": { "babel-runtime": "^6.18.0", "highlight.js": "~9.12.0", "lowlight": "~1.9.1", "prismjs": "^1.8.4", "refractor": "^2.0.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-ahNwcZ0FhUd8U5TQYcmAqC/pec6Q308mUAATKMcLFmNYkvGhN9wfmoqxzjACcccGb2e85d5ZnGpOiCIIzGO3yA=="], + "react-test-renderer": ["react-test-renderer@19.2.3", "", { "dependencies": { "react-is": "^19.2.3", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw=="], + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], @@ -1712,6 +1733,8 @@ "recast": ["recast@0.21.5", "", { "dependencies": { "ast-types": "0.15.2", "esprima": "~4.0.0", "source-map": "~0.6.1", "tslib": "^2.0.1" } }, "sha512-hjMmLaUXAm1hIuTqOdeYObMslq/q+Xff6QE3Y2P+uoHAg2nmVlLBps2hzh1UJDdMtDTMXOFewK6ky51JQIeECg=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "refractor": ["refractor@2.10.1", "", { "dependencies": { "hastscript": "^5.0.0", "parse-entities": "^1.1.2", "prismjs": "~1.17.0" } }, "sha512-Xh9o7hQiQlDbxo5/XkOX6H+x/q8rmlmZKr97Ie1Q8ZM32IRRd3B/UxuA/yXDW79DBSXGWxm2yRTbcTVmAciJRw=="], "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], @@ -1848,6 +1871,8 @@ "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], @@ -2176,6 +2201,8 @@ "@react-native/metro-config/@react-native/js-polyfills": ["@react-native/js-polyfills@0.82.1", "", {}, "sha512-tf70X7pUodslOBdLN37J57JmDPB/yiZcNDzS2m+4bbQzo8fhx3eG9QEBv5n4fmzqfGAgSB4BWRHgDMXmmlDSVA=="], + "@testing-library/react-native/pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="], + "@types/react-native/react-native": ["react-native@0.72.17", "", { "dependencies": { "@jest/create-cache-key-function": "^29.2.1", "@react-native-community/cli": "^11.4.1", "@react-native-community/cli-platform-android": "^11.4.1", "@react-native-community/cli-platform-ios": "^11.4.1", "@react-native/assets-registry": "^0.72.0", "@react-native/codegen": "^0.72.8", "@react-native/gradle-plugin": "^0.72.11", "@react-native/js-polyfills": "^0.72.1", "@react-native/normalize-colors": "^0.72.0", "@react-native/virtualized-lists": "^0.72.8", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "base64-js": "^1.1.2", "deprecated-react-native-prop-types": "^4.2.3", "event-target-shim": "^5.0.1", "flow-enums-runtime": "^0.0.5", "invariant": "^2.2.4", "jest-environment-node": "^29.2.1", "jsc-android": "^250231.0.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.76.9", "metro-source-map": "^0.76.9", "mkdirp": "^0.5.1", "nullthrows": "^1.1.1", "pretty-format": "^26.5.2", "promise": "^8.3.0", "react-devtools-core": "^4.27.2", "react-refresh": "^0.4.0", "react-shallow-renderer": "^16.15.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.24.0-canary-efb381bbf-20230505", "stacktrace-parser": "^0.1.10", "use-sync-external-store": "^1.0.0", "whatwg-fetch": "^3.0.0", "ws": "^6.2.2", "yargs": "^17.6.2" }, "peerDependencies": { "react": "18.2.0" }, "bin": { "react-native": "cli.js" } }, "sha512-k3dNe0XqoYCGGWTenbupWSj+ljW3GIfmYS5P4s3if4j0csx2YbenKgH1aJNWLp+UP7ONwfId6G+uBoUJfyMxXg=="], "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], @@ -2238,6 +2265,10 @@ "istanbul-lib-instrument/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "jest-diff/pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="], + + "jest-matcher-utils/pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="], + "jscodeshift/write-file-atomic": ["write-file-atomic@2.4.3", "", { "dependencies": { "graceful-fs": "^4.1.11", "imurmurhash": "^0.1.4", "signal-exit": "^3.0.2" } }, "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ=="], "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -2302,6 +2333,8 @@ "path-scurry/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], @@ -2314,6 +2347,10 @@ "react-native-worklets/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "react-shallow-renderer/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-test-renderer/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "read-cache/pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -2560,6 +2597,10 @@ "@react-native/metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="], + "@testing-library/react-native/pretty-format/@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="], + + "@testing-library/react-native/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@types/react-native/react-native/@react-native-community/cli": ["@react-native-community/cli@11.4.1", "", { "dependencies": { "@react-native-community/cli-clean": "11.4.1", "@react-native-community/cli-config": "11.4.1", "@react-native-community/cli-debugger-ui": "11.4.1", "@react-native-community/cli-doctor": "11.4.1", "@react-native-community/cli-hermes": "11.4.1", "@react-native-community/cli-plugin-metro": "11.4.1", "@react-native-community/cli-server-api": "11.4.1", "@react-native-community/cli-tools": "11.4.1", "@react-native-community/cli-types": "11.4.1", "chalk": "^4.1.2", "commander": "^9.4.1", "execa": "^5.0.0", "find-up": "^4.1.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "prompts": "^2.4.0", "semver": "^7.5.2" }, "bin": { "react-native": "build/bin.js" } }, "sha512-NdAageVMtNhtvRsrq4NgJf5Ey2nA1CqmLvn7PhSawg+aIzMKmZuzWxGVwr9CoPGyjvNiqJlCWrLGR7NzOyi/sA=="], "@types/react-native/react-native/@react-native/assets-registry": ["@react-native/assets-registry@0.72.0", "", {}, "sha512-Im93xRJuHHxb1wniGhBMsxLwcfzdYreSZVQGDoMJgkd6+Iky61LInGEHnQCTN0fKNYF1Dvcofb4uMmE1RQHXHQ=="], @@ -2598,6 +2639,14 @@ "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "jest-diff/pretty-format/@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="], + + "jest-diff/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "jest-matcher-utils/pretty-format/@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="], + + "jest-matcher-utils/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jscodeshift/write-file-atomic/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -2740,6 +2789,8 @@ "@react-native/metro-babel-transformer/@react-native/babel-preset/@react-native/babel-plugin-codegen/@react-native/codegen": ["@react-native/codegen@0.82.1", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.32.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "yargs": "^17.6.2" } }, "sha512-ezXTN70ygVm9l2m0i+pAlct0RntoV4afftWMGUIeAWLgaca9qItQ54uOt32I/9dBJvzBibT33luIR/pBG0dQvg=="], + "@testing-library/react-native/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], + "@types/react-native/react-native/@react-native-community/cli/@react-native-community/cli-clean": ["@react-native-community/cli-clean@11.4.1", "", { "dependencies": { "@react-native-community/cli-tools": "11.4.1", "chalk": "^4.1.2", "execa": "^5.0.0", "prompts": "^2.4.0" } }, "sha512-cwUbY3c70oBGv3FvQJWe2Qkq6m1+/dcEBonMDTYyH6i+6OrkzI4RkIGpWmbG1IS5JfE9ISUZkNL3946sxyWNkw=="], "@types/react-native/react-native/@react-native-community/cli/@react-native-community/cli-config": ["@react-native-community/cli-config@11.4.1", "", { "dependencies": { "@react-native-community/cli-tools": "11.4.1", "chalk": "^4.1.2", "cosmiconfig": "^5.1.0", "deepmerge": "^4.3.0", "glob": "^7.1.3", "joi": "^17.2.1" } }, "sha512-sLdv1HFVqu5xNpeaR1+std0t7FFZaobpmpR0lFCOzKV7H/l611qS2Vo8zssmMK+oQbCs5JsX3SFPciODeIlaWA=="], @@ -2776,6 +2827,10 @@ "babel-plugin-module-resolver/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "jest-diff/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], + + "jest-matcher-utils/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], + "log-symbols/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "log-symbols/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], diff --git a/packages/streamdown-rn/ARCHITECTURE.md b/packages/streamdown-rn/ARCHITECTURE.md index 5d5b247..7629cd7 100644 --- a/packages/streamdown-rn/ARCHITECTURE.md +++ b/packages/streamdown-rn/ARCHITECTURE.md @@ -1,4 +1,4 @@ -# StreamdownRN v0.2.0 Architecture +# StreamdownRN v0.2.2 Architecture ## Overview @@ -7,8 +7,10 @@ High-performance streaming markdown renderer for React Native with: - **AST-based rendering** — Robust via remark + remark-gfm - **Block-level memoization** — Stable blocks never re-render - **Inline component support** — `[{c:"Name",p:{...}}]` syntax +- **Custom renderers** — Override all block-level elements (images, links, headings, tables, blockquotes, code blocks) - **Full GFM support** — Tables, strikethrough, task lists, footnotes - **Syntax highlighting** — Prism-based, lightweight (~30KB) +- **FlashList/LegendList support** — `extraData` prop for virtualized list compatibility --- @@ -47,11 +49,12 @@ Incoming stream: "# Hello\n\nSome **bold** text" ``` src/ -├── __tests__/ # Unit tests (201 tests) +├── __tests__/ # Unit tests (271 tests) │ ├── splitter.test.ts # Block boundary detection │ ├── incomplete.test.ts # Tag state tracking │ ├── parser.test.ts # Remark/GFM parsing │ ├── component-extraction.test.ts # Component syntax +│ ├── custom-renderers.test.tsx # Custom renderer data flow │ └── README.md # Test documentation │ ├── components/ # Reusable components @@ -144,6 +147,100 @@ Via `remark-gfm`: - Autolinks (`www.example.com`) - Footnotes (`[^1]`) +### 5. Custom Renderers + +Override built-in rendering for any block-level element type. + +**Supported elements:** + +| Element | Props Interface | Use Cases | +|---------|-----------------|-----------| +| `codeBlock` | `CodeBlockRendererProps` | Syntax highlighting, copy button | +| `image` | `ImageRendererProps` | Lazy loading, lightbox, CDN transforms | +| `link` | `LinkRendererProps` | In-app navigation, external handling | +| `blockquote` | `BlockquoteRendererProps` | Callouts, admonitions (tips/warnings) | +| `table` | `TableRendererProps` | Sortable/filterable, responsive | +| `heading` | `HeadingRendererProps` | Anchor links, collapsible sections | + +```tsx +import { + StreamdownRN, + type CodeBlockRendererProps, + type ImageRendererProps, + type LinkRendererProps, + type HeadingRendererProps, +} from 'streamdown-rn'; + +// Custom code block with copy button +function CustomCodeBlock({ code, language, theme }: CodeBlockRendererProps) { + return ( + + {language} + Clipboard.setString(code)}> + Copy + + {code} + + ); +} + +// Custom image with lazy loading +function CustomImage({ src, alt, theme }: ImageRendererProps) { + return ; +} + +// Custom link with in-app navigation +function CustomLink({ href, children, theme }: LinkRendererProps) { + const handlePress = () => { + if (href.startsWith('/')) { + navigation.navigate(href); + } else { + Linking.openURL(href); + } + }; + return {children}; +} + +// Custom heading with anchor links +function CustomHeading({ level, children, theme }: HeadingRendererProps) { + const Tag = `h${level}`; + return # {children}; +} + +// Memoize renderers object for performance +const renderers = { + codeBlock: CustomCodeBlock, + image: CustomImage, + link: CustomLink, + heading: CustomHeading, +}; + + + {markdownContent} + +``` + +**Props received by each renderer:** + +| Renderer | Props | +|----------|-------| +| `codeBlock` | `code`, `language`, `theme`, `key` | +| `image` | `src`, `alt?`, `title?`, `theme`, `key` | +| `link` | `href`, `title?`, `children`, `theme`, `key` | +| `blockquote` | `children`, `theme`, `key` | +| `table` | `headers`, `rows`, `alignments`, `theme`, `key` | +| `heading` | `level`, `children`, `theme`, `key` | + +**Streaming behavior:** +- Custom renderers work identically during streaming +- The data layer handles incomplete markdown (auto-closing code fences) +- Renderers just receive progressively longer content +- No special streaming logic needed in custom renderers + +**Performance note:** +- Define renderer functions at module level (not inside components) +- Memoize the `renderers` object to avoid unnecessary re-renders + --- ## Performance Characteristics @@ -172,7 +269,7 @@ Via `remark-gfm`: ```bash bun test -# ✓ 201 tests passing +# ✓ 271 tests passing ``` **Test Coverage:** @@ -180,6 +277,7 @@ bun test - Incomplete handler (tag state tracking, auto-close) - Parser (remark/GFM parsing) - Component extraction (syntax parsing, streaming) +- Custom renderers (all 6 element types: code blocks, images, links, headings, tables, blockquotes) - Security (URL sanitization, XSS prevention) --- diff --git a/packages/streamdown-rn/package.json b/packages/streamdown-rn/package.json index 323a4ba..80ff8d3 100644 --- a/packages/streamdown-rn/package.json +++ b/packages/streamdown-rn/package.json @@ -37,7 +37,9 @@ }, "devDependencies": { "@changesets/cli": "^2.29.7", + "@testing-library/react-native": "^13.2.0", "@types/react": "~19.2.7", + "react-test-renderer": "^19.0.0", "typescript": "~5.9.3" }, "files": [ diff --git a/packages/streamdown-rn/src/StreamdownRN.tsx b/packages/streamdown-rn/src/StreamdownRN.tsx index e15133d..c24e2eb 100644 --- a/packages/streamdown-rn/src/StreamdownRN.tsx +++ b/packages/streamdown-rn/src/StreamdownRN.tsx @@ -46,6 +46,7 @@ export const StreamdownRN: React.FC = React.memo(({ onError, onDebug, isComplete = false, + renderers, }) => { // Persistent registry reference — survives across renders const registryRef = useRef(INITIAL_REGISTRY); @@ -172,25 +173,28 @@ export const StreamdownRN: React.FC = React.memo(({ block={block} theme={themeConfig} componentRegistry={componentRegistry} + renderers={renderers} /> ))} - + {/* Active block — re-renders on each token */} ); }, (prev, next) => { // Custom comparison for memo - // Re-render if content, theme, or isComplete changes + // Re-render if content, theme, isComplete, or renderers changes return ( prev.children === next.children && prev.theme === next.theme && - prev.isComplete === next.isComplete + prev.isComplete === next.isComplete && + prev.renderers === next.renderers ); }); diff --git a/packages/streamdown-rn/src/__tests__/custom-renderers.test.tsx b/packages/streamdown-rn/src/__tests__/custom-renderers.test.tsx new file mode 100644 index 0000000..24ff04c --- /dev/null +++ b/packages/streamdown-rn/src/__tests__/custom-renderers.test.tsx @@ -0,0 +1,1211 @@ +/** + * Custom Renderers Tests + * + * Tests for all custom renderer data flows: + * - Code blocks + * - Images + * - Links + * - Blockquotes + * - Tables + * - Headings + * + * These tests verify the data layer: that elements are correctly + * detected, parsed, and would receive correct props when rendered. + * + * Note: Actual rendering tests require React Native environment + * and are performed via manual testing in debugger-ios. + */ + +import { describe, it, expect } from 'bun:test'; +import { processNewContent } from '../core/splitter'; +import { parseBlockContent } from '../core/parser'; +import { + fixIncompleteMarkdown, + updateTagState, + INITIAL_INCOMPLETE_STATE, +} from '../core/incomplete'; +import { INITIAL_REGISTRY } from '../core/types'; +import type { Content, Code, Image, Link, Blockquote, Table, Heading } from 'mdast'; + +// ============================================================================ +// Helper Functions to Extract Nodes from AST +// ============================================================================ + +// Helper to extract code node from AST +function findCodeNode(ast: Content): Code | null { + if (ast.type === 'code') { + return ast as Code; + } + if ('children' in ast && Array.isArray(ast.children)) { + for (const child of ast.children) { + const found = findCodeNode(child as Content); + if (found) return found; + } + } + return null; +} + +// Helper to extract image node from AST +function findImageNode(ast: Content): Image | null { + if (ast.type === 'image') { + return ast as Image; + } + if ('children' in ast && Array.isArray(ast.children)) { + for (const child of ast.children) { + const found = findImageNode(child as Content); + if (found) return found; + } + } + return null; +} + +// Helper to extract all image nodes from AST +function findAllImageNodes(ast: Content): Image[] { + const images: Image[] = []; + if (ast.type === 'image') { + images.push(ast as Image); + } + if ('children' in ast && Array.isArray(ast.children)) { + for (const child of ast.children) { + images.push(...findAllImageNodes(child as Content)); + } + } + return images; +} + +// Helper to extract link node from AST +function findLinkNode(ast: Content): Link | null { + if (ast.type === 'link') { + return ast as Link; + } + if ('children' in ast && Array.isArray(ast.children)) { + for (const child of ast.children) { + const found = findLinkNode(child as Content); + if (found) return found; + } + } + return null; +} + +// Helper to extract all link nodes from AST +function findAllLinkNodes(ast: Content): Link[] { + const links: Link[] = []; + if (ast.type === 'link') { + links.push(ast as Link); + } + if ('children' in ast && Array.isArray(ast.children)) { + for (const child of ast.children) { + links.push(...findAllLinkNodes(child as Content)); + } + } + return links; +} + +// Helper to extract blockquote node from AST +function findBlockquoteNode(ast: Content): Blockquote | null { + if (ast.type === 'blockquote') { + return ast as Blockquote; + } + if ('children' in ast && Array.isArray(ast.children)) { + for (const child of ast.children) { + const found = findBlockquoteNode(child as Content); + if (found) return found; + } + } + return null; +} + +// Helper to extract table node from AST +function findTableNode(ast: Content): Table | null { + if (ast.type === 'table') { + return ast as Table; + } + if ('children' in ast && Array.isArray(ast.children)) { + for (const child of ast.children) { + const found = findTableNode(child as Content); + if (found) return found; + } + } + return null; +} + +// Helper to extract heading node from AST +function findHeadingNode(ast: Content): Heading | null { + if (ast.type === 'heading') { + return ast as Heading; + } + if ('children' in ast && Array.isArray(ast.children)) { + for (const child of ast.children) { + const found = findHeadingNode(child as Content); + if (found) return found; + } + } + return null; +} + +// Helper to extract all heading nodes from AST +function findAllHeadingNodes(ast: Content): Heading[] { + const headings: Heading[] = []; + if (ast.type === 'heading') { + headings.push(ast as Heading); + } + if ('children' in ast && Array.isArray(ast.children)) { + for (const child of ast.children) { + headings.push(...findAllHeadingNodes(child as Content)); + } + } + return headings; +} + +// Helper to extract text content from AST node +function getTextContent(node: Content): string { + if (node.type === 'text') { + return (node as { type: 'text'; value: string }).value; + } + if ('children' in node && Array.isArray(node.children)) { + return node.children.map((child) => getTextContent(child as Content)).join(''); + } + return ''; +} + +// ============================================================================ +// Unit Tests - Code Block Detection & Parsing +// ============================================================================ + +describe('Custom Code Block Renderer - Code Block Detection', () => { + describe('Basic code block parsing', () => { + it('should parse code block with language identifier', () => { + const ast = parseBlockContent('```typescript\nconst x = 1;\n```'); + expect(ast).toBeDefined(); + + const codeNode = findCodeNode(ast!); + expect(codeNode).not.toBeNull(); + expect(codeNode!.lang).toBe('typescript'); + expect(codeNode!.value).toBe('const x = 1;'); + }); + + it('should parse code block without language identifier', () => { + const ast = parseBlockContent('```\nsome code\n```'); + expect(ast).toBeDefined(); + + const codeNode = findCodeNode(ast!); + expect(codeNode).not.toBeNull(); + expect(codeNode!.lang).toBeNull(); + expect(codeNode!.value).toBe('some code'); + }); + + it('should parse various language identifiers correctly', () => { + const languages = ['javascript', 'python', 'rust', 'go', 'jsx', 'tsx']; + + for (const lang of languages) { + const ast = parseBlockContent(`\`\`\`${lang}\ncode\n\`\`\``); + const codeNode = findCodeNode(ast!); + expect(codeNode!.lang).toBe(lang); + } + }); + + it('should handle empty code block', () => { + const ast = parseBlockContent('```\n```'); + expect(ast).toBeDefined(); + + const codeNode = findCodeNode(ast!); + expect(codeNode).not.toBeNull(); + expect(codeNode!.value).toBe(''); + }); + + it('should handle multiline code correctly', () => { + const ast = parseBlockContent( + '```typescript\nfunction hello() {\n console.log("hi");\n}\n```' + ); + const codeNode = findCodeNode(ast!); + expect(codeNode!.value).toBe('function hello() {\n console.log("hi");\n}'); + }); + }); +}); + +// ============================================================================ +// Streaming Behavior Tests +// ============================================================================ + +describe('Custom Code Block Renderer - Streaming', () => { + it('should auto-close incomplete code block for streaming preview', () => { + const incompleteContent = '```ts\nconst x'; + const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, incompleteContent); + const fixedContent = fixIncompleteMarkdown(incompleteContent, tagState); + + // Should auto-close the code block + expect(fixedContent).toContain('```ts'); + expect(fixedContent).toMatch(/```\s*$/); + + // Parse the fixed content + const ast = parseBlockContent(fixedContent); + const codeNode = findCodeNode(ast!); + expect(codeNode).not.toBeNull(); + expect(codeNode!.lang).toBe('ts'); + expect(codeNode!.value.trim()).toBe('const x'); + }); + + it('should correctly parse partial code at various streaming stages', () => { + const stages = [ + { input: '```ts\ncon', expectedCode: 'con' }, + { input: '```ts\nconst', expectedCode: 'const' }, + { input: '```ts\nconst x = ', expectedCode: 'const x =' }, + { input: '```ts\nconst x = 1;', expectedCode: 'const x = 1;' }, + ]; + + for (const stage of stages) { + const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, stage.input); + const fixedContent = fixIncompleteMarkdown(stage.input, tagState); + const ast = parseBlockContent(fixedContent); + + const codeNode = findCodeNode(ast!); + expect(codeNode).not.toBeNull(); + expect(codeNode!.value.trim()).toBe(stage.expectedCode.trim()); + } + }); + + it('should track code evolution during streaming', () => { + const codeHistory: string[] = []; + const streamChunks = [ + '```python\nprint', + '```python\nprint("Hello', + '```python\nprint("Hello, World!")', + '```python\nprint("Hello, World!")\n```', + ]; + + for (const chunk of streamChunks) { + const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, chunk); + const fixedContent = fixIncompleteMarkdown(chunk, tagState); + const ast = parseBlockContent(fixedContent); + + const codeNode = findCodeNode(ast!); + if (codeNode) { + codeHistory.push(codeNode.value); + } + } + + // Verify code values evolved correctly + expect(codeHistory[0]).toContain('print'); + expect(codeHistory[codeHistory.length - 1]).toBe('print("Hello, World!")'); + }); + + it('should maintain language throughout streaming', () => { + const languageHistory: (string | null)[] = []; + const streamChunks = [ + '```typescript\nconst', + '```typescript\nconst x', + '```typescript\nconst x = 1;\n```', + ]; + + for (const chunk of streamChunks) { + const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, chunk); + const fixedContent = fixIncompleteMarkdown(chunk, tagState); + const ast = parseBlockContent(fixedContent); + + const codeNode = findCodeNode(ast!); + if (codeNode) { + languageHistory.push(codeNode.lang); + } + } + + // Language should be consistent + expect(languageHistory.every((l) => l === 'typescript')).toBe(true); + }); +}); + +// ============================================================================ +// Edge Cases +// ============================================================================ + +describe('Custom Code Block Renderer - Edge Cases', () => { + it('should handle code block with trailing newlines', () => { + const ast = parseBlockContent('```js\ncode\n\n\n```'); + const codeNode = findCodeNode(ast!); + // Note: mdast preserves trailing newlines in value, trimming happens at render + expect(codeNode!.value).toBe('code\n\n'); + }); + + it('should detect multiple code blocks in content', () => { + const content = + '```js\ncode1\n```\n\nText paragraph\n\n```python\ncode2\n```'; + let registry = processNewContent(INITIAL_REGISTRY, content); + + const codeBlocks = registry.blocks.filter((b) => b.type === 'codeBlock'); + expect(codeBlocks.length).toBe(2); + + // Parse each and verify language + const languages: (string | null)[] = []; + for (const block of codeBlocks) { + if (block.ast) { + const codeNode = findCodeNode(block.ast); + if (codeNode) { + languages.push(codeNode.lang); + } + } + } + + expect(languages).toContain('js'); + expect(languages).toContain('python'); + }); + + it('should handle code block mixed with other content', () => { + const content = + '# Heading\n\nSome text\n\n```rust\nfn main() {}\n```\n\nMore text'; + let registry = processNewContent(INITIAL_REGISTRY, content); + + // Find code block + const codeBlock = registry.blocks.find((b) => b.type === 'codeBlock'); + expect(codeBlock).toBeDefined(); + + if (codeBlock?.ast) { + const codeNode = findCodeNode(codeBlock.ast); + expect(codeNode!.value).toBe('fn main() {}'); + expect(codeNode!.lang).toBe('rust'); + } + }); + + it('should correctly identify code block type in registry', () => { + const content = '```typescript\nconst x = 1;\n```'; + let registry = processNewContent(INITIAL_REGISTRY, content); + + expect(registry.blocks.length).toBeGreaterThan(0); + const codeBlock = registry.blocks.find((b) => b.type === 'codeBlock'); + expect(codeBlock).toBeDefined(); + expect(codeBlock!.type).toBe('codeBlock'); + }); +}); + +// ============================================================================ +// Helper to simulate streaming +// ============================================================================ + +function simulateStreaming( + fullContent: string, + chunkSize: number = 5 +): string[] { + const chunks: string[] = []; + for (let i = chunkSize; i <= fullContent.length; i += chunkSize) { + chunks.push(fullContent.slice(0, i)); + } + if (chunks[chunks.length - 1] !== fullContent) { + chunks.push(fullContent); + } + return chunks; +} + +describe('Custom Code Block Renderer - Streaming Helper', () => { + it('should simulate streaming correctly with helper', () => { + const fullContent = '```js\nconst x = 1;\n```'; + const chunks = simulateStreaming(fullContent, 5); + + const codeHistory: string[] = []; + + for (const chunk of chunks) { + const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, chunk); + const fixedContent = fixIncompleteMarkdown(chunk, tagState); + const ast = parseBlockContent(fixedContent); + const codeNode = findCodeNode(ast!); + if (codeNode) { + codeHistory.push(codeNode.value); + } + } + + // Should have multiple parses as content progresses + expect(codeHistory.length).toBeGreaterThan(1); + // Final parse should have complete code + expect(codeHistory[codeHistory.length - 1]).toBe('const x = 1;'); + }); +}); + +// ============================================================================ +// Props Extraction Tests (simulating what renderer would receive) +// ============================================================================ + +describe('Custom Code Block Renderer - Props Extraction', () => { + it('should extract props that would be passed to custom renderer', () => { + const ast = parseBlockContent('```typescript\nconst x = 1;\n```'); + const codeNode = findCodeNode(ast!); + + // These are the props that would be passed to CodeBlockRendererProps + const extractedProps = { + code: codeNode!.value, + language: codeNode!.lang || 'text', + }; + + expect(extractedProps.code).toBe('const x = 1;'); + expect(extractedProps.language).toBe('typescript'); + }); + + it('should default language to "text" when not specified', () => { + const ast = parseBlockContent('```\nsome code\n```'); + const codeNode = findCodeNode(ast!); + + const language = codeNode!.lang || 'text'; + expect(language).toBe('text'); + }); + + it('should handle special characters in code correctly', () => { + const codeWithSpecialChars = '```js\nconst str = "
"\n```'; + const ast = parseBlockContent(codeWithSpecialChars); + const codeNode = findCodeNode(ast!); + + expect(codeNode!.value).toContain(' { + describe('Basic image parsing', () => { + it('should parse image with alt text and URL', () => { + const ast = parseBlockContent('![Alt text](https://example.com/image.png)'); + expect(ast).toBeDefined(); + + const imageNode = findImageNode(ast!); + expect(imageNode).not.toBeNull(); + expect(imageNode!.url).toBe('https://example.com/image.png'); + expect(imageNode!.alt).toBe('Alt text'); + }); + + it('should parse image with title attribute', () => { + const ast = parseBlockContent('![Alt](https://example.com/img.jpg "Image title")'); + const imageNode = findImageNode(ast!); + + expect(imageNode!.url).toBe('https://example.com/img.jpg'); + expect(imageNode!.alt).toBe('Alt'); + expect(imageNode!.title).toBe('Image title'); + }); + + it('should parse image without alt text', () => { + const ast = parseBlockContent('![](https://example.com/image.png)'); + const imageNode = findImageNode(ast!); + + expect(imageNode!.url).toBe('https://example.com/image.png'); + expect(imageNode!.alt).toBe(''); + }); + + it('should handle various URL formats', () => { + const urls = [ + 'https://example.com/image.png', + 'http://example.com/image.jpg', + '/relative/path/image.gif', + './local/image.webp', + ]; + + for (const url of urls) { + const ast = parseBlockContent(`![test](${url})`); + const imageNode = findImageNode(ast!); + expect(imageNode!.url).toBe(url); + } + }); + }); +}); + +describe('Custom Image Renderer - Streaming', () => { + it('should auto-close incomplete image during streaming', () => { + const incompleteContent = '![Alt text](https://example.com/img'; + const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, incompleteContent); + const fixedContent = fixIncompleteMarkdown(incompleteContent, tagState); + + // Should auto-close the image syntax + expect(fixedContent).toContain('![Alt text]'); + expect(fixedContent).toMatch(/\)$/); + }); + + it('should track image URL evolution during streaming', () => { + const stages = [ + { input: '![alt](https:', hasImage: false }, + { input: '![alt](https://ex', hasImage: true }, + { input: '![alt](https://example.com/img.png)', hasImage: true }, + ]; + + for (const stage of stages) { + const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, stage.input); + const fixedContent = fixIncompleteMarkdown(stage.input, tagState); + const ast = parseBlockContent(fixedContent); + const imageNode = findImageNode(ast!); + + if (stage.hasImage) { + expect(imageNode).not.toBeNull(); + } + } + }); +}); + +describe('Custom Image Renderer - Edge Cases', () => { + it('should handle multiple images in content', () => { + // Add trailing content to finalize all blocks + const content = `![First](https://example.com/1.png) + +Some text + +![Second](https://example.com/2.png) + +End text`; + // Use processNewContent to get multiple blocks + let registry = processNewContent(INITIAL_REGISTRY, content); + + // Find all images across all blocks + const images: Image[] = []; + for (const block of registry.blocks) { + if (block.ast) { + images.push(...findAllImageNodes(block.ast)); + } + } + + expect(images.length).toBe(2); + expect(images[0].url).toBe('https://example.com/1.png'); + expect(images[1].url).toBe('https://example.com/2.png'); + }); + + it('should handle image mixed with other content', () => { + const content = `# Heading + +![Image](https://example.com/img.png) + +Some **bold** text`; + let registry = processNewContent(INITIAL_REGISTRY, content); + + // Find blocks with images + const blocksWithImages = registry.blocks.filter((b) => { + if (b.ast) { + const img = findImageNode(b.ast); + return img !== null; + } + return false; + }); + + expect(blocksWithImages.length).toBeGreaterThan(0); + }); +}); + +describe('Custom Image Renderer - Props Extraction', () => { + it('should extract props that would be passed to custom renderer', () => { + const ast = parseBlockContent('![Alt text](https://example.com/img.png "Title")'); + const imageNode = findImageNode(ast!); + + // These are the props that would be passed to ImageRendererProps + const extractedProps = { + src: imageNode!.url, + alt: imageNode!.alt || undefined, + title: imageNode!.title || undefined, + }; + + expect(extractedProps.src).toBe('https://example.com/img.png'); + expect(extractedProps.alt).toBe('Alt text'); + expect(extractedProps.title).toBe('Title'); + }); + + it('should handle missing optional props', () => { + const ast = parseBlockContent('![](https://example.com/img.png)'); + const imageNode = findImageNode(ast!); + + // alt is empty string, title is null in mdast + const alt = imageNode!.alt; + const title = imageNode!.title; + + expect(alt).toBe(''); + expect(title).toBeNull(); + }); +}); + +// ============================================================================ +// LINK RENDERER TESTS +// ============================================================================ + +describe('Custom Link Renderer - Link Detection', () => { + describe('Basic link parsing', () => { + it('should parse link with text and URL', () => { + const ast = parseBlockContent('[Click here](https://example.com)'); + expect(ast).toBeDefined(); + + const linkNode = findLinkNode(ast!); + expect(linkNode).not.toBeNull(); + expect(linkNode!.url).toBe('https://example.com'); + }); + + it('should parse link with title attribute', () => { + const ast = parseBlockContent('[Link](https://example.com "Link title")'); + const linkNode = findLinkNode(ast!); + + expect(linkNode!.url).toBe('https://example.com'); + expect(linkNode!.title).toBe('Link title'); + }); + + it('should extract link text content', () => { + const ast = parseBlockContent('[Click me](https://example.com)'); + const linkNode = findLinkNode(ast!); + + const textContent = getTextContent(linkNode!); + expect(textContent).toBe('Click me'); + }); + + it('should handle various URL formats', () => { + const urls = [ + 'https://example.com', + 'http://example.com/path', + '/relative/path', + '#anchor', + 'mailto:test@example.com', + ]; + + for (const url of urls) { + const ast = parseBlockContent(`[link](${url})`); + const linkNode = findLinkNode(ast!); + expect(linkNode!.url).toBe(url); + } + }); + + it('should handle link with nested formatting', () => { + const ast = parseBlockContent('[**Bold link**](https://example.com)'); + const linkNode = findLinkNode(ast!); + + expect(linkNode).not.toBeNull(); + expect(linkNode!.url).toBe('https://example.com'); + // Link should have children for the bold formatting + expect(linkNode!.children.length).toBeGreaterThan(0); + }); + }); +}); + +describe('Custom Link Renderer - Streaming', () => { + it('should auto-close incomplete link during streaming', () => { + const incompleteContent = '[Link text](https://example'; + const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, incompleteContent); + const fixedContent = fixIncompleteMarkdown(incompleteContent, tagState); + + // Should auto-close the link syntax + expect(fixedContent).toContain('[Link text]'); + expect(fixedContent).toMatch(/\)$/); + }); + + it('should handle partial link text streaming', () => { + const stages = [ + '[Lin', + '[Link', + '[Link text](', + '[Link text](https://ex', + '[Link text](https://example.com)', + ]; + + for (const input of stages) { + const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, input); + const fixedContent = fixIncompleteMarkdown(input, tagState); + // Should not throw during parsing + const ast = parseBlockContent(fixedContent); + expect(ast).toBeDefined(); + } + }); +}); + +describe('Custom Link Renderer - Edge Cases', () => { + it('should handle multiple links in content', () => { + const content = 'Visit [Google](https://google.com) or [GitHub](https://github.com)'; + const ast = parseBlockContent(content); + const links = findAllLinkNodes(ast!); + + expect(links.length).toBe(2); + expect(links[0].url).toBe('https://google.com'); + expect(links[1].url).toBe('https://github.com'); + }); + + it('should handle autolinks (GFM)', () => { + const ast = parseBlockContent('Check out https://example.com for more info'); + const linkNode = findLinkNode(ast!); + + expect(linkNode).not.toBeNull(); + expect(linkNode!.url).toBe('https://example.com'); + }); +}); + +describe('Custom Link Renderer - Props Extraction', () => { + it('should extract props that would be passed to custom renderer', () => { + const ast = parseBlockContent('[Click here](https://example.com "Title")'); + const linkNode = findLinkNode(ast!); + + // These are the props that would be passed to LinkRendererProps + const extractedProps = { + href: linkNode!.url, + title: linkNode!.title || undefined, + // children would be rendered ReactNodes from linkNode.children + }; + + expect(extractedProps.href).toBe('https://example.com'); + expect(extractedProps.title).toBe('Title'); + }); +}); + +// ============================================================================ +// BLOCKQUOTE RENDERER TESTS +// ============================================================================ + +describe('Custom Blockquote Renderer - Blockquote Detection', () => { + describe('Basic blockquote parsing', () => { + it('should parse simple blockquote', () => { + const ast = parseBlockContent('> This is a quote'); + expect(ast).toBeDefined(); + + const blockquoteNode = findBlockquoteNode(ast!); + expect(blockquoteNode).not.toBeNull(); + }); + + it('should parse multiline blockquote', () => { + const ast = parseBlockContent('> Line one\n> Line two\n> Line three'); + const blockquoteNode = findBlockquoteNode(ast!); + + expect(blockquoteNode).not.toBeNull(); + expect(blockquoteNode!.children.length).toBeGreaterThan(0); + }); + + it('should extract blockquote text content', () => { + const ast = parseBlockContent('> Hello world'); + const blockquoteNode = findBlockquoteNode(ast!); + + const textContent = getTextContent(blockquoteNode!); + expect(textContent).toContain('Hello world'); + }); + + it('should handle blockquote with formatting', () => { + const ast = parseBlockContent('> This is **bold** and *italic*'); + const blockquoteNode = findBlockquoteNode(ast!); + + expect(blockquoteNode).not.toBeNull(); + // Should have nested formatting nodes + expect(blockquoteNode!.children.length).toBeGreaterThan(0); + }); + + it('should handle nested blockquotes', () => { + const ast = parseBlockContent('> Outer quote\n>> Nested quote'); + const blockquoteNode = findBlockquoteNode(ast!); + + expect(blockquoteNode).not.toBeNull(); + // Should have nested blockquote + const nestedBlockquote = findBlockquoteNode(blockquoteNode!.children[0] as Content); + // Note: nested blockquotes may be rendered differently by remark + }); + }); +}); + +describe('Custom Blockquote Renderer - Streaming', () => { + it('should handle partial blockquote streaming', () => { + const stages = [ + '>', + '> Hel', + '> Hello', + '> Hello world', + ]; + + for (const input of stages) { + const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, input); + const fixedContent = fixIncompleteMarkdown(input, tagState); + const ast = parseBlockContent(fixedContent); + expect(ast).toBeDefined(); + } + }); + + it('should track blockquote content evolution', () => { + const contentHistory: string[] = []; + const streamChunks = [ + '> He', + '> Hello', + '> Hello, World!', + ]; + + for (const chunk of streamChunks) { + const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, chunk); + const fixedContent = fixIncompleteMarkdown(chunk, tagState); + const ast = parseBlockContent(fixedContent); + + const blockquoteNode = findBlockquoteNode(ast!); + if (blockquoteNode) { + contentHistory.push(getTextContent(blockquoteNode)); + } + } + + expect(contentHistory.length).toBeGreaterThan(0); + expect(contentHistory[contentHistory.length - 1]).toContain('Hello, World!'); + }); +}); + +describe('Custom Blockquote Renderer - Edge Cases', () => { + it('should handle blockquote mixed with other content', () => { + const content = `# Heading + +> This is a quote + +Some regular text`; + let registry = processNewContent(INITIAL_REGISTRY, content); + + const blockquoteBlocks = registry.blocks.filter((b) => b.type === 'blockquote'); + expect(blockquoteBlocks.length).toBeGreaterThan(0); + }); + + it('should correctly identify blockquote type in registry', () => { + const content = '> Quote text\n\nFollowing paragraph'; + let registry = processNewContent(INITIAL_REGISTRY, content); + + // Blockquote is finalized as a block when followed by other content + const blockquoteBlock = registry.blocks.find((b) => b.type === 'blockquote'); + expect(blockquoteBlock).toBeDefined(); + expect(blockquoteBlock!.type).toBe('blockquote'); + }); +}); + +describe('Custom Blockquote Renderer - Props Extraction', () => { + it('should extract children that would be passed to custom renderer', () => { + const ast = parseBlockContent('> Quote with **bold**'); + const blockquoteNode = findBlockquoteNode(ast!); + + // Props: children would be the rendered ReactNodes + expect(blockquoteNode!.children.length).toBeGreaterThan(0); + }); +}); + +// ============================================================================ +// TABLE RENDERER TESTS +// ============================================================================ + +describe('Custom Table Renderer - Table Detection', () => { + describe('Basic table parsing', () => { + it('should parse simple table', () => { + const ast = parseBlockContent(`| A | B | +| --- | --- | +| 1 | 2 |`); + expect(ast).toBeDefined(); + + const tableNode = findTableNode(ast!); + expect(tableNode).not.toBeNull(); + }); + + it('should extract table headers', () => { + const ast = parseBlockContent(`| Header 1 | Header 2 | +| --- | --- | +| Cell 1 | Cell 2 |`); + const tableNode = findTableNode(ast!); + + expect(tableNode!.children.length).toBeGreaterThan(0); + const headerRow = tableNode!.children[0]; + expect(headerRow.children.length).toBe(2); + }); + + it('should extract table body rows', () => { + const ast = parseBlockContent(`| A | B | +| --- | --- | +| 1 | 2 | +| 3 | 4 |`); + const tableNode = findTableNode(ast!); + + // First row is header, rest are body + expect(tableNode!.children.length).toBe(3); + }); + + it('should parse table with alignment', () => { + const ast = parseBlockContent(`| Left | Center | Right | +| :--- | :---: | ---: | +| L | C | R |`); + const tableNode = findTableNode(ast!); + + expect(tableNode!.align).toEqual(['left', 'center', 'right']); + }); + + it('should handle table without alignment specified', () => { + const ast = parseBlockContent(`| A | B | +| --- | --- | +| 1 | 2 |`); + const tableNode = findTableNode(ast!); + + expect(tableNode!.align).toEqual([null, null]); + }); + }); +}); + +describe('Custom Table Renderer - Streaming', () => { + it('should handle partial table streaming', () => { + const stages = [ + '| A |', + '| A | B |', + '| A | B |\n| --- |', + '| A | B |\n| --- | --- |', + '| A | B |\n| --- | --- |\n| 1 |', + '| A | B |\n| --- | --- |\n| 1 | 2 |', + ]; + + for (const input of stages) { + const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, input); + const fixedContent = fixIncompleteMarkdown(input, tagState); + // Should not throw + const ast = parseBlockContent(fixedContent); + expect(ast).toBeDefined(); + } + }); +}); + +describe('Custom Table Renderer - Edge Cases', () => { + it('should handle table with empty cells', () => { + const ast = parseBlockContent(`| A | B | +| --- | --- | +| | 2 |`); + const tableNode = findTableNode(ast!); + + expect(tableNode).not.toBeNull(); + }); + + it('should handle table with formatting in cells', () => { + const ast = parseBlockContent(`| **Bold** | *Italic* | +| --- | --- | +| \`code\` | [link](url) |`); + const tableNode = findTableNode(ast!); + + expect(tableNode).not.toBeNull(); + }); + + it('should correctly identify table in AST', () => { + const content = `| A | B | +| --- | --- | +| 1 | 2 | + +Following paragraph`; + let registry = processNewContent(INITIAL_REGISTRY, content); + + // Table is parsed correctly in AST, even if block.type is 'paragraph' + // (The splitter doesn't distinguish table blocks, but AST parsing does) + const blockWithTable = registry.blocks.find((b) => { + if (b.ast) { + return findTableNode(b.ast) !== null; + } + return false; + }); + expect(blockWithTable).toBeDefined(); + expect(findTableNode(blockWithTable!.ast!)).not.toBeNull(); + }); +}); + +describe('Custom Table Renderer - Props Extraction', () => { + it('should extract props that would be passed to custom renderer', () => { + const ast = parseBlockContent(`| Header 1 | Header 2 | +| :--- | ---: | +| Cell 1 | Cell 2 | +| Cell 3 | Cell 4 |`); + const tableNode = findTableNode(ast!); + + // Props extraction for TableRendererProps + const headerRow = tableNode!.children[0]; + const bodyRows = tableNode!.children.slice(1); + const alignments = tableNode!.align; + + // Headers + expect(headerRow.children.length).toBe(2); + + // Body rows + expect(bodyRows.length).toBe(2); + + // Alignments + expect(alignments).toEqual(['left', 'right']); + }); + + it('should handle varying column counts correctly', () => { + const ast = parseBlockContent(`| A | B | C | D | +| --- | --- | --- | --- | +| 1 | 2 | 3 | 4 |`); + const tableNode = findTableNode(ast!); + + expect(tableNode!.children[0].children.length).toBe(4); + }); +}); + +// ============================================================================ +// HEADING RENDERER TESTS +// ============================================================================ + +describe('Custom Heading Renderer - Heading Detection', () => { + describe('Basic heading parsing', () => { + it('should parse all heading levels', () => { + const headings = [ + { input: '# H1', level: 1 }, + { input: '## H2', level: 2 }, + { input: '### H3', level: 3 }, + { input: '#### H4', level: 4 }, + { input: '##### H5', level: 5 }, + { input: '###### H6', level: 6 }, + ]; + + for (const { input, level } of headings) { + const ast = parseBlockContent(input); + const headingNode = findHeadingNode(ast!); + expect(headingNode).not.toBeNull(); + expect(headingNode!.depth).toBe(level); + } + }); + + it('should extract heading text content', () => { + const ast = parseBlockContent('# Hello World'); + const headingNode = findHeadingNode(ast!); + + const textContent = getTextContent(headingNode!); + expect(textContent).toBe('Hello World'); + }); + + it('should handle heading with formatting', () => { + const ast = parseBlockContent('## This is **bold** heading'); + const headingNode = findHeadingNode(ast!); + + expect(headingNode).not.toBeNull(); + expect(headingNode!.children.length).toBeGreaterThan(0); + }); + + it('should handle heading with inline code', () => { + const ast = parseBlockContent('### Code: `const x = 1`'); + const headingNode = findHeadingNode(ast!); + + expect(headingNode).not.toBeNull(); + }); + }); +}); + +describe('Custom Heading Renderer - Streaming', () => { + it('should handle partial heading streaming', () => { + const stages = [ + '#', + '# H', + '# He', + '# Hel', + '# Hello', + ]; + + for (const input of stages) { + const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, input); + const fixedContent = fixIncompleteMarkdown(input, tagState); + const ast = parseBlockContent(fixedContent); + expect(ast).toBeDefined(); + } + }); + + it('should track heading content evolution', () => { + const contentHistory: string[] = []; + const streamChunks = [ + '# He', + '# Hello', + '# Hello World', + ]; + + for (const chunk of streamChunks) { + const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, chunk); + const fixedContent = fixIncompleteMarkdown(chunk, tagState); + const ast = parseBlockContent(fixedContent); + + const headingNode = findHeadingNode(ast!); + if (headingNode) { + contentHistory.push(getTextContent(headingNode)); + } + } + + expect(contentHistory.length).toBe(3); + expect(contentHistory[2]).toBe('Hello World'); + }); + + it('should maintain heading level throughout streaming', () => { + const levelHistory: number[] = []; + const streamChunks = [ + '## He', + '## Hello', + '## Hello World', + ]; + + for (const chunk of streamChunks) { + const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, chunk); + const fixedContent = fixIncompleteMarkdown(chunk, tagState); + const ast = parseBlockContent(fixedContent); + + const headingNode = findHeadingNode(ast!); + if (headingNode) { + levelHistory.push(headingNode.depth); + } + } + + // Level should remain consistent + expect(levelHistory.every((l) => l === 2)).toBe(true); + }); +}); + +describe('Custom Heading Renderer - Edge Cases', () => { + it('should handle multiple headings in content', () => { + // Add trailing content to finalize all blocks + const content = `# First + +Some text + +## Second + +More text + +### Third + +End text`; + // Use processNewContent to get multiple blocks + let registry = processNewContent(INITIAL_REGISTRY, content); + + // Find all headings across all blocks + const headings: Heading[] = []; + for (const block of registry.blocks) { + if (block.ast) { + headings.push(...findAllHeadingNodes(block.ast)); + } + } + + expect(headings.length).toBe(3); + expect(headings[0].depth).toBe(1); + expect(headings[1].depth).toBe(2); + expect(headings[2].depth).toBe(3); + }); + + it('should correctly identify heading type in registry', () => { + const content = '## Introduction\n\nFollowing paragraph'; + let registry = processNewContent(INITIAL_REGISTRY, content); + + // Heading is finalized as a block when followed by other content + const headingBlock = registry.blocks.find((b) => b.type === 'heading'); + expect(headingBlock).toBeDefined(); + expect(headingBlock!.type).toBe('heading'); + }); + + it('should not confuse hash symbols in other contexts', () => { + // Hash in code should not be detected as heading + const codeContent = '```\n# This is a comment\n```'; + let registry = processNewContent(INITIAL_REGISTRY, codeContent); + + const codeBlock = registry.blocks.find((b) => b.type === 'codeBlock'); + expect(codeBlock).toBeDefined(); + }); +}); + +describe('Custom Heading Renderer - Props Extraction', () => { + it('should extract props that would be passed to custom renderer', () => { + const ast = parseBlockContent('### Heading Text'); + const headingNode = findHeadingNode(ast!); + + // Props for HeadingRendererProps + const extractedProps = { + level: headingNode!.depth as 1 | 2 | 3 | 4 | 5 | 6, + // children would be the rendered ReactNodes from headingNode.children + }; + + expect(extractedProps.level).toBe(3); + expect(headingNode!.children.length).toBeGreaterThan(0); + }); + + it('should correctly type heading levels', () => { + for (let level = 1; level <= 6; level++) { + const hashes = '#'.repeat(level); + const ast = parseBlockContent(`${hashes} Heading`); + const headingNode = findHeadingNode(ast!); + + expect(headingNode!.depth).toBe(level); + // Level should be in valid range + expect(headingNode!.depth).toBeGreaterThanOrEqual(1); + expect(headingNode!.depth).toBeLessThanOrEqual(6); + } + }); +}); diff --git a/packages/streamdown-rn/src/core/types.ts b/packages/streamdown-rn/src/core/types.ts index d2904ca..79f60cb 100644 --- a/packages/streamdown-rn/src/core/types.ts +++ b/packages/streamdown-rn/src/core/types.ts @@ -188,6 +188,168 @@ export interface ValidationResult { errors: string[]; } +// ============================================================================ +// Custom Renderers +// ============================================================================ + +/** + * Props passed to a custom code block renderer. + * Allows consumers to provide their own code block component. + */ +export interface CodeBlockRendererProps { + /** The code content (trailing newlines trimmed) */ + code: string; + /** The language identifier (e.g., "typescript", "python", or "text" if none) */ + language: string; + /** Current theme configuration for styling consistency */ + theme: ThemeConfig; + /** React key for list rendering */ + key?: string | number; +} + +/** + * A custom renderer function for code blocks. + * Return a React node to render your custom code block. + */ +export type CodeBlockRenderer = (props: CodeBlockRendererProps) => ReactNode; + +/** + * Props passed to a custom image renderer. + */ +export interface ImageRendererProps { + /** Image source URL (already sanitized) */ + src: string; + /** Alt text for accessibility */ + alt?: string; + /** Title attribute */ + title?: string; + /** Current theme configuration */ + theme: ThemeConfig; + /** React key for list rendering */ + key?: string | number; +} + +/** + * A custom renderer function for images. + */ +export type ImageRenderer = (props: ImageRendererProps) => ReactNode; + +/** + * Props passed to a custom link renderer. + */ +export interface LinkRendererProps { + /** Link URL (already sanitized) */ + href: string; + /** Title attribute */ + title?: string; + /** Rendered link content (text or nested elements) */ + children: ReactNode; + /** Current theme configuration */ + theme: ThemeConfig; + /** React key for list rendering */ + key?: string | number; +} + +/** + * A custom renderer function for links. + */ +export type LinkRenderer = (props: LinkRendererProps) => ReactNode; + +/** + * Props passed to a custom blockquote renderer. + */ +export interface BlockquoteRendererProps { + /** Rendered blockquote content */ + children: ReactNode; + /** Current theme configuration */ + theme: ThemeConfig; + /** React key for list rendering */ + key?: string | number; +} + +/** + * A custom renderer function for blockquotes. + */ +export type BlockquoteRenderer = (props: BlockquoteRendererProps) => ReactNode; + +/** + * Props passed to a custom table renderer. + */ +export interface TableRendererProps { + /** Header cell contents (as rendered ReactNodes) */ + headers: ReactNode[]; + /** Body rows - array of cells (as rendered ReactNodes) */ + rows: ReactNode[][]; + /** Column alignments from GFM table syntax */ + alignments: ('left' | 'center' | 'right' | null)[]; + /** Current theme configuration */ + theme: ThemeConfig; + /** React key for list rendering */ + key?: string | number; +} + +/** + * A custom renderer function for tables. + */ +export type TableRenderer = (props: TableRendererProps) => ReactNode; + +/** + * Props passed to a custom heading renderer. + */ +export interface HeadingRendererProps { + /** Heading level (1-6) */ + level: 1 | 2 | 3 | 4 | 5 | 6; + /** Rendered heading content */ + children: ReactNode; + /** Current theme configuration */ + theme: ThemeConfig; + /** React key for list rendering */ + key?: string | number; +} + +/** + * A custom renderer function for headings. + */ +export type HeadingRenderer = (props: HeadingRendererProps) => ReactNode; + +/** + * Registry of custom renderers to override built-in rendering. + * Each renderer is optional — if not provided, the default renderer is used. + * + * @example + * ```tsx + * ( + * + * ), + * image: ({ src, alt, theme }) => ( + * + * ), + * link: ({ href, children, theme }) => ( + * {children} + * ), + * }} + * > + * {content} + * + * ``` + */ +export interface CustomRenderers { + /** Custom code block renderer (```code```) */ + codeBlock?: CodeBlockRenderer; + /** Custom image renderer (![alt](src)) */ + image?: ImageRenderer; + /** Custom link renderer ([text](href)) */ + link?: LinkRenderer; + /** Custom blockquote renderer (> quote) */ + blockquote?: BlockquoteRenderer; + /** Custom table renderer (GFM tables) */ + table?: TableRenderer; + /** Custom heading renderer (# heading) */ + heading?: HeadingRenderer; +} + // ============================================================================ // Theme Configuration // ============================================================================ @@ -298,7 +460,7 @@ export interface StreamdownRNProps { style?: object; /** Error callback for component failures */ onError?: (error: Error, componentName?: string) => void; - /** + /** * Debug callback — called on every content update. * Use for observability, debugging, or testing. * Only enable in development to avoid performance overhead. @@ -311,6 +473,11 @@ export interface StreamdownRNProps { * transition from skeleton to final state. */ isComplete?: boolean; + /** + * Custom renderers to override built-in block rendering. + * Use this to provide your own code block component, etc. + */ + renderers?: CustomRenderers; } /** diff --git a/packages/streamdown-rn/src/index.ts b/packages/streamdown-rn/src/index.ts index 5158b10..1ac5b8d 100644 --- a/packages/streamdown-rn/src/index.ts +++ b/packages/streamdown-rn/src/index.ts @@ -42,11 +42,30 @@ export { export type { // Component props StreamdownRNProps, - + // Component injection (for custom component registries) ComponentDefinition, ComponentRegistry, - + + // Custom renderers (for overriding built-in rendering) + CustomRenderers, + CodeBlockRenderer, + CodeBlockRendererProps, + ImageRenderer, + ImageRendererProps, + LinkRenderer, + LinkRendererProps, + BlockquoteRenderer, + BlockquoteRendererProps, + TableRenderer, + TableRendererProps, + HeadingRenderer, + HeadingRendererProps, + + // Theme (for custom renderer styling) + ThemeConfig, + ThemeColors, + // Debug/Observability DebugSnapshot, } from './core/types'; diff --git a/packages/streamdown-rn/src/renderers/ASTRenderer.tsx b/packages/streamdown-rn/src/renderers/ASTRenderer.tsx index dc6b4be..bbe0435 100644 --- a/packages/streamdown-rn/src/renderers/ASTRenderer.tsx +++ b/packages/streamdown-rn/src/renderers/ASTRenderer.tsx @@ -15,7 +15,7 @@ import React, { ReactNode, useState, useEffect } from 'react'; import { Text, View, ScrollView, Image, Platform } from 'react-native'; import SyntaxHighlighter from 'react-native-syntax-highlighter'; import type { Content, Parent, Table as TableNode, Code as CodeNode, List as ListNode, Image as ImageNode, Link as LinkNode } from 'mdast'; -import type { ThemeConfig, ComponentRegistry, StableBlock } from '../core/types'; +import type { ThemeConfig, ComponentRegistry, StableBlock, CustomRenderers } from '../core/types'; import { getTextStyles, getBlockStyles } from '../themes'; import { extractComponentData, type ComponentData } from '../core/componentParser'; import { sanitizeURL } from '../core/sanitize'; @@ -95,6 +95,8 @@ export interface ASTRendererProps { componentRegistry?: ComponentRegistry; /** Whether this is streaming (for components) */ isStreaming?: boolean; + /** Custom renderers to override built-in rendering */ + renderers?: CustomRenderers; } /** @@ -107,8 +109,9 @@ export const ASTRenderer: React.FC = ({ theme, componentRegistry, isStreaming = false, + renderers, }) => { - return <>{renderNode(node, theme, componentRegistry, isStreaming)}; + return <>{renderNode(node, theme, componentRegistry, isStreaming, renderers)}; }; // ============================================================================ @@ -123,57 +126,136 @@ function renderNode( theme: ThemeConfig, componentRegistry?: ComponentRegistry, isStreaming = false, + renderers?: CustomRenderers, key?: string | number ): ReactNode { const styles = getTextStyles(theme); const blockStyles = getBlockStyles(theme); - + switch (node.type) { // ======================================================================== // Block-level nodes // ======================================================================== - + case 'paragraph': return ( - {renderChildren(node, theme, componentRegistry, isStreaming)} + {renderChildren(node, theme, componentRegistry, isStreaming, renderers)} ); - + case 'heading': + // Check for custom heading renderer + if (renderers?.heading) { + return ( + + {renderers.heading({ + level: node.depth as 1 | 2 | 3 | 4 | 5 | 6, + children: renderChildren(node, theme, componentRegistry, isStreaming, renderers), + theme, + })} + + ); + } const headingStyle = styles[`heading${node.depth}` as keyof typeof styles]; return ( - {renderChildren(node, theme, componentRegistry, isStreaming)} + {renderChildren(node, theme, componentRegistry, isStreaming, renderers)} ); - + case 'code': + // Check for custom code block renderer + if (renderers?.codeBlock) { + const codeNode = node as CodeNode; + const code = codeNode.value.replace(/\n+$/, ''); + return ( + + {renderers.codeBlock({ + code, + language: codeNode.lang || 'text', + theme, + })} + + ); + } return renderCodeBlock(node as CodeNode, theme, key); case 'blockquote': - return renderBlockquote(node, theme, componentRegistry, isStreaming, key); - + // Check for custom blockquote renderer + if (renderers?.blockquote) { + return ( + + {renderers.blockquote({ + children: renderChildren(node, theme, componentRegistry, isStreaming, renderers), + theme, + })} + + ); + } + return renderBlockquote(node, theme, componentRegistry, isStreaming, renderers, key); + case 'list': - return renderList(node as ListNode, theme, componentRegistry, isStreaming, key); - + return renderList(node as ListNode, theme, componentRegistry, isStreaming, renderers, key); + case 'listItem': return ( • - {renderChildren(node, theme, componentRegistry, isStreaming)} + {renderChildren(node, theme, componentRegistry, isStreaming, renderers)} ); - + case 'thematicBreak': return ( ); - - case 'table': - return renderTable(node as TableNode, theme, componentRegistry, isStreaming, key); + + case 'table': { + const tableNode = node as TableNode; + + // Check for custom table renderer + if (renderers?.table) { + const tableRows = tableNode.children; + if (tableRows.length === 0) return null; + + const headerRow = tableRows[0]; + const bodyRows = tableRows.slice(1); + + // Render header cells as ReactNode[] + const headers = headerRow.children.map((cell, cellIndex) => + cell.children.map((child, childIndex) => + renderNode(child as Content, theme, componentRegistry, isStreaming, renderers, `h-${cellIndex}-${childIndex}`) + ) + ); + + // Render body rows as ReactNode[][] + const rows = bodyRows.map((row, rowIndex) => + row.children.map((cell, cellIndex) => + cell.children.map((child, childIndex) => + renderNode(child as Content, theme, componentRegistry, isStreaming, renderers, `r-${rowIndex}-${cellIndex}-${childIndex}`) + ) + ) + ); + + // Get alignments from table node (GFM feature) + const alignments = tableNode.align || []; + + return ( + + {renderers.table({ + headers, + rows, + alignments, + theme, + })} + + ); + } + return renderTable(tableNode, theme, componentRegistry, isStreaming, renderers, key); + } case 'html': // Render HTML as plain text (React Native doesn't support HTML) @@ -190,29 +272,29 @@ function renderNode( case 'text': // Check if text contains inline component syntax if (node.value.includes('[{c:')) { - return renderTextWithComponents(node.value, theme, componentRegistry, isStreaming, key); + return renderTextWithComponents(node.value, theme, componentRegistry, isStreaming, renderers, key); } return node.value; - + case 'strong': return ( - {renderChildren(node, theme, componentRegistry, isStreaming)} + {renderChildren(node, theme, componentRegistry, isStreaming, renderers)} ); - + case 'emphasis': return ( - {renderChildren(node, theme, componentRegistry, isStreaming)} + {renderChildren(node, theme, componentRegistry, isStreaming, renderers)} ); - + case 'delete': // GFM strikethrough return ( - {renderChildren(node, theme, componentRegistry, isStreaming)} + {renderChildren(node, theme, componentRegistry, isStreaming, renderers)} ); @@ -227,29 +309,61 @@ function renderNode( // Sanitize URL to prevent XSS via javascript: or data: protocols const linkNode = node as LinkNode; const safeUrl = sanitizeURL(linkNode.url); - + // If URL is dangerous, render children as plain text without link styling if (!safeUrl) { return ( - {renderChildren(node, theme, componentRegistry, isStreaming)} + {renderChildren(node, theme, componentRegistry, isStreaming, renderers)} ); } - + + // Check for custom link renderer + if (renderers?.link) { + return ( + + {renderers.link({ + href: safeUrl, + title: linkNode.title ?? undefined, + children: renderChildren(node, theme, componentRegistry, isStreaming, renderers), + theme, + })} + + ); + } + return ( - {renderChildren(node, theme, componentRegistry, isStreaming)} + {renderChildren(node, theme, componentRegistry, isStreaming, renderers)} ); } - case 'image': - return renderImage(node as ImageNode, theme, key); + case 'image': { + const imageNode = node as ImageNode; + const safeImageUrl = sanitizeURL(imageNode.url); + + // Check for custom image renderer + if (renderers?.image) { + if (!safeImageUrl) return null; + return ( + + {renderers.image({ + src: safeImageUrl, + alt: imageNode.alt ?? undefined, + title: imageNode.title ?? undefined, + theme, + })} + + ); + } + return renderImage(imageNode, theme, key); + } case 'break': return '\n'; @@ -290,14 +404,15 @@ function renderChildren( node: Parent, theme: ThemeConfig, componentRegistry?: ComponentRegistry, - isStreaming = false + isStreaming = false, + renderers?: CustomRenderers ): ReactNode { if (!('children' in node) || !node.children) { return null; } - + return node.children.map((child, index) => - renderNode(child as Content, theme, componentRegistry, isStreaming, index) + renderNode(child as Content, theme, componentRegistry, isStreaming, renderers, index) ); } @@ -370,11 +485,12 @@ function renderList( theme: ThemeConfig, componentRegistry?: ComponentRegistry, isStreaming = false, + renderers?: CustomRenderers, key?: string | number ): ReactNode { const styles = getTextStyles(theme); const ordered = node.ordered ?? false; - + return ( {node.children.map((item, index) => ( @@ -384,7 +500,7 @@ function renderList( {item.children.map((child, childIndex) => - renderListItemChild(child as Content, theme, componentRegistry, isStreaming, childIndex) + renderListItemChild(child as Content, theme, componentRegistry, isStreaming, renderers, childIndex) )} @@ -402,30 +518,31 @@ function renderListItemChild( theme: ThemeConfig, componentRegistry?: ComponentRegistry, isStreaming = false, + renderers?: CustomRenderers, key?: string | number ): ReactNode { const styles = getTextStyles(theme); - + // For paragraphs inside list items, render without margin if (node.type === 'paragraph') { return ( - {renderChildren(node, theme, componentRegistry, isStreaming)} + {renderChildren(node, theme, componentRegistry, isStreaming, renderers)} ); } - + // For nested lists, render with reduced margin if (node.type === 'list') { return ( - {renderList(node as ListNode, theme, componentRegistry, isStreaming)} + {renderList(node as ListNode, theme, componentRegistry, isStreaming, renderers)} ); } - + // For other types, use normal rendering - return renderNode(node, theme, componentRegistry, isStreaming, key); + return renderNode(node, theme, componentRegistry, isStreaming, renderers, key); } /** @@ -437,11 +554,12 @@ function renderBlockquote( theme: ThemeConfig, componentRegistry?: ComponentRegistry, isStreaming = false, + renderers?: CustomRenderers, key?: string | number ): ReactNode { const styles = getTextStyles(theme); const blockStyles = getBlockStyles(theme); - + return ( {node.children?.map((child, index) => { @@ -449,12 +567,12 @@ function renderBlockquote( if (child.type === 'paragraph') { return ( - {renderChildren(child, theme, componentRegistry, isStreaming)} + {renderChildren(child, theme, componentRegistry, isStreaming, renderers)} ); } // For other types, use normal rendering - return renderNode(child, theme, componentRegistry, isStreaming, index); + return renderNode(child, theme, componentRegistry, isStreaming, renderers, index); })} ); @@ -468,22 +586,23 @@ function renderTable( theme: ThemeConfig, componentRegistry?: ComponentRegistry, isStreaming = false, + renderers?: CustomRenderers, key?: string | number ): ReactNode { const styles = getTextStyles(theme); const rows = node.children; - + if (rows.length === 0) return null; - + const headerRow = rows[0]; const bodyRows = rows.slice(1); - + return ( {/* Header */} - {cell.children.map((child, childIndex) => - renderNode(child as Content, theme, componentRegistry, isStreaming, childIndex) + renderNode(child as Content, theme, componentRegistry, isStreaming, renderers, childIndex) )} ))} - + {/* Body */} {bodyRows.map((row, rowIndex) => ( - {cell.children.map((child, childIndex) => - renderNode(child as Content, theme, componentRegistry, isStreaming, childIndex) + renderNode(child as Content, theme, componentRegistry, isStreaming, renderers, childIndex) )} @@ -623,20 +742,21 @@ function renderTextWithComponents( theme: ThemeConfig, componentRegistry?: ComponentRegistry, isStreaming = false, + renderers?: CustomRenderers, key?: string | number ): ReactNode { // Look for inline components const componentMatch = text.match(/\[\{c:\s*"([^"]+)"\s*,\s*p:\s*(\{[\s\S]*?\})\s*\}\]/); - + if (!componentMatch) { return text; } - + const before = text.slice(0, componentMatch.index); const after = text.slice(componentMatch.index! + componentMatch[0].length); - + const { name, props } = extractComponentData(componentMatch[0]); - + if (!componentRegistry) { return ( <> @@ -646,7 +766,7 @@ function renderTextWithComponents( ); } - + const componentDef = componentRegistry.get(name); if (!componentDef) { return ( @@ -657,14 +777,14 @@ function renderTextWithComponents( ); } - + const Component = componentDef.component; - + return ( <> {before} - {renderTextWithComponents(after, theme, componentRegistry, isStreaming, `${key}-after`)} + {renderTextWithComponents(after, theme, componentRegistry, isStreaming, renderers, `${key}-after`)} ); } @@ -831,7 +951,8 @@ export function renderAST( nodes: Content[], theme: ThemeConfig, componentRegistry?: ComponentRegistry, - isStreaming = false + isStreaming = false, + renderers?: CustomRenderers ): ReactNode { - return nodes.map((node, index) => renderNode(node, theme, componentRegistry, isStreaming, index)); + return nodes.map((node, index) => renderNode(node, theme, componentRegistry, isStreaming, renderers, index)); } diff --git a/packages/streamdown-rn/src/renderers/ActiveBlock.tsx b/packages/streamdown-rn/src/renderers/ActiveBlock.tsx index 37bebf1..315aa46 100644 --- a/packages/streamdown-rn/src/renderers/ActiveBlock.tsx +++ b/packages/streamdown-rn/src/renderers/ActiveBlock.tsx @@ -10,7 +10,7 @@ */ import React from 'react'; -import type { ActiveBlock as ActiveBlockType, ThemeConfig, ComponentRegistry, IncompleteTagState } from '../core/types'; +import type { ActiveBlock as ActiveBlockType, ThemeConfig, ComponentRegistry, IncompleteTagState, CustomRenderers } from '../core/types'; import { fixIncompleteMarkdown } from '../core/incomplete'; import { parseBlockContent } from '../core/parser'; import { ASTRenderer, ComponentBlock, extractComponentData } from './ASTRenderer'; @@ -20,6 +20,7 @@ interface ActiveBlockProps { tagState: IncompleteTagState; theme: ThemeConfig; componentRegistry?: ComponentRegistry; + renderers?: CustomRenderers; } /** @@ -33,12 +34,13 @@ export const ActiveBlock: React.FC = ({ tagState, theme, componentRegistry, + renderers, }) => { // No active block — nothing to render if (!block || !block.content.trim()) { return null; } - + // Special handling for component blocks (don't use remark) if (block.type === 'component') { const { name, props } = extractComponentData(block.content); @@ -52,13 +54,13 @@ export const ActiveBlock: React.FC = ({ /> ); } - + // Fix incomplete markdown for format-as-you-type UX const fixedContent = fixIncompleteMarkdown(block.content, tagState); - + // Parse with remark const ast = parseBlockContent(fixedContent); - + // Render from AST if (ast) { return ( @@ -67,10 +69,11 @@ export const ActiveBlock: React.FC = ({ theme={theme} componentRegistry={componentRegistry} isStreaming={true} + renderers={renderers} /> ); } - + // Fallback if parsing fails (shouldn't happen) return null; }; diff --git a/packages/streamdown-rn/src/renderers/StableBlock.tsx b/packages/streamdown-rn/src/renderers/StableBlock.tsx index 517496f..10f979d 100644 --- a/packages/streamdown-rn/src/renderers/StableBlock.tsx +++ b/packages/streamdown-rn/src/renderers/StableBlock.tsx @@ -6,13 +6,14 @@ */ import React from 'react'; -import type { StableBlock as StableBlockType, ThemeConfig, ComponentRegistry } from '../core/types'; +import type { StableBlock as StableBlockType, ThemeConfig, ComponentRegistry, CustomRenderers } from '../core/types'; import { ASTRenderer, ComponentBlock } from './ASTRenderer'; interface StableBlockProps { block: StableBlockType; theme: ThemeConfig; componentRegistry?: ComponentRegistry; + renderers?: CustomRenderers; } /** @@ -22,7 +23,7 @@ interface StableBlockProps { * The block prop is immutable — once finalized, content never changes. */ export const StableBlock: React.FC = React.memo( - ({ block, theme, componentRegistry }) => { + ({ block, theme, componentRegistry, renderers }) => { // Component blocks don't have AST (custom syntax, not markdown) if (block.type === 'component') { return ( @@ -33,7 +34,7 @@ export const StableBlock: React.FC = React.memo( /> ); } - + // Render from cached AST if (block.ast) { return ( @@ -41,16 +42,24 @@ export const StableBlock: React.FC = React.memo( node={block.ast} theme={theme} componentRegistry={componentRegistry} + renderers={renderers} /> ); } - + // Fallback if no AST (shouldn't happen for stable blocks) console.warn('StableBlock has no AST:', block.type, block.id); return null; }, - // Only re-render if the block's content hash changes (which shouldn't happen for stable blocks) - (prev, next) => prev.block.contentHash === next.block.contentHash + // Re-render if content hash OR any renderer changes + (prev, next) => + prev.block.contentHash === next.block.contentHash && + prev.renderers?.codeBlock === next.renderers?.codeBlock && + prev.renderers?.image === next.renderers?.image && + prev.renderers?.link === next.renderers?.link && + prev.renderers?.blockquote === next.renderers?.blockquote && + prev.renderers?.table === next.renderers?.table && + prev.renderers?.heading === next.renderers?.heading ); StableBlock.displayName = 'StableBlock';