/** * AST Renderer * * THE single source of truth for rendering MDAST nodes to React Native. * Handles all GitHub Flavored Markdown via remark-gfm. * * Includes: * - All block-level nodes (paragraph, heading, code, blockquote, list, table, hr, image) * - All inline nodes (text, strong, emphasis, delete, code, link, break) * - Custom component injection via [{c:"Name",p:{...}}] syntax * - Syntax highlighting for code blocks */ 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 { getTextStyles, getBlockStyles } from '../themes'; import { extractComponentData, type ComponentData } from '../core/componentParser'; import { sanitizeURL } from '../core/sanitize'; // ============================================================================ // Syntax Highlighting Utilities // ============================================================================ /** * Map common language aliases to Prism language names */ function normalizeLanguage(lang: string): string { const aliases: Record = { 'js': 'javascript', 'ts': 'typescript', 'tsx': 'tsx', 'jsx': 'jsx', 'py': 'python', 'rb': 'ruby', 'sh': 'bash', 'shell': 'bash', 'zsh': 'bash', 'yml': 'yaml', 'md': 'markdown', 'json5': 'json', 'dockerfile': 'docker', }; return aliases[lang.toLowerCase()] || lang.toLowerCase(); } /** * Create Prism syntax style from theme colors */ function createSyntaxStyle(theme: ThemeConfig) { return { 'pre[class*="language-"]': { color: theme.colors.syntaxDefault, background: 'transparent', }, 'token': { color: theme.colors.syntaxDefault }, 'keyword': { color: theme.colors.syntaxKeyword }, 'builtin': { color: theme.colors.syntaxOperator }, 'class-name': { color: theme.colors.syntaxClass }, 'function': { color: theme.colors.syntaxFunction }, 'string': { color: theme.colors.syntaxString }, 'number': { color: theme.colors.syntaxNumber }, 'operator': { color: theme.colors.syntaxOperator }, 'comment': { color: theme.colors.syntaxComment }, 'punctuation': { color: theme.colors.syntaxDefault }, 'property': { color: theme.colors.syntaxClass }, 'constant': { color: theme.colors.syntaxNumber }, 'boolean': { color: theme.colors.syntaxNumber }, 'tag': { color: theme.colors.syntaxKeyword }, 'attr-name': { color: theme.colors.syntaxString }, 'attr-value': { color: theme.colors.syntaxString }, 'selector': { color: theme.colors.syntaxClass }, 'regex': { color: theme.colors.syntaxString }, }; } // ============================================================================ // Component Extraction (re-export for backwards compatibility) // ============================================================================ export { extractComponentData, type ComponentData }; // ============================================================================ // Main Component // ============================================================================ export interface ASTRendererProps { /** MDAST node to render */ node: Content; /** Theme configuration */ theme: ThemeConfig; /** Component registry for custom components */ componentRegistry?: ComponentRegistry; /** Whether this is streaming (for components) */ isStreaming?: boolean; } /** * Main AST Renderer Component * * Renders a single MDAST node and its children recursively. */ export const ASTRenderer: React.FC = ({ node, theme, componentRegistry, isStreaming = false, }) => { return <>{renderNode(node, theme, componentRegistry, isStreaming)}; }; // ============================================================================ // Node Rendering // ============================================================================ /** * Render a single MDAST node */ function renderNode( node: Content, theme: ThemeConfig, componentRegistry?: ComponentRegistry, isStreaming = false, 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)} ); case 'heading': const headingStyle = styles[`heading${node.depth}` as keyof typeof styles]; return ( {renderChildren(node, theme, componentRegistry, isStreaming)} ); case 'code': return renderCodeBlock(node as CodeNode, theme, key); case 'blockquote': return renderBlockquote(node, theme, componentRegistry, isStreaming, key); case 'list': return renderList(node as ListNode, theme, componentRegistry, isStreaming, key); case 'listItem': return ( {renderChildren(node, theme, componentRegistry, isStreaming)} ); case 'thematicBreak': return ( ); case 'table': return renderTable(node as TableNode, theme, componentRegistry, isStreaming, key); case 'html': // Render HTML as plain text (React Native doesn't support HTML) return ( {node.value} ); // ======================================================================== // Inline (phrasing) nodes // ======================================================================== case 'text': // Check if text contains inline component syntax if (node.value.includes('[{c:')) { return renderTextWithComponents(node.value, theme, componentRegistry, isStreaming, key); } return node.value; case 'strong': return ( {renderChildren(node, theme, componentRegistry, isStreaming)} ); case 'emphasis': return ( {renderChildren(node, theme, componentRegistry, isStreaming)} ); case 'delete': // GFM strikethrough return ( {renderChildren(node, theme, componentRegistry, isStreaming)} ); case 'inlineCode': return ( {node.value} ); case 'link': { // 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)} ); } return ( {renderChildren(node, theme, componentRegistry, isStreaming)} ); } case 'image': return renderImage(node as ImageNode, theme, key); case 'break': return '\n'; // ======================================================================== // GFM-specific nodes (handled above or ignored) // ======================================================================== case 'tableRow': case 'tableCell': // Handled by renderTable return null; case 'footnoteReference': return ( [{node.identifier}] ); case 'footnoteDefinition': return null; // Footnotes rendered separately // ======================================================================== // Fallback // ======================================================================== default: console.warn('Unhandled MDAST node type:', (node as Content).type); return null; } } /** * Render children of a parent node */ function renderChildren( node: Parent, theme: ThemeConfig, componentRegistry?: ComponentRegistry, isStreaming = false ): ReactNode { if (!('children' in node) || !node.children) { return null; } return node.children.map((child, index) => renderNode(child as Content, theme, componentRegistry, isStreaming, index) ); } // ============================================================================ // Specialized Renderers // ============================================================================ /** * Render a code block with syntax highlighting */ function renderCodeBlock( node: CodeNode, theme: ThemeConfig, key?: string | number ): ReactNode { const blockStyles = getBlockStyles(theme); const code = node.value.replace(/\n+$/, ''); // Trim trailing newlines const language = node.lang || 'text'; const normalizedLanguage = normalizeLanguage(language); const syntaxStyle = createSyntaxStyle(theme); return ( {language && language !== 'text' && ( {language} )} {code} ); } /** * Render a list (ordered or unordered) */ function renderList( node: ListNode, theme: ThemeConfig, componentRegistry?: ComponentRegistry, isStreaming = false, key?: string | number ): ReactNode { const styles = getTextStyles(theme); const ordered = node.ordered ?? false; return ( {node.children.map((item, index) => ( {ordered ? `${index + 1}.` : '•'} {item.children.map((child, childIndex) => renderListItemChild(child as Content, theme, componentRegistry, isStreaming, childIndex) )} ))} ); } /** * Render a child node inside a list item. * Strips paragraph margins to prevent double-spacing in lists. */ function renderListItemChild( node: Content, theme: ThemeConfig, componentRegistry?: ComponentRegistry, isStreaming = false, 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)} ); } // For nested lists, render with reduced margin if (node.type === 'list') { return ( {renderList(node as ListNode, theme, componentRegistry, isStreaming)} ); } // For other types, use normal rendering return renderNode(node, theme, componentRegistry, isStreaming, key); } /** * Render a blockquote. * Strips paragraph margins to prevent extra spacing at the end. */ function renderBlockquote( node: { children?: Content[] }, theme: ThemeConfig, componentRegistry?: ComponentRegistry, isStreaming = false, key?: string | number ): ReactNode { const styles = getTextStyles(theme); const blockStyles = getBlockStyles(theme); return ( {node.children?.map((child, index) => { // For paragraphs inside blockquotes, render without bottom margin if (child.type === 'paragraph') { return ( {renderChildren(child, theme, componentRegistry, isStreaming)} ); } // For other types, use normal rendering return renderNode(child, theme, componentRegistry, isStreaming, index); })} ); } /** * Render a table */ function renderTable( node: TableNode, theme: ThemeConfig, componentRegistry?: ComponentRegistry, isStreaming = false, 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 */} {headerRow.children.map((cell, cellIndex) => ( {cell.children.map((child, childIndex) => renderNode(child as Content, theme, componentRegistry, isStreaming, childIndex) )} ))} {/* Body */} {bodyRows.map((row, rowIndex) => ( {row.children.map((cell, cellIndex) => ( {cell.children.map((child, childIndex) => renderNode(child as Content, theme, componentRegistry, isStreaming, childIndex) )} ))} ))} ); } /** * Auto-sized image component that fetches dimensions and renders with correct aspect ratio */ function AutoSizedImage({ uri, alt, theme, }: { uri: string; alt?: string; theme: ThemeConfig; }) { const [aspectRatio, setAspectRatio] = useState(16 / 9); // Default fallback const [loaded, setLoaded] = useState(false); useEffect(() => { let mounted = true; Image.getSize( uri, (width, height) => { if (mounted && width && height) { setAspectRatio(width / height); setLoaded(true); } }, () => { // On error, keep default aspect ratio if (mounted) { setLoaded(true); } } ); return () => { mounted = false; }; }, [uri]); return ( ); } /** * Render an image * URL is sanitized to prevent XSS via javascript: or data: protocols */ function renderImage( node: ImageNode, theme: ThemeConfig, key?: string | number ): ReactNode { const styles = getTextStyles(theme); if (!node.url) { return null; } // Sanitize URL to prevent XSS const safeUrl = sanitizeURL(node.url); if (!safeUrl) { // Dangerous URL - render alt text only as a fallback if (node.alt) { return ( [Image: {node.alt}] ); } return null; } return ( ); } /** * Render text that may contain inline component syntax */ function renderTextWithComponents( text: string, theme: ThemeConfig, componentRegistry?: ComponentRegistry, isStreaming = false, 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 ( <> {before} ⚠️ [{name}] {after} ); } const componentDef = componentRegistry.get(name); if (!componentDef) { return ( <> {before} ⚠️ [{name}] {after} ); } const Component = componentDef.component; return ( <> {before} {renderTextWithComponents(after, theme, componentRegistry, isStreaming, `${key}-after`)} ); } // ============================================================================ // Block-level Component Renderer (for StableBlock with type='component') // ============================================================================ export interface ComponentBlockProps { theme: ThemeConfig; componentRegistry?: ComponentRegistry; /** StableBlock input */ block?: StableBlock; /** Direct component name (when not using block) */ componentName?: string; /** Direct props (when not using block) */ props?: Record; /** CSS Grid-like style for layout positioning */ style?: Record; /** Direct children (when not using block) */ children?: ComponentData[]; /** Whether streaming (for active blocks) */ isStreaming?: boolean; } /** * Render error/fallback states for components. */ function renderComponentError(theme: ThemeConfig, message: string): ReactNode { return ( {message} ); } /** * Render a block-level custom component with skeleton and children support. */ export const ComponentBlock: React.FC = React.memo( ({ theme, componentRegistry, block, componentName: directName, props: directProps, style: directStyle, children: directChildren, isStreaming = false, }) => { // Extract component data from block or direct props let componentName: string; let props: Record; let style: Record | undefined; let children: ComponentData[] | undefined; if (block) { const meta = block.meta as { type: 'component'; name: string; props: Record }; if (meta.name) { componentName = meta.name; props = meta.props || {}; } else { const extracted = extractComponentData(block.content); componentName = extracted.name; props = extracted.props; style = extracted.style; children = extracted.children; } } else { componentName = directName ?? ''; props = directProps ?? {}; style = directStyle; children = directChildren; } // No component name yet (still streaming) - render nothing // The component will appear once we have enough to show its skeleton if (!componentName) { return null; } // No registry provided if (!componentRegistry) { return renderComponentError(theme, '⚠️ No component registry provided'); } // Component not found const componentDef = componentRegistry.get(componentName); if (!componentDef) { return renderComponentError(theme, `⚠️ Unknown component: ${componentName}`); } // Render children recursively if present, passing style for layout const renderedChildren = children?.length ? ( children.map((child, index) => ( )) ) : undefined; // Merge props.style (component config) with layout style (positioning) // props.style = component-specific config (e.g., Canvas gridTemplateColumns) // style = layout positioning in parent (e.g., gridColumn: "span 2") const mergedStyle = { ...(props.style as object), ...style }; // When streaming, prefer skeleton component if available if (isStreaming && componentDef.skeletonComponent) { const SkeletonComponent = componentDef.skeletonComponent; return ( {renderedChildren} ); } // Render the main component const Component = componentDef.component; return ( {renderedChildren} ); }, (prev, next) => { if (prev.block && next.block) { return prev.block.contentHash === next.block.contentHash; } return ( prev.componentName === next.componentName && prev.isStreaming === next.isStreaming && JSON.stringify(prev.props) === JSON.stringify(next.props) && JSON.stringify(prev.children) === JSON.stringify(next.children) ); } ); ComponentBlock.displayName = 'ComponentBlock'; // ============================================================================ // Exports // ============================================================================ /** * Render a complete MDAST tree (for testing) */ export function renderAST( nodes: Content[], theme: ThemeConfig, componentRegistry?: ComponentRegistry, isStreaming = false ): ReactNode { return nodes.map((node, index) => renderNode(node, theme, componentRegistry, isStreaming, index)); }