Skip to content

Commit 286f15f

Browse files
feat: support using README.py (marimo notebook) to replace README.md in the home page
1 parent 8290757 commit 286f15f

13 files changed

Lines changed: 256 additions & 24 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ on:
1515
- 'afterpython/_website/**'
1616
- 'afterpython/authors.yml'
1717
- 'afterpython/faq.yml'
18+
- 'afterpython/README.py'
1819
- '.github/workflows/deploy.yml'
1920
workflow_dispatch: # Allow manual deployment from Actions tab
2021

afterpython/README_sample.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import marimo
2+
3+
__generated_with = "0.23.4"
4+
app = marimo.App()
5+
6+
7+
@app.cell(hide_code=True)
8+
def _():
9+
import marimo as mo
10+
11+
return (mo,)
12+
13+
14+
@app.cell(hide_code=True)
15+
def _(mo):
16+
mo.md(r"""
17+
# Test
18+
""")
19+
return
20+
21+
22+
@app.cell
23+
def _():
24+
print("Hello AfterPython")
25+
return
26+
27+
28+
if __name__ == "__main__":
29+
app.run()

afterpython/_website/.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,6 @@ static/blog
3636
static/tutorial
3737
static/example
3838
static/guide
39-
static/*.json
39+
static/llms.txt
40+
static/readme_py/
41+
static/*.json

afterpython/afterpython.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ logo = "logo.svg"
1010
logo_dark = "logo.svg"
1111
thumbnail = "thumbnail.png" # thumbnail for the website, also used as default thumbnail for all content types, e.g. blog posts, tutorials, examples, and guides
1212
announcement = ""
13+
readme_py = "wasm" # marimo html export format: "wasm" or "static"
1314

1415
# [website.blog]
1516
# thumbnail = "blog_default_thumbnail.png" # inside blog/static/ folder, default thumbnail for blog posts

afterpython/doc/project_website.md

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,24 @@ featured_post = "getting-started.md"
146146

147147
---
148148
## Built-in Features
149-
- full-text search using [PageFind]
150-
- 🚧 AI chatbot using [WebLLM]
149+
150+
### Search
151+
A search bar in the navigation lets users search across all of your content (docs, blog posts, tutorials, etc.) at once. Powered by [PageFind] — fully client-side, no server needed.
152+
153+
### README.py
154+
Drop a marimo notebook at `afterpython/README.py` to replace the markdown `README.md` rendering in the home page's central section. AfterPython detects the file, exports it to HTML, and embeds it on the landing page.
155+
156+
Choose the export mode in `afterpython.toml`:
157+
158+
```toml
159+
[website]
160+
readme_py = "wasm" # or "static"
161+
```
162+
163+
- **`wasm`** (default) — interactive. Cells run in the browser via Pyodide. Great for live demos of your package, but adds a ~10MB+ Pyodide download on first visit. Won't work for packages with C extensions that aren't ported to Pyodide.
164+
- **`static`** — pre-rendered HTML, no runtime. Lighter, but cells can't execute. AfterPython adds an "Open in molab" badge so users can still run the notebook on a real Python server hosted by marimo.
165+
166+
`README.md` is still required (PyPI uses it for the long description) and is shown if `README.py` is absent or isn't a marimo notebook.
151167

152168
### FAQs
153169
`afterpython/faq.yml` is rendered as the FAQs section on the project website. Each item needs a `question` and `answer`; `category` is optional.
@@ -181,10 +197,12 @@ announcement = "🎉 v2.0 is out — [read the changelog](/blog/v2-release)"
181197

182198
For longer messages, use a triple-quoted string. Keep it concise — the banner is meant for a one-glance heads-up, not a full announcement post. Leave it as `""` to hide the banner.
183199

184-
### API Reference
200+
### 🚧 AI chatbot using [WebLLM]
201+
202+
### 🚧 API Reference
185203
[great-docs] will be used to build the API Reference section on the project website.
186204

187-
### Google Analytics
205+
### 🚧 Google Analytics
188206
add google analytics support for the entire website
189207

190208
### Compatibility

src/afterpython/builders/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
)
77
from afterpython.builders.jupyter_notebook import build_jupyter_notebooks
88
from afterpython.builders.llms_txt import build_llms_txt
9+
from afterpython.builders.marimo_notebook import build_marimo_readme
910
from afterpython.builders.markdown import build_markdown
1011
from afterpython.builders.metadata import build_metadata
1112
from afterpython.builders.url_md import build_url_md
@@ -15,6 +16,7 @@
1516
"build_faq_json",
1617
"build_jupyter_notebooks",
1718
"build_llms_txt",
19+
"build_marimo_readme",
1820
"build_markdown",
1921
"build_metadata",
2022
"build_url_md",

src/afterpython/builders/jupyter_notebook.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,11 @@
55
import click
66

77
import afterpython as ap
8+
from afterpython.builders.marimo_notebook import _create_molab_url, _get_molab_badge
89
from afterpython.const import CONTENT_TYPES
910
from afterpython.tools.pyproject import read_metadata
1011

1112

12-
def _get_molab_badge() -> str:
13-
return "https://marimo.io/molab-shield.svg"
14-
15-
16-
def _create_molab_url(github_url: str, content_path: Path):
17-
"""Create a molab URL for a given content type and notebook path.
18-
Args:
19-
github_url: str, e.g. "https://github.com/AfterPythonOrg/afterpython"
20-
content_path: str, e.g. "tutorial/test.ipynb"
21-
"""
22-
github_url = github_url.replace("https://github.com/", "github/")
23-
return f"https://molab.marimo.io/{github_url}/blob/main/afterpython/{content_path.as_posix()}"
24-
25-
2613
def _read_notebook(notebook_path: Path, content_path: Path) -> dict | None:
2714
"""Read and validate a Jupyter notebook.
2815
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import re
5+
import subprocess
6+
from pathlib import Path
7+
from typing import Literal
8+
9+
import click
10+
11+
import afterpython as ap
12+
from afterpython.tools.pyproject import read_metadata
13+
14+
MarimoExportMode = Literal["wasm", "static"]
15+
16+
17+
def _get_molab_badge() -> str:
18+
return "https://marimo.io/molab-shield.svg"
19+
20+
21+
def _create_molab_url(github_url: str, content_path: Path) -> str:
22+
"""Create a molab URL for a marimo notebook at content_path (relative to afterpython/)."""
23+
github_url = github_url.replace("https://github.com/", "github/")
24+
return f"https://molab.marimo.io/{github_url}/blob/main/afterpython/{content_path.as_posix()}"
25+
26+
27+
def is_marimo_notebook(path: Path) -> bool:
28+
"""Check if a Python file is a marimo notebook.
29+
30+
Looks for marimo's two reliable signature lines (`__generated_with` and
31+
`marimo.App(`) — both are emitted by marimo for any notebook file, and
32+
a plain script that just `import`s marimo won't match.
33+
"""
34+
if not path.exists() or not path.is_file():
35+
return False
36+
try:
37+
# marimo's signature is at the top of the file, no need to read all
38+
head = path.read_text(encoding="utf-8", errors="ignore")[:2048]
39+
except OSError:
40+
return False
41+
return "__generated_with" in head and "marimo.App(" in head
42+
43+
44+
def _export_marimo(source: Path, output_html: Path, mode: MarimoExportMode):
45+
"""Run `marimo export` to produce HTML at `output_html`."""
46+
output_html.parent.mkdir(parents=True, exist_ok=True)
47+
subcommand = "html-wasm" if mode == "wasm" else "html"
48+
cmd = ["marimo", "export", subcommand, str(source), "-o", str(output_html)]
49+
# WASM-only flag: --mode edit shows code cells (interactive). Without it,
50+
# marimo's html-wasm subcommand defaults to "run" which hides them.
51+
if mode == "wasm":
52+
cmd += ["--mode", "edit"]
53+
result = subprocess.run(cmd, check=False)
54+
if result.returncode != 0:
55+
raise click.ClickException(f"marimo export failed for {source}")
56+
57+
58+
def _inject_molab_badge(html_path: Path, molab_url: str):
59+
"""Insert a fixed-position molab badge near the top of the exported HTML body.
60+
61+
Used so users can run the notebook on a real Python server (molab) when the
62+
static export can't execute, or as an alternative to the in-page WASM kernel.
63+
"""
64+
if not html_path.exists():
65+
return
66+
html = html_path.read_text(encoding="utf-8")
67+
badge = (
68+
f'<a href="{molab_url}" target="_top" rel="noopener" '
69+
# right offset clears marimo's circular toolbar buttons in edit mode;
70+
# top offset is hand-tuned to vertically center against those buttons
71+
# (we can't group with marimo's UI, so this is a visual eyeball)
72+
f'style="position:fixed;top:16px;right:100px;z-index:9999;">'
73+
f'<img src="{_get_molab_badge()}" alt="Open in molab" />'
74+
f"</a>"
75+
)
76+
new_html, n = re.subn(r"(<body[^>]*>)", r"\1" + badge, html, count=1)
77+
if n == 0:
78+
click.echo(f"⚠ Could not locate <body> in {html_path}, skipping molab badge")
79+
return
80+
html_path.write_text(new_html, encoding="utf-8")
81+
82+
83+
def build_marimo_notebook(
84+
source: Path,
85+
output_html: Path,
86+
content_path: Path,
87+
mode: MarimoExportMode = "wasm",
88+
):
89+
"""Build a single marimo notebook to HTML and inject the molab badge.
90+
91+
Generic core for marimo builds — keep this notebook-agnostic so future
92+
callers (e.g. content-type builds in blog/tutorial) can reuse it.
93+
94+
Args:
95+
source: Path to the marimo .py file.
96+
output_html: Path to write the exported HTML to.
97+
content_path: Path of `source` relative to afterpython/, used to build
98+
the molab URL (which assumes the file lives under afterpython/ on
99+
the user's GitHub repo).
100+
mode: "wasm" (interactive, Pyodide) or "static" (pre-rendered).
101+
"""
102+
if not is_marimo_notebook(source):
103+
click.echo(f"{source} is not a marimo notebook, skip building")
104+
return
105+
106+
click.echo(f"Building marimo notebook {source.name} (mode={mode})...")
107+
_export_marimo(source, output_html, mode)
108+
109+
# WASM mode runs the notebook in-browser via Pyodide, so molab (a remote
110+
# runtime) is redundant. Skip the badge.
111+
if mode == "wasm":
112+
return
113+
114+
add_molab_badge = os.getenv("AP_MOLAB_BADGE", "1") == "1"
115+
if not add_molab_badge:
116+
return
117+
118+
metadata = read_metadata()
119+
github_url = (
120+
metadata.urls.get("repository") if "repository" in metadata.urls else None
121+
)
122+
if not github_url:
123+
click.echo(
124+
"⚠ Repository URL not found in [project.urls] in pyproject.toml, "
125+
"skipping molab badge"
126+
)
127+
return
128+
129+
molab_url = _create_molab_url(github_url, content_path)
130+
_inject_molab_badge(output_html, molab_url)
131+
132+
133+
def build_marimo_readme(mode: MarimoExportMode = "wasm"):
134+
"""Build afterpython/README.py to _build/readme_py/readme_py.html.
135+
136+
Skips silently if README.py is absent or isn't a marimo notebook so users
137+
who keep only README.md aren't penalized.
138+
"""
139+
readme_path = ap.paths.afterpython_path / "README.py"
140+
if not readme_path.exists():
141+
return
142+
output_html = ap.paths.build_path / "readme_py" / "readme_py.html"
143+
content_path = Path("README.py")
144+
build_marimo_notebook(readme_path, output_html, content_path, mode)

src/afterpython/builders/metadata.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,11 @@ def convert_paths():
3838
def build_metadata():
3939
"""Build metadata.json using pyproject.toml + afterpython.toml.
4040
41-
Adds an `announcement` field (markdown string) sourced from
42-
`[website].announcement` in afterpython.toml, so the frontend can render
43-
a top-of-site banner. Empty/missing announcement → empty string.
41+
Adds two frontend-only fields sourced from `[website]` in afterpython.toml:
42+
- `announcement`: markdown banner string (empty when unset)
43+
- `readme_py`: "wasm" | "static" | "" — resolved to "" when README.py is
44+
missing or isn't a marimo notebook, so the frontend can use a single
45+
truthiness check to decide between iframe and markdown description.
4446
"""
4547
from afterpython._io.toml import _from_tomlkit
4648
from afterpython.tools._afterpython import read_afterpython
@@ -54,8 +56,28 @@ def build_metadata():
5456
afterpython = read_afterpython()
5557
website = _from_tomlkit(afterpython.get("website", {}))
5658
metadata_json["announcement"] = str(website.get("announcement", "")).strip()
59+
metadata_json["readme_py"] = _resolve_readme_py(website)
5760

5861
with open(build_path / "metadata.json", "w") as f:
5962
json.dump(metadata_json, f, indent=2)
6063

6164
convert_paths()
65+
66+
67+
def _resolve_readme_py(website_config: dict) -> str:
68+
"""Resolve the readme_py field for metadata.json.
69+
70+
Returns "wasm" | "static" only when afterpython/README.py exists AND is a
71+
marimo notebook, so the toml setting is honored only when there's actually
72+
something to render. Otherwise returns "".
73+
"""
74+
from afterpython.builders.marimo_notebook import is_marimo_notebook
75+
76+
readme_path = ap.paths.afterpython_path / "README.py"
77+
if not is_marimo_notebook(readme_path):
78+
return ""
79+
80+
mode = str(website_config.get("readme_py", "wasm"))
81+
if mode not in ("wasm", "static"):
82+
return "wasm"
83+
return mode

src/afterpython/cli/commands/build.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
if TYPE_CHECKING:
77
from pathlib import Path
8+
from typing import Literal
89

910
from afterpython._typing import NodeEnv
1011

@@ -20,6 +21,7 @@
2021
build_faq_json,
2122
build_jupyter_notebooks,
2223
build_llms_txt,
24+
build_marimo_readme,
2325
build_markdown,
2426
build_metadata,
2527
build_url_md,
@@ -88,13 +90,29 @@ def _clean_up_builds():
8890
shutil.rmtree(path)
8991
build_path.mkdir(parents=True, exist_ok=True)
9092

93+
def _get_readme_py_mode() -> Literal["wasm", "static"]:
94+
"""Read `[website].readme_py` from afterpython.toml. Defaults to "wasm".
95+
96+
Falls back to "wasm" silently for unknown values rather than failing the
97+
build — the metadata.json resolver does the same, keeping the two views
98+
consistent.
99+
"""
100+
from afterpython._io.toml import _from_tomlkit
101+
from afterpython.tools._afterpython import read_afterpython
102+
103+
afterpython = read_afterpython()
104+
website = _from_tomlkit(afterpython.get("website", {}))
105+
mode = str(website.get("readme_py", "wasm"))
106+
return mode if mode in ("wasm", "static") else "wasm"
107+
91108
_check_initialized()
92109
_clean_up_builds()
93110

94111
delete_placeholder_index_md_files()
95112
create_placeholder_index_md_files()
96113
build_metadata()
97114
build_faq_json()
115+
build_marimo_readme(mode=_get_readme_py_mode())
98116
build_markdown()
99117
build_jupyter_notebooks()
100118

0 commit comments

Comments
 (0)