Reuses md2txt's existing nex and text renderers (both already ship unmodified) -- rendered unwrapped gemtext still works for Gemini/Spartan clients that wrap themselves, but Nex and Gopher clients don't, so those two get the classic 80-column pre-wrap instead. Neither protocol has a redirect status of its own, so Site.resolve_flat() follows the /foo.md -> /foo canonical bounce and the WML-card-only -> parent redirect server-side and hands back real content directly, rather than leaving the two new listeners to invent a redirect convention that doesn't exist on the wire. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
4.3 KiB
smolweb
Serve a folder of Markdown as Gemini capsules, Spartan capsules, Nex and Gopher documents, 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, Nex on :1900, Gopher on :70. 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:
- gemtext, Nex, and Gopher text via md2txt's
gemini,nex, andtextrenderers respectively. - WML and XHTML-MP via wapdown.
Gemini and Spartan clients wrap gemtext themselves, so it's rendered
unwrapped (width=0). Nex and Gopher clients don't, so those two are
pre-wrapped at the classic 80-column convention instead.
Neither Nex nor Gopher has a redirect status of its own, so the /foo.md -> /foo canonical-URL bounce and the WML-card-only-path-redirects-to-its-
parent behaviour (see below) are both resolved server-side instead of
bounced back to the client — the client always gets real content back
directly, on the first request.
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.
Nex and Gopher serve /trail's own content directly for the same request,
since neither has a redirect status to bounce with.
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.