diff --git a/apps/web/package.json b/apps/web/package.json index f7cfc106..4c8e7798 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -54,6 +54,7 @@ "e2b": "^2.12.1", "foxact": "^0.2.52", "inngest": "^3.52.3", + "katex": "^0.16.33", "lucide-react": "^0.575.0", "motion": "^12.34.3", "next": "16.1.6", @@ -64,10 +65,12 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-markdown": "^10.1.0", + "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "shiki": "^3.22.0", diff --git a/apps/web/src/app/(app)/repos/[owner]/[repo]/blob/[...path]/page.tsx b/apps/web/src/app/(app)/repos/[owner]/[repo]/blob/[...path]/page.tsx index 7f89432b..c64b8d7f 100644 --- a/apps/web/src/app/(app)/repos/[owner]/[repo]/blob/[...path]/page.tsx +++ b/apps/web/src/app/(app)/repos/[owner]/[repo]/blob/[...path]/page.tsx @@ -8,11 +8,13 @@ import { } from "@/lib/github"; import { parseRefAndPath, formatBytes, getLanguageFromFilename } from "@/lib/github-utils"; import { CodeViewer } from "@/components/repo/code-viewer"; +import { NotebookViewer } from "@/components/repo/notebook-viewer"; import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; import { MarkdownBlobView } from "@/components/repo/markdown-blob-view"; import { File, Download } from "lucide-react"; const MARKDOWN_EXTENSIONS = new Set(["md", "mdx", "markdown", "mdown", "mkd"]); +const NOTEBOOK_EXTENSIONS = new Set(["ipynb"]); const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "bmp"]); @@ -127,6 +129,7 @@ export default async function BlobPage({ } const isMarkdown = MARKDOWN_EXTENSIONS.has(ext); + const isNotebook = NOTEBOOK_EXTENSIONS.has(ext); const fileDir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""; if (isMarkdown) { @@ -173,6 +176,14 @@ export default async function BlobPage({ ); } + if (isNotebook) { + return ( +
+ +
+ ); + } + return ( +
+
{showTree && ( <> {/* Collapsed toggle */} @@ -312,7 +312,7 @@ export function CodeContentWrapper({ )} )} -
+
{isBlobOrTree && (
; -} +"use client"; + +import { useMemo } from "react"; +import "katex/dist/katex.min.css"; +import { ClientMarkdown } from "@/components/shared/client-markdown"; +import { HighlightedCodeBlock } from "@/components/shared/highlighted-code-block"; +import { cn } from "@/lib/utils"; -interface NotebookOutput { +// Standard Jupyter Notebook JSON structure +interface Output { output_type: string; - text?: string[] | string; - data?: Record; + text?: string | string[]; + data?: Record; + execution_count?: number | null; ename?: string; evalue?: string; traceback?: string[]; - name?: string; +} + +interface Cell { + cell_type: "markdown" | "code" | "raw"; + execution_count?: number | null; + source: string | string[]; + outputs?: Output[]; } interface Notebook { - cells: NotebookCell[]; + cells: Cell[]; metadata?: { - kernelspec?: { language?: string; display_name?: string }; - language_info?: { name?: string }; + language_info?: { name: string }; }; } -function joinSource(source: string[] | string): string { - return Array.isArray(source) ? source.join("") : source; +/** + * Jupyter MathJax is very forgiving, but standard remark-math is strict. + * This preprocessor cleans up common Jupyter math quirks before rendering. + */ +function preprocessJupyterMarkdown(source: string) { + let text = source; + + // 1. Upgrade inline math ($...$) containing \tag{} to block math ($$...$$). + // KaTeX strictly forbids \tag{} in inline mode and will crash/fail to style it. + text = text.replace(/(^|[^$])\$([^$]+?\\tag\{[^}]+\}[^$]*?)\$([^$]|$)/g, "$1$$$$$2$$$$$3"); + + // 2. Wrap naked LaTeX environments (like \begin{equation}...\end{equation}) in $$...$$ + // remark-math completely ignores these otherwise. + text = text.replace( + /(^|\n)(\\begin\{[a-zA-Z*]+\}[\s\S]*?\\end\{[a-zA-Z*]+\})(\n|$)/g, + "$1$$$$\n$2\n$$$$$3", + ); + + return text; } -function getOutputText(output: NotebookOutput): string { - if (output.text) return joinSource(output.text); - if (output.data) { - if (output.data["text/plain"]) return joinSource(output.data["text/plain"]); +export function NotebookViewer({ content }: { content: string }) { + const notebook = useMemo(() => { + try { + return JSON.parse(content); + } catch (e) { + console.error("Failed to parse Jupyter Notebook", e); + return null; + } + }, [content]); + + if (!notebook) { + return ( +
+ Failed to parse Jupyter Notebook. The file might be corrupted. +
+ ); } - if (output.traceback) return output.traceback.join("\n"); - if (output.ename) return `${output.ename}: ${output.evalue || ""}`; - return ""; -} -function getOutputHtml(output: NotebookOutput): string | null { - if (output.data?.["text/html"]) return joinSource(output.data["text/html"]); - return null; + const language = notebook.metadata?.language_info?.name || "python"; + + return ( +
to be transparent and NOT establish a scroll context + "[&_.notebook-code_pre.shiki]:!p-0 [&_.notebook-code_pre.shiki]:!m-0 [&_.notebook-code_pre.shiki]:!bg-transparent [&_.notebook-code_pre.shiki]:!overflow-visible", + // 2. Syntax Highlighting map + "[&_.notebook-code_.shiki_span]:!text-[var(--shiki-light)] dark:[&_.notebook-code_.shiki_span]:!text-[var(--shiki-dark)]", + )} + > + {notebook.cells.map((cell, idx) => ( + + ))} +
+ ); } -function getOutputImage(output: NotebookOutput): { src: string; mime: string } | null { - const data = output.data; - if (!data) return null; - for (const mime of ["image/png", "image/jpeg", "image/gif", "image/svg+xml"]) { - if (data[mime]) { - const raw = joinSource(data[mime]); - if (mime === "image/svg+xml") { - return { - src: `data:${mime};utf8,${encodeURIComponent(raw)}`, - mime, - }; - } - return { src: `data:${mime};base64,${raw.trim()}`, mime }; - } +function NotebookCell({ cell, language }: { cell: Cell; language: string }) { + const rawSource = Array.isArray(cell.source) ? cell.source.join("") : cell.source; + + if (cell.cell_type === "markdown") { + const processedSource = preprocessJupyterMarkdown(rawSource); + return ( +
+
+ {/* overflow-x-auto placed on the WRAPPER instead of the math element so tags don't break */} +
+ +
+
+ ); } - return null; -} -// Strip ANSI escape codes from terminal output -function stripAnsi(str: string): string { - // eslint-disable-next-line no-control-regex - return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); -} + if (cell.cell_type === "code") { + return ( +
+
+
+ {cell.execution_count + ? `In [${cell.execution_count}]:` + : "In [ ]:"} +
+
+ {/* Strictly clamp down on scrollbars here */} +
+ +
+
+
+ + {cell.outputs && cell.outputs.length > 0 && ( +
+ {cell.outputs.map((output, idx) => ( + + ))} +
+ )} +
+ ); + } -export interface RenderedCell { - type: "code" | "markdown" | "raw"; - executionCount: number | null; - sourceHtml: string; - source: string; - outputs: RenderedOutput[]; - markdownHtml?: string; + return null; } -export interface RenderedOutput { - type: "text" | "html" | "image" | "error"; - content: string; - mime?: string; -} +function NotebookOutput({ output }: { output: Output }) { + const isStream = output.output_type === "stream"; + const isError = output.output_type === "error"; + const isData = + output.output_type === "display_data" || output.output_type === "execute_result"; -export async function NotebookViewer({ - content, - repoContext, -}: { - content: string; - repoContext?: { owner: string; repo: string; branch: string; dir: string }; -}) { - let notebook: Notebook; - try { - notebook = JSON.parse(content); - } catch { + if (isStream) { + const text = Array.isArray(output.text) ? output.text.join("") : output.text; return ( -
-

- Invalid notebook format -

+
+
+
+
+						{text}
+					
+
); } - if (!notebook.cells || !Array.isArray(notebook.cells)) { + if (isError) { + const traceback = + output.traceback?.join("\n") || `${output.ename}: ${output.evalue}`; + const cleanTraceback = traceback.replace(/\u001B\[[0-9;]*[a-zA-Z]/g, ""); return ( -
-

- No cells found in notebook -

+
+
+
+
+						{cleanTraceback}
+					
+
); } - const kernelLang = - notebook.metadata?.language_info?.name || - notebook.metadata?.kernelspec?.language || - "python"; - - const renderedCells: RenderedCell[] = await Promise.all( - notebook.cells.map(async (cell) => { - const source = joinSource(cell.source); - - if (cell.cell_type === "markdown") { - const mdHtml = await renderMarkdownToHtml(source, repoContext); - return { - type: "markdown" as const, - executionCount: null, - sourceHtml: "", - source, - outputs: [], - markdownHtml: mdHtml, - }; - } - - // Code or raw cell — highlight source - const lang = cell.cell_type === "code" ? kernelLang : "text"; - const sourceHtml = source.trim() ? await highlightCode(source, lang) : ""; - - // Process outputs - const outputs: RenderedOutput[] = []; - for (const output of cell.outputs || []) { - const img = getOutputImage(output); - if (img) { - outputs.push({ - type: "image", - content: img.src, - mime: img.mime, - }); - continue; - } - - const html = getOutputHtml(output); - if (html) { - outputs.push({ type: "html", content: html }); - continue; - } - - if (output.output_type === "error" && output.traceback) { - outputs.push({ - type: "error", - content: stripAnsi(output.traceback.join("\n")), - }); - continue; - } - - const text = getOutputText(output); - if (text) { - outputs.push({ type: "text", content: stripAnsi(text) }); - } - } - - return { - type: cell.cell_type as "code" | "raw", - executionCount: cell.execution_count ?? null, - sourceHtml, - source, - outputs, - }; - }), - ); + if (isData && output.data) { + const data = output.data; + const prompt = output.execution_count ? `Out[${output.execution_count}]:` : ""; - const kernelName = notebook.metadata?.kernelspec?.display_name || kernelLang; + return ( +
+
+ {prompt} +
+
+ {data["image/png"] ? ( + Cell output + ) : data["image/jpeg"] ? ( + Cell output + ) : data["text/html"] ? ( +
+ ) : data["text/plain"] ? ( +
+							{Array.isArray(data["text/plain"])
+								? data["text/plain"].join("")
+								: data["text/plain"]}
+						
+ ) : null} +
+
+ ); + } - return ( - - ); + return null; } diff --git a/apps/web/src/components/repo/repo-layout-wrapper.tsx b/apps/web/src/components/repo/repo-layout-wrapper.tsx index d8422380..f6f68388 100644 --- a/apps/web/src/components/repo/repo-layout-wrapper.tsx +++ b/apps/web/src/components/repo/repo-layout-wrapper.tsx @@ -225,7 +225,7 @@ export function RepoLayoutWrapper({ {/* Main content */}
(null); useEffect(() => { let cancelled = false; - highlightCodeClient(code, lang, themeId).then((result) => { - if (!cancelled) setHtml(result); - }); + (async () => { + try { + const highlighter = await getClientHighlighter(); + const loaded = highlighter.getLoadedLanguages(); + let effectiveLang = lang; + if (!loaded.includes(lang)) { + try { + await highlighter.loadLanguage( + lang as BundledLanguage, + ); + } catch { + effectiveLang = "text"; + if (!loaded.includes("text")) { + try { + await highlighter.loadLanguage( + "text" as BundledLanguage, + ); + } catch {} + } + } + } + if (!cancelled) { + const result = highlighter.codeToHtml(code, { + lang: effectiveLang, + themes: { + light: "vitesse-light", + dark: "vitesse-black", + }, + defaultColor: inlineColors ? "light-dark()" : false, + }); + setHtml(result); + } + } catch { + // silently fall back to plain text + } + })(); return () => { cancelled = true; }; - }, [code, lang, themeId]); + }, [code, lang, inlineColors]); if (html) { - const parser = typeof window !== "undefined" ? new window.DOMParser() : null; - let codeLines: string[] = []; - if (parser) { - const doc = parser.parseFromString(html, "text/html"); - const shiki = doc.querySelector(".shiki"); - if (shiki) { - // Try to get .line spans - const lineSpans = shiki.querySelectorAll(".line"); - if (lineSpans.length > 0) { - codeLines = Array.from(lineSpans).map( - (line) => line.innerHTML, - ); - } else { - // Fallback: split by
or by lines - codeLines = shiki.innerHTML.split( - /(?![^<]*<\/span>)/i, - ); - } - } else { - // Fallback: split by \n - codeLines = html.split(/\n/); - } - } else { - codeLines = html.split(/\n/); - } - - return ( -
-
- {codeLines.map((_, i) => ( -
- {i + 1} -
- ))} -
-
- {codeLines.map((line, i) => ( -
- ))} -
-
- ); + return
; } return ( -
+		
 			{code}
 		
);