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
-
+
);
}
- 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
-
+
);
}
- 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"] ? (
+

+ ) : data["image/jpeg"] ? (
+

+ ) : 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}
);