A polished GitHub template for static Wybthon applications: Python components running in the browser through Pyodide, deployed straight from the main branch via GitHub Pages. No server, no bundler, no production build workflow.
- A working Wybthon app (
app/): run-once components, signals, memos, and event handlers, with a live counter and text-input demo. - A static bootstrap (
bootstrap.js): loads a pinned Pyodide from CDN, installs a pinned Wybthon wheel via micropip, copies your Python modules into Pyodide's virtual filesystem, and mounts the app, reporting progress the whole way, capturing Python stdout/stderr, and showing a retryable error overlay if anything fails. - One configuration source (
config.json): the Pyodide and Wybthon versions live in a single file read at boot. - Content-based cache busting:
tools/build_manifest.pystamps every Python module,bootstrap.js, andassets/styles.csswith a short SHA-256 of its content, so browsers can cache aggressively but never go stale. - Modern Python tooling via uv:
.python-version, dependency groups, and a committeduv.lock. - CI (
.github/workflows/ci.yml): Ruff, Black, MyPy, pytest, manifest-staleness validation, and an optional (non-blocking) Playwright smoke test that boots the real site in Chromium.
demo-template/
├── index.html # Page shell, metadata, loading overlay
├── bootstrap.js # Pyodide + micropip + manifest-driven module loader
├── config.json # Pinned Pyodide + Wybthon versions (single source of truth)
├── assets/
│ └── styles.css # All styling; design tokens on :root
├── app/ # Your Python application
│ ├── manifest.json # Generated: files + content hashes (committed)
│ ├── main.py # async main(), called by bootstrap.js
│ ├── app.py # Root <App> component
│ └── components/ # Counter and Greeting demo components
├── tools/
│ └── build_manifest.py # Regenerates manifest + index.html cache busters
├── tests/ # Generator unit tests, config guardrails, e2e smoke
├── .github/workflows/ci.yml # Lint, types, tests, manifest check, browser smoke
├── .nojekyll # Serve files as-is, skip Jekyll on GitHub Pages
├── pyproject.toml # Tooling config + dependency groups
└── uv.lock # Locked dev dependencies (committed)
- Click Use this template on GitHub (or clone this repo).
- Install the toolchain and run the site:
# Install Python (from .python-version) and all dependency groups
uv sync --all-groups
# Serve the static site
make serve # → http://localhost:8000That's it. The page itself needs nothing but a static file server; Pyodide and Wybthon are fetched at runtime from jsDelivr and PyPI using the versions pinned in config.json.
Prefer raw commands over
make? Every target is a thin wrapper; e.g.,make serveisuv run python -m http.server 8000.
The Python app lives in app/. Components use the @component decorator; bodies run once at mount, and zero-argument callables embedded in the tree become fine-grained reactive holes. Start with app/app.py (page structure) and app/components/ (the demos).
Whenever you add, rename, remove, or edit files under app/ (or touch bootstrap.js / assets/styles.css), regenerate the manifest and cache busters, and commit the result:
make manifest # uv run python tools/build_manifest.pybootstrap.js fetches exactly the files listed in app/manifest.json, using each entry's content hash as a ?v= query parameter. The same script keeps the ?v= hashes on the stylesheet and bootstrap references inside index.html current. Editing a file without regenerating serves the old cached copy to returning visitors; CI catches this (see below).
make ci # everything CI runs, in one shotOr piece by piece:
make lint # ruff check + black --check
make fmt # black + ruff --fix (auto-format)
make typecheck # mypy
make test # pytest (generator + config guardrail tests)
make check # fail if manifest/cache busters are staleBoots the real site in headless Chromium, waits for the Python app to mount, clicks the counter, and asserts the DOM reacted:
uv run playwright install chromium # one-time browser download
make e2eCI runs this as a non-blocking job because it exercises live CDNs; delete continue-on-error: true in ci.yml to make it required.
config.json is the single source of truth; bootstrap.js reads it at boot:
{
"pyodideVersion": "314.0.6",
"wybthonVersion": "0.28.0"
}When bumping Wybthon, also update the wybthon==X.Y.Z pin in pyproject.toml (it exists only so Ruff/MyPy/your editor resolve the same version the browser installs) and run uv lock. tests/test_config.py fails if the two pins drift apart, and if a version gets hard-coded back into bootstrap.js.
A note on [tool.uv]: dependency resolution ignores packages published within the last 7 days (exclude-newer = "P7D"), a supply-chain guard against freshly compromised releases. Wybthon itself is exempt via exclude-newer-package, so you can pin a new Wybthon release the day it ships. Other tooling upgrades (Ruff, Black, and so on) become resolvable once they're a week old.
All CSS lives in assets/styles.css. The palette, radii, shadows, and fonts are CSS variables on :root; components reference the classes via class_= props. Remember make manifest after editing so the stylesheet's cache buster updates.
index.html owns the <title>, description, favicon (an inline SVG; swap in your own), and the loading overlay. Open Graph and Twitter cards need absolute URLs, so add those after you know your deployed URL (there's a commented stub in the file).
- Runtime (browser) dependencies are installed by micropip in
bootstrap.js; add moremicropip.install(...)calls there if your app needs them. - Local tooling lives in
[dependency-groups]inpyproject.toml(devfor lint/type/test,e2efor Playwright). After changing them, runuv lockand commituv.lock.
This repository deploys directly from the main branch; merging is the deployment. There's no build workflow to maintain:
- Push the repository to GitHub.
- Go to Settings → Pages.
- Under Build and deployment, set Source to Deploy from a branch.
- Choose the
mainbranch and the/ (root)folder, then save.
Your site appears at https://<user>.github.io/<repo>/ within a minute or two.
Notes:
- Repository subpaths just work. Every URL in
index.htmlandbootstrap.jsis relative (./app/...,./assets/...), so the site behaves identically at a domain root and under/<repo>/. .nojekyllis required. It tells Pages to serve files as-is instead of running Jekyll.- Custom domains are opt-in: this template deliberately ships no
CNAMEfile. If you want one, add aCNAMEfile containing your domain and configure DNS per the GitHub Pages docs. - Caching:
config.jsonandapp/manifest.jsonare fetched withcache: "no-store", while every hashed file is safe to cache indefinitely; its URL changes when its content does.
bootstrap.jsfetchesconfig.jsonand loads the pinned Pyodide fromcdn.jsdelivr.net, wiring Python stdout/stderr into a capture buffer (window.__wybthonPyLog) and the devtools console.- micropip installs the pinned
wybthonwheel from PyPI. - Every file in
app/manifest.jsonis fetched with its content-hash query parameter and written into Pyodide's virtual filesystem under/app. app.main.main()is awaited, mounting the component tree into<div id="app">; the loading overlay (with live status and a real progress bar) fades out.
If any step fails, an overlay shows the error, the recent Python output, and a Retry button that reloads the page.
MIT © 2026 Owen Carey