Skip to content

Commit dcc99f4

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

File tree

6 files changed

+495
-0
lines changed

6 files changed

+495
-0
lines changed

CONTRIBUTING.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,31 @@ yarn dev:developer-docs
2020
```
2121

2222
With that, the repo is fully set up and you are ready to open local docs under http://localhost:3000
23+
24+
## GitHub Link Unfurling
25+
26+
You can display rich previews of GitHub code files directly in documentation pages using the `<GitHubLinkPreview>` component.
27+
28+
### Usage
29+
30+
```mdx
31+
<GitHubLinkPreview url="https://github.com/getsentry/sentry/blob/master/src/sentry/api/endpoints/organization_details.py" />
32+
```
33+
34+
### Features
35+
36+
- Displays file content with syntax highlighting
37+
- Supports line number ranges (#L100-L150)
38+
- Automatically caches content for performance
39+
- Adapts to light and dark themes
40+
- Falls back to regular links if preview fails
41+
42+
### Configuration
43+
44+
For higher GitHub API rate limits, set the `GITHUB_TOKEN` environment variable:
45+
46+
```bash
47+
GITHUB_TOKEN=your_github_personal_access_token
48+
```
49+
50+
See [docs/contributing/github-link-unfurling.mdx](docs/contributing/github-link-unfurling.mdx) for full documentation.

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+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
title: GitHub Link Unfurling
3+
sidebar_order: 999
4+
description: Learn how to use GitHub link unfurling in documentation pages
5+
draft: true
6+
---
7+
8+
# GitHub Link Unfurling
9+
10+
You can display rich previews of GitHub code files directly in documentation pages using the `GitHubLinkPreview` component.
11+
12+
## Usage
13+
14+
To unfurl a GitHub link, use the `<GitHubLinkPreview>` component with the GitHub URL:
15+
16+
```mdx
17+
<GitHubLinkPreview url="https://github.com/getsentry/sentry/blob/master/src/sentry/api/endpoints/organization_details.py" />
18+
```
19+
20+
## Examples
21+
22+
### Basic File Preview
23+
24+
Display a preview of an entire file:
25+
26+
<GitHubLinkPreview url="https://github.com/getsentry/sentry/blob/master/.editorconfig" />
27+
28+
### With Line Numbers
29+
30+
Display specific lines from a file:
31+
32+
<GitHubLinkPreview url="https://github.com/getsentry/sentry/blob/master/src/sentry/conf/server.py#L100-L150" />
33+
34+
### Single Line
35+
36+
Display a single line:
37+
38+
<GitHubLinkPreview url="https://github.com/getsentry/sentry/blob/master/pyproject.toml#L1" />
39+
40+
## Features
41+
42+
- **Syntax highlighting**: Code is displayed with proper syntax highlighting
43+
- **Line number support**: Show specific lines using `#L100` or ranges with `#L100-L150`
44+
- **Caching**: Content is cached for better performance
45+
- **Dark mode**: Automatically adapts to light and dark themes
46+
- **Fallback**: Falls back to a regular link if the preview fails to load
47+
48+
## Configuration
49+
50+
### GitHub Token (Optional)
51+
52+
For higher API rate limits, you can configure a GitHub token in your environment:
53+
54+
```bash
55+
GITHUB_TOKEN=your_github_personal_access_token
56+
```
57+
58+
Without a token, the API is limited to 60 requests per hour. With a token, the limit increases to 5,000 requests per hour.
59+
60+
## Supported URLs
61+
62+
The component supports GitHub file URLs in the following format:
63+
64+
```
65+
https://github.com/{owner}/{repo}/blob/{branch}/{path}
66+
https://github.com/{owner}/{repo}/blob/{branch}/{path}#L{line}
67+
https://github.com/{owner}/{repo}/blob/{branch}/{path}#L{start}-L{end}
68+
```
69+
70+
Examples:
71+
- `https://github.com/getsentry/sentry/blob/master/README.md`
72+
- `https://github.com/getsentry/sentry/blob/master/src/sentry/__init__.py#L10`
73+
- `https://github.com/getsentry/sentry/blob/master/src/sentry/__init__.py#L10-L20`
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
.preview {
2+
border: 1px solid var(--border-color, #d1d5da);
3+
border-radius: 6px;
4+
margin: 1rem 0;
5+
overflow: hidden;
6+
background: var(--background, #fff);
7+
font-size: 0.9rem;
8+
}
9+
10+
.header {
11+
display: flex;
12+
align-items: center;
13+
padding: 0.75rem 1rem;
14+
background: var(--header-background, #f6f8fa);
15+
border-bottom: 1px solid var(--border-color, #d1d5da);
16+
gap: 0.5rem;
17+
}
18+
19+
.icon {
20+
fill: var(--text-muted, #586069);
21+
flex-shrink: 0;
22+
}
23+
24+
.repository {
25+
font-weight: 600;
26+
color: var(--text-primary, #24292e);
27+
}
28+
29+
.separator {
30+
color: var(--text-muted, #586069);
31+
}
32+
33+
.path {
34+
color: var(--text-secondary, #586069);
35+
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
36+
'Liberation Mono', monospace;
37+
font-size: 0.85rem;
38+
}
39+
40+
.loading {
41+
color: var(--text-muted, #586069);
42+
font-style: italic;
43+
}
44+
45+
.content {
46+
padding: 1rem;
47+
overflow-x: auto;
48+
background: var(--code-background, #f6f8fa);
49+
max-height: 400px;
50+
overflow-y: auto;
51+
52+
pre {
53+
margin: 0;
54+
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
55+
'Liberation Mono', monospace;
56+
font-size: 0.85rem;
57+
line-height: 1.5;
58+
}
59+
60+
code {
61+
color: var(--text-primary, #24292e);
62+
background: transparent;
63+
padding: 0;
64+
}
65+
}
66+
67+
.footer {
68+
padding: 0.5rem 1rem;
69+
background: var(--header-background, #f6f8fa);
70+
border-top: 1px solid var(--border-color, #d1d5da);
71+
}
72+
73+
.link {
74+
display: inline-flex;
75+
align-items: center;
76+
gap: 0.25rem;
77+
color: var(--link-color, #0969da);
78+
text-decoration: none;
79+
font-size: 0.85rem;
80+
81+
&:hover {
82+
text-decoration: underline;
83+
}
84+
}
85+
86+
.externalIcon {
87+
fill: currentColor;
88+
}
89+
90+
// Dark mode support
91+
@media (prefers-color-scheme: dark) {
92+
.preview {
93+
--background: #0d1117;
94+
--header-background: #161b22;
95+
--border-color: #30363d;
96+
--text-primary: #c9d1d9;
97+
--text-secondary: #8b949e;
98+
--text-muted: #8b949e;
99+
--code-background: #161b22;
100+
--link-color: #58a6ff;
101+
}
102+
}

0 commit comments

Comments
 (0)