web/app/%5Bowner%5D/%5Brepo%5D/blob/%5B...path%5D/page.tsxblame
View source
1da98741import type { Metadata } from "next";
3e3af552import Link from "next/link";
bf5fc333import { createHighlighter, type Highlighter } from "shiki";
bc1b2ba4import { formatSize, encodePath } from "@/lib/utils";
bc1b2ba5import { getRepoBlob } from "@/lib/grove-api";
bf5fc336
bf5fc337let highlighter: Highlighter | null = null;
bf5fc338
bf5fc339async function getHighlighter() {
bf5fc3310 if (!highlighter) {
bf5fc3311 highlighter = await createHighlighter({
bf5fc3312 themes: ["vitesse-light", "vitesse-dark"],
bf5fc3313 langs: [
bf5fc3314 "typescript", "tsx", "javascript", "jsx", "json", "markdown",
bf5fc3315 "css", "html", "python", "rust", "go", "ruby", "yaml", "toml",
bf5fc3316 "bash", "sql", "graphql", "xml", "c", "cpp", "java", "kotlin",
bf5fc3317 "swift", "lua", "diff", "dockerfile", "makefile", "ini",
bf5fc3318 ],
bf5fc3319 });
bf5fc3320 }
bf5fc3321 return highlighter;
bf5fc3322}
3e3af5523
3e3af5524interface Props {
3e3af5525 params: Promise<{ owner: string; repo: string; path: string[] }>;
3e3af5526}
3e3af5527
1da987428export async function generateMetadata({ params }: Props): Promise<Metadata> {
86450dc29 const { repo, path: pathParts } = await params;
1da987430 const filePath = pathParts.slice(1).join("/");
1da987431 const fileName = filePath.split("/").pop() ?? filePath;
86450dc32 return { title: `${fileName} · ${repo}` };
1da987433}
1da987434
3e3af5535
bf5fc3336function getLang(filename: string): string {
bf5fc3337 const ext = filename.split(".").pop()?.toLowerCase() ?? "";
bf5fc3338 const map: Record<string, string> = {
bf5fc3339 ts: "typescript",
bf5fc3340 tsx: "tsx",
bf5fc3341 js: "javascript",
bf5fc3342 jsx: "jsx",
bf5fc3343 json: "json",
bf5fc3344 md: "markdown",
bf5fc3345 css: "css",
bf5fc3346 html: "html",
bf5fc3347 py: "python",
bf5fc3348 rs: "rust",
bf5fc3349 go: "go",
bf5fc3350 rb: "ruby",
bf5fc3351 yml: "yaml",
bf5fc3352 yaml: "yaml",
bf5fc3353 toml: "toml",
bf5fc3354 sh: "bash",
bf5fc3355 bash: "bash",
bf5fc3356 zsh: "bash",
bf5fc3357 sql: "sql",
bf5fc3358 graphql: "graphql",
bf5fc3359 svg: "xml",
bf5fc3360 xml: "xml",
bf5fc3361 c: "c",
bf5fc3362 cpp: "cpp",
bf5fc3363 h: "c",
bf5fc3364 hpp: "cpp",
bf5fc3365 java: "java",
bf5fc3366 kt: "kotlin",
bf5fc3367 swift: "swift",
bf5fc3368 lua: "lua",
bf5fc3369 diff: "diff",
bf5fc3370 };
bf5fc3371 const nameMap: Record<string, string> = {
bf5fc3372 Dockerfile: "dockerfile",
bf5fc3373 Makefile: "makefile",
bf5fc3374 ".gitignore": "gitignore",
bf5fc3375 ".gitmodules": "ini",
bf5fc3376 };
818dc9077 if (filename.startsWith("Dockerfile")) return "dockerfile";
818dc9078 if (filename.startsWith("Makefile")) return "makefile";
bf5fc3379 return nameMap[filename] ?? map[ext] ?? "text";
3e3af5580}
3e3af5581
12ffdd482export default async function BlobPage({ params }: Props) {
3e3af5583 const { owner, repo, path: pathParts } = await params;
12ffdd484 const ref = pathParts[0] ?? "main";
12ffdd485 const path = pathParts.slice(1).join("/");
3e3af5586 const filename = pathParts[pathParts.length - 1];
3e3af5587
bc1b2ba88 const blob = await getRepoBlob(owner, repo, ref, path);
3e3af5589
3e3af5590 if (!blob) {
3e3af5591 return (
135dfe592 <div className="max-w-3xl mx-auto px-4 py-16">
cf89d3c93 <h1 className="text-lg" style={{ color: "var(--text-secondary)" }}>
135dfe594 File not found
135dfe595 </h1>
3e3af5596 </div>
3e3af5597 );
3e3af5598 }
3e3af5599
3e3af55100 const lines = blob.content.split("\n");
bf5fc33101 const lang = getLang(filename);
bf5fc33102
bf5fc33103 let highlighted: string | null = null;
bf5fc33104 try {
bf5fc33105 const hl = await getHighlighter();
bf5fc33106 const loadedLangs = hl.getLoadedLanguages();
bf5fc33107 const effectiveLang = loadedLangs.includes(lang) ? lang : "text";
bf5fc33108 highlighted = hl.codeToHtml(blob.content, {
bf5fc33109 lang: effectiveLang,
bf5fc33110 themes: {
bf5fc33111 light: "vitesse-light",
bf5fc33112 dark: "vitesse-dark",
bf5fc33113 },
bf5fc33114 defaultColor: false,
bf5fc33115 });
bf5fc33116 } catch (e) {
bf5fc33117 console.error(`[shiki] Failed to highlight ${filename} as ${lang}:`, e);
bf5fc33118 }
bf5fc33119
bf5fc33120 let htmlLines: string[] | null = null;
bf5fc33121 if (highlighted) {
bf5fc33122 const codeMatch = highlighted.match(/<code[^>]*>([\s\S]*)<\/code>/);
bf5fc33123 if (codeMatch) {
bf5fc33124 htmlLines = codeMatch[1].split("\n");
bf5fc33125 }
bf5fc33126 }
3e3af55127
3e3af55128 return (
4dfd09b129 <div className="px-4 py-6">
bf5fc33130 <div
bf5fc33131 style={{
bf5fc33132 border: "1px solid var(--border-subtle)",
bf5fc33133 }}
bf5fc33134 >
bf5fc33135 <div
bf5fc33136 className="flex items-center justify-between px-3 py-2 text-xs"
bf5fc33137 style={{
bf5fc33138 color: "var(--text-muted)",
bf5fc33139 backgroundColor: "var(--bg-inset)",
bf5fc33140 borderBottom: "1px solid var(--border-subtle)",
bf5fc33141 }}
135dfe5142 >
bf5fc33143 <div className="flex items-center gap-4">
bf5fc33144 <span>{formatSize(blob.size)}</span>
bf5fc33145 <span>{lines.length} lines</span>
bf5fc33146 </div>
bf5fc33147 <div className="flex items-center gap-3">
bf5fc33148 <Link
d744b82149 href={`/${owner}/${repo}/blame/${ref}/${encodePath(path)}`}
bf5fc33150 style={{ color: "var(--accent)" }}
bf5fc33151 className="hover:underline"
bf5fc33152 >
bf5fc33153 Blame
bf5fc33154 </Link>
bf5fc33155 </div>
bf5fc33156 </div>
135dfe5157
bf5fc33158 <div className="overflow-x-auto shiki">
bf5fc33159 <table className="w-full text-sm font-mono">
bf5fc33160 <tbody>
bf5fc33161 {htmlLines
bf5fc33162 ? htmlLines.map((html, i) => (
bf5fc33163 <tr key={i}>
bf5fc33164 <td
bf5fc33165 className="text-right select-none pr-4 pl-3 py-0 w-10"
bf5fc33166 style={{
bf5fc33167 color: "var(--text-faint)",
bf5fc33168 borderRight: "1px solid var(--divide)",
bf5fc33169 }}
bf5fc33170 >
bf5fc33171 {i + 1}
bf5fc33172 </td>
bf5fc33173 <td
bf5fc33174 className="pl-4 py-0 whitespace-pre"
bf5fc33175 dangerouslySetInnerHTML={{ __html: html || "&nbsp;" }}
bf5fc33176 />
bf5fc33177 </tr>
bf5fc33178 ))
bf5fc33179 : lines.map((line: string, i: number) => (
bf5fc33180 <tr key={i}>
bf5fc33181 <td
bf5fc33182 className="text-right select-none pr-4 pl-3 py-0 w-10"
bf5fc33183 style={{
bf5fc33184 color: "var(--text-faint)",
bf5fc33185 borderRight: "1px solid var(--divide)",
bf5fc33186 }}
bf5fc33187 >
bf5fc33188 {i + 1}
bf5fc33189 </td>
bf5fc33190 <td
bf5fc33191 className="pl-4 py-0 whitespace-pre"
bf5fc33192 style={{ color: "var(--text-secondary)" }}
bf5fc33193 >
bf5fc33194 {line}
bf5fc33195 </td>
bf5fc33196 </tr>
bf5fc33197 ))}
bf5fc33198 </tbody>
bf5fc33199 </table>
bf5fc33200 </div>
3e3af55201 </div>
3e3af55202 </div>
3e3af55203 );
3e3af55204}