| content | ||
| src/smolweb | ||
| tests | ||
| .gitignore | ||
| devbox.json | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
smolweb
Serve a folder of Markdown as Gemini capsules, Spartan capsules, and HTTP pages content-negotiated between WML (WAP 1.x) and XHTML-MP (WAP 2.0 and modern browsers) — all rendered on the fly, no build step.
smolweb --root ./content --host example.com
Every listener binds by default: Gemini on :1965 (TLS), Spartan on :300,
HTTP on :8080. An empty address disables one:
smolweb --root ./content --host example.com --spartan ""
Rendering
Markdown is parsed once per file (cached by mtime, re-parsed the moment the file changes) and rendered into every format from the same source:
Both libraries read the same leading --- frontmatter block independently
and ignore keys they don't recognise, so one file can freely mix wapdown's
deck-structure keys (title, split_level, deck_per_card, ...) with
md2txt's typography keys (links_per_block, ...).
wapdown's {.card Title} card-break directive has no meaning to md2txt's
parser, so it is stripped before gemtext rendering rather than leaking
through as literal text — a card break already renders as nothing in
XHTML-MP for the same reason (cards are a WAP 1.x screen-budget concern;
a Gemini client or browser just scrolls through one continuous document).
URLs
Links should be root-relative and extensionless ([about](/about), not
about.md) — the same link then resolves identically from a Gemini,
Spartan, or HTTP client:
| Request | Serves |
|---|---|
/ |
index.md |
/foo |
foo.md, else foo/index.md |
/foo.md |
redirects to /foo |
/img.png |
served as-is, by MIME type |
A document with deck_per_card: true gets its own URL space for WML only:
/trail serves the whole document (the menu deck, if it has one), and
/trail/weather serves one card's own deck directly. Gemini, Spartan, and
an HTTP client negotiating gemtext/XHTML-MP all redirect /trail/weather
back to /trail instead — that URL space only ever makes sense for WML.
HTTP content negotiation
| Accept | Served as |
|---|---|
text/vnd.wap.wml (literal, non-wildcard) |
WML |
application/vnd.wap.xhtml+xml (literal, non-wildcard) |
XHTML-MP |
anything else, including a */* wildcard or no header at all |
the same XHTML-MP body, as text/html |
A */* wildcard never selects WML — a browser's Accept: text/html, application/xhtml+xml;q=0.9,*/*;q=0.8 must never be handed WML through the
wildcard. ?format=wml, ?format=xhtml, or ?format=html overrides
negotiation outright, for testing without real WAP hardware.
TLS
If --cert/--key don't exist, a self-signed EC P-256 certificate is
generated on first run (via openssl, so devbox must provide it), valid for
ten years. Gemini clients use TOFU (trust-on-first-use) fingerprint pinning
rather than CA validation, so this is the norm rather than a warning.
Development
devbox run sync # install everything, including wapdown/md2txt
devbox run test # pytest
devbox run run # serve ./content on localhost, Spartan on :3000 (unprivileged)
pyproject.toml currently points wapdown/md2txt at local paths (see the
comment there) rather than their git origins, since this checkout carries
unpushed changes to both.