Skip to content

Commit a9ba62f

Browse files
feat: Add GitHub link unfurling component
Co-authored-by: neel.shah <[email protected]>
1 parent c58fd63 commit a9ba62f

File tree

7 files changed

+466
-284
lines changed

7 files changed

+466
-284
lines changed

app/api/github-preview/route.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import {NextRequest, NextResponse} from 'next/server';
2+
3+
interface GitHubApiResponse {
4+
content: string;
5+
encoding: string;
6+
name: string;
7+
path: string;
8+
sha: string;
9+
}
10+
11+
export async function GET(request: NextRequest) {
12+
const searchParams = request.nextUrl.searchParams;
13+
const url = searchParams.get('url');
14+
15+
if (!url) {
16+
return NextResponse.json({error: 'Missing URL parameter'}, {status: 400});
17+
}
18+
19+
// Parse GitHub URL
20+
// e.g., https://github.com/getsentry/sentry/blob/master/src/sentry/api/endpoints/organization_details.py#L123-L145
21+
const fileMatch = url.match(
22+
/https?:\/\/github\.com\/([\w-]+\/[\w-]+)\/blob\/([\w.-]+)\/(.*?)(?:#L(\d+)(?:-L(\d+))?)?$/
23+
);
24+
25+
if (!fileMatch) {
26+
return NextResponse.json({error: 'Invalid GitHub URL'}, {status: 400});
27+
}
28+
29+
const [, repo, ref, filePath, startLine, endLine] = fileMatch;
30+
31+
try {
32+
// Use GitHub API to fetch file content
33+
const apiUrl = `https://api.github.com/repos/${repo}/contents/${filePath}?ref=${ref}`;
34+
35+
const headers: Record<string, string> = {
36+
'Accept': 'application/vnd.github.v3+json',
37+
'User-Agent': 'Sentry-Docs',
38+
};
39+
40+
// Use GitHub token if available for higher rate limits
41+
if (process.env.GITHUB_TOKEN) {
42+
headers['Authorization'] = `Bearer ${process.env.GITHUB_TOKEN}`;
43+
}
44+
45+
const response = await fetch(apiUrl, {
46+
headers,
47+
next: {revalidate: 3600}, // Cache for 1 hour
48+
});
49+
50+
if (!response.ok) {
51+
if (response.status === 404) {
52+
return NextResponse.json({error: 'File not found'}, {status: 404});
53+
}
54+
if (response.status === 403) {
55+
return NextResponse.json(
56+
{error: 'GitHub API rate limit exceeded'},
57+
{status: 429}
58+
);
59+
}
60+
throw new Error(`GitHub API error: ${response.statusText}`);
61+
}
62+
63+
const data: GitHubApiResponse = await response.json();
64+
65+
// Decode base64 content
66+
const content = Buffer.from(data.content, 'base64').toString('utf-8');
67+
68+
// If line numbers are specified, extract only those lines
69+
let displayContent = content;
70+
if (startLine) {
71+
const lines = content.split('\n');
72+
const start = parseInt(startLine, 10) - 1;
73+
const end = endLine ? parseInt(endLine, 10) : parseInt(startLine, 10);
74+
displayContent = lines.slice(start, end).join('\n');
75+
}
76+
77+
// Detect language from file extension
78+
const language = getLanguageFromPath(filePath);
79+
80+
return NextResponse.json(
81+
{
82+
content: displayContent,
83+
language,
84+
path: data.path,
85+
name: data.name,
86+
},
87+
{
88+
headers: {
89+
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
90+
},
91+
}
92+
);
93+
} catch (error) {
94+
console.error('Error fetching GitHub file:', error);
95+
return NextResponse.json(
96+
{error: 'Failed to fetch file content'},
97+
{status: 500}
98+
);
99+
}
100+
}
101+
102+
function getLanguageFromPath(path: string): string {
103+
const ext = path.split('.').pop()?.toLowerCase();
104+
const languageMap: Record<string, string> = {
105+
js: 'javascript',
106+
jsx: 'jsx',
107+
ts: 'typescript',
108+
tsx: 'tsx',
109+
py: 'python',
110+
rb: 'ruby',
111+
java: 'java',
112+
cpp: 'cpp',
113+
c: 'c',
114+
cs: 'csharp',
115+
go: 'go',
116+
rs: 'rust',
117+
php: 'php',
118+
swift: 'swift',
119+
kt: 'kotlin',
120+
scala: 'scala',
121+
sh: 'bash',
122+
yml: 'yaml',
123+
yaml: 'yaml',
124+
json: 'json',
125+
md: 'markdown',
126+
html: 'html',
127+
css: 'css',
128+
scss: 'scss',
129+
sass: 'sass',
130+
};
131+
132+
return languageMap[ext || ''] || 'text';
133+
}

0 commit comments

Comments
 (0)