# smolweb Serve a folder of Markdown as [Gemini](https://geminiprotocol.net/) capsules, [Spartan](https://portal.mozz.us/gemini/spartan.mozz.us/) capsules, [Nex](https://nightfall.city/nex/info/specification.txt) and [Gopher](https://www.rfc-editor.org/rfc/rfc1436) 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. ```bash 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: ```bash 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](https://code.randogoth.com/randogoth/md2txt)'s `gemini`, `nex`, and `text` renderers respectively. - **WML** and **XHTML-MP** via [wapdown](https://code.randogoth.com/randogoth/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 ```bash 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.