RFC 4266 defines a bare gopher://host/ (no item-type prefix in the URL) as defaulting to type '1' -- a menu -- so every RFC-following client, Lagrange included, parses whatever the empty selector returns as tab-delimited menu lines rather than displaying it as text. Handing back the homepage's own rendered prose there has no tabs in it, so it parses as zero valid entries and renders blank. Only the truly empty selector gets the synthetic menu; its one item points at the homepage under "/", a distinguishable non-empty selector that still resolves to real content exactly as before. This still isn't directory browsing -- there's still no listing of the content tree, just the one link needed to make the root itself navigable. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|---|---|---|
| content | ||
| src/smolweb | ||
| tests | ||
| .gitignore | ||
| devbox.json | ||
| flake.lock | ||
| flake.nix | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
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.