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, '
') + table.insert(parts, '') + table.insert(parts, '
') + return pandoc.RawInline("html", table.concat(parts)) + end + + -- ISLAND MODE -------------------------------------------------------- + local show_code = kwarg(kwargs, "show-code", "true") + + -- Read pre-generated island HTML from _marimo_islands/.html + local stem = file:match("([^/]+)%.py$") + if not stem then + quarto.log.warning("[marimo] Cannot determine notebook stem from: " .. file) + return pandoc.RawInline("html", + '
Invalid notebook path: ' + .. escape_html(file) .. '
') + 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
') + table.insert(parts, '') + table.insert(parts, + 'Run locally: marimo edit ' .. + escape_html(file:match("[^/]+$") or file) .. '') + table.insert(parts, '
') + end + end + + table.insert(parts, '\n
') + + return pandoc.RawInline("html", table.concat(parts)) + end +} diff --git a/great_docs/assets/great-docs.default.yml b/great_docs/assets/great-docs.default.yml index 6b071247..c9fb155d 100644 --- a/great_docs/assets/great-docs.default.yml +++ b/great_docs/assets/great-docs.default.yml @@ -110,6 +110,14 @@ sidebar_filter: enabled: true # Enable/disable filter (default: true) min_items: 20 # Minimum items before showing filter (default: 20) +# Marimo Notebooks +# ---------------- +# Embed interactive WASM notebooks via marimo islands. `marimo: true` is +# shorthand for `enabled: true`. +marimo: + enabled: false # Enable marimo island notebooks + version: null # @marimo-team/islands CDN version; defaults to the installed marimo version + # CLI Documentation # ----------------- cli: diff --git a/great_docs/assets/marimo-islands.css b/great_docs/assets/marimo-islands.css new file mode 100644 index 00000000..e85bd516 --- /dev/null +++ b/great_docs/assets/marimo-islands.css @@ -0,0 +1,254 @@ +/* marimo-islands.css — Styling for embedded Marimo island notebooks. */ + +/* Island group container */ +.gd-marimo-island-group { + margin: 1.5rem 0; + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.5rem; + overflow: hidden; + background: var(--bs-body-bg, #fff); +} + +/* Individual island cells */ +.gd-marimo-island-group marimo-island { + display: block; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--bs-border-color, #dee2e6); +} + +.gd-marimo-island-group marimo-island:last-of-type { + border-bottom: none; +} + +/* Cell code styling (our visible code block) */ +.gd-marimo-code { + display: block; + font-family: var(--bs-font-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, monospace); + font-size: 0.875rem; + line-height: 1.5; + padding: 0.75rem 1rem; + margin: 0; + background: var(--bs-tertiary-bg, #f8f9fa); + border-bottom: 1px solid var(--bs-border-color, #dee2e6); + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; +} + +.gd-marimo-code code { + font-size: inherit; + color: inherit; + background: none; + padding: 0; +} + +/* Hide the raw marimo-cell-code (runtime uses it internally) */ +marimo-cell-code { + display: none; +} + +/* Cell output area */ +marimo-cell-output { + display: block; + min-height: 1.5rem; +} + +/* Copy notebook button area */ +.gd-marimo-copy { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 1rem; + background: var(--bs-tertiary-bg, #f8f9fa); + border-top: 1px solid var(--bs-border-color, #dee2e6); +} + +.gd-marimo-copy-btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.3rem 0.65rem; + font-size: 0.8rem; + font-weight: 500; + color: var(--bs-body-color, #212529); + background: var(--bs-body-bg, #fff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.375rem; + cursor: pointer; + transition: border-color 0.15s, background-color 0.15s; +} + +.gd-marimo-copy-btn:hover { + background: var(--bs-secondary-bg, #e9ecef); + border-color: var(--bs-secondary, #6c757d); +} + +.gd-marimo-copy-btn.gd-marimo-copied { + color: var(--bs-success, #198754); + border-color: var(--bs-success, #198754); +} + +.gd-marimo-copy-hint { + font-size: 0.75rem; + color: var(--bs-secondary-color, #6c757d); +} + +.gd-marimo-copy-hint code { + font-size: 0.75rem; + padding: 0.1rem 0.3rem; + background: var(--bs-body-bg, #fff); + border-radius: 0.2rem; +} + +/* No-code mode: hide source, show outputs only */ +.gd-marimo-nocode .gd-marimo-code { + display: none; +} + +.gd-marimo-nocode marimo-cell-code { + display: none; +} + +/* Error state */ +.gd-marimo-error { + padding: 1rem; + color: var(--bs-danger, #dc3545); + font-style: italic; + border: 1px dashed var(--bs-danger, #dc3545); + border-radius: 0.5rem; + margin: 1rem 0; +} + +/* Iframe mode */ +.gd-marimo-iframe-wrap { + margin: 1.5rem 0; + border-radius: 0.5rem; + overflow: hidden; + border: 1px solid var(--bs-border-color, #dee2e6); +} + +.gd-marimo-iframe { + display: block; + border: none; + width: 100%; +} + +/* Dark mode adjustments */ +[data-bs-theme="dark"] .gd-marimo-island-group { + border-color: var(--bs-border-color); +} + +[data-bs-theme="dark"] marimo-cell-code { + background: var(--bs-tertiary-bg); +} + +[data-bs-theme="dark"] .gd-marimo-copy { + background: var(--bs-tertiary-bg); +} + +[data-bs-theme="dark"] .gd-marimo-copy-btn { + background: var(--bs-body-bg); + border-color: var(--bs-border-color); + color: var(--bs-body-color); +} + +/* Loading state ----------------------------------------------------------- */ +/* While the kernel boots and widgets mount, hide the assembling cells so + readers don't watch them shuffle and collapse. Space is reserved (cells are + only made invisible, not removed) and the loader is centered over it; the + whole notebook reveals together once settled. The `gd-marimo-booting` class + is added by JS, so with no JS (or a failed boot) the static output still + shows. */ +.gd-marimo-island-group.gd-marimo-booting:not(.gd-marimo-ready) { + position: relative; + min-height: 8rem; +} + +.gd-marimo-island-group.gd-marimo-booting:not(.gd-marimo-ready) + marimo-island:not(.gd-marimo-loader) { + visibility: hidden; +} + +/* The captured loader island, centered over the reserved space. */ +.gd-marimo-loader { + position: absolute !important; + inset: 0; + display: flex !important; + align-items: center; + justify-content: center; + border-bottom: none !important; +} + +.gd-marimo-island-group.gd-marimo-ready .gd-marimo-loader { + display: none !important; +} + +/* No-code mode: hide marimo's per-cell action toolbar (copy/run icons). In an + outputs-only presentation the code-oriented controls don't belong. */ +.gd-marimo-nocode marimo-island .absolute.top-0.right-0.z-50 { + display: none !important; +} + +/* Trim the leading/trailing block margins inside a cell's rendered output. + marimo's prose gives the first heading a large top margin, which as the first + element in a cell reads as excess space above the output. */ +.gd-marimo-island-group marimo-island .prose > :first-child, +.gd-marimo-island-group marimo-island .markdown > :first-child { + margin-top: 0 !important; +} + +.gd-marimo-island-group marimo-island .prose > :last-child, +.gd-marimo-island-group marimo-island .markdown > :last-child { + margin-bottom: 0 !important; +} + +/* Collapsible setup block --------------------------------------------------- */ +/* Leading import/utility cells that render no output are collapsed behind a + disclosure toggle (added by JS) so boilerplate doesn't clutter the notebook. + The toggle mirrors the gray "Copy Notebook" footer. */ +/* Setup cells are tagged `data-gd-setup` at build time and hidden by default. + Higher specificity than `.gd-marimo-island-group marimo-island` so they + actually stay hidden. */ +.gd-marimo-island-group marimo-island[data-gd-setup] { + display: none; +} + +.gd-marimo-island-group.gd-marimo-setup-open marimo-island[data-gd-setup] { + display: block; +} + +.gd-marimo-setup-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; + margin: 0; + padding: 0.4rem 1rem; + font-size: 0.78rem; + font-weight: 500; + color: var(--bs-secondary-color, #6c757d); + background: var(--bs-tertiary-bg, #f8f9fa); + border: none; + border-bottom: 1px solid var(--bs-border-color, #dee2e6); + cursor: pointer; + text-align: left; +} + +.gd-marimo-setup-toggle:hover { + background: var(--bs-secondary-bg, #e9ecef); + color: var(--bs-body-color, #212529); +} + +.gd-marimo-setup-chevron { + flex-shrink: 0; + transition: transform 0.15s ease; +} + +.gd-marimo-island-group.gd-marimo-setup-open .gd-marimo-setup-chevron { + transform: rotate(90deg); +} + +/* Tint the revealed setup cell so it reads as distinct boilerplate. */ +.gd-marimo-island-group.gd-marimo-setup-open marimo-island[data-gd-setup] { + background: var(--bs-tertiary-bg, #f8f9fa); +} diff --git a/great_docs/assets/marimo-islands.js b/great_docs/assets/marimo-islands.js new file mode 100644 index 00000000..638977ad --- /dev/null +++ b/great_docs/assets/marimo-islands.js @@ -0,0 +1,409 @@ +/** + * marimo-islands.js — Lazy-loads marimo islands and handles copy-notebook. + * + * Attached to pages that use the {{< marimo >}} shortcode. + * - Uses IntersectionObserver to defer Pyodide boot until islands scroll into view. + * - Provides "Copy Notebook" button handler. + * - Syncs marimo's dark theme to the Great Docs site theme (marimo islands read a + * `.dark` class on an ancestor element). + * - Hides the "Initializing…" loader island once the notebook cells have hydrated. + */ +(function () { + "use strict"; + + // --- Copy Notebook Handler --- + function initCopyButtons() { + document.querySelectorAll(".gd-marimo-copy-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + var group = btn.closest(".gd-marimo-island-group"); + if (!group) return; + var sourceEl = group.querySelector("script.gd-marimo-source"); + if (!sourceEl) return; + + var text = sourceEl.textContent; + navigator.clipboard.writeText(text).then(function () { + var original = btn.innerHTML; + btn.innerHTML = + ' Copied!'; + btn.classList.add("gd-marimo-copied"); + setTimeout(function () { + btn.innerHTML = original; + btn.classList.remove("gd-marimo-copied"); + }, 2000); + }); + }); + }); + } + + // --- Theme Sync --- + // marimo islands render in the light DOM and pick up dark styling from a + // `.dark` class on an ancestor. The Great Docs site signals dark mode via a + // `quarto-dark` class / `data-bs-theme="dark"` on , so bridge the two. + function siteIsDark() { + var el = document.documentElement; + return ( + el.classList.contains("quarto-dark") || + el.getAttribute("data-bs-theme") === "dark" + ); + } + + function applyTheme() { + var dark = siteIsDark(); + document.querySelectorAll(".gd-marimo-island-group").forEach(function (group) { + // Per-shortcode override: data-theme="light" | "dark" | "auto" (default). + var mode = group.getAttribute("data-theme") || "auto"; + var isDark = mode === "dark" || (mode !== "light" && dark); + group.classList.toggle("dark", isDark); + }); + } + + function initThemeSync() { + if (document.querySelectorAll(".gd-marimo-island-group").length === 0) return; + applyTheme(); + // React to the site's dark-mode toggle (class / attribute changes on ). + var observer = new MutationObserver(applyTheme); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-bs-theme"], + }); + } + + // --- Hide the "Initializing…" loader once cells hydrate --- + // marimo's init island renders a spinner that boots the kernel but isn't + // auto-removed in embedded islands mode. marimo mounts its cells (CodeMirror + // editors, rendered output) inside shadow DOM, so detection must pierce shadow + // roots — and shadow mutations don't bubble to a light-DOM observer, so we + // poll rather than rely on MutationObserver. + function deepHas(root, selector) { + if (root.querySelector(selector)) return true; + var hosts = root.querySelectorAll("*"); + for (var i = 0; i < hosts.length; i++) { + if (hosts[i].shadowRoot && deepHas(hosts[i].shadowRoot, selector)) return true; + } + return false; + } + + // Inject style fixes into marimo's shadow roots (which our external stylesheet + // and CSS variables can't reach — CodeMirror's styles in particular live in + // adopted stylesheets): + // 1. `.marimo{color:inherit}` — the widget wrapper carries a hardcoded + // light-mode text color that marimo's own dark styling never overrides, + // so labels (e.g. a slider's) stay dark on a dark site. `!important` + // because marimo appends its own rule after ours. Only the container is + // forced to inherit; children with their own color are unaffected. + // 2. Shrink the CodeMirror font — marimo's `.9rem` renders large against the + // docs site's root, so code wraps early (worse on mobile / narrow content). + // Idempotent; recurses into nested roots. + function injectShadowStyles(root) { + var els = root.querySelectorAll("*"); + for (var i = 0; i < els.length; i++) { + var sr = els[i].shadowRoot; + if (!sr) continue; + var needed = sr.querySelector(".marimo") || sr.querySelector(".cm-editor"); + if (needed && !sr.querySelector("style[data-gd-marimo-fix]")) { + var st = document.createElement("style"); + st.setAttribute("data-gd-marimo-fix", "1"); + st.textContent = + ".marimo{color:inherit!important;}" + + ".cm-editor,.cm-content,.cm-line,.cm-gutters{font-size:0.78rem!important;}"; + sr.appendChild(st); + } + injectShadowStyles(sr); + } + } + + // Length of *rendered output* text. marimo renders cell output into shadow + // roots, so count only shadow-DOM text — this deliberately ignores the cell's + // hidden source (a light-DOM ), which the runtime strips + // slightly after boot and which must not count as "has output". + function renderedTextLen(root) { + var len = 0; + var hosts = root.querySelectorAll("*"); + for (var i = 0; i < hosts.length; i++) { + var sr = hosts[i].shadowRoot; + if (sr) len += (sr.textContent || "").trim().length + renderedTextLen(sr); + } + return len; + } + + function groupHasHydrated(group) { + // A code editor mounted (code visible) … + if (deepHas(group, ".cm-editor")) return true; + // … or a reactive cell rendered marimo output (no-code mode). marimo output + // lands in an element carrying the `marimo` class once hydrated. + var cells = group.querySelectorAll('marimo-island[data-reactive="true"]'); + for (var i = 0; i < cells.length; i++) { + if (deepHas(cells[i], ".markdown, .prose, table, img, .cm-editor")) return true; + } + return false; + } + + // In no-code mode, utility cells (e.g. `import marimo as mo`) are kept in the + // markup so the reactive kernel can run them, but render no visible output. + // Once cells have settled, collapse islands that produced neither text nor an + // interactive widget so they don't show as empty boxes. Cells that render only + // a widget (e.g. a bare slider) have no text but must be kept. + function pruneEmptyNocodeCells(group) { + if (!group.classList.contains("gd-marimo-nocode")) return; + group + .querySelectorAll('marimo-island[data-reactive="true"]') + .forEach(function (island) { + var hasText = renderedTextLen(island) > 0; + var hasWidget = deepHas( + island, + "input, button, select, textarea, [role=slider], canvas, svg, img, table" + ); + if (!hasText && !hasWidget) island.style.display = "none"; + }); + } + + // Collapse the "setup" cells behind a disclosure toggle so the boilerplate + // doesn't clutter the notebook. Setup cells are tagged at build time with + // `data-gd-setup` (where emptiness is authoritative — no render race) and + // hidden by CSS from first paint; this just wires up the reveal toggle. + // No-op in no-code mode (which prunes these cells entirely). + function wireSetupToggle(group) { + if (group.classList.contains("gd-marimo-nocode")) return; + if (group.classList.contains("gd-marimo-setup-done")) return; + var setupCells = group.querySelectorAll("marimo-island[data-gd-setup]"); + if (setupCells.length === 0) return; + group.classList.add("gd-marimo-setup-done"); + + var toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "gd-marimo-setup-toggle"; + toggle.setAttribute("aria-expanded", "false"); + var label = setupCells.length > 1 ? "Setup (" + setupCells.length + " cells)" : "Setup"; + toggle.innerHTML = + '' + + ''; + toggle.querySelector("span").textContent = label; + toggle.addEventListener("click", function () { + var open = group.classList.toggle("gd-marimo-setup-open"); + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + }); + setupCells[0].parentNode.insertBefore(toggle, setupCells[0]); + } + + // Reveal a group's cells and drop its loader. Called once the notebook has + // settled (or as a timeout fallback so a failed boot still shows something). + function revealGroup(group, loaderIsland) { + if (loaderIsland) loaderIsland.style.display = "none"; + group.classList.add("gd-marimo-ready"); + } + + function initPostHydrationCleanup() { + document.querySelectorAll(".gd-marimo-island-group").forEach(function (group) { + // Capture the loader island now, while its spinner is present: the runtime + // may clear the spinner before we hide it, and the empty island would then + // linger as a tall blank box above the content. + var spinner = group.querySelector(".animate-spin"); + var loaderIsland = spinner ? spinner.closest("marimo-island") : null; + if (loaderIsland) loaderIsland.classList.add("gd-marimo-loader"); + // Enter the loading state: CSS hides the assembling cells (reserving space) + // and centers the loader, so readers don't see cells shuffle/collapse as + // widgets mount. Added via JS so no-JS/failed-boot still shows static output. + group.classList.add("gd-marimo-booting"); + + // Wire the setup-collapse toggle up-front: the setup cells are tagged at + // build time and hidden by CSS already, so this is race-free. + wireSetupToggle(group); + + var elapsed = 0; + var settled = false; + var timer = setInterval(function () { + elapsed += 500; + // Re-apply on every tick: marimo mounts widget shadow roots lazily as + // cells execute, so late-mounted sliders/labels still get themed. The + // injection is idempotent, so repeating is cheap. + injectShadowStyles(group); + if (!settled && groupHasHydrated(group)) { + settled = true; + // Let widgets finish mounting, then prune empty cells and reveal. + setTimeout(function () { + injectShadowStyles(group); + pruneEmptyNocodeCells(group); + revealGroup(group, loaderIsland); + }, 1500); + } + if (elapsed >= 30000) { + // Fallback: never leave cells hidden if boot stalls. + revealGroup(group, loaderIsland); + clearInterval(timer); + } + }, 500); + }); + } + + // --- Lazy-Load Islands Runtime --- + var runtimeLoaded = false; + + function loadIslandsRuntime() { + if (runtimeLoaded) return; + runtimeLoaded = true; + + // The actual marimo islands JS/CSS is loaded via tags injected by + // the build pipeline. Once those are present, the custom elements + // () self-initialize. This function just marks that we've + // triggered observation. The CDN script handles the rest. + document.querySelectorAll(".gd-marimo-island-group").forEach(function (group) { + group.classList.add("gd-marimo-active"); + }); + } + + function initLazyLoad() { + var groups = document.querySelectorAll(".gd-marimo-island-group"); + if (groups.length === 0) return; + + if (!("IntersectionObserver" in window)) { + // Fallback: load immediately + loadIslandsRuntime(); + return; + } + + var observer = new IntersectionObserver( + function (entries) { + for (var i = 0; i < entries.length; i++) { + if (entries[i].isIntersecting) { + loadIslandsRuntime(); + observer.disconnect(); + return; + } + } + }, + { rootMargin: "200px" } + ); + + groups.forEach(function (group) { + observer.observe(group); + }); + } + + // --- Iframe auto-height --- + // Grow each iframe-mode notebook to fit its content so a tall notebook isn't + // stuck scrolling inside a fixed-height frame. Works because the WASM export is + // same-origin and marimo's editor flows naturally (its document height reflects + // the cell count + outputs). Cross-origin embeds silently keep the fixed height. + function initIframeAutosize() { + document.querySelectorAll("iframe.gd-marimo-iframe").forEach(function (iframe) { + // The shortcode `height` becomes the minimum (a placeholder while Pyodide + // boots); auto-sizing grows from there. + if (!iframe.style.minHeight && iframe.getAttribute("height")) { + iframe.style.minHeight = iframe.getAttribute("height"); + } + + // Neutralize marimo's viewport-based layout so the document ends right + // after the last cell (otherwise there's residual scroll inside the frame): + // - `#App [class*="pb-["]` is a `pb-[40vh]` scroll-past-end gutter that + // adds ~40% of the viewport below the last cell. + // - `#root` has `min-height: 100vh`, which also inflates the document. + // Injected into the same-origin iframe document. + function injectFitCss(doc) { + if (doc.getElementById("gd-marimo-fit")) return; + var st = doc.createElement("style"); + st.id = "gd-marimo-fit"; + st.textContent = + "#root{min-height:0 !important;}" + + '#App [class*="pb-["]{padding-bottom:1.5rem !important;}'; + (doc.head || doc.documentElement).appendChild(st); + } + + // With the gutter/min-height neutralized, the document's scrollHeight is an + // exact, stable measure of the content. Cap it at the last cell's bottom + + // margin so that if marimo ever changes those class names (fit CSS misses), + // the leftover gutter can't run the height away. + function contentHeight() { + var doc = iframe.contentDocument; + var win = iframe.contentWindow; + var cells = doc.querySelectorAll("[data-cell-id]"); + if (!cells.length) return 0; + var scrollY = (win && win.scrollY) || doc.documentElement.scrollTop || 0; + var lastBottom = 0; + for (var i = 0; i < cells.length; i++) { + var bottom = cells[i].getBoundingClientRect().bottom + scrollY; + if (bottom > lastBottom) lastBottom = bottom; + } + return Math.ceil(Math.min(doc.documentElement.scrollHeight, lastBottom + 200)); + } + + function sync() { + try { + injectFitCss(iframe.contentDocument); + var h = contentHeight(); + var cur = parseInt(iframe.style.height, 10) || 0; + if (h > 0 && Math.abs(h - cur) > 4) iframe.style.height = h + "px"; + } catch (e) { + /* cross-origin — leave the fixed height */ + } + } + + function attach() { + var doc; + try { + doc = iframe.contentDocument; + } catch (e) { + return; + } + if (!doc) return; + injectFitCss(doc); + sync(); + // Ongoing changes (outputs rendering, cells added/removed, a reactive + // output growing, edits) show up as DOM mutations. A ResizeObserver is + // no good here: marimo's containers are all `height:100%`, so content + // overflows via scrollHeight without any box changing size. Watch DOM + // mutations instead, debounced so bursts (typing, re-render) coalesce. + if (window.MutationObserver) { + var scheduled = false; + var schedule = function () { + if (scheduled) return; + scheduled = true; + setTimeout(function () { + scheduled = false; + sync(); + }, 150); + }; + var target = doc.getElementById("root") || doc.body; + if (target) { + new MutationObserver(schedule).observe(target, { + childList: true, + subtree: true, + characterData: true, + }); + } + } + // Boot window: Pyodide + auto-run render outputs over several seconds. + var n = 0; + var timer = setInterval(function () { + sync(); + if (++n > 60) clearInterval(timer); + }, 500); + } + + iframe.addEventListener("load", attach); + try { + if (iframe.contentDocument && iframe.contentDocument.readyState === "complete") { + attach(); + } + } catch (e) { + /* not ready yet — the load handler will fire */ + } + }); + } + + // --- Init --- + function init() { + initCopyButtons(); + initThemeSync(); + initPostHydrationCleanup(); + initLazyLoad(); + initIframeAutosize(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/great_docs/config.py b/great_docs/config.py index 2a3a7eb3..25b106ba 100644 --- a/great_docs/config.py +++ b/great_docs/config.py @@ -164,6 +164,7 @@ def _lift_legacy_site_keys(self, config: dict[str, Any]) -> dict[str, Any]: "social_cards", "markdown_pages", "mcp", + "marimo", ) def _normalize_shorthands(self, config: dict[str, Any]) -> dict[str, Any]: @@ -557,6 +558,27 @@ def mcp_categories(self) -> dict: """Get manual MCP tool categories.""" return self["mcp.categories"] + @property + def marimo_enabled(self) -> bool: + """Check if marimo islands integration is enabled.""" + return self["marimo.enabled"] + + @property + def marimo_version(self) -> str: + """Get the @marimo-team/islands CDN runtime version. + + Defaults to the installed marimo version so the browser runtime matches + the version that generated the island markup. An explicit ``version`` in + the config overrides this. + """ + version = self["marimo.version"] + if version: + return str(version) + + from great_docs._marimo import islands_runtime_version + + return islands_runtime_version() + @property def skill_enabled(self) -> bool: """Check if skill.md generation is enabled.""" diff --git a/great_docs/core.py b/great_docs/core.py index 5d252992..24c5706b 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -13,6 +13,30 @@ from ._subprocess import TEXT_MODE_KWARGS from .config import Config, create_default_config +# Injected into marimo `--mode edit` WASM exports (iframe mode). Those load inert +# — cells are stale and the kernel isn't instantiated, so nothing renders and +# nothing reacts until the reader runs the notebook. This clicks every cell's run +# button once the kernel is ready (a cell reports "outdated"), so the embedded +# notebook loads live and reactive. Run buttons remain in the DOM even when the +# editor chrome is hidden (?show-chrome=false), so this works in the trimmed view. +_MARIMO_AUTORUN_MARKER = "great-docs:marimo-autorun" +_MARIMO_AUTORUN_SCRIPT = """ + +""" + # Quarto's default input file types, enumerated as render globs. Used to seed # `project.render` whenever we also add `!` exclusions. A recursive `**` glob # overpowers any negation that follows it (so `!skill.md` would be ignored), @@ -322,6 +346,107 @@ def _prepare_build_directory(self) -> None: extensions_dst = self.project_path / "_extensions" shutil.copytree(extensions_src, extensions_dst, dirs_exist_ok=True) + # Copy notebooks directory and pre-generate marimo island HTML + if self._config.marimo_enabled: + import importlib.util + + notebooks_src = self.project_root / "notebooks" + if importlib.util.find_spec("marimo") is None: + # `marimo: true` is set but the package isn't installed. Warn and + # skip rather than crashing the whole build; pages using the + # {{< marimo >}} shortcode will show a "not generated" notice. + print( + "Warning: marimo notebooks are enabled in great-docs.yml but " + "the 'marimo' package is not installed; skipping notebook " + "island generation. Install it with: pip install marimo" + ) + elif notebooks_src.exists() and notebooks_src.is_dir(): + notebooks_dst = self.project_path / "notebooks" + shutil.copytree(notebooks_src, notebooks_dst, dirs_exist_ok=True) + + # Pre-generate island HTML for each .py notebook + # Marimo's import accesses sys.stdout.encoding at class-def time, + # so ensure the stream has that attribute before importing. + import io as _io + import sys as _sys + + _orig_stdout = _sys.stdout + _orig_stderr = _sys.stderr + if not hasattr(_sys.stdout, "encoding"): + _sys.stdout = _io.TextIOWrapper(_io.BytesIO(), encoding="utf-8") + if not hasattr(_sys.stderr, "encoding"): + _sys.stderr = _io.TextIOWrapper(_io.BytesIO(), encoding="utf-8") + try: + from great_docs._marimo import generate_islands_for_build + + islands_dir = self.project_path / "_marimo_islands" + islands_dir.mkdir(exist_ok=True) + for nb_file in notebooks_src.glob("*.py"): + out_file = islands_dir / f"{nb_file.stem}.html" + generate_islands_for_build(nb_file, out_file, reactive=True) + # Also generate nocode variant for show-code="false" + nocode_file = islands_dir / f"{nb_file.stem}-nocode.html" + generate_islands_for_build( + nb_file, nocode_file, display_code=False, reactive=True + ) + + # Generate WASM exports for iframe mode. + # `--mode edit` produces a fully editable, reactive notebook + # (readers can edit code and re-run, with dependents updating) + # rendered with marimo's full editor chrome. Inline island mode + # is the lightweight, read-only-code alternative. + import subprocess + + for nb_file in notebooks_src.glob("*.py"): + wasm_dir = self.project_path / "notebooks" / nb_file.stem + wasm_dir.mkdir(parents=True, exist_ok=True) + result = subprocess.run( + [ + _sys.executable, + "-m", + "marimo", + "export", + "html-wasm", + str(nb_file), + "-o", + str(wasm_dir) + "/", + "--mode", + "edit", + ], + input="n\n", + capture_output=True, + text=True, + ) + # Surface failures: a silent export failure otherwise + # leaves iframe-mode shortcodes pointing at a missing + # index.html (404) with no explanation in the build log. + index_html = wasm_dir / "index.html" + if result.returncode != 0 or not index_html.exists(): + _orig_stderr.write( + f"Warning: marimo WASM export failed for " + f"{nb_file.name} (iframe mode will 404). " + f"{(result.stderr or result.stdout or '').strip()[:500]}\n" + ) + else: + # Edit-mode exports load inert (cells stale, kernel not + # instantiated) — no outputs and no reactivity until the + # reader runs the notebook. Inject a small script that + # clicks every cell's run button once the kernel is ready, + # so the embedded notebook loads live and reactive. Works + # even with the chrome hidden (?show-chrome=false), since + # the run buttons stay in the DOM. + html = index_html.read_text(encoding="utf-8") + if _MARIMO_AUTORUN_MARKER not in html: + index_html.write_text( + html.replace( + "", _MARIMO_AUTORUN_SCRIPT + "", 1 + ), + encoding="utf-8", + ) + finally: + _sys.stdout = _orig_stdout + _sys.stderr = _orig_stderr + # Copy lightbox assets (JS + CSS live with the extension but also need # to be available as top-level resources for the Lua filter's injection) lb_ext = self.assets_path / "_extensions" / "gd-lightbox" @@ -382,12 +507,21 @@ def _prepare_build_directory(self) -> None: js_files.append("skill-switcher.js") # termshow player is always available (lightweight, only activates if shortcode used) js_files.append("termshow.js") + # marimo islands (only when enabled) + if self._config.marimo_enabled: + js_files.append("marimo-islands.js") for js_file in js_files: js_src = self.assets_path / js_file if js_src.exists(): js_dst = self.project_path / js_file shutil.copy2(js_src, js_dst) + # Copy marimo CSS when enabled + if self._config.marimo_enabled: + marimo_css_src = self.assets_path / "marimo-islands.css" + if marimo_css_src.exists(): + shutil.copy2(marimo_css_src, self.project_path / "marimo-islands.css") + # Create .gitignore for the great-docs directory gitignore_content = """# Great Docs build directory # This directory is ephemeral and regenerated on each build @@ -11611,6 +11745,20 @@ def _update_quarto_config(self) -> None: if "termshow.css" not in config["project"]["resources"]: config["project"]["resources"].append("termshow.css") + # Add marimo islands resources when enabled + if self._config.marimo_enabled: + for marimo_res in ("marimo-islands.js", "marimo-islands.css"): + if marimo_res not in config["project"]["resources"]: + config["project"]["resources"].append(marimo_res) + # Include notebooks directory so .py files are available to the shortcode + notebooks_dir = self.project_path / "notebooks" + if notebooks_dir.exists() and notebooks_dir.is_dir(): + if "notebooks/**" not in config["project"]["resources"]: + config["project"]["resources"].append("notebooks/**") + # Include pre-generated island HTML fragments + if "_marimo_islands/**" not in config["project"]["resources"]: + config["project"]["resources"].append("_marimo_islands/**") + # Add gd-lightbox assets as resources for lb_res in ( "gd-lightbox.js", @@ -11784,6 +11932,48 @@ def _update_quarto_config(self) -> None: ): config["format"]["html"]["include-in-header"].append(tp_css_entry) + # Add marimo islands runtime (CDN JS/CSS) when enabled + if self._config.marimo_enabled: + from great_docs._marimo import get_islands_head_html + + marimo_version = self._config.marimo_version + marimo_entry = {"text": get_islands_head_html(marimo_version)} + if not any( + "marimo-team/islands" in str(item) + for item in config["format"]["html"]["include-in-header"] + ): + config["format"]["html"]["include-in-header"].append(marimo_entry) + + # Add marimo-islands.js (lazy-load + copy handler) + marimo_js_entry = { + "text": ( + "" + ) + } + if not any( + "marimo-islands.js" in str(item) + for item in config["format"]["html"]["include-in-header"] + ): + config["format"]["html"]["include-in-header"].append(marimo_js_entry) + + # Add marimo-islands.css + marimo_css_entry = { + "text": ( + "" + ) + } + if not any( + "marimo-islands.css" in str(item) + for item in config["format"]["html"]["include-in-header"] + ): + config["format"]["html"]["include-in-header"].append(marimo_css_entry) + # Add gd-lightbox CSS (uses quarto:offset for subdirectory-safe paths) lb_css_entry = { "text": ( diff --git a/notebooks/gt-basics.py b/notebooks/gt-basics.py new file mode 100644 index 00000000..a6416c8b --- /dev/null +++ b/notebooks/gt-basics.py @@ -0,0 +1,110 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "marimo", +# # great-tables >= 0.22 depends on multimark, a compiled package with no +# # pure-Python / WASM wheel, so it can't install under Pyodide. Pin to the +# # last pure-Python release (0.21.x) so the notebook runs in the browser. +# "great-tables<0.22", +# "polars", +# ] +# /// + +import marimo + +app = marimo.App() + + +@app.cell +def __(): + import marimo as mo + + return (mo,) + + +@app.cell +async def __(): + import sys + + if "pyodide" in sys.modules: + import micropip + + await micropip.install(["great-tables<0.22", "polars"]) + + import great_tables as gt + import polars as pl + + return gt, pl + + +@app.cell +def __(mo): + mo.md( + """ + # Getting Started with Great Tables + + This notebook builds a styled table with **Great Tables**. Drag the + slider below — the table redraws reactively as the value changes. + """ + ) + return + + +@app.cell +def __(pl): + # The full dataset — a slider chooses how many rows to show. + students = pl.DataFrame( + { + "name": ["Alice", "Bob", "Charlie", "Diana", "Evan", "Fiona", "Grace", "Hugo"], + "score": [95, 87, 92, 88, 79, 96, 84, 91], + "grade": ["A", "B+", "A-", "B+", "C+", "A", "B", "A-"], + } + ) + return (students,) + + +@app.cell +def __(mo): + top_n = mo.ui.slider(1, 8, value=4, label="Show top N students") + top_n + return (top_n,) + + +@app.cell +def __(gt, students, top_n): + # Reactive: re-runs whenever the slider moves. + df = students.sort("score", descending=True).head(top_n.value) + + ( + gt.GT(df) + .tab_header( + title="Student Scores", + subtitle=f"Top {top_n.value} of {students.height}, Fall 2026", + ) + .cols_label( + name="Student", + score="Score", + grade="Grade", + ) + .data_color( + columns="score", + palette=["#fde725", "#21918c"], + ) + ) + return (df,) + + +@app.cell +def __(mo): + mo.md( + """ + Every cell that depends on the slider re-runs the moment its value + changes — no "Run" button required. In iframe mode you can also edit + the code above and re-run it live. + """ + ) + return + + +if __name__ == "__main__": + app.run() diff --git a/notebooks/gt-output-demo.py b/notebooks/gt-output-demo.py new file mode 100644 index 00000000..0981bd6c --- /dev/null +++ b/notebooks/gt-output-demo.py @@ -0,0 +1,67 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "marimo", +# # great-tables >= 0.22 depends on multimark, a compiled package with no +# # pure-Python / WASM wheel, so it can't install under Pyodide. Pin to the +# # last pure-Python release (0.21.x) so the notebook runs in the browser. +# "great-tables<0.22", +# "polars", +# ] +# /// + +import marimo + +app = marimo.App() + + +@app.cell +def __(): + return + + +@app.cell +async def __(): + import sys + + if "pyodide" in sys.modules: + import micropip + + await micropip.install(["great-tables<0.22", "polars"]) + + import great_tables as _gt + import polars as _pl + + # Monthly sales data + sales = _pl.DataFrame( + { + "month": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], + "revenue": [12400, 15800, 14200, 18900, 21300, 19700], + "growth": [None, 0.274, -0.101, 0.331, 0.127, -0.075], + } + ) + + ( + _gt.GT(sales) + .tab_header( + title="Monthly Revenue", + subtitle="H1 2026 Performance", + ) + .cols_label( + month="Month", + revenue="Revenue", + growth="Growth", + ) + .fmt_currency(columns="revenue", decimals=0) + .fmt_percent(columns="growth", decimals=1) + .data_color( + columns="revenue", + palette=["#f0f9e8", "#0868ac"], + ) + .sub_missing(missing_text="—") + ) + return + + +if __name__ == "__main__": + app.run() diff --git a/notebooks/reactive-intro.py b/notebooks/reactive-intro.py new file mode 100644 index 00000000..403f93d4 --- /dev/null +++ b/notebooks/reactive-intro.py @@ -0,0 +1,101 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "marimo", +# ] +# /// + +import marimo + +app = marimo.App() + + +@app.cell +def __(): + import marimo as mo + + return (mo,) + + +@app.cell +def __(mo): + mo.md( + """ + # A Reactive Notebook + + marimo notebooks are **reactive**: change a value and every cell that + depends on it re-runs on its own — no "Run" button, no page reload. + Drag the sliders below and watch the summary, chart, and table update + together. (This notebook uses only marimo, so it boots instantly.) + """ + ) + return + + +@app.cell +def __(mo): + bars = mo.ui.slider(1, 12, value=5, label="Number of bars") + peak = mo.ui.slider(20, 100, value=70, label="Tallest bar (px)") + mo.hstack([bars, peak], justify="start", gap=2) + return bars, peak + + +@app.cell +def __(bars, mo, peak): + mo.md( + f"You asked for **{bars.value}** bars with a peak height of " + f"**{peak.value}px**. Everything below recomputes from those two values." + ) + return + + +@app.cell +def __(bars, peak): + # Deterministic pseudo-data so the demo is stable but varied. + values = [((i * 37 + 13) % 100) + 1 for i in range(bars.value)] + return (values,) + + +@app.cell +def __(mo, peak, values): + # A dependency-free bar chart, drawn as inline SVG straight from Python. + width, gap = 30, 10 + top = peak.value + rects = "".join( + f'' + for i, v in enumerate(values) + ) + svg = ( + f'{rects}' + ) + mo.Html(svg) + return + + +@app.cell +def __(mo, values): + mo.ui.table( + [{"bar": i + 1, "value": v} for i, v in enumerate(values)], + selection=None, + ) + return + + +@app.cell +def __(mo): + mo.md( + """ + Notice you never touched the chart or table cells — they depend on the + sliders, so marimo re-ran them for you. In **iframe mode** you can go a + step further and edit this code yourself. + """ + ) + return + + +if __name__ == "__main__": + app.run() diff --git a/pyproject.toml b/pyproject.toml index ab0c331e..2c6f4687 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,12 @@ svg = [ mcp = [ "mcp>=1.0.0", ] +marimo = [ + "marimo>=0.20.0", + # marimo's `export html-wasm` (iframe mode) shells out to uv to resolve the + # notebook's inline (PEP 723) dependencies. + "uv>=0.5.0", +] dev = [ "pytest>=6.0", "pytest-cov>=3.0", diff --git a/test-packages/synthetic/catalog.py b/test-packages/synthetic/catalog.py index 2e60be4d..7d7312c4 100644 --- a/test-packages/synthetic/catalog.py +++ b/test-packages/synthetic/catalog.py @@ -412,6 +412,8 @@ "gdtest_type_aliases", # 204 # 205: Complete docstrings for non-class/function objects "gdtest_complete_docstrings", # 205 + # 206: Marimo notebook islands showcase + "gdtest_marimo", # 206 ] diff --git a/test-packages/synthetic/specs/gdtest_marimo.py b/test-packages/synthetic/specs/gdtest_marimo.py new file mode 100644 index 00000000..fd3c5821 --- /dev/null +++ b/test-packages/synthetic/specs/gdtest_marimo.py @@ -0,0 +1,212 @@ +""" +gdtest_marimo — Verify the marimo notebook integration (islands). + +Focus: The `{{< marimo >}}` shortcode + `marimo: true` config. Exercises the + build-time island generation (MarimoIslandGenerator) and the browser-side + @marimo-team/islands runtime. + +Uses a lightweight, dependency-free notebook (marimo only, no micropip installs) +so the WASM kernel boots fast and reliably during iteration — this isolates the +island *rendering mechanics* from package-install concerns. +""" + +# A minimal reactive marimo notebook: a slider whose value drives a dependent +# markdown cell. Demonstrates island rendering + reactivity with no external deps. +_DEMO_NOTEBOOK = '''# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "marimo", +# ] +# /// + +import marimo + +app = marimo.App() + + +@app.cell +def __(): + import marimo as mo + return (mo,) + + +@app.cell +def __(mo): + mo.md( + """ + # Interactive Demo + + Drag the slider below — the output updates reactively. + """ + ) + return + + +@app.cell +def __(mo): + n = mo.ui.slider(1, 20, value=5, label="How many?") + n + return (n,) + + +@app.cell +def __(mo, n): + mo.md(f"You chose **{n.value}**. Its square is **{n.value ** 2}**.") + return + + +@app.cell +def __(mo): + factor = mo.ui.slider(1, 5, value=2, label="Multiplier") + factor + return (factor,) + + +@app.cell +def __(factor, mo, n): + mo.md(f"**{n.value}** × **{factor.value}** = **{n.value * factor.value}**") + return + + +@app.cell +def __(mo): + mo.md( + """ + ## Notes + + This notebook is intentionally longer to show that the iframe grows to + fit however many cells you add: + + - Cells run in the browser via WebAssembly (Pyodide) + - Edit any cell and press Ctrl+Enter to re-run + - Dependent cells update reactively + """ + ) + return + + +@app.cell +def __(mo, n): + mo.ui.table( + [{"i": i, "i_squared": i * i} for i in range(1, n.value + 1)], + selection=None, + ) + return + + +if __name__ == "__main__": + app.run() +''' + +SPEC = { + "name": "gdtest_marimo", + "description": "Interactive marimo notebook islands via the {{< marimo >}} shortcode", + "dimensions": ["A1", "B1", "C4", "D2", "E6", "F1", "G1", "H7"], + "pyproject_toml": { + "project": { + "name": "gdtest-marimo", + "version": "1.0.0", + "description": "A package demonstrating embedded marimo notebooks", + }, + "build-system": { + "requires": ["setuptools"], + "build-backend": "setuptools.build_meta", + }, + }, + "files": { + # ── Python module (minimal) ────────────────────────────────────── + "gdtest_marimo/__init__.py": ( + '"""Marimo islands demo package."""\n' + "\n" + '__version__ = "1.0.0"\n' + '__all__ = ["greet"]\n' + "\n" + "\n" + "def greet(name: str) -> str:\n" + ' """Return a friendly greeting.\n' + "\n" + " Parameters\n" + " ----------\n" + " name\n" + " Who to greet.\n" + "\n" + " Returns\n" + " -------\n" + " str\n" + " The greeting.\n" + ' """\n' + ' return f"Hello, {name}!"\n' + ), + # ── The marimo notebook the shortcode embeds ───────────────────── + "notebooks/demo.py": _DEMO_NOTEBOOK, + # ── User guide: island mode (default) ──────────────────────────── + "user_guide/01-islands.qmd": ( + "---\n" + "title: Marimo Islands\n" + "---\n" + "\n" + "# Interactive Notebook (Island Mode)\n" + "\n" + "The notebook below is embedded with the default island mode. Its cells\n" + "run in the browser via WebAssembly (Pyodide).\n" + "\n" + '{{< marimo file="notebooks/demo.py" >}}\n' + ), + # ── User guide: hide code (outputs only) ───────────────────────── + "user_guide/02-nocode.qmd": ( + "---\n" + "title: Outputs Only\n" + "---\n" + "\n" + "# Outputs Only (show-code=false)\n" + "\n" + "The same notebook, rendered with the source hidden — useful for\n" + "dashboard-style presentations.\n" + "\n" + '{{< marimo file="notebooks/demo.py" show-code="false" >}}\n' + ), + # ── User guide: iframe mode (full notebook, self-hosted WASM) ───── + "user_guide/03-iframe.qmd": ( + "---\n" + "title: Iframe Mode\n" + "---\n" + "\n" + "# Full Notebook (Iframe Mode)\n" + "\n" + "The same notebook embedded as a **fully editable, reactive** Marimo\n" + "notebook in a sandboxed iframe (self-hosted WASM, full editor chrome).\n" + "Edit any cell and press Ctrl+Enter — dependent cells re-run live.\n" + "\n" + '{{< marimo file="notebooks/demo.py" mode="iframe" height="760px" >}}\n' + ), + }, + "config": { + "marimo": True, + "dark_mode": True, + }, + "expected": { + "files_exist": [ + "reference/index.html", + "reference/greet.html", + "user-guide/islands.html", + "user-guide/nocode.html", + "user-guide/iframe.html", + "notebooks/demo/index.html", + ], + "files_contain": { + "user-guide/islands.html": [ + "marimo-island", + "gd-marimo-island-group", + "gd-marimo-copy-btn", + ], + "user-guide/nocode.html": [ + "gd-marimo-nocode", + ], + "user-guide/iframe.html": [ + "gd-marimo-iframe", + "notebooks/demo/index.html", + ], + }, + "coverage_exclude": ['ref', 'nodoc', 'bigcl', 'ug', 'supp', 'title', 'badge', 'sig', 'desc', 'param', 'pmatch', 'ret', 'refidx', 'sechdg', 'sbsec', 'hdg'], + }, +} diff --git a/tests/test_config_defaults.py b/tests/test_config_defaults.py index 7cf8a747..fc8fa4b0 100644 --- a/tests/test_config_defaults.py +++ b/tests/test_config_defaults.py @@ -30,6 +30,7 @@ "site_url": None, "source": {"enabled": True, "branch": None, "path": None, "placement": "usage"}, "sidebar_filter": {"enabled": True, "min_items": 20}, + "marimo": {"enabled": False, "version": None}, "cli": { "enabled": False, "module": None, diff --git a/user_guide/43-marimo-notebooks.qmd b/user_guide/43-marimo-notebooks.qmd new file mode 100644 index 00000000..2d69fc82 --- /dev/null +++ b/user_guide/43-marimo-notebooks.qmd @@ -0,0 +1,311 @@ +--- +title: "Marimo Notebooks" +guide-section: "Site Content" +bread-crumbs: false +tags: [Content, Extensions, Interactive, Notebooks] +status: experimental +--- + +# Marimo Notebooks + +Great Docs can embed interactive [Marimo](https://marimo.io/) notebooks directly in your +documentation pages. Readers run code examples live in their browser (no installation required) +powered by WebAssembly (Pyodide). They can also copy the notebook source to run locally with +`marimo edit`. + +There are two ways to embed a notebook, and they make different trade-offs: + +- **Island mode** (default) renders cells inline in the page. Outputs and `mo.ui` widgets are +live (drag a slider and the output updates) but the code shown is **read-only**. It's the +lightweight choice for illustrating a result in the flow of your prose. +- **Iframe mode** embeds a **fully editable, reactive** notebook. Readers edit any cell, re-run it, +and dependent cells recompute. It's the choice when you want readers to experiment with the code +itself, not just its inputs. + +Pick island mode to *show* a live result, and pick iframe mode to let readers *rewrite* it. + +## How It Works + +Marimo notebooks are Python files with `@app.cell` decorators. What Great Docs does with them at +build time depends on the mode. + +**Island mode** uses the [`@marimo-team/islands`](https://docs.marimo.io/guides/exporting/#islands) +runtime: + +1. Reads the `.py` notebook at build time +2. Extracts each cell and emits `` HTML elements +3. On page load, the islands runtime boots a Pyodide kernel in the browser +4. `mo.ui` widgets become interactive and changing a widget re-runs the cells that depend on it + +The code editors in island mode are for display only. Editing the source text does not re-execute. +Reactivity flows from the UI controls, not from edits to the code. + +**Iframe mode** self-hosts a full WASM export: + +1. Runs `marimo export html-wasm --mode edit` on the notebook at build time +2. Bundles the result as static assets and embeds it in a sandboxed `