Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
20 changes: 20 additions & 0 deletions docs/guides/apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,26 @@ 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 notebook that uses the slides layout as static HTML or WebAssembly HTML:

```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.
```

WebAssembly exports (html-wasm) require an HTTP server. Serve the output directory
locally with `python -m http.server --directory presentation`.

Both formats preserve slide types, fragments, speaker notes, and deck settings.
Static HTML includes the outputs generated during export and supports speaker
view. WebAssembly HTML runs Python in the browser, so notebook controls remain
interactive. Speaker view is not available in WebAssembly HTML exports.

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
2 changes: 2 additions & 0 deletions docs/guides/exporting/static_html.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ be non-zero. However, the export result may still be generated, with the error
included in the output. Errors can be ignored by appending `|| true` to the
command, e.g. `marimo export html notebook.py || true`.

**Slides.** If your notebook is configured with the slides layout, a HTML reveal.js slide deck is created. It preserves slide structure, speaker notes and deck settings. Speaker notes are embedded in the HTML file and readable by anyone who receives it.

## Pre-render HTML exports

Static marimo exports execute Javascript to render the notebook source code as HTML at browser runtime. If you would like to directly serve the HTML representation of your notebook, you can run the following post-processing script and serve the resulting file instead.
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
```

With `--mode run`, a notebook that uses the slides layout opens as a reveal.js
deck. The export preserves slide types, fragments, speaker notes, and deck
settings. Python runs in the browser, so notebook controls remain interactive.

`--mode edit` opens the notebook editor instead of the slides layout. Speaker
view is not available in WebAssembly HTML exports.

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

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