|
| 1 | +'use client'; |
| 2 | + |
| 3 | +import {useEffect, useState} from 'react'; |
| 4 | + |
| 5 | +import {CodeBlock} from './codeBlock'; |
| 6 | +import {CodeTabs} from './codeTabs'; |
| 7 | +import {codeToJsx} from './highlightCode'; |
| 8 | + |
| 9 | +interface GitHubCodePreviewProps { |
| 10 | + /** |
| 11 | + * GitHub blob URL with optional line numbers |
| 12 | + * Examples: |
| 13 | + * - https://github.com/owner/repo/blob/main/src/file.ts#L10-L20 |
| 14 | + * - https://github.com/owner/repo/blob/abc123/src/file.ts#L5 |
| 15 | + */ |
| 16 | + url: string; |
| 17 | +} |
| 18 | + |
| 19 | +interface ParsedGitHubUrl { |
| 20 | + owner: string; |
| 21 | + path: string; |
| 22 | + ref: string; |
| 23 | + repo: string; |
| 24 | + endLine?: number; |
| 25 | + startLine?: number; |
| 26 | +} |
| 27 | + |
| 28 | +function parseGitHubUrl(url: string): ParsedGitHubUrl | null { |
| 29 | + try { |
| 30 | + const urlObj = new URL(url); |
| 31 | + |
| 32 | + // Check if it's a GitHub URL |
| 33 | + if (urlObj.hostname !== 'github.com') { |
| 34 | + return null; |
| 35 | + } |
| 36 | + |
| 37 | + // Parse pathname: /owner/repo/blob/ref/path/to/file |
| 38 | + const pathParts = urlObj.pathname.split('/').filter(Boolean); |
| 39 | + |
| 40 | + if (pathParts.length < 5 || pathParts[2] !== 'blob') { |
| 41 | + return null; |
| 42 | + } |
| 43 | + |
| 44 | + const owner = pathParts[0]; |
| 45 | + const repo = pathParts[1]; |
| 46 | + const ref = pathParts[3]; |
| 47 | + const path = pathParts.slice(4).join('/'); |
| 48 | + |
| 49 | + // Parse line numbers from hash (#L10-L20 or #L10) |
| 50 | + let startLine: number | undefined; |
| 51 | + let endLine: number | undefined; |
| 52 | + |
| 53 | + if (urlObj.hash) { |
| 54 | + const lineMatch = urlObj.hash.match(/^#L(\d+)(?:-L(\d+))?$/); |
| 55 | + if (lineMatch) { |
| 56 | + startLine = parseInt(lineMatch[1], 10); |
| 57 | + endLine = lineMatch[2] ? parseInt(lineMatch[2], 10) : startLine; |
| 58 | + } |
| 59 | + } |
| 60 | + |
| 61 | + return {owner, repo, ref, path, startLine, endLine}; |
| 62 | + } catch { |
| 63 | + return null; |
| 64 | + } |
| 65 | +} |
| 66 | + |
| 67 | +function getLanguageFromPath(path: string): string { |
| 68 | + const ext = path.split('.').pop()?.toLowerCase(); |
| 69 | + |
| 70 | + // Map file extensions to supported languages |
| 71 | + const langMap: Record<string, string> = { |
| 72 | + js: 'javascript', |
| 73 | + jsx: 'javascript', |
| 74 | + ts: 'typescript', |
| 75 | + tsx: 'typescript', |
| 76 | + json: 'json', |
| 77 | + sh: 'bash', |
| 78 | + bash: 'bash', |
| 79 | + py: 'python', |
| 80 | + }; |
| 81 | + |
| 82 | + return langMap[ext || ''] || 'text'; |
| 83 | +} |
| 84 | + |
| 85 | +async function fetchGitHubContent(parsed: ParsedGitHubUrl): Promise<string | null> { |
| 86 | + try { |
| 87 | + // Use GitHub raw content URL - encode path segments to handle special characters |
| 88 | + const encodedPath = parsed.path |
| 89 | + .split('/') |
| 90 | + .map(segment => encodeURIComponent(segment)) |
| 91 | + .join('/'); |
| 92 | + const rawUrl = `https://raw.githubusercontent.com/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}/${encodeURIComponent(parsed.ref)}/${encodedPath}`; |
| 93 | + |
| 94 | + const response = await fetch(rawUrl); |
| 95 | + |
| 96 | + if (!response.ok) { |
| 97 | + return null; |
| 98 | + } |
| 99 | + |
| 100 | + const content = await response.text(); |
| 101 | + |
| 102 | + // Extract specific lines if specified |
| 103 | + if (parsed.startLine && parsed.endLine) { |
| 104 | + const lines = content.split('\n'); |
| 105 | + const selectedLines = lines.slice(parsed.startLine - 1, parsed.endLine); |
| 106 | + return selectedLines.join('\n'); |
| 107 | + } |
| 108 | + |
| 109 | + return content; |
| 110 | + } catch { |
| 111 | + return null; |
| 112 | + } |
| 113 | +} |
| 114 | + |
| 115 | +export function GitHubCodePreview({url}: GitHubCodePreviewProps) { |
| 116 | + const [code, setCode] = useState<string | null>(null); |
| 117 | + const [loading, setLoading] = useState(true); |
| 118 | + const [error, setError] = useState<string | null>(null); |
| 119 | + const [parsed, setParsed] = useState<ParsedGitHubUrl | null>(null); |
| 120 | + |
| 121 | + useEffect(() => { |
| 122 | + const loadCode = async () => { |
| 123 | + setLoading(true); |
| 124 | + setError(null); |
| 125 | + |
| 126 | + const parsedUrl = parseGitHubUrl(url); |
| 127 | + |
| 128 | + if (!parsedUrl) { |
| 129 | + setError('Invalid GitHub URL'); |
| 130 | + setLoading(false); |
| 131 | + return; |
| 132 | + } |
| 133 | + |
| 134 | + setParsed(parsedUrl); |
| 135 | + |
| 136 | + const content = await fetchGitHubContent(parsedUrl); |
| 137 | + |
| 138 | + if (content === null) { |
| 139 | + setError('Failed to fetch code from GitHub'); |
| 140 | + setLoading(false); |
| 141 | + return; |
| 142 | + } |
| 143 | + |
| 144 | + setCode(content); |
| 145 | + setLoading(false); |
| 146 | + }; |
| 147 | + |
| 148 | + loadCode(); |
| 149 | + }, [url]); |
| 150 | + |
| 151 | + if (loading) { |
| 152 | + return ( |
| 153 | + <div style={{padding: '1rem', color: 'var(--gray-500)'}}> |
| 154 | + Loading code from GitHub... |
| 155 | + </div> |
| 156 | + ); |
| 157 | + } |
| 158 | + |
| 159 | + if (error || !parsed || !code) { |
| 160 | + return ( |
| 161 | + <div style={{padding: '1rem', color: 'var(--red-500)'}}> |
| 162 | + {error || 'Failed to load code'} |
| 163 | + </div> |
| 164 | + ); |
| 165 | + } |
| 166 | + |
| 167 | + const language = getLanguageFromPath(parsed.path); |
| 168 | + const filename = parsed.path.split('/').pop() || parsed.path; |
| 169 | + const lineInfo = |
| 170 | + parsed.startLine && parsed.endLine |
| 171 | + ? `#L${parsed.startLine}${parsed.startLine !== parsed.endLine ? `-L${parsed.endLine}` : ''}` |
| 172 | + : ''; |
| 173 | + |
| 174 | + return ( |
| 175 | + <CodeTabs> |
| 176 | + <CodeBlock filename={filename + lineInfo} language={language} externalLink={url}> |
| 177 | + <pre className={`language-${language}`}> |
| 178 | + <code>{codeToJsx(code, language)}</code> |
| 179 | + </pre> |
| 180 | + </CodeBlock> |
| 181 | + </CodeTabs> |
| 182 | + ); |
| 183 | +} |
0 commit comments