smolweb/README.md
randogoth 0b67069760 feat: add Nex and Gopher listeners
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>
2026-09-26 15:16:40 +03:00

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, and text renderers 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.