/** * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ import type { ReactNode } from "react"; /** * NotebookViewer - Renders Jupyter notebook content in Fern docs. * * Uses Fern's code block structure (fern-code, fern-code-block, etc.) so input * and output cells match the default Fern code block styling. * * Accepts notebook cells (markdown + code) and optionally a Colab URL. * Designed to work with notebooks converted via `fern/scripts/ipynb-to-fern-json.py`. * * NOTE: Fern's custom component pipeline uses the automatic JSX runtime. * Only type-only imports from "react" are used (erased at compile time). * * SECURITY / TRUST MODEL: * -------- * Notebook output cells of `format: "html"` (typically pandas DataFrame `_repr_html_`, * matplotlib HTML, or similar) are rendered with `dangerouslySetInnerHTML` and * are NOT sanitized — see the renderer near the bottom of this file. * * The trust boundary is the converter pipeline: * * docs/notebook_source/*.py (jupytext source — code-reviewed at PR time) * └─> jupytext --execute (runs in CI/maintainer shell with NVIDIA_API_KEY) * └─> *.ipynb (real outputs captured) * └─> fern/scripts/ipynb-to-fern-json.py * └─> fern/components/notebooks/*.{json,ts} (committed) * * Both ends of the pipeline (the .py source and the generated *.ts) are * code-reviewed before merge. Fern's bundle is then static. * * If a future tutorial ever embeds output that incorporates LLM-generated HTML * or arbitrary user-controlled content, switch the `format === "html"` branch * to a sanitizer (e.g. DOMPurify) before merging. * * Usage in MDX: * import { NotebookViewer } from "@/components/NotebookViewer"; * import notebook from "@/components/notebooks/1-the-basics"; * * */ export interface CellOutput { type: "text" | "image"; data: string; format?: "plain" | "html"; /** MIME for image outputs. Defaults to image/png; the converter sets * image/jpeg when it re-encodes large outputs to keep the .ts payload small. */ mime?: string; } export interface NotebookCell { type: "markdown" | "code"; source: string; /** Pre-rendered syntax-highlighted HTML (from Pygments). When present, used instead of escaped source. */ source_html?: string; language?: string; outputs?: CellOutput[]; } export interface NotebookData { cells: NotebookCell[]; } export interface NotebookViewerProps { /** Notebook data with cells array. If import fails, this may be undefined. */ notebook?: NotebookData | null; /** Optional Colab URL for "Run in Colab" badge */ colabUrl?: string; /** Show code cell outputs (default: true) */ showOutputs?: boolean; } function NotebookViewerError({ message, detail }: { message: string; detail?: string }) { return (
NotebookViewer error: {message} {detail && (
          {detail}
        
)}
); } function escapeHtml(text: string): string { if (typeof text !== "string") return ""; return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function handleCopy(content: string, button: HTMLButtonElement) { navigator.clipboard.writeText(content).catch(() => {}); const originalHtml = button.innerHTML; const originalLabel = button.getAttribute("aria-label") ?? "Copy code"; button.innerHTML = "Copied!"; button.setAttribute("aria-label", "Copied to clipboard"); setTimeout(() => { button.innerHTML = originalHtml; button.setAttribute("aria-label", originalLabel); }, 1500); } const FLAG_ICON = ( ); const SCROLL_AREA_STYLE = `[data-radix-scroll-area-viewport]{scrollbar-width:thin;scrollbar-color:var(--grayscale-a7) transparent;-webkit-overflow-scrolling:touch;}[data-radix-scroll-area-viewport]::-webkit-scrollbar{height:8px;width:8px;}[data-radix-scroll-area-viewport]::-webkit-scrollbar-track{background:transparent;}[data-radix-scroll-area-viewport]::-webkit-scrollbar-thumb{background:var(--grayscale-a7);border-radius:9999px;}`; const BUTTON_BASE_CLASS = "focus-visible:ring-(color:--accent) rounded-2 inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-colors hover:transition-none focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-(color:--grayscale-a11) hover:bg-(color:--accent-a3) hover:text-(color:--accent-11) pointer-coarse:size-9 size-7"; /** Fern code block structure – matches Fern docs (header with language + buttons, pre with scroll area). */ function FernCodeBlock({ title, children, className = "", asPre = true, copyContent, showLineNumbers = false, codeHtml, }: { title: string; children: ReactNode; className?: string; /** Use div instead of pre for content (needed when children include block elements like img/div). */ asPre?: boolean; /** Raw text to copy when copy button is clicked. When provided, shows a copy button. */ copyContent?: string; /** Show line numbers in a table layout (matches Fern's code block structure). */ showLineNumbers?: boolean; /** Pre-rendered HTML for each line when showLineNumbers is true. Lines are split by newline. */ codeHtml?: string; }) { const headerLabel = title === "Output" ? "Output" : title.charAt(0).toUpperCase() + title.slice(1); const wrapperClasses = "fern-code fern-code-block bg-card-background border-card-border rounded-3 shadow-card-grayscale relative mb-6 mt-4 flex w-full min-w-0 max-w-full flex-col border first:mt-0"; const preStyle = { backgroundColor: "rgb(255, 255, 255)", ["--shiki-dark-bg" as string]: "#212121", color: "rgb(36, 41, 46)", ["--shiki-dark" as string]: "#EEFFFF", }; const scrollAreaContent = () => { if (codeHtml == null) return null; const lines = codeHtml.split("\n"); return (