/**
* 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 (
{cell.outputs.map((out, i) =>
out.type === "image" ? (
) : out.format === "html" ? (
// out.data is trusted: it comes from a notebook .py file
// executed via jupytext at build time, then committed to
// fern/components/notebooks/*.ts (review boundary). See the
// SECURITY / TRUST MODEL section in this file's header.
) : (
)
)}