diff --git a/package.json b/package.json index 8bfd17b8..7d8fd4eb 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "docker:sync": "wrangler dev --ip 0.0.0.0 --port 8787", "dev:runtime": "./scripts/dev-runtime.sh", "build:iframe": "cd iframe-outputs && vite build", + "prebuild": "pnpm --filter @runtimed/components build", "build": "vite build --mode development", "bump-schema": "./scripts/bump-schema-version.sh", "test-publish": "./scripts/test-publish.sh", diff --git a/packages/components/package.json b/packages/components/package.json index 49363218..16a8648e 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@runtimed/components", - "version": "0.3.0-beta.1", + "version": "0.3.0", "description": "React components for rendering notebook cell outputs", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/packages/components/src/ExecutionCount.tsx b/packages/components/src/ExecutionCount.tsx new file mode 100644 index 00000000..21f3294a --- /dev/null +++ b/packages/components/src/ExecutionCount.tsx @@ -0,0 +1,23 @@ +import { cn } from "./utils/cn"; + +interface ExecutionCountProps { + count: number | null; + isExecuting?: boolean; + className?: string; +} + +export function ExecutionCount({ + count, + isExecuting, + className, +}: ExecutionCountProps) { + const display = isExecuting ? "*" : (count ?? " "); + return ( + + [{display}]: + + ); +} diff --git a/packages/components/src/OutputTypesDemoPage.tsx b/packages/components/src/OutputTypesDemoPage.tsx index de6e4263..0062aaf3 100644 --- a/packages/components/src/OutputTypesDemoPage.tsx +++ b/packages/components/src/OutputTypesDemoPage.tsx @@ -27,9 +27,11 @@ const createOutput = ( } as OutputData; }; -export const OutputTypesDemoPage: React.FC<{ iframeUri: string }> = ({ +export const OutputTypesDemoPage: React.FC<{ iframeUri?: string }> = ({ iframeUri, }) => { + const finalIframeUriPrefix = iframeUri ?? "."; + // Terminal outputs const stdoutOutput: OutputData = createOutput("terminal-stdout", "terminal", { streamName: "stdout", @@ -313,10 +315,10 @@ def hello(): {section.type === "html" || section.type === "svg" ? (
- {iframeUri}/react.html
+ {finalIframeUriPrefix}/react.html
= ({
+ children,
+ language = "",
+ enableCopy = true,
+ customStyle,
+ className,
+ showLineNumbers = false,
+}) => {
+ const [copied, setCopied] = React.useState(false);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(children);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error("Failed to copy code:", err);
+ }
+ };
+
+ return (
+
+
+ {children}
+
+ {enableCopy && (
+
+ )}
+
+ );
+};
+
+export default SyntaxHighlighter;
diff --git a/packages/notebook-preview/demo.html b/packages/notebook-preview/demo.html
new file mode 100644
index 00000000..5971d56a
--- /dev/null
+++ b/packages/notebook-preview/demo.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Output Types Demo
+
+
+
+
+
+
diff --git a/packages/notebook-preview/index.html b/packages/notebook-preview/index.html
new file mode 100644
index 00000000..6135342e
--- /dev/null
+++ b/packages/notebook-preview/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Notebook Preview
+
+
+
+
+
+
diff --git a/packages/notebook-preview/package.json b/packages/notebook-preview/package.json
new file mode 100644
index 00000000..d59b0be3
--- /dev/null
+++ b/packages/notebook-preview/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "@runtimed/notebook-preview",
+ "version": "0.3.0-beta.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite build --watch --mode development",
+ "prebuild": "pnpm --filter @runtimed/components build",
+ "build": "vite build",
+ "preview": "vite --port 5175",
+ "type-check": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@runtimed/components": "workspace:*",
+ "react": "19.2.1",
+ "react-dom": "19.2.1"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.1.10",
+ "@types/react": "^19.1.8",
+ "@types/react-dom": "^19.1.6",
+ "@vitejs/plugin-react": "^4.5.2",
+ "tailwindcss": "^4.1.10",
+ "typescript": "^5.8.3",
+ "vite": "^6.3.5"
+ },
+ "engines": {
+ "node": ">=23.0.0",
+ "pnpm": ">=10.9.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/notebook-preview/public/health.html b/packages/notebook-preview/public/health.html
new file mode 100644
index 00000000..5a495500
--- /dev/null
+++ b/packages/notebook-preview/public/health.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+ Health Check
+
+
+
+ Health Check
+
+ Put this page in an iframe and send a message to the iframe to check
+ health.
+
+ iframe.contentWindow?.postMessage({ healthy: true }, "*");
+ Messages received:
+ No message received
+
+
+
diff --git a/packages/notebook-preview/react.html b/packages/notebook-preview/react.html
new file mode 100644
index 00000000..a5df9d5f
--- /dev/null
+++ b/packages/notebook-preview/react.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ React IFrame Content
+
+
+
+
+
+
diff --git a/packages/notebook-preview/src/App.tsx b/packages/notebook-preview/src/App.tsx
new file mode 100644
index 00000000..a4c45d72
--- /dev/null
+++ b/packages/notebook-preview/src/App.tsx
@@ -0,0 +1,39 @@
+import { useState, useEffect } from "react";
+import { NotebookRenderer, type JupyterNotebook } from "./NotebookRenderer";
+
+export function App() {
+ const [notebookData, setNotebookData] = useState(
+ null
+ );
+
+ useEffect(() => {
+ // Notify parent that iframe is ready
+ window.parent.postMessage({ type: "iframe-loaded" }, "*");
+
+ // Accept messages from any origin
+ const handleMessage = (event: MessageEvent) => {
+ if (event.data?.json !== undefined) {
+ const json =
+ typeof event.data.json === "string"
+ ? JSON.parse(event.data.json)
+ : event.data.json;
+ setNotebookData(json as JupyterNotebook);
+ // Scroll to top when new notebook data arrives
+ window.scrollTo({ top: 0, behavior: "instant" });
+ }
+ };
+
+ window.addEventListener("message", handleMessage);
+ return () => window.removeEventListener("message", handleMessage);
+ }, []);
+
+ if (!notebookData) {
+ return (
+
+ Waiting for notebook data...
+
+ );
+ }
+
+ return ;
+}
diff --git a/packages/notebook-preview/src/NotebookRenderer.tsx b/packages/notebook-preview/src/NotebookRenderer.tsx
new file mode 100644
index 00000000..32e1d250
--- /dev/null
+++ b/packages/notebook-preview/src/NotebookRenderer.tsx
@@ -0,0 +1,266 @@
+import type { OutputData, OutputType } from "@runtimed/schema";
+import {
+ SingleOutput,
+ OutputsContainer,
+ SuspenseSpinner,
+ ExecutionCount,
+ SyntaxHighlighter,
+} from "@runtimed/components";
+
+// Jupyter notebook types
+export interface JupyterOutput {
+ output_type: "stream" | "execute_result" | "display_data" | "error";
+ name?: string; // stdout, stderr for stream
+ text?: string | string[];
+ data?: Record;
+ metadata?: Record;
+ execution_count?: number | null;
+ ename?: string;
+ evalue?: string;
+ traceback?: string[];
+}
+
+export interface JupyterCell {
+ id: string;
+ cell_type: "code" | "markdown" | "raw";
+ source: string | string[];
+ outputs?: JupyterOutput[];
+ execution_count?: number | null;
+}
+
+export interface JupyterNotebook {
+ cells: JupyterCell[];
+ metadata?: Record;
+ nbformat?: number;
+ nbformat_minor?: number;
+}
+
+// Helper to join source lines
+function joinSource(source: string | string[]): string {
+ return Array.isArray(source) ? source.join("") : source;
+}
+
+// Convert Jupyter output to OutputData format
+function convertJupyterOutput(
+ output: JupyterOutput,
+ cellId: string,
+ position: number
+): OutputData | null {
+ const baseOutput = {
+ id: `${cellId}-output-${position}`,
+ cellId,
+ position,
+ streamName: null,
+ executionCount: null,
+ displayId: null,
+ artifactId: null,
+ mimeType: null,
+ metadata: null,
+ representations: null,
+ data: null,
+ };
+
+ switch (output.output_type) {
+ case "stream": {
+ const text = Array.isArray(output.text)
+ ? output.text.join("")
+ : output.text || "";
+ return {
+ ...baseOutput,
+ outputType: "terminal" as OutputType,
+ streamName: (output.name as "stdout" | "stderr") || "stdout",
+ data: text,
+ } as OutputData;
+ }
+
+ case "execute_result":
+ case "display_data": {
+ const outputData = output.data || {};
+ const representations: Record =
+ {};
+
+ for (const [mimeType, content] of Object.entries(outputData)) {
+ const data = Array.isArray(content) ? content.join("") : content;
+ representations[mimeType] = { type: "inline", data };
+ }
+
+ // Get primary mime type
+ const mimeTypes = Object.keys(representations);
+ const primaryMimeType =
+ mimeTypes.find((m) => m.startsWith("text/html")) ||
+ mimeTypes.find((m) => m.startsWith("image/")) ||
+ mimeTypes.find((m) => m === "application/json") ||
+ mimeTypes.find((m) => m === "text/plain") ||
+ mimeTypes[0];
+
+ return {
+ ...baseOutput,
+ outputType:
+ output.output_type === "execute_result"
+ ? ("multimedia_result" as OutputType)
+ : ("multimedia_display" as OutputType),
+ executionCount: output.execution_count ?? null,
+ mimeType: primaryMimeType || null,
+ representations,
+ data: representations["text/plain"]?.data?.toString() || null,
+ } as OutputData;
+ }
+
+ case "error": {
+ return {
+ ...baseOutput,
+ outputType: "error" as OutputType,
+ data: JSON.stringify({
+ ename: output.ename || "Error",
+ evalue: output.evalue || "",
+ traceback: output.traceback || [],
+ }),
+ } as OutputData;
+ }
+
+ default:
+ return null;
+ }
+}
+
+// Extract language from notebook metadata
+function getNotebookLanguage(metadata?: Record): string {
+ // Try kernelspec name first (e.g., "python3", "ir", "julia")
+ const kernelspec = metadata?.kernelspec as
+ | { language?: string; name?: string }
+ | undefined;
+ if (kernelspec?.language) {
+ return kernelspec.language;
+ }
+ // Try language_info (more detailed)
+ const languageInfo = metadata?.language_info as { name?: string } | undefined;
+ if (languageInfo?.name) {
+ return languageInfo.name;
+ }
+ // Default to python
+ return "python";
+}
+
+// Code cell component
+function CodeCell({ cell, language }: { cell: JupyterCell; language: string }) {
+ const source = joinSource(cell.source);
+ const outputs = (cell.outputs || [])
+ .map((output, i) => convertJupyterOutput(output, cell.id, i))
+ .filter((o): o is OutputData => o !== null);
+
+ return (
+
+ {/* Execution count badge */}
+
+
+
+
+
+ {/* Input */}
+
+
+ {source}
+
+
+
+ {/* Outputs */}
+ {outputs.length > 0 && (
+
+
+
+ {outputs.map((output) => (
+
+ ))}
+
+
+
+ )}
+
+
+
+ );
+}
+
+// Markdown cell component
+function MarkdownCell({ cell }: { cell: JupyterCell }) {
+ const source = joinSource(cell.source);
+
+ // For markdown cells, render the output using SingleOutput with markdown type
+ const markdownOutput: OutputData = {
+ id: `${cell.id}-markdown`,
+ cellId: cell.id,
+ outputType: "markdown",
+ position: 0,
+ streamName: null,
+ executionCount: null,
+ displayId: null,
+ artifactId: null,
+ mimeType: null,
+ metadata: null,
+ representations: null,
+ data: source,
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// Raw cell component
+function RawCell({ cell }: { cell: JupyterCell }) {
+ const source = joinSource(cell.source);
+
+ return (
+
+
+
+
+
+ {source}
+
+
+
+
+ );
+}
+
+// Notebook renderer
+export function NotebookRenderer({ notebook }: { notebook: JupyterNotebook }) {
+ const language = getNotebookLanguage(notebook.metadata);
+
+ return (
+
+ {notebook.cells.map((cell, index) => {
+ switch (cell.cell_type) {
+ case "code":
+ return (
+
+ );
+ case "markdown":
+ return ;
+ case "raw":
+ return ;
+ default:
+ return null;
+ }
+ })}
+
+ );
+}
diff --git a/packages/notebook-preview/src/demo-main.tsx b/packages/notebook-preview/src/demo-main.tsx
new file mode 100644
index 00000000..fa5bfb34
--- /dev/null
+++ b/packages/notebook-preview/src/demo-main.tsx
@@ -0,0 +1,12 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./style.css";
+import "@runtimed/components/styles.css";
+
+import { OutputTypesDemoPage } from "@runtimed/components";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/packages/notebook-preview/src/main.tsx b/packages/notebook-preview/src/main.tsx
new file mode 100644
index 00000000..72f39d1e
--- /dev/null
+++ b/packages/notebook-preview/src/main.tsx
@@ -0,0 +1,11 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { App } from "./App";
+import "./style.css";
+import "@runtimed/components/styles.css";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/packages/notebook-preview/src/react-main.tsx b/packages/notebook-preview/src/react-main.tsx
new file mode 100644
index 00000000..bb6a13ab
--- /dev/null
+++ b/packages/notebook-preview/src/react-main.tsx
@@ -0,0 +1,30 @@
+import { createRoot } from "react-dom/client";
+import { IframeReactApp, sendFromIframe } from "@runtimed/components";
+import "./style.css";
+import "@runtimed/components/styles.css";
+
+// Main React initialization for iframe outputs
+function initializeReactIframe() {
+ const container = document.getElementById("react-root");
+ if (!container) {
+ console.error("React root element not found");
+ return;
+ }
+
+ const root = createRoot(container);
+ root.render( );
+
+ // Send iframe loaded message
+ sendFromIframe({ type: "iframe-loaded" });
+}
+
+document.addEventListener("dblclick", () => {
+ sendFromIframe({ type: "iframe-double-click" });
+});
+
+// Initialize when DOM is ready
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", initializeReactIframe);
+} else {
+ initializeReactIframe();
+}
diff --git a/packages/notebook-preview/src/style.css b/packages/notebook-preview/src/style.css
new file mode 100644
index 00000000..a9656f3c
--- /dev/null
+++ b/packages/notebook-preview/src/style.css
@@ -0,0 +1,50 @@
+@import "tailwindcss";
+
+@source "../node_modules/@runtimed/components/src";
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.13 0.028 261.692);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.13 0.028 261.692);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.13 0.028 261.692);
+ --primary: oklch(0.21 0.034 264.665);
+ --primary-foreground: oklch(0.985 0.002 247.839);
+ --secondary: oklch(0.967 0.003 264.542);
+ --secondary-foreground: oklch(0.21 0.034 264.665);
+ --muted: oklch(0.967 0.003 264.542);
+ --muted-foreground: oklch(0.551 0.027 264.364);
+ --accent: oklch(0.967 0.003 264.542);
+ --accent-foreground: oklch(0.21 0.034 264.665);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.928 0.006 264.531);
+ --input: oklch(0.928 0.006 264.531);
+ --ring: oklch(0.707 0.022 261.325);
+}
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+}
diff --git a/packages/notebook-preview/tsconfig.json b/packages/notebook-preview/tsconfig.json
new file mode 100644
index 00000000..a4c834a6
--- /dev/null
+++ b/packages/notebook-preview/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/packages/notebook-preview/vite.config.ts b/packages/notebook-preview/vite.config.ts
new file mode 100644
index 00000000..bb59aef6
--- /dev/null
+++ b/packages/notebook-preview/vite.config.ts
@@ -0,0 +1,28 @@
+import { defineConfig } from "vite";
+import { resolve } from "path";
+import react from "@vitejs/plugin-react";
+import tailwindcss from "@tailwindcss/vite";
+
+export default defineConfig({
+ plugins: [tailwindcss(), react()],
+ base: "./",
+ // resolve: {
+ // alias: {
+ // "@runtimed/components/styles.css": resolve(
+ // __dirname,
+ // "../components/dist/styles.css"
+ // ),
+ // },
+ // },
+ build: {
+ outDir: "dist",
+ emptyOutDir: true,
+ rollupOptions: {
+ input: {
+ index: resolve(__dirname, "index.html"),
+ react: resolve(__dirname, "react.html"),
+ health: resolve(__dirname, "demo.html"),
+ },
+ },
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b089b1a4..21c5ee86 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -597,6 +597,40 @@ importers:
specifier: ^5.8.3
version: 5.8.3
+ packages/notebook-preview:
+ dependencies:
+ '@runtimed/components':
+ specifier: workspace:*
+ version: link:../components
+ react:
+ specifier: 19.2.1
+ version: 19.2.1
+ react-dom:
+ specifier: 19.2.1
+ version: 19.2.1(react@19.2.1)
+ devDependencies:
+ '@tailwindcss/vite':
+ specifier: ^4.1.10
+ version: 4.1.11(vite@6.3.5(@types/node@24.0.13)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.21.0)(yaml@2.8.1))
+ '@types/react':
+ specifier: ^19.1.8
+ version: 19.1.8
+ '@types/react-dom':
+ specifier: ^19.1.6
+ version: 19.1.6(@types/react@19.1.8)
+ '@vitejs/plugin-react':
+ specifier: ^4.5.2
+ version: 4.6.0(vite@6.3.5(@types/node@24.0.13)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.21.0)(yaml@2.8.1))
+ tailwindcss:
+ specifier: ^4.1.10
+ version: 4.1.11
+ typescript:
+ specifier: ^5.8.3
+ version: 5.8.3
+ vite:
+ specifier: ^6.3.5
+ version: 6.3.5(@types/node@24.0.13)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.21.0)(yaml@2.8.1)
+
packages/pyodide-runtime:
dependencies:
'@runtimed/agent-core':
@@ -8486,7 +8520,7 @@ snapshots:
dependencies:
'@types/estree': 1.0.8
estree-walker: 2.0.2
- picomatch: 4.0.2
+ picomatch: 4.0.3
optionalDependencies:
rollup: 4.45.0
@@ -11690,7 +11724,7 @@ snapshots:
dependencies:
color: 4.2.3
detect-libc: 2.0.4
- semver: 7.7.2
+ semver: 7.7.3
optionalDependencies:
'@img/sharp-darwin-arm64': 0.33.5
'@img/sharp-darwin-x64': 0.33.5
diff --git a/src/components/notebook/cell/MarkdownCell.tsx b/src/components/notebook/cell/MarkdownCell.tsx
index 8a5e0dc0..8a41878c 100644
--- a/src/components/notebook/cell/MarkdownCell.tsx
+++ b/src/components/notebook/cell/MarkdownCell.tsx
@@ -15,7 +15,7 @@ import React, {
useState,
} from "react";
-import { IframeOutput } from "@/components/outputs/IframeOutput.js";
+import { IframeOutput } from "@runtimed/components";
import { Button } from "@/components/ui/button.js";
import { useFeatureFlag } from "@/contexts/FeatureFlagContext.js";
import { useUserRegistry } from "@/hooks/useUserRegistry.js";
@@ -335,6 +335,7 @@ export const MarkdownCell: React.FC = ({
>
{/* Send markdown content to iframe */}
setIsEditing(true)}
onMarkdownRendered={() => setReadyToShowRendered(true)}
outputs={[