Skip to content

Commit e7ba7bc

Browse files
authored
feat: rich notebook previews through OpenGraph metadata (#8097)
## 📝 Summary Adds notebook-level OpenGraph metadata (PEP 723 + [Next.js-inspired](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) optional generator hook) with thumbnail generation plumbing. As a first use case, wired it up to improve gallery cards in `marimo run <dir>`. Follow-up of #8056. ## 🔍 Description of Changes - Introduced `OpenGraphMetadata` for notebooks via PEP 723: `[tool.marimo.opengraph]` with `title`, `description`, `image`, and optional `generator` - `image` supports either an HTTPS URL or a notebook-relative path - Added Next.js-style merge semantics for generators: static PEP 723 fields are the base; generator can override only the fields it returns - Added a canonical thumbnail endpoint: `GET /api/home/thumbnail?file=...` - redirects to HTTPS images - serves notebook-relative images only from the notebook’s `__marimo__/` directory - falls back to a branded placeholder SVG when no image exists - Inject OG tags into notebook pages (`og:title`, `og:description`, `og:image`) - Gallery cards now prefer `FileInfo.opengraph` (title/description/image) and fall back to the existing title-casing behavior - Added `marimo tools thumbnails generate ...` to write thumbnails to `__marimo__/assets/<stem>/opengraph.png` - default is `--no-execute` (fast; no cell outputs) - opt-in `--execute` to include outputs - opt-in `--sandbox` (only applies with `--execute`) ## 📸 Thumbnails > in the examples below `marimo-team/learn` is a local clone of https://github.com/marimo-team/learn ### Auto-generated thumbnails without `playwright` No file gets written to disk. The `/thumbnail` API endpoint calls `DEFAULT_OPENGRAPH_PLACEHOLDER_IMAGE_GENERATOR` to construct an SVG dynamically and serves it. <img width="1111" height="1314" alt="Screenshot 2026-02-03 at 14 36 25" src="https://github.com/user-attachments/assets/73cdaa8c-ba4a-4c4e-a27d-cb48163b17b5" /> ### Auto-generated thumbnails with `playwright`, but without notebook execution Renders code blocks without outputs, similar to Observable, as suggested by @manzt. ``` marimo tools thumbnails generate marimo-team/learn marimo run marimo-team/learn ``` <img width="1111" height="1314" alt="Screenshot 2026-02-03 at 14 40 50" src="https://github.com/user-attachments/assets/d02611bd-e094-4031-ac7b-fec82330960a" /> ### Auto-generated thumbnails with `playwright`, with sandboxed notebook execution for cell outputs > If you have generated thumbnails, pass ` --overwrite` to `marimo tools thumbnails` to ensure they get replaced. ``` marimo tools thumbnails generate marimo-team/learn --execute --sandbox marimo run marimo-team/learn ``` <img width="1111" height="1314" alt="Screenshot 2026-02-03 at 14 49 52" src="https://github.com/user-attachments/assets/9d6e0ea4-3845-436f-9ab5-9defd274cdc6" /> ## ⚡️ Generators Users can define custom functions to return OG metadata based on bespoke logic. Below an example of a notebook (1) defining `generator = "generate_opengraph"` in PEP 723 then (2) implementing `def generate_opengraph(context, parent):` as `@app.function` to return the metadata dynamically, yielding a custom card from with image from https://placehold.co/1200x630/png. <img width="374" height="345" alt="Screenshot 2026-02-03 at 14 58 42" src="https://github.com/user-attachments/assets/478af1c1-e6f3-4fc7-a79f-e604f39b591d" /> ```python # /// script # dependencies = [ # "marimo>=0.19.0", # "pyzmq>=27.1.0", # ] # [tool.marimo.opengraph] # description = "The description is static, but the title and image have been computed dynamically using a generator function defined within the notebook." # generator = "generate_opengraph" # /// import marimo __generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell(hide_code=True) def _(mo): mo.md(""" # Dynamic OpenGraph Image """) return @app.function def generate_opengraph(context, parent): import datetime as dt from pathlib import Path from urllib.parse import quote_plus # Merge behavior: we return `title` and `image`, so static PEP 723 description # remains intact, as `description` is already present in `parent` label = quote_plus(dt.datetime.now().isoformat()) return { "title": f"Dynamic OpenGraph", "image": f"https://placehold.co/1200x630/png?text={label}" } @app.cell(hide_code=True) def _(): import marimo as mo return (mo,) if __name__ == "__main__": app.run() ```
1 parent 892a44d commit e7ba7bc

File tree

19 files changed

+1888
-27
lines changed

19 files changed

+1888
-27
lines changed

frontend/src/components/pages/gallery-page.tsx

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ const tabTarget = (path: string): string => {
3333
return `${getSessionId()}-${encodeURIComponent(path)}`;
3434
};
3535

36+
const isHttpsUrl = (value: string): boolean => {
37+
try {
38+
const url = new URL(value);
39+
return url.protocol === "https:";
40+
} catch {
41+
return false;
42+
}
43+
};
44+
3645
const SEARCH_THRESHOLD = 10;
3746

3847
const GalleryPage: React.FC = () => {
@@ -43,28 +52,39 @@ const GalleryPage: React.FC = () => {
4352
[],
4453
);
4554
const workspace = response.data;
46-
const files = workspace?.files ?? [];
47-
const root = workspace?.root ?? "";
4855

4956
const formattedFiles = useMemo(() => {
57+
const files = workspace?.files ?? [];
58+
const root = workspace?.root ?? "";
5059
return files
5160
.filter((file) => !file.isDirectory)
5261
.map((file) => {
5362
const relativePath =
5463
root && Paths.isAbsolute(file.path) && file.path.startsWith(root)
5564
? Paths.rest(file.path, root)
5665
: file.path;
57-
const title = titleCase(Paths.basename(relativePath));
66+
const title =
67+
file.opengraph?.title ?? titleCase(Paths.basename(relativePath));
5868
const subtitle = titleCase(Paths.dirname(relativePath));
69+
const description = file.opengraph?.description ?? "";
70+
const opengraphImage = file.opengraph?.image;
71+
const thumbnailUrl =
72+
opengraphImage && isHttpsUrl(opengraphImage)
73+
? opengraphImage
74+
: asURL(
75+
`/og/thumbnail?file=${encodeURIComponent(relativePath)}`,
76+
).toString();
5977
return {
6078
...file,
6179
relativePath,
6280
title,
6381
subtitle,
82+
description,
83+
thumbnailUrl,
6484
};
6585
})
6686
.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
67-
}, [files, root]);
87+
}, [workspace?.files, workspace?.root]);
6888

6989
const filteredFiles = useMemo(() => {
7090
if (!searchQuery) {
@@ -130,8 +150,14 @@ const GalleryPage: React.FC = () => {
130150
target={tabTarget(file.path)}
131151
className="no-underline"
132152
>
133-
<Card className="h-full hover:bg-accent/20 transition-colors">
134-
<CardContent className="p-6">
153+
<Card className="h-full overflow-hidden hover:bg-accent/20 transition-colors">
154+
<img
155+
src={file.thumbnailUrl}
156+
alt={file.title}
157+
loading="lazy"
158+
className="w-full aspect-1200/630 object-cover border-b border-border/60"
159+
/>
160+
<CardContent className="p-6 pt-4">
135161
<div className="flex flex-col gap-1">
136162
{file.subtitle && (
137163
<div className="text-sm font-semibold text-muted-foreground">
@@ -141,6 +167,11 @@ const GalleryPage: React.FC = () => {
141167
<div className="text-lg font-medium">
142168
{file.title}
143169
</div>
170+
{file.description && (
171+
<div className="text-sm text-muted-foreground line-clamp-3 mt-1">
172+
{file.description}
173+
</div>
174+
)}
144175
</div>
145176
</CardContent>
146177
</Card>

marimo/_cli/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from marimo._cli.run_docker import (
2828
prompt_run_in_docker_container,
2929
)
30+
from marimo._cli.tools.commands import tools
3031
from marimo._cli.upgrade import check_for_updates, print_latest_version
3132
from marimo._cli.utils import (
3233
check_app_correctness,
@@ -1476,3 +1477,4 @@ def check(
14761477
main.add_command(export)
14771478
main.add_command(config)
14781479
main.add_command(development)
1480+
main.add_command(tools)

marimo/_cli/development/commands.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def _generate_server_api_schema() -> dict[str, Any]:
2828
import marimo._data.models as data
2929
import marimo._messaging.errors as errors
3030
import marimo._messaging.notification as notifications
31+
import marimo._metadata.opengraph as opengraph
3132
import marimo._runtime.commands as commands
3233
import marimo._secrets.models as secrets_models
3334
import marimo._server.models.completion as completion
@@ -132,6 +133,7 @@ def _generate_server_api_schema() -> dict[str, Any]:
132133
ToolDefinition,
133134
# Sub components
134135
home.MarimoFile,
136+
opengraph.OpenGraphMetadata,
135137
files.FileInfo,
136138
commands.ExecuteCellCommand,
137139
snippets.SnippetSection,

marimo/_cli/tools/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Copyright 2026 Marimo. All rights reserved.

marimo/_cli/tools/commands.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Copyright 2026 Marimo. All rights reserved.
2+
from __future__ import annotations
3+
4+
import click
5+
6+
from marimo._cli.tools.thumbnails import thumbnails
7+
8+
9+
@click.group(help="Utilities for marimo notebooks.")
10+
def tools() -> None:
11+
pass
12+
13+
14+
tools.add_command(thumbnails)

0 commit comments

Comments
 (0)