diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 43f74e77..80e76029 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -22,8 +22,8 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- python -m pip install -e ".[mcp]"
- python -m pip install jupyter polars pandas pyarrow plotnine
+ python -m pip install -e ".[mcp,marimo]"
+ python -m pip install jupyter polars pandas pyarrow plotnine 'great-tables<0.22'
- name: Set up Quarto
uses: quarto-dev/quarto-actions/setup@v2
diff --git a/great-docs.yml b/great-docs.yml
index 650be4b3..1f255208 100644
--- a/great-docs.yml
+++ b/great-docs.yml
@@ -64,6 +64,11 @@ sidebar_filter:
site:
show_dates: true # Display creation/modification dates on pages
+# Marimo Notebooks
+# ----------------
+# Enable interactive WASM notebooks via marimo islands
+marimo: true
+
# Dark Mode Toggle
# ----------------
# Enable/disable the dark mode toggle in navbar (default: true)
@@ -194,6 +199,7 @@ nav_icons:
Blog: pen-line
Diagrams: shapes
Videos: video
+ Marimo Notebooks: notebook-pen
Building & Previewing: hammer
Freeze & Caching: snowflake
Deployment: cloud-upload
diff --git a/great_docs/_marimo.py b/great_docs/_marimo.py
new file mode 100644
index 00000000..09b07c3a
--- /dev/null
+++ b/great_docs/_marimo.py
@@ -0,0 +1,266 @@
+"""Marimo notebook integration for Great Docs.
+
+Provides utilities for generating marimo island HTML at build time using MarimoIslandGenerator, and
+supporting the marimo Quarto shortcode.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import re
+import sys
+from pathlib import Path
+
+# Marimo islands CDN base
+_ISLANDS_CDN = "https://cdn.jsdelivr.net/npm/@marimo-team/islands"
+
+# Fallback used only when the installed marimo version can't be determined.
+_FALLBACK_VERSION = "0.23.8"
+
+# Google Fonts + KaTeX CSS that the islands runtime expects (kept in sync with
+# marimo's own MarimoIslandGenerator.render_head).
+_FONT_URL = (
+ "https://fonts.googleapis.com/css2?family=Fira+Mono:wght@400;500;700"
+ "&family=Lora&family=PT+Sans:wght@400;700&display=swap"
+)
+_KATEX_CSS = "https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css"
+
+
+def islands_runtime_version() -> str:
+ """Return the @marimo-team/islands runtime version to load from the CDN.
+
+ The browser runtime must match the marimo version that generated the island markup, so this
+ defaults to the installed marimo package version. Falls back to a known-good pin if marimo can't
+ be imported.
+ """
+ try:
+ import marimo
+
+ return str(marimo.__version__)
+ except Exception:
+ return _FALLBACK_VERSION
+
+
+def get_islands_head_html(version: str | None = None) -> str:
+ """Return the \n'
+ f'\n'
+ '\n'
+ '\n'
+ '\n'
+ f'\n'
+ f'\n'
+ ""
+ )
+
+
+def generate_islands_html(
+ notebook_path: Path,
+ *,
+ display_code: bool = True,
+ reactive: bool = True,
+ app_id: str | None = None,
+) -> str:
+ """Generate marimo island HTML from a notebook file.
+
+ Uses MarimoIslandGenerator to produce correct island markup that the @marimo-team/islands
+ runtime can activate.
+
+ Parameters
+ ----------
+ notebook_path
+ Path to the .py marimo notebook file.
+ display_code
+ Whether to show cell source code.
+ reactive
+ Whether cells should be reactive (run with Pyodide in browser).
+ app_id
+ Unique app identifier for namespacing islands on the same page. Defaults to the notebook
+ stem name.
+
+ Returns
+ -------
+ str
+ HTML string containing elements.
+ """
+ import io
+
+ from marimo import MarimoIslandGenerator
+
+ gen = MarimoIslandGenerator.from_file(str(notebook_path), display_code=display_code)
+
+ # Build the app (runs cells to capture output; errors are non-fatal)
+ # Redirect stdout/stderr during build to avoid marimo writing to
+ # wrapped streams that might lack attributes
+ old_stdout, old_stderr = sys.stdout, sys.stderr
+ sys.stdout = io.TextIOWrapper(io.BytesIO(), encoding="utf-8")
+ sys.stderr = io.TextIOWrapper(io.BytesIO(), encoding="utf-8")
+ try:
+ loop = asyncio.new_event_loop()
+ try:
+ loop.run_until_complete(gen.build())
+ finally:
+ loop.close()
+ finally:
+ sys.stdout = old_stdout
+ sys.stderr = old_stderr
+
+ # Render body HTML (the islands themselves). The init island renders a
+ # loading spinner and is what triggers the islands runtime to boot the
+ # Pyodide kernel — without it the custom elements load but never hydrate,
+ # so cells stay static and non-interactive.
+ body_html = gen.render_body(
+ include_init_island=True,
+ max_width="100%",
+ )
+
+ # Ensure data-reactive matches the requested mode
+ if not reactive:
+ body_html = body_html.replace('data-reactive="true"', 'data-reactive="false"')
+
+ # NOTE: We intentionally do NOT strip "empty output" islands (e.g. an `import marimo as mo`
+ # cell) even when hiding code. Those cells are part of the reactive graph and removing their
+ # island leaves the runtime unable to resolve `mo` and every dependent cell fails with a
+ # NameError. With display_code=False the code editor isn't shown, and marimo's own
+ # `empty:hidden` styling collapses the empty output, so the cell stays invisible while still
+ # executing.
+
+ # Tag the leading run of output-less "setup" cells (imports/utility) so the front-end can
+ # collapse them behind a disclosure toggle. Only meaningful when code is shown and in no-code
+ # mode these cells are hidden anyway. This is done at build time (where MarimoIslandGenerator
+ # has actually run the notebook) so emptiness is authoritative and not subject to render races.
+ if display_code:
+ body_html = _tag_setup_islands(body_html)
+
+ # Namespace islands with a unique app_id (defaults to notebook stem)
+ resolved_app_id = app_id or notebook_path.stem
+ if resolved_app_id != "main":
+ body_html = body_html.replace('data-app-id="main"', f'data-app-id="{resolved_app_id}"')
+
+ return body_html
+
+
+# Matches a marimo cell whose output is empty (e.g. an `import` cell).
+_EMPTY_OUTPUT_RE = re.compile(
+ r"\s*\s*\s*", re.DOTALL
+)
+_ISLAND_RE = re.compile(r"", re.DOTALL)
+
+
+def _tag_setup_islands(body_html: str) -> str:
+ """Add `data-gd-setup="true"` to the leading run of empty-output cells.
+
+ Walks islands in document order, skipping the non-reactive init/loader island, and marks each
+ reactive cell whose output is empty until the first cell that produces output. Only the leading
+ run is tagged, so a cell that renders anything is never collapsed.
+ """
+ out: list[str] = []
+ pos = 0
+ leading = True
+
+ for match in _ISLAND_RE.finditer(body_html):
+ out.append(body_html[pos : match.start()])
+ pos = match.end()
+ block = match.group(0)
+
+ open_tag = block[: block.find(">") + 1]
+ is_reactive = 'data-reactive="true"' in open_tag
+
+ if is_reactive:
+ if leading and _EMPTY_OUTPUT_RE.search(block):
+ block = block.replace(" None:
+ """Pre-generate island HTML and save to a file for the Lua shortcode to read.
+
+ Parameters
+ ----------
+ notebook_path
+ Path to the .py marimo notebook.
+ output_path
+ Path to write the generated HTML fragment.
+ display_code
+ Whether to show cell source code.
+ reactive
+ Whether cells should be reactive.
+ app_id
+ Unique app identifier for namespacing islands on the same page. Defaults to the notebook
+ stem name.
+ """
+ html = generate_islands_html(
+ notebook_path,
+ display_code=display_code,
+ reactive=reactive,
+ app_id=app_id,
+ )
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(html, encoding="utf-8")
+
+
+def parse_marimo_source(source: str) -> list[dict[str, str]]:
+ """Parse marimo notebook source text into cells (for fallback/testing)."""
+ cells: list[dict[str, str]] = []
+
+ cell_pattern = re.compile(
+ r"@app\.cell(?:\([^)]*\))?\s*\n"
+ r"def\s+([A-Za-z_]\w*)\s*\([^)]*\)\s*(?:->[^:]*)?:\s*\n"
+ r"((?:(?: .*)?\n)*)",
+ re.MULTILINE,
+ )
+
+ for match in cell_pattern.finditer(source):
+ name = match.group(1)
+ body = match.group(2)
+
+ lines = body.split("\n")
+ dedented = []
+ for line in lines:
+ if line.startswith(" "):
+ dedented.append(line[4:])
+ elif line.strip() == "":
+ dedented.append("")
+ else:
+ dedented.append(line)
+
+ while dedented and dedented[-1].strip() == "":
+ dedented.pop()
+ if dedented and dedented[-1].strip().startswith("return"):
+ dedented.pop()
+ while dedented and dedented[-1].strip() == "":
+ dedented.pop()
+
+ code = "\n".join(dedented)
+ if code.strip():
+ cells.append({"code": code, "name": name})
+
+ return cells
+
+
+def notebook_source(path: Path) -> str:
+ """Return raw notebook source for copy-to-clipboard."""
+ return path.read_text(encoding="utf-8")
diff --git a/great_docs/_pr_preview.py b/great_docs/_pr_preview.py
index c7dbc803..21362ca9 100644
--- a/great_docs/_pr_preview.py
+++ b/great_docs/_pr_preview.py
@@ -27,6 +27,10 @@
DEFAULT_ARTIFACT = "docs-html"
_TIMEOUT = 15
_DOWNLOAD_TIMEOUT = 300
+# Per-read timeout for the streamed artifact download (generous: blob storage
+# can be slow) and how many times to retry, resuming from bytes already on disk.
+_READ_TIMEOUT = 120
+_DOWNLOAD_RETRIES = 5
class PreviewError(Exception):
@@ -363,39 +367,81 @@ def _gh_download(self, run_id: int, artifact: dict[str, Any], dest: Path) -> Non
raise PreviewError(f"'gh run download' failed: {result.stderr.strip()}")
def _requests_download(self, artifact: dict[str, Any], dest: Path) -> None:
+ import time
+
import requests
url = artifact.get("archive_download_url") or (
f"{GITHUB_API}/repos/{self.owner}/{self.repo}/actions/artifacts/{artifact['id']}/zip"
)
- headers = {"Accept": "application/vnd.github+json"}
+ base_headers = {"Accept": "application/vnd.github+json"}
if self.token:
- headers["Authorization"] = f"Bearer {self.token}"
+ base_headers["Authorization"] = f"Bearer {self.token}"
zip_path = dest / "_artifact.zip"
- try:
- with requests.get(url, headers=headers, timeout=_TIMEOUT, stream=True) as resp:
- if resp.status_code == 410:
+ zip_path.unlink(missing_ok=True)
+
+ # Artifacts can be hundreds of MB from slow blob storage. A single stalled
+ # read shouldn't lose the whole transfer, so retry on network errors and
+ # resume from the bytes already on disk via a Range request (the blob
+ # store returns 206 + the remainder; if it ignores Range and returns 200,
+ # we restart the file).
+ last_exc: Exception | None = None
+ for attempt in range(_DOWNLOAD_RETRIES):
+ have = zip_path.stat().st_size if zip_path.exists() else 0
+ headers = dict(base_headers)
+ if have:
+ headers["Range"] = f"bytes={have}-"
+ try:
+ # (connect timeout, per-read timeout) — a generous read timeout
+ # tolerates slow chunks without abandoning the download.
+ with requests.get(
+ url, headers=headers, timeout=(_TIMEOUT, _READ_TIMEOUT), stream=True
+ ) as resp:
+ if resp.status_code == 410:
+ raise PreviewError(
+ "This artifact has expired and can no longer be downloaded. "
+ "Re-run the workflow to regenerate it."
+ )
+ if resp.status_code == 206: # resuming
+ mode, start = "ab", have
+ total = have + int(resp.headers.get("Content-Length") or 0)
+ elif resp.status_code == 200: # Range ignored; start over
+ mode, start = "wb", 0
+ total = int(resp.headers.get("Content-Length") or 0)
+ else:
+ raise PreviewError(
+ f"Artifact download failed (HTTP {resp.status_code})."
+ )
+ _stream_to_file(resp, zip_path, total, mode=mode, start=start)
+ break # completed
+ except requests.RequestException as exc:
+ last_exc = exc
+ if attempt == _DOWNLOAD_RETRIES - 1:
raise PreviewError(
- "This artifact has expired and can no longer be downloaded. "
- "Re-run the workflow to regenerate it."
- )
- if resp.status_code != 200:
- raise PreviewError(f"Artifact download failed (HTTP {resp.status_code}).")
- total = int(resp.headers.get("Content-Length") or 0)
- _stream_to_file(resp, zip_path, total)
- except requests.RequestException as exc:
- raise PreviewError(f"Artifact download failed: {exc}") from exc
+ f"Artifact download failed after {_DOWNLOAD_RETRIES} attempts: {exc}"
+ ) from exc
+ got = zip_path.stat().st_size if zip_path.exists() else 0
+ print(
+ f" … download interrupted ({type(exc).__name__}); "
+ f"resuming from {got / 1e6:.1f} MB "
+ f"(attempt {attempt + 2}/{_DOWNLOAD_RETRIES})",
+ file=sys.stderr,
+ )
+ time.sleep(2 * (attempt + 1))
_safe_extract_zip(zip_path, dest)
zip_path.unlink(missing_ok=True)
-def _stream_to_file(resp: Any, zip_path: Path, total: int) -> None:
+def _stream_to_file(
+ resp: Any, zip_path: Path, total: int, mode: str = "wb", start: int = 0
+) -> None:
"""Stream a response body to disk, showing a progress bar on an interactive terminal.
Progress is rendered to stderr only when it's a TTY and the size is known. Otherwise the
- download runs quietly (e.g. in CI logs or when piped).
+ download runs quietly (e.g. in CI logs or when piped). ``mode`` is the file open mode
+ (``"ab"`` to resume) and ``start`` is the byte count already on disk, used to seed the bar.
"""
chunk_size = 1 << 16
show_bar = total > 0 and sys.stderr.isatty()
@@ -404,18 +450,20 @@ def _stream_to_file(resp: Any, zip_path: Path, total: int) -> None:
import click
with (
- open(zip_path, "wb") as handle,
+ open(zip_path, mode) as handle,
click.progressbar(
length=total,
label="→ Downloading",
file=sys.stderr,
) as bar,
):
+ if start:
+ bar.update(start)
for chunk in resp.iter_content(chunk_size=chunk_size):
handle.write(chunk)
bar.update(len(chunk))
else:
- with open(zip_path, "wb") as handle:
+ with open(zip_path, mode) as handle:
for chunk in resp.iter_content(chunk_size=chunk_size):
handle.write(chunk)
diff --git a/great_docs/assets/_extensions/marimo/_extension.yml b/great_docs/assets/_extensions/marimo/_extension.yml
new file mode 100644
index 00000000..930cd61b
--- /dev/null
+++ b/great_docs/assets/_extensions/marimo/_extension.yml
@@ -0,0 +1,7 @@
+title: Marimo Islands
+author: Great Docs
+version: 1.0.0
+quarto-required: ">=1.3.0"
+contributes:
+ shortcodes:
+ - marimo.lua
diff --git a/great_docs/assets/_extensions/marimo/marimo.lua b/great_docs/assets/_extensions/marimo/marimo.lua
new file mode 100644
index 00000000..cbb2db7a
--- /dev/null
+++ b/great_docs/assets/_extensions/marimo/marimo.lua
@@ -0,0 +1,153 @@
+-- marimo.lua — Quarto shortcode for embedding Marimo notebooks as WASM islands.
+--
+-- Usage in .qmd files:
+--
+-- {{< marimo file="notebooks/gt-basics.py" >}}
+--
+-- {{< marimo file="notebooks/gt-basics.py" show-code="false" >}}
+--
+-- {{< marimo file="notebooks/gt-basics.py" mode="iframe" height="600px" >}}
+--
+-- Options:
+-- file (required) Path to .py marimo notebook relative to project root
+-- mode "island" (default), "iframe"
+-- show-copy "true"/"false" — show Copy Notebook button (default: true)
+-- theme "auto"/"light"/"dark" — color theme (default: auto)
+-- height CSS height for iframe mode (default: 600px)
+--
+-- Island mode uses pre-generated HTML from MarimoIslandGenerator (built
+-- during the Great Docs build step). The HTML is read from
+-- _marimo_islands/.html.
+
+local function escape_html(s)
+ if s == nil then return "" end
+ return (s:gsub("&", "&"):gsub("<", "<"):gsub(">", ">"):gsub('"', """))
+end
+
+local function kwarg(kwargs, key, default)
+ local raw = kwargs[key]
+ if raw == nil then return default end
+ local s = pandoc.utils.stringify(raw)
+ if s == "" then return default end
+ return s
+end
+
+--- Read a file relative to the Quarto project root.
+local function read_project_file(rel_path)
+ local base = ""
+ if quarto and quarto.project and quarto.project.directory then
+ base = quarto.project.directory .. "/"
+ end
+ local path = base .. rel_path
+ local f = io.open(path, "r")
+ if not f then return nil end
+ local content = f:read("*a")
+ f:close()
+ return content
+end
+
+return {
+ ["marimo"] = function(args, kwargs, meta)
+ -- Get file path (required)
+ local file = kwarg(kwargs, "file", "")
+ if file == "" and #args > 0 then
+ file = pandoc.utils.stringify(args[1])
+ end
+ if file == "" then
+ quarto.log.warning("[marimo] 'file' attribute is required")
+ return pandoc.Null()
+ end
+
+ -- Read options
+ local mode = kwarg(kwargs, "mode", "island")
+ local show_copy = kwarg(kwargs, "show-copy", "true")
+ local theme = kwarg(kwargs, "theme", "auto")
+ local height = kwarg(kwargs, "height", "600px")
+ -- iframe chrome: "trimmed" (default) hides marimo's editor sidebar,
+ -- add-cell bar, and status bar while keeping cells editable/reactive;
+ -- "full" shows the complete editor chrome.
+ local chrome = kwarg(kwargs, "chrome", "trimmed")
+
+ -- IFRAME MODE --------------------------------------------------------
+ if mode == "iframe" then
+ local offset = ""
+ if quarto and quarto.project and quarto.project.offset then
+ offset = quarto.project.offset .. "/"
+ end
+ local wasm_path = file:gsub("%.py$", "") .. "/index.html"
+ -- marimo reads ?show-chrome to toggle the editor sidebar/footer/status.
+ if chrome ~= "full" then
+ wasm_path = wasm_path .. "?show-chrome=false"
+ end
+ local parts = {}
+ table.insert(parts, '
')
+ end
+
+ -- Use -nocode variant when show-code is false
+ local island_file = "_marimo_islands/" .. stem .. ".html"
+ if show_code == "false" then
+ island_file = "_marimo_islands/" .. stem .. "-nocode.html"
+ end
+ local island_html = read_project_file(island_file)
+ if not island_html then
+ quarto.log.warning("[marimo] Pre-generated island HTML not found for: " .. stem)
+ return pandoc.RawInline("html",
+ '
Island HTML not generated for: '
+ .. escape_html(file) .. '
')
+ end
+
+ local parts = {}
+ local wrapper_classes = "gd-marimo-island-group"
+ if show_code == "false" then
+ wrapper_classes = wrapper_classes .. " gd-marimo-nocode"
+ end
+ table.insert(parts, '
')
+ table.insert(parts, island_html)
+
+ -- Copy notebook button
+ if show_copy == "true" then
+ local source = read_project_file(file)
+ if source then
+ local escaped_source = escape_html(source)
+ table.insert(parts,
+ '\n ')
+ table.insert(parts, '\n