Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/guides/apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,27 @@ If you prefer a slideshow-like experience, you can use the slides layout. Enable
- Add speaker notes at the bottom of each slide and launch speaker view by pressing `S`.
- Powered by [reveal.js](https://revealjs.com/), so you can use most of its features like keyboard shortcuts, navigation, etc.

#### Export slides

Export a configured deck as static HTML or as an interactive WebAssembly app:
Comment thread
peter-gy marked this conversation as resolved.
Outdated

```bash
marimo export html presentation.py -o presentation.html
marimo export html-wasm presentation.py -o presentation --mode run
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Comment thread
peter-gy marked this conversation as resolved.
Outdated
```

The WebAssembly export loads its assets over HTTP. Serve the output directory
locally with `python -m http.server --directory presentation`.
Comment thread
peter-gy marked this conversation as resolved.
Outdated

When a notebook uses the slides layout, both exports open as slides and preserve
slide types, fragments, speaker notes, and deck settings. Static HTML captures
the outputs from the export run and supports reveal.js speaker view. WebAssembly
HTML runs Python in the browser, so controls remain reactive. Use static HTML
when the presentation needs speaker view.
Comment thread
peter-gy marked this conversation as resolved.
Outdated

Speaker notes are embedded in the HTML file and readable by anyone who
receives it.

#### Styling slides

The slides layout is rendered with [reveal.js](https://revealjs.com/), so you
Expand Down
5 changes: 5 additions & 0 deletions docs/guides/exporting/static_html.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ Export to HTML at the command line:
marimo export html notebook.py -o notebook.html
```

A notebook configured with the slides layout opens as a reveal.js deck and
preserves its slide structure, speaker notes, and deck settings.
Speaker notes are embedded in the HTML file and readable by anyone who
receives it.

Comment thread
peter-gy marked this conversation as resolved.
Outdated
Exclude code from the export:

```bash
Expand Down
9 changes: 9 additions & 0 deletions docs/guides/exporting/webassembly_html.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ marimo export html-wasm notebook.py -o output_dir --mode run
marimo export html-wasm notebook.py -o output_dir --mode edit
```

In `--mode run`, a notebook configured with the slides layout opens as a
reveal.js deck. The export preserves slide structure, speaker notes, and deck
settings while Pyodide keeps notebook controls reactive.
Speaker notes are embedded in the HTML file and readable by anyone who
receives it.

Use static HTML when the presentation needs reveal.js speaker view.
WebAssembly HTML exports keep speaker view disabled.
Comment thread
peter-gy marked this conversation as resolved.
Outdated

The exported HTML file will run your notebook using WebAssembly, making it completely self-contained and executable in the browser. This means users can interact with your notebook without needing Python or marimo installed.

Options:
Expand Down
50 changes: 50 additions & 0 deletions frontend/e2e-tests/slides.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
/* Copyright 2026 Marimo. All rights reserved. */

import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "@playwright/test";
import { getAppUrl } from "../playwright.config";
import { openCommandPalette, takeScreenshot } from "./helper";
import { waitForMarimoApp } from "./test-utils";

const __filename = fileURLToPath(import.meta.url);
const staticRoot = fileURLToPath(
new URL("../../marimo/_static/", import.meta.url),
);

const appUrl = getAppUrl("slides.py");
test.beforeEach(async ({ page }, info) => {
Expand Down Expand Up @@ -79,3 +83,49 @@ test("slides fullscreen", async ({ page }) => {
// Slides container should still be visible after exiting fullscreen
await expect(slidesContainer).toBeVisible();
});

test("slides static HTML export", async ({ page }, testInfo) => {
await openCommandPalette({ page, command: "Download as HTML" });

const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: "Export HTML" }).click(),
]);
const outputPath = testInfo.outputPath("slides.html");
await download.saveAs(outputPath);

const exportPage = await page.context().newPage();
await exportPage.route("http://slides.test/slides.html", async (route) => {
await route.fulfill({ path: outputPath });
});
await exportPage.route(
"https://cdn.jsdelivr.net/npm/@marimo-team/frontend@*/dist/**",
async (route) => {
const pathname = new URL(route.request().url()).pathname;
const assetPath = pathname.slice(pathname.indexOf("/dist/") + 6);
await route.fulfill({ path: path.join(staticRoot, assetPath) });
},
);
await exportPage.goto("http://slides.test/slides.html#/1/0", {
waitUntil: "domcontentloaded",
});

const slidesContainer = exportPage.locator(".reveal.mo-slides-theme");
await expect(slidesContainer).toBeVisible();
const slides = slidesContainer.locator(".slides > section");
await expect(slides).toHaveCount(2);
await expect(slides.nth(1)).toHaveClass(/present/);
await expect(exportPage.getByTestId("static-notebook-banner")).toHaveCount(0);
await expect(exportPage.getByTestId("watermark")).toHaveCount(0);

await slidesContainer.click();
await exportPage.keyboard.press("ArrowLeft");
await expect(slides.nth(0)).toHaveClass(/present/);
await expect
.poll(() =>
exportPage
.locator("#App")
.evaluate((app) => app.scrollHeight <= app.clientHeight),
)
.toBe(true);
});
56 changes: 54 additions & 2 deletions frontend/src/__tests__/mount.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getSerializedLayout } from "@/core/layout/layout";
import { initialLayoutState, layoutStateAtom } from "@/core/layout/state";
import { kioskModeAtom } from "@/core/mode";
import { connectionAtom } from "@/core/network/connection";
import { store } from "@/core/state/jotai";
import { isStaticNotebook } from "@/core/static/static-state";
import { WebSocketState } from "@/core/websocket/types";
import { mount, visibleForTesting } from "../mount";

Expand All @@ -15,6 +19,8 @@ vi.mock("react-dom/client", () => ({

// Mock static state
vi.mock("@/core/static/static-state", () => ({
getStaticModelNotifications: vi.fn(() => undefined),
getStaticVirtualFiles: vi.fn(() => ({})),
isStaticNotebook: vi.fn(() => false),
}));

Expand Down Expand Up @@ -46,6 +52,10 @@ describe("mount", () => {

beforeEach(() => {
visibleForTesting.reset();
window.history.replaceState({}, "", "/");
vi.mocked(isStaticNotebook).mockReturnValue(false);
store.set(layoutStateAtom, initialLayoutState());
store.set(kioskModeAtom, false);
// Reset connection atom to initial state
store.set(connectionAtom, { state: WebSocketState.NOT_STARTED });
});
Expand All @@ -66,6 +76,12 @@ describe("mount", () => {
serverToken: "",
};

const mountRead = (options: Record<string, unknown> = {}) =>
mount(
{ ...baseOptions, mode: "read", runtimeConfig: [], ...options },
mockElement,
);

describe("connection state initialization", () => {
it("should set connection to CONNECTING when runtimeConfig has lazy=false", () => {
mount(
Expand Down Expand Up @@ -106,8 +122,7 @@ describe("mount", () => {
expect(connection.state).toBe(WebSocketState.NOT_STARTED);
});

it("should keep connection as NOT_STARTED for static notebooks even with lazy=false", async () => {
const { isStaticNotebook } = await import("@/core/static/static-state");
it("should keep connection as NOT_STARTED for static notebooks even with lazy=false", () => {
vi.mocked(isStaticNotebook).mockReturnValue(true);

// Reset mount state to allow another mount
Expand All @@ -125,4 +140,41 @@ describe("mount", () => {
expect(connection.state).toBe(WebSocketState.NOT_STARTED);
});
});

it("hydrates the layout embedded in the mount config", () => {
const layout = {
type: "slides",
data: {
deck: { transition: "fade", verticalAlign: "center" },
},
};
const error = mountRead({ layout });

expect(error).toBeUndefined();
const layoutState = store.get(layoutStateAtom);
expect(layoutState.selectedLayout).toBe("slides");
expect(getSerializedLayout()).toEqual({
type: "slides",
data: {
cells: [],
deck: { transition: "fade", verticalAlign: "center" },
},
});
});

it("uses vertical layout for malformed mount data", () => {
const error = mountRead({ layout: { data: {} } });

expect(error).toBeUndefined();
expect(store.get(layoutStateAtom)).toEqual(initialLayoutState());
});

it("starts kiosk clients in kiosk mode", () => {
window.history.replaceState({}, "", "/?kiosk=true");

const error = mountRead();

expect(error).toBeUndefined();
expect(store.get(kioskModeAtom)).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react";
import { createStore, Provider } from "jotai";
import { describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { layoutStateAtom } from "@/core/layout/layout";
import { layoutStateAtom } from "@/core/layout/state";
import { kioskModeAtom, viewStateAtom } from "@/core/mode";
import { API } from "@/core/network/api";
import { ViewerBanner } from "../viewer-banner";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,17 @@ describe("exportNotebook", () => {
let captureOutputs: Mock<() => Promise<void>>;
let capturePNG: Mock<() => Promise<void>>;
let downloadFile: Mock<(file: ExportedFile) => void>;
let getLayout: Mock;

beforeEach(() => {
requests = makeRequests();
captureOutputs = vi.fn().mockResolvedValue(undefined);
capturePNG = vi.fn().mockResolvedValue(undefined);
downloadFile = vi.fn();
getLayout = vi.fn().mockResolvedValue({
type: "slides",
data: { deck: { transition: "fade" } },
});
});

const run = (
Expand All @@ -61,6 +66,7 @@ describe("exportNotebook", () => {
requests,
sourceFilename: "notebook.py",
htmlFiles: ["data.csv"],
getLayout,
captureOutputs,
capturePNG,
downloadFile,
Expand All @@ -73,8 +79,10 @@ describe("exportNotebook", () => {
download: false,
files: ["data.csv"],
includeCode: false,
layout: { type: "slides", data: { deck: { transition: "fade" } } },
});
expect(downloadFile).toHaveBeenCalledWith(FILE);
expect(getLayout).toHaveBeenCalledOnce();
});

it("passes the selected Markdown flavor through the session API", async () => {
Expand All @@ -84,6 +92,7 @@ describe("exportNotebook", () => {
download: false,
flavor: "qmd",
});
expect(getLayout).not.toHaveBeenCalled();
expect(downloadFile).toHaveBeenCalledWith(FILE);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/* Copyright 2026 Marimo. All rights reserved. */

import type { EditRequests, ExportedFile } from "@/core/network/types";
import type {
EditRequests,
ExportAsHTMLRequest,
ExportedFile,
} from "@/core/network/types";
import { assertNever } from "@/utils/assertNever";
import { runServerSidePDFDownload } from "../pdf-export";
import type { ExportFormat, ExportOptions } from "./state";
Expand All @@ -21,6 +25,7 @@ export async function exportNotebook({
requests,
sourceFilename,
htmlFiles,
getLayout,
captureOutputs,
capturePNG,
downloadFile,
Expand All @@ -30,6 +35,7 @@ export async function exportNotebook({
requests: ExportRequests;
sourceFilename: string;
htmlFiles: string[];
getLayout: () => Promise<ExportAsHTMLRequest["layout"]>;
captureOutputs: () => Promise<void>;
capturePNG: () => Promise<void>;
downloadFile: (file: ExportedFile) => void;
Expand All @@ -40,6 +46,7 @@ export async function exportNotebook({
download: false,
files: htmlFiles,
includeCode: options.html.includeCode,
layout: await getLayout(),
});
downloadFile(file);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
updateCellOutputsWithScreenshots,
useEnrichCellOutputs,
} from "@/core/export/hooks";
import { getExportLayout } from "@/core/export/layout";
import { runDuringPresentMode, useInstallAllowed } from "@/core/mode";
import { useRequestClient } from "@/core/network/requests";
import type { ExportAvailabilityResponse } from "@/core/network/types";
Expand Down Expand Up @@ -349,6 +350,7 @@ function useExportDialogAction({
requests,
sourceFilename,
htmlFiles: VirtualFileTracker.INSTANCE.filenames(),
getLayout: getExportLayout,
captureOutputs: () => captureOutputs(progress),
capturePNG,
downloadFile: downloadExportedFile,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ import { disabledCellIds } from "@/core/cells/utils";
import { capabilitiesAtom } from "@/core/config/capabilities";
import { aiEnabledAtom, useResolvedMarimoConfig } from "@/core/config/config";
import { Constants } from "@/core/constants";
import { useLayoutActions, useLayoutState } from "@/core/layout/layout";
import { useLayoutActions, useLayoutState } from "@/core/layout/state";
import { useTogglePresenting } from "@/core/layout/useTogglePresenting";
import { kioskModeAtom, viewStateAtom } from "@/core/mode";
import { useRequestClient } from "@/core/network/requests";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
invalidateDataSourceDiscovery,
} from "@/core/datasets/data-source-discovery";
import { DiscoverDataSources } from "@/core/datasets/request-registry";
import { layoutStateAtom } from "@/core/layout/layout";
import { layoutStateAtom } from "@/core/layout/state";
import { kioskModeAtom, viewStateAtom } from "@/core/mode";
import { connectionAtom } from "@/core/network/connection";
import { requestClientAtom } from "@/core/network/requests";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createStore, Provider } from "jotai";
import { describe, expect, it } from "vitest";
import { MockRequestClient } from "@/__mocks__/requests";
import { parseAppConfig } from "@/core/config/config-schema";
import { initialLayoutState, layoutStateAtom } from "@/core/layout/layout";
import { initialLayoutState, layoutStateAtom } from "@/core/layout/state";
import { type AppMode, kioskModeAtom } from "@/core/mode";
import { requestClientAtom } from "@/core/network/requests";
import { CellsRenderer } from "../cells-renderer";
Expand Down
Loading
Loading