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, -}) => { - 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 && ( - - )} -
- ); -}; - const generateSlug = (text: string): string => { return text .toLowerCase() @@ -130,9 +71,12 @@ export const MarkdownRenderer: React.FC = ({ const isBlockCode = codeContent.includes("\n") || className; return isBlockCode ? ( - + {codeContent} - + ) : ( = ({ + 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={[