Advertisement

← vibesdk

src/routes/chat/components/markdown-docs-preview.tsx

6.9 KB Download file
import { useState, useEffect, useMemo, useRef, useCallback, type ReactNode } from 'react';
import { Loader, FileText, FileDown } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeExternalLinks from 'rehype-external-links';
import { DocsSidebar } from './docs-sidebar';
import { NO_IMAGE_MARKDOWN_COMPONENTS } from './markdown-components';
import { ExportButton } from './export-button';
import { exportMarkdownAsFile } from '@/utils/markdown-export';
import type { FileType } from '@/api-types';
import './markdown-docs-preview.css';

interface MarkdownDocsPreviewProps {
	files: FileType[];
	isGenerating: boolean;
}

export function MarkdownDocsPreview({
	files,
	isGenerating: _isGenerating,
}: MarkdownDocsPreviewProps) {
	// Prioritize README as default, otherwise first file
	const defaultFile = useMemo(() => {
		const readmeFile = files.find((f) =>
			f.filePath.toLowerCase().includes('readme')
		);
		return readmeFile || files[0];
	}, [files]);

	const [activeFilePath, setActiveFilePath] = useState<string>(
		defaultFile?.filePath || ''
	);

	// Update active file if default changes
	useEffect(() => {
		if (defaultFile && !activeFilePath) {
			setActiveFilePath(defaultFile.filePath);
		}
	}, [defaultFile, activeFilePath]);

	const activeFile = files.find((f) => f.filePath === activeFilePath);

	// Ref for print export
	const contentRef = useRef<HTMLElement>(null);

	// Export handlers
	const handleExportMarkdown = useCallback(() => {
		if (!activeFile) return;
		const filename = activeFile.filePath.split('/').pop() || 'documentation.md';
		exportMarkdownAsFile(activeFile.fileContents || '', filename);
	}, [activeFile]);

	const handlePrint = useCallback(() => {
		window.print();
	}, []);

	// Extract table of contents from markdown headings
	const tableOfContents = useMemo(() => {
		if (!activeFile?.fileContents) return [];

		const headingRegex = /^(#{1,3})\s+(.+)$/gm;
		const headings: { level: number; text: string; id: string }[] = [];
		let match;

		while ((match = headingRegex.exec(activeFile.fileContents)) !== null) {
			const level = match[1].length;
			const text = match[2];
			const id = text
				.toLowerCase()
				.replace(/[^\w\s-]/g, '')
				.replace(/\s+/g, '-');

			headings.push({ level, text, id });
		}

		return headings;
	}, [activeFile?.fileContents]);

	const handleFileSelect = (filePath: string) => {
		setActiveFilePath(filePath);
	};

	const markdownContent = useMemo(() => {
		if (!activeFile) return '';

		let content = activeFile.fileContents || '';

		// Add generating indicator if still streaming
		if (activeFile.isGenerating && content) {
			content += '\n\n_Generating..._';
		}

		return content;
	}, [activeFile]);

	return (
		<div className="flex-1 flex overflow-hidden">
			{/* Sidebar */}
			<DocsSidebar
				files={files}
				activeFile={activeFilePath}
				onFileSelect={handleFileSelect}
			/>

			{/* Main content area */}
			<div className="flex-1 flex flex-col overflow-hidden">
				{/* Header */}
				<div className="flex items-center gap-3 px-6 h-12 bg-kumo-elevated border-b">
					{/* Left: File name and status */}
					<div className="flex items-center gap-3 flex-1">
						<span className="text-sm font-medium text-text-primary">
							{activeFile?.filePath || 'Documentation'}
						</span>
						{activeFile?.isGenerating && (
							<div className="flex items-center gap-2 text-xs text-kumo-brand">
								<Loader className="size-3 animate-spin" />
								<span>Generating...</span>
							</div>
						)}
					</div>

					{/* Right: Export buttons */}
					<div className="flex items-center gap-2 export-button-container">
						<ExportButton
							icon={FileText}
							onClick={handleExportMarkdown}
							tooltip="Download Markdown"
							disabled={!activeFile}
						/>
						<ExportButton
							icon={FileDown}
							onClick={handlePrint}
							tooltip="Print to PDF"
							disabled={!activeFile}
						/>
					</div>
				</div>

				{/* Content with TOC */}
				<div className="flex-1 flex overflow-hidden">
					{/* Main markdown content */}
					<div className="flex-1 overflow-y-auto px-6 py-8">
						{!activeFile ? (
							<div className="flex flex-col items-center justify-center h-full gap-4 text-text-secondary">
								<p>No documentation file selected</p>
							</div>
						) : !markdownContent ? (
							<div className="flex flex-col items-center justify-center h-full gap-4 text-text-secondary">
								<Loader className="size-8 animate-spin text-kumo-brand" />
								<p>Waiting for content...</p>
							</div>
						) : (
							<article ref={contentRef} className="prose prose-sm prose-invert max-w-none">
								<ReactMarkdown
									remarkPlugins={[remarkGfm]}
									rehypePlugins={[[rehypeExternalLinks, { target: '_blank' }]]}
									components={{
										...NO_IMAGE_MARKDOWN_COMPONENTS,
										h1: ({ node, ...props }) => (
											<h1 id={createId(props.children)} {...props} />
										),
										h2: ({ node, ...props }) => (
											<h2 id={createId(props.children)} {...props} />
										),
										h3: ({ node, ...props }) => (
											<h3 id={createId(props.children)} {...props} />
										),
									}}
								>
									{markdownContent}
								</ReactMarkdown>
							</article>
						)}
					</div>

					{/* Table of contents (if headings exist) */}
					{tableOfContents.length > 0 && (
						<div className="w-56 border-l bg-kumo-elevated overflow-y-auto py-6 px-4">
							<h4 className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-3">
								On This Page
							</h4>
							<nav>
								<ul className="space-y-2">
									{tableOfContents.map((heading, idx) => (
										<li
											key={idx}
											style={{
												paddingLeft: `${(heading.level - 1) * 12}px`,
											}}
										>
											<a
												href={`#${heading.id}`}
												className="text-xs text-text-tertiary hover:text-text-primary transition-colors block"
											>
												{heading.text}
											</a>
										</li>
									))}
								</ul>
							</nav>
						</div>
					)}
				</div>
			</div>
		</div>
	);
}

/**
 * Create ID from heading text for anchor links
 */
function createId(children: ReactNode): string {
	const text = extractText(children);
	return text
		.toLowerCase()
		.replace(/[^\w\s-]/g, '')
		.replace(/\s+/g, '-');
}

/**
 * Extract text from React children
 */
function extractText(children: ReactNode): string {
	if (typeof children === 'string') return children;
	if (Array.isArray(children)) return children.map(extractText).join('');
	if (children && typeof children === 'object' && 'props' in children) {
		const element = children as { props: { children?: ReactNode } };
		return extractText(element.props.children);
	}
	return '';
}